Skip to content

桌面端 ​

Infinia 桌面外壳是一个用 TypeScript 编写(主进程)的 Electron 43.x 应用。它的职责是进程监管:拉起 Java 后端、发现它的端口、驱动它从 SETUP 进入 APP 模式、把 UI 所需的凭据交给它,并在用户退出时把一切拆除。产品名为 Infinia,版本 4.0.0(见 desktop/electron/package.json,productName: "Infinia")。后端生命周期与之前的 Tauri 外壳保持不变——被替换的只是实现它的外壳本身。

开发版与发布版 ​

外壳的行为取决于是否已打包:

Profile后端窗口
Dev — 外部(默认;!app.isPackaged,无 env 或设置了 FENGyu_DEV_BACKEND)无——连接你自行启动的后端(IDE / mvn spring-boot:run)http://127.0.0.1:24056。不拉起、不生成 token、无监管。外部后端的 /api/health 可达后打开
Dev — 自拉起(!app.isPackaged,设置了 FENGyu_JAR 或 FENGyu_DEV_BACKEND=disabled)由外壳以 jar sidecar 方式拉起,使用 FENGyu_JAR 指向的 jar立即打开,加载 localhost:5173
Release(app.isPackaged)由外壳以 jar sidecar 方式拉起在后端健康后打开,加载内嵌的 SPA

默认情况下,yarn run dev 连接你在 IDE 中不带 --token= 启动的后端——此时 TokenAuthFilter 禁用认证,外壳传入空 token,与 SPA 的空 token 回退一致。外壳不拉起 java、不生成 token、不运行 SETUP→APP 监管;后端的生命周期由你掌控。如果你带 --token=<t> 启动了后端,也需设置 FENGyu_TOKEN=<t>。要指向其他端口,设置 FENGyu_DEV_BACKEND=http://127.0.0.1:<端口>。

要让外壳自行拉起后端(自包含开发),设置 FENGyu_JAR=<路径>(或 FENGyu_DEV_BACKEND=disabled):拉起 jar、生成单次启动 token、运行健康检查 + 监管——完整的发布生命周期,只是从开发版 Vite 服务器加载。该路径下 FENGyu_JAR 必填(未设置时外壳会抛出 Dev mode requires FENGyu_JAR...)。Vite 开发服务器(端口 5173)会把 /api 代理到当前生效的后端,开发者也可借此单独在浏览器中运行前端。在发布模式下,外壳端到端地掌管后端进程。

后端拉起(发布版) ​

发布版以一种固定的命令形式拉起打包好的 jar(从旧的 Rust 实现逐字移植):

bash
java -Dfengyu.plugins.official-directory=<plugins-dir> \
     -cp <jar> \
     fan.summer.fengyu.HeadlessLauncher \
     --port=24056 \
     --token=<t>

外壳读取子进程的 stdout 寻找 FENGYU_PORT=<n> 这一行,期限为 120 秒(可取消,因此缓慢启动期间关闭窗口不会挂起)。端口行要等到 WebServerInitializedEvent(即整个 Spring 上下文构建完毕)才会输出,慢速硬件(UOS 实测冷启动约 39 秒)可能远超半分钟,因此期限必须宽松;JVM 崩溃则通过 stdout 关闭立即失败,不会拖满期限。如果该行在期限内没有出现,启动即告失败。后端的 stdout/stderr 行会同步写入 <运行目录>/.fengyu/logs/backend-stdout.log。

