Files
ClassHelper/packages/desktop-client/src/main/index.ts
T
LoveChengke 3880eee08e feat(desktop,island,shared): 灵动岛支持多条通知竖排列表 + 触摸模式 + 收回动画,产物加凭据门禁
灵动岛列表:多条通知时展开为竖排列表,默认显示 3 条,点「展开更多」铺到屏幕容纳
上限,放不下的收进「更多请前往应用内操作」。卡片高度由主进程与渲染进程各算一次
同一个数 —— 度量表 ISLAND_LIST_METRICS 与排序权重 PRIORITY_RANK 放在 shared,
窗口包围盒随之长高(不够高会把底部按钮裁掉)。

触摸模式:触摸屏上手指既不产生 mousemove、也不移动系统光标,原先两条命中链路都会
让窗口永远停在穿透态、手指点不开岛。现按 navigator.maxTouchPoints 上报后,主进程把
窗口贴合岛体并始终接收输入;非触摸屏机器完全不走这条路。

收回动画:main 先发 island:closing,渲染进程形变收敛后回 closing-done 才淡出,
不再靠猜时长(rAF 被节流时会把卡片缩到一半窗口就没了)。

安全门禁:新增 scripts/check-bundle-secrets.mjs 并接进 build:desktop ——
dist 产物会被打进 app.asar 发到每台学生机、且可解包,冒烟凭据一律改由环境变量注入,
产物里出现种子口令即构建失败。
2026-10-02 00:25:10 +08:00

237 lines
8.1 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.
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
if (smokeProfile) app.setPath('userData', smokeProfile);
let mainWindow: BrowserWindow | null = null;
/** 是否正在退出:用于区分「关闭窗口 = 隐藏到托盘」与「真正退出」 */
let isQuitting = false;
/** 生产环境下主窗口加载的本页地址(用于导航白名单的精确比对) */
const prodRendererUrl = pathToFileURL(path.join(currentDir, '../renderer/index.html')).href;
/**
* 是否允许窗口导航到这个地址。
*
* 为什么不能用 `url.startsWith(...)`:
* - 开发态 `url.startsWith(devServerUrl)` 没有主机边界,`http://localhost:5174.evil.com` 也会被放行;
* - 生产态 `url.startsWith('file://')` 等于放行**任意本地页面**。
* 而 preload 是挂在窗口上的(不是挂在 URL 上):一旦窗口被导航到别处,那个页面照样持有
* `window.desktop`(含 `getConfig` 返回的明文 token)。因此这里按"精确同源 / 精确同一文件"比对。
*/
function isAllowedNavigation(url: string): boolean {
try {
const target = new URL(url);
if (devServerUrl) return target.origin === new URL(devServerUrl).origin;
target.hash = '';
target.search = '';
return target.href === prodRendererUrl;
} catch {
return false;
}
}
function createWindow(): BrowserWindow {
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 980,
minHeight: 640,
title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
autoHideMenuBar: true,
backgroundColor: '#f5f7fa',
webPreferences: {
preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
},
});
// 外部链接交给系统浏览器,禁止在应用内新开窗口
win.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
if (isAllowedNavigation(url)) return;
event.preventDefault();
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
logger.warn(`主窗口拒绝了页面导航:${url}`);
});
win.on('closed', () => {
mainWindow = null;
});
return win;
}
async function loadRenderer(win: BrowserWindow): Promise<void> {
if (devServerUrl) {
await win.loadURL(devServerUrl);
win.webContents.openDevTools({ mode: 'detach' });
return;
}
// 生产:加载打包后的本地 HTML(vite base 为 './',使用 file:// 相对路径)
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
}
// 单实例:重复启动时聚焦已有窗口
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(async () => {
registerIpcHandlers();
registerIslandIpc();
mainWindow = createWindow();
await loadRenderer(mainWindow);
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
try {
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
});
try {
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
}
}
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
function showMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
mainWindow = createWindow();
void loadRenderer(mainWindow);
}
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
/**
* 退出前的统一资源清理(需求 5:托盘 / 定时器 / 网络连接 / 窗口都不留残留)。
* 幂等:托盘退出、before-quit、will-quit 都可能触发。
*/
function shutdownResources(): string {
const done: string[] = [];
if (isTrayReady()) {
destroyTray();
done.push('托盘已销毁');
}
if (island.isReady()) {
island.destroy();
done.push('灵动岛已销毁');
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) {
// 渲染进程随之结束:其中的 Socket.IO 连接、定时器、IndexedDB 句柄一并释放
win.destroy();
done.push('窗口已销毁');
}
}
return done.length > 0 ? done.join('、') : '无需要清理的资源';
}
/** 真正退出:先清理资源,再 app.quit,并用 app.exit 兜底(确保不留进程) */
function quitApp(): void {
if (isQuitting) return;
isQuitting = true;
logger.info(`正在退出:${shutdownResources()}`);
app.quit();
setTimeout(() => app.exit(0), 1500);
}
app.on('window-all-closed', () => {
// 有托盘常驻:窗口全关也不退出,继续后台接收通知
logger.info('所有窗口已关闭,程序继续在系统托盘后台运行');
});
app.on('before-quit', () => {
isQuitting = true;
shutdownResources();
});
app.on('will-quit', () => {
const summary = shutdownResources();
logger.info(`应用退出清理:${summary}`);
});
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
mainWindow = createWindow();
await loadRenderer(mainWindow);
}
});
}