dsh-zen-remote 2.3.6 → 2.3.8

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.
Files changed (80) hide show
  1. package/AGENTS.md +15 -5
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +2 -2
  4. package/README.zh-CN.md +2 -2
  5. package/docs/interface.md +10 -4
  6. package/docs/remote-access.en.md +6 -0
  7. package/docs/remote-access.md +6 -0
  8. package/lan-gate.mjs +7 -0
  9. package/lib/client-pairing.js +10 -0
  10. package/lib/client-pairing.js.map +1 -1
  11. package/lib/client-routes.js +183 -94
  12. package/lib/client-routes.js.map +1 -1
  13. package/lib/client.js +505 -78
  14. package/lib/debug-redact.js +253 -0
  15. package/lib/debug-redact.js.map +1 -0
  16. package/lib/debug-report.js +60 -0
  17. package/lib/debug-report.js.map +1 -0
  18. package/lib/fetch-route-intercept.js +23 -0
  19. package/lib/fetch-route-intercept.js.map +1 -1
  20. package/lib/index.js +293 -45
  21. package/lib/index.js.map +1 -1
  22. package/lib/log-ring.js +76 -0
  23. package/lib/log-ring.js.map +1 -0
  24. package/lib/merge-streams.js +55 -19
  25. package/lib/merge-streams.js.map +1 -1
  26. package/lib/relay-filter.js +4 -3
  27. package/lib/relay-filter.js.map +1 -1
  28. package/lib/relay-server.js +5 -3
  29. package/lib/relay-server.js.map +1 -1
  30. package/lib/types/client/effects/remote-drop.d.ts +20 -8
  31. package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +8 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  35. package/lib/types/client/styles/misc.css.d.ts +1 -1
  36. package/lib/types/client/styles/misc.css.d.ts.map +1 -1
  37. package/lib/types/client-data/remote-drop.d.ts +64 -0
  38. package/lib/types/client-data/remote-drop.d.ts.map +1 -1
  39. package/lib/types/client-data/settings-form.d.ts +3 -0
  40. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  41. package/lib/types/client-pairing.d.ts +8 -0
  42. package/lib/types/client-pairing.d.ts.map +1 -1
  43. package/lib/types/client-routes.d.ts +23 -0
  44. package/lib/types/client-routes.d.ts.map +1 -1
  45. package/lib/types/debug-redact.d.ts +68 -0
  46. package/lib/types/debug-redact.d.ts.map +1 -0
  47. package/lib/types/debug-report.d.ts +58 -0
  48. package/lib/types/debug-report.d.ts.map +1 -0
  49. package/lib/types/fetch-route-intercept.d.ts +26 -0
  50. package/lib/types/fetch-route-intercept.d.ts.map +1 -1
  51. package/lib/types/index.d.ts +7 -1
  52. package/lib/types/index.d.ts.map +1 -1
  53. package/lib/types/log-ring.d.ts +57 -0
  54. package/lib/types/log-ring.d.ts.map +1 -0
  55. package/lib/types/merge-streams.d.ts +27 -0
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/relay-filter.d.ts +4 -3
  58. package/lib/types/relay-filter.d.ts.map +1 -1
  59. package/lib/types/relay-server.d.ts.map +1 -1
  60. package/package.json +6 -3
  61. package/pwa/app.css +13 -2
  62. package/scripts/check-debug-report.mjs +277 -0
  63. package/scripts/check-remote-drop.mjs +130 -0
  64. package/scripts/check-tablet-safe-area.mjs +49 -0
  65. package/src/client/effects/remote-drop.ts +271 -24
  66. package/src/client/locales.ts +20 -4
  67. package/src/client/settings/SettingsSection.tsx +120 -3
  68. package/src/client/styles/misc.css.ts +38 -0
  69. package/src/client-data/remote-drop.ts +103 -0
  70. package/src/client-data/settings-form.ts +3 -0
  71. package/src/client-pairing.ts +11 -0
  72. package/src/client-routes.ts +196 -90
  73. package/src/debug-redact.ts +290 -0
  74. package/src/debug-report.ts +93 -0
  75. package/src/fetch-route-intercept.ts +29 -0
  76. package/src/index.ts +282 -43
  77. package/src/log-ring.ts +101 -0
  78. package/src/merge-streams.ts +71 -16
  79. package/src/relay-filter.ts +4 -3
  80. package/src/relay-server.ts +5 -3
