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 +34 -1
- package/client.js +12 -3
- package/lib/box.js +88 -15
- package/lib/index.js +19 -9
- package/lib/platform.js +79 -0
- package/lib/zip.js +234 -0
- package/package.json +2 -1
- package/scripts/selfcheck.js +1035 -0
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
|
-
-
|
|
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
|
-
|
|
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 ?
|
|
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)
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
97
|
-
fs.
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
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:
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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) })
|
package/lib/platform.js
ADDED
|
@@ -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.
|
|
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",
|