From 301f7d33ed924e8d4ac0ed8aa328c171cfd7984f Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Mon, 20 Apr 2026 23:29:10 +0800 Subject: [PATCH] feat: implement launch at startup functionality in zn-ai - Added a new setting for "launch at startup" in the GeneralSettingsPanel. - Integrated the setting with the existing settings store and IPC mechanisms. - Implemented platform-specific logic for enabling/disabling startup behavior in the main process. - Created a new service for managing launch at startup settings, including Linux desktop entry creation. - Added unit tests for the new functionality and ensured existing tests are updated accordingly. - Updated i18n messages for the new setting in English, Chinese, and Japanese. --- dist-electron/main/main.js | 2 +- docs/Launch-At-Startup-Migration-Plan.md | 463 ++++++++++++++++++ electron/main.ts | 17 + electron/service/config-service/index.ts | 1 + electron/service/launch-at-startup/index.ts | 111 +++++ runtime-shared/lib/constants.ts | 1 + runtime-shared/lib/types.ts | 2 + src/i18n/messages.ts | 6 + .../components/GeneralSettingsPanel.tsx | 22 + src/pages/Setting/index.tsx | 9 +- src/stores/index.ts | 2 + src/stores/settings.ts | 26 +- src/types/runtime.ts | 2 + tests/general-settings-panel.test.tsx | 108 ++++ tests/launch-at-startup.test.ts | 155 ++++++ 15 files changed, 924 insertions(+), 3 deletions(-) create mode 100644 docs/Launch-At-Startup-Migration-Plan.md create mode 100644 electron/service/launch-at-startup/index.ts create mode 100644 tests/general-settings-panel.test.tsx create mode 100644 tests/launch-at-startup.test.ts diff --git a/dist-electron/main/main.js b/dist-electron/main/main.js index cbdcb48..e9661ca 100644 --- a/dist-electron/main/main.js +++ b/dist-electron/main/main.js @@ -1,6 +1,6 @@ "use strict"; require("electron"); -require("./main-Cjv0_4P-.js"); +require("./main-CRe21cho.js"); require("electron-squirrel-startup"); require("electron-log"); require("bytenode"); diff --git a/docs/Launch-At-Startup-Migration-Plan.md b/docs/Launch-At-Startup-Migration-Plan.md new file mode 100644 index 0000000..c785871 --- /dev/null +++ b/docs/Launch-At-Startup-Migration-Plan.md @@ -0,0 +1,463 @@ +# 开机自动启动功能复制计划 + +## 1. 目标与结论 + +目标是在 `zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` 中复制 `ClawX` 的“开机自动启动”能力,并把视觉位置排在“语言设置”后面、“网关设置”前面。 + +先给结论: + +- `ClawX` 的“开机自动启动”不是单纯的前端 `Switch`,而是“设置持久化 + 主进程 OS 级生效 + 启动时回放同步”的完整链路。 +- `zn-ai` 已经具备可复用的设置基础设施:`settingsStore`、`config-service`、`SET_CONFIG/GET_CONFIG` IPC、`/api/settings` 本地路由。 +- `zn-ai` 当前没有 `launchAtStartup` 配置键,也没有任何 OS 级自启动应用逻辑。 +- 这次复制不需要新增复杂的状态 hook、也不需要新增 preload 专用桥接;最小可行方案是复用现有设置存取链路,只补 `launchAtStartup` 配置模型和主进程副作用。 + +## 2. ClawX 现有实现拆解 + +### 2.1 渲染层 UI 结构 + +`ClawX/src/pages/Settings/index.tsx` 中,“开机自动启动”位于通用设置区块内,紧跟在语言设置后面,位于 Gateway 区块前: + +- 主题按钮组 +- 语言按钮组 +- `launchAtStartup` 开关 +- `Separator` +- Gateway 区块 + +对应 UI 代码位置: + +- `ClawX/src/pages/Settings/index.tsx:528` +- `ClawX/src/pages/Settings/index.tsx:545` + +交互特征: + +- 左侧是标题和说明文案。 +- 右侧是一个 `Switch`。 +- 没有额外保存按钮,属于“切换即保存”。 +- 没有单独 loading/error 状态,也没有回滚提示。 + +### 2.2 渲染层状态链路 + +`ClawX` 渲染层把 `launchAtStartup` 放在 `settings` store 中统一管理: + +- 字段定义:`ClawX/src/stores/settings.ts:19` +- 默认值:`ClawX/src/stores/settings.ts:72` +- 初始化从 `/api/settings` 拉取:`ClawX/src/stores/settings.ts:86` +- setter 里直接调用 `/api/settings/launchAtStartup`:`ClawX/src/stores/settings.ts:132` + +实现特征: + +- 先本地 `set({ launchAtStartup })`,再异步请求主进程。 +- 请求失败时静默吞掉,没有回滚。 +- 这和 `gatewayAutoStart`、`theme`、`language` 是同一类“轻量即改即存”模式。 + +### 2.3 i18n 文案 + +`ClawX` 使用 `settings.appearance` 下的两个键: + +- `launchAtStartup` +- `launchAtStartupDesc` + +对应位置: + +- 中文:`ClawX/src/i18n/locales/zh/settings.json:12` +- 英文:`ClawX/src/i18n/locales/en/settings.json:12` +- 日文:`ClawX/src/i18n/locales/ja/settings.json:12` + +### 2.4 主进程与 Host API 链路 + +`ClawX` 的主进程实现分成 4 层: + +1. 持久化配置 + - `ClawX/electron/utils/store.ts:24` + - `launchAtStartup` 被定义为 `AppSettings` 字段,默认值为 `false` + +2. 设置路由 + - `ClawX/electron/api/routes/settings.ts:27` + - `PUT /api/settings` + - `PUT /api/settings/launchAtStartup` + - `POST /api/settings/reset` + - 只要 patch/key 触达 `launchAtStartup`,都会执行 `syncLaunchAtStartupSettingFromStore()` + +3. OS 级应用逻辑 + - `ClawX/electron/main/launch-at-startup.ts:1` + - Windows/macOS:`app.setLoginItemSettings({ openAtLogin, openAsHidden: false })` + - Linux:写入或删除 `~/.config/autostart/clawx.desktop` + - 错误只记录日志,不向上抛出 + +4. 启动时同步 + - `ClawX/electron/main/index.ts:298` + - 应用启动后会从 store 读取 `launchAtStartup`,再回放到操作系统 + +### 2.5 测试覆盖 + +`ClawX` 已经对 OS 级逻辑做了单元测试: + +- `ClawX/tests/unit/launch-at-startup.test.ts` + +覆盖点: + +- Windows `setLoginItemSettings` +- macOS `setLoginItemSettings` +- Linux `.desktop` 文件创建/删除 +- 非支持平台不抛异常 + +## 3. zn-ai 当前现状 + +### 3.1 已有可复用能力 + +`zn-ai` 已经具备以下基础: + +- 通用设置 UI:`zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` +- 设置页容器:`zn-ai/src/pages/Setting/index.tsx` +- 自定义开关组件:`zn-ai/src/pages/Setting/components/ToggleSwitch.tsx` +- 全局设置 store:`zn-ai/src/stores/settings.ts` +- 配置持久化:`zn-ai/electron/service/config-service/index.ts` +- 通用设置本地路由:`zn-ai/electron/api/routes/settings.ts` +- 主进程初始化入口:`zn-ai/electron/main.ts` + +### 3.2 关键差异 + +和 `ClawX` 相比,`zn-ai` 当前的关键差异有 5 个: + +1. `launchAtStartup` 不在 `ConfigValueMap` 中 + - `zn-ai/src/types/runtime.ts` + - `zn-ai/runtime-shared/lib/constants.ts` + - `zn-ai/runtime-shared/lib/types.ts` + +2. `settingsStore` 只管理 `gatewayAutoStart`,没有 `launchAtStartup` + - `zn-ai/src/stores/settings.ts` + +3. `config-service` 默认配置里没有 `launchAtStartup` + - `zn-ai/electron/service/config-service/index.ts` + +4. 主进程没有 OS 级开机自启动服务 + - `zn-ai/electron` 下目前不存在 `launch-at-startup` 模块 + +5. `zn-ai` 的设置主写路径不是 Host API,而是 `SET_CONFIG` IPC + - `zn-ai/src/stores/settings.ts` + - `zn-ai/electron/service/config-service/index.ts` + +这意味着: + +- 不能只在 `/api/settings` 路由里补副作用。 +- 必须覆盖到 `SET_CONFIG` 这条实际写入通路,否则 UI 开关会“写进配置但不生效到系统”。 + +## 4. 复制范围与非目标 + +### 4.1 本次复制范围 + +只复制 `ClawX` 的这一项能力: + +- “开机自动启动”开关 +- 配套说明文案 +- 配置持久化 +- 主进程 OS 级应用 +- 应用启动时回放同步 + +### 4.2 明确不做的内容 + +这次不要把以下内容一起带进来: + +- `startMinimized` +- `minimizeToTray` +- Gateway 自动启动 +- 代理设置 +- 日志目录打开 +- 开发者模式 +- Telemetry + +原因: + +- 这些不是 `ClawX`“开机自动启动”功能的必要组成。 +- 一起迁移会把范围从“复制单一功能”扩大成“重新设计通用设置”。 + +## 5. zn-ai 的最小复制方案 + +### 5.1 UI 方案 + +在 `zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` 中,把新行插在语言设置后面: + +- 保持和现有设置页一致的 `border-b` 行布局 +- 左侧:标题 + 描述 +- 右侧:复用 `ToggleSwitch` + +建议结构: + +1. 主题设置 +2. 语言设置 +3. 开机自动启动 +4. Gateway +5. 更新 + +说明: + +- 不需要新建卡片。 +- 不需要新建 section header。 +- 不需要新建独立 hook。 +- 这项功能足够简单,沿用 `themeMode/language` 的 props 传递模式即可。 + +### 5.2 状态与设置层方案 + +推荐在 `zn-ai/src/stores/settings.ts` 中新增: + +- `launchAtStartup: boolean` +- `updateLaunchAtStartup(launchAtStartup: boolean, persist = true)` + +并在 `SettingsState`、`createInitialState()`、`hydrate()` 中补齐: + +- 读取 `CONFIG_KEYS.LAUNCH_AT_STARTUP` +- 默认值 `false` + +`GeneralSettingsPanel` 通过 props 收到: + +- `launchAtStartup` +- `onLaunchAtStartupChange` + +`Setting/index.tsx` 负责从 `useSettingsStore` 取值并透传。 + +### 5.3 配置模型方案 + +需要同时修改以下位置,保证 key 一致: + +- `zn-ai/runtime-shared/lib/constants.ts` +- `zn-ai/runtime-shared/lib/types.ts` +- `zn-ai/src/types/runtime.ts` +- `zn-ai/electron/service/config-service/index.ts` + +建议新增 key: + +- `launchAtStartup` + +默认值建议: + +- `false` + +原因: + +- 与 `ClawX` 保持一致。 +- 对桌面应用更保守,避免首次启动时直接注册系统自启动。 + +### 5.4 主进程方案 + +建议新增模块: + +- `zn-ai/electron/service/launch-at-startup/index.ts` + +职责直接对齐 `ClawX`,但把应用标识替换为 `zn-ai` 当前产品信息: + +- Windows/macOS + - `app.setLoginItemSettings({ openAtLogin: enabled, openAsHidden: false })` +- Linux + - 写入或删除 `~/.config/autostart/*.desktop` + - 不能直接照搬 `clawx.desktop` + - `Name`、`Comment`、桌面文件名需要改成 zn-ai 当前产品名 + +建议暴露两个函数: + +- `applyLaunchAtStartupSetting(enabled: boolean)` +- `syncLaunchAtStartupSettingFromStore()` + +### 5.5 副作用挂载位置 + +这是本次复制里最关键的实现点。 + +因为 `zn-ai` 当前 renderer 主要通过 `SET_CONFIG` IPC 写设置,所以副作用至少要覆盖两条路径: + +1. `SET_CONFIG` / `UPDATE_CONFIG` + - 位置:`zn-ai/electron/service/config-service/index.ts` + - 当 key 或 patch 触达 `launchAtStartup` 时,调用 `applyLaunchAtStartupSetting(...)` + +2. 应用启动回放 + - 位置:`zn-ai/electron/main.ts` + - `await configManager.init()` 之后调用 `syncLaunchAtStartupSettingFromStore()` + +一致性建议: + +- `zn-ai/electron/api/routes/settings.ts` 也补齐同样的副作用。 +- 虽然当前 UI 主通路不是走这个 route,但本地 Host API 应该与 IPC 行为一致。 + +## 6. 文件级改动清单 + +### 6.1 渲染层 + +- `zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` +- `zn-ai/src/pages/Setting/index.tsx` +- `zn-ai/src/stores/settings.ts` +- `zn-ai/src/i18n/messages.ts` +- `zn-ai/src/types/runtime.ts` + +### 6.2 共享常量与类型 + +- `zn-ai/runtime-shared/lib/constants.ts` +- `zn-ai/runtime-shared/lib/types.ts` + +### 6.3 主进程 + +- `zn-ai/electron/service/config-service/index.ts` +- `zn-ai/electron/service/launch-at-startup/index.ts`(新增) +- `zn-ai/electron/api/routes/settings.ts` +- `zn-ai/electron/main.ts` + +### 6.4 测试 + +- `zn-ai/tests/launch-at-startup.test.ts`(建议新增) + +## 7. 关键风险与处理建议 + +### 7.1 双写路径风险 + +风险: + +- `SET_CONFIG` IPC 和 `/api/settings` route 都能改配置。 +- 如果只给其中一条路径挂副作用,会出现“配置值正确但系统登录项没同步”的假成功。 + +处理: + +- 两条路径都挂同一个 helper。 + +### 7.2 Linux 桌面文件元数据风险 + +风险: + +- 直接复制 `ClawX` 的 `clawx.desktop`、`Name=ClawX` 会导致 Linux 桌面环境识别错误。 + +处理: + +- 使用 zn-ai 当前产品名、可执行路径和桌面文件名。 +- 优先通过 `app.getName()` / `process.execPath` 生成,而不是写死 ClawX 文案。 + +### 7.3 产品名文案不一致风险 + +风险: + +- `package.json` 中 `productName` 是 `NIANXX`,但前端文案里大量使用 `ZN-AI`。 + +处理: + +- 本次设置说明文案优先使用“应用”或“系统登录后自动启动”这种泛化表达。 +- 不在这次迁移里额外引入品牌命名统一改造。 + +### 7.4 静默失败体验风险 + +风险: + +- `ClawX` 的写法对失败是静默吞掉,用户可能看不出 OS 级注册失败。 + +处理: + +- 第一版可以保持与 `ClawX` 相同的轻量体验。 +- 如果后续需要提高可观测性,再补 toast 或错误文案,但这不属于这次最小复制范围。 + +## 8. 推荐实施顺序 + +### Phase 1:主进程能力补齐 + +1. 新增 `launchAtStartup` config key +2. 新增 `launch-at-startup` 服务 +3. 在 `config-service` 和 `/api/settings` 中挂副作用 +4. 在 `electron/main.ts` 启动时回放同步 + +### Phase 2:渲染层接入 + +1. `settingsStore` 增加 `launchAtStartup` +2. `Setting/index.tsx` 透传 prop +3. `GeneralSettingsPanel.tsx` 在语言设置后插入 UI +4. `messages.ts` 补三语文案 + +### Phase 3:验证 + +1. `typecheck` +2. `launch-at-startup` 单元测试 +3. 手动验证设置页切换 +4. 手动验证 Windows/macOS/Linux 的系统登录项行为 + +## 9. 最小测试集合 + +建议至少覆盖以下 4 类验证: + +1. 单元测试 + - Windows/macOS 调用 `app.setLoginItemSettings` + - Linux `.desktop` 创建/删除 + +2. 配置链路测试 + - 改写 `launchAtStartup` 后,`configManager` 中值已更新 + +3. 渲染层回归 + - 设置页中开关显示在语言设置后面 + - 开关值能和 store 状态联动 + +4. 启动同步验证 + - 应用启动后能读取持久化值并重新应用 OS 级自启动 + +## 10. sub-agent 估算与分工 + +### 10.1 推荐数量 + +推荐 `3` 个 sub-agent。 + +理由: + +- 这次复制的改动面天然分成“渲染层”“配置/状态链路”“主进程 OS 级服务”三块。 +- 三块之间耦合度低,适合并行推进。 +- 再往上拆会让协同成本高于收益。 + +### 10.2 分工建议 + +#### Worker 1:渲染层与 i18n + +负责文件: + +- `zn-ai/src/pages/Setting/components/GeneralSettingsPanel.tsx` +- `zn-ai/src/pages/Setting/index.tsx` +- `zn-ai/src/i18n/messages.ts` + +职责: + +- 在语言设置后插入 UI +- 接入 `launchAtStartup` prop +- 补齐中英日文案 +- 确保视觉风格与现有设置页一致 + +#### Worker 2:设置模型与配置链路 + +负责文件: + +- `zn-ai/src/stores/settings.ts` +- `zn-ai/src/types/runtime.ts` +- `zn-ai/runtime-shared/lib/constants.ts` +- `zn-ai/runtime-shared/lib/types.ts` + +职责: + +- 新增 `launchAtStartup` 配置键和类型 +- 在 store 中完成读取、更新、对外导出 +- 保证 renderer 能通过现有设置链路读写该值 + +#### Worker 3:主进程自启动服务与测试 + +负责文件: + +- `zn-ai/electron/service/launch-at-startup/index.ts` +- `zn-ai/electron/service/config-service/index.ts` +- `zn-ai/electron/api/routes/settings.ts` +- `zn-ai/electron/main.ts` +- `zn-ai/tests/launch-at-startup.test.ts` + +职责: + +- 实现跨平台 OS 级自启动逻辑 +- 把副作用挂到配置写入与启动同步节点 +- 补齐最小单元测试 + +## 11. 最终建议 + +这次复制应严格控制在“ClawX 的 launchAtStartup 单功能迁移”范围内推进。 + +建议落地原则: + +- UI 只加一行,不做新的设置分组。 +- 状态层只加一个布尔配置,不新建复杂 hook。 +- 主进程只加一个独立服务,不顺手扩散到别的系统设置功能。 +- 测试优先覆盖 OS 级副作用,而不是先写大量 UI 测试。 + +按这个方案推进,能以最小代价把 `ClawX` 的成熟能力复制到 `zn-ai`,同时避免出现“只做了开关外观、没有真正接到系统登录项”的伪完成状态。 diff --git a/electron/main.ts b/electron/main.ts index 724a5b5..cf71bde 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -14,6 +14,7 @@ import { onProviderChange } from '@electron/service/provider-api-service'; import { gatewayManager } from '@electron/gateway/manager'; import { dispatchLocalHostApi } from '@electron/api/router'; import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync'; +import { applyLaunchAtStartupSetting, syncLaunchAtStartupSettingFromConfig } from '@electron/service/launch-at-startup'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '@electron/utils/skill-config'; // 初始化 updater,确保在 app ready 之前或者之中注册好 IPC @@ -113,8 +114,24 @@ if (started) { app.whenReady().then(async () => { await configManager.init(); + await syncLaunchAtStartupSettingFromConfig(); await themeManager.init(); + let launchAtStartup = Boolean(configManager.get(CONFIG_KEYS.LAUNCH_AT_STARTUP)); + const stopLaunchAtStartupSync = configManager.onConfigChange((config) => { + const nextLaunchAtStartup = Boolean(config[CONFIG_KEYS.LAUNCH_AT_STARTUP]); + if (nextLaunchAtStartup === launchAtStartup) { + return; + } + + launchAtStartup = nextLaunchAtStartup; + void applyLaunchAtStartupSetting(nextLaunchAtStartup); + }); + + app.once('will-quit', () => { + stopLaunchAtStartupSync(); + }); + void ensureBuiltinSkillsInstalled().catch((error) => { log.warn('Failed to install built-in skills:', error); }); diff --git a/electron/service/config-service/index.ts b/electron/service/config-service/index.ts index 35edda5..3ea3450 100644 --- a/electron/service/config-service/index.ts +++ b/electron/service/config-service/index.ts @@ -16,6 +16,7 @@ const DEFAULT_CONFIG: AppConfig = { [CONFIG_KEYS.LANGUAGE]: 'zh', [CONFIG_KEYS.FONT_SIZE]: 14, [CONFIG_KEYS.MINIMIZE_TO_TRAY]: false, + [CONFIG_KEYS.LAUNCH_AT_STARTUP]: false, [CONFIG_KEYS.PROVIDER]: '', [CONFIG_KEYS.DEFAULT_MODEL]: null, [CONFIG_KEYS.SELECTED_CHANNELS]: [], diff --git a/electron/service/launch-at-startup/index.ts b/electron/service/launch-at-startup/index.ts new file mode 100644 index 0000000..2830a1c --- /dev/null +++ b/electron/service/launch-at-startup/index.ts @@ -0,0 +1,111 @@ +import { app } from 'electron'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { CONFIG_KEYS } from '@runtime/lib/constants'; +import configManager from '@electron/service/config-service'; +import { logManager } from '@electron/service/logger'; + +const LINUX_AUTOSTART_DIR = join('.config', 'autostart'); + +function getApplicationName(): string { + const applicationName = typeof app.getName === 'function' ? app.getName() : ''; + return applicationName.trim() || 'zn-ai'; +} + +function sanitizeDesktopFileName(name: string): string { + const normalized = name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'zn-ai'; +} + +function quoteDesktopArg(value: string): string { + if (!value) return '""'; + + const escaped = value.replace(/(["\\`$])/g, '\\$1'); + if (/[\s"'\\`$]/.test(value)) { + return `"${escaped}"`; + } + + return value; +} + +function getLinuxExecCommand(): string { + if (app.isPackaged) { + return quoteDesktopArg(process.execPath); + } + + const launchArgs = process.argv.slice(1).filter(Boolean); + const commandParts = [process.execPath, ...launchArgs].map(quoteDesktopArg); + return commandParts.join(' '); +} + +function getLinuxDesktopEntryPath(): string { + const desktopFileName = `${sanitizeDesktopFileName(getApplicationName())}.desktop`; + return join(app.getPath('home'), LINUX_AUTOSTART_DIR, desktopFileName); +} + +function getLinuxDesktopEntry(): string { + const applicationName = getApplicationName(); + + return [ + '[Desktop Entry]', + 'Type=Application', + 'Version=1.0', + `Name=${applicationName}`, + `Comment=${applicationName} desktop application`, + `Exec=${getLinuxExecCommand()}`, + 'Terminal=false', + 'Categories=Utility;', + 'X-GNOME-Autostart-enabled=true', + '', + ].join('\n'); +} + +async function applyLinuxLaunchAtStartup(enabled: boolean): Promise { + const targetPath = getLinuxDesktopEntryPath(); + + if (enabled) { + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, getLinuxDesktopEntry(), 'utf8'); + logManager.info(`Launch-at-startup enabled via desktop entry: ${targetPath}`); + return; + } + + await rm(targetPath, { force: true }); + logManager.info(`Launch-at-startup disabled and desktop entry removed: ${targetPath}`); +} + +function applyWindowsOrMacLaunchAtStartup(enabled: boolean): void { + app.setLoginItemSettings({ + openAtLogin: enabled, + openAsHidden: false, + }); + logManager.info(`Launch-at-startup ${enabled ? 'enabled' : 'disabled'} via login items`); +} + +export async function applyLaunchAtStartupSetting(enabled: boolean): Promise { + try { + if (process.platform === 'linux') { + await applyLinuxLaunchAtStartup(enabled); + return; + } + + if (process.platform === 'win32' || process.platform === 'darwin') { + applyWindowsOrMacLaunchAtStartup(enabled); + return; + } + + logManager.warn(`Launch-at-startup unsupported on platform: ${process.platform}`); + } catch (error) { + logManager.error(`Failed to apply launch-at-startup=${enabled}:`, error); + } +} + +export async function syncLaunchAtStartupSettingFromConfig(): Promise { + const launchAtStartup = configManager.get(CONFIG_KEYS.LAUNCH_AT_STARTUP); + await applyLaunchAtStartupSetting(Boolean(launchAtStartup)); +} diff --git a/runtime-shared/lib/constants.ts b/runtime-shared/lib/constants.ts index b3eacfd..71b2cef 100644 --- a/runtime-shared/lib/constants.ts +++ b/runtime-shared/lib/constants.ts @@ -81,6 +81,7 @@ export enum CONFIG_KEYS { LANGUAGE = 'language', FONT_SIZE = 'fontSize', MINIMIZE_TO_TRAY = 'minimizeToTray', + LAUNCH_AT_STARTUP = 'launchAtStartup', PROVIDER = 'provider', DEFAULT_MODEL = 'defaultModel', AUTO_CHECK_UPDATE = 'autoCheckUpdate', diff --git a/runtime-shared/lib/types.ts b/runtime-shared/lib/types.ts index ae423da..bd820c8 100644 --- a/runtime-shared/lib/types.ts +++ b/runtime-shared/lib/types.ts @@ -11,10 +11,12 @@ export interface IConfig { [CONFIG_KEYS.LANGUAGE]: 'zh' | 'en'; [CONFIG_KEYS.FONT_SIZE]: number; [CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean; + [CONFIG_KEYS.LAUNCH_AT_STARTUP]: boolean; [CONFIG_KEYS.PROVIDER]?: string; [CONFIG_KEYS.DEFAULT_MODEL]?: string | null; [CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean; [CONFIG_KEYS.AUTO_DOWNLOAD_UPDATE]?: boolean; + [CONFIG_KEYS.GATEWAY_AUTO_START]?: boolean; [CONFIG_KEYS.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>; [CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>; [CONFIG_KEYS.TASK_LIST]?: Task[]; diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 840ce67..d8b89f4 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -433,6 +433,8 @@ export const messages: I18nMessages = { description: 'Customize the look and feel of the application.', themeSection: 'Theme Settings', languageSection: 'Language', + launchAtStartupTitle: 'Launch at startup', + launchAtStartupDescription: 'Start the app automatically after you sign in', gatewayTitle: 'Gateway', gatewayDescription: 'View the current Gateway state and basic runtime controls.', gatewayStatusLabel: 'Status', @@ -911,6 +913,8 @@ export const messages: I18nMessages = { description: '自定义应用的外观与使用体验。', themeSection: '主题设置', languageSection: '语言', + launchAtStartupTitle: '开机自动启动', + launchAtStartupDescription: '登录系统后自动启动应用', gatewayTitle: '网关', gatewayDescription: '查看当前网关状态与基础运行控制。', gatewayStatusLabel: '状态', @@ -1389,6 +1393,8 @@ export const messages: I18nMessages = { description: 'アプリの見た目と操作感をカスタマイズします。', themeSection: 'テーマ設定', languageSection: '言語', + launchAtStartupTitle: 'システム起動時に自動起動', + launchAtStartupDescription: 'システムにログインした後、アプリを自動的に起動します', gatewayTitle: 'ゲートウェイ', gatewayDescription: '現在のゲートウェイ状態と基本的なランタイム操作を確認します。', gatewayStatusLabel: '状態', diff --git a/src/pages/Setting/components/GeneralSettingsPanel.tsx b/src/pages/Setting/components/GeneralSettingsPanel.tsx index 6eace64..15654a5 100644 --- a/src/pages/Setting/components/GeneralSettingsPanel.tsx +++ b/src/pages/Setting/components/GeneralSettingsPanel.tsx @@ -10,8 +10,10 @@ import ToggleSwitch from './ToggleSwitch'; type GeneralSettingsPanelProps = { themeMode: ThemeMode; language: LanguageCode; + launchAtStartup: boolean; onThemeChange: (theme: ThemeMode) => void | Promise; onLanguageChange: (language: LanguageCode) => void | Promise; + onLaunchAtStartupChange: (enabled: boolean) => void | Promise; gatewayState: GatewaySettingState; updateState: SettingUpdateState; }; @@ -83,8 +85,10 @@ function getGatewayStatusMeta( export default function GeneralSettingsPanel({ themeMode, language, + launchAtStartup, onThemeChange, onLanguageChange, + onLaunchAtStartupChange, gatewayState, updateState, }: GeneralSettingsPanelProps) { @@ -160,6 +164,24 @@ export default function GeneralSettingsPanel({ +
+
+
+ {t('settings.general.launchAtStartupTitle')} +
+
+ {t('settings.general.launchAtStartupDescription')} +
+
+ + { + void onLaunchAtStartupChange(nextValue); + }} + /> +
+
{t('settings.general.gatewayTitle')} diff --git a/src/pages/Setting/index.tsx b/src/pages/Setting/index.tsx index 9483e4d..484a08c 100644 --- a/src/pages/Setting/index.tsx +++ b/src/pages/Setting/index.tsx @@ -1,7 +1,7 @@ import { Suspense, lazy, useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useI18n } from '../../i18n'; -import { updateLanguage, updateThemeMode, useSettingsStore } from '../../stores'; +import { updateLanguage, updateLaunchAtStartup, updateThemeMode, useSettingsStore } from '../../stores'; import type { LanguageCode, ThemeMode } from '../../types/runtime'; import AccountSettingsPanel from './components/AccountSettingsPanel'; import GeneralSettingsPanel from './components/GeneralSettingsPanel'; @@ -18,6 +18,7 @@ export default function SettingPage() { const { t } = useI18n(); const themeMode = useSettingsStore((state) => state.themeMode); const language = useSettingsStore((state) => state.language); + const launchAtStartup = useSettingsStore((state) => state.launchAtStartup); const updateState = useSettingUpdateState(); const gatewayState = useGatewaySettingState(); const rawView = new URLSearchParams(location.search).get('view'); @@ -45,6 +46,10 @@ export default function SettingPage() { await updateLanguage(nextLanguage); }; + const handleLaunchAtStartupChange = async (nextValue: boolean) => { + await updateLaunchAtStartup(nextValue); + }; + const handleViewChange = (nextView: SettingView) => { if (nextView === currentView) return; @@ -93,8 +98,10 @@ export default function SettingPage() { diff --git a/src/stores/index.ts b/src/stores/index.ts index 45eef2d..6a96f05 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -12,6 +12,8 @@ export { updateLanguage as setLanguage, updateFontSize as setFontSize, updateMinimizeToTray as setMinimizeToTray, + updateLaunchAtStartup, + updateLaunchAtStartup as setLaunchAtStartup, updatePrimaryColor as setPrimaryColor, updateGatewayAutoStart, updateGatewayAutoStart as setGatewayAutoStart, diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 0e895b0..c33f3d2 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -27,6 +27,7 @@ export interface SettingsState { primaryColor: string; fontSize: number; minimizeToTray: boolean; + launchAtStartup: boolean; providerId: string | null; defaultModel: string | null; gatewayAutoStart: boolean; @@ -72,6 +73,7 @@ function createInitialState(): SettingsState { primaryColor: '#1677ff', fontSize: 14, minimizeToTray: false, + launchAtStartup: false, providerId: null, defaultModel: null, gatewayAutoStart: true, @@ -205,11 +207,12 @@ async function hydrate(): Promise { const systemTheme = detectSystemTheme(); const systemLanguage = detectSystemLanguage(); - const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel, gatewayAutoStart] = await Promise.all([ + const [themeMode, language, fontSize, minimizeToTray, launchAtStartup, primaryColor, providerId, defaultModel, gatewayAutoStart] = await Promise.all([ readThemeMode(), readConfigValue(CONFIG_KEYS.LANGUAGE, systemLanguage), readConfigValue(CONFIG_KEYS.FONT_SIZE, 14), readConfigValue(CONFIG_KEYS.MINIMIZE_TO_TRAY, false), + readConfigValue(CONFIG_KEYS.LAUNCH_AT_STARTUP, false), readConfigValue(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'), readConfigValue(CONFIG_KEYS.PROVIDER, null), readConfigValue(CONFIG_KEYS.DEFAULT_MODEL, null), @@ -231,6 +234,7 @@ async function hydrate(): Promise { primaryColor: primaryColor ?? '#1677ff', fontSize: fontSize ?? 14, minimizeToTray: Boolean(minimizeToTray), + launchAtStartup: Boolean(launchAtStartup), providerId: providerId ?? null, defaultModel: defaultModel ?? null, gatewayAutoStart: Boolean(gatewayAutoStart), @@ -306,6 +310,21 @@ async function setMinimizeToTray(minimizeToTray: boolean, persist = true): Promi return state; } +async function setLaunchAtStartup(launchAtStartup: boolean, persist = true): Promise { + const next = Boolean(launchAtStartup); + if (state.launchAtStartup === next && state.initialized) return state; + + patchState({ + launchAtStartup: next, + }); + + if (persist) { + await writeConfigValue(CONFIG_KEYS.LAUNCH_AT_STARTUP, next); + } + + return state; +} + async function setPrimaryColor(primaryColor: string, persist = true): Promise { const next = primaryColor || '#1677ff'; if (state.primaryColor === next && state.initialized) return state; @@ -353,6 +372,7 @@ export const settingsStore = { setLanguage, setFontSize, setMinimizeToTray, + setLaunchAtStartup, setPrimaryColor, setGatewayAutoStart, hostApiFetch, @@ -391,6 +411,10 @@ export async function updateMinimizeToTray(minimizeToTray: boolean, persist = tr return setMinimizeToTray(minimizeToTray, persist); } +export async function updateLaunchAtStartup(launchAtStartup: boolean, persist = true): Promise { + return setLaunchAtStartup(launchAtStartup, persist); +} + export async function updateGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise { return setGatewayAutoStart(gatewayAutoStart, persist); } diff --git a/src/types/runtime.ts b/src/types/runtime.ts index bc427d9..7b15a48 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -28,6 +28,7 @@ export const CONFIG_KEYS = { LANGUAGE: 'language', FONT_SIZE: 'fontSize', MINIMIZE_TO_TRAY: 'minimizeToTray', + LAUNCH_AT_STARTUP: 'launchAtStartup', PROVIDER: 'provider', DEFAULT_MODEL: 'defaultModel', GATEWAY_AUTO_START: 'gatewayAutoStart', @@ -46,6 +47,7 @@ export interface ConfigValueMap { language: LanguageCode; fontSize: number; minimizeToTray: boolean; + launchAtStartup: boolean; provider: string | null; defaultModel: string | null; gatewayAutoStart: boolean; diff --git a/tests/general-settings-panel.test.tsx b/tests/general-settings-panel.test.tsx new file mode 100644 index 0000000..badedaa --- /dev/null +++ b/tests/general-settings-panel.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setLocale } from '../src/i18n'; +import GeneralSettingsPanel from '../src/pages/Setting/components/GeneralSettingsPanel'; +import type { GatewaySettingState } from '../src/pages/Setting/useGatewaySettingState'; +import type { SettingUpdateState } from '../src/pages/Setting/useSettingUpdateState'; + +function createGatewayState(): GatewaySettingState { + return { + status: 'connected', + loading: false, + showLogs: false, + gatewayAutoStart: true, + gateway: { + ok: true, + status: 'connected', + initialized: true, + mode: null, + port: 18789, + pid: null, + }, + logContent: '', + statusLoading: false, + logLoading: false, + restarting: false, + autoStartUpdating: false, + error: null, + lastFetchedAt: null, + refreshStatus: async () => {}, + restartGateway: async () => {}, + toggleLogs: async () => {}, + loadLogs: async () => {}, + viewLogs: async () => {}, + setGatewayAutoStart: async () => {}, + }; +} + +function createUpdateState(): SettingUpdateState { + return { + status: 'idle', + currentVersion: '1.0.0', + updateInfo: null, + progress: null, + error: null, + autoCheckUpdate: true, + autoDownloadUpdate: false, + checkUpdate: async () => {}, + downloadUpdate: async () => {}, + installUpdate: async () => {}, + setAutoCheckUpdate: () => {}, + setAutoDownloadUpdate: () => {}, + }; +} + +describe('GeneralSettingsPanel', () => { + beforeEach(() => { + setLocale('zh'); + }); + + it('renders the launch-at-startup setting after language and before gateway', () => { + render( + {}} + onLanguageChange={async () => {}} + onLaunchAtStartupChange={async () => {}} + gatewayState={createGatewayState()} + updateState={createUpdateState()} + />, + ); + + const languageLabel = screen.getByText('语言'); + const launchLabel = screen.getByText('开机自动启动'); + const gatewayLabel = screen.getByText('网关'); + + expect(languageLabel.compareDocumentPosition(launchLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(launchLabel.compareDocumentPosition(gatewayLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('calls the launch-at-startup handler when the switch is toggled', () => { + const handleLaunchAtStartupChange = vi.fn(); + + render( + {}} + onLanguageChange={async () => {}} + onLaunchAtStartupChange={handleLaunchAtStartupChange} + gatewayState={createGatewayState()} + updateState={createUpdateState()} + />, + ); + + const launchLabel = screen.getByText('开机自动启动'); + const launchRow = launchLabel.parentElement?.parentElement; + expect(launchRow).toBeTruthy(); + + const launchSwitch = within(launchRow as HTMLElement).getByRole('switch'); + fireEvent.click(launchSwitch); + + expect(handleLaunchAtStartupChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/tests/launch-at-startup.test.ts b/tests/launch-at-startup.test.ts new file mode 100644 index 0000000..b6d87de --- /dev/null +++ b/tests/launch-at-startup.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const originalPlatform = process.platform; + +const { + configManagerMock, + electronAppMock, + loggerMock, + mkdirMock, + rmMock, + setLoginItemSettingsMock, + testHome, + writeFileMock, +} = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + const setLoginItemSettingsMock = vi.fn(); + const mkdirMock = vi.fn(); + const rmMock = vi.fn(); + const writeFileMock = vi.fn(); + const configManagerMock = { + get: vi.fn(), + }; + const loggerMock = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const electronAppMock = { + isPackaged: true, + getName: () => 'zn-ai', + getPath: (name: string) => (name === 'home' ? `/tmp/zn-ai-launch-startup-${suffix}` : '/tmp'), + setLoginItemSettings: setLoginItemSettingsMock, + }; + + return { + configManagerMock, + electronAppMock, + loggerMock, + mkdirMock, + rmMock, + setLoginItemSettingsMock, + testHome: `/tmp/zn-ai-launch-startup-${suffix}`, + writeFileMock, + }; +}); + +vi.mock('electron', () => ({ + app: electronAppMock, +})); + +vi.mock('@electron/service/config-service', () => ({ + default: configManagerMock, +})); + +vi.mock('@electron/service/logger', () => ({ + logManager: loggerMock, + default: loggerMock, +})); + +vi.mock('node:fs/promises', () => ({ + mkdir: mkdirMock, + rm: rmMock, + writeFile: writeFileMock, +})); + +vi.mock('node:path', () => ({ + dirname: (path: string) => path.slice(0, path.lastIndexOf('/')) || '/', + join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'), +})); + +function setPlatform(platform: string): void { + Object.defineProperty(process, 'platform', { value: platform, writable: true }); +} + +describe('launch-at-startup service', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + configManagerMock.get.mockReturnValue(false); + electronAppMock.isPackaged = true; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true }); + }); + + it('uses login item settings on Windows', async () => { + setPlatform('win32'); + const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup'); + + await applyLaunchAtStartupSetting(true); + + expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ + openAtLogin: true, + openAsHidden: false, + }); + }); + + it('uses login item settings on macOS', async () => { + setPlatform('darwin'); + const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup'); + + await applyLaunchAtStartupSetting(false); + + expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ + openAtLogin: false, + openAsHidden: false, + }); + }); + + it('creates and removes a Linux autostart desktop entry', async () => { + setPlatform('linux'); + const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup'); + const autostartPath = `${testHome}/.config/autostart/zn-ai.desktop`; + + await applyLaunchAtStartupSetting(true); + + expect(mkdirMock).toHaveBeenCalledWith(`${testHome}/.config/autostart`, { recursive: true }); + expect(writeFileMock).toHaveBeenCalledTimes(1); + expect(writeFileMock).toHaveBeenCalledWith( + autostartPath, + expect.stringContaining('[Desktop Entry]'), + 'utf8', + ); + expect(writeFileMock.mock.calls[0]?.[1]).toContain('Name=zn-ai'); + expect(writeFileMock.mock.calls[0]?.[1]).toContain('Exec='); + + await applyLaunchAtStartupSetting(false); + + expect(rmMock).toHaveBeenCalledWith(autostartPath, { force: true }); + }); + + it('syncs the persisted setting from config on startup', async () => { + setPlatform('win32'); + configManagerMock.get.mockReturnValue(true); + const { syncLaunchAtStartupSettingFromConfig } = await import('@electron/service/launch-at-startup'); + + await syncLaunchAtStartupSettingFromConfig(); + + expect(configManagerMock.get).toHaveBeenCalledWith('launchAtStartup'); + expect(setLoginItemSettingsMock).toHaveBeenCalledWith({ + openAtLogin: true, + openAsHidden: false, + }); + }); + + it('does not throw on unsupported platforms', async () => { + setPlatform('freebsd'); + const { applyLaunchAtStartupSetting } = await import('@electron/service/launch-at-startup'); + + await expect(applyLaunchAtStartupSetting(true)).resolves.toBeUndefined(); + }); +});