dsh-zen-remote 2.3.7 → 2.3.9

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 (59) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/docs/interface.md +1 -1
  5. package/docs/remote-access.en.md +1 -1
  6. package/docs/remote-access.md +1 -1
  7. package/lib/adapters/dsh-better-sidebar/index.js +21 -3
  8. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  9. package/lib/client.js +470 -81
  10. package/lib/merge-streams.js +55 -19
  11. package/lib/merge-streams.js.map +1 -1
  12. package/lib/relay-filter.js +4 -3
  13. package/lib/relay-filter.js.map +1 -1
  14. package/lib/relay-server.js +5 -3
  15. package/lib/relay-server.js.map +1 -1
  16. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  17. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  18. package/lib/types/client/MobileHome.d.ts.map +1 -1
  19. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
  20. package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
  21. package/lib/types/client/effects/permission-sheet.d.ts +51 -10
  22. package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
  23. package/lib/types/client/locales.d.ts +1 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
  26. package/lib/types/client/styles/composer.css.d.ts +1 -1
  27. package/lib/types/client/styles/composer.css.d.ts.map +1 -1
  28. package/lib/types/client/styles/header.css.d.ts +1 -1
  29. package/lib/types/client/styles/header.css.d.ts.map +1 -1
  30. package/lib/types/client/styles/layout.css.d.ts +1 -1
  31. package/lib/types/client/styles/layout.css.d.ts.map +1 -1
  32. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
  33. package/lib/types/client-data/new-session.d.ts +26 -0
  34. package/lib/types/client-data/new-session.d.ts.map +1 -0
  35. package/lib/types/client-data/settings-form.d.ts +36 -0
  36. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  37. package/lib/types/merge-streams.d.ts +27 -0
  38. package/lib/types/merge-streams.d.ts.map +1 -1
  39. package/lib/types/relay-filter.d.ts +4 -3
  40. package/lib/types/relay-filter.d.ts.map +1 -1
  41. package/lib/types/relay-server.d.ts.map +1 -1
  42. package/package.json +4 -2
  43. package/scripts/check-settings-form.mjs +63 -0
  44. package/src/adapters/dsh-better-sidebar/index.ts +23 -3
  45. package/src/client/MobileHome.tsx +44 -7
  46. package/src/client/MobileSessionHeader.tsx +7 -1
  47. package/src/client/effects/native-trigger-overlay.ts +50 -11
  48. package/src/client/effects/permission-sheet.ts +199 -22
  49. package/src/client/locales.ts +2 -0
  50. package/src/client/settings/SettingsSection.tsx +43 -1
  51. package/src/client/styles/composer.css.ts +24 -0
  52. package/src/client/styles/header.css.ts +58 -46
  53. package/src/client/styles/layout.css.ts +25 -0
  54. package/src/client/wizard/GatewayWizard.tsx +23 -1
  55. package/src/client-data/new-session.ts +30 -0
  56. package/src/client-data/settings-form.ts +57 -0
  57. package/src/merge-streams.ts +71 -16
  58. package/src/relay-filter.ts +4 -3
  59. package/src/relay-server.ts +5 -3
@@ -33,11 +33,13 @@ import {
33
33
  CLIENT_CONFIG_ROUTE,
34
34
  CLIENT_RECONNECT_ROUTE,
35
35
  CLIENT_STATUS_ROUTE,
36
+ awaitClientStatusReady,
36
37
  claimDeviceNameOf,
37
38
  clientStatusLineOf,
38
39
  deriveClientStatusView,
39
40
  deriveSettingsView,
40
41
  normalizePairingCode,
42
+ shouldReloadAfterClientActivation,
41
43
  } from '../../client-data/settings-form.ts'
42
44
  import { qrMatrix } from '../../client-data/qr.ts'
