feat: 重构

This commit is contained in:
chenxiangtong
2026-05-28 17:37:57 +08:00
parent 88a0c45f5b
commit 3ff2454c63
26 changed files with 1248 additions and 2055 deletions

845
AGENTS.md
View File

@@ -1,845 +0,0 @@
# PROJECT: voxy_import
> Machine-readable project documentation for LLM agents.
> Read this before making any changes. Follow all conventions exactly.
## 1. OVERVIEW
**A Minecraft TUI management tool built with bubbletea (Elm Architecture).**
Master menu lists feature modules. User selects a module with Enter.
Esc returns from module to menu. Ctrl+C quits globally.
```
Module: voxy_import
Go: 1.25.5
Binary: voxy_import.exe
Build: go build -o voxy_import.exe .
Dependencies (from go.mod):
github.com/charmbracelet/bubbles v1.0.0 // TUI components (list, textinput, spinner, progress)
github.com/charmbracelet/bubbletea v1.3.10 // Elm Architecture for TUIs
github.com/charmbracelet/lipgloss v1.1.0 // Styling
```
## 2. FILE STRUCTURE
```
voxy_import/
├── AGENTS.md ← this file
├── go.mod
├── go.sum
├── main.go ← entry point: register modules, run app
├── app/
│ └── app.go ← framework: Module interface, registry, menu model, key dispatch
└── modules/
└── voxyimport/
├── voxyimport.go ← TUI module + VoxyService interface definition
└── service.go ← DefaultVoxyService concrete implementation
```
**When adding a new module `foo`:**
```
voxy_import/
└── modules/
└── foo/
├── foo.go ← TUI module + FooService interface definition
└── service.go ← concrete service implementation (if needed)
```
Each module is self-contained under `modules/<name>/` — TUI views, state machine,
service interface, and default implementation all live in one package.
Deleting a module = removing its directory + one line in `main.go`.
## 3. ARCHITECTURE
### app package: framework
The `app` package provides the unified TUI framework: `Module` interface, module registry,
menu model, and key dispatch. It does NOT import any module or service packages.
```
app.Model {
menu list.Model // built from entries
active tea.Model // nil = showing menu; non-nil = active module
entries []Entry // registered modules with factory functions
}
```
### Module Interface
Defined in `app/app.go`:
```go
type Module interface {
tea.Model
Title() string
Description() string
}
```
A module MUST implement:
- `Init() tea.Cmd`
- `Update(tea.Msg) (tea.Model, tea.Cmd)`
- `View() string`
- `Title() string` — shown in menu list
- `Description() string` — shown in menu list subtitle
Module packages implement this interface implicitly (Go structural typing).
They do NOT import app or reference the Module interface directly.
### Service Interface (per-module)
Each module defines its own service interface in the module package.
The default implementation lives alongside it (e.g. `service.go` in the same package).
This keeps each module self-contained — one directory = one module = one package.
```
modules/voxyimport/
voxyimport.go ← VoxyService interface + TUI Model
service.go ← DefaultVoxyService struct implements VoxyService
```
Deleting a module is a single operation: remove the directory, remove one line from `main.go`.
### Key Bindings
| Key | Context | Action |
|-----|---------|--------|
| Ctrl+C | anywhere | `tea.Quit` — exit program |
| Esc | inside module | return to menu (`m.active = nil`) |
| Esc | on menu | `tea.Quit` — exit program |
| Enter | on menu | activate selected module, call `Init()` |
| Enter | inside module | module-specific (e.g. confirm input) |
| ↑↓ / j k | list views | navigate |
| q | on menu or module done/error states | handled per-module |
Key dispatch logic in `app/app.go`:
```go
case "ctrl+c": return m, tea.Quit
case "esc": if m.active != nil { m.active = nil; return m, nil }
return m, tea.Quit
```
**Modules do NOT handle Esc or Ctrl+C.** These are intercepted by app before delegate.
Modules SHOULD handle Enter for confirmation. Modules MAY handle `q` for internal back/quit.
### Module Lifecycle
```
1. User presses Enter on menu item
2. app matches title → calls create() → stores as m.active
3. app calls m.active.Init() → returns tea.Cmd (e.g. textinput.Blink, spinner.Tick)
4. All subsequent messages routed via updateModule() → m.active.Update(msg)
5. Result stored back: m.active = newMod
6. User presses Esc → m.active = nil (module discarded, no explicit cleanup)
7. User presses Ctrl+C → tea.Quit (main defer calls module.Cleanup() if exported)
```
### New Module Registration
In `main.go`, `entries` slice:
```go
entries := []app.Entry{
{
Title: "Module Name",
Desc: "description",
Create: func() tea.Model { return modname.New(svc) },
},
}
```
Then add the import: `"amt/modules/modname"` at top of main.go.
## 4. MODULE CODE CONVENTIONS
### 4.1 Package Layout
Every module file follows this section order:
```
1. package declaration + imports
2. module metadata: Title(), Description()
3. module-specific config/data (sources, constants)
4. state machine: type state int + const iota
5. sub-steps (if applicable): const iota
6. lipgloss styles: var block
7. custom message types: type ...Msg struct{}
8. shared-memory tracker types (if async with goroutines)
9. temporary file tracking (if module creates temp files)
10. list item types: type ...Item struct + FilterValue/Title/Description
11. Model struct (exported)
12. New() constructor function → returns *Model
13. Init() tea.Cmd
14. Update() → state dispatcher
15. Per-state update methods: updateInput, updateSearching, ...
16. View() → state dispatcher
17. Per-state view methods: viewInput, viewSearching, ...
18. Async commands: pure functions returning tea.Cmd
```
### 4.2 Model Type
```go
// ALWAYS exported as "Model" with pointer receiver methods
// ALL fields unexported (lowercase)
type Model struct {
state state // ALWAYS: state field (custom type)
// input widgets
textInput textinput.Model
// spinner (for waiting states)
spinner spinner.Model
// list (for selection states)
list list.Model
// progress bar (for download/processing)
progress progress.Model
// module-specific data fields
errMsg string // ALWAYS: error message for stateError
}
```
Constructor ALWAYS returns `*Model`:
```go
func New() *Model {
// create all components with defaults
return &Model{state: stateInput, ...}
}
```
### 4.3 Receiver Types
ALL methods on Model use **pointer receiver** `(m *Model)`.
```go
func (m *Model) Init() tea.Cmd
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd)
func (m *Model) View() string
func (m *Model) Title() string
```
### 4.4 State Machine
```go
type state int
const (
stateInput state = iota // first state = initial
stateProcessing
stateDone
stateError // always last (shared handler with stateDone)
)
```
State dispatcher in `Update()`:
```go
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// handle cross-cutting messages first:
// tea.WindowSizeMsg → resize components
// progress.FrameMsg → forward to progress bar
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateProcessing:
return m.updateProcessing(msg)
case stateDone, stateError:
return m.updateDone(msg)
}
return m, nil
}
```
State dispatcher in `View()`:
```go
func (m *Model) View() string {
switch m.state {
case stateInput: return m.viewInput()
case stateDone: return m.viewDone()
case stateError: return m.viewError()
}
return ""
}
```
### 4.5 Messages and Async Commands
**Pattern: one-shot async operation**
```go
// 1. Define message type
type myDoneMsg struct {
result string
err error
}
// 2. Create command function (returns tea.Cmd)
func myAsyncCmd(param string) tea.Cmd {
return func() tea.Msg {
// blocking work here (runs in goroutine via tea.Batch)
result, err := doWork(param)
if err != nil {
return myDoneMsg{err: err}
}
return myDoneMsg{result: result}
}
}
// 3. Handle in Update
case myDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.state = stateDone
return m, nil
```
**Pattern: progress-tracked async with goroutine**
Used when a long operation needs progress bar updates.
See Section 6 for the full tracker pattern.
### 4.6 Components
| Component | Import | Init | Update pattern |
|-----------|--------|------|----------------|
| `textinput` | `bubbles/textinput` | `ti := textinput.New(); ti.Focus()` | `m.textInput, cmd = m.textInput.Update(msg)` |
| `spinner` | `bubbles/spinner` | `s := spinner.New(); s.Spinner = spinner.Dot` | Init returns `m.spinner.Tick`; per tick: `m.spinner, cmd = m.spinner.Update(msg)` |
| `list` | `bubbles/list` | `l := list.New(items, list.NewDefaultDelegate(), 0, 0)` | `m.list, cmd = m.list.Update(msg)` |
| `progress` | `bubbles/progress` | `p := progress.New(progress.WithDefaultGradient())` | `m.progress, cmd = m.progress.Update(msg)` (via FrameMsg) |
**List items must implement:**
```go
type item struct { title string; desc string }
func (i item) FilterValue() string { return i.title }
func (i item) Title() string { return i.title }
func (i item) Description() string { return i.desc }
```
**List selection (Enter key):**
```go
case "enter":
if m.list.FilterState() == list.Filtering { break } // ignore during filter
if sel, ok := m.list.SelectedItem().(myItem); ok {
// use sel
}
```
### 4.7 Styles
```go
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
)
```
### 4.8 Chinese UI
ALL user-facing strings are in Chinese (简体中文).
Comments and identifiers are in English.
Log messages (fmt.Sprintf for error) can be either.
### 4.9 Temp File Cleanup
If a module creates temp files that outlive a single Update cycle,
it MUST track them and export a `Cleanup()` function:
```go
var (
pendingTempMu sync.Mutex
pendingTemps = map[string]struct{}{}
)
func trackTemp(path string) { /* add to map */ }
func untrackTemp(path string) { /* remove from map */ }
func Cleanup() { /* os.Remove all tracked files */ }
```
In `main.go`: `defer modulepackage.Cleanup()` for every module that exports Cleanup.
## 5. BUBBLES v1.0.0 API REFERENCE
```go
// textinput
ti := textinput.New()
ti.Placeholder = "..."
ti.Focus()
ti.CharLimit = 50
ti.Width = 40
m.textInput, cmd = m.textInput.Update(msg)
textinput.Blink // tea.Cmd for cursor blink
// spinner
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
m.spinner, cmd = m.spinner.Update(msg) // on spinner.TickMsg
m.spinner.Tick // tea.Cmd
// list
l := list.New(items []list.Item, delegate list.ItemDelegate, width int, height int)
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "..."
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(true)
l.SetItems(items)
l.SetSize(w, h)
l.SelectedItem() list.Item
l.FilterState() list.FilterState // Filtering, Unfiltered, FilterApplied
m.list, cmd = m.list.Update(msg)
// progress
p := progress.New(progress.WithDefaultGradient())
p.Width = 40
p.SetPercent(float64) tea.Cmd // returns tea.Cmd
p.View() string
// Handle progress.FrameMsg in Update, forward via:
pm, cmd := m.progress.Update(msg)
m.progress = pm.(progress.Model)
return m, cmd
// generic
tea.Batch(cmd1, cmd2, ...) tea.Cmd // run commands in parallel
tea.Tick(d, func(time) Msg) tea.Cmd // fire message after duration
tea.Quit tea.Cmd // exit program
```
## 6. TEMPLATE: New Module (Minimal)
```go
package newmodule
import (
"fmt"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ===== metadata =====
func (m *Model) Title() string { return "Module Name" }
func (m *Model) Description() string { return "brief description" }
// ===== styles =====
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
)
// ===== state machine =====
type state int
const (
stateInput state = iota
stateDone
stateError
)
// ===== messages =====
type myDoneMsg struct {
result string
err error
}
// ===== model =====
type Model struct {
state state
textInput textinput.Model
result string
errMsg string
}
func New() *Model {
ti := textinput.New()
ti.Placeholder = "..."
ti.Focus()
ti.CharLimit = 50
ti.Width = 40
return &Model{
state: stateInput,
textInput: ti,
}
}
func (m *Model) Init() tea.Cmd {
return textinput.Blink
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// resize components if needed
}
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateDone, stateError:
return m.updateDone(msg)
}
return m, nil
}
func (m *Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
// process input
m.state = stateDone
return m, nil
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
func (m *Model) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *Model) View() string {
switch m.state {
case stateInput:
return titleStyle.Render("Module Name") + "\n\n" +
"Enter something:\n\n" +
m.textInput.View() + "\n\n" +
helpStyle.Render("Enter · 确认 Esc · 返回菜单")
case stateDone:
return successStyle.Render("OK!") + "\n\n" + m.result
case stateError:
return errorStyle.Render("Error!") + "\n\n" + m.errMsg
}
return ""
}
```
## 7. TEMPLATE: Module with List Selection
```go
package newmodule
import (
"github.com/charmbracelet/bubbles/list"
// ...
)
type state int
const (
stateSelect state = iota
stateDone
stateError
)
type myItem struct {
title string
desc string
}
func (i myItem) FilterValue() string { return i.title }
func (i myItem) Title() string { return i.title }
func (i myItem) Description() string { return i.desc }
type Model struct {
state state
list list.Model
selected string
errMsg string
}
func New() *Model {
items := []list.Item{
myItem{title: "Option A", desc: "Description A"},
myItem{title: "Option B", desc: "Description B"},
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "请选择"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(true)
return &Model{state: stateSelect, list: l}
}
func (m *Model) Init() tea.Cmd { return nil }
func (m *Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" {
if m.list.FilterState() == list.Filtering { return m, nil }
if sel, ok := m.list.SelectedItem().(myItem); ok {
m.selected = sel.title
m.state = stateDone
return m, nil
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
```
## 8. TEMPLATE: Module with Spinner + Async Operation
```go
import (
"github.com/charmbracelet/bubbles/spinner"
// ...
)
type state int
const (
stateInput state = iota
stateWorking
stateDone
stateError
)
type workDoneMsg struct {
result string
err error
}
type Model struct {
state state
spinner spinner.Model
errMsg string
}
func New() *Model {
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
return &Model{state: stateInput, spinner: s}
}
func (m *Model) Init() tea.Cmd { return m.spinner.Tick }
func (m *Model) updateWorking(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case workDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.state = stateDone
return m, nil
}
return m, nil
}
// Command that runs work asynchronously
func doWorkCmd() tea.Cmd {
return func() tea.Msg {
// blocking work
return workDoneMsg{result: "ok"}
}
}
// Trigger: when entering working state:
// return m, tea.Batch(m.spinner.Tick, doWorkCmd())
```
## 9. TEMPLATE: Module with Download Progress (Goroutine + Ticker)
For file downloads with a progress bar, use this pattern:
```go
import (
"sync"
"time"
"github.com/charmbracelet/bubbles/progress"
)
type downloadProgressMsg struct{}
type downloadTracker struct {
mu sync.Mutex
total int64
current int64
done bool
path string
err error
}
type Model struct {
// ...
progress progress.Model
dlTracker *downloadTracker
}
// Start download - opens connection, returns immediately.
// Goroutine does the actual I/O and updates tracker.
func startDownloadCmd(url string, tr *downloadTracker) tea.Cmd {
return func() tea.Msg {
resp, err := http.Get(url)
// error handling...
tr.total = resp.ContentLength
go func() {
defer resp.Body.Close()
buf := make([]byte, 32*1024)
for {
n, _ := resp.Body.Read(buf)
if n > 0 {
// write to file...
tr.mu.Lock()
tr.current += int64(n)
tr.mu.Unlock()
}
// check done...
}
tr.mu.Lock()
tr.done = true
tr.path = tmpFile.Name()
tr.mu.Unlock()
}()
return downloadProgressMsg{}
}
}
// Ticker fires every 100ms to poll tracker
func tickDownload() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return downloadProgressMsg{}
})
}
// Handle in Update:
case downloadProgressMsg:
m.dlTracker.mu.Lock()
current, total, done, path, dlErr := m.dlTracker.current, m.dlTracker.total, m.dlTracker.done, m.dlTracker.path, m.dlTracker.err
m.dlTracker.mu.Unlock()
if done {
if dlErr != nil { /* error */ }
m.downloadedPath = path
// proceed to next step...
return m, tea.Batch(m.spinner.Tick, nextStepCmd())
}
pct := float64(current) / float64(total)
return m, tea.Batch(
m.spinner.Tick,
m.progress.SetPercent(pct), // returns tea.Cmd
tickDownload(),
)
// In View, show progress bar:
// m.progress.View()
// In top-level Update, handle progress.FrameMsg:
if frameMsg, ok := msg.(progress.FrameMsg); ok {
pm, cmd := m.progress.Update(frameMsg)
m.progress = pm.(progress.Model)
return m, cmd
}
```
## 10. TEMPLATE: Module with Sequential Steps (chain of async ops)
Use a `stepDoneMsg` that carries the next step number:
```go
const (
stepA = iota
stepB
stepC
stepFinished
)
type stepDoneMsg struct {
nextStep int
detail string
err error
}
// Step command returns stepDoneMsg with the NEXT step:
func stepACmd() tea.Cmd {
return func() tea.Msg {
// do work
return stepDoneMsg{nextStep: stepB, detail: "some value"}
}
}
func stepBCmd() tea.Cmd {
return func() tea.Msg {
return stepDoneMsg{nextStep: stepC}
}
}
func stepCCmd() tea.Cmd {
return func() tea.Msg {
return stepDoneMsg{nextStep: stepFinished}
}
}
// Handle in Update:
case stepDoneMsg:
if msg.err != nil { /* error */ }
switch msg.nextStep {
case stepB: return m, tea.Batch(m.spinner.Tick, stepBCmd())
case stepC: return m, tea.Batch(m.spinner.Tick, stepCCmd())
case stepFinished: m.state = stateDone; return m, nil
}
```
## 11. CHECKLIST: Adding a New Module
When adding a new module, follow this exact sequence:
1. [ ] Create directory `voxy_import/<modulename>/`
2. [ ] Create file `<modulename>.go` with `package <modulename>`
3. [ ] Implement `Title()` and `Description()` methods on `*Model`
4. [ ] Define `type state int` and `const` iota block
5. [ ] Define `type Model struct` with `state` field and necessary UI components
6. [ ] Write `func New() *Model` constructor
7. [ ] Implement `Init() tea.Cmd`
8. [ ] Implement `Update(tea.Msg) (tea.Model, tea.Cmd)` with state dispatch
9. [ ] Implement `View() string` with state dispatch
10. [ ] Implement per-state `update*` and `view*` methods
11. [ ] Define custom message types if doing async work
12. [ ] Write async command functions that return `tea.Cmd`
13. [ ] In `main.go`: add import for the new module package
14. [ ] In `main.go`: add entry to `entries` slice with `Create: func() tea.Model { return modname.New(svc) }`
15. [ ] If module creates temp files: implement `Cleanup()` and add `defer modname.Cleanup()` to `main()`
16. [ ] Run `go build -o voxy_import.exe .` — fix ALL compilation errors
17. [ ] Test: verify module appears in menu, Enter activates it, Esc returns to menu
## 12. DO NOT
- Do NOT use value receivers `(m Model)` — always `(m *Model)`
- Do NOT handle Esc or Ctrl+C in module Update — main.go handles these
- Do NOT define `main()` in module packages
- Do NOT import the main package from module packages
- Do NOT export module fields — all fields lowercase
- Do NOT use `tea.Batch` for sequential operations — use the stepDoneMsg chain pattern
- Do NOT create new module files in the root directory — always in a sub-package
- Do NOT use `fmt.Println` for logging — it breaks the TUI
- Do NOT modify `go.mod` — if new deps needed, run `go mod tidy`
- Do NOT use `os.Exit(1)` in modules — return error state instead

