1
0

refactor: 桌面应用对话框代码拆分为平台专用文件

- 新增 dialog_windows.go、dialog_darwin.go、dialog_linux.go
- 使用 Go 构建标签实现条件编译
- 修复跨平台编译错误(syscall.NewLazyDLL 在 macOS/Linux 未定义)
- 实现 Linux 多工具降级策略(zenity → kdialog → notify-send → xmessage → stderr)
- 实现 macOS AppleScript 字符转义
- 更新 messagebox_test.go 构建标签
- 更新 desktop-app spec 新增 Linux 降级策略和 macOS 字符转义规范
This commit is contained in:
2026-04-23 11:47:48 +08:00
parent 58ebcaa299
commit 65ac9f740a
6 changed files with 233 additions and 63 deletions

View File

@@ -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
}

View File

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

View File

@@ -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),
)
}

View File

@@ -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),
)
}

View File

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

View File

@@ -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** 输出格式为 `错误: <title>: <message>`
#### Scenario: 工具检测缓存
- **WHEN** 应用启动
- **THEN** 系统检测一次可用对话框工具
- **AND** 检测结果缓存在包级变量
- **AND** 后续对话框调用直接使用缓存结果,不重复检测
### Requirement: macOS AppleScript 字符转义
系统 SHALL 对 AppleScript 对话框中的特殊字符进行转义,确保脚本正确执行。
#### Scenario: 转义反斜杠
- **WHEN** 对话框消息包含反斜杠字符 `\`
- **THEN** 转义为 `\\`
#### Scenario: 转义双引号
- **WHEN** 对话框消息包含双引号字符 `"`
- **THEN** 转义为 `\"`
#### Scenario: 多行文本处理
- **WHEN** 对话框消息包含换行符 `\n`
- **THEN** AppleScript 正确显示多行文本