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.
- package/CHANGELOG.md +41 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +1 -1
- package/docs/remote-access.en.md +1 -1
- package/docs/remote-access.md +1 -1
- package/lib/adapters/dsh-better-sidebar/index.js +21 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client.js +470 -81
- package/lib/merge-streams.js +55 -19
- package/lib/merge-streams.js.map +1 -1
- package/lib/relay-filter.js +4 -3
- package/lib/relay-filter.js.map +1 -1
- package/lib/relay-server.js +5 -3
- package/lib/relay-server.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/effects/native-trigger-overlay.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +51 -10
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +1 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/composer.css.d.ts +1 -1
- package/lib/types/client/styles/composer.css.d.ts.map +1 -1
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client-data/new-session.d.ts +26 -0
- package/lib/types/client-data/new-session.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +36 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +27 -0
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/relay-filter.d.ts +4 -3
- package/lib/types/relay-filter.d.ts.map +1 -1
- package/lib/types/relay-server.d.ts.map +1 -1
- package/package.json +4 -2
- package/scripts/check-settings-form.mjs +63 -0
- package/src/adapters/dsh-better-sidebar/index.ts +23 -3
- package/src/client/MobileHome.tsx +44 -7
- package/src/client/MobileSessionHeader.tsx +7 -1
- package/src/client/effects/native-trigger-overlay.ts +50 -11
- package/src/client/effects/permission-sheet.ts +199 -22
- package/src/client/locales.ts +2 -0
- package/src/client/settings/SettingsSection.tsx +43 -1
- package/src/client/styles/composer.css.ts +24 -0
- package/src/client/styles/header.css.ts +58 -46
- package/src/client/styles/layout.css.ts +25 -0
- package/src/client/wizard/GatewayWizard.tsx +23 -1
- package/src/client-data/new-session.ts +30 -0
- package/src/client-data/settings-form.ts +57 -0
- package/src/merge-streams.ts +71 -16
- package/src/relay-filter.ts +4 -3
- 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
|
package/src/merge-streams.ts
CHANGED
|
@@ -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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
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 []
|
package/src/relay-filter.ts
CHANGED
|
@@ -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,
|
|
92
|
-
* value, seq
|
|
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
|
package/src/relay-server.ts
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
2533
|
+
void writeLine({ type: 'frame', frame: controlProjectionFrame(sessionId, key, values[key], seq) })
|
|
2532
2534
|
}
|
|
2533
2535
|
}
|
|
2534
2536
|
unsubscribe = store.subscribe((event) => {
|