可选功能组件
Spark Work 可选功能组件完整指南:Codex 本地运行环境、离线 Office 预览、本地深度处理、FFmpeg、Chromium 与语音输入资源六个大体积组件不随安装包分发,而是按需下载。本页先讲清 definitions.ts 里 archive 与 external 两条安装通道的真实差异——四个 external 能力里三个照样从 Spark 自建制品仓库下载,只有 Chromium 走 npm 生态,selectArtifacts 返回空数组只是「不走通用流水线」的意思;接着逐个拆解四个专用完整性服务的探测与安装:FFmpeg 的 managed/system/none 三级降级与从目录名解析版本、Chromium 的四组包解析候选与三级浏览器探测及其 150 MB 固定估值、语音包的三组件与四值平台白名单及单飞保护、Codex 运行时的四段目录与 bundled 开发态旁路。版本协商部分给出协议基线 0.144.5、exact-sdk-match 与 newest-compatible 的取舍、五种失败原因对应的用户提示原文,以及三个环境变量的注入点。archive 部分覆盖七个阶段、包内 manifest 与全量文件 SHA-256、三层路径与符号链接防护、CapabilityPackageHealth 实际只覆盖两个能力的事实,以及激活失败时「原版本已保留」的回滚语义。操作语义部分说明 install/update/repair 是同一个实现(修复必然整包重下)、取消与卸载只对 archive 能力可用、卸载只删状态目录而够不到外部能力的二进制、自动更新默认值与 check 的触发条件、启动提醒的 7 天冷却与按版本静音,以及清单缓存 24 小时内不联网会让 remoteAvailable 为假从而静默跳过提醒的关键坑。协议部分覆盖 capability-asset:// 的五步路径穿越与符号链接防护、四条 host 分支与构建期资源外部化插件的 fail-fast,以及 Codex Runtime 诊断卡片的指标、四个告警阈值与「只回收空闲实例」的重启语义。文末附 11 个 IPC 通道契约、30 行排查表,以及 11 项声明了但当前不起作用的东西。
可选功能组件(Optional Capabilities)是 Spark Work 里「按需下载、不进基础安装包」的那批资源: Codex 原生运行时、Office 离线预览、本地深度处理、FFmpeg、Chromium 和语音输入资源。 它们加起来有几百 MB,如果塞进安装包,每个用户都要为可能永远用不到的能力付费。
这一页按真实代码讲清四件事:六个能力其实走两条完全不同的安装通道、 四个 external 能力真正的探测与安装实现在哪、「修复」按钮到底做了什么、 以及 capability-asset://这个自定义协议怎么把 Office 预览器喂起来。
先给三条最容易搞错的结论:第一,definitions.ts里那个 source: 'external'并不意味着「不来自 Spark 仓库」—— 它指的是「不走本文档第 6 节的通用归档流水线」,四个 external 能力里有三个照样从 Spark 自建制品仓库下载; 第二,界面上「安装 / 更新 / 修复」三个按钮调用的是同一个函数,repair(id)的实现就是 enqueueInstall(id); 第三,「卸载」只删元数据状态目录,不动那些落在别处的二进制。
1. 这套子系统解决什么问题
可选功能组件是一层资源按需分发的薄管理层:它自己不做下载实现, 而是把「有没有装、该装哪个版本、下载到哪、装完怎么校验」这四个问题, 分派给 capabilities 目录下的通用流水线或四个专用完整性服务。
1.1 为什么基础安装包不带这些资源
代码里的注释把动机写得很直白。Office 那条(构建期资源外部化插件)写的是: 「File Viewer 渲染器通过import.meta.url附带大型默认 PPT/PPTX 资源。 Spark 在安装 office-viewer 后总是提供capability-asset://URL, 因此保留这些默认资源会在 app.asar里重复一份 Worker/WASM/字体文件。」
语音那条例外更具体:识别模型约 219 MB,弱网下不能沿用通用归档的 2 分钟超时, 所以单独给了 VOICE_ARCHIVE_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000(30 分钟)。
Chromium 那条则是「体积换独立性」:CHROMIUM_ESTIMATED_DOWNLOAD_SIZE = 150 * 1024 * 1024, 下载它是因为 Playwright 需要一个独立的浏览器,而不是借用你系统里的 Chrome。
1.2 「可选功能组件」与「完整性」是什么关系
在界面上它们是同一块地方——设置 → 系统 → 完整性(该设置分区的项 id 是 integrity, 图标Icons.Shield,关键词含「校验 / 修复 / Chromium / 下载 / 完整性」)。 这一块从上到下依次渲染五张卡片:
- SDK 完整性(内联在
SettingsView的IntegritySection里,不是独立组件); FfmpegStatusCard(FFmpeg 专项卡片);CodexRuntimeDiagnosticsCard(Codex 运行时诊断,见第 11 节);VoiceIntegritySettingsItem(语音输入 ASR 专项卡片);OptionalCapabilitiesSettingsCard(六个能力的统一列表,见第 3.1 节)。
所以「完整性」是一个界面分区,不是一套服务;它下面既有统一的能力列表, 也有历史遗留下来的三个专项卡片(FFmpeg、语音、Codex 诊断)。
一个容易被误解的点:Chromium 虽然属于可选功能组件、也能从这个列表里安装, 但它的独立状态卡片并不在「完整性」里,而是在设置 → 系统 → 浏览器自动化(设置分区「系统」下的项 idplaywright,组件PlaywrightStatusCard)。
1.3 六个能力清单
能力定义在apps/desktop/src/main/services/optional-capabilities/definitions.ts的 OPTIONAL_CAPABILITY_DEFINITIONS数组里,一共六项。 每项的字段类型如下:
| 字段 | 类型 | 含义 |
|---|---|---|
id | OptionalCapabilityId | 六选一的联合类型(见第 12 节) |
displayName | string | 界面显示名 |
description | string | 界面副标题 |
source | 'archive' | 'external' | 安装通道,二选一 |
cancellable | boolean | 安装过程中能否取消 |
supportsUninstall | boolean | 能否在应用内卸载 |
selectArtifacts | (manifest, platform, arch) => SparkInstallArtifact[] | 从安装清单里挑出要下载的制品 |
六个能力的实际取值:
| id | 显示名 | source | cancellable | supportsUninstall | 制品选择 |
|---|---|---|---|---|---|
codex-runtime | Codex 本地运行环境 | external | false | false | () => [] |
office-viewer | 离线 Office 预览 | archive | true | true | 前缀archive.optional-office-viewer-,取最新一个 |
local-depth | 本地深度处理 | archive | true | true | 前缀runtime.optional-depth-+ model.depth-anything-v2-small-int8-,两个都要有 |
ffmpeg | FFmpeg | external | false | false | () => [] |
chromium | Chromium 浏览器运行环境 | external | false | false | () => [] |
voice-pack | 语音输入资源 | external | false | false | () => [] |
注意这个分布:六个能力里只有两个(office-viewer、local-depth) 走通用归档通道,另外四个走 external 通道。而「能在应用内取消」和「能在应用内卸载」这两个能力,只有那两个 archive 能力有。
archive与external的差异在performInstall 的第一行体现: 只要definition.source === 'external',立刻转向 performExternalInstall, 完全不碰后面那套 manifest 解析、暂存、备份、激活的代码。
2. 两条安装通道
OptionalCapabilityManager.performInstall是个分叉点: 它按source 把请求送给两条互不相干的实现。
2.1 source: archive —— 通用归档流水线
这条通道完全由OptionalCapabilityManager自己实现,特点是统一性: 两个 archive 能力共享同一套「解析清单 → 校验制品 → 下载 → 校验包内文件 → 暂存 → 备份 → 激活 → 写状态」 流程,所有路径都在 {userData}/optional-capabilities/<capabilityId>/之下。 细节见第 6 节。
归档格式有硬性限制:validateArtifacts要求artifact.archive.format 必须是tar.gz或zip,其它一律拒绝。制品 URL 也必须是 https:,这是在同一处显式检查的。
2.2 source: external —— 四个专用完整性服务
四个 external 能力的真正实现不在optional-capabilities/目录里, 而在 apps/desktop/src/main/services/下的四个独立服务:
| 能力 | 服务文件 | 行数 |
|---|---|---|
codex-runtime | services/CodexRuntimeIntegrityService.ts | 507 |
ffmpeg | services/FfmpegIntegrityService.ts | 492 |
chromium | services/PlaywrightIntegrityService.ts | 442 |
voice-pack | services/VoiceIntegrityService.ts | 615 |
桥接层是optional-capabilities/externalCapabilityAdapters.ts:它把每个服务包成一个ExternalCapabilityAdapter,只有两个方法——describe()(读状态)和 install()(执行安装)。管理器只认这两个方法,不认识底下的服务。
getExternalCapabilityAdapters()在registerOptionalCapabilityIpc.ts 里 被传给管理器构造函数,这就是四个能力的装配点。
2.3 为什么 external 的 selectArtifacts 返回空数组
这是最容易读错的一处:selectArtifacts: () => [] 看起来像「这个能力不需要制品」, 实际含义是「这个能力不使用通用通道的制品选择器」。
证据是调用关系:selectArtifacts只在 archive 分支里被调用 (performInstall里definition.selectArtifacts(manifest, ...) 那几行), 而 external 分支在它之前就 return 了。四个 external 能力各自在适配器里用自己的选择器直接读清单:
| 能力 | 真正的选择器 | 过滤条件 |
|---|---|---|
codex-runtime | selectCodexRuntimeArtifact(artifacts, triple, sdkVersion, platform, arch) | runtime === 'codex'或 id 前缀runtime.codex,再按平台/架构/协议基线筛 |
ffmpeg | selectFfmpegArtifact(artifacts, platform, arch) | type === 'binary'且 id 前缀binary.ffmpeg |
chromium | 无——见下方说明 | 不走 Spark 清单 |
voice-pack | selectVoiceNativeArtifact+selectVoiceModelArtifact | type === 'voice',id 前缀voice.native./ voice.model. |
Chromium 是唯一的例外:它不走 Spark 自建制品清单,而是直接用应用内已装好的 Playwright 包去拉浏览器。这一点在performExternalInstall的注释里写得很明确: 「Chromium 可以从已经随包提供的 Playwright 包安装,不需要 Spark 制品清单。 其他适配器会从它们自己的选择器给出精确的『制品不可用』错误。」
也就是说:external 里有三个依赖 Spark 自建仓库,一个(Chromium)依赖 npm 生态。 这一层差异是排查时最该先分清的一步—— 前三者装不上要怀疑制品仓库与网络,Chromium 装不上要怀疑 pnpm / 内置 Node 运行时。
3. 用户能看到的三个入口
这套子系统在界面上没有独立页面,只有三个入口:一张设置卡片、一个启动弹窗、以及组件内部的按需提示。
3.1 设置 → 系统 → 完整性 → 可选功能组件
组件是OptionalCapabilitiesSettingsCard,区块标题「可选功能组件」, 副标题「Codex、Office、视频处理、浏览器和语音资源按需安装,不占用基础安装包空间。」, 右上角一个「检查更新」按钮(调actions.refresh(true),即强制走远程)。
列表每行显示:显示名、状态文本、描述、以及(正在安装时的)进度条与进度文案;出错时多一行红色错误文本。 右侧操作区依次是:
| 控件 | 出现条件 | 实际行为 |
|---|---|---|
| 「自动更新」开关 | item.installedVersion非空 | optional-capability:set-auto-update |
| 主按钮 | 始终渲染,但targetVersion == null时禁用 | 按状态分派到 install / update / repair |
| 「卸载」按钮 | installedVersion非空且supportsUninstall !== false | 先弹确认框,再调 uninstall |
主按钮的文案与分派规则(primaryLabel/primaryAction)是:
| 能力状态 | 按钮文案 | 调用 |
|---|---|---|
update_available | 更新 | actions.update(id) |
damaged或error | 修复 | actions.repair(id) |
| 已安装但无更新 | 重新安装 | actions.install(id) |
| 未安装 | 安装 | actions.install(id) |
状态文本(statusText)逐字如下:
ready→ 「已安装 <版本号>」update_available→ 「已安装 <版本号>,有更新」damaged→ 「组件损坏 · <版本号>」error→ 「安装失败」- 其它且有目标版本 → 「未安装 · 下载 xx.x MB」
- 其它且无目标版本 → 「当前平台暂不可用」
卡片底部还有一个「选择并安装可选功能」按钮,副文案「重新打开批量安装弹窗,查看全部组件状态」, 它不直接安装,而是派发一个 CustomEvent(事件名spark:open-optional-capability-center)去打开第 3.2 节的弹窗。 这个导航桥接就写在 5 行的optionalCapabilityNavigation.ts里。
卸载确认框的文案是固定两条:标题「卸载<显示名>」,描述「卸载后再次使用该功能需要重新下载资源。」, 确认按钮「卸载」,且danger: true。
3.2 启动批量安装弹窗
组件是OptionalCapabilityCenter,挂在App.tsx的顶层, 条件是 t.view !== 'onboarding'(引导流程里不打扰)。它同时承担两种角色: 启动提醒,和手动打开的「安装可选功能」弹窗。
| 模式 | 触发 | 标题 | 列表内容 |
|---|---|---|---|
| startup | shouldShowCapabilityPrompt为真且用户没关掉 | 「安装可选功能」;若全部是更新则「核心组件更新」 | 只列出「值得提醒」的能力 |
| manual | 收到spark:open-optional-capability-center事件 | 「安装可选功能」 | 列出全部六个能力 |
弹窗宽度 640,底部按钮依次是「稍后」(manual 模式下文案变成「关闭」)、 「前往完整性」(只在 startup 模式出现)、以及主按钮 「后台更新」/「后台安装」(按当前选中项是否全为更新切换),后面跟一个括号里的已选数量。
选中项的下载总量会实时汇总显示在底部(共 xx MB)。 startup 模式下还有一个复选框,文案完整是: 「不再在启动时提醒(下个版本更新会再提醒一次;也可在“设置 → 完整性”中手动安装)」。
另外还有一个独立的悬浮进度卡(aside,aria-label="可选功能安装进度"), 标题「功能资源」,可点 × 收起。它只显示处于活跃阶段(非missing)的项, 每项显示进度条、已下载 / 总量或消息文本、以及队列位次(队列 N)。 取消按钮只在阶段为queued或downloading、且该能力 cancellable !== false时出现——按第 1.3 节的表,这意味着四个 external 能力身上不会出现这个按钮。
3.3 组件内按需入口
当某个功能真的被用到、而资源还没装时,组件会就地提示而不是报错。 目前的实例是 OfficeFileViewer:它订阅同一份能力快照, 只要office-viewer 没就绪,就把文件查看器替换成一块占位区。
占位区的文案逐字如下:
- 标题:「需要安装离线 Office 预览资源」
- 正在检查时:「正在检查当前平台可用的 Office 预览资源…」
- 有目标版本时:「需下载约 xx.x MB,安装后会自动重试预览。」
- 没有目标版本时:「当前平台暂时没有可用的 Office 预览资源。」
- 按钮:「安装 Office 预览资源」(检查中时显示「正在检查资源…」)
- 安装中:显示进度消息与百分比,不再显示按钮
这里有一个值得学的细节:如果快照里office-viewer既没装、也没有 targetVersion,它会先强制刷新一次远程清单(refreshCapabilities(true)), 且用manifestRefreshAttemptedRef 保证只尝试一次,避免打转。 这么做是因为清单缓存可能是空的或过期的,直接判定「当前平台不可用」会误导用户。
4. 四个 external 能力的探测与安装
这一节是全文最实在的部分:逐个说明「怎么判断装了没」和「点安装之后到底发生了什么」。 四个能力共用一个描述结构ExternalCapabilityDescription,字段是state、installedVersion、targetVersion、downloadSize、installedSize、error、errorCode、retryable。
4.1 codex-runtime
探测:
- 先调
configureCodexRuntimeEnvironment(),它会写SPARK_CODEX_RUNTIME_ROOT,从包元数据里读出 Codex SDK 版本并写SPARK_CODEX_SDK_VERSION;打包态还会写SPARK_CODEX_REQUIRE_RUNTIME=1。 - 读取托管状态文件,位置是
{userData}/agent-runtimes/codex(开发/非 Electron 环境回落到{cwd}/.spark-agent/agent-runtimes/codex)。 - 另外有一个开发态旁路:只有没打包(
!app.isPackaged)且能解析出内置 Codex CLI 时,才算「已安装」,并把版本标成字符串bundled。
安装:
- 从 Spark 自建制品清单里选制品(
fetchSparkInstallManifest()+selectCodexRuntimeArtifact),下载到暂存目录{root}/.staging-<pid>-<时间戳>。 - 激活后的目录结构是
{root}/<版本>/<targetTriple>,并写一个active.json指针。 - 前置校验很硬:没有 SDK 版本直接失败,提示「应用内缺少 Codex JS SDK,请先升级或重新安装 Spark Agent」;平台不支持时提示「当前平台不支持 Codex runtime (平台/架构)」。
codexTargetTriple()决定平台三元组,targetTriple为 'unsupported'时 会被归一成null,适配器随即给出「当前平台暂不支持 Codex 运行时」。
4.2 ffmpeg
探测优先级是三级(doDetectFfmpegIntegrity):
- managed:扫
{userData}/bin/下的子目录,找含ffmpeg(Windows 是ffmpeg.exe)可执行文件的目录,跑一次ffmpeg -version(超时 5 秒)确认它能真的执行。 - system:用
which(Windows 是where)定位系统 PATH 里的 ffmpeg,同样要-version成功——注释说明这是为了排除「文件存在但 dyld 库缺失崩溃」的假可用。 - none:都没有,则
ffmpegReady: false。
返回结构FfmpegIntegrityState的字段是:
| 字段 | 含义 |
|---|---|
ffmpegReady | 是否可用 |
ffmpegSource | 'managed' | 'system' | 'none' |
ffmpegVersion | 从ffmpeg -version首行解析出的版本号 |
ffprobeReady | 同目录下 ffprobe 是否存在 |
binaryPath | ffmpeg 绝对路径 |
ffprobePath | ffprobe 绝对路径 |
lastError | 上一次失败原因 |
版本选择用目录名而不是跑二进制:正则 /(\d+(?:\.\d+){1,2})/从目录名里抽版本元组,例如FFmpeg-8.1.2-Windows-x64→[8,1,2]。 这么做的理由写在注释里:保持 resolveManagedBinaryDir的同步签名,并规避在 Windows 上对陈旧ffmpeg.exe起子进程(可能触发杀软)。 比较函数compareVersionTuples 是降序的,所以[0]就是最高版本, 这也是「升级后新旧目录并存时不会误命中旧版本」的保障。
安装:installFfmpegFromSparkManifest从清单里选制品, 落盘目录是 {userData}/bin/<artifact.name 清洗后的名字>。 清洗规则是:去掉非法字符、空白折叠成-、去掉圆括号;名字为空则退回用 artifact id 清洗。 装完做三件事:非 Windows 平台对 ffmpeg/ffprobechmod 0o755、二次探测确认真的可用、 最后清理其它旧的 ffmpeg 目录(cleanupOldFfmpegDirs,失败只 warn)。
二次探测失败时报的是「FFmpeg 下载完成,但未能在 <目录> 检测到可用的 ffmpeg 二进制」, 这是个明确的「下载成功但不可用」信号,排查时和「下载失败」要分开看。
运行时取二进制的入口是resolveFfmpegBin(),它优先用缓存状态, 缺失时报「FFmpeg 不可用。请在「设置 → 完整性」中下载 FFmpeg,或确认系统已安装 ffmpeg 并在 PATH 中。」; ffprobe 缺失时另有一条提示,说明关键帧时间戳解析需要它。
4.3 chromium
这一项和其他三个都不同——它不读 Spark 制品清单。判定与安装都围绕 Playwright 包展开。
探测(detectIntegrity)返回 PlaywrightIntegrityState:
| 字段 | 来源 |
|---|---|
mcpInstalled/mcpVersion | 能否解析出@playwright/mcp的package.json |
playwrightInstalled | 能否解析出playwright的package.json |
browserReady | 浏览器探测结果 |
browserSource | 'bundled' | 'system' | 'none' |
lastError | 沿用缓存里的错误 |
包解析是同步的,路径按四组候选依次尝试:<root>/node_modules/...、把.asar换成 .asar.unpacked的变体、<root>/apps/desktop/node_modules/...、<root>/packages/agent-runtime/node_modules/...。 这是为了同时覆盖打包态与 monorepo 开发态的多种 pnpm 布局。
浏览器就绪探测按优先级三级:内置browsers/目录 → 系统 Chrome/Edge → Playwright 默认缓存。
安装(installBrowser):
| 场景 | 命令 | 工作目录 |
|---|---|---|
| 打包态 | <内置 Node> <resourcesPath>/playwright-mcp/node_modules/playwright/cli.js install chromium | {userData} |
| 开发态 | pnpm exec playwright install chromium(Windows 用pnpm.cmd且 shell: true) | desktop 应用目录 |
下载目录由getBrowserInstallDirectory决定:打包态是{userData}/browsers, 开发态是 <desktopDir>/browsers。 命令通过环境变量PLAYWRIGHT_BROWSERS_PATH指向该目录, 装完后再把它写进 process.env,这样后续的 Playwright 与 MCP 子进程都能解析到新浏览器。 子进程超时是 5 分钟(300_000)。
装完会重新探测,若browserSource !== 'bundled'就判定失败,提示 「浏览器下载完成,但未能在内置目录检测到 Chromium: <目录>」。
另外注意installMcp()是独立的一条路径(pnpm add @playwright/mcp playwright), 它属于浏览器自动化的 MCP 依赖安装,不在这个能力列表的按钮里。
4.4 voice-pack
语音包由三个组件构成,这是它比其它三个复杂的原因:native(sherpa-onnx 运行时)、model(识别模型)、refine(离线精修模型,可选)。
平台支持是白名单(voicePlatformKey):
| 平台键 | 平台 / 架构 |
|---|---|
darwin-arm64 | macOS Apple Silicon |
darwin-x64 | macOS Intel |
win32-x64 | Windows x64 |
linux-x64 | Linux x64 |
不在表里的组合返回null,能力状态直接标成不支持: 「当前平台不支持语音输入 (平台/架构)」。 例如 Windows on ARM 与 Linux arm64 就是这种情况。
探测(checkVoiceIntegrity)读三处落盘:
- 根目录
{userData}/voice(非 Electron 环境回落到{cwd}/.spark-agent/voice); - 状态文件
voice-state.json,记录每个组件的version/artifactId与updatedAt; - 各组件的实际目录:native 在
voice/native/<版本>-<平台键>、model 在voice/model/<版本>、refine 在voice/refine/<版本>。
native 的判定不是只看状态文件,还会读目录里的package.json; model / refine 则检查model-package.json/refine-package.json是否存在。 状态里还有一个从磁盘恢复的兜底(recovered-from-disk), 用于状态文件丢失但文件其实在的情况。
安装(installVoicePack(force, onProgress))的行为:
- 用一个模块级布尔
installInFlight做单飞保护,重复调用直接返回「语音包正在安装中,请稍候」。 - 下载顺序是先 model 后 native 再 refine。注释解释了原因:模型体积最大,先完成并原子激活;每完成一个组件就立刻写状态,这样后续组件失败时重试不会重复下载大模型。
- 非强制模式下,若核心组件(native + model)已就绪且 refine 无需补装,直接返回「语音包已就绪」。
- 已安装同版本时不重复下载(按
isVoiceModelVersionInstalled/isVoiceNativeVersionInstalled/isVoiceRefineVersionInstalled判断)。 - 激活方式是「先删目标目录、再
rename暂存目录」,暂存目录是{root}/.staging-<pid>-<时间戳>,finally里清理。 - 超时错误会被翻译成人话:匹配到 timeout / timed out / aborted 就提示「下载超时,请检查网络或代理后重试」。30 分钟的超时常量正是为它准备的。
refine组件是允许静默缺失的:清单里没有它不算失败, 缺它时组件状态里的提示是「未安装离线精修模型(可选,用于说完后整段优化)」。 另外 ready的定义是native && model,不含 refine。
5. Codex 运行时的版本选择规则
这是四个 external 能力里唯一有复杂版本协商的一个,也是最容易被用户误解为 bug 的地方: 「为什么我这里是 0.144.5,云端明明有更新的版本却不让我装?」 这一节讲清规则,以及界面上会给出的解释。
5.1 协议基线
常量MIN_SUPPORTED_MANAGED_CODEX_RUNTIME_VERSION = '0.144.5'定义在 packages/agent-runtime/src/sdk/codex-runtime.ts。 选择器第一件事就是按它过滤:低于这个版本的制品连候选都进不去, 理由是「低于协议基线的 runtime 连加载都不允许,更不该被推荐安装」。
如果清单里该平台的制品全都低于基线,结果是below-protocol-baseline, 对用户的解释是:「云端 <平台> 平台的 Codex 运行时(版本…)全部低于应用要求的协议基线 0.144.5,无法安装。 请等待仓库补齐该平台的运行时制品。」
5.2 五种选择结果
selectCodexRuntimeArtifact返回 { artifact?, reason, candidateVersions },reason 是五种之一:
| reason | 含义 | 是否给出可用制品 |
|---|---|---|
exact-sdk-match | 云端有与应用内 JS SDK精确配对的制品(最稳) | 是 |
newest-compatible | 没有精确配对,退到「不高于应用内 SDK 的最新受支持版本」 | 是 |
no-published-runtime | 清单里没有任何本平台/架构的 Codex 运行时 | 否 |
below-protocol-baseline | 该平台有制品,但版本全部低于协议基线 | 否 |
newer-than-app-sdk | 云端制品全部晚于应用内 SDK(应用比仓库旧) | 否 |
「精确配对」的判定写得很具体——isCompatibleWithCodexSdk要求制品满足二者之一:
artifact.sdkPackage === '@openai/codex-sdk@' + sdkVersion;- 或者
artifact.dependencies数组里包含上面这个字符串。
这就是文档标题里说的「与应用内 SDK 匹配」的实际比对代码—— 它不是比较主版本号,而是一个拼好的精确字符串。
选择顺序是:先筛平台/架构/版本安全性 → 按协议基线过滤 → 按版本降序排 → 尝试精确配对 → 退到「不新于应用内 SDK」的第一个 → 都没有则返回newer-than-app-sdk。 注意最后那个分支注释特别说明:「SDK 版本未知时上面的 notNewerThanApp 会保留全部候选,因此不会再落到这个分支」—— 也就是说newer-than-app-sdk只在确实知道 SDK 版本、且云端全部更新时才出现。
还有一层制品合法性校验(validateCodexArtifact):类型必须是binary、 必须有合法的 64 位十六进制 SHA256、targetTriple必须匹配、 版本号必须符合 /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/、sdkPackage若存在必须以 @openai/codex-sdk@开头。
界面上会把「为什么不给我更新」解释清楚。三种提示逐字如下:
- 云端版本更新但不配对:「云端已有更新版本 <版本>,但它与应用内置的 Codex SDK <版本> 不配对。请先升级 Spark Agent 应用,再更新 Codex 运行时。」
- 没有该平台制品:「云端仓库暂未提供 <triple> 平台的 Codex 运行时。请检查网络后重试;若持续存在,请等待仓库补齐该平台制品或反馈给维护者。」
- 云端全部更新:「云端 Codex 运行时(…)全部新于应用内置的 Codex SDK <版本>,直接安装可能不兼容。请先升级 Spark Agent 应用,再更新 Codex 运行时。」
另有一个反向优化:若本机已装版本不低于云端最新,则按「已是最新」呈现而不是报错, 注释写的是「已经没有可执行的动作,此时既不该报错,也不该解释『为什么不装更新的』」。
5.3 环境变量注入
运行时靠环境变量被下游发现,注入点只有configureCodexRuntimeEnvironment()一处:
| 环境变量 | 值 | 条件 |
|---|---|---|
SPARK_CODEX_RUNTIME_ROOT | {userData}/agent-runtimes/codex | 总是设置 |
SPARK_CODEX_SDK_VERSION | 从@openai/codex-sdk包元数据读到的版本 | 读到了才设置 |
SPARK_CODEX_REQUIRE_RUNTIME | 1 | 仅打包态 |
函数注释强调「必须在任何 SessionService 可能创建前设置,避免 Codex executor 看到旧环境」。 这也是为什么适配器的describe()一进来就先调它——读状态的同时顺便把环境对齐。
SDK 版本的读取方式有个坑值得记:它用findPackageJSON直接定位包元数据, 而不是 createRequire().resolve()。原因是 Codex SDK 只暴露 ESMimport 条件,用 require 解析会抛ERR_PACKAGE_PATH_NOT_EXPORTED。
6. archive 能力的安装流水线
这一节讲office-viewer与local-depth共用的通用流程。 它是整台子系统里校验最严格的部分,也是「修复」按钮真正起作用的地方。
6.1 七个阶段
performInstall的顺序是:
- 刷新清单:
refreshManifest(),失败则报manifest_unavailable,文案「无法连接组件仓库,请检查网络后重试」。 - 选制品:
selectArtifacts为空则报artifact_unavailable,文案「当前平台暂无可用制品」,且retryable: false。 - 校验制品:
validateArtifacts,失败报artifact_invalid「远程制品清单无效」。 - 准备目录:暂存目录
{capabilityRoot}/.staging-<uuid>,目标目录{capabilityRoot}/versions/<安全化版本号>,备份目录是目标目录加.backup-<uuid>后缀。 - 逐个下载并校验:对每个制品调
installArchive(下载 + 校验 sha256 + 解压),然后validateInstalledArtifact逐文件比对哈希;任何一步失败都报package_invalid,文案「组件缺少必需文件或完整性校验未通过,请重试安装」。 - 激活:把已存在的目标目录改名成备份,再把暂存目录改名成目标目录,然后写
active.json。 - 清场:成功后删掉备份;
finally里无条件清理暂存与备份残留。
进度阶段(OptionalCapabilityPhase里可用于进度的取值)依次是queued →downloading→verifying→extracting→ activating→ready,失败时是error,取消时是 cancelled。 注意extracting由installArchive的 onStage回调上报, 而downloading阶段会被跳过(if (stage === 'downloading') return),避免重复发事件。
安装是串行队列:enqueueInstall用一个queueTail Promise 链把任务排队, 同一个能力的重复请求直接复用已有的 Promise(if (existing) return existing)。 排队中的任务会收到一条queued 进度,带当前队列位置。
6.2 包内 manifest 与全量哈希
每个制品解压后必须包含一个包内 manifest,文件名由 artifactId 决定:model.开头的是model-package.json, 其它一律是 capability-package.json。
validatePackageDirectory会依次检查:
schemaVersion === 1,且version与制品版本一致;- 非 model 制品还要求
capabilityId与artifactId都对得上; - 必须有
files对象; - 调
validateCapabilityPackageHealth(见 6.3); - 逐文件比对 SHA-256,路径必须安全(见下),且不允许符号链接逃逸。
路径与符号链接的防护有三层:
| 函数 | 检查 |
|---|---|
safePackageFile | 路径非空、不含反斜杠、resolve后必须仍在包根目录之下 |
assertNoSymlinkEscape | 文件本身不能是符号链接;realpath后必须在包根目录之下 |
directorySize | 遍历时遇到符号链接直接抛错「能力包不能包含符号链接」 |
6.3 CapabilityPackageHealth 的实际覆盖面
这一项要特别说清楚,因为它的名字容易让人以为它管所有能力。validateCapabilityPackageHealth的分支只有两条:
- 能力是
office-viewer:要求 artifactId 以archive.optional-office-viewer-开头,再校验 15 个必需文件是否都有哈希。 - artifactId 以
runtime.optional-depth-或model.depth-anything-v2-small-int8-开头:走深度处理的两条校验。 - 其它任何输入 →抛错:「…不是受支持的 Office Viewer 制品」/「…不是受支持的本地深度制品」。
也就是说,四个 external 能力完全不经过这套校验——它们走 performExternalInstall, 根本不会走到这一层。而「完整性修复」在界面上的两个能力也只有office-viewer与 local-depth。
Office 的 15 个必需文件(原样抄自代码):
{ 'flyfish-viewer-manifest.json', 'flyfish-viewer-assets.json', 'vendor/docx/docx.worker.js', 'vendor/docx/jszip.min.js', 'vendor/xlsx/sheet.worker.js', 'vendor/pptx/pptx.worker.js', 'vendor/ppt/index.mjs', 'vendor/ppt/worker.mjs', 'vendor/ppt/frame-cache.mjs', 'vendor/ppt/ppt-native.wasm', 'vendor/ppt/ppt-font-cjk.otf', 'vendor/ppt/manifest.json', 'vendor/ppt/package.json', 'vendor/ppt/LICENSE', 'vendor/ppt/NOTICE', }
深度处理的校验明显更严,理由是可以写出来的:
| 检查项 | 规则 |
|---|---|
| Runtime 入口 | 必须是node_modules/@huggingface/transformers/src/transformers.js |
| 平台/架构 | 包内 manifest 的platform/arch必须与制品一致 |
| 依赖 | 必须声明@huggingface/transformers与onnxruntime-node |
| 禁止项 | 不得包含onnxruntime-web(依赖里和工作目录里都查) |
| 原生绑定 | 必须有 node_modules/onnxruntime-node/bin/napi-v6/<平台>/<架构>/onnxruntime_binding.node |
| 原生动态库 | 该目录下必须至少有一个匹配平台的可执行库:macOS.dylib、Linux .so、Windows.dll |
| 异平台文件 | onnxruntime-node/bin/napi-v6/下不允许出现非目标平台目录的文件 |
model 那一侧的校验很简单:包内 manifest 的modelId必须是 depth-anything-v2-small-int8,并且必须含LICENSE、config.json、onnx/model_int8.onnx、preprocessor_config.json四个文件。
6.4 回滚与旧版本保留
回滚发生在两个不同的时间点,语义不同:
| 时机 | 行为 |
|---|---|
| 激活之前失败(下载/校验出错) | 目标目录从未被改过,旧版本原封不动还在。有一个测试专门锁死这点:「keeps the active version when an update fails before activation」。 |
| 激活过程中失败 | 删掉半成品目标目录,把备份目录改名还原回去,报activation_failed「无法激活新版本,原版本已保留」。 |
备份机制天然提供了单版本回退:激活时先把当前目标目录改名成 .backup-<uuid>,成功后才删除。但要注意它不是版本历史—— 备份只在这一次激活期间存在,成功即删,没有「回滚到上一个版本」的功能。
7. 状态与落盘位置
这一节把「东西到底存在哪」一次列清。三个状态文件都在 {userData}/optional-capabilities/之下。
7.1 active.json 的字段与校验
位置是{userData}/optional-capabilities/<能力 id>/active.json, 写入用「先写.new再rename」的原子替换,文件权限0o600。 结构是ActiveCapabilityState:
| 字段 | 说明 |
|---|---|
schemaVersion | 固定1 |
capabilityId | 必须是请求的那个能力 |
version | 当前激活版本 |
autoUpdate | 布尔 |
activatedAt | ISO 时间字符串 |
runtimeFailure | 可选;含code(固定package_invalid)、message、retryable、reportedAt |
artifacts | 以 artifactId 为键;每项含version、sha256、manifestSha256、directory、size |
read()对这份文件做严格的结构校验:schemaVersion 不符、capabilityId 不符、 two 个 sha256 不是 64 位十六进制、size 不是正整数——任意一条不满足就抛Invalid active state for <id>。只有文件不存在(ENOENT)才返回 null。
状态不能只信文件。真正判定「这份状态还有效吗」的是 validateActiveState, 它会重新算包内 manifest 的哈希并与 manifestSha256比对,再跑一遍完整的validatePackageDirectory(即 6.2 + 6.3 的全部检查)。 校验结果按activeStateKey(activatedAt + version + 各制品指纹)缓存, 缓存命中就不再重复算哈希。
这个过程直接决定界面状态:校验通过且没有运行时失败 →ready; 校验通过但目标版本不同 →update_available;校验不通过 →damaged,界面显示「需要修复」,主按钮变成「修复」。
另一个把状态标成damaged的来源是运行时失败上报:reportRuntimeFailure(id, cause)会往 active.json 里写runtimeFailure, 文案固定为「<显示名>运行验证失败:原生 Runtime 无法加载,请更新或修复组件」。 目前的调用点是画布深度任务(registerCanvasDepthTaskIpc)——也就是说,只有local-depth会走这条路径,而且它是「真跑崩了才报」,比哈希校验更晚也更有说服力。
7.2 manifest-cache.json 与 24 小时缓存
位置{userData}/optional-capabilities/manifest-cache.json, 结构是 { schemaVersion: 1, checkedAt, manifest },同样是原子写 + 0o600。
缓存有效期常量MANIFEST_CACHE_TTL_MS = 24 * 60 * 60 * 1_000(24 小时)。check(forceRemote)的逻辑是:只有forceRemote 为真、或没有清单、或缓存过期时才真的去拉远程。 界面上「检查更新」按钮走的是 refresh(true),也就是强制远程。
一个必须知道的语义:从磁盘加载缓存时,remoteAvailable会被显式置为 false。 代码注释解释得很清楚:「磁盘缓存避免了多余的启动流量,但它不是网络可用的证据。」 这个字段直接影响启动提醒(见 9.3)——只有真的联网拉取过清单,remoteAvailable才是 true。
7.3 auto-update.json
位置{userData}/optional-capabilities/auto-update.json, 形状是一个扁平的<能力 id>: boolean映射,原子写 +0o600。
写之前会先读旧文件并只保留布尔值(把非法值丢掉),读失败且不是 ENOENT才抛错。
注意这里有个分工:archive 能力的自动更新偏好同时写进active.json的 autoUpdate字段和这份 JSON;而 external 能力的偏好只写这份 JSON (因为它们没有 active.json)。读取时 external 侧的顺序是: 内存缓存 → auto-update.json→ 默认值。
7.4 各能力的实际落盘目录
| 能力 | 实际落盘位置 | 状态文件 |
|---|---|---|
codex-runtime | {userData}/agent-runtimes/codex/<版本>/<triple> | 同目录下的active.json(由 CodexRuntimeIntegrityService 自己维护) |
ffmpeg | {userData}/bin/<清洗后的制品名> | 无独立状态文件,靠目录扫描 + 缓存 |
chromium | 打包态{userData}/browsers;开发态 <desktopDir>/browsers | 无,靠包解析与路径探测 |
voice-pack | {userData}/voice/native|model|refine/... | {userData}/voice/voice-state.json |
office-viewer | {userData}/optional-capabilities/office-viewer/versions/<版本>/<artifactId> | {userData}/optional-capabilities/office-viewer/active.json |
local-depth | {userData}/optional-capabilities/local-depth/versions/<版本>/<artifactId> | {userData}/optional-capabilities/local-depth/active.json |
这张表解释了 8.3 节的那个结论:只有两个 archive 能力的文件真的住在 optional-capabilities/<id>/里,所以「卸载」删这个目录对它们有效; 另外四个的二进制散落在agent-runtimes/、bin/、browsers/、voice/,删optional-capabilities/下的目录根本碰不到它们。 而它们又都是supportsUninstall: false,界面上连卸载按钮都不给。
8. install / update / repair / cancel / uninstall 的真实语义
界面给了你五个动作,但底下的实现比你想象的少。这一节逐个说清,尤其是哪些动作其实是同一个。
8.1 三者是同一条路径
OptionalCapabilityManager上这三个方法的实现分别是:
| 方法 | 实现 |
|---|---|
install(id) | return this.enqueueInstall(id) |
update(id) | return this.enqueueInstall(id) |
repair(id) | return this.enqueueInstall(id) |
三个方法体一字不差。所以「修复」不是一种特殊的修复动作—— 它就是「重新走一遍完整安装流程」,靠下载覆盖来达成修复效果。
这带来几个实际后果,都是可以推导出来的:
- 修复必然重新下载。它不会只校验、也不会只补缺失文件,整个制品(含
local-depth的两个制品)都要重新拉一遍。 - 修复需要能连上仓库。断网时点「修复」得到的错误和点「安装」完全一样(
manifest_unavailable)。 - 真正的「校验型修复」不存在。即便损坏只是因为某个文件哈希不匹配,也必须整包重下。
为什么三个都要重新下载?因为performInstall的下载-校验-激活流程本身就是幂等的: 它会重新校验所有制品、重新激活、并保留旧版本直到成功。用一个流程覆盖三种语义,代价是流量,收益是没有特例分支。
测试里也把这个行为固定住了:有一个用例先装成功、再让下载抛错,然后断言 repair('office-viewer')返回success: false, 且 active.json 里的版本仍是旧的。也就是说「修复失败不会把已装好的搞坏」是被测试保证的。
还有一点:enqueueInstall里if (existing) return existing意味着同一个能力在安装中时,连点五次只会执行一次,另外四次直接拿到同一个 Promise。
8.2 取消只对 archive 能力可用
cancel(id)先看definition.cancellable,为 false 时直接返回 「<显示名>当前安装任务不支持取消」,连 AbortController 都不碰。 按第 1.3 节的表,四个 external 能力都是cancellable: false。
另外两种失败也要分清:能力可取消但当前没有在跑安装时,返回 「<显示名>当前没有可取消的安装」。
真能取消时,动作是controller.abort()。中止信号在两个地方被检查:performInstall的各阶段边界(throwIfAborted)和 installArchive内部的signal。 external 适配器里也有 throwIfAborted(signal),但因为不可取消,这条永远走不到。
一个容易踩的坑:取消不会自动回滚已下载的暂存目录—— 回滚由 finally块无条件完成(删暂存、删备份残留),所以结果是干净的, 但已经下完的部分不会保留,下次要重下。
8.3 卸载做了什么、没做什么
同样先过supportsUninstall,为 false 时返回「<显示名>不支持从应用内卸载」。 能卸载时(只有office-viewer与local-depth),实际动作是:
- 若该能力有正在进行的安装,先
abort()并等它结束; store.remove(id)→rm({userData}/optional-capabilities/<id>, { recursive: true, force: true });- 清掉内存里的错误状态与校验缓存;
- 重新构建快照并推送。
也就是说,卸载是删掉这个能力的整个状态目录——active.json 和 versions/下的实际文件一起没了。这很彻底,但也意味着:
- 它不会清理
manifest-cache.json(那是全局共享的); - 它不会为 external 能力删任何东西——因为 external 能力压根不允许卸载, 而它们真正的二进制也不住在
optional-capabilities/里(见 7.4); - 卸载后的能力状态回到
missing,界面显示「未安装 · 下载 xx MB」。
如果你想真正清掉某个 external 能力占的空间,代码里没有提供入口, 只能手动删对应目录(agent-runtimes/codex、bin/下的 ffmpeg 目录、browsers/、voice/),并且注意 Codex 那处还需要留意 active.json指针。 这一点在文档里如实说明,而不是承诺一个不存在的按钮。
9. 自动更新与启动提醒
9.1 自动更新的默认值
默认值由一个函数决定,只有一个例外:
function defaultAutoUpdateForCapability(id: OptionalCapabilityId): boolean { // Codex App Server 是 Spark 适配器的协议上游。已安装的兼容 runtime 应保持可用, // 新版本默认由用户明确选择;其他可选能力沿用原有自动更新默认值。 return id !== 'codex-runtime' }
即:除codex-runtime外全部默认开启自动更新,Codex 运行时默认关闭。 原因写在上面的注释里——它是协议上游,已装的兼容版本比新版更值得保稳。
这个默认值被测试锁死:一个用例断言 Codex 在update_available状态下 autoUpdate: false,并且expect(install).not.toHaveBeenCalled()—— 也就是有更新但不会自动装;再把开关打开后,重启管理器仍能读到 true(验证了持久化)。
界面上这个开关只在installedVersion非空时出现,所以「没装过」的能力看不到开关。 对 archive 能力,开关值存在 active.json;对 external 能力存在 auto-update.json(见 7.3)。
9.2 check 何时触发自动安装
check(forceRemote)在返回快照之后会做一件事:遍历快照里的能力, 凡满足下面三个条件的就静默触发一次安装:
manifestAvailable为真(真的联网拉到过清单,而不是读了磁盘缓存);- 状态是
update_available; autoUpdate为真。
也就是说:只是打开设置页、看到有更新,不一定会自动装—— 因为 check({ forceRemote: false })在缓存新鲜时不会联网,manifestAvailable就是 false。 只有点了「检查更新」(refresh(true))、或缓存过期后重新拉取,自动更新才会真的启动。 这个设计避免了每次渲染设置页都触发下载。
配合 9.1 的默认值,可以推出一个实际行为:除 Codex 外,任何显示「有更新」的能力在你点过「检查更新」之后都可能已经开始后台下载了, 进度通过悬浮卡显示。
9.3 启动提醒的冷却与静音
启动提醒的判定是shouldShowCapabilityPrompt(snapshot, preference, now), 顺序如下:
remoteAvailable为假,或manifestUpdatedAt为空 → 不提醒;- 没有任何「值得提醒」的能力 → 不提醒;
- 没有历史偏好记录 →提醒;
- 用户勾过「不再在启动时提醒」(
disabled)→ 只在出现没提醒过的新目标版本时提醒一次; - 有没提醒过的更新类目标 → 提醒;
- 剩下的(缺失/损坏)走「清单变化 或 冷却到期」; 冷却常量
OPTIONAL_CAPABILITY_PROMPT_COOLDOWN_MS = 7 天。
「值得提醒」(isPromptWorthyCapability)的判定很窄,必须同时满足:
targetVersion非空;downloadSize > 0;- 状态是
missing、damaged或update_available之一。
注意第三条排除了ready和error——出错的能力不会进启动提醒, 只能到设置页自己看。
偏好存在window.localStorage的spark-optional-capability-prompt 键下,结构包含manifestUpdatedAt、dismissedAt、dismissedTargets(能力 id → 目标版本)与可选的disabled。 代码注释解释了为什么要记dismissedTargets:「『稍后』对同一版本不再打扰,出现新版本才再提醒」 ——也就是避开了「点一次稍后就被静音到永远」。
10. capability-asset:// 与 Office 预览
前面讲了 office-viewer 怎么装。这一节讲装完之后它是怎么被页面加载的—— 这条链路是自定义协议,也是本子系统里安全校验最密集的地方。
10.1 scheme 与特权声明
scheme 名是capability-asset,特权声明在 CAPABILITY_ASSET_PRIVILEGED_SCHEME里,五项全开:standard、secure、supportFetchAPI、corsEnabled、stream。
注册被集中在PrivilegedProtocolSchemes.ts,与另两个应用内协议一起提交:
| 注册项 | 来源 |
|---|---|
SAFE_FILE_PRIVILEGED_SCHEME | SafeFileProtocol.ts |
SNAPSHOT_PRIVILEGED_SCHEME | computer-use/SnapshotProtocol.ts |
CAPABILITY_ASSET_PRIVILEGED_SCHEME | CapabilityAssetProtocol.ts |
这个集中注册不是洁癖,注释给了原因:「Electron 在应用就绪前只接受一次特权 scheme 注册调用。 把所有应用协议放在这一个注册表里,后来新增的协议就不会静默移除先注册协议身上的 fetch/CORS/安全特权。」 ——这是一个真实的 Electron 陷阱:分开调用只有第一次生效。 请求处理本身在应用就绪后注册(index.ts里调 registerCapabilityAssetProtocol())。
10.2 路径穿越与符号链接防护
resolveCapabilityAssetPath是给文件型 host 用的解析器,防护分五步,缺一不可:
| 步骤 | 检查 | 拒绝理由 |
|---|---|---|
| 1 | 协议必须是capability-asset:且 hostname 必须是office-viewer | Unsupported capability asset URL |
| 2 | 逐段decodeURIComponent后,段不能为空、不能是.或 ..、不能含/或\ | Capability asset path traversal is forbidden |
| 3 | 路径段数量不能为 0 | Capability asset path is empty |
| 4 | resolve(root, ...segments)后必须以root + sep开头 | Capability asset path traversal is forbidden |
| 5 | lstat必须是普通文件、不能是符号链接;且realpath后仍必须在 realpath(root)之下 | Capability asset symlink escape is forbidden |
第 5 步是两层检查:先拒符号链接本身,再用realpath 处理「路径里有真实目录被换成链接」的情况。 第 2 步用rawPathSegments 先按原文切段再做解码,正是为了防%2e%2e%2f这类编码穿越。
另外注意这个解析器要求能力已安装:resolveRoot返回空时抛「Office Viewer capability is not installed」。
最终的错误响应也做了区分:not installed或ENOENT→ 404 Not Found; 其它一律403 Forbidden;且只有非 404 的情况才写 warn 日志, 避免页面正常探测时刷日志。
10.3 三条 host 分支
capability-asset不只服务 Office 文件。虽然它的特权声明在这里, 但同一 scheme 下挂了三个不同的 host,处理逻辑完全不同:
| hostname | 用途 | 数据来源 |
|---|---|---|
| 子应用沙箱文档 host | 子应用运行时 HTML 文档 | 内存登记表,按 token 取(takeSubAppRuntimeDoc) |
| HTML 渲染运行时 host | HTML 产物沙箱文档 | 内存登记表,按 token 取(takeHtmlRenderRuntimeDoc) |
| 子应用包资源 host | 子应用静态资源 | resolveSubAppRuntimePackagePath,再走安全文件响应 |
office-viewer | Office 预览器的 Worker / WASM / 字体 | 第 10.2 节的路径解析器 |
前两条是「内存文档」模式:HTML 不写磁盘,登记后按 token 取,取不到就 404,响应头固定 no-store。这条链路的设计意图(写在RuntimeDocRegistry的注释里)是: 渲染进程的 CSP 允许script-src 'self' capability-asset:, 用自定义 scheme 的文档加载可以避开srcdoc的沙箱策略限制。
所以排查 Office 预览问题时,capability-asset相关的失败也可能来自子应用/HTML 渲染那两条分支,不只是 Office。第 10.2 节的五步检查只作用于 office-viewer 那条, 另外两条各走自己的校验。
10.4 构建期资源外部化
这是「可选」在构建期落地的方式:Vite 插件externalizeOptionalOfficeAssetsPlugin (名字externalize-optional-office-assets,apply: 'build'、 enforce: 'pre')在打包时把上游模块里的import.meta.url 资源引用改写成capability-asset://URL。
目前有两处改写规则(模块 id 后缀 → 改写列表):
| 模块 | 被改写的引用 | 改成 |
|---|---|---|
/@file-viewer/ppt/index.mjs | ./ppt-native.wasm | capability-asset://office-viewer/vendor/ppt/ppt-native.wasm |
/@file-viewer/ppt/index.mjs | 字体包文件 | capability-asset://office-viewer/vendor/ppt/ppt-font-cjk.otf |
/@file-viewer/ppt/index.mjs | worker 文件 | capability-asset://office-viewer/vendor/ppt/worker.mjs |
/@file-viewer/pptx/dist/worker.js | ./worker/pptx.worker.js | capability-asset://office-viewer/vendor/pptx/pptx.worker.js |
注意字体与 worker 那两处用的是模板字符串形式的原串 (MANIFEST.fontPack.file之类),说明改写是字符串精确匹配而不是 AST。
这也带来一个刻意设计的行为:如果上游模块改了写法导致匹配不到,插件会直接抛错而 不是静默跳过——
[optional-office-assets] Upstream module changed; refusing to bundle its fallback asset: <模块 id>
这是一个 fail-fast:与其把几百 MB 的默认资源偷偷打进 app.asar,不如让构建先失败。 升级 File Viewer 依赖后如果打包报这个错,说明那三处引用写法变了,需要同步更新改写表。
另外rewriteOptionalOfficeAssetFallbacks是独立导出的纯函数(输入代码与模块 id, 输出改写后代码或null),因此改写规则本身有单测覆盖,不依赖跑完整构建。
11. Codex Runtime 诊断卡片
这张卡片(CodexRuntimeDiagnosticsCard)位于设置 → 系统 → 完整性, 和「可选功能组件」列表摆在一起,但它管的不是安装,而是已装 Codex 运行时的资源与性能。
11.1 两个通道
| 通道 | 入参 | 返回 |
|---|---|---|
codex-runtime:diagnostics | 空对象(z.object({}).strict()) | 持久运行时策略字段 +diagnostics(策略关闭时为null) |
codex-runtime:restart-idle | 空对象 | { enabled, result };策略关闭时result为 null |
两个通道都由registerCodexRuntimeIpc注册,先读 persistentCodexRuntimePolicy();策略关闭时不调用后端,直接返回空结果。 卡片上按钮的禁用条件也是!snapshot?.enabled || diagnostics == null。
11.2 指标与告警阈值
顶部四张概要卡片(标签逐字):
| 标签 | 值 | 副文本 |
|---|---|---|
| Runtime | activeRuntimeCount | leasedRuntimeCount个运行中 |
| 总内存 | totalRssBytes格式化 | processCount个进程 |
| 暖启动命中率 | counters.warmHitRate百分比 | warmHitCount / acquireCount次 |
| 暖 turn/start p95 | latency.warmTurnStart.p95Ms | count个样本 |
下面是四行延迟明细,每行给出 p50 / p95 / max 与样本数:
- 冷启动 acquire
- 暖启动 acquire
- 冷 turn/start
- 暖 turn/start
再下面是逐个 Runtime 的列表,每行显示:
Runtime <leaseId>;- 状态标签(
starting→ 启动中、running→ 运行中、idle→ 空闲、exited→ 已退出)、内存、句柄数(缺失显示—); N 个 thread · M 个 sidecar · 最近使用 <本地时间>;- 右侧一个原始状态 Tag。
列表为空时的文案是「尚无活跃 Runtime;首次 Codex 会话执行后会显示诊断。」 策略关闭时整块替换为「持久 Runtime 已通过启动环境关闭,当前使用兼容的每轮临时载具。」
健康度判定(右上角 Tag)与四条告警,阈值全部是组件内的常量:
| 常量 | 值 | 触发文案 |
|---|---|---|
TOTAL_RSS_WARNING_BYTES | 1 GiB | 「Codex Runtime 总内存超过 1 GiB,建议在任务结束后重启空闲 Runtime。」 |
TOTAL_HANDLE_WARNING_COUNT | 2048 | 「Codex Runtime 总句柄数超过 2048,建议检查长时间未回收的工具连接。」 |
WARM_TURN_START_WARNING_MS | 300 ms | 「暖 turn/start p95 为 Nms,超过 300ms 发布目标。」 |
WARM_RATE_MIN_SAMPLE_COUNT/WARM_RATE_WARNING_RATIO | 5 / 0.6 | 「暖启动命中率低于 60%,请检查 Provider、MCP 或身份配置是否频繁变化。」 |
第五条不是阈值而是计数:启动失败数与崩溃替换数大于 0 就提示 「已记录 N 次启动失败、M 次崩溃替换。」,并且这两个计数没有平滑窗口—— 只要发生过一次就会一直显示。
命中率那条有样本数下限(至少 5 次 acquire),否则刚启动时会误报。 另外代码里有几处!= null判断(内存、句柄、p95),说明这几个字段可能是 null, 卡片在缺失时显示—而不是 0。
卡片底部固定一行说明:「手动重启只回收空闲 Runtime;运行中的任务不会被中断。 告警阈值:总内存 1 GiB、总句柄 2048、暖 turn/start p95 300ms。」
11.3 重启空闲 Runtime
按钮「重启空闲 Runtime」调codex-runtime:restart-idle, 成功后显示的消息是: 「已重启 N 个空闲 Runtime」;若有被跳过的,追加「,跳过 M 个运行中任务」。
返回值里restartedLeaseIds与busyLeaseIds两个数组各自给出 id, 卡片只取长度做文案,不展示具体 id。点完会自动刷新一次诊断。
这不是紧急刹车:它的语义是「回收空闲的那部分」,正在跑任务的 Runtime 不会被碰—— 这一点在按钮文案与页脚注释里都写明了。想中断正在跑的任务需要从会话侧操作。
12. IPC 与协议契约
这套子系统一共 11 个 IPC 通道,分三组:optional-capability:*8 个(见 12.1)、sdk:integrity-*2 个(见 12.2),以及同一文件开头顺带注册的 video-workbench:get-ffmpeg-capabilities1 个。 11 个通道都在 packages/protocol里登记了类型对,但 zod 入参 schema 只覆盖其中 10 个, 例外见 12.4。
12.1 optional-capability:*
| 通道 | 入参 | 返回 | 渲染端调用点 |
|---|---|---|---|
optional-capability:list | {}(strict) | 快照 | 无 |
optional-capability:check | { forceRemote?: boolean } | 快照 | useOptionalCapabilities |
optional-capability:install | { capabilityId } | 变更响应 | 同上 |
optional-capability:update | 同上 | 变更响应 | 同上 |
optional-capability:repair | 同上 | 变更响应 | 同上 |
optional-capability:cancel | 同上 | 变更响应 | 同上 |
optional-capability:uninstall | 同上 | 变更响应 | 同上 |
optional-capability:set-auto-update | { capabilityId, enabled } | 快照 | 同上 |
capabilityId在 zod 里是联合字面量(OptionalCapabilityIdSchema), 所以传一个不存在的 id 会在 IP 边界就被拒,不会走到 getOptionalCapabilityDefinition的抛错分支。
optional-capability:list主进程有 handler 但渲染端没有任何调用点: 界面只通过check 拿快照。它的存在意义是给未来或外部调用留一个不触发远程请求的读取入口 (list() 只读缓存清单)。
这五个变更通道共用同一个注册器registerMutation,行为一致: 执行变更 → 推送快照 → 成功时才回调onChanged。 而onChanged只对 codex-runtime起作用: 它会重新跑一次 SDK 完整性检测并推送 stream:sdk:integrity, 保证卡片上的运行时版本信息立即同步。
12.2 sdk:integrity-*
| 通道 | 入参 | 行为 |
|---|---|---|
sdk:integrity-check | { checkLatest?: boolean } | 跑 SDK 完整性检测(含 Codex 运行时检测与 node/npm/git 探测) |
sdk:integrity-install | { packageName } | 安装指定包,进度推stream:sdk:install-progress |
sdk:integrity-install有一个联动:若安装的包名是@openai/codex-sdk 且成功,会额外刷新 Codex 运行时快照与可选能力快照 (publishCodexRuntimeSnapshots)。两个刷新都各自 try/catch 并有 warn 日志, 一处失败不会影响安装结果本身。
第二个刷新的注释点出了一处细节:它用的是capabilityManager.list()而不是 check(),理由是「只重读本地激活状态;check()可能在缓存过期时联网 并触发其他组件自动更新」。也就是说这里刻意避开了 9.2 节的自动更新副作用。
渲染端的调用点有两处:SettingsView的完整性区块, 以及工具函数 design/utils/codex-runtime-install.ts。
还有一个容易误判的行为:checkSdkIntegrity里 非 Codex 的 SDK 在打包态不会报「有更新」—— 代码把latestVersion 直接设成installedVersion, 注释写的是「签名桌面应用里的 SDK 包随应用一起升级,不要宣传打包构建根本装不了的 npm 更新」。 所以打包态下能更新的只有 Codex 运行时那条。
12.3 事件流
| 事件 | 触发 | 载荷 |
|---|---|---|
stream:optional-capability:snapshot | check / 变更 / 自动更新后 | 完整快照 |
stream:optional-capability:progress | 安装各阶段 | 单条进度 |
stream:sdk:integrity | 启动推送 / Codex 运行时变化后 | SDK 完整性结果 |
stream:sdk:install-progress | SDK 安装过程 | 安装进度 |
渲染端用模块级单例 store +useSyncExternalStore订阅前两个事件,ensureStarted保证只初始化一次并立即发起一次check。 另外 SDK 完整性结果会被写进localStorage的spark-sdk-integrity 键并优先读缓存,避免每次打开设置页都重新检测。
12.4 zod 校验覆盖面
11 个通道里有 10 个登记了 zod 条目(8 + 2 + 1:optional-capability:*8 个、sdk:integrity-*2 个)。唯一没有 zod 的是video-workbench:get-ffmpeg-capabilities—— 它只登记在video-workbench.ts的VideoWorkbenchIpcChannelMap 类型表里,schemas/index.ts中没有对应条目;typedIpcHandle取不到 schema 时会原样放行 (schema != null ? schema.parse(rawRequest) : rawRequest), 所以这条通道不校验入参。其余通道里, 无入参的一律写成 z.object({}).strict(), 带参的都用.strict() 拒绝多余字段。
这一点值得单独提,因为同仓库里其它子系统存在「只有类型注册、没有 zod」的情况; 本子系统 11 个通道里有 10 个补齐了 schema,唯一例外就是上面那条video-workbench:get-ffmpeg-capabilities。 排查时如果看到参数校验错,那一定是调用方传多了字段,不是缺少 schema 定义。
13. 排查表
下面每一条都是可以从代码推导出的具体原因,按「现象 → 原因 → 怎么办」组织。
| 现象 | 真实原因 | 怎么办 |
|---|---|---|
| 点「修复」后进度条从 0 开始重新下载 | repair就是install,不存在「只校验不下载」的修复路径 | 属预期行为;只想校验时就别点,或直接看错误文案定位 |
| 主按钮灰着点不动 | 该能力targetVersion为空——清单里没有你平台的制品,或平台不支持 | 看按钮下面那行状态文本:「当前平台暂不可用」就是没有制品,不是网络问题 |
| Codex 显示「有更新」但一直不自动装 | codex-runtime的自动更新默认关闭 | 在那一行打开「自动更新」开关,或直接点「更新」 |
| 只点了一次「检查更新」,别的组件自己开始下载了 | check()在manifestAvailable为真时会对所有 update_available且autoUpdate的能力静默安装 | 属预期行为;不想要就在对应行关掉「自动更新」开关 |
| 安装进度卡上没有「取消」按钮 | 四个 external 能力都是cancellable: false,取消按钮的渲染条件是 cancellable !== false | 外部能力的安装不能取消,只能等它结束或失败 |
| 某能力装好了但没有「卸载」按钮 | 四个 external 能力都是supportsUninstall: false | 应用内没有卸载入口;要清空间只能手动删目录(见 7.4) |
| 卸载了 Office 预览,但磁盘空间没释放多少 | 卸载删的是{userData}/optional-capabilities/<id>,external 能力的二进制不在这个目录下 | 确认你要清的是哪个能力;external 的要按 7.4 的表找对应目录 |
| 启动时没有弹出「安装可选功能」 | 三种可能:只读了磁盘缓存导致remoteAvailable为 false;没有缺失/损坏/有更新的能力;或偏好里该目标版本已被「稍后」静音 | 打开设置 → 系统 → 完整性点「检查更新」;或清掉 localStorage 的 spark-optional-capability-prompt |
某个组件一直是error,但启动时从不提醒 | isPromptWorthyCapability只认missing/damaged/ update_available,不含error | 启动提醒不覆盖它,只能去设置页看 |
| 点了「稍后」,之后再也不提醒了 | 偏好按目标版本静音(dismissedTargets);同版本不再打扰,新版本会再提醒一次 | 想立刻装就手动打开弹窗;不需要改配置 |
| 打包版里 Claude SDK 永远显示没有更新 | 打包态下checkSdkIntegrity把latestVersion 直接设成已装版本,注释说明「签名应用里的 SDK 随应用升级」 | 属预期行为;打包态只有 Codex 运行时可以单独更新 |
| Codex 提示「云端已有更新版本,但与 SDK 不配对」 | 精确配对要求sdkPackage或dependencies里出现 @openai/codex-sdk@<应用内版本> | 按文案升级 Spark Agent 应用本身,再回来更新运行时 |
| Codex 提示「全部低于协议基线 0.144.5」 | 该平台制品都被MIN_SUPPORTED_MANAGED_CODEX_RUNTIME_VERSION过滤掉了 | 等仓库补齐该平台制品;这在应用内无解 |
| Chromium 下载失败 | 开发态依赖pnpm,打包态依赖内置 Node 与 resourcesPath/playwright-mcp下的 cli.js;任何一环缺失都会失败 | 开发态确认 pnpm 可用;打包态提示「无法定位内置 Playwright 下载程序」时说明安装包不完整,需重装应用 |
| Chromium 显示下载完成但仍判为未就绪 | 装完复查browserSource !== 'bundled',说明没落到 {userData}/browsers | 看报错里给出的目录,确认PLAYWRIGHT_BROWSERS_PATH指向的位置可写 |
| 系统装了 ffmpeg,卡片却显示未安装 | 系统探测要求which ffmpeg能找到且 ffmpeg -version能正常返回 | 手动跑一次ffmpeg -version;如果报 dyld / 依赖库错误,会被判为不可用 |
| 明明是 managed 安装的,却回退到了系统版 ffmpeg | managed 目录里的二进制若跑不出-version,探测会静默继续往下走 | 删掉{userData}/bin下那个坏目录后重新下载 |
| ffmpeg 显示「下载完成但未检测到可用二进制」 | 下载与解压成功了,但目标目录里的可执行文件跑不起来(通常是平台/架构不匹配) | 和「下载失败」区分开:这是制品本身的问题,重试无效 |
| 提示「ffprobe 不可用」 | resolveFfmpegBin可以退到系统 ffmpeg,但 ffprobe 需要单独解析(managed 目录 → 系统 PATH) | 装 FFmpeg 整包,不要只软链 ffmpeg 一个文件 |
| Windows ARM / Linux ARM 上语音输入直接不可用 | voicePlatformKey是四值白名单:darwin-arm64、darwin-x64、win32-x64、linux-x64 | 应用内无解,等上游提供 prebuilt |
| 语音包下载中途失败 | 通用归档超时 2 分钟,语音单独放宽到 30 分钟;超时会翻译成「下载超时,请检查网络或代理后重试」 | 按提示检查网络;重试时只会重下没装完的组件,已成功的大模型不会重下 |
| 语音的 refine 一直显示「未安装」 | refine 是可选组件,ready只要求 native + model;清单没提供 refine 时不算失败 | 不影响语音识别可用性;它只用于「说完后整段优化」 |
| Office 文件打不开,显示占位提示 | office-viewer 未就绪时组件会主动替换成占位区 | 点占位区的「安装 Office 预览资源」;若按钮灰着,说明当前平台没有制品 |
| Office 预览加载资源时 403 | resolveCapabilityAssetPath的路径穿越或符号链接检查拒绝了请求 | 403 是安全拒绝,不是缺文件;检查是不是有非预期字符或链接被放进了资源目录 |
| Office 预览加载资源时 404 | office-viewer 未安装,或请求的文件不存在(这条路径不写 warn 日志) | 先在设置页确认 office-viewer 状态;404 时去看是否真的装了 |
打包时报[optional-office-assets] Upstream module changed | 上游 File Viewer 模块里那三处import.meta.url写法变了,精确字符串匹配失败 | 属 fail-fast 保护;同步更新optionalOfficeBuildAssets.ts里的改写表 |
能力状态一直是「需要修复」(damaged) | 要么validateActiveState没通过(包内 manifest 哈希变了、目录被挪动),要么曾经上报过runtimeFailure | 点「修复」重装;若是local-depth,先看是否报过「原生 Runtime 无法加载」 |
| 深度处理任务失败后能力被标红 | 画布深度任务会调reportRuntimeFailure('local-depth', ...),把失败写进 active.json | 点「修复」重装local-depth;同时那条报错信息才是根因(例如签名被拒) |
| 进度条没有百分比,只有文案 | percent在total === 0时是null,进度组件按 0 展示 | 属正常——准备阶段与校验阶段本来就没有可算的总量 |
| 参数校验直接报错,没进到能力逻辑 | 11 个通道里有 10 个带.strict()的 zod schema,多传字段会被拒 | 检查调用方是否多传了字段;唯一没有 schema 的 video-workbench:get-ffmpeg-capabilities不受这条约束 |
14. 声明了但当前不起作用的东西
这一节列的是「协议或类型里写了、但生产路径上没有实现或没有消费者」的部分。 写出来是为了让你在排查时不要被字段名误导。
| 项 | 实际情况 |
|---|---|
OptionalCapabilityItem.entryUrl | 协议里声明了可选字段,但全仓没有任何地方给它赋值,也没有任何地方读它 |
installedSize | 四个 external 适配器一律填null;archive 能力会算出真实值,但界面从不显示它(两个卡片都只读downloadSize) |
optional-capability:list | 主进程有 handler、协议有类型与 schema,渲染端零调用点 |
repair的独立性 | 与 install / update 是同一个实现,不存在单独的修复策略(不校验、不增量、不跳过下载) |
CapabilityPackageHealth的覆盖面 | 名字像是管所有能力,实际只校验office-viewer与local-depth;其它任何 id 进来都直接抛错 |
| Chromium 的版本号 | 适配器把installedVersion与targetVersion都填成字符串 'chromium',不是真实版本。所以界面会显示「已安装 chromium」 |
| Chromium 的下载体积 | CHROMIUM_ESTIMATED_DOWNLOAD_SIZE是固定估值 150 MB;进度按命令行输出的百分比反算,没有真实总字节数 |
| 进度上报里的死分支 | Chromium 的report(percent == null ? 'downloading' : 'downloading', ...) 两个分支取值相同,条件实际无意义 |
| external 能力的「取消」 | 适配器里写了throwIfAborted(signal),但四个能力都不可取消,这条检查在生产路径不可达 |
| 能力内卸载 external 资源 | 没有实现。四个外部能力的二进制不在optional-capabilities/ 下,卸载逻辑也够不到它们 |
capability-asset的单一 scheme | 同一个 scheme 下挂了四个 host(子应用文档、HTML 文档、子应用包资源、office-viewer),但第 10.2 节那套路径校验只作用于 office-viewer 一条 |
最后一栏不只是洁癖问题:如果你在排查capability-asset相关的加载失败, 要先把「是 Office 的资源,还是子应用/HTML 渲染的沙箱文档」分清楚,两者的校验与错误码完全不同。
速查表
| 项目 | 内容 |
|---|---|
| 设置入口 | 设置 → 系统 → 完整性(项 id integrity);Chromium 的状态卡片在 设置 → 系统 → 浏览器自动化 |
| 能力总数 | 6 个:codex-runtime / office-viewer / local-depth / ffmpeg / chromium / voice-pack |
| archive 能力 | office-viewer、local-depth(可取消、可卸载) |
| external 能力 | codex-runtime、ffmpeg、chromium、voice-pack(不可取消、不可卸载) |
| 状态枚举 | 12 个:checking/missing/queued/downloading/verifying/extracting/activating/cancelled/ready/update_available/damaged/error |
| 错误码 | 8 个:manifest_unavailable/artifact_unavailable/artifact_invalid/download_failed/package_invalid/activation_failed/cancelled/internal_error |
| 清单缓存 TTL | 24 小时(MANIFEST_CACHE_TTL_MS) |
| 状态目录 | userData/optional-capabilities/<能力 id>/active.json |
| 清单缓存文件 | userData/optional-capabilities/manifest-cache.json |
| 自动更新偏好 | userData/optional-capabilities/auto-update.json |
| Codex 运行时目录 | userData/agent-runtimes/codex/<版本>/<triple> |
| FFmpeg 落盘 | userData/bin/<清洗后的制品名> |
| Chromium 落盘 | 打包态 userData/browsers;开发态 <desktopDir>/browsers |
| 语音落盘 | userData/voice/native|model|refine/... |
| Codex 协议基线 | 0.144.5(MIN_SUPPORTED_MANAGED_CODEX_RUNTIME_VERSION) |
| Codex 选择结果 | exact-sdk-match / newest-compatible / no-published-runtime / below-protocol-baseline / newer-than-app-sdk |
| Codex 环境变量 | SPARK_CODEX_RUNTIME_ROOT / SPARK_CODEX_SDK_VERSION / SPARK_CODEX_REQUIRE_RUNTIME |
| 自动更新默认值 | 除 codex-runtime 外全部默认开启;Codex 默认关闭 |
| 启动提醒冷却 | 7 天(OPTIONAL_CAPABILITY_PROMPT_COOLDOWN_MS) |
| 提醒偏好存储 | localStorage 键 spark-optional-capability-prompt |
| 语音 30 分钟超时 | VOICE_ARCHIVE_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000 |
| 语音平台白名单 | darwin-arm64 / darwin-x64 / win32-x64 / linux-x64 |
| Chromium 估算体积 | 150 MB(CHROMIUM_ESTIMATED_DOWNLOAD_SIZE,固定估值) |
| 自定义协议 | capability-asset(office-viewer + 三个沙箱文档/资源 host) |
| Office 必需文件 | 15 个(含 ppt-native.wasm、ppt-font-cjk.otf、四个 viewer manifest) |
| 诊断告警阈值 | 总内存 1 GiB / 总句柄 2048 / 暖 turn-start p95 300ms / 命中率 60% |
| IPC 通道数 | 11 个(optional-capability:* 8 + sdk:integrity-* 2 + codex-runtime:* 2,其中 list 无渲染端调用) |
| 事件流 | stream:optional-capability:snapshot / :progress、stream:sdk:integrity、stream:sdk:install-progress |
上手清单:按需安装一个可选功能组件并验证它真的可用
从看清状态、判断该点哪个按钮,到装完验证与善后,走一遍完整流程。
- 打开「设置 → 系统 → 完整性」,滚到最下面的「可选功能组件」卡片。六个能力的当前状态会在这里一次性列出。
- 先读每行的状态文本再动手:显示「已安装 版本号」说明就绪;「有更新」说明有新版;「组件损坏」说明校验没过;「安装失败」是上一次操作留下的错误;「未安装 · 下载 xx MB」才是真的可以装。
- 如果某行显示「当前平台暂不可用」,先别点。这意味着清单里没有你平台/架构的制品,或该平台本就不支持(语音的四个平台键之外都是这种),装也不会成功。
- 选对按钮:有更新点「更新」,损坏或失败点「修复」,其余点「安装」或「重新安装」。但要记住「修复」会整包重下,不要在大流量环境下随手点。
- 想先确认云端有没有新版本,先点右上角「检查更新」。注意一个副作用:除 Codex 运行时外,其它能力默认开启自动更新,这一步之后处于「有更新」的能力会立刻开始后台下载。
- 安装过程中的进度显示在左下角悬浮卡「功能资源」里,含进度条、已下载/总量与队列位次。四个外部能力不会出现取消按钮,这是设计而非故障。
- 装 Chromium 时留意日志里的路径:开发态执行的是 pnpm exec playwright install chromium,打包态用的是内置 Node 加 resourcesPath 下的 cli.js,下载目录固定在 userData/browsers。失败时先看是不是这条链路缺环。
- 装 FFmpeg 时如果看到「下载完成但未检测到可用的 ffmpeg 二进制」,那是制品本身在你机器上跑不起来(多为平台不匹配),重试无效;如果只是「系统装了但显示未安装」,手动跑 ffmpeg -version 看是否报依赖库错误。
- 装 office-viewer 后回到任意 Office 文件处验证:占位提示会消失、文件正常预览。若仍显示「需要安装离线 Office 预览资源」,看是不是装完没刷新,或资源请求被 capability-asset 协议以 403/404 拒绝。
- 最后处理善后:只有 office-viewer 与 local-depth 能一键卸载;四个外部能力要清空间只能手动删 userData 下的 agent-runtimes/codex、bin 里的 ffmpeg 目录、browsers 与 voice。删之前先确认没有正在跑的任务在用它们。
常见问题
「安装」「更新」「修复」有什么区别?
三个按钮调用的是同一个实现。管理器的 install、update、repair 三个方法体一字不差,都是 enqueueInstall(id),所以修复不是增量修复,而是重新走一遍完整安装:重新拉清单、重新下载整包、重新逐文件校验、重新激活。代价是流量,收益是没有特例分支。实测被测试锁死的一点是:修复失败不会破坏已装好的版本——下载阶段抛错时 activate 之前的目标目录从未被改动,旧版本原样保留。
为什么有的能力没有「取消」和「卸载」按钮?
因为定义表里那两项都是 false。codex-runtime、ffmpeg、chromium、voice-pack 四个能力都是 cancellable: false、supportsUninstall: false;只有 office-viewer 与 local-depth 两项都是 true。界面严格按这两个字段渲染,所以外部能力安装到一半没有取消入口,装好了也没有卸载入口。想清掉它们占的空间,代码里没有提供入口,只能按第 7.4 节的表手动删目录(agent-runtimes/codex、bin 下的 ffmpeg 目录、browsers、voice)。
为什么 Codex 运行时显示有更新却不会自动安装?
因为它是自动更新默认值里唯一的例外。defaultAutoUpdateForCapability 的实现是 return id !== 'codex-runtime',也就是除 Codex 外全部默认开启、Codex 默认关闭,注释给的理由是它是 Spark 适配器的协议上游、已装的兼容版本更值得保稳。测试把这个行为锁死了:Codex 处于 update_available 时断言 autoUpdate 为 false,且断言安装函数未被调用。想更新就在那一行手动打开开关或直接点更新。
什么是 external?这四个能力不是从 Spark 仓库下载的吗?
external 描述的是走哪条安装通道,不是来源。它指的是不走 OptionalCapabilityManager 里那条通用归档流水线(解析清单、下载、校验包内文件、暂存、备份、激活),而是交给四个专用完整性服务,所以 definitions.ts 里它们的 selectArtifacts 写成 () => []。其中三个照样从 Spark 自建制品仓库下载:codex-runtime、ffmpeg、voice-pack 都调 fetchSparkInstallManifest 并用各自的选择器挑制品;只有 chromium 例外,它用应用内已装好的 Playwright 包执行 playwright install chromium,不读 Spark 清单。
Office 预览为什么一定要装 office-viewer?装到哪了?
因为它的 Worker、WASM 与中日韩字体被打包流程主动外部化了——构建期有一个 Vite 插件把上游模块里的 import.meta.url 资源引用改写成 capability-asset://office-viewer/... URL;注释写明了理由:Spark 在安装 office-viewer 后总是提供这些 URL,保留默认资源会在 app.asar 里重复一份。安装位置是 userData/optional-capabilities/office-viewer/versions/<版本>/<artifactId>,状态写在同目录的 active.json。加载时走自定义协议 capability-asset,只认 office-viewer 这个 host。
能力状态显示「需要修复」是什么意思?
两种情况。一种是校验没过:管理器会重算包内 manifest 的哈希并与 active.json 里记录的 manifestSha256 比对,再完整跑一遍包内文件校验,任一环节不过就判 damaged。另一种是运行时主动上报失败:reportRuntimeFailure 会往 active.json 写 runtimeFailure,报错文案是「运行验证失败:原生 Runtime 无法加载,请更新或修复组件」,目前只有画布深度任务会对 local-depth 走这条路径。两种情况点「修复」都是重装整包,都需要能连上仓库。