Files
ClassHelper/packages/desktop-client/src/main/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

217 lines
8.5 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 { app, type BrowserWindow } from 'electron';
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
/**
* 更新检查(桌面客户端)。
*
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
*
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
export { UPDATE_AVAILABLE_CHANNEL };
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
let startupTimer: ReturnType<typeof setTimeout> | null = null;
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
let startupCheckSkipped = false;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: app.getVersion(),
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion,
latestVersion,
hasUpdate: isNewerVersion(currentVersion, latestVersion),
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
*
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
*/
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
const currentVersion = app.getVersion();
const now = Date.now();
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
// GitHub API 强制要求 User-Agent,不带会直接 403
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
logger.info(
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
}
cache = { at: now, info };
return info;
}
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
export function ignoreUpdateVersion(version: string): void {
const normalized = normalizeVersion(version);
if (!normalized) return;
saveConfig({ ignoredUpdateVersion: normalized });
logger.info(`已忽略版本 ${normalized} 的更新提示`);
}
/** 该版本是否已被用户忽略 */
export function isUpdateIgnored(version: string): boolean {
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
}
/**
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
*
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
*/
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
startupCheckSkipped = true;
logger.info('更新检查:冒烟模式下跳过启动自动检查');
return;
}
if (startupTimer) clearTimeout(startupTimer);
startupTimer = setTimeout(() => {
startupTimer = null;
void (async () => {
try {
const info = await checkForUpdates();
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
if (isUpdateIgnored(info.latestVersion)) {
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
return;
}
const win = getWindow();
if (!win || win.isDestroyed()) return;
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
} catch (error) {
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
}
})();
}, STARTUP_DELAY_MS);
// 不阻止进程退出:这个定时器只是"顺便查一下"
startupTimer.unref?.();
}
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
export function wasStartupCheckSkipped(): boolean {
return startupCheckSkipped;
}