145
CLAUDE.md Normal file
View File

@@ -0,0 +1,145 @@
# AMT - ARinera Minecraft Tool
Minecraft 实用 TUI 工具。Go + bubbletea前后端分离。
## 文件结构
```
main.go 入口,解析 exe 目录tea.NewProgram(WithAltScreen)
model.go 顶层 model 定义page 枚举Init/Update/View 路由
pages.go 4 个页面的 update/view + resetExecState + describeAction
scan.go 扫描 <exeDir>/*/.minecraft/versions/* 目录
api.go HTTP 客户端 + Action/StepResponse 类型定义
actions.go action 执行引擎download/unzip/delete/copy/move/backup
items.go list.Item 实现versionItem, menuItem, mirrorItem
styles.go lipgloss 样式常量
backend/ Python FastAPI 后端(独立进程,端口 3131
```
全部 Go 文件在项目根目录package main。`go build -o dist/amt.exe .`
## 依赖
- `bubbletea` v1.3.10 / `bubbles` v1.0.0 / `lipgloss` v1.1.0
- 标准库:`net/http`, `archive/zip`, `encoding/json`, `io`, `os`, `path/filepath`
## 架构:页面状态机
```
pageVersionSelect ──Enter──▶ pageMainMenu ──"输入数字码"──▶ pageCodeInput ──Enter(4位)──▶ pageExecuting
▲ │ │ │ │
│ Esc/q=退出 │ Esc=返回 任意键=返回
└──"切换版本"─────────────┘ └──────────────────────────┘◀──────────────────────────┘
```
### model 核心字段
| 字段 | 类型 | 用途 |
|------|------|------|
| `currentPage` | `page` (iota 枚举) | 当前页面 |
| `exeDir` | string | 可执行文件所在目录 |
| `versionDir` | string | 选中的版本完整路径 |
| `versionName` | string | 版本目录名 |
| `versionList` | list.Model | 版本选择列表Init 时预初始化,避免零值 panic |
| `menuList` | list.Model | 主菜单列表(同上) |
| `codeInput` | textinput.Model | 4 位数字码输入框 |
| `actions` | []Action | 当前执行的 action 列表 |
| `actionIdx` | int | 当前执行到第几个 |
| `logLines` | []string | 执行日志(也被 viewVersionSelect 用于显示扫描错误) |
| `execErr` / `execDone` | error / bool | 执行状态 |
| `choosingMirror` | bool | 是否在 mirror 选择子页面 |
| `pendingAction` | *Action | mirror 选择时暂存的 action |
| `backupDir` | string | 当次执行的备份相对路径 `amt/backup/<timestamp>` |
| `progressCh` | chan float64 | 下载进度 channel预留 |
### 消息类型
| 消息 | 来源 | 处理页面 |
|------|------|----------|
| `versionsFoundMsg` | `scanVersions()` | pageVersionSelect |
| `scanErrorMsg` | `scanVersions()` | pageVersionSelect |
| `actionsReceivedMsg` | `fetchActions()` | pageExecuting |
| `apiErrorMsg` | `fetchActions()` | pageExecuting |
| `actionCompleteMsg` | `executeAction()` | pageExecuting |
| `actionErrorMsg` | `executeAction()` | pageExecuting |
| `actionProgressMsg` | `waitForProgress()` | pageExecuting |
| `mirrorChoiceMsg` | `executeAdd()` | pageExecuting |
### 状态清理规则
**关键约束**`logLines``viewVersionSelect` 用于判断是否显示错误,必须在离开相关上下文时清空。
- **进入 pageExecuting (T4)**:调用 `resetExecState(m)` 清空所有执行状态
- **离开 pageExecuting (T6)**:同上
- **进入 pageVersionSelect (T3)**:清空 `logLines` + 清空列表项
- **`versionsFoundMsg` 到达时**:清空 `logLines`(覆盖旧扫描错误)
- **离开 pageVersionSelect (T1)**:清空 `logLines`
- **mirror Esc 取消**:清空 `pendingAction`
`resetExecState` 清空字段:`actions`, `actionIdx`, `logLines`, `execErr`, `execDone`, `choosingMirror`, `pendingAction`, `backupDir`, `progressCh`
## 后端 API
地址:`http://localhost:3131`
### GET /tools?code=XXXX
返回 step 结构:
```json
{
"actions": [
{"type": "add", "path": "相对路径", "unzip": false, "url": "https://...", "mirrors": ["https://..."]},
{"type": "delete", "path": "相对路径"},
{"type": "copy", "path": "源相对路径", "new_path": "目标相对路径"},
{"type": "move", "path": "源相对路径", "new_path": "目标相对路径"}
]
}
```
所有 `path` 相对于 `versionDir`
## Action 执行逻辑 (actions.go)
顺序执行,任一失败立即中断。
| type | 行为 | 备份 |
|------|------|------|
| `add` | 下载 url→pathunzip=true 时解压后删 zip | 目标已存在则备份 |
| `delete` | 删除 path | 删前备份 |
| `copy` | 复制 path→new_path | new_path 已存在则备份 |
| `move` | os.Rename失败则 copy+delete | new_path 已存在则备份 |
### 备份
目录:`<versionDir>/amt/backup/<YYYYMMDD_HHMMSS>/<原相对路径>`
每次执行创建一个时间戳目录,同次执行的所有备份共享。
### 下载失败 Mirror 处理
主 URL 失败 → 发送 `mirrorChoiceMsg` → 设 `choosingMirror=true` → 显示 mirror 列表 → 用户选择后用新 URL 重试。用户 Esc 取消则中断执行。
### 关键函数
```
executeAction(versionDir, action, index, backupDir) → tea.Cmd → actionCompleteMsg | actionErrorMsg | mirrorChoiceMsg
executeAdd(...) add 专用,处理下载/解压/mirror
downloadFile(url, dest) 标准 http.Get + 临时文件 + rename
unzipFile(zip, dir) archive/zip 解压,含 zip slip 防护
backupPath(versionDir, relPath, backupDir) 备份单个文件/目录
copyPath(src, dst) 递归复制文件/目录
copyFile(src, dst) 单文件复制
copyDir(src, dst) filepath.Walk 递归复制
```
## 版本扫描 (scan.go)
`filepath.Glob(exeDir/*/.minecraft/versions/*)` → 过滤目录 → `versionsFoundMsg`
## 构建
```bash
go build -o dist/amt.exe . # 或 make
make backend # 启动后端 (uvicorn, port 3131)
```

