/** * IPC 通道注册:渲染层 window.desktopAPI 各能力的主进程实现 * 安全约定:所有 handler 统一先校验 sender 来源(app://local 或本地 dev server), * 防止意外加载的第三方页面调用桌面能力 */ import type { IpcMainInvokeEvent } from 'electron'; import { app, BrowserWindow, ipcMain, nativeImage, Notification, } from 'electron'; import log from 'electron-log/main'; import { PRODUCT_NAME } from './config'; import { SETTING_KEYS, settingsStore } from './store'; import { refreshTrayMenu } from './tray'; import { checkUpdateManually, quitAndInstallNow } from './updater'; import { getAutoLaunchEnabled, getResourcePath, setAutoLaunchEnabled, } from './utils'; import { getMainWindow, isTrustedWebContentsUrl, showMainWindow, } from './window'; /** 渲染层 notify() 的入参结构(与 preload 保持同步) */ interface NotifyOptions { body?: string; flash?: boolean; route?: string; silent?: boolean; title: string; } /** 渲染层 printHTML() 的入参结构(与 preload 保持同步) */ interface PrintHTMLOptions { deviceName?: string; html: string; landscape?: boolean; silent?: boolean; } /** * 带来源校验的 ipcMain.handle 包装 * 为什么不用裸 ipcMain.handle:每个通道都要校验 sender,包一层避免每处复制粘贴校验代码 */ function handle( channel: string, handler: (event: IpcMainInvokeEvent, ...args: any[]) => unknown, ): void { ipcMain.handle(channel, (event, ...args) => { const senderUrl = event.senderFrame?.url ?? ''; if (!isTrustedWebContentsUrl(senderUrl)) { log.warn('[ipc] 拒绝不可信来源的 IPC 调用:', channel, senderUrl); throw new Error('IPC 调用来源不可信'); } return handler(event, ...args); }); } /** 注册全部 IPC 通道,应用启动时调用一次 */ export function registerIpcHandlers(): void { handle('desktop:get-version', () => app.getVersion()); // ---- 系统通知 ---- handle('desktop:notify', (_event, options: NotifyOptions) => { showNotification(options); }); // ---- 未读角标(dataUrl 由 preload 的 canvas 绘制,仅 Windows 用到) ---- handle('desktop:set-badge', (_event, count: number, dataUrl: string) => { setBadge(count, dataUrl); }); // ---- 任务栏闪烁 ---- handle('desktop:flash-frame', (_event, flag: boolean) => { const win = getMainWindow(); if (!win) return; // 已聚焦时闪烁没有意义(还会造成任务栏图标闪一下的视觉bug) if (flag && win.isFocused()) return; win.flashFrame(!!flag); }); // ---- 开机自启 ---- handle('desktop:get-auto-launch', () => getAutoLaunchEnabled()); handle('desktop:set-auto-launch', (_event, enabled: boolean) => { const result = setAutoLaunchEnabled(!!enabled); // 渲染层改了自启后,托盘菜单的勾选状态要同步 refreshTrayMenu(); return result; }); // ---- 静默打印 ---- handle('desktop:get-printers', async () => { const win = getMainWindow(); return win ? await win.webContents.getPrintersAsync() : []; }); handle('desktop:print-html', (_event, options: PrintHTMLOptions) => printHTML(options), ); // ---- 桌面端本地设置 ---- handle('desktop:get-setting', (_event, key: string) => settingsStore.get(key)); handle('desktop:set-setting', (_event, key: string, value: unknown) => { settingsStore.set(key, value); }); // ---- 更新 ---- handle('desktop:check-update', () => checkUpdateManually()); handle('desktop:quit-and-install', () => quitAndInstallNow()); } /** * 弹系统通知:点击唤起主窗口,携带 route 时再通知渲染层跳转站内路由 * Windows 能弹的前提:app.setAppUserModelId 与安装包 appId 一致(入口处已设置) */ function showNotification(options: NotifyOptions): void { if (!Notification.isSupported()) { log.warn('[ipc] 当前系统不支持通知'); return; } const notification = new Notification({ body: options.body ?? '', icon: nativeImage.createFromPath(getResourcePath('icon.png')), silent: options.silent ?? false, title: options.title || PRODUCT_NAME, }); notification.on('click', () => { showMainWindow(); if (options.route) { getMainWindow()?.webContents.send('desktop:navigate', options.route); } }); notification.show(); // 默认行为:窗口未聚焦时同步闪烁任务栏(mac 表现为 Dock 弹跳),聚焦后自动清除 const win = getMainWindow(); if ((options.flash ?? true) && win && !win.isFocused()) { win.flashFrame(true); } } /** * 设置未读角标,按平台分支: * mac 用 Dock 原生数字角标;Windows 用 preload 画好的红底数字图做任务栏覆盖图; * Linux 尽力而为走 setBadgeCount(仅部分桌面环境支持) */ function setBadge(count: number, dataUrl: string): void { const normalized = Math.max(0, Math.floor(count) || 0); if (process.platform === 'darwin') { app.dock?.setBadge(normalized > 0 ? String(normalized) : ''); return; } if (process.platform === 'win32') { const win = getMainWindow(); if (!win) return; if (normalized > 0 && dataUrl) { win.setOverlayIcon( nativeImage.createFromDataURL(dataUrl), `${normalized} 条未读`, ); } else { win.setOverlayIcon(null, ''); } return; } app.setBadgeCount(normalized); } /** * 打印一段完整 HTML * 实现:隐藏窗口加载 HTML(base64 data URL 避免特殊字符截断)→ webContents.print * 静默打印必须有明确的打印机名(入参或本地设置),否则自动降级为弹系统打印对话框 */ async function printHTML( options: PrintHTMLOptions, ): Promise<{ message?: string; success: boolean }> { const deviceName = options.deviceName || (settingsStore.get(SETTING_KEYS.printerDeviceName) as string | undefined); const silent = (options.silent ?? true) && !!deviceName; const printWindow = new BrowserWindow({ show: false, webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); try { const dataUrl = `data:text/html;charset=utf-8;base64,${Buffer.from( options.html, 'utf8', ).toString('base64')}`; await printWindow.loadURL(dataUrl); return await new Promise((resolvePrint) => { printWindow.webContents.print( { deviceName: silent ? deviceName : undefined, landscape: options.landscape ?? false, printBackground: true, silent, }, (success, failureReason) => { resolvePrint({ message: success ? undefined : failureReason, success, }); }, ); }); } catch (error) { log.error('[ipc] 打印失败:', error); return { message: String(error), success: false }; } finally { if (!printWindow.isDestroyed()) printWindow.destroy(); } }