Files
ClassHelper/packages/desktop-client/scripts/dist-win.mjs
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

215 lines
10 KiB
JavaScript
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.
/**
* Windows 打包入口(等价于 electron-builder --win,但做了三件工程化处理):
*
* 1) 复用本地已解压的 Electron:node_modules/electron/dist 存在时通过
* --config.electronDist 直接使用,避免再下载 150MB+ 发行包(受网络影响最大的步骤)。
* 2) 默认使用国内镜像(可用环境变量覆盖):
* ELECTRON_MIRROR Electron 发行包
* ELECTRON_BUILDER_BINARIES_MIRROR nsis / winCodeSign 等构建工具
* 3) 缓存固定到仓库内的 .cache 目录,便于清理与迁移,不污染用户全局目录。
*
* 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 构建中间/ builder-*.yml、*.nsis.7z
*
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
* electron-builder,别往 releases/ 里塞。
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..');
const passthrough = process.argv.slice(2);
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
// `!tempfile: Unable to create temporary file!`,NSIS 那一步就失败(磁盘并不紧张)。
// 统一指到仓库内的真实目录,避开宿主终端带来的环境差异。
const tmpDir = path.join(repoRoot, '.cache', 'tmp');
fs.mkdirSync(tmpDir, { recursive: true });
const env = {
...process.env,
TEMP: tmpDir,
TMP: tmpDir,
ELECTRON_MIRROR: process.env.ELECTRON_MIRROR ?? 'https://cdn.npmmirror.com/binaries/electron/',
ELECTRON_BUILDER_BINARIES_MIRROR:
process.env.ELECTRON_BUILDER_BINARIES_MIRROR ??
'https://npmmirror.com/mirrors/electron-builder-binaries/',
ELECTRON_BUILDER_CACHE:
process.env.ELECTRON_BUILDER_CACHE ?? path.join(repoRoot, '.cache', 'electron-builder'),
ELECTRON_CACHE: process.env.ELECTRON_CACHE ?? path.join(repoRoot, '.cache', 'electron'),
};
const localElectronDist = path.join(root, 'node_modules', 'electron', 'dist');
const overrides = [];
if (fs.existsSync(path.join(localElectronDist, 'electron.exe'))) {
overrides.push(`--config.electronDist=${localElectronDist}`);
console.log('[dist] 复用本地已解压的 Electron:', localElectronDist);
} else {
console.log('[dist] 未找到本地 Electron,将由 electron-builder 下载(镜像:' + env.ELECTRON_MIRROR + ')');
}
const cli = path.join(root, 'node_modules', 'electron-builder', 'out', 'cli', 'cli.js');
if (!fs.existsSync(cli)) {
console.error('[dist] 未找到 electron-builder,请先执行 pnpm install');
process.exit(2);
}
// 交付产物落到 releases/client/<版本>/(与服务端、插件同一约定;releases/ 不入库)
const clientVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const explicitOutput = passthrough.find((item) => item.includes('directories.output'));
const destDir = explicitOutput
? path.resolve(root, explicitOutput.slice(explicitOutput.indexOf('=') + 1))
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
// 必须用真正的 node 执行 electron-builder CLI:
// 若宿主终端是 Electron(本机 pnpm 即如此),process.execPath 会指向 Electron,
// 直接 spawn 会让 Electron 把 cli.js 当成应用入口,多出一个位置参数,
// electron-builder 报 `Unknown argument: .../cli.js`。
const runtime = resolveNodeRuntime();
console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
let cleaned = false;
for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
const target = path.join(destDir, name);
if (!fs.existsSync(target)) continue;
try {
fs.rmSync(target, { recursive: true, force: true });
cleaned = true;
} catch (error) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
}
if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
// "cannot open file ... Access is denied",打包直接失败。
try {
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
} catch {
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
}
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
/**
* 把交付产物的完整性标签修正为 Medium(Windows)。
*
* 背景(踩过一次,代价是"客户端装不上、便捷版也双击没反应"):
* 本仓库目录树被宿主沙箱打上了 `Low Mandatory Level` 标签(可继承),
* 因此在仓库内构建出的 exe 自身也带 Low 标签。Windows 会把这类进程降到 Low 完整性,
* 于是 setup.exe 写不进 `%LOCALAPPDATA%`(报"拒绝访问"→ 装不上)、
* 便捷版解不出 `%TEMP%`(→ 起不来);即使装上了,Low 标签的应用文件也会让
* Chromium 的 AppContainer 子进程读不到(0x80000003 秒崩)。
*
* 要点:**只有交付物自身的标签要紧**。归档内部的载荷标签不会被还原
* (装到正常位置得到的是默认 Medium 文件),所以打包完成后再统一修正即可 ——
* 反过来说,不能在打包前把 appOutDir 改成 Medium:那样 7za(自身 Low)
* 会因"不能向上读/写"而无法生成归档。
*/
function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
try {
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
} catch {
/* 忽略 */
}
}
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
function tidyArtifacts(dir) {
if (!fs.existsSync(dir)) return;
const moveInto = (from, sub) => {
const to = path.join(dir, sub);
fs.mkdirSync(to, { recursive: true });
fs.renameSync(from, path.join(to, path.basename(from)));
};
// 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
// 提前删会让 `-portable.exe` 这一步找不到源文件。
const throwaway = [];
for (const name of ['win-unpacked', '免安装']) {
const from = path.join(dir, name);
if (fs.existsSync(from)) throwaway.push(from);
}
for (const name of fs.readdirSync(dir)) {
const from = path.join(dir, name);
if (!fs.statSync(from).isFile()) continue;
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
}
for (const from of throwaway) {
// 体积必须在删之前量:删完再 walk 只会得到 0
const mb = Math.round(dirSizeMb(from));
try {
fs.rmSync(from, { recursive: true, force: true });
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
} catch (error) {
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
}
}
}
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
function dirSizeMb(target) {
let total = 0;
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else total += fs.statSync(full).size;
}
};
if (fs.existsSync(target)) walk(target);
return total / 1024 / 1024;
}
child.on('exit', (code) => {
if (code === 0) {
tidyArtifacts(destDir);
normalizeIntegrityLabel(destDir);
console.log('\n[dist] 打包完成,产物目录:', destDir);
}
process.exit(code ?? 1);
});