Files
lgp-admin-plus/apps/desktop/src/main/ipc.ts
2026-08-13 19:04:28 +08:00

224 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
}