@linxin666/dsh-remote-web-ui 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -7
  3. package/README.zh.md +9 -7
  4. package/lib/client.js +282 -619
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +418 -901
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +9 -10
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +56 -11
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +2 -86
  17. package/lib/types/client/pair-api.d.ts +4 -0
  18. package/lib/types/client/pair-api.d.ts.map +1 -1
  19. package/lib/types/client/pair-api.js +11 -7
  20. package/lib/types/client/remote-channel.d.ts +27 -7
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +25 -17
  23. package/lib/types/client/settings-entry-form.d.ts +50 -0
  24. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  25. package/lib/types/client/settings-entry-form.js +98 -0
  26. package/lib/types/index.d.ts +99 -7
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +104 -137
  29. package/lib/types/lan-bind-plan.d.ts +14 -0
  30. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  31. package/lib/types/lan-bind-plan.js +16 -0
  32. package/lib/types/lan-bind.d.ts +5 -1
  33. package/lib/types/lan-bind.d.ts.map +1 -1
  34. package/lib/types/lan-bind.js +65 -2
  35. package/lib/types/mount-once.d.ts +38 -7
  36. package/lib/types/mount-once.d.ts.map +1 -1
  37. package/lib/types/mount-once.js +96 -12
  38. package/lib/types/pairing.d.ts +15 -2
  39. package/lib/types/pairing.d.ts.map +1 -1
  40. package/lib/types/pairing.js +26 -14
  41. package/lib/types/pairing.persist.test.js +43 -3
  42. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  43. package/lib/types/remote-channel-boot.js +13 -3
  44. package/lib/types/remote-channel-rules.d.ts +74 -0
  45. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  46. package/lib/types/remote-channel-rules.js +104 -0
  47. package/lib/types/tunnel.d.ts +60 -3
  48. package/lib/types/tunnel.d.ts.map +1 -1
  49. package/lib/types/tunnel.js +136 -3
  50. package/package.json +21 -20
  51. package/src/client/RemoteEntry.tsx +8 -10
  52. package/src/client/RemoteSettingsCard.tsx +0 -2
  53. package/src/client/index.ts +58 -12
  54. package/src/client/locales.ts +2 -86
  55. package/src/client/pair-api.ts +11 -7
  56. package/src/client/remote-channel.ts +38 -15
  57. package/src/client/remote.module.css +61 -193
  58. package/src/client/settings-entry-form.ts +142 -0
  59. package/src/index.ts +156 -152
  60. package/src/lan-bind-plan.ts +22 -0
  61. package/src/lan-bind.ts +63 -2
  62. package/src/mount-once.ts +107 -13
  63. package/src/pairing.persist.test.ts +47 -3
  64. package/src/pairing.ts +26 -12
  65. package/src/remote-channel-boot.ts +13 -3
  66. package/src/remote-channel-rules.ts +113 -0
  67. package/src/tunnel.ts +142 -3
  68. package/lib/types/client/UpdateEntry.d.ts +0 -14
  69. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  70. package/lib/types/client/UpdateEntry.js +0 -112
  71. package/lib/types/client/UpdatePanel.d.ts +0 -42
  72. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  73. package/lib/types/client/UpdatePanel.js +0 -101
  74. package/lib/types/client/update-api.d.ts +0 -29
  75. package/lib/types/client/update-api.d.ts.map +0 -1
  76. package/lib/types/client/update-api.js +0 -47
  77. package/lib/types/console-output.d.ts +0 -41
  78. package/lib/types/console-output.d.ts.map +0 -1
  79. package/lib/types/console-output.js +0 -89
  80. package/lib/types/update-routes.d.ts +0 -30
  81. package/lib/types/update-routes.d.ts.map +0 -1
  82. package/lib/types/update-routes.js +0 -47
  83. package/lib/types/update.d.ts +0 -272
  84. package/lib/types/update.d.ts.map +0 -1
  85. package/lib/types/update.js +0 -744
  86. package/src/client/UpdateEntry.tsx +0 -144
  87. package/src/client/UpdatePanel.tsx +0 -256
  88. package/src/client/update-api.ts +0 -49
  89. package/src/console-output.ts +0 -97
  90. package/src/update-routes.ts +0 -65
  91. package/src/update.ts +0 -882
