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:
duanshuwen
2026-04-20 23:29:10 +08:00
parent 35319e6a1d
commit 301f7d33ed
15 changed files with 924 additions and 3 deletions

View File

@@ -1,6 +1,6 @@
"use strict"; "use strict";
require("electron"); require("electron");
require("./main-Cjv0_4P-.js"); require("./main-CRe21cho.js");
require("electron-squirrel-startup"); require("electron-squirrel-startup");
require("electron-log"); require("electron-log");
require("bytenode"); require("bytenode");

View 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`,同时避免出现“只做了开关外观、没有真正接到系统登录项”的伪完成状态。

View File

@@ -14,6 +14,7 @@ import { onProviderChange } from '@electron/service/provider-api-service';
import { gatewayManager } from '@electron/gateway/manager'; import { gatewayManager } from '@electron/gateway/manager';
import { dispatchLocalHostApi } from '@electron/api/router'; import { dispatchLocalHostApi } from '@electron/api/router';
import { syncProviderRuntimeSnapshot } from '@electron/service/provider-runtime-sync'; 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'; import { ensureBuiltinSkillsInstalled, ensurePreinstalledSkillsInstalled } from '@electron/utils/skill-config';
// 初始化 updater确保在 app ready 之前或者之中注册好 IPC // 初始化 updater确保在 app ready 之前或者之中注册好 IPC
@@ -113,8 +114,24 @@ if (started) {
app.whenReady().then(async () => { app.whenReady().then(async () => {
await configManager.init(); await configManager.init();
await syncLaunchAtStartupSettingFromConfig();
await themeManager.init(); 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) => { void ensureBuiltinSkillsInstalled().catch((error) => {
log.warn('Failed to install built-in skills:', error); log.warn('Failed to install built-in skills:', error);
}); });

View File

@@ -16,6 +16,7 @@ const DEFAULT_CONFIG: AppConfig = {
[CONFIG_KEYS.LANGUAGE]: 'zh', [CONFIG_KEYS.LANGUAGE]: 'zh',
[CONFIG_KEYS.FONT_SIZE]: 14, [CONFIG_KEYS.FONT_SIZE]: 14,
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: false, [CONFIG_KEYS.MINIMIZE_TO_TRAY]: false,
[CONFIG_KEYS.LAUNCH_AT_STARTUP]: false,
[CONFIG_KEYS.PROVIDER]: '', [CONFIG_KEYS.PROVIDER]: '',
[CONFIG_KEYS.DEFAULT_MODEL]: null, [CONFIG_KEYS.DEFAULT_MODEL]: null,
[CONFIG_KEYS.SELECTED_CHANNELS]: [], [CONFIG_KEYS.SELECTED_CHANNELS]: [],

View 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));
}

View File

@@ -81,6 +81,7 @@ export enum CONFIG_KEYS {
LANGUAGE = 'language', LANGUAGE = 'language',
FONT_SIZE = 'fontSize', FONT_SIZE = 'fontSize',
MINIMIZE_TO_TRAY = 'minimizeToTray', MINIMIZE_TO_TRAY = 'minimizeToTray',
LAUNCH_AT_STARTUP = 'launchAtStartup',
PROVIDER = 'provider', PROVIDER = 'provider',
DEFAULT_MODEL = 'defaultModel', DEFAULT_MODEL = 'defaultModel',
AUTO_CHECK_UPDATE = 'autoCheckUpdate', AUTO_CHECK_UPDATE = 'autoCheckUpdate',

View File

@@ -11,10 +11,12 @@ export interface IConfig {
[CONFIG_KEYS.LANGUAGE]: 'zh' | 'en'; [CONFIG_KEYS.LANGUAGE]: 'zh' | 'en';
[CONFIG_KEYS.FONT_SIZE]: number; [CONFIG_KEYS.FONT_SIZE]: number;
[CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean; [CONFIG_KEYS.MINIMIZE_TO_TRAY]: boolean;
[CONFIG_KEYS.LAUNCH_AT_STARTUP]: boolean;
[CONFIG_KEYS.PROVIDER]?: string; [CONFIG_KEYS.PROVIDER]?: string;
[CONFIG_KEYS.DEFAULT_MODEL]?: string | null; [CONFIG_KEYS.DEFAULT_MODEL]?: string | null;
[CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean; [CONFIG_KEYS.AUTO_CHECK_UPDATE]?: boolean;
[CONFIG_KEYS.AUTO_DOWNLOAD_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.SELECTED_CHANNELS]: Array<{ id: string; channelName: string; channelUrl: string }>;
[CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>; [CONFIG_KEYS.IMAGE_CACHE]: Array<[string, any]>;
[CONFIG_KEYS.TASK_LIST]?: Task[]; [CONFIG_KEYS.TASK_LIST]?: Task[];

View File

@@ -433,6 +433,8 @@ export const messages: I18nMessages = {
description: 'Customize the look and feel of the application.', description: 'Customize the look and feel of the application.',
themeSection: 'Theme Settings', themeSection: 'Theme Settings',
languageSection: 'Language', languageSection: 'Language',
launchAtStartupTitle: 'Launch at startup',
launchAtStartupDescription: 'Start the app automatically after you sign in',
gatewayTitle: 'Gateway', gatewayTitle: 'Gateway',
gatewayDescription: 'View the current Gateway state and basic runtime controls.', gatewayDescription: 'View the current Gateway state and basic runtime controls.',
gatewayStatusLabel: 'Status', gatewayStatusLabel: 'Status',
@@ -911,6 +913,8 @@ export const messages: I18nMessages = {
description: '自定义应用的外观与使用体验。', description: '自定义应用的外观与使用体验。',
themeSection: '主题设置', themeSection: '主题设置',
languageSection: '语言', languageSection: '语言',
launchAtStartupTitle: '开机自动启动',
launchAtStartupDescription: '登录系统后自动启动应用',
gatewayTitle: '网关', gatewayTitle: '网关',
gatewayDescription: '查看当前网关状态与基础运行控制。', gatewayDescription: '查看当前网关状态与基础运行控制。',
gatewayStatusLabel: '状态', gatewayStatusLabel: '状态',
@@ -1389,6 +1393,8 @@ export const messages: I18nMessages = {
description: 'アプリの見た目と操作感をカスタマイズします。', description: 'アプリの見た目と操作感をカスタマイズします。',
themeSection: 'テーマ設定', themeSection: 'テーマ設定',
languageSection: '言語', languageSection: '言語',
launchAtStartupTitle: 'システム起動時に自動起動',
launchAtStartupDescription: 'システムにログインした後、アプリを自動的に起動します',
gatewayTitle: 'ゲートウェイ', gatewayTitle: 'ゲートウェイ',
gatewayDescription: '現在のゲートウェイ状態と基本的なランタイム操作を確認します。', gatewayDescription: '現在のゲートウェイ状態と基本的なランタイム操作を確認します。',
gatewayStatusLabel: '状態', gatewayStatusLabel: '状態',

View File

@@ -10,8 +10,10 @@ import ToggleSwitch from './ToggleSwitch';
type GeneralSettingsPanelProps = { type GeneralSettingsPanelProps = {
themeMode: ThemeMode; themeMode: ThemeMode;
language: LanguageCode; language: LanguageCode;
launchAtStartup: boolean;
onThemeChange: (theme: ThemeMode) => void | Promise<void>; onThemeChange: (theme: ThemeMode) => void | Promise<void>;
onLanguageChange: (language: LanguageCode) => void | Promise<void>; onLanguageChange: (language: LanguageCode) => void | Promise<void>;
onLaunchAtStartupChange: (enabled: boolean) => void | Promise<void>;
gatewayState: GatewaySettingState; gatewayState: GatewaySettingState;
updateState: SettingUpdateState; updateState: SettingUpdateState;
}; };
@@ -83,8 +85,10 @@ function getGatewayStatusMeta(
export default function GeneralSettingsPanel({ export default function GeneralSettingsPanel({
themeMode, themeMode,
language, language,
launchAtStartup,
onThemeChange, onThemeChange,
onLanguageChange, onLanguageChange,
onLaunchAtStartupChange,
gatewayState, gatewayState,
updateState, updateState,
}: GeneralSettingsPanelProps) { }: GeneralSettingsPanelProps) {
@@ -160,6 +164,24 @@ export default function GeneralSettingsPanel({
</div> </div>
</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="mt-10">
<div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100"> <div className="mb-6 text-[24px] font-medium text-[#171717] dark:text-gray-100">
{t('settings.general.gatewayTitle')} {t('settings.general.gatewayTitle')}

View File

@@ -1,7 +1,7 @@
import { Suspense, lazy, useEffect } from 'react'; import { Suspense, lazy, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useI18n } from '../../i18n'; 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 type { LanguageCode, ThemeMode } from '../../types/runtime';
import AccountSettingsPanel from './components/AccountSettingsPanel'; import AccountSettingsPanel from './components/AccountSettingsPanel';
import GeneralSettingsPanel from './components/GeneralSettingsPanel'; import GeneralSettingsPanel from './components/GeneralSettingsPanel';
@@ -18,6 +18,7 @@ export default function SettingPage() {
const { t } = useI18n(); const { t } = useI18n();
const themeMode = useSettingsStore((state) => state.themeMode); const themeMode = useSettingsStore((state) => state.themeMode);
const language = useSettingsStore((state) => state.language); const language = useSettingsStore((state) => state.language);
const launchAtStartup = useSettingsStore((state) => state.launchAtStartup);
const updateState = useSettingUpdateState(); const updateState = useSettingUpdateState();
const gatewayState = useGatewaySettingState(); const gatewayState = useGatewaySettingState();
const rawView = new URLSearchParams(location.search).get('view'); const rawView = new URLSearchParams(location.search).get('view');
@@ -45,6 +46,10 @@ export default function SettingPage() {
await updateLanguage(nextLanguage); await updateLanguage(nextLanguage);
}; };
const handleLaunchAtStartupChange = async (nextValue: boolean) => {
await updateLaunchAtStartup(nextValue);
};
const handleViewChange = (nextView: SettingView) => { const handleViewChange = (nextView: SettingView) => {
if (nextView === currentView) return; if (nextView === currentView) return;
@@ -93,8 +98,10 @@ export default function SettingPage() {
<GeneralSettingsPanel <GeneralSettingsPanel
themeMode={themeMode} themeMode={themeMode}
language={language} language={language}
launchAtStartup={launchAtStartup}
onThemeChange={handleThemeChange} onThemeChange={handleThemeChange}
onLanguageChange={handleLanguageChange} onLanguageChange={handleLanguageChange}
onLaunchAtStartupChange={handleLaunchAtStartupChange}
gatewayState={gatewayState} gatewayState={gatewayState}
updateState={updateState} updateState={updateState}
/> />

View File

@@ -12,6 +12,8 @@ export {
updateLanguage as setLanguage, updateLanguage as setLanguage,
updateFontSize as setFontSize, updateFontSize as setFontSize,
updateMinimizeToTray as setMinimizeToTray, updateMinimizeToTray as setMinimizeToTray,
updateLaunchAtStartup,
updateLaunchAtStartup as setLaunchAtStartup,
updatePrimaryColor as setPrimaryColor, updatePrimaryColor as setPrimaryColor,
updateGatewayAutoStart, updateGatewayAutoStart,
updateGatewayAutoStart as setGatewayAutoStart, updateGatewayAutoStart as setGatewayAutoStart,

View File

@@ -27,6 +27,7 @@ export interface SettingsState {
primaryColor: string; primaryColor: string;
fontSize: number; fontSize: number;
minimizeToTray: boolean; minimizeToTray: boolean;
launchAtStartup: boolean;
providerId: string | null; providerId: string | null;
defaultModel: string | null; defaultModel: string | null;
gatewayAutoStart: boolean; gatewayAutoStart: boolean;
@@ -72,6 +73,7 @@ function createInitialState(): SettingsState {
primaryColor: '#1677ff', primaryColor: '#1677ff',
fontSize: 14, fontSize: 14,
minimizeToTray: false, minimizeToTray: false,
launchAtStartup: false,
providerId: null, providerId: null,
defaultModel: null, defaultModel: null,
gatewayAutoStart: true, gatewayAutoStart: true,
@@ -205,11 +207,12 @@ async function hydrate(): Promise<SettingsState> {
const systemTheme = detectSystemTheme(); const systemTheme = detectSystemTheme();
const systemLanguage = detectSystemLanguage(); 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(), readThemeMode(),
readConfigValue<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage), readConfigValue<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage),
readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14), readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14),
readConfigValue<boolean>(CONFIG_KEYS.MINIMIZE_TO_TRAY, false), 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>(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null), readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null),
readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null), readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null),
@@ -231,6 +234,7 @@ async function hydrate(): Promise<SettingsState> {
primaryColor: primaryColor ?? '#1677ff', primaryColor: primaryColor ?? '#1677ff',
fontSize: fontSize ?? 14, fontSize: fontSize ?? 14,
minimizeToTray: Boolean(minimizeToTray), minimizeToTray: Boolean(minimizeToTray),
launchAtStartup: Boolean(launchAtStartup),
providerId: providerId ?? null, providerId: providerId ?? null,
defaultModel: defaultModel ?? null, defaultModel: defaultModel ?? null,
gatewayAutoStart: Boolean(gatewayAutoStart), gatewayAutoStart: Boolean(gatewayAutoStart),
@@ -306,6 +310,21 @@ async function setMinimizeToTray(minimizeToTray: boolean, persist = true): Promi
return state; 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> { async function setPrimaryColor(primaryColor: string, persist = true): Promise<SettingsState> {
const next = primaryColor || '#1677ff'; const next = primaryColor || '#1677ff';
if (state.primaryColor === next && state.initialized) return state; if (state.primaryColor === next && state.initialized) return state;
@@ -353,6 +372,7 @@ export const settingsStore = {
setLanguage, setLanguage,
setFontSize, setFontSize,
setMinimizeToTray, setMinimizeToTray,
setLaunchAtStartup,
setPrimaryColor, setPrimaryColor,
setGatewayAutoStart, setGatewayAutoStart,
hostApiFetch, hostApiFetch,
@@ -391,6 +411,10 @@ export async function updateMinimizeToTray(minimizeToTray: boolean, persist = tr
return setMinimizeToTray(minimizeToTray, persist); 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> { export async function updateGatewayAutoStart(gatewayAutoStart: boolean, persist = true): Promise<SettingsState> {
return setGatewayAutoStart(gatewayAutoStart, persist); return setGatewayAutoStart(gatewayAutoStart, persist);
} }

View File

@@ -28,6 +28,7 @@ export const CONFIG_KEYS = {
LANGUAGE: 'language', LANGUAGE: 'language',
FONT_SIZE: 'fontSize', FONT_SIZE: 'fontSize',
MINIMIZE_TO_TRAY: 'minimizeToTray', MINIMIZE_TO_TRAY: 'minimizeToTray',
LAUNCH_AT_STARTUP: 'launchAtStartup',
PROVIDER: 'provider', PROVIDER: 'provider',
DEFAULT_MODEL: 'defaultModel', DEFAULT_MODEL: 'defaultModel',
GATEWAY_AUTO_START: 'gatewayAutoStart', GATEWAY_AUTO_START: 'gatewayAutoStart',
@@ -46,6 +47,7 @@ export interface ConfigValueMap {
language: LanguageCode; language: LanguageCode;
fontSize: number; fontSize: number;
minimizeToTray: boolean; minimizeToTray: boolean;
launchAtStartup: boolean;
provider: string | null; provider: string | null;
defaultModel: string | null; defaultModel: string | null;
gatewayAutoStart: boolean; gatewayAutoStart: boolean;

View 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);
});
});

View 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();
});
});