AI代码生成工具、个人中心

This commit is contained in:
李琦
2026-08-13 19:04:28 +08:00
parent c45bcde94d
commit 318aeab092
84 changed files with 37648 additions and 56 deletions

12
apps/desktop/src/env.d.ts vendored Normal file
View 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;
}

View 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';

View 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();
}

View 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();
}
}

View 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' },
},
);
}
}

View 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());
}

View File

@@ -0,0 +1,11 @@
/**
* 主进程共享运行时状态
* 单独成模块是为了避免 window.ts / tray.ts / updater.ts 之间互相 import 造成循环依赖
*/
export const appState = {
/**
* 是否处于「真正退出」流程(托盘菜单退出 / mac Cmd+Q / 更新重启安装)
* false 时点击窗口关闭按钮只隐藏到托盘,不退出应用
*/
quitting: false,
};

View 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' });

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

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

View 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();
}

View 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();
}
}

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