dsh-liuhen 0.1.1 → 0.1.2

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
@@ -63,7 +63,8 @@
63
63
  - 小盒子会连仓库一起打包,并把 `pet-memory/`(名字、承诺、跨会话汇总、交接信)带上;箱子里有一张 `00-读我.txt` 说明三层关系
64
64
  - **打的时候会脱敏**(B 方案):姓名保留,生日、学校、籍贯/住址在**包里那份副本**上换成 `[已隐去]`;磁盘原件一个字不改
65
65
  - 按日期补打:`exportBox({ date: 'YYYY-MM-DD' })` 只装那天的原文,索引截到那天
66
- - ⚠️ **这个包里有完整原文**(明文)。它会复制到手机的共享目录,别的 App 读得到;`pet-memory/` 里还有个人信息。存好,别随手转出去
66
+ - 打完往共享目录放一份:手机是 `/storage/emulated/0/DSHBox`,电脑(Windows / macOS)是下载文件夹
67
+ - ⚠️ **这个包里有完整原文**(明文)。落到共享目录后,同一台机器上别的程序也读得到;`pet-memory/` 里还有个人信息。存好,别随手转出去
67
68
 
68
69
  ## 隐私
69
70
 
@@ -83,6 +84,38 @@
83
84
  dsh plugin --profile web add dsh-liuhen