@@ -0,0 +1,101 @@
1
+ /**
2
+ * CU-1: the plugin's in-process diagnostic log ring (SPEC §方案 C). A bounded
3
+ * FIFO of the plugin row's own log lines, kept in memory only — the desktop
4
+ * app writes no log file, so this is the one artifact the debug report (CU-2)
5
+ * can read on either role. Deliberately NOT a file, and deliberately only
6
+ * this plugin's lines: DSH's own log stream stays untouched.
7
+ *
8
+ * Pure module: no DSH imports, no I/O, no clocks beyond the timestamp it
9
+ * stamps entries with. The wiring lives in src/index.ts, which funnels its
10
+ * `logWarn` / `logInfo` through {@link createPluginLog} so existing call
11
+ * sites enter the ring unchanged (double write: logger first, ring second),
12
+ * and pushes the handful of lifecycle events (row apply/reload, gateway
13
+ * spawn/exit, relay link flips, fetch-route installs) by hand.
14
+ *
15
+ * One discipline the debug report leans on: ring lines carry event codes,
16
+ * id shapes and counters — never payloads. The redactor (debug-redact.ts)
17
+ * is the second layer, not the license to log secrets here.
18
+ */
19
+
20
+ import { format } from 'node:util'
21
+
22
+ /** Severity of one ring entry. The ring only distinguishes what the report
23
+ * renders: info lines and warnings. */
24
+ export type LogLevel = 'info' | 'warn'
25
+
26
+ /** One ring entry: ISO timestamp, level, fully interpolated message. */
27
+ export interface LogRingEntry {
28
+ t: string
29
+ level: LogLevel
30
+ msg: string
31
+ }
32
+
33
+ /** Longest message kept, in characters (UTF-16 code units). Debug lines are
34
+ * codes and shapes, not dumps — anything longer was the wrong thing to log. */
35
+ const MAX_MSG_CHARS = 512
36
+
37
+ /** The ring's face. `push` never throws and never returns anything; callers
38
+ * log through it exactly like they would through the context logger. */
39
+ export interface LogRing {
40
+ push(level: LogLevel, formatText: string, ...args: unknown[]): void
41
+ /** Oldest-last copy of the ring. Mutating the returned array or its
42
+ * entries never reaches the ring's own state. */
43
+ snapshot(): LogRingEntry[]
44
+ }
45
+
46
+ /**
47
+ * Create one bounded log ring. `cap` defaults to 300 entries — a full debug
48
+ * session of lifecycle lines plus a healthy share of warnings, while keeping
49
+ * the report a fixed small size. A cap below 1 is clamped to 1.
50
+ */
51
+ export function createLogRing(cap = 300): LogRing {
52
+ const capacity = Number.isFinite(cap) && cap >= 1 ? Math.floor(cap) : 1
53
+ const entries: LogRingEntry[] = []
54
+ return {
55
+ push(level, formatText, ...args) {
56
+ let msg: string
57
+ try {
58
+ msg = args.length === 0 ? String(formatText) : format(formatText, ...args)
59
+ } catch {
60
+ // A malformed format string must never break the caller's flow —
61
+ // keep the raw template as the message.
62
+ msg = String(formatText)
63
+ }
64
+ if (msg.length > MAX_MSG_CHARS) msg = msg.slice(0, MAX_MSG_CHARS)
65
+ entries.push({ t: new Date().toISOString(), level, msg })
66
+ if (entries.length > capacity) entries.splice(0, entries.length - capacity)
67
+ },
68
+ snapshot() {
69
+ return entries.map((entry) => ({ ...entry }))
70
+ },
71
+ }
72
+ }
73
+
74
+ /** The logger face this module needs: the cordis context logger's two
75
+ * methods, each optional (test fakes carry none). */
76
+ export interface PluginLogLike {
77
+ info?(format: string, ...args: unknown[]): void
78
+ warn?(format: string, ...args: unknown[]): void
79
+ }
80
+
81
+ /** The double-writer the apply() wiring funnels its logging through: every
82
+ * line reaches the context logger (unchanged behavior, unchanged call
83
+ * sites) AND the ring (the debug report's source). A ring failure is
84
+ * swallowed; a logger failure propagates exactly as it did before this
85
+ * module existed. */
86
+ export function createPluginLog(ring: LogRing, logger: PluginLogLike | undefined): { info(format: string, ...args: unknown[]): void, warn(format: string, ...args: unknown[]): void } {
87
+ return {
88
+ info(formatText, ...args) {
89
+ logger?.info?.(formatText, ...args)
90
+ try {
91
+ ring.push('info', formatText, ...args)
92
+ } catch { /* the ring must never break logging */ }
93
+ },
94
+ warn(formatText, ...args) {
95
+ logger?.warn?.(formatText, ...args)
96
+ try {
97
+ ring.push('warn', formatText, ...args)
98
+ } catch { /* the ring must never break logging */ }
99
+ },
100
+ }
101
+ }
@@ -968,6 +968,49 @@ export interface ControlMerger {
968
968
  retarget(identity: MergerIdentity): void
969
969
  }
