dsh-liuhen 0.1.1 → 0.1.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 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,75 @@
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
+ - **软链/目录联接会被解开**:以前 `fs.cpSync` 原样重建软链,Windows 建软链要管理员权限,
95
+ 于是 `.dsh/vault` 是个联接的机器会报 `EPERM: operation not permitted, symlink …`。
96
+ 现在一律跟着链接拷真内容(0.1.3)。
97
+ - **共享目录按平台走**(`lib/platform.js`):手机 → `/storage/emulated/0/DSHBox`;
98
+ Windows → `%USERPROFILE%\Downloads`;macOS → `~/Downloads`。面板文案也跟着换,
99
+ 电脑上不会再写「存到手机」。
100
+ - 打包是**可复现**的:同一天同样的数据,打两次 `sha256` 一样(目录条目固定时间戳,
101
+ 脱敏后把 mtime 拨回去)——面板上那个 sha 才有核对的意义。
102
+
103
+ ### 拿一台新机器,先体检
104
+
105
+ ```bash
106
+ node scripts/doctor.js
107
+ ```
108
+
109
+ 只读、不碰真数据、30 秒跑完,逐项说清这台机器上哪一层不行:
110
+
111
+ - 十六个模块能不能 `require`
112
+ - 三层数据在哪、**是不是软链/目录联接**
113
+ - `zstd` 有没有(Node ≥ 22.15 才有,存档与会话回填要用)
114
+ - zip 能不能自己写自己读(中文名)
115
+ - 软链/联接能不能拷出真内容
116
+ - 包该放到哪、那个目录在不在
117
+
118
+ 带 ❌ 的那几行就是病灶,整段贴出来即可。
119
+
120
+ **离线装(不发 npm 也能验)**:把 `dsh-liuhen-x.y.z.tgz` 解出来得到 `package/`:
121
+
122
+ ```bash
123
+ node package/scripts/doctor.js # 先体检,确认环境没问题
124
+ # 备份旧的,再把 package/ 整个覆盖到插件目录(一般在 <DSH_HOME>\profiles\web
125
+ ode_modules\dsh-liuhen)
126
+ # 然后重启 DSH
127
+ ```
128
+
129
+ **在电脑上验一遍**(`lib/*.js` 改了必须重启一次 DSH,面板的 HMR 管不到服务端):
130
+
131
+ ```bash
132
+ # 1. 重启 DSH 后,打开面板 → 小盒子 → 「打一份小盒子」
133
+ # 2. 没有红灯、出现「已放进下载文件夹:C:\Users\<你>\Downloads\留痕-YYYY-MM-DD.zip」即通过
134
+ # 3. 解压那个 zip,看中文名是否正常、00-读我.txt 第 20 行左右有没有「去向:」
135
+ # 4. 自检(可选,Windows 上也能跑):node scripts/selfcheck.js → 82/82
136
+ ```
137
+
138
+ ## 0.1.3 · 电脑也能用(小更新,不是第二版)
139
+
140
+ - **修 Windows 上的 `EPERM … symlink`**:`$DSH_HOME/vault` 是软链 / 目录联接时,打包会在
141
+ 「把数据拷进沙箱」这一步炸。根因是 `fs.cpSync` 会原样重建软链,而 Windows 建符号链接要
142
+ 管理员权限或开发者模式。现在换成自己走目录树、解开链接拷真内容(`copyTree`),
143
+ 顺带挡住了软链绕圈导致的无限递归。
144
+ - 自检仍 82/82;软链场景已单独验过(vault 是软链时,包里带的是真实文件内容)
145
+
146
+ ## 0.1.2(小更新,不是第二版)
147
+
148
+ - **电脑分支**:打包与列清单不再依赖系统的 `zip` / `unzip` 命令(Windows 上两者都没有),
149
+ 改成 `lib/zip.js` 自己读写;`lib/platform.js` 管共享目录与文案
150
+ - 打包结果**可复现**:同一天同样的数据两次打包 sha256 一致
151
+ - 包里补回**目录条目**(含空目录),解出来的结构跟打进去的一致
152
+ - `/summary` 多带一个 `where` 字段(`os` / `kind` / `dir` / `label` / `word`),
153
+ 面板据此说「手机」还是「下载文件夹」
154
+ - 自检 76 → 82 条:新增跨平台 4 条(手机路径、电脑路径、面板文案、zip 中文名往返)
155
+
86
156
  ## 0.1.1(小更新,不是第二版)
87
157
 
88
158
  - 面板每 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,15 +175,59 @@ 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
  }
136
188
 
