Files
arinera-minecraft-tool/modules/voxyimport/voxyimport.go
2026-05-27 19:31:47 +08:00

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