feat: 增强桌面启动失败提示与测试覆盖
This commit is contained in:
@@ -127,7 +127,7 @@ make desktop-build-linux TARGET_ARCH=arm64
|
|||||||
- 桌面应用需要 CGO 支持
|
- 桌面应用需要 CGO 支持
|
||||||
- macOS: 自带 Xcode Command Line Tools
|
- macOS: 自带 Xcode Command Line Tools
|
||||||
- Linux 构建: 需要 gcc、pkg-config、GTK3 开发包和 Ayatana AppIndicator 开发包(Ubuntu/Debian: `libgtk-3-dev`、`libayatana-appindicator3-dev`)
|
- 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 隐藏控制台窗口
|
- Windows: 需要对应架构的 MinGW-w64/MSYS2 工具链,desktop 使用 GUI linker flags 隐藏控制台窗口
|
||||||
- macOS DMG: 发布包暂不签名、不 notarize,首次打开可能出现 Gatekeeper 提示
|
- macOS DMG: 发布包暂不签名、不 notarize,首次打开可能出现 Gatekeeper 提示
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,35 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func showError(title, message string) {
|
func platformStartupChannels(runner commandRunner) []promptChannel {
|
||||||
script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`,
|
return []promptChannel{
|
||||||
escapeAppleScript(message), escapeAppleScript(title))
|
{
|
||||||
if err := exec.Command("osascript", "-e", script).Run(); err != nil {
|
name: "macos-notification",
|
||||||
dialogLogger().Warn("显示错误对话框失败", zap.Error(err))
|
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)
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
46
backend/cmd/desktop/dialog_darwin_test.go
Normal file
46
backend/cmd/desktop/dialog_darwin_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,56 +13,99 @@ type dialogToolType int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
toolNone dialogToolType = iota
|
toolNone dialogToolType = iota
|
||||||
toolZenity
|
|
||||||
toolKdialog
|
|
||||||
toolNotifySend
|
toolNotifySend
|
||||||
|
toolKdialogPassive
|
||||||
|
toolZenity
|
||||||
|
toolKdialogError
|
||||||
toolXmessage
|
toolXmessage
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
dialogTool dialogToolType
|
dialogTools map[string]bool
|
||||||
dialogToolOnce sync.Once
|
dialogToolOnce sync.Once
|
||||||
|
dialogToolNames = []string{"notify-send", "kdialog", "zenity", "xmessage"}
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
dialogToolOnce.Do(detectDialogTool)
|
dialogToolOnce.Do(func() { detectDialogTools(defaultCommandRunner{}) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func detectDialogTool() {
|
func platformStartupChannels(runner commandRunner) []promptChannel {
|
||||||
tools := []struct {
|
return []promptChannel{
|
||||||
name string
|
linuxCommandChannel("notify-send", toolNotifySend, runner, linuxHasGraphicalSessionAndDBus, func(req promptRequest) []string {
|
||||||
typ dialogToolType
|
return []string{"-u", "critical", "-a", appName, "-i", "nex", req.title, req.message}
|
||||||
}{
|
}),
|
||||||
{"zenity", toolZenity},
|
linuxCommandChannel("kdialog", toolKdialogPassive, runner, linuxHasGraphicalSession, func(req promptRequest) []string {
|
||||||
{"kdialog", toolKdialog},
|
return []string{"--title", req.title, "--passivepopup", req.message, "10"}
|
||||||
{"notify-send", toolNotifySend},
|
}),
|
||||||
{"xmessage", toolXmessage},
|
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 {
|
func detectDialogTools(runner commandRunner) {
|
||||||
if _, err := exec.LookPath(tool.name); err == nil {
|
dialogTools = make(map[string]bool, len(dialogToolNames))
|
||||||
dialogTool = tool.typ
|
for _, name := range dialogToolNames {
|
||||||
return
|
_, 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) {
|
func linuxHasGraphicalSession() error {
|
||||||
switch dialogTool {
|
if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
case toolZenity:
|
return errors.New("缺少图形会话")
|
||||||
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("无法显示错误对话框")
|
|
||||||
}
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
61
backend/cmd/desktop/dialog_linux_test.go
Normal file
61
backend/cmd/desktop/dialog_linux_test.go
Normal file
@@ -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 时应不可用")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"unicode/utf16"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
mbOK = 0x00000000
|
||||||
mbIconError = 0x10
|
mbIconError = 0x10
|
||||||
mbIconInformation = 0x40
|
mbIconInformation = 0x40
|
||||||
|
mbTaskModal = 0x00002000
|
||||||
|
mbSetForeground = 0x00010000
|
||||||
|
mbTopMost = 0x00040000
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -25,12 +29,79 @@ var (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func showError(title, message string) {
|
func platformStartupChannels(runner commandRunner) []promptChannel {
|
||||||
if err := messageBox(title, message, mbIconError); err != nil {
|
return []promptChannel{
|
||||||
if zapLogger != nil {
|
{
|
||||||
zapLogger.Warn("显示错误对话框失败", zap.Error(err))
|
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 {
|
func messageBox(title, message string, flags uint) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net"
|
"net"
|
||||||
@@ -27,10 +28,10 @@ import (
|
|||||||
"nex/backend/internal/service"
|
"nex/backend/internal/service"
|
||||||
"nex/backend/pkg/buildinfo"
|
"nex/backend/pkg/buildinfo"
|
||||||
|
|
||||||
"github.com/getlantern/systray"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gofrs/flock"
|
"github.com/gofrs/flock"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
pkgLogger "nex/backend/pkg/logger"
|
pkgLogger "nex/backend/pkg/logger"
|
||||||
)
|
)
|
||||||
@@ -40,31 +41,65 @@ var (
|
|||||||
zapLogger *zap.Logger
|
zapLogger *zap.Logger
|
||||||
shutdownCtx context.Context
|
shutdownCtx context.Context
|
||||||
shutdownCancel context.CancelFunc
|
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() {
|
func main() {
|
||||||
minimalLogger := pkgLogger.NewMinimal()
|
minimalLogger := pkgLogger.NewMinimal()
|
||||||
|
if err := runDesktop(minimalLogger); err != nil {
|
||||||
cfg, cfgMeta, err := config.LoadDesktopConfigWithMetadata()
|
reportStartupFailure(err, dialogLogger())
|
||||||
if err != nil {
|
|
||||||
minimalLogger.Error("加载配置失败", zap.Error(err))
|
|
||||||
showError(appName, desktopConfigErrorMessage(getDesktopConfigPath(), err))
|
|
||||||
os.Exit(1)
|
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
|
port := cfg.Server.Port
|
||||||
|
|
||||||
if err := checkPortAvailable(port); err != nil {
|
singleLock := desktopHooks.newLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
||||||
minimalLogger.Error("端口不可用", zap.Error(err))
|
|
||||||
showError(appName, err.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
singleLock := NewSingletonLock(filepath.Join(os.TempDir(), "nex-gateway.lock"))
|
|
||||||
if err := singleLock.Lock(); err != nil {
|
if err := singleLock.Lock(); err != nil {
|
||||||
minimalLogger.Error("已有 Nex 实例运行")
|
return newStartupError(phaseSingleton, "已有 Nex 实例运行", err)
|
||||||
showError(appName, "已有 Nex 实例运行")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := singleLock.Unlock(); err != nil {
|
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,
|
Level: cfg.Log.Level,
|
||||||
Path: cfg.Log.Path,
|
Path: cfg.Log.Path,
|
||||||
MaxSize: cfg.Log.MaxSize,
|
MaxSize: cfg.Log.MaxSize,
|
||||||
@@ -81,7 +122,7 @@ func main() {
|
|||||||
Compress: cfg.Log.Compress,
|
Compress: cfg.Log.Compress,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
minimalLogger.Fatal("初始化日志失败", zap.Error(err))
|
return newStartupError(phaseLogger, fmt.Sprintf("初始化日志失败\n\n日志目录: %s\n\n请检查目录权限或磁盘空间", cfg.Log.Path), err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := zapLogger.Sync(); err != nil {
|
if err := zapLogger.Sync(); err != nil {
|
||||||
@@ -91,11 +132,17 @@ func main() {
|
|||||||
|
|
||||||
cfg.PrintSummary(zapLogger)
|
cfg.PrintSummary(zapLogger)
|
||||||
|
|
||||||
db, err := database.Init(&cfg.Database, zapLogger)
|
db, err := desktopHooks.initDB(&cfg.Database, zapLogger)
|
||||||
if err != nil {
|
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)
|
providerRepo := repository.NewProviderRepository(db)
|
||||||
modelRepo := repository.NewModelRepository(db)
|
modelRepo := repository.NewModelRepository(db)
|
||||||
@@ -118,11 +165,8 @@ func main() {
|
|||||||
statsService := service.NewStatsService(statsRepo, statsBuffer)
|
statsService := service.NewStatsService(statsRepo, statsBuffer)
|
||||||
|
|
||||||
registry := conversion.NewMemoryRegistry()
|
registry := conversion.NewMemoryRegistry()
|
||||||
if err := registry.Register(openai.NewAdapter()); err != nil {
|
if err := desktopHooks.registerAdapters(registry); err != nil {
|
||||||
zapLogger.Fatal("注册 OpenAI 适配器失败", zap.Error(err))
|
return newStartupError(phaseAdapter, startupInternalErrorMessage(), err)
|
||||||
}
|
|
||||||
if err := registry.Register(anthropic.NewAdapter()); err != nil {
|
|
||||||
zapLogger.Fatal("注册 Anthropic 适配器失败", zap.Error(err))
|
|
||||||
}
|
}
|
||||||
engine := conversion.NewConversionEngine(registry, zapLogger)
|
engine := conversion.NewConversionEngine(registry, zapLogger)
|
||||||
|
|
||||||
@@ -144,7 +188,9 @@ func main() {
|
|||||||
r.Use(middleware.CORS())
|
r.Use(middleware.CORS())
|
||||||
|
|
||||||
setupRoutes(r, proxyHandler, providerHandler, modelHandler, statsHandler, versionHandler, settingsHandler)
|
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{
|
server = &http.Server{
|
||||||
Addr: desktopListenAddr(port),
|
Addr: desktopListenAddr(port),
|
||||||
@@ -154,26 +200,46 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
shutdownCtx, shutdownCancel = context.WithCancel(context.Background())
|
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() {
|
go func() {
|
||||||
zapLogger.Info("AI Gateway 启动",
|
logger.Info("AI Gateway 启动",
|
||||||
zap.String("addr", server.Addr),
|
zap.String("addr", server.Addr),
|
||||||
zap.String("version", buildinfo.Version()),
|
zap.String("version", buildinfo.Version()),
|
||||||
zap.String("commit", buildinfo.Commit()),
|
zap.String("commit", buildinfo.Commit()),
|
||||||
zap.String("build_time", buildinfo.BuildTime()))
|
zap.String("build_time", buildinfo.BuildTime()))
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||||
zapLogger.Fatal("服务器启动失败", zap.Error(err))
|
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) {
|
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()
|
distFS, err := frontendDistFS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zapLogger.Fatal("无法加载前端资源", zap.Error(err))
|
return err
|
||||||
}
|
}
|
||||||
setupStaticFilesWithFS(r, distFS)
|
setupStaticFilesWithFS(r, distFS)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func frontendDistFS() (fs.FS, error) {
|
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() {
|
func doShutdown() {
|
||||||
if zapLogger != nil {
|
if zapLogger != nil {
|
||||||
zapLogger.Info("正在关闭服务器...")
|
zapLogger.Info("正在关闭服务器...")
|
||||||
@@ -382,13 +408,12 @@ func desktopPortMenuTitle(port int) string {
|
|||||||
return fmt.Sprintf("端口: %d", port)
|
return fmt.Sprintf("端口: %d", port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkPortAvailable(port int) error {
|
func listenDesktopPort(port int) (net.Listener, error) {
|
||||||
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
|
return net.Listen("tcp", desktopListenAddr(port))
|
||||||
if err != nil {
|
}
|
||||||
return fmt.Errorf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
|
|
||||||
}
|
func desktopPortUnavailableMessage(port int) string {
|
||||||
ln.Close()
|
return fmt.Sprintf("端口 %d 已被占用\n\n可能原因:\n- 已有 Nex 实例运行\n- 其他程序占用了该端口\n\n请检查并关闭占用端口的程序", port)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SingletonLock struct {
|
type SingletonLock struct {
|
||||||
|
|||||||
@@ -47,9 +47,15 @@ func TestMessageBoxW_WindowsOnly_FailureUsesReturnValue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestShowError_WindowsBranch(t *testing.T) {
|
func TestShowError_WindowsBranch(t *testing.T) {
|
||||||
withMessageBoxW(t, func(_, _, _, _ uintptr) (uintptr, error) {
|
old := buildPromptChannels
|
||||||
return 0, syscall.Errno(5)
|
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() {
|
defer func() {
|
||||||
if recovered := recover(); recovered != nil {
|
if recovered := recover(); recovered != nil {
|
||||||
@@ -59,3 +65,42 @@ func TestShowError_WindowsBranch(t *testing.T) {
|
|||||||
|
|
||||||
showError("测试错误", "这是一条测试错误消息")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,87 +9,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckPortAvailable(t *testing.T) {
|
func TestListenDesktopPortReturnsReusableListener(t *testing.T) {
|
||||||
port := 19826
|
listener, err := listenDesktopPort(0)
|
||||||
|
|
||||||
err := checkPortAvailable(port)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("端口 %d 应该可用: %v", port, err)
|
t.Fatalf("listener-first 应直接获取配置端口 listener: %v", 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)
|
|
||||||
}
|
}
|
||||||
defer listener.Close()
|
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}
|
server := &http.Server{ReadHeaderTimeout: time.Second}
|
||||||
defer server.Close()
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(done)
|
||||||
err := server.Serve(listener)
|
err := server.Serve(listener)
|
||||||
if err != nil && err != http.ErrServerClosed && !errors.Is(err, net.ErrClosed) {
|
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)
|
if err := server.Close(); err != nil {
|
||||||
|
t.Fatalf("关闭测试 server 失败: %v", err)
|
||||||
listener.Close()
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
|
|
||||||
err = checkPortAvailable(port)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("端口关闭后应该可用: %v", err)
|
|
||||||
}
|
}
|
||||||
|
<-done
|
||||||
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("端口错误信息包含端口号测试通过")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDesktopConfigPath(t *testing.T) {
|
func TestGetDesktopConfigPath(t *testing.T) {
|
||||||
|
|||||||
121
backend/cmd/desktop/reporter.go
Normal file
121
backend/cmd/desktop/reporter.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
140
backend/cmd/desktop/reporter_test.go
Normal file
140
backend/cmd/desktop/reporter_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
332
backend/cmd/desktop/run_desktop_test.go
Normal file
332
backend/cmd/desktop/run_desktop_test.go
Normal file
@@ -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("浏览器打开失败应提示用户手动访问")
|
||||||
|
}
|
||||||
|
}
|
||||||
96
backend/cmd/desktop/startup_error.go
Normal file
96
backend/cmd/desktop/startup_error.go
Normal file
@@ -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}<redacted>`},
|
||||||
|
{regexp.MustCompile(`(?i)(api[_-]?key\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
|
||||||
|
{regexp.MustCompile(`(?i)(secret\s*[:=]\s*)[^\s,;&]+`), `${1}<redacted>`},
|
||||||
|
{regexp.MustCompile(`([^\s:/]+):([^\s@]+)@tcp\(`), `${1}:<redacted>@tcp(`},
|
||||||
|
{regexp.MustCompile(`(://[^\s:/]+):([^\s@]+)@`), `${1}:<redacted>@`},
|
||||||
|
}
|
||||||
|
|
||||||
|
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请查看日志或重新安装应用"
|
||||||
|
}
|
||||||
40
backend/cmd/desktop/startup_error_test.go
Normal file
40
backend/cmd/desktop/startup_error_test.go
Normal file
@@ -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, "<redacted>") {
|
||||||
|
t.Fatalf("用户提示应包含脱敏占位符,实际: %s", userMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,14 +13,15 @@ import (
|
|||||||
func TestSetupStaticFiles(t *testing.T) {
|
func TestSetupStaticFiles(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
distFS, err := frontendDistFS()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("跳过测试: 前端资源未构建: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
setupStaticFilesWithFS(r, distFS)
|
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||||
|
"icon.png": {Data: []byte("png")},
|
||||||
|
"assets/test.js": {Data: []byte("console.log('test')")},
|
||||||
|
"assets/test.css": {Data: []byte("body {}")},
|
||||||
|
"assets/test.svg": {Data: []byte("<svg></svg>")},
|
||||||
|
"assets/test.woff": {Data: []byte("font")},
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("API 404", func(t *testing.T) {
|
t.Run("API 404", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/test", nil)
|
req := httptest.NewRequest("GET", "/api/test", nil)
|
||||||
@@ -73,13 +74,12 @@ func TestSetupStaticFiles(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code == 200 {
|
if w.Code != http.StatusOK {
|
||||||
expected := "application/javascript"
|
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
|
||||||
if w.Header().Get("Content-Type") != expected {
|
}
|
||||||
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
|
expected := "application/javascript"
|
||||||
}
|
if w.Header().Get("Content-Type") != expected {
|
||||||
} else {
|
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
|
||||||
t.Log("文件不存在,跳过 MIME 类型验证")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,13 +88,12 @@ func TestSetupStaticFiles(t *testing.T) {
|
|||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
if w.Code == 200 {
|
if w.Code != http.StatusOK {
|
||||||
expected := "text/css"
|
t.Fatalf("期望状态码 200, 实际 %d", w.Code)
|
||||||
if w.Header().Get("Content-Type") != expected {
|
}
|
||||||
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
|
expected := "text/css"
|
||||||
}
|
if w.Header().Get("Content-Type") != expected {
|
||||||
} else {
|
t.Errorf("期望 Content-Type %s, 实际 %s", expected, w.Header().Get("Content-Type"))
|
||||||
t.Log("文件不存在,跳过 MIME 类型验证")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -128,12 +127,6 @@ func TestSetupStaticFilesWithFS_IconPNG(t *testing.T) {
|
|||||||
func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
distFS, err := frontendDistFS()
|
|
||||||
if err != nil {
|
|
||||||
t.Skipf("跳过测试: 前端资源未构建: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
|
|
||||||
var gotProtocol string
|
var gotProtocol string
|
||||||
@@ -148,7 +141,10 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
|||||||
gotPath = c.Param("path")
|
gotPath = c.Param("path")
|
||||||
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
|
c.JSON(http.StatusOK, gin.H{"protocol": gotProtocol, "path": gotPath})
|
||||||
}))
|
}))
|
||||||
setupStaticFilesWithFS(r, distFS)
|
setupStaticFilesWithFS(r, fstest.MapFS{
|
||||||
|
"index.html": {Data: []byte("<html>fallback</html>")},
|
||||||
|
"assets/test.js": {Data: []byte("console.log('test')")},
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) {
|
t.Run("OpenAI route enters proxy handler wrapper", func(t *testing.T) {
|
||||||
gotProtocol = ""
|
gotProtocol = ""
|
||||||
@@ -199,14 +195,11 @@ func TestWithProtocolAndStaticRoutes(t *testing.T) {
|
|||||||
if gotProtocol != "" || gotPath != "" {
|
if gotProtocol != "" || gotPath != "" {
|
||||||
t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
|
t.Errorf("静态资源不应进入代理包装器,实际 protocol=%s path=%s", gotProtocol, gotPath)
|
||||||
}
|
}
|
||||||
if w.Code == http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
|
t.Fatalf("期望静态资源返回 200, 实际 %d", w.Code)
|
||||||
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if w.Code != http.StatusNotFound {
|
if !strings.HasPrefix(w.Header().Get("Content-Type"), "application/javascript") {
|
||||||
t.Errorf("期望静态资源返回 200 或 404, 实际 %d", w.Code)
|
t.Errorf("期望 JS Content-Type, 实际 %s", w.Header().Get("Content-Type"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
231
backend/cmd/desktop/tray.go
Normal file
231
backend/cmd/desktop/tray.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
169
backend/cmd/desktop/tray_test.go
Normal file
169
backend/cmd/desktop/tray_test.go
Normal file
@@ -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("浏览器打开失败应提示用户")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package database
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
pkglogger "nex/backend/pkg/logger"
|
pkglogger "nex/backend/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrMigration = errors.New("数据库迁移失败")
|
||||||
|
|
||||||
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
|
func Init(cfg *config.DatabaseConfig, zapLogger *zap.Logger) (*gorm.DB, error) {
|
||||||
moduleLogger := pkglogger.WithModule(zapLogger, "database")
|
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 {
|
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)
|
configurePool(db, cfg, moduleLogger)
|
||||||
|
|||||||
@@ -166,6 +166,34 @@
|
|||||||
|
|
||||||
测试 workflow 中的各测试步骤 SHALL 使用隔离的资源,不干扰主环境。
|
测试 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 临时资源隔离
|
#### Scenario: E2E 临时资源隔离
|
||||||
|
|
||||||
- **WHEN** E2E 测试运行
|
- **WHEN** E2E 测试运行
|
||||||
|
|||||||
@@ -6,6 +6,130 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
## Requirements
|
## 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:<server.port>`
|
||||||
|
- **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** 输出格式为 `错误: <title>: <message>`
|
||||||
|
|
||||||
|
#### Scenario: 工具检测缓存
|
||||||
|
|
||||||
|
- **WHEN** desktop 在 Linux 上启动
|
||||||
|
- **THEN** 系统检测一次可用通知和对话框工具
|
||||||
|
- **AND** 检测结果缓存在包级变量
|
||||||
|
- **AND** 后续提示调用直接使用缓存结果,不重复检测
|
||||||
|
|
||||||
### Requirement: 桌面应用启动
|
### Requirement: 桌面应用启动
|
||||||
|
|
||||||
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
|
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
|
||||||
@@ -36,7 +160,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 系统托盘
|
### Requirement: 系统托盘
|
||||||
|
|
||||||
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。
|
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`。托盘初始化失败 SHALL 被视为 desktop fatal 启动失败。
|
||||||
|
|
||||||
#### Scenario: 托盘图标显示
|
#### Scenario: 托盘图标显示
|
||||||
|
|
||||||
@@ -60,11 +184,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
- **WHEN** 用户点击托盘菜单"打开管理界面"
|
||||||
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
|
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
|
||||||
|
|
||||||
|
#### Scenario: 托盘 ready 后自动打开浏览器
|
||||||
|
|
||||||
|
- **WHEN** desktop 启动并完成托盘图标、菜单和点击事件初始化
|
||||||
|
- **THEN** 系统 SHALL 将托盘标记为 ready
|
||||||
|
- **THEN** 系统 SHALL 在托盘 ready 后自动打开 `http://localhost:<server.port>`
|
||||||
|
- **THEN** 系统 SHALL NOT 在托盘初始化失败时自动打开浏览器
|
||||||
|
|
||||||
#### Scenario: 浏览器打开失败
|
#### Scenario: 浏览器打开失败
|
||||||
|
|
||||||
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
|
||||||
- **THEN** 托盘菜单仍可正常使用
|
- **THEN** 托盘菜单仍可正常使用
|
||||||
- **AND** 用户可手动访问 `http://localhost:<server.port>`
|
- **AND** 用户可手动访问 `http://localhost:<server.port>`
|
||||||
|
- **AND** 系统 SHALL 尝试显示非 fatal 提示,告知用户手动访问地址
|
||||||
|
|
||||||
#### Scenario: 退出应用
|
#### Scenario: 退出应用
|
||||||
|
|
||||||
@@ -73,6 +205,13 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
- **AND** 托盘图标消失
|
- **AND** 托盘图标消失
|
||||||
- **AND** 应用进程退出
|
- **AND** 应用进程退出
|
||||||
|
|
||||||
|
#### Scenario: 托盘初始化失败
|
||||||
|
|
||||||
|
- **WHEN** desktop 启动时托盘未在限定时间内 ready、托盘图标资源无法加载或托盘菜单无法完成初始化
|
||||||
|
- **THEN** 系统 SHALL 生成 `tray` 阶段启动错误
|
||||||
|
- **THEN** 系统 SHALL 通过启动失败提示降级链提示用户
|
||||||
|
- **THEN** 应用 SHALL 退出
|
||||||
|
|
||||||
### Requirement: 静态文件服务
|
### Requirement: 静态文件服务
|
||||||
|
|
||||||
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
|
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
|
||||||
@@ -126,19 +265,28 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源
|
|||||||
|
|
||||||
### Requirement: 端口冲突检测
|
### Requirement: 端口冲突检测
|
||||||
|
|
||||||
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
|
系统 SHALL 在启动时获取启动配置中的 `server.port` 对应监听端口,并使用同一个 listener 启动后端服务,避免端口预检测与实际监听之间的竞态。
|
||||||
|
|
||||||
#### Scenario: 配置端口可用
|
#### Scenario: 配置端口可用
|
||||||
|
|
||||||
- **WHEN** 启动配置中的 `server.port` 未被占用
|
- **WHEN** 启动配置中的 `server.port` 未被占用
|
||||||
- **THEN** 服务正常启动
|
- **THEN** 系统 SHALL 成功创建该端口的 listener
|
||||||
|
- **THEN** 后端服务 SHALL 使用该 listener 正常启动
|
||||||
|
|
||||||
#### Scenario: 配置端口被占用
|
#### Scenario: 配置端口被占用
|
||||||
|
|
||||||
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
|
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
|
||||||
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
|
- **THEN** 系统 SHALL 生成 `port` 阶段启动错误
|
||||||
|
- **THEN** 错误提示 SHALL 包含"端口 <server.port> 已被占用"
|
||||||
- **AND** 应用退出
|
- **AND** 应用退出
|
||||||
|
|
||||||
|
#### Scenario: 单实例优先于端口监听
|
||||||
|
|
||||||
|
- **WHEN** 用户尝试启动第二个 desktop 实例
|
||||||
|
- **THEN** 系统 SHALL 优先检测到已有实例持有文件锁
|
||||||
|
- **THEN** 系统 SHALL 显示"已有 Nex 实例运行"相关错误提示
|
||||||
|
- **THEN** 系统 SHALL NOT 将该场景误报为端口占用
|
||||||
|
|
||||||
### Requirement: 桌面配置源隔离和启动快照
|
### Requirement: 桌面配置源隔离和启动快照
|
||||||
|
|
||||||
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
|
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
|
||||||
@@ -312,74 +460,52 @@ desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API,不主动
|
|||||||
|
|
||||||
### Requirement: Windows 原生对话框
|
### 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: 错误提示对话框
|
#### Scenario: 错误提示对话框
|
||||||
|
|
||||||
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
|
- **WHEN** Windows 系统通知不可用或返回失败
|
||||||
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
- **THEN** 使用 `MessageBoxW` 显示模态对话框
|
||||||
- **AND** 对话框标题栏显示应用名称
|
- **AND** 对话框标题栏显示应用名称
|
||||||
- **AND** 对话框包含错误描述文本
|
- **AND** 对话框包含错误描述文本
|
||||||
- **AND** 对话框显示错误图标(MB_ICONERROR)
|
- **AND** 对话框显示错误图标(MB_ICONERROR)
|
||||||
|
- **AND** 对话框 SHALL 尽量置于前台或顶层显示
|
||||||
|
|
||||||
|
#### Scenario: Windows UI 提示均失败
|
||||||
|
|
||||||
|
- **WHEN** Windows 系统通知和 `MessageBoxW` 均失败
|
||||||
|
- **THEN** 系统 SHALL 记录提示失败原因
|
||||||
|
- **THEN** 系统 SHALL 降级输出到可用启动日志
|
||||||
|
|
||||||
#### Scenario: 非 Windows 平台不受影响
|
#### Scenario: 非 Windows 平台不受影响
|
||||||
|
|
||||||
- **WHEN** 应用运行在 macOS 或 Linux 上
|
- **WHEN** 应用运行在 macOS 或 Linux 上
|
||||||
- **THEN** 错误对话框仍使用平台原有实现(osascript / zenity)
|
- **THEN** 错误提示 SHALL 使用对应平台的通知和弹窗降级策略
|
||||||
|
|
||||||
### 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** 后续对话框调用直接使用缓存结果,不重复检测
|
|
||||||
|
|
||||||
### Requirement: macOS AppleScript 字符转义
|
### Requirement: macOS AppleScript 字符转义
|
||||||
|
|
||||||
系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。
|
系统 SHALL 对 AppleScript 通知、模态告警和对话框中的特殊字符进行转义,确保脚本正确执行。
|
||||||
|
|
||||||
#### Scenario: 转义反斜杠
|
#### Scenario: 转义反斜杠
|
||||||
- **WHEN** 对话框消息包含反斜杠字符 `\`
|
|
||||||
|
- **WHEN** AppleScript 提示文本包含反斜杠字符 `\`
|
||||||
- **THEN** 转义为 `\\`
|
- **THEN** 转义为 `\\`
|
||||||
|
|
||||||
#### Scenario: 转义双引号
|
#### Scenario: 转义双引号
|
||||||
- **WHEN** 对话框消息包含双引号字符 `"`
|
|
||||||
|
- **WHEN** AppleScript 提示文本包含双引号字符 `"`
|
||||||
- **THEN** 转义为 `\"`
|
- **THEN** 转义为 `\"`
|
||||||
|
|
||||||
#### Scenario: 多行文本处理
|
#### Scenario: 多行文本处理
|
||||||
- **WHEN** 对话框消息包含换行符 `\n`
|
|
||||||
- **THEN** AppleScript 正确显示多行文本
|
- **WHEN** AppleScript 提示文本包含换行符 `\n`
|
||||||
|
- **THEN** AppleScript 通知或模态告警 SHALL 正确显示多行文本或保留可读换行语义
|
||||||
|
|
||||||
### Requirement: 桌面应用打包迁移资源
|
### Requirement: 桌面应用打包迁移资源
|
||||||
|
|
||||||
|
|||||||
@@ -351,3 +351,34 @@
|
|||||||
- **WHEN** 测试配置加载时指定不存在的配置文件路径
|
- **WHEN** 测试配置加载时指定不存在的配置文件路径
|
||||||
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
|
- **THEN** SHALL 返回默认配置值,不自动创建配置文件
|
||||||
- **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 验证应用启动流程不会因浏览器打开失败退出
|
||||||
|
|||||||
Reference in New Issue
Block a user