dsh-my-computer 0.1.0 → 0.2.0

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 CHANGED
@@ -12,7 +12,7 @@ pnpm add dsh-my-computer
12
12
  # 然后把 "dsh-my-computer" 加进 package.json 的 dsh.profile.bundles,重启 dsh web
13
13
  ```
14
14
 
15
- > 已实测环境:DSH 0.2.2-rc.1 + Windows 10/11 + Node 24,宿主侧 11 条路由 + 客户端右栏 tab 均正常。
15
+ > 已实测环境:DSH 0.2.2-rc.1 + Windows 10/11 + Node 24,宿主侧 12 条路由 + 客户端右栏 tab 均正常。
16
16
 
17
17
  ---
18
18
 
@@ -35,11 +35,13 @@ dsh-my-computer/
35
35
  ├── package.json # dsh.bundle.patch + dsh.client 声明
36
36
  ├── cordis.patch.yml # insert: [{ id: my-computer, name: dsh-my-computer }]
37
37
  ├── lib/
38
- │ ├── index.js # host 半区:ESM cordis 插件,注册 11 条 HTTP 路由(零依赖,只用 node: 内置)
38
+ │ ├── index.js # host 半区:ESM cordis 插件,注册 12 条 HTTP 路由(零依赖,只用 node: 内置)
39
+ │ ├── zip.js # 整目录打包:自己写的流式 zip 写入器(零依赖,走 node:zlib)
39
40
  │ └── client.js # 客户端 bundle:window.__ModuleLoader__.load({id, factory}) 格式,手写 React.createElement
40
41
  ├── tools/
41
- │ ├── smoke.mjs # host 冒烟测试(伪造 ctx,直接打路由)
42
- │ └── client-check.mjs # 客户端 bundle 自检(语法/注册/四个渲染状态)
42
+ │ ├── smoke.mjs # host 冒烟测试(伪造 ctx,直接打路由,含 zip 解包校验)
43
+ │ ├── client-check.mjs # 客户端 bundle 自检(语法/注册/渲染状态)
44
+ │ └── right-tab-check.mjs # 右栏 tab 三件套注册轨迹检查
43
45
  └── README.md
44
46
  ```
45
47
 
@@ -67,7 +69,8 @@ dsh-my-computer/
67
69
  | `/list` | `GET /api/my-computer/list?path=D:\foo` | GET | 列目录。目录在前、`localeCompare(zh-Hans-CN, numeric)` 排序。返回 `{ok,path,parent,entries:[{name,path,kind,size,mtime,ext,targetKind,statError}],count,total,free}`;盘符根 `parent:null` |
68
70
  | `/read` | `GET /api/my-computer/read?path=&maxBytes=` | GET | 文本预览,默认 ≤512KB、硬上限 8MB。UTF-8 优先,失败退 GB18030,再失败判二进制。返回 `{ok,path,size,mtime,truncated,encoding,text}` |
69
71
  | `/write` | `POST /api/my-computer/write` | POST | `{path,text}` 覆盖写:先写同目录 `.dsh-mc-write-*.tmp` 再 rename。返回 `{ok,path,size,mtime}` |
70
- | `/download` | `GET /api/my-computer/download?path=` | GET | 单文件流式下载,`Content-Disposition: attachment`(含 `filename*=UTF-8''`,中文名不乱码)。**目录 → 422**,不做 zip |
72
+ | `/download` | `GET /api/my-computer/download?path=` | GET | 单文件流式下载,`Content-Disposition: attachment`(含 `filename*=UTF-8''`,中文名不乱码)。给目录会 400 `IS_DIRECTORY`,提示改用下一个 |
73
+ | `/download-dir` | `GET /api/my-computer/download-dir?path=` | GET | **整目录打包成 zip 流式下发**,不落临时文件、不占内存。响应头带 `x-zip-entries` / `x-zip-bytes`。空目录 400 `ZIP_EMPTY`,条目 >20000 或 >3.9GB 在发头之前就 413,整盘根 403 |
71
74
  | `/upload` | `PUT /api/my-computer/upload?path=<目录>&name=<文件名>[&overwrite=1]` | PUT | 原始二进制 body 流式落盘(`requestBody:'streaming'`),先写 `.dsh-mc-upload-*.tmp` 再 rename。同名 409,`overwrite=1` 才覆盖 |
72
75
  | `/mkdir` | `POST /api/my-computer/mkdir` | POST | `{path:父目录,name}`,不带 `recursive`,重名 409 |
73
76
  | `/rename` | `POST /api/my-computer/rename` | POST | `{path,name}`,同目录改名,目标存在 409 |
@@ -81,6 +84,19 @@ dsh-my-computer/
81
84
 
