feat: 重构
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -34,4 +34,4 @@ wheels/
|
|||||||
*.egg-info
|
*.egg-info
|
||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
845
AGENTS.md
845
AGENTS.md
@@ -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
145
CLAUDE.md
Normal 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→path,unzip=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
18
LICENSE
@@ -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
14
Makefile
Normal 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
|
||||||
262
actions.go
Normal file
262
actions.go
Normal 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
50
api.go
Normal 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}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/app.go
126
app/app.go
@@ -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
BIN
backend/data.db
Normal file
Binary file not shown.
@@ -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
165
backend/manage.py
Normal 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()
|
||||||
@@ -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"}
|
|
||||||
@@ -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",
|
|
||||||
}
|
|
||||||
@@ -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
52
backend/uv.lock
generated
@@ -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
2
go.mod
@@ -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
27
items.go
Normal 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
26
main.go
@@ -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
137
model.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
332
pages.go
Normal 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
39
scan.go
Normal 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
15
styles.go
Normal 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")
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user