Files
u-desk/app.go
T
lxy c7978a5bfd 修复: 版本更新与设置面板走查问题
- 更新链路: 检查记录远端SHA256,下载完成事件回传,安装时带哈希校验
- 续传: 服务器忽略Range返回200时截断重下防追加损坏,文件名去query
- 定时器: 自动检查可重入,配置变更免重启生效,关闭即停,下载加互斥
- 前端: 支持跳过此版本并按版本去重,手动检查不受门禁限制,启动未就绪延时重试
- 更新面板: 新增自动检查开关/间隔/地址配置与进度条状态映射
- 版本历史: cache参数强制刷新与加载超时重试兜底
- 配置: 侧边栏分区全链路持久化,保存请求复用api类型防字段丢失
- 侧边栏: 区块定位改显式类名,不再依赖排序位置
- 绑定: 重新生成并与镜像目录同步
2026-10-03 10:18:27 +08:00

343 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// [fs-only] 数据库客户端模块已移除(feature/fs-only 分支)
// 保留模块:文件系统 | Markdown编辑器 | 版本历史(抽屉) | 系统信息 | 更新检查 | PDF导出
// 顶部Tab仅:file-system(数据库 db-cli 已删除)
package main
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"golang.org/x/sync/errgroup"
"u-desk/internal/api"
"u-desk/internal/filewatch"
"u-desk/internal/filesystem"
"u-desk/internal/hotkey"
osssvc "u-desk/internal/ossdrv"
"u-desk/internal/service"
"u-desk/internal/sftp"
"u-desk/internal/storage"
"github.com/wailsapp/wails/v3/pkg/application"
)
// App 应用结构体
type App struct {
ctx context.Context
mainWindow *application.WebviewWindow
updateAPI *api.UpdateAPI
updateTicker *time.Ticker
updateCheckStop chan struct{}
configAPI *api.ConfigAPI
pdfAPI *api.PdfAPI
filesystem *filesystem.FileSystemService
sftpService *sftp.Service
ossService *osssvc.Service
profileSvc *service.ProfileService
fileWatcher *filewatch.Watcher
isAlwaysOnTop bool
mu sync.Mutex
unregisterHotkey func()
}
// App 方法命名约定:
// - 多参数操作 → XxxRequest 结构体(Wails 自动生成 TS 类型)
// - 单参数查询/简单操作 → 直接参数
// NewApp 创建新的应用实例
func NewApp() *App {
return &App{}
}
// emitEvent 向前端发送事件
func (a *App) emitEvent(name string, data ...any) {
if a.mainWindow != nil {
a.mainWindow.EmitEvent(name, data...)
}
}
// SetMainWindow 设置主窗口引用(由 main.go 在创建窗口后调用)
func (a *App) SetMainWindow(w *application.WebviewWindow) {
a.mainWindow = w
a.fileWatcher = filewatch.NewWatcher(func(name string, data ...any) {
if a.mainWindow != nil {
a.mainWindow.EmitEvent(name, data...)
}
})
}
// RegisterGlobalHotkey 注册 Ctrl+Shift+B 全局热键(需在窗口创建后调用)
func (a *App) RegisterGlobalHotkey() {
if a.mainWindow == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
if a.unregisterHotkey != nil {
return
}
hwnd := uintptr(a.mainWindow.NativeWindow())
if hwnd == 0 {
slog.Warn("HWND 为 0,注册跳过")
return
}
const id int32 = 1
if err := hotkey.Register(hwnd, id, hotkey.ModControl|hotkey.ModShift, 0x42); err != nil {
slog.Error("RegisterHotKey Ctrl+Shift+B 失败", "error", err)
return
}
slog.Info("Ctrl+Shift+B 已注册")
a.unregisterHotkey = func() { hotkey.Unregister(hwnd, id) }
}
// HandleHotkey 处理全局热键回调:切换 BgmBar 显示/隐藏
func (a *App) HandleHotkey() {
if a.mainWindow == nil {
return
}
a.mainWindow.EmitEvent("toggle-bgm-bar")
}
// ServiceStartup Wails v3 服务启动生命周期(替代 v2 的 Startup)
func (a *App) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
a.ctx = ctx
// dev 模式打开 DevTools
openDevTools(a.mainWindow)
// 1. 核心初始化:SQLite(必须同步,很快)
if _, err := storage.InitFast(); err != nil {
return fmt.Errorf("SQLite 初始化失败,应用无法启动: %w", err)
}
// 2. 初始化配置服务
configService, err := api.NewConfigAPI()
if err != nil {
return fmt.Errorf("配置服务初始化失败: %w", err)
}
a.configAPI = configService
// 2.5. 迁移旧配置
if err := a.configAPI.MigrateTabConfig(); err != nil {
slog.Warn("配置迁移失败", "error", err)
}
// 3. 初始化版本号(提前触发缓存,避免后续重复计算)
version := service.GetCurrentVersion()
slog.Info("当前版本", "version", version)
// 4. 读取配置,获取可见的 Tabs
visibleTabs := a.getVisibleTabs()
slog.Info("可用的模块", "modules", visibleTabs)
// 非关键路径并行初始化(PdfAPI/Filesystem/Cleanup 不阻塞首屏渲染)
go func() {
// 2.6. 初始化PDF导出API
slog.Info("初始化PDF导出模块")
if pdfAPI, err := api.NewPdfAPI(); err != nil {
slog.Error("PDF导出API初始化失败", "error", err)
} else {
a.mu.Lock()
a.pdfAPI = pdfAPI
a.mu.Unlock()
slog.Info("PDF导出模块初始化完成")
}
// 根据配置初始化模块(条件初始化)
if err := a.initModulesByConfig(visibleTabs); err != nil {
slog.Error("模块初始化失败", "error", err)
}
// 清理过期的下载缓存
storage.CleanupExpiredCache()
storage.CleanupSiteCacheDirs()
}()
// 6. 异步初始化:UpdateAPI(涉及网络请求,完全异步)
go func() {
checkURL := "https://c.1216.top/last-version.json"
if cfg, err := service.LoadUpdateConfig(); err == nil && cfg.CheckURL != "" {
checkURL = cfg.CheckURL
}
if updateAPI, err := api.NewUpdateAPI(checkURL); err == nil {
a.mu.Lock()
a.updateAPI = updateAPI
a.mu.Unlock()
a.updateAPI.SetContext(ctx)
a.updateAPI.SetEventEmitter(func(name string, data ...any) {
if a.mainWindow != nil {
a.mainWindow.EmitEvent(name, data...)
}
})
a.updateAPI.SetConfigChangedCallback(a.resetAutoUpdateCheck)
a.startAutoUpdateCheck()
}
}()
// 延迟注册全局热键(轮询等待原生窗口创建完成)
// RegisterHotKey 必须在创建窗口的同一线程调用,
// 通过 PostMessage 将注册请求投递到主线程消息循环
go func() {
delays := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond}
for i := 0; i < 15; i++ {
time.Sleep(delays[min(i, len(delays)-1)])
if a.mainWindow == nil {
return
}
hwnd := uintptr(a.mainWindow.NativeWindow())
if hwnd != 0 {
hotkey.PostMessage(hwnd, hotkey.WM_APP_HOTKEY, 0, 0)
return
}
}
slog.Warn("等待窗口超时")
}()
return nil
}
// ServiceShutdown Wails v3 服务关闭生命周期(替代 v2 的 Shutdown)
func (a *App) ServiceShutdown() error {
a.stopUpdateTicker()
if a.unregisterHotkey != nil {
a.unregisterHotkey()
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
g, ctx := errgroup.WithContext(shutdownCtx)
// 并行关闭所有子系统(互不依赖,共享 5s 总超时)
g.Go(func() error {
if a.filesystem != nil {
slog.Info("文件系统服务正在关闭")
if err := a.filesystem.Close(ctx); err != nil {
slog.Error("文件系统服务关闭失败", "error", err)
return err
}
slog.Info("文件系统服务已关闭")
}
return nil
})
g.Go(func() error {
slog.Info("文件服务器正在关闭")
if err := filesystem.ShutdownLocalFileServer(); err != nil {
slog.Error("文件服务器关闭失败", "error", err)
return err
}
slog.Info("文件服务器已关闭")
return nil
})
g.Go(func() error {
if a.sftpService != nil {
sftp.GetManager().Shutdown()
}
storage.CleanupExpiredCache()
storage.CleanupSiteCacheDirs()
return nil
})
g.Go(func() error {
osssvc.GetManager().Shutdown()
return nil
})
if err := g.Wait(); err != nil {
slog.Error("部分子系统关闭失败", "error", err)
}
return nil
}
// WriteFileRequest 写入文件请求结构体
type WriteFileRequest struct {
Path string `json:"path"`
Content string `json:"content"`
}
// SaveBase64FileRequest 保存 Base64 编码的二进制文件
type SaveBase64FileRequest struct {
Path string `json:"path"`
Content string `json:"content"` // base64 编码的文件内容
}
// RenamePathRequest 重命名文件或目录请求结构体
type RenamePathRequest struct {
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
// SftpConnectRequest SFTP 连接请求
type SftpConnectRequest struct {
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
KeyPassphrase string `json:"key_passphrase"`
}
// SftpWriteFileRequest SFTP 写入请求
type SftpWriteFileRequest struct {
SessionID string `json:"session_id"`
Path string `json:"path"`
Content string `json:"content"`
}
// SftpRenamePathRequest SFTP 重命名请求
type SftpRenamePathRequest struct {
SessionID string `json:"session_id"`
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
}
type OssConnectRequest struct {
Provider string `json:"provider"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Endpoint string `json:"endpoint"`
}
// OssRenamePathRequest OSS 重命名请求
type OssRenamePathRequest struct {
ConnID string `json:"conn_id"`
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
}
// --- 连接配置 CRUD (SQLite 持久化) ---
type SaveProfileRequest struct {
ID *uint `json:"id"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
KeyPath string `json:"key_path"`
Type string `json:"type"`
Provider string `json:"provider"`
Token string `json:"token"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Bucket string `json:"bucket"`
Region string `json:"region"`
Endpoint string `json:"endpoint"`
LastConnected *int64 `json:"last_connected"`
}
// ========== BGM 播放列表持久化 ==========
// BgmPlaylistItem 播放列表条目
type BgmPlaylistItem struct {
Name string `json:"name"`
Path string `json:"path"`
ProfileID string `json:"profile_id"`
}