18
LICENSE
View File

@@ -1,18 +0,0 @@
MIT License
Copyright (c) 2026 arinera
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

14
Makefile Normal file
View File

@@ -0,0 +1,14 @@
.PHONY: all backend
all: dist/amt.exe
./dist/amt.exe
dist/amt.exe: $(wildcard *.go) go.mod go.sum
@mkdir -p dist
go build -o dist/amt.exe .
backend:
uv run backend/main.py
manager:
uv run backend/manager.py

View File

@@ -1,2 +0,0 @@
# arinera-minecraft-tool

262
actions.go Normal file
View File

@@ -0,0 +1,262 @@
package main
import (
"archive/zip"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
func executeAction(versionDir string, action Action, index int, backupDir string) tea.Cmd {
total := index + 1
prefix := fmt.Sprintf("[%d]", total)
switch action.Type {
case "add":
return executeAdd(versionDir, action, index, backupDir, prefix)
case "delete":
return func() tea.Msg {
absPath := filepath.Join(versionDir, action.Path)
if err := backupPath(versionDir, action.Path, backupDir); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)}
}
if err := os.RemoveAll(absPath); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("delete failed: %w", err)}
}
return actionCompleteMsg{index: index}
}
case "copy":
return func() tea.Msg {
dst := filepath.Join(versionDir, action.NewPath)
if _, err := os.Stat(dst); err == nil {
if err := backupPath(versionDir, action.NewPath, backupDir); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)}
}
}
src := filepath.Join(versionDir, action.Path)
if err := copyPath(src, dst); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("copy failed: %w", err)}
}
return actionCompleteMsg{index: index}
}
case "move":
return func() tea.Msg {
src := filepath.Join(versionDir, action.Path)
dst := filepath.Join(versionDir, action.NewPath)
if _, err := os.Stat(dst); err == nil {
if err := backupPath(versionDir, action.NewPath, backupDir); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)}
}
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)}
}
if err := os.Rename(src, dst); err != nil {
if err := copyPath(src, dst); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("move failed: %w", err)}
}
os.RemoveAll(src)
}
return actionCompleteMsg{index: index}
}
default:
return func() tea.Msg {
return actionErrorMsg{index: index, err: fmt.Errorf("unknown action type: %s", action.Type)}
}
}
}
func executeAdd(versionDir string, action Action, index int, backupDir, prefix string) tea.Cmd {
return func() tea.Msg {
absPath := filepath.Join(versionDir, action.Path)
if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("mkdir failed: %w", err)}
}
if _, err := os.Stat(absPath); err == nil {
if err := backupPath(versionDir, action.Path, backupDir); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("backup failed: %w", err)}
}
}
err := downloadFile(action.URL, absPath)
if err != nil {
if len(action.Mirrors) > 0 {
return mirrorChoiceMsg{index: index, mirrors: action.Mirrors, action: action}
}
return actionErrorMsg{index: index, err: fmt.Errorf("download failed: %w", err)}
}
if action.Unzip {
destDir := filepath.Dir(absPath)
if err := unzipFile(absPath, destDir); err != nil {
return actionErrorMsg{index: index, err: fmt.Errorf("unzip failed: %w", err)}
}
os.Remove(absPath)
}
return actionCompleteMsg{index: index}
}
}
func waitForProgress(ch chan float64, index int) tea.Cmd {
return func() tea.Msg {
p, ok := <-ch
if !ok {
return nil
}
return actionProgressMsg{percent: p}
}
}
func downloadFile(url, destPath string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
tmpPath := destPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return err
}
_, err = io.Copy(f, resp.Body)
f.Close()
if err != nil {
os.Remove(tmpPath)
return err
}
return os.Rename(tmpPath, destPath)
}
func unzipFile(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
for _, f := range r.File {
target := filepath.Join(destDir, f.Name)
if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(destDir)+string(os.PathSeparator)) {
continue
}
if f.FileInfo().IsDir() {
os.MkdirAll(target, 0o755)
continue
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
outFile, err := os.Create(target)
if err != nil {
rc.Close()
return err
}
_, err = io.Copy(outFile, rc)
outFile.Close()
rc.Close()
if err != nil {
return err
}
}
return nil
}
func backupPath(versionDir, relativePath, backupDir string) error {
src := filepath.Join(versionDir, relativePath)
if _, err := os.Stat(src); os.IsNotExist(err) {
return nil
}
dst := filepath.Join(versionDir, backupDir, relativePath)
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
info, err := os.Stat(src)
if err != nil {
return err
}
if info.IsDir() {
return copyDir(src, dst)
}
return copyFile(src, dst)
}
func copyPath(src, dst string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
if info.IsDir() {
return copyDir(src, dst)
}
return copyFile(src, dst)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
func copyDir(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if info.IsDir() {
return os.MkdirAll(target, 0o755)
}
return copyFile(path, target)
})
}

