From 2dec9e5c54840855704408d5d028df313c2f7b09 Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Fri, 8 May 2026 23:42:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/cmd/desktop/dialog_darwin.go | 34 ++- backend/cmd/desktop/dialog_darwin_test.go | 46 +++ backend/cmd/desktop/dialog_linux.go | 114 +++++--- backend/cmd/desktop/dialog_linux_test.go | 61 ++++ backend/cmd/desktop/dialog_windows.go | 83 +++++- backend/cmd/desktop/main.go | 203 +++++++------ backend/cmd/desktop/messagebox_test.go | 51 +++- backend/cmd/desktop/port_test.go | 78 +---- backend/cmd/desktop/reporter.go | 121 ++++++++ backend/cmd/desktop/reporter_test.go | 140 +++++++++ backend/cmd/desktop/run_desktop_test.go | 332 ++++++++++++++++++++++ backend/cmd/desktop/startup_error.go | 96 +++++++ backend/cmd/desktop/startup_error_test.go | 40 +++ backend/cmd/desktop/static_test.go | 63 ++-- backend/cmd/desktop/tray.go | 231 +++++++++++++++ backend/cmd/desktop/tray_test.go | 169 +++++++++++ backend/internal/database/database.go | 5 +- openspec/specs/ci-test-gate/spec.md | 28 ++ openspec/specs/desktop-app/spec.md | 226 +++++++++++---- openspec/specs/test-coverage/spec.md | 31 ++ 21 files changed, 1857 insertions(+), 297 deletions(-) create mode 100644 backend/cmd/desktop/dialog_darwin_test.go create mode 100644 backend/cmd/desktop/dialog_linux_test.go create mode 100644 backend/cmd/desktop/reporter.go create mode 100644 backend/cmd/desktop/reporter_test.go create mode 100644 backend/cmd/desktop/run_desktop_test.go create mode 100644 backend/cmd/desktop/startup_error.go create mode 100644 backend/cmd/desktop/startup_error_test.go create mode 100644 backend/cmd/desktop/tray.go create mode 100644 backend/cmd/desktop/tray_test.go diff --git a/README.md b/README.md index 39ae33e..f60c6c6 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ make desktop-build-linux TARGET_ARCH=arm64 - 桌面应用需要 CGO 支持 - macOS: 自带 Xcode Command Line Tools - Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包(Ubuntu/Debian: `libgtk-3-dev`、`libayatana-appindicator3-dev`) -- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils;AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含 +- Linux 运行: 需要 GTK3、Ayatana AppIndicator 和 xdg-utils;启动失败提示会 best-effort 使用 `notify-send`、`kdialog`、`zenity` 或 `xmessage`,这些通知/弹窗工具为软依赖,缺失时会降级到标准错误输出或日志;AppImage 也依赖系统提供 AppImage runtime/FUSE 能力,不承诺完全自包含 - Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链,desktop 使用 GUI linker flags 隐藏控制台窗口 - macOS DMG: 发布包暂不签名、不 notarize,首次打开可能出现 Gatekeeper 提示 diff --git a/backend/cmd/desktop/dialog_darwin.go b/backend/cmd/desktop/dialog_darwin.go index 8ea2062..b870c04 100644 --- a/backend/cmd/desktop/dialog_darwin.go +++ b/backend/cmd/desktop/dialog_darwin.go @@ -4,17 +4,35 @@ package main import ( "fmt" - "os/exec" "strings" - - "go.uber.org/zap" ) -func showError(title, message string) { - script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, - escapeAppleScript(message), escapeAppleScript(title)) - if err := exec.Command("osascript", "-e", script).Run(); err != nil { - dialogLogger().Warn("显示错误对话框失败", zap.Error(err)) +func platformStartupChannels(runner commandRunner) []promptChannel { + return []promptChannel{ + { + name: "macos-notification", + available: func() error { + _, err := runner.LookPath("osascript") + return err + }, + run: func(req promptRequest) error { + script := fmt.Sprintf(`display notification "%s" with title "%s" subtitle "%s"`, + escapeAppleScript(req.message), escapeAppleScript(req.title), escapeAppleScript(req.subtitle)) + return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script) + }, + }, + { + name: "macos-alert", + available: func() error { + _, err := runner.LookPath("osascript") + return err + }, + run: func(req promptRequest) error { + script := fmt.Sprintf(`display alert "%s" message "%s" as critical buttons {"OK"} default button "OK"`, + escapeAppleScript(req.title), escapeAppleScript(req.message)) + return runner.Run(promptCommandTimeout, nil, "osascript", "-e", script) + }, + }, } } diff --git a/backend/cmd/desktop/dialog_darwin_test.go b/backend/cmd/desktop/dialog_darwin_test.go new file mode 100644 index 0000000..3452c01 --- /dev/null +++ b/backend/cmd/desktop/dialog_darwin_test.go @@ -0,0 +1,46 @@ +//go:build darwin + +package main + +import ( + "strings" + "testing" +) + +func TestDarwinStartupChannelsBuildNotificationAndAlert(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]bool{"osascript": true}} + channels := platformStartupChannels(runner) + if len(channels) != 2 { + t.Fatalf("macOS 应有 notification 和 alert 两级通道,实际: %d", len(channels)) + } + + req := promptRequest{title: "Nex 启动失败", subtitle: "config", message: "路径 C:\\tmp 包含 \"quote\""} + for _, channel := range channels { + if err := channel.available(); err != nil { + t.Fatalf("通道 %s 应可用: %v", channel.name, err) + } + if err := channel.run(req); err != nil { + t.Fatalf("通道 %s 执行失败: %v", channel.name, err) + } + } + + if len(runner.calls) != 2 { + t.Fatalf("应执行两次 osascript,实际: %d", len(runner.calls)) + } + if runner.calls[0].name != "osascript" || runner.calls[0].args[0] != "-e" { + t.Fatalf("notification 命令参数错误: %#v", runner.calls[0]) + } + if script := runner.calls[0].args[1]; !strings.Contains(script, "display notification") || !strings.Contains(script, `\\tmp`) || !strings.Contains(script, `\"quote\"`) { + t.Fatalf("notification AppleScript 未正确构造或转义: %s", script) + } + if script := runner.calls[1].args[1]; !strings.Contains(script, "display alert") || !strings.Contains(script, "as critical") { + t.Fatalf("alert AppleScript 未使用 critical 告警: %s", script) + } +} + +func TestEscapeAppleScript(t *testing.T) { + got := escapeAppleScript(`C:\tmp "quote"`) + if !strings.Contains(got, `C:\\tmp`) || !strings.Contains(got, `\"quote\"`) { + t.Fatalf("AppleScript 转义结果错误: %s", got) + } +} diff --git a/backend/cmd/desktop/dialog_linux.go b/backend/cmd/desktop/dialog_linux.go index d52f946..942b46a 100644 --- a/backend/cmd/desktop/dialog_linux.go +++ b/backend/cmd/desktop/dialog_linux.go @@ -3,8 +3,9 @@ package main import ( + "errors" "fmt" - "os/exec" + "os" "sync" ) @@ -12,56 +13,99 @@ type dialogToolType int const ( toolNone dialogToolType = iota - toolZenity - toolKdialog toolNotifySend + toolKdialogPassive + toolZenity + toolKdialogError toolXmessage ) var ( - dialogTool dialogToolType - dialogToolOnce sync.Once + dialogTools map[string]bool + dialogToolOnce sync.Once + dialogToolNames = []string{"notify-send", "kdialog", "zenity", "xmessage"} ) func init() { - dialogToolOnce.Do(detectDialogTool) + dialogToolOnce.Do(func() { detectDialogTools(defaultCommandRunner{}) }) } -func detectDialogTool() { - tools := []struct { - name string - typ dialogToolType - }{ - {"zenity", toolZenity}, - {"kdialog", toolKdialog}, - {"notify-send", toolNotifySend}, - {"xmessage", toolXmessage}, +func platformStartupChannels(runner commandRunner) []promptChannel { + return []promptChannel{ + linuxCommandChannel("notify-send", toolNotifySend, runner, linuxHasGraphicalSessionAndDBus, func(req promptRequest) []string { + return []string{"-u", "critical", "-a", appName, "-i", "nex", req.title, req.message} + }), + linuxCommandChannel("kdialog", toolKdialogPassive, runner, linuxHasGraphicalSession, func(req promptRequest) []string { + return []string{"--title", req.title, "--passivepopup", req.message, "10"} + }), + linuxCommandChannel("zenity", toolZenity, runner, linuxHasGraphicalSession, func(req promptRequest) []string { + return []string{"--error", fmt.Sprintf("--title=%s", req.title), fmt.Sprintf("--text=%s", req.message)} + }), + linuxCommandChannel("kdialog", toolKdialogError, runner, linuxHasGraphicalSession, func(req promptRequest) []string { + return []string{"--title", req.title, "--error", req.message} + }), + linuxCommandChannel("xmessage", toolXmessage, runner, linuxHasX11Display, func(req promptRequest) []string { + return []string{"-center", "-buttons", "OK:0", "-default", "OK", fmt.Sprintf("%s: %s", req.title, req.message)} + }), } +} - for _, tool := range tools { - if _, err := exec.LookPath(tool.name); err == nil { - dialogTool = tool.typ - return +func detectDialogTools(runner commandRunner) { + dialogTools = make(map[string]bool, len(dialogToolNames)) + for _, name := range dialogToolNames { + _, err := runner.LookPath(name) + dialogTools[name] = err == nil + } +} + +func linuxCommandChannel(name string, typ dialogToolType, runner commandRunner, environmentOK func() error, args func(promptRequest) []string) promptChannel { + return promptChannel{ + name: fmt.Sprintf("linux-%s-%d", name, typ), + available: func() error { + if err := linuxCommandAvailable(runner, name); err != nil { + return err + } + return environmentOK() + }, + run: func(req promptRequest) error { + return runner.Run(promptCommandTimeout, nil, name, args(req)...) + }, + } +} + +func linuxCommandAvailable(runner commandRunner, name string) error { + if _, ok := runner.(defaultCommandRunner); ok { + dialogToolOnce.Do(func() { detectDialogTools(runner) }) + if dialogTools[name] { + return nil } + return fmt.Errorf("%s 不可用", name) } - dialogTool = toolNone + _, err := runner.LookPath(name) + return err } -func showError(title, message string) { - switch dialogTool { - case toolZenity: - exec.Command("zenity", "--error", - fmt.Sprintf("--title=%s", title), - fmt.Sprintf("--text=%s", message)).Run() - case toolKdialog: - exec.Command("kdialog", "--error", message, "--title", title).Run() - case toolNotifySend: - exec.Command("notify-send", "-u", "critical", title, message).Run() - case toolXmessage: - exec.Command("xmessage", "-center", - fmt.Sprintf("%s: %s", title, message)).Run() - default: - dialogLogger().Error("无法显示错误对话框") +func linuxHasGraphicalSession() error { + if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { + return errors.New("缺少图形会话") } + return nil +} + +func linuxHasGraphicalSessionAndDBus() error { + if err := linuxHasGraphicalSession(); err != nil { + return err + } + if os.Getenv("DBUS_SESSION_BUS_ADDRESS") == "" { + return errors.New("缺少 DBus session bus") + } + return nil +} + +func linuxHasX11Display() error { + if os.Getenv("DISPLAY") == "" { + return errors.New("缺少 X11 DISPLAY") + } + return nil } diff --git a/backend/cmd/desktop/dialog_linux_test.go b/backend/cmd/desktop/dialog_linux_test.go new file mode 100644 index 0000000..1457337 --- /dev/null +++ b/backend/cmd/desktop/dialog_linux_test.go @@ -0,0 +1,61 @@ +//go:build linux + +package main + +import "testing" + +func TestLinuxStartupChannelsPriorityAndArguments(t *testing.T) { + t.Setenv("DISPLAY", ":0") + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/dbus") + runner := &fakeCommandRunner{paths: map[string]bool{ + "notify-send": true, + "kdialog": true, + "zenity": true, + "xmessage": true, + }} + + channels := platformStartupChannels(runner) + if len(channels) != 5 { + t.Fatalf("Linux 应有 5 个 UI 通道,实际: %d", len(channels)) + } + + req := promptRequest{title: "Nex 启动失败", message: "端口被占用"} + for _, channel := range channels { + if err := channel.available(); err != nil { + t.Fatalf("通道 %s 应可用: %v", channel.name, err) + } + if err := channel.run(req); err != nil { + t.Fatalf("通道 %s 执行失败: %v", channel.name, err) + } + } + + wantNames := []string{"notify-send", "kdialog", "zenity", "kdialog", "xmessage"} + for i, want := range wantNames { + if got := runner.calls[i].name; got != want { + t.Fatalf("第 %d 个命令 = %s, want %s", i, got, want) + } + } + if got := runner.calls[0].args; len(got) < 2 || got[0] != "-u" || got[1] != "critical" { + t.Fatalf("notify-send 应使用 critical 参数,实际: %#v", got) + } + if got := runner.calls[1].args; len(got) < 3 || got[2] != "--passivepopup" { + t.Fatalf("kdialog 第一跳应使用 passivepopup,实际: %#v", got) + } + if got := runner.calls[2].args; len(got) < 1 || got[0] != "--error" { + t.Fatalf("zenity 应使用 --error,实际: %#v", got) + } + if got := runner.calls[4].args; len(got) < 1 || got[0] != "-center" { + t.Fatalf("xmessage 应居中显示,实际: %#v", got) + } +} + +func TestLinuxNotifySendRequiresDBus(t *testing.T) { + t.Setenv("DISPLAY", ":0") + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "") + runner := &fakeCommandRunner{paths: map[string]bool{"notify-send": true}} + + channels := platformStartupChannels(runner) + if err := channels[0].available(); err == nil { + t.Fatal("notify-send 缺少 DBus session bus 时应不可用") + } +} diff --git a/backend/cmd/desktop/dialog_windows.go b/backend/cmd/desktop/dialog_windows.go index 4ecdd2b..caf9597 100644 --- a/backend/cmd/desktop/dialog_windows.go +++ b/backend/cmd/desktop/dialog_windows.go @@ -3,17 +3,21 @@ package main import ( + "encoding/base64" "errors" "fmt" "syscall" + "unicode/utf16" "unsafe" - - "go.uber.org/zap" ) const ( + mbOK = 0x00000000 mbIconError = 0x10 mbIconInformation = 0x40 + mbTaskModal = 0x00002000 + mbSetForeground = 0x00010000 + mbTopMost = 0x00040000 ) var ( @@ -25,12 +29,79 @@ var ( } ) -func showError(title, message string) { - if err := messageBox(title, message, mbIconError); err != nil { - if zapLogger != nil { - zapLogger.Warn("显示错误对话框失败", zap.Error(err)) +func platformStartupChannels(runner commandRunner) []promptChannel { + return []promptChannel{ + { + name: "windows-toast", + available: func() error { + _, err := findPowerShell(runner) + return err + }, + run: func(req promptRequest) error { + name, err := findPowerShell(runner) + if err != nil { + return err + } + return runner.Run(promptCommandTimeout, []string{ + "NEX_TOAST_TITLE=" + req.title, + "NEX_TOAST_BODY=" + req.message, + }, name, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encodePowerShellCommand(windowsToastScript())) + }, + }, + { + name: "windows-messagebox", + available: func() error { + return messageBoxAvailable() + }, + run: func(req promptRequest) error { + return messageBox(req.title, req.message, messageBoxStartupFlags()) + }, + }, + } +} + +func findPowerShell(runner commandRunner) (string, error) { + for _, name := range []string{"powershell.exe", "powershell"} { + if _, err := runner.LookPath(name); err == nil { + return name, nil } } + return "", fmt.Errorf("PowerShell 不可用") +} + +func windowsToastScript() string { + return `$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Runtime.WindowsRuntime +$template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02 +$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($template) +$texts = $xml.GetElementsByTagName('text') +$texts.Item(0).AppendChild($xml.CreateTextNode($env:NEX_TOAST_TITLE)) | Out-Null +$texts.Item(1).AppendChild($xml.CreateTextNode($env:NEX_TOAST_BODY)) | Out-Null +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Nex').Show($toast)` +} + +func encodePowerShellCommand(script string) string { + encoded := utf16.Encode([]rune(script)) + buf := make([]byte, 0, len(encoded)*2) + for _, value := range encoded { + buf = append(buf, byte(value), byte(value>>8)) + } + return base64.StdEncoding.EncodeToString(buf) +} + +func messageBoxAvailable() error { + if _, err := syscall.UTF16PtrFromString("Nex"); err != nil { + return err + } + if _, err := syscall.UTF16PtrFromString("test"); err != nil { + return err + } + return procMessageBoxW.Find() +} + +func messageBoxStartupFlags() uint { + return mbOK | mbIconError | mbTaskModal | mbSetForeground | mbTopMost } func messageBox(title, message string, flags uint) error { diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index c73a166..3addf0d 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "io/fs" "net" @@ -27,10 +28,10 @@ import ( "nex/backend/internal/service" "nex/backend/pkg/buildinfo" - "github.com/getlantern/systray" "github.com/gin-gonic/gin" "github.com/gofrs/flock" "go.uber.org/zap" + "gorm.io/gorm" pkgLogger "nex/backend/pkg/logger" ) @@ -40,31 +41,65 @@ var ( zapLogger *zap.Logger shutdownCtx context.Context shutdownCancel context.CancelFunc + desktopHooks = defaultDesktopRuntimeHooks() ) +type singletonLocker interface { + Lock() error + Unlock() error +} + +type desktopRuntimeHooks struct { + loadConfig func() (*config.Config, config.ConfigMetadata, error) + newLock func(string) singletonLocker + listen func(int) (net.Listener, error) + upgradeLogger func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error) + initDB func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error) + closeDB func(*gorm.DB) + registerAdapters func(conversion.AdapterRegistry) error + setupStaticFiles func(*gin.Engine) error + startServer func(*http.Server, net.Listener, chan<- error, *zap.Logger) + setupSystray func(int, <-chan error) error +} + +func defaultDesktopRuntimeHooks() desktopRuntimeHooks { + return desktopRuntimeHooks{ + loadConfig: config.LoadDesktopConfigWithMetadata, + newLock: func(lockPath string) singletonLocker { return NewSingletonLock(lockPath) }, + listen: listenDesktopPort, + upgradeLogger: pkgLogger.Upgrade, + initDB: database.Init, + closeDB: database.Close, + registerAdapters: registerDesktopAdapters, + setupStaticFiles: setupStaticFiles, + startServer: startDesktopServer, + setupSystray: setupSystray, + } +} + func main() { minimalLogger := pkgLogger.NewMinimal() - - cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata() - if err != nil { - minimalLogger.Error("加载配置失败", zap.Error(err)) - showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err)) + if err := runDesktop(minimalLogger); err != nil { + reportStartupFailure(err, dialogLogger()) os.Exit(1) } +} + +func runDesktop(minimalLogger *zap.Logger) error { + if minimalLogger == nil { + minimalLogger = pkgLogger.NewMinimal() + } + + cfg, cfgMeta, err := desktopHooks.loadConfig() + if err != nil { + return newStartupError(phaseConfig, desktopConfigErrorMessage(getDesktopConfigPath(), err), err) + } port := cfg.Server.Port - if err := checkPortAvailable(port); err != nil { - minimalLogger.Error("端口不可用", zap.Error(err)) - showError(appName, err.Error()) - os.Exit(1) - } - - singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock")) + singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock")) if err := singleLock.Lock(); err != nil { - minimalLogger.Error("已有 Nex 实例运行") - showError(appName, "已有 Nex 实例运行") - os.Exit(1) + return newStartupError(phaseSingleton, "已有 Nex 实例运行", err) } defer func() { if err := singleLock.Unlock(); err != nil { @@ -72,7 +107,13 @@ func main() { } }() - zapLogger, err = pkgLogger.Upgrade(minimalLogger, pkgLogger.Config{ + listener, err := desktopHooks.listen(port) + if err != nil { + return newStartupError(phasePort, desktopPortUnavailableMessage(port), err) + } + defer listener.Close() + + zapLogger, err = desktopHooks.upgradeLogger(minimalLogger, pkgLogger.Config{ Level: cfg.Log.Level, Path: cfg.Log.Path, MaxSize: cfg.Log.MaxSize, @@ -81,7 +122,7 @@ func main() { Compress: cfg.Log.Compress, }) if err != nil { - minimalLogger.Fatal("初始化日志失败", zap.Error(err)) + return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err) } defer func() { if err := zapLogger.Sync(); err != nil { @@ -91,11 +132,17 @@ func main() { cfg.PrintSummary(zapLogger) - db, err := database.Init(&cfg.Database, zapLogger) + db, err := desktopHooks.initDB(&cfg.Database, zapLogger) if err != nil { - zapLogger.Fatal("初始化数据库失败", zap.Error(err)) + phase := phaseDatabase + message := fmt.Sprintf("数据库初始化失败\n\n请检查数据库配置、文件权限或连接状态\n\n%v", err) + if errors.Is(err, database.ErrMigration) { + phase = phaseMigration + message = fmt.Sprintf("数据库迁移失败\n\n请查看日志或检查数据库迁移权限\n\n%v", err) + } + return newStartupError(phase, message, err) } - defer database.Close(db) + defer desktopHooks.closeDB(db) providerRepo := repository.NewProviderRepository(db) modelRepo := repository.NewModelRepository(db) @@ -118,11 +165,8 @@ func main() { statsService := service.NewStatsService(statsRepo, statsBuffer) registry := conversion.NewMemoryRegistry() - if err := registry.Register(openai.NewAdapter()); err != nil { - zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err)) - } - if err := registry.Register(anthropic.NewAdapter()); err != nil { - zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err)) + if err := desktopHooks.registerAdapters(registry); err != nil { + return newStartupError(phaseAdapter, startupInternalErrorMessage(), err) } engine := conversion.NewConversionEngine(registry, zapLogger) @@ -144,7 +188,9 @@ func main() { r.Use(middleware.CORS()) setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler) - setupStaticFiles(r) + if err := desktopHooks.setupStaticFiles(r); err != nil { + return newStartupError(phaseStaticResource, startupInternalErrorMessage(), err) + } server = &http.Server{ Addr: desktopListenAddr(port), @@ -154,26 +200,46 @@ func main() { } shutdownCtx, shutdownCancel = context.WithCancel(context.Background()) + defer doShutdown() + serverErrCh := make(chan error, 1) + desktopHooks.startServer(server, listener, serverErrCh, zapLogger) + select { + case err := <-serverErrCh: + return newStartupError(phaseServer, startupServerErrorMessage(), err) + case <-time.After(50 * time.Millisecond): + } + + if err := desktopHooks.setupSystray(port, serverErrCh); err != nil { + return err + } + + select { + case err := <-serverErrCh: + return newStartupError(phaseServer, startupServerErrorMessage(), err) + default: + return nil + } +} + +func registerDesktopAdapters(registry conversion.AdapterRegistry) error { + if err := registry.Register(openai.NewAdapter()); err != nil { + return err + } + return registry.Register(anthropic.NewAdapter()) +} + +func startDesktopServer(server *http.Server, listener net.Listener, serverErrCh chan<- error, logger *zap.Logger) { go func() { - zapLogger.Info("AI Gateway 启动", + logger.Info("AI Gateway 启动", zap.String("addr", server.Addr), zap.String("version", buildinfo.Version()), zap.String("commit", buildinfo.Commit()), zap.String("build_time", buildinfo.BuildTime())) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - zapLogger.Fatal("服务器启动失败", zap.Error(err)) + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + serverErrCh <- err } }() - - go func() { - time.Sleep(500 * time.Millisecond) - if err := openBrowser(desktopURL(port)); err != nil { - zapLogger.Warn("无法打开浏览器", zap.Error(err)) - } - }() - - setupSystray(port) } func setupRoutes(r *gin.Engine, proxyHandler *handler.ProxyHandler, providerHandler *handler.ProviderHandler, modelHandler *handler.ModelHandler, statsHandler *handler.StatsHandler, versionHandler *handler.VersionHandler, settingsHandler *handler.SettingsHandler) { @@ -223,12 +289,13 @@ func withProtocol(protocol string, next gin.HandlerFunc) gin.HandlerFunc { } } -func setupStaticFiles(r *gin.Engine) { +func setupStaticFiles(r *gin.Engine) error { distFS, err := frontendDistFS() if err != nil { - zapLogger.Fatal("无法加载前端资源", zap.Error(err)) + return err } setupStaticFilesWithFS(r, distFS) + return nil } func frontendDistFS() (fs.FS, error) { @@ -299,47 +366,6 @@ func setupStaticFilesWithFS(r *gin.Engine, distFS fs.FS) { }) } -func setupSystray(port int) { - systray.Run(func() { - var icon []byte - var err error - if runtime.GOOS == "windows" { - icon, err = embedfs.Assets.ReadFile("assets/icon.ico") - } else { - icon, err = embedfs.Assets.ReadFile("assets/icon.png") - } - if err != nil { - zapLogger.Error("无法加载托盘图标", zap.Error(err)) - } - systray.SetIcon(icon) - systray.SetTooltip(appTooltip) - - mOpen := systray.AddMenuItem("打开管理界面", "在浏览器中打开") - systray.AddSeparator() - mStatus := systray.AddMenuItem("状态: 运行中", "") - mStatus.Disable() - mPort := systray.AddMenuItem(desktopPortMenuTitle(port), "") - mPort.Disable() - systray.AddSeparator() - mQuit := systray.AddMenuItem("退出", "停止服务并退出") - - go func() { - for { - select { - case <-mOpen.ClickedCh: - if err := openBrowser(desktopURL(port)); err != nil { - zapLogger.Warn("打开浏览器失败", zap.Error(err)) - } - case <-mQuit.ClickedCh: - doShutdown() - systray.Quit() - return - } - } - }() - }, nil) -} - func doShutdown() { if zapLogger != nil { zapLogger.Info("正在关闭服务器...") @@ -382,13 +408,12 @@ func desktopPortMenuTitle(port int) string { return fmt.Sprintf("端口: %d", port) } -func checkPortAvailable(port int) error { - ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port) - } - ln.Close() - return nil +func listenDesktopPort(port int) (net.Listener, error) { + return net.Listen("tcp", desktopListenAddr(port)) +} + +func desktopPortUnavailableMessage(port int) string { + return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port) } type SingletonLock struct { diff --git a/backend/cmd/desktop/messagebox_test.go b/backend/cmd/desktop/messagebox_test.go index 3b76e0e..9319c0b 100644 --- a/backend/cmd/desktop/messagebox_test.go +++ b/backend/cmd/desktop/messagebox_test.go @@ -47,9 +47,15 @@ func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) { } func TestShowError_WindowsBranch(t *testing.T) { - withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) { - return 0, syscall.Errno(5) - }) + old := buildPromptChannels + buildPromptChannels = func(commandRunner) []promptChannel { + return []promptChannel{{ + name: "fake-failed-channel", + available: func() error { return nil }, + run: func(promptRequest) error { return syscall.Errno(5) }, + }} + } + t.Cleanup(func() { buildPromptChannels = old }) defer func() { if recovered := recover(); recovered != nil { @@ -59,3 +65,42 @@ func TestShowError_WindowsBranch(t *testing.T) { showError("测试错误", "这是一条测试错误消息") } + +func TestMessageBoxW_WindowsOnly_StartupFlags(t *testing.T) { + var gotFlags uintptr + withMessageBoxW(t, func(_, _, _, flags uintptr) (uintptr, error) { + gotFlags = flags + return 1, syscall.Errno(0) + }) + + if err := messageBox("测试标题", "测试消息", messageBoxStartupFlags()); err != nil { + t.Fatalf("MessageBoxW 应成功: %v", err) + } + + for _, flag := range []uint{mbIconError, mbTaskModal, mbSetForeground, mbTopMost} { + if gotFlags&uintptr(flag) == 0 { + t.Fatalf("startup flags 缺少 0x%x,实际: 0x%x", flag, gotFlags) + } + } +} + +func TestWindowsStartupChannelsUseToastBeforeMessageBox(t *testing.T) { + runner := &fakeCommandRunner{paths: map[string]bool{"powershell.exe": true}} + channels := platformStartupChannels(runner) + if len(channels) != 2 { + t.Fatalf("Windows 应有 Toast 和 MessageBox 两级通道,实际: %d", len(channels)) + } + + if channels[0].name != "windows-toast" || channels[1].name != "windows-messagebox" { + t.Fatalf("Windows 通道顺序错误: %s, %s", channels[0].name, channels[1].name) + } + if err := channels[0].available(); err != nil { + t.Fatalf("PowerShell 存在时 Toast 通道应可用: %v", err) + } + if err := channels[0].run(promptRequest{title: "Nex 启动失败", message: "端口被占用"}); err != nil { + t.Fatalf("Toast fake runner 应执行成功: %v", err) + } + if len(runner.calls) != 1 || runner.calls[0].name != "powershell.exe" { + t.Fatalf("Toast 应调用 powershell.exe,实际: %#v", runner.calls) + } +} diff --git a/backend/cmd/desktop/port_test.go b/backend/cmd/desktop/port_test.go index 9c20edd..149e56e 100644 --- a/backend/cmd/desktop/port_test.go +++ b/backend/cmd/desktop/port_test.go @@ -9,87 +9,27 @@ import ( "time" ) -func TestCheckPortAvailable(t *testing.T) { - port := 19826 - - err := checkPortAvailable(port) +func TestListenDesktopPortReturnsReusableListener(t *testing.T) { + listener, err := listenDesktopPort(0) if err != nil { - t.Fatalf("端口 %d 应该可用: %v", port, err) - } - - t.Log("端口可用测试通过") -} - -func TestCheckPortOccupied(t *testing.T) { - port := 19827 - - listener, err := net.Listen("tcp", ":19827") //nolint:gosec // 需要验证 checkPortAvailable 对通配地址占用的检测行为 - if err != nil { - t.Fatalf("无法启动测试服务器: %v", err) + t.Fatalf("listener-first 应直接获取配置端口 listener: %v", err) } defer listener.Close() - time.Sleep(100 * time.Millisecond) - - err = checkPortAvailable(port) - if err == nil { - t.Fatal("端口被占用时应该返回错误") - } - - t.Log("端口占用检测测试通过") -} - -func TestCheckPortAvailableAfterClose(t *testing.T) { - port := 19828 - - listener, err := net.Listen("tcp", "127.0.0.1:19828") - if err != nil { - t.Fatalf("无法启动测试服务器: %v", err) - } - server := &http.Server{ReadHeaderTimeout: time.Second} - defer server.Close() + done := make(chan struct{}) go func() { + defer close(done) err := server.Serve(listener) if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) { - t.Errorf("serve failed: %v", err) + t.Errorf("使用同一个 listener 启动 server 失败: %v", err) } }() - time.Sleep(100 * time.Millisecond) - - listener.Close() - time.Sleep(100 * time.Millisecond) - - err = checkPortAvailable(port) - if err != nil { - t.Fatalf("端口关闭后应该可用: %v", err) + if err := server.Close(); err != nil { + t.Fatalf("关闭测试 server 失败: %v", err) } - - t.Log("端口关闭后可用测试通过") -} - -func TestCheckPortAvailableErrorContainsPort(t *testing.T) { - port := 19829 - - listener, err := net.Listen("tcp", ":19829") //nolint:gosec - if err != nil { - t.Fatalf("无法启动测试服务器: %v", err) - } - defer listener.Close() - - time.Sleep(100 * time.Millisecond) - - err = checkPortAvailable(port) - if err == nil { - t.Fatal("端口被占用时应该返回错误") - } - - if !strings.Contains(err.Error(), "19829") { - t.Fatalf("错误信息应包含端口号 19829,实际: %v", err) - } - - t.Log("端口错误信息包含端口号测试通过") + <-done } func TestGetDesktopConfigPath(t *testing.T) { diff --git a/backend/cmd/desktop/reporter.go b/backend/cmd/desktop/reporter.go new file mode 100644 index 0000000..023eec7 --- /dev/null +++ b/backend/cmd/desktop/reporter.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "time" + + "go.uber.org/zap" +) + +const promptCommandTimeout = 5 * time.Second + +type promptRequest struct { + title string + message string + subtitle string +} + +type promptChannel struct { + name string + available func() error + run func(promptRequest) error +} + +type commandRunner interface { + LookPath(file string) (string, error) + Run(timeout time.Duration, env []string, name string, args ...string) error +} + +type defaultCommandRunner struct{} + +var buildPromptChannels = platformStartupChannels + +func (defaultCommandRunner) LookPath(file string) (string, error) { + return exec.LookPath(file) +} + +func (defaultCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + + if err := cmd.Run(); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + return nil +} + +func showError(title, message string) { + reportPrompt(promptRequest{title: title, message: message}, os.Stderr, dialogLogger()) +} + +func reportStartupFailure(err error, logger *zap.Logger) { + if err == nil { + return + } + + var startupErr *startupError + if !errors.As(err, &startupErr) { + startupErr = newStartupError(phaseServer, startupServerErrorMessage(), err) + } + + if logger == nil { + logger = dialogLogger() + } + logger.Error("desktop 启动失败", + zap.String("phase", startupErr.Phase()), + zap.Error(startupErr)) + + reportPrompt(promptRequest{ + title: startupTitle(), + message: startupErr.UserMessage(), + subtitle: startupErr.Phase(), + }, os.Stderr, logger) +} + +func reportPrompt(req promptRequest, fallback io.Writer, logger *zap.Logger) { + runPromptPipeline(req, buildPromptChannels(defaultCommandRunner{}), fallback, logger) +} + +func runPromptPipeline(req promptRequest, channels []promptChannel, fallback io.Writer, logger *zap.Logger) { + if logger == nil { + logger = dialogLogger() + } + + for _, channel := range channels { + if channel.available != nil { + if err := channel.available(); err != nil { + logger.Warn("提示通道不可用", zap.String("channel", channel.name), zap.Error(err)) + continue + } + } + + if err := channel.run(req); err != nil { + logger.Warn("提示通道执行失败", zap.String("channel", channel.name), zap.Error(err)) + continue + } + return + } + + writePromptFallback(fallback, req.title, req.message) +} + +func writePromptFallback(w io.Writer, title, message string) { + if w == nil { + return + } + if _, err := io.WriteString(w, "错误: "+title+": "+message+"\n"); err != nil { + return + } +} diff --git a/backend/cmd/desktop/reporter_test.go b/backend/cmd/desktop/reporter_test.go new file mode 100644 index 0000000..490da1d --- /dev/null +++ b/backend/cmd/desktop/reporter_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strings" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +type commandCall struct { + timeout time.Duration + env []string + name string + args []string +} + +type fakeCommandRunner struct { + paths map[string]bool + runErrs map[string]error + calls []commandCall +} + +func (r *fakeCommandRunner) LookPath(file string) (string, error) { + if r.paths[file] { + return "/usr/bin/" + file, nil + } + return "", exec.ErrNotFound +} + +func (r *fakeCommandRunner) Run(timeout time.Duration, env []string, name string, args ...string) error { + r.calls = append(r.calls, commandCall{ + timeout: timeout, + env: append([]string(nil), env...), + name: name, + args: append([]string(nil), args...), + }) + if err := r.runErrs[name]; err != nil { + return err + } + return nil +} + +func TestRunPromptPipelineFallbackOrder(t *testing.T) { + var calls []string + channels := []promptChannel{ + { + name: "unavailable", + available: func() error { + calls = append(calls, "available-1") + return errors.New("missing") + }, + run: func(promptRequest) error { + calls = append(calls, "run-1") + return nil + }, + }, + { + name: "failed", + available: func() error { + calls = append(calls, "available-2") + return nil + }, + run: func(promptRequest) error { + calls = append(calls, "run-2") + return errors.New("failed") + }, + }, + { + name: "success", + available: func() error { + calls = append(calls, "available-3") + return nil + }, + run: func(promptRequest) error { + calls = append(calls, "run-3") + return nil + }, + }, + } + + var fallback bytes.Buffer + runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "启动失败"}, channels, &fallback, zap.NewNop()) + + want := []string{"available-1", "available-2", "run-2", "available-3", "run-3"} + if fmt.Sprint(calls) != fmt.Sprint(want) { + t.Fatalf("调用顺序 = %v, want %v", calls, want) + } + if fallback.Len() != 0 { + t.Fatalf("成功通道后不应写入 fallback,实际: %s", fallback.String()) + } +} + +func TestRunPromptPipelineWritesFallback(t *testing.T) { + channels := []promptChannel{ + { + name: "unavailable", + available: func() error { return errors.New("missing") }, + run: func(promptRequest) error { return nil }, + }, + } + + var fallback bytes.Buffer + runPromptPipeline(promptRequest{title: "Nex 启动失败", message: "端口被占用"}, channels, &fallback, zap.NewNop()) + + want := "错误: Nex 启动失败: 端口被占用\n" + if fallback.String() != want { + t.Fatalf("fallback = %q, want %q", fallback.String(), want) + } +} + +func TestReportStartupFailureLogsRedactedError(t *testing.T) { + old := buildPromptChannels + buildPromptChannels = func(commandRunner) []promptChannel { + return []promptChannel{{name: "fake-success", run: func(promptRequest) error { return nil }}} + } + t.Cleanup(func() { buildPromptChannels = old }) + + core, logs := observer.New(zap.ErrorLevel) + logger := zap.New(core) + err := errors.New("数据库连接失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test") + + reportStartupFailure(err, logger) + + entries := logs.All() + if len(entries) != 1 { + t.Fatalf("应记录 1 条错误日志,实际: %d", len(entries)) + } + fields := fmt.Sprint(entries[0].ContextMap()) + for _, secret := range []string{"secret", "sk-test"} { + if strings.Contains(fields, secret) { + t.Fatalf("启动失败日志不应包含敏感信息 %q,实际: %s", secret, fields) + } + } +} diff --git a/backend/cmd/desktop/run_desktop_test.go b/backend/cmd/desktop/run_desktop_test.go new file mode 100644 index 0000000..63c31c0 --- /dev/null +++ b/backend/cmd/desktop/run_desktop_test.go @@ -0,0 +1,332 @@ +package main + +import ( + "errors" + "fmt" + "net" + "net/http" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "nex/backend/internal/config" + "nex/backend/internal/conversion" + "nex/backend/internal/database" + pkgLogger "nex/backend/pkg/logger" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type fakeDesktopLock struct { + lockErr error + unlockCount atomic.Int32 +} + +func (l *fakeDesktopLock) Lock() error { + return l.lockErr +} + +func (l *fakeDesktopLock) Unlock() error { + l.unlockCount.Add(1) + return nil +} + +func (l *fakeDesktopLock) unlocked() bool { + return l.unlockCount.Load() > 0 +} + +type recordingListener struct { + net.Listener + closeCount atomic.Int32 +} + +func newRecordingListener(t *testing.T) *recordingListener { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("创建测试 listener 失败: %v", err) + } + return &recordingListener{Listener: listener} +} + +func (l *recordingListener) Close() error { + l.closeCount.Add(1) + return l.Listener.Close() +} + +func (l *recordingListener) closed() bool { + return l.closeCount.Load() > 0 +} + +func testDesktopConfig(t *testing.T) *config.Config { + t.Helper() + + tmpDir := t.TempDir() + cfg := config.DefaultConfig() + cfg.Server.Port = 0 + cfg.Database.Driver = "sqlite" + cfg.Database.Path = filepath.Join(tmpDir, "config.db") + cfg.Log.Path = filepath.Join(tmpDir, "log") + return cfg +} + +func installDesktopTestHooks(t *testing.T, cfg *config.Config, mutate func(*desktopRuntimeHooks)) { + t.Helper() + + oldHooks := desktopHooks + oldServer := server + oldLogger := zapLogger + oldShutdownCtx := shutdownCtx + oldShutdownCancel := shutdownCancel + + server = nil + zapLogger = nil + shutdownCtx = nil + shutdownCancel = nil + + hooks := defaultDesktopRuntimeHooks() + if cfg != nil { + hooks.loadConfig = func() (*config.Config, config.ConfigMetadata, error) { + return cfg, config.ConfigMetadata{ConfigPath: filepath.Join(t.TempDir(), "config.yaml")}, nil + } + } + hooks.upgradeLogger = func(_ *zap.Logger, _ pkgLogger.Config) (*zap.Logger, error) { + return zap.NewNop(), nil + } + hooks.setupStaticFiles = func(*gin.Engine) error { return nil } + hooks.startServer = func(*http.Server, net.Listener, chan<- error, *zap.Logger) {} + hooks.setupSystray = func(int, <-chan error) error { return nil } + + if mutate != nil { + mutate(&hooks) + } + desktopHooks = hooks + + t.Cleanup(func() { + if server != nil { + _ = server.Close() + } + desktopHooks = oldHooks + server = oldServer + zapLogger = oldLogger + shutdownCtx = oldShutdownCtx + shutdownCancel = oldShutdownCancel + }) +} + +func requireStartupPhase(t *testing.T, err error, want startupPhase) { + t.Helper() + + if err == nil { + t.Fatalf("期望 %s 阶段启动错误,实际 nil", want) + } + var startupErr *startupError + if !errors.As(err, &startupErr) { + t.Fatalf("期望 startupError,实际: %T %v", err, err) + } + if startupErr.phase != want { + t.Fatalf("phase = %s, want %s", startupErr.phase, want) + } +} + +func TestRunDesktopConfigFailureReturnsConfigPhase(t *testing.T) { + installDesktopTestHooks(t, nil, func(h *desktopRuntimeHooks) { + h.loadConfig = func() (*config.Config, config.ConfigMetadata, error) { + return nil, config.ConfigMetadata{}, errors.New("yaml 解析失败") + } + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, phaseConfig) +} + +func TestRunDesktopSingletonFailurePrecedesPortListen(t *testing.T) { + cfg := testDesktopConfig(t) + lock := &fakeDesktopLock{lockErr: errors.New("已有实例运行")} + listenCalled := false + installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { + h.newLock = func(string) singletonLocker { return lock } + h.listen = func(int) (net.Listener, error) { + listenCalled = true + return nil, errors.New("不应监听端口") + } + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, phaseSingleton) + if listenCalled { + t.Fatal("单实例锁失败时不应继续监听端口") + } +} + +func TestRunDesktopPortFailureUnlocksSingleton(t *testing.T) { + cfg := testDesktopConfig(t) + lock := &fakeDesktopLock{} + installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { + h.newLock = func(string) singletonLocker { return lock } + h.listen = func(int) (net.Listener, error) { return nil, errors.New("bind failed") } + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, phasePort) + if !lock.unlocked() { + t.Fatal("端口监听失败时应释放单实例锁") + } +} + +func TestRunDesktopLoggerFailureClosesListenerAndUnlocks(t *testing.T) { + cfg := testDesktopConfig(t) + lock := &fakeDesktopLock{} + listener := newRecordingListener(t) + installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { + h.newLock = func(string) singletonLocker { return lock } + h.listen = func(int) (net.Listener, error) { return listener, nil } + h.upgradeLogger = func(*zap.Logger, pkgLogger.Config) (*zap.Logger, error) { + return nil, errors.New("log permission denied") + } + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, phaseLogger) + if !listener.closed() { + t.Fatal("日志初始化失败时应关闭 listener") + } + if !lock.unlocked() { + t.Fatal("日志初始化失败时应释放单实例锁") + } +} + +func TestRunDesktopDatabaseFailureClassification(t *testing.T) { + tests := []struct { + name string + err error + want startupPhase + }{ + {name: "database", err: errors.New("open failed"), want: phaseDatabase}, + {name: "migration", err: fmt.Errorf("%w: %w", database.ErrMigration, errors.New("goose failed")), want: phaseMigration}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := testDesktopConfig(t) + lock := &fakeDesktopLock{} + listener := newRecordingListener(t) + installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { + h.newLock = func(string) singletonLocker { return lock } + h.listen = func(int) (net.Listener, error) { return listener, nil } + h.initDB = func(*config.DatabaseConfig, *zap.Logger) (*gorm.DB, error) { return nil, tt.err } + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, tt.want) + if !listener.closed() { + t.Fatal("数据库失败时应关闭 listener") + } + if !lock.unlocked() { + t.Fatal("数据库失败时应释放单实例锁") + } + }) + } +} + +func TestRunDesktopInternalStartupFailurePhasesAndDatabaseCleanup(t *testing.T) { + tests := []struct { + name string + mutate func(*desktopRuntimeHooks) + want startupPhase + }{ + { + name: "adapter", + mutate: func(h *desktopRuntimeHooks) { + h.registerAdapters = func(conversion.AdapterRegistry) error { return errors.New("adapter failed") } + }, + want: phaseAdapter, + }, + { + name: "static", + mutate: func(h *desktopRuntimeHooks) { + h.setupStaticFiles = func(*gin.Engine) error { return errors.New("missing frontend") } + }, + want: phaseStaticResource, + }, + { + name: "server", + mutate: func(h *desktopRuntimeHooks) { + h.startServer = func(_ *http.Server, _ net.Listener, errCh chan<- error, _ *zap.Logger) { + errCh <- errors.New("serve failed") + } + }, + want: phaseServer, + }, + { + name: "tray", + mutate: func(h *desktopRuntimeHooks) { + h.setupSystray = func(int, <-chan error) error { + return newStartupError(phaseTray, "托盘初始化失败", errors.New("tray failed")) + } + }, + want: phaseTray, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := testDesktopConfig(t) + lock := &fakeDesktopLock{} + listener := newRecordingListener(t) + closeDBCalled := false + installDesktopTestHooks(t, cfg, func(h *desktopRuntimeHooks) { + h.newLock = func(string) singletonLocker { return lock } + h.listen = func(int) (net.Listener, error) { return listener, nil } + h.closeDB = func(db *gorm.DB) { + closeDBCalled = true + database.Close(db) + } + tt.mutate(h) + }) + + err := runDesktop(zap.NewNop()) + requireStartupPhase(t, err, tt.want) + if !closeDBCalled { + t.Fatal("数据库初始化后的启动失败应关闭数据库") + } + if !listener.closed() { + t.Fatal("数据库初始化后的启动失败应关闭 listener") + } + if !lock.unlocked() { + t.Fatal("数据库初始化后的启动失败应释放单实例锁") + } + }) + } +} + +func TestRunDesktopBrowserFailureRemainsNonFatal(t *testing.T) { + controller := newFakeTrayController() + notified := make(chan string, 1) + controller.run = func(onReady func(), _ func()) { + onReady() + <-controller.quitCh + } + + err := runSystray(19826, trayOptions{ + controller: controller, + readyTimeout: time.Second, + iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, + openBrowser: func(string) error { return errors.New("no browser") }, + notify: func(_, message string) { + notified <- message + controller.Quit() + }, + logger: zap.NewNop(), + }) + if err != nil { + t.Fatalf("浏览器打开失败不应导致 runSystray 返回 fatal: %v", err) + } + if got := <-notified; got == "" { + t.Fatal("浏览器打开失败应提示用户手动访问") + } +} diff --git a/backend/cmd/desktop/startup_error.go b/backend/cmd/desktop/startup_error.go new file mode 100644 index 0000000..efdac67 --- /dev/null +++ b/backend/cmd/desktop/startup_error.go @@ -0,0 +1,96 @@ +package main + +import ( + "fmt" + "regexp" +) + +type startupPhase string + +const ( + phaseConfig startupPhase = "config" + phaseSingleton startupPhase = "singleton" + phasePort startupPhase = "port" + phaseLogger startupPhase = "logger" + phaseDatabase startupPhase = "database" + phaseMigration startupPhase = "migration" + phaseAdapter startupPhase = "adapter" + phaseStaticResource startupPhase = "static" + phaseServer startupPhase = "server" + phaseTray startupPhase = "tray" +) + +type startupError struct { + phase startupPhase + message string + cause error +} + +func newStartupError(phase startupPhase, message string, cause error) *startupError { + return &startupError{ + phase: phase, + message: redactSensitive(message), + cause: cause, + } +} + +func (e *startupError) Error() string { + if e == nil { + return "" + } + if e.cause == nil { + return fmt.Sprintf("%s: %s", e.phase, e.message) + } + return fmt.Sprintf("%s: %s: %s", e.phase, e.message, redactSensitive(e.cause.Error())) +} + +func (e *startupError) Unwrap() error { + if e == nil { + return nil + } + return e.cause +} + +func (e *startupError) Phase() string { + if e == nil { + return "" + } + return string(e.phase) +} + +func (e *startupError) UserMessage() string { + if e == nil { + return "" + } + return redactSensitive(e.message) +} + +var sensitiveReplacers = []struct { + pattern *regexp.Regexp + replacement string +}{ + {regexp.MustCompile(`(?i)(password\s*[:=]\s*)[^\s,;&]+`), `${1}`}, + {regexp.MustCompile(`(?i)(api[_-]?key\s*[:=]\s*)[^\s,;&]+`), `${1}`}, + {regexp.MustCompile(`(?i)(secret\s*[:=]\s*)[^\s,;&]+`), `${1}`}, + {regexp.MustCompile(`([^\s:/]+):([^\s@]+)@tcp\(`), `${1}:@tcp(`}, + {regexp.MustCompile(`(://[^\s:/]+):([^\s@]+)@`), `${1}:@`}, +} + +func redactSensitive(s string) string { + for _, replacer := range sensitiveReplacers { + s = replacer.pattern.ReplaceAllString(s, replacer.replacement) + } + return s +} + +func startupTitle() string { + return appName + " 启动失败" +} + +func startupServerErrorMessage() string { + return "后端服务启动失败\n\n请检查端口占用、网络权限或查看日志获取更多信息" +} + +func startupInternalErrorMessage() string { + return "应用初始化失败\n\n请查看日志或重新安装应用" +} diff --git a/backend/cmd/desktop/startup_error_test.go b/backend/cmd/desktop/startup_error_test.go new file mode 100644 index 0000000..2735dab --- /dev/null +++ b/backend/cmd/desktop/startup_error_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +func TestStartupErrorContainsPhaseAndCause(t *testing.T) { + cause := errors.New("底层失败") + err := newStartupError(phaseDatabase, "数据库初始化失败", cause) + + if err.Phase() != "database" { + t.Fatalf("phase = %q, want database", err.Phase()) + } + if !errors.Is(err, cause) { + t.Fatal("startupError 应保留底层 cause") + } + if !strings.Contains(err.Error(), "database") { + t.Fatalf("错误字符串应包含 phase,实际: %s", err.Error()) + } +} + +func TestStartupErrorRedactsSensitiveUserMessage(t *testing.T) { + message := "数据库初始化失败: nex:secret@tcp(localhost:3306)/nex password=secret api_key=sk-test" + err := newStartupError(phaseDatabase, message, errors.New("cause password=secret api_key=sk-test")) + userMessage := err.UserMessage() + + for _, secret := range []string{"secret", "sk-test"} { + if strings.Contains(userMessage, secret) { + t.Fatalf("用户提示不应包含敏感信息 %q,实际: %s", secret, userMessage) + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("日志错误字符串不应包含敏感信息 %q,实际: %s", secret, err.Error()) + } + } + if !strings.Contains(userMessage, "") { + t.Fatalf("用户提示应包含脱敏占位符,实际: %s", userMessage) + } +} diff --git a/backend/cmd/desktop/static_test.go b/backend/cmd/desktop/static_test.go index 28ae5c0..18b3850 100644 --- a/backend/cmd/desktop/static_test.go +++ b/backend/cmd/desktop/static_test.go @@ -13,14 +13,15 @@ import ( func TestSetupStaticFiles(t *testing.T) { gin.SetMode(gin.TestMode) - distFS, err := frontendDistFS() - if err != nil { - t.Skipf("跳过测试: 前端资源未构建: %v", err) - return - } - r := gin.New() - setupStaticFilesWithFS(r, distFS) + setupStaticFilesWithFS(r, fstest.MapFS{ + "index.html": {Data: []byte("fallback")}, + "icon.png": {Data: []byte("png")}, + "assets/test.js": {Data: []byte("console.log('test')")}, + "assets/test.css": {Data: []byte("body {}")}, + "assets/test.svg": {Data: []byte("")}, + "assets/test.woff": {Data: []byte("font")}, + }) t.Run("API 404", func(t *testing.T) { req := httptest.NewRequest("GET", "/api/test", nil) @@ -73,13 +74,12 @@ func TestSetupStaticFiles(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - if w.Code == 200 { - expected := "application/javascript" - if w.Header().Get("Content-Type") != expected { - t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) - } - } else { - t.Log("文件不存在,跳过 MIME 类型验证") + if w.Code != http.StatusOK { + t.Fatalf("期望状态码 200, 实际 %d", w.Code) + } + expected := "application/javascript" + if w.Header().Get("Content-Type") != expected { + t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) } }) @@ -88,13 +88,12 @@ func TestSetupStaticFiles(t *testing.T) { w := httptest.NewRecorder() r.ServeHTTP(w, req) - if w.Code == 200 { - expected := "text/css" - if w.Header().Get("Content-Type") != expected { - t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) - } - } else { - t.Log("文件不存在,跳过 MIME 类型验证") + if w.Code != http.StatusOK { + t.Fatalf("期望状态码 200, 实际 %d", w.Code) + } + expected := "text/css" + if w.Header().Get("Content-Type") != expected { + t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type")) } }) @@ -128,12 +127,6 @@ func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) { func TestWithProtocolAndStaticRoutes(t *testing.T) { gin.SetMode(gin.TestMode) - distFS, err := frontendDistFS() - if err != nil { - t.Skipf("跳过测试: 前端资源未构建: %v", err) - return - } - r := gin.New() var gotProtocol string @@ -148,7 +141,10 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) { gotPath = c.Param("path") c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath}) })) - setupStaticFilesWithFS(r, distFS) + setupStaticFilesWithFS(r, fstest.MapFS{ + "index.html": {Data: []byte("fallback")}, + "assets/test.js": {Data: []byte("console.log('test')")}, + }) t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) { gotProtocol = "" @@ -199,14 +195,11 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) { if gotProtocol != "" || gotPath != "" { t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath) } - if w.Code == http.StatusOK { - if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") { - t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type")) - } - return + if w.Code != http.StatusOK { + t.Fatalf("期望静态资源返回 200, 实际 %d", w.Code) } - if w.Code != http.StatusNotFound { - t.Errorf("期望静态资源返回 200 或 404, 实际 %d", w.Code) + if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") { + t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type")) } }) diff --git a/backend/cmd/desktop/tray.go b/backend/cmd/desktop/tray.go new file mode 100644 index 0000000..5d3af2e --- /dev/null +++ b/backend/cmd/desktop/tray.go @@ -0,0 +1,231 @@ +package main + +import ( + "fmt" + "runtime" + "sync" + "time" + + "nex/embedfs" + + "github.com/getlantern/systray" + "go.uber.org/zap" +) + +const defaultTrayReadyTimeout = 5 * time.Second + +type trayMenuItem interface { + Disable() + Clicked() <-chan struct{} +} + +type trayController interface { + Run(onReady func(), onExit func()) + Quit() + SetIcon(icon []byte) + SetTooltip(tooltip string) + AddMenuItem(title, tooltip string) trayMenuItem + AddSeparator() +} + +type realTrayController struct{} + +func (realTrayController) Run(onReady func(), onExit func()) { + systray.Run(onReady, onExit) +} + +func (realTrayController) Quit() { + systray.Quit() +} + +func (realTrayController) SetIcon(icon []byte) { + systray.SetIcon(icon) +} + +func (realTrayController) SetTooltip(tooltip string) { + systray.SetTooltip(tooltip) +} + +func (realTrayController) AddMenuItem(title, tooltip string) trayMenuItem { + return realTrayMenuItem{item: systray.AddMenuItem(title, tooltip)} +} + +func (realTrayController) AddSeparator() { + systray.AddSeparator() +} + +type realTrayMenuItem struct { + item *systray.MenuItem +} + +func (m realTrayMenuItem) Disable() { + m.item.Disable() +} + +func (m realTrayMenuItem) Clicked() <-chan struct{} { + return m.item.ClickedCh +} + +type trayOptions struct { + controller trayController + readyTimeout time.Duration + iconLoader func() ([]byte, error) + openBrowser func(string) error + notify func(string, string) + logger *zap.Logger + fatalErrCh <-chan error +} + +func setupSystray(port int, fatalErrCh <-chan error) error { + return runSystray(port, trayOptions{ + controller: realTrayController{}, + readyTimeout: defaultTrayReadyTimeout, + iconLoader: loadTrayIcon, + openBrowser: openBrowser, + notify: showError, + logger: dialogLogger(), + fatalErrCh: fatalErrCh, + }) +} + +func runSystray(port int, opts trayOptions) error { + if opts.controller == nil { + opts.controller = realTrayController{} + } + if opts.readyTimeout <= 0 { + opts.readyTimeout = defaultTrayReadyTimeout + } + if opts.iconLoader == nil { + opts.iconLoader = loadTrayIcon + } + if opts.openBrowser == nil { + opts.openBrowser = openBrowser + } + if opts.notify == nil { + opts.notify = showError + } + if opts.logger == nil { + opts.logger = dialogLogger() + } + + readyCh := make(chan struct{}) + doneCh := make(chan struct{}) + errCh := make(chan error, 1) + var readyOnce sync.Once + var errOnce sync.Once + + signalReady := func() { + readyOnce.Do(func() { close(readyCh) }) + } + signalError := func(err error) { + errOnce.Do(func() { errCh <- err }) + } + + go monitorTrayStartup(port, opts, readyCh, doneCh, signalError) + + opts.controller.Run(func() { + handleTrayReady(port, opts, signalReady, signalError) + }, nil) + close(doneCh) + + select { + case err := <-errCh: + return err + default: + return nil + } +} + +func monitorTrayStartup(port int, opts trayOptions, readyCh <-chan struct{}, doneCh <-chan struct{}, signalError func(error)) { + timer := time.NewTimer(opts.readyTimeout) + defer timer.Stop() + + ready := false + for { + select { + case <-readyCh: + ready = true + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + openDesktopBrowser(port, opts) + readyCh = nil + case <-timer.C: + if !ready { + signalError(newStartupError(phaseTray, "托盘初始化超时", fmt.Errorf("托盘未在 %s 内 ready", opts.readyTimeout))) + opts.controller.Quit() + } + case err := <-opts.fatalErrCh: + if err != nil { + signalError(newStartupError(phaseServer, startupServerErrorMessage(), err)) + opts.controller.Quit() + } + case <-doneCh: + return + } + } +} + +func handleTrayReady(port int, opts trayOptions, signalReady func(), signalError func(error)) { + defer func() { + if recovered := recover(); recovered != nil { + err := fmt.Errorf("托盘初始化 panic: %v", recovered) + signalError(newStartupError(phaseTray, "托盘菜单初始化失败", err)) + opts.controller.Quit() + } + }() + + icon, err := opts.iconLoader() + if err != nil { + signalError(newStartupError(phaseTray, "托盘图标资源无法加载", err)) + opts.controller.Quit() + return + } + + opts.controller.SetIcon(icon) + opts.controller.SetTooltip(appTooltip) + + mOpen := opts.controller.AddMenuItem("打开管理界面", "在浏览器中打开") + opts.controller.AddSeparator() + mStatus := opts.controller.AddMenuItem("状态: 运行中", "") + mStatus.Disable() + mPort := opts.controller.AddMenuItem(desktopPortMenuTitle(port), "") + mPort.Disable() + opts.controller.AddSeparator() + mQuit := opts.controller.AddMenuItem("退出", "停止服务并退出") + + go func() { + for { + select { + case <-mOpen.Clicked(): + if err := opts.openBrowser(desktopURL(port)); err != nil { + opts.logger.Warn("打开浏览器失败", zap.Error(err)) + } + case <-mQuit.Clicked(): + doShutdown() + opts.controller.Quit() + return + } + } + }() + + signalReady() +} + +func openDesktopBrowser(port int, opts trayOptions) { + url := desktopURL(port) + if err := opts.openBrowser(url); err != nil { + opts.logger.Warn("无法打开浏览器", zap.Error(err)) + opts.notify(appName, fmt.Sprintf("无法自动打开浏览器,请手动访问 %s", url)) + } +} + +func loadTrayIcon() ([]byte, error) { + if runtime.GOOS == "windows" { + return embedfs.Assets.ReadFile("assets/icon.ico") + } + return embedfs.Assets.ReadFile("assets/icon.png") +} diff --git a/backend/cmd/desktop/tray_test.go b/backend/cmd/desktop/tray_test.go new file mode 100644 index 0000000..7cb9ee7 --- /dev/null +++ b/backend/cmd/desktop/tray_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "errors" + "sync" + "testing" + "time" + + "go.uber.org/zap" +) + +type fakeTrayController struct { + run func(onReady func(), onExit func()) + + quitCh chan struct{} + quitOnce sync.Once + + icon []byte + tooltip string + menuItems []*fakeTrayMenuItem +} + +func newFakeTrayController() *fakeTrayController { + return &fakeTrayController{quitCh: make(chan struct{})} +} + +func (c *fakeTrayController) Run(onReady func(), onExit func()) { + if c.run != nil { + c.run(onReady, onExit) + return + } + onReady() + <-c.quitCh + if onExit != nil { + onExit() + } +} + +func (c *fakeTrayController) Quit() { + c.quitOnce.Do(func() { close(c.quitCh) }) +} + +func (c *fakeTrayController) SetIcon(icon []byte) { + c.icon = append([]byte(nil), icon...) +} + +func (c *fakeTrayController) SetTooltip(tooltip string) { + c.tooltip = tooltip +} + +func (c *fakeTrayController) AddMenuItem(title, tooltip string) trayMenuItem { + item := &fakeTrayMenuItem{clicked: make(chan struct{}), title: title, tooltip: tooltip} + c.menuItems = append(c.menuItems, item) + return item +} + +func (c *fakeTrayController) AddSeparator() {} + +type fakeTrayMenuItem struct { + clicked chan struct{} + title string + tooltip string + disabled bool +} + +func (m *fakeTrayMenuItem) Disable() { + m.disabled = true +} + +func (m *fakeTrayMenuItem) Clicked() <-chan struct{} { + return m.clicked +} + +func TestRunSystrayReadyOpensBrowser(t *testing.T) { + controller := newFakeTrayController() + opened := make(chan string, 1) + + err := runSystray(19826, trayOptions{ + controller: controller, + readyTimeout: time.Second, + iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, + openBrowser: func(url string) error { + opened <- url + controller.Quit() + return nil + }, + notify: func(string, string) {}, + logger: zap.NewNop(), + }) + if err != nil { + t.Fatalf("托盘 ready 成功不应返回错误: %v", err) + } + + if got := <-opened; got != "http://localhost:19826" { + t.Fatalf("浏览器 URL = %s", got) + } + if string(controller.icon) != "icon" { + t.Fatalf("应设置托盘图标") + } + if controller.tooltip != appTooltip { + t.Fatalf("tooltip = %q, want %q", controller.tooltip, appTooltip) + } +} + +func TestRunSystrayReadyTimeoutReturnsTrayStartupError(t *testing.T) { + controller := newFakeTrayController() + controller.run = func(_ func(), _ func()) { + <-controller.quitCh + } + + err := runSystray(19826, trayOptions{ + controller: controller, + readyTimeout: 10 * time.Millisecond, + iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, + openBrowser: func(string) error { return nil }, + notify: func(string, string) {}, + logger: zap.NewNop(), + }) + if err == nil { + t.Fatal("托盘 ready timeout 应返回错误") + } + var startupErr *startupError + if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" { + t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err) + } +} + +func TestRunSystrayIconLoadFailureReturnsTrayStartupError(t *testing.T) { + controller := newFakeTrayController() + + err := runSystray(19826, trayOptions{ + controller: controller, + readyTimeout: time.Second, + iconLoader: func() ([]byte, error) { return nil, errors.New("missing icon") }, + openBrowser: func(string) error { return nil }, + notify: func(string, string) {}, + logger: zap.NewNop(), + }) + if err == nil { + t.Fatal("托盘图标加载失败应返回错误") + } + var startupErr *startupError + if !errors.As(err, &startupErr) || startupErr.Phase() != "tray" { + t.Fatalf("应返回 tray 阶段启动错误,实际: %v", err) + } +} + +func TestRunSystrayBrowserOpenFailureIsNonFatal(t *testing.T) { + controller := newFakeTrayController() + notified := make(chan string, 1) + + err := runSystray(19826, trayOptions{ + controller: controller, + readyTimeout: time.Second, + iconLoader: func() ([]byte, error) { return []byte("icon"), nil }, + openBrowser: func(string) error { return errors.New("no browser") }, + notify: func(_, message string) { + notified <- message + controller.Quit() + }, + logger: zap.NewNop(), + }) + if err != nil { + t.Fatalf("浏览器打开失败不应成为 fatal: %v", err) + } + if got := <-notified; got == "" { + t.Fatal("浏览器打开失败应提示用户") + } +} diff --git a/backend/internal/database/database.go b/backend/internal/database/database.go index c851b6a..d64e696 100644 --- a/backend/internal/database/database.go +++ b/backend/internal/database/database.go @@ -2,6 +2,7 @@ package database import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -17,6 +18,8 @@ import ( pkglogger "nex/backend/pkg/logger" ) +var ErrMigration = errors.New("数据库迁移失败") + func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) { moduleLogger := pkglogger.WithModule(zapLogger, "database") @@ -26,7 +29,7 @@ func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) { } if err := runMigrations(db, cfg.Driver, moduleLogger); err != nil { - return nil, fmt.Errorf("数据库迁移失败: %w", err) + return nil, fmt.Errorf("%w: %w", ErrMigration, err) } configurePool(db, cfg, moduleLogger) diff --git a/openspec/specs/ci-test-gate/spec.md b/openspec/specs/ci-test-gate/spec.md index caa21d1..8bd1c62 100644 --- a/openspec/specs/ci-test-gate/spec.md +++ b/openspec/specs/ci-test-gate/spec.md @@ -166,6 +166,34 @@ 测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。 +### Requirement: Desktop 原生 UI 测试不依赖真实图形环境 + +CI 测试门禁 SHALL 允许验证 desktop 启动失败报告的 UI 无关逻辑,但 SHALL NOT 要求 GitHub Actions runner 或本地 CI 环境具备真实系统通知、模态弹窗或托盘可见性。 + +#### Scenario: CI 运行 desktop 启动失败测试 + +- **WHEN** `check` job 执行 `make test` +- **THEN** desktop 专属测试 SHALL 可以覆盖启动失败分类、提示通道选择、fallback 顺序和托盘 ready/timeout 逻辑 +- **THEN** 测试 SHALL 使用 mock、fake runner 或接口注入验证调用意图 + +#### Scenario: CI 不验证真实原生 UI 展示 + +- **WHEN** `check` job 在 Linux、macOS 或 Windows runner 上运行 +- **THEN** 测试 SHALL NOT 要求真实系统通知可见 +- **THEN** 测试 SHALL NOT 要求真实模态弹窗被显示或被人工点击 +- **THEN** 测试 SHALL NOT 要求真实托盘图标可见 +- **THEN** runner 的通知权限、勿扰模式、DBus 状态或桌面会话差异 SHALL NOT 导致正常 CI 失败 + +#### Scenario: Linux CI 系统依赖边界 + +- **WHEN** Linux `check` job 安装 desktop 构建和测试所需系统依赖 +- **THEN** 该依赖安装 SHALL NOT 被解释为需要在 CI 中验证真实 Linux 通知或弹窗展示 +- **THEN** Linux 通知/弹窗命令 SHALL 在测试中通过 fake runner 覆盖 + +### Requirement: 测试 workflow 资源隔离 + +测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。 + #### Scenario: E2E 临时资源隔离 - **WHEN** E2E 测试运行 diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index 330daaf..c1c0264 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -6,6 +6,130 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ## Requirements +### Requirement: Desktop 启动失败报告 + +Desktop SHALL 将无法进入可用状态的启动失败统一转换为包含阶段、用户消息和底层原因的启动错误,并通过统一报告器提示用户。 + +#### Scenario: 配置加载或验证失败 + +- **WHEN** desktop 启动时 `~/.nex/config.yaml` 无法解析或配置验证失败 +- **THEN** 系统 SHALL 生成 `config` 阶段启动错误 +- **THEN** 错误提示 SHALL 包含配置文件路径和失败原因 +- **THEN** 应用 SHALL 退出 + +#### Scenario: 日志初始化失败 + +- **WHEN** desktop 启动时完整 logger 初始化失败 +- **THEN** 系统 SHALL 生成 `logger` 阶段启动错误 +- **THEN** 错误提示 SHALL 描述日志初始化失败并包含可操作的路径或权限线索 +- **THEN** 应用 SHALL 退出 + +#### Scenario: 数据库初始化或迁移失败 + +- **WHEN** desktop 启动时数据库打开、连接、初始化或迁移失败 +- **THEN** 系统 SHALL 生成 `database` 或 `migration` 阶段启动错误 +- **THEN** 错误提示 SHALL 描述数据库初始化失败或迁移失败 +- **THEN** 错误提示 SHALL NOT 暴露 MySQL password、完整 DSN 或 API key 等敏感信息 +- **THEN** 应用 SHALL 退出 + +#### Scenario: 嵌入资源或内部组件初始化失败 + +- **WHEN** desktop 启动时前端嵌入资源、协议 adapter 或其他内部组件初始化失败 +- **THEN** 系统 SHALL 生成对应启动阶段的启动错误 +- **THEN** 错误提示 SHALL 描述应用初始化失败并建议查看日志或重新安装 +- **THEN** 应用 SHALL 退出 + +#### Scenario: 启动失败提示降级链 + +- **WHEN** desktop 生成 fatal 启动错误 +- **THEN** 系统 SHALL 先尝试平台系统通知 +- **THEN** 系统 SHALL 在通知不可用或返回失败时尝试平台模态弹窗 +- **THEN** 系统 SHALL 在模态弹窗不可用或返回失败时输出到 stderr 或可用启动日志 +- **THEN** 每一次提示通道失败 SHALL 被记录但 SHALL NOT 阻止后续 fallback + +#### Scenario: 提示通道调用前可用性检查 + +- **WHEN** desktop 启动失败报告器准备调用任一系统通知或模态弹窗通道 +- **THEN** 系统 SHALL 在调用前检查该通道对应命令、系统 API 或图形会话条件是否可用 +- **THEN** 系统 SHALL 在通道不可用时跳过该通道并进入下一 fallback +- **THEN** 系统 SHALL NOT 因某个通知或弹窗工具缺失而中断后续降级链 + +#### Scenario: 浏览器打开失败为非 fatal + +- **WHEN** desktop 后端服务和托盘已启动但浏览器自动打开失败 +- **THEN** 系统 SHALL 记录 warning +- **THEN** 系统 SHALL 尝试通过非 fatal 提示告知用户可手动访问 `http://localhost:` +- **THEN** 应用 SHALL 继续运行 + +### Requirement: macOS 通知和对话框降级策略 + +系统 SHALL 在 macOS 上优先使用通知中心提示 desktop 启动错误,并在通知不可用时降级到 AppleScript 模态告警。 + +#### Scenario: macOS 通知可用 + +- **WHEN** desktop 在 macOS 上生成启动错误且通知命令可用 +- **THEN** 系统 SHALL 使用 `osascript display notification` 发送系统通知 +- **THEN** 通知 SHALL 包含应用名称和错误描述文本 + +#### Scenario: macOS 通知失败 + +- **WHEN** macOS 系统通知命令不可用或返回失败 +- **THEN** 系统 SHALL 使用 `osascript display alert` 显示模态告警 +- **THEN** 模态告警 SHALL 使用 critical 错误语义 +- **THEN** 告警 SHALL 包含应用名称和错误描述文本 + +#### Scenario: macOS UI 提示均失败 + +- **WHEN** macOS 系统通知和模态告警均失败 +- **THEN** 系统 SHALL 降级输出到 stderr 或可用启动日志 + +### Requirement: Linux 启动错误提示降级策略 + +系统 SHALL 在 Linux 上为 desktop 启动错误按优先级使用可用的通知或对话框工具,优先使用系统通知栏,再降级到模态弹窗,最后降级到标准错误输出。该策略 SHALL 不移除既有通用信息提示能力。 + +#### Scenario: notify-send 可用 + +- **WHEN** desktop 在 Linux 上生成启动错误且 `notify-send` 命令可用并存在图形会话 +- **THEN** 使用 `notify-send` 显示系统通知 +- **AND** 错误通知使用 `-u critical` 参数 +- **AND** 通知 SHALL 包含应用名称和错误描述文本 + +#### Scenario: kdialog passive popup 可用 + +- **WHEN** `notify-send` 不可用或返回失败且 `kdialog` 命令可用 +- **THEN** 系统 SHALL 优先尝试使用 `kdialog --passivepopup` 显示系统通知式提示 + +#### Scenario: zenity 可用 + +- **WHEN** Linux 通知工具不可用或返回失败且系统检测到 `zenity` 命令可用 +- **THEN** 使用 zenity 显示 GTK 风格错误对话框 +- **AND** 错误对话框使用 `zenity --error` 命令 + +#### Scenario: kdialog 模态对话框可用 + +- **WHEN** `zenity` 不可用或返回失败且 `kdialog` 命令可用 +- **THEN** 使用 kdialog 显示 KDE 风格错误对话框 +- **AND** 错误对话框使用 `kdialog --error` 命令 + +#### Scenario: xmessage 可用 + +- **WHEN** `zenity`、`kdialog` 模态对话框均不可用或返回失败且 `xmessage` 命令可用 +- **THEN** 使用 xmessage 显示基础 X11 对话框 +- **AND** 对话框居中显示(`-center` 参数) + +#### Scenario: 无 UI 工具可用 + +- **WHEN** 所有通知和对话框工具均不可用、图形会话不存在或所有 UI 提示均返回失败 +- **THEN** 降级到标准错误输出 +- **AND** 输出格式为 `错误: : <message>` + +#### Scenario: 工具检测缓存 + +- **WHEN** desktop 在 Linux 上启动 +- **THEN** 系统检测一次可用通知和对话框工具 +- **AND** 检测结果缓存在包级变量 +- **AND** 后续提示调用直接使用缓存结果,不重复检测 + ### Requirement: 桌面应用启动 系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。 @@ -36,7 +160,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 系统托盘 -系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。 +系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。托盘初始化失败 SHALL 被视为 desktop fatal 启动失败。 #### Scenario: 托盘图标显示 @@ -60,11 +184,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **WHEN** 用户点击托盘菜单"打开管理界面" - **THEN** 系统在浏览器中打开 `http://localhost:<server.port>` +#### Scenario: 托盘 ready 后自动打开浏览器 + +- **WHEN** desktop 启动并完成托盘图标、菜单和点击事件初始化 +- **THEN** 系统 SHALL 将托盘标记为 ready +- **THEN** 系统 SHALL 在托盘 ready 后自动打开 `http://localhost:<server.port>` +- **THEN** 系统 SHALL NOT 在托盘初始化失败时自动打开浏览器 + #### Scenario: 浏览器打开失败 - **WHEN** 系统无法打开浏览器(浏览器未安装等) - **THEN** 托盘菜单仍可正常使用 - **AND** 用户可手动访问 `http://localhost:<server.port>` +- **AND** 系统 SHALL 尝试显示非 fatal 提示,告知用户手动访问地址 #### Scenario: 退出应用 @@ -73,6 +205,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 托盘图标消失 - **AND** 应用进程退出 +#### Scenario: 托盘初始化失败 + +- **WHEN** desktop 启动时托盘未在限定时间内 ready、托盘图标资源无法加载或托盘菜单无法完成初始化 +- **THEN** 系统 SHALL 生成 `tray` 阶段启动错误 +- **THEN** 系统 SHALL 通过启动失败提示降级链提示用户 +- **THEN** 应用 SHALL 退出 + ### Requirement: 静态文件服务 系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。 @@ -126,19 +265,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 ### Requirement: 端口冲突检测 -系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。 +系统 SHALL 在启动时获取启动配置中的 `server.port` 对应监听端口,并使用同一个 listener 启动后端服务,避免端口预检测与实际监听之间的竞态。 #### Scenario: 配置端口可用 - **WHEN** 启动配置中的 `server.port` 未被占用 -- **THEN** 服务正常启动 +- **THEN** 系统 SHALL 成功创建该端口的 listener +- **THEN** 后端服务 SHALL 使用该 listener 正常启动 #### Scenario: 配置端口被占用 - **WHEN** 启动配置中的 `server.port` 已被其他程序占用 -- **THEN** 显示错误提示"端口 <server.port> 已被占用" +- **THEN** 系统 SHALL 生成 `port` 阶段启动错误 +- **THEN** 错误提示 SHALL 包含"端口 <server.port> 已被占用" - **AND** 应用退出 +#### Scenario: 单实例优先于端口监听 + +- **WHEN** 用户尝试启动第二个 desktop 实例 +- **THEN** 系统 SHALL 优先检测到已有实例持有文件锁 +- **THEN** 系统 SHALL 显示"已有 Nex 实例运行"相关错误提示 +- **THEN** 系统 SHALL NOT 将该场景误报为端口占用 + ### Requirement: 桌面配置源隔离和启动快照 desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。 @@ -312,74 +460,52 @@ desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API,不主动 ### Requirement: Windows 原生对话框 -系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。 +系统 SHALL 在 Windows 上优先使用系统通知提示启动错误,并在通知不可用或失败时使用 `user32.dll` 的 `MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。 + +#### Scenario: Windows 通知优先 + +- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等) +- **THEN** 系统 SHALL 优先尝试发送 Windows 系统通知 +- **THEN** 通知 SHALL 包含应用名称和错误描述文本 #### Scenario: 错误提示对话框 -- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等) +- **WHEN** Windows 系统通知不可用或返回失败 - **THEN** 使用 `MessageBoxW` 显示模态对话框 - **AND** 对话框标题栏显示应用名称 - **AND** 对话框包含错误描述文本 - **AND** 对话框显示错误图标(MB_ICONERROR) +- **AND** 对话框 SHALL 尽量置于前台或顶层显示 + +#### Scenario: Windows UI 提示均失败 + +- **WHEN** Windows 系统通知和 `MessageBoxW` 均失败 +- **THEN** 系统 SHALL 记录提示失败原因 +- **THEN** 系统 SHALL 降级输出到可用启动日志 #### Scenario: 非 Windows 平台不受影响 - **WHEN** 应用运行在 macOS 或 Linux 上 -- **THEN** 错误对话框仍使用平台原有实现(osascript / zenity) - -### Requirement: Linux 对话框降级策略 - -系统 SHALL 在 Linux 上按优先级检测并使用可用的对话框工具,确保在不同桌面环境下都能显示对话框。 - -#### Scenario: zenity 可用 -- **WHEN** 系统检测到 `zenity` 命令可用 -- **THEN** 使用 zenity 显示 GTK 风格对话框 -- **AND** 错误对话框使用 `zenity --error` 命令 -- **AND** 信息对话框使用 `zenity --info` 命令 - -#### Scenario: kdialog 可用 -- **WHEN** zenity 不可用且 `kdialog` 命令可用 -- **THEN** 使用 kdialog 显示 KDE 风格对话框 -- **AND** 错误对话框使用 `kdialog --error` 命令 -- **AND** 信息对话框使用 `kdialog --msgbox` 命令 - -#### Scenario: notify-send 可用 -- **WHEN** zenity 和 kdialog 均不可用且 `notify-send` 命令可用 -- **THEN** 使用 notify-send 显示系统通知 -- **AND** 错误通知使用 `-u critical` 参数 -- **AND** 信息通知使用默认参数 - -#### Scenario: xmessage 可用 -- **WHEN** zenity、kdialog、notify-send 均不可用且 `xmessage` 命令可用 -- **THEN** 使用 xmessage 显示基础 X11 对话框 -- **AND** 对话框居中显示(`-center` 参数) - -#### Scenario: 无对话框工具可用 -- **WHEN** 所有对话框工具均不可用 -- **THEN** 降级到标准错误输出 -- **AND** 输出格式为 `错误: <title>: <message>` - -#### Scenario: 工具检测缓存 -- **WHEN** 应用启动 -- **THEN** 系统检测一次可用对话框工具 -- **AND** 检测结果缓存在包级变量 -- **AND** 后续对话框调用直接使用缓存结果,不重复检测 +- **THEN** 错误提示 SHALL 使用对应平台的通知和弹窗降级策略 ### Requirement: macOS AppleScript 字符转义 -系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。 +系统 SHALL 对 AppleScript 通知、模态告警和对话框中的特殊字符进行转义,确保脚本正确执行。 #### Scenario: 转义反斜杠 -- **WHEN** 对话框消息包含反斜杠字符 `\` + +- **WHEN** AppleScript 提示文本包含反斜杠字符 `\` - **THEN** 转义为 `\\` #### Scenario: 转义双引号 -- **WHEN** 对话框消息包含双引号字符 `"` + +- **WHEN** AppleScript 提示文本包含双引号字符 `"` - **THEN** 转义为 `\"` #### Scenario: 多行文本处理 -- **WHEN** 对话框消息包含换行符 `\n` -- **THEN** AppleScript 正确显示多行文本 + +- **WHEN** AppleScript 提示文本包含换行符 `\n` +- **THEN** AppleScript 通知或模态告警 SHALL 正确显示多行文本或保留可读换行语义 ### Requirement: 桌面应用打包迁移资源 diff --git a/openspec/specs/test-coverage/spec.md b/openspec/specs/test-coverage/spec.md index 571b759..ec66337 100644 --- a/openspec/specs/test-coverage/spec.md +++ b/openspec/specs/test-coverage/spec.md @@ -351,3 +351,34 @@ - **WHEN** 测试配置加载时指定不存在的配置文件路径 - **THEN** SHALL 返回默认配置值,不自动创建配置文件 - **THEN** 测试 SHALL 验证配置文件未被创建 + +### Requirement: Desktop 启动失败提示测试边界 + +系统 SHALL 为 desktop 启动失败报告建立 UI 无关测试覆盖,验证启动错误分类、提示通道选择和 fallback 行为,但 SHALL NOT 要求测试真实系统通知、模态弹窗或托盘 UI 可见性。 + +#### Scenario: 启动错误分类测试 + +- **WHEN** 运行 desktop 专属测试 +- **THEN** 测试 SHALL 覆盖配置、单实例、端口、日志、数据库、迁移、静态资源、HTTP server 和托盘初始化失败的错误分类 +- **THEN** 测试 SHALL 验证每类错误包含正确 phase 和用户可读消息 +- **THEN** 测试 SHALL 验证敏感信息不会出现在用户提示文本中 + +#### Scenario: 提示通道选择测试 + +- **WHEN** 运行跨平台提示逻辑测试 +- **THEN** 测试 SHALL 使用 fake runner 或 fake notifier 验证通知、模态弹窗和 stderr/log fallback 的调用顺序 +- **THEN** 测试 SHALL 验证命令参数构造、AppleScript 转义、Windows MessageBox flags 和 Linux 工具优先级 +- **THEN** 测试 SHALL NOT 调用真实 `osascript`、`notify-send`、`zenity`、`kdialog`、`xmessage` 或显示真实 `MessageBoxW` + +#### Scenario: 托盘 ready/timeout 测试 + +- **WHEN** 运行托盘启动封装测试 +- **THEN** 测试 SHALL 使用 fake systray runner 验证 ready 成功路径 +- **THEN** 测试 SHALL 使用 fake systray runner 验证 ready timeout 会返回 `tray` 阶段 fatal 启动错误 +- **THEN** 测试 SHALL NOT 要求真实桌面托盘图标出现 + +#### Scenario: 浏览器打开失败测试 + +- **WHEN** 测试浏览器自动打开失败 +- **THEN** 测试 SHALL 验证该错误被记录为非 fatal warning +- **THEN** 测试 SHALL 验证应用启动流程不会因浏览器打开失败退出