Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Deploy Website on push / Rerun on failure (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|