- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
34 KiB
12. 插件系统、跨进程通信、单实例与自动更新
12.1 目标与边界
- 你必须实现一套进程隔离、权限受控、可市场分发的插件系统,能力范围对齐 ClassIsland 的插件体系(组件、提醒提供者、自动化触发器/行动、设置页、主题、信息提供者),但实现形态必须是 Electron 下的「npm 风格动态模块 + utilityProcess 宿主」,不得沿用 .NET 程序集加载。
- 你必须实现完整的跨进程通信层:主进程 ↔ 应用窗口渲染进程 ↔ 岛渲染进程 ↔ 插件宿主进程 ↔ 更新助手。任何进程间只传纯数据,不得传函数、类实例、Proxy。
- 你必须实现单实例、URI 协议唤起、自动更新、崩溃自愈与看门狗。这些能力的失败模式直接决定"教室里能不能无人值守跑一学期"。
- 你不得把插件能力做成"直接在主进程 eval 用户代码"。插件崩溃/死循环不得拖垮主进程,这是本部分的第一验收项。
12.2 ClassIsland 插件体系事实(必须对齐的语义)
包格式与目录:
- 插件包扩展名
.cipx,本质是 zip。依据:repos/ClassIsland/ClassIsland.Core/Abstractions/Services/IPluginService.cs:17、repos/ClassIsland/ClassIsland/Services/PluginService.cs:79-107。 - 包内必须含
manifest.yml;解压后安装到Plugins/<插件Id>/;包缓存目录为Cache/PluginPackages/;插件自己的设置目录为Config/Plugins/<插件Id>/。依据:PluginService.cs:35-44。 - 安装流程:扫描包缓存里的
*.cipx→ 读取manifest.yml(IgnoreUnmatchedProperties+ CamelCase 命名约定)→ 删除同名旧目录(保留.disabled状态)→ 解压 → 删除包文件。依据:PluginService.cs:57-107。 - 启用/禁用状态用插件目录下的标记文件表达:
.disabled存在即禁用;.uninstall存在表示"下次启动时删除该插件目录"。依据:repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginInfo.cs:47-100。
清单字段(manifest.yml,YAML 键为 camelCase):
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
entranceAssembly |
string | "" |
入口程序集(本实现改为 main.mjs 入口文件) |
name |
string | "" |
显示名称 |
id |
string | "" |
插件唯一 ID(本实现要求反向域名式,如 com.example.weather) |
description |
string | "" |
描述 |
icon |
string | icon.png |
图标相对路径 |
readme |
string | README.md |
自述文件相对路径 |
url |
string? | null |
项目地址 |
version |
string | "" |
插件版本(semver) |
apiVersion |
string | "" |
目标宿主 API 版本;本实现要求 >=2.0.0 |
author |
string | "" |
作者 |
dependencies |
{id, isRequired}[] |
[] |
依赖;isRequired 默认 true |
supportedOSPlatforms |
string[] | 全平台 | 有效值 windows/linux/osx |
依据:repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginManifest.cs:12-75、repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginDependency.cs:6-17。
清单命名兼容(强制):本实现统一写 camelCase(与 ClassIsland manifest.yml 的 CamelCase 约定一致);同时必须兼容读取 PascalCase 旧清单(Id/Name/EntranceAssembly/ApiVersion/SupportedOSPlatforms 等)。加载时必须先做 camelCase ↔ PascalCase 归一化再校验;同名键以 camelCase 为准(不得让旧拼写覆盖新值);无法识别的键忽略并记 debug 日志。依据:PluginService.cs:68-72(IgnoreUnmatchedProperties + CamelCaseNamingConvention)。
加载规则:
- 按依赖拓扑排序加载;必选依赖缺失或不可加载 → 该插件置为
Error;检测到循环依赖必须抛错并拒绝整组。依据:PluginService.cs:288-339。 apiVersion < 2.0.0.0→ 拒绝加载并记录Error与异常说明(对齐PluginService.cs:168-173)。- 加载状态枚举固定四种:
NotLoaded/Loaded/Disabled/Error,并带Exception供设置页展示。依据:repos/ClassIsland/ClassIsland.Core/Enums/PluginLoadStatus.cs。 - 每个插件有独立的"注册上下文":插件在
Initialize期间注册的组件会自动归属该插件。依据:PluginService.cs:211-221(ComponentRegistryService.CurrentRegisteringPlugin)。 - 加载失败/损坏的插件必须在下次启动自动禁用(
Settings.AutoDisableCorruptPlugins默认true,Settings.CorruptPluginsDisabledLastSession记录上次会话是否发生过自动禁用)。依据:repos/ClassIsland/ClassIsland/Models/Settings.cs:1844-1845。
插件市场:
- 索引结构
PluginIndex { plugins[], downloadMirrors{ name: root } };条目在PluginInfo之上增加downloadUrl与downloadMd5。依据:repos/ClassIsland/ClassIsland.Core/Models/Plugin/PluginIndex.cs:12-21、PluginIndexItem.cs:8-19。 - 下载镜像:镜像根替换下载链接里的模板;内置回退镜像
github、ghproxy、moeyy;官方索引地址带时间戳参数https://get.classisland.tech/d/ClassIsland-Ningbo-S3/classisland/plugin/index.zip?time={time};索引默认 7 天刷新一次。依据:repos/ClassIsland/ClassIsland/Services/PluginMarketService.cs:43-48,66-69,212。 - 设置项:
userPluginIndexes、additionalPluginIndexes、pluginIndexSelectedMirrors、officialSelectedMirror(默认github)、ignoreSslForPluginMirrors(默认false)、isPluginsAutoUpdateEnabled(默认true)、isPluginsUpdateNotificationEnabled(默认true)、isPluginMarketWarningVisible。依据:Settings.cs:166-178,1858-1859,204。 - SDK 打包:插件项目
CreateCipx=true时,构建后自动把输出目录压成<项目名>.cipx,并在checksums.md里写一行 HTML 注释形式的 MD5 JSON(<!-- CLASSISLAND_PKG_MD5 {...} -->)。依据:repos/ClassIsland/ClassIsland.PluginSdk/ClassIsland.PluginSdk.targets(CreateCipxTarget)与generate-md5.ps1。
12.3 本实现的插件规范(必须逐项落地)
包与目录:
- 插件包扩展名沿用
.cipx(zip),内部结构固定:
my-plugin.cipx
├─ manifest.yml # 必须存在;字段见下表
├─ main.mjs # 必须存在;ESM 入口,默认导出 activate/deactivate
├─ icon.png # 可选,默认 icon.png
├─ README.md # 可选
└─ assets/ # 可选静态资源(只允许相对路径引用)
- 运行期目录:
<userData>/plugins/<id>/(解压后)、<userData>/plugin-cache/packages/*.cipx(下载缓存)、<userData>/plugin-data/<id>/(插件配置与缓存)、<userData>/plugin-cache/index.json(市场索引缓存)。必须与 13 部分的目录规划一致。 - 启用状态用
<pluginDir>/.disabled标记,卸载用<pluginDir>/.uninstall标记;不得用配置里的布尔值表达启用状态(否则用户手工操作目录后状态会不一致)。依据:PluginInfo.cs:47-100。 - 插件数据写入必须由宿主代理:插件调用
ctx.storage.set(key, value),宿主写到<userData>/plugin-data/<id>/storage.json,不得给插件直接fs访问。
清单(manifest.yml,camelCase,字段与默认值必须与 12.2 表一致,另加以下扩展):
id: com.example.weather-plus
name: 天气增强
description: 提供逐小时降水提醒
author: Example
version: 1.2.0
apiVersion: 2.0.0
icon: icon.png
readme: README.md
entry: main.mjs
url: https://example.com/weather-plus
supportedOSPlatforms: [windows, linux, osx]
dependencies:
- id: com.classisland.core-miniinfo
isRequired: true
permissions:
- notifications:push # 允许发提醒
- automation:register # 允许注册触发器/行动
- components:register # 允许注册岛组件
- settings:page # 允许注册设置页
- storage:read
- storage:write
- network:api.example.com # 仅允许访问列出的域名(精确匹配,禁止通配)
permissions是本实现在 ClassIsland 基础上的强制安全增强:ClassIsland 的插件是 DLL 全信任,本实现必须默认拒绝、按声明放行。任何未声明的能力调用都必须抛PluginPermissionError并写 warn 日志;设置页必须在启用前展示权限清单并二次确认。【推断】apiVersion必须是 semver;宿主 API 当前版本固定为2.0.0,apiVersion主版本不同即拒绝加载;次版本号高于宿主时给出"可能不兼容"警告但仍允许(与 ClassIsland 的"最高版本门槛"语义相反,此处以兼容性优先【推断】)。- 依赖解析必须:先校验所有
isRequired依赖存在且Loaded;再做拓扑排序;循环依赖必须报错并列出环上的 ID;可选依赖缺失只记 info。依据:PluginService.cs:306-339。
生命周期与宿主:
- 插件宿主必须是
utilityProcess.fork(path.join(__dirname,'plugin-host.js'))启动的独立进程;插件代码在该进程内用await import(pathToFileURL(entry))加载。不得在主进程import()插件代码。 - 宿主暴露给插件的 API 对象(
ctx)只允许包含:ctx.manifest、ctx.apiVersion、ctx.log、ctx.events、ctx.storage、ctx.notifications、ctx.components、ctx.automation、ctx.settings、ctx.themes、ctx.commands、ctx.timers。禁止暴露process、require、fs、child_process、net。 - 插件导出的入口签名固定:
export async function activate(ctx) { /* 注册能力;不要在这里做耗时 I/O */ }
export async function deactivate(ctx) { /* 清理定时器与监听器 */ }
export const manifest = {/* 可选:与 manifest.yml 一致 */};
- 宿主必须为每个插件维护独立状态机:
discovered → validating → disabled | loading → loaded | error → restart-required。状态与错误信息通过 IPCclassisland:plugins:list交给设置页渲染。 - 插件加载超时必须为 5 秒;单个插件崩溃后宿主必须重启该插件(最多 2 次),仍失败则置
error并在下一次启动写入.disabled(对齐AutoDisableCorruptPlugins语义)。同一次运行内不得无限重启。 - 启用/禁用/卸载必须标记"需要重启",并在 UI 上明确显示「重启 ClassIsland 后生效」;不得热替换已加载插件(热替换会留下旧的全局监听器)。依据:
PluginInfo.RestartRequired(PluginInfo.cs:159-172)。 - 插件注册的扩展点必须进入统一注册表,重复 ID 时后注册者失败并记录错误(不得静默覆盖)。依据:ClassIsland 的
ComponentRegistryService/RulesetRegistryExtensions单注册表模式。
扩展点(必须全部实现并映射):
| ClassIsland 扩展点 | 插件 API | 说明 |
|---|---|---|
组件(ComponentBase 派生 + 注册表) |
ctx.components.register({id, name, group, settingsSchema, render}) |
组件设置必须用声明式 schema;render 是可序列化描述或受控的 Vue 组件名,不得注入任意 DOM |
提醒提供者(NotificationProvider) |
ctx.notifications.registerProvider({id, name, request}) |
提供者只能"请求提醒",是否播放由宿主队列决定 |
| 自动化触发器 | ctx.automation.registerTrigger({id, name, schema, setup}) |
触发回调可发信号给宿主 |
| 自动化行动 | ctx.automation.registerAction({id, name, schema, run}) |
行动执行受权限约束(执行外部程序需 exec 权限) |
| 设置页 | ctx.settings.registerPage({id, title, schema, section}) |
只能是声明式 schema,由宿主统一表单渲染;不得提供任意 HTML/iframe(见下方安全红线) |
| 主题 | ctx.themes.registerManifest(themeManifest) |
主题包格式见 9 部分 |
| 迷你信息提供者 | ctx.miniInfo.registerProvider({id, name, fetch}) |
天气/温度等文本信息,带超时与缓存 |
| 设置叠层 | ctx.settings.overlay({key, value}) |
对齐 ClassIsland AddSettingsOverlay 的"临时覆盖并自动还原"语义(repos/ClassIsland/ClassIsland/Services/SettingsService.cs:191-259) |
设置页安全红线(强制):插件设置页只能是声明式 schema(字段名、类型、默认值、校验规则、布局分组、可见性条件),由宿主用统一表单渲染;禁止插件注入任意 HTML/CSS/脚本,禁止用 iframe/webview 承载插件设置页,禁止插件直接访问 DOM 或 window。schema 允许的控件仅限:文本、多行文本、数字、开关、下拉、单选、多选、颜色、时间、文件路径(由宿主弹选择器)、按钮(只能触发宿主声明允许的行动)。任何试图绕过 schema 的插件必须加载失败并记 error。
插件市场(本实现):
- 索引 JSON schema 必须为:
{
"plugins": [{
"id": "com.example.weather-plus",
"name": "天气增强",
"description": "……",
"author": "Example",
"version": "1.2.0",
"apiVersion": "2.0.0",
"supportedOSPlatforms": ["windows"],
"downloadUrl": "{root}/example/weather-plus/releases/download/v1.2.0/weather-plus.cipx",
"downloadMd5": "…32位十六进制…",
"downloadSha256": "…64位十六进制…",
"downloadSize": 1048576,
"repoOwner": "example",
"repoName": "weather-plus",
"assetsRoot": "master",
"artifactName": "weather-plus.cipx"
}],
"downloadMirrors": { "github": "https://github.com", "ghproxy": "https://mirror.ghproxy.com/https://github.com" }
}
downloadUrl必须支持{root}模板,按当前选中镜像替换;镜像失败必须按列表回退,不得只试一个。- 索引必须支持"多源":默认不内置在线官方索引,只有用户源(
userPluginIndexes);内置源只允许是随安装包分发的离线示例索引。每源独立记录"最后刷新时间/错误/是否过期";7 天未刷新时后台刷新,失败保留旧索引并标stale。依据:PluginMarketService.cs:43-69,107-120(ClassIsland 的刷新语义)+ 本应用默认离线策略【推断】。 - 下载必须同时校验 MD5 与 SHA-256:MD5 用于兼容 ClassIsland 生态,SHA-256 用于本实现的安全底线;任一不符即删除文件并报错。不得"只校验 MD5"。
- 下载完成后必须走与安装包相同的原子流程(
.part→ 校验 → rename),不得把半截 zip 交给解压器。 - 插件更新必须遵守「上课不打断」:
IsPluginsAutoUpdateEnabled=true时,只在非上课时段且设置窗口不可见时才后台下载;安装必须等用户确认或下次启动。依据:ClassIsland 默认值(Settings.cs:1858-1859)+ 本应用"零打扰"原则。 - 默认离线(强制):安装包只能内置一份离线示例索引(
resources/plugin-index.example.json,0–2 条示例,默认不可安装或只指向本地文件)。用户必须手动添加索引 URL 并二次确认后才允许联网;确认弹窗必须写明"将从该地址下载并执行第三方代码,请确认来源可信"。网络失败、索引 404、源不可达均不得影响本机已安装插件与本地索引。主题市场遵循同一默认策略(详见 9 部分)。
12.4 跨进程通信(IPC)设计(必须完整实现)
通道命名与组织:
- 通道名必须为
classisland:<domain>:<action>的 kebab 形式,全部集中在src/main/channels.ts(零 import 叶子模块)。依据:repos/ClassHelper-master/packages/desktop-client/src/main/channels.ts:16-28。 - 通道分三类,不得混用:
invoke/handle:请求-响应(读写配置、查询状态、执行命令);send/on:单向事件(岛事件、心跳、交互上报);webContents.send:主进程→渲染进程推送(状态、外观、进度)。
- 必须实现的通道清单(最小集,新增通道必须同表登记):
| 通道 | 方向 | 用途 |
|---|---|---|
classisland:config:get / :save / :reset |
双向 | 读/局部写/重置配置;:save 必须剔除更新安装字段 |
classisland:app:info |
双向 | 版本、Electron/Chrome/Node 版本、平台、userData、配置路径 |
classisland:app:get-autolaunch / :set-autolaunch |
双向 | 开机自启(以系统登录项为准) |
classisland:app:open-external / :open-log-dir / :open-devtools |
双向 | 受控系统能力 |
classisland:app:window |
双向 | 显示/隐藏/最小化/聚焦设置窗口 |
classisland:profile:list / :load / :save / :create / :delete / :import / :export |
双向 | 档案增删改查与导入导出 |
classisland:schedule:get / :save |
双向 | 课表/时间表读写 |
classisland:notification:list / :clear / :test |
双向 | 提醒列表与测试提醒 |
classisland:automation:list / :save / :run |
双向 | 自动化配置与手动触发 |
classisland:plugins:list / :enable / :disable / :uninstall / :install-package / :market-refresh |
双向 | 插件管理 |
classisland:update:check / :download / :install / :progress |
双向 | 更新 |
classisland:backup:list / :create / :restore |
双向 | 备份 |
island:push / island:remove-notification / island:class-state / island:get-state |
双向 | 主窗口→岛 |
island:set-appearance / island:get-appearance |
双向 | 外观热更新 |
island:action / island:set-interactive / island:set-hit-rect / island:set-touch-mode / island:alive / island:report-size / island:viewport |
岛→主进程 | 用户操作、命中、触摸、心跳、尺寸上报 |
island:state / island:appearance / island:visibility / island:schedules / island:time-points / island:layout / island:editing |
主进程→岛 | 状态与数据推送 |
island:mark-read / island:mark-all-read / island:open-editor |
主进程→应用窗口 | 岛侧操作回流到应用窗口/编辑器 |
依据:repos/ClassHelper-master/packages/desktop-client/src/main/channels.ts:16-28、src/preload/island.ts:64-121、src/preload/index.ts:25-99(通道命名与成对关系)。
Payload 与错误约定:
- 所有跨桥接数据必须是结构化克隆可序列化值:无函数、无 Symbol、无 Proxy、无
Date之外的自定义类;Error必须转成{code, message, detail?}普通对象。依据:src/preload/index.ts:17-24的注释。 - 单个 IPC 消息必须 ≤ 8MB;大文件(备份、导出档案、主题包)一律"传路径不传内容",由主进程读文件。
- 所有
invoke处理必须:校验 sender 必须是本应用的窗口(比对event.senderFrame.url白名单)→ 校验参数(类型、范围、长度)→ 执行业务 → 返回{ok:true, data}或{ok:false, error}。不得让异常穿透 IPC 抛给渲染层(生产构建的堆栈会泄漏内部路径)。 - 每次
invoke在渲染层必须包一层 10 秒超时;超时必须提示"操作超时,请重试",不得让按钮永久转圈。 - 高频事件必须节流:外观滑块 ≤ 20 次/秒(50ms)、岛状态推送 100ms 合并(尾部保留最后一次)、心跳 1 次/秒。依据:
island.ts:216-224、auto-update.ts:216-219的节流写法。 - 事件订阅必须可取消:preload 上的
onXxx(handler)必须返回() => void移除监听;组件卸载时调用。依据:src/preload/index.ts:32-36,56-60。
岛窗口专用通信纪律:
- 岛渲染进程只允许两类操作:订阅主进程推来的状态/外观/课表,以及上报用户操作与命中信息。不得让岛直接读写配置。依据:
src/preload/island.ts:10-13的注释。 - 命中控制必须"只上报 true,关闭由主进程按真实光标位置决定":双向下发会让窗口在"接收/穿透"之间抖动,表现为"点了没反应"。依据:
src/types/desktop.d.ts:221-228。 - 岛必须每秒发一次心跳(
alive());主进程超过 3 秒未收到心跳即判定"幽灵窗口",隐藏并重建。依据:src/main/island.ts:425-430,541-599(lastAliveAt、checkRendererAlive思路)。 - 触摸屏必须上报
setTouchMode(navigator.maxTouchPoints > 0);触摸模式下窗口必须贴合岛体(岛体+10px 阴影留白)并始终接收输入。依据:src/main/island.ts:245-316。 - 主进程必须对岛窗口做导航白名单与
window-open(e) = deny;岛是置顶窗口,被导航走等于把window.island桥交给陌生页面。依据:src/main/island.ts:32-50,516-522。
可选本地只读 HTTP API(外部程序集成,默认关闭):
- 本实现不恢复 ClassIsland 的命名管道 IPC;如需让外部程序(脚本、班级大屏看板、第三方展示页)读取当前课程,只能启用"本地只读 HTTP API",且必须满足以下全部约束:
- 只监听
127.0.0.1(不得监听0.0.0.0、不得绑定局域网地址); - 端口随机分配(
listen(0)后读取实际端口),端口与开关写入config.localApi,默认enabled=false; - 开启时必须生成一次性 token(随机 32 字节,base64url),请求必须带
Authorization: Bearer <token>;token 在设置页可见/可重置,关闭即失效; - 只提供读接口:
GET /v1/status(当前时间点/上下课状态/剩余时间)、GET /v1/schedule/today(当天课表)、GET /v1/profile/summary(档案名/科目表摘要);不得提供任何写操作(不得改课表、不得发提醒、不得触发自动化、不得改配置); - 响应必须含 CORS 白名单(默认不放开跨域;放开需用户在设置页显式勾选并提示风险);
- 每次启用/停用必须写审计日志;非法 token 返回 401 且限流(≥10 次/分钟即临时封禁)。
依据:ClassIsland 的
IPublicLessonsService/IPublicProfileService是"读服务为主"的公开契约(repos/ClassIsland/ClassIsland.Shared.IPC/Abstractions/Services/IPublic*.cs),本实现以只读 HTTP 等价替代,不引入写能力。【推断】
- 只监听
12.5 单实例与 URI 协议(必须实现)
- 单实例:
app.requestSingleInstanceLock();未拿到锁必须app.quit()并立即返回;拿到锁必须监听second-instance→ 显示/聚焦主窗口(若被最小化先restore)。依据:repos/ClassHelper-master/packages/desktop-client/src/main/index.ts:176-182,279-285。 second-instance必须解析新实例的argv:若带classisland://URI,转交现有实例导航;若带--hidden只做唤醒不抢焦点。参考repos/ClassIsland/ClassIsland/Program.cs:88-110,141-159的"第二次启动把 URI 通过 IPC 发给已运行实例后退出"语义。- 必须注册
classisland://协议:Windows 用app.setAsDefaultProtocolClient('classisland')并在 NSIS 安装器中登记;macOS 监听open-url;Linux 通过.desktop的MimeType=x-scheme-handler/classisland。 - URI 语法固定:
classisland://<host>/<path>?<query>;允许的 host 与行为:app/settings/<page>→ 打开设置页(例:classisland://app/settings/update对齐 ClassIslandUpdateService.cs:326);app/notification→ 打开提醒历史;lesson/current→ 显示当前课程详情;profile/import?path=<urlencoded>→ 导入档案(必须二次确认);action/<id>→ 触发自动化行动(仅当该行动声明uriTrigger=true且用户在设置中允许)。 未知 host/path 必须忽略并提示「无法识别的链接」,不得静默执行。URI 长度上限 2048,必须 URL 解码后再解析,禁止..路径穿越。
- 单实例锁必须与"冒烟/隔离配置"兼容:验收时有独立的
userData,否则会被真实实例的锁挡住而"静默退出(退出码 0、无输出)"。依据:src/main/index.ts:29-32注释。
12.6 自动更新(必须按此流程实现)
来源与通道:
- 更新通道至少三档:
stable(默认)、beta、dev;通道描述含id、name、description、rootUrl。默认只接受https://;镜像/自定义源必须由用户显式开启并二次确认"非官方来源风险",默认不启用。清单必须校验 SHA-256;清单签名可选,一旦提供则必须是 Ed25519(带keyId),客户端内置公钥表(支持多 keyId,便于轮换),验签失败必须拒绝整个更新;未签名清单必须在设置页显著标注"未签名发布"并写审计日志。依据:repos/ClassIsland/ClassIsland/Services/AppUpdating/UpdateService.cs:660-669(DetachedSignatureProcessor的验签语义)。 - 检查更新必须支持:启动后延迟 30 秒检查一次、之后每 6 小时一次、设置页手动检查、
classisland://app/settings/update深链进入。检查间隔与LastCheckUpdateTime必须持久化,避免频繁联网。依据:Settings.cs:75-78。 - 更新状态必须走固定枚举:
UpToDate(已是最新)、UpdateAvailable(发现新版)、UpdateDownloaded(已下载)、UpdateDeployed(已部署待重启);工作状态:Idle、Checking、Downloading、Extracting。依据:repos/ClassIsland/ClassIsland.Shared/Enums/UpdateStatus.cs、UpdateWorkingStatus.cs。
清单与下载:
- 更新清单必须包含:
version、releaseNotes、pubDate、minOsVersion、files[{path,size,sha256,url}]、installer(整包)或components(分组件增量);清单可选附带signature{alg:"ed25519", keyId, sig},只要附带就必须校验通过。不得只给一个"最新版本号"就让客户端猜下载地址。 - 下载必须:先查磁盘空间(≥ 3 倍包大小)→ 写
.part→ 流式写盘(不得整包读进内存)→ 校验长度 → 校验 SHA-256 →rename为正式文件。依据:repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:342-394。 - 下载必须支持断点续传与超时(单包 10 分钟);网络失败必须按 1/2/4/8 分钟退避重试 3 次,仍失败则置
failed并展示错误码,不得无限重试。依据:auto-update.ts:124-125,407-414。 - 增量更新必须至少支持"按文件 SHA-256 跳过未变化文件"的差分(对齐
UpdateService.cs:447-485的AllowDiffUpdate语义);差分失败必须自动回退到整包下载,不得让用户停在半更新状态。
安装时机与安全:
- 安装策略三档,必须严格实现:
immediate:下载校验完成后立即安装(仍须满足"不在上课"除非用户显式确认);after-school:到达updateNotBefore(放学时间)后且不在上课才安装;idle(默认):不在上课、且主窗口/设置窗口隐藏满 5 分钟(CLIENT_UPDATE_IDLE_MS)才安装。依据:packages/shared/src/constants.ts:677、auto-update.ts:474-506。
- 关闭自动更新(
autoUpdate=false)时,不得安装任何更新,但仍可展示"有可用更新"的提示;不得静默强装。依据:auto-update.ts:280-296,458。 - 安装必须交给独立助手,并采用固定安装目录 + 回滚副本(不做 side-by-side 多版本并存):主进程把安装包路径、SHA-256、版本、回滚目录写入
update-plan.json→ 生成/校验助手 → 助手就绪回执 → 主进程退出 → 助手等待旧进程结束 → 先把当前安装目录的关键文件(ClassIsland.exe、resources/app.asar等)复制到updates/rollback/<旧版本>/→ 替换/安装 → 启动新版并检查版本 → 写install-result.json(成功/失败+错误)→ 失败用回滚副本恢复旧版并重新拉起。依据:auto-update.ts:516-573(就绪→commit→退出→结果文件)与repos/ClassIsland/ClassIsland/Models/AppUpdating/DeploymentLock.cs(记录已存在文件与组件根目录以便回滚)。 - 安装过程中必须避免"文件被占用":不得覆盖正在运行的
exe/asar;助手必须从updates/目录运行。依据:auto-update.ts:75-88(专用目录、只接受目录内 exe)。 - 安装包路径必须做双重校验:
path.dirname(realpath(file)) === realpath(updatesDir)且扩展名.exe;任何不符必须拒绝执行并清空待安装记录。依据:auto-update.ts:477-490。 - 更新失败必须留下可诊断证据:
update.log(含 HTTP 状态、哈希期望/实际、助手退出码),并在设置页"更新"页展示最近一次失败原因(可复制)。 - 版本回退必须保留上一版安装包与
updates/rollback/<旧版本>/回滚副本及"上次可用版本"标记;新版启动失败(连续 2 次崩溃)必须提示"回退到上一版本"并给出按钮。【推断】 - 插件更新与主程序更新必须分开:插件更新不得要求重启整个应用(除非插件声明
restartRequired);主程序更新必须重启。两者进度不得互相覆盖同一个policy.json。
12.7 崩溃自愈与看门狗(必须实现)
- 主进程必须监听以下事件并分别处理(不得合并成一个"崩溃就重启"):
| 事件 | 处理 | 限流 |
|---|---|---|
设置窗口 render-process-gone |
静默销毁并重建窗口;保留配置与状态 | 10 分钟内最多 3 次,超限只记日志 |
设置窗口 unresponsive 超过 10 秒 |
重建窗口;期间 responsive 恢复则取消 |
同上 |
岛 render-process-gone |
隐藏死窗口 → 重建岛窗口 → 重推状态 | 同上 |
| 岛心跳超时(>3 秒) | 判定幽灵窗口 → 隐藏并重建 | 同上 |
child-process-gone |
记录 type/reason/exitCode;插件宿主崩溃按插件粒度重启 | — |
主进程 uncaughtException |
记录完整堆栈;仅当"不在上课且所有窗口不可见"才自动重启 | 10 分钟内最多 3 次 |
| 内存超限 | 1.5GB(MemoryLimitBytes=1500000000)时触发一次自重启(带静默参数),并记录 -psmk 语义的"上次因 MLE 退出"标记 |
每次启动最多 1 次 |
依据:repos/ClassHelper-master/packages/desktop-client/src/main/crash-guard.ts:20-25,49-75,103-184;repos/ClassIsland/ClassIsland/Services/MemoryWatchDogService.cs:16-70(60 秒检查一次、1.5GB 阈值、用 -q -m -psmk 重启)。
2. 重建窗口前必须 hide() 再 destroy(),避免留下一帧残影或白屏。依据:crash-guard.ts:139-145。
3. 冒烟/自动化验收模式下绝对禁止 app.relaunch()(会把验证父进程搞乱);这条必须写进代码分支并以环境变量控制。依据:crash-guard.ts:153-159。
4. 岛窗口自愈必须同时处理"渲染进程没了"和"窗口还在但点不动"两种形态;后者只能靠心跳发现。依据:island.ts:583-599。
5. 所有自愈操作必须写日志:reason、exitCode、本窗口期第几次、是否达到限流上限。日志是教室现场排障的唯一证据。
12.8 验收标准(第 12 部分)
- 插件安装/启用/禁用/卸载全流程可用:安装后重启生效、
.disabled标记正确、卸载后目录被删除;同一插件重复安装不丢用户配置。 - 依赖排序正确:A 依赖 B 时先加载 B;必选依赖缺失时 A 状态为
Error且错误信息可在设置页看到;循环依赖给出明确错误。 - 插件崩溃不会导致主进程退出:杀死插件宿主进程后,主进程存活、岛继续刷新、设置页显示"插件已停止"。
- 未声明权限的插件调用受控 API 时必须被拒绝并有日志,且不会抛到渲染进程。
- 插件市场能读取索引、切换镜像、下载并校验 MD5+SHA-256;校验失败时文件被删除并报错。
- 通道白名单:渲染进程无法调用未登记通道;
window.desktop中不存在ipcRenderer/fs/process。 - 任意跨桥接传参若含 Vue 响应式对象,必须抛出可读错误(
An object could not be cloned.)而不是静默失效;所有调用点已展开为纯数据。 - 第二次启动应用不产生第二个实例,而是唤起已有窗口;
classisland://app/settings/update能在已运行实例中打开更新页。 - 更新全流程:检查 → 下载 → 校验 → 等待空闲 → 助手安装 → 新版启动写回执;
autoUpdate=false时绝不安装;上课时段绝不安装。 - 更新助手失败时能回滚并写
install-result.json,设置页能展示失败原因。 - 主窗口渲染进程崩溃后 10 分钟内最多重建 3 次,之后停止并可人工介入;日志含原因与次数。
- 内存看门狗在超过 1.5GB 时自重启,且重启后保留配置、跳过一次引导、记录 MLE 标记。
- 岛心跳超时能触发重建;重建后状态、课表、外观全部恢复(不出现空白岛)。
- 所有新增 IPC 通道都登记在
channels.ts与 12.4 表中,且类型定义在packages/shared/src/ipc.ts中单一来源。 - 可选本地 HTTP API 默认关闭;开启后只监听
127.0.0.1、端口随机、请求必须带一次性 token、只存在GET /v1/status、GET /v1/schedule/today、GET /v1/profile/summary三个只读接口,任何写请求必须返回 405;关闭后端口立即不可达。 - 插件市场默认只有离线示例索引;添加外部索引 URL 必须二次确认后才联网;断网/源不可达时本机插件与本地索引不受影响。
- 插件设置页只能渲染声明式 schema;插件若尝试注入 HTML/iframe 或直接操作 DOM,必须被拒绝并记 error。
- 插件清单 camelCase 写入、PascalCase 旧清单可正常加载(同一插件两种清单导入后行为一致)。
- 更新默认只接受
https://;未签名清单在设置页显示"未签名发布"标记;签名清单的keyId未在公钥表内时拒绝更新。 - 更新安装使用固定安装目录,替换前生成
updates/rollback/<旧版本>/回滚副本;安装失败能回滚并写install-result.json;磁盘上不出现 side-by-side 并存的多个app-<版本>目录。