@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
@@ -7,15 +7,14 @@
7
7
  * `./client` entry) renders the sidebar entry, the pairing panel, and the
8
8
  * phone-side pair/accept + deep-link flow.
9
9
  */
10
- import { createRequire } from 'node:module';
11
10
  import { join } from 'node:path';
12
11
  import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers';
13
- import z from 'schemastery';
12
+ import z from '@deepseek-ai/schemastery';
14
13
  import { DEFAULT_IDLE_EXPIRE_MS, PairingService } from "./pairing.js";
15
14
  import { dshHome } from "./dsh-home.js";
16
15
  import { isPairedDeviceRequest, makeGateListener } from "./gate.js";
17
16
  import { RemoteWebUiPairing } from "./pairing-access.js";
18
- import { isTrustedApiRequest, makeRoutes } from "./routes.js";
17
+ import { makeRoutes } from "./routes.js";
19
18
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from "./remote-api.js";
20
19
  import { startRemotePresencePet } from "./remote-presence-pet.js";
21
20
  import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from "./posture.js";
@@ -24,13 +23,11 @@ import { ensureFirewallRule, firewallSummary, removeFirewallRule } from "./firew
24
23
  import { lanBindState, writeLanBind } from "./lan-bind.js";
25
24
  import { isHttpUrl, tunnelPlanOf } from "./tunnel-plan.js";
26
25
  import { loadRelayIdentity, RelayRegistrar } from "./relay-registry.js";
27
- import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf } from "./lan-bind-plan.js";
26
+ import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, resolveManagedProfile } from "./lan-bind-plan.js";
28
27
  import { createInnerAuth } from "./inner-auth.js";
29
28
  import { withIdentityEncoding } from "./http.js";
30
29
  import { TunnelManager } from "./tunnel.js";
31
30
  import { PublicBaseKeeper } from "./public-base.js";
32
- import { checkUpdates, fetchGitHubReleaseNotes, fetchLatestVersion, RELEASE_NOTES_CACHE_TTL_MS, resolveAnchorManifest, resolveUpdateTarget, runUpdateVerified, } from "./update.js";
33
- import { makeUpdateRoutes } from "./update-routes.js";
34
31
  import { mountOnce } from "./mount-once.js";
35
32
  import { REMOTE_CHANNEL_BOOT_SCRIPT } from "./remote-channel-boot.js";
36
33
  import { UUID_POLYFILL_SCRIPT } from "./uuid-polyfill.js";
@@ -47,31 +44,68 @@ export const inject = ['webServer', 'typertGateway', 'connection'];
47
44
  * Host package.
48
45
  */
49
46
  export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui';
47
+ /**
48
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
49
+ * entry's settings page: the Host derives one form per profile entry from it
50
+ * and serves that form only when at least one field is `volatile()`. The marker
51
+ * is also what admits a write and what keeps the edit on the live path — the
52
+ * Loader commits the new value into the field's reference and announces
53
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
54
+ * pairing service, its device sessions, the tunnel and the route registrations
55
+ * survive a settings save (see {@link applyImpl}'s sync).
56
+ *
57
+ * The schema is left to inference rather than annotated with `z<Config>`: a
58
+ * volatile field's parsed output is a live reference while its accepted input
59
+ * stays the plain value, so the two sides no longer share one shape and the
60
+ * annotation would reject the schema the Host must be given.
61
+ *
62
+ * The deployment-level fields (`trustedHosts`, `devicesFile`, `profile`) are
63
+ * deliberately NOT volatile: they belong in the profile patch, so the form
64
+ * leaves them to the operator instead of offering a card control that a
65
+ * document write could not honor.
66
+ */
50
67
  export const Config = z.object({
51
- tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000),
52
- offlineAfterMs: z.number().step(1).min(5_000).default(25_000),
53
- maxDevices: z.number().step(1).min(1).max(64).default(4),
54
- idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS),
55
- cookieName: z.string().min(1).default('dsh_pair'),
56
- requirePairingForLan: z.boolean().default(true),
57
- publicBaseUrl: z.string(),
68
+ tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000).volatile(),
69
+ offlineAfterMs: z.number().step(1).min(5_000).default(25_000).volatile(),
70
+ maxDevices: z.number().step(1).min(1).max(64).default(4).volatile(),
71
+ idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS).volatile(),
72
+ cookieName: z.string().min(1).default('dsh_pair').volatile(),
73
+ requirePairingForLan: z.boolean().default(true).volatile(),
74
+ publicBaseUrl: z.string().volatile(),
58
75
  trustedHosts: z.array(z.string()),
