dsh-instruction-memory 1.0.14 → 1.0.16
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/CHANGELOG.md +318 -0
- package/README.md +97 -12
- package/lib/client.js +645 -78
- package/lib/index.js +671 -50
- package/package.json +7 -5
package/lib/index.js
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* plugin's fiber, so unmounting the row removes all of them.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { copyFile, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { copyFile, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { watch } from 'node:fs'
|
|
15
|
+
import { randomUUID } from 'node:crypto'
|
|
16
|
+
import { isIP } from 'node:net'
|
|
14
17
|
import { homedir } from 'node:os'
|
|
15
|
-
import { dirname, join } from 'node:path'
|
|
18
|
+
import { basename, dirname, join } from 'node:path'
|
|
16
19
|
|
|
17
20
|
export const name = 'instruction-memory'
|
|
18
21
|
|
|
@@ -105,6 +108,13 @@ const MAX_WHEN = 200
|
|
|
105
108
|
*/
|
|
106
109
|
export const MAX_BODY_CHARS = MAX_ENTRIES * (MAX_CONTENT + MAX_TITLE + MAX_WHEN + 200) * 2 + 100_000
|
|
107
110
|
|
|
111
|
+
/** How long a crashed holder's `memory.json.lock` may block writes before it is stolen. */
|
|
112
|
+
const LOCK_STALE_MS = 10_000
|
|
113
|
+
/** How long a write waits for the cross-process lock before proceeding unlocked. */
|
|
114
|
+
const LOCK_WAIT_MS = 2_000
|
|
115
|
+
/** Coalescing window for store-directory watcher events before a re-read. */
|
|
116
|
+
const WATCH_DEBOUNCE_MS = 250
|
|
117
|
+
|
|
108
118
|
/* ------------------------------------------------------------------ *
|
|
109
119
|
* Pure helpers (no ctx, fully unit-testable)
|
|
110
120
|
* ------------------------------------------------------------------ */
|
|
@@ -187,18 +197,64 @@ export function truncatedFields(raw) {
|
|
|
187
197
|
return cut
|
|
188
198
|
}
|
|
189
199
|
|
|
200
|
+
/** JSON.stringify keeps quotes/type visible; the String() tail is for values JSON cannot encode. */
|
|
201
|
+
const describeValue = (value) => {
|
|
202
|
+
const text = JSON.stringify(value)
|
|
203
|
+
return typeof text === 'string' ? text : String(value)
|
|
204
|
+
}
|
|
205
|
+
|
|
190
206
|
/**
|
|
191
207
|
* Load a parsed memory file. Returns the sanitized data plus how many array
|
|
192
208
|
* items were discarded (blank entries, or valid ones past the cap). The count
|
|
193
209
|
* is surfaced rather than swallowed because the next save rewrites the file
|
|
194
210
|
* without those items — silently destroying user data is exactly what this
|
|
195
211
|
* plugin must never do.
|
|
212
|
+
*
|
|
213
|
+
* `rev` is the store revision, bumped on every committed write and persisted
|
|
214
|
+
* in the file. Clients echo it back as `baseRev` so a write built on a stale
|
|
215
|
+
* snapshot (another window, another process) can be refused instead of
|
|
216
|
+
* silently overwriting it. Missing (pre-rev files) reads as 0.
|
|
217
|
+
*
|
|
218
|
+
* A numeric `version` other than 1 sets `rejectedVersion` and leaves the data
|
|
219
|
+
* untouched: a future format's fields cannot be best-effort parsed as v1, and
|
|
220
|
+
* adopting a misread would let the next save rewrite the file in the wrong
|
|
221
|
+
* shape. A MISSING version still loads as v1 — the field has been written as
|
|
222
|
+
* 1 since the first release, so absence is an old/hand-made file, not a
|
|
223
|
+
* foreign format.
|
|
224
|
+
*
|
|
225
|
+
* `optionFixes` lists options the file carried but the loader had to correct
|
|
226
|
+
* (a non-boolean `enabled`, an out-of-range or non-numeric `budgetChars`).
|
|
227
|
+
* Each is a human-readable "field 原值 → 修正值 (原因)" string, surfaced the
|
|
228
|
+
* same way as `dropped`: the next save persists the corrected values, so the
|
|
229
|
+
* user must be told their hand edit did not survive verbatim.
|
|
196
230
|
*/
|
|
197
231
|
function normalizeData(raw) {
|
|
198
|
-
const data = { version: 1, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
199
|
-
if (raw === null || typeof raw !== 'object') return { data, dropped: 0 }
|
|
200
|
-
if (typeof raw.
|
|
201
|
-
|
|
232
|
+
const data = { version: 1, rev: 0, enabled: true, budgetChars: DEFAULT_BUDGET, entries: [] }
|
|
233
|
+
if (raw === null || typeof raw !== 'object') return { data, dropped: 0, optionFixes: [] }
|
|
234
|
+
if (typeof raw.version === 'number' && raw.version !== 1) {
|
|
235
|
+
return { data, dropped: 0, optionFixes: [], rejectedVersion: raw.version }
|
|
236
|
+
}
|
|
237
|
+
if (typeof raw.rev === 'number' && Number.isFinite(raw.rev) && raw.rev >= 0) {
|
|
238
|
+
data.rev = Math.round(raw.rev)
|
|
239
|
+
}
|
|
240
|
+
// Hand-edited options the loader has to correct are disclosed, not silently
|
|
241
|
+
// adopted: the next save would otherwise rewrite the file with the fixed
|
|
242
|
+
// values and the user would never learn their edit had been ignored.
|
|
243
|
+
const optionFixes = []
|
|
244
|
+
if (typeof raw.enabled === 'boolean') {
|
|
245
|
+
data.enabled = raw.enabled
|
|
246
|
+
} else if ('enabled' in raw) {
|
|
247
|
+
optionFixes.push('enabled ' + describeValue(raw.enabled) + ' → true(需要 true/false)')
|
|
248
|
+
}
|
|
249
|
+
if ('budgetChars' in raw) {
|
|
250
|
+
const clamped = clampBudget(raw.budgetChars)
|
|
251
|
+
if (clamped !== raw.budgetChars) {
|
|
252
|
+
optionFixes.push(typeof raw.budgetChars === 'number' && Number.isFinite(raw.budgetChars)
|
|
253
|
+
? 'budgetChars ' + raw.budgetChars + ' → ' + clamped + '(有效范围 ' + MIN_BUDGET + '–' + MAX_BUDGET + ')'
|
|
254
|
+
: 'budgetChars ' + describeValue(raw.budgetChars) + ' → ' + clamped + '(需要 ' + MIN_BUDGET + '–' + MAX_BUDGET + ' 的数字)')
|
|
255
|
+
}
|
|
256
|
+
data.budgetChars = clamped
|
|
257
|
+
}
|
|
202
258
|
const list = Array.isArray(raw.entries) ? raw.entries : []
|
|
203
259
|
const seen = new Set()
|
|
204
260
|
let dropped = 0
|
|
@@ -216,7 +272,7 @@ function normalizeData(raw) {
|
|
|
216
272
|
seen.add(entry.id)
|
|
217
273
|
data.entries.push(entry)
|
|
218
274
|
}
|
|
219
|
-
return { data, dropped }
|
|
275
|
+
return { data, dropped, optionFixes }
|
|
220
276
|
}
|
|
221
277
|
|
|
222
278
|
/**
|
|
@@ -231,6 +287,7 @@ function normalizeData(raw) {
|
|
|
231
287
|
function cloneData(data) {
|
|
232
288
|
return {
|
|
233
289
|
version: 1,
|
|
290
|
+
rev: data.rev || 0,
|
|
234
291
|
enabled: data.enabled === true,
|
|
235
292
|
budgetChars: data.budgetChars,
|
|
236
293
|
entries: (Array.isArray(data.entries) ? data.entries : []).map((entry) => ({ ...entry })),
|
|
@@ -242,6 +299,22 @@ const droppedEntriesMessage = (count) =>
|
|
|
242
299
|
? '存储文件中有 ' + count + ' 条条目未能载入(内容为空或超过 ' + MAX_ENTRIES + ' 条上限);下次保存时它们将从文件中移除'
|
|
243
300
|
: null
|
|
244
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The load-time disclosures for one normalizeData result: dropped entries and
|
|
304
|
+
* corrected hand-edited options, joined with a full-width semicolon.
|
|
305
|
+
* Returns null when neither applies, so callers can assign it directly to
|
|
306
|
+
* `state.storageWarning` (a null means "no warning", not an empty string).
|
|
307
|
+
*/
|
|
308
|
+
const loadWarnings = (loaded) => {
|
|
309
|
+
const parts = []
|
|
310
|
+
const dropped = droppedEntriesMessage(loaded.dropped)
|
|
311
|
+
if (dropped !== null) parts.push(dropped)
|
|
312
|
+
if (Array.isArray(loaded.optionFixes) && loaded.optionFixes.length > 0) {
|
|
313
|
+
parts.push('存储文件中的以下选项值无效,已按规则修正:' + loaded.optionFixes.join(';') + ';下次保存时修正值将写入文件')
|
|
314
|
+
}
|
|
315
|
+
return parts.length > 0 ? parts.join(';') : null
|
|
316
|
+
}
|
|
317
|
+
|
|
245
318
|
/**
|
|
246
319
|
* Hostnames a loopback-bound server may legitimately be reached by.
|
|
247
320
|
*
|
|
@@ -255,6 +328,13 @@ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1', '::ffff:127
|
|
|
255
328
|
*
|
|
256
329
|
* `127.0.0.1:8080` -> `127.0.0.1`; `[::1]:8080` -> `::1`; `localhost` -> `localhost`.
|
|
257
330
|
* Exported so the parsing itself can be pinned by tests.
|
|
331
|
+
*
|
|
332
|
+
* HTTP requires IPv6 literals in Host to wear brackets, so the bracketed
|
|
333
|
+
* form is the one real traffic uses; the bare form still has to parse instead
|
|
334
|
+
* of being chopped at the last colon (the old code turned a bare `::1` into
|
|
335
|
+
* `:`, which never matched the loopback set). A bare value carrying MORE than
|
|
336
|
+
* one colon is an IPv6 literal with no port — with at most one colon it is
|
|
337
|
+
* host:port.
|
|
258
338
|
*/
|
|
259
339
|
export function hostnameOf(value) {
|
|
260
340
|
if (typeof value !== 'string') return ''
|
|
@@ -262,15 +342,104 @@ export function hostnameOf(value) {
|
|
|
262
342
|
if (trimmed === '') return ''
|
|
263
343
|
if (trimmed.startsWith('[')) {
|
|
264
344
|
const end = trimmed.indexOf(']')
|
|
265
|
-
return end === -1 ? trimmed : trimmed.slice(1, end)
|
|
345
|
+
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
|
|
266
346
|
}
|
|
347
|
+
// Unbracketed IPv6 literal: the colons are part of the address, not a port.
|
|
348
|
+
if (trimmed.indexOf(':') !== trimmed.lastIndexOf(':')) return trimmed
|
|
267
349
|
const colon = trimmed.lastIndexOf(':')
|
|
268
350
|
return colon === -1 ? trimmed : trimmed.slice(0, colon)
|
|
269
351
|
}
|
|
270
352
|
|
|
271
353
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
354
|
+
* Normalise a hostname the same way on BOTH sides of the Origin comparison.
|
|
355
|
+
*
|
|
356
|
+
* `new URL('http://[::1]:8080').hostname` keeps the brackets (`[::1]`), while
|
|
357
|
+
* `hostnameOf('[::1]:8080')` strips them (`::1`) — comparing the two raw forms
|
|
358
|
+
* rejected every same-origin request that reached the GUI over IPv6 loopback.
|
|
359
|
+
* Strip one surrounding pair of brackets here so both sides spell an IPv6
|
|
360
|
+
* literal identically.
|
|
361
|
+
*/
|
|
362
|
+
const normalizeHostname = (value) => (
|
|
363
|
+
typeof value === 'string' && value.startsWith('[') && value.endsWith(']') && value.length >= 2
|
|
364
|
+
? value.slice(1, -1)
|
|
365
|
+
: value
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Expand an IPv6 literal to eight lower-cased zero-padded groups, translating a
|
|
370
|
+
* trailing embedded IPv4 suffix to two hex groups. Returns null for anything
|
|
371
|
+
* that is not a valid literal.
|
|
372
|
+
*
|
|
373
|
+
* Needed because the two sides of the Origin check spell the SAME address
|
|
374
|
+
* differently: a browser sends Host `[::ffff:127.0.0.1]` while the URL parser
|
|
375
|
+
* canonicalises Origin to `[::ffff:7f00:1]` — text comparison says these are
|
|
376
|
+
* different; address comparison must say they are the same.
|
|
377
|
+
*/
|
|
378
|
+
export function canonicalIp(value) {
|
|
379
|
+
if (typeof value !== 'string') return null
|
|
380
|
+
let addr = normalizeHostname(value.trim().toLowerCase())
|
|
381
|
+
const percent = addr.indexOf('%')
|
|
382
|
+
if (percent !== -1) addr = addr.slice(0, percent)
|
|
383
|
+
if (isIP(addr) === 4) {
|
|
384
|
+
return 'v4:' + addr.split('.').map((part) => Number(part)).join('.')
|
|
385
|
+
}
|
|
386
|
+
if (isIP(addr) !== 6) return null
|
|
387
|
+
|
|
388
|
+
// Translate a trailing dotted-quad (e.g. ::ffff:127.0.0.1) into two groups.
|
|
389
|
+
const v4 = /(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(addr)
|
|
390
|
+
if (v4 !== null) {
|
|
391
|
+
const octets = v4[1].split('.').map((part) => Number(part))
|
|
392
|
+
if (octets.some((octet) => octet > 255)) return null
|
|
393
|
+
const prefix = addr.slice(0, v4.index)
|
|
394
|
+
const hi = (octets[0] << 8 | octets[1]).toString(16)
|
|
395
|
+
const lo = (octets[2] << 8 | octets[3]).toString(16)
|
|
396
|
+
// Keep the trailing ':' of the prefix as the separator before the two
|
|
397
|
+
// translated groups; it may also be the second ':' of a '::' compression,
|
|
398
|
+
// which must survive (so do not strip a leading colon afterwards).
|
|
399
|
+
addr = (prefix.endsWith(':') ? prefix : prefix + ':') + hi + ':' + lo
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const halves = addr.split('::')
|
|
403
|
+
if (halves.length > 2) return null
|
|
404
|
+
if (halves.length === 1) {
|
|
405
|
+
const groups = halves[0].split(':')
|
|
406
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
407
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
408
|
+
}
|
|
409
|
+
const [leftRaw, rightRaw] = halves
|
|
410
|
+
const left = leftRaw === '' ? [] : leftRaw.split(':')
|
|
411
|
+
const right = rightRaw === '' ? [] : rightRaw.split(':')
|
|
412
|
+
const missing = 8 - left.length - right.length
|
|
413
|
+
if (missing < 0) return null
|
|
414
|
+
const groups = left.concat(Array(missing).fill('0'), right)
|
|
415
|
+
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/i.test(group))) return null
|
|
416
|
+
return groups.map((group) => parseInt(group, 16).toString(16).padStart(4, '0')).join(':')
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Whether two hostnames are the same endpoint: exact text or same IP literal. */
|
|
420
|
+
const sameHostname = (a, b) => {
|
|
421
|
+
if (a === b) return true
|
|
422
|
+
const ca = canonicalIp(a)
|
|
423
|
+
const cb = canonicalIp(b)
|
|
424
|
+
return ca !== null && cb !== null && ca === cb
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Loopback by exact name or by equivalent IP spelling (mapped/hex forms). */
|
|
428
|
+
const isLoopbackName = (name) => {
|
|
429
|
+
if (LOOPBACK_HOSTNAMES.has(name)) return true
|
|
430
|
+
const canonical = canonicalIp(name)
|
|
431
|
+
if (canonical === null) return false
|
|
432
|
+
for (const known of LOOPBACK_HOSTNAMES) {
|
|
433
|
+
const knownCanonical = canonicalIp(known)
|
|
434
|
+
if (knownCanonical !== null && knownCanonical === canonical) return true
|
|
435
|
+
}
|
|
436
|
+
return false
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Why the settings-page route checks `Host`, `Origin` and `Sec-Fetch-Site` at
|
|
441
|
+
* all, when it already refuses any content-type that is not
|
|
442
|
+
* `application/json`.
|
|
274
443
|
*
|
|
275
444
|
* The content-type rule stops a cross-site form post, and no CORS headers are
|
|
276
445
|
* sent so a cross-origin `fetch` with a JSON body dies in preflight. Both
|
|
@@ -283,10 +452,21 @@ export function hostnameOf(value) {
|
|
|
283
452
|
* change is the `Host` header the browser sends: it still carries `evil.com`.
|
|
284
453
|
*
|
|
285
454
|
* So when the server is bound to loopback (the default) the route requires a
|
|
286
|
-
* loopback `Host`. An operator who binds `0.0.0.0
|
|
287
|
-
*
|
|
288
|
-
* advance — the check is disabled
|
|
289
|
-
* client, and that trade-off is
|
|
455
|
+
* loopback `Host`. An operator who binds a non-loopback address (`0.0.0.0`,
|
|
456
|
+
* `::`, or a concrete one) has deliberately published the server beyond this
|
|
457
|
+
* machine, where no Host value can be known in advance — the check is disabled
|
|
458
|
+
* there rather than breaking every legitimate client, and that trade-off is
|
|
459
|
+
* documented in the README.
|
|
460
|
+
*
|
|
461
|
+
* `Sec-Fetch-Site` adds a second browser-only signal (absent from curl, Node,
|
|
462
|
+
* and pre-2020 browsers, which is exactly why it can only ADD rejections, never
|
|
463
|
+
* replace the checks above): a modern browser stamps every request with it, so
|
|
464
|
+
* a page at evil.com talking to this route — even through tricks that drop the
|
|
465
|
+
* `Origin` header — still says `cross-site`. Only `cross-site` is refused;
|
|
466
|
+
* `same-site` must pass because schemeful same-site ignores ports (the GUI and
|
|
467
|
+
* the route live on the same host but different ports), and local non-browser
|
|
468
|
+
* clients never send the header at all, so they are governed by the Host and
|
|
469
|
+
* content-type checks alone.
|
|
290
470
|
*/
|
|
291
471
|
export function requestOriginVerdict(headers, guarded) {
|
|
292
472
|
const read = (name) => {
|
|
@@ -297,22 +477,32 @@ export function requestOriginVerdict(headers, guarded) {
|
|
|
297
477
|
const requestHost = read('host').trim().toLowerCase()
|
|
298
478
|
const requestHostname = hostnameOf(requestHost)
|
|
299
479
|
|
|
300
|
-
if (guarded && !
|
|
480
|
+
if (guarded && !isLoopbackName(requestHostname)) {
|
|
301
481
|
return { ok: false, reason: 'Host 头不是回环地址(可能是 DNS 重绑定攻击):' + (requestHost === '' ? '(缺失)' : requestHost) }
|
|
302
482
|
}
|
|
303
483
|
|
|
484
|
+
const fetchSite = read('sec-fetch-site').trim().toLowerCase()
|
|
485
|
+
if (fetchSite === 'cross-site') {
|
|
486
|
+
return { ok: false, reason: '请求被浏览器标记为跨站(Sec-Fetch-Site: cross-site)' }
|
|
487
|
+
}
|
|
488
|
+
|
|
304
489
|
const origin = read('origin').trim()
|
|
305
490
|
if (origin !== '') {
|
|
306
491
|
let originHostname = ''
|
|
307
492
|
try {
|
|
308
|
-
|
|
493
|
+
// URL.hostname keeps the brackets on an IPv6 literal; normalizeHostname
|
|
494
|
+
// takes them off so it can meet the bracket-free Host parsing on equal
|
|
495
|
+
// terms.
|
|
496
|
+
originHostname = normalizeHostname(new URL(origin).hostname.toLowerCase())
|
|
309
497
|
} catch {
|
|
310
498
|
originHostname = ''
|
|
311
499
|
}
|
|
312
500
|
// Compare hostnames, not host:port: a proxy may rewrite the port, and the
|
|
313
501
|
// hostname is the part rebinding actually attacks. `Origin: null`
|
|
314
502
|
// (sandboxed iframe, file://) parses to no hostname and is refused.
|
|
315
|
-
|
|
503
|
+
// Canonical IP comparison covers the two spellings of one address:
|
|
504
|
+
// ::1 vs [::1], and ::ffff:127.0.0.1 vs ::ffff:7f00:1.
|
|
505
|
+
if (originHostname === '' || !sameHostname(originHostname, requestHostname)) {
|
|
316
506
|
return { ok: false, reason: 'Origin 与 Host 不同源:' + origin }
|
|
317
507
|
}
|
|
318
508
|
}
|
|
@@ -435,6 +625,13 @@ export function apply(ctx) {
|
|
|
435
625
|
loadedFrom: null,
|
|
436
626
|
savedAt: null,
|
|
437
627
|
migratedFrom: null,
|
|
628
|
+
/**
|
|
629
|
+
* Set while the store on disk declares an unsupported format version.
|
|
630
|
+
* Mutating commands are refused in that state: a save would rewrite a
|
|
631
|
+
* future-format file into v1, destroying whatever the newer format meant.
|
|
632
|
+
*/
|
|
633
|
+
versionRejected: false,
|
|
634
|
+
rejectedVersion: null,
|
|
438
635
|
sectionRegistered: false,
|
|
439
636
|
sectionError: null,
|
|
440
637
|
/** Whether a store file is actually present on disk (diagnostics + the panel). */
|
|
@@ -590,14 +787,194 @@ export function apply(ctx) {
|
|
|
590
787
|
console.error('[instruction-memory] cleanup failed:', err)
|
|
591
788
|
}
|
|
592
789
|
}
|
|
790
|
+
stopWatching()
|
|
791
|
+
disposeStreams()
|
|
593
792
|
})
|
|
594
793
|
|
|
794
|
+
/* ---------------- external-change watching (SSE) ---------------- */
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Live subscribers to the settings page's event stream. Each entry maps a
|
|
798
|
+
* stream's `send(payload)` writer to its own teardown, so a write that fails
|
|
799
|
+
* (the socket went away) can dispose THAT stream instead of just dropping
|
|
800
|
+
* it from the broadcast set while its response stays open.
|
|
801
|
+
*/
|
|
802
|
+
const sseStreams = new Map()
|
|
803
|
+
let storeWatcher = null
|
|
804
|
+
let watchDebounce = null
|
|
805
|
+
|
|
806
|
+
const pushSnapshot = () => {
|
|
807
|
+
if (sseStreams.size === 0) return
|
|
808
|
+
const payload = 'data: ' + JSON.stringify(snapshot()) + '\n\n'
|
|
809
|
+
for (const [send, cleanup] of Array.from(sseStreams)) {
|
|
810
|
+
try {
|
|
811
|
+
send(payload)
|
|
812
|
+
} catch {
|
|
813
|
+
// The response is dead: run its full teardown (end + unsubscribe),
|
|
814
|
+
// not just a set delete that leaves the stream half-registered.
|
|
815
|
+
cleanup()
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* A store file changed on disk. Debounced (one atomic save fires rename +
|
|
822
|
+
* copy events), then serialised through the same diskQueue as every other
|
|
823
|
+
* mutation so a watcher re-read can never interleave with a save.
|
|
824
|
+
*
|
|
825
|
+
* The file is read WITHOUT adopting it first, because this plugin's own
|
|
826
|
+
* writes fire the watcher too: if the sanitised content is identical to the
|
|
827
|
+
* in-memory state, the event was ours and there is nothing to tell anyone.
|
|
828
|
+
* A failed read (the file is momentarily absent mid-replace, or corrupt) is
|
|
829
|
+
* skipped rather than recovered-from — recovery belongs to reload, which a
|
|
830
|
+
* human asked for.
|
|
831
|
+
*/
|
|
832
|
+
const onStoreFileEvent = () => {
|
|
833
|
+
if (watchDebounce !== null) clearTimeout(watchDebounce)
|
|
834
|
+
watchDebounce = setTimeout(() => {
|
|
835
|
+
watchDebounce = null
|
|
836
|
+
void enqueue(async () => {
|
|
837
|
+
try {
|
|
838
|
+
const parsed = await readStoreFile(storePath)
|
|
839
|
+
if (parsed === null) return
|
|
840
|
+
const loaded = normalizeData(parsed)
|
|
841
|
+
if (loaded.rejectedVersion === undefined
|
|
842
|
+
&& JSON.stringify(loaded.data) === JSON.stringify(state.data)) {
|
|
843
|
+
return
|
|
844
|
+
}
|
|
845
|
+
await readFromDisk()
|
|
846
|
+
syncSection()
|
|
847
|
+
pushSnapshot()
|
|
848
|
+
} catch {
|
|
849
|
+
// Transient: the next event, or the panel's 重新载入, catches up.
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
}, WATCH_DEBOUNCE_MS)
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const stopWatching = () => {
|
|
856
|
+
if (watchDebounce !== null) {
|
|
857
|
+
clearTimeout(watchDebounce)
|
|
858
|
+
watchDebounce = null
|
|
859
|
+
}
|
|
860
|
+
if (storeWatcher !== null) {
|
|
861
|
+
const watcher = storeWatcher
|
|
862
|
+
storeWatcher = null
|
|
863
|
+
try {
|
|
864
|
+
watcher.close()
|
|
865
|
+
} catch {
|
|
866
|
+
// Already closed by the OS side.
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Watch the store DIRECTORY, not the file: this plugin's saves replace
|
|
873
|
+
* memory.json through an atomic rename, which on Windows can orphan a
|
|
874
|
+
* file-level watch handle (the watched inode is gone). Directory events
|
|
875
|
+
* survive replaces. A missing directory (first run, nothing materialised
|
|
876
|
+
* yet) throws and is retried by the first successful write.
|
|
877
|
+
*/
|
|
878
|
+
const startWatching = () => {
|
|
879
|
+
if (storeWatcher !== null) return
|
|
880
|
+
try {
|
|
881
|
+
storeWatcher = watch(dirname(storePath), { persistent: false }, (event, filename) => {
|
|
882
|
+
// Only events naming memory.json matter; the .tmp/.bak/.lock churn of
|
|
883
|
+
// our own writes is filtered out here (the content compare in
|
|
884
|
+
// onStoreFileEvent catches what slips through).
|
|
885
|
+
if (typeof filename === 'string' && filename !== basename(storePath)) return
|
|
886
|
+
onStoreFileEvent()
|
|
887
|
+
})
|
|
888
|
+
storeWatcher.on('error', () => stopWatching())
|
|
889
|
+
} catch {
|
|
890
|
+
storeWatcher = null
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const disposeStreams = () => {
|
|
895
|
+
for (const cleanup of Array.from(sseStreams.values())) {
|
|
896
|
+
try {
|
|
897
|
+
cleanup()
|
|
898
|
+
} catch {
|
|
899
|
+
// The response is already gone.
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
sseStreams.clear()
|
|
903
|
+
}
|
|
904
|
+
|
|
595
905
|
/* ---------------- storage ---------------- */
|
|
596
906
|
|
|
597
907
|
const storePath = resolveStorePath()
|
|
598
908
|
|
|
599
909
|
const serialize = () => JSON.stringify(state.data, null, 2) + '\n'
|
|
600
910
|
|
|
911
|
+
/**
|
|
912
|
+
* Cross-process write lock: a `memory.json.lock` marker created exclusively
|
|
913
|
+
* (`wx`) holding a random owner token, kept for the duration of one atomic
|
|
914
|
+
* replace, and removed afterwards ONLY BY ITS OWNER.
|
|
915
|
+
*
|
|
916
|
+
* The in-process diskQueue serialises this plugin's own writes, but two DSH
|
|
917
|
+
* processes sharing a `$DSH_HOME` (e.g. `web` and `tui` profiles running at
|
|
918
|
+
* once) can interleave a copy-backup/atomic-rename sequence. The rename
|
|
919
|
+
* itself keeps the file uncorrupted; what the lock narrows is the
|
|
920
|
+
* last-write-wins window in which one process's edit silently erases the
|
|
921
|
+
* other's.
|
|
922
|
+
*
|
|
923
|
+
* Availability beats exclusion for a user-facing store: a lock that cannot
|
|
924
|
+
* be acquired within a short wait is NEVER allowed to fail the save — the
|
|
925
|
+
* write proceeds anyway (disclosed in the debug log). A lock older than
|
|
926
|
+
* LOCK_STALE_MS is treated as crashed-holder debris and stolen, so a killed
|
|
927
|
+
* process cannot wedge every later save behind a stale marker.
|
|
928
|
+
*
|
|
929
|
+
* The token matters: stealing used to be a plain unlink, and the slow
|
|
930
|
+
* holder's `finally` then unlinked the lock the NEW owner had just created —
|
|
931
|
+
* a classic TOCTOU that silently unlocked the third writer. The marker
|
|
932
|
+
* carries this attempt's UUID, and release deletes it only when the file
|
|
933
|
+
* still carries that UUID, so a finished holder can never remove a lock it
|
|
934
|
+
* no longer owns.
|
|
935
|
+
*
|
|
936
|
+
* @returns `{ path, token }` while held, or `null` when the wait elapsed
|
|
937
|
+
* (the save proceeds unlocked by design).
|
|
938
|
+
*/
|
|
939
|
+
const acquireStoreLock = async () => {
|
|
940
|
+
const lockPath = storePath + '.lock'
|
|
941
|
+
const token = randomUUID()
|
|
942
|
+
const deadline = Date.now() + LOCK_WAIT_MS
|
|
943
|
+
for (;;) {
|
|
944
|
+
try {
|
|
945
|
+
await writeFile(lockPath, token, { flag: 'wx' })
|
|
946
|
+
return { path: lockPath, token }
|
|
947
|
+
} catch (err) {
|
|
948
|
+
if (err.code !== 'EEXIST') throw err
|
|
949
|
+
const info = await stat(lockPath).catch(() => null)
|
|
950
|
+
if (info !== null && Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
951
|
+
// Crashed-holder debris. It may be recreated by a live process
|
|
952
|
+
// between this unlink and the next wx attempt — that case simply
|
|
953
|
+
// loops and re-evaluates freshness instead of forcing its way in.
|
|
954
|
+
await unlink(lockPath).catch(() => {})
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
if (Date.now() >= deadline) return null
|
|
958
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Release a lock only when THIS attempt still owns it. Reading the marker
|
|
965
|
+
* back and comparing the token closes the window in which a finished slow
|
|
966
|
+
* holder deleted a lock the next owner had already taken over.
|
|
967
|
+
*/
|
|
968
|
+
const releaseStoreLock = async (lock) => {
|
|
969
|
+
if (lock === null) return
|
|
970
|
+
try {
|
|
971
|
+
const owner = await readFile(lock.path, 'utf8').catch(() => null)
|
|
972
|
+
if (owner === lock.token) await unlink(lock.path).catch(() => {})
|
|
973
|
+
} catch {
|
|
974
|
+
// Cleanup must never throw into the save's finally.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
601
978
|
const writeToDisk = async () => {
|
|
602
979
|
// Atomic replace (write a sibling temp file, then rename) with a rolling
|
|
603
980
|
// one-version backup: a crash mid-write must never leave a truncated
|
|
@@ -614,8 +991,18 @@ export function apply(ctx) {
|
|
|
614
991
|
// any step leaves the previous store readable at its own path.
|
|
615
992
|
const tmpPath = storePath + '.tmp'
|
|
616
993
|
const bakPath = storePath + '.bak'
|
|
994
|
+
let lock = null
|
|
995
|
+
let previousRev = null
|
|
617
996
|
try {
|
|
618
997
|
await mkdir(dirname(storePath), { recursive: true })
|
|
998
|
+
lock = await acquireStoreLock()
|
|
999
|
+
if (lock === null) {
|
|
1000
|
+
logDebug('store lock still held by another process; writing anyway (last write wins)')
|
|
1001
|
+
}
|
|
1002
|
+
// The rev is bumped before serialising so the file on disk and the state
|
|
1003
|
+
// in memory agree on it; a failed write restores the old value.
|
|
1004
|
+
previousRev = state.data.rev || 0
|
|
1005
|
+
state.data.rev = previousRev + 1
|
|
619
1006
|
const text = serialize()
|
|
620
1007
|
await writeFile(tmpPath, text, 'utf8')
|
|
621
1008
|
try {
|
|
@@ -644,14 +1031,20 @@ export function apply(ctx) {
|
|
|
644
1031
|
// A successful write is the one thing that makes the file real, so it is
|
|
645
1032
|
// also what clears the "not materialised yet" flag set by a first run.
|
|
646
1033
|
needsMaterialize = false
|
|
1034
|
+
// ...and what guarantees the store directory exists, so the watcher can
|
|
1035
|
+
// be (re)armed after a first run that had nothing to watch.
|
|
1036
|
+
startWatching()
|
|
647
1037
|
state.savedAt = Date.now()
|
|
648
1038
|
logDebug('store written: ' + storePath + ' bytes=' + text.length
|
|
649
|
-
+ ' entries=' + state.data.entries.length
|
|
1039
|
+
+ ' entries=' + state.data.entries.length + ' rev=' + state.data.rev
|
|
650
1040
|
+ (state.backupError === null ? ' backup=refreshed' : ' backup=FAILED'))
|
|
651
1041
|
return true
|
|
652
1042
|
} catch (err) {
|
|
1043
|
+
if (previousRev !== null) state.data.rev = previousRev
|
|
653
1044
|
state.storageError = '保存失败:' + describeError(err)
|
|
654
1045
|
return false
|
|
1046
|
+
} finally {
|
|
1047
|
+
await releaseStoreLock(lock)
|
|
655
1048
|
}
|
|
656
1049
|
}
|
|
657
1050
|
|
|
@@ -660,25 +1053,57 @@ export function apply(ctx) {
|
|
|
660
1053
|
return text.trim() === '' ? null : JSON.parse(text)
|
|
661
1054
|
}
|
|
662
1055
|
|
|
1056
|
+
/**
|
|
1057
|
+
* Load the store from disk. Most branches only read; two of them also write —
|
|
1058
|
+
* the missing-primary recovery promotes the backup to memory.json, and the
|
|
1059
|
+
* legacy-location migration writes the migrated store. The return value says
|
|
1060
|
+
* which happened, because `reload` reports it as its `saved` field: the
|
|
1061
|
+
* response envelope's `saved` means "this operation persisted the store",
|
|
1062
|
+
* and a read-only reload claiming true would be indistinguishable from a
|
|
1063
|
+
* real save.
|
|
1064
|
+
*
|
|
1065
|
+
* @returns whether this load performed a successful write.
|
|
1066
|
+
*/
|
|
663
1067
|
const readFromDisk = async () => {
|
|
664
1068
|
state.storagePath = storePath
|
|
665
1069
|
state.storageDisplay = storePath
|
|
666
1070
|
state.storageWarning = null
|
|
667
1071
|
state.loadedFrom = null
|
|
668
1072
|
state.storeExists = false
|
|
1073
|
+
state.versionRejected = false
|
|
1074
|
+
state.rejectedVersion = null
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* A store declaring an unsupported format version: adopt nothing, write
|
|
1078
|
+
* nothing, and say why. The one thing that must NEVER happen in this
|
|
1079
|
+
* state is a save — it would rewrite the file as v1 and destroy whatever
|
|
1080
|
+
* the newer format meant (the mutating commands refuse while
|
|
1081
|
+
* versionRejected is set).
|
|
1082
|
+
*/
|
|
1083
|
+
const rejectVersion = (version, source) => {
|
|
1084
|
+
state.versionRejected = true
|
|
1085
|
+
state.rejectedVersion = version
|
|
1086
|
+
state.storageError = '存储文件声明为格式版本 ' + version + ',本插件只支持版本 1;已拒绝载入以防数据损坏,原文件已保留,修改操作已暂停。请升级插件,或手动将文件改回版本 1 后点「重新载入」'
|
|
1087
|
+
state.loadedFrom = source + ' (unsupported version ' + version + ')'
|
|
1088
|
+
state.storeExists = true
|
|
1089
|
+
}
|
|
669
1090
|
|
|
670
1091
|
// 1. The canonical location under the harness home.
|
|
671
1092
|
try {
|
|
672
1093
|
const parsed = await readStoreFile(storePath)
|
|
673
1094
|
if (parsed !== null) {
|
|
674
1095
|
const loaded = normalizeData(parsed)
|
|
1096
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1097
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json')
|
|
1098
|
+
return false
|
|
1099
|
+
}
|
|
675
1100
|
state.data = loaded.data
|
|
676
|
-
state.storageWarning =
|
|
1101
|
+
state.storageWarning = loadWarnings(loaded)
|
|
677
1102
|
state.loadedFrom = 'memory.json'
|
|
678
1103
|
state.storeExists = true
|
|
679
1104
|
}
|
|
680
1105
|
state.storageError = null
|
|
681
|
-
return
|
|
1106
|
+
return false
|
|
682
1107
|
} catch (err) {
|
|
683
1108
|
if (err === null || err === undefined || err.code !== 'ENOENT') {
|
|
684
1109
|
// The main file is unreadable or corrupt. A previous good version
|
|
@@ -687,16 +1112,27 @@ export function apply(ctx) {
|
|
|
687
1112
|
// itself stays untouched for hand repair; it gets overwritten on the
|
|
688
1113
|
// next save (the pre-existing and disclosed behaviour).
|
|
689
1114
|
if (err instanceof SyntaxError) {
|
|
1115
|
+
// The file was READ and only failed to PARSE, so a store file is
|
|
1116
|
+
// physically present on disk. Saying onDisk=no here contradicted
|
|
1117
|
+
// the branch's own story ("损坏文件已保留") and produced a mounted
|
|
1118
|
+
// log line reading "from=memory.json.bak (main file corrupt)
|
|
1119
|
+
// onDisk=no". (Other non-ENOENT errors stay no: ENOTDIR, say, means
|
|
1120
|
+
// nothing exists at the store path at all.)
|
|
1121
|
+
state.storeExists = true
|
|
690
1122
|
try {
|
|
691
1123
|
const backup = await readStoreFile(storePath + '.bak')
|
|
692
1124
|
if (backup !== null) {
|
|
693
1125
|
const loaded = normalizeData(backup)
|
|
1126
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1127
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
1128
|
+
return false
|
|
1129
|
+
}
|
|
694
1130
|
state.data = loaded.data
|
|
695
|
-
|
|
696
|
-
|
|
1131
|
+
const warnings = loadWarnings(loaded)
|
|
1132
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
697
1133
|
+ 'memory.json 解析失败(' + describeError(err) + '),已临时回退到最近备份 memory.json.bak;损坏文件已保留,下次保存时会覆盖它'
|
|
698
1134
|
state.loadedFrom = 'memory.json.bak (main file corrupt)'
|
|
699
|
-
return
|
|
1135
|
+
return false
|
|
700
1136
|
}
|
|
701
1137
|
} catch {
|
|
702
1138
|
// No usable backup either; fall through to the error below.
|
|
@@ -706,7 +1142,7 @@ export function apply(ctx) {
|
|
|
706
1142
|
state.storageError = err instanceof SyntaxError
|
|
707
1143
|
? '配置文件解析失败(' + describeError(err) + '),原文件已保留;你下次保存时会覆盖它'
|
|
708
1144
|
: '读取失败:' + describeError(err)
|
|
709
|
-
return
|
|
1145
|
+
return false
|
|
710
1146
|
}
|
|
711
1147
|
}
|
|
712
1148
|
|
|
@@ -722,14 +1158,18 @@ export function apply(ctx) {
|
|
|
722
1158
|
const backup = await readStoreFile(storePath + '.bak')
|
|
723
1159
|
if (backup !== null) {
|
|
724
1160
|
const loaded = normalizeData(backup)
|
|
1161
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1162
|
+
rejectVersion(loaded.rejectedVersion, 'memory.json.bak')
|
|
1163
|
+
return false
|
|
1164
|
+
}
|
|
725
1165
|
state.data = loaded.data
|
|
726
1166
|
const promoted = await writeToDisk()
|
|
727
|
-
|
|
728
|
-
|
|
1167
|
+
const warnings = loadWarnings(loaded)
|
|
1168
|
+
state.storageWarning = (warnings !== null ? warnings + ';' : '')
|
|
729
1169
|
+ 'memory.json 不存在,已从最近备份 memory.json.bak 恢复'
|
|
730
1170
|
+ (promoted ? ',并已写回 memory.json' : ';写回 memory.json 失败,下次保存时会重试')
|
|
731
1171
|
state.loadedFrom = 'memory.json.bak (main file missing)'
|
|
732
|
-
return
|
|
1172
|
+
return promoted
|
|
733
1173
|
}
|
|
734
1174
|
} catch {
|
|
735
1175
|
// An unusable backup must never block a fresh store; fall through.
|
|
@@ -742,15 +1182,21 @@ export function apply(ctx) {
|
|
|
742
1182
|
const parsed = await readStoreFile(legacyPath)
|
|
743
1183
|
if (parsed !== null) {
|
|
744
1184
|
const loaded = normalizeData(parsed)
|
|
1185
|
+
if (loaded.rejectedVersion !== undefined) {
|
|
1186
|
+
// A legacy file in a future format is not ours to migrate or judge.
|
|
1187
|
+
state.storageError = '旧位置文件为不支持的格式版本 ' + loaded.rejectedVersion + ',已忽略'
|
|
1188
|
+
return false
|
|
1189
|
+
}
|
|
745
1190
|
state.data = loaded.data
|
|
746
|
-
state.storageWarning =
|
|
747
|
-
|
|
1191
|
+
state.storageWarning = loadWarnings(loaded)
|
|
1192
|
+
const migrated = await writeToDisk()
|
|
1193
|
+
if (migrated) {
|
|
748
1194
|
state.migratedFrom = legacyPath
|
|
749
1195
|
// Keep the original as evidence instead of deleting user data.
|
|
750
1196
|
await rename(legacyPath, legacyPath + '.migrated').catch(() => {})
|
|
751
1197
|
}
|
|
752
1198
|
state.loadedFrom = 'legacy cwd file (migrated)'
|
|
753
|
-
return
|
|
1199
|
+
return migrated
|
|
754
1200
|
}
|
|
755
1201
|
} catch (err) {
|
|
756
1202
|
// A missing or unreadable legacy file must never block a fresh store.
|
|
@@ -770,6 +1216,7 @@ export function apply(ctx) {
|
|
|
770
1216
|
needsMaterialize = true
|
|
771
1217
|
state.loadedFrom = 'first run (no store on disk yet)'
|
|
772
1218
|
state.storeExists = false
|
|
1219
|
+
return false
|
|
773
1220
|
}
|
|
774
1221
|
|
|
775
1222
|
/**
|
|
@@ -789,11 +1236,19 @@ export function apply(ctx) {
|
|
|
789
1236
|
// Load the store, then register the section if anything must be injected.
|
|
790
1237
|
// Every entry point awaits this, so a request that arrives before the disk
|
|
791
1238
|
// read settles can never overwrite the file with an empty in-memory state.
|
|
1239
|
+
//
|
|
1240
|
+
// `bootSettled` flips only after the read lands. The SSE connect frame is
|
|
1241
|
+
// gated on it: a stream opened in the gap between apply() and the read
|
|
1242
|
+
// finishing must wait, or its first frame would describe the EMPTY boot
|
|
1243
|
+
// state and the panel would flash "no instructions" before the real
|
|
1244
|
+
// snapshot arrived (POST /state already awaited boot; the stream did not).
|
|
1245
|
+
let bootSettled = false
|
|
792
1246
|
const boot = readFromDisk()
|
|
793
1247
|
.catch((err) => {
|
|
794
1248
|
state.storageError = '读取失败:' + describeError(err)
|
|
795
1249
|
})
|
|
796
1250
|
.then(() => {
|
|
1251
|
+
bootSettled = true
|
|
797
1252
|
syncSection()
|
|
798
1253
|
// The one always-on line: it answers "is memory mounted, where does it
|
|
799
1254
|
// live, and how much is being injected?" without any further digging.
|
|
@@ -809,6 +1264,9 @@ export function apply(ctx) {
|
|
|
809
1264
|
+ ' section=' + (chars > 0 ? chars + ' chars (pending assembly)' : 'nothing to inject')
|
|
810
1265
|
+ ' pulls=' + sectionPulls
|
|
811
1266
|
+ (state.storageError !== null ? ' error=' + state.storageError : ''))
|
|
1267
|
+
// Arm the external-change watcher once the store is known to exist. A
|
|
1268
|
+
// first run has nothing to watch yet; the first successful write rearms.
|
|
1269
|
+
if (state.storeExists) startWatching()
|
|
812
1270
|
})
|
|
813
1271
|
|
|
814
1272
|
/**
|
|
@@ -833,6 +1291,7 @@ export function apply(ctx) {
|
|
|
833
1291
|
return {
|
|
834
1292
|
data: {
|
|
835
1293
|
version: 1,
|
|
1294
|
+
rev: state.data.rev || 0,
|
|
836
1295
|
enabled: state.data.enabled === true,
|
|
837
1296
|
budgetChars: clampBudget(state.data.budgetChars),
|
|
838
1297
|
entries: (Array.isArray(state.data.entries) ? state.data.entries : []).map((entry) => ({ ...entry })),
|
|
@@ -859,7 +1318,13 @@ export function apply(ctx) {
|
|
|
859
1318
|
pulls: sectionPulls,
|
|
860
1319
|
},
|
|
861
1320
|
preview,
|
|
862
|
-
|
|
1321
|
+
/**
|
|
1322
|
+
* `maxBodyChars` is the route's request-body ceiling, published so the
|
|
1323
|
+
* Client can refuse an obviously oversized import file BEFORE reading it
|
|
1324
|
+
* into memory. It is a character budget against byte sizes, so the
|
|
1325
|
+
* Client applies a generous multiplier — see importSizeVerdict there.
|
|
1326
|
+
*/
|
|
1327
|
+
limits: { minBudget: MIN_BUDGET, maxBudget: MAX_BUDGET, maxEntries: MAX_ENTRIES, maxBodyChars: MAX_BODY_CHARS },
|
|
863
1328
|
}
|
|
864
1329
|
}
|
|
865
1330
|
|
|
@@ -872,6 +1337,41 @@ export function apply(ctx) {
|
|
|
872
1337
|
|
|
873
1338
|
/* ---------------- commands (shared by route and callers) ---------------- */
|
|
874
1339
|
|
|
1340
|
+
/**
|
|
1341
|
+
* Optimistic concurrency: a mutating command may carry `baseRev` — the
|
|
1342
|
+
* `data.rev` its caller last saw. A mismatch means another window or process
|
|
1343
|
+
* committed in between, and applying this command now would build on a stale
|
|
1344
|
+
* view (e.g. resurrect an entry another tab just deleted). The refusal hands
|
|
1345
|
+
* back the FRESH snapshot so the panel resyncs immediately; the user retries
|
|
1346
|
+
* and the retry carries the new rev.
|
|
1347
|
+
*
|
|
1348
|
+
* Absent `baseRev` (older clients) disables the check entirely — legacy
|
|
1349
|
+
* behaviour, not an error.
|
|
1350
|
+
*/
|
|
1351
|
+
const isStaleBaseRev = (baseRev) =>
|
|
1352
|
+
typeof baseRev === 'number' && Number.isFinite(baseRev) && baseRev !== (state.data.rev || 0)
|
|
1353
|
+
|
|
1354
|
+
const conflictResponse = () => ({
|
|
1355
|
+
ok: false,
|
|
1356
|
+
saved: false,
|
|
1357
|
+
conflict: true,
|
|
1358
|
+
message: '指令记忆刚被其他窗口或进程修改,已为你载入最新内容;请检查后重试本次操作',
|
|
1359
|
+
snapshot: snapshot(),
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Refusal while the store on disk declares an unsupported format version:
|
|
1364
|
+
* a save here would rewrite the file as v1 and destroy whatever the newer
|
|
1365
|
+
* format meant. The file stays untouched until the user upgrades the plugin
|
|
1366
|
+
* or restores a v1 file and hits 重新载入.
|
|
1367
|
+
*/
|
|
1368
|
+
const versionRefusal = () => ({
|
|
1369
|
+
ok: false,
|
|
1370
|
+
saved: false,
|
|
1371
|
+
message: '存储文件为不支持的格式版本 ' + state.rejectedVersion + ',修改已暂停:现在保存会用版本 1 覆盖它,可能损坏数据。请先升级插件,或手动将文件改回版本 1 后点「重新载入」',
|
|
1372
|
+
snapshot: snapshot(),
|
|
1373
|
+
})
|
|
1374
|
+
|
|
875
1375
|
/**
|
|
876
1376
|
* Apply an in-memory mutation, persist it, and UNDO it when the write fails.
|
|
877
1377
|
*
|
|
@@ -896,6 +1396,14 @@ export function apply(ctx) {
|
|
|
896
1396
|
// dropped-entries warning from load time ("they will be removed on the
|
|
897
1397
|
// next save") has played out and must not keep showing.
|
|
898
1398
|
state.storageWarning = null
|
|
1399
|
+
// The write is real, so every OTHER open panel must hear about it. The
|
|
1400
|
+
// file watcher stays silent for this plugin's own writes (disk now
|
|
1401
|
+
// equals memory, see onStoreFileEvent), which is correct for recovery —
|
|
1402
|
+
// but a second window on the same host would otherwise sit on its stale
|
|
1403
|
+
// view until someone clicks 重新载入. The initiating window receives the
|
|
1404
|
+
// same frame and drops it client-side (identical content), so pushing to
|
|
1405
|
+
// everyone is safe and closing the loop needs no per-client bookkeeping.
|
|
1406
|
+
pushSnapshot()
|
|
899
1407
|
return true
|
|
900
1408
|
}
|
|
901
1409
|
logDebug('commit rolled back: the disk write failed, in-memory state restored')
|
|
@@ -905,7 +1413,9 @@ export function apply(ctx) {
|
|
|
905
1413
|
return false
|
|
906
1414
|
}
|
|
907
1415
|
|
|
908
|
-
const saveEntry = (raw) => enqueue(async () => {
|
|
1416
|
+
const saveEntry = (raw, baseRev) => enqueue(async () => {
|
|
1417
|
+
if (state.versionRejected) return versionRefusal()
|
|
1418
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
909
1419
|
const entry = sanitizeEntry(raw)
|
|
910
1420
|
if (entry === null) return { ok: false, saved: false, message: '标题与指令内容不能同时为空', snapshot: snapshot() }
|
|
911
1421
|
entry.updatedAt = Date.now()
|
|
@@ -928,14 +1438,25 @@ export function apply(ctx) {
|
|
|
928
1438
|
return settle(note, saved)
|
|
929
1439
|
})
|
|
930
1440
|
|
|
931
|
-
const deleteEntry = (id) => enqueue(async () => {
|
|
1441
|
+
const deleteEntry = (id, baseRev) => enqueue(async () => {
|
|
1442
|
+
if (state.versionRejected) return versionRefusal()
|
|
1443
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
1444
|
+
// Deleting an id that is not there changes nothing, so it must not touch
|
|
1445
|
+
// disk, bump rev, or push an echo frame — otherwise two panels deleting
|
|
1446
|
+
// the same entry one after another would manufacture a pointless write
|
|
1447
|
+
// (and an external process watching the file would wake for nothing).
|
|
1448
|
+
if (!state.data.entries.some((entry) => entry.id === id)) {
|
|
1449
|
+
return { ok: true, saved: false, message: null, snapshot: snapshot() }
|
|
1450
|
+
}
|
|
932
1451
|
const saved = await commit(() => {
|
|
933
1452
|
state.data.entries = state.data.entries.filter((entry) => entry.id !== id)
|
|
934
1453
|
})
|
|
935
1454
|
return settle(null, saved)
|
|
936
1455
|
})
|
|
937
1456
|
|
|
938
|
-
const setOptions = (patch) => enqueue(async () => {
|
|
1457
|
+
const setOptions = (patch, baseRev) => enqueue(async () => {
|
|
1458
|
+
if (state.versionRejected) return versionRefusal()
|
|
1459
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
939
1460
|
const saved = await commit(() => {
|
|
940
1461
|
if (patch !== null && typeof patch === 'object') {
|
|
941
1462
|
if (typeof patch.enabled === 'boolean') state.data.enabled = patch.enabled
|
|
@@ -948,14 +1469,19 @@ export function apply(ctx) {
|
|
|
948
1469
|
})
|
|
949
1470
|
|
|
950
1471
|
const reload = () => enqueue(async () => {
|
|
951
|
-
|
|
1472
|
+
// `saved` is only true when the load itself wrote to disk (the
|
|
1473
|
+
// backup-promotion or legacy-migration branch); a plain re-read claiming
|
|
1474
|
+
// true would be indistinguishable from a real save on the wire.
|
|
1475
|
+
const wrote = await readFromDisk()
|
|
952
1476
|
syncSection()
|
|
953
|
-
return { ok: state.storageError === null, saved: true, message: state.storageError, snapshot: snapshot() }
|
|
1477
|
+
return { ok: state.storageError === null, saved: wrote === true, message: state.storageError, snapshot: snapshot() }
|
|
954
1478
|
})
|
|
955
1479
|
|
|
956
1480
|
const exportData = () => enqueue(async () => ({
|
|
957
1481
|
ok: true,
|
|
958
|
-
saved:
|
|
1482
|
+
// An export reads the store and never writes it; a truthful `saved:false`
|
|
1483
|
+
// keeps the field's meaning ("this operation persisted the store") intact.
|
|
1484
|
+
saved: false,
|
|
959
1485
|
message: null,
|
|
960
1486
|
// A self-describing envelope: re-importable by this plugin, and readable
|
|
961
1487
|
// as plain JSON by anything else.
|
|
@@ -963,7 +1489,9 @@ export function apply(ctx) {
|
|
|
963
1489
|
snapshot: snapshot(),
|
|
964
1490
|
}))
|
|
965
1491
|
|
|
966
|
-
const importData = (payload) => enqueue(async () => {
|
|
1492
|
+
const importData = (payload, baseRev) => enqueue(async () => {
|
|
1493
|
+
if (state.versionRejected) return versionRefusal()
|
|
1494
|
+
if (isStaleBaseRev(baseRev)) return conflictResponse()
|
|
967
1495
|
if (payload === null || typeof payload !== 'object') {
|
|
968
1496
|
return { ok: false, saved: false, message: '导入内容不是有效的 JSON 对象', snapshot: snapshot() }
|
|
969
1497
|
}
|
|
@@ -1037,23 +1565,31 @@ export function apply(ctx) {
|
|
|
1037
1565
|
// A first run writes nothing at boot (readFromDisk step 3), so the
|
|
1038
1566
|
// materialisation happens here, on a deliberate user action, instead of
|
|
1039
1567
|
// as a side effect of the host merely starting up.
|
|
1040
|
-
await ensureStoreMaterialized()
|
|
1568
|
+
const materialized = await ensureStoreMaterialized()
|
|
1041
1569
|
// Same envelope as every other method. One response shape means the
|
|
1042
1570
|
// Client can never again be handed a payload it silently ignores.
|
|
1043
|
-
|
|
1571
|
+
// `saved` is true only on the first run, when materialisation actually
|
|
1572
|
+
// wrote the file; every later read wrote nothing and says so.
|
|
1573
|
+
return { ok: true, saved: materialized === true, message: null, snapshot: snapshot() }
|
|
1044
1574
|
}
|
|
1045
1575
|
case 'save-entry':
|
|
1046
|
-
return saveEntry(
|
|
1576
|
+
return saveEntry(
|
|
1577
|
+
args !== null && typeof args === 'object' ? args.entry : null,
|
|
1578
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1047
1579
|
case 'delete-entry':
|
|
1048
|
-
return deleteEntry(
|
|
1580
|
+
return deleteEntry(
|
|
1581
|
+
args !== null && typeof args === 'object' && typeof args.id === 'string' ? args.id : '',
|
|
1582
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1049
1583
|
case 'set-options':
|
|
1050
|
-
return setOptions(args)
|
|
1584
|
+
return setOptions(args, args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1051
1585
|
case 'reload':
|
|
1052
1586
|
return reload()
|
|
1053
1587
|
case 'export-data':
|
|
1054
1588
|
return exportData()
|
|
1055
1589
|
case 'import-data':
|
|
1056
|
-
return importData(
|
|
1590
|
+
return importData(
|
|
1591
|
+
args !== null && typeof args === 'object' ? args.payload : null,
|
|
1592
|
+
args !== null && typeof args === 'object' ? args.baseRev : undefined)
|
|
1057
1593
|
default:
|
|
1058
1594
|
return { ok: false, saved: false, message: '未知方法:' + String(method), snapshot: snapshot() }
|
|
1059
1595
|
}
|
|
@@ -1068,7 +1604,88 @@ export function apply(ctx) {
|
|
|
1068
1604
|
let guardHost = true
|
|
1069
1605
|
|
|
1070
1606
|
let routeDispose = null
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* The live-update stream for the settings page (GET + `Accept:
|
|
1610
|
+
* text/event-stream`, i.e. exactly what an EventSource sends).
|
|
1611
|
+
*
|
|
1612
|
+
* Why the method/accept pair instead of a sub-path: the route is registered
|
|
1613
|
+
* as a plain prefix and how the harness presents `req.url` to it is not part
|
|
1614
|
+
* of any contract, while `req.method` and the Accept header are. A plain GET
|
|
1615
|
+
* (someone navigating to the URL) keeps the old 405.
|
|
1616
|
+
*
|
|
1617
|
+
* Frames carry the bare snapshot — the same shape `state` answers with —
|
|
1618
|
+
* which the Client's normalizer already accepts.
|
|
1619
|
+
*/
|
|
1620
|
+
const handleEventStream = (req, res) => {
|
|
1621
|
+
const verdict = requestOriginVerdict(req.headers, guardHost)
|
|
1622
|
+
if (!verdict.ok) {
|
|
1623
|
+
logInfo('route rejected a request: ' + verdict.reason)
|
|
1624
|
+
res.writeHead(403, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
1625
|
+
res.end(JSON.stringify({ ok: false, message: '请求来源被拒绝:' + verdict.reason }))
|
|
1626
|
+
return
|
|
1627
|
+
}
|
|
1628
|
+
res.writeHead(200, {
|
|
1629
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1630
|
+
'cache-control': 'no-store',
|
|
1631
|
+
connection: 'keep-alive',
|
|
1632
|
+
})
|
|
1633
|
+
// Ask the browser to retry politely if the stream drops (e.g. the host was
|
|
1634
|
+
// restarted) instead of hammering the route.
|
|
1635
|
+
res.write('retry: 5000\n\n')
|
|
1636
|
+
const send = (payload) => { res.write(payload) }
|
|
1637
|
+
let closed = false
|
|
1638
|
+
const cleanup = () => {
|
|
1639
|
+
closed = true
|
|
1640
|
+
sseStreams.delete(send)
|
|
1641
|
+
try {
|
|
1642
|
+
res.end()
|
|
1643
|
+
} catch {
|
|
1644
|
+
// The response is already gone.
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
sseStreams.set(send, cleanup)
|
|
1648
|
+
req.on('close', cleanup)
|
|
1649
|
+
req.on('error', cleanup)
|
|
1650
|
+
res.on('error', cleanup)
|
|
1651
|
+
// A snapshot on connect, so a late subscriber synchronises immediately
|
|
1652
|
+
// instead of waiting for the next change. It must wait for boot: a stream
|
|
1653
|
+
// opened while the initial disk read is still in flight would otherwise be
|
|
1654
|
+
// handed the empty boot state as its first frame, flashing "no
|
|
1655
|
+
// instructions" before the loaded store arrives.
|
|
1656
|
+
const sendConnectFrame = () => {
|
|
1657
|
+
if (closed) return
|
|
1658
|
+
try {
|
|
1659
|
+
send('data: ' + JSON.stringify(snapshot()) + '\n\n')
|
|
1660
|
+
} catch {
|
|
1661
|
+
cleanup()
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (bootSettled) {
|
|
1665
|
+
sendConnectFrame()
|
|
1666
|
+
} else {
|
|
1667
|
+
boot.then(sendConnectFrame, sendConnectFrame)
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1071
1671
|
const handler = (req, res) => {
|
|
1672
|
+
if (req.method === 'GET') {
|
|
1673
|
+
const accept = String(
|
|
1674
|
+
(req.headers !== null && typeof req.headers === 'object') ? (req.headers.accept || '') : '',
|
|
1675
|
+
)
|
|
1676
|
+
if (accept.indexOf('text/event-stream') !== -1) {
|
|
1677
|
+
// A call aborted mid-flight makes these streams emit 'error'; a server
|
|
1678
|
+
// stream with no error listener would take the host process down over
|
|
1679
|
+
// one dead HTTP request.
|
|
1680
|
+
req.on('error', () => {})
|
|
1681
|
+
res.on('error', () => {})
|
|
1682
|
+
handleEventStream(req, res)
|
|
1683
|
+
return
|
|
1684
|
+
}
|
|
1685
|
+
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
1686
|
+
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
1687
|
+
return
|
|
1688
|
+
}
|
|
1072
1689
|
if (req.method !== 'POST') {
|
|
1073
1690
|
res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
|
|
1074
1691
|
res.end(JSON.stringify({ ok: false, message: 'only POST is supported' }))
|
|
@@ -1149,12 +1766,16 @@ export function apply(ctx) {
|
|
|
1149
1766
|
return
|
|
1150
1767
|
}
|
|
1151
1768
|
// The Host allowlist only makes sense when the operator bound the server to
|
|
1152
|
-
// loopback. `0.0.0.0
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1157
|
-
|
|
1769
|
+
// loopback. Anything else — `0.0.0.0`/`::` (every interface) or a concrete
|
|
1770
|
+
// address such as `192.168.1.5` — is a deliberate publication beyond this
|
|
1771
|
+
// machine, where no Host value can be known in advance; enforcing a
|
|
1772
|
+
// loopback allowlist there would 403 every legitimate client of that
|
|
1773
|
+
// published deployment. (The previous check compared against `0.0.0.0`
|
|
1774
|
+
// only, which silently broke `::` and concrete-address binds.) The check is
|
|
1775
|
+
// disabled for all non-loopback binds and the trade-off is documented in
|
|
1776
|
+
// the README.
|
|
1777
|
+
const boundHost = typeof webServer.host === 'string' ? webServer.host.trim().toLowerCase() : ''
|
|
1778
|
+
guardHost = boundHost === '' || isLoopbackName(boundHost)
|
|
1158
1779
|
try {
|
|
1159
1780
|
routeDispose = webServer.register({ kind: 'prefix', path: API_PREFIX, handler })
|
|
1160
1781
|
logDebug('settings route registered at ' + API_PREFIX + ' (boundHost=' + boundHost + ', hostGuard=' + guardHost + ')')
|