上文的 <运行目录> 是外壳的运行时锚点(bootstrap-cwd.ts),打包版本按平台选取:

  • Windows —— 可执行文件所在目录:NSIS 安装版的安装根目录、便携 ZIP 的解压目录。整棵 .fengyu 树(配置、内嵌数据库、日志、插件、技能、聊天数据)随应用走,与 Web 发行版的 <解压目录>\data 一致。早期版本遗留在 %APPDATA%\fengyu-desktop\.fengyu 的运行时树会在首次 可写启动时自动迁移过去(同盘原子重命名;跨盘安装先复制到 .fengyu.migrating 暂存目录再原子 改名落位,中断的复制不会留下半成品树,下次启动从完好的旧树重试)。迁移只由持有单实例锁的 实例执行;安装目录不可写(如未提权的 Program Files)时保持原有的 userData 锚点,遗留树 不会被挪走。
  • macOS / Linux —— Electron 的 userData 目录(~/Library/Application Support/… / ~/.config/…)。macOS 的 .app 包内不能存放用户数据(zip 自动更新会整体替换 .app),而 Linux AppImage 的可执行路径是只读的临时 squashfs 挂载点。

开发运行保持自己的工作目录(desktop/electron/);UOS 版本则按下文所述重新锚定到用户主目录。Electron 自身的配置数据(浏览器分区、Local Storage、更新缓存)始终留在操作系统的用户数据目录(Windows 上为 %APPDATA%\fengyu-desktop)——移动的只是 FengYu 的运行时树。

Java 在运行时解析:带 JRE 版本优先使用 <resourcesPath>/jre/bin/java;不带 JRE 版本使用 PATH 中的 java。若找不到 java,外壳会弹出一个原生错误对话框并退出。

健康检查与初始化编排 ​

一旦端口已知,外壳会立即创建主窗口——渲染端加载(取包、解析、Vue 挂载)与 JVM 启动重叠进行,而不是排在它之后。SPA 在启动门控(App.vue)后挂载:整个外壳被自身的 /api/health 轮询挡住,首帧呈现骨架屏,后端应答后判定 SETUP 模式并启用功能。与此同时,外壳与渲染端加载并行地驱动后端经过三个阶段:

  1. wait_for_health——以 300 毫秒为间隔、每次请求 2 秒超时、总体 120 秒为期限,带上 X-FengYu-Token 头轮询 GET /api/health。只有 HTTP 200 才算就绪。使用 Node 24.18 内置的 fetch + AbortController。
  2. check_setup_mode——探测 GET /api/setup/status,以判断后端启动进入了 SETUP 还是 APP 模式(响应体含 "initialized":false → SETUP)。
  3. run_backend_until_app_mode——把整个循环串起来:拉起 →(创建窗口)→ 等待健康 → 检查初始化模式。等待期间后端退出会立即失败(不会让一个已死的 JVM 挂满整个期限)。如果后端处于 SETUP 模式,外壳会等待该进程以退出码 0(SETUP_DONE)退出,然后重新拉起后端,此时它会带着已生效的数据源以 APP 模式重新启动。重新拉起后,外壳会校验端口未改变、且后端已进入 APP 模式;任一不满足即视为致命错误。

前端 bridge(contextBridge) ​

外壳的 preload 脚本在页面加载前,通过 contextBridge 在 window.fengyu 上暴露一个受控的 API:

js
window.fengyu.apiBase()        // 'http://127.0.0.1:<port>'——只读快照
window.fengyu.token()          // 每次启动的 X-FengYu-Token——只读快照
window.fengyu.desktop          // true——特性标志
window.fengyu.initialTheme()   // 'dark' | 'light'——外壳在启动时确定的主题(避免闪烁)
window.fengyu.setupMode()      // boolean | null——预先探测的 setup 状态;首次启动为 null(SPA 在启动门控处自行探测),浏览器中也为 null
window.fengyu.setTheme(theme)  // 请求外壳持久化/应用主题
window.fengyu.pickFile(filters)   // → 原生打开对话框(IPC)
window.fengyu.pickDirectory()     // → 原生打开对话框(IPC)
window.fengyu.openExternal(url)   // → 在系统浏览器打开校验后的 http(s) URL(IPC)

