705 lines
15 KiB
Go
705 lines
15 KiB
Go
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{}
|
|
})
|
|
}
|