-
Notifications
You must be signed in to change notification settings - Fork 331
feat(desktop): add launch at login and start-in-tray options #3575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
bc76e70
d845cd8
6ef0746
f0782c4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, boolean>(); | ||
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<LaunchAtLoginState> => { | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 即便已经校验 sender,这个新 handler 仍可被任意 Cindy 顶层 renderer 调用,而 preload 在所有平台都暴露了该方法;因此在 macOS 上,遭遇 XSS 的 renderer 可以绕过仅存在于 UI 的 AGENTS.md reference: AGENTS.md:L30-L31 Useful? React with 👍 / 👎. |
||
| }); | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 当设置文件只读、磁盘已满或系统登录项 API 抛错时,这里和上面的 AGENTS.md reference: AGENTS.md:L103-L105 Useful? React with 👍 / 👎. |
||
| return { ok: true as const }; | ||
| }, | ||
| ); | ||
| ipcMain.on(WINDOW_BEHAVIOR_WINDOWS_CLOSE_BEHAVIOR_SHOWN_CHANNEL, (event) => { | ||
| if (BrowserWindow.fromWebContents(event.sender) === mainWindowRef) { | ||
| windowsClosePromptFallback.acknowledge(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
当当前进程确实由登录项启动时,
--opened-at-login会在process.argv中保留整个会话;而updateService.ts的更新通道切换及本文件的数据库清理都调用无参数app.relaunch(),Electron 会默认把当前参数传给新实例。用户稍后主动执行这些重启时,新进程仍会在这里被判定为登录启动并再次隐藏到托盘,导致点击“重启”后窗口消失;这些 relaunch 路径需要过滤该一次性标记,或采用不会被应用内重启继承的启动来源判定。Useful? React with 👍 / 👎.