Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 203 additions & 0 deletions apps/desktop/src/main/__tests__/launchAtLogin.test.ts
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
Expand Up @@ -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);
});
});
60 changes: 58 additions & 2 deletions apps/desktop/src/main/bootstrap-electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Comment on lines +3723 to +3726

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 避免把登录启动标记继承到主动重启

当当前进程确实由登录项启动时,--opened-at-login 会在 process.argv 中保留整个会话;而 updateService.ts 的更新通道切换及本文件的数据库清理都调用无参数 app.relaunch(),Electron 会默认把当前参数传给新实例。用户稍后主动执行这些重启时,新进程仍会在这里被判定为登录启动并再次隐藏到托盘,导致点击“重启”后窗口消失;这些 relaunch 路径需要过滤该一次性标记,或采用不会被应用内重启继承的启动来源判定。

Useful? React with 👍 / 👎.

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 和首份进程快照预热;回调绑定当代主窗口,重建/退出后不会创建孤儿窗。
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 在 handler 中拒绝非 Windows 的登录项写入

即便已经校验 sender,这个新 handler 仍可被任意 Cindy 顶层 renderer 调用,而 preload 在所有平台都暴露了该方法;因此在 macOS 上,遭遇 XSS 的 renderer 可以绕过仅存在于 UI 的 isWindows 判断,调用 Electron 同样支持的 setLoginItemSettings() 建立持久化登录项。请在 main 的写入 handler 中显式校验 process.platform === 'win32',不要把隐藏设置项当成权限边界。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 将登录设置写入失败转换为统一 IPC 错误

当设置文件只读、磁盘已满或系统登录项 API 抛错时,这里和上面的 writeLaunchAtLogin() 会让底层异常直接穿过新 IPC handler;文件写入错误通常包含用户数据目录等内部绝对路径,而且不符合 renderer 依赖的 [IpcErrorCode] 协议。请捕获两个写入调用并通过 throwIpcError('INTERNAL', ...) 返回受控错误。

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();
Expand Down
Loading