1
0
Files
nex/openspec/specs/desktop-app/spec.md
lanyuanxiaoyao 5513f0c13d feat: 区分 server 与 desktop 配置加载入口,取消自动创建配置文件
- config.go 重构:抽取 loadConfig 共享逻辑,新增 LoadServerConfig/LoadDesktopConfig/LoadDesktopConfigAtPath,LoadConfig 保持向后兼容
- setupConfigFile 移除 SafeWriteConfigAs 自动创建逻辑,文件不存在时仅使用默认值
- cmd/desktop 切换为 LoadDesktopConfig,端口/HTTP/浏览器/托盘统一使用 cfg.Server.Port
- cmd/server 显式使用 LoadServerConfig 明确入口语义
- 提取 desktop 可测 helper:desktopListenAddr/desktopURL/desktopPortMenuTitle/desktopConfigErrorMessage
- 新增测试:desktop 忽略 CLI/env/未知参数、配置快照不变、无效配置文件不静默回退、端口 helper 一致性
- README 区分 server/desktop 配置源,移除首次启动自动创建配置文件描述
- 同步 delta specs 到 openspec/specs/ 主规范
2026-05-06 11:59:19 +08:00

408 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 桌面应用
## Purpose
TBD - 提供跨平台桌面应用支持,将后端服务与前端静态资源打包为单一可执行文件
## Requirements
### Requirement: 桌面应用启动
系统 SHALL 支持作为桌面应用启动,将后端服务与前端静态资源打包为单一可执行文件,并使用启动配置中的 `server.port` 作为本次运行端口。
#### Scenario: 双击启动
- **WHEN** 用户双击桌面应用可执行文件
- **THEN** 系统从 `~/.nex/config.yaml` 和默认值加载启动配置快照
- **AND** 系统使用 `gofrs/flock` 尝试获取排他文件锁
- **AND** 锁文件路径为系统临时目录下的 `nex-gateway.lock`
- **AND** 系统使用启动配置中的 `server.port` 启动后端服务
- **AND** 未配置 `server.port` 时默认端口为 9826
- **AND** 系统托盘图标出现
- **AND** 浏览器自动打开 `http://localhost:<server.port>` 显示管理界面
#### Scenario: 单实例检查
- **WHEN** 用户尝试启动第二个实例
- **THEN** 系统检测到已有实例持有文件锁
- **AND** 显示错误提示"已有 Nex 实例运行"
- **AND** 新实例退出
#### Scenario: 退出释放锁
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统释放文件锁
- **AND** 应用进程退出
### Requirement: 系统托盘
系统 SHALL 提供跨平台系统托盘功能,支持托盘图标和菜单。图标格式 SHALL 根据平台自动选择,端口显示 SHALL 使用启动配置中的 `server.port`
#### Scenario: 托盘图标显示
- **WHEN** 桌面应用启动成功
- **THEN** 系统根据平台加载正确的图标格式
- **AND** 在 Windows 上加载 ICO 格式图标(`assets/icon.ico`
- **AND** 在 macOS 和 Linux 上加载 PNG 格式图标(`assets/icon.png`
- **AND** 托盘图标 tooltip 显示 `Nex`
#### Scenario: 托盘菜单显示
- **WHEN** 用户点击托盘图标(左键或右键)
- **THEN** 显示托盘菜单
- **AND** 菜单包含"打开管理界面"选项
- **AND** 菜单包含"状态: 运行中"选项(禁用状态)
- **AND** 菜单包含"端口: <server.port>"选项(禁用状态)
- **AND** 菜单包含"退出"选项
#### Scenario: 打开管理界面
- **WHEN** 用户点击托盘菜单"打开管理界面"
- **THEN** 系统在浏览器中打开 `http://localhost:<server.port>`
#### Scenario: 浏览器打开失败
- **WHEN** 系统无法打开浏览器(浏览器未安装等)
- **THEN** 托盘菜单仍可正常使用
- **AND** 用户可手动访问 `http://localhost:<server.port>`
#### Scenario: 退出应用
- **WHEN** 用户点击托盘菜单"退出"
- **THEN** 系统优雅关闭后端服务
- **AND** 托盘图标消失
- **AND** 应用进程退出
### Requirement: 静态文件服务
系统 SHALL 通过 Gin 同时服务 API、协议代理和前端静态资源。
#### Scenario: API 请求路由
- **WHEN** 请求路径以 `/api/``/health` 开头
- **THEN** 请求由现有业务 handler 处理或返回 API 风格 404
#### Scenario: 版本接口路由
- **WHEN** desktop 模式收到 `GET /api/version` 请求
- **THEN** 请求 SHALL 由版本信息 handler 处理
- **THEN** 响应 SHALL 为 API JSON 响应
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: 协议代理请求路由
- **WHEN** 请求路径以 `/openai/``/anthropic/` 开头
- **THEN** 请求 SHALL 被视为协议代理请求或返回 API 风格 404
- **THEN** 请求 SHALL NOT 返回前端 `index.html`
#### Scenario: OpenAI 代理路由
- **WHEN** desktop 模式收到 `/openai/v1/chat/completions` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `openai`
#### Scenario: Anthropic 代理路由
- **WHEN** desktop 模式收到 `/anthropic/v1/messages` 请求
- **THEN** 请求 SHALL 进入 ProxyHandler
- **THEN** ProxyHandler SHALL 获取 clientProtocol 为 `anthropic`
#### Scenario: 静态资源路由
- **WHEN** 请求路径为 `/assets/*`
- **THEN** 返回嵌入的前端静态资源文件
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: PNG Favicon 路由
- **WHEN** 请求路径为 `/icon.png`
- **THEN** 返回来源于统一应用图标的 PNG favicon 资源
- **THEN** 请求 SHALL NOT 被协议代理路由处理
#### Scenario: SPA 路由回退
- **WHEN** 请求路径不匹配任何 API、协议代理或静态资源路由
- **THEN** 返回 `index.html`(支持前端 SPA 路由)
### Requirement: 端口冲突检测
系统 SHALL 在启动前检测启动配置中的 `server.port` 是否可用。
#### Scenario: 配置端口可用
- **WHEN** 启动配置中的 `server.port` 未被占用
- **THEN** 服务正常启动
#### Scenario: 配置端口被占用
- **WHEN** 启动配置中的 `server.port` 已被其他程序占用
- **THEN** 显示错误提示"端口 <server.port> 已被占用"
- **AND** 应用退出
### Requirement: 桌面配置源隔离和启动快照
desktop SHALL 仅在启动时从默认配置文件 `~/.nex/config.yaml` 和默认值加载运行时配置,并将加载结果作为本次运行的 immutable runtime snapshot。
#### Scenario: Desktop 仅使用默认配置文件
- **WHEN** desktop 启动
- **THEN** SHALL 从 `~/.nex/config.yaml` 加载配置
- **THEN** SHALL 在配置文件不存在时使用默认值
- **THEN** SHALL 使用默认值补齐配置文件未设置的配置项
#### Scenario: Desktop 不支持 CLI 配置源
- **WHEN** desktop 启动时传入 `--server-port 9000``--database-path /tmp/test.db``--config /tmp/custom.yaml`
- **THEN** SHALL 忽略这些参数
- **THEN** SHALL NOT 将这些参数应用到运行时配置
- **THEN** SHALL NOT 使用 `--config` 指定的配置文件路径
#### Scenario: Desktop 不支持环境变量配置源
- **WHEN** desktop 启动环境中存在 `NEX_SERVER_PORT``NEX_DATABASE_PATH``NEX_LOG_LEVEL` 或其他 `NEX_*` 环境变量
- **THEN** SHALL NOT 将这些环境变量应用到运行时配置
- **THEN** SHALL 使用默认配置文件和默认值确定运行时配置
#### Scenario: Desktop 忽略未知启动参数
- **WHEN** desktop 启动时传入未知命令行参数
- **THEN** SHALL NOT 因未知参数导致配置加载失败
- **THEN** SHALL 继续使用默认配置文件和默认值加载配置
#### Scenario: 配置文件修改仅下次启动生效
- **WHEN** desktop 已启动并正在处理请求
- **AND** 用户修改 `~/.nex/config.yaml` 中的 `server.port``database.*``log.*` 或 timeout 配置
- **THEN** 当前运行中的 desktop SHALL NOT 重新加载配置文件
- **THEN** 当前运行中的 HTTP server、数据库连接、日志器和请求处理 SHALL NOT 因配置文件修改而重建或中断
- **THEN** 修改后的配置 SHALL 在下一次 desktop 启动时生效
#### Scenario: 配置文件无效
- **WHEN** desktop 启动时 `~/.nex/config.yaml` 存在但内容无法解析或验证失败
- **THEN** SHALL 显示包含配置文件路径和失败原因的错误提示
- **THEN** SHALL 退出应用
- **THEN** SHALL NOT 静默回退默认配置继续启动
### Requirement: Desktop 前端同源 API 访问
desktop 内嵌前端 SHALL 使用同源相对路径访问后端 API不主动发现、缓存或覆盖 desktop 端口。
#### Scenario: 同源 API 请求
- **WHEN** desktop 浏览器页面打开在 `http://localhost:<server.port>`
- **THEN** 前端 SHALL 使用 `/api/*``/openai/*``/anthropic/*` 等相对路径访问同一 origin
- **THEN** 前端 SHALL NOT 硬编码 desktop 端口
#### Scenario: 重启后新端口访问
- **WHEN** 用户修改配置文件中的 `server.port` 并重启 desktop
- **THEN** desktop SHALL 按新端口启动并打开 `http://localhost:<new-port>`
- **THEN** 前端 SHALL 继续使用当前 origin 的相对路径访问 API
- **THEN** 前端 SHALL NOT 扫描端口、读取本地缓存端口或请求端口发现接口
### Requirement: 跨平台构建
系统 SHALL 支持跨平台构建和打包。构建 target SHALL 按平台和架构分离或参数化,中间构建产物文件名 SHALL 保持可区分目标平台和架构,最终桌面发布资产文件名 SHALL 包含统一版本号、组件、平台、架构和格式信息。
#### Scenario: macOS 构建
- **WHEN** 执行 macOS desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 macOS arm64 和 amd64 桌面可执行文件
- **AND** 系统 SHALL 使用 `lipo` 生成 macOS universal 桌面可执行文件
- **AND** 系统 SHALL 生成可打包为 `.app` bundle 的 macOS desktop 产物
- **AND** 最终 macOS desktop 发布资产文件名 SHALL 包含 `1.2.3``macos``universal`
#### Scenario: Windows 构建
- **WHEN** 执行 Windows desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 Windows amd64 desktop 可执行文件
- **AND** Windows desktop 构建 SHALL 使用 `-H=windowsgui` linker flag 隐藏控制台窗口
- **AND** 最终 Windows desktop 发布资产文件名 SHALL 包含 `1.2.3``windows``amd64`
#### Scenario: Linux 构建
- **WHEN** 执行 Linux desktop 构建命令且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 Linux amd64 和 arm64 desktop 可执行文件
- **AND** Linux desktop 构建 SHALL 使用 CGO 和 GTK/AppIndicator 构建依赖
- **AND** 最终 Linux desktop 发布资产文件名 SHALL 包含 `1.2.3``linux` 和对应架构标识
### Requirement: Linux 桌面发布安装包
系统 SHALL 为 Linux desktop amd64 和 arm64 生成 tar.gz、AppImage、deb 和 rpm 发布安装包,并 SHALL 在安装包中包含标准桌面集成元数据。
#### Scenario: Linux desktop tar.gz 裸包
- **WHEN** 构建 Linux desktop 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.tar.gz`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.tar.gz`
#### Scenario: Linux desktop AppImage 包
- **WHEN** 构建 Linux desktop AppImage 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.AppImage`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.AppImage`
- **AND** AppImage SHALL 包含 desktop entry、应用图标和 desktop 可执行文件
- **AND** AppImage SHALL 依赖目标系统提供 GTK3、Ayatana AppIndicator 和运行 AppImage 所需的 runtime/FUSE 能力
#### Scenario: Linux desktop deb 包
- **WHEN** 构建 Linux desktop deb 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.deb`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.deb`
- **AND** deb 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** deb 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** deb 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** deb 包 SHALL 声明 `libgtk-3-0``libayatana-appindicator3-1``xdg-utils` 运行时依赖
- **AND** deb 包 metadata 的架构字段 SHALL 使用 `amd64``arm64`
#### Scenario: Linux desktop rpm 包
- **WHEN** 构建 Linux desktop rpm 发布资产
- **THEN** 系统 SHALL 为 amd64 生成 `nex-desktop_<version>_linux_amd64.rpm`
- **AND** 系统 SHALL 为 arm64 生成 `nex-desktop_<version>_linux_arm64.rpm`
- **AND** rpm 包 SHALL 将可执行文件安装到 `/usr/bin/nex`
- **AND** rpm 包 SHALL 将 desktop entry 安装到 `/usr/share/applications/nex.desktop`
- **AND** rpm 包 SHALL 将 hicolor 图标安装到 `/usr/share/icons/hicolor/.../apps/nex.png`
- **AND** rpm 包 SHALL 声明 `gtk3``libayatana-appindicator-gtk3``xdg-utils` 运行时依赖
- **AND** rpm 包 metadata 的架构字段 SHALL 使用 `x86_64``aarch64`
### Requirement: macOS DMG 打包
系统 SHALL 为 macOS desktop universal `.app` 生成 unsigned DMG 安装包,并 SHALL 保留 universal zip 发布资产。
#### Scenario: macOS universal zip 包
- **WHEN** 构建 macOS desktop 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.zip`
- **AND** zip 包 SHALL 包含 `Nex.app`
#### Scenario: macOS universal DMG 包
- **WHEN** 构建 macOS desktop DMG 发布资产且当前版本为 `1.2.3`
- **THEN** 系统 SHALL 生成 `nex-desktop_1.2.3_macos_universal.dmg`
- **AND** DMG SHALL 包含 `Nex.app`
- **AND** DMG SHALL 包含指向 `/Applications` 的快捷方式
- **AND** DMG SHALL NOT 要求 macOS 签名或 notarization 才能完成构建
#### Scenario: macOS universal 架构校验
- **WHEN** macOS desktop universal 可执行文件生成完成
- **THEN** 系统 SHALL 验证该可执行文件包含 amd64 和 arm64 架构
### Requirement: macOS .app 打包
系统 SHALL 支持打包为 macOS `.app` bundle并使 bundle 元数据中的版本字段来源于统一版本号而非硬编码值。
#### Scenario: .app 结构
- **WHEN** 执行 macOS 桌面打包脚本
- **THEN** 生成 `Nex.app` 目录结构
- **AND** 包含 `Contents/Info.plist` 元数据
- **AND** 包含 `Contents/MacOS/nex` 可执行文件
- **AND** 包含 `Contents/Resources/icon.icns` 图标
- **AND** `Info.plist``LSUIElement``true`(不显示 Dock 图标)
#### Scenario: bundle 版本元数据同步
- **WHEN** 当前统一版本号为 `1.2.3`
- **THEN** `Info.plist``CFBundleShortVersionString` SHALL 为 `1.2.3`
- **AND** `Info.plist``CFBundleVersion` SHALL 为 `1.2.3`
- **AND** 打包流程 SHALL NOT 使用硬编码版本值
### Requirement: Windows 原生对话框
系统 SHALL 在 Windows 上使用 `user32.dll``MessageBoxW` API 显示错误对话框,替代 `msg *` 命令。
#### Scenario: 错误提示对话框
- **WHEN** 应用在 Windows 上遇到启动错误(端口占用、配置加载失败等)
- **THEN** 使用 `MessageBoxW` 显示模态对话框
- **AND** 对话框标题栏显示应用名称
- **AND** 对话框包含错误描述文本
- **AND** 对话框显示错误图标MB_ICONERROR
#### 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 正确显示多行文本
### Requirement: 桌面应用打包迁移资源
桌面应用 SHALL 在打包安装后仍能访问数据库迁移资源,并 SHALL 在首次启动时完成数据库初始化和迁移。
#### Scenario: 打包安装后首次启动执行迁移
- **WHEN** 用户从 macOS DMG 安装并首次启动 `Nex.app`
- **THEN** 系统 SHALL 初始化默认配置和数据库
- **THEN** 系统 SHALL 使用打包在应用内的迁移资源执行 SQLite 迁移
- **THEN** 系统 SHALL NOT 尝试访问构建机源码路径或仓库源码路径
- **THEN** 系统 SHALL 成功启动后端服务、托盘和管理界面
#### Scenario: .app 包含运行时必需迁移资源
- **WHEN** 执行 macOS 桌面打包脚本
- **THEN** `Nex.app` SHALL 包含启动后端服务所需的数据库迁移资源
- **THEN** 迁移资源 SHALL 随应用移动到任意安装位置后仍可用
- **THEN** `.app` SHALL NOT 依赖构建目录、源码目录或 GitHub Actions runner 路径
#### Scenario: DMG 安装后运行时资源完整
- **WHEN** 用户从 DMG 将 `Nex.app` 拖入 `/Applications` 并启动
- **THEN** 应用 SHALL 能访问数据库迁移资源
- **THEN** 应用 SHALL NOT 因 `migrations/sqlite``migrations/mysql` 文件系统目录不存在而启动失败