59
76
  devicesFile: z.string(),
60
- autoTunnel: z.boolean().default(false),
61
- tunnelToken: z.string().role('secret'),
62
- relay: z.boolean().default(true),
63
- lanBind: z.boolean(),
77
+ autoTunnel: z.boolean().default(false).volatile(),
78
+ tunnelToken: z.string().role('secret').volatile(),
79
+ relay: z.boolean().default(true).volatile(),
80
+ lanBind: z.boolean().volatile(),
64
81
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
65
- enabled: z.boolean().default(true),
82
+ enabled: z.boolean().default(true).volatile(),
66
83
  });
67
84
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
68
85
  const SWEEP_INTERVAL_MS = 10_000;
86
+ /** Read one resolved config field, following the live reference the schema produces. */
87
+ function readConfigField(field, fallback) {
88
+ if (field === undefined)
89
+ return fallback;
90
+ if (typeof field === 'object' && field !== null && typeof field.get === 'function') {
91
+ const value = field.get();
92
+ return value === undefined ? fallback : value;
93
+ }
94
+ return field;
95
+ }
69
96
  /**
70
- * How long one update-status probe answer is reused. The probe fans out one
71
- * registry GET per family package plus a GitHub call, and the sidebar update
72
- * entry asks for the status on every GUI page load.
97
+ * Read one optional resolved config field. A volatile reference is read at
98
+ * call time, so an unset field stays `undefined` rather than falling back to a
99
+ * schema default (the distinction the LAN toggle and the tunnel plan depend on).
73
100
  */
