AI代码生成工具、个人中心
This commit is contained in:
12
apps/desktop/src/env.d.ts
vendored
Normal file
12
apps/desktop/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* electron-vite 构建期注入的环境变量类型声明
|
||||
* 值来源见 apps/desktop/.env.development / .env.production
|
||||
*/
|
||||
interface ImportMetaEnv {
|
||||
/** /api 转发上游地址(MAIN_VITE_ 前缀 = 仅主进程可见) */
|
||||
readonly MAIN_VITE_API_UPSTREAM?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
54
apps/desktop/src/main/config.ts
Normal file
54
apps/desktop/src/main/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 桌面端全局配置常量
|
||||
*
|
||||
* 所有「待确认占位项」集中在本文件(TODO 标注),后续对齐生产环境后只需改这里,
|
||||
* 不要把这些常量散落到其它模块(避免上线前漏改)。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 应用唯一标识
|
||||
* 必须与 electron-builder.yml 的 appId 完全一致:
|
||||
* Windows 通知依赖 AppUserModelID(AUMID)与安装包快捷方式的 appId 对齐,不一致通知不弹
|
||||
*/
|
||||
export const APP_ID = 'com.nladmin.desktop';
|
||||
|
||||
/** 产品名:窗口标题、托盘 tooltip、通知标题兜底 */
|
||||
export const PRODUCT_NAME = 'Nl Admin';
|
||||
|
||||
/** app:// 自定义协议的 scheme 与 host,生产环境渲染层 Origin 固定为 app://local */
|
||||
export const APP_SCHEME = 'app';
|
||||
export const APP_HOST = 'local';
|
||||
|
||||
/**
|
||||
* /api 上游地址:按构建模式从 env 文件注入(electron-vite 构建期替换)
|
||||
* - 联调包 pnpm build:desktop:dev → .env.development(固定本地后端)
|
||||
* - 正式包 pnpm build:desktop → .env.production(TODO:生产域名确认后改该文件)
|
||||
* 转发语义与 apps/web-antd/vite.config.ts 的 dev 代理完全一致:
|
||||
* 渲染层请求 /api/xxx → 剥掉 /api 前缀 → 转发到 `${API_UPSTREAM}/xxx`
|
||||
* 兜底本地后端,保证 env 缺失时包仍可用
|
||||
*/
|
||||
export const API_UPSTREAM =
|
||||
import.meta.env.MAIN_VITE_API_UPSTREAM ?? 'http://127.0.0.1:18006/api';
|
||||
|
||||
/** 渲染层请求统一前缀:与桌面端动态下发的 _app.config.js 中 VITE_GLOB_API_URL 保持一致 */
|
||||
export const API_PREFIX = '/api';
|
||||
|
||||
/**
|
||||
* TODO(待确认):自动更新托管地址(1panel 加一个静态站点即可)
|
||||
* 目录内需存放 electron-builder 产物:
|
||||
* Windows:latest.yml + exe + blockmap;mac:latest-mac.yml + dmg + zip + blockmap
|
||||
*/
|
||||
export const UPDATE_URL = 'https://updates.example.com/nl-desktop/';
|
||||
|
||||
/** TODO(待确认):mac 未签名时自动更新降级提示跳转的手动下载页,默认复用更新托管地址 */
|
||||
export const DOWNLOAD_PAGE_URL = UPDATE_URL;
|
||||
|
||||
/**
|
||||
* 开发模式渲染层地址:web-antd 的 vite dev server
|
||||
* 注意端口是 14002(apps/web-antd/vite.config.ts 的 server.port 覆盖了 .env.development 的 5666)
|
||||
*/
|
||||
export const DEV_SERVER_URL =
|
||||
process.env.ELECTRON_RENDERER_URL ?? 'http://localhost:14002';
|
||||
|
||||
/** 全局快捷键:切换主窗口显示/隐藏(CommandOrControl 自动映射 Windows=Ctrl / mac=Cmd) */
|
||||
export const GLOBAL_SHORTCUT = 'CommandOrControl+Alt+N';
|
||||
66
apps/desktop/src/main/index.ts
Normal file
66
apps/desktop/src/main/index.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 主进程入口:装配协议、窗口、托盘、IPC、快捷键、更新器
|
||||
* 启动顺序有讲究:协议特权注册必须在 app ready 之前,其余都在 whenReady 之后
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { APP_ID, PRODUCT_NAME } from './config';
|
||||
import { registerIpcHandlers } from './ipc';
|
||||
import {
|
||||
installAppProtocolHandler,
|
||||
registerAppSchemePrivileges,
|
||||
} from './protocol';
|
||||
import { registerGlobalShortcut } from './shortcut';
|
||||
import { appState } from './state';
|
||||
import { createTray } from './tray';
|
||||
import { initUpdater } from './updater';
|
||||
import {
|
||||
createMainWindow,
|
||||
setupSessionSecurity,
|
||||
showMainWindow,
|
||||
} from './window';
|
||||
|
||||
// 日志落盘到 userData/logs,线上问题靠它排查
|
||||
log.initialize();
|
||||
log.transports.file.level = 'info';
|
||||
|
||||
// Windows 通知/任务栏分组依赖 AUMID,必须与 electron-builder.yml 的 appId 完全一致
|
||||
app.setAppUserModelId(APP_ID);
|
||||
|
||||
// app:// 特权协议注册(Electron 要求必须在 ready 之前)
|
||||
registerAppSchemePrivileges();
|
||||
|
||||
// 单实例锁:重复启动时唤起已有窗口而不是开第二个实例
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (gotSingleInstanceLock) {
|
||||
app.on('second-instance', () => showMainWindow());
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
setupSessionSecurity();
|
||||
installAppProtocolHandler();
|
||||
registerIpcHandlers();
|
||||
// --hidden 由开机自启注入(见 utils.setAutoLaunchEnabled):创建窗口但静默待在托盘
|
||||
const startHidden = process.argv.includes('--hidden');
|
||||
await createMainWindow({ startHidden });
|
||||
createTray();
|
||||
registerGlobalShortcut();
|
||||
initUpdater();
|
||||
log.info(`[main] ${PRODUCT_NAME} 启动完成 hidden=${startHidden}`);
|
||||
});
|
||||
|
||||
// mac:点击 Dock 图标恢复窗口(关窗只是隐藏,窗口实例一直存活)
|
||||
app.on('activate', () => showMainWindow());
|
||||
|
||||
// 所有窗口关闭也不退出:应用常驻托盘,真正退出走托盘菜单 / Cmd+Q
|
||||
app.on('window-all-closed', () => {
|
||||
// 故意留空:覆盖 Electron 默认的「非 mac 全关即退出」行为
|
||||
});
|
||||
|
||||
// Cmd+Q / app.quit() 都会先经过这里:放行窗口 close(不再拦截为隐藏)
|
||||
app.on('before-quit', () => {
|
||||
appState.quitting = true;
|
||||
});
|
||||
} else {
|
||||
app.quit();
|
||||
}
|
||||
223
apps/desktop/src/main/ipc.ts
Normal file
223
apps/desktop/src/main/ipc.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
223
apps/desktop/src/main/protocol.ts
Normal file
223
apps/desktop/src/main/protocol.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* app:// 自定义协议层——桌面端的「内置 nginx」
|
||||
*
|
||||
* 生产环境渲染进程加载 app://local/,本模块负责三件事:
|
||||
* 1. 静态文件服务:把 web-antd 的生产 dist(extraResources 拷入 resources/web)按路径吐出,
|
||||
* 并做 SPA fallback(等价 nginx try_files;本项目生产是 hash 路由,fallback 仅兜底)
|
||||
* 2. 动态覆盖 /_app.config.js:利用 vben 的运行时配置机制,把 VITE_GLOB_API_URL 改写为 /api,
|
||||
* 使桌面端 API 指向与网页端部署解耦(网页端 .env.production 完全不用动)
|
||||
* 3. 转发 /api/*:用主进程 net.fetch 转发到后端上游(剥掉 /api 前缀,语义与
|
||||
* apps/web-antd/vite.config.ts 的 dev 代理 rewrite 一致),因为是主进程发起、
|
||||
* 与页面同源(app://local),所以后端无需配置 CORS
|
||||
*/
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { extname, isAbsolute, join, normalize, relative, resolve } from 'node:path';
|
||||
|
||||
import { app, net, protocol } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { API_PREFIX, API_UPSTREAM, APP_HOST, APP_SCHEME } from './config';
|
||||
|
||||
/** 常见静态资源的 MIME 映射(dist 内实际会出现的类型) */
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
'.avif': 'image/avif',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.eot': 'application/vnd.ms-fontobject',
|
||||
'.gif': 'image/gif',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.ico': 'image/x-icon',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.otf': 'font/otf',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ttf': 'font/ttf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.xml': 'application/xml; charset=utf-8',
|
||||
};
|
||||
|
||||
/**
|
||||
* vben 运行时配置文件的路径模式
|
||||
* vben 5.x 产出 `_app-config-{version}-{hash}.js`(本仓库实测),历史版本为 `_app.config.js`,
|
||||
* 用模式匹配兼容两种命名,避免升级 vben 后改写逻辑悄悄失效
|
||||
*/
|
||||
const APP_CONFIG_FILE_RE = /^\/_app[.-]config[^/]*\.js$/;
|
||||
|
||||
/**
|
||||
* 注册 app:// 为特权协议
|
||||
* 必须在 app ready 之前调用(Electron 硬性要求),否则协议没有 standard/secure 等特权:
|
||||
* - standard:拥有标准 Origin 语义,localStorage / IndexedDB 才可用(登录态依赖)
|
||||
* - secure:视为 Secure Context,clipboard 等现代 Web 能力才可用
|
||||
* - supportFetchAPI + corsEnabled:允许渲染层 fetch/XHR 请求本协议(axios 调 /api 必需)
|
||||
* - stream:允许流式响应;codeCache:v8 代码缓存加速二次启动
|
||||
*/
|
||||
export function registerAppSchemePrivileges(): void {
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: APP_SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
codeCache: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算 web 静态资源根目录
|
||||
* 打包后:resources/web(electron-builder extraResources 从 ../web-antd/dist 拷入)
|
||||
* 开发态:直接指向 apps/web-antd/dist(用于本地不打包也能验证 app:// 生产链路)
|
||||
*/
|
||||
function getWebRoot(): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, 'web')
|
||||
: resolve(app.getAppPath(), '../web-antd/dist');
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装 app:// 协议处理器,必须在 app ready 之后调用
|
||||
*/
|
||||
export function installAppProtocolHandler(): void {
|
||||
protocol.handle(APP_SCHEME, async (request) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
// 只认 app://local,其它 host 一律 404,避免协议被意外扩散使用
|
||||
if (url.host !== APP_HOST) {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
// 1) /api 前缀 → 转发到后端上游
|
||||
if (pathname === API_PREFIX || pathname.startsWith(`${API_PREFIX}/`)) {
|
||||
return await forwardApiRequest(request, url);
|
||||
}
|
||||
// 2) vben 运行时配置文件 → 动态改写 API 地址后下发
|
||||
if (APP_CONFIG_FILE_RE.test(pathname)) {
|
||||
return await serveAppConfigOverride(pathname);
|
||||
}
|
||||
// 3) 其余走静态文件服务 + SPA fallback
|
||||
return await serveStatic(pathname);
|
||||
} catch (error) {
|
||||
log.error('[protocol] 处理请求异常:', request.url, error);
|
||||
return new Response('Internal Error', { status: 500 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 转发 /api/* 请求到后端上游
|
||||
* 为什么整体缓冲 body 再转发:管理后台的上传体量可控(几 MB 级),
|
||||
* 缓冲比流式转发(需处理 duplex half 兼容性)简单可靠得多
|
||||
*/
|
||||
async function forwardApiRequest(request: Request, url: URL): Promise<Response> {
|
||||
// 剥掉 /api 前缀,保留其后的路径与查询串
|
||||
const restPath = url.pathname.slice(API_PREFIX.length) || '/';
|
||||
const upstream = API_UPSTREAM.replace(/\/+$/, '');
|
||||
const target = `${upstream}${restPath}${url.search}`;
|
||||
const headers = new Headers(request.headers);
|
||||
// Origin/Referer 是 app://local,对上游没有意义,转发时移除避免干扰后端框架的来源判断
|
||||
headers.delete('origin');
|
||||
headers.delete('referer');
|
||||
try {
|
||||
const hasBody = !['GET', 'HEAD'].includes(request.method.toUpperCase());
|
||||
return await net.fetch(target, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: hasBody ? await request.arrayBuffer() : undefined,
|
||||
redirect: 'follow',
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('[protocol] API 转发失败:', target, error);
|
||||
// 返回 vben 响应拦截器可识别的结构,前端会用 message 弹错误提示
|
||||
return new Response(
|
||||
JSON.stringify({ code: 502, message: '无法连接后端服务,请检查网络后重试' }),
|
||||
{
|
||||
status: 502,
|
||||
headers: { 'content-type': 'application/json; charset=utf-8' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态下发 vben 运行时配置文件
|
||||
* vben 生产构建会产出该文件定义 window._VBEN_ADMIN_PRO_APP_CONF_(含 VITE_GLOB_API_URL),
|
||||
* 桌面端读取 dist 里的原文件、只把 VITE_GLOB_API_URL 改写为 /api(其余运行时配置键原样保留),
|
||||
* 这样 axios 的 baseURL 变成同源相对路径,全部命中上面的 /api 转发
|
||||
*/
|
||||
async function serveAppConfigOverride(pathname: string): Promise<Response> {
|
||||
const filePath = join(getWebRoot(), pathname.replace(/^\/+/, ''));
|
||||
let content: string;
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf8');
|
||||
content = raw.replace(
|
||||
/("VITE_GLOB_API_URL"\s*:\s*")[^"]*(")/,
|
||||
`$1${API_PREFIX}$2`,
|
||||
);
|
||||
log.info('[protocol] 运行时配置已改写 VITE_GLOB_API_URL → /api:', pathname);
|
||||
} catch {
|
||||
// 文件读不到时兜底生成最小可用配置(理论上 vben 构建一定会产出)
|
||||
log.warn('[protocol] 运行时配置文件缺失,使用兜底配置:', pathname);
|
||||
content = `window._VBEN_ADMIN_PRO_APP_CONF_={"VITE_GLOB_API_URL":"${API_PREFIX}"};Object.freeze(window._VBEN_ADMIN_PRO_APP_CONF_);`;
|
||||
}
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'content-type': 'text/javascript; charset=utf-8',
|
||||
// 运行时配置不缓存,保证改上游地址后重启即生效
|
||||
'cache-control': 'no-cache',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 静态文件服务 + SPA fallback
|
||||
* 带目录穿越防护:解析后的物理路径必须仍在 webRoot 内
|
||||
*/
|
||||
async function serveStatic(pathname: string): Promise<Response> {
|
||||
const webRoot = normalize(getWebRoot());
|
||||
const relPath = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
|
||||
let filePath = normalize(join(webRoot, relPath));
|
||||
// 目录穿越防护:必须用 path.relative 判断真实包含关系。
|
||||
// 不能用 filePath.startsWith(webRoot) —— 那样 `resources\web` 的同级目录
|
||||
// `resources\website` 也会命中前缀,从而逃出 web 根读到相邻目录文件
|
||||
const rel = relative(webRoot, filePath);
|
||||
if (rel.startsWith('..') || isAbsolute(rel)) {
|
||||
return new Response('Forbidden', { status: 403 });
|
||||
}
|
||||
// 文件不存在或是目录时回退 index.html(等价 nginx try_files $uri /index.html)
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (!fileStat || fileStat.isDirectory()) {
|
||||
filePath = join(webRoot, 'index.html');
|
||||
}
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const mime =
|
||||
MIME_TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
|
||||
return new Response(data as unknown as BodyInit, {
|
||||
headers: { 'content-type': mime },
|
||||
});
|
||||
} catch (error) {
|
||||
// 连 index.html 都没有:一般是没先构建 web-antd,给出可读的提示页
|
||||
log.error('[protocol] 静态资源读取失败:', filePath, error);
|
||||
return new Response(
|
||||
'<h3>未找到 web 构建产物</h3><p>请先在仓库根目录执行 <code>pnpm build</code> 生成 apps/web-antd/dist 后重试。</p>',
|
||||
{
|
||||
status: 404,
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
33
apps/desktop/src/main/shortcut.ts
Normal file
33
apps/desktop/src/main/shortcut.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 全局快捷键:一键唤起/隐藏主窗口(默认 CommandOrControl+Alt+N,见 config.ts)
|
||||
*/
|
||||
import { app, globalShortcut } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import { GLOBAL_SHORTCUT } from './config';
|
||||
import { getMainWindow, showMainWindow } from './window';
|
||||
|
||||
/**
|
||||
* 注册全局快捷键
|
||||
* 快捷键可能被其它程序占用:注册失败只记录告警,绝不影响应用启动
|
||||
*/
|
||||
export function registerGlobalShortcut(): void {
|
||||
const ok = globalShortcut.register(GLOBAL_SHORTCUT, () => {
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
// 可见且聚焦 → 隐藏;否则(隐藏/最小化/失焦)→ 唤起到前台
|
||||
if (win.isVisible() && win.isFocused()) {
|
||||
win.hide();
|
||||
} else {
|
||||
showMainWindow();
|
||||
}
|
||||
});
|
||||
if (!ok) {
|
||||
log.warn(
|
||||
'[shortcut] 全局快捷键注册失败(可能被其它程序占用):',
|
||||
GLOBAL_SHORTCUT,
|
||||
);
|
||||
}
|
||||
// 退出前必须注销,否则某些平台会残留系统级占用
|
||||
app.on('will-quit', () => globalShortcut.unregisterAll());
|
||||
}
|
||||
11
apps/desktop/src/main/state.ts
Normal file
11
apps/desktop/src/main/state.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* 主进程共享运行时状态
|
||||
* 单独成模块是为了避免 window.ts / tray.ts / updater.ts 之间互相 import 造成循环依赖
|
||||
*/
|
||||
export const appState = {
|
||||
/**
|
||||
* 是否处于「真正退出」流程(托盘菜单退出 / mac Cmd+Q / 更新重启安装)
|
||||
* false 时点击窗口关闭按钮只隐藏到托盘,不退出应用
|
||||
*/
|
||||
quitting: false,
|
||||
};
|
||||
16
apps/desktop/src/main/store.ts
Normal file
16
apps/desktop/src/main/store.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 桌面端本地设置持久化(electron-store,落盘到 userData/settings.json)
|
||||
* 存放:静默打印选中的打印机、托盘首次隐藏提示标记等轻量配置
|
||||
* 统一从这里取单例,禁止各模块自行 new Store 造成多份文件
|
||||
*/
|
||||
import Store from 'electron-store';
|
||||
|
||||
/** 设置项的键值约定(集中声明避免魔法字符串散落) */
|
||||
export const SETTING_KEYS = {
|
||||
/** 静默打印默认打印机名称 */
|
||||
printerDeviceName: 'printer.deviceName',
|
||||
/** Windows 关闭进托盘的首次提示是否已展示 */
|
||||
closeHintShown: 'tray.closeHintShown',
|
||||
} as const;
|
||||
|
||||
export const settingsStore = new Store({ name: 'settings' });
|
||||
66
apps/desktop/src/main/tray.ts
Normal file
66
apps/desktop/src/main/tray.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 系统托盘:应用常驻入口
|
||||
* Windows:关闭窗口后靠托盘活着,单击托盘还原窗口
|
||||
* mac:托盘(菜单栏图标)仅提供快捷菜单,窗口恢复主要靠 Dock 的 activate 事件
|
||||
*/
|
||||
import { app, Menu, nativeImage, Tray } from 'electron';
|
||||
|
||||
import { PRODUCT_NAME } from './config';
|
||||
import { appState } from './state';
|
||||
import { checkUpdateManually } from './updater';
|
||||
import { getAutoLaunchEnabled, getResourcePath, setAutoLaunchEnabled } from './utils';
|
||||
import { showMainWindow } from './window';
|
||||
|
||||
/** 托盘单例(必须持有引用,否则会被 GC 导致图标消失) */
|
||||
let tray: null | Tray = null;
|
||||
|
||||
/**
|
||||
* 创建托盘图标与菜单
|
||||
* 托盘直接用品牌应用图标缩小(resources/icon.png,年糕猫+奶酪)
|
||||
*/
|
||||
export function createTray(): Tray {
|
||||
// TODO(待确认):mac 后续可换 Template 单色托盘图(setTemplateImage 随菜单栏深浅色自动反色)
|
||||
const icon = nativeImage
|
||||
.createFromPath(getResourcePath('icon.png'))
|
||||
.resize({ height: 16, width: 16 });
|
||||
tray = new Tray(icon);
|
||||
tray.setToolTip(PRODUCT_NAME);
|
||||
refreshTrayMenu();
|
||||
// Windows 惯例:单击托盘直接还原主窗口;mac 惯例是单击弹菜单,不抢行为
|
||||
if (process.platform !== 'darwin') {
|
||||
tray.on('click', () => showMainWindow());
|
||||
}
|
||||
return tray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建托盘右键菜单
|
||||
* 单独导出是因为「开机自启」勾选状态可能被渲染层(desktopAPI.setAutoLaunch)改掉,
|
||||
* 改完需要同步刷新菜单勾选态
|
||||
*/
|
||||
export function refreshTrayMenu(): void {
|
||||
if (!tray) return;
|
||||
const menu = Menu.buildFromTemplate([
|
||||
{ click: () => showMainWindow(), label: '显示主窗口' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
checked: getAutoLaunchEnabled(),
|
||||
click: (item) => {
|
||||
setAutoLaunchEnabled(item.checked);
|
||||
},
|
||||
label: '开机自启',
|
||||
type: 'checkbox',
|
||||
},
|
||||
{ click: () => checkUpdateManually(), label: '检查更新' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
click: () => {
|
||||
// 标记真正退出,绕过窗口 close 事件里的「隐藏到托盘」拦截
|
||||
appState.quitting = true;
|
||||
app.quit();
|
||||
},
|
||||
label: '退出',
|
||||
},
|
||||
]);
|
||||
tray.setContextMenu(menu);
|
||||
}
|
||||
154
apps/desktop/src/main/updater.ts
Normal file
154
apps/desktop/src/main/updater.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 自动更新:electron-updater(generic provider,消费静态托管的 latest*.yml + 安装包)
|
||||
* Windows 消费 latest.yml + exe;mac 消费 latest-mac.yml + zip
|
||||
* mac 未签名包无法通过更新签名校验:捕获后降级为「前往下载页手动安装」提示
|
||||
*/
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
|
||||
import { app, dialog, shell } from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
import electronUpdater from 'electron-updater';
|
||||
|
||||
import { DOWNLOAD_PAGE_URL, PRODUCT_NAME } from './config';
|
||||
import { appState } from './state';
|
||||
import { getMainWindow } from './window';
|
||||
|
||||
// electron-updater 是 CJS 包,ESM 下先整体导入再解构(named import 在部分打包场景会失败)
|
||||
const { autoUpdater } = electronUpdater;
|
||||
|
||||
/** 是否用户手动触发的检查:手动才弹「已是最新/失败」反馈,启动时的静默检查不打扰用户 */
|
||||
let manualCheck = false;
|
||||
/** 防止重复初始化事件监听 */
|
||||
let initialized = false;
|
||||
|
||||
/** 把更新流程事件转发给渲染层(web 侧可用 desktopAPI.onUpdateEvent 展示下载进度) */
|
||||
function sendUpdateEvent(type: string, payload?: unknown): void {
|
||||
getMainWindow()?.webContents.send('desktop:update-event', { payload, type });
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化更新器:注册事件 + 启动延时静默检查
|
||||
* 只在打包环境生效(开发态没有 app-update.yml,检查必然报错)
|
||||
*/
|
||||
export function initUpdater(): void {
|
||||
if (!app.isPackaged || initialized) return;
|
||||
initialized = true;
|
||||
|
||||
autoUpdater.logger = log;
|
||||
autoUpdater.autoDownload = true;
|
||||
// 用户选「退出时安装」时靠这个兜底自动装
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
autoUpdater.on('checking-for-update', () => sendUpdateEvent('checking'));
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) =>
|
||||
sendUpdateEvent('available', { version: info.version }),
|
||||
);
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
sendUpdateEvent('not-available');
|
||||
if (manualCheck) {
|
||||
dialog.showMessageBox({
|
||||
message: '当前已是最新版本',
|
||||
title: PRODUCT_NAME,
|
||||
type: 'info',
|
||||
});
|
||||
}
|
||||
manualCheck = false;
|
||||
});
|
||||
autoUpdater.on('download-progress', (progress) =>
|
||||
sendUpdateEvent('progress', {
|
||||
percent: progress.percent,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
}),
|
||||
);
|
||||
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
|
||||
sendUpdateEvent('downloaded', { version: info.version });
|
||||
void promptInstall(info);
|
||||
});
|
||||
autoUpdater.on('error', (error) => {
|
||||
const message = String(error?.message ?? error);
|
||||
sendUpdateEvent('error', { message });
|
||||
log.error('[updater] 更新出错:', message);
|
||||
// mac 未签名包的签名校验失败:降级为手动下载引导
|
||||
const isMacSignatureIssue =
|
||||
process.platform === 'darwin' &&
|
||||
/code signature|codesign|not signed/i.test(message);
|
||||
if (isMacSignatureIssue) {
|
||||
void promptManualDownload();
|
||||
} else if (manualCheck) {
|
||||
dialog.showMessageBox({
|
||||
detail: message,
|
||||
message: '检查更新失败',
|
||||
title: PRODUCT_NAME,
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
manualCheck = false;
|
||||
});
|
||||
|
||||
// 启动 8 秒后静默检查:等窗口/协议就绪,也避免抢启动期资源
|
||||
setTimeout(() => {
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.catch((error) => log.warn('[updater] 启动静默检查失败:', error));
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
/** 手动检查更新(托盘菜单 / desktopAPI.checkUpdate 共用入口) */
|
||||
export function checkUpdateManually(): void {
|
||||
if (!app.isPackaged) {
|
||||
dialog.showMessageBox({
|
||||
message: '开发模式不支持检查更新',
|
||||
title: PRODUCT_NAME,
|
||||
type: 'info',
|
||||
});
|
||||
return;
|
||||
}
|
||||
manualCheck = true;
|
||||
autoUpdater
|
||||
.checkForUpdates()
|
||||
.catch((error) => log.warn('[updater] 手动检查失败:', error));
|
||||
}
|
||||
|
||||
/** 立即重启安装(更新属于真正退出,先置 quitting 绕过「关闭进托盘」拦截) */
|
||||
export function quitAndInstallNow(): void {
|
||||
appState.quitting = true;
|
||||
// isSilent=false 走正常安装界面;isForceRunAfter=true 装完自动拉起新版本
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
|
||||
/** 下载完成后的安装确认弹窗 */
|
||||
async function promptInstall(info: UpdateInfo): Promise<void> {
|
||||
const options = {
|
||||
buttons: ['立即重启安装', '退出时安装'],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
detail: '选择「退出时安装」则下次退出应用时自动完成升级。',
|
||||
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
|
||||
title: PRODUCT_NAME,
|
||||
type: 'info' as const,
|
||||
};
|
||||
const win = getMainWindow();
|
||||
const { response } = win
|
||||
? await dialog.showMessageBox(win, options)
|
||||
: await dialog.showMessageBox(options);
|
||||
if (response === 0) {
|
||||
quitAndInstallNow();
|
||||
}
|
||||
}
|
||||
|
||||
/** mac 未签名降级:引导用户去下载页手动装新版本 */
|
||||
async function promptManualDownload(): Promise<void> {
|
||||
const { response } = await dialog.showMessageBox({
|
||||
buttons: ['打开下载页', '取消'],
|
||||
cancelId: 1,
|
||||
defaultId: 0,
|
||||
detail: '当前安装包未进行代码签名,无法自动更新,请前往下载页手动安装最新版本。',
|
||||
message: '检测到新版本',
|
||||
title: PRODUCT_NAME,
|
||||
type: 'info',
|
||||
});
|
||||
if (response === 0) {
|
||||
void shell.openExternal(DOWNLOAD_PAGE_URL);
|
||||
}
|
||||
}
|
||||
35
apps/desktop/src/main/utils.ts
Normal file
35
apps/desktop/src/main/utils.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 主进程通用小工具
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* 取运行时资源文件的绝对路径(图标等)
|
||||
* 打包后:resources/ 下(electron-builder extraResources 拷入)
|
||||
* 开发态:apps/desktop/resources/ 源目录
|
||||
*/
|
||||
export function getResourcePath(rel: string): string {
|
||||
return app.isPackaged
|
||||
? join(process.resourcesPath, rel)
|
||||
: join(app.getAppPath(), 'resources', rel);
|
||||
}
|
||||
|
||||
/** 读取开机自启状态(Windows 读注册表,mac 读系统「登录项」) */
|
||||
export function getAutoLaunchEnabled(): boolean {
|
||||
return app.getLoginItemSettings().openAtLogin;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置开机自启
|
||||
* args --hidden 仅 Windows 生效:自启时静默进托盘不弹主窗口(入口处解析该参数);
|
||||
* mac 走 SMAppService(系统设置 → 登录项 可见),不支持自定义参数,自启后正常显示窗口
|
||||
*/
|
||||
export function setAutoLaunchEnabled(enabled: boolean): boolean {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
args: enabled ? ['--hidden'] : [],
|
||||
});
|
||||
return getAutoLaunchEnabled();
|
||||
}
|
||||
187
apps/desktop/src/main/window.ts
Normal file
187
apps/desktop/src/main/window.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 主窗口管理:创建、加载策略、安全基线、关闭行为
|
||||
*/
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
nativeImage,
|
||||
Notification,
|
||||
session,
|
||||
shell,
|
||||
} from 'electron';
|
||||
import log from 'electron-log/main';
|
||||
|
||||
import {
|
||||
APP_HOST,
|
||||
APP_SCHEME,
|
||||
DEV_SERVER_URL,
|
||||
PRODUCT_NAME,
|
||||
} from './config';
|
||||
import { appState } from './state';
|
||||
import { SETTING_KEYS, settingsStore } from './store';
|
||||
import { getResourcePath } from './utils';
|
||||
|
||||
/** 主窗口单例引用(应用生命周期内只有一个主窗口) */
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
/** 取主窗口(已销毁则返回 null,调用方需自行兜底) */
|
||||
export function getMainWindow(): BrowserWindow | null {
|
||||
return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
|
||||
}
|
||||
|
||||
/** 显示并聚焦主窗口(托盘单击 / 通知点击 / 二次启动 / mac Dock 激活共用) */
|
||||
export function showMainWindow(): void {
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某个 URL 是否属于「自己人」页面
|
||||
* 用途:will-navigate 导航拦截、IPC sender 来源校验
|
||||
* 生产只信 app://local,开发态额外放行本地 vite dev server(localhost 与 127.0.0.1 等价)
|
||||
*/
|
||||
export function isTrustedWebContentsUrl(url: string): boolean {
|
||||
if (url.startsWith(`${APP_SCHEME}://${APP_HOST}`)) return true;
|
||||
if (!app.isPackaged) {
|
||||
return (
|
||||
url.startsWith(DEV_SERVER_URL) ||
|
||||
url.startsWith(DEV_SERVER_URL.replace('localhost', '127.0.0.1'))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话级安全设置:权限请求默认拒绝,仅放行白名单
|
||||
* 本项目无 IM 音视频,media(摄像头/麦克风)一律拒绝;派生项目需要时再把 'media' 加进白名单
|
||||
*/
|
||||
export function setupSessionSecurity(): void {
|
||||
const allowedPermissions = new Set([
|
||||
'clipboard-read',
|
||||
'clipboard-sanitized-write',
|
||||
'fullscreen',
|
||||
'notifications',
|
||||
]);
|
||||
session.defaultSession.setPermissionRequestHandler(
|
||||
(_webContents, permission, callback) => {
|
||||
callback(allowedPermissions.has(permission));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建主窗口
|
||||
* @param options.startHidden 开机自启(--hidden)时为 true:创建后不显示、静默待在托盘
|
||||
*/
|
||||
export async function createMainWindow(options: {
|
||||
startHidden: boolean;
|
||||
}): Promise<BrowserWindow> {
|
||||
const win = new BrowserWindow({
|
||||
title: PRODUCT_NAME,
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1024,
|
||||
minHeight: 680,
|
||||
// 先隐藏,ready-to-show 再展示,避免加载期白屏闪烁
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// mac 的应用图标由打包配置决定,这里只给 Windows/Linux 的窗口图标
|
||||
icon:
|
||||
process.platform === 'darwin'
|
||||
? undefined
|
||||
: nativeImage.createFromPath(getResourcePath('icon.png')),
|
||||
backgroundColor: '#f5f5f5',
|
||||
webPreferences: {
|
||||
// 安全基线:隔离 + 沙箱 + 禁 node,一律不许放开(渲染层是可热替换的 web 产物)
|
||||
preload: join(import.meta.dirname, '../preload/index.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
mainWindow = win;
|
||||
|
||||
win.on('ready-to-show', () => {
|
||||
if (!options.startHidden) win.show();
|
||||
});
|
||||
|
||||
// 渲染层 window.open 的外链统一交给系统浏览器,壳内不开新窗口
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// 页面内直接跳转外部地址也拦下来走系统浏览器,防止壳被当成通用浏览器用
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (!isTrustedWebContentsUrl(url)) {
|
||||
event.preventDefault();
|
||||
// 与 setWindowOpenHandler 保持一致:只把 http(s) 交给系统浏览器,
|
||||
// file:// / 自定义协议等一律仅拦截不外开,避免被诱导打开危险协议
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
shell.openExternal(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭按钮 = 隐藏到托盘(双平台一致;真正退出走托盘菜单/Cmd+Q,见 state.quitting)
|
||||
win.on('close', (event) => {
|
||||
if (appState.quitting) return;
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
showFirstHideHint();
|
||||
});
|
||||
|
||||
// 拿到焦点时清掉任务栏闪烁提醒
|
||||
win.on('focus', () => win.flashFrame(false));
|
||||
|
||||
if (app.isPackaged) {
|
||||
// 生产:走 app:// 协议加载 web-antd 的 dist(hash 路由,入口固定 index.html)
|
||||
await win.loadURL(`${APP_SCHEME}://${APP_HOST}/index.html`);
|
||||
} else {
|
||||
// 开发:连 web-antd 的 vite dev server,起得比 vite 快时自动重试等它就绪
|
||||
loadDevServerWithRetry(win);
|
||||
}
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发模式加载 dev server,失败每秒重试(dev:desktop 并行启动时 electron 通常比 vite 先就绪)
|
||||
*/
|
||||
function loadDevServerWithRetry(win: BrowserWindow, attempt = 0): void {
|
||||
if (win.isDestroyed()) return;
|
||||
win.webContents.once('did-fail-load', (_event, code, desc) => {
|
||||
if (attempt >= 120) {
|
||||
log.error('[window] 连接 dev server 失败已放弃:', DEV_SERVER_URL, code, desc);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => loadDevServerWithRetry(win, attempt + 1), 1000);
|
||||
});
|
||||
win.loadURL(DEV_SERVER_URL).catch(() => {
|
||||
// loadURL 的 promise 失败与 did-fail-load 重复,重试统一由上面的事件驱动
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 首次「关闭进托盘」时弹一条系统通知告知用户,只提示一次(标记持久化)
|
||||
* mac 关窗隐藏是系统惯例,无需提示
|
||||
*/
|
||||
function showFirstHideHint(): void {
|
||||
if (process.platform !== 'win32') return;
|
||||
if (settingsStore.get(SETTING_KEYS.closeHintShown)) return;
|
||||
settingsStore.set(SETTING_KEYS.closeHintShown, true);
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({
|
||||
title: PRODUCT_NAME,
|
||||
body: '已最小化到系统托盘,单击托盘图标可恢复窗口',
|
||||
icon: nativeImage.createFromPath(getResourcePath('icon.png')),
|
||||
}).show();
|
||||
}
|
||||
}
|
||||
121
apps/desktop/src/preload/index.ts
Normal file
121
apps/desktop/src/preload/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* preload:通过 contextBridge 向渲染层暴露 window.desktopAPI
|
||||
*
|
||||
* 运行在 sandbox 沙箱中(只能使用 electron 受限模块 + DOM),
|
||||
* 所有能力都走 ipcRenderer.invoke 白名单通道,渲染层拿不到任何 Node 能力。
|
||||
* web 侧的类型声明见 apps/web-antd/src/types/desktop.d.ts(两边字段需保持同步)。
|
||||
*/
|
||||
import type { IpcRendererEvent } from 'electron';
|
||||
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
/**
|
||||
* 用 canvas 绘制 Windows 任务栏角标覆盖图(红底白字数字),返回 dataURL
|
||||
* 为什么在 preload 画:主进程没有 DOM canvas,nativeImage 也不能绘制文字;
|
||||
* preload 跑在渲染进程里有完整 DOM 能力,画完把 dataURL 交给主进程 setOverlayIcon
|
||||
*/
|
||||
function drawBadgeDataURL(count: number): string {
|
||||
const size = 32;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return '';
|
||||
const text = count > 99 ? '99+' : String(count);
|
||||
ctx.beginPath();
|
||||
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#f5222d';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = `bold ${text.length > 2 ? 13 : 18}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, size / 2, size / 2 + 1);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
/** 订阅主进程推送事件的通用封装,返回取消订阅函数(组件卸载时记得调用防止泄漏) */
|
||||
function subscribe<T>(
|
||||
channel: string,
|
||||
callback: (payload: T) => void,
|
||||
): () => void {
|
||||
const listener = (_event: IpcRendererEvent, payload: T) => callback(payload);
|
||||
ipcRenderer.on(channel, listener);
|
||||
return () => ipcRenderer.removeListener(channel, listener);
|
||||
}
|
||||
|
||||
const desktopAPI = {
|
||||
/** 恒为 true,web 侧用它判断是否运行在 Electron 壳内 */
|
||||
isElectron: true as const,
|
||||
/** 当前平台:win32 / darwin / linux(渲染层做平台差异 UI 时用) */
|
||||
platform: process.platform,
|
||||
/** 应用版本号(package.json version,用于「关于」展示) */
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke('desktop:get-version'),
|
||||
/**
|
||||
* 发系统通知;route 传入站内路由(如 /notice/list)时点击通知会唤起窗口并跳转
|
||||
* flash 默认 true:窗口未聚焦时同时闪烁任务栏(mac 表现为 Dock 弹跳)
|
||||
*/
|
||||
notify: (options: {
|
||||
title: string;
|
||||
body?: string;
|
||||
route?: string;
|
||||
silent?: boolean;
|
||||
flash?: boolean;
|
||||
}): Promise<void> => ipcRenderer.invoke('desktop:notify', options),
|
||||
/** 设置未读角标数,0 清除;mac 用 Dock 原生数字,Windows 用 canvas 画的覆盖图 */
|
||||
setBadge: (count: number): Promise<void> => {
|
||||
const dataUrl =
|
||||
count > 0 && process.platform === 'win32' ? drawBadgeDataURL(count) : '';
|
||||
return ipcRenderer.invoke('desktop:set-badge', count, dataUrl);
|
||||
},
|
||||
/** 手动控制任务栏闪烁(一般用不到,notify 已内置) */
|
||||
flashFrame: (flag = true): Promise<void> =>
|
||||
ipcRenderer.invoke('desktop:flash-frame', flag),
|
||||
/** 读取开机自启状态 */
|
||||
getAutoLaunch: (): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:get-auto-launch'),
|
||||
/** 设置开机自启(--hidden 静默启动进托盘) */
|
||||
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke('desktop:set-auto-launch', enabled),
|
||||
/** 枚举系统打印机列表 */
|
||||
getPrinters: (): Promise<unknown[]> =>
|
||||
ipcRenderer.invoke('desktop:get-printers'),
|
||||
/**
|
||||
* 打印一段完整 HTML:不传 deviceName 时读持久化的默认打印机;
|
||||
* silent=true 静默打印,false 弹系统打印对话框
|
||||
*/
|
||||
printHTML: (options: {
|
||||
html: string;
|
||||
deviceName?: string;
|
||||
silent?: boolean;
|
||||
landscape?: boolean;
|
||||
}): Promise<{ message?: string; success: boolean }> =>
|
||||
ipcRenderer.invoke('desktop:print-html', options),
|
||||
/** 读桌面端本地设置(如默认打印机) */
|
||||
getSetting: (key: string): Promise<unknown> =>
|
||||
ipcRenderer.invoke('desktop:get-setting', key),
|
||||
/** 写桌面端本地设置 */
|
||||
setSetting: (key: string, value: unknown): Promise<void> =>
|
||||
ipcRenderer.invoke('desktop:set-setting', key, value),
|
||||
/** 手动检查更新(托盘菜单也有同款入口) */
|
||||
checkUpdate: (): Promise<void> => ipcRenderer.invoke('desktop:check-update'),
|
||||
/** 更新下载完成后立即重启安装 */
|
||||
quitAndInstall: (): Promise<void> =>
|
||||
ipcRenderer.invoke('desktop:quit-and-install'),
|
||||
/** 订阅「点击通知要求跳转路由」事件,web 侧收到后自行 router.push */
|
||||
onNavigate: (callback: (route: string) => void): (() => void) =>
|
||||
subscribe<string>('desktop:navigate', callback),
|
||||
/** 订阅更新流程事件(checking / available / progress / downloaded 等),用于展示进度 */
|
||||
onUpdateEvent: (
|
||||
callback: (event: { payload?: unknown; type: string }) => void,
|
||||
): (() => void) =>
|
||||
subscribe<{ payload?: unknown; type: string }>(
|
||||
'desktop:update-event',
|
||||
callback,
|
||||
),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('desktopAPI', desktopAPI);
|
||||
|
||||
/** 导出类型供主进程侧引用(仅类型层面,运行时无依赖) */
|
||||
export type DesktopAPI = typeof desktopAPI;
|
||||
Reference in New Issue
Block a user