dsh-zen-remote 1.1.16 → 2.0.0
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/AGENTS.md +54 -9
- package/README.md +249 -190
- package/README.zh-CN.md +247 -201
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +12 -11
- package/cordis.patch.yml.example +24 -9
- package/docs/interface.md +57 -2
- package/docs/remote-access.en.md +99 -58
- package/docs/remote-access.md +99 -42
- package/dsh-push.mjs +67 -54
- package/lan-gate.mjs +88 -20
- package/lib/activity.js +168 -0
- package/lib/activity.js.map +1 -0
- package/lib/admin-routes.js +526 -0
- package/lib/admin-routes.js.map +1 -0
- package/lib/client-pairing.js +224 -0
- package/lib/client-pairing.js.map +1 -0
- package/lib/client-routes.js +674 -0
- package/lib/client-routes.js.map +1 -0
- package/lib/client.js +10051 -4777
- package/lib/config.js +346 -0
- package/lib/config.js.map +1 -0
- package/lib/fetch-route-intercept.js +418 -0
- package/lib/fetch-route-intercept.js.map +1 -0
- package/lib/fingerprint.js +363 -0
- package/lib/fingerprint.js.map +1 -0
- package/lib/http.js +69 -0
- package/lib/http.js.map +1 -0
- package/lib/index.js +675 -54
- package/lib/index.js.map +1 -1
- package/lib/intercept-shape.js +81 -0
- package/lib/intercept-shape.js.map +1 -0
- package/lib/intercept.js +3040 -0
- package/lib/intercept.js.map +1 -0
- package/lib/lan-gate-server.cjs +532 -121
- package/lib/merge-streams.js +1165 -0
- package/lib/merge-streams.js.map +1 -0
- package/lib/relay-access.js +496 -0
- package/lib/relay-access.js.map +1 -0
- package/lib/relay-client.js +1303 -0
- package/lib/relay-client.js.map +1 -0
- package/lib/relay-filter.js +282 -0
- package/lib/relay-filter.js.map +1 -0
- package/lib/relay-server.js +1792 -0
- package/lib/relay-server.js.map +1 -0
- package/lib/restart-fields.js +34 -0
- package/lib/restart-fields.js.map +1 -0
- package/lib/restart-watcher.js +106 -0
- package/lib/restart-watcher.js.map +1 -0
- package/lib/session-reference.js +157 -0
- package/lib/session-reference.js.map +1 -0
- package/lib/share-export.js +16 -1
- package/lib/share-export.js.map +1 -1
- package/lib/share-ops.js +86 -0
- package/lib/share-ops.js.map +1 -0
- package/lib/share-store.js +294 -0
- package/lib/share-store.js.map +1 -0
- package/lib/types/activity.d.ts +99 -0
- package/lib/types/activity.d.ts.map +1 -0
- package/lib/types/admin-routes.d.ts +129 -0
- package/lib/types/admin-routes.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -1
- package/lib/types/client/MobileHome.d.ts +1 -1
- package/lib/types/client/MobileHome.d.ts.map +1 -1
- package/lib/types/client/MobileHomeChips.d.ts +1 -1
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +13 -2
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts +20 -3
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/RemoteComposerBanner.d.ts +42 -0
- package/lib/types/client/RemoteComposerBanner.d.ts.map +1 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts +37 -0
- package/lib/types/client/RemoteHeaderIcon.d.ts.map +1 -0
- package/lib/types/client/RemoteShareMenu.d.ts +28 -0
- package/lib/types/client/RemoteShareMenu.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts +36 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -0
- package/lib/types/client/compat/desktop.d.ts +21 -0
- package/lib/types/client/compat/desktop.d.ts.map +1 -0
- package/lib/types/client/compat/types.d.ts +57 -0
- package/lib/types/client/compat/types.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +37 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/effects/permission-sheet.d.ts +39 -0
- package/lib/types/client/effects/permission-sheet.d.ts.map +1 -0
- package/lib/types/client/effects/remote-session.d.ts +25 -0
- package/lib/types/client/effects/remote-session.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +9 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +187 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/remote-fetch.d.ts +51 -0
- package/lib/types/client/remote-fetch.d.ts.map +1 -0
- package/lib/types/client/remote-group-expansion.d.ts +41 -0
- package/lib/types/client/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client/remote-share-css.d.ts +19 -0
- package/lib/types/client/remote-share-css.d.ts.map +1 -0
- package/lib/types/client/remote-share-register.d.ts +113 -0
- package/lib/types/client/remote-share-register.d.ts.map +1 -0
- package/lib/types/client/remote-status-css.d.ts +15 -0
- package/lib/types/client/remote-status-css.d.ts.map +1 -0
- package/lib/types/client/remote-status-register.d.ts +46 -0
- package/lib/types/client/remote-status-register.d.ts.map +1 -0
- package/lib/types/client/settings/SettingsSection.d.ts +46 -0
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -0
- package/lib/types/client/settings/register-settings.d.ts +58 -0
- package/lib/types/client/settings/register-settings.d.ts.map +1 -0
- package/lib/types/client/settings/settings-css.d.ts +10 -0
- package/lib/types/client/settings/settings-css.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.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/info.css.d.ts +1 -1
- package/lib/types/client/styles/info.css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +2 -0
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -0
- package/lib/types/client-data/current-session.d.ts +41 -0
- package/lib/types/client-data/current-session.d.ts.map +1 -0
- package/lib/types/client-data/remote-group-expansion.d.ts +76 -0
- package/lib/types/client-data/remote-group-expansion.d.ts.map +1 -0
- package/lib/types/client-data/remote-status.d.ts +148 -0
- package/lib/types/client-data/remote-status.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +774 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -0
- package/lib/types/client-data/shares.d.ts +211 -0
- package/lib/types/client-data/shares.d.ts.map +1 -0
- package/lib/types/client-pairing.d.ts +114 -0
- package/lib/types/client-pairing.d.ts.map +1 -0
- package/lib/types/client-routes.d.ts +112 -0
- package/lib/types/client-routes.d.ts.map +1 -0
- package/lib/types/config.d.ts +200 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +156 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -0
- package/lib/types/fingerprint.d.ts +164 -0
- package/lib/types/fingerprint.d.ts.map +1 -0
- package/lib/types/http.d.ts +41 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/index.d.ts +56 -24
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept-shape.d.ts +32 -0
- package/lib/types/intercept-shape.d.ts.map +1 -0
- package/lib/types/intercept.d.ts +432 -0
- package/lib/types/intercept.d.ts.map +1 -0
- package/lib/types/merge-streams.d.ts +393 -0
- package/lib/types/merge-streams.d.ts.map +1 -0
- package/lib/types/relay-access.d.ts +310 -0
- package/lib/types/relay-access.d.ts.map +1 -0
- package/lib/types/relay-client.d.ts +274 -0
- package/lib/types/relay-client.d.ts.map +1 -0
- package/lib/types/relay-filter.d.ts +103 -0
- package/lib/types/relay-filter.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +208 -0
- package/lib/types/relay-server.d.ts.map +1 -0
- package/lib/types/restart-fields.d.ts +22 -0
- package/lib/types/restart-fields.d.ts.map +1 -0
- package/lib/types/restart-watcher.d.ts +83 -0
- package/lib/types/restart-watcher.d.ts.map +1 -0
- package/lib/types/session-reference.d.ts +66 -0
- package/lib/types/session-reference.d.ts.map +1 -0
- package/lib/types/share-export.d.ts +14 -1
- package/lib/types/share-export.d.ts.map +1 -1
- package/lib/types/share-ops.d.ts +79 -0
- package/lib/types/share-ops.d.ts.map +1 -0
- package/lib/types/share-store.d.ts +87 -0
- package/lib/types/share-store.d.ts.map +1 -0
- package/lib/types/virtual-id.d.ts +38 -0
- package/lib/types/virtual-id.d.ts.map +1 -0
- package/lib/virtual-id.js +52 -0
- package/lib/virtual-id.js.map +1 -0
- package/llms.txt +17 -14
- package/package.json +60 -50
- package/scripts/build-client.mjs +5 -3
- package/scripts/check-host-shadow.mjs +19 -2
- package/scripts/check-settings-form.mjs +933 -0
- package/scripts/check-shares.mjs +397 -0
- package/scripts/check-upload-endpoint.mjs +59 -3
- package/scripts/sync-doc-version.mjs +8 -15
- package/scripts/verify-fingerprint-live.mjs +111 -0
- package/src/activity.ts +218 -0
- package/src/admin-routes.ts +627 -0
- package/src/client/MobileAttachButton.tsx +2 -2
- package/src/client/MobileAttachChips.tsx +3 -3
- package/src/client/MobileDrawerFooter.tsx +7 -4
- package/src/client/MobileHome.tsx +20 -16
- package/src/client/MobileHomeChips.tsx +16 -16
- package/src/client/MobileNavOverlay.tsx +2 -2
- package/src/client/MobileNavToggle.tsx +3 -3
- package/src/client/MobileSessionHeader.tsx +63 -42
- package/src/client/MobileSessionInfo.tsx +118 -17
- package/src/client/RemoteComposerBanner.tsx +76 -0
- package/src/client/RemoteHeaderIcon.tsx +103 -0
- package/src/client/RemoteShareMenu.tsx +57 -0
- package/src/client/RemoteStatusIcon.tsx +91 -0
- package/src/client/compat/desktop.ts +22 -0
- package/src/client/compat/types.ts +62 -0
- package/src/client/current-session-report.ts +164 -0
- package/src/client/effects/header-status.ts +21 -17
- package/src/client/effects/keyboard-guard.ts +65 -1
- package/src/client/effects/permission-sheet.ts +149 -0
- package/src/client/effects/remote-session.ts +68 -0
- package/src/client/effects/turn-fold.ts +52 -0
- package/src/client/index.tsx +151 -36
- package/src/client/locales.ts +396 -2
- package/src/client/remote-fetch.ts +125 -0
- package/src/client/remote-group-expansion.ts +190 -0
- package/src/client/remote-share-css.ts +54 -0
- package/src/client/remote-share-register.ts +244 -0
- package/src/client/remote-status-css.ts +64 -0
- package/src/client/remote-status-register.ts +107 -0
- package/src/client/settings/SettingsSection.tsx +1229 -0
- package/src/client/settings/register-settings.ts +116 -0
- package/src/client/settings/settings-css.ts +236 -0
- package/src/client/share/share-preview.tsx +6 -3
- package/src/client/share/share-sheet.tsx +3 -3
- package/src/client/styles/composer.css.ts +119 -26
- package/src/client/styles/header.css.ts +7 -7
- package/src/client/styles/info.css.ts +74 -0
- package/src/client/styles/remote-session.css.ts +251 -0
- package/src/client-data/current-session.ts +57 -0
- package/src/client-data/remote-group-expansion.ts +145 -0
- package/src/client-data/remote-status.ts +374 -0
- package/src/client-data/settings-form.ts +1371 -0
- package/src/client-data/shares.ts +467 -0
- package/src/client-pairing.ts +244 -0
- package/src/client-routes.ts +732 -0
- package/src/config.ts +447 -0
- package/src/fetch-route-intercept.ts +510 -0
- package/src/fingerprint.ts +408 -0
- package/src/http.ts +68 -0
- package/src/index.ts +719 -62
- package/src/intercept-shape.ts +87 -0
- package/src/intercept.ts +3362 -0
- package/src/merge-streams.ts +1272 -0
- package/src/relay-access.ts +669 -0
- package/src/relay-client.ts +1514 -0
- package/src/relay-filter.ts +291 -0
- package/src/relay-server.ts +2025 -0
- package/src/restart-fields.ts +36 -0
- package/src/restart-watcher.ts +140 -0
- package/src/session-reference.ts +156 -0
- package/src/share-export.ts +26 -1
- package/src/share-ops.ts +114 -0
- package/src/share-store.ts +373 -0
- package/src/virtual-id.ts +59 -0
|
@@ -0,0 +1,1371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged configuration form for the `dsh-zen-remote` plugin row, in the same
|
|
3
|
+
* shape the dsh-llm-verifier page uses (a control stages what the user types,
|
|
4
|
+
* one save writes every staged edit as a single revision-fenced path mutation
|
|
5
|
+
* through the shared `configForms` form). Browser-import-free so tests drive
|
|
6
|
+
* it directly:
|
|
7
|
+
*
|
|
8
|
+
* - `deriveSettingsView(status)` maps the `GET /_dsh/zen-remote/admin/status`
|
|
9
|
+
* body (T14's same-origin route wrapping the gateway's local admin API) into
|
|
10
|
+
* what the settings block renders: per-field effective value + source layer,
|
|
11
|
+
* the device list, the live pairing code with its remaining seconds, and the
|
|
12
|
+
* `viaGateway` flag that disables every server-local operation.
|
|
13
|
+
* - `deriveClientStatusView(status)` maps the `GET /_dsh/zen-remote/client/status`
|
|
14
|
+
* body (T16's sub-client probe) into the client group's connection line.
|
|
15
|
+
* - `ZenRemoteSettingsForm` stages the row-layer field edits and saves them as
|
|
16
|
+
* one `mutate(ops, expectedRevision)` call, and owns the two DIRECT writes
|
|
17
|
+
* of the client group (pairing write / token clear) plus the device token's
|
|
18
|
+
* configured flag read from the describe view's secrets sidecar. Since T17
|
|
19
|
+
* it also carries the page's two-level role decision — the saved row role
|
|
20
|
+
* first, the client-config probe as fallback — and the `restartPending`
|
|
21
|
+
* flag behind the "saving reloads the plugin" note.
|
|
22
|
+
*
|
|
23
|
+
* The wire contract lives here because the host half that serves it is a
|
|
24
|
+
* separate task; the shapes mirror `lib/lan-gate-server.cjs`'s status payload
|
|
25
|
+
* (devices / pairing) plus the T14 envelope (ok / gateway / config / viaGateway).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { RESTART_FIELDS } from '../restart-fields.ts'
|
|
29
|
+
|
|
30
|
+
// --- wire shapes -----------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Where a resolved field value came from (mirror of src/config.ts's union; the client half cannot import the host module). */
|
|
33
|
+
export type ConfigSource = 'env' | 'row' | 'file' | 'default'
|
|
34
|
+
|
|
35
|
+
/** One paired gateway device, as the gateway's status payload lists it. */
|
|
36
|
+
export interface AdminDevice {
|
|
37
|
+
id: string
|
|
38
|
+
name: string
|
|
39
|
+
role: 'web' | 'desktop-client'
|
|
40
|
+
kind: 'auto' | 'phone' | 'desktop'
|
|
41
|
+
createdAt: number
|
|
42
|
+
lastSeen: number
|
|
43
|
+
ua?: string
|
|
44
|
+
hasPush: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The one currently-live pairing code, if any. */
|
|
48
|
+
export interface AdminPairing {
|
|
49
|
+
code: string
|
|
50
|
+
expiresAt: number
|
|
51
|
+
role: 'web' | 'desktop-client'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The gateway's `/lan-gate/status` reply, relayed verbatim inside
|
|
56
|
+
* `admin/status`'s `gateway` field (see lib/lan-gate-server.cjs's
|
|
57
|
+
* statusHandler — this mirror keeps only what the settings block reads).
|
|
58
|
+
*/
|
|
59
|
+
export interface GatewayStatus {
|
|
60
|
+
state?: string
|
|
61
|
+
port?: number
|
|
62
|
+
target?: string
|
|
63
|
+
devices?: AdminDevice[]
|
|
64
|
+
pairing?: AdminPairing | null
|
|
65
|
+
pushSubscriptions?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `GET /_dsh/zen-remote/admin/status` body, exactly as src/admin-routes.ts
|
|
70
|
+
* answers it: the gateway payload lives NESTED under `gateway` (null when the
|
|
71
|
+
* gateway did not answer healthily) and `gatewayStatus` is the HTTP status the
|
|
72
|
+
* route saw (`null` when there was no answer at all).
|
|
73
|
+
*/
|
|
74
|
+
export interface AdminStatusBody {
|
|
75
|
+
ok?: boolean
|
|
76
|
+
gateway?: GatewayStatus | null
|
|
77
|
+
gatewayReachable?: boolean
|
|
78
|
+
gatewayStatus?: number | null
|
|
79
|
+
config?: {
|
|
80
|
+
values?: Record<string, unknown>
|
|
81
|
+
sources?: Record<string, string>
|
|
82
|
+
}
|
|
83
|
+
viaGateway?: boolean
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Same-origin admin routes the settings block talks to (host half: T14). */
|
|
87
|
+
export const ADMIN_STATUS_ROUTE = '/_dsh/zen-remote/admin/status'
|
|
88
|
+
export const ADMIN_PAIR_ROUTE = '/_dsh/zen-remote/admin/pair'
|
|
89
|
+
export const ADMIN_ACTION_ROUTE = '/_dsh/zen-remote/admin/action'
|
|
90
|
+
export const ADMIN_PUSH_TEST_ROUTE = '/_dsh/zen-remote/admin/push-test'
|
|
91
|
+
|
|
92
|
+
/** Same-origin client routes the sub-client block talks to (host half: T16). */
|
|
93
|
+
export const CLIENT_CLAIM_ROUTE = '/_dsh/zen-remote/client/claim'
|
|
94
|
+
export const CLIENT_STATUS_ROUTE = '/_dsh/zen-remote/client/status'
|
|
95
|
+
/** T43: one immediate reconnect — answered 409 unless the client is offline. */
|
|
96
|
+
export const CLIENT_RECONNECT_ROUTE = '/_dsh/zen-remote/client/reconnect'
|
|
97
|
+
|
|
98
|
+
/** The lightweight client-facing config route (host half, both roles): the
|
|
99
|
+
* settings page's FALLBACK role probe (T17) — it registers wherever a
|
|
100
|
+
* webServer exists, and since T17 its body carries the effective `role`
|
|
101
|
+
* (resolveConfig's merged value, nothing sensitive). */
|
|
102
|
+
export const CLIENT_CONFIG_ROUTE = '/_dsh/mobile-nav/client-config'
|
|
103
|
+
|
|
104
|
+
/** The body of `GET /_dsh/mobile-nav/client-config` — the interface-half knobs
|
|
105
|
+
* the client bundle reads, plus (T17) the effective role the settings page
|
|
106
|
+
* falls back to. */
|
|
107
|
+
export interface ClientConfigBody {
|
|
108
|
+
role?: unknown
|
|
109
|
+
turnFoldDesktop?: boolean
|
|
110
|
+
keyboardLiftRatio?: number
|
|
111
|
+
keyboardLiftMaxPx?: number
|
|
112
|
+
keyboardSafetyPadPx?: number
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Field name of the row secret the pairing flow writes (never echoed back
|
|
116
|
+
* anywhere; the describe view's secrets sidecar is the only "is it set"). */
|
|
117
|
+
export const DEVICE_TOKEN_FIELD = 'deviceToken'
|
|
118
|
+
|
|
119
|
+
// --- client-half wire shapes -------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The `GET /_dsh/zen-remote/client/status` body, exactly as
|
|
123
|
+
* src/client-routes.ts answers it: `serverUrl` is present only once a token
|
|
124
|
+
* exists (the unpaired answer is `{ state: 'unpaired' }` alone). The token
|
|
125
|
+
* itself never rides any status response. The T43 diagnostics fields ride
|
|
126
|
+
* only on a failed live connect of a wired relay client; `intercept` (T23b)
|
|
127
|
+
* and `compat` (T42) are rendered only when the body carries them — earlier
|
|
128
|
+
* servers answer neither.
|
|
129
|
+
*/
|
|
130
|
+
export interface ClientStatusBody {
|
|
131
|
+
/** The probe vocabulary (`connected` / `unreachable` / `revoked` /
|
|
132
|
+
* `unexpected`), the row's two own answers (`unpaired` / `invalid-url`),
|
|
133
|
+
* the relay verdicts (`revoked` / `incompatible`) — and, since the
|
|
134
|
+
* relay-wired route reports the relay client's own state verbatim, the
|
|
135
|
+
* `online` / `offline` words too ({@link deriveClientStatusView} maps
|
|
136
|
+
* them; T55). */
|
|
137
|
+
state?: string
|
|
138
|
+
serverUrl?: string
|
|
139
|
+
serverName?: unknown
|
|
140
|
+
/** Epoch ms of the relay client's next automatic reconnect (offline only). */
|
|
141
|
+
nextRetryAt?: unknown
|
|
142
|
+
/** The error code of the last failure — a code, never a message or URL. */
|
|
143
|
+
lastError?: unknown
|
|
144
|
+
/**
|
|
145
|
+
* The SERVER's record of THIS device's name (T59, presence-gated like the
|
|
146
|
+
* diagnostics fields — older servers answer without it). The settings page
|
|
147
|
+
* follows it into the row while the user is not editing the field.
|
|
148
|
+
*/
|
|
149
|
+
deviceName?: unknown
|
|
150
|
+
/** T23b request-interceptor diagnostics (provisional shape; presence-gated). */
|
|
151
|
+
intercept?: unknown
|
|
152
|
+
/** T42 relay compat diagnostics: `{ identical: string[], different:
|
|
153
|
+
* string[], unavailable: string[], incompatibleCalls: { time: number,
|
|
154
|
+
* endpoint: string, code: string }[] }` (presence-gated). */
|
|
155
|
+
compat?: unknown
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** One remote-call failure line in the diagnostics lists. `time` is epoch
|
|
159
|
+
* milliseconds — ISO-stamped rings are parsed at derive time; `method`
|
|
160
|
+
* carries the wire's method-or-endpoint name. */
|
|
161
|
+
export interface ClientDiagFailureView {
|
|
162
|
+
time: number
|
|
163
|
+
method: string
|
|
164
|
+
code: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* T23b interceptor diagnostics as the block renders them. The wire field is
|
|
169
|
+
* presence-gated: `undefined` here means the body carried none (an older
|
|
170
|
+
* server), and the whole interceptor group stays hidden.
|
|
171
|
+
*/
|
|
172
|
+
export interface ClientInterceptView {
|
|
173
|
+
installed: boolean
|
|
174
|
+
/** Shape-detection failure reasons — non-empty means remote features are off. */
|
|
175
|
+
reasons: string[]
|
|
176
|
+
/** The most recent remote call failures (at most 10). */
|
|
177
|
+
recentFailures: ClientDiagFailureView[]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** T42 compat diagnostics as the block renders them (presence-gated like {@link ClientInterceptView}). */
|
|
181
|
+
export interface ClientCompatView {
|
|
182
|
+
/** Names of the groups whose fingerprints differ. */
|
|
183
|
+
mismatchedGroups: string[]
|
|
184
|
+
/** The most recent incompatible calls (at most 10). */
|
|
185
|
+
recentCalls: ClientDiagFailureView[]
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** One client connection as the block renders it. */
|
|
189
|
+
export interface ClientConnectionView {
|
|
190
|
+
state: 'unpaired' | 'connected' | 'revoked' | 'unreachable' | 'unexpected' | 'invalid-url' | 'incompatible'
|
|
191
|
+
serverUrl: string
|
|
192
|
+
serverName: string
|
|
193
|
+
/** Verbatim from the body when it carried a finite number; the countdown
|
|
194
|
+
* math happens at render time against the live clock. */
|
|
195
|
+
nextRetryAt: number | undefined
|
|
196
|
+
/** The last failure's code, '' when none is reported. */
|
|
197
|
+
lastError: string
|
|
198
|
+
/** The SERVER's record of this device's name (T59): '' when the body did
|
|
199
|
+
* not carry one — empty means "nothing to follow". */
|
|
200
|
+
deviceName: string
|
|
201
|
+
intercept: ClientInterceptView | undefined
|
|
202
|
+
compat: ClientCompatView | undefined
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const CLIENT_STATES: readonly ClientConnectionView['state'][] = ['unpaired', 'connected', 'revoked', 'unreachable', 'unexpected', 'invalid-url', 'incompatible']
|
|
206
|
+
|
|
207
|
+
/** Wire `state` words the status route can answer that are NOT the view
|
|
208
|
+
* vocabulary (T55): the relay client's `online` is the page's `connected`,
|
|
209
|
+
* and its `offline` is the probe vocabulary's `unreachable` — the offline
|
|
210
|
+
* line, whose countdown the body's `nextRetryAt` backs when present. */
|
|
211
|
+
const WIRE_STATE_ALIASES: Readonly<Record<string, ClientConnectionView['state']>> = {
|
|
212
|
+
online: 'connected',
|
|
213
|
+
offline: 'unreachable',
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** At most `cap` strings out of an array-shaped value; anything else is none. */
|
|
217
|
+
function stringListOf(value: unknown, cap: number): string[] {
|
|
218
|
+
if (!Array.isArray(value)) return []
|
|
219
|
+
return value.filter((entry): entry is string => typeof entry === 'string').slice(0, cap)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The MOST RECENT 10 diagnostics failure rows out of an array-shaped value
|
|
224
|
+
* (the rings are oldest-first, so the tail is the fresh end). Both wire
|
|
225
|
+
* dialects render: the T42 compat ring stamps epoch-millisecond numbers
|
|
226
|
+
* under `endpoint`, the T23b interceptor ring ISO strings under the same
|
|
227
|
+
* key — times parse leniently either way, and an unparseable value renders
|
|
228
|
+
* as 0 rather than dropping the row.
|
|
229
|
+
*/
|
|
230
|
+
function failureListOf(value: unknown): ClientDiagFailureView[] {
|
|
231
|
+
if (!Array.isArray(value)) return []
|
|
232
|
+
const rows: ClientDiagFailureView[] = []
|
|
233
|
+
for (const entry of value.slice(-10)) {
|
|
234
|
+
const record = asRecord(entry)
|
|
235
|
+
let time = 0
|
|
236
|
+
if (typeof record.time === 'number' && Number.isFinite(record.time)) time = record.time
|
|
237
|
+
else if (typeof record.time === 'string') {
|
|
238
|
+
const parsed = Date.parse(record.time)
|
|
239
|
+
if (Number.isFinite(parsed)) time = parsed
|
|
240
|
+
}
|
|
241
|
+
rows.push({
|
|
242
|
+
time,
|
|
243
|
+
method: asStringSet(record.method ?? record.endpoint),
|
|
244
|
+
code: asStringSet(record.code),
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
return rows
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** T23b interceptor block, only when the body carried the field at all. */
|
|
251
|
+
function deriveInterceptView(value: unknown): ClientInterceptView | undefined {
|
|
252
|
+
if (value === undefined || value === null) return undefined
|
|
253
|
+
const record = asRecord(value)
|
|
254
|
+
return {
|
|
255
|
+
installed: record.installed === true,
|
|
256
|
+
reasons: stringListOf(record.reasons, 20),
|
|
257
|
+
recentFailures: failureListOf(record.recentFailures),
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** T42 compat block, only when the body carried the field at all. The wire
|
|
262
|
+
* names are the status route's (`different` / `incompatibleCalls`); the view
|
|
263
|
+
* keeps the render-side names the block was written against. */
|
|
264
|
+
function deriveCompatView(value: unknown): ClientCompatView | undefined {
|
|
265
|
+
if (value === undefined || value === null) return undefined
|
|
266
|
+
const record = asRecord(value)
|
|
267
|
+
return {
|
|
268
|
+
mismatchedGroups: stringListOf(record.different, 20),
|
|
269
|
+
recentCalls: failureListOf(record.incompatibleCalls),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Map one `client/status` body into the view the client group renders.
|
|
275
|
+
* Tolerant like {@link deriveSettingsView}: an unexpected shape degrades to
|
|
276
|
+
* "unpaired" instead of throwing into the plugin page.
|
|
277
|
+
*/
|
|
278
|
+
export function deriveClientStatusView(body: ClientStatusBody): ClientConnectionView {
|
|
279
|
+
const safe = body !== null && typeof body === 'object' ? body : {}
|
|
280
|
+
// T55: the route answers TWO vocabularies. The probe fallback speaks the
|
|
281
|
+
// view's own words (connected / unreachable / …, ProbeState), but the
|
|
282
|
+
// relay-wired route reports the RELAY client's state — `online` verbatim
|
|
283
|
+
// (client-routes.ts sends `state: relay.state` on the cached-verdict and
|
|
284
|
+
// fresh-connect paths) — which the view never listed, so a PAIRED, CONNECTED
|
|
285
|
+
// client fell through the unknown-state bucket into "未配对" while the
|
|
286
|
+
// diagnostics below it showed live relay calls. `offline` rides the same
|
|
287
|
+
// mapping to the probe word for it; anything else unknown still degrades
|
|
288
|
+
// to `unpaired`.
|
|
289
|
+
const rawState = typeof safe.state === 'string' ? safe.state : ''
|
|
290
|
+
const state = CLIENT_STATES.includes(rawState as ClientConnectionView['state'])
|
|
291
|
+
? rawState as ClientConnectionView['state']
|
|
292
|
+
: WIRE_STATE_ALIASES[rawState] ?? 'unpaired'
|
|
293
|
+
const nextRetryAt = typeof safe.nextRetryAt === 'number' && Number.isFinite(safe.nextRetryAt)
|
|
294
|
+
? safe.nextRetryAt
|
|
295
|
+
: undefined
|
|
296
|
+
return {
|
|
297
|
+
state,
|
|
298
|
+
serverUrl: typeof safe.serverUrl === 'string' ? safe.serverUrl : '',
|
|
299
|
+
serverName: typeof safe.serverName === 'string' ? safe.serverName : '',
|
|
300
|
+
nextRetryAt,
|
|
301
|
+
lastError: typeof safe.lastError === 'string' ? safe.lastError : '',
|
|
302
|
+
deviceName: typeof safe.deviceName === 'string' ? safe.deviceName : '',
|
|
303
|
+
intercept: deriveInterceptView(safe.intercept),
|
|
304
|
+
compat: deriveCompatView(safe.compat),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Which connection line the block renders (T43 diagnostics wording, chosen
|
|
310
|
+
* as a pure descriptor so the copy table and the countdown math stay
|
|
311
|
+
* testable without a browser): connected prefers the handshake's server
|
|
312
|
+
* name; any verdict the body backs with a `nextRetryAt` is OFFLINE first —
|
|
313
|
+
* the relay client is mid-retry and the line counts down to it, whether the
|
|
314
|
+
* probe classified the failure `unreachable` or `unexpected` (T43-fix);
|
|
315
|
+
* without a `nextRetryAt` the two map one plain copy each. The rest map one
|
|
316
|
+
* state each.
|
|
317
|
+
*/
|
|
318
|
+
export type ClientStatusLine =
|
|
319
|
+
| { kind: 'connectedName', serverName: string }
|
|
320
|
+
| { kind: 'connected', serverUrl: string }
|
|
321
|
+
| { kind: 'offlineRetry', seconds: number }
|
|
322
|
+
| { kind: 'offlineRetrySoon' }
|
|
323
|
+
| { kind: 'unreachable' }
|
|
324
|
+
| { kind: 'revoked' }
|
|
325
|
+
| { kind: 'incompatible' }
|
|
326
|
+
| { kind: 'unexpected' }
|
|
327
|
+
| { kind: 'invalidUrl' }
|
|
328
|
+
| { kind: 'unpaired' }
|
|
329
|
+
|
|
330
|
+
export function clientStatusLineOf(view: ClientConnectionView, now: number): ClientStatusLine {
|
|
331
|
+
switch (view.state) {
|
|
332
|
+
case 'connected':
|
|
333
|
+
return view.serverName !== ''
|
|
334
|
+
? { kind: 'connectedName', serverName: view.serverName }
|
|
335
|
+
: { kind: 'connected', serverUrl: view.serverUrl }
|
|
336
|
+
case 'unreachable':
|
|
337
|
+
case 'unexpected':
|
|
338
|
+
if (view.nextRetryAt !== undefined) {
|
|
339
|
+
const seconds = Math.max(0, Math.ceil((view.nextRetryAt - now) / 1000))
|
|
340
|
+
return seconds > 0 ? { kind: 'offlineRetry', seconds } : { kind: 'offlineRetrySoon' }
|
|
341
|
+
}
|
|
342
|
+
return view.state === 'unexpected' ? { kind: 'unexpected' } : { kind: 'unreachable' }
|
|
343
|
+
case 'revoked': return { kind: 'revoked' }
|
|
344
|
+
case 'incompatible': return { kind: 'incompatible' }
|
|
345
|
+
case 'invalid-url': return { kind: 'invalidUrl' }
|
|
346
|
+
default: return { kind: 'unpaired' }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The `POST /_dsh/zen-remote/client/claim` body (T16's pairing round-trip;
|
|
351
|
+
* on success `token` is the gateway-minted device token — it appears exactly
|
|
352
|
+
* once, on its way into the row's secret field). */
|
|
353
|
+
export interface ClaimRouteBody {
|
|
354
|
+
ok?: boolean
|
|
355
|
+
token?: unknown
|
|
356
|
+
deviceId?: unknown
|
|
357
|
+
deviceName?: unknown
|
|
358
|
+
/** The normalized address the backend validated — what the form writes. */
|
|
359
|
+
serverUrl?: unknown
|
|
360
|
+
code?: string
|
|
361
|
+
message?: string
|
|
362
|
+
retryAfterMs?: number
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** What the pairing-code box keeps as its draft: uppercase, no spaces or
|
|
366
|
+
* hyphens (the gateway strips every other character at claim time anyway). */
|
|
367
|
+
export function normalizePairingCode(input: string): string {
|
|
368
|
+
return input.toUpperCase().replace(/[\s-]/g, '')
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Whether the settings page may follow the SERVER's record of this device's
|
|
373
|
+
* name into the row (T59): the body carried a name, it differs from the row
|
|
374
|
+
* (nothing to do otherwise — and this equality is the anti-bounce half: the
|
|
375
|
+
* page's own write echoes back through client/status with both sides equal),
|
|
376
|
+
* and the user has no staged draft in the field (their edit wins until they
|
|
377
|
+
* save — a follow mid-typing would clobber it).
|
|
378
|
+
*/
|
|
379
|
+
export function shouldFollowServerDeviceName(deviceName: string, rowName: string, hasDraft: boolean): boolean {
|
|
380
|
+
return deviceName !== '' && deviceName !== rowName && !hasDraft
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Latest-wins sequencing for the status loads: every request takes a ticket,
|
|
385
|
+
* and only the newest ticket may still apply its result. An earlier request
|
|
386
|
+
* that answers LATE is dropped, so a stale body can never overwrite a fresh
|
|
387
|
+
* one (a revoked device reappearing, an old "no pairing code" answer wiping
|
|
388
|
+
* a just-minted code, an unpair racing a refresh).
|
|
389
|
+
*/
|
|
390
|
+
export interface LatestGate {
|
|
391
|
+
/** Issue the ticket for one new in-flight request. */
|
|
392
|
+
next(): number
|
|
393
|
+
/** Whether that ticket is still the newest issued one. */
|
|
394
|
+
isLatest(ticket: number): boolean
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function createLatestGate(): LatestGate {
|
|
398
|
+
let current = 0
|
|
399
|
+
return {
|
|
400
|
+
next: () => { current += 1; return current },
|
|
401
|
+
isLatest: (ticket) => ticket === current,
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// --- T16-fix 3: the page's role and status-source decisions -------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The SAVED role of the plugin row, read from the configForms snapshot's row
|
|
409
|
+
* document — the settings page's FIRST role signal (T16-fix 3). The resolved
|
|
410
|
+
* role an admin/status body reports is unusable here: a client deployment has
|
|
411
|
+
* no admin route at all, and a stale kept body pinned the page to the old
|
|
412
|
+
* role after a switch. `value` is the schema-resolved section the Host
|
|
413
|
+
* accepted; a row value that only lives in the raw user layer reads from
|
|
414
|
+
* there. Since T17 every row field is volatile, so the form (and this
|
|
415
|
+
* snapshot) carries every stored field — `role` included, with any volatile
|
|
416
|
+
* wrapper already peeled by the host's plainConfig (the dsh-settings
|
|
417
|
+
* describe path calls `value.get()` recursively before the wire).
|
|
418
|
+
*
|
|
419
|
+
* `undefined` when NEITHER layer carries a role: the field's value lives only
|
|
420
|
+
* in a lower layer (`lan-gate.config.json`) or nowhere. That is not "host" —
|
|
421
|
+
* the caller falls back to the effective role the client-config route
|
|
422
|
+
* reports ({@link settingsPollOf}), because a file-layer client row must
|
|
423
|
+
* still render the client page.
|
|
424
|
+
*/
|
|
425
|
+
export function savedRowRole(snapshot: { value?: unknown, user?: unknown }): 'host' | 'client' | undefined {
|
|
426
|
+
if (asRecord(snapshot.value).role === 'client') return 'client'
|
|
427
|
+
if (asRecord(snapshot.user).role === 'client') return 'client'
|
|
428
|
+
if (asRecord(snapshot.value).role === 'host') return 'host'
|
|
429
|
+
if (asRecord(snapshot.user).role === 'host') return 'host'
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The EFFECTIVE role for one scope snapshot — the shared two-level decision
|
|
435
|
+
* every role-aware surface uses (the settings page via {@link settingsPollOf},
|
|
436
|
+
* the T33b session-sharing parts through the remote-share registration):
|
|
437
|
+
* while the namespace mirror is still loading the role is not knowable
|
|
438
|
+
* (`'unknown'` — a client deployment must never see a wasted `admin/*` 404);
|
|
439
|
+
* a snapshot whose row carries a role decides from it; a row-silent snapshot
|
|
440
|
+
* falls back to `probed`, the effective role fetched from
|
|
441
|
+
* `/_dsh/mobile-nav/client-config` (T17: the route carries the merged role),
|
|
442
|
+
* and stays `'unknown'` until that probe answers.
|
|
443
|
+
*/
|
|
444
|
+
export function settingsRoleOf(
|
|
445
|
+
status: 'loading' | 'ready' | 'unavailable',
|
|
446
|
+
snapshot: { value?: unknown, user?: unknown },
|
|
447
|
+
probed?: 'host' | 'client',
|
|
448
|
+
): 'unknown' | 'host' | 'client' {
|
|
449
|
+
if (status === 'loading') return 'unknown'
|
|
450
|
+
return savedRowRole(snapshot) ?? probed ?? 'unknown'
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Which status source the page polls for one scope snapshot —
|
|
455
|
+
* {@link settingsRoleOf} mapped onto poll sources (`'unknown'` polls
|
|
456
|
+
* nothing). See there for the decision.
|
|
457
|
+
*/
|
|
458
|
+
export function settingsPollOf(
|
|
459
|
+
status: 'loading' | 'ready' | 'unavailable',
|
|
460
|
+
snapshot: { value?: unknown, user?: unknown },
|
|
461
|
+
probed?: 'host' | 'client',
|
|
462
|
+
): 'none' | 'admin' | 'client' {
|
|
463
|
+
const role = settingsRoleOf(status, snapshot, probed)
|
|
464
|
+
if (role === 'unknown') return 'none'
|
|
465
|
+
return role === 'client' ? 'client' : 'admin'
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// --- T17: the plugin-reload note ------------------------------------------------
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The row fields whose changed value reloads the plugin row after a save —
|
|
472
|
+
* re-exported from src/restart-fields.ts, the single list the host half's
|
|
473
|
+
* restart watcher fingerprints too (T17b collapsed the two copies; the leaf
|
|
474
|
+
* module is dependency-free so the client bundler can inline it). Only the
|
|
475
|
+
* fields this page edits can ever stage a draft here — `targetPort` /
|
|
476
|
+
* `pushEvents` are not page fields — but the list is the full shared set so
|
|
477
|
+
* a host-side change surfaces in the diff.
|
|
478
|
+
*/
|
|
479
|
+
export { RESTART_FIELDS } from '../restart-fields.ts'
|
|
480
|
+
|
|
481
|
+
// --- staged form (row layer) -----------------------------------------------
|
|
482
|
+
|
|
483
|
+
/** One path-addressed edit a save sends (the wire `SettingsPathOpView` shape). */
|
|
484
|
+
export type SettingsFormOp =
|
|
485
|
+
| { op: 'set', path: string[], value: unknown }
|
|
486
|
+
| { op: 'unset', path: string[] }
|
|
487
|
+
|
|
488
|
+
/** Structural subset of the shared `ConfigForm` snapshot this form reads. */
|
|
489
|
+
export interface SettingsFormScopeSnapshot {
|
|
490
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
491
|
+
value: Record<string, unknown> | undefined
|
|
492
|
+
/** Composition layer; what a field reverts to once cleared. */
|
|
493
|
+
base: unknown
|
|
494
|
+
/** Raw user layer as stored; field PRESENCE here marks an override. */
|
|
495
|
+
user: unknown
|
|
496
|
+
/** Revision fencing the next write; sent back as `expectedRevision`. */
|
|
497
|
+
revision: number | undefined
|
|
498
|
+
writable: boolean
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* The form face this controller stages over (structural subset of
|
|
503
|
+
* ui-settings' `ConfigForm` — declared locally because this package does not
|
|
504
|
+
* depend on the ui-settings types).
|
|
505
|
+
*/
|
|
506
|
+
export interface SettingsFormScope {
|
|
507
|
+
getSnapshot(): SettingsFormScopeSnapshot
|
|
508
|
+
subscribe(listener: () => void): () => void
|
|
509
|
+
mutate(ops: readonly SettingsFormOp[], expectedRevision?: number): Promise<boolean>
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* One secret path in the describe view's sidecar: the path plus whether it
|
|
514
|
+
* is set — the VALUE itself never rides any describe surface.
|
|
515
|
+
*/
|
|
516
|
+
export interface DescribeSecret {
|
|
517
|
+
path: string[]
|
|
518
|
+
set: boolean
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The `configForms.describe()` mirror (structural, like {@link ConfigFormsLike}
|
|
523
|
+
* — the dsh-client-ui-settings package is not a dependency). Read only for
|
|
524
|
+
* the secrets sidecar: whether the row's `deviceToken` is set.
|
|
525
|
+
*/
|
|
526
|
+
export interface ConfigFormsDescribe {
|
|
527
|
+
getSnapshot(): {
|
|
528
|
+
view?: { namespaces?: ReadonlyArray<{ ns: string, secrets?: ReadonlyArray<DescribeSecret> }> }
|
|
529
|
+
}
|
|
530
|
+
subscribe(listener: () => void): () => void
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The `configForms` service face this plugin uses, mirrored onto the cordis
|
|
535
|
+
* Context. Declared locally (the dsh-client-ui-settings package is not a
|
|
536
|
+
* dependency — same trick as the local `UiWorkspaceLike`): the runtime
|
|
537
|
+
* instance is provided by the host, and the lazy `ctx.inject(['configForms'], …)`
|
|
538
|
+
* in register-settings keeps a composition without the service loadable.
|
|
539
|
+
*/
|
|
540
|
+
export interface ConfigFormsLike {
|
|
541
|
+
get(entryId: string): SettingsFormScope
|
|
542
|
+
describe(): ConfigFormsDescribe
|
|
543
|
+
whileServed(namespaces: readonly string[], register: (served: ReadonlySet<string>) => () => void): () => void
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** The write one staged draft performs when the form is saved. */
|
|
547
|
+
export type SettingsFieldWrite = { kind: 'set', value: unknown } | { kind: 'clear' }
|
|
548
|
+
|
|
549
|
+
/** How one field converts between its stored value and draft text. */
|
|
550
|
+
export interface SettingsFieldSpec {
|
|
551
|
+
field: string
|
|
552
|
+
format(value: unknown): string
|
|
553
|
+
/** The staged write, or undefined when the draft is not a value this field accepts. */
|
|
554
|
+
parse(text: string): SettingsFieldWrite | undefined
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Free-text field: an empty draft clears, so the field re-inherits its default. */
|
|
558
|
+
export function textField(field: string): SettingsFieldSpec {
|
|
559
|
+
return {
|
|
560
|
+
field,
|
|
561
|
+
format: (value) => (typeof value === 'string' ? value : ''),
|
|
562
|
+
parse: (text) => (text === '' ? { kind: 'clear' } : { kind: 'set', value: text }),
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Whole-number field within inclusive bounds; empty clears, anything else out of range blocks the save. */
|
|
567
|
+
export function intField(field: string, min: number, max: number): SettingsFieldSpec {
|
|
568
|
+
return {
|
|
569
|
+
field,
|
|
570
|
+
format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
|
|
571
|
+
parse: (text) => {
|
|
572
|
+
if (text.trim() === '') return { kind: 'clear' }
|
|
573
|
+
const n = Number(text)
|
|
574
|
+
if (!Number.isInteger(n) || n < min || n > max) return undefined
|
|
575
|
+
return { kind: 'set', value: n }
|
|
576
|
+
},
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Finite number in (min, max] — the `idleHours` shape: zero excluded, 8760 allowed. */
|
|
581
|
+
export function hoursField(field: string, max: number): SettingsFieldSpec {
|
|
582
|
+
return {
|
|
583
|
+
field,
|
|
584
|
+
format: (value) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : ''),
|
|
585
|
+
parse: (text) => {
|
|
586
|
+
if (text.trim() === '') return { kind: 'clear' }
|
|
587
|
+
const n = Number(text)
|
|
588
|
+
if (!Number.isFinite(n) || n <= 0 || n > max) return undefined
|
|
589
|
+
return { kind: 'set', value: n }
|
|
590
|
+
},
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** `serverName`: an empty draft clears; a blank-but-nonempty draft or one over
|
|
595
|
+
* the 40-character cap is invalid and blocks the save (src/config.ts clips at
|
|
596
|
+
* resolve time, so an over-long write would silently lose its tail). */
|
|
597
|
+
export function nameField(field: string, max: number): SettingsFieldSpec {
|
|
598
|
+
return {
|
|
599
|
+
field,
|
|
600
|
+
format: (value) => (typeof value === 'string' ? value : ''),
|
|
601
|
+
parse: (text) => {
|
|
602
|
+
if (text === '') return { kind: 'clear' }
|
|
603
|
+
if (text.trim() === '' || text.length > max) return undefined
|
|
604
|
+
return { kind: 'set', value: text }
|
|
605
|
+
},
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The name one pairing claim registers under (T57, trimmed since T60): the
|
|
611
|
+
* shared 设备名称 field's CURRENT displayed value — a staged draft when one
|
|
612
|
+
* exists — trimmed FIRST and judged on the trimmed text (a 40-character name
|
|
613
|
+
* with padded spaces is legal; whitespace alone is not a name), returned
|
|
614
|
+
* trimmed so what the claim SENDS is exactly what a later save would store.
|
|
615
|
+
* Falls back to the default device-name copy otherwise. The fallback is
|
|
616
|
+
* load-bearing since T55: a client page reads no admin baseline, so an
|
|
617
|
+
* unstored name displays EMPTY and the claim must still carry a usable name.
|
|
618
|
+
*/
|
|
619
|
+
export function claimDeviceNameOf(displayText: string, fallback: string): string {
|
|
620
|
+
const trimmed = displayText.trim()
|
|
621
|
+
return nameField('serverName', SERVER_NAME_MAX).parse(trimmed)?.kind === 'set'
|
|
622
|
+
? trimmed
|
|
623
|
+
: fallback
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** One-of field over a fixed vocabulary (rendered as a select). */
|
|
627
|
+
export function oneOfField(field: string, options: readonly string[]): SettingsFieldSpec {
|
|
628
|
+
return {
|
|
629
|
+
field,
|
|
630
|
+
format: (value) => (typeof value === 'string' && options.includes(value) ? value : options[0] ?? ''),
|
|
631
|
+
parse: (text) => (options.includes(text) ? { kind: 'set', value: text } : undefined),
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Boolean field staged as 'true'/'false' draft text (rendered as a checkbox). */
|
|
636
|
+
export function boolField(field: string): SettingsFieldSpec {
|
|
637
|
+
return {
|
|
638
|
+
field,
|
|
639
|
+
format: (value) => (value === true ? 'true' : 'false'),
|
|
640
|
+
parse: (text) => (text === 'true' || text === 'false' ? { kind: 'set', value: text === 'true' } : undefined),
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Names of the row fields the settings page edits, in group order. */
|
|
645
|
+
export type SettingsFieldName =
|
|
646
|
+
| 'role'
|
|
647
|
+
| 'host'
|
|
648
|
+
| 'port'
|
|
649
|
+
| 'trustedProxies'
|
|
650
|
+
| 'rateLimit'
|
|
651
|
+
| 'vapidSubject'
|
|
652
|
+
| 'pushSummary'
|
|
653
|
+
| 'pushTurnEnd'
|
|
654
|
+
| 'pushTool'
|
|
655
|
+
| 'pushDebounceMs'
|
|
656
|
+
| 'lang'
|
|
657
|
+
| 'serverName'
|
|
658
|
+
| 'idleHours'
|
|
659
|
+
| 'autoShareNewSessions'
|
|
660
|
+
|
|
661
|
+
/** Longest legal `serverName`, mirroring src/config.ts's cap. */
|
|
662
|
+
export const SERVER_NAME_MAX = 40
|
|
663
|
+
|
|
664
|
+
/** Upper bound of `idleHours`, mirroring src/config.ts. */
|
|
665
|
+
export const IDLE_HOURS_MAX = 8760
|
|
666
|
+
|
|
667
|
+
/** The row fields the settings page edits, in render order. */
|
|
668
|
+
export const SETTINGS_FIELDS: readonly SettingsFieldSpec[] = [
|
|
669
|
+
oneOfField('role', ['host', 'client']),
|
|
670
|
+
textField('host'),
|
|
671
|
+
intField('port', 1, 65535),
|
|
672
|
+
textField('trustedProxies'),
|
|
673
|
+
intField('rateLimit', 1, Number.MAX_SAFE_INTEGER),
|
|
674
|
+
textField('vapidSubject'),
|
|
675
|
+
boolField('pushSummary'),
|
|
676
|
+
boolField('pushTurnEnd'),
|
|
677
|
+
boolField('pushTool'),
|
|
678
|
+
intField('pushDebounceMs', 0, Number.MAX_SAFE_INTEGER),
|
|
679
|
+
oneOfField('lang', ['auto', 'zh', 'en']),
|
|
680
|
+
nameField('serverName', SERVER_NAME_MAX),
|
|
681
|
+
hoursField('idleHours', IDLE_HOURS_MAX),
|
|
682
|
+
boolField('autoShareNewSessions'),
|
|
683
|
+
]
|
|
684
|
+
|
|
685
|
+
// --- the status -> view mapping ---------------------------------------------
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Whether the settings page's server-local operations (pairing, device
|
|
689
|
+
* management, the push probe) are usable: only once a status load answered
|
|
690
|
+
* AND it answered as the local machine (`viaGateway: false`). Everything
|
|
691
|
+
* else — no answer yet, or the page opened through the gateway — keeps them
|
|
692
|
+
* disabled. Pure so the check script pins all three cases.
|
|
693
|
+
*/
|
|
694
|
+
export function localOpsAllowed(view: SettingsView | undefined): boolean {
|
|
695
|
+
return view?.viaGateway === false
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Per-field presentation facts the block renders next to each control. */
|
|
699
|
+
export interface SettingsFieldView {
|
|
700
|
+
/** Effective value (the resolved `config.values[field]`). */
|
|
701
|
+
value: unknown
|
|
702
|
+
/** Layer that supplied the effective value. */
|
|
703
|
+
source: ConfigSource
|
|
704
|
+
/** `source === 'env'`: shown locked, edits would be shadowed. */
|
|
705
|
+
locked: boolean
|
|
706
|
+
/** `source === 'file'`: the legacy lan-gate.config.json supplies this. */
|
|
707
|
+
fromFile: boolean
|
|
708
|
+
/** The row layer stores this field but another layer supplied the value —
|
|
709
|
+
* i.e. the saved value was illegal and resolveConfig skipped it. */
|
|
710
|
+
savedRowInvalid: boolean
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** One device row the block renders. */
|
|
714
|
+
export interface SettingsDeviceView {
|
|
715
|
+
id: string
|
|
716
|
+
name: string
|
|
717
|
+
role: 'web' | 'desktop-client'
|
|
718
|
+
kind: 'auto' | 'phone' | 'desktop'
|
|
719
|
+
lastSeen: number
|
|
720
|
+
hasPush: boolean
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** The live pairing code with its countdown, or null when none is active. */
|
|
724
|
+
export interface SettingsPairingView {
|
|
725
|
+
code: string
|
|
726
|
+
role: 'web' | 'desktop-client'
|
|
727
|
+
remainingSeconds: number
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** Everything the settings block renders, derived from one status body. */
|
|
731
|
+
export interface SettingsView {
|
|
732
|
+
/** The status route answered ok. */
|
|
733
|
+
available: boolean
|
|
734
|
+
/** Which half this DSH process runs as (`client` shows only the role group). */
|
|
735
|
+
role: 'host' | 'client'
|
|
736
|
+
/** Opened through the gateway (a remote device): server-local operations disabled. */
|
|
737
|
+
viaGateway: boolean
|
|
738
|
+
/** Whether the gateway child answers. */
|
|
739
|
+
gatewayReachable: boolean
|
|
740
|
+
gatewayPort: number | undefined
|
|
741
|
+
gatewayTarget: string | undefined
|
|
742
|
+
/** The probe status the admin route saw from the gateway; null = no answer. */
|
|
743
|
+
gatewayStatus: number | null
|
|
744
|
+
/** `gatewayStatus` is a non-2xx reply: the status line shows the abnormal message instead. */
|
|
745
|
+
gatewayAbnormal: boolean
|
|
746
|
+
fields: Record<SettingsFieldName, SettingsFieldView>
|
|
747
|
+
devices: SettingsDeviceView[]
|
|
748
|
+
pairing: SettingsPairingView | null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export interface DeriveSettingsOptions {
|
|
752
|
+
/** Clock for the pairing countdown; defaults to `Date.now()`. */
|
|
753
|
+
now?: number
|
|
754
|
+
/** The shared form snapshot's raw user layer; field presence marks a row-layer override. */
|
|
755
|
+
rowUser?: unknown
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const asRecord = (value: unknown): Record<string, unknown> =>
|
|
759
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
760
|
+
? value as Record<string, unknown>
|
|
761
|
+
: {}
|
|
762
|
+
|
|
763
|
+
const asStringSet = (value: unknown): string =>
|
|
764
|
+
typeof value === 'string' ? value : ''
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Map one `admin/status` body into the view the settings block renders.
|
|
768
|
+
* Tolerant by design: every wire field is optional, an unexpected shape
|
|
769
|
+
* degrades to the conservative rendering (status unavailable, gateway down,
|
|
770
|
+
* no devices, no pairing) instead of throwing into the plugin page.
|
|
771
|
+
*/
|
|
772
|
+
export function deriveSettingsView(status: AdminStatusBody, options: DeriveSettingsOptions = {}): SettingsView {
|
|
773
|
+
const now = options.now ?? Date.now()
|
|
774
|
+
const values = asRecord(status.config?.values)
|
|
775
|
+
const sources = asRecord(status.config?.sources)
|
|
776
|
+
const rowUser = asRecord(options.rowUser)
|
|
777
|
+
// The gateway payload rides NESTED (`gateway` = /lan-gate/status verbatim);
|
|
778
|
+
// null means the gateway did not answer healthily — devices and the pairing
|
|
779
|
+
// code both render as empty then.
|
|
780
|
+
const gateway = status.gateway === null || typeof status.gateway !== 'object' ? undefined : status.gateway
|
|
781
|
+
|
|
782
|
+
const fields = {} as Record<SettingsFieldName, SettingsFieldView>
|
|
783
|
+
for (const spec of SETTINGS_FIELDS) {
|
|
784
|
+
const rawSource = sources[spec.field]
|
|
785
|
+
const source: ConfigSource =
|
|
786
|
+
rawSource === 'env' || rawSource === 'row' || rawSource === 'file' || rawSource === 'default'
|
|
787
|
+
? rawSource
|
|
788
|
+
: 'default'
|
|
789
|
+
fields[spec.field as SettingsFieldName] = {
|
|
790
|
+
value: values[spec.field],
|
|
791
|
+
source,
|
|
792
|
+
locked: source === 'env',
|
|
793
|
+
fromFile: source === 'file',
|
|
794
|
+
// Being shadowed by an environment variable is not "invalid" (the lock
|
|
795
|
+
// badge already says so) — only a skipped row value under a non-env
|
|
796
|
+
// winner marks the saved value as rejected by resolveConfig.
|
|
797
|
+
savedRowInvalid: Object.hasOwn(rowUser, spec.field) && source !== 'row' && source !== 'env',
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const devices: SettingsDeviceView[] = (Array.isArray(gateway?.devices) ? gateway.devices : [])
|
|
802
|
+
.map((device) => {
|
|
803
|
+
const record = asRecord(device)
|
|
804
|
+
const kind = record.kind === 'phone' ? 'phone' as const : record.kind === 'desktop' ? 'desktop' as const : 'auto' as const
|
|
805
|
+
return {
|
|
806
|
+
id: asStringSet(record.id),
|
|
807
|
+
name: asStringSet(record.name),
|
|
808
|
+
role: record.role === 'desktop-client' ? 'desktop-client' as const : 'web' as const,
|
|
809
|
+
kind,
|
|
810
|
+
lastSeen: typeof record.lastSeen === 'number' && Number.isFinite(record.lastSeen) ? record.lastSeen : 0,
|
|
811
|
+
hasPush: record.hasPush === true,
|
|
812
|
+
}
|
|
813
|
+
})
|
|
814
|
+
.filter((device) => device.id !== '')
|
|
815
|
+
|
|
816
|
+
const rawPairing = asRecord(gateway?.pairing)
|
|
817
|
+
const code = asStringSet(rawPairing.code)
|
|
818
|
+
const expiresAt = rawPairing.expiresAt
|
|
819
|
+
const pairing: SettingsPairingView | null =
|
|
820
|
+
code !== '' && typeof expiresAt === 'number' && Number.isFinite(expiresAt) && expiresAt > now
|
|
821
|
+
? {
|
|
822
|
+
code,
|
|
823
|
+
role: rawPairing.role === 'desktop-client' ? 'desktop-client' : 'web',
|
|
824
|
+
remainingSeconds: Math.max(0, Math.ceil((expiresAt - now) / 1000)),
|
|
825
|
+
}
|
|
826
|
+
: null
|
|
827
|
+
|
|
828
|
+
const gatewayStatus = typeof status.gatewayStatus === 'number' && Number.isFinite(status.gatewayStatus)
|
|
829
|
+
? status.gatewayStatus
|
|
830
|
+
: null
|
|
831
|
+
return {
|
|
832
|
+
available: status.ok === true,
|
|
833
|
+
role: values.role === 'client' ? 'client' : 'host',
|
|
834
|
+
viaGateway: status.viaGateway === true,
|
|
835
|
+
gatewayReachable: status.gatewayReachable === true,
|
|
836
|
+
gatewayPort: typeof gateway?.port === 'number' && Number.isFinite(gateway.port) ? gateway.port : undefined,
|
|
837
|
+
gatewayTarget: typeof gateway?.target === 'string' ? gateway.target : undefined,
|
|
838
|
+
gatewayStatus,
|
|
839
|
+
// A non-null, non-2xx probe reply: the status line swaps to the abnormal message.
|
|
840
|
+
gatewayAbnormal: gatewayStatus !== null && !(gatewayStatus >= 200 && gatewayStatus < 300),
|
|
841
|
+
fields,
|
|
842
|
+
devices,
|
|
843
|
+
pairing,
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// --- the controller ----------------------------------------------------------
|
|
848
|
+
|
|
849
|
+
/** Card-level state the shared form frame renders. */
|
|
850
|
+
export interface SettingsFormShellState {
|
|
851
|
+
available: boolean
|
|
852
|
+
writable: boolean
|
|
853
|
+
dirty: boolean
|
|
854
|
+
invalid: boolean
|
|
855
|
+
saving: boolean
|
|
856
|
+
failed: boolean
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** One control's state as its field renders it. */
|
|
860
|
+
export interface SettingsFieldState {
|
|
861
|
+
text: string
|
|
862
|
+
overridden: boolean
|
|
863
|
+
invalid: boolean
|
|
864
|
+
/** A clear is staged: the box keeps showing the CURRENT value — the next
|
|
865
|
+
* layer's value only exists after the save — and the field renders the
|
|
866
|
+
* "reverts on save" hint instead of a fake preview. */
|
|
867
|
+
cleared: boolean
|
|
868
|
+
/** `admin/status` reported this field's value locked by an environment variable. */
|
|
869
|
+
locked: boolean
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The row secret's face: presence only, never a value. */
|
|
873
|
+
export interface SettingsSecretFieldState {
|
|
874
|
+
/** The describe view's secrets sidecar reports `deviceToken` set. */
|
|
875
|
+
configured: boolean
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** The whole staged-form snapshot the page renders. */
|
|
879
|
+
export interface ZenRemoteFormState extends SettingsFormShellState {
|
|
880
|
+
/** The shared form's scope sync state, reactively exposed so the page's
|
|
881
|
+
* role probe can wait out the mirror's loading phase. */
|
|
882
|
+
scopeStatus: 'loading' | 'ready' | 'unavailable'
|
|
883
|
+
role: SettingsFieldState
|
|
884
|
+
host: SettingsFieldState
|
|
885
|
+
port: SettingsFieldState
|
|
886
|
+
trustedProxies: SettingsFieldState
|
|
887
|
+
rateLimit: SettingsFieldState
|
|
888
|
+
vapidSubject: SettingsFieldState
|
|
889
|
+
pushSummary: SettingsFieldState
|
|
890
|
+
pushTurnEnd: SettingsFieldState
|
|
891
|
+
pushTool: SettingsFieldState
|
|
892
|
+
pushDebounceMs: SettingsFieldState
|
|
893
|
+
lang: SettingsFieldState
|
|
894
|
+
serverName: SettingsFieldState
|
|
895
|
+
idleHours: SettingsFieldState
|
|
896
|
+
autoShareNewSessions: SettingsFieldState
|
|
897
|
+
deviceToken: SettingsSecretFieldState
|
|
898
|
+
/** A restart-required field ({@link RESTART_FIELDS}) has a staged change a
|
|
899
|
+
* save would actually write: the save will move the row and the host
|
|
900
|
+
* reloads it, briefly restarting the gateway — the page shows the reload
|
|
901
|
+
* note while this is true. Since T17b it is not merely "a draft exists":
|
|
902
|
+
* a draft equal to the displayed effective value is no change at all, an
|
|
903
|
+
* invalid draft blocks the save instead of saving anything, and an
|
|
904
|
+
* env-locked field never stages (nor counts if locked after staging). */
|
|
905
|
+
restartPending: boolean
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** One staged draft: typed text, or an explicit clear back to the composition layer. */
|
|
909
|
+
type Staged = { text: string } | { clear: true }
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Stages one page's edits over the plugin row's shared form and writes them on
|
|
913
|
+
* save as one atomic, revision-fenced mutation. Fields whose `admin/status`
|
|
914
|
+
* source is `env` are locked via {@link setLockedFields}: they cannot be
|
|
915
|
+
* staged, because a written value would be shadowed by the environment anyway.
|
|
916
|
+
*/
|
|
917
|
+
export class ZenRemoteSettingsForm {
|
|
918
|
+
private readonly listeners = new Set<() => void>()
|
|
919
|
+
private readonly staged = new Map<string, Staged>()
|
|
920
|
+
private readonly lockedFields = new Set<string>()
|
|
921
|
+
/** Fields whose saved row value `admin/status` reported invalid
|
|
922
|
+
* (savedRowInvalid): those display the RAW stored value instead of the
|
|
923
|
+
* resolved one, so the user can see (and fix) what they actually wrote. */
|
|
924
|
+
private readonly rowInvalidFields = new Set<string>()
|
|
925
|
+
private readonly scope: SettingsFormScope
|
|
926
|
+
/** Reads the describe view's secrets sidecar for `deviceToken`'s
|
|
927
|
+
* configured flag; injectable so tests run without a describe mirror. */
|
|
928
|
+
private readonly secretConfigured: () => boolean
|
|
929
|
+
/** Effective values from `admin/status`'s `config.values` — what a field
|
|
930
|
+
* displays while the row layer does not carry it. Empty until the page's
|
|
931
|
+
* first status load feeds it via {@link setBaseline}. Consulted only while
|
|
932
|
+
* the page is NOT a client page (T55): see {@link displayValue}. */
|
|
933
|
+
private baseline: Record<string, unknown> = {}
|
|
934
|
+
/** The effective role the page probed from the client-config route (T17):
|
|
935
|
+
* the fallback for {@link savedRoleIsClient} / {@link statusPoll} when the
|
|
936
|
+
* row document cannot answer the saved role (role only in
|
|
937
|
+
* lan-gate.config.json). Undefined until that probe answers. */
|
|
938
|
+
private probedRole: 'host' | 'client' | undefined = undefined
|
|
939
|
+
private saving = false
|
|
940
|
+
private failed = false
|
|
941
|
+
/** Revision the drafts started from; the save's `expectedRevision` fence. */
|
|
942
|
+
private stagedRevision: number | undefined
|
|
943
|
+
private cache: ZenRemoteFormState | undefined
|
|
944
|
+
private readonly unsubscribe: () => void
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* @param scope - the shared configuration form for the plugin row entry.
|
|
948
|
+
* (`scope` and `secretConfigured` are assigned in the body rather than as
|
|
949
|
+
* parameter properties: scripts/check-settings-form.mjs imports this module
|
|
950
|
+
* through Node's strip-only type stripping, which rejects that syntax.)
|
|
951
|
+
* @param secretConfigured - whether the row's device token is set, read
|
|
952
|
+
* from the describe view's secrets sidecar; defaults to "not set".
|
|
953
|
+
*/
|
|
954
|
+
constructor(scope: SettingsFormScope, secretConfigured: () => boolean = () => false) {
|
|
955
|
+
this.scope = scope
|
|
956
|
+
this.secretConfigured = secretConfigured
|
|
957
|
+
this.unsubscribe = scope.subscribe(() => { this.publish() })
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** @returns the current form snapshot (stable reference until the next change). */
|
|
961
|
+
getSnapshot(): ZenRemoteFormState {
|
|
962
|
+
if (this.cache === undefined) this.cache = this.project()
|
|
963
|
+
return this.cache
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** Observe snapshot replacements (the renderer binds this as its store). */
|
|
967
|
+
subscribe(listener: () => void): () => void {
|
|
968
|
+
this.listeners.add(listener)
|
|
969
|
+
return () => { this.listeners.delete(listener) }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** Republish from the current reads (admin/status moved underneath). */
|
|
973
|
+
refresh(): void {
|
|
974
|
+
this.publish()
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** Replace the set of environment-locked fields reported by admin/status. */
|
|
978
|
+
setLockedFields(fields: Iterable<string>): void {
|
|
979
|
+
this.lockedFields.clear()
|
|
980
|
+
for (const field of fields) this.lockedFields.add(field)
|
|
981
|
+
this.publish()
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Replace the set of fields whose saved row value resolveConfig rejected
|
|
986
|
+
* (admin/status's savedRowInvalid). Those keep showing the RAW stored
|
|
987
|
+
* value — the resolved value belongs to another layer and would hide the
|
|
988
|
+
* mistake the user needs to fix.
|
|
989
|
+
*/
|
|
990
|
+
setRowInvalidFields(fields: Iterable<string>): void {
|
|
991
|
+
this.rowInvalidFields.clear()
|
|
992
|
+
for (const field of fields) this.rowInvalidFields.add(field)
|
|
993
|
+
this.publish()
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* The entry document's stored value for one field (the shared form
|
|
998
|
+
* snapshot's `value` — the redacted section: secrets never ride it). What
|
|
999
|
+
* the client group prefills the server address from, and what the page
|
|
1000
|
+
* reads the SAVED role from (the role field's display text also carries
|
|
1001
|
+
* staged drafts, which must not flip the page's mode).
|
|
1002
|
+
*/
|
|
1003
|
+
rowValue(field: string): unknown {
|
|
1004
|
+
return this.scope.getSnapshot().value?.[field]
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* The scope sync state as the shared form sees it. The page waits it out
|
|
1009
|
+
* before choosing a status source: a 'loading' snapshot cannot answer the
|
|
1010
|
+
* role yet (T16-fix 3).
|
|
1011
|
+
*/
|
|
1012
|
+
scopeStatus(): 'loading' | 'ready' | 'unavailable' {
|
|
1013
|
+
return this.scope.getSnapshot().status
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Which status source the page should poll right now ({@link settingsPollOf}
|
|
1018
|
+
* over the live snapshot): `none` while the mirror loads — and while a row
|
|
1019
|
+
* without a stored role waits for the client-config probe — `client` for a
|
|
1020
|
+
* client row — never admin there — and `admin` otherwise.
|
|
1021
|
+
*/
|
|
1022
|
+
statusPoll(): 'none' | 'admin' | 'client' {
|
|
1023
|
+
const snap = this.scope.getSnapshot()
|
|
1024
|
+
return settingsPollOf(snap.status, snap, this.probedRole)
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Feed the fallback role the page probed from the client-config route
|
|
1029
|
+
* (T17). Only consulted when the row document carries no `role` — a stored
|
|
1030
|
+
* row role always wins, so a staged-then-saved switch is never masked by a
|
|
1031
|
+
* stale probe.
|
|
1032
|
+
*/
|
|
1033
|
+
setProbedRole(role: 'host' | 'client' | undefined): void {
|
|
1034
|
+
this.probedRole = role
|
|
1035
|
+
this.publish()
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Whether the SAVED row role is client ({@link savedRowRole} over the live
|
|
1040
|
+
* snapshot, falling back to the probed role) — the page mode's single
|
|
1041
|
+
* source of truth.
|
|
1042
|
+
*/
|
|
1043
|
+
savedRoleIsClient(): boolean {
|
|
1044
|
+
return (savedRowRole(this.scope.getSnapshot()) ?? this.probedRole ?? 'host') === 'client'
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Whether the row document answers the saved role at all (T17): false when
|
|
1049
|
+
* neither snapshot layer carries one — the page then probes the
|
|
1050
|
+
* client-config route for the effective role instead of assuming host.
|
|
1051
|
+
*/
|
|
1052
|
+
rowRoleKnown(): boolean {
|
|
1053
|
+
return savedRowRole(this.scope.getSnapshot()) !== undefined
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* One direct write for the pairing flow (T16): the normalized server
|
|
1058
|
+
* address, the token just redeemed from the server, and (T60) the name
|
|
1059
|
+
* actually registered with — the SERVER's confirmed echo when the claim
|
|
1060
|
+
* carried one, else the name the claim sent — ride ONE revision-fenced
|
|
1061
|
+
* mutate. The name lands in the row's `serverName` (the role card's 设备
|
|
1062
|
+
* 名称 field), so a pairing that used an UNSAVED draft can never leave the
|
|
1063
|
+
* local row empty while the server records the name. Not a staged edit —
|
|
1064
|
+
* all three fields are volatile row settings and apply immediately. The
|
|
1065
|
+
* caller drops the field's stale draft afterwards (see
|
|
1066
|
+
* {@link discardField}). @returns whether the write landed.
|
|
1067
|
+
*/
|
|
1068
|
+
async writeClientPairing(serverUrl: string, token: string, deviceName: string): Promise<boolean> {
|
|
1069
|
+
return this.directWrite([
|
|
1070
|
+
{ op: 'set', path: ['serverUrl'], value: serverUrl },
|
|
1071
|
+
{ op: 'set', path: [DEVICE_TOKEN_FIELD], value: token },
|
|
1072
|
+
{ op: 'set', path: ['serverName'], value: deviceName },
|
|
1073
|
+
])
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* One direct write for unpairing: forget the token, keep the address so
|
|
1078
|
+
* the next pairing only needs a fresh code.
|
|
1079
|
+
*/
|
|
1080
|
+
async clearDeviceToken(): Promise<boolean> {
|
|
1081
|
+
return this.directWrite([{ op: 'unset', path: [DEVICE_TOKEN_FIELD] }])
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Drop ONE field's staged draft (T60): after the pairing write landed the
|
|
1086
|
+
* name in the row, the draft that produced it is stale — it now equals the
|
|
1087
|
+
* displayed value, and keeping it would show a phantom "overridden" badge.
|
|
1088
|
+
* The revision fence goes with the last draft (there is nothing staged
|
|
1089
|
+
* left to fence); an in-flight save-failure flag is not touched — that is
|
|
1090
|
+
* the staged-save surface's own story.
|
|
1091
|
+
*/
|
|
1092
|
+
discardField(field: string): void {
|
|
1093
|
+
if (!this.staged.delete(field)) return
|
|
1094
|
+
if (this.staged.size === 0) this.stagedRevision = undefined
|
|
1095
|
+
this.publish()
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Whether one field currently has a STAGED edit (a typed draft or a staged
|
|
1100
|
+
* clear) — the "the user is mid-edit" fact the T59 server-name follow
|
|
1101
|
+
* reads before it may write. Reads the live draft map, so it answers per
|
|
1102
|
+
* call; the page re-runs its effect on every republished snapshot.
|
|
1103
|
+
*/
|
|
1104
|
+
hasDraft(field: string): boolean {
|
|
1105
|
+
return this.staged.has(field)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* One direct write for the T59 name follow: the SERVER's record of this
|
|
1110
|
+
* device's name landing in the row's `serverName` (the role card's 设备
|
|
1111
|
+
* 名称 field). The page only calls it when the user is not mid-edit —
|
|
1112
|
+
* see {@link shouldFollowServerDeviceName} — and the write's own volatile
|
|
1113
|
+
* update echoes back to the backend with both sides now equal, so the
|
|
1114
|
+
* push path stays silent (no overwrite loop).
|
|
1115
|
+
*/
|
|
1116
|
+
async writeDeviceName(name: string): Promise<boolean> {
|
|
1117
|
+
return this.directWrite([{ op: 'set', path: ['serverName'], value: name }])
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The shared write path of the two pairing flows. Deliberately does NOT
|
|
1122
|
+
* touch the frame's `failed` flag — a refused pairing write surfaces in
|
|
1123
|
+
* the client group's own copy, not as a staged-save failure.
|
|
1124
|
+
*/
|
|
1125
|
+
private async directWrite(ops: SettingsFormOp[]): Promise<boolean> {
|
|
1126
|
+
const snap = this.scope.getSnapshot()
|
|
1127
|
+
if (snap.status !== 'ready' || !snap.writable || this.saving) return false
|
|
1128
|
+
this.saving = true
|
|
1129
|
+
this.publish()
|
|
1130
|
+
try {
|
|
1131
|
+
const landed = await this.scope.mutate(ops, snap.revision)
|
|
1132
|
+
if (!landed) return false
|
|
1133
|
+
// The row moved underneath any staged drafts: their revision fence is
|
|
1134
|
+
// stale, so the next save fences from the fresh revision instead of
|
|
1135
|
+
// being refused by a conflict it did not cause.
|
|
1136
|
+
this.stagedRevision = undefined
|
|
1137
|
+
return true
|
|
1138
|
+
} catch {
|
|
1139
|
+
return false
|
|
1140
|
+
} finally {
|
|
1141
|
+
this.saving = false
|
|
1142
|
+
this.publish()
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Feed the effective values (`admin/status`'s `config.values`) the fields
|
|
1148
|
+
* display while the row layer does not carry them; also the baseline the
|
|
1149
|
+
* "did the user change anything" comparison reads. The page clears it
|
|
1150
|
+
* (`undefined`) whenever the poll source is not the admin one (T55): a
|
|
1151
|
+
* stale host baseline must not survive a role switch on a client page.
|
|
1152
|
+
*/
|
|
1153
|
+
setBaseline(values: unknown): void {
|
|
1154
|
+
this.baseline = asRecord(values)
|
|
1155
|
+
this.publish()
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Stage draft text for one row field; ignored for env-locked fields. */
|
|
1159
|
+
stage(field: string, text: string): void {
|
|
1160
|
+
if (this.lockedFields.has(field)) return
|
|
1161
|
+
this.staged.set(field, { text })
|
|
1162
|
+
this.noteStage()
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Stage a clear so the field re-inherits the composition layer; ignored for env-locked fields. */
|
|
1166
|
+
resetField(field: string): void {
|
|
1167
|
+
if (this.lockedFields.has(field)) return
|
|
1168
|
+
this.staged.set(field, { clear: true })
|
|
1169
|
+
this.noteStage()
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Drop every staged edit. */
|
|
1173
|
+
discard(): void {
|
|
1174
|
+
if (this.staged.size === 0 && !this.failed) return
|
|
1175
|
+
this.staged.clear()
|
|
1176
|
+
this.stagedRevision = undefined
|
|
1177
|
+
this.failed = false
|
|
1178
|
+
this.publish()
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* The raw user layer as the shared form stores it; a field's presence here
|
|
1183
|
+
* marks a row-layer override, which is what `savedRowInvalid` compares
|
|
1184
|
+
* against.
|
|
1185
|
+
*/
|
|
1186
|
+
rowUser(): Record<string, unknown> {
|
|
1187
|
+
return asRecord(this.scope.getSnapshot().user)
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/** Whether a save would do anything and is allowed to run right now. */
|
|
1191
|
+
canSave(): boolean {
|
|
1192
|
+
const snap = this.scope.getSnapshot()
|
|
1193
|
+
return snap.status === 'ready'
|
|
1194
|
+
&& snap.writable
|
|
1195
|
+
&& !this.saving
|
|
1196
|
+
&& this.plan().length > 0
|
|
1197
|
+
&& !this.anyInvalid()
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Write every staged edit as one mutation fenced by the revision the drafts
|
|
1202
|
+
* started from. The Host is the only authority on acceptance: a refused
|
|
1203
|
+
* save keeps its drafts for correction.
|
|
1204
|
+
* @returns whether the save landed.
|
|
1205
|
+
*/
|
|
1206
|
+
async save(): Promise<boolean> {
|
|
1207
|
+
const ops = this.plan()
|
|
1208
|
+
const snap = this.scope.getSnapshot()
|
|
1209
|
+
if (ops.length === 0 || this.saving || snap.status !== 'ready' || !snap.writable || this.anyInvalid()) return false
|
|
1210
|
+
this.saving = true
|
|
1211
|
+
this.failed = false
|
|
1212
|
+
this.publish()
|
|
1213
|
+
try {
|
|
1214
|
+
const landed = await this.scope.mutate(ops, this.stagedRevision ?? snap.revision)
|
|
1215
|
+
if (!landed) {
|
|
1216
|
+
this.failed = true
|
|
1217
|
+
return false
|
|
1218
|
+
}
|
|
1219
|
+
this.staged.clear()
|
|
1220
|
+
this.stagedRevision = undefined
|
|
1221
|
+
return true
|
|
1222
|
+
} catch {
|
|
1223
|
+
this.failed = true
|
|
1224
|
+
return false
|
|
1225
|
+
} finally {
|
|
1226
|
+
this.saving = false
|
|
1227
|
+
this.publish()
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Release the scope subscription. */
|
|
1232
|
+
dispose(): void {
|
|
1233
|
+
this.unsubscribe()
|
|
1234
|
+
this.listeners.clear()
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
private noteStage(): void {
|
|
1238
|
+
if (this.stagedRevision === undefined) this.stagedRevision = this.scope.getSnapshot().revision
|
|
1239
|
+
this.failed = false
|
|
1240
|
+
this.publish()
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** Whether the user layer currently carries an entry for one field. */
|
|
1244
|
+
private stored(field: string): boolean {
|
|
1245
|
+
return Object.hasOwn(asRecord(this.scope.getSnapshot().user), field)
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The value one field DISPLAYS, which is also the baseline a draft must
|
|
1250
|
+
* differ from to count as a change: an env-locked field shows the effective
|
|
1251
|
+
* value (a written one would be shadowed anyway); a field the row layer
|
|
1252
|
+
* stores shows the RESOLVED value from admin/status — resolveConfig
|
|
1253
|
+
* normalizes stored shapes (a trustedProxies array becomes the comma
|
|
1254
|
+
* string, pushTool 1 becomes true) and the box must match what a save
|
|
1255
|
+
* writes — EXCEPT when the saved row value was rejected (savedRowInvalid):
|
|
1256
|
+
* then the raw stored value shows, so the user sees the mistake; before
|
|
1257
|
+
* the first status load the stored raw value stands in, and otherwise the
|
|
1258
|
+
* shared form's own effective layer does, so drafts behave sensibly even
|
|
1259
|
+
* with no admin/status yet.
|
|
1260
|
+
*
|
|
1261
|
+
* T55: the admin/status baseline counts only while the page is not a CLIENT
|
|
1262
|
+
* page. A role switch leaves the host page's kept status load (and with it
|
|
1263
|
+
* the last-fed baseline) in place forever — a client page never refreshes
|
|
1264
|
+
* admin/status again — so reading the baseline there would pin every field,
|
|
1265
|
+
* the role dropdown included, to the old role. Under a client poll the
|
|
1266
|
+
* fields fall back to the row's stored values (the page stops feeding the
|
|
1267
|
+
* baseline too, {@link setBaseline}).
|
|
1268
|
+
*/
|
|
1269
|
+
private displayValue(field: string): unknown {
|
|
1270
|
+
const staleAdmin = this.statusPoll() === 'client'
|
|
1271
|
+
if (this.lockedFields.has(field)) {
|
|
1272
|
+
// Lock sets only ever arrive from an admin/status load; while a stale
|
|
1273
|
+
// set lingers after a switch, the row document stands in.
|
|
1274
|
+
return staleAdmin ? this.scope.getSnapshot().value?.[field] : this.baseline[field]
|
|
1275
|
+
}
|
|
1276
|
+
const user = asRecord(this.scope.getSnapshot().user)
|
|
1277
|
+
const rowStored = Object.hasOwn(user, field)
|
|
1278
|
+
if (rowStored && this.rowInvalidFields.has(field)) return user[field]
|
|
1279
|
+
if (!staleAdmin && Object.hasOwn(this.baseline, field)) return this.baseline[field]
|
|
1280
|
+
if (rowStored) return user[field]
|
|
1281
|
+
return this.scope.getSnapshot().value?.[field]
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
private anyInvalid(): boolean {
|
|
1285
|
+
return SETTINGS_FIELDS.some((spec) => {
|
|
1286
|
+
const draft = this.staged.get(spec.field)
|
|
1287
|
+
return draft !== undefined && 'text' in draft && spec.parse(draft.text) === undefined
|
|
1288
|
+
})
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* Every staged edit a save would send, in field order, mirroring the
|
|
1293
|
+
* upstream form model's plan: a draft equal to the field's current value is
|
|
1294
|
+
* no change at all; a reset only writes when the user layer actually
|
|
1295
|
+
* carries the field; an invalid draft produces none — the save refuses
|
|
1296
|
+
* rather than dropping the edit; a locked field never produces one.
|
|
1297
|
+
*/
|
|
1298
|
+
private plan(): SettingsFormOp[] {
|
|
1299
|
+
const ops: SettingsFormOp[] = []
|
|
1300
|
+
for (const spec of SETTINGS_FIELDS) {
|
|
1301
|
+
if (this.lockedFields.has(spec.field)) continue
|
|
1302
|
+
const draft = this.staged.get(spec.field)
|
|
1303
|
+
if (draft === undefined) continue
|
|
1304
|
+
if ('clear' in draft) {
|
|
1305
|
+
if (this.stored(spec.field)) ops.push({ op: 'unset', path: [spec.field] })
|
|
1306
|
+
continue
|
|
1307
|
+
}
|
|
1308
|
+
if (draft.text === spec.format(this.displayValue(spec.field))) continue
|
|
1309
|
+
const write = spec.parse(draft.text)
|
|
1310
|
+
if (write === undefined) continue
|
|
1311
|
+
ops.push(write.kind === 'clear'
|
|
1312
|
+
? { op: 'unset', path: [spec.field] }
|
|
1313
|
+
: { op: 'set', path: [spec.field], value: write.value })
|
|
1314
|
+
}
|
|
1315
|
+
return ops
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private project(): ZenRemoteFormState {
|
|
1319
|
+
const snap = this.scope.getSnapshot()
|
|
1320
|
+
const user = asRecord(snap.user)
|
|
1321
|
+
const fields = {} as Record<SettingsFieldName, SettingsFieldState>
|
|
1322
|
+
for (const spec of SETTINGS_FIELDS) {
|
|
1323
|
+
const draft = this.staged.get(spec.field)
|
|
1324
|
+
const stagedClear = draft !== undefined && 'clear' in draft
|
|
1325
|
+
const stagedText = draft !== undefined && 'text' in draft ? draft.text : undefined
|
|
1326
|
+
// A staged clear keeps showing the CURRENT value: the next layer's
|
|
1327
|
+
// value does not exist yet (and base is empty for a plugin row), so
|
|
1328
|
+
// there is nothing honest to preview — the field instead renders the
|
|
1329
|
+
// "reverts on save" hint off `cleared`.
|
|
1330
|
+
const text = stagedText ?? spec.format(this.displayValue(spec.field))
|
|
1331
|
+
const overridden = draft === undefined
|
|
1332
|
+
? Object.hasOwn(user, spec.field)
|
|
1333
|
+
: 'clear' in draft
|
|
1334
|
+
? false
|
|
1335
|
+
: spec.parse(draft.text)?.kind === 'set'
|
|
1336
|
+
const invalid = stagedText !== undefined && spec.parse(stagedText) === undefined
|
|
1337
|
+
fields[spec.field as SettingsFieldName] = {
|
|
1338
|
+
text,
|
|
1339
|
+
overridden,
|
|
1340
|
+
invalid,
|
|
1341
|
+
cleared: stagedClear,
|
|
1342
|
+
locked: this.lockedFields.has(spec.field),
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
// One plan drives both flags: dirty is "any write at all", and
|
|
1346
|
+
// restartPending (T17b) is "a write touching a restart-required field" —
|
|
1347
|
+
// derived from plan()'s own op decisions (T17b-fix), never restated, so
|
|
1348
|
+
// the reload note can never disagree with what a save would actually
|
|
1349
|
+
// send: an identical draft, an invalid draft, a locked field and a clear
|
|
1350
|
+
// of a field the row layer does not carry all plan no op here either.
|
|
1351
|
+
const ops = this.plan()
|
|
1352
|
+
const restartFields = RESTART_FIELDS as readonly string[]
|
|
1353
|
+
return {
|
|
1354
|
+
available: snap.status === 'ready',
|
|
1355
|
+
writable: snap.writable,
|
|
1356
|
+
scopeStatus: snap.status,
|
|
1357
|
+
dirty: ops.length > 0,
|
|
1358
|
+
invalid: this.anyInvalid(),
|
|
1359
|
+
saving: this.saving,
|
|
1360
|
+
failed: this.failed,
|
|
1361
|
+
restartPending: ops.some((op) => op.path[0] !== undefined && restartFields.includes(op.path[0])),
|
|
1362
|
+
...fields,
|
|
1363
|
+
deviceToken: { configured: this.secretConfigured() },
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
private publish(): void {
|
|
1368
|
+
this.cache = this.project()
|
|
1369
|
+
for (const listener of this.listeners) listener()
|
|
1370
|
+
}
|
|
1371
|
+
}
|