@@ -0,0 +1,142 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/settings-entry-form.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
3
+ /**
4
+ * Family settings form bound to the profile entry id a Host actually serves.
5
+ *
6
+ * Under the 0.1.7 settings model a form is addressed by PROFILE ENTRY ID, while
7
+ * a family card knows only its own settings namespace. The family binder
8
+ * (`ctx.get('webUiSettings')`, published by dsh-web-settings) resolves one onto
9
+ * the other and is the preferred transport; this module is the fallback for a
10
+ * page that serves no binder — the group plugin is not installed, or its client
11
+ * half has not applied yet (the aggregate mounts its children in order, and a
12
+ * child still waiting on its own injects publishes nothing).
13
+ *
14
+ * The shared describe mirror is the only local evidence of which row id this
15
+ * profile serves, but it answers asynchronously: at plugin activation it usually
16
+ * holds nothing yet. A one-shot guess therefore cannot be right for every
17
+ * deployment, and the form is bound once per session:
18
+ *
19
+ * - binding the bare namespace made every save answer `No configurable plugin
20
+ * entry "<namespace>"` on an aggregate install (the namespace is not an entry
21
+ * id there), which the card reported as "the deployment did not accept these
22
+ * values";
23
+ * - binding the aggregate row id would break a standalone install, whose row id
24
+ * is the namespace or the package's own `ui-*` row.
25
+ *
26
+ * So the fallback starts on the most likely row and REBINDS the moment the
27
+ * mirror names one of this package's rows. Until the mirror answers, the form
28
+ * reports the binding it holds (usually `loading`), which the card renders as an
29
+ * unavailable form rather than a doomed one.
30
+ */
31
+ import type { ConfigForm, ConfigFormSnapshot, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
32
+
33
+ /** Options of {@link createServedEntryForm}. */
34
+ export interface ServedEntryFormOptions<T> {
35
+ /** The shared configuration forms service (`ctx.configForms`). */
36
+ forms: ConfigForms
37
+ /**
38
+ * Profile entry ids this package's rows carry, most likely first: the
39
+ * aggregate's generated row, the package's own standalone row, then the bare
40
+ * namespace. The first is the fallback while the mirror has no answer; the
41
+ * last is the answer when the mirror serves none of them.
42
+ */
43
+ entryIds: readonly string[]
44
+ }
45
+
46
+ /** The snapshot a form reports before the Host has answered with this entry. */
47
+ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
48
+ return {
49
+ status: 'loading',
50
+ value: undefined,
51
+ base: undefined,
52
+ user: undefined,
53
+ revision: undefined,
54
+ writable: false,
55
+ mode: 'host',
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Create a settings form bound to the profile entry id this Host serves, and
61
+ * rebound whenever the shared describe mirror names a different one of this
62
+ * package's rows.
63
+ * @param options - the shared forms service and this package's candidate row ids.
64
+ * @returns a form delegating to the currently resolved entry's own form.
65
+ */
66
+ export function createServedEntryForm<T>(options: ServedEntryFormOptions<T>): ConfigForm<T> {
67
+ const { forms, entryIds } = options
68
+ const fallbackId = entryIds[0]
69
+ const namespaceId = entryIds[entryIds.length - 1]
70
+ const listeners = new Set<() => void>()
71
+ let boundId: string | undefined
72
+ let bound: ConfigForm<T> | undefined
73
+ let offBound: (() => void) | undefined
74
+ let snapshot: ConfigFormSnapshot<T> = pendingSnapshot<T>()
75
+
76
+ /** Republish: the delegated snapshot when one is bound, the pending one otherwise. */
77
+ const publish = (): void => {
78
+ if (bound !== undefined) snapshot = bound.getSnapshot()
79
+ for (const listener of [...listeners]) listener()
80
+ }
81
+
82
+ /**
83
+ * The entry id the mirror currently justifies. An unanswered or empty mirror
84
+ * is not evidence of absence, so it keeps the first candidate; a mirror that
85
+ * answers without any of this package's rows leaves the namespace itself.
86
+ * @returns the entry id to bind.
87
+ */
88
+ const resolve = (): string => {
89
+ let served: readonly string[] | undefined
90
+ try {
91
+ served = forms.describe().getSnapshot().view?.namespaces.map(row => row.ns)
92
+ } catch {
93
+ // A mirror that refuses the read is unanswered too, not absent.
94
+ served = undefined
95
+ }
96
+ if (served === undefined || served.length === 0) return fallbackId
97
+ return entryIds.find(id => served.includes(id)) ?? namespaceId
98
+ }
99
+
100
+ /** Bind (or rebind) the resolved entry's form; a no-op while it is unchanged. */
101
+ const bind = (): void => {
102
+ const target = resolve()
103
+ if (target === boundId) return
104
+ offBound?.()
105
+ offBound = undefined
106
+ let form: ConfigForm<T>
107
+ try {
108
+ form = forms.get<T>(target)
109
+ } catch {
110
+ // A service that refuses the id stays unbound; the next mirror answer
111
+ // retries rather than pinning a wrong entry for the session.
112
+ boundId = undefined
113
+ return
114
+ }
115
+ boundId = target
116
+ bound = form
117
+ offBound = form.subscribe(() => { publish() })
118
+ publish()
119
+ }
120
+
121
+ // The mirror subscription lives for the page: the card owns its form for the
122
+ // session, and the ConfigForm contract carries no disposer to hang it on. The
123
+ // callback is cheap (a resolve plus a no-op while the id is unchanged). A
124
+ // mirror without a subscription seat keeps the initial binding.
125
+ try {
126
+ forms.describe().subscribe(() => { bind() })
127
+ } catch {
128
+ // A describe face that refuses the subscription: the initial binding stands.
129
+ }
130
+ bind()
131
+
132
+ return {
133
+ getSnapshot: () => snapshot,
134
+ subscribe: (listener) => {
135
+ listeners.add(listener)
136
+ return () => { listeners.delete(listener) }
137
+ },
138
+ set: (field, value) => bound?.set(field, value) ?? Promise.resolve(false),
139
+ unset: (field) => bound?.unset(field) ?? Promise.resolve(false),
140
+ mutate: (ops, expectedRevision) => bound?.mutate(ops, expectedRevision) ?? Promise.resolve(false),
141
+ }
142
+ }
package/src/index.ts CHANGED
@@ -8,13 +8,12 @@
8
8
  * phone-side pair/accept + deep-link flow.
9
9
  */
10
10
 
11
- import { createRequire } from 'node:module'
12
11
  import { join } from 'node:path'
13
12
  import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
14
13
  import type { IncomingMessage } from 'node:http'
15
14
  import type { Context } from '@deepseek-ai/cordis'
16
15
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
17
- import z from 'schemastery'
16
+ import z from '@deepseek-ai/schemastery'
18
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
19
18
  import type {} from '@deepseek-ai/dsh-agent'
20
19
  import type {} from '@deepseek-ai/dsh-commands'
@@ -34,29 +33,26 @@ import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firew
34
33
  import { lanBindState, writeLanBind } from './lan-bind.ts'
35
34
  import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
36
35
  import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
37
- import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
36
+ import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, resolveManagedProfile, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
38
37
  import { createInnerAuth } from './inner-auth.ts'
39
38
  import { withIdentityEncoding } from './http.ts'
40
39
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
41
40
  import { PublicBaseKeeper } from './public-base.ts'
42
- import {
43
- checkUpdates,
44
- fetchGitHubReleaseNotes,
45
- fetchLatestVersion,
46
- RELEASE_NOTES_CACHE_TTL_MS,
47
- resolveAnchorManifest,
48
- resolveUpdateTarget,
49
- runUpdateVerified,
50
- type UpdateReleaseNotes,
51
- type UpdateRunResult,
52
- } from './update.ts'
53
- import { makeUpdateRoutes } from './update-routes.ts'
54
41
  import { mountOnce } from './mount-once.ts'
55
42
  import { REMOTE_CHANNEL_BOOT_SCRIPT } from './remote-channel-boot.ts'
56
43
  import { UUID_POLYFILL_SCRIPT } from './uuid-polyfill.ts'
57
44
 
58
45
  declare module '@deepseek-ai/cordis' {
59
46
  interface Events {
47
+ /**
48
+ * Volatile config values were committed into the running fiber without a
49
+ * remount; dispatched to the owning fiber only. Spelled here because the
50
+ * Loader package is not a dependency of this plugin, with the Loader's own
51
+ * shape so the two declarations merge when a Host program carries both.
52
+ * @param paths - changed config paths as key arrays; every value is committed before dispatch.
53
+ * @mode emit
54
+ */
55
+ 'loader/volatile-update'(paths: readonly (readonly string[])[]): void
60
56
  /**
61
57
  * Waterfall seam on the /api transport fence: the connection plugin
62
58
  * fires this per /api request before bridging to the API proxy on
@@ -199,44 +195,60 @@ export interface Config {
199
195
  */
200
196
  lanBind?: boolean
201
197
  /**
202
- * The profile whose cordis.patch.yml the LAN bind toggle manages.
203
- * Defaults to the DSH_PROFILE environment variable, then "web". Must be
204
- * a single safe path segment (the DSH_PROFILE env fallback bypasses this
205
- * schema, so the path builder asserts containment independently).
198
+ * The profile whose cordis.patch.yml the LAN bind toggle manages. Defaults
199
+ * to the profile the Host booted (its `profileContext`), then to the
200
+ * DSH_PROFILE environment variable, then "web" — the Desktop client boots
201
+ * "desktop" without exporting DSH_PROFILE, so the environment alone names
202
+ * the wrong profile there. Must be a single safe path segment (the runtime
203
+ * and environment fallbacks bypass this schema, so the path builder asserts
204
+ * containment independently).
206
205
  */
207
206
  profile?: string
208
207
  /** Master switch for the plugin (browser half + host pairing surfaces). */
209
208
  enabled?: boolean
210
209
  }
211
210
 
212
- export const Config: z<Config> = z.object({
213
- tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000),
214
- offlineAfterMs: z.number().step(1).min(5_000).default(25_000),
215
- maxDevices: z.number().step(1).min(1).max(64).default(4),
216
- idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS),
217
- cookieName: z.string().min(1).default('dsh_pair'),
218
- requirePairingForLan: z.boolean().default(true),
219
- publicBaseUrl: z.string(),
211
+ /**
212
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
213
+ * entry's settings page: the Host derives one form per profile entry from it
214
+ * and serves that form only when at least one field is `volatile()`. The marker
215
+ * is also what admits a write and what keeps the edit on the live path — the
216
+ * Loader commits the new value into the field's reference and announces
217
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
218
+ * pairing service, its device sessions, the tunnel and the route registrations
219
+ * survive a settings save (see {@link applyImpl}'s sync).
220
+ *
221
+ * The schema is left to inference rather than annotated with `z<Config>`: a
222
+ * volatile field's parsed output is a live reference while its accepted input
223
+ * stays the plain value, so the two sides no longer share one shape and the
224
+ * annotation would reject the schema the Host must be given.
225
+ *
226
+ * The deployment-level fields (`trustedHosts`, `devicesFile`, `profile`) are
227
+ * deliberately NOT volatile: they belong in the profile patch, so the form
228
+ * leaves them to the operator instead of offering a card control that a
229
+ * document write could not honor.
230
+ */
231
+ export const Config = z.object({
232
+ tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000).volatile(),
233
+ offlineAfterMs: z.number().step(1).min(5_000).default(25_000).volatile(),
234
+ maxDevices: z.number().step(1).min(1).max(64).default(4).volatile(),
235
+ idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS).volatile(),
236
+ cookieName: z.string().min(1).default('dsh_pair').volatile(),
237
+ requirePairingForLan: z.boolean().default(true).volatile(),
238
+ publicBaseUrl: z.string().volatile(),
220
239
  trustedHosts: z.array(z.string()),
221
240
  devicesFile: z.string(),
222
- autoTunnel: z.boolean().default(false),
223
- tunnelToken: z.string().role('secret'),
224
- relay: z.boolean().default(true),
225
- lanBind: z.boolean(),
241
+ autoTunnel: z.boolean().default(false).volatile(),
242
+ tunnelToken: z.string().role('secret').volatile(),
243
+ relay: z.boolean().default(true).volatile(),
244
+ lanBind: z.boolean().volatile(),
226
245
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
227
- enabled: z.boolean().default(true),
246
+ enabled: z.boolean().default(true).volatile(),
228
247
  })
229
248
 
230
249
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
231
250
  const SWEEP_INTERVAL_MS = 10_000
232
251
 
233
- /**
234
- * How long one update-status probe answer is reused. The probe fans out one
235
- * registry GET per family package plus a GitHub call, and the sidebar update
236
- * entry asks for the status on every GUI page load.
237
- */
238
- const UPDATE_STATUS_TTL_MS = 60_000
239
-
240
252
  /**
241
253
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
242
254
  * which legitimately resolves to `undefined` when unset (the schema keeps it
@@ -252,6 +264,60 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | '
252
264
  profile: string
253
265
  }
254
266
 
267
+ /**
268
+ * The stable reference a `volatile()` config field resolves to; its owner
269
+ * updates it in place when the user saves.
270
+ */
271
+ interface ConfigRef<T> {
272
+ /** @returns the value currently committed for the running instance. */
273
+ get(): T
274
+ }
275
+
276
+ /** One resolved config field: a live reference, or a plain value from a hand-built context. */
277
+ type ConfigField<T> = ConfigRef<T> | T
278
+
279
+ /** The config the Host hands {@link applyImpl} — the runtime face of {@link Config}. */
280
+ export interface ResolvedConfigFields {
281
+ tokenTtlMs?: ConfigField<number>
282
+ offlineAfterMs?: ConfigField<number>
283
+ maxDevices?: ConfigField<number>
284
+ idleExpireMs?: ConfigField<number>
285
+ cookieName?: ConfigField<string>
286
+ requirePairingForLan?: ConfigField<boolean>
287
+ publicBaseUrl?: ConfigField<string>
288
+ trustedHosts?: ConfigField<string[]>
289
+ devicesFile?: ConfigField<string>
290
+ autoTunnel?: ConfigField<boolean>
291
+ tunnelToken?: ConfigField<string>
292
+ relay?: ConfigField<boolean>
293
+ lanBind?: ConfigField<boolean>
294
+ profile?: ConfigField<string>
295
+ enabled?: ConfigField<boolean>
296
+ }
297
+
298
+ /** Read one resolved config field, following the live reference the schema produces. */
299
+ function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
300
+ if (field === undefined) return fallback
301
+ if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
302
+ const value = (field as ConfigRef<T>).get()
303
+ return value === undefined ? fallback : value
304
+ }
305
+ return field as T
306
+ }
307
+
308
+ /**
309
+ * Read one optional resolved config field. A volatile reference is read at
310
+ * call time, so an unset field stays `undefined` rather than falling back to a
311
+ * schema default (the distinction the LAN toggle and the tunnel plan depend on).
312
+ */
313
+ function readOptionalConfigField<T>(field: ConfigField<T> | undefined): T | undefined {
314
+ if (field === undefined) return undefined
315
+ if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
316
+ return (field as ConfigRef<T>).get()
317
+ }
318
+ return field as T
319
+ }
320
+
255
321
  /**
256
322
  * The single mapping from resolved plugin config to the pairing service
257
323
  * config. Both the constructed service and every later apply of this row
@@ -291,10 +357,24 @@ const DEFAULTS: ResolvedConfig = {
291
357
  tunnelToken: undefined,
292
358
  relay: true,
293
359
  lanBind: undefined,
294
- profile: process.env.DSH_PROFILE ?? 'web',
360
+ profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
295
361
  enabled: true,
296
362
  }
297
363
 
364
+ /**
365
+ * The launched profile the Host publishes on its `profileContext` service
366
+ * (`{ name, dir, patchPath, … }`). The Desktop client boots the "desktop"
367
+ * profile without exporting DSH_PROFILE, so the environment alone resolves
368
+ * the wrong profile there and the LAN bind toggle would edit a profile that
369
+ * is not running.
370
+ * @param ctx - host plugin context.
371
+ * @returns the profile name when the Host publishes one; undefined otherwise.
372
+ */
373
+ function launchedProfileName(ctx: Context): string | undefined {
374
+ const fact = ctx.get('profileContext') as { name?: unknown } | undefined
375
+ return typeof fact?.name === 'string' && fact.name.length > 0 ? fact.name : undefined
376
+ }
377
+
298
378
  /**
299
379
  * Mount the pairing service, routes, gate listener, and presence sweep.
300
380
  * @param ctx - host plugin context carrying webServer.
@@ -302,32 +382,31 @@ const DEFAULTS: ResolvedConfig = {
302
382
  */