50
api.go Normal file
View File

@@ -0,0 +1,50 @@
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
tea "github.com/charmbracelet/bubbletea"
)
const backendURL = "http://localhost:3131"
type StepResponse struct {
Actions []Action `json:"actions"`
}
type Action struct {
Type string `json:"type"`
Path string `json:"path"`
NewPath string `json:"new_path,omitempty"`
Unzip bool `json:"unzip,omitempty"`
URL string `json:"url,omitempty"`
Mirrors []string `json:"mirrors,omitempty"`
}
type actionsReceivedMsg struct{ actions []Action }
type apiErrorMsg struct{ err error }
func fetchActions(code string) tea.Cmd {
return func() tea.Msg {
url := fmt.Sprintf("%s/tools?code=%s", backendURL, code)
resp, err := http.Get(url)
if err != nil {
return apiErrorMsg{err: fmt.Errorf("request failed: %w", err)}
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return apiErrorMsg{err: fmt.Errorf("server returned %d: %s", resp.StatusCode, string(body))}
}
var step StepResponse
if err := json.NewDecoder(resp.Body).Decode(&step); err != nil {
return apiErrorMsg{err: fmt.Errorf("decode failed: %w", err)}
}
return actionsReceivedMsg{actions: step.Actions}
}
}

View File

@@ -1,126 +0,0 @@
package app
import (
"github.com/charmbracelet/bubbles/list"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// Module is the interface that all TUI feature modules must implement.
type Module interface {
tea.Model
Title() string
Description() string
}
// Entry represents a registered module with its factory function.
type Entry struct {
Title string
Desc string
Create func() tea.Model
}
// ===== menu item =====
type menuItem struct {
title string
desc string
}
func (mi menuItem) FilterValue() string { return mi.title }
func (mi menuItem) Title() string { return mi.title }
func (mi menuItem) Description() string { return mi.desc }
// ===== styles =====
var menuStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("39")).
PaddingLeft(1)
// ===== model =====
type Model struct {
menu list.Model
active tea.Model
entries []Entry
}
func New(entries []Entry) *Model {
items := make([]list.Item, len(entries))
for i, e := range entries {
items[i] = menuItem{title: e.Title, desc: e.Desc}
}
l := list.New(items, list.NewDefaultDelegate(), 0, 0)
l.Title = "ARinera Minecraft 管理工具"
l.SetShowStatusBar(false)
l.SetShowHelp(true)
return &Model{menu: l, entries: entries}
}
func (m *Model) Init() tea.Cmd {
return nil
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
case "esc":
if m.active != nil {
m.active = nil
return m, nil
}
return m, tea.Quit
}
}
if ws, ok := msg.(tea.WindowSizeMsg); ok {
if m.active == nil {
m.menu.SetSize(ws.Width, ws.Height-4)
}
}
if m.active != nil {
return m.updateModule(msg)
}
return m.updateMenu(msg)
}
func (m *Model) updateMenu(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" {
sel, ok := m.menu.SelectedItem().(menuItem)
if !ok {
return m, nil
}
for _, e := range m.entries {
if e.Title == sel.title {
m.active = e.Create()
return m, m.active.Init()
}
}
}
}
var cmd tea.Cmd
m.menu, cmd = m.menu.Update(msg)
return m, cmd
}
func (m *Model) updateModule(msg tea.Msg) (tea.Model, tea.Cmd) {
newMod, cmd := m.active.Update(msg)
m.active = newMod
return m, cmd
}
func (m *Model) View() string {
if m.active != nil {
return m.active.View()
}
return menuStyle.Render("ARinera Minecraft 管理工具") + "\n\n" + m.menu.View()
}

BIN
backend/data.db Normal file

Binary file not shown.

View File

@@ -1,14 +1,36 @@
import json
import os
from contextlib import asynccontextmanager
import uvicorn import uvicorn
from fastapi import FastAPI, HTTPException, Query
from peewee import SqliteDatabase, Model, CharField, TextField
from fastapi import FastAPI db_path = os.path.join(os.path.dirname(__file__), "data.db")
db = SqliteDatabase(db_path)
from modules import example
from modules import voxy_import
app = FastAPI(title="ARinera Minecraft TUI Backend") class BaseModel(Model):
class Meta:
database = db
app.include_router(example.router, prefix="/api/v1")
app.include_router(voxy_import.router, prefix="/api/v1") class Tool(BaseModel):
code = CharField(max_length=4, unique=True)
step = TextField()
desp = TextField(default="")
@asynccontextmanager
async def lifespan(app: FastAPI):
db.connect()
db.create_tables([Tool])
yield
if not db.is_closed():
db.close()
app = FastAPI(title="ARinera Minecraft TUI Backend", lifespan=lifespan)
@app.get("/health") @app.get("/health")
@@ -16,6 +38,16 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
@app.get("/tools")
async def get_tools(
code: str = Query(..., min_length=4, max_length=4, pattern=r"^\d{4}$"),
):
tool = Tool.get_or_none(Tool.code == code)
if tool is None:
raise HTTPException(status_code=404, detail=f"code {code} not found")
return json.loads(tool.step)
def main(): def main():
uvicorn.run("main:app", reload=True, port=3131) uvicorn.run("main:app", reload=True, port=3131)

165
backend/manage.py Normal file
View File

@@ -0,0 +1,165 @@
"""AMT 数据库管理脚本 - 交互式管理 Tool 表"""
import json
import os
import sys
from peewee import SqliteDatabase, Model, CharField, TextField
db_path = os.path.join(os.path.dirname(__file__), "data.db")
db = SqliteDatabase(db_path)
class BaseModel(Model):
class Meta:
database = db
class Tool(BaseModel):
code = CharField(max_length=4, unique=True)
step = TextField()
desp = TextField(default="")
def init_db():
db.connect()
db.create_tables([Tool])
def list_tools():
tools = Tool.select().order_by(Tool.code)
if not tools:
print("\n (空)")
return
print()
for t in tools:
print(f" [{t.code}] {t.desp or '(无描述)'}")
def show_tool():
code = input("输入 code: ").strip()
tool = Tool.get_or_none(Tool.code == code)
if tool is None:
print(f" code {code} 不存在")
return
print(f"\n code: {tool.code}")
print(f" desp: {tool.desp or '(无描述)'}")
print(f" step:")
print(json.dumps(json.loads(tool.step), indent=2, ensure_ascii=False))
def add_tool():
code = input("输入 code (4位数字): ").strip()
if len(code) != 4 or not code.isdigit():
print(" code 必须是4位数字")
return
if Tool.get_or_none(Tool.code == code):
print(f" code {code} 已存在,请用编辑功能修改")
return
desp = input("输入描述: ").strip()
print("输入 step JSON (输入空行结束):")
lines = []
while True:
line = input()
if line == "":
break
lines.append(line)
raw = "\n".join(lines)
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
print(f" JSON 解析失败: {e}")
return
Tool.create(code=code, step=json.dumps(parsed, ensure_ascii=False), desp=desp)
print(f" 已添加 [{code}]")
def edit_tool():
code = input("输入要编辑的 code: ").strip()
tool = Tool.get_or_none(Tool.code == code)
if tool is None:
print(f" code {code} 不存在")
return
print(f" 当前描述: {tool.desp or '(无描述)'}")
desp = input("新描述 (回车跳过): ").strip()
if desp:
tool.desp = desp
print(f" 当前 step:")
print(json.dumps(json.loads(tool.step), indent=2, ensure_ascii=False))
choice = input("修改 step? (y/N): ").strip().lower()
if choice == "y":
print("输入新的 step JSON (输入空行结束):")
lines = []
while True:
line = input()
if line == "":
break
lines.append(line)
raw = "\n".join(lines)
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
print(f" JSON 解析失败: {e}")
return
tool.step = json.dumps(parsed, ensure_ascii=False)
tool.save()
print(f" 已更新 [{code}]")
def delete_tool():
code = input("输入要删除的 code: ").strip()
tool = Tool.get_or_none(Tool.code == code)
if tool is None:
print(f" code {code} 不存在")
return
print(f" [{tool.code}] {tool.desp or '(无描述)'}")
confirm = input("确认删除? (y/N): ").strip().lower()
if confirm == "y":
tool.delete_instance()
print(f" 已删除 [{code}]")
else:
print(" 已取消")
MENU = {
"1": ("列出所有", list_tools),
"2": ("查看详情", show_tool),
"3": ("添加", add_tool),
"4": ("编辑", edit_tool),
"5": ("删除", delete_tool),
}
def main():
init_db()
print("=== AMT 数据库管理 ===")
while True:
print()
for k, (label, _) in MENU.items():
print(f" {k}. {label}")
print(" q. 退出")
choice = input("\n> ").strip().lower()
if choice == "q":
break
action = MENU.get(choice)
if action:
try:
action[1]()
except (KeyboardInterrupt, EOFError):
print()
else:
print(" 无效选项")
db.close()
if __name__ == "__main__":
main()

