- 切换连接统一switchConnection入口,删watcher与抑制机制 - 删除活跃服务器后回落本地并加载 - 侧边栏与指示器点击统一上抛,重复点击可刷新 - Wails事件解析归一,查重谓词抽公共 - 删空壳别名文件与过期文档截图
350 lines
9.5 KiB
Go
350 lines
9.5 KiB
Go
// [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
|
||
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.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 {
|
||
if a.updateTicker != nil {
|
||
a.updateTicker.Stop()
|
||
}
|
||
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"`
|
||
}
|
||
|
||
// SaveAppConfigRequest 保存应用配置请求
|
||
type SaveAppConfigRequest struct {
|
||
Tabs []api.AppTabDefinition `json:"tabs"`
|
||
VisibleTabs []string `json:"visibleTabs"`
|
||
DefaultTab string `json:"defaultTab"`
|
||
}
|
||
|
||
// 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"`
|
||
}
|