303
383
  export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
304
384
 
305
- function applyImpl(ctx: Context, config?: Config): void {
385
+ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
306
386
  const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined
307
- const resolved: ResolvedConfig = {
308
- tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
309
- offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
310
- maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
311
- idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
312
- cookieName: config?.cookieName ?? DEFAULTS.cookieName,
313
- requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
314
- publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
315
- trustedHosts: config?.trustedHosts,
316
- devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
317
- autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
318
- tunnelToken: config?.tunnelToken,
319
- relay: config?.relay ?? DEFAULTS.relay,
320
- lanBind: config?.lanBind,
321
- profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
322
- enabled: config?.enabled ?? DEFAULTS.enabled,
323
- }
324
- // The effective configuration this activation runs on: under the 0.1.7
325
- // settings model the plugin's own Config IS its settings document, so the
326
- // Host serves the settings page from this schema and reloads the row after a
327
- // save — a live edit re-enters apply() with the new value instead of being
328
- // pushed into a running instance.
329
- const resolve = (): ResolvedConfig => resolved
330
- const service = new PairingService(pairingConfigOf(resolved))
387
+ // The settings are read through the volatile references at every call rather
388
+ // than captured once: a settings save commits into those references and
389
+ // announces `loader/volatile-update` (see the listener below), so resolving
390
+ // them here is what makes a live edit reach the running instance without a
391
+ // remount — and keeps the device sessions, the tunnel and the routes alive.
392
+ const resolve = (): ResolvedConfig => ({
393
+ tokenTtlMs: readConfigField(config?.tokenTtlMs, DEFAULTS.tokenTtlMs),
394
+ offlineAfterMs: readConfigField(config?.offlineAfterMs, DEFAULTS.offlineAfterMs),
395
+ maxDevices: readConfigField(config?.maxDevices, DEFAULTS.maxDevices),
396
+ idleExpireMs: readConfigField(config?.idleExpireMs, DEFAULTS.idleExpireMs),
397
+ cookieName: readConfigField(config?.cookieName, DEFAULTS.cookieName),
398
+ requirePairingForLan: readConfigField(config?.requirePairingForLan, DEFAULTS.requirePairingForLan),
399
+ publicBaseUrl: readOptionalConfigField(config?.publicBaseUrl) ?? envPublicBase,
400
+ trustedHosts: readOptionalConfigField(config?.trustedHosts),
401
+ devicesFile: readConfigField(config?.devicesFile, DEFAULTS.devicesFile),
402
+ autoTunnel: readConfigField(config?.autoTunnel, DEFAULTS.autoTunnel),
403
+ tunnelToken: readOptionalConfigField(config?.tunnelToken),
404
+ relay: readConfigField(config?.relay, DEFAULTS.relay),
405
+ lanBind: readOptionalConfigField(config?.lanBind),
406
+ profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
407
+ enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
408
+ })
409
+ const service = new PairingService(pairingConfigOf(resolve()))
331
410
 
332
411
  // ── auto tunnel ─────────────────────────────────────────────────────────
333
412
  // The minted public URL becomes the QR base (and the pairing fence's
@@ -394,7 +473,7 @@ function applyImpl(ctx: Context, config?: Config): void {
394
473
  }
395
474
  // 'off' until a sync pass turns a mode on; the phase listener only feeds
396
475
  // the public base while a plugin-managed tunnel (quick or named) runs.
397
- let tunnelMode: 'off' | 'quick' | 'named' = resolved.autoTunnel ? 'quick' : 'off'
476
+ let tunnelMode: 'off' | 'quick' | 'named' = resolve().autoTunnel ? 'quick' : 'off'
398
477
  tunnel.onPhase((info: TunnelInfo) => {
399
478
  if (tunnelMode === 'off') return
400
479
  if (info.phase === 'running' && info.url !== undefined) {
@@ -450,89 +529,6 @@ function applyImpl(ctx: Context, config?: Config): void {
450
529
  // request) instead of opening the fence.
451
530
  let disposeRoutes: (() => void) | undefined
452
531
  let disposeSweep: (() => void) | undefined
453
- // ── remote update ────────────────────────────────────────────────────────
454
- // The dsh-web self-update surface: probe the npm registry for family
455
- // releases and run `pnpm update --latest` in the owning profile. Resolutions
456
- // anchor on the host process's own module graph, so the update always
457
- // targets the profile the running web GUI was booted from. The anchor path
458
- // is re-resolved per operation: pnpm removes the old version's .pnpm
459
- // directory on update, so a boot-time captured path would fail to read
460
- // after a successful update; versions are re-read from disk per check.
461
- const requireFromHost = createRequire(import.meta.url)
462
- /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
463
- const hostResolve = (specifier: string): string | undefined => {
464
- try {
465
- return requireFromHost.resolve(specifier)
466
- } catch {
467
- return undefined
468
- }
469
- }
470
- const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(hostResolve)
471
-
472
- const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
473
- const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
474
- const cached = releaseNotesCache.get(version)
475
- if (cached !== undefined && Date.now() - cached.at < RELEASE_NOTES_CACHE_TTL_MS) return cached.notes
476
- const notes = await fetchGitHubReleaseNotes(version, fetch)
477
- releaseNotesCache.set(version, { at: Date.now(), notes })
478
- return notes
479
- }
480
- // The status probe fans out one registry GET per family package (plus a
481
- // GitHub release-notes call), and the sidebar update entry asks for it on
482
- // every GUI page load — so the answer is memoized briefly and concurrent
483
- // callers share one probe. A completed update invalidates it.
484
- let updateStatusCache: { at: number; value: Awaited<ReturnType<typeof checkUpdates>> } | undefined
485
- let updateStatusInFlight: Promise<Awaited<ReturnType<typeof checkUpdates>>> | undefined
486
- const checkStatusCached = (): Promise<Awaited<ReturnType<typeof checkUpdates>>> => {
487
- const cached = updateStatusCache
488
- if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value)
489
- if (updateStatusInFlight !== undefined) return updateStatusInFlight
490
- updateStatusInFlight = checkUpdates({
491
- anchorManifestPath: resolveAnchorPath(),
492
- resolve: hostResolve,
493
- fetchLatest: name => fetchLatestVersion(name, fetch),
494
- fetchReleaseNotes: fetchReleaseNotesCached,
495
- }).then((value) => {
496
- updateStatusCache = { at: Date.now(), value }
497
- return value
498
- }).finally(() => { updateStatusInFlight = undefined })
499
- return updateStatusInFlight
500
- }
501
- const updateRoutes = makeUpdateRoutes({
502
- // Control endpoints are host-surface only: a LAN/phone origin must never
503
- // trigger a real install on this machine.
504
- fence: request => isTrustedApiRequest(request, []),
505
- check: () => checkStatusCached(),
506
- run: async (): Promise<UpdateRunResult> => {
507
- const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
508
- if ('error' in target) {
509
- const code = target.error
510
- return {
511
- ok: false,
512
- exitCode: null,
513
- output: '',
514
- error: code === 'not-found' ? 'dsh-web aggregate not installed' : 'local link install — update unavailable',
515
- errorCode: code,
516
- }
517
- }
518
- // Verify the versions actually moved after a green pnpm exit: the pnpm
519
- // 11 minimumReleaseAge gate can silently keep the installed versions
520
- // (same-day releases), which a plain exit-0 check would report as
521
- // success — the user then restarts and nothing changed.
522
- const result = await runUpdateVerified({
523
- run: { profileDir: target.profileDir, packages: target.packages },
524
- check: {
525
- anchorManifestPath: resolveAnchorPath(),
526
- resolve: hostResolve,
527
- fetchLatest: name => fetchLatestVersion(name, fetch),
528
- fetchReleaseNotes: fetchReleaseNotesCached,
529
- },
530
- })
531
- // The install moved (or failed) the versions the cached status reported.
532
- updateStatusCache = undefined
533
- return result
534
- },
535
- })
536
532
  // LAN-bind facts for the settings card, re-read per request so a hot
537
533
  // rebind (the patch watcher recomposes the process) and a fresh toggle
538
534
  // round are both reflected without a restart.
@@ -653,7 +649,6 @@ function applyImpl(ctx: Context, config?: Config): void {
653
649
  port: ctx.webServer.port,
654
650
  auth: innerAuth,
655
651
  }),
656
- ...updateRoutes,
657
652
  ]
658
653
  const upgrades = makeRemoteApiUpgradeRoutes({
659
654
  service,
@@ -917,8 +912,17 @@ function applyImpl(ctx: Context, config?: Config): void {
917
912
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
918
913
  }), 'remote-web-ui: remote channel boot patch')
919
914
 
915
+ // The settings write path. The Host commits the new values into this row's
916
+ // volatile references and announces it here instead of remounting the row, so
917
+ // `sync` is what re-applies them: it re-reads every field through those
918
+ // references (see `resolve`) and is idempotent, so a save that changes
919
+ // nothing observable is a no-op while a disable, a bind flip or a tunnel
920
+ // change lands on the live instance without dropping the pairing service,
921
+ // its device sessions or the route registrations.
922
+ ctx.on('loader/volatile-update', () => { sync() })
923
+
920
924
  // No settings registration: the Host derives this plugin's settings page
921
- // from the exported `Config` schema and reloads the row after a save, so
922
- // every write re-enters apply() with the new config (see `resolve`).
925
+ // from the exported `Config` schema, whose volatile fields are what make the
926
+ // page writable and what route a save through the live path above.
923
927
  sync()
924
928
  }
