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.
This commit is contained in:
@@ -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");
|
||||
|
||||
463
docs/Launch-At-Startup-Migration-Plan.md
Normal file
463
docs/Launch-At-Startup-Migration-Plan.md
Normal file
@@ -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`,同时避免出现“只做了开关外观、没有真正接到系统登录项”的伪完成状态。
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]: [],
|
||||
|
||||
111
electron/service/launch-at-startup/index.ts
Normal file
111
electron/service/launch-at-startup/index.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const launchAtStartup = configManager.get<boolean>(CONFIG_KEYS.LAUNCH_AT_STARTUP);
|
||||
await applyLaunchAtStartupSetting(Boolean(launchAtStartup));
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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: '状態',
|
||||
|
||||
@@ -10,8 +10,10 @@ import ToggleSwitch from './ToggleSwitch';
|
||||
type GeneralSettingsPanelProps = {
|
||||
themeMode: ThemeMode;
|
||||
language: LanguageCode;
|
||||
launchAtStartup: boolean;
|
||||
onThemeChange: (theme: ThemeMode) => void | Promise<void>;
|
||||
onLanguageChange: (language: LanguageCode) => void | Promise<void>;
|
||||
onLaunchAtStartupChange: (enabled: boolean) => void | Promise<void>;
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 box-border flex w-full items-center justify-between gap-4 border-b border-dashed border-[#E5E8EE] py-5 dark:border-gray-700">
|
||||
<div>
|
||||
<div className="mb-1 text-[16px] text-[#171717] dark:text-gray-100">
|
||||
{t('settings.general.launchAtStartupTitle')}
|
||||
</div>
|
||||
<div className="text-[14px] text-[#99A0AE] dark:text-gray-500">
|
||||
{t('settings.general.launchAtStartupDescription')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch
|
||||
checked={launchAtStartup}
|
||||
onChange={(nextValue) => {
|
||||
void onLaunchAtStartupChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
|
||||
{t('settings.general.gatewayTitle')}
|
||||
|
||||
@@ -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() {
|
||||
<GeneralSettingsPanel
|
||||
themeMode={themeMode}
|
||||
language={language}
|
||||
launchAtStartup={launchAtStartup}
|
||||
onThemeChange={handleThemeChange}
|
||||
onLanguageChange={handleLanguageChange}
|
||||
onLaunchAtStartupChange={handleLaunchAtStartupChange}
|
||||
gatewayState={gatewayState}
|
||||
updateState={updateState}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SettingsState> {
|
||||
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<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage),
|
||||
readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14),
|
||||
readConfigValue<boolean>(CONFIG_KEYS.MINIMIZE_TO_TRAY, false),
|
||||
readConfigValue<boolean>(CONFIG_KEYS.LAUNCH_AT_STARTUP, false),
|
||||
readConfigValue<string>(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null),
|
||||
@@ -231,6 +234,7 @@ async function hydrate(): Promise<SettingsState> {
|
||||
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<SettingsState> {
|
||||
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<SettingsState> {
|
||||
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<SettingsState> {
|
||||
return setLaunchAtStartup(launchAtStartup, persist);
|
||||
}
|
||||
|
||||
export async function updateGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
|
||||
return setGatewayAutoStart(gatewayAutoStart, persist);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
tests/general-settings-panel.test.tsx
Normal file
108
tests/general-settings-panel.test.tsx
Normal file
@@ -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(
|
||||
<GeneralSettingsPanel
|
||||
themeMode="system"
|
||||
language="zh"
|
||||
launchAtStartup={false}
|
||||
onThemeChange={async () => {}}
|
||||
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(
|
||||
<GeneralSettingsPanel
|
||||
themeMode="system"
|
||||
language="zh"
|
||||
launchAtStartup={false}
|
||||
onThemeChange={async () => {}}
|
||||
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);
|
||||
});
|
||||
});
|
||||
155
tests/launch-at-startup.test.ts
Normal file
155
tests/launch-at-startup.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user