2026-08-14 07:52:01 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
_ "embed"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
2026-08-15 17:18:00 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"os/exec"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"runtime"
|
2026-08-14 17:54:06 +08:00
|
|
|
|
"strings"
|
2026-08-14 07:52:01 +08:00
|
|
|
|
"sync/atomic"
|
|
|
|
|
|
"time"
|
2026-08-15 17:18:00 +08:00
|
|
|
|
"view/platform"
|
2026-08-14 07:52:01 +08:00
|
|
|
|
"view/service"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/application"
|
|
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/events"
|
2026-08-14 17:54:06 +08:00
|
|
|
|
"github.com/wailsapp/wails/v3/pkg/services/notifications"
|
2026-08-14 07:52:01 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
//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
|
2026-08-15 17:18:00 +08:00
|
|
|
|
trayShow, trayBatch, trayQuit, trayRestart, aiHub string
|
|
|
|
|
|
trayLaunchpad string
|
2026-08-14 07:52:01 +08:00
|
|
|
|
account, accountLogin, traySync, profile, logout string
|
|
|
|
|
|
trayProjects, trayAllProjects string
|
|
|
|
|
|
trayTodos, trayTickets, trayMessages, trayAutostart string
|
2026-08-14 17:54:06 +08:00
|
|
|
|
trayToday, trayQuick, trayStatusSyncing string
|
|
|
|
|
|
trayStatusOnline, trayStatusOffline, trayStatusSignedOut string
|
|
|
|
|
|
trayStatusError, trayPending, trayUnread, trayDueToday string
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-08-15 17:18:00 +08:00
|
|
|
|
trayShow: "Show Main Window", trayLaunchpad: "Open Launchpad", trayBatch: "Analyze All Projects", trayRestart: "Restart", trayQuit: "Quit", aiHub: "AI Analysis",
|
2026-08-14 07:52:01 +08:00
|
|
|
|
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",
|
2026-08-14 17:54:06 +08:00
|
|
|
|
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",
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return shellText{
|
|
|
|
|
|
file: "文件", newProject: "新建项目", batchAnalyze: "批量统计", database: "数据管理", quit: "退出",
|
|
|
|
|
|
view: "视图", theme: "主题", themeDark: "暗色", themeLight: "浅色", themeSystem: "跟随系统", language: "语言",
|
|
|
|
|
|
tools: "工具", analyzeNow: "立即分析全部", settings: "设置", logsPage: "运行日志", help: "帮助", about: "关于",
|
2026-08-15 17:18:00 +08:00
|
|
|
|
trayShow: "显示主窗口", trayLaunchpad: "打开启动台", trayBatch: "批量统计全部项目", trayRestart: "重新启动", trayQuit: "退出", aiHub: "AI 分析",
|
2026-08-14 07:52:01 +08:00
|
|
|
|
account: "账号", accountLogin: "登录 / 注册", traySync: "立即同步", profile: "个人中心", logout: "退出登录",
|
|
|
|
|
|
trayProjects: "打开项目", trayAllProjects: "全部项目…",
|
|
|
|
|
|
trayTodos: "待办事项", trayTickets: "需求工单", trayMessages: "消息中心", trayAutostart: "开机启动",
|
2026-08-14 17:54:06 +08:00
|
|
|
|
trayToday: "今日任务", trayQuick: "快捷入口",
|
|
|
|
|
|
trayStatusSyncing: "同步中…", trayStatusOnline: "在线", trayStatusOffline: "离线",
|
|
|
|
|
|
trayStatusSignedOut: "未登录", trayStatusError: "同步异常",
|
|
|
|
|
|
trayPending: "待推送 %d", trayUnread: "未读消息 %d", trayDueToday: "今日到期 %d",
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// 原生拖放:仅落在 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,
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-08-14 07:52:01 +08:00
|
|
|
|
a.shell.tray = wapp.SystemTray.New()
|
|
|
|
|
|
a.shell.tray.SetIcon(appIcon)
|
2026-08-14 17:54:06 +08:00
|
|
|
|
a.shell.tray.OnClick(a.showMainWindow)
|
|
|
|
|
|
a.shell.tray.OnDoubleClick(a.showMainWindow)
|
|
|
|
|
|
a.wireNotificationClicks()
|
2026-08-14 07:52:01 +08:00
|
|
|
|
a.RefreshShell()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:54:06 +08:00
|
|
|
|
// 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"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
// 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)
|
2026-08-14 17:54:06 +08:00
|
|
|
|
// macOS 用全局应用菜单;Windows 已走无边框自定义顶栏,不再 SetMenu。
|
2026-08-14 07:52:01 +08:00
|
|
|
|
a.shell.wapp.Menu.SetApplicationMenu(menu)
|
2026-08-14 17:54:06 +08:00
|
|
|
|
a.refreshTray(t, locale)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// refreshTray 更新托盘 tooltip / 状态色点图标 / 右键菜单。
|
|
|
|
|
|
func (a *App) refreshTray(t shellText, locale string) {
|
|
|
|
|
|
if a.shell == nil || a.shell.tray == nil {
|
|
|
|
|
|
return
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
2026-08-14 17:54:06 +08:00
|
|
|
|
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))
|
2026-08-14 07:52:01 +08:00
|
|
|
|
a.shell.tray.OnClick(a.showMainWindow)
|
2026-08-14 17:54:06 +08:00
|
|
|
|
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)
|
2026-08-14 07:52:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 是托盘「打开项目」子菜单最多列出的项目数,超出走「全部项目…」。
|
2026-08-14 17:54:06 +08:00
|
|
|
|
const trayProjectLimit = 8
|
2026-08-14 07:52:01 +08:00
|
|
|
|
|
2026-08-14 17:54:06 +08:00
|
|
|
|
func (a *App) buildTrayMenu(t shellText, st trayStatus) *application.Menu {
|
2026-08-14 07:52:01 +08:00
|
|
|
|
menu := a.shell.wapp.Menu.New()
|
|
|
|
|
|
menu.Add(t.trayShow).OnClick(func(*application.Context) { a.showMainWindow() })
|
2026-08-15 17:18:00 +08:00
|
|
|
|
menu.Add(t.trayLaunchpad).OnClick(func(*application.Context) { a.navigateTo("/launchpad") })
|
2026-08-14 07:52:01 +08:00
|
|
|
|
menu.AddSeparator()
|
2026-08-14 17:54:06 +08:00
|
|
|
|
|
|
|
|
|
|
// 状态区:点击状态行触发同步(或登录)。
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
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") })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-14 17:54:06 +08:00
|
|
|
|
|
|
|
|
|
|
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") })
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
menu.AddSeparator()
|
|
|
|
|
|
menu.Add(t.traySync).OnClick(func(*application.Context) { a.trayQuickSync() })
|
2026-08-14 17:54:06 +08:00
|
|
|
|
menu.Add(t.trayBatch).OnClick(func(*application.Context) { _, _ = a.StartBatchAnalysis() })
|
2026-08-14 07:52:01 +08:00
|
|
|
|
menu.AddSeparator()
|
2026-08-14 17:54:06 +08:00
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
autostart, autoErr := a.GetAutostart()
|
|
|
|
|
|
menu.AddCheckbox(t.trayAutostart, autoErr == nil && autostart).OnClick(func(*application.Context) {
|
|
|
|
|
|
_ = a.SetAutostart(!autostart)
|
|
|
|
|
|
a.RefreshShell()
|
|
|
|
|
|
})
|
|
|
|
|
|
menu.AddSeparator()
|
2026-08-15 17:18:00 +08:00
|
|
|
|
menu.Add(t.trayRestart).OnClick(func(*application.Context) { a.RestartApp() })
|
2026-08-14 07:52:01 +08:00
|
|
|
|
menu.Add(t.trayQuit).OnClick(func(*application.Context) { a.QuitApp() })
|
|
|
|
|
|
return menu
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 17:54:06 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 17:18:00 +08:00
|
|
|
|
// 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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-14 07:52:01 +08:00
|
|
|
|
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()
|
|
|
|
|
|
}
|