970
970
 
971
+ /** One `session/control` projection change, independent of the wire shape. */
972
+ export interface ControlProjectionChange {
973
+ key: string
974
+ value: unknown
975
+ }
976
+
977
+ /**
978
+ * Read the changes of one live `session/control` `projection` frame in either
979
+ * wire shape. DSH 0.2.2-alpha.3 batched the frame per committed event —
980
+ * `{type:'projection', sessionId, seq, changes:[{key, val}]}` (its client
981
+ * face's handleControlFrame iterates `frame.changes`) — where 0.2.1 and
982
+ * 0.2.2-alpha.2 send one key per frame, `{type:'projection', sessionId, key,
983
+ * value, seq}` (that client face reads `frame.key` / `frame.value`). A
984
+ * `changes` array wins when present; `undefined` means neither shape is
985
+ * usable (a malformed entry spoils the whole frame, like every other
986
+ * malformed frame here).
987
+ */
988
+ export function controlProjectionChanges(frame: Record<string, unknown>): ControlProjectionChange[] | undefined {
989
+ if (Array.isArray(frame.changes)) {
990
+ const out: ControlProjectionChange[] = []
991
+ for (const change of frame.changes) {
992
+ if (!isPlainObject(change) || typeof change.key !== 'string' || !('val' in change)) return undefined
993
+ out.push({ key: change.key, value: change.val })
994
+ }
995
+ return out
996
+ }
997
+ if (typeof frame.key === 'string' && 'value' in frame) return [{ key: frame.key, value: frame.value }]
998
+ return undefined
999
+ }
1000
+
1001
+ /**
1002
+ * One single-key `session/control` projection frame that BOTH client
1003
+ * generations apply: the per-key `key` / `value` fields for DSH ≤ 0.2.2-alpha.2
1004
+ * and a one-entry `changes` list for ≥ 0.2.2-alpha.3. Neither client face
1005
+ * validates stream frames (the gateway client only runs a binary `decode`),
1006
+ * and each reads only its own fields, so the extra ones are inert. Splitting a
1007
+ * batched frame into one frame per key at the same seq is lossless: the
1008
+ * projection store applies per key (`seq <= row.seq` per row).
1009
+ */
1010
+ export function controlProjectionFrame(sessionId: string, key: string, value: unknown, seq: number): Record<string, unknown> {
1011
+ return { type: 'projection', sessionId, key, value, seq, changes: [{ key, val: value }] }
1012
+ }
1013
+
971
1014
  /**
972
1015
  * The session/control merger. The control stream is key-value projection
973
1016
  * traffic with no ordering constraints between sessions, so it needs no
@@ -1016,13 +1059,14 @@ export function createControlMerger(options: ControlMergerOptions): ControlMerge
1016
1059
  // UNCONDITIONALLY (T52-fix3): the host's projection store is
1017
1060
  // first-write-wins per seq, so a catalog-gated original value would
1018
1061
  // occupy the seq and the later virtual rewrite could never land.
1019
- out.push({
1020
- type: 'projection',
1021
- sessionId: virtualize(sessionId),
1022
- key,
1023
- value: key === 'modelSelection' ? virtualizeModelSelectionValue(values[key], identity.serverId) : values[key],
1024
- seq: asOfSeq,
1025
- })
1062
+ out.push(
1063
+ controlProjectionFrame(
1064
+ virtualize(sessionId),
1065
+ key,
1066
+ key === 'modelSelection' ? virtualizeModelSelectionValue(values[key], identity.serverId) : values[key],
1067
+ asOfSeq,
1068
+ ),
1069
+ )
1026
1070
  }
1027
1071
  }
1028
1072
  return out
@@ -1038,18 +1082,29 @@ export function createControlMerger(options: ControlMergerOptions): ControlMerge
1038
1082
  onDiagnostic?.(`dropped a session/control projection frame with unusable seq ${String(frame.seq)}`)
1039
1083
  return []
1040
1084
  }
1041
- return [
1042
- {
1043
- ...frame,
1044
- sessionId: virtualize(frame.sessionId),
1085
+ // DSH 0.2.2-alpha.3 batches one event's changes into `changes[]`;
1086
+ // older hosts send one key per frame. Read either, then emit one
1087
+ // dual-shape frame per key so a local UI of EITHER generation applies
1088
+ // it (a mixed local/server pair is only a fingerprint warning, the
1089
+ // stream still flows).
1090
+ const changes = controlProjectionChanges(frame)
1091
+ if (changes === undefined) {
1092
+ onDiagnostic?.('dropped a session/control projection frame with neither key/value nor a changes list')
1093
+ return []
1094
+ }
1095
+ const sessionId = virtualize(frame.sessionId)
1096
+ const seq = frame.seq
1097
+ return changes.map(({ key, value }) =>
1098
+ controlProjectionFrame(
1099
+ sessionId,
1100
+ key,
1045
1101
  // The modelSelection update's providers go virtual with the session
1046
1102
  // id (T52) — same unconditional rewrite as the exploded baseline
1047
1103
  // above (T52-fix3: first-write-wins per seq forbids a catalog gate).
1048
- ...(frame.key === 'modelSelection'
1049
- ? { value: virtualizeModelSelectionValue(frame.value, identity.serverId) }
1050
- : {}),
1051
- },
1052
- ]
1104
+ key === 'modelSelection' ? virtualizeModelSelectionValue(value, identity.serverId) : value,
1105
+ seq,
1106
+ ),
1107
+ )
1053
1108
  }
1054
1109
  onDiagnostic?.(`dropped a session/control frame of unknown type ${String(frame.type)}`)
1055
1110
  return []
@@ -88,9 +88,10 @@ export function filterWorkspaceFrame(frame: unknown, isAccessible: Accessibility
88
88
  /**
89
89
  * Filter one `session/control` frame. Exactly two frame types exist:
90
90
  * `baseline{value:{projections: Record<sessionId, {asOfSeq, values}>}}` — the
91
- * per-session map keeps only accessible keys — and `projection{sessionId, key,
92
- * value, seq}` — kept whole or dropped by its session id. Anything else is
93
- * dropped.
91
+ * per-session map keeps only accessible keys — and `projection{sessionId, …}`
92
+ * (per-key `key, value, seq` up to DSH 0.2.2-alpha.2, batched `seq,
93
+ * changes[]` from alpha.3) — kept whole or dropped by its session id, so either
94
+ * shape passes untouched. Anything else is dropped.
94
95
  *
95
96
  * The baseline record is rebuilt with `defineProperty` because a session id of
96
97
  * `__proto__` must become an own data property of the new record, not fire the
@@ -27,6 +27,7 @@
27
27
  */