View File

@@ -1,18 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/example", tags=["example"])
@router.get("/")
async def list_items():
return {"items": ["a", "b", "c"]}
@router.get("/{item_id}")
async def get_item(item_id: int):
return {"id": item_id, "name": f"item_{item_id}"}
@router.post("/")
async def create_item():
return {"status": "created"}

View File

@@ -1,12 +0,0 @@
from fastapi import APIRouter
router = APIRouter(prefix="/voxy_import", tags=["voxy_import"])
@router.get("/")
async def list_urls():
return {
"default": "https://dav.arinera.fun/voxy.zip",
"cf": "https://oss.arinera.space/voxy.zip",
"test": "http://local.arinera.fun:9000/oss/voxy.zip",
}

View File

@@ -6,6 +6,6 @@ readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"fastapi>=0.136.3", "fastapi>=0.136.3",
"httpx>=0.28.1", "peewee>=4.0.6",
"uvicorn>=0.48.0", "uvicorn>=0.48.0",
] ]

52
backend/uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.12" requires-python = ">=3.12"
[[package]] [[package]]
@@ -8,14 +8,14 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "peewee" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", specifier = ">=0.136.3" }, { name = "fastapi", specifier = ">=0.136.3" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "peewee", specifier = ">=4.0.6" },
{ name = "uvicorn", specifier = ">=0.48.0" }, { name = "uvicorn", specifier = ">=0.48.0" },
] ]
@@ -50,15 +50,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
] ]
[[package]]
name = "certifi"
version = "2026.5.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.4.1" version = "8.4.1"
@@ -105,34 +96,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.16" version = "3.16"
@@ -142,6 +105,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" },
] ]
[[package]]
name = "peewee"
version = "4.0.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/09/a3b2a32ce498f405dce4320267e99b1b076c1ea39ad01151a353bc7f81d7/peewee-4.0.6.tar.gz", hash = "sha256:ea2f78f24ff9e3660281dc5b0be8bc00d9a9514bdc40c98e416fcd042b66ac6a", size = 724591, upload-time = "2026-05-20T13:18:17.26Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/6a/e1455b94ee48f5666f2e7831b6247098794bfe9747da457111be4d0bea10/peewee-4.0.6-py3-none-any.whl", hash = "sha256:5fa665913c410f0b5faef1469ed0aa9eceb9fef262665ebbb6f29408f826eeeb", size = 146222, upload-time = "2026-05-20T13:18:15.694Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"

2
go.mod
View File

@@ -1,6 +1,6 @@
module amt module amt
go 1.25.5 go 1.24.5
require ( require (
github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbles v1.0.0

27
items.go Normal file
View File

@@ -0,0 +1,27 @@
package main
type versionItem struct {
name string
path string
}
func (v versionItem) Title() string { return v.name }
func (v versionItem) Description() string { return v.path }
func (v versionItem) FilterValue() string { return v.name }
type menuItem struct {
title string
desc string
}
func (m menuItem) Title() string { return m.title }
func (m menuItem) Description() string { return m.desc }
func (m menuItem) FilterValue() string { return m.title }
type mirrorItem struct {
url string
}
func (m mirrorItem) Title() string { return m.url }
func (m mirrorItem) Description() string { return "" }
func (m mirrorItem) FilterValue() string { return m.url }

26
main.go
View File

@@ -1,33 +1,25 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"amt/app"
"amt/modules/voxyimport"
) )
func main() { var errNotDigit = errors.New("only digits allowed")
defer voxyimport.Cleanup()
entries := []app.Entry{ func main() {
{ exeDir := "."
Title: "Voxy Import", if exePath, err := os.Executable(); err == nil {
Desc: "导入服务器 Voxy 数据", exeDir = filepath.Dir(exePath)
Create: func() tea.Model {
return voxyimport.New(voxyimport.NewDefaultVoxyService())
},
},
// 在这里添加新模块即可:
// {Title: "...", Desc: "...", Create: func() tea.Model { return newmodule.New(newmodule.NewDefaultService()) }},
} }
p := tea.NewProgram(app.New(entries)) p := tea.NewProgram(newModel(exeDir), tea.WithAltScreen())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Println("Error:", err) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) os.Exit(1)
} }
} }

137
model.go Normal file
View File

@@ -0,0 +1,137 @@
package main
import (
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type page int
const (
pageVersionSelect page = iota
pageMainMenu
pageCodeInput
pageExecuting
)
type model struct {
currentPage page
exeDir string
versionDir string
versionName string
width int
height int
versionList list.Model
menuList list.Model
codeInput textinput.Model
spinner spinner.Model
progress progress.Model
actions []Action
actionIdx int
logLines []string
execErr error
execDone bool
choosingMirror bool
mirrorList list.Model
pendingAction *Action
backupDir string
progressCh chan float64
}
func newModel(exeDir string) model {
s := spinner.New()
s.Spinner = spinner.Dot
ti := textinput.New()
ti.Placeholder = "0000"
ti.CharLimit = 4
ti.Width = 10
ti.Validate = func(s string) error {
for _, r := range s {
if r < '0' || r > '9' {
return errNotDigit
}
}
return nil
}
delegate := list.NewDefaultDelegate()
vl := list.New(nil, delegate, 80, 20)
vl.Title = "选择 Minecraft 版本"
vl.SetFilteringEnabled(false)
vl.SetShowStatusBar(false)
ml := list.New(nil, delegate, 80, 20)
ml.Title = "主菜单"
ml.SetFilteringEnabled(false)
ml.SetShowStatusBar(false)
return model{
currentPage: pageVersionSelect,
exeDir: exeDir,
spinner: s,
progress: progress.New(progress.WithDefaultGradient()),
codeInput: ti,
versionList: vl,
menuList: ml,
}
}
func (m model) Init() tea.Cmd {
return scanVersions(m.exeDir)
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
h := msg.Height - 4
if h < 5 {
h = 5
}
m.versionList.SetSize(msg.Width, h)
m.menuList.SetSize(msg.Width, h)
m.progress.Width = msg.Width - 8
if m.progress.Width > 80 {
m.progress.Width = 80
}
}
switch m.currentPage {
case pageVersionSelect:
return updateVersionSelect(m, msg)
case pageMainMenu:
return updateMainMenu(m, msg)
case pageCodeInput:
return updateCodeInput(m, msg)
case pageExecuting:
return updateExecuting(m, msg)
}
return m, nil
}
func (m model) View() string {
switch m.currentPage {
case pageVersionSelect:
return viewVersionSelect(m)
case pageMainMenu:
return viewMainMenu(m)
case pageCodeInput:
return viewCodeInput(m)
case pageExecuting:
return viewExecuting(m)
}
return ""
}

View File