apiBase/token 是在启动时捕获的只读快照。SPA 直接通过环回地址与后端通信——AI 对话的 SSE 流、文件上传、插件微前端宿主都需要原生的 fetch/EventSource/FormData,而 IPC 无法承载这些,因此令牌以快照形式暴露,而非隐藏在完整的 IPC 代理背后。该令牌每次启动重新生成、仅限环回地址,且后端无论如何都强制执行 endpoint ACL。这取代了旧的 Tauri window.__FENGYU_* 全局变量。Vue SPA 通过 connection store / config.ts 读取它们来配置每一次 API 调用。在普通浏览器中 window.fengyu 为 undefined,因此 Web 模式会回退到环境变量。见前端。

云账号登录使用 openExternal:无头后端启动 PKCE 尝试并返回 authorization URL,renderer 再请求 Electron 打开它。主进程会重新解析 URL,在调用 shell.openExternal 前拒绝除 http:、https: 以外的所有 scheme。普通浏览器模式则打开新标签页。

BrowserWindow 安全姿态: contextIsolation: true、nodeIntegration: false、sandbox: true、webSecurity: true(默认)——标准的 Electron 安全姿态。CSP 由后端的 SPA 响应头治理,主进程中不会设为 null。

桌面增强能力 ​

旧的 Tauri 外壳所不具备的四项能力:

  • 单实例锁——app.requestSingleInstanceLock()。再次启动会显示并聚焦已有窗口(也会从托盘恢复)。
  • 系统托盘——图标从旧外壳迁移而来;菜单:显示 / 隐藏 / 退出。驱动下文的关闭语义。
  • 文件日志——electron-log 把主进程日志写入 <运行目录>/.fengyu/logs/desktop.log(与后端日志同目录);后端的 stdout/stderr 同步写入 <运行目录>/.fengyu/logs/backend-stdout.log。内置按大小/日期滚动。
  • 自动更新——electron-updater,源为 GitHub Releases(latest*.yml 由 electron-builder 生成)。在 app.whenReady() 之后做非阻塞检查。自动安装(下载 + quitAndInstall)以已签名发行版为门禁(FENGYU_SIGNED_RELEASE=true,由未来的签名+公证构建注入)。当前构建为未签名,因此发现更新时只通知用户并提供打开手动下载页——绝不调用安装器,因为仅凭 GitHub feed 无法校验发布者(尚无 OS 代码签名 / macOS 公证)。

关停语义(已变更——重要) ​

由于引入了托盘,后端的生命周期现在绑定到应用退出,而非窗口关闭:

动作Tauri(旧)Electron(新)
窗口关闭按钮杀死后端并退出隐藏到托盘,后端保持存活
托盘「退出」/ Cmd+Q / Alt+F4不适用杀死后端(SIGTERM,兜底 SIGKILL)并退出

主进程在 before-quit 事件(而非 window.on('close'))时杀死后端。close 处理器在应用并非真正退出时会调用 preventDefault() + window.hide()。

窗口与对话框集成 ​

  • 窗口尺寸: 1280 × 820,最小 960 × 640(与之前的外壳一致)。
  • 原生对话框: pickFile / pickDirectory 通过 IPC 走 Electron 的原生对话框,并暴露在 window.fengyu 上;前端通过 desktop.ts 外观来访问它们。

macOS 标题栏对齐不变量 ​

渲染器接管的窗口栏高度为 48 px。在 macOS 上,原生红绿灯、侧栏折叠按钮与路由工具栏 控件必须共用 y = 24 中心线。desktop/electron/src/window/create-window.ts 中必须保留以下 BrowserWindow 组合:

ts
frame: false,
titleBarStyle: 'hidden',

win.setWindowButtonVisibility(true)
win.setWindowButtonPosition({ x: 14, y: 18 })

