diff --git a/.gitignore b/.gitignore index a9c3fb8..7c2a053 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ wheels/ *.egg-info # Virtual environments -.venv +.venv \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 616984b..0000000 --- a/AGENTS.md +++ /dev/null @@ -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//` — 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//` -2. [ ] Create file `.go` with `package ` -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e61d96d --- /dev/null +++ b/CLAUDE.md @@ -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 扫描 /*/.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/` | +| `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→path,unzip=true 时解压后删 zip | 目标已存在则备份 | +| `delete` | 删除 path | 删前备份 | +| `copy` | 复制 path→new_path | new_path 已存在则备份 | +| `move` | os.Rename,失败则 copy+delete | new_path 已存在则备份 | + +### 备份 + +目录:`/amt/backup//<原相对路径>` + +每次执行创建一个时间戳目录,同次执行的所有备份共享。 + +### 下载失败 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) +``` diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d264f78..0000000 --- a/LICENSE +++ /dev/null @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..560d144 --- /dev/null +++ b/Makefile @@ -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 \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index 646b31c..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# arinera-minecraft-tool - diff --git a/actions.go b/actions.go new file mode 100644 index 0000000..1c30e57 --- /dev/null +++ b/actions.go @@ -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) + }) +} diff --git a/api.go b/api.go new file mode 100644 index 0000000..11a7110 --- /dev/null +++ b/api.go @@ -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} + } +} diff --git a/app/app.go b/app/app.go deleted file mode 100644 index 9bd9679..0000000 --- a/app/app.go +++ /dev/null @@ -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() -} diff --git a/backend/modules/__init__.py b/backend/README.md similarity index 100% rename from backend/modules/__init__.py rename to backend/README.md diff --git a/backend/data.db b/backend/data.db new file mode 100644 index 0000000..0606bb6 Binary files /dev/null and b/backend/data.db differ diff --git a/backend/main.py b/backend/main.py index 9d1689a..13e725a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,14 +1,36 @@ +import json +import os +from contextlib import asynccontextmanager + 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") @@ -16,6 +38,16 @@ async def health(): 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(): uvicorn.run("main:app", reload=True, port=3131) diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..4fee17d --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/modules/example.py b/backend/modules/example.py deleted file mode 100644 index 08245eb..0000000 --- a/backend/modules/example.py +++ /dev/null @@ -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"} diff --git a/backend/modules/voxy_import.py b/backend/modules/voxy_import.py deleted file mode 100644 index ce7c1de..0000000 --- a/backend/modules/voxy_import.py +++ /dev/null @@ -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", - } diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 77abeff..cb94d5c 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -6,6 +6,6 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "fastapi>=0.136.3", - "httpx>=0.28.1", + "peewee>=4.0.6", "uvicorn>=0.48.0", ] diff --git a/backend/uv.lock b/backend/uv.lock index 67e4d19..672975d 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.12" [[package]] @@ -8,14 +8,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "fastapi" }, - { name = "httpx" }, + { name = "peewee" }, { name = "uvicorn" }, ] [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.136.3" }, - { name = "httpx", specifier = ">=0.28.1" }, + { name = "peewee", specifier = ">=4.0.6" }, { 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" }, ] -[[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]] name = "click" 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" }, ] -[[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]] name = "idna" 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" }, ] +[[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]] name = "pydantic" version = "2.13.4" diff --git a/go.mod b/go.mod index 0cca0b4..5d390f4 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module amt -go 1.25.5 +go 1.24.5 require ( github.com/charmbracelet/bubbles v1.0.0 diff --git a/items.go b/items.go new file mode 100644 index 0000000..fb9f6cf --- /dev/null +++ b/items.go @@ -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 } diff --git a/main.go b/main.go index 9c4abbe..218684c 100644 --- a/main.go +++ b/main.go @@ -1,33 +1,25 @@ package main import ( + "errors" "fmt" "os" + "path/filepath" tea "github.com/charmbracelet/bubbletea" - - "amt/app" - "amt/modules/voxyimport" ) -func main() { - defer voxyimport.Cleanup() +var errNotDigit = errors.New("only digits allowed") - entries := []app.Entry{ - { - Title: "Voxy Import", - Desc: "导入服务器 Voxy 数据", - Create: func() tea.Model { - return voxyimport.New(voxyimport.NewDefaultVoxyService()) - }, - }, - // 在这里添加新模块即可: - // {Title: "...", Desc: "...", Create: func() tea.Model { return newmodule.New(newmodule.NewDefaultService()) }}, +func main() { + exeDir := "." + if exePath, err := os.Executable(); err == nil { + exeDir = filepath.Dir(exePath) } - p := tea.NewProgram(app.New(entries)) + p := tea.NewProgram(newModel(exeDir), tea.WithAltScreen()) if _, err := p.Run(); err != nil { - fmt.Println("Error:", err) + fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } diff --git a/model.go b/model.go new file mode 100644 index 0000000..0a529f5 --- /dev/null +++ b/model.go @@ -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 "" +} diff --git a/modules/voxyimport/service.go b/modules/voxyimport/service.go deleted file mode 100644 index 5e10a60..0000000 --- a/modules/voxyimport/service.go +++ /dev/null @@ -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 -} diff --git a/modules/voxyimport/voxyimport.go b/modules/voxyimport/voxyimport.go deleted file mode 100644 index 33dad50..0000000 --- a/modules/voxyimport/voxyimport.go +++ /dev/null @@ -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{} - }) -} diff --git a/pages.go b/pages.go new file mode 100644 index 0000000..fd0271a --- /dev/null +++ b/pages.go @@ -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)) + } +} diff --git a/scan.go b/scan.go new file mode 100644 index 0000000..ca817ee --- /dev/null +++ b/scan.go @@ -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) + } +} diff --git a/styles.go b/styles.go new file mode 100644 index 0000000..5349477 --- /dev/null +++ b/styles.go @@ -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") +)