package main import ( _ "embed" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync/atomic" "time" "view/platform" "view/service" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/services/notifications" ) //go:embed build/appicon.png var appIcon []byte const appVersion = "2.0.0" // shellText 是原生菜单/托盘的本地化文案。 type shellText struct { file, newProject, batchAnalyze, database, quit string view, theme, themeDark, themeLight, themeSystem, language string tools, analyzeNow, settings, logsPage, help, about string trayShow, trayBatch, trayQuit, trayRestart, aiHub string trayLaunchpad string account, accountLogin, traySync, profile, logout string trayProjects, trayAllProjects string trayTodos, trayTickets, trayMessages, trayAutostart string trayToday, trayQuick, trayStatusSyncing string trayStatusOnline, trayStatusOffline, trayStatusSignedOut string trayStatusError, trayPending, trayUnread, trayDueToday string } func shellTexts(locale string) shellText { if locale == "en" { return shellText{ file: "File", newProject: "New Project", batchAnalyze: "Analyze All", database: "Data Management", quit: "Quit", view: "View", theme: "Theme", themeDark: "Dark", themeLight: "Light", themeSystem: "System", language: "Language", tools: "Tools", analyzeNow: "Analyze All Now", settings: "Settings", logsPage: "Activity Log", help: "Help", about: "About", trayShow: "Show Main Window", trayLaunchpad: "Open Launchpad", trayBatch: "Analyze All Projects", trayRestart: "Restart", trayQuit: "Quit", aiHub: "AI Analysis", account: "Account", accountLogin: "Sign In / Register", traySync: "Sync Now", profile: "Profile", logout: "Sign Out", trayProjects: "Open Project", trayAllProjects: "All Projects…", trayTodos: "Todos", trayTickets: "Tickets", trayMessages: "Messages", trayAutostart: "Start at Login", trayToday: "Today", trayQuick: "Quick Open", trayStatusSyncing: "Syncing…", trayStatusOnline: "Online", trayStatusOffline: "Offline", trayStatusSignedOut: "Signed out", trayStatusError: "Sync error", trayPending: "Pending %d", trayUnread: "Unread %d", trayDueToday: "Due today %d", } } return shellText{ file: "文件", newProject: "新建项目", batchAnalyze: "批量统计", database: "数据管理", quit: "退出", view: "视图", theme: "主题", themeDark: "暗色", themeLight: "浅色", themeSystem: "跟随系统", language: "语言", tools: "工具", analyzeNow: "立即分析全部", settings: "设置", logsPage: "运行日志", help: "帮助", about: "关于", trayShow: "显示主窗口", trayLaunchpad: "打开启动台", trayBatch: "批量统计全部项目", trayRestart: "重新启动", trayQuit: "退出", aiHub: "AI 分析", account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录", trayProjects: "打开项目", trayAllProjects: "全部项目…", trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动", trayToday: "今日任务", trayQuick: "快捷入口", trayStatusSyncing: "同步中…", trayStatusOnline: "在线", trayStatusOffline: "离线", trayStatusSignedOut: "未登录", trayStatusError: "同步异常", trayPending: "待推送 %d", trayUnread: "未读消息 %d", trayDueToday: "今日到期 %d", } } // shellState 保存原生外壳(窗口/托盘/菜单)的运行引用。 type shellState struct { wapp *application.App win *application.WebviewWindow tray *application.SystemTray quitting atomic.Bool } // SetupShell 在窗口创建后接管关闭行为,并挂载原生菜单与系统托盘。 func (a *App) SetupShell(wapp *application.App, win *application.WebviewWindow) { a.shell = &shellState{wapp: wapp, win: win} win.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { if a.shell.quitting.Load() { return } if a.minimizeToTrayEnabled() { win.Hide() e.Cancel() } }) // 原生拖放:仅落在 data-file-drop-target 的区域会触发;把绝对路径转给前端。 win.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) { files := event.Context().DroppedFiles() if len(files) == 0 { return } target := "" if d := event.Context().DropTargetDetails(); d != nil { target = d.ElementID } a.emit("files-dropped", map[string]any{ "files": files, "target": target, }) }) a.shell.tray = wapp.SystemTray.New() a.shell.tray.SetIcon(appIcon) a.shell.tray.OnClick(a.showMainWindow) a.shell.tray.OnDoubleClick(a.showMainWindow) a.wireNotificationClicks() a.RefreshShell() } // wireNotificationClicks 点击系统通知时唤起窗口并跳到对应页面。 func (a *App) wireNotificationClicks() { if a.notifier == nil { return } a.notifier.OnNotificationResponse(func(result notifications.NotificationResult) { if result.Error != nil { return } application.InvokeAsync(func() { a.showMainWindow() path := notificationNavPath(result.Response) if path != "" { a.emit("menu:navigate", path) } }) }) } func notificationNavPath(r notifications.NotificationResponse) string { if r.UserInfo != nil { if p, ok := r.UserInfo["path"].(string); ok && p != "" { return p } } switch { case strings.HasPrefix(r.ID, "cc-todo_due-"): return "/todos" case strings.HasPrefix(r.ID, "cc-ticket_due-"): return "/tickets" case strings.HasPrefix(r.ID, "cc-team-"): return "/team/tasks" case strings.HasPrefix(r.ID, "cc-sync-"): return "/messages" case strings.HasPrefix(r.ID, "cc-analysis-"): return "/logs" default: return "/messages" } } // RefreshShell 按当前语言与登录态重建原生菜单与托盘菜单(设置/登录变更后调用)。 func (a *App) RefreshShell() { if a.shell == nil { return } locale, theme := "zh-CN", "dark" if a.store != nil { if st, e := a.store.Settings(); e == nil { locale, theme = st.Locale, st.Theme } } t := shellTexts(locale) menu := a.buildAppMenu(t, theme, locale) // macOS 用全局应用菜单;Windows 已走无边框自定义顶栏,不再 SetMenu。 a.shell.wapp.Menu.SetApplicationMenu(menu) a.refreshTray(t, locale) } // refreshTray 更新托盘 tooltip / 状态色点图标 / 右键菜单。 func (a *App) refreshTray(t shellText, locale string) { if a.shell == nil || a.shell.tray == nil { return } st := a.collectTrayStatus() a.shell.tray.SetTooltip(st.tooltip(locale)) a.shell.tray.SetIcon(trayIconWithBadge(appIcon, st.badgeColor())) a.shell.tray.SetMenu(a.buildTrayMenu(t, st)) a.shell.tray.OnClick(a.showMainWindow) a.shell.tray.OnDoubleClick(a.showMainWindow) } // RefreshTrayStatus 轻量刷新托盘状态(同步/消息变更时调用,不重建应用菜单)。 func (a *App) RefreshTrayStatus() { if a.shell == nil { return } locale := "zh-CN" if a.store != nil { if st, e := a.store.Settings(); e == nil { locale = st.Locale } } a.refreshTray(shellTexts(locale), locale) } func (a *App) buildAppMenu(t shellText, theme, locale string) *application.Menu { menu := a.shell.wapp.Menu.New() file := menu.AddSubmenu(t.file) file.Add(t.newProject).SetAccelerator("CmdOrCtrl+N").OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:action", "addProject") }) file.Add(t.batchAnalyze).SetAccelerator("CmdOrCtrl+Shift+A").OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() }) file.Add(t.database).OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:navigate", "/settings?tab=database") }) file.AddSeparator() file.Add(t.quit).SetAccelerator("CmdOrCtrl+Q").OnClick(func(*application.Context) { a.QuitApp() }) view := menu.AddSubmenu(t.view) themeMenu := view.AddSubmenu(t.theme) for _, x := range []struct{ label, value string }{{t.themeDark, "dark"}, {t.themeLight, "light"}, {t.themeSystem, "system"}} { v := x.value themeMenu.AddRadio(x.label, theme == v).OnClick(func(*application.Context) { a.emit("menu:set", map[string]string{"key": "theme", "value": v}) }) } langMenu := view.AddSubmenu(t.language) for _, x := range []struct{ label, value string }{{"简体中文", "zh-CN"}, {"English", "en"}} { v := x.value langMenu.AddRadio(x.label, locale == v).OnClick(func(*application.Context) { a.emit("menu:set", map[string]string{"key": "locale", "value": v}) }) } tools := menu.AddSubmenu(t.tools) tools.Add(t.analyzeNow).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() }) tools.Add(t.aiHub).SetAccelerator("CmdOrCtrl+I").OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:navigate", "/ai") }) tools.Add(t.settings).SetAccelerator("CmdOrCtrl+,").OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:navigate", "/settings") }) tools.Add(t.logsPage).OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:navigate", "/logs") }) account := menu.AddSubmenu(t.account) if a.store != nil && a.syncUserID() > 0 { name := a.store.Meta("sync_username") if name == "" { name = t.profile } account.Add(name + "(" + t.profile + ")").OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:navigate", "/profile") }) account.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() }) account.AddSeparator() account.Add(t.logout).OnClick(func(*application.Context) { _ = a.SyncLogout() a.emit("menu:action", "sync-refresh") }) } else { account.Add(t.accountLogin).OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:action", "account") }) } help := menu.AddSubmenu(t.help) help.Add(t.about).OnClick(func(*application.Context) { a.showMainWindow() a.emit("menu:action", "about") }) return menu } // trayQuickSync 托盘/菜单快捷同步:已登录直接后台同步一轮,未登录弹出登录层。 func (a *App) trayQuickSync() { if a.store != nil && a.syncUserID() > 0 { go a.syncOnce(true) return } a.showMainWindow() a.emit("menu:action", "account") } // trayProjectLimit 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。 const trayProjectLimit = 8 func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu { menu := a.shell.wapp.Menu.New() menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() }) menu.Add(t.trayLaunchpad).OnClick(func(*application.Context) { a.navigateTo("/launchpad") }) menu.AddSeparator() // 状态区:点击状态行触发同步(或登录)。 statusLabel := trayStatusLabel(t, st) menu.Add(statusLabel).OnClick(func(*application.Context) { a.trayQuickSync() }) if st.unread > 0 { menu.Add(fmt.Sprintf(t.trayUnread, st.unread)).OnClick(func(*application.Context) { a.navigateTo("/messages") }) } if st.todayDue > 0 { menu.Add(fmt.Sprintf(t.trayDueToday, st.todayDue)).OnClick(func(*application.Context) { a.navigateTo("/today") }) } menu.AddSeparator() if projects, e := a.ListProjects(); e == nil && len(projects) > 0 { sub := menu.AddSubmenu(t.trayProjects) for i, p := range projects { if i >= trayProjectLimit { break } path := fmt.Sprintf("/project/%d", p.ID) sub.Add(p.Name).OnClick(func(*application.Context) { a.navigateTo(path) }) } if len(projects) > trayProjectLimit { sub.AddSeparator() sub.Add(t.trayAllProjects).OnClick(func(*application.Context) { a.navigateTo("/projects") }) } } quick := menu.AddSubmenu(t.trayQuick) quick.Add(t.trayToday).OnClick(func(*application.Context) { a.navigateTo("/today") }) quick.Add(t.trayTodos).OnClick(func(*application.Context) { a.navigateTo("/todos") }) quick.Add(t.trayTickets).OnClick(func(*application.Context) { a.navigateTo("/tickets") }) quick.Add(t.trayMessages).OnClick(func(*application.Context) { a.navigateTo("/messages") }) quick.Add(t.aiHub).OnClick(func(*application.Context) { a.navigateTo("/ai") }) menu.AddSeparator() menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() }) menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() }) menu.AddSeparator() autostart, autoErr := a.GetAutostart() menu.AddCheckbox(t.trayAutostart, autoErr == nil && autostart).OnClick(func(*application.Context) { _ = a.SetAutostart(!autostart) a.RefreshShell() }) menu.AddSeparator() menu.Add(t.trayRestart).OnClick(func(*application.Context) { a.RestartApp() }) menu.Add(t.trayQuit).OnClick(func(*application.Context) { a.QuitApp() }) return menu } func trayStatusLabel(t shellText, st trayStatus) string { var core string switch { case st.syncing: core = t.trayStatusSyncing case !st.loggedIn: core = t.trayStatusSignedOut case st.lastErr != "": core = t.trayStatusError case st.online: core = t.trayStatusOnline if st.username != "" { core = st.username + " · " + core } default: core = t.trayStatusOffline } if st.pending > 0 { core += " · " + fmt.Sprintf(t.trayPending, st.pending) } return core } // navigateTo 唤起主窗口并让前端路由跳到指定页面(托盘/原生菜单共用)。 func (a *App) navigateTo(path string) { a.showMainWindow() a.emit("menu:navigate", path) } func (a *App) showMainWindow() { if a.shell == nil || a.shell.win == nil { return } a.shell.win.Show() a.shell.win.Restore() a.shell.win.Focus() } func (a *App) minimizeToTrayEnabled() bool { if a.store == nil { return true } st, e := a.store.Settings() if e != nil { return true } return st.MinimizeToTray } // QuitApp 退出应用(绕过最小化到托盘逻辑)。 func (a *App) QuitApp() { if a.shell != nil { a.shell.quitting.Store(true) a.shell.wapp.Quit() } } // RestartApp 延迟拉起当前可执行文件后退出(避开单实例抢占)。 func (a *App) RestartApp() { exe, err := os.Executable() if err != nil || strings.TrimSpace(exe) == "" { a.QuitApp() return } if resolved, e := filepath.EvalSymlinks(exe); e == nil && resolved != "" { exe = resolved } // 去掉 Windows 扩展路径前缀,否则 start/cmd 会解析成「\\」之类的无效目标。 exe = strings.TrimPrefix(exe, `\\?\`) exe = filepath.Clean(exe) if runtime.GOOS == "windows" { bat := filepath.Join(os.TempDir(), fmt.Sprintf("cc-restart-%d.bat", os.Getpid())) body := "@echo off\r\n" + "ping -n 2 127.0.0.1 >nul\r\n" + "start \"\" \"" + exe + "\"\r\n" + "del \"%~f0\"\r\n" if e := os.WriteFile(bat, []byte(body), 0644); e != nil { if a.store != nil { a.store.Log("error", "系统", "写入重启脚本失败", e.Error()) } a.QuitApp() return } cmd := exec.Command("cmd", "/C", bat) platform.ConfigureHidden(cmd) _ = cmd.Start() } else { cmd := exec.Command("sh", "-c", `sleep 1; exec "$1"`, "_", exe) _ = cmd.Start() } if a.store != nil { a.store.Log("info", "系统", "应用即将重新启动", exe) } a.QuitApp() } func (a *App) GetAppVersion() string { return appVersion } // GetAutostart 查询开机自启是否已注册。 func (a *App) GetAutostart() (bool, error) { app := application.Get() if app == nil { return false, errors.New("AUTOSTART_UNAVAILABLE") } return app.Autostart.IsEnabled() } // SetAutostart 注册/注销开机自启。 func (a *App) SetAutostart(enable bool) error { app := application.Get() if app == nil { return errors.New("AUTOSTART_UNAVAILABLE") } var e error if enable { e = app.Autostart.Enable() } else { e = app.Autostart.Disable() } if e != nil { return coded("AUTOSTART_FAILED", e) } if a.store != nil { state := "已开启" if !enable { state = "已关闭" } a.store.Log("info", "系统", "开机自启"+state, "") } return nil } // runAutoUpdateLoop 周期检查是否到达自动更新时间点。 func (a *App) runAutoUpdateLoop() { t := time.NewTicker(30 * time.Second) defer t.Stop() for { select { case <-a.ctx.Done(): return case <-t.C: a.checkAutoUpdate() } } } func (a *App) checkAutoUpdate() { if a.store == nil || a.bootstrap.State != BootstrapReady { return } st, e := a.store.Settings() if e != nil || !st.AutoUpdateEnabled { return } now := time.Now() last, _ := time.Parse(time.RFC3339, a.store.Meta("lastAutoUpdateAt")) if last.IsZero() { // 首次启用:以当前时间为基线,避免立即触发一轮全量统计。 _ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339)) return } next := service.NextAutoUpdate(st.AutoUpdateMode, st.AutoUpdateInterval, st.AutoUpdateTime, last, now) if now.Before(next) { return } a.mu.Lock() busy := len(a.tasks) > 0 a.mu.Unlock() if busy { return } _ = a.store.SetMeta("lastAutoUpdateAt", now.Format(time.RFC3339)) a.store.Log("info", "自动更新", "定时批量统计开始", "模式: "+st.AutoUpdateMode) _, _ = a.StartBatchAnalysis() }