74
- const UPDATE_STATUS_TTL_MS = 60_000;
101
+ function readOptionalConfigField(field) {
102
+ if (field === undefined)
103
+ return undefined;
104
+ if (typeof field === 'object' && field !== null && typeof field.get === 'function') {
105
+ return field.get();
106
+ }
107
+ return field;
108
+ }
75
109
  /**
76
110
  * The single mapping from resolved plugin config to the pairing service
77
111
  * config. Both the constructed service and every later apply of this row
@@ -106,9 +140,22 @@ const DEFAULTS = {
106
140
  tunnelToken: undefined,
107
141
  relay: true,
108
142
  lanBind: undefined,
109
- profile: process.env.DSH_PROFILE ?? 'web',
143
+ profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
110
144
  enabled: true,
111
145
  };
146
+ /**
147
+ * The launched profile the Host publishes on its `profileContext` service
148
+ * (`{ name, dir, patchPath, … }`). The Desktop client boots the "desktop"
149
+ * profile without exporting DSH_PROFILE, so the environment alone resolves
150
+ * the wrong profile there and the LAN bind toggle would edit a profile that
151
+ * is not running.
152
+ * @param ctx - host plugin context.
153
+ * @returns the profile name when the Host publishes one; undefined otherwise.
154
+ */
155
+ function launchedProfileName(ctx) {
156
+ const fact = ctx.get('profileContext');
157
+ return typeof fact?.name === 'string' && fact.name.length > 0 ? fact.name : undefined;
158
+ }
112
159
  /**
113
160
  * Mount the pairing service, routes, gate listener, and presence sweep.
114
161
  * @param ctx - host plugin context carrying webServer.
@@ -117,30 +164,29 @@ const DEFAULTS = {
117
164
  export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl);
118
165
  function applyImpl(ctx, config) {
119
166
  const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined;
120
- const resolved = {
121
- tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
122
- offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
123
- maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
124
- idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
125
- cookieName: config?.cookieName ?? DEFAULTS.cookieName,
126
- requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
127
- publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
128
- trustedHosts: config?.trustedHosts,
129
- devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
130
- autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
131
- tunnelToken: config?.tunnelToken,
132
- relay: config?.relay ?? DEFAULTS.relay,
133
- lanBind: config?.lanBind,
134
- profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
135
- enabled: config?.enabled ?? DEFAULTS.enabled,
136
- };
137
- // The effective configuration this activation runs on: under the 0.1.7
138
- // settings model the plugin's own Config IS its settings document, so the
139
- // Host serves the settings page from this schema and reloads the row after a
140
- // save — a live edit re-enters apply() with the new value instead of being
141
- // pushed into a running instance.
142
- const resolve = () => resolved;
143
- const service = new PairingService(pairingConfigOf(resolved));
167
+ // The settings are read through the volatile references at every call rather
168
+ // than captured once: a settings save commits into those references and
169
+ // announces `loader/volatile-update` (see the listener below), so resolving
170
+ // them here is what makes a live edit reach the running instance without a
171
+ // remount — and keeps the device sessions, the tunnel and the routes alive.
172
+ const resolve = () => ({
173
+ tokenTtlMs: readConfigField(config?.tokenTtlMs, DEFAULTS.tokenTtlMs),
174
+ offlineAfterMs: readConfigField(config?.offlineAfterMs, DEFAULTS.offlineAfterMs),
175
+ maxDevices: readConfigField(config?.maxDevices, DEFAULTS.maxDevices),
176
+ idleExpireMs: readConfigField(config?.idleExpireMs, DEFAULTS.idleExpireMs),
177
+ cookieName: readConfigField(config?.cookieName, DEFAULTS.cookieName),
178
+ requirePairingForLan: readConfigField(config?.requirePairingForLan, DEFAULTS.requirePairingForLan),
179
+ publicBaseUrl: readOptionalConfigField(config?.publicBaseUrl) ?? envPublicBase,
180
+ trustedHosts: readOptionalConfigField(config?.trustedHosts),
181
+ devicesFile: readConfigField(config?.devicesFile, DEFAULTS.devicesFile),
182
+ autoTunnel: readConfigField(config?.autoTunnel, DEFAULTS.autoTunnel),
183
+ tunnelToken: readOptionalConfigField(config?.tunnelToken),
184
+ relay: readConfigField(config?.relay, DEFAULTS.relay),
185
+ lanBind: readOptionalConfigField(config?.lanBind),
186
+ profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
187
+ enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
188
+ });
189
+ const service = new PairingService(pairingConfigOf(resolve()));
144
190
  // ── auto tunnel ─────────────────────────────────────────────────────────
145
191
  // The minted public URL becomes the QR base (and the pairing fence's
146
192
  // trusted host). Phone /api traffic rides the plugin's own /remote channel,
@@ -213,7 +259,7 @@ function applyImpl(ctx, config) {
213
259
  };
214
260
  // 'off' until a sync pass turns a mode on; the phase listener only feeds
215
261
  // the public base while a plugin-managed tunnel (quick or named) runs.
216
- let tunnelMode = resolved.autoTunnel ? 'quick' : 'off';
262
+ let tunnelMode = resolve().autoTunnel ? 'quick' : 'off';
217
263
  tunnel.onPhase((info) => {
218
264
  if (tunnelMode === 'off')
219
265
  return;
@@ -272,92 +318,6 @@ function applyImpl(ctx, config) {
272
318
  // request) instead of opening the fence.
273
319
  let disposeRoutes;
274
320
  let disposeSweep;
275
- // ── remote update ────────────────────────────────────────────────────────
276
- // The dsh-web self-update surface: probe the npm registry for family
277
- // releases and run `pnpm update --latest` in the owning profile. Resolutions
278
- // anchor on the host process's own module graph, so the update always
279
- // targets the profile the running web GUI was booted from. The anchor path
280
- // is re-resolved per operation: pnpm removes the old version's .pnpm
281
- // directory on update, so a boot-time captured path would fail to read
282
- // after a successful update; versions are re-read from disk per check.
283
- const requireFromHost = createRequire(import.meta.url);
284
- /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
285
- const hostResolve = (specifier) => {
286
- try {
287
- return requireFromHost.resolve(specifier);
288
- }
289
- catch {
290
- return undefined;
291
- }
292
- };
293
- const resolveAnchorPath = () => resolveAnchorManifest(hostResolve);
294
- const releaseNotesCache = new Map();
295
- const fetchReleaseNotesCached = async (version) => {
296
- const cached = releaseNotesCache.get(version);
297
- if (cached !== undefined && Date.now() - cached.at < RELEASE_NOTES_CACHE_TTL_MS)
298
- return cached.notes;
299
- const notes = await fetchGitHubReleaseNotes(version, fetch);
300
- releaseNotesCache.set(version, { at: Date.now(), notes });
301
- return notes;
302
- };
303
- // The status probe fans out one registry GET per family package (plus a
304
- // GitHub release-notes call), and the sidebar update entry asks for it on
305
- // every GUI page load — so the answer is memoized briefly and concurrent
306
- // callers share one probe. A completed update invalidates it.
307
- let updateStatusCache;
308
- let updateStatusInFlight;
309
- const checkStatusCached = () => {
310
- const cached = updateStatusCache;
311
- if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS)
312
- return Promise.resolve(cached.value);
313
- if (updateStatusInFlight !== undefined)
314
- return updateStatusInFlight;
315
- updateStatusInFlight = checkUpdates({
316
- anchorManifestPath: resolveAnchorPath(),
317
- resolve: hostResolve,
318
- fetchLatest: name => fetchLatestVersion(name, fetch),
319
- fetchReleaseNotes: fetchReleaseNotesCached,
320
- }).then((value) => {
321
- updateStatusCache = { at: Date.now(), value };
322
- return value;
323
- }).finally(() => { updateStatusInFlight = undefined; });
324
- return updateStatusInFlight;
325
- };
326
- const updateRoutes = makeUpdateRoutes({
327
- // Control endpoints are host-surface only: a LAN/phone origin must never
328
- // trigger a real install on this machine.
329
- fence: request => isTrustedApiRequest(request, []),
330
- check: () => checkStatusCached(),
331
- run: async () => {
332
- const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
333
- if ('error' in target) {
334
- const code = target.error;
335
- return {
336
- ok: false,
337
- exitCode: null,
338
- output: '',
339
- error: code === 'not-found' ? 'dsh-web aggregate not installed' : 'local link install — update unavailable',
340
- errorCode: code,
341
- };
342
- }
343
- // Verify the versions actually moved after a green pnpm exit: the pnpm
344
- // 11 minimumReleaseAge gate can silently keep the installed versions
345
- // (same-day releases), which a plain exit-0 check would report as
346
- // success — the user then restarts and nothing changed.
347
- const result = await runUpdateVerified({
348
- run: { profileDir: target.profileDir, packages: target.packages },
349
- check: {
350
- anchorManifestPath: resolveAnchorPath(),
351
- resolve: hostResolve,
352
- fetchLatest: name => fetchLatestVersion(name, fetch),
353
- fetchReleaseNotes: fetchReleaseNotesCached,
354
- },
355
- });
356
- // The install moved (or failed) the versions the cached status reported.
357
- updateStatusCache = undefined;
358
- return result;
359
- },
360
- });
361
321
  // LAN-bind facts for the settings card, re-read per request so a hot
362
322
  // rebind (the patch watcher recomposes the process) and a fresh toggle
363
323
  // round are both reflected without a restart.
@@ -488,7 +448,6 @@ function applyImpl(ctx, config) {
488
448
  port: ctx.webServer.port,
489
449
  auth: innerAuth,
490
450
  }),
491
- ...updateRoutes,
492
451
  ];
493
452
  const upgrades = makeRemoteApiUpgradeRoutes({
494
453
  service,
@@ -760,8 +719,16 @@ function applyImpl(ctx, config) {
760
719
  return;
761
720
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT });
762
721
  }), 'remote-web-ui: remote channel boot patch');
722
+ // The settings write path. The Host commits the new values into this row's
723
+ // volatile references and announces it here instead of remounting the row, so
724
+ // `sync` is what re-applies them: it re-reads every field through those
725
+ // references (see `resolve`) and is idempotent, so a save that changes
726
+ // nothing observable is a no-op while a disable, a bind flip or a tunnel
727
+ // change lands on the live instance without dropping the pairing service,
728
+ // its device sessions or the route registrations.
729
+ ctx.on('loader/volatile-update', () => { sync(); });
763
730
  // No settings registration: the Host derives this plugin's settings page
764
- // from the exported `Config` schema and reloads the row after a save, so
765
- // every write re-enters apply() with the new config (see `resolve`).
731
+ // from the exported `Config` schema, whose volatile fields are what make the
732
+ // page writable and what route a save through the live path above.
766
733
  sync();
767
734
  }
@@ -44,6 +44,20 @@ export interface AppliedFirewallState {
44
44
  enabled: boolean;
45
45
  port: number;
46
46
  }
47
+ /** The managed profile when neither the config nor the runtime names one. */
48
+ export declare const DEFAULT_MANAGED_PROFILE = "web";
49
+ /**
50
+ * The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
51
+ * explicit `profile` config wins; otherwise the launched profile the Host
52
+ * publishes is authoritative, because the DSH Desktop client boots the
53
+ * `desktop` profile without exporting `DSH_PROFILE` — an environment-only
54
+ * fallback edited `profiles/web` there, so the toggle looked applied while
55
+ * the running host never followed the block.
56
+ * @param configured - the explicit `profile` plugin config, when set.
57
+ * @param launched - the launched profile name the Host published, when available.
58
+ * @param env - the DSH_PROFILE environment value, when set.
59
+ */
60
+ export declare function resolveManagedProfile(configured: string | undefined, launched: string | undefined, env: string | undefined): string;
47
61
  /**
48
62
  * Whether the firewall rule must be (re)applied: only when the toggle or the
49
63
  * bound port moved since the last application. Keeps unrelated settings
@@ -1 +1 @@
1
- {"version":3,"file":"lan-bind-plan.d.ts","sourceRoot":"","sources":["../../src/lan-bind-plan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iDAAiD;AACjD,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,CAAA;AAErD,oFAAoF;AACpF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAGlG;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGjH;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,WAAW,EAAE,eAAe,GAAG,SAAS,EACxC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,OAAO,CAGT;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,oBAAoB,GAAG,SAAS,EAC1C,IAAI,EAAE,oBAAoB,GAAG,SAAS,GACrC,OAAO,CAIT"}
1
+ {"version":3,"file":"lan-bind-plan.d.ts","sourceRoot":"","sources":["../../src/lan-bind-plan.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,iDAAiD;AACjD,MAAM,MAAM,eAAe,GAAG,SAAS,GAAG,WAAW,CAAA;AAErD,oFAAoF;AACpF,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,MAAM,GAAG,SAAS,GAAG,eAAe,CAGlG;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGjH;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,OAAO,GAAG,SAAS,EAC5B,WAAW,EAAE,eAAe,GAAG,SAAS,EACxC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,OAAO,CAGT;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAED,6EAA6E;AAC7E,eAAO,MAAM,uBAAuB,QAAQ,CAAA;AAE5C;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,GAAG,EAAE,MAAM,GAAG,SAAS,GACtB,MAAM,CAER;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAClC,QAAQ,EAAE,oBAAoB,GAAG,SAAS,EAC1C,IAAI,EAAE,oBAAoB,GAAG,SAAS,GACrC,OAAO,CAIT"}
@@ -44,6 +44,22 @@ export function pendingRestartOf(lanBind, desiredHost, liveHost) {
44
44
  return false;
45
45
  return liveHost !== desiredHost;
46
46
  }
47
+ /** The managed profile when neither the config nor the runtime names one. */
48
+ export const DEFAULT_MANAGED_PROFILE = 'web';
49
+ /**
50
+ * The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
51
+ * explicit `profile` config wins; otherwise the launched profile the Host
52
+ * publishes is authoritative, because the DSH Desktop client boots the
53
+ * `desktop` profile without exporting `DSH_PROFILE` — an environment-only
54
+ * fallback edited `profiles/web` there, so the toggle looked applied while
55
+ * the running host never followed the block.
56
+ * @param configured - the explicit `profile` plugin config, when set.
57
+ * @param launched - the launched profile name the Host published, when available.
58
+ * @param env - the DSH_PROFILE environment value, when set.
59
+ */
60
+ export function resolveManagedProfile(configured, launched, env) {
61
+ return configured ?? launched ?? env ?? DEFAULT_MANAGED_PROFILE;
62
+ }
47
63
  /**
48
64
  * Whether the firewall rule must be (re)applied: only when the toggle or the
49
65
  * bound port moved since the last application. Keeps unrelated settings
@@ -24,7 +24,7 @@ export declare const LAN_BIND_BLOCK_END = "# --- end remote-web-ui lan-bind bloc
24
24
  export type LanBindHost = '0.0.0.0' | '127.0.0.1';
25
25
  /**
26
26
  * The absolute path of the profile patch file this toggle manages. The value
27
- * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
27
+ * is config-controlled (and the runtime/environment fallbacks bypass schema
28
28
  * validation entirely), so the path is guarded twice: the profile must be a
29
29
  * single safe path segment, and the resolved file must stay under the
30
30
  * profiles directory.
@@ -64,6 +64,10 @@ export declare function lanBindState(profile: string, home?: string): {
64
64
  * unterminated block (BEGIN marker without END, which stripManagedBlock
65
65
  * cannot match) is truncated at its BEGIN marker first so the rewrite can
66
66
  * never stack a second webserver row onto the orphan.
67
+ *
68
+ * The written file is always exactly one valid YAML document: a base the
69
+ * plugin manager left in flow style is re-emitted as a block sequence before
70
+ * the managed block is appended (see mergePatchContent).
67
71
  */