@@ -64,6 +64,28 @@ export interface AppliedFirewallState {
64
64
  port: number
65
65
  }
66
66
 
67
+ /** The managed profile when neither the config nor the runtime names one. */
68
+ export const DEFAULT_MANAGED_PROFILE = 'web'
69
+
70
+ /**
71
+ * The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
72
+ * explicit `profile` config wins; otherwise the launched profile the Host
73
+ * publishes is authoritative, because the DSH Desktop client boots the
74
+ * `desktop` profile without exporting `DSH_PROFILE` — an environment-only
75
+ * fallback edited `profiles/web` there, so the toggle looked applied while
76
+ * the running host never followed the block.
77
+ * @param configured - the explicit `profile` plugin config, when set.
78
+ * @param launched - the launched profile name the Host published, when available.
79
+ * @param env - the DSH_PROFILE environment value, when set.
80
+ */
81
+ export function resolveManagedProfile(
82
+ configured: string | undefined,
83
+ launched: string | undefined,
84
+ env: string | undefined,
85
+ ): string {
86
+ return configured ?? launched ?? env ?? DEFAULT_MANAGED_PROFILE
87
+ }
88
+
67
89
  /**
68
90
  * Whether the firewall rule must be (re)applied: only when the toggle or the
69
91
  * bound port moved since the last application. Keeps unrelated settings