28
28
 
29
29
  import { withPeer } from './owner-peer.js'
30
+ import { controlProjectionFrame } from './merge-streams.js'
30
31
  import { randomBytes, timingSafeEqual } from 'node:crypto'
31
32
  import { Readable, Transform } from 'node:stream'
32
33
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
@@ -2508,8 +2509,9 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2508
2509
  })
2509
2510
  } else if (decision.filter === 'control') {
2510
2511
  // A newly shared session needs its current projections: fetch
2511
- // them once and emit one synthesized `projection` frame per key,
2512
- // shaped exactly like the upstream frames. An unshared session
2512
+ // them once and emit one synthesized `projection` frame per key in
2513
+ // the dual shape both DSH generations read (per-key key/value for
2514
+ // ≤ 0.2.2-alpha.2, a one-entry `changes` list for ≥ alpha.3). An unshared session
2513
2515
  // synthesizes nothing — the client drops it via the workspace
2514
2516
  // stream's re-filtered upserts.
2515
2517
  const syncProjections = async (sessionId: string): Promise<void> => {
@@ -2528,7 +2530,7 @@ export function createRelayHandler(options: RelayHandlerOptions): RelayHandler {
2528
2530
  const values = isPlainObject(result.values) ? result.values : {}
2529
2531
  const seq = typeof result.asOfSeq === 'number' ? result.asOfSeq : 0
2530
2532
  for (const key of Object.keys(values)) {
2531
- void writeLine({ type: 'frame', frame: { type: 'projection', sessionId, key, value: values[key], seq } })
2533
+ void writeLine({ type: 'frame', frame: controlProjectionFrame(sessionId, key, values[key], seq) })
2532
2534
  }
2533
2535
  }
2534
2536
  unsubscribe = store.subscribe((event) => {