82
85
  只校验三件事:非空、是绝对 Windows 路径(`X:\` 或 `\\server\share`)、不含 NUL。**不做 workspace 门卫**——用户要的就是全盘权限,设门卫反而把「远程救火」的场景卡死。真正的边界交给 Windows 自己的 ACL:没权限就返回 `PERMISSION_DENIED`。
83
86
 
87
+ ### 3.4 整目录打包(`lib/zip.js`)
88
+
89
+ 没在 `/download` 上加个 `?zip=1`,而是单独一条路由、单独一个模块,因为「打包」的坑和「读一个文件」完全不同:
90
+
91
+ - **先扫再发**:`scanDir()` 先走一遍目录树(只 `stat`、不读内容),拿到条目清单与总字节数,**在写响应头之前**就判断「空目录 / 条目超 20000 / 超 3.9GB」。所以客户端拿到的是 JSON 错误,而不是下到一半的残包。
92
+ - **小文件先试压**:≤8MB 的文件读进内存,`zlib.deflateRawSync` 压得动就 DEFLATE、压不动就 STORE(jpg/mp4/zip/pdf 这类直接跳过压缩),CRC 与大小在写本地头之前就已知。
93
+ - **大文件走数据描述符**:>8MB 直接 STORE 流式吐出,边读边算 CRC,写完补 16 字节 data descriptor(通用标志 bit 3)。内存恒定,不落临时文件。
94
+ - **中文名**:所有条目带 UTF-8 标志(bit 11),解压不乱码;目录条目带尾部 `/`,空目录也能还原。
95
+ - **符号链接跳过**:不跟随,避免环和跨盘。
96
+ - **不支持 Zip64**:超阈值明确报 `ZIP_TOO_LARGE` / `ZIP_TOO_MANY_ENTRIES`,不静默截断。
97
+
98
+ 已验证:Windows 自带 `Expand-Archive` 能直接解开(含中文文件名、空目录、11MB 大文件),解出来的文件 sha256 与源文件一致;`tools/smoke.mjs` 还会自己解析中央目录逐条比对 CRC。
99
+
84
100
  ---
85
101
 
86
102
  ## 4. 面板
@@ -94,8 +110,10 @@ dsh-my-computer/
94
110
 
95
111
  - **顶部**:盘符行(chip,显示可用空间,一键切换)+ 面包屑(每一级可点)+ 刷新
96
112
  - **路径框**:粘贴 `D:\dsh-workspace\vault` 回车直接跳转(远程操作的主力功能)
97
- - **列表**:图标 + 名称 + 大小/时间两行式;点目录进入,点文件开预览抽屉;每行右侧 下载 / 改名 / 删除
98
- - **多选**:行首复选框 + 「全选」,选中后出现「删除 N 项」
113
+ - **列表**:图标 + 名称 + 大小/时间两行式;点目录进入,点文件开预览抽屉;每行右侧 下载(文件)/ 打包(目录 zip)/ 剪切 / 改名 / 删除
114
+ - **打包下载**:目录行点 🗜 直接下 `<目录名>.zip`,走浏览器原生下载(不把整包攒成 blob,手机上下几百 MB 也不会崩标签页)
115
+ - **剪切 / 粘贴**:行内 ✂ 或选中多项后点「✂ 剪切 N 项」→ 进目标目录点「📋 粘贴到此处」,走 `POST /move`(同盘 rename,跨盘自动退化成 复制+删除)。剪切板只在内存里,刷新即失,避免误粘
116
+ - **多选**:行首复选框 + 「全选」,选中后出现「删除 N 项」/「剪切 N 项」
99
117
  - **筛选**:一个输入框过滤当前目录(手机上一个目录几百个文件时很有用)
100
118
  - **底部**:共 N 项、可用/总容量、已选数、上传进度
101
119
  - **底部工具条**:新建文件夹 / 上传(`<input type=file multiple>`,逐个 PUT,遇到同名弹「覆盖上传」确认)/ 全选 / 删除
@@ -167,7 +185,7 @@ Get-Content D:\dsh\dsh.log -Tail 40
167
185
  启动日志里应该能看到一行:
168
186
 
169
187
  ```
170
- [dsh-my-computer] 已注册 11 条路由:GET /api/my-computer/health, GET /api/my-computer/drives, ...
188
+ [dsh-my-computer] 已注册 12 条路由:GET /api/my-computer/health, GET /api/my-computer/drives, ...
171
189
  ```
172
190
 
173
191
  (这行是 `ctx.logger.info`,`console.log` 也会打一份。)
@@ -209,7 +227,7 @@ pnpm remove dsh-my-computer
209
227
 
210
228
  ```powershell
211
229
  cd D:\dsh-workspace\packages\dsh-my-computer
212
- node tools/smoke.mjs # host:11 条路由 + 正常/错误路径
230
+ node tools/smoke.mjs # host:12 条路由 + 正常/错误路径 + zip 解包比对
213
231
  node tools/client-check.mjs # client:bundle 形状 + slots 注册 + 四个渲染状态
214
232
  node tools/right-tab-check.mjs # client:右栏 tab 三件套(sidebarRightTabs + pane.tab + title)注册轨迹
215
233
  ```
@@ -224,15 +242,17 @@ node tools/right-tab-check.mjs # client:右栏 tab 三件套(sidebarRightTab
224
242
 
225
243
  - **已实测(2026-11-11)**:Linux 容器与本机之外的 Windows 宿主上原生跑通,右栏「+」菜单出现「我的电脑」,盘符、路径跳转、上传下载、改名删除、文本编辑全部可用。客户端注册失败时会把原因挂在 `window.__MYCOMP_ERR`,方便定位。
226
244
  - **`dsh.client.inject` 故意留空 `[]`**:`slots` 服务在任何客户端插件里都直接可用(同 profile 里工作的 `dsh-a2a-panel` 就是 `inject: []`),而写一个本机不存在的模块 id 会让整个 bundle 加载失败。
227
- - **不做整目录下载(zip)**:按要求 MVP 跳过,目录下载返回 422 并说明原因。
228
- - **上传没有进度条**:只有「上传中 n/总数」。要字节级进度得换 XMLHttpRequest 或流式上传端点。
245
+ - **整目录打包有限制**:超 20000 个条目或 3.9GB 会直接 413(不支持 Zip64,请分批下)。大文件是 STORE 不压缩(保速度与内存恒定),所以下大电影不会变小。
246
+ - **上传没有字节进度**:只有「上传中 n/总数」。要字节级百分比得换 XMLHttpRequest。
229
247
  - **删除不进回收站**:`fs.rm` 是硬删除,所以面板做了两步确认 + 后端要求 `confirm:true` + 拒绝删盘符根。
230
248
  - **大文件文本编辑**:`/read` 只给前 512KB。截断状态下保存会用编辑器里的内容覆盖整个文件——面板在截断时给了红字提示,但这是真实风险,编辑大文件请先下载。
231
249
  - **NTFS 权限/长路径/只读盘**:没有做特殊处理,出错就按 `PERMISSION_DENIED` / `IO_ERROR` 报给面板。超过 260 字符的路径依赖 Node 的长路径支持,未实测。
232
- - **`/move` 没有 UI**:路由实现了(剪切粘贴用),面板暂未接。要接就在工具栏加「粘贴」按钮,调 `POST /move`。
250
+ - **剪切板只在内存里**:刷新/关面板就没了(故意这样,避免手滑把文件粘到奇怪的地方)。跨目录批量移动请用「选中 → 剪切 → 粘贴」。
233
251
 
234
252
  ---
235
253
 
236
254
  ## 9. 版本
237
255
 
256
+ 0.2.0 —— 整目录打包下载(`GET /download-dir`,自研流式 zip,不落临时文件)+ 面板接上剪切/粘贴(走 `POST /move`)+ 客户端与宿主版本对不上时给提示而不是 404。
257
+
238
258
  0.1.0 —— 首个公开版本。11 条路由 + 右栏 tab 面板(盘符切换 / 面包屑 / 路径粘贴跳转 / 列表 / 多选删除 / 新建 / 上传 / 下载 / 改名 / 预览与文本编辑),Windows 原生,跨盘符、不受会话工作区限制。
package/lib/client.js CHANGED
@@ -35,6 +35,9 @@ try {
35
35
  var PANEL_ID = 'my-computer'
36
36
  // DSH 的精确 fetch 路由挂在 /api 共享通道下(鉴权在那一层做),所以真实前缀是 /api/my-computer
37
37
  var BASE = '/api/my-computer'
38
+ // 与 package.json 同步;宿主 /health 会报它自己的版本,两边不一致说明
39
+ // 「客户端已经更新、宿主还没重启」,这时不能放用户去点新路由(会 404)
40
+ var CLIENT_VERSION = '0.2.0'
38
41
  var LS_KEY = 'dsh-my-computer:last-path'
39
42
 
40
43
  /* ------------------------------------------------------------ *
@@ -193,9 +196,15 @@ try {
193
196
  return BASE + '/download?path=' + enc(p)
194
197
  }
195
198
 
196
- function triggerDownload(p) {
199
+ function downloadDirUrl(p) {
200
+ return BASE + '/download-dir?path=' + enc(p)
201
+ }
202
+
203
+ /* 用隐藏 <a> 触发浏览器原生下载:走 cookie 鉴权、直接落盘,
204
+ 不用 fetch+blob(手机上几百 MB 的包塞进内存会直接崩标签页)。 */
205
+ function triggerAnchor(url) {
197
206
  var anchor = document.createElement('a')
198
- anchor.href = downloadUrl(p)
207
+ anchor.href = url
199
208
  anchor.setAttribute('download', '')
200
209
  anchor.rel = 'noopener'
201
210
  document.body.appendChild(anchor)
@@ -205,6 +214,14 @@ try {
205
214
  }, 0)
206
215
  }
207
216
 
217
+ function triggerDownload(p) {
218
+ triggerAnchor(downloadUrl(p))
219
+ }
220
+
221
+ function triggerDirDownload(p) {
222
+ triggerAnchor(downloadDirUrl(p))
223
+ }
224
+
208
225
  /* ------------------------------------------------------------ *
209
226
  * 样式:跟随宿主主题变量(--dsw-alias-*),拿不到时退到明/暗两套兜底
210
227
  * ------------------------------------------------------------ */
@@ -370,6 +387,15 @@ try {
370
387
  var busy = _busy[0]
371
388
  var setBusy = _busy[1]
372
389
 
390
+ // 剪切板:{ paths: [...], label: '文件名' },只活在内存里(刷新就没,避免误粘)
391
+ var _clip = useState(null)
392
+ var clip = _clip[0]
393
+ var setClip = _clip[1]
394
+
395
+ var _hostVer = useState('')
396
+ var hostVersion = _hostVer[0]
397
+ var setHostVersion = _hostVer[1]
398
+
373
399
  var fileInputRef = useRef(null)
374
400
  var mountedRef = useRef(true)
375
401
 
@@ -418,6 +444,49 @@ try {
418
444
  })
419
445
  }, [])
420
446
 
447
+ function pasteClip() {
448
+ if (!clip || !clip.paths || clip.paths.length === 0) return
449
+ var target = cwd
450
+ if (!target) return
451
+ clearMessages()
452
+ setBusy('正在移动 ' + clip.paths.length + ' 项…')
453
+ apiPost('/move', { sources: clip.paths, targetDir: target })
454
+ .then(function (data) {
455
+ if (!mountedRef.current) return
456
+ var count = (data.moved || []).length
457
+ var skipped = (data.failed || []).length
458
+ setClip(null)
459
+ return navigate(target).then(function () {
460
+ if (!mountedRef.current) return
461
+ setNotice('已移动 ' + count + ' 项到 ' + target + (skipped ? '(' + skipped + ' 项失败)' : ''))
462
+ })
463
+ })
464
+ .catch(function (error) {
465
+ if (mountedRef.current) reportError(error)
466
+ })
467
+ .then(function () {
468
+ if (mountedRef.current) setBusy('')
469
+ })
470
+ }
471
+
472
+ function setClipFrom(paths, label) {
473
+ if (!paths || paths.length === 0) return
474
+ setClip({ paths: paths.slice(), label: label })
475
+ setSelected([])
476
+ clearMessages()
477
+ setNotice('已剪切:' + label + '。到目标目录后点「粘贴到此处」')
478
+ }
479
+
480
+ useEffect(function () {
481
+ apiGet('/health')
482
+ .then(function (data) {
483
+ if (mountedRef.current && data && data.version) setHostVersion(String(data.version))
484
+ })
485
+ .catch(function () {
486
+ /* 探不到就当未知,不做任何限制 */
487
+ })
488
+ }, [])
489
+
421
490
  var loadDrives = useCallback(function () {
422
491
  return apiGet('/drives')
423
492
  .then(function (data) {
@@ -729,6 +798,47 @@ try {
729
798
  '+ 新建文件夹',
730
799
  ),
731
800
  h('button', { type: 'button', className: 'mc-btn', onClick: pickFiles }, '⬆ 上传'),
801
+ selected.length > 0
802
+ ? h(
803
+ 'button',
804
+ {
805
+ type: 'button',
806
+ className: 'mc-btn',
807
+ title: '剪切选中项,去别的目录粘贴',
808
+ onClick: function () {
809
+ setClipFrom(selected, selected.length + ' 项')
810
+ },
811
+ },
812
+ '✂ 剪切 ' + selected.length + ' 项',
813
+ )
814
+ : null,
815
+ clip
816
+ ? h(
817
+ 'button',
818
+ {
819
+ type: 'button',
820
+ className: 'mc-btn solid',
821
+ title: '把剪切板里的内容移动到当前目录:' + cwd,
822
+ onClick: pasteClip,
823
+ },
824
+ '📋 粘贴「' + clip.label + '」到此处',
825
+ )
826
+ : null,
827
+ clip
828
+ ? h(
829
+ 'button',
830
+ {
831
+ type: 'button',
832
+ className: 'mc-btn',
833
+ title: '清空剪切板',
834
+ onClick: function () {
835
+ setClip(null)
836
+ clearMessages()
837
+ },
838
+ },
839
+ '取消剪切',
840
+ )
841
+ : null,
732
842
  h(
733
843
  'button',
734
844
  { type: 'button', className: 'mc-btn', onClick: toggleSelectAll, disabled: visibleEntries.length === 0 },
@@ -825,6 +935,39 @@ try {
825
935
  '⤓',
826
936
  )
827
937
  : null,
938
+ effective === 'dir' && !entry.statError
939
+ ? h(
940
+ 'button',
941
+ {
942
+ type: 'button',
943
+ className: 'mc-ibtn',
944
+ title: '打包下载这个目录(zip)',
945
+ onClick: function () {
946
+ if (hostVersion && hostVersion !== CLIENT_VERSION) {
947
+ clearMessages()
948
+ setNotice(
949
+ '宿主侧还是 v' + hostVersion + ',「打包下载」要重启 dsh web 后才生效(本面板 v' + CLIENT_VERSION + ')',
950
+ )
951
+ return
952
+ }
953
+ triggerDirDownload(entry.path)
954
+ },
955
+ },
956
+ '🗜',
957
+ )
958
+ : null,
959
+ h(
960
+ 'button',
961
+ {
962
+ type: 'button',
963
+ className: 'mc-ibtn',
964
+ title: '剪切(到别的目录粘贴)',
965
+ onClick: function () {
966
+ setClipFrom([entry.path], entry.name)
967
+ },
968
+ },
969
+ '✂',
970
+ ),
828
971
  h(
829
972
  'button',
830
973
  {
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import fsp from 'node:fs/promises'
23
23
  import path from 'node:path'
24
24
  import { Readable } from 'node:stream'
25
25
  import { pipeline } from 'node:stream/promises'
26
+ import { createZipArchive } from './zip.js'
26
27
 
27
28
  export const name = 'dsh-my-computer'
28
29
 
@@ -36,7 +37,7 @@ const DEFAULT_READ_BYTES = 512 * 1024
36
37
  const HARD_READ_CAP = 8 * 1024 * 1024
37
38
  const MAX_JSON_BODY = 8 * 1024 * 1024
38
39
 
39
- const VERSION = '0.1.0'
40
+ const VERSION = '0.2.0'
40
41
 
41
42
  /* ------------------------------------------------------------------ *
42
43
  * 错误与响应
@@ -535,7 +536,7 @@ async function handleDownload(request) {
535
536
  const target = requirePath(url.searchParams.get('path'))
536
537
  const st = await statOrFail(target)
537
538
  if (st.isDirectory()) {
538
- fail('DIRECTORY_NOT_SUPPORTED', '不支持整目录下载(MVP 不做 zip 打包),请进入目录逐个下载文件', 422)
539
+ fail('IS_DIRECTORY', '这是目录,单文件下载不支持;整目录请用 /download-dir 打包成 zip', 400)
539
540
  }
540
541
 
541
542
  const headers = {
@@ -549,6 +550,48 @@ async function handleDownload(request) {
549
550
  return new Response(Readable.toWeb(stream), { status: 200, headers })
550
551
  }
551
552
 
553
+ /**
554
+ * GET /download-dir?path= —— 整目录打包成 zip 流式下发(不落临时文件)。
555
+ *
556
+ * 发响应头之前已经把目录扫了一遍(条目数 / 总字节 / 可读性),所以「太大」
557
+ * 「空目录」这类问题会以 JSON 错误体正常返回,而不是下载到一半断掉。
558
+ * 中间读失败的文件会跳过,并把清单打在宿主日志里(压缩包本身仍然完整)。
559
+ */
560
+ async function handleDownloadDir(request) {
561
+ const url = new URL(request.url)
562
+ const target = requirePath(url.searchParams.get('path'))
563
+ const st = await statOrFail(target)
564
+ if (!st.isDirectory()) {
565
+ fail('NOT_DIRECTORY', `不是目录:${target}(单文件请用 /download)`, 400)
566
+ }
567
+ if (isDriveRoot(target)) {
568
+ fail('FORBIDDEN', '整盘打包请进入具体目录后再下载(盘符根通常几十万文件)', 403)
569
+ }
570
+
571
+ const warnings = []
572
+ const archive = await createZipArchive(target, {
573
+ fail,
574
+ onWarn: (info) => {
575
+ warnings.push(info)
576
+ if (warnings.length <= 20) {
577
+ console.warn(`[dsh-my-computer] zip 跳过 ${info.path}:${info.message}`)
578
+ }
579
+ },
580
+ })
581
+
582
+ const headers = {
583
+ 'content-type': 'application/zip',
584
+ 'content-disposition': contentDisposition(archive.filename),
585
+ 'cache-control': 'no-store',
586
+ 'x-content-type-options': 'nosniff',
587
+ // 面板拿来显示进度提示(非标准头,纯提示;跨站读不到也无所谓)
588
+ 'x-zip-entries': String(archive.entries),
589
+ 'x-zip-bytes': String(archive.bytes),
590
+ 'x-zip-skipped': String(warnings.length),
591
+ }
592
+ return new Response(Readable.toWeb(Readable.from(archive.parts)), { status: 200, headers })
593
+ }
594
+
552
595
  /** PUT /upload?path=<目录>&name=<文件名>[&overwrite=1] —— 原始二进制流式落盘。 */
553
596
  async function handleUpload(request) {
554
597
  const url = new URL(request.url)
@@ -736,6 +779,7 @@ const ROUTES = [
736
779
  { id: 'list', path: PREFIX + '/list', methods: ['GET'], requestBody: 'buffered', run: handleList },
737
780
  { id: 'read', path: PREFIX + '/read', methods: ['GET'], requestBody: 'buffered', run: handleRead },
738
781
  { id: 'download', path: PREFIX + '/download', methods: ['GET'], requestBody: 'buffered', run: handleDownload },
782
+ { id: 'downloadDir', path: PREFIX + '/download-dir', methods: ['GET'], requestBody: 'buffered', run: handleDownloadDir },
739
783
  { id: 'write', path: PREFIX + '/write', methods: ['POST'], requestBody: 'buffered', run: handleWrite },
740
784
  { id: 'upload', path: PREFIX + '/upload', methods: ['PUT'], requestBody: 'streaming', run: handleUpload },
741
785
  { id: 'mkdir', path: PREFIX + '/mkdir', methods: ['POST'], requestBody: 'buffered', run: handleMkdir },
package/lib/zip.js ADDED
@@ -0,0 +1,365 @@
1
+ // dsh-my-computer —— 整目录打包(零依赖、流式、边读边发)
2
+ //
3
+ // 为什么自己写:这个插件承诺零 npm 依赖(profile 里 pnpm prune 会干掉
4
+ // extraneous 包),为了一个 zip 引入 archiver / yazl 不划算,而 Node 自带
5
+ // 的 zlib.deflateRawSync 已经足够——zip 的「压缩」就是把 deflateRaw 的字节
6
+ // 塞进文件条目里。
7
+ //
8
+ // 策略(对手机远程下载友好):
9
+ // * 小文件(<= 8MB)先读进内存 → 试压 → 压不动的就 STORE,压得动的用 DEFLATE,
10
+ // 两种都能在写本地头之前知道大小和 CRC,不必用数据描述符;
11
+ // * 大文件 STORE + 流式,靠数据描述符(bit 3)补 CRC/大小,内存恒定;
12
+ // * 一律带 UTF-8 名字标志(bit 11),中文文件名解压不乱码;
13
+ // * 目录条目带尾部 '/',空目录也能还原;
14
+ // * 打包前先扫一遍做上限检查(条目数 / 总字节),超了在发响应头之前就报错,
15
+ // 避免「下载了一半才失败」。
16
+ //
17
+ // 不支持(明确报错,不静默降级):Zip64(>4GB 或 >65535 个条目)。真需要就分批。
18
+
19
+ import fs from 'node:fs'
20
+ import fsp from 'node:fs/promises'
21
+ import path from 'node:path'
22
+ import zlib from 'node:zlib'
23
+
24
+ const MAX_ENTRIES = 20000
25
+ const MAX_TOTAL_BYTES = 3.9 * 1000 * 1000 * 1000 // 留出 Zip64 阈值的余量
26
+ const INLINE_LIMIT = 8 * 1024 * 1024 // 进内存试压的上限
27
+
28
+ /** 已经是压缩格式的扩展名,再 deflate 只是白烧 CPU。 */
29
+ const INCOMPRESSIBLE = new Set([
30
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.heic', '.bmp', '.ico',
31
+ '.mp4', '.mov', '.mkv', '.avi', '.webm', '.mp3', '.m4a', '.aac', '.ogg', '.flac', '.wav',
32
+ '.zip', '.7z', '.rar', '.gz', '.bz2', '.xz', '.zst', '.tgz',
33
+ '.pdf', '.docx', '.xlsx', '.pptx', '.odt', '.epub',
34
+ '.apk', '.exe', '.dll', '.msi', '.iso', '.bin', '.pak',
35
+ '.db', '.sqlite', '.sqlite3', '.woff', '.woff2', '.ttf', '.otf',
36
+ ])
37
+
38
+ /* ------------------------------------------------------------------ *
39
+ * CRC32(zip 用的多项式,查表法)
40
+ * ------------------------------------------------------------------ */
41
+
42
+ const CRC_TABLE = (() => {
43
+ const table = new Int32Array(256)
44
+ for (let i = 0; i < 256; i += 1) {
45
+ let c = i
46
+ for (let k = 0; k < 8; k += 1) {
47
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
48
+ }
49
+ table[i] = c
50
+ }
51
+ return table
52
+ })()
53
+
54
+ function crc32(buffer) {
55
+ let crc = -1
56
+ for (let i = 0; i < buffer.length; i += 1) {
57
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ buffer[i]) & 0xff]
58
+ }
59
+ return (crc ^ -1) >>> 0
60
+ }
61
+
62
+ /* ------------------------------------------------------------------ *
63
+ * 时间戳 → DOS 时间
64
+ * ------------------------------------------------------------------ */
65
+
66
+ function dosDateTime(ms) {
67
+ const d = ms ? new Date(ms) : new Date()
68
+ const year = d.getFullYear()
69
+ if (!Number.isFinite(year) || year < 1980) return { date: 0x21, time: 0 }
70
+ const date = ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()
71
+ const time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)
72
+ return { date: date & 0xffff, time: time & 0xffff }
73
+ }
74
+
75
+ /* ------------------------------------------------------------------ *
76
+ * 扫描:先看清要打什么,才能在写响应头之前做上限判断
77
+ * ------------------------------------------------------------------ */
78
+
79
+ async function scanDir(rootDir, { fail, onWarn }) {
80
+ const items = []
81
+ let totalBytes = 0
82
+
83
+ async function walk(absDir, relDir) {
84
+ let dirents
85
+ try {
86
+ dirents = await fsp.readdir(absDir, { withFileTypes: true })
87
+ } catch (error) {
88
+ // 读不动的子目录不要整个打包跟着失败,跳过并记一笔
89
+ onWarn?.({ path: absDir, message: String(error?.message || error) })
90
+ return
91
+ }
92
+ dirents.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
93
+ for (const dirent of dirents) {
94
+ const abs = path.win32.join(absDir, dirent.name)
95
+ const rel = relDir ? relDir + '/' + dirent.name : dirent.name
96
+ const isLink = dirent.isSymbolicLink()
97
+ let st
98
+ try {
99
+ // 快捷方式/链接按「不跟随」处理:跟随可能绕成环,也可能跨到别的盘
100
+ st = isLink ? await fsp.lstat(abs) : await fsp.stat(abs)
101
+ } catch (error) {
102
+ onWarn?.({ path: abs, message: String(error?.message || error) })
103
+ continue
104
+ }
105
+ if (isLink) {
106
+ onWarn?.({ path: abs, message: '跳过符号链接/快捷方式(避免打包出环)' })
107
+ continue
108
+ }
109
+ if (st.isDirectory()) {
110
+ items.push({ kind: 'dir', abs, rel, size: 0, mtimeMs: st.mtimeMs })
111
+ if (items.length > MAX_ENTRIES) fail('ZIP_TOO_MANY_ENTRIES', `目录内条目超过 ${MAX_ENTRIES} 个,请分批下载`, 413)
112
+ await walk(abs, rel)
113
+ } else if (st.isFile()) {
114
+ totalBytes += st.size
115
+ items.push({ kind: 'file', abs, rel, size: st.size, mtimeMs: st.mtimeMs })
116
+ if (items.length > MAX_ENTRIES) fail('ZIP_TOO_MANY_ENTRIES', `目录内条目超过 ${MAX_ENTRIES} 个,请分批下载`, 413)
117
+ if (totalBytes > MAX_TOTAL_BYTES) {
118
+ fail('ZIP_TOO_LARGE', '目录内容超过 3.9GB(Zip64 未支持),请分批下载', 413)
119
+ }
120
+ } else {
121
+ onWarn?.({ path: abs, message: '跳过非常规文件(设备/管道等)' })
122
+ }
123
+ }
124
+ }
125
+
126
+ await walk(rootDir, '')
127
+ return { items, totalBytes }
128
+ }
129
+
130
+ /* ------------------------------------------------------------------ *
131
+ * 结构体小工具
132
+ * ------------------------------------------------------------------ */
133
+
134
+ function localHeader({ nameBuf, method, crc, compSize, size, dos, flags }) {
135
+ const head = Buffer.alloc(30)
136
+ head.writeUInt32LE(0x04034b50, 0)
137
+ head.writeUInt16LE(20, 4) // version needed
138
+ head.writeUInt16LE(flags, 6)
139
+ head.writeUInt16LE(method, 8)
140
+ head.writeUInt16LE(dos.time, 10)
141
+ head.writeUInt16LE(dos.date, 12)
142
+ head.writeUInt32LE(crc, 14)
143
+ head.writeUInt32LE(compSize, 18)
144
+ head.writeUInt32LE(size, 22)
145
+ head.writeUInt16LE(nameBuf.length, 26)
146
+ head.writeUInt16LE(0, 28) // extra
147
+ return Buffer.concat([head, nameBuf])
148
+ }
149
+
150
+ function dataDescriptor({ crc, compSize, size }) {
151
+ const buf = Buffer.alloc(16)
152
+ buf.writeUInt32LE(0x08074b50, 0)
153
+ buf.writeUInt32LE(crc, 4)
154
+ buf.writeUInt32LE(compSize, 8)
155
+ buf.writeUInt32LE(size, 12)
156
+ return buf
157
+ }
158
+
159
+ function centralHeader(entry) {
160
+ const head = Buffer.alloc(46)
161
+ head.writeUInt32LE(0x02014b50, 0)
162
+ head.writeUInt16LE(20, 4) // version made by
163
+ head.writeUInt16LE(20, 6) // version needed
164
+ head.writeUInt16LE(entry.flags, 8)
165
+ head.writeUInt16LE(entry.method, 10)
166
+ head.writeUInt16LE(entry.dos.time, 12)
167
+ head.writeUInt16LE(entry.dos.date, 14)
168
+ head.writeUInt32LE(entry.crc, 16)
169
+ head.writeUInt32LE(entry.compSize, 20)
170
+ head.writeUInt32LE(entry.size, 24)
171
+ head.writeUInt16LE(entry.nameBuf.length, 28)
172
+ head.writeUInt16LE(0, 30) // extra len
173
+ head.writeUInt16LE(0, 32) // comment len
174
+ head.writeUInt16LE(0, 34) // disk number start
175
+ head.writeUInt16LE(0, 36) // internal attrs
176
+ head.writeUInt32LE(entry.kind === 'dir' ? 0x10 : 0x20, 38) // external attrs
177
+ head.writeUInt32LE(entry.offset, 42)
178
+ return Buffer.concat([head, entry.nameBuf])
179
+ }
180
+
181
+ function endOfCentralDirectory({ count, cdSize, cdOffset }) {
182
+ const buf = Buffer.alloc(22)
183
+ buf.writeUInt32LE(0x06054b50, 0)
184
+ buf.writeUInt16LE(0, 4)
185
+ buf.writeUInt16LE(0, 6)
186
+ buf.writeUInt16LE(count, 8)
187
+ buf.writeUInt16LE(count, 10)
188
+ buf.writeUInt32LE(cdSize, 12)
189
+ buf.writeUInt32LE(cdOffset, 16)
190
+ buf.writeUInt16LE(0, 20)
191
+ return buf
192
+ }
193
+
194
+ const FLAG_UTF8 = 0x0800
195
+ const FLAG_DESCRIPTOR = 0x0008
196
+
197
+ async function* streamFile(abs, size) {
198
+ const stream = fs.createReadStream(abs, { highWaterMark: 256 * 1024 })
199
+ for await (const chunk of stream) {
200
+ yield chunk
201
+ }
202
+ if (size === 0) yield Buffer.alloc(0)
203
+ }
204
+
205
+ /* ------------------------------------------------------------------ *
206
+ * 主入口:返回 { parts, entries, bytes, skipped }
207
+ * parts —— 异步生成器,yield Buffer(可以直接 Readable.from 成响应体)
208
+ * ------------------------------------------------------------------ */
209
+
210
+ export async function createZipArchive(rootDir, options = {}) {
211
+ const { fail, onWarn } = options
212
+ const mustFail = fail || ((code, message, status) => {
213
+ const error = new Error(message)
214
+ error.code = code
215
+ error.status = status
216
+ throw error
217
+ })
218
+
219
+ const rootName = path.win32.basename(rootDir) || 'archive'
220
+ const { items, totalBytes } = await scanDir(rootDir, { fail: mustFail, onWarn })
221
+ if (items.length === 0) {
222
+ mustFail('ZIP_EMPTY', '这个目录是空的,没有可打包的内容', 400)
223
+ }
224
+ if (items.length >= 0xffff) {
225
+ mustFail('ZIP_TOO_MANY_ENTRIES', `条目数 ${items.length} 超过 65535(Zip64 未支持)`, 413)
226
+ }
227
+
228
+ async function* parts() {
229
+ let offset = 0
230
+ const central = []
231
+ const skipped = []
232
+
233
+ for (const item of items) {
234
+ const entryName = item.rel ? rootName + '/' + item.rel : rootName + '/'
235
+ const nameForZip = item.kind === 'dir' ? entryName.replace(/\/?$/, '/') : entryName
236
+ const nameBuf = Buffer.from(nameForZip, 'utf8')
237
+ const dos = dosDateTime(item.mtimeMs)
238
+ const entryOffset = offset
239
+
240
+ if (item.kind === 'dir') {
241
+ const header = localHeader({
242
+ nameBuf,
243
+ method: 0,
244
+ crc: 0,
245
+ compSize: 0,
246
+ size: 0,
247
+ dos,
248
+ flags: FLAG_UTF8,
249
+ })
250
+ yield header
251
+ offset += header.length
252
+ central.push({ kind: 'dir', nameBuf, method: 0, crc: 0, compSize: 0, size: 0, dos, flags: FLAG_UTF8, offset: entryOffset })
253
+ continue
254
+ }
255
+
256
+ let handled = false
257
+ if (item.size <= INLINE_LIMIT) {
258
+ let data
259
+ try {
260
+ data = await fsp.readFile(item.abs)
261
+ } catch (error) {
262
+ skipped.push({ path: item.abs, message: String(error?.message || error) })
263
+ onWarn?.({ path: item.abs, message: String(error?.message || error) })
264
+ continue
265
+ }
266
+ const crc = crc32(data)
267
+ let method = 0
268
+ let payload = data
269
+ const ext = path.win32.extname(item.abs).toLowerCase()
270
+ if (data.length > 0 && !INCOMPRESSIBLE.has(ext)) {
271
+ const deflated = zlib.deflateRawSync(data, { level: 6 })
272
+ if (deflated.length < data.length) {
273
+ method = 8
274
+ payload = deflated
275
+ }
276
+ }
277
+ const header = localHeader({
278
+ nameBuf,
279
+ method,
280
+ crc,
281
+ compSize: payload.length,
282
+ size: data.length,
283
+ dos,
284
+ flags: FLAG_UTF8,
285
+ })
286
+ yield header
287
+ if (payload.length > 0) yield payload
288
+ offset += header.length + payload.length
289
+ central.push({
290
+ kind: 'file',
291
+ nameBuf,
292
+ method,
293
+ crc,
294
+ compSize: payload.length,
295
+ size: data.length,
296
+ dos,
297
+ flags: FLAG_UTF8,
298
+ offset: entryOffset,
299
+ })
300
+ handled = true
301
+ }
302
+
303
+ if (!handled) {
304
+ // 大文件:STORE + 数据描述符,内存恒定
305
+ const flags = FLAG_UTF8 | FLAG_DESCRIPTOR
306
+ const header = localHeader({ nameBuf, method: 0, crc: 0, compSize: 0, size: 0, dos, flags })
307
+ yield header
308
+ offset += header.length
309
+ let crc = -1
310
+ let written = 0
311
+ try {
312
+ for await (const chunk of streamFile(item.abs, item.size)) {
313
+ if (chunk.length === 0) continue
314
+ for (let i = 0; i < chunk.length; i += 1) {
315
+ crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ chunk[i]) & 0xff]
316
+ }
317
+ written += chunk.length
318
+ offset += chunk.length
319
+ yield chunk
320
+ }
321
+ } catch (error) {
322
+ // 已经写出去一半的文件没法撤回,只能在中央目录里标成 0 长度并记一笔
323
+ skipped.push({ path: item.abs, message: String(error?.message || error) })
324
+ onWarn?.({ path: item.abs, message: String(error?.message || error) })
325
+ written = 0
326
+ crc = -1
327
+ }
328
+ const finalCrc = (crc ^ -1) >>> 0
329
+ const descriptor = dataDescriptor({ crc: finalCrc, compSize: written, size: written })
330
+ yield descriptor
331
+ offset += descriptor.length
332
+ central.push({
333
+ kind: 'file',
334
+ nameBuf,
335
+ method: 0,
336
+ crc: finalCrc,
337
+ compSize: written,
338
+ size: written,
339
+ dos,
340
+ flags,
341
+ offset: entryOffset,
342
+ })
343
+ }
344
+ }
345
+
346
+ const cdOffset = offset
347
+ let cdSize = 0
348
+ for (const entry of central) {
349
+ const header = centralHeader(entry)
350
+ yield header
351
+ cdSize += header.length
352
+ }
353
+ const eocd = endOfCentralDirectory({ count: central.length, cdSize, cdOffset })
354
+ yield eocd
355
+ }
356
+
357
+ return {
358
+ filename: rootName + '.zip',
359
+ entries: items.length,
360
+ bytes: totalBytes,
361
+ parts: parts(),
362
+ }
363
+ }
364
+
365
+ export const internals = { crc32, dosDateTime, MAX_ENTRIES, MAX_TOTAL_BYTES, INLINE_LIMIT }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-my-computer",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "DSH Web 右栏「我的电脑」:远程浏览与操作整台 Windows 磁盘(跨盘符、不受会话工作区限制)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -130,11 +130,17 @@ check('exports.inject 是数组', Array.isArray(mod.inject), JSON.stringify(mod.
130
130
  section('apply(ctx) 注册的 slots')
131
131
 
132
132
  const registrations = []
133
+ const tabRegistrations = []
133
134
  const fakeClientCtx = {
134
135
  effect(fn) {
135
136
  const dispose = fn()
136
137
  return typeof dispose === 'function' ? dispose : () => {}
137
138
  },
139
+ // 真实宿主里 ctx.inject(names, cb) 会把依赖就绪后的服务集合交给 cb。
140
+ // 这里直接把 ctx 自己给过去(slots / sidebarRightTabs 都在上面)。
141
+ inject(names, callback) {
142
+ return callback(fakeClientCtx)
143
+ },
138
144
  slots: {
139
145
  inject(name, callback) {
140
146
  return callback()
@@ -144,6 +150,12 @@ const fakeClientCtx = {
144
150
  return () => {}
145
151
  },
146
152
  },
153
+ sidebarRightTabs: {
154
+ register(spec) {
155
+ tabRegistrations.push(spec)
156
+ return () => {}
157
+ },
158
+ },
147
159
  }
148
160
 
149
161
  let applyError = null
@@ -153,7 +165,7 @@ try {
153
165
  applyError = error
154
166
  }
155
167
  check('apply(ctx) 没抛异常', applyError === null, applyError ? String(applyError) : '')
156
- check('注册了 2 个 slot', registrations.length === 2, registrations.map((r) => r.spec && r.spec.name).join(', '))
168
+ check('注册了 >=2 个 slot', registrations.length >= 2, registrations.map((r) => r.spec && r.spec.name).join(', '))
157
169
  check('注入了 <style>(样式跟随主题)', styleNodes.length === 1, styleNodes.length + ' 个 style 节点')
158
170
 
159
171
  const side = registrations.find((r) => r.spec && r.spec.name === 'sidebar.panellist')
@@ -173,6 +185,15 @@ check('有 main 注册', Boolean(main))
173
185
  check('main.key 与侧栏 id 一致', main && side && main.spec.key === side.spec.id, main ? String(main.spec.key) : '')
174
186
  check('面板是函数组件', main && typeof main.component === 'function')
175
187
 
188
+ /* 右栏 tab 三件套(sidebarRightTabs + pane.tab + pane.tab.title) */
189
+ const tab = tabRegistrations.find((spec) => spec && spec.id === 'my-computer')
190
+ const paneTab = registrations.find((r) => r.spec && r.spec.name === 'sidebar.right.pane.tab')
191
+ const paneTitle = registrations.find((r) => r.spec && r.spec.name === 'sidebar.right.pane.tab.title')
192
+ check('有 sidebarRightTabs 注册', Boolean(tab))
193
+ check('guide 非空(否则不进右栏「+」菜单)', Boolean(tab && Array.isArray(tab.guide) && tab.guide.length > 0))
194
+ check('右栏 pane.tab key 与 tab id 一致', Boolean(paneTab && tab && paneTab.spec.key === tab.id))
195
+ check('右栏 pane.tab.title 也注册了', Boolean(paneTitle && paneTitle.spec.key === 'my-computer'))
196
+
176
197
  /* ------------------------------------------------------------------ *
177
198
  * 手搭一个只够跑首屏的渲染器:真 React.createElement + 最小 hook dispatcher
178
199
  * ------------------------------------------------------------------ */
package/tools/smoke.mjs CHANGED
@@ -12,6 +12,7 @@
12
12
  import fs from 'node:fs'
13
13
  import fsp from 'node:fs/promises'
14
14
  import path from 'node:path'
15
+ import zlib from 'node:zlib'
15
16
  import { fileURLToPath } from 'node:url'
16
17
 
17
18
  const HERE = path.dirname(fileURLToPath(import.meta.url))
@@ -119,7 +120,7 @@ console.log(' 临时目录:' + TMP_DIR)
119
120
 
120
121
  section('路由注册')
121
122
 
122
- checkEqual('注册了 11 条路由', registered.length, 11)
123
+ checkEqual('注册了 12 条路由', registered.length, 12)
123
124
  for (const route of registered) {
124
125
  const name = route.path.slice(route.path.lastIndexOf('/') + 1)
125
126
  const bodyModeOk = route.requestBody === 'buffered' || route.requestBody === 'streaming'
@@ -342,8 +343,60 @@ check('Content-Disposition 是 attachment',
342
343
  check('Content-Type 有值', Boolean(dl.headers.get('content-type')), String(dl.headers.get('content-type')))
343
344
 
344
345
  const dlDir = await call('download', { query: q({ path: TMP_DIR }) })
345
- checkEqual('下载目录 → 422(MVP 不做 zip)', dlDir.status, 422)
346
- checkEqual('下载目录 → DIRECTORY_NOT_SUPPORTED', dlDir.data.error && dlDir.data.error.code, 'DIRECTORY_NOT_SUPPORTED')
346
+ checkEqual('用 /download 下目录 → 400(应改用 download-dir)', dlDir.status, 400)
347
+ checkEqual('错误码提示 IS_DIRECTORY', dlDir.data.error && dlDir.data.error.code, 'IS_DIRECTORY')
348
+
349
+ /* ---------------- GET /download-dir(整目录 zip) ---------------- */
350
+
351
+ section('GET /download-dir(zip 打包)')
352
+
353
+ const zipSrc = path.join(TMP_DIR, '打包测试')
354
+ await fsp.mkdir(path.join(zipSrc, '空目录'), { recursive: true })
355
+ await fsp.mkdir(path.join(zipSrc, '子目录'), { recursive: true })
356
+ const zipFileA = path.join(zipSrc, 'a.txt')
357
+ const zipFileB = path.join(zipSrc, '子目录', '中文名.md')
358
+ await fsp.writeFile(zipFileA, 'hello zip\n'.repeat(50), 'utf8')
359
+ await fsp.writeFile(zipFileB, '# 中文内容\n'.repeat(30), 'utf8')
360
+
361
+ const zipRes = await call('download-dir', { query: q({ path: zipSrc }), raw: true })
362
+ checkEqual('/download-dir 状态码 200', zipRes.status, 200)
363
+ checkEqual('Content-Type 是 application/zip', String(zipRes.headers.get('content-type')), 'application/zip')
364
+ check('Content-Disposition 是 <目录名>.zip',
365
+ String(zipRes.headers.get('content-disposition') || '').includes(encodeURIComponent('打包测试') + '.zip'),
366
+ String(zipRes.headers.get('content-disposition') || ''))
367
+ const zipBuf = Buffer.from(await zipRes.arrayBuffer())
368
+ check('ZIP 魔数正确', zipBuf.readUInt32LE(0) === 0x04034b50, zipBuf.length + ' 字节')
369
+
370
+ let zipEntries = []
371
+ let zipError = ''
372
+ try {
373
+ zipEntries = parseZip(zipBuf)
374
+ } catch (error) {
375
+ zipError = String(error && error.message)
376
+ }
377
+ const zipNames = zipEntries.map((entry) => entry.name).sort()
378
+ check('zip 能解析出条目', zipError === '', zipError || zipNames.join(' | '))
379
+ check('zip 含两个文件', zipNames.filter((name) => !name.endsWith('/')).length === 2, zipNames.join(' | '))
380
+ check('zip 保留中文文件名', zipNames.some((name) => name.includes('中文名.md')))
381
+ check('zip 保留空目录', zipNames.some((name) => name.endsWith('空目录/')), zipNames.join(' | '))
382
+
383
+ const entryA = zipEntries.find((entry) => entry.name.endsWith('a.txt'))
384
+ const entryB = zipEntries.find((entry) => entry.name.endsWith('中文名.md'))
385
+ check('a.txt 内容与磁盘一致', Boolean(entryA) && entryA.data.equals(await fsp.readFile(zipFileA)))
386
+ check('中文名.md 内容与磁盘一致', Boolean(entryB) && entryB.data.equals(await fsp.readFile(zipFileB)))
387
+ check('每个条目 CRC 都对',
388
+ zipEntries.every((entry) => crc32Of(entry.data) === entry.crc),
389
+ zipEntries.map((entry) => entry.name + ':' + (crc32Of(entry.data) === entry.crc ? 'ok' : 'BAD')).join(' '))
390
+
391
+ const zipEmpty = await call('download-dir', { query: q({ path: path.join(zipSrc, '空目录') }) })
392
+ checkEqual('打包空目录 → 400', zipEmpty.status, 400)
393
+ checkEqual('错误码 ZIP_EMPTY', zipEmpty.data.error && zipEmpty.data.error.code, 'ZIP_EMPTY')
394
+
395
+ const zipOnFile = await call('download-dir', { query: q({ path: zipFileA }) })
396
+ checkEqual('对文件用 download-dir → 400', zipOnFile.status, 400)
397
+
398
+ const zipMissing = await call('download-dir', { query: q({ path: path.join(TMP_DIR, '没有这个目录') }) })
399
+ checkEqual('打包不存在的目录 → 404', zipMissing.status, 404)
347
400
 
348
401
  section('POST /move')
349
402
 
@@ -395,3 +448,42 @@ if (passed !== total) {
395
448
  async function readByteLength(text) {
396
449
  return Buffer.byteLength(text, 'utf8')
397
450
  }
451
+
452
+ /** 只够自检用的 zip 读取:走中央目录,支持 STORE(0) 与 DEFLATE(8)。 */
453
+ function parseZip(buf) {
454
+ const eocd = buf.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]))
455
+ if (eocd < 0) throw new Error('找不到中央目录结尾(EOCD)')
456
+ const count = buf.readUInt16LE(eocd + 10)
457
+ let offset = buf.readUInt32LE(eocd + 16)
458
+ const out = []
459
+ for (let i = 0; i < count; i += 1) {
460
+ if (buf.readUInt32LE(offset) !== 0x02014b50) throw new Error('中央目录签名不对 @' + offset)
461
+ const flags = buf.readUInt16LE(offset + 8)
462
+ const method = buf.readUInt16LE(offset + 10)
463
+ const crc = buf.readUInt32LE(offset + 16)
464
+ const compSize = buf.readUInt32LE(offset + 20)
465
+ const size = buf.readUInt32LE(offset + 24)
466
+ const nameLen = buf.readUInt16LE(offset + 28)
467
+ const extraLen = buf.readUInt16LE(offset + 30)
468
+ const commentLen = buf.readUInt16LE(offset + 32)
469
+ const localOffset = buf.readUInt32LE(offset + 42)
470
+ const name = buf.slice(offset + 46, offset + 46 + nameLen).toString('utf8')
471
+ offset += 46 + nameLen + extraLen + commentLen
472
+ if (buf.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('本地头签名不对:' + name)
473
+ const localNameLen = buf.readUInt16LE(localOffset + 26)
474
+ const localExtraLen = buf.readUInt16LE(localOffset + 28)
475
+ const dataStart = localOffset + 30 + localNameLen + localExtraLen
476
+ const raw = buf.slice(dataStart, dataStart + compSize)
477
+ let data
478
+ if (method === 0) data = raw
479
+ else if (method === 8) data = zlib.inflateRawSync(raw)
480
+ else throw new Error('不支持的压缩方法 ' + method + ':' + name)
481
+ out.push({ name, flags, method, crc, compSize, size, data })
482
+ }
483
+ return out
484
+ }
485
+
486
+ function crc32Of(buffer) {
487
+ // Node 24 自带 zlib.crc32,省得在自检里再维护一张表
488
+ return zlib.crc32(buffer) >>> 0
489
+ }