189
+ /**
190
+ * 把源目录拷进沙箱:跟着软链走、拷真内容。
191
+ *
192
+ * 为什么不用 fs.cpSync(2026-11-01 在 Windows 上踩到的):
193
+ * cpSync 遇到软链 / 目录联接会「原样重建一个软链」,而 Windows 创建符号链接
194
+ * 要管理员权限或开发者模式,没有就报
195
+ * EPERM: operation not permitted, symlink 'C:\Users\…\.dsh\vault' -> '…\.box-stage-…\vault'
196
+ * 整个打包直接废。小盒子要的是内容,不是链接本身,所以一律解开。
197
+ *
198
+ * mtime 手动带过去:zip 条目要写时间戳,不带的话每次拷贝都盖成"现在",
199
+ * 同一份数据打两次 sha256 就不一样了。
200
+ */
201
+ function copyTree(from, to, keep, seen, depth) {
202
+ const visited = seen || new Set()
203
+ const level = depth || 0
204
+ if (level > 32) return
205
+ let stat
206
+ try { stat = fs.statSync(from) } catch { return } /* 跟随软链;断链就跳过 */
207
+ if (stat.isDirectory()) {
208
+ let real = from
209
+ try { real = fs.realpathSync(from) } catch {}
210
+ /* 软链绕回来会无限递归:撞见过的真实目录就停 */
211
+ if (visited.has(real)) return
212
+ visited.add(real)
213
+ try { fs.mkdirSync(to, { recursive: true, mode: 0o700 }) } catch { return }
214
+ let names = []
215
+ try { names = fs.readdirSync(from) } catch { return }
216
+ for (const name of names) {
217
+ const child = path.join(from, name)
218
+ if (keep && !keep(child)) continue
219
+ copyTree(child, path.join(to, name), keep, visited, level + 1)
220
+ }
221
+ try { fs.utimesSync(to, stat.atime, stat.mtime) } catch {}
222
+ return
223
+ }
224
+ if (!stat.isFile()) return
225
+ try {
226
+ fs.copyFileSync(from, to)
227
+ try { fs.utimesSync(to, stat.atime, stat.mtime) } catch {}
228
+ } catch {}
229
+ }
230
+
137
231
  /**
138
232
  * 打小盒子。
139
233
  *
@@ -173,22 +267,19 @@ function exportBox(options = {}) {
173
267
  */
174
268
  try { fs.rmSync(out, { force: true }) } catch {}
175
269
  /* 三层都拷进沙箱;private/、备份、半成品一概不拷,连排除参数都省了。 */
176
- for (const name of present) {
177
- fs.cpSync(path.join(paths.home, name), path.join(stage, name), {
178
- recursive: true,
179
- filter: (src) => {
180
- const base = path.basename(src)
181
- if (name === 'liuhen' && base === 'private') return false
182
- if (base.endsWith('.tmp')) return false
183
- /* 锁文件是运行时的临时物,不该进包 */
184
- if (base.endsWith('.lock')) return false
185
- if (/\.bak($|[-.])/.test(base)) return false
186
- if (name === 'liuhen' && base === 'backfill.json') return false
187
- if (base.startsWith('.box-stage-')) return false
188
- /* 按日期补打:仓库只装那一天的原文 */
189
- if (name === 'vault' && rebuildDate && base.endsWith('.jsonl')) return base === `${rebuildDate}.jsonl`
190
- return true
191
- }
270
+ for (const layer of present) {
271
+ copyTree(path.join(paths.home, layer), path.join(stage, layer), (src) => {
272
+ const base = path.basename(src)
273
+ if (layer === 'liuhen' && base === 'private') return false
274
+ if (base.endsWith('.tmp')) return false
275
+ /* 锁文件是运行时的临时物,不该进包 */
276
+ if (base.endsWith('.lock')) return false
277
+ if (/\.bak($|[-.])/.test(base)) return false
278
+ if (layer === 'liuhen' && base === 'backfill.json') return false
279
+ if (base.startsWith('.box-stage-')) return false
280
+ /* 按日期补打:仓库只装那一天的原文 */
281
+ if (layer === 'vault' && rebuildDate && base.endsWith('.jsonl')) return base === `${rebuildDate}.jsonl`
282
+ return true
192
283
  })
193
284
  }
194
285
  /*
@@ -208,9 +299,25 @@ function exportBox(options = {}) {
208
299
  for (const name of present) sanitizeTree(path.join(stage, name))
209
300
 
210
301
  /* 纸条也放进沙箱,压完随沙箱一起删。 */
211
- fs.writeFileSync(path.join(stage, MANIFEST), manifestBody(rebuildDate), { mode: 0o600 })
212
- const args = ['-qr', out, ...present, MANIFEST]
213
- execFileSync('zip', args, { cwd: stage })
302
+ const where = platform.describe()
303
+ const manifestFile = path.join(stage, MANIFEST)
304
+ fs.writeFileSync(manifestFile, manifestBody(rebuildDate, where), { mode: 0o600 })
305
+ /*
306
+ * 纸条的时间戳钉在当天零点:它是每次现写的,要是按"现在"记,
307
+ * 同一份数据隔一分钟打两次,sha256 就变了——那个 sha 是给人核对
308
+ * 「这份和我早上那份是不是同一份」用的,会漂就没意义。
309
+ * 其余条目的 mtime 来自源文件,本来就不变。
310
+ */
311
+ try {
312
+ const stamp = new Date(`${rebuildDate || dayKey()}T00:00:00`)
313
+ fs.utimesSync(manifestFile, stamp, stamp)
314
+ } catch {}
315
+ /*
316
+ * 自己拼 zip(lib/zip.js):Windows 上没有 zip 命令,spawn 直接 ENOENT——
317
+ * 这就是 0.1.1 在电脑上「打包失败:spawnSync zip ENOENT」的根。
318
+ * 以前 `zip -qr` 顺带做的递归收集,现在 collectEntries 干。
319
+ */
320
+ writeZip(out, collectEntries(stage))
214
321
  } catch (error) {
215
322
  return { ok: false, text: `打包失败:${error && error.message ? error.message : error}` }
216
323
  } finally {
@@ -227,4 +334,5 @@ function exportBox(options = {}) {
227
334
  return { ok: true, path: out, name: path.basename(out), bytes, sha256: sha, excluded: EXCLUDES, shared }
228
335
  }
229
336
 
230
- module.exports = { exportBox, boxPath }
337
+ /* copyTree 给 scripts/doctor.js 用:体检要能在不碰真数据的前提下验「软链拷不拷得动」。 */
338
+ module.exports = { exportBox, boxPath, copyTree }
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
+ }