84
85
  ```
85
86
 
87
+ ## 电脑上也能用(0.1.2 起)
88
+
89
+ 同一份插件,Windows / macOS / 手机都跑,不用装 7-Zip 之类的东西:
90
+
91
+ - **打包不再调 `zip` 命令**:自己拼 zip 容器(`lib/zip.js`,只用 Node 自带的 `zlib`)。
92
+ 0.1.1 在电脑上会报 `打包失败:spawnSync zip ENOENT`,根就在这儿。
93
+ - **面板「打开」不再调 `unzip -l`**:直接读 zip 的中央目录,中文文件名按 UTF-8 原样读回来。
94
+ - **共享目录按平台走**(`lib/platform.js`):手机 → `/storage/emulated/0/DSHBox`;
95
+ Windows → `%USERPROFILE%\Downloads`;macOS → `~/Downloads`。面板文案也跟着换,
96
+ 电脑上不会再写「存到手机」。
97
+ - 打包是**可复现**的:同一天同样的数据,打两次 `sha256` 一样(目录条目固定时间戳,
98
+ 脱敏后把 mtime 拨回去)——面板上那个 sha 才有核对的意义。
99
+
100
+ **在电脑上验一遍**(`lib/*.js` 改了必须重启一次 DSH,面板的 HMR 管不到服务端):
101
+
102
+ ```bash
103
+ # 1. 重启 DSH 后,打开面板 → 小盒子 → 「打一份小盒子」
104
+ # 2. 没有红灯、出现「已放进下载文件夹:C:\Users\<你>\Downloads\留痕-YYYY-MM-DD.zip」即通过
105
+ # 3. 解压那个 zip,看中文名是否正常、00-读我.txt 第 20 行左右有没有「去向:」
106
+ # 4. 自检(可选,Windows 上也能跑):node scripts/selfcheck.js → 82/82
107
+ ```
108
+
109
+ ## 0.1.2(小更新,不是第二版)
110
+
111
+ - **电脑分支**:打包与列清单不再依赖系统的 `zip` / `unzip` 命令(Windows 上两者都没有),
112
+ 改成 `lib/zip.js` 自己读写;`lib/platform.js` 管共享目录与文案
113
+ - 打包结果**可复现**:同一天同样的数据两次打包 sha256 一致
114
+ - 包里补回**目录条目**(含空目录),解出来的结构跟打进去的一致
115
+ - `/summary` 多带一个 `where` 字段(`os` / `kind` / `dir` / `label` / `word`),
116
+ 面板据此说「手机」还是「下载文件夹」
117
+ - 自检 76 → 82 条:新增跨平台 4 条(手机路径、电脑路径、面板文案、zip 中文名往返)
118
+
86
119
  ## 0.1.1(小更新,不是第二版)
87
120
 
88
121
  - 面板每 30 秒自动刷新,不用关掉重开
package/client.js CHANGED
@@ -108,6 +108,15 @@
108
108
  }).sort((a, b) => (a.first.t < b.first.t ? -1 : 1))
109
109
  }
110
110
 
111
+ /*
112
+ * 这台机器把包往哪放:手机上是「手机的 DSHBox」,电脑上是「下载文件夹」。
113
+ * 文案从后端来(status.where):面板跑在 WebView 里,读不到 process.platform,
114
+ * 写死就会在电脑上写着「存到手机」。取不到时退成中性词。
115
+ */
116
+ function spotWord(d) {
117
+ return (d && d.where && d.where.word) || '共享目录'
118
+ }
119
+
111
120
  function pairRows(rows, options) {
112
121
  const desc = Boolean(options && options.desc)
113
122
  const sessions = buildSessions(rows)
@@ -511,12 +520,12 @@
511
520
  Card('装着我的小盒子',
512
521
  h('div', { style: S.muted }, '把留痕的数据打成一个 zip:索引摘要、完整原文(仓库)、日记、自省、承诺,加上 pet-memory(我是谁、答应过的事、跨会话汇总、交接信)。换机器就靠它。箱子里有 00-读我.txt 说明三层怎么对应。'),
513
522
  h('div', { style: { ...S.muted, marginTop: '6px', color: '#d9534f' } },
514
- '这个包里有完整原文:你说过的每句话都在里面,明文的;pet-memory 里还有个人信息(名字、生日、学校)。放进手机 DSHBox 后,别的 App 也读得到——存好,别再转出去。'),
523
+ `这个包里有完整原文:你说过的每句话都在里面,明文的;pet-memory 里还有个人信息(名字、生日、学校)。落到${spotWord(d)}之后,同一台机器上别的程序也读得到——存好,别再转出去。`),
515
524
  d.box
516
525
  ? h('div', { style: { marginTop: '10px', padding: '10px', borderRadius: '8px', background: 'rgba(127,127,127,.07)' } },
517
526
  h('div', { style: { fontSize: '12.5px', marginBottom: '4px', wordBreak: 'break-all' } }, `${d.box.name} · ${(d.box.bytes / 1024).toFixed(1)} KB`),
518
527
  h('div', { style: { ...S.muted, marginBottom: '8px', wordBreak: 'break-all' } },
519
- d.box.shared ? `已放进手机的 DSHBox 文件夹:${d.box.shared}(用「文件」app 找得到)` : '还没同步到手机共享目录,可用下面的「保存」取走。'),
528
+ d.box.shared ? `已放进${spotWord(d)}:${d.box.shared}(用系统自带的文件管理器找得到)` : `还没同步到${spotWord(d)},可用下面的「保存」取走。`),
520
529
  h('div', { style: S.row },
521
530
  h('button', { type: 'button', style: S.btn, disabled: boxList.loading, onClick: () => {
522
531
  if (boxList.count > 0 && !boxList.loading) { setBoxList({ loading: false, entries: [], count: 0, error: '' }); return }
@@ -529,7 +538,7 @@
529
538
  h('a', {
530
539
  href: `${PREFIX}/box/file`,
531
540
  style: { ...S.btn, display: 'inline-block', textDecoration: 'none', color: 'inherit' }
532
- }, '存到手机'),
541
+ }, d.where && d.where.kind === 'desktop' ? '存到下载文件夹' : '存到手机'),
533
542
  h('button', { type: 'button', style: S.btn, disabled: boxSend.busy, onClick: () => {
534
543
  setBoxSend({ busy: true, msg: '', error: '' })
535
544
  fetch(`${PREFIX}/box/send`, { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: '{}' })
package/lib/box.js CHANGED
@@ -5,10 +5,11 @@
5
5
  const fs = require('node:fs')
6
6
  const path = require('node:path')
7
7
  const crypto = require('node:crypto')
8
- const { execFileSync } = require('node:child_process')
9
8
 
10
9
  const { paths, dayKey } = require('./paths')
11
10
  const { redact } = require('./redact')
11
+ const { writeZip } = require('./zip')
12
+ const platform = require('./platform')
12
13
 
13
14
  /** 永远不打包出去的东西:用户画像、备份、半成品。 */
14
15
  const EXCLUDES = [
@@ -73,7 +74,14 @@ function sanitizeTree(root) {
73
74
  if (stat.size > 8 * 1024 * 1024) continue
74
75
  const before = fs.readFileSync(full, 'utf8')
75
76
  const after = sanitizeText(before)
76
- if (after !== before) fs.writeFileSync(full, after, { mode: 0o600 })
77
+ if (after !== before) {
78
+ fs.writeFileSync(full, after, { mode: 0o600 })
79
+ /*
80
+ * 改完把 mtime 拨回去:脱敏是"打这份副本"的动作,不是"这份数据变了"。
81
+ * 不拨回去的话,每打一次包 mtime 都变成现在,sha256 跟着漂。
82
+ */
83
+ try { fs.utimesSync(full, stat.atime, stat.mtime) } catch {}
84
+ }
77
85
  } catch {}
78
86
  }
79
87
  }
@@ -81,29 +89,71 @@ function sanitizeTree(root) {
81
89
  }
82
90
 
83
91
  /*
84
- * proot 把手机的 /storage/emulated/0/DSHBox 挂在 /root/share/DSHBox。
85
- * 打包后往那儿放一份,用户用「文件」app 就能找到,不用猜浏览器存到哪了。
92
+ * 打包后往共享目录放一份,人用系统自带的「文件 / 资源管理器」就能找到,不用猜浏览器存到哪了。
93
+ * 具体是哪个目录交给 platform.js:手机是 proot 挂的 DSHBox,电脑是 Downloads。
86
94
  */
87
95
  /* 打开箱子第一眼该看到的东西:三层怎么对应、先读哪份。 */
88
96
  const MANIFEST = '00-读我.txt'
89
97
 
90
- const SHARED_DIR = '/root/share/DSHBox'
91
- const SHARED_LABEL = '/storage/emulated/0/DSHBox'
92
-
93
98
  function publishCopy(src, name, allowed) {
94
99
  if (allowed === false) return null
95
100
  try {
96
- if (!fs.existsSync(SHARED_DIR)) return null
97
- fs.copyFileSync(src, path.join(SHARED_DIR, name))
98
- return `${SHARED_LABEL}/${name}`
101
+ const dir = platform.sharedDir()
102
+ if (!fs.existsSync(dir)) return null
103
+ fs.copyFileSync(src, path.join(dir, name))
104
+ return `${platform.sharedLabel()}/${name}`
99
105
  } catch { return null }
100
106
  }
101
107
 
108
+ /**
109
+ * 把沙箱里的文件收成 zip 条目(路径相对沙箱,统一 / 分隔)。
110
+ *
111
+ * 按名字排序:同样的数据打两次,包内顺序一致,sha256 才对得上——
112
+ * 面板上那个 sha 是给人核对「这份和上次那份是不是同一份」用的,乱序就没意义了。
113
+ */
114
+ function collectEntries(root) {
115
+ const out = []
116
+ const walk = (dir, prefix) => {
117
+ let items = []
118
+ try { items = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
119
+ for (const item of items) {
120
+ const full = path.join(dir, item.name)
121
+ const rel = prefix === '' ? item.name : `${prefix}/${item.name}`
122
+ if (item.isDirectory()) {
123
+ /*
124
+ * 空目录也要进包:以前 `zip -qr` 会给每个目录写一条(名字以 / 结尾)。
125
+ * 不写的话,像 pet-memory/自省/ 这种暂时还空着的目录,解出来就没了,
126
+ * 而「解出来的样子」应该和「打进去的样子」一样。
127
+ *
128
+ * 时间戳钉成 1980-01-01:目录 mtime 会随着往里面拷文件而变,
129
+ * 带着它走 sha256 就会漂;目录的时间戳本来也没人看。
130
+ */
131
+ out.push({ name: `${rel}/`, data: Buffer.alloc(0), dir: true, mtime: new Date(0) })
132
+ walk(full, rel)
133
+ continue
134
+ }
135
+ try {
136
+ out.push({ name: rel, data: fs.readFileSync(full), mtime: fs.statSync(full).mtime })
137
+ } catch {}
138
+ }
139
+ }
140
+ walk(root, '')
141
+ out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
142
+ return out
143
+ }
144
+
102
145
  /**
103
146
  * 箱子里那张纸条:谁打开这个 zip,一眼就知道里面有什么、隐私怎么处理过。
104
147
  * 写在沙箱里,随包打进去,打完跟着沙箱一起删。
148
+ *
149
+ * where 是 platform.describe() 的结果——「放进手机 DSHBox 后」这句话在电脑上说不通,
150
+ * 而这张纸条是要跟着包走的,说不通就等于没写。
105
151
  */
106
- function manifestBody(rebuildDate) {
152
+ function manifestBody(rebuildDate, where) {
153
+ const spot = where || platform.describe()
154
+ const placed = spot.exists
155
+ ? `打完顺手放了一份在${spot.word}:${spot.label}`
156
+ : `打完没找到${spot.word},包留在容器里,用面板上的「保存」取走。`
107
157
  return [
108
158
  ...(rebuildDate ? [`补打:按 ${rebuildDate} 那天重打(原文只含那天,索引截到那天)。`, ''] : []),
109
159
  '留痕 · 小盒子 —— 一个"我"搬家用的箱子',
@@ -125,11 +175,13 @@ function manifestBody(rebuildDate) {
125
175
  '',
126
176
  '没有 liuhen/private/(用户画像),也没有备份文件。',
127
177
  '',
178
+ `去向:${placed}`,
179
+ '',
128
180
  '隐私处理(2026-10-26 定的 B 方案):',
129
181
  ' 姓名保留;生日、学校、籍贯/住址已隐去(写成「[已隐去]」)。',
130
182
  ' 只改了包里这份副本,磁盘上的原件一个字没动。',
131
183
  '',
132
- '⚠️ 这个包里是明文。放进手机共享目录后,别的 App 也读得到——存好,别再转出去。',
184
+ `⚠️ 这个包里是明文。放进${spot.word}之后,同一台机器上别的程序也读得到——存好,别再转出去。`,
133
185
  ''
134
186
  ].join('\n')
135
187
  }
@@ -176,6 +228,11 @@ function exportBox(options = {}) {
176
228
  for (const name of present) {
177
229
  fs.cpSync(path.join(paths.home, name), path.join(stage, name), {
178
230
  recursive: true,
231
+ /*
232
+ * 保住源文件的 mtime:zip 条目要写时间戳,不保的话每次拷贝都盖上"现在",
233
+ * 同一份数据打两次 sha256 就不一样了。
234
+ */
235
+ preserveTimestamps: true,
179
236
  filter: (src) => {
180
237
  const base = path.basename(src)
181
238
  if (name === 'liuhen' && base === 'private') return false
@@ -208,9 +265,25 @@ function exportBox(options = {}) {
208
265
  for (const name of present) sanitizeTree(path.join(stage, name))
209
266
 
210
267
  /* 纸条也放进沙箱,压完随沙箱一起删。 */
211
- fs.writeFileSync(path.join(stage, MANIFEST), manifestBody(rebuildDate), { mode: 0o600 })
212
- const args = ['-qr', out, ...present, MANIFEST]
213
- execFileSync('zip', args, { cwd: stage })
268
+ const where = platform.describe()
269
+ const manifestFile = path.join(stage, MANIFEST)
270
+ fs.writeFileSync(manifestFile, manifestBody(rebuildDate, where), { mode: 0o600 })
271
+ /*
272
+ * 纸条的时间戳钉在当天零点:它是每次现写的,要是按"现在"记,
273
+ * 同一份数据隔一分钟打两次,sha256 就变了——那个 sha 是给人核对
274
+ * 「这份和我早上那份是不是同一份」用的,会漂就没意义。
275
+ * 其余条目的 mtime 来自源文件,本来就不变。
276
+ */
277
+ try {
278
+ const stamp = new Date(`${rebuildDate || dayKey()}T00:00:00`)
279
+ fs.utimesSync(manifestFile, stamp, stamp)
280
+ } catch {}
281
+ /*
282
+ * 自己拼 zip(lib/zip.js):Windows 上没有 zip 命令,spawn 直接 ENOENT——
283
+ * 这就是 0.1.1 在电脑上「打包失败:spawnSync zip ENOENT」的根。
284
+ * 以前 `zip -qr` 顺带做的递归收集,现在 collectEntries 干。
285
+ */
286
+ writeZip(out, collectEntries(stage))
214
287
  } catch (error) {
215
288
  return { ok: false, text: `打包失败:${error && error.message ? error.message : error}` }
216
289
  } finally {
package/lib/index.js CHANGED
@@ -17,6 +17,8 @@ const { backfill, readIndex, messagesFor, readOriginal, remarkSensitive } = requ
17
17
  const { diaryStatus, readDiary } = require('./diary')
18
18
  const { readNotes, readPromises } = require('./notes')
19
19
  const { exportBox } = require('./box')
20
+ const platform = require('./platform')
21
+ const { listZip } = require('./zip')
20
22
  const { archiveOld, retentionStats } = require('./retention')
21
23
  const vault = require('./vault')
22
24
  const { isDate } = require('./rows')
@@ -183,13 +185,21 @@ exports.apply = function apply(ctx) {
183
185
  /* 感受之外还有犹豫、权衡、没说出口的:一起数,面板上分开看。 */
184
186
  feels: vault.markCounts(today),
185
187
  promises: readPromises(),
188
+ /*
189
+ * 这台机器是什么系统、包该往哪放、该怎么说。
190
+ * 面板跑在浏览器里,读不到 process.platform——不从这里带过去,
191
+ * 电脑上就会写着「存到手机」。
192
+ */
193
+ where: platform.describe(),
186
194
  box: fs.existsSync(boxFile)
187
195
  ? {
188
196
  name: path.basename(boxFile),
189
197
  bytes: fs.statSync(boxFile).size,
190
- shared: fs.existsSync(path.join('/root/share/DSHBox', path.basename(boxFile)))
191
- ? `/storage/emulated/0/DSHBox/${path.basename(boxFile)}`
192
- : null
198
+ shared: (() => {
199
+ const dir = platform.sharedDir()
200
+ const base = path.basename(boxFile)
201
+ return fs.existsSync(path.join(dir, base)) ? `${platform.sharedLabel(dir)}/${base}` : null
202
+ })()
193
203
  }
194
204
  : null
195
205
  })
@@ -288,12 +298,12 @@ exports.apply = function apply(ctx) {
288
298
  const file = path.join(paths.home, `留痕-${dayKey()}.zip`)
289
299
  if (!fs.existsSync(file)) return json(404, { ok: false, error: '还没有小盒子,先打一份' })
290
300
  try {
291
- const out = require('node:child_process').execFileSync('unzip', ['-l', file], { encoding: 'utf8' })
292
- const entries = []
293
- for (const line of out.split('\n')) {
294
- const m = /^\s*(\d+)\s+(\d{4}-\d{2}-\d{2}|\S+)\s+(\S+)\s+(.+)$/.exec(line)
295
- if (m) entries.push({ bytes: Number(m[1]), time: m[2], name: String(m[4]).trim() })
296
- }
301
+ /*
302
+ * 以前是 `unzip -l` 解文本再正则抠——Windows 上一样没有 unzip,
303
+ * 「打开」按钮跟着挂。现在直接读 zip 的中央目录(lib/zip.js),
304
+ * 不依赖机器上装了什么,而且名字是 UTF-8 原样,不怕空格和中文。
305
+ */
306
+ const entries = listZip(file).map((e) => ({ bytes: e.bytes, time: e.time, name: e.name }))
297
307
  return json(200, { ok: true, name: path.basename(file), count: entries.length, entries: entries.slice(0, 300) })
298
308
  } catch (error) {
299
309
  return json(500, { ok: false, error: String(error && error.message ? error.message : error) })
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * 平台分支:只回答两件事——打好包往哪放、让人怎么找到它。
5
+ *
6
+ * 为什么单开一个文件(2026-10-27 主人定的 A 方案):
7
+ * 手机(proot 里的 Android)和电脑(Windows / macOS)跑的是同一份插件,
8
+ * 差别只在这两件事上。要是把 `if (isAndroid)` 撒进 box.js 和 index.js,
9
+ * 加第三个平台就得改十处,还会像 190 行那段一样抄出第二份映射。
10
+ *
11
+ * 每个函数都收一个可选的 os 参数(默认 process.platform):
12
+ * 自检要靠它模拟 win32,不然"电脑分支"在手机上永远验不到——那才是白加。
13
+ */
14
+
15
+ const path = require('node:path')
16
+ const fs = require('node:fs')
17
+
18
+ /* 手机那条路的固定坐标:proot 把 /storage/emulated/0/DSHBox 挂在 /root/share/DSHBox。 */
19
+ const ANDROID_SHARED_DIR = '/root/share/DSHBox'
20
+ const ANDROID_SHARED_LABEL = '/storage/emulated/0/DSHBox'
21
+
22
+ function osName(os) {
23
+ return String(os || process.platform)
24
+ }
25
+
26
+ /** 用户主目录:Windows 上没有 HOME,只有 USERPROFILE。 */
27
+ function homeDir() {
28
+ return process.env.USERPROFILE || process.env.HOME || '/root'
29
+ }
30
+
31
+ /**
32
+ * 打包好的 zip 复制到哪:系统的「文件/资源管理器」里一眼能找到的地方。
33
+ * 手机是 DSHBox(用户自己建的共享目录),电脑是 Downloads。
34
+ */
35
+ function sharedDir(os) {
36
+ const name = osName(os)
37
+ if (name === 'win32') return path.join(homeDir(), 'Downloads')
38
+ if (name === 'darwin') return path.join(homeDir(), 'Downloads')
39
+ return ANDROID_SHARED_DIR
40
+ }
41
+
42
+ /** 给人看的那个路径:对外说「去这儿找」,不能把 proot 里的 /root/share 说给电脑用户听。 */
43
+ function sharedLabel(os, dir) {
44
+ const name = osName(os)
45
+ if (name === 'win32' || name === 'darwin') return dir || sharedDir(os)
46
+ return ANDROID_SHARED_LABEL
47
+ }
48
+
49
+ /** 这个平台上到底有没有那个共享目录(电脑上可能没有 Downloads 这个叫法的目录)。 */
50
+ function sharedExists(os) {
51
+ try {
52
+ return fs.existsSync(sharedDir(os))
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 面板上该怎么说这个包去哪了。
60
+ * 手机:「能放进手机的 DSHBox」;电脑:「下载文件夹」。一句话,两边都读得懂。
61
+ */
62
+ function describe(os) {
63
+ const name = osName(os)
64
+ if (name === 'win32' || name === 'darwin') {
65
+ return { os: name, kind: 'desktop', dir: sharedDir(os), label: sharedLabel(os), exists: sharedExists(os), word: '下载文件夹' }
66
+ }
67
+ return { os: name, kind: 'phone', dir: sharedDir(os), label: sharedLabel(os), exists: sharedExists(os), word: '手机的 DSHBox' }
68
+ }
69
+
70
+ module.exports = {
71
+ ANDROID_SHARED_DIR,
72
+ ANDROID_SHARED_LABEL,
73
+ osName,
74
+ homeDir,
75
+ sharedDir,
76
+ sharedLabel,
77
+ sharedExists,
78
+ describe
79
+ }
package/lib/zip.js ADDED
@@ -0,0 +1,234 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * 最小 ZIP:自己写、自己读。零外部命令、零 npm 依赖。
5
+ *
6
+ * 为什么不用 zip / unzip 命令(2026-10-27 踩到的):
7
+ * Windows 上既没有 zip 也没有 unzip——打包直接 `spawnSync zip ENOENT`,
8
+ * 面板的「打开」也跟着 500。而 Node 自带 zlib,拼一个 ZIP 容器是几十行的事,
9
+ * 比让用户在每台机器上装 7-Zip 靠谱。
10
+ *
11
+ * 只实现两边用得到的:
12
+ * 写:deflate(method 8);压不动就退回 store(method 0)——小文件压缩反而更大
13
+ * 读:中央目录(列清单)+ 单个条目(自检要把 00-读我.txt 读出来对照)
14
+ * 不做:目录条目(解压器会按路径自己建)、注释、ZIP64(4GB 以上不认,留痕的包几 MB)。
15
+ *
16
+ * 中文文件名必须打上 UTF-8 标志位(bit 11),否则 Windows 资源管理器按 CP437 解,
17
+ * 文件名全成乱码——「留痕-2026-10-27.zip」解出来会是一串问号。
18
+ */
19
+
20
+ const fs = require('node:fs')
21
+ const zlib = require('node:zlib')
22
+
23
+ const SIG_LOCAL = 0x04034b50
24
+ const SIG_CENTRAL = 0x02014b50
25
+ const SIG_END = 0x06054b50
26
+ const UTF8_FLAG = 0x0800
27
+ /* 解压后应有的权限:文件 600、目录 700。版本号高字节 3 = unix,低字节 20 = zip 2.0。 */
28
+ const MADE_BY_UNIX = 0x0314
29
+ const FILE_ATTRS = (0o100600 << 16) >>> 0
30
+ const DIR_ATTRS = (0o040700 << 16) >>> 0
31
+
32
+ /** CRC32 查表,建一次用到底。 */
33
+ const CRC_TABLE = (() => {
34
+ const table = new Int32Array(256)
35
+ for (let i = 0; i < 256; i += 1) {
36
+ let c = i
37
+ for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
38
+ table[i] = c
39
+ }
40
+ return table
41
+ })()
42
+
43
+ function crc32(buf) {
44
+ let c = -1
45
+ for (let i = 0; i < buf.length; i += 1) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
46
+ return (c ^ -1) >>> 0
47
+ }
48
+
49
+ /** MS-DOS 时间戳:1980 起算,秒按 2 秒一格。 */
50
+ function dosStamp(at) {
51
+ const d = at instanceof Date && !Number.isNaN(at.getTime()) ? at : new Date()
52
+ const year = Math.max(1980, d.getFullYear())
53
+ return {
54
+ date: ((year - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate(),
55
+ time: (d.getHours() << 11) | (d.getMinutes() << 5) | Math.floor(d.getSeconds() / 2)
56
+ }
57
+ }
58
+
59
+ function dosToText(date, time) {
60
+ const year = 1980 + ((date >> 9) & 0x7f)
61
+ const month = String((date >> 5) & 0x0f).padStart(2, '0')
62
+ const day = String(date & 0x1f).padStart(2, '0')
63
+ const hour = String((time >> 11) & 0x1f).padStart(2, '0')
64
+ const minute = String((time >> 5) & 0x3f).padStart(2, '0')
65
+ return `${year}-${month}-${day} ${hour}:${minute}`
66
+ }
67
+
68
+ /**
69
+ * 写一个 zip。
70
+ *
71
+ * @param {string} outPath 落盘位置(已存在的会被覆盖)
72
+ * @param {Array<{name: string, data: Buffer|string, mtime?: Date}>} entries
73
+ * @returns {{entries: number, bytes: number, ratio: number}} ratio = 压缩后 / 原始
74
+ */
75
+ function writeZip(outPath, entries) {
76
+ const fd = fs.openSync(outPath, 'w', 0o600)
77
+ const central = []
78
+ let offset = 0
79
+ let rawTotal = 0
80
+ try {
81
+ for (const entry of entries) {
82
+ /* 统一用 / 分隔:zip 规范只认这个,Windows 的 \ 解出来会变成一个长文件名。 */
83
+ const name = Buffer.from(String(entry.name).replace(/\\/g, '/'), 'utf8')
84
+ const raw = Buffer.isBuffer(entry.data)
85
+ ? entry.data
86
+ : Buffer.from(String(entry.data === undefined ? '' : entry.data), 'utf8')
87
+ const crc = crc32(raw)
88
+ let method = 0
89
+ let body = raw
90
+ try {
91
+ const packed = zlib.deflateRawSync(raw, { level: 9 })
92
+ if (packed.length < raw.length) {
93
+ method = 8
94
+ body = packed
95
+ }
96
+ } catch {
97
+ /* 压不动就用原文,不影响正确性 */
98
+ }
99
+ const stamp = dosStamp(entry.mtime)
100
+
101
+ const local = Buffer.alloc(30)
102
+ local.writeUInt32LE(SIG_LOCAL, 0)
103
+ local.writeUInt16LE(20, 4)
104
+ local.writeUInt16LE(UTF8_FLAG, 6)
105
+ local.writeUInt16LE(method, 8)
106
+ local.writeUInt16LE(stamp.time, 10)
107
+ local.writeUInt16LE(stamp.date, 12)
108
+ local.writeUInt32LE(crc, 14)
109
+ local.writeUInt32LE(body.length, 18)
110
+ local.writeUInt32LE(raw.length, 22)
111
+ local.writeUInt16LE(name.length, 26)
112
+ local.writeUInt16LE(0, 28)
113
+ fs.writeSync(fd, local)
114
+ fs.writeSync(fd, name)
115
+ fs.writeSync(fd, body)
116
+
117
+ central.push({ name, crc, method, body: body.length, raw: raw.length, dir: entry.dir === true, ...stamp, offset })
118
+ rawTotal += raw.length
119
+ offset += 30 + name.length + body.length
120
+ }
121
+
122
+ const cdStart = offset
123
+ let cdSize = 0
124
+ for (const c of central) {
125
+ const head = Buffer.alloc(46)
126
+ head.writeUInt32LE(SIG_CENTRAL, 0)
127
+ head.writeUInt16LE(MADE_BY_UNIX, 4)
128
+ head.writeUInt16LE(20, 6)
129
+ head.writeUInt16LE(UTF8_FLAG, 8)
130
+ head.writeUInt16LE(c.method, 10)
131
+ head.writeUInt16LE(c.time, 12)
132
+ head.writeUInt16LE(c.date, 14)
133
+ head.writeUInt32LE(c.crc, 16)
134
+ head.writeUInt32LE(c.body, 20)
135
+ head.writeUInt32LE(c.raw, 24)
136
+ head.writeUInt16LE(c.name.length, 28)
137
+ head.writeUInt16LE(0, 30)
138
+ head.writeUInt16LE(0, 32)
139
+ head.writeUInt16LE(0, 34)
140
+ head.writeUInt16LE(0, 36)
141
+ head.writeUInt32LE(c.dir === true ? DIR_ATTRS : FILE_ATTRS, 38)
142
+ head.writeUInt32LE(c.offset, 42)
143
+ fs.writeSync(fd, head)
144
+ fs.writeSync(fd, c.name)
145
+ cdSize += 46 + c.name.length
146
+ }
147
+
148
+ const end = Buffer.alloc(22)
149
+ end.writeUInt32LE(SIG_END, 0)
150
+ end.writeUInt16LE(0, 4)
151
+ end.writeUInt16LE(0, 6)
152
+ end.writeUInt16LE(central.length, 8)
153
+ end.writeUInt16LE(central.length, 10)
154
+ end.writeUInt32LE(cdSize, 12)
155
+ end.writeUInt32LE(cdStart, 16)
156
+ end.writeUInt16LE(0, 20)
157
+ fs.writeSync(fd, end)
158
+
159
+ return {
160
+ entries: central.length,
161
+ bytes: cdStart + cdSize + 22,
162
+ rawBytes: rawTotal,
163
+ ratio: rawTotal === 0 ? 1 : Math.round(((cdStart + cdSize + 22) / rawTotal) * 100) / 100
164
+ }
165
+ } finally {
166
+ fs.closeSync(fd)
167
+ }
168
+ }
169
+
170
+ /** 找 EOCD:可能是 22 字节,也可能带注释,所以从尾部往前扫(最多 64KB)。 */
171
+ function findEnd(buf) {
172
+ const floor = Math.max(0, buf.length - 65557)
173
+ for (let i = buf.length - 22; i >= floor; i -= 1) {
174
+ if (buf.readUInt32LE(i) === SIG_END) return i
175
+ }
176
+ return -1
177
+ }
178
+
179
+ /** 解析中央目录。抛错就说明这不是个能读的 zip——调用方自己决定怎么兜。 */
180
+ function readCentral(zipPath) {
181
+ const buf = fs.readFileSync(zipPath)
182
+ if (buf.length < 22) throw new Error('不是 zip:文件太小')
183
+ const end = findEnd(buf)
184
+ if (end < 0) throw new Error('不是 zip:找不到结尾记录')
185
+ const count = buf.readUInt16LE(end + 10)
186
+ let cursor = buf.readUInt32LE(end + 16)
187
+ const entries = []
188
+ for (let i = 0; i < count; i += 1) {
189
+ if (cursor + 46 > buf.length) throw new Error('不是 zip:中央目录越界')
190
+ if (buf.readUInt32LE(cursor) !== SIG_CENTRAL) throw new Error('不是 zip:中央目录标记不对')
191
+ const method = buf.readUInt16LE(cursor + 10)
192
+ const time = buf.readUInt16LE(cursor + 12)
193
+ const date = buf.readUInt16LE(cursor + 14)
194
+ const bytes = buf.readUInt32LE(cursor + 20)
195
+ const rawBytes = buf.readUInt32LE(cursor + 24)
196
+ const nameLen = buf.readUInt16LE(cursor + 28)
197
+ const extraLen = buf.readUInt16LE(cursor + 30)
198
+ const commentLen = buf.readUInt16LE(cursor + 32)
199
+ const offset = buf.readUInt32LE(cursor + 42)
200
+ const name = buf.subarray(cursor + 46, cursor + 46 + nameLen).toString('utf8')
201
+ entries.push({ name, bytes, rawBytes, method, time: dosToText(date, time), offset })
202
+ cursor += 46 + nameLen + extraLen + commentLen
203
+ }
204
+ return { buf, entries }
205
+ }
206
+
207
+ /** 列清单:面板「打开」和自检共用。不依赖机器上装了什么。 */
208
+ function listZip(zipPath) {
209
+ return readCentral(zipPath).entries.map(({ name, bytes, rawBytes, method, time }) => ({
210
+ name, bytes, rawBytes, method, time
211
+ }))
212
+ }
213
+
214
+ /**
215
+ * 读一个条目出来。
216
+ * 找不到就抛——自检靠它区分"条目为空"和"条目缺失"。
217
+ */
218
+ function readEntry(zipPath, entryName) {
219
+ const { buf, entries } = readCentral(zipPath)
220
+ const wanted = String(entryName).replace(/\\/g, '/')
221
+ const hit = entries.find((e) => e.name === wanted)
222
+ if (!hit) throw new Error(`zip 里没有 ${wanted}`)
223
+ const head = hit.offset
224
+ if (buf.readUInt32LE(head) !== SIG_LOCAL) throw new Error('不是 zip:条目头不对')
225
+ const nameLen = buf.readUInt16LE(head + 26)
226
+ const extraLen = buf.readUInt16LE(head + 28)
227
+ const start = head + 30 + nameLen + extraLen
228
+ const body = buf.subarray(start, start + hit.bytes)
229
+ if (hit.method === 0) return Buffer.from(body)
230
+ if (hit.method === 8) return zlib.inflateRawSync(body)
231
+ throw new Error(`不认识的压缩方式:${hit.method}`)
232
+ }
233
+
234
+ module.exports = { writeZip, listZip, readEntry, crc32 }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-liuhen",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "留痕 Trace — 可核对的 AI 记忆:索引、溯源、自动注入(默认关)、可选日记。",
5
5
  "author": "xiaoman000",
6
6
  "keywords": [
@@ -17,6 +17,7 @@
17
17
  "main": "lib/index.js",
18
18
  "files": [
19
19
  "lib",
20
+ "scripts/selfcheck.js",
20
21
  "client.js",
21
22
  "cordis.patch.yml",
22
23
  "README.md",