68
72
  export declare function writeLanBind(host: LanBindHost, port: number, profile: string, home?: string): void;
69
73
  //# sourceMappingURL=lan-bind.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"lan-bind.d.ts","sourceRoot":"","sources":["../../src/lan-bind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AACpG,eAAO,MAAM,kBAAkB,+CAA+C,CAAA;AAE9E,iDAAiD;AACjD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,CAAA;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,MAAM,CAUlF;AAYD,qEAAqE;AACrE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKzD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcpE;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;IACjC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAW3E;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG;IAAE,YAAY,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAI/H;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,IAAI,CAa7G"}
1
+ {"version":3,"file":"lan-bind.d.ts","sourceRoot":"","sources":["../../src/lan-bind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAUH,eAAO,MAAM,oBAAoB,mEAAmE,CAAA;AACpG,eAAO,MAAM,kBAAkB,+CAA+C,CAAA;AAE9E,iDAAiD;AACjD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,WAAW,CAAA;AAEjD;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,MAAM,CAUlF;AAYD,qEAAqE;AACrE,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAKzD;AA0DD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAcpE;AAED,8EAA8E;AAC9E,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,WAAW,GAAG,CAAC,MAAM,GAAG,EAAE,CAAC,CAAA;IACjC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAW3E;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG;IAAE,YAAY,EAAE,OAAO,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,CAI/H;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,MAAkB,GAAG,IAAI,CAa7G"}
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
22
22
  import { dirname, join, resolve, sep } from 'node:path';
23
+ import { parseDocument } from 'yaml';
23
24
  import { dshHome } from "./dsh-home.js";
24
25
  /** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
25
26
  const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -27,7 +28,7 @@ export const LAN_BIND_BLOCK_BEGIN = '# --- remote-web-ui lan-bind block (managed
27
28
  export const LAN_BIND_BLOCK_END = '# --- end remote-web-ui lan-bind block ---';
28
29
  /**
29
30
  * The absolute path of the profile patch file this toggle manages. The value
30
- * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
31
+ * is config-controlled (and the runtime/environment fallbacks bypass schema
31
32
  * validation entirely), so the path is guarded twice: the profile must be a
32
33
  * single safe path segment, and the resolved file must stay under the
33
34
  * profiles directory.
@@ -59,6 +60,64 @@ export function stripManagedBlock(content) {
59
60
  const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, 'g');
60
61
  return content.replace(pattern, '\n');
61
62
  }
63
+ /**
64
+ * Render the managed block for one bind state. The values are static: the
65
+ * user patch layer has no reliable lazy service evaluation, and the plugin
66
+ * re-asserts the block at every boot so CLI flags (--port, --host) win by
67
+ * rewriting it before the next start.
68
+ */
69
+ /**
70
+ * Whether the base opens a flow collection at the root. That is the shape the
71
+ * plugin manager's YAML document round-trip leaves behind when it appends rows
72
+ * to the profile's placeholder `[]`: `[ { id: … }, … ]`. Every other base
73
+ * (empty, block sequence, comment-only prefix) keeps the historical string
74
+ * concatenation, so the common paths stay byte-identical.
75
+ */
76
+ function isFlowRoot(base) {
77
+ return base.trimStart().startsWith('[');
78
+ }
79
+ /** Force one parsed collection (and its item collections) into block style. */
80
+ function toBlockStyle(node) {
81
+ if (typeof node !== 'object' || node === null)
82
+ return;
83
+ const collection = node;
84
+ if (!Array.isArray(collection.items) && typeof collection.flow !== 'boolean')
85
+ return;
86
+ collection.flow = false;
87
+ for (const item of collection.items ?? [])
88
+ toBlockStyle(item);
89
+ }
90
+ /** Plain-value view of one parsed node (YAML nodes keep their own accessors). */
91
+ function plainOf(node) {
92
+ if (typeof node !== 'object' || node === null)
93
+ return node;
94
+ const json = node.toJSON;
95
+ return typeof json === 'function' ? json.call(node) : node;
96
+ }
97
+ /**
98
+ * Produce the file text for one bind state. A flow-style base is re-emitted as
99
+ * a block sequence before the managed block is appended, so the file stays
100
+ * exactly one valid YAML document; concatenating the block onto a non-empty
101
+ * flow array produced two root documents and the profile failed to parse at
102
+ * `dsh web` startup (#1675). A base that cannot be parsed is refused with the
103
+ * file path rather than written half-valid.
104
+ */
105
+ function mergePatchContent(base, block, file) {
106
+ if (base.length === 0)
107
+ return block;
108
+ if (!isFlowRoot(base))
109
+ return `${base}\n\n${block}`;
110
+ const doc = parseDocument(base, { uniqueKeys: false });
111
+ if (doc.errors.length > 0 || doc.contents === null) {
112
+ throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: ${doc.errors[0]?.message ?? 'the patch file is not a YAML document'}`);
113
+ }
114
+ if (!('items' in doc.contents)) {
115
+ throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: the patch file is not a top-level patch list`);
116
+ }
117
+ toBlockStyle(doc.contents);
118
+ const rendered = doc.toString({ lineWidth: 0 }).trimEnd();
119
+ return `${rendered}\n\n${block}`;
120
+ }
62
121
  /**
63
122
  * Render the managed block for one bind state. The values are static: the
64
123
  * user patch layer has no reliable lazy service evaluation, and the plugin
@@ -113,6 +172,10 @@ export function lanBindState(profile, home = dshHome()) {
113
172
  * unterminated block (BEGIN marker without END, which stripManagedBlock
114
173
  * cannot match) is truncated at its BEGIN marker first so the rewrite can
115
174
  * never stack a second webserver row onto the orphan.
175
+ *
176
+ * The written file is always exactly one valid YAML document: a base the
177
+ * plugin manager left in flow style is re-emitted as a block sequence before
178
+ * the managed block is appended (see mergePatchContent).
116
179
  */
