zipcalm 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/bin/zipcalm.js +1 -1
- package/package.json +14 -8
- package/server/core/auth/password.js +1 -1
- package/server/core/engineRunner.js +3 -3
- package/server/core/fs/platformProfile.js +167 -0
- package/server/core/fs/roots.js +49 -56
- package/server/plugins/capabilities/password/index.js +1 -1
- package/server/plugins/formats/rar/index.js +1 -1
- package/server/plugins/formats/rar/unrar.js +1 -1
- package/server/plugins/formats/sevenzip/engine.js +3 -3
- package/server/plugins/formats/sevenzip/index.js +2 -2
- package/shared/app-version.js +1 -1
- package/web/dist/assets/index-C_rk5uTi.js +13 -0
- package/web/dist/assets/index-DbWfI5U3.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-D9Vdmrb9.css +0 -1
- package/web/dist/assets/index-DiDB4ClR.js +0 -131
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> 一个跑在 NAS 上的 Node.js 网页服务端:浏览文件目录、选择文件/文件夹压缩(多算法多格式)、按类型去重(硬链接)、带性能监控看板。
|
|
4
4
|
>
|
|
5
|
-
> 当前状态:**M0~M8 已完成 + 硬链接去重已上线 + 硬链接预检提示**(版本 `1.0.
|
|
5
|
+
> 当前状态:**M0~M8 已完成 + 硬链接去重已上线 + 硬链接预检提示**(版本 `1.0.3`),二十套验收全绿。手机端 PWA(M9)待做。
|
|
6
6
|
|
|
7
7
|
## 界面预览
|
|
8
8
|
|
|
@@ -131,7 +131,7 @@ npm run build # 构建前端产物到 web/dist
|
|
|
131
131
|
| **硬链接去重** | ✅ | 按白名单类型扫重复大文件 → 三层判定(`size` 分组 → 头尾+中间抽样**快筛** → **全量 SHA-256 终判**)→ 建硬链接省空间;dry-run 默认 + 强制确认弹窗 + 操作日志;替换走 `rename→link→unlink`,**崩溃时数据始终一份可达** |
|
|
132
132
|
| **硬链接预检提示** | ✅ | 压前用 `dev + ino` 归组识别硬链接(零额外 syscall):所选格式不保留硬链接且多存占比 ≥10% 时提示「预计产出 / 换 TAR 省多少」,tar 家族给正向说明;纯信息条不阻断,空间与删除判定不变 |
|
|
133
133
|
| **RAR 解压(只读)** | ✅ | 新增 RAR 格式包:**只解压、不生成**(闭源商业算法,未授权不能压缩);走用户已装的 `unrar`(优先)或 `7z`,缺二进制显式报错并给修复建议;rar4/rar5、多卷、加密(缺密码 / 密码错 / 包损坏三态分开)全支持;条目安全与 zip、tar 共用同一套 zip-slip 判定 |
|
|
134
|
-
| **7z(双向)** | ✅ | 新增 7z 格式包:**可压缩可解压**,走三级回退链 —— 系统 `7z/7za
|
|
134
|
+
| **7z(双向)** | ✅ | 新增 7z 格式包:**可压缩可解压**,走三级回退链 —— 系统 `7z/7za`(如 p7zip 16.02)→ WASM 引擎(`js7z-tools`,内置 7-Zip 25.01 多线程)→ 不可用(置灰 + 说明)。支持 `-mx0..9` 压缩级别、原生密码(三态错误分离)、条目安全与 zip/tar/rar 同一套;卖点是 **Windows 双击即开**(压缩率实测只比 tar.zst 优约 1%) |
|
|
135
135
|
| M9 | ⏳ | **手机端 PWA**(可添加到主屏幕;曾评估"做 MT 管理器插件",两轮调研——文档调研 + APK 逆向——均确认**不可行**:v3 插件只有 editor/translation/regex 三类扩展点,且 `PluginContext` 无用户文件访问能力。详见 `docs/research/mt-apk-reverse-04-借鉴点与结论.md`) |
|
|
136
136
|
|
|
137
137
|
**已实现能力**(截至 `0.12.1`):
|
|
@@ -171,12 +171,14 @@ npm run build # 构建前端产物到 web/dist
|
|
|
171
171
|
|
|
172
172
|
| 版本 | 里程碑 | 内容 |
|
|
173
173
|
|---|---|---|
|
|
174
|
-
| **1.0.
|
|
174
|
+
| **1.0.3** | **构建工具链升级 + 依赖收敛** | 构建工具 vite 5→8(打包器改 rolldown)、`@vitejs/plugin-vue` 6;`overrides.glob` 到 `14.0.3`,`npm audit` 10 → 4(剩余为 archiver 链已知风险);`build-web.mjs` 加固:依赖缓存按 lock 指纹分目录、打包器探测兼容 rolldown;文档补密码压缩的传参契约(`options.capabilities.password`)与 archiver 链风险记录 |
|
|
175
|
+
| **1.0.2** | **识别层外置 + 措辞清理** | 平台识别改由 `sys-id@0.1.0` 提供(`PLATFORM_PROFILE` 映射表仍在本项目,对外接口与行为不变);公开文档与注释去掉只对单一机型成立的描述(CIFS noexec、无系统 zstd 等改为通用表述);`prepublishOnly` 改用 `build:web`,在无 `.bin/vite`、原生模块无法加载的工作区也能完成发布前构建 |
|
|
176
|
+
| **1.0.1** | **修 npx 启动** | 修一个让 `npx zipcalm` / 全局安装**完全不可用**的 bug:CLI 的「是否被直接执行」判据只比较 `path.resolve` 的字面路径,而 npm 在 `node_modules/.bin/` 下建的是**符号链接** —— 链接路径与真实路径字面不等,于是 `main()` 不跑、进程**静默 exit 0、零输出、服务不起**。改为比较 `realpath`(解析符号链接)。验证时用 `node <真实路径>` 跑所以一路没暴露,直到从 registry 装包跑 npx 才现形;新增 `cli` 套件(7 条)钉住这条,含符号链接调用用例,并已用「回退到旧判据」反向验证该用例确会变红。**同时**:构建不再产出 sourcemap(`.map` 是压缩产物的 5 倍大、且其 `sources` 会带出构建机目录结构),发布包 855 KB → **346 KB**;README 移除机器专属的依赖安装说明与内部参考快照段(此类内容归 `docs/14`) |
|
|
175
177
|
| **1.0.0** | **npm 首发** | 可 `npx zipcalm` / `npm i -g zipcalm` 运行:新增 CLI(`bin/zipcalm.js`,只做参数→环境变量翻译)、`files` 白名单(只带 `bin/ server/ shared/ web/dist/ README LICENSE`,不含 docs)、首次运行自动生成随机口令配置;数据默认落 `~/.zipcalm`(含 fs-root/trash/logs/tasks.json),**从 git 仓库启动的行为一字未变**(数据仍在 `<项目根>/storage`)。README 里 29 处相对文档/图片链接改为绝对地址(npm 上才不 404) |
|
|
176
178
|
| 0.14.0 | 乱码名还原 | 解压时自动还原 PUA 转义的乱码文件名(共享阶段,zip/tar/7z/rar 通用);新增 `POST /fs/repair-names` 与 CLI 修复已解压目录(dryRun 默认、绝不覆盖、四类计数);RAR 引擎强制 `en_US.UTF-8`(默认 locale / C.UTF-8 会输出有损 `?`);实测六款 RAR 解包库,结论是不换库、只做解码 |
|
|
177
179
|
| 0.13.0 | RAR 双引擎 | RAR 解压引擎改为三级回退(系统 `unrar` → `node-unrar-js@2.0.2` 纯 JS 兜底 → 置灰),新增 `ZC_UNRAR_ENGINE=native\|js` 与 `probe().filenameSafe`。**实测澄清了根因**:RAR5 中文名乱码是 unrar 版本 bug(5.70 坏 / 7.23 好),而 js 引擎内置的 6.1.7 **同样坏** → 换引擎修不了文件名,因此**有原生 unrar 时仍优先原生**(它还能正确报损坏包,js 会静默部分解出)。版本判 minor:新增引擎与依赖、影响部署面(没装 unrar 的机器从"不可用"变"可用") |
|
|
178
|
-
| 0.12.1 | 7z 双引擎 | 接入回退② **WASM 引擎 `js7z-tools@2.5.0`(7-Zip 25.01)**:没装 p7zip 的机器也能压缩/解压 7z,产物与原生引擎**双向兼容**(同输入同尺寸、互解互验通过)。实现要点:每次操作独立子进程、严格等 `onExit`(MT 异步语义)再读产物;`ZC_7Z_DISABLE_WASM=1` 可关;单任务输入 >2 GB 提前拒绝并建议 tar.zst;CPU 默认绑「总核数 − 1」+ `nice` +
|
|
179
|
-
| 0.12.0 | 7z(双向) | 新增 7z 格式包(`server/plugins/formats/sevenzip/`):可压缩可解压;三级引擎回退(系统 7z/7za → WASM `js7z-tools`(本版未接入,0.12.1 补齐)→ 不可用);`-mx0..9` 级别透传;原生密码三态分离;条目安全复用共享判定;压缩用"条目名走 argv 分批"
|
|
180
|
+
| 0.12.1 | 7z 双引擎 | 接入回退② **WASM 引擎 `js7z-tools@2.5.0`(7-Zip 25.01)**:没装 p7zip 的机器也能压缩/解压 7z,产物与原生引擎**双向兼容**(同输入同尺寸、互解互验通过)。实现要点:每次操作独立子进程、严格等 `onExit`(MT 异步语义)再读产物;`ZC_7Z_DISABLE_WASM=1` 可关;单任务输入 >2 GB 提前拒绝并建议 tar.zst;CPU 默认绑「总核数 − 1」+ `nice` + 全局串行(这是 CPU 过载 503 事故的直接修复)。版本判 patch:对外仍是"7z 双向",变的是引擎覆盖范围 |
|
|
181
|
+
| 0.12.0 | 7z(双向) | 新增 7z 格式包(`server/plugins/formats/sevenzip/`):可压缩可解压;三级引擎回退(系统 7z/7za → WASM `js7z-tools`(本版未接入,0.12.1 补齐)→ 不可用);`-mx0..9` 级别透传;原生密码三态分离;条目安全复用共享判定;压缩用"条目名走 argv 分批"(p7zip 16.02 是 `Utf16=off` 构建,读 UTF-8 listfile 的中文名会失败) |
|
|
180
182
|
| 0.11.0 | RAR 解压(只读) | 新增 RAR 格式包(`server/plugins/formats/rar/`):**只解压不生成**(闭源商业算法,`create()`/`estimate()` 显式拒绝);解压走外部 `unrar`(优先)/`7z`(兜底),二进制缺失时置灰并给安装建议;rar4/rar5、多卷、加密包(`MISSING_PASSWORD` / `BAD_PASSWORD` / `ARCHIVE_CORRUPTED` 三态分离);外部程序先解到中转目录,再经共享条目判定(zip-slip / 绝对路径 / 符号链接 / 不覆盖)搬入目标目录;进度按文件级降级上报 |
|
|
181
183
|
| 0.10.1 | 硬链接预检提示 | 预检按 `dev + ino` 归组统计硬链接(跨分区同 `ino` 不误判、黑名单跳过项不计入、嵌套选择不重复计数);所选格式不保留硬链接且多存占比 ≥10% 时在压缩抽屉提示「预计产出 / 换 TAR 只存一份省多少」,tar 家族给正向说明;纯提示不阻断、不改空间判定与删除逻辑 |
|
|
182
184
|
| 0.10.0 | 能力插件 + 压缩密码 | 新增「能力插件」扩展点:格式插件用 `capabilities` 声明能力、能力插件按目录自注册、加密算法可继续追加;落地压缩包密码(AES-256 / ZipCrypto),含压完同密码自测、明文三通道脱敏(API / `tasks.json` / 日志)、删除前复核密码可解;解压侧支持加密包(AES-256 与 ZipCrypto 走 7z 解包 + 白名单搬移,缺密码 / 密码错 / 包损坏三态区分) |
|
package/bin/zipcalm.js
CHANGED
|
@@ -95,7 +95,7 @@ export function lanAddress() {
|
|
|
95
95
|
/**
|
|
96
96
|
* 首次运行引导:数据目录里没有 config.json 时,生成一份**带随机口令**的最小配置。
|
|
97
97
|
*
|
|
98
|
-
* 为什么必须做:仓库里那份 config.json 是 gitignore
|
|
98
|
+
* 为什么必须做:仓库里那份 config.json 是 gitignore 的运行态文件(含运行机器的口令),**不会进 npm 包**;
|
|
99
99
|
* 而配置校验要求 auth.password 或 auth.passwordHash 二选一,缺了就启动失败。
|
|
100
100
|
* 这里给新用户生成随机口令并打印一次 —— 比内置默认口令安全(不会所有人都是 admin/zipcalm)。
|
|
101
101
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zipcalm",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
|
+
"description": "跑在 NAS 上的网页版文件管理与压缩工具:浏览、压缩、解压、按内容去重,含任务队列与性能看板",
|
|
4
5
|
"private": false,
|
|
5
6
|
"type": "module",
|
|
6
7
|
"engines": {
|
|
@@ -9,9 +10,11 @@
|
|
|
9
10
|
"scripts": {
|
|
10
11
|
"dev": "vite --config web/vite.config.js",
|
|
11
12
|
"build": "node scripts/check-drift.js && vite build --config web/vite.config.js",
|
|
13
|
+
"build:web": "node scripts/build-web.mjs",
|
|
12
14
|
"preview": "vite preview --config web/vite.config.js",
|
|
13
15
|
"start": "node server/boot.js",
|
|
14
|
-
"check:drift": "node scripts/check-drift.js"
|
|
16
|
+
"check:drift": "node scripts/check-drift.js",
|
|
17
|
+
"prepublishOnly": "npm run build:web"
|
|
15
18
|
},
|
|
16
19
|
"bin": {
|
|
17
20
|
"zipcalm": "bin/zipcalm.js"
|
|
@@ -24,7 +27,6 @@
|
|
|
24
27
|
"README.md",
|
|
25
28
|
"LICENSE"
|
|
26
29
|
],
|
|
27
|
-
"prepublishOnly": "npm run build",
|
|
28
30
|
"dependencies": {
|
|
29
31
|
"@fastify/cookie": "^11.1.3",
|
|
30
32
|
"@fastify/static": "^10.1.6",
|
|
@@ -38,6 +40,7 @@
|
|
|
38
40
|
"node-unrar-js": "2.0.2",
|
|
39
41
|
"pinia": "2.3.1",
|
|
40
42
|
"pretty-bytes": "6.1.0",
|
|
43
|
+
"sys-id": "0.1.0",
|
|
41
44
|
"tar": "^7.5.1",
|
|
42
45
|
"unzipper": "0.12.5",
|
|
43
46
|
"uplot": "^1.6.32",
|
|
@@ -45,15 +48,14 @@
|
|
|
45
48
|
"vue-router": "4.6.4"
|
|
46
49
|
},
|
|
47
50
|
"devDependencies": {
|
|
48
|
-
"
|
|
49
|
-
"
|
|
51
|
+
"@vitejs/plugin-vue": "6.0.10",
|
|
52
|
+
"vite": "8.3.5"
|
|
50
53
|
},
|
|
54
|
+
"license": "MIT",
|
|
51
55
|
"repository": {
|
|
52
56
|
"type": "git",
|
|
53
57
|
"url": "git+https://github.com/EIGHTfs/ZipCalm.git"
|
|
54
58
|
},
|
|
55
|
-
"license": "MIT",
|
|
56
|
-
"author": "EIGHTfs",
|
|
57
59
|
"keywords": [
|
|
58
60
|
"nas",
|
|
59
61
|
"compression",
|
|
@@ -62,5 +64,9 @@
|
|
|
62
64
|
"zstd",
|
|
63
65
|
"synology",
|
|
64
66
|
"self-hosted"
|
|
65
|
-
]
|
|
67
|
+
],
|
|
68
|
+
"author": "EIGHTfs",
|
|
69
|
+
"overrides": {
|
|
70
|
+
"glob": "^14.0.3"
|
|
71
|
+
}
|
|
66
72
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 密码哈希(scrypt,Node 内置,零依赖)
|
|
2
2
|
//
|
|
3
3
|
// 为什么不用明文比较、也不引 bcrypt:NAS 上不想再引原生编译依赖(项目原则:优先成熟库,但
|
|
4
|
-
// 这里的"成熟库"bcrypt/argon2
|
|
4
|
+
// 这里的"成熟库"bcrypt/argon2 都要原生编译,在 noexec 挂载等受限环境下风险高)。
|
|
5
5
|
// Node 内置 scrypt 属标准方案,格式自带参数,便于将来升级参数而不破坏老哈希。
|
|
6
6
|
//
|
|
7
7
|
// 存储格式:scrypt$N$r$p$<salt-base64>$<hash-base64>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 外部引擎统一执行入口(**所有** 走外部程序/WASM 的格式插件都必须经这里)
|
|
2
2
|
//
|
|
3
3
|
// 为什么要有这一层(而不是各插件自己 spawn):
|
|
4
|
-
//
|
|
4
|
+
// 限核、超时硬杀、全局串行是**部署环境约束**(CPU 核数少的机器上跑满会触发宿主"系统 CPU 过载"保护,
|
|
5
5
|
// 实测已造成过一次 503),不是某个格式的特性。散在各插件里 → 每加一个格式就漏一次;
|
|
6
6
|
// 各插件各有一把串行锁时,7z 任务与 RAR 任务还能同时跑,一起把 CPU 打满。
|
|
7
7
|
//
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// ① 应用层线程数:7z 原生 `-mmt=N`(产物 md5 与线程数无关,实测一致)。
|
|
13
13
|
// 但 **unrar 没有线程参数、7z WASM 库内固定 3 线程**,所以这一层不能兜底,只能锦上添花。
|
|
14
14
|
// ② OS 级绑核:Linux `taskset -c <list>`(实测可把 Cpus_allowed_list 收到指定核);
|
|
15
|
-
// Windows 可用 PowerShell 启动后设 `ProcessorAffinity
|
|
15
|
+
// Windows 可用 PowerShell 启动后设 `ProcessorAffinity`(**当前环境无 Windows,未实测**);
|
|
16
16
|
// **macOS 没有可用绑核手段**(taskpolicy 只调 QoS、cpuset 是 FreeBSD 的)→ 如实跳过。
|
|
17
17
|
// ③ 优先级:Linux/macOS `nice -n 10`;Windows `PriorityClass = Idle`。
|
|
18
18
|
//
|
|
@@ -174,7 +174,7 @@ export function detectStrategy(probe = {}) {
|
|
|
174
174
|
* - Linux 的 `taskset`/`nice` 是 exec 直通,退出码就是引擎的退出码 ✅
|
|
175
175
|
* - Windows 的 `cmd /c start /wait` **不保证透传子进程退出码**(start 返回的是自身结果),
|
|
176
176
|
* 所以这里**不用 start**,改用 PowerShell:`Start-Process -Wait -PassThru` 后读 `ExitCode` 并 `exit`,
|
|
177
|
-
* 这样拿到的才是引擎的退出码。⚠️
|
|
177
|
+
* 这样拿到的才是引擎的退出码。⚠️ 该分支未在真机验证(开发环境只有 Linux),已在 docs/17 登记。
|
|
178
178
|
*
|
|
179
179
|
* @param {{command: string, args: string[]}} spec
|
|
180
180
|
* @param {{strategy?: object, cores?: number, cpuList?: string, mask?: string}} [context]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// 平台画像:系统判断 + 侧栏模式 + 显示文案 + 根发现策略的**唯一入口**
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独抽一个文件:这三件事原本散在四处知识里 ——
|
|
4
|
+
// ① roots.js 的两个枚举(PLATFORMS / SIDEBAR_MODES)
|
|
5
|
+
// ② roots.js 的 detectPlatform()(群晖特征文件常量也在这里)
|
|
6
|
+
// ③ roots.js 的 sidebarModeOf() 与 listRoots() 里的 if/else(决定根从哪来)
|
|
7
|
+
// ④ 前端 Sidebar.vue 里写死的「模式 → 标题」映射
|
|
8
|
+
// 结果是「同一台机器该显示什么」由四处各自回答,加一个平台要改四个地方,必然漂移。
|
|
9
|
+
//
|
|
10
|
+
// 收敛后的规则:任何「这是什么系统 → 侧栏怎么显示 → 根从哪来」的判断,
|
|
11
|
+
// **只允许**由 resolvePlatformProfile() 回答;其余模块(含前端)只消费它的返回值。
|
|
12
|
+
//
|
|
13
|
+
// 分工(识别层 / 表现层):
|
|
14
|
+
// - **识别层**由 `sys-id` 提供(它判断"这是什么系统",返回大写身份如 Synology / Linux);
|
|
15
|
+
// - **表现层**(身份 → 侧栏模式 / 标题 / 根发现策略)是本项目的资产,留在下面的画像表里。
|
|
16
|
+
// 换识别实现只动 detectPlatform 内部,画像表与所有调用方不变。
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
|
|
20
|
+
import { detectPlatform as detectSysIdPlatform } from 'sys-id';
|
|
21
|
+
|
|
22
|
+
/** 平台常量(将来接入独立探测包时,这张表由包的规则数据取代) */
|
|
23
|
+
export const PLATFORMS = Object.freeze({ SYNOLOGY: 'synology', WINDOWS: 'windows', POSIX: 'posix' });
|
|
24
|
+
|
|
25
|
+
/** 侧栏模式:B = 列卷(默认);C = 列共享文件夹(群晖) */
|
|
26
|
+
export const SIDEBAR_MODES = Object.freeze({ VOLUMES: 'B', SHARES: 'C' });
|
|
27
|
+
|
|
28
|
+
/** 群晖特征文件(docs/16 §2.1 实测依据) */
|
|
29
|
+
const SYNOLOGY_MARKER = '/etc/synoinfo.conf'; // dsh-skip-sensitive
|
|
30
|
+
|
|
31
|
+
/** 系统目录前缀:@appdata / @eaDir / #recycle 等不是用户数据目录 */
|
|
32
|
+
const DEFAULT_SYSTEM_DIR_PREFIXES = Object.freeze(['@', '#']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 平台画像表:一个平台一行,决定它在界面上长什么样、根从哪来。
|
|
36
|
+
*
|
|
37
|
+
* 每个字段的用途:
|
|
38
|
+
* mode 侧栏模式(B = 卷 / C = 共享)
|
|
39
|
+
* volumeLabel 侧栏标题 —— 由后端下发,前端不再自己判断
|
|
40
|
+
* rootsStrategy 根发现策略:`shares` 扫共享 / `volumes` 扫卷与挂载点
|
|
41
|
+
* systemDirPrefixes 扫描时要排除的系统目录前缀
|
|
42
|
+
* volumePattern 卷目录名的匹配规则
|
|
43
|
+
*
|
|
44
|
+
* 加平台 = 在这张表加一行 + 在 PLATFORMS 加一个值(等独立包接入后,连这一步都省掉)。
|
|
45
|
+
*/
|
|
46
|
+
export const PLATFORM_PROFILE = Object.freeze({
|
|
47
|
+
[PLATFORMS.SYNOLOGY]: Object.freeze({
|
|
48
|
+
mode: SIDEBAR_MODES.SHARES,
|
|
49
|
+
volumeLabel: '位置',
|
|
50
|
+
rootsStrategy: 'shares',
|
|
51
|
+
systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
|
|
52
|
+
volumePattern: /^\/volume\d+$/,
|
|
53
|
+
}),
|
|
54
|
+
[PLATFORMS.WINDOWS]: Object.freeze({
|
|
55
|
+
mode: SIDEBAR_MODES.VOLUMES,
|
|
56
|
+
volumeLabel: '存储卷',
|
|
57
|
+
rootsStrategy: 'volumes',
|
|
58
|
+
systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
|
|
59
|
+
volumePattern: /^\/volume\d+$/,
|
|
60
|
+
}),
|
|
61
|
+
[PLATFORMS.POSIX]: Object.freeze({
|
|
62
|
+
mode: SIDEBAR_MODES.VOLUMES,
|
|
63
|
+
volumeLabel: '存储卷',
|
|
64
|
+
rootsStrategy: 'volumes',
|
|
65
|
+
systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
|
|
66
|
+
volumePattern: /^\/volume\d+$/,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** 未知平台一律回落 POSIX 画像:宁可显示成普通 Linux,也不能白屏 */
|
|
71
|
+
const FALLBACK_PROFILE = PLATFORM_PROFILE[PLATFORMS.POSIX];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 本项目小写强制值 → sys-id 身份(大写)。
|
|
75
|
+
* `posix` 落到 `Linux` 身份:本项目用它表示「非群晖、非 Windows」的通用表现。
|
|
76
|
+
*/
|
|
77
|
+
const FORCE_ENV_TO_IDENTITY = Object.freeze({
|
|
78
|
+
[PLATFORMS.SYNOLOGY]: 'Synology',
|
|
79
|
+
[PLATFORMS.WINDOWS]: 'Windows',
|
|
80
|
+
[PLATFORMS.POSIX]: 'Linux',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* sys-id 身份 → 本项目枚举。
|
|
85
|
+
* 只有群晖与 Windows 需要区别对待;其余身份(Linux/macOS/fnOS/Unknown/未来新增)都是 posix。
|
|
86
|
+
*/
|
|
87
|
+
const IDENTITY_TO_PLATFORM = Object.freeze({
|
|
88
|
+
Synology: PLATFORMS.SYNOLOGY,
|
|
89
|
+
Windows: PLATFORMS.WINDOWS,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 平台探测:把识别工作交给 sys-id,本文件只维护「身份 → 本项目枚举」的映射。
|
|
94
|
+
*
|
|
95
|
+
* 三条与既有契约相关的处理(都不能省):
|
|
96
|
+
* 1. 身份大写、本项目枚举小写 —— 只有 Synology / Windows 有专属枚举,
|
|
97
|
+
* 其余身份(Linux / macOS / fnOS / Unknown / 将来新增的平台)一律落到 posix,
|
|
98
|
+
* 因为界面只需要区分「群晖(列共享)/ Windows / 其他(列卷)」三种表现;
|
|
99
|
+
* 2. 强制值方向相反 —— 本项目环境变量是小写,sys-id 的 force 要大写身份,
|
|
100
|
+
* `posix` 对应 sys-id 的 `Linux` 身份;不在映射表里的值**不传 force**,走正常探测;
|
|
101
|
+
* 3. sys-id 对非法 force 会抛错,而本项目既有契约是「非法强制值回落真实探测、不抛错」,
|
|
102
|
+
* 所以必须 try/catch 兜住。
|
|
103
|
+
*
|
|
104
|
+
* 保留最后一层本地兜底:sys-id 自身异常(规则目录缺失等)时不能让整个页面挂掉。
|
|
105
|
+
*
|
|
106
|
+
* @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
|
|
107
|
+
*/
|
|
108
|
+
export function detectPlatform({ env = process.env, existsSync = fs.existsSync } = {}) {
|
|
109
|
+
const forced = env.ZIPCALM_FORCE_PLATFORM;
|
|
110
|
+
const forcedIdentity = FORCE_ENV_TO_IDENTITY[forced];
|
|
111
|
+
// sys-id 返回 { <身份>: {...} },识别场景下只有一个键
|
|
112
|
+
const identify = (force) => Object.keys(detectSysIdPlatform([], {
|
|
113
|
+
env,
|
|
114
|
+
existsSync,
|
|
115
|
+
...(force ? { force } : {}),
|
|
116
|
+
}))[0];
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
return IDENTITY_TO_PLATFORM[identify(forcedIdentity)] || PLATFORMS.POSIX;
|
|
120
|
+
} catch {
|
|
121
|
+
// 强制值不可用(sys-id 认为非法)或识别过程出错:**不抛错**,按无 force 重探一次
|
|
122
|
+
try {
|
|
123
|
+
return IDENTITY_TO_PLATFORM[identify(undefined)] || PLATFORMS.POSIX;
|
|
124
|
+
} catch {
|
|
125
|
+
return detectPlatformLocally(existsSync);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 最后兜底:sys-id 完全不可用时的最小识别(保证不抛错、不白屏) */
|
|
131
|
+
function detectPlatformLocally(existsSync) {
|
|
132
|
+
try {
|
|
133
|
+
if (existsSync(SYNOLOGY_MARKER)) return PLATFORMS.SYNOLOGY;
|
|
134
|
+
} catch {
|
|
135
|
+
// 探测失败按「不是群晖」处理
|
|
136
|
+
}
|
|
137
|
+
return process.platform === 'win32' ? PLATFORMS.WINDOWS : PLATFORMS.POSIX;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 平台 → 侧栏模式。
|
|
142
|
+
* 保留这个导出是为了不破坏既有调用方;**内部已改走画像表**,不再单独写一遍判断。
|
|
143
|
+
*/
|
|
144
|
+
export function sidebarModeOf(platform) {
|
|
145
|
+
return (PLATFORM_PROFILE[platform] || FALLBACK_PROFILE).mode;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* **唯一入口**:一次给出「平台 + 该平台在界面上的一切表现」。
|
|
150
|
+
*
|
|
151
|
+
* 调用方拿到后不要再自己判断平台 —— 需要什么字段就用返回体里的:
|
|
152
|
+
* const { platform, mode, volumeLabel, rootsStrategy, systemDirPrefixes, volumePattern } = resolvePlatformProfile();
|
|
153
|
+
*
|
|
154
|
+
* @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
|
|
155
|
+
*/
|
|
156
|
+
export function resolvePlatformProfile({ env = process.env, existsSync = fs.existsSync } = {}) {
|
|
157
|
+
const platform = detectPlatform({ env, existsSync });
|
|
158
|
+
const profile = PLATFORM_PROFILE[platform] || FALLBACK_PROFILE;
|
|
159
|
+
return {
|
|
160
|
+
platform,
|
|
161
|
+
mode: profile.mode,
|
|
162
|
+
volumeLabel: profile.volumeLabel,
|
|
163
|
+
rootsStrategy: profile.rootsStrategy,
|
|
164
|
+
systemDirPrefixes: profile.systemDirPrefixes,
|
|
165
|
+
volumePattern: profile.volumePattern,
|
|
166
|
+
};
|
|
167
|
+
}
|
package/server/core/fs/roots.js
CHANGED
|
@@ -1,58 +1,40 @@
|
|
|
1
|
-
// 存储根发现(docs/16
|
|
1
|
+
// 存储根发现(docs/16):按平台画像决定根从哪来
|
|
2
2
|
//
|
|
3
3
|
// 心智模型(docs/16 §一):本项目的「根」不是文件系统 `/`,而是**可配置的存储根**。
|
|
4
4
|
// NAS 用户想的是"照片在哪个共享",桌面用户想的是"先看卷"——所以两套数据源、同一个形状:
|
|
5
5
|
// B 模式(非群晖):卷 / 挂载点(/volume1、/mnt/data)
|
|
6
6
|
// C 模式(群晖): /volumeN 下的共享文件夹(排除 @ / # 开头的系统目录)
|
|
7
7
|
//
|
|
8
|
+
// **平台判断不在本文件**:`PLATFORMS` / `SIDEBAR_MODES` / `detectPlatform` / `sidebarModeOf`
|
|
9
|
+
// 全部由 `platformProfile.js` 提供(本文件只做 re-export 兼容既有调用方)。
|
|
10
|
+
// 本文件只负责「拿到画像后怎么扫根」。
|
|
11
|
+
//
|
|
8
12
|
// 所有失败都降级:扫不到就空列表、statfs 失败就容量 null —— 列卷失败绝不能让文件页打不开。
|
|
9
13
|
|
|
10
|
-
import fs from 'node:fs';
|
|
11
14
|
import fsp from 'node:fs/promises';
|
|
12
15
|
import path from 'node:path';
|
|
13
16
|
|
|
14
17
|
import { ERROR_CODES, apiError } from '../../lib/errors.js';
|
|
18
|
+
import {
|
|
19
|
+
PLATFORMS,
|
|
20
|
+
SIDEBAR_MODES,
|
|
21
|
+
detectPlatform,
|
|
22
|
+
resolvePlatformProfile,
|
|
23
|
+
sidebarModeOf,
|
|
24
|
+
} from './platformProfile.js';
|
|
25
|
+
|
|
26
|
+
// 兼容导出:既有调用方/测试脚本仍从 roots.js 取这些名字,签名与语义保持不变
|
|
27
|
+
export { PLATFORMS, SIDEBAR_MODES, detectPlatform, sidebarModeOf };
|
|
15
28
|
|
|
16
|
-
/** 平台常量(将来加厂商在这里加一个值即可) */
|
|
17
|
-
export const PLATFORMS = Object.freeze({ SYNOLOGY: 'synology', WINDOWS: 'windows', POSIX: 'posix' });
|
|
18
|
-
/** 侧栏模式:B = 列卷(默认);C = 列共享文件夹(群晖) */
|
|
19
|
-
export const SIDEBAR_MODES = Object.freeze({ VOLUMES: 'B', SHARES: 'C' });
|
|
20
29
|
/** 默认根在列表里的固定 id */
|
|
21
30
|
export const DEFAULT_ROOT_ID = 'default';
|
|
22
31
|
|
|
23
|
-
/** 群晖特征文件(docs/16 §2.1 实测依据) */
|
|
24
|
-
const SYNOLOGY_MARKER = '/etc/synoinfo.conf';
|
|
25
|
-
/** 群晖系统目录前缀:@appdata / @eaDir / #recycle 等不是共享 */
|
|
26
|
-
const SYSTEM_DIR_PREFIX = /^[@#]/;
|
|
27
32
|
/** B 模式的挂载点形态:/mnt/<name>、/media/<name>(只认一级,不认子目录) */
|
|
28
33
|
const MOUNT_POINT_ONE_LEVEL = /^\/(mnt|media)\/[^/]+$/;
|
|
29
|
-
/** 群晖卷名规则:/volume1 /volume2 ... */
|
|
30
|
-
const SYNOLOGY_VOLUME = /^\/volume\d+$/;
|
|
31
34
|
|
|
32
35
|
const statfsOf = (target) => fsp.statfs(target);
|
|
33
36
|
const readdirOf = (target) => fsp.readdir(target, { withFileTypes: true });
|
|
34
37
|
|
|
35
|
-
/**
|
|
36
|
-
* 平台探测:环境变量覆盖优先(测试必需),其次群晖特征文件,最后按 Node platform 归类。
|
|
37
|
-
* @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
|
|
38
|
-
*/
|
|
39
|
-
export function detectPlatform({ env = process.env, existsSync = fs.existsSync } = {}) {
|
|
40
|
-
const forced = env.ZIPCALM_FORCE_PLATFORM;
|
|
41
|
-
if (forced === PLATFORMS.SYNOLOGY || forced === PLATFORMS.POSIX || forced === PLATFORMS.WINDOWS) return forced;
|
|
42
|
-
try {
|
|
43
|
-
if (existsSync(SYNOLOGY_MARKER)) return PLATFORMS.SYNOLOGY;
|
|
44
|
-
} catch {
|
|
45
|
-
// 探测失败按"不是群晖"处理:回退 B 模式,不能因为探测异常让整个页面挂掉
|
|
46
|
-
}
|
|
47
|
-
if (process.platform === 'win32') return PLATFORMS.WINDOWS;
|
|
48
|
-
return PLATFORMS.POSIX;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 平台 → 侧栏模式(未知/将来厂商:默认 B,绝不白屏) */
|
|
52
|
-
export function sidebarModeOf(platform) {
|
|
53
|
-
return platform === PLATFORMS.SYNOLOGY ? SIDEBAR_MODES.SHARES : SIDEBAR_MODES.VOLUMES;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
38
|
/** 容量(statfs);拿不到就两项都 null —— 契约要求降级不报错 */
|
|
57
39
|
export async function rootStats(absPath) {
|
|
58
40
|
try {
|
|
@@ -90,35 +72,38 @@ async function readMountPoints() {
|
|
|
90
72
|
}
|
|
91
73
|
}
|
|
92
74
|
|
|
75
|
+
/** 顶层卷目录(/volumeN)—— 匹配规则由画像表提供,避免本文件另写一份 */
|
|
76
|
+
async function listVolumeRoots(profile) {
|
|
77
|
+
const found = [];
|
|
78
|
+
for (const dirName of await listSubdirs('/')) {
|
|
79
|
+
const absPath = `/${dirName}`;
|
|
80
|
+
if (profile.volumePattern.test(absPath)) found.push(absPath);
|
|
81
|
+
}
|
|
82
|
+
return found.sort();
|
|
83
|
+
}
|
|
84
|
+
|
|
93
85
|
/**
|
|
94
86
|
* B 模式:自动发现卷/挂载点。
|
|
95
|
-
*
|
|
87
|
+
* 规则:画像表认可的 /volumeN + /proc/mounts 里的 /mnt|/media 挂载点(去重,按路径排序)。
|
|
96
88
|
* 为什么不全用 /proc/mounts:容器里它会列出一堆伪文件系统(proc/sysfs/tmpfs),对用户无意义。
|
|
97
89
|
*/
|
|
98
|
-
async function discoverVolumeDirs() {
|
|
99
|
-
const found = new Set();
|
|
100
|
-
for (const dirName of await listSubdirs('/')) {
|
|
101
|
-
if (/^volume\d+$/.test(dirName)) found.add(`/${dirName}`);
|
|
102
|
-
}
|
|
90
|
+
async function discoverVolumeDirs(profile) {
|
|
91
|
+
const found = new Set(await listVolumeRoots(profile));
|
|
103
92
|
// 只认"真正挂载了的"一级目录:/mnt/data、/media/usb 这种。
|
|
104
93
|
// 不能拿 /mnt/* 的**子目录**当卷 —— 很多机器 /mnt 本身就是一个挂载点,里面全是普通目录(实测踩过)。
|
|
105
94
|
for (const mountPoint of await readMountPoints()) {
|
|
106
95
|
if (MOUNT_POINT_ONE_LEVEL.test(mountPoint)) found.add(mountPoint);
|
|
107
|
-
if (
|
|
96
|
+
if (profile.volumePattern.test(mountPoint)) found.add(mountPoint);
|
|
108
97
|
}
|
|
109
98
|
return [...found].sort();
|
|
110
99
|
}
|
|
111
100
|
|
|
112
|
-
/** C 模式:/volumeN
|
|
113
|
-
async function discoverShares() {
|
|
114
|
-
const volumes = [];
|
|
115
|
-
for (const dirName of await listSubdirs('/')) {
|
|
116
|
-
if (/^volume\d+$/.test(dirName)) volumes.push(`/${dirName}`);
|
|
117
|
-
}
|
|
101
|
+
/** C 模式:/volumeN 下的共享文件夹(排除画像表里的系统目录前缀,以及点开头的噪音目录) */
|
|
102
|
+
async function discoverShares(profile) {
|
|
118
103
|
const shares = [];
|
|
119
|
-
for (const volumeAbs of
|
|
104
|
+
for (const volumeAbs of await listVolumeRoots(profile)) {
|
|
120
105
|
for (const name of await listSubdirs(volumeAbs)) {
|
|
121
|
-
if (
|
|
106
|
+
if (profile.systemDirPrefixes.some((prefix) => name.startsWith(prefix))) continue;
|
|
122
107
|
// 点开头的目录(.pnpm-store / .git / .Trash …)对用户是噪音:
|
|
123
108
|
// DSM 的共享文件夹本来就不会以点开头,列出来只会让列表变脏。
|
|
124
109
|
if (name.startsWith('.')) continue;
|
|
@@ -131,14 +116,15 @@ async function discoverShares() {
|
|
|
131
116
|
/**
|
|
132
117
|
* 构造根列表。
|
|
133
118
|
* - 配置了 storage.roots → 直接用(两种模式都用它;显式配置优先于自动发现)
|
|
134
|
-
* -
|
|
135
|
-
* -
|
|
119
|
+
* - rootsStrategy=shares 且没配置 → 扫共享;扫不到任何共享 → 回退列出默认根(避免侧栏空白)
|
|
120
|
+
* - rootsStrategy=volumes 且没配置 → 扫卷;扫不到 → 只列默认根
|
|
136
121
|
* - ZIPCALM_SHOW_APP_ROOT=1 → 任何模式都额外列出默认根(标注「应用数据」)
|
|
122
|
+
*
|
|
123
|
+
* 返回体除根列表外还带 `volumeLabel`(侧栏标题由后端下发,前端不再自己判断平台)。
|
|
137
124
|
* @param {{storage: object, env?: object}} options
|
|
138
125
|
*/
|
|
139
126
|
export async function listRoots({ storage, env = process.env }) {
|
|
140
|
-
const
|
|
141
|
-
const mode = sidebarModeOf(platform);
|
|
127
|
+
const profile = resolvePlatformProfile({ env });
|
|
142
128
|
const configured = Array.isArray(storage.roots) && storage.roots.length > 0
|
|
143
129
|
? storage.roots.map((root) => ({ id: root.id, label: root.label || root.id, path: root.path }))
|
|
144
130
|
: null;
|
|
@@ -147,11 +133,11 @@ export async function listRoots({ storage, env = process.env }) {
|
|
|
147
133
|
let roots;
|
|
148
134
|
if (configured) {
|
|
149
135
|
roots = configured;
|
|
150
|
-
} else if (
|
|
151
|
-
const shares = await discoverShares();
|
|
136
|
+
} else if (profile.rootsStrategy === 'shares') {
|
|
137
|
+
const shares = await discoverShares(profile);
|
|
152
138
|
roots = shares.length > 0 ? shares : [defaultRoot];
|
|
153
139
|
} else {
|
|
154
|
-
const volumes = await discoverVolumeDirs();
|
|
140
|
+
const volumes = await discoverVolumeDirs(profile);
|
|
155
141
|
roots = volumes.length > 0
|
|
156
142
|
? volumes.map((volumePath) => ({ id: volumePath.replace(/^\/+/, ''), label: path.basename(volumePath), path: volumePath }))
|
|
157
143
|
: [defaultRoot];
|
|
@@ -163,7 +149,14 @@ export async function listRoots({ storage, env = process.env }) {
|
|
|
163
149
|
}
|
|
164
150
|
|
|
165
151
|
const withStats = await Promise.all(roots.map(async (root) => ({ ...root, ...(await rootStats(root.path)) })));
|
|
166
|
-
return {
|
|
152
|
+
return {
|
|
153
|
+
platform: profile.platform,
|
|
154
|
+
mode: profile.mode,
|
|
155
|
+
// 侧栏标题的唯一来源:平台画像 → 后端下发 → 前端只渲染
|
|
156
|
+
volumeLabel: profile.volumeLabel,
|
|
157
|
+
roots: withStats,
|
|
158
|
+
canSwitch: withStats.length > 1,
|
|
159
|
+
};
|
|
167
160
|
}
|
|
168
161
|
|
|
169
162
|
/**
|
|
@@ -104,7 +104,7 @@ export default {
|
|
|
104
104
|
// 统一走 7z 试解:
|
|
105
105
|
// unzipper 只有 ZipCrypto 的解密实现,AES-256 包(compressionMethod=99)会被它
|
|
106
106
|
// 当成普通 inflate 解读 → 报「inflate 失败/坏数据」或 BAD_PASSWORD,属**假阴性**。
|
|
107
|
-
// 7-Zip
|
|
107
|
+
// 7-Zip 两种加密都能读,且本地环境与 NAS 上通常都有;探测不到时明确报错,不静默放行。
|
|
108
108
|
return verifyWith7z(archivePath, params.password);
|
|
109
109
|
},
|
|
110
110
|
|
|
@@ -314,7 +314,7 @@ export default {
|
|
|
314
314
|
*
|
|
315
315
|
* 为什么必须由本插件提供、而不是让编排层统一用 7z:
|
|
316
316
|
* p7zip 16.02 读不了加密 RAR5(实测报 `Can not open the file as archive`),
|
|
317
|
-
*
|
|
317
|
+
* 而 `unrar 5.70` 可以(实测)。工具能力按格式不同,这个知识属于插件自己。
|
|
318
318
|
*
|
|
319
319
|
* @param {string} archivePath
|
|
320
320
|
* @param {string} password
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// - 缺密码与错密码在 unrar 侧**同为 exit 11 + 同一句提示**,无法从输出区分 —— 只能由调用方
|
|
9
9
|
// 根据"我们有没有传密码"来判定 MISSING_PASSWORD / BAD_PASSWORD(三态口径见 P4)
|
|
10
10
|
// - 损坏包 exit 1("Unexpected end of archive" / "Total errors: N")
|
|
11
|
-
// - p7zip 16.02 对 rar5 返回 exit 2
|
|
11
|
+
// - p7zip 16.02 对 rar5 返回 exit 2(实测),所以 **unrar 是唯一可靠后端**,7z 仅作兜底
|
|
12
12
|
|
|
13
13
|
import { execFileSync } from 'node:child_process';
|
|
14
14
|
import fs from 'node:fs';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// 7z 外部引擎封装(三级回退:系统 7z/7za → WASM 引擎 → 不可用)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 为什么先探测系统二进制:系统 `/bin/7z`(p7zip 16.02)零新增依赖、零加载开销,
|
|
4
4
|
// 只有部署机上没有 7z 时才需要 WASM 兜底(WASM 引擎需额外依赖,见 docs/18 §附四)。
|
|
5
5
|
//
|
|
6
6
|
// 实测事实(p7zip 16.02,本项目验证环境):
|
|
@@ -52,7 +52,7 @@ export async function makeLocalWorkDir(prefix) {
|
|
|
52
52
|
/**
|
|
53
53
|
* 把本地盘上的文件搬到目标路径(可能是 CIFS 网络盘)。
|
|
54
54
|
*
|
|
55
|
-
* **不能用 `fs.copyFile
|
|
55
|
+
* **不能用 `fs.copyFile`**:CIFS 挂载上它必报 `EPERM: operation not permitted, copyfile`
|
|
56
56
|
* (实测 copyFile / COPYFILE_EXCL 都失败,而 `readFile+writeFile` 与流式 pipeline 都成功)。
|
|
57
57
|
* 这里用流式管道:内存占用与文件大小无关,适合大压缩包。
|
|
58
58
|
*/
|
|
@@ -61,7 +61,7 @@ export async function copyLocalToTarget(sourcePath, targetPath) {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// CPU 纪律(绑核「总核数 − 1」+ nice + 全局串行)统一由 `server/core/engineRunner.js` 提供,与 7z 共用同一把锁。
|
|
64
|
-
//
|
|
64
|
+
// 这是部署环境约束(CPU 核数少的机器上跑满可能影响系统响应),不是 7z 私有实现,RAR 的 js 引擎同样要用。
|
|
65
65
|
|
|
66
66
|
/** 探测结果缓存(60s,与格式注册表的可用性缓存同频) */
|
|
67
67
|
const PROBE_TTL_MS = 60 * 1000;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 7z 格式包(**双向**:可压缩、可解压)
|
|
2
2
|
//
|
|
3
3
|
// 引擎:三级回退链(见 engine.js 与 docs/18 §附四)——
|
|
4
|
-
// ① 系统 7z / 7za
|
|
4
|
+
// ① 系统 7z / 7za(如 p7zip 16.02)→ 默认走这条,零新增依赖
|
|
5
5
|
// ② WASM 引擎(js7z-tools,MT)→ **尚未接入调用适配**(依赖需额外安装,见 docs/17 D-7)
|
|
6
6
|
// ③ 都没有 → available=false + unavailableReason 说明
|
|
7
7
|
//
|
|
@@ -287,7 +287,7 @@ async function create(ctx) {
|
|
|
287
287
|
const outputPath = localWorkDir ? path.join(localWorkDir, path.basename(ctx.tempPath)) : ctx.tempPath;
|
|
288
288
|
|
|
289
289
|
// 逐个源、分批把条目名当**命令行参数**传给 7z(cwd = 源的父目录,条目名即"源名/子路径")。
|
|
290
|
-
// 为什么不用 `-i@listfile
|
|
290
|
+
// 为什么不用 `-i@listfile`:p7zip 16.02 是 `Utf16=off` 构建,读 UTF-8 listfile 里的中文名会报
|
|
291
291
|
// `????: No more files`(实测),而中文名走 argv 是正常的。分批是为了避免参数过长撞 ARG_MAX。
|
|
292
292
|
const MAX_ARGS_CHARS = 60000;
|
|
293
293
|
for (const group of plan.perSource) {
|
package/shared/app-version.js
CHANGED