diff --git a/backend/cmd/desktop/dialog_darwin.go b/backend/cmd/desktop/dialog_darwin.go new file mode 100644 index 0000000..6ffcfe1 --- /dev/null +++ b/backend/cmd/desktop/dialog_darwin.go @@ -0,0 +1,28 @@ +//go:build darwin + +package main + +import ( + "fmt" + "os/exec" + "strings" +) + +func showError(title, message string) { + script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, + escapeAppleScript(message), escapeAppleScript(title)) + exec.Command("osascript", "-e", script).Run() +} + +func showAbout() { + message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway" + script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, + escapeAppleScript(message)) + exec.Command("osascript", "-e", script).Run() +} + +func escapeAppleScript(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "\"", "\\\"") + return s +} diff --git a/backend/cmd/desktop/dialog_linux.go b/backend/cmd/desktop/dialog_linux.go new file mode 100644 index 0000000..f268f5d --- /dev/null +++ b/backend/cmd/desktop/dialog_linux.go @@ -0,0 +1,88 @@ +//go:build linux + +package main + +import ( + "fmt" + "os" + "os/exec" + "sync" +) + +type dialogToolType int + +const ( + toolNone dialogToolType = iota + toolZenity + toolKdialog + toolNotifySend + toolXmessage +) + +var ( + dialogTool dialogToolType + dialogToolOnce sync.Once +) + +func init() { + dialogToolOnce.Do(detectDialogTool) +} + +func detectDialogTool() { + tools := []struct { + name string + typ dialogToolType + }{ + {"zenity", toolZenity}, + {"kdialog", toolKdialog}, + {"notify-send", toolNotifySend}, + {"xmessage", toolXmessage}, + } + + for _, tool := range tools { + if _, err := exec.LookPath(tool.name); err == nil { + dialogTool = tool.typ + return + } + } + + dialogTool = toolNone +} + +func showError(title, message string) { + switch dialogTool { + case toolZenity: + exec.Command("zenity", "--error", + fmt.Sprintf("--title=%s", title), + fmt.Sprintf("--text=%s", message)).Run() + case toolKdialog: + exec.Command("kdialog", "--error", message, "--title", title).Run() + case toolNotifySend: + exec.Command("notify-send", "-u", "critical", title, message).Run() + case toolXmessage: + exec.Command("xmessage", "-center", + fmt.Sprintf("%s: %s", title, message)).Run() + default: + fmt.Fprintf(os.Stderr, "错误: %s: %s\n", title, message) + } +} + +func showAbout() { + message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway" + + switch dialogTool { + case toolZenity: + exec.Command("zenity", "--info", + "--title=关于 Nex Gateway", + fmt.Sprintf("--text=%s", message)).Run() + case toolKdialog: + exec.Command("kdialog", "--msgbox", message, "--title", "关于 Nex Gateway").Run() + case toolNotifySend: + exec.Command("notify-send", "关于 Nex Gateway", message).Run() + case toolXmessage: + exec.Command("xmessage", "-center", + fmt.Sprintf("关于 Nex Gateway: %s", message)).Run() + default: + fmt.Fprintf(os.Stderr, "关于 Nex Gateway: %s\n", message) + } +} diff --git a/backend/cmd/desktop/dialog_windows.go b/backend/cmd/desktop/dialog_windows.go new file mode 100644 index 0000000..0ee461d --- /dev/null +++ b/backend/cmd/desktop/dialog_windows.go @@ -0,0 +1,38 @@ +//go:build windows + +package main + +import ( + "syscall" + "unsafe" +) + +const ( + MB_ICONERROR = 0x10 + MB_ICONINFORMATION = 0x40 +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procMessageBoxW = user32.NewProc("MessageBoxW") +) + +func showError(title, message string) { + messageBox(title, message, MB_ICONERROR) +} + +func showAbout() { + message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway" + messageBox("关于 Nex Gateway", message, MB_ICONINFORMATION) +} + +func messageBox(title, message string, flags uint) { + titlePtr, _ := syscall.UTF16PtrFromString(title) + messagePtr, _ := syscall.UTF16PtrFromString(message) + procMessageBoxW.Call( + 0, + uintptr(unsafe.Pointer(messagePtr)), + uintptr(unsafe.Pointer(titlePtr)), + uintptr(flags), + ) +} diff --git a/backend/cmd/desktop/main.go b/backend/cmd/desktop/main.go index d4d5875..f43832d 100644 --- a/backend/cmd/desktop/main.go +++ b/backend/cmd/desktop/main.go @@ -12,12 +12,10 @@ import ( "path/filepath" "runtime" "strings" - "syscall" "time" - "unsafe" - "github.com/gin-gonic/gin" "github.com/getlantern/systray" + "github.com/gin-gonic/gin" "github.com/gofrs/flock" "github.com/pressly/goose/v3" "go.uber.org/zap" @@ -447,49 +445,3 @@ func openBrowser(url string) error { return cmd.Start() } - -func showError(title, message string) { - switch runtime.GOOS { - case "darwin": - script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "%s"`, message, title) - exec.Command("osascript", "-e", script).Run() - case "windows": - messageBox(title, message, MB_ICONERROR) - case "linux": - exec.Command("zenity", "--error", fmt.Sprintf("--title=%s", title), fmt.Sprintf("--text=%s", message)).Run() - } -} - -func showAbout() { - message := "Nex Gateway\n\nAI Gateway - 统一的大模型 API 网关\n\nhttps://github.com/nex/gateway" - switch runtime.GOOS { - case "darwin": - script := fmt.Sprintf(`display dialog "%s" buttons {"OK"} default button "OK" with title "关于 Nex Gateway"`, message) - exec.Command("osascript", "-e", script).Run() - case "windows": - messageBox("关于 Nex Gateway", message, MB_ICONINFORMATION) - case "linux": - exec.Command("zenity", "--info", "--title=关于 Nex Gateway", fmt.Sprintf("--text=%s", message)).Run() - } -} - -const ( - MB_ICONERROR = 0x10 - MB_ICONINFORMATION = 0x40 -) - -var ( - user32 = syscall.NewLazyDLL("user32.dll") - procMessageBoxW = user32.NewProc("MessageBoxW") -) - -func messageBox(title, message string, flags uint) { - titlePtr, _ := syscall.UTF16PtrFromString(title) - messagePtr, _ := syscall.UTF16PtrFromString(message) - procMessageBoxW.Call( - 0, - uintptr(unsafe.Pointer(messagePtr)), - uintptr(unsafe.Pointer(titlePtr)), - uintptr(flags), - ) -} diff --git a/backend/cmd/desktop/messagebox_test.go b/backend/cmd/desktop/messagebox_test.go index 7879a07..94dfc9b 100644 --- a/backend/cmd/desktop/messagebox_test.go +++ b/backend/cmd/desktop/messagebox_test.go @@ -1,30 +1,19 @@ +//go:build windows + package main import ( - "runtime" "testing" ) func TestMessageBoxW_WindowsOnly(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("MessageBoxW 仅在 Windows 上测试") - } - messageBox("测试标题", "测试消息", MB_ICONINFORMATION) } func TestShowError_WindowsBranch(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows 原生对话框测试仅在 Windows 上运行") - } - showError("测试错误", "这是一条测试错误消息") } func TestShowAbout_WindowsBranch(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows 原生对话框测试仅在 Windows 上运行") - } - showAbout() } diff --git a/openspec/specs/desktop-app/spec.md b/openspec/specs/desktop-app/spec.md index 9fbf270..adc1bce 100644 --- a/openspec/specs/desktop-app/spec.md +++ b/openspec/specs/desktop-app/spec.md @@ -11,6 +11,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件。 #### Scenario: 双击启动 + - **WHEN** 用户双击桌面应用可执行文件 - **THEN** 系统使用 `gofrs/flock` 尝试获取排他文件锁 - **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock` @@ -19,12 +20,14 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 浏览器自动打开 `http://localhost:9826` 显示管理界面 #### Scenario: 单实例检查 + - **WHEN** 用户尝试启动第二个实例 - **THEN** 系统检测到已有实例持有文件锁 - **AND** 显示错误提示"已有 Nex 实例运行" - **AND** 新实例退出 #### Scenario: 退出释放锁 + - **WHEN** 用户点击托盘菜单"退出" - **THEN** 系统释放文件锁 - **AND** 应用进程退出 @@ -34,6 +37,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择。 #### Scenario: 托盘图标显示 + - **WHEN** 桌面应用启动成功 - **THEN** 系统根据平台加载正确的图标格式 - **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`) @@ -41,6 +45,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 托盘图标 tooltip 显示"AI Gateway" #### Scenario: 托盘菜单显示 + - **WHEN** 用户点击托盘图标(左键或右键) - **THEN** 显示托盘菜单 - **AND** 菜单包含"打开管理界面"选项 @@ -50,15 +55,18 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 菜单包含"退出"选项 #### Scenario: 打开管理界面 + - **WHEN** 用户点击托盘菜单"打开管理界面" - **THEN** 系统在浏览器中打开 `http://localhost:9826` #### Scenario: 浏览器打开失败 + - **WHEN** 系统无法打开浏览器(浏览器未安装等) - **THEN** 托盘菜单仍可正常使用 - **AND** 用户可手动访问 `http://localhost:9826` #### Scenario: 退出应用 + - **WHEN** 用户点击托盘菜单"退出" - **THEN** 系统优雅关闭后端服务 - **AND** 托盘图标消失 @@ -69,14 +77,17 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 通过 Gin 同时服务 API 和前端静态资源。 #### Scenario: API 请求路由 + - **WHEN** 请求路径以 `/api/` 或 `/v1/` 开头 - **THEN** 请求由现有业务 handler 处理 #### Scenario: 静态资源路由 + - **WHEN** 请求路径为 `/assets/*` - **THEN** 返回嵌入的前端静态资源文件 #### Scenario: SPA 路由回退 + - **WHEN** 请求路径不匹配任何 API 或静态资源路由 - **THEN** 返回 `index.html`(支持前端 SPA 路由) @@ -85,10 +96,12 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 在启动前检测端口是否可用。 #### Scenario: 端口可用 + - **WHEN** 端口 9826 未被占用 - **THEN** 服务正常启动 #### Scenario: 端口被占用 + - **WHEN** 端口 9826 已被其他程序占用 - **THEN** 显示错误提示"端口 9826 已被占用" - **AND** 应用退出 @@ -98,16 +111,19 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台分离,产物文件名 SHALL 使用 `nex-{os}-{arch}[.exe]` 格式。 #### Scenario: macOS 构建 + - **WHEN** 执行 `desktop-mac` 构建命令 - **THEN** 生成 `nex-mac-arm64` 和 `nex-mac-amd64` 可执行文件 - **AND** 可打包为 `.app` bundle #### Scenario: Windows 构建 + - **WHEN** 执行 `desktop-win` 构建命令 - **THEN** 生成 `nex-win-amd64.exe` 可执行文件 - **AND** 使用 `-H=windowsgui` linker flag 隐藏控制台窗口 #### Scenario: Linux 构建 + - **WHEN** 执行 `desktop-linux` 构建命令 - **THEN** 生成 `nex-linux-amd64` 可执行文件 @@ -116,11 +132,12 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 支持打包为 macOS .app bundle。 #### Scenario: .app 结构 + - **WHEN** 执行打包脚本 - **THEN** 生成 `Nex.app` 目录结构 - **AND** 包含 `Contents/Info.plist` 元数据 - **AND** 包含 `Contents/MacOS/nex` 可执行文件 -- **AND** 包含 `Contents/Resources/AppIcon.icns` 图标 +- **AND** 包含 `Contents/Resources/icon.icns` 图标 - **AND** `Info.plist` 中 `LSUIElement` 为 `true`(不显示 Dock 图标) ### Requirement: 关于对话框 @@ -128,6 +145,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 提供关于对话框显示应用信息。在 Windows 上 SHALL 使用 `user32.dll` 的 `MessageBoxW` API 实现。 #### Scenario: 显示关于 + - **WHEN** 用户点击托盘菜单"关于" - **THEN** 显示对话框包含应用名称、项目链接 - **AND** 在 Windows 上使用 `MessageBoxW` 原生对话框实现 @@ -137,6 +155,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 系统 SHALL 在 Windows 上使用 `user32.dll` 的 `MessageBoxW` API 显示错误和关于对话框,替代 `msg *` 命令。 #### Scenario: 错误提示对话框 + - **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等) - **THEN** 使用 `MessageBoxW` 显示模态对话框 - **AND** 对话框标题栏显示应用名称 @@ -144,6 +163,7 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 对话框显示错误图标(MB_ICONERROR) #### Scenario: 关于对话框 + - **WHEN** 用户在 Windows 上点击托盘菜单"关于" - **THEN** 使用 `MessageBoxW` 显示模态对话框 - **AND** 对话框标题栏显示"关于 Nex Gateway" @@ -151,5 +171,60 @@ TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源 - **AND** 对话框显示信息图标(MB_ICONINFORMATION) #### Scenario: 非 Windows 平台不受影响 + - **WHEN** 应用运行在 macOS 或 Linux 上 - **THEN** 错误和关于对话框仍使用平台原有实现(osascript / zenity) + +### Requirement: Linux 对话框降级策略 + +系统 SHALL 在 Linux 上按优先级检测并使用可用的对话框工具,确保在不同桌面环境下都能显示对话框。 + +#### Scenario: zenity 可用 +- **WHEN** 系统检测到 `zenity` 命令可用 +- **THEN** 使用 zenity 显示 GTK 风格对话框 +- **AND** 错误对话框使用 `zenity --error` 命令 +- **AND** 信息对话框使用 `zenity --info` 命令 + +#### Scenario: kdialog 可用 +- **WHEN** zenity 不可用且 `kdialog` 命令可用 +- **THEN** 使用 kdialog 显示 KDE 风格对话框 +- **AND** 错误对话框使用 `kdialog --error` 命令 +- **AND** 信息对话框使用 `kdialog --msgbox` 命令 + +#### Scenario: notify-send 可用 +- **WHEN** zenity 和 kdialog 均不可用且 `notify-send` 命令可用 +- **THEN** 使用 notify-send 显示系统通知 +- **AND** 错误通知使用 `-u critical` 参数 +- **AND** 信息通知使用默认参数 + +#### Scenario: xmessage 可用 +- **WHEN** zenity、kdialog、notify-send 均不可用且 `xmessage` 命令可用 +- **THEN** 使用 xmessage 显示基础 X11 对话框 +- **AND** 对话框居中显示(`-center` 参数) + +#### Scenario: 无对话框工具可用 +- **WHEN** 所有对话框工具均不可用 +- **THEN** 降级到标准错误输出 +- **AND** 输出格式为 `错误: : <message>` + +#### Scenario: 工具检测缓存 +- **WHEN** 应用启动 +- **THEN** 系统检测一次可用对话框工具 +- **AND** 检测结果缓存在包级变量 +- **AND** 后续对话框调用直接使用缓存结果,不重复检测 + +### Requirement: macOS AppleScript 字符转义 + +系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。 + +#### Scenario: 转义反斜杠 +- **WHEN** 对话框消息包含反斜杠字符 `\` +- **THEN** 转义为 `\\` + +#### Scenario: 转义双引号 +- **WHEN** 对话框消息包含双引号字符 `"` +- **THEN** 转义为 `\"` + +#### Scenario: 多行文本处理 +- **WHEN** 对话框消息包含换行符 `\n` +- **THEN** AppleScript 正确显示多行文本