diff --git a/apps/desktop/src/main/__tests__/launchAtLogin.test.ts b/apps/desktop/src/main/__tests__/launchAtLogin.test.ts new file mode 100644 index 0000000000..c07a63d01a --- /dev/null +++ b/apps/desktop/src/main/__tests__/launchAtLogin.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + OPENED_AT_LOGIN_FLAG, + readLaunchAtLogin, + shouldStartHiddenInTray, + wasOpenedAtLogin, + writeLaunchAtLogin, + type LoginItemApp, +} from '../launchAtLogin'; + +function trayAlwaysReady(): () => boolean { + return vi.fn(() => true); +} + +describe('wasOpenedAtLogin', () => { + it('detects the login-item flag anywhere in argv', () => { + expect(wasOpenedAtLogin(['Cindy.exe', OPENED_AT_LOGIN_FLAG])).toBe(true); + expect(wasOpenedAtLogin([OPENED_AT_LOGIN_FLAG, '--other'])).toBe(true); + }); + + it('reports a manual launch when the flag is absent', () => { + expect(wasOpenedAtLogin(['Cindy.exe'])).toBe(false); + expect(wasOpenedAtLogin([])).toBe(false); + }); + + it('does not match a partial or suffixed argument', () => { + expect(wasOpenedAtLogin(['--opened-at-login=1'])).toBe(false); + expect(wasOpenedAtLogin(['--opened-at-logins'])).toBe(false); + }); +}); + +describe('shouldStartHiddenInTray', () => { + const base = { + platform: 'win32' as NodeJS.Platform, + argv: ['Cindy.exe', OPENED_AT_LOGIN_FLAG], + startInTrayOnLogin: true, + }; + + it('hides only when every condition holds', () => { + expect(shouldStartHiddenInTray({ ...base, ensureTray: trayAlwaysReady() })).toBe(true); + }); + + it('shows the window when the setting is off', () => { + expect( + shouldStartHiddenInTray({ + ...base, + startInTrayOnLogin: false, + ensureTray: trayAlwaysReady(), + }), + ).toBe(false); + }); + + it('shows the window on a manual launch even with the setting on', () => { + expect( + shouldStartHiddenInTray({ ...base, argv: ['Cindy.exe'], ensureTray: trayAlwaysReady() }), + ).toBe(false); + }); + + it.each(['darwin', 'linux'] as const)('never hides on %s', (platform) => { + expect( + shouldStartHiddenInTray({ ...base, platform, ensureTray: trayAlwaysReady() }), + ).toBe(false); + }); + + // 这条是安全边界:托盘建不出来还隐藏窗口,用户就只剩任务管理器可用了。 + it('falls back to showing the window when the tray icon cannot be created', () => { + expect(shouldStartHiddenInTray({ ...base, ensureTray: () => false })).toBe(false); + }); + + it('does not create a tray icon when an earlier condition already rules out hiding', () => { + const ensureTray = vi.fn(() => true); + shouldStartHiddenInTray({ ...base, startInTrayOnLogin: false, ensureTray }); + shouldStartHiddenInTray({ ...base, argv: ['Cindy.exe'], ensureTray }); + shouldStartHiddenInTray({ ...base, platform: 'darwin', ensureTray }); + expect(ensureTray).not.toHaveBeenCalled(); + }); +}); + +describe('login item read/write', () => { + /** + * 按 Windows 的实际语义建模:登录项以 args 为键存取,查询时传入的 args 必须与 + * 注册时一致才能命中。替身若忽略 args,就会把「查询漏传 args」这类缺陷一并测过。 + */ + function createApp( + initial: boolean, + /** + * 模拟用户在任务管理器「启动应用」里停用 Cindy:注册表项仍在,但 run key + * 被停用。Electron 用 executableWillLaunchAtLogin 反映这一点,且该字段忽略 + * args——所以它按可执行文件而非条目来记。 + */ + { runKeyDeactivated = false }: { runKeyDeactivated?: boolean } = {}, + ): LoginItemApp & { readArgs: (string[] | undefined)[]; writes: unknown[] } { + const entries = new Map(); + const key = (args?: string[]): string => JSON.stringify(args ?? []); + if (initial) entries.set(key([OPENED_AT_LOGIN_FLAG]), true); + const readArgs: (string[] | undefined)[] = []; + const writes: unknown[] = []; + return { + readArgs, + writes, + getLoginItemSettings: (options) => { + readArgs.push(options?.args); + return { + openAtLogin: entries.get(key(options?.args)) ?? false, + // 忽略 args:只要该 exe 有任一登录项且未被停用就是 true。 + executableWillLaunchAtLogin: entries.size > 0 && !runKeyDeactivated, + }; + }, + setLoginItemSettings: (settings) => { + writes.push(settings); + if (settings.openAtLogin) entries.set(key(settings.args), true); + else entries.delete(key(settings.args)); + }, + }; + } + + it('reads the current login item state', () => { + expect(readLaunchAtLogin(createApp(true))).toBe(true); + expect(readLaunchAtLogin(createApp(false))).toBe(false); + }); + + // 回归:Windows 上 args 是「用于比对的参数」,缺省空数组。漏传会匹配不到我们 + // 注册的条目而恒返回 false,开关因此永远显示为关。 + it('queries with the same args used at registration', () => { + const app = createApp(true); + readLaunchAtLogin(app); + expect(app.readArgs).toEqual([[OPENED_AT_LOGIN_FLAG]]); + }); + + // 回归:用户在任务管理器「启动应用」里停用 Cindy 后,注册表项还在、openAtLogin + // 仍为 true,但开机不会启动。只读 openAtLogin 会把开关显示成开、并让「收起到 + // 托盘」保持可用,用户还无法靠再点一次开启把自启动恢复。 + it('reports off when the run key is deactivated in Task Manager', () => { + const app = createApp(true, { runKeyDeactivated: true }); + // 条目确实还在。 + expect(app.getLoginItemSettings({ args: [OPENED_AT_LOGIN_FLAG] }).openAtLogin).toBe(true); + // 但实际不会启动,所以对外必须报 false。 + expect(readLaunchAtLogin(app)).toBe(false); + }); + + // 非 Windows 的 Electron 不返回该字段,不能把 undefined 当成「已停用」。 + it('ignores the missing Windows-only field on other platforms', () => { + const app: LoginItemApp = { + getLoginItemSettings: () => ({ openAtLogin: true }), + setLoginItemSettings: () => {}, + }; + expect(readLaunchAtLogin(app)).toBe(true); + }); + + it('does not find the entry when queried without matching args', () => { + const app = createApp(true); + // 模拟旧实现:不传 args。 + expect(app.getLoginItemSettings().openAtLogin).toBe(false); + // 传对了才命中。 + expect(app.getLoginItemSettings({ args: [OPENED_AT_LOGIN_FLAG] }).openAtLogin).toBe(true); + }); + + it('treats a failing query as not enabled', () => { + const app: LoginItemApp = { + getLoginItemSettings: () => { + throw new Error('registry unavailable'); + }, + setLoginItemSettings: () => {}, + }; + expect(readLaunchAtLogin(app)).toBe(false); + }); + + it('registers the login item with the flag so startup can be recognised', () => { + const app = createApp(false); + expect(writeLaunchAtLogin(app, true)).toBe(true); + expect(app.writes).toEqual([ + { openAtLogin: true, args: [OPENED_AT_LOGIN_FLAG], enabled: true }, + ]); + }); + + // 关闭时漏传 args 会留下匹配不到的孤儿登录项,用户看起来"关不掉"。 + it('keeps passing the flag when disabling so Electron matches the existing entry', () => { + const app = createApp(true); + expect(writeLaunchAtLogin(app, false)).toBe(false); + expect(app.writes).toEqual([ + { openAtLogin: false, args: [OPENED_AT_LOGIN_FLAG], enabled: false }, + ]); + }); + + it('reports the real state when the write does not take effect', () => { + const app: LoginItemApp = { + // 无权限改登录项:写入被系统忽略,查询仍返回旧值。 + getLoginItemSettings: () => ({ openAtLogin: false }), + setLoginItemSettings: () => {}, + }; + expect(writeLaunchAtLogin(app, true)).toBe(false); + }); + + it('round-trips through the same entry so a freshly enabled item reads back as on', () => { + const app = createApp(false); + expect(writeLaunchAtLogin(app, true)).toBe(true); + // 关键联动:写入后立刻再查(设置页每次挂载都会查),必须仍是 true。 + expect(readLaunchAtLogin(app)).toBe(true); + expect(writeLaunchAtLogin(app, false)).toBe(false); + expect(readLaunchAtLogin(app)).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/__tests__/windowBehaviorSettingsStore.test.ts b/apps/desktop/src/main/__tests__/windowBehaviorSettingsStore.test.ts index f733bc460a..8fd6cf43f7 100644 --- a/apps/desktop/src/main/__tests__/windowBehaviorSettingsStore.test.ts +++ b/apps/desktop/src/main/__tests__/windowBehaviorSettingsStore.test.ts @@ -32,4 +32,28 @@ describe('window behavior settings store', () => { it('rejects invalid persisted close behavior', () => { expect(__testing.normalize({ windowsCloseBehavior: 'hide' }).windowsCloseBehavior).toBeNull(); }); + + it('shows the window on login start until the user opts in', () => { + expect(__testing.normalize(undefined).startInTrayOnLogin).toBe(false); + expect(__testing.normalize({}).startInTrayOnLogin).toBe(false); + }); + + it('keeps the persisted start-in-tray choice', () => { + expect(__testing.normalize({ startInTrayOnLogin: true }).startInTrayOnLogin).toBe(true); + expect(__testing.normalize({ startInTrayOnLogin: false }).startInTrayOnLogin).toBe(false); + }); + + it('falls back to showing the window for a non-boolean start-in-tray value', () => { + expect(__testing.normalize({ startInTrayOnLogin: 'yes' }).startInTrayOnLogin).toBe(false); + }); + + // 两个开关互不影响:关掉自启动不该清除用户对托盘启动的选择。 + it('keeps start-in-tray independent from the close behavior', () => { + const settings = __testing.normalize({ + windowsCloseBehavior: 'quit', + startInTrayOnLogin: true, + }); + expect(settings.windowsCloseBehavior).toBe('quit'); + expect(settings.startInTrayOnLogin).toBe(true); + }); }); diff --git a/apps/desktop/src/main/bootstrap-electron.ts b/apps/desktop/src/main/bootstrap-electron.ts index 9adc7e542d..9dfe6a338e 100644 --- a/apps/desktop/src/main/bootstrap-electron.ts +++ b/apps/desktop/src/main/bootstrap-electron.ts @@ -816,9 +816,15 @@ import { healWindowsShortcuts } from './windowsShortcutSelfHeal.js'; import { CURRENT_APP_ID, CURRENT_CINDY_REGION } from '../shared/brandRegion.js'; import { readWindowBehaviorSettings, + writeStartInTrayOnLogin, writeSwallowActivationClick, writeWindowsCloseBehavior, } from './window-behavior-settings-store.js'; +import { + readLaunchAtLogin, + shouldStartHiddenInTray, + writeLaunchAtLogin, +} from './launchAtLogin.js'; import { hideWindowToWindowsTray, popUpWindowsTrayMenu, @@ -828,11 +834,15 @@ import { import { createWindowsClosePromptFallbackController } from './windowsClosePromptFallback.js'; import { isWindowsCloseBehavior, + WINDOW_BEHAVIOR_GET_LAUNCH_AT_LOGIN_CHANNEL, WINDOW_BEHAVIOR_GET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL, + WINDOW_BEHAVIOR_SET_LAUNCH_AT_LOGIN_CHANNEL, + WINDOW_BEHAVIOR_SET_START_IN_TRAY_ON_LOGIN_CHANNEL, WINDOW_BEHAVIOR_SET_SWALLOW_ACTIVATION_CLICK_CHANNEL, WINDOW_BEHAVIOR_SET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL, WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_REQUESTED_CHANNEL, WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_SHOWN_CHANNEL, + type LaunchAtLoginState, type WindowsCloseBehavior, } from '../shared/windowBehavior.js'; import { getDesktopCommandRegistry, registerBuiltinDesktopCommands } from './commands/index.js'; @@ -3706,9 +3716,23 @@ const createWindow = () => { // Show window only after content is rendered — eliminates theme flash mainWindow.once('ready-to-show', () => { - showMainWindowAndRestoreFullscreen(mainWindow, { - restoreFullscreen: shouldRestoreMacFullscreen, + // 自启动静默模式:窗口本来就是 show:false 创建的,这里直接不 show 即可—— + // 不存在"先显示再隐藏"的闪现。ensureWindowsTray 作为最后一个条件求值, + // 它返回 false(图标资源缺失等)时必须照常显示窗口,否则用户既没窗口也 + // 没托盘图标,只能去任务管理器结束进程。 + const startHidden = shouldStartHiddenInTray({ + platform: process.platform, + argv: process.argv, + startInTrayOnLogin: readWindowBehaviorSettings().startInTrayOnLogin, + ensureTray: ensureWindowsTray, }); + if (startHidden) { + windowsTrayLog.info('main window stays hidden in tray on login start'); + } else { + showMainWindowAndRestoreFullscreen(mainWindow, { + restoreFullscreen: shouldRestoreMacFullscreen, + }); + } if (!app.isPackaged) markDesktopDevWindowReady(); // 资源用量窗口不应与主窗口首帧争 CPU。主窗口可见后再后台完成 BrowserWindow、 // renderer 和首份进程快照预热;回调绑定当代主窗口,重建/退出后不会创建孤儿窗。 @@ -4434,6 +4458,38 @@ const registerIpcHandlers = () => { return behavior; }, ); + // 这三个 channel 写系统登录项与启动偏好,属特权副作用,一律先校验 sender 来自 + // Cindy 自有顶层 frame(electron-security-and-process-boundaries.md §158)。 + ipcMain.handle( + WINDOW_BEHAVIOR_GET_LAUNCH_AT_LOGIN_CHANNEL, + async (event): Promise => { + assertTrustedAppRendererEvent(event); + return { + launchAtLogin: readLaunchAtLogin(app), + startInTrayOnLogin: readWindowBehaviorSettings().startInTrayOnLogin, + }; + }, + ); + ipcMain.handle(WINDOW_BEHAVIOR_SET_LAUNCH_AT_LOGIN_CHANNEL, async (event, enabled: unknown) => { + assertTrustedAppRendererEvent(event); + if (typeof enabled !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'launchAtLogin required (boolean)'); + } + // 回传写入后重新查询到的事实状态:用户没权限改登录项时 renderer 要把 + // 乐观更新退回真实值,而不是显示一个并未生效的开启态。 + return writeLaunchAtLogin(app, enabled); + }); + ipcMain.handle( + WINDOW_BEHAVIOR_SET_START_IN_TRAY_ON_LOGIN_CHANNEL, + async (event, enabled: unknown) => { + assertTrustedAppRendererEvent(event); + if (typeof enabled !== 'boolean') { + throwIpcError('INVALID_PARAMS', 'startInTrayOnLogin required (boolean)'); + } + writeStartInTrayOnLogin(enabled); + return { ok: true as const }; + }, + ); ipcMain.on(WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_SHOWN_CHANNEL, (event) => { if (BrowserWindow.fromWebContents(event.sender) === mainWindowRef) { windowsClosePromptFallback.acknowledge(); diff --git a/apps/desktop/src/main/launchAtLogin.ts b/apps/desktop/src/main/launchAtLogin.ts new file mode 100644 index 0000000000..57204f81bd --- /dev/null +++ b/apps/desktop/src/main/launchAtLogin.ts @@ -0,0 +1,115 @@ +/** + * launchAtLogin —— 开机自启动的登录项管理与「自启时收起到托盘」的判定。 + * + * ## 为什么用 argv 标记而不是 Electron 的 wasOpenedAtLogin + * + * `app.getLoginItemSettings().wasOpenedAtLogin` 只在 macOS 上有值,Windows 上 + * 恒为 false(Electron 文档明确标注 macOS only)。所以注册登录项时我们自己 + * 追加 `--opened-at-login`,启动时从 argv 判定本次是否来自登录项——这条路径 + * 两个平台同构,也不依赖 Electron 未来是否补齐该字段。 + * + * 标记参数只加在登录项的命令行里:用户从开始菜单、桌面快捷方式或命令行手动 + * 启动时 argv 里没有它,窗口照常显示。 + * + * ## 为什么不自己写注册表 + * + * `app.setLoginItemSettings()` 在 Windows 上写 HKCU\...\CurrentVersion\Run, + * 并且带上正确的可执行文件路径与工作目录。手写注册表容易漏掉 Squirrel/NSIS + * 安装布局下的 Update.exe 间接层,升级后路径失效;更常见的用户错误是把 + * Cindy.exe 复制进启动目录——脱离安装目录后 Electron 找不到 icudtl.dat 等 + * 运行时文件,启动直接崩(见 issue #3568)。交给 Electron 处理这些细节。 + */ + +/** 登录项命令行里的自启动标记。 */ +export const OPENED_AT_LOGIN_FLAG = '--opened-at-login'; + +/** Electron `app` 的登录项相关子集,便于单测替身。 */ +export interface LoginItemApp { + getLoginItemSettings(options?: { args?: string[] }): { + openAtLogin: boolean; + /** Windows only。非 win32 平台的 Electron 不返回该字段。 */ + executableWillLaunchAtLogin?: boolean; + }; + setLoginItemSettings(settings: { + openAtLogin: boolean; + args?: string[]; + enabled?: boolean; + }): void; +} + +/** 本次进程是否由登录项拉起。 */ +export function wasOpenedAtLogin(argv: readonly string[]): boolean { + return argv.includes(OPENED_AT_LOGIN_FLAG); +} + +/** + * 决定主窗口首帧是否跳过 show。 + * + * 四个条件全部成立才隐藏——任一不成立都必须正常显示窗口: + * - 平台是 Windows(托盘常驻语义与 macOS 的 Dock 不同,该功能只做 Windows); + * - 本次是登录项拉起(用户手动双击图标时必须弹窗,否则会以为没启动成功); + * - 用户开了 startInTrayOnLogin; + * - 托盘图标确实创建成功。 + * + * ⚠️ 最后一条是安全边界,不能省。`ensureWindowsTray()` 会因为图标资源缺失等 + * 原因失败并返回 false;那时若仍跳过 show,用户既没有窗口也没有托盘图标, + * 只剩任务管理器可用。宁可多显示一个窗口,也不能让应用变成不可操作的幽灵 + * 进程。调用方必须把 ensureTray 的真实返回值传进来,不要传常量 true。 + */ +export function shouldStartHiddenInTray(input: { + platform: NodeJS.Platform; + argv: readonly string[]; + startInTrayOnLogin: boolean; + ensureTray: () => boolean; +}): boolean { + if (input.platform !== 'win32') return false; + if (!wasOpenedAtLogin(input.argv)) return false; + if (!input.startInTrayOnLogin) return false; + // 放在最后:前三条都不满足时不该有建托盘的副作用。 + return input.ensureTray(); +} + +/** + * 读取系统登录项的当前状态——「我们注册的那条存在,且开机真的会启动」。 + * + * 两个字段各自只回答一半,必须取交集: + * + * - `openAtLogin` 认 `args`,所以能区分是不是我们注册的那条。⚠️ 必须传与注册时 + * 相同的 args:Windows 上 args 的语义是「用于比对的命令行参数」、缺省空数组, + * 漏传就是拿空参数去比对,匹配不到而恒返回 false。 + * - `executableWillLaunchAtLogin` 反映 run key 有没有被停用,但**忽略 args** + * (Electron 文档原文:"this property will be true if the given executable + * would be launched at login with **any** arguments")。 + * + * 只看前者:用户在任务管理器「启动应用」里禁用 Cindy 后,注册表项还在,开关仍 + * 显示为开,实际却不会启动。只看后者:任何参数的登录项都算,分不清是不是我们 + * 写的那条。 + * + * 非 win32 平台不返回 `executableWillLaunchAtLogin`,此时按 undefined 处理、 + * 只取 `openAtLogin`——该功能本身只在 Windows 暴露,这里只保证跨平台读取不炸。 + */ +export function readLaunchAtLogin(app: LoginItemApp): boolean { + try { + const settings = app.getLoginItemSettings({ args: [OPENED_AT_LOGIN_FLAG] }); + if (!settings.openAtLogin) return false; + // 字段缺失(非 Windows)时不参与判断,不要把 undefined 当成"已停用"。 + return settings.executableWillLaunchAtLogin !== false; + } catch { + return false; + } +} + +/** + * 写入系统登录项。返回写入后重新查询到的事实状态,便于调用方回传给 renderer + * 做乐观更新的纠偏——用户可能没有权限改登录项,那时 UI 要退回真实值。 + */ +export function writeLaunchAtLogin(app: LoginItemApp, openAtLogin: boolean): boolean { + app.setLoginItemSettings({ + openAtLogin, + // 关闭时也要传 args:Electron 用 (path, args) 一起定位既有登录项,漏传会 + // 留下一条我们再也匹配不到的孤儿项,用户看起来"关不掉"。 + args: [OPENED_AT_LOGIN_FLAG], + enabled: openAtLogin, + }); + return readLaunchAtLogin(app); +} diff --git a/apps/desktop/src/main/window-behavior-settings-store.ts b/apps/desktop/src/main/window-behavior-settings-store.ts index c86d9b00be..99648f9efd 100644 --- a/apps/desktop/src/main/window-behavior-settings-store.ts +++ b/apps/desktop/src/main/window-behavior-settings-store.ts @@ -8,10 +8,16 @@ * 这是相对 PR #446 / macOS 原生 acceptFirstMouse:false 的行为变更,想要 * 防误触的用户需在设置里显式打开) * - windowsCloseBehavior: null (Windows 首次关闭时弹窗询问,选择后持久化) + * - startInTrayOnLogin: false (开机自启时也照常显示窗口;想要静默启动的用户 + * 在设置里显式打开) * * swallowActivationClick 仍由 renderer localStorage 承担运行时事实标准,main * 侧文件只供下次创建 BrowserWindow 时读取。windowsCloseBehavior 则完全由 * main 侧持久化与执行,renderer 通过 IPC 读写同一份状态。 + * + * 注意 startInTrayOnLogin 只记「用户想要什么」,不记「系统登录项是否已启用」—— + * 后者的事实源是操作系统(用户可能在任务管理器或系统设置里改掉),每次都向 + * Electron 查询,不在这里持久化,避免两份状态漂移。 */ import { app } from 'electron'; @@ -26,11 +32,13 @@ const log = desktopMakerLogger.child('window-behavior-settings-store'); export interface WindowBehaviorSettings { swallowActivationClick: boolean; windowsCloseBehavior: WindowsCloseBehavior | null; + startInTrayOnLogin: boolean; } const DEFAULTS: WindowBehaviorSettings = { swallowActivationClick: false, windowsCloseBehavior: null, + startInTrayOnLogin: false, }; function settingsFilePath(): string { @@ -48,6 +56,10 @@ function normalize(raw: unknown): WindowBehaviorSettings { windowsCloseBehavior: isWindowsCloseBehavior(r.windowsCloseBehavior) ? r.windowsCloseBehavior : DEFAULTS.windowsCloseBehavior, + startInTrayOnLogin: + typeof r.startInTrayOnLogin === 'boolean' + ? r.startInTrayOnLogin + : DEFAULTS.startInTrayOnLogin, }; } @@ -73,4 +85,9 @@ export function writeWindowsCloseBehavior(windowsCloseBehavior: WindowsCloseBeha log.info('Windows close behavior written', { windowsCloseBehavior }); } +export function writeStartInTrayOnLogin(startInTrayOnLogin: boolean): void { + store.writePatch({ startInTrayOnLogin }); + log.info('start in tray on login written', { startInTrayOnLogin }); +} + export const __testing = { normalize }; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 1570d7c432..997058bdad 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -34,11 +34,15 @@ import { } from '../shared/agentIsland'; import type { AgentProxyTunnelState, SshHostAgentProxyPref } from '../shared/agentProxyConfig'; import { + WINDOW_BEHAVIOR_GET_LAUNCH_AT_LOGIN_CHANNEL, WINDOW_BEHAVIOR_GET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL, + WINDOW_BEHAVIOR_SET_LAUNCH_AT_LOGIN_CHANNEL, + WINDOW_BEHAVIOR_SET_START_IN_TRAY_ON_LOGIN_CHANNEL, WINDOW_BEHAVIOR_SET_SWALLOW_ACTIVATION_CLICK_CHANNEL, WINDOW_BEHAVIOR_SET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL, WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_REQUESTED_CHANNEL, WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_SHOWN_CHANNEL, + type LaunchAtLoginState, type WindowsCloseBehavior, } from '../shared/windowBehavior'; import { @@ -1606,6 +1610,13 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke(WINDOW_BEHAVIOR_GET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL), setWindowsCloseBehavior: (behavior: WindowsCloseBehavior): Promise => ipcRenderer.invoke(WINDOW_BEHAVIOR_SET_WINDOWS_CLOSE_BEHAVIOR_CHANNEL, behavior), + getLaunchAtLogin: (): Promise => + ipcRenderer.invoke(WINDOW_BEHAVIOR_GET_LAUNCH_AT_LOGIN_CHANNEL), + // 返回写入后的事实状态(可能与请求值不同:改登录项失败时保持原状)。 + setLaunchAtLogin: (enabled: boolean): Promise => + ipcRenderer.invoke(WINDOW_BEHAVIOR_SET_LAUNCH_AT_LOGIN_CHANNEL, enabled), + setStartInTrayOnLogin: (enabled: boolean): Promise<{ ok: true }> => + ipcRenderer.invoke(WINDOW_BEHAVIOR_SET_START_IN_TRAY_ON_LOGIN_CHANNEL, enabled), onWindowsCloseBehaviorRequested: (callback: () => void): (() => void) => { const listener = (): void => callback(); ipcRenderer.on(WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_REQUESTED_CHANNEL, listener); diff --git a/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx b/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx index 09aa9cc07b..3af4516b02 100644 --- a/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx +++ b/apps/desktop/src/renderer/components/settings/WindowBehaviorSection.tsx @@ -1,11 +1,15 @@ /** * WindowBehaviorSection — 「应用行为」section:本机相关的应用级开关。 * - * 三项设置: + * 五项设置: * 1. 「保持电脑唤醒」(keepAwake):main 用 powerSaveBlocker 防系统休眠、放行锁屏, * 让后台 agent / 定时任务持续运行。跨平台生效(mac/win/linux),故常驻显示。 - * 2. 「关闭主窗口时」(windowsCloseBehavior):仅 Windows 显示,选择退出或收起到托盘。 - * 3. 「后台窗口首次左键点击仅激活不透传」(swallowActivationClick,PR #446):仅 + * 2. 「开机时启动 Cindy」(launchAtLogin):仅 Windows 显示。事实源是操作系统登录项, + * 每次挂载都重新查询——用户可能在任务管理器「启动应用」里禁用过它。 + * 3. 「开机启动时收起到托盘」(startInTrayOnLogin):仅 Windows 显示,依赖第 2 项, + * 关着时置灰。只影响登录项拉起的那次启动,手动双击图标仍正常显示窗口。 + * 4. 「关闭主窗口时」(windowsCloseBehavior):仅 Windows 显示,选择退出或收起到托盘。 + * 5. 「后台窗口首次左键点击仅激活不透传」(swallowActivationClick,PR #446):仅 * macOS + Windows 有实际效果,Linux 上两条底层路径均 no-op,故该行在 Linux 隐藏。 * * 卡片样式沿用 NotificationSection 的规格(rounded 12 / Card bg / 1px Board / @@ -29,6 +33,7 @@ function BehaviorCard({ checked, onCheckedChange, ariaLabel, + disabled, }: { label: string; hint: string; @@ -36,6 +41,8 @@ function BehaviorCard({ checked: boolean; onCheckedChange: (next: boolean) => void; ariaLabel: string; + /** 依赖项未满足时置灰(仍渲染,让用户看得到这个能力的存在与前置条件)。 */ + disabled?: boolean; }) { return (
-
+ {/* 只让文字区跟随禁用态变淡:Switch 自己有 --switch-disabled-* token + 表达不可用,外层再叠一层整体透明度会把它压成两级灰。 */} +

- +

); } @@ -76,6 +90,8 @@ export function WindowBehaviorSection() { const isWindows = window.electronAPI?.platform === 'win32'; const [windowsCloseBehavior, setWindowsCloseBehaviorState] = useState(null); + const [launchAtLogin, setLaunchAtLoginState] = useState(false); + const [startInTrayOnLogin, setStartInTrayOnLoginState] = useState(false); useEffect(() => { if (!isWindows) return; @@ -93,6 +109,26 @@ export function WindowBehaviorSection() { }; }, [isWindows]); + // 登录项的事实源是操作系统:用户可能在任务管理器「启动应用」里禁用它, + // 所以每次进设置页都重新查询,不缓存。 + useEffect(() => { + if (!isWindows) return; + let active = true; + void window.electronAPI.windowBehavior + .getLaunchAtLogin() + .then((state) => { + if (!active) return; + setLaunchAtLoginState(state.launchAtLogin); + setStartInTrayOnLoginState(state.startInTrayOnLogin); + }) + .catch(() => { + // Leave both switches off when main cannot report the login item state. + }); + return () => { + active = false; + }; + }, [isWindows]); + const setWindowsCloseBehavior = (behavior: WindowsCloseBehavior): void => { const previous = windowsCloseBehavior; setWindowsCloseBehaviorState(behavior); @@ -101,6 +137,25 @@ export function WindowBehaviorSection() { }); }; + const setLaunchAtLogin = (next: boolean): void => { + const previous = launchAtLogin; + setLaunchAtLoginState(next); + void window.electronAPI.windowBehavior + .setLaunchAtLogin(next) + // main 回传写入后的事实状态:改登录项失败时它仍是旧值,UI 要跟着退回, + // 不能停留在用户以为已生效的乐观态。 + .then((applied) => setLaunchAtLoginState(applied)) + .catch(() => setLaunchAtLoginState(previous)); + }; + + const setStartInTrayOnLogin = (next: boolean): void => { + const previous = startInTrayOnLogin; + setStartInTrayOnLoginState(next); + void window.electronAPI.windowBehavior + .setStartInTrayOnLogin(next) + .catch(() => setStartInTrayOnLoginState(previous)); + }; + return (

@@ -116,52 +171,73 @@ export function WindowBehaviorSection() { /> {isWindows && ( -
-
-

- {t('settings.windowBehavior.closeBehavior.label')} -

-

- {t('settings.windowBehavior.closeBehavior.hint')} -

-
+ <> + + +
- {(['tray', 'quit'] as const).map((behavior) => { - const active = windowsCloseBehavior === behavior; - return ( - - ); - })} +
+

+ {t('settings.windowBehavior.closeBehavior.label')} +

+

+ {t('settings.windowBehavior.closeBehavior.hint')} +

+
+ +
+ {(['tray', 'quit'] as const).map((behavior) => { + const active = windowsCloseBehavior === behavior; + return ( + + ); + })} +
-
+ )} {showsSwallowActivationClick && ( diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index 15afd439f9..7cf1e9205b 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -539,6 +539,14 @@ }, "windowBehavior": { "title": "App Behavior", + "launchAtLogin": { + "label": "Launch Cindy at login", + "hint": "Start Cindy automatically after you sign in to Windows. You can also turn this off under \"Startup apps\" in Task Manager." + }, + "startInTrayOnLogin": { + "label": "Start in the tray at login", + "hint": "When Cindy starts at login, keep the main window hidden and leave only a tray icon — click it to open the window.\nOpening Cindy yourself still shows the window as usual." + }, "closeBehavior": { "label": "When closing the main window", "hint": "You will be asked the first time. You can change the choice here later.", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 75eca7b89f..6123b23e31 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -539,6 +539,14 @@ }, "windowBehavior": { "title": "アプリの動作", + "launchAtLogin": { + "label": "ログイン時に Cindy を起動", + "hint": "Windows にサインインすると Cindy を自動的に起動します。タスク マネージャーの「スタートアップ アプリ」からオフにすることもできます。" + }, + "startInTrayOnLogin": { + "label": "ログイン時はトレイで起動", + "hint": "ログイン時の自動起動ではメインウィンドウを表示せず、トレイアイコンだけを残します。アイコンをクリックすると開きます。\n自分で Cindy を起動したときは通常どおりウィンドウが表示されます。" + }, "closeBehavior": { "label": "メインウィンドウを閉じるとき", "hint": "初回に確認します。後からここで変更できます。", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 591a3a85d5..30d68b4875 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -539,6 +539,14 @@ }, "windowBehavior": { "title": "앱 동작", + "launchAtLogin": { + "label": "로그인 시 Cindy 시작", + "hint": "Windows에 로그인하면 Cindy를 자동으로 시작합니다. 작업 관리자의 「시작 앱」에서 끌 수도 있습니다." + }, + "startInTrayOnLogin": { + "label": "로그인 시 트레이로 시작", + "hint": "로그인 시 자동으로 시작할 때는 기본 창을 표시하지 않고 트레이 아이콘만 남깁니다. 아이콘을 클릭하면 열립니다.\n직접 Cindy를 열면 창이 평소대로 표시됩니다." + }, "closeBehavior": { "label": "기본 창을 닫을 때", "hint": "처음 닫을 때 묻습니다. 나중에 여기에서 변경할 수 있습니다.", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index 1dedf30edd..a83a303b69 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -539,6 +539,14 @@ }, "windowBehavior": { "title": "应用行为", + "launchAtLogin": { + "label": "开机时启动 Cindy", + "hint": "登录 Windows 后自动启动 Cindy。你也可以在任务管理器的「启动应用」里关闭它。" + }, + "startInTrayOnLogin": { + "label": "开机启动时收起到托盘", + "hint": "开机自动启动时不显示主窗口,只在系统托盘留一个图标,点击图标即可打开。\n你自己打开 Cindy 时窗口照常显示。" + }, "closeBehavior": { "label": "关闭主窗口时", "hint": "首次关闭时会询问,选择后可随时在这里修改。", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json index 1f3aa5ed11..3bc3469af8 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-TW/common.json @@ -539,6 +539,14 @@ }, "windowBehavior": { "title": "應用行為", + "launchAtLogin": { + "label": "開機時啟動 Cindy", + "hint": "登入 Windows 後自動啟動 Cindy。你也可以在工作管理員的「開機應用程式」裡關閉它。" + }, + "startInTrayOnLogin": { + "label": "開機啟動時收起到系統匣", + "hint": "開機自動啟動時不顯示主視窗,只在系統匣留一個圖示,點擊圖示即可開啟。\n你自己開啟 Cindy 時視窗照常顯示。" + }, "closeBehavior": { "label": "關閉主視窗時", "hint": "首次關閉時會詢問,選擇後可隨時在這裡修改。", diff --git a/apps/desktop/src/renderer/vite-env.d.ts b/apps/desktop/src/renderer/vite-env.d.ts index 6b6ca1eff6..ef1d387ba5 100644 --- a/apps/desktop/src/renderer/vite-env.d.ts +++ b/apps/desktop/src/renderer/vite-env.d.ts @@ -1834,6 +1834,9 @@ interface ElectronAPI { setWindowsCloseBehavior: (behavior: 'quit' | 'tray') => Promise<'quit' | 'tray'>; onWindowsCloseBehaviorRequested: (callback: () => void) => () => void; notifyWindowsCloseBehaviorPromptShown: () => void; + getLaunchAtLogin: () => Promise<{ launchAtLogin: boolean; startInTrayOnLogin: boolean }>; + setLaunchAtLogin: (enabled: boolean) => Promise; + setStartInTrayOnLogin: (enabled: boolean) => Promise<{ ok: true }>; }; workLouderCodex: { diff --git a/apps/desktop/src/shared/windowBehavior.ts b/apps/desktop/src/shared/windowBehavior.ts index ff1285feb4..96cd865797 100644 --- a/apps/desktop/src/shared/windowBehavior.ts +++ b/apps/desktop/src/shared/windowBehavior.ts @@ -1,7 +1,8 @@ /** * windowBehavior — 窗口交互行为相关的 IPC 通道 & 常量。 * - * 承载后台窗口点击行为和 Windows 主窗口关闭行为。 + * 承载后台窗口点击行为、Windows 主窗口关闭行为,以及开机自启动与自启时的 + * 窗口呈现方式。 * * Windows 上此开关由 renderer 层的 `swallowActivationClick.ts` 用 localStorage * 同步读取,toggle 即时生效。macOS 上因为等效能力(`acceptFirstMouse: false`) @@ -27,3 +28,23 @@ export const WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_SHOWN_CHANNEL = export function isWindowsCloseBehavior(value: unknown): value is WindowsCloseBehavior { return value === 'quit' || value === 'tray'; } + +/** + * 开机自启动状态。`launchAtLogin` 是系统登录项的事实状态(由 main 侧向 + * Electron 查询,不自行持久化——用户可能在系统设置或任务管理器里改掉它); + * `startInTrayOnLogin` 是我们自己的偏好,记在 window-behavior-settings.json。 + * + * 两者独立:关掉自启动不清除 startInTrayOnLogin,用户重新打开自启动时保留 + * 原来的选择。 + */ +export interface LaunchAtLoginState { + launchAtLogin: boolean; + startInTrayOnLogin: boolean; +} + +export const WINDOW_BEHAVIOR_GET_LAUNCH_AT_LOGIN_CHANNEL = + 'window-behavior:get-launch-at-login'; +export const WINDOW_BEHAVIOR_SET_LAUNCH_AT_LOGIN_CHANNEL = + 'window-behavior:set-launch-at-login'; +export const WINDOW_BEHAVIOR_SET_START_IN_TRAY_ON_LOGIN_CHANNEL = + 'window-behavior:set-start-in-tray-on-login';