@@ -1,264 +0,0 @@
package voxyimport
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// ===== default implementation =====
// DefaultVoxyService is the production implementation of VoxyService.
type DefaultVoxyService struct {
SourcesAPIURL string
}
// NewDefaultVoxyService creates a new DefaultVoxyService with the standard API URL.
func NewDefaultVoxyService() *DefaultVoxyService {
return &DefaultVoxyService{
SourcesAPIURL: "http://127.0.0.1:3131/api/v1/voxy_import/",
}
}
// ===== SearchDirs =====
func (s *DefaultVoxyService) SearchDirs(serverAddr string) ([]string, error) {
var dirs []string
suffix := filepath.Join(".voxy", "saves", serverAddr)
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
clean := filepath.ToSlash(path)
s := filepath.ToSlash(suffix)
if strings.HasSuffix(clean, "/"+s) || clean == s {
dirs = append(dirs, path)
return filepath.SkipDir
}
}
return nil
})
sort.Strings(dirs)
return dirs, err
}
// ===== FetchSources =====
func (s *DefaultVoxyService) FetchSources() ([]Source, error) {
resp, err := http.Get(s.SourcesAPIURL)
if err != nil {
return nil, fmt.Errorf("获取下载源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("获取下载源 HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var urls []string
if err := json.Unmarshal(body, &urls); err != nil {
// not a plain array — try {"urls": [...]}
var wrapper struct {
URLs []string `json:"urls"`
}
if err := json.Unmarshal(body, &wrapper); err != nil {
return nil, fmt.Errorf("解析下载源数据失败: %w", err)
}
urls = wrapper.URLs
}
var sources []Source
if len(urls) > 0 {
sources = append(sources, Source{Name: "默认源 (arinera.fun)", URL: urls[0]})
}
if len(urls) > 1 {
sources = append(sources, Source{Name: "CloudFlare 源 (arinera.space)", URL: urls[1]})
}
return sources, nil
}
// ===== DownloadFile =====
func (s *DefaultVoxyService) DownloadFile(url string, destPath string, onProgress func(current, total int64)) error {
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("create dest file: %w", err)
}
defer out.Close()
buf := make([]byte, 32*1024)
var current int64
total := resp.ContentLength
for {
n, readErr := resp.Body.Read(buf)
if n > 0 {
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
return fmt.Errorf("write dest file: %w", writeErr)
}
current += int64(n)
if onProgress != nil {
onProgress(current, total)
}
}
if readErr != nil {
if readErr != io.EOF {
return fmt.Errorf("read response: %w", readErr)
}
break
}
}
return out.Close()
}
// ===== BackupDir =====
func (s *DefaultVoxyService) BackupDir(dir string) (string, error) {
timestamp := time.Now().Format("2006-01-02_150405")
backupName := timestamp + ".bak.zip"
backupPath := filepath.Join(dir, backupName)
f, err := os.Create(backupPath)
if err != nil {
return "", fmt.Errorf("create backup: %w", err)
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if path == backupPath {
return nil
}
relPath, err := filepath.Rel(dir, path)
if err != nil {
return nil
}
relPath = filepath.ToSlash(relPath)
entry, err := w.Create(relPath)
if err != nil {
return fmt.Errorf("create zip entry %s: %w", relPath, err)
}
src, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
defer src.Close()
if _, err := io.Copy(entry, src); err != nil {
return fmt.Errorf("write %s: %w", relPath, err)
}
return nil
})
if err != nil {
return "", fmt.Errorf("backup walk: %w", err)
}
return backupName, nil
}
// ===== CleanupDir =====
func (s *DefaultVoxyService) CleanupDir(dir string, keepName string) error {
keepPath := filepath.Join(dir, keepName)
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if path == dir {
return nil
}
if path == keepPath {
return nil
}
return os.RemoveAll(path)
})
}
// ===== ExtractZip =====
func (s *DefaultVoxyService) ExtractZip(zipPath, dir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("open zip: %w", err)
}
defer r.Close()
for _, f := range r.File {
if err := extractEntry(f, dir); err != nil {
return err
}
}
return nil
}
func extractEntry(f *zip.File, destDir string) error {
targetPath := filepath.Join(destDir, f.Name)
cleanDest, _ := filepath.Abs(destDir)
cleanTarget, _ := filepath.Abs(targetPath)
if !strings.HasPrefix(cleanTarget, cleanDest+string(os.PathSeparator)) && cleanTarget != cleanDest {
return fmt.Errorf("path traversal detected: %s", f.Name)
}
if f.FileInfo().IsDir() {
return os.MkdirAll(targetPath, os.ModePerm)
}
if err := os.MkdirAll(filepath.Dir(targetPath), os.ModePerm); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(targetPath), err)
}
rc, err := f.Open()
if err != nil {
return fmt.Errorf("open zip entry %s: %w", f.Name, err)
}
defer rc.Close()
out, err := os.Create(targetPath)
if err != nil {
return fmt.Errorf("create %s: %w", targetPath, err)
}
defer out.Close()
if _, err := io.Copy(out, rc); err != nil {
return fmt.Errorf("write %s: %w", f.Name, err)
}
return nil
}

View File

