独立弹窗需要一份 DSH 不会给的运行时:DSH 的桌面主进程禁掉了 window.open,也不把
BrowserWindow 暴露给插件。所以本插件自带一份可选的 Electron 运行时,由用户显式下载,
装在自己的目录里,带自己的固定校验和。不共享、不借用任何其它插件的东西。
四条设计规则(lib/component/layout.js:20-30),其余模块都靠它们成立:
ready 意味着”标记文件和可执行文件都在”。 别的任何东西都无权宣称就绪。全部在插件数据目录下,所以一次数据目录迁移会把它一起搬走:
<dataDir>/component/
├── state.v1.json 生命周期记录
├── manifest.v1.json 装了什么、什么时候、从哪来
├── pending-cleanup.v1.json 删不掉的路径(少见),下次启动再扫
├── download/<asset>.part 可续传的下载体
├── download/<asset>.part.json { url, etag, received, totalBytes }
└── runtime-<stamp>/ 一次安装一个**不可变**的运行时
└── .complete-44.0.0 ← 标记:存在即 "ready"
为什么运行时目录带版本戳。 安装发布到一个全新的 runtime-<stamp> 并把 state 指过去,
而不是 rename 覆盖 runtime/。Windows 在目录里任何文件被打开时都会拒绝 rename(EPERM)
或删除(EBUSY)——Defender 的扫描器、搜索索引、还没退出的 helper 都算。覆盖固定路径因此会
随机失败且无法恢复,而换一个新名字总是成功,旧目录留给后面扫。从不把东西 rename 走。
staging 目录故意放在 component/ 外面(<dataDir>/.component-staging):
半解压的树留在里面会让”删除组件”失败在那些残留文件上,而用户没法删掉一个被告知”就是一个目录”的东西。
| 项 | 值 |
|---|---|
ELECTRON_VERSION |
44.0.0 |
| 资产名 | electron-v44.0.0-<platform>-<arch>.zip |
| 校验 | PINNED_SHA256,六个平台/架构各一个,抄自 Electron 官方 SHASUMS256.txt |
| 下载体积 | 157 455 369 B(约 150 MB) |
| 磁盘占用 | 383 650 096 B(约 366 MB) |
哈希不匹配会在解压之前中止安装,所以被截断或被篡改的下载永远不会被执行。
没有固定哈希的平台直接 component-download-failed(no pinned checksum for <asset>),
而不是”跳过校验”。
MIRRORS(lib/component/layout.js:71),按顺序尝试:
| id | 前缀 |
|---|---|
mirror |
https://cdn.npmmirror.com/binaries/electron/v44.0.0/ |
backup |
https://mirrors.huaweicloud.com/electron/44.0.0/ |
npmreg |
https://registry.npmmirror.com/-/binary/electron/v44.0.0/ |
github |
https://github.com/electron/electron/releases/download/v44.0.0/ |
三个独立的国内镜像排在 GitHub 前面,因为 GitHub 的 release 下载在国内经常 302 到一个会卡住的 CDN。
它们提供的都是同一份官方 zip,固定 SHA-256 仍然会校验,所以镜像换不掉字节。
install 传 source: "github" 时顺序反转(lib/component/manager.js:414)。
续传只能续同一个镜像(lib/component/downloader.js:244):.part.json 里的 url 与当前
镜像不符时先删掉旧片段。另一个镜像的字节是另一条流,追加进去正是”续传出坏包”的成因。
镜像不支持 Range(对分段请求直接返回整个文件)时,清空重下一次。
下载用系统的 curl 子进程,--progress 由轮询 .part 的大小得到(PROGRESS_INTERVAL_MS),
而不是解析 curl 的输出。
体积上限 1 GiB(MAX_ASSET_BYTES,lib/component/downloader.js:42):content-length
超限在写第一个字节前就拒(component-download-too-large),实际落盘超限同样拒;curl 侧还有
--max-filesize 兜底。正常的 Electron 包约 110 MB,一个数量级的余量足够,同时把”被劫持的镜像
先写满磁盘再被摘要拒掉”变成”直接拒掉”。
PHASES(lib/component/state.js:25):absent / downloading / verifying / extracting /
ready / error。未知 phase 降级成 absent,而不是渲染一个 UI 不认识的状态。
一次 install 的顺序(lib/component/manager.js:380-520):
| # | 步骤 | 细节 |
|---|---|---|
| 0 | 单飞锁 | run() 里若已有操作在跑,直接 component-busy;不排队——点第二次的人想让第二下被忽略,而不是稍后执行 |
| 1 | 复用检查 | 磁盘上已有带 marker + binary 的完整运行时就直接 ready,source: "reuse"。卸载被拒后字节还在,让用户为还在硬盘上的 150 MB 再下一次是说不过去的 |
| 2 | 磁盘检查 | 需要 downloadBytes × 2.2 + 500 MB 空闲,不够就 component-disk-full(507) |
| 3 | downloading |
依次试镜像,AbortController + 1 小时超时(INSTALL_TIMEOUT_MS) |
| 4 | verifying |
verifyZip() 比对固定 SHA-256 |
| 5 | extracting |
先清空 staging(上次中断的残留),再解压——tar 用 --exclude、unzip 用 -x 跳过 resources/default_app.asar,Expand-Archive 没有排除开关,由 dropExcluded 用子进程事后删;最后写 helper 自己的 resources/app。之后必须能找到可执行文件,否则 component-extract-failed。解压子进程接同一个 AbortSignal,超时/取消会 SIGKILL 它,而不是让 tar 在后台继续写 |
| 6 | 发布 | publishRuntime() 到 runtime-<stamp>,写 manifest.v1.json,删 .part 与 .part.json |
| 7 | 扫旧 | sweepRuntimes() 删旧运行时;删不掉的进 pending-cleanup.v1.json |
| 8 | ready |
receivedBytes: 0,source 记录来源 |
取消(component-cancelled)回到 absent 且不留错误;其它失败进 error 并把消息写进 state。
卸载前先关窗口,而且”关掉了”必须是真的:remove() 调 closeHelper()(即
api.detached.close()),只有它返回 true 才继续(lib/component/manager.js:549-552)。
close() 的返回值因此是”现在确实没有在跑的 helper”:先发 close 消息,1.5 秒后还在就 SIGTERM,
再 0.5 秒后还在就 SIGKILL,最后用 exitCode !== null 判断(lib/detached/launcher.js:318-345)。
以前它无条件返回 true,于是一个不理会退出信号的窗口会让 remove 删掉它正在用的可执行文件。
resources/default_app.asar 是每个官方 Electron zip 都带的 111 KB 应用包,唯一作用是”把命令行上
那个脚本跑起来”——本插件从来不用它。它曾经是运行时里唯一删不掉的字节:Electron 的 node:fs
把任何以 .asar 结尾的路径交给 asar 层,连一次 stat 都会打开文件,并把句柄缓存在进程里直到
进程退出;Windows 上 rm 遇到这种文件还会先读出来复制再删原文件,于是稳定 EBUSY。也就是说,
只要 DSH 读过一次组件状态(directorySize() 会 stat 每个文件),那个句柄就再也不会松开。
现在两边都堵住了:
extractZip() 给 tar 传 --exclude resources/default_app.asar,给 unzip 传
-x;PowerShell 的 Expand-Archive 没有排除开关,解压后再用子进程 del 兜一次
(dropExcluded)。用子进程是刻意的:它没有 asar 层。resources/app(installHelperApp),Electron 启动时
优先用它;directorySize() 跳过 .asar;删整棵树也交给子进程 rmdir /s /q
(removeTreeWithRmdir)。DSH 进程里从头到尾没有一次 fs 调用落到 .asar 上。旧版本装下的运行时里还有这个文件——它还在,但现在谁都不打开它,所以卸载时能正常删掉;
isKnownUnremovable 只作为兜底留着。pending-cleanup.v1.json 因此只剩”扫描器/索引器临时占用”
这一种来源,下次启动再扫一遍即可(仍是纯磁盘 JSON,读取时只接受 component/ 之内的路径——
readPending + lib/paths.js 的 isInside,坏文件不能让启动清理变成删任意目录)。
lib/detached/ 三件:
| 文件 | 职责 |
|---|---|
launcher.js |
在 DSH 主进程这一侧 spawn / 聚焦 / 关闭窗口,持有 profile 键 |
main.cjs |
Electron 侧的窗口本体(.cjs 后缀是刻意的:包里 "type": "module",.js 会被当 ESM 加载,require("electron") 直接抛 require is not defined in ES module scope) |
preload.cjs |
预加载:把窗口需要的最小 API 注入页面 |
component.binary 解析的是已安装那个带戳的目录;component.paths.binary 是没戳的默认值,
一次安装发布之后那个路径根本不存在。把默认值交给 launcher 会 spawn 一个不存在的路径,
而失败表现为”窗口永远打不开”——子进程在能报告任何东西之前就退出了。
启动命令行里不再带脚本路径。 electron.exe <script> 这种模式是 default_app.asar 提供的,
而这个文件已经不装了(§5)。所以安装写的是一个两文件应用 resources/app:
| 文件 | 内容 |
|---|---|
resources/app/package.json |
{"name": "dsh-session-navigator-helper", "main": "main.cjs"} |
resources/app/main.cjs |
require 仓库里的 lib/detached/main.cjs,绝对路径 |
Electron 在没有命令行路径时去 resources/app 找应用,而且它优先于 default_app.asar——
所以同一行命令既服务新运行时,也服务旧版本装下的(还带着 bundle 的)运行时。
staging 里就把 app 写好,跟着树一起 rename 发布;每次 launch() 会再刷一遍,于是升级前的
运行时也不用重下。入口路径走 DSH_SE_HELPER_MAIN 环境变量,不拼进 package.json。
绝对路径是必须的:shim 在 resources/app/ 里,require("./main.cjs") 会解析到它自己。
同理不能把绝对 main.cjs 交给 package.json——main 会在 app 目录里解析
(ERROR: File "…\\lib\\detached\\main.cjs" ("main.mjs") not found),这也是那个 shim 存在的原因。
还有一处必须显式清掉的环境变量:DSH 桌面版自己是带着 ELECTRON_RUN_AS_NODE=1 跑的,子进程会
继承它,于是”窗口”变成一个跑完就退的纯 Node 进程。launch() 里 delete env.ELECTRON_RUN_AS_NODE。
窗口通过 onEvent 把 hello / closed 报告给 host,host 再转成总线上的 window-open /
window-closed(lib/index.js:179-180)。窗口的 OS 层面知识只有那个进程有,走同一条总线意味着
面板不需要第二条通道。
| 文件 | 职责 |
|---|---|
lib/component/layout.js |
版本、固定哈希、镜像、路径表、体积常量 |
lib/component/state.js |
phase 集合、state 的读写与归一化、进度写入节流 |
lib/component/downloader.js |
curl 下载、续传、镜像切换、进度轮询 |
lib/component/install.js |
verifyZip / extractZip / installHelperApp / publishRuntime / removeTree / freeBytes |
lib/component/manager.js |
单飞状态机、status()、install / cancel / remove / retry、pending 清扫 |
lib/detached/launcher.js |
spawn / 聚焦 / 关闭;spawn 前刷 resources/app |
lib/detached/main.cjs |
Electron 窗口 |
lib/detached/preload.cjs |
预加载注入 |