## 12. 插件系统、跨进程通信、单实例与自动更新 ### 12.1 目标与边界 1. 你必须实现一套**进程隔离、权限受控、可市场分发**的插件系统,能力范围对齐 ClassIsland 的插件体系(组件、提醒提供者、自动化触发器/行动、设置页、主题、信息提供者),但实现形态必须是 Electron 下的「npm 风格动态模块 + utilityProcess 宿主」,不得沿用 .NET 程序集加载。 2. 你必须实现完整的跨进程通信层:主进程 ↔ 应用窗口渲染进程 ↔ 岛渲染进程 ↔ 插件宿主进程 ↔ 更新助手。**任何进程间只传纯数据**,不得传函数、类实例、Proxy。 3. 你必须实现单实例、URI 协议唤起、自动更新、崩溃自愈与看门狗。这些能力的失败模式直接决定"教室里能不能无人值守跑一学期"。 4. 你**不得**把插件能力做成"直接在主进程 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(``)。依据:`repos/ClassIsland/ClassIsland.PluginSdk/ClassIsland.PluginSdk.targets`(`CreateCipx` Target)与 `generate-md5.ps1`。 ### 12.3 本实现的插件规范(必须逐项落地) **包与目录**: 1. 插件包扩展名沿用 `.cipx`(zip),内部结构固定: ```text my-plugin.cipx ├─ manifest.yml # 必须存在;字段见下表 ├─ main.mjs # 必须存在;ESM 入口,默认导出 activate/deactivate ├─ icon.png # 可选,默认 icon.png ├─ README.md # 可选 └─ assets/ # 可选静态资源(只允许相对路径引用) ``` 2. 运行期目录:`/plugins//`(解压后)、`/plugin-cache/packages/*.cipx`(下载缓存)、`/plugin-data//`(插件配置与缓存)、`/plugin-cache/index.json`(市场索引缓存)。必须与 13 部分的目录规划一致。 3. 启用状态用 `/.disabled` 标记,卸载用 `/.uninstall` 标记;**不得**用配置里的布尔值表达启用状态(否则用户手工操作目录后状态会不一致)。依据:`PluginInfo.cs:47-100`。 4. 插件数据写入必须由宿主代理:插件调用 `ctx.storage.set(key, value)`,宿主写到 `/plugin-data//storage.json`,**不得**给插件直接 `fs` 访问。 **清单(`manifest.yml`,camelCase,字段与默认值必须与 12.2 表一致,另加以下扩展)**: ```yaml 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 # 仅允许访问列出的域名(精确匹配,禁止通配) ``` 5. `permissions` 是**本实现在 ClassIsland 基础上的强制安全增强**:ClassIsland 的插件是 DLL 全信任,本实现必须默认拒绝、按声明放行。任何未声明的能力调用都必须抛 `PluginPermissionError` 并写 warn 日志;设置页必须在启用前展示权限清单并二次确认。**【推断】** 6. `apiVersion` 必须是 semver;宿主 API 当前版本固定为 `2.0.0`,`apiVersion` 主版本不同即拒绝加载;次版本号高于宿主时给出"可能不兼容"警告但仍允许(与 ClassIsland 的"最高版本门槛"语义相反,此处以兼容性优先【推断】)。 7. 依赖解析必须:先校验所有 `isRequired` 依赖存在且 `Loaded`;再做拓扑排序;循环依赖必须报错并列出环上的 ID;可选依赖缺失只记 info。依据:`PluginService.cs:306-339`。 **生命周期与宿主**: 1. 插件宿主必须是 `utilityProcess.fork(path.join(__dirname,'plugin-host.js'))` 启动的独立进程;插件代码在该进程内用 `await import(pathToFileURL(entry))` 加载。**不得**在主进程 `import()` 插件代码。 2. 宿主暴露给插件的 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`。 3. 插件导出的入口签名固定: ```js export async function activate(ctx) { /* 注册能力;不要在这里做耗时 I/O */ } export async function deactivate(ctx) { /* 清理定时器与监听器 */ } export const manifest = {/* 可选:与 manifest.yml 一致 */}; ``` 4. 宿主必须为每个插件维护独立状态机:`discovered → validating → disabled | loading → loaded | error → restart-required`。状态与错误信息通过 IPC `classisland:plugins:list` 交给设置页渲染。 5. 插件加载超时必须为 5 秒;单个插件崩溃后宿主必须重启该插件(最多 2 次),仍失败则置 `error` 并在**下一次启动**写入 `.disabled`(对齐 `AutoDisableCorruptPlugins` 语义)。同一次运行内不得无限重启。 6. 启用/禁用/卸载必须标记"需要重启",并在 UI 上明确显示「重启 ClassIsland 后生效」;不得热替换已加载插件(热替换会留下旧的全局监听器)。依据:`PluginInfo.RestartRequired`(`PluginInfo.cs:159-172`)。 7. 插件注册的扩展点必须进入统一注册表,重复 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。 **插件市场(本实现)**: 1. 索引 JSON schema 必须为: ```json { "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" } } ``` 2. `downloadUrl` 必须支持 `{root}` 模板,按当前选中镜像替换;镜像失败必须按列表回退,不得只试一个。 3. 索引必须支持"多源":**默认不内置在线官方索引**,只有用户源(`userPluginIndexes`);内置源只允许是随安装包分发的离线示例索引。每源独立记录"最后刷新时间/错误/是否过期";7 天未刷新时后台刷新,失败保留旧索引并标 `stale`。依据:`PluginMarketService.cs:43-69,107-120`(ClassIsland 的刷新语义)+ 本应用默认离线策略【推断】。 4. 下载必须**同时校验 MD5 与 SHA-256**:MD5 用于兼容 ClassIsland 生态,SHA-256 用于本实现的安全底线;任一不符即删除文件并报错。不得"只校验 MD5"。 5. 下载完成后必须走与安装包相同的原子流程(`.part` → 校验 → rename),不得把半截 zip 交给解压器。 6. 插件更新必须遵守「上课不打断」:`IsPluginsAutoUpdateEnabled=true` 时,只在**非上课时段**且设置窗口不可见时才后台下载;安装必须等用户确认或下次启动。依据:ClassIsland 默认值(`Settings.cs:1858-1859`)+ 本应用"零打扰"原则。 7. **默认离线(强制)**:安装包只能内置一份离线示例索引(`resources/plugin-index.example.json`,0–2 条示例,默认不可安装或只指向本地文件)。用户必须**手动添加索引 URL 并二次确认**后才允许联网;确认弹窗必须写明"将从该地址下载并执行第三方代码,请确认来源可信"。网络失败、索引 404、源不可达均不得影响本机已安装插件与本地索引。主题市场遵循同一默认策略(详见 9 部分)。 ### 12.4 跨进程通信(IPC)设计(必须完整实现) **通道命名与组织**: 1. 通道名必须为 `classisland::` 的 kebab 形式,全部集中在 `src/main/channels.ts`(**零 import 叶子模块**)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/channels.ts:16-28`。 2. 通道分三类,不得混用: - `invoke/handle`:请求-响应(读写配置、查询状态、执行命令); - `send/on`:单向事件(岛事件、心跳、交互上报); - `webContents.send`:主进程→渲染进程推送(状态、外观、进度)。 3. 必须实现的通道清单(最小集,新增通道必须同表登记): | 通道 | 方向 | 用途 | |---|---|---| | `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 与错误约定**: 4. 所有跨桥接数据必须是结构化克隆可序列化值:无函数、无 Symbol、无 Proxy、无 `Date` 之外的自定义类;`Error` 必须转成 `{code, message, detail?}` 普通对象。依据:`src/preload/index.ts:17-24` 的注释。 5. 单个 IPC 消息必须 ≤ 8MB;大文件(备份、导出档案、主题包)一律"传路径不传内容",由主进程读文件。 6. 所有 `invoke` 处理必须:校验 sender 必须是本应用的窗口(比对 `event.senderFrame.url` 白名单)→ 校验参数(类型、范围、长度)→ 执行业务 → 返回 `{ok:true, data}` 或 `{ok:false, error}`。**不得让异常穿透 IPC 抛给渲染层**(生产构建的堆栈会泄漏内部路径)。 7. 每次 `invoke` 在渲染层必须包一层 10 秒超时;超时必须提示"操作超时,请重试",不得让按钮永久转圈。 8. 高频事件必须节流:外观滑块 ≤ 20 次/秒(50ms)、岛状态推送 100ms 合并(尾部保留最后一次)、心跳 1 次/秒。依据:`island.ts:216-224`、`auto-update.ts:216-219` 的节流写法。 9. 事件订阅必须可取消:preload 上的 `onXxx(handler)` 必须返回 `() => void` 移除监听;组件卸载时调用。依据:`src/preload/index.ts:32-36,56-60`。 **岛窗口专用通信纪律**: 10. 岛渲染进程只允许两类操作:订阅主进程推来的状态/外观/课表,以及上报用户操作与命中信息。不得让岛直接读写配置。依据:`src/preload/island.ts:10-13` 的注释。 11. 命中控制必须"只上报 true,关闭由主进程按真实光标位置决定":双向下发会让窗口在"接收/穿透"之间抖动,表现为"点了没反应"。依据:`src/types/desktop.d.ts:221-228`。 12. 岛必须每秒发一次心跳(`alive()`);主进程超过 3 秒未收到心跳即判定"幽灵窗口",隐藏并重建。依据:`src/main/island.ts:425-430,541-599`(`lastAliveAt`、`checkRendererAlive` 思路)。 13. 触摸屏必须上报 `setTouchMode(navigator.maxTouchPoints > 0)`;触摸模式下窗口必须贴合岛体(岛体+10px 阴影留白)并始终接收输入。依据:`src/main/island.ts:245-316`。 14. 主进程必须对岛窗口做导航白名单与 `window-open(e) = deny`;岛是置顶窗口,被导航走等于把 `window.island` 桥交给陌生页面。依据:`src/main/island.ts:32-50,516-522`。 **可选本地只读 HTTP API(外部程序集成,默认关闭)**: 15. 本实现**不恢复** ClassIsland 的命名管道 IPC;如需让外部程序(脚本、班级大屏看板、第三方展示页)读取当前课程,只能启用"本地只读 HTTP API",且必须满足以下全部约束: - 只监听 `127.0.0.1`(不得监听 `0.0.0.0`、不得绑定局域网地址); - 端口**随机分配**(`listen(0)` 后读取实际端口),端口与开关写入 `config.localApi`,默认 `enabled=false`; - 开启时必须生成**一次性 token**(随机 32 字节,base64url),请求必须带 `Authorization: Bearer `;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 协议(必须实现) 1. 单实例:`app.requestSingleInstanceLock()`;未拿到锁必须 `app.quit()` 并立即返回;拿到锁必须监听 `second-instance` → 显示/聚焦主窗口(若被最小化先 `restore`)。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/index.ts:176-182,279-285`。 2. `second-instance` 必须解析新实例的 `argv`:若带 `classisland://` URI,转交现有实例导航;若带 `--hidden` 只做唤醒不抢焦点。参考 `repos/ClassIsland/ClassIsland/Program.cs:88-110,141-159` 的"第二次启动把 URI 通过 IPC 发给已运行实例后退出"语义。 3. 必须注册 `classisland://` 协议:Windows 用 `app.setAsDefaultProtocolClient('classisland')` 并在 NSIS 安装器中登记;macOS 监听 `open-url`;Linux 通过 `.desktop` 的 `MimeType=x-scheme-handler/classisland`。 4. URI 语法固定:`classisland:///?`;允许的 host 与行为: - `app/settings/` → 打开设置页(例:`classisland://app/settings/update` 对齐 ClassIsland `UpdateService.cs:326`); - `app/notification` → 打开提醒历史; - `lesson/current` → 显示当前课程详情; - `profile/import?path=` → 导入档案(必须二次确认); - `action/` → 触发自动化行动(仅当该行动声明 `uriTrigger=true` 且用户在设置中允许)。 未知 host/path 必须忽略并提示「无法识别的链接」,不得静默执行。URI 长度上限 2048,必须 URL 解码后再解析,禁止 `..` 路径穿越。 5. 单实例锁必须与"冒烟/隔离配置"兼容:验收时有独立的 `userData`,否则会被真实实例的锁挡住而"静默退出(退出码 0、无输出)"。依据:`src/main/index.ts:29-32` 注释。 ### 12.6 自动更新(必须按此流程实现) **来源与通道**: 1. 更新通道至少三档:`stable`(默认)、`beta`、`dev`;通道描述含 `id`、`name`、`description`、`rootUrl`。**默认只接受 `https://`**;镜像/自定义源必须由用户显式开启并二次确认"非官方来源风险",默认不启用。清单必须校验 SHA-256;清单签名可选,一旦提供则必须是 Ed25519(带 `keyId`),客户端内置公钥表(支持多 keyId,便于轮换),验签失败必须拒绝整个更新;未签名清单必须在设置页显著标注"未签名发布"并写审计日志。依据:`repos/ClassIsland/ClassIsland/Services/AppUpdating/UpdateService.cs:660-669`(`DetachedSignatureProcessor` 的验签语义)。 2. 检查更新必须支持:启动后延迟 30 秒检查一次、之后每 6 小时一次、设置页手动检查、`classisland://app/settings/update` 深链进入。检查间隔与 `LastCheckUpdateTime` 必须持久化,避免频繁联网。依据:`Settings.cs:75-78`。 3. 更新状态必须走固定枚举:`UpToDate`(已是最新)、`UpdateAvailable`(发现新版)、`UpdateDownloaded`(已下载)、`UpdateDeployed`(已部署待重启);工作状态:`Idle`、`Checking`、`Downloading`、`Extracting`。依据:`repos/ClassIsland/ClassIsland.Shared/Enums/UpdateStatus.cs`、`UpdateWorkingStatus.cs`。 **清单与下载**: 4. 更新清单必须包含:`version`、`releaseNotes`、`pubDate`、`minOsVersion`、`files[{path,size,sha256,url}]`、`installer`(整包)或 `components`(分组件增量);清单可选附带 `signature{alg:"ed25519", keyId, sig}`,只要附带就必须校验通过。**不得**只给一个"最新版本号"就让客户端猜下载地址。 5. 下载必须:先查磁盘空间(≥ 3 倍包大小)→ 写 `.part` → 流式写盘(不得整包读进内存)→ 校验长度 → 校验 SHA-256 → `rename` 为正式文件。依据:`repos/ClassHelper-master/packages/desktop-client/src/main/auto-update.ts:342-394`。 6. 下载必须支持断点续传与超时(单包 10 分钟);网络失败必须按 1/2/4/8 分钟退避重试 3 次,仍失败则置 `failed` 并展示错误码,不得无限重试。依据:`auto-update.ts:124-125,407-414`。 7. 增量更新必须至少支持"按文件 SHA-256 跳过未变化文件"的差分(对齐 `UpdateService.cs:447-485` 的 `AllowDiffUpdate` 语义);差分失败必须自动回退到整包下载,不得让用户停在半更新状态。 **安装时机与安全**: 8. 安装策略三档,必须严格实现: - `immediate`:下载校验完成后立即安装(仍须满足"不在上课"除非用户显式确认); - `after-school`:到达 `updateNotBefore`(放学时间)后且不在上课才安装; - `idle`(默认):不在上课、且主窗口/设置窗口隐藏满 5 分钟(`CLIENT_UPDATE_IDLE_MS`)才安装。依据:`packages/shared/src/constants.ts:677`、`auto-update.ts:474-506`。 9. 关闭自动更新(`autoUpdate=false`)时,**不得**安装任何更新,但仍可展示"有可用更新"的提示;不得静默强装。依据:`auto-update.ts:280-296,458`。 10. 安装必须交给独立助手,并采用**固定安装目录 + 回滚副本**(不做 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`(记录已存在文件与组件根目录以便回滚)。 11. 安装过程中必须避免"文件被占用":不得覆盖正在运行的 `exe`/`asar`;助手必须从 `updates/` 目录运行。依据:`auto-update.ts:75-88`(专用目录、只接受目录内 exe)。 12. 安装包路径必须做双重校验:`path.dirname(realpath(file)) === realpath(updatesDir)` 且扩展名 `.exe`;任何不符必须拒绝执行并清空待安装记录。依据:`auto-update.ts:477-490`。 13. 更新失败必须留下可诊断证据:`update.log`(含 HTTP 状态、哈希期望/实际、助手退出码),并在设置页"更新"页展示最近一次失败原因(可复制)。 14. 版本回退必须保留上一版安装包与 `updates/rollback/<旧版本>/` 回滚副本及"上次可用版本"标记;新版启动失败(连续 2 次崩溃)必须提示"回退到上一版本"并给出按钮。**【推断】** 15. 插件更新与主程序更新必须**分开**:插件更新不得要求重启整个应用(除非插件声明 `restartRequired`);主程序更新必须重启。两者进度不得互相覆盖同一个 `policy.json`。 ### 12.7 崩溃自愈与看门狗(必须实现) 1. 主进程必须监听以下事件并分别处理(不得合并成一个"崩溃就重启"): | 事件 | 处理 | 限流 | |---|---|---| | 设置窗口 `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 部分) 1. 插件安装/启用/禁用/卸载全流程可用:安装后重启生效、`.disabled` 标记正确、卸载后目录被删除;同一插件重复安装不丢用户配置。 2. 依赖排序正确:A 依赖 B 时先加载 B;必选依赖缺失时 A 状态为 `Error` 且错误信息可在设置页看到;循环依赖给出明确错误。 3. 插件崩溃不会导致主进程退出:杀死插件宿主进程后,主进程存活、岛继续刷新、设置页显示"插件已停止"。 4. 未声明权限的插件调用受控 API 时必须被拒绝并有日志,且不会抛到渲染进程。 5. 插件市场能读取索引、切换镜像、下载并校验 MD5+SHA-256;校验失败时文件被删除并报错。 6. 通道白名单:渲染进程无法调用未登记通道;`window.desktop` 中不存在 `ipcRenderer`/`fs`/`process`。 7. 任意跨桥接传参若含 Vue 响应式对象,必须抛出可读错误(`An object could not be cloned.`)而不是静默失效;所有调用点已展开为纯数据。 8. 第二次启动应用不产生第二个实例,而是唤起已有窗口;`classisland://app/settings/update` 能在已运行实例中打开更新页。 9. 更新全流程:检查 → 下载 → 校验 → 等待空闲 → 助手安装 → 新版启动写回执;`autoUpdate=false` 时绝不安装;上课时段绝不安装。 10. 更新助手失败时能回滚并写 `install-result.json`,设置页能展示失败原因。 11. 主窗口渲染进程崩溃后 10 分钟内最多重建 3 次,之后停止并可人工介入;日志含原因与次数。 12. 内存看门狗在超过 1.5GB 时自重启,且重启后保留配置、跳过一次引导、记录 MLE 标记。 13. 岛心跳超时能触发重建;重建后状态、课表、外观全部恢复(不出现空白岛)。 14. 所有新增 IPC 通道都登记在 `channels.ts` 与 12.4 表中,且类型定义在 `packages/shared/src/ipc.ts` 中单一来源。 15. 可选本地 HTTP API 默认关闭;开启后只监听 `127.0.0.1`、端口随机、请求必须带一次性 token、只存在 `GET /v1/status`、`GET /v1/schedule/today`、`GET /v1/profile/summary` 三个只读接口,任何写请求必须返回 405;关闭后端口立即不可达。 16. 插件市场默认只有离线示例索引;添加外部索引 URL 必须二次确认后才联网;断网/源不可达时本机插件与本地索引不受影响。 17. 插件设置页只能渲染声明式 schema;插件若尝试注入 HTML/iframe 或直接操作 DOM,必须被拒绝并记 error。 18. 插件清单 camelCase 写入、PascalCase 旧清单可正常加载(同一插件两种清单导入后行为一致)。 19. 更新默认只接受 `https://`;未签名清单在设置页显示"未签名发布"标记;签名清单的 `keyId` 未在公钥表内时拒绝更新。 20. 更新安装使用固定安装目录,替换前生成 `updates/rollback/<旧版本>/` 回滚副本;安装失败能回滚并写 `install-result.json`;磁盘上不出现 side-by-side 并存的多个 `app-<版本>` 目录。