43
45
  import {
@@ -761,6 +763,12 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
761
763
  const runClientClaim = async (): Promise<void> => {
762
764
  setFormError(null)
763
765
  setClientClaimFail(null)
766
+ // UX-1: the facts that decide the post-pairing reload — whether the page
767
+ // opened as an already-effective client, and whether the backend's
768
+ // stream interceptor was already active (from the last client/status
769
+ // diagnostics). Captured BEFORE the role switch and the claim below.
770
+ const wasEffectiveClient = effectiveClient
771
+ const streamInterceptAlreadyActive = clientView?.intercept?.installed === true
764
772
  const trimmedUrl = clientServerUrl.trim()
765
773
  const normalizedCode = normalizePairingCode(clientPairCode)
766
774
  if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
@@ -801,7 +809,21 @@ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
801
809
  if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
802
810
  const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
803
811
  const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
804
- await config.writeClientPairing(urlToWrite, body.token, echoed)
812
+ const landed = await config.writeClientPairing(urlToWrite, body.token, echoed)
813
+ if (landed && shouldReloadAfterClientActivation({
814
+ wasEffectiveClient,
815
+ nowClientRole: true,
816
+ savedOrPaired: true,
817
+ streamInterceptAlreadyActive,
818
+ })) {
819
+ // UX-1: this was the FIRST client activation while the page's three
820
+ // resident global streams were opened unwrapped — wait out the
821
+ // backend's row-reload window, then reload so the streams reopen
822
+ // wrapped and the server's shared sessions enter the sidebar.
823
+ await awaitClientStatusReady()
824
+ window.location.reload()
825
+ return
826
+ }
805
827
  setClientPairCode('')
806
828
  await loadClientStatus()
807
829
  setStep('verify')
@@ -0,0 +1,30 @@
1
+ /**
2
+ * UX-3 — what tapping the phone home screen's "+ 新建会话" FAB should do.
3
+ *
4
+ * Entering a blank session with NO workspace leaves the phone dangling in
5
+ * the hero's inert "choose a workspace" state (the host's `marshal-main`
6
+ * belongs to no workspace, so the "all" filter often resolves to
7
+ * `undefined`). Decide BEFORE entering:
8
+ *
9
+ * - a workspace is already selected → start there;
10
+ * - exactly one workspace exists (or none at all) → start with it
11
+ * (`undefined` keeps a no-workspace host usable);
12
+ * - several workspaces and none selected → the FAB opens the same
13
+ * bottom-sheet workspace drawer the title bar uses, and the session only
14
+ * starts once one is picked.
15
+ *
16
+ * Pure: no DOM, no imports — MobileHome calls it inside the FAB's onClick
17
+ * and test/permission-sheet.test.cjs drives it directly.
18
+ */
19
+ export type NewSessionAction<W extends string> =
20
+ | { kind: 'start'; workspaceId: W | undefined }
21
+ | { kind: 'pick-workspace' }
22
+
23
+ export function decideNewSessionAction<W extends string>(
24
+ selectedWorkspaceId: W | undefined,
25
+ workspaceIds: readonly W[],
26
+ ): NewSessionAction<W> {
27
+ if (selectedWorkspaceId !== undefined) return { kind: 'start', workspaceId: selectedWorkspaceId }
28
+ if (workspaceIds.length <= 1) return { kind: 'start', workspaceId: workspaceIds[0] }
29
+ return { kind: 'pick-workspace' }
30
+ }
@@ -433,6 +433,63 @@ export function shouldFollowServerDeviceName(deviceName: string, rowName: string
433
433
  return deviceName !== '' && deviceName !== rowName && !hasDraft
434
434
  }
435
435
 
436
+ // --- UX-1: reload after the FIRST client activation ---------------------------
437
+
438
+ /**
439
+ * Whether one successful client-side activation (a staged-save role switch, a
440
+ * first pairing) needs a page reload: the backend's restart-watcher only
441
+ * installs the stream interceptor when the row reloads into client mode, and
442
+ * the page's three resident global streams were opened under whatever gateway
443
+ * object was current AT LOAD TIME. Without a reload they stay unwrapped and
444
+ * the server's remote sessions never enter the merged sidebar lists.
445
+ *
446
+ * - A write that did not land, or a save that is not heading to `client`,
447
+ * never reloads.
448
+ * - A host→client switch always reloads: the streams predate the interceptor.
449
+ * - An already-client page reloads only when the diagnostics say the
450
+ * interceptor was NOT yet active (`streamInterceptAlreadyActive === false`).
451
+ * `undefined` — an older server that answers no diagnostics — is UNKNOWN and
452
+ * stays on the safe no-reload side (a re-pairing must not needlessly reload).
453
+ */
454
+ export interface ClientActivationReloadInput {
455
+ /** Whether the page was ALREADY an effective client before this operation. */
456
+ wasEffectiveClient: boolean
457
+ /** Whether the role after the operation is (or is switching to) client. */
458
+ nowClientRole: boolean
459
+ /** Whether this operation actually landed (the save / pairing write). */
460
+ savedOrPaired: boolean
461
+ /** The client/status diagnostics' interceptor flag before the operation
462
+ * (derived by the caller); `undefined` when unknown. */
463
+ streamInterceptAlreadyActive?: boolean
464
+ }
465
+
466
+ export function shouldReloadAfterClientActivation(input: ClientActivationReloadInput): boolean {
467
+ if (!input.savedOrPaired || !input.nowClientRole) return false
468
+ if (!input.wasEffectiveClient) return true
469
+ if (input.streamInterceptAlreadyActive === false) return true
470
+ return false
471
+ }
472
+
473
+ /**
474
+ * Poll {@link CLIENT_STATUS_ROUTE} until the backend answers ok — the bounded
475
+ * wait before the post-activation reload, covering the restart-watcher's row
476
+ * reload window (the route 404s / fails while the row restarts). Resolves
477
+ * after `maxMs` regardless, so the reload always fires. Injectable fetch and
478
+ * timings keep the check script fast; defaults are the page's 3 s / 250 ms.
479
+ */
480
+ export async function awaitClientStatusReady(fetchFn: typeof fetch = fetch, maxMs = 3000, intervalMs = 250): Promise<void> {
481
+ const deadline = Date.now() + maxMs
482
+ while (true) {
483
+ try {
484
+ if ((await fetchFn(CLIENT_STATUS_ROUTE)).ok) return
485
+ } catch {
486
+ // the row is mid-reload; keep polling
487
+ }
488
+ if (Date.now() >= deadline) return
489
+ await new Promise((resolve) => setTimeout(resolve, intervalMs))
490
+ }
491
+ }
492
+
436
493
  /**
437
494
  * Latest-wins sequencing for the status loads: every request takes a ticket,
438
495
  * and only the newest ticket may still apply its result. An earlier request
@@ -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) => {