Files
ClassHelper/packages/desktop-client/src/main/auto-update.ts
T
LoveChengke 817af02357 feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

305 lines
12 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 crypto from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { island } from './island.js';
import { logger } from './logger.js';
/**
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
*
* 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
* 本进程还没装过;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
export function pendingUpdateDir(): string {
return path.join(app.getPath('userData'), 'updates');
}
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
let mainWindowHiddenAt: number | null = null;
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
let installing = false;
/**
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
*
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
*/
let quitHandler: (() => void) | null = null;
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
export function setQuitHandler(handler: () => void): void {
quitHandler = handler;
}
/** 正在下载的版本(同一版本不并发下载) */
const downloading = new Set<string>();
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
let hookedWindow: BrowserWindow | null = null;
/**
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
*
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
*/
export function watchMainWindow(win: BrowserWindow): void {
if (hookedWindow === win) return;
hookedWindow = win;
win.on('hide', () => {
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
void maybeInstallPendingUpdate();
});
win.on('show', () => {
mainWindowHiddenAt = null;
});
win.on('closed', () => {
mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null;
});
if (win.isVisible()) mainWindowHiddenAt = null;
}
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
function mainWindowIdleLongEnough(): boolean {
if (mainWindowHiddenAt === null) return false;
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
}
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
function mainWindowVisible(): boolean {
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
}
/** 服务端是否要求升级 / 本机是否确实更旧 */
export function shouldConsider(version: string): boolean {
return getConfig().autoUpdate !== false && !!version.trim();
}
/**
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
*
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
*/
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
const version = targetVersion.trim() || info.version.trim();
if (!shouldConsider(version)) {
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
return;
}
if (downloading.has(version)) {
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
return;
}
const config = getConfig();
if (config.pendingUpdateVersion === version) {
const existing = config.pendingUpdateFile;
if (existing && fs.existsSync(existing)) {
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
await maybeInstallPendingUpdate();
return;
}
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
}
if (!SAFE_FILE_NAME.test(info.fileName)) {
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
return;
}
const dir = pendingUpdateDir();
const target = path.join(dir, info.fileName);
const temp = `${target}.part`;
const token = config.token?.trim() ?? '';
if (!token) {
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
return;
}
downloading.add(version);
const startedAt = Date.now();
try {
fs.mkdirSync(dir, { recursive: true });
// 先删掉上次中断留下的临时文件
await fsp.rm(temp, { force: true });
const url = `${config.serverUrl}${info.url}`;
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
const response = await fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`下载失败:HTTP ${response.status}`);
}
if (!response.body) {
throw new Error('下载失败:响应没有内容');
}
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
const handle = await fsp.open(temp, 'w');
try {
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
await handle.write(chunk);
}
} finally {
await handle.close();
}
const digest = await sha256File(temp);
const expected = info.sha256.trim().toLowerCase();
if (!expected) {
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
await fsp.rm(temp, { force: true });
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
return;
}
if (digest !== expected) {
await fsp.rm(temp, { force: true });
logger.error(
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
);
return;
}
await fsp.rm(target, { force: true });
await fsp.rename(temp, target);
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`,
);
await maybeInstallPendingUpdate();
} catch (error) {
await fsp.rm(temp, { force: true }).catch(() => undefined);
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
} finally {
downloading.delete(version);
}
}
/** 文件的 sha256(十六进制小写) */
async function sha256File(file: string): Promise<string> {
const hash = crypto.createHash('sha256');
const handle = await fsp.open(file, 'r');
try {
const buffer = Buffer.alloc(1024 * 256);
for (;;) {
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
if (bytesRead <= 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
await handle.close();
}
return hash.digest('hex');
}
/** 人类可读的字节数(日志与界面提示用) */
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* 如果条件全部满足就装更新。
*
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
*/
export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return;
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return;
if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return;
}
if (mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return;
}
if (!mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
);
return;
}
if (process.platform !== 'win32') {
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
logger.warn(
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
);
return;
}
installing = true;
logger.info(`开始静默安装更新 ${version}:${installer}`);
try {
const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
const child = spawn('cmd.exe', ['/c', command], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
if (quitHandler) {
quitHandler();
} else {
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
app.quit();
}
} catch (error) {
installing = false;
logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
);
}
}
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
export function isMainWindowIdleLongEnough(): boolean {
return mainWindowIdleLongEnough();
}