// [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"` }