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{} }) }