Files
code-utils/shell.go

526 lines
17 KiB
Go
Raw Normal View History

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()
}