不要把它简化为 frame: false 加 setWindowButtonVisibility(true)。使用默认标题栏样式时, Electron 43 不会创建原生 WindowButtonsProxy;此时位置 API 虽会保存坐标,却没有代理负责 重绘,红绿灯仍停留在系统默认高度。titleBarStyle: 'hidden' 用来初始化该代理,而 frame: false 仍保留完全无边框的渲染器及其可交互 HTML 控件。

调用顺序同样是有意的:先恢复可见性,再应用坐标。当前 macOS 版本中的原生可见性更新可能 重新布局或重置按钮 frame。y = 18 的顶端内缩会把 12 px 原生按钮的中心放在 y = 24; 28 px 的 HTML 折叠按钮使用 top: 10px,因此中心线相同。

修改这项约束前,必须运行两组聚焦检查并观察真实 macOS 窗口——裸 JAR 冒烟测试不会经过 Electron 原生窗口装饰:

bash
cd desktop/electron
yarn build:ts
yarn vitest run test/window-open-handler.test.ts

cd ../../frontend
yarn node --test test/sidebar-collapse.test.mjs

cd ../desktop/electron
yarn run dev  # IDE 后端运行于 :24056 时启动,并观察激活状态下的窗口

打包 ​

打包由 electron-builder 处理(desktop/electron/electron-builder.yml)。每个平台发布两种安装包变体,由 CI 的 --config 覆盖从同一份基础配置构建。产物遵循统一命名 <product>-<version>-<platform>-<arch>[<form>].<ext>(例如 Infinia-4.0.0-mac-arm64.dmg、Infinia-4.0.0-win-x64-setup.exe):

平台不带 JRE(lite)带 JRE(自包含)
macOS(arm64)Infinia-<ver>-mac-arm64.dmgInfinia-<ver>-mac-arm64-jre.dmg
Windows(x64)Infinia-<ver>-win-x64-setup.exe(NSIS)+ *-portable.zipInfinia-<ver>-win-x64-setup-jre.exe + *-portable-jre.zip
Linux(x64)Infinia-<ver>-linux-x64.AppImage + .debInfinia-<ver>-linux-x64-jre.AppImage

Windows 的便携版是解压即用的 ZIP(解压后直接运行 Infinia.exe)——无需安装,启动时也无需自解压。带 JRE 的变体在 <resources>/jre/ 下内嵌一个 jlink 最小化的 JRE(由 CI 从 JDK 21 通过 jdeps + jlink --strip-debug 生成)。Alpha 构建为未签名。

另有仅 Linux 的 UOS(统信)变体,产物为 Infinia-UOS-<ver>-linux-x64.AppImage + .deb(desktop/electron/electron-builder.uos.yml,基于 JRE、自包含)。它的启动入口在真实命令行上以 --no-sandbox 启动 Electron(linux.executableArgs 把该开关写入 deb 的 /usr/share/applications/infinia-uos.desktop 菜单快捷方式以及 AppImage 内嵌的 desktop 文件)——UOS 上 Chromium 依据进程 argv 决定沙箱行为,仅靠 JS 内添加开关并不可靠。deb 升级会覆盖旧版安装的无参数菜单快捷方式,postinst(scripts/uos-deb-postinstall.sh)刷新 desktop 数据库使菜单立即生效、无需重新登录。该变体同时把 fengyu.uos: true 烙入包元数据;主进程(src/desktop/uos.ts)检测到该标志即追加进程内 appendSwitch 兜底(覆盖绕过 desktop 文件的直接启动),并把工作目录重定向到用户主目录——UOS 非 root 环境严禁启动任何 OS 级沙箱,且从菜单启动时初始工作目录不可写。渲染进程自身的加固(webPreferences.sandbox、contextIsolation)不受影响。

下一步 ​

  • 后端——sidecar 实际在运行什么,以及外壳所驱动的 SETUP/APP 模式。
  • 前端——SPA 如何消费 window.fengyu bridge。
  • 快速开始——cd desktop/electron && yarn run dev 与 yarn run build。

Released under the GPL-3.0 License.