@@ -1,704 +0,0 @@
package voxyimport
import (
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// ===== shared types =====
// Source represents a downloadable update source.
type Source struct {
Name string
URL string
}
// ===== service interface =====
// VoxyService defines all data operations needed by this module.
// Each module defines its own service interface — the concrete
// implementation lives alongside it in this package.
type VoxyService interface {
SearchDirs(serverAddr string) ([]string, error)
FetchSources() ([]Source, error)
DownloadFile(url string, destPath string, onProgress func(current, total int64)) error
BackupDir(dir string) (string, error)
CleanupDir(dir string, keepName string) error
ExtractZip(zipPath, dir string) error
}
// ===== module metadata =====
func (m *Model) Title() string { return "Voxy Import" }
func (m *Model) Description() string { return "下载更新包、备份、清理、解压" }
// ===== state machine =====
type state int
const (
stateInput state = iota
stateSearching
stateSelectDir
stateFetchSources
stateSelectSource
stateWorking
stateDone
stateError
)
// work steps inside stateWorking
const (
stepDownload = iota
stepBackup
stepCleanup
stepExtract
stepFinished
)
// ===== styles =====
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Bold(true)
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")).Bold(true)
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
stepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226"))
)
// ===== messages =====
type searchDoneMsg struct {
dirs []string
err error
}
type stepDoneMsg struct {
nextStep int
detail string
err error
}
type sourcesFetchedMsg struct {
sources []Source
err error
}
type downloadProgressMsg struct{}
// downloadTracker is shared between the download goroutine and the bubbletea
// event loop. The goroutine writes progress, the ticker reads it.
type downloadTracker struct {
mu sync.Mutex
total int64
current int64
done bool
zipPath string
err error
}
// ===== temp file tracking =====
var (
pendingTempMu sync.Mutex
pendingTemps = map[string]struct{}{}
)
func trackTemp(path string) {
pendingTempMu.Lock()
pendingTemps[path] = struct{}{}
pendingTempMu.Unlock()
}
func untrackTemp(path string) {
pendingTempMu.Lock()
delete(pendingTemps, path)
pendingTempMu.Unlock()
}
func cleanupPendingTemps() {
pendingTempMu.Lock()
defer pendingTempMu.Unlock()
for p := range pendingTemps {
os.Remove(p)
}
pendingTemps = map[string]struct{}{}
}
// Cleanup removes any lingering temp files. Call with defer from main.
func Cleanup() {
cleanupPendingTemps()
}
// ===== list items =====
type dirItem struct {
title string
}
func (d dirItem) FilterValue() string { return d.title }
func (d dirItem) Title() string { return d.title }
func (d dirItem) Description() string { return "" }
type sourceItem struct {
name string
url string
}
func (s sourceItem) FilterValue() string { return s.name }
func (s sourceItem) Title() string { return s.name }
func (s sourceItem) Description() string { return s.url }
// ===== model =====
type Model struct {
state state
// service
svc VoxyService
// input
textInput textinput.Model
// search
spinner spinner.Model
foundDirs []string
// selection lists
list list.Model
selectedDir string
fetchedSources []Source
// working
currentStep int
stepLabel string
downloadedZip string
selectedURL string
backupName string
progress progress.Model
dlTracker *downloadTracker
// error
errMsg string
}
func New(svc VoxyService) *Model {
ti := textinput.New()
ti.Placeholder = "e.g. nas.arinera.fun"
ti.Focus()
ti.CharLimit = 50
ti.Width = 40
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("39"))
l := list.New([]list.Item{}, list.NewDefaultDelegate(), 80, 20)
l.Title = "请选择目录"
l.SetShowStatusBar(false)
l.SetFilteringEnabled(false)
l.SetShowHelp(true)
p := progress.New(progress.WithDefaultGradient())
p.Width = 40
return &Model{
state: stateInput,
svc: svc,
textInput: ti,
spinner: s,
list: l,
progress: p,
}
}
// ===== init =====
func (m *Model) Init() tea.Cmd {
return tea.Batch(
textinput.Blink,
m.spinner.Tick,
)
}
// ===== update =====
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.list.SetSize(msg.Width, msg.Height-4)
if msg.Width > 100 {
m.progress.Width = 100
} else if msg.Width > 4 {
m.progress.Width = msg.Width - 4
}
}
if frameMsg, ok := msg.(progress.FrameMsg); ok {
pm, cmd := m.progress.Update(frameMsg)
m.progress = pm.(progress.Model)
return m, cmd
}
switch m.state {
case stateInput:
return m.updateInput(msg)
case stateSearching:
return m.updateSearching(msg)
case stateSelectDir:
return m.updateSelectDir(msg)
case stateFetchSources:
return m.updateFetchSources(msg)
case stateSelectSource:
return m.updateSelectSource(msg)
case stateWorking:
return m.updateWorking(msg)
case stateDone, stateError:
return m.updateDone(msg)
}
return m, nil
}
// ---- input ----
func (m *Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
addr := strings.TrimSpace(m.textInput.Value())
if addr == "" {
return m, nil
}
m.state = stateSearching
return m, tea.Batch(
m.spinner.Tick,
m.searchDirsCmd(addr),
)
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
// ---- searching ----
func (m *Model) updateSearching(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case searchDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.foundDirs = msg.dirs
if len(m.foundDirs) == 0 {
m.state = stateError
m.errMsg = "没找到指定的服务器目录"
return m, nil
}
if len(m.foundDirs) == 1 {
m.selectedDir = m.foundDirs[0]
m.state = stateFetchSources
return m, tea.Batch(
m.spinner.Tick,
m.fetchSourcesCmd(),
)
}
items := make([]list.Item, len(m.foundDirs))
for i, d := range m.foundDirs {
items[i] = dirItem{title: d}
}
m.list.SetItems(items)
m.list.Title = "请选择目录"
m.state = stateSelectDir
return m, nil
}
return m, nil
}
// ---- select dir ----
func (m *Model) updateSelectDir(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.list.FilterState() == list.Filtering {
break
}
if sel, ok := m.list.SelectedItem().(dirItem); ok && sel.title != "" {
m.selectedDir = sel.title
m.state = stateFetchSources
return m, tea.Batch(
m.spinner.Tick,
m.fetchSourcesCmd(),
)
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
// ---- fetch sources ----
func (m *Model) updateFetchSources(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case sourcesFetchedMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = msg.err.Error()
return m, nil
}
m.fetchedSources = msg.sources
m.populateSourceList()
m.state = stateSelectSource
return m, nil
}
return m, nil
}
// ---- select source ----
func (m *Model) populateSourceList() {
items := make([]list.Item, len(m.fetchedSources))
for i, src := range m.fetchedSources {
items[i] = sourceItem{name: src.Name, url: src.URL}
}
m.list.SetItems(items)
m.list.Title = "请选择下载源"
}
func (m *Model) updateSelectSource(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
if m.list.FilterState() == list.Filtering {
break
}
if sel, ok := m.list.SelectedItem().(sourceItem); ok && sel.url != "" {
m.selectedURL = sel.url
m.state = stateWorking
m.currentStep = stepDownload
m.stepLabel = "正在下载..."
m.dlTracker = &downloadTracker{}
return m, tea.Batch(
m.spinner.Tick,
m.startDownloadCmd(m.selectedURL, m.dlTracker),
tickDownload(),
)
}
}
}
var cmd tea.Cmd
m.list, cmd = m.list.Update(msg)
return m, cmd
}
// ---- working ----
func (m *Model) updateWorking(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case downloadProgressMsg:
if m.dlTracker == nil {
return m, nil
}
m.dlTracker.mu.Lock()
current := m.dlTracker.current
total := m.dlTracker.total
done := m.dlTracker.done
zipPath := m.dlTracker.zipPath
dlErr := m.dlTracker.err
m.dlTracker.mu.Unlock()
if done {
if dlErr != nil {
m.state = stateError
m.errMsg = fmt.Sprintf("Download failed: %v", dlErr)
return m, nil
}
m.downloadedZip = zipPath
m.currentStep = stepBackup
m.stepLabel = "创建备份..."
m.progress.SetPercent(1.0)
return m, tea.Batch(
m.spinner.Tick,
m.backupDirCmd(m.selectedDir),
)
}
var pct float64
if total > 0 {
pct = float64(current) / float64(total)
}
return m, tea.Batch(
m.spinner.Tick,
m.progress.SetPercent(pct),
tickDownload(),
)
case stepDoneMsg:
if msg.err != nil {
m.state = stateError
m.errMsg = fmt.Sprintf("Step '%s' failed: %v", m.stepLabel, msg.err)
return m, nil
}
switch msg.nextStep {
case stepBackup:
m.currentStep = stepBackup
m.stepLabel = "创建备份..."
m.downloadedZip = msg.detail
return m, tea.Batch(
m.spinner.Tick,
m.backupDirCmd(m.selectedDir),
)
case stepCleanup:
m.currentStep = stepCleanup
m.stepLabel = "正在清理旧文件..."
m.backupName = msg.detail
return m, tea.Batch(
m.spinner.Tick,
m.cleanupDirCmd(m.selectedDir, m.backupName),
)
case stepExtract:
m.currentStep = stepExtract
m.stepLabel = "正在解压更新..."
return m, tea.Batch(
m.spinner.Tick,
m.extractZipCmd(m.downloadedZip, m.selectedDir),
)
case stepFinished:
m.state = stateDone
if m.downloadedZip != "" {
untrackTemp(m.downloadedZip)
os.Remove(m.downloadedZip)
}
return m, nil
}
}
return m, nil
}
// ---- done / error ----
func (m *Model) updateDone(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
// ===== view =====
func (m *Model) View() string {
switch m.state {
case stateInput:
return m.viewInput()
case stateSearching:
return m.viewSearching()
case stateSelectDir:
return m.viewSelectDir()
case stateFetchSources:
return m.viewFetchSources()
case stateSelectSource:
return m.viewSelectSource()
case stateWorking:
return m.viewWorking()
case stateDone:
return m.viewDone()
case stateError:
return m.viewError()
}
return ""
}
func (m *Model) viewInput() string {
return titleStyle.Render("Voxy Import") + "\n\n" +
"输入服务器地址:\n\n" +
m.textInput.View() + "\n\n" +
helpStyle.Render("Enter · 确认 Esc · 返回菜单")
}
func (m *Model) viewSearching() string {
return fmt.Sprintf("%s 正在搜索保存目录...\n", m.spinner.View())
}
func (m *Model) viewSelectDir() string {
return m.list.View()
}
func (m *Model) viewFetchSources() string {
return titleStyle.Render("Voxy Import") + "\n\n" +
fmt.Sprintf("%s 正在获取下载源...\n", m.spinner.View())
}
func (m *Model) viewSelectSource() string {
return m.list.View()
}
func (m *Model) viewWorking() string {
s := fmt.Sprintf(
"%s %s\n",
m.spinner.View(),
stepStyle.Render(m.stepLabel),
)
if m.currentStep == stepDownload {
s += "\n" + m.progress.View() + "\n"
}
s += m.progressSteps()
return s
}
func (m *Model) progressSteps() string {
steps := []string{"下载", "备份", "清理", "解压"}
var out strings.Builder
for i, name := range steps {
marker := "○"
if i < m.currentStep {
marker = "✓"
} else if i == m.currentStep {
marker = "●"
}
style := subtleStyle
if i == m.currentStep {
style = stepStyle
} else if i < m.currentStep {
style = successStyle
}
out.WriteString(fmt.Sprintf(" %s %s", style.Render(marker), style.Render(name)))
}
return "\n\n" + out.String()
}
func (m *Model) viewDone() string {
return successStyle.Render("导入成功!") + "\n\n" +
"目录: " + m.selectedDir + "\n" +
"备份: " + m.backupName
}
func (m *Model) viewError() string {
return errorStyle.Render("错误!") + "\n\n" +
m.errMsg + "\n\n" +
helpStyle.Render("Esc · 返回菜单")
}
// ===== async commands =====
func (m *Model) searchDirsCmd(serverAddr string) tea.Cmd {
return func() tea.Msg {
dirs, err := m.svc.SearchDirs(serverAddr)
return searchDoneMsg{dirs: dirs, err: err}
}
}
func (m *Model) fetchSourcesCmd() tea.Cmd {
return func() tea.Msg {
sources, err := m.svc.FetchSources()
return sourcesFetchedMsg{sources: sources, err: err}
}
}
func (m *Model) startDownloadCmd(url string, tr *downloadTracker) tea.Cmd {
return func() tea.Msg {
tmpFile, err := os.CreateTemp("", "voxy_update_*.zip")
if err != nil {
tr.mu.Lock()
tr.done = true
tr.err = fmt.Errorf("create temp file: %w", err)
tr.mu.Unlock()
return downloadProgressMsg{}
}
tmpPath := tmpFile.Name()
tmpFile.Close()
trackTemp(tmpPath)
go func() {
err := m.svc.DownloadFile(url, tmpPath, func(current, total int64) {
tr.mu.Lock()
tr.current = current
tr.total = total
tr.mu.Unlock()
})
tr.mu.Lock()
tr.done = true
if err != nil {
tr.err = err
} else {
tr.zipPath = tmpPath
}
tr.mu.Unlock()
}()
return downloadProgressMsg{}
}
}
func (m *Model) backupDirCmd(dir string) tea.Cmd {
return func() tea.Msg {
backupName, err := m.svc.BackupDir(dir)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: err}
}
return stepDoneMsg{nextStep: stepCleanup, detail: backupName}
}
}
func (m *Model) cleanupDirCmd(dir string, keepName string) tea.Cmd {
return func() tea.Msg {
err := m.svc.CleanupDir(dir, keepName)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: fmt.Errorf("cleanup: %w", err)}
}
return stepDoneMsg{nextStep: stepExtract}
}
}
func (m *Model) extractZipCmd(zipPath, dir string) tea.Cmd {
return func() tea.Msg {
err := m.svc.ExtractZip(zipPath, dir)
if err != nil {
return stepDoneMsg{nextStep: stepFinished, err: err}
}
return stepDoneMsg{nextStep: stepFinished}
}
}
func tickDownload() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return downloadProgressMsg{}
})
}

332
pages.go Normal file
View File