117
180
  export function writeLanBind(host, port, profile, home = dshHome()) {
118
181
  const file = profilePatchFile(profile, home);
@@ -121,7 +184,7 @@ export function writeLanBind(host, port, profile, home = dshHome()) {
121
184
  const rawBase = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd();
122
185
  const base = rawBase.replace(/\[\s*\]\s*$/, '').trimEnd();
123
186
  const block = managedBlock(host, port);
124
- const content = base.length > 0 ? `${base}\n\n${block}` : block;
187
+ const content = mergePatchContent(base, block, file);
125
188
  const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600;
126
189
  mkdirSync(dirname(file), { recursive: true });
127
190
  const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -4,19 +4,50 @@
4
4
  * the loader accepts a standalone install of the same package side by side;
5
5
  * without this guard the second instance would still re-register the same
6
6
  * webserver routes, tools, settings namespaces, and system-prompt sections
7
- * and fail the boot. mountOnce makes the second host apply a no-op for the
8
- * lifetime of the first instance (the browser half is already deduped by
9
- * package name in the client module host).
7
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
8
+ * no-op for as long as the first instance lives (the browser half is already
9
+ * deduped by package name in the client module host).
10
+ *
11
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
12
+ * long after the refused mount ran its course: the Host reloads a profile by
13
+ * creating the new loader entries before the old ones are torn down (a
14
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
15
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
16
+ * previous entry still owns the name. Dropping that refused mount lost the
17
+ * plugin for good - the previous entry then disposed its own mount, releasing
18
+ * the name with nobody left to take it, and the family row stayed listed as
19
+ * active while its host routes 404ed (the task-board panel showed
20
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
21
+ * restart recovered it).
22
+ *
23
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
24
+ * the holder releases the name - if the waiting fiber is still alive then. The
25
+ * single-instance guarantee is unchanged: exactly one mount is live per
26
+ * package name, and the replay re-enters the guard so a later mount still
27
+ * dedupes against it.
10
28
  *
11
29
  * The registry rides a global symbol so two module instances of the same
12
- * package (npm copy vs repository link) still share one verdict. cordis
13
- * `ctx.effect` runs its callback immediately and treats the callback's
30
+ * package (npm copy vs repository link) still share one verdict. That symbol
31
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
32
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
33
+ * dsh-community-plugins) are separate repositories carrying their own copy of
34
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
35
+ * must keep the shape every published copy reads (a `Set` of package names
36
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
37
+ * under their own additive key, and the registry reads back a `Set` even when
38
+ * some other build left a different value there. Changing `MOUNTED`'s shape
39
+ * in place broke that contract once: a satellite's legacy copy created a
40
+ * `Set`, the family's new copy read it as a `Map`, and every family row
41
+ * mounted after it failed with "claims.get is not a function".
42
+ *
43
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
14
44
  * return value as the fiber disposer, so the unmarker is returned, not run.
15
45
  */
16
46
  /**
17
47
  * Wrap a cordis plugin apply so the package runs at most once per process.
18
- * The first mount registers normally and unmarks when its fiber disposes;
19
- * any later mount of the same package name is a no-op.
48
+ * The first mount registers normally and releases the name when its fiber
49
+ * disposes; a mount refused while that name is held waits for the release and
50
+ * then runs, unless its own fiber disposes first.
20
51
  * @param packageName - npm package identity shared by every install source.
21
52
  * @param fn - the original plugin apply.
22
53
  * @returns an apply of the same shape.
@@ -1 +1 @@
1
- {"version":3,"file":"mount-once.d.ts","sourceRoot":"","sources":["../../src/mount-once.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;GAcG;AAaH;;;;;;;GAOG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CAW9F"}
1
+ {"version":3,"file":"mount-once.d.ts","sourceRoot":"","sources":["../../src/mount-once.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AA6CH;;;;;;;;GAQG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,GAAG,CAAC,CA0C9F"}