dsh-my-computer 0.0.0-stage → 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/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,58 @@
1
1
  {
2
2
  "name": "dsh-my-computer",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "DSH Web 右栏「我的电脑」:远程浏览与操作整台 Windows 磁盘(跨盘符、不受会话工作区限制)",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./cordis.patch.yml": "./cordis.patch.yml",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "lib",
15
+ "tools",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "keywords": [
21
+ "deepseek-harness",
22
+ "dsh",
23
+ "dsh-plugin",
24
+ "file-manager",
25
+ "windows",
26
+ "remote"
27
+ ],
28
+ "author": "ymf2317",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/ymf2317-tech/dsh-my-computer.git"
33
+ },
34
+ "homepage": "https://github.com/ymf2317-tech/dsh-my-computer#readme",
35
+ "bugs": {
36
+ "url": "https://github.com/ymf2317-tech/dsh-my-computer/issues"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.5.0",
40
+ "dsh": ">=0.2.2-rc.1 <0.3.0-0"
41
+ },
42
+ "scripts": {
43
+ "smoke": "node tools/smoke.mjs"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "inject": []
55
+ },
56
+ "manifestVersion": 1
57
+ }
58
+ }