@@ -0,0 +1,332 @@
package main
import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
)
// --- Version Select Page ---
func updateVersionSelect(m model, msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case versionsFoundMsg:
items := make([]list.Item, len(msg))
for i, v := range msg {
items[i] = versionItem{name: v.name, path: v.path}
}
m.logLines = nil // Bug3: 清除旧的扫描错误,否则 view 永远显示错误
m.versionList.SetItems(items)
return m, nil
case scanErrorMsg:
m.logLines = []string{errorStyle.Render("扫描版本目录失败: " + msg.err.Error())}
return m, nil
case tea.KeyMsg:
if msg.String() == "enter" {
selected, ok := m.versionList.SelectedItem().(versionItem)
if ok {
m.versionDir = selected.path
m.versionName = selected.name
m.currentPage = pageMainMenu
m.logLines = nil // Bug1: 清除版本选择页的 logLines
m.menuList.SetItems([]list.Item{
menuItem{title: "输入数字码", desc: "输入工具码以执行操作"},
menuItem{title: "切换版本", desc: "选择其他游戏版本"},
})
return m, nil
}
}
}
var cmd tea.Cmd
m.versionList, cmd = m.versionList.Update(msg)
return m, cmd
}
func viewVersionSelect(m model) string {
if len(m.logLines) > 0 {
return "\n" + strings.Join(m.logLines, "\n") + "\n\n" + subtleStyle.Render("按 ctrl+c 退出")
}
return m.versionList.View()
}
// --- Main Menu Page ---
func updateMainMenu(m model, msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
idx := m.menuList.Index()
switch idx {
case 0:
m.currentPage = pageCodeInput
m.codeInput.SetValue("")
return m, m.codeInput.Focus()
case 1:
m.currentPage = pageVersionSelect
m.logLines = nil // Bug2: 清除残留 logLines否则版本列表被错误文本遮挡
m.versionList.SetItems(nil) // Bug10: 清除旧列表,避免短暂显示过时数据
return m, scanVersions(m.exeDir)
}
case "esc", "q":
return m, tea.Quit
}
}
var cmd tea.Cmd
m.menuList, cmd = m.menuList.Update(msg)
return m, cmd
}
func viewMainMenu(m model) string {
header := subtleStyle.Render(fmt.Sprintf("当前版本: %s", m.versionName))
return header + "\n" + m.menuList.View()
}
// --- Code Input Page ---
func updateCodeInput(m model, msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "enter":
code := m.codeInput.Value()
if len(code) == 4 {
m.currentPage = pageExecuting
m = resetExecState(m)
return m, tea.Batch(m.spinner.Tick, fetchActions(code))
}
case "esc":
m.currentPage = pageMainMenu
return m, nil
}
}
var cmd tea.Cmd
m.codeInput, cmd = m.codeInput.Update(msg)
return m, cmd
}
func viewCodeInput(m model) string {
return fmt.Sprintf(
"%s\n\n 输入4位数字码:\n\n %s\n\n %s\n",
appTitle,
m.codeInput.View(),
subtleStyle.Render("enter 确认 | esc 返回"),
)
}
// --- Executing Page ---
type actionProgressMsg struct {
percent float64
message string
}
type actionCompleteMsg struct{ index int }
type actionErrorMsg struct {
index int
err error
}
type mirrorChoiceMsg struct {
index int
mirrors []string
action Action
}
func updateExecuting(m model, msg tea.Msg) (model, tea.Cmd) {
if m.choosingMirror {
return updateMirrorChoice(m, msg)
}
switch msg := msg.(type) {
case actionsReceivedMsg:
m.actions = msg.actions
m.actionIdx = 0
if len(m.actions) == 0 {
m.execDone = true
m.logLines = append(m.logLines, successStyle.Render("没有需要执行的操作"))
return m, nil
}
m.backupDir = fmt.Sprintf("amt/backup/%s", time.Now().Format("20060102_150405"))
m.logLines = append(m.logLines, boldStyle.Render(fmt.Sprintf("共 %d 个操作", len(m.actions))))
m.logLines = append(m.logLines, describeAction(m.actions[0], 0, len(m.actions)))
return m, tea.Batch(m.spinner.Tick, executeAction(m.versionDir, m.actions[0], 0, m.backupDir))
case apiErrorMsg:
m.execErr = msg.err
m.logLines = append(m.logLines, crossMark+" "+errorStyle.Render("API 请求失败: "+msg.err.Error()))
return m, nil
case actionProgressMsg:
if msg.message != "" {
m.logLines = append(m.logLines, msg.message)
}
cmd := m.progress.SetPercent(msg.percent)
var cmds []tea.Cmd
cmds = append(cmds, cmd)
if m.progressCh != nil {
cmds = append(cmds, waitForProgress(m.progressCh, m.actionIdx))
}
return m, tea.Batch(cmds...)
case actionCompleteMsg:
m.progressCh = nil
total := len(m.actions)
m.logLines = append(m.logLines, fmt.Sprintf("%s [%d/%d] 完成", checkMark, msg.index+1, total))
if msg.index+1 >= total {
m.execDone = true
m.logLines = append(m.logLines, "\n"+successStyle.Render("全部操作完成!"))
return m, m.progress.SetPercent(1.0)
}
m.actionIdx = msg.index + 1
m.logLines = append(m.logLines, describeAction(m.actions[m.actionIdx], m.actionIdx, total))
progressCmd := m.progress.SetPercent(float64(m.actionIdx) / float64(total))
return m, tea.Batch(
progressCmd,
executeAction(m.versionDir, m.actions[m.actionIdx], m.actionIdx, m.backupDir),
)
case actionErrorMsg:
m.progressCh = nil
m.execErr = msg.err
m.logLines = append(m.logLines, fmt.Sprintf("%s [%d/%d] 失败: %s", crossMark, msg.index+1, len(m.actions), msg.err.Error()))
return m, nil
case mirrorChoiceMsg:
m.progressCh = nil
m.choosingMirror = true
m.pendingAction = &msg.action
items := make([]list.Item, len(msg.mirrors))
for i, u := range msg.mirrors {
items[i] = mirrorItem{url: u}
}
delegate := list.NewDefaultDelegate()
l := list.New(items, delegate, m.width, m.height-6)
l.Title = "主 URL 下载失败,请选择镜像源"
l.SetFilteringEnabled(false)
l.SetShowStatusBar(false)
m.mirrorList = l
return m, nil
case tea.KeyMsg:
if m.execDone || m.execErr != nil {
m.currentPage = pageMainMenu
m = resetExecState(m)
return m, nil
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
case progress.FrameMsg:
p, cmd := m.progress.Update(msg)
m.progress = p.(progress.Model)
return m, cmd
}
return m, nil
}
func updateMirrorChoice(m model, msg tea.Msg) (model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "enter" {
selected, ok := m.mirrorList.SelectedItem().(mirrorItem)
if ok && m.pendingAction != nil {
m.choosingMirror = false
action := *m.pendingAction
action.URL = selected.url
m.pendingAction = nil
m.logLines = append(m.logLines, subtleStyle.Render("使用镜像: "+selected.url))
return m, executeAction(m.versionDir, action, m.actionIdx, m.backupDir)
}
}
if msg.String() == "esc" {
m.choosingMirror = false
m.pendingAction = nil
m.execErr = fmt.Errorf("用户取消镜像选择")
m.logLines = append(m.logLines, crossMark+" "+errorStyle.Render("已取消"))
return m, nil
}
}
var cmd tea.Cmd
m.mirrorList, cmd = m.mirrorList.Update(msg)
return m, cmd
}
func viewExecuting(m model) string {
if m.choosingMirror {
return m.mirrorList.View()
}
var sb strings.Builder
sb.WriteString(appTitle)
sb.WriteString("\n\n")
for _, line := range m.logLines {
sb.WriteString(" ")
sb.WriteString(line)
sb.WriteString("\n")
}
if !m.execDone && m.execErr == nil && len(m.actions) > 0 {
sb.WriteString("\n")
sb.WriteString(fmt.Sprintf(" %s 正在执行 [%d/%d]...\n", m.spinner.View(), m.actionIdx+1, len(m.actions)))
sb.WriteString("\n ")
sb.WriteString(m.progress.View())
sb.WriteString("\n")
}
if m.execDone || m.execErr != nil {
sb.WriteString("\n ")
sb.WriteString(subtleStyle.Render("按任意键返回主菜单"))
sb.WriteString("\n")
}
return sb.String()
}
func resetExecState(m model) model {
m.actions = nil
m.actionIdx = 0
m.logLines = nil
m.execErr = nil
m.execDone = false
m.choosingMirror = false
m.pendingAction = nil
m.backupDir = ""
m.progressCh = nil
return m
}
func describeAction(a Action, idx, total int) string {
prefix := fmt.Sprintf("[%d/%d]", idx+1, total)
switch a.Type {
case "add":
if a.Unzip {
return subtleStyle.Render(fmt.Sprintf("%s 下载并解压 %s ...", prefix, a.Path))
}
return subtleStyle.Render(fmt.Sprintf("%s 下载 %s ...", prefix, a.Path))
case "delete":
return subtleStyle.Render(fmt.Sprintf("%s 删除 %s ...", prefix, a.Path))
case "copy":
return subtleStyle.Render(fmt.Sprintf("%s 复制 %s -> %s ...", prefix, a.Path, a.NewPath))
case "move":
return subtleStyle.Render(fmt.Sprintf("%s 移动 %s -> %s ...", prefix, a.Path, a.NewPath))
default:
return subtleStyle.Render(fmt.Sprintf("%s %s %s ...", prefix, a.Type, a.Path))
}
}

39
scan.go Normal file
View File

@@ -0,0 +1,39 @@
package main
import (
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
)
type versionInfo struct {
name string
path string
}
type versionsFoundMsg []versionInfo
type scanErrorMsg struct{ err error }
func scanVersions(exeDir string) tea.Cmd {
return func() tea.Msg {
pattern := filepath.Join(exeDir, ".minecraft", "versions", "*")
matches, err := filepath.Glob(pattern)
if err != nil {
return scanErrorMsg{err: err}
}
var versions []versionInfo
for _, m := range matches {
info, err := os.Stat(m)
if err != nil || !info.IsDir() {
continue
}
versions = append(versions, versionInfo{
name: filepath.Base(m),
path: m,
})
}
return versionsFoundMsg(versions)
}
}

15
styles.go Normal file
View File

@@ -0,0 +1,15 @@
package main
import "github.com/charmbracelet/lipgloss"
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("205")).Padding(0, 1)
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
successStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
boldStyle = lipgloss.NewStyle().Bold(true)
checkMark = successStyle.Render("[OK]")
crossMark = errorStyle.Render("[X]")
appTitle = titleStyle.Render("AMT - ARinera Minecraft Tool")
)