@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
package/lib/index.js CHANGED
@@ -1,7 +1,6 @@
1
- import { createRequire } from "node:module";
2
1
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
2
  import { setInterval, setTimeout as setTimeout$1 } from "node:timers";
4
- import z from "schemastery";
3
+ import z from "@deepseek-ai/schemastery";
5
4
  import { randomBytes } from "node:crypto";
6
5
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
7
6
  import { homedir, networkInterfaces } from "node:os";
@@ -11,6 +10,7 @@ import { z as z$1 } from "zod";
11
10
  import http, { request } from "node:http";
12
11
  import { connect } from "node:net";
13
12
  import { spawn, spawnSync } from "node:child_process";
13
+ import { parseDocument } from "yaml";
14
14
  import { EventEmitter } from "node:events";
15
15
  import { Tunnel, bin, install } from "cloudflared";
16
16
  //#region src/pairing.ts
@@ -108,6 +108,14 @@ var PairingService = class {
108
108
  * Restore device sessions persisted by a previous process run. A corrupt
109
109
  * or missing file is tolerated (an empty device table, never a throw) —
110
110
  * persistence is an availability convenience, not a security boundary.
111
+ *
112
+ * Every still-valid session is restored, deliberately without applying
113
+ * `maxDevices`: the constructor runs with whatever cap the configuration
114
+ * layer has delivered so far (the schema default until the saved settings
115
+ * row is applied), and trimming here would permanently drop authorizations
116
+ * the user never revoked — the trimmed table reaches disk on the next
117
+ * heartbeat/sweep write. The cap is enforced where a device is admitted
118
+ * (see `accept`), against the configuration in force at that moment.
111
119
  */
112
120
  loadPersisted() {
113
121
  const file = this.config.devicesFile;
@@ -127,17 +135,9 @@ var PairingService = class {
127
135
  ...label !== void 0 ? { userAgent: label } : {}
128
136
  });
129
137
  }
130
- this.clampToMaxDevices();
131
138
  if (this.evictIdle()) this.persistRevocation();
132
139
  } catch {}
133
140
  }
134
- /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
135
- clampToMaxDevices() {
136
- if (this.devices.size <= this.config.maxDevices) return;
137
- const overflow = this.devices.size - this.config.maxDevices;
138
- const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
139
- for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id);
140
- }
141
141
  /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
142
142
  evictIdle() {
143
143
  const now = this.clock.now();
@@ -294,13 +294,14 @@ var PairingService = class {
294
294
  };
295
295
  const deviceId = this.clock.randomToken();
296
296
  const now = this.clock.now();
297
- if (this.devices.size >= this.config.maxDevices) {
297
+ while (this.devices.size >= this.config.maxDevices) {
298
298
  let oldest;
299
299
  for (const [id, session] of this.devices) if (oldest === void 0 || session.createdAt < oldest.createdAt) oldest = {
300
300
  id,
301
301
  createdAt: session.createdAt
302
302
  };
303
- if (oldest !== void 0) this.devices.delete(oldest.id);
303
+ if (oldest === void 0) break;
304
+ this.devices.delete(oldest.id);
304
305
  }
305
306
  const label = sanitizeUserAgent(userAgent);
306
307
  this.devices.set(deviceId, {
@@ -402,6 +403,15 @@ var PairingService = class {
402
403
  hasDevice(deviceId) {
403
404
  return this.liveSession(deviceId) !== void 0;
404
405
  }
406
+ /**
407
+ * Number of device sessions in the table, including idle ones whose idle
408
+ * window has not been swept yet. Restore keeps every persisted session
409
+ * (see `loadPersisted`), so this can exceed the configured cap until the
410
+ * next `accept` trims the excess.
411
+ */
412
+ deviceCount() {
413
+ return this.devices.size;
414
+ }
405
415
  /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
406
416
  onState(listener) {
407
417
  this.listeners.add(listener);
@@ -883,6 +893,32 @@ function localOnlyDenial(innerPath) {
883
893
  /** The gated mirror prefix (must match src/remote-methods.ts). */
884
894
  const REMOTE_PREFIX = "/remote";
885
895
  /**
896
+ * The schemes that can deliver a page a remote party controls: the network
897
+ * transports, plus the documents a network page can mint. Every other scheme
898
+ * is registered and served by an application on this machine, so its page is
899
+ * the machine's own page whatever the application calls the scheme.
900
+ *
901
+ * The list is deliberately the *web* side rather than an allowlist of known
902
+ * desktop shells. The official DSH Desktop shell serves its Web GUI from
903
+ * `dsh-app://app/` (`location.hostname === 'app'`), which no hostname
904
+ * predicate can recognise; a scheme allowlist fixed that instance (#1682) but
905
+ * left the next shell — or a `file:` page — fenced behind a pairing page it
906
+ * can never complete, because a pairing link is reachable only over the
907
+ * network. Naming the web side instead makes an unknown application scheme
908
+ * local by construction.
909
+ *
910
+ * `blob:`, `data:`, `about:` and `filesystem:` stay on the web side: a
911
+ * network page mints those documents, so they must keep the fence.
912
+ */
913
+ const WEB_PAGE_PROTOCOLS = [
914
+ "http:",
915
+ "https:",
916
+ "blob:",
917
+ "data:",
918
+ "about:",
919
+ "filesystem:"
920
+ ];
921
+ /**
886
922
  * The window global the device-gated app landing publishes to grant host mode.
887
923
  * Set by the /pair-app capture script, read by the parse-time boot patch: the
888
924
  * transport hook (ownsHost) is server-granted, not asserted from the origin.
@@ -910,6 +946,7 @@ const REMOTE_CHANNEL_RULES = {
910
946
  deviceQuery: REMOTE_DEVICE_QUERY,
911
947
  uploadPath: "/api/session/uploadFileBinary",
912
948
  uploadHookGlobal: "__DSH_FILE_UPLOAD__",
949
+ webProtocols: WEB_PAGE_PROTOCOLS,
913
950
  hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL
914
951
  };
915
952
  /** The window global the boot patch publishes its seat under. */
@@ -2644,7 +2681,7 @@ const LAN_BIND_BLOCK_BEGIN = "# --- remote-web-ui lan-bind block (managed - do n
2644
2681
  const LAN_BIND_BLOCK_END = "# --- end remote-web-ui lan-bind block ---";
2645
2682
  /**
2646
2683
  * The absolute path of the profile patch file this toggle manages. The value
2647
- * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
2684
+ * is config-controlled (and the runtime/environment fallbacks bypass schema
2648
2685
  * validation entirely), so the path is guarded twice: the profile must be a
2649
2686
  * single safe path segment, and the resolved file must stay under the
2650
2687
  * profiles directory.
@@ -2677,6 +2714,47 @@ function stripManagedBlock(content) {
2677
2714
  * re-asserts the block at every boot so CLI flags (--port, --host) win by
2678
2715
  * rewriting it before the next start.
2679
2716
  */
2717
+ /**
2718
+ * Whether the base opens a flow collection at the root. That is the shape the
2719
+ * plugin manager's YAML document round-trip leaves behind when it appends rows
2720
+ * to the profile's placeholder `[]`: `[ { id: … }, … ]`. Every other base
2721
+ * (empty, block sequence, comment-only prefix) keeps the historical string
2722
+ * concatenation, so the common paths stay byte-identical.
2723
+ */
2724
+ function isFlowRoot(base) {
2725
+ return base.trimStart().startsWith("[");
2726
+ }
2727
+ /** Force one parsed collection (and its item collections) into block style. */
2728
+ function toBlockStyle(node) {
2729
+ if (typeof node !== "object" || node === null) return;
2730
+ const collection = node;
2731
+ if (!Array.isArray(collection.items) && typeof collection.flow !== "boolean") return;
2732
+ collection.flow = false;
2733
+ for (const item of collection.items ?? []) toBlockStyle(item);
2734
+ }
2735
+ /**
2736
+ * Produce the file text for one bind state. A flow-style base is re-emitted as
2737
+ * a block sequence before the managed block is appended, so the file stays
2738
+ * exactly one valid YAML document; concatenating the block onto a non-empty
2739
+ * flow array produced two root documents and the profile failed to parse at
2740
+ * `dsh web` startup (#1675). A base that cannot be parsed is refused with the
2741
+ * file path rather than written half-valid.
2742
+ */
2743
+ function mergePatchContent(base, block, file) {
2744
+ if (base.length === 0) return block;
2745
+ if (!isFlowRoot(base)) return `${base}\n\n${block}`;
2746
+ const doc = parseDocument(base, { uniqueKeys: false });
2747
+ if (doc.errors.length > 0 || doc.contents === null) 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"}`);
2748
+ if (!("items" in doc.contents)) throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: the patch file is not a top-level patch list`);
2749
+ toBlockStyle(doc.contents);
2750
+ return `${doc.toString({ lineWidth: 0 }).trimEnd()}\n\n${block}`;
2751
+ }
2752
+ /**
2753
+ * Render the managed block for one bind state. The values are static: the
2754
+ * user patch layer has no reliable lazy service evaluation, and the plugin
2755
+ * re-asserts the block at every boot so CLI flags (--port, --host) win by
2756
+ * rewriting it before the next start.
2757
+ */
2680
2758
  function managedBlock(host, port) {
2681
2759
  return [
2682
2760
  LAN_BIND_BLOCK_BEGIN,
@@ -2727,14 +2805,16 @@ function lanBindState(profile, home = dshHome()) {
2727
2805
  * unterminated block (BEGIN marker without END, which stripManagedBlock
2728
2806
  * cannot match) is truncated at its BEGIN marker first so the rewrite can
2729
2807
  * never stack a second webserver row onto the orphan.
2808
+ *
2809
+ * The written file is always exactly one valid YAML document: a base the
2810
+ * plugin manager left in flow style is re-emitted as a block sequence before
2811
+ * the managed block is appended (see mergePatchContent).
2730
2812
  */
2731
2813
  function writeLanBind(host, port, profile, home = dshHome()) {
2732
2814
  const file = profilePatchFile(profile, home);
2733
2815
  const stripped = stripManagedBlock(readPatchContent(file));
2734
2816
  const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN);
2735
- const base = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd().replace(/\[\s*\]\s*$/, "").trimEnd();
2736
- const block = managedBlock(host, port);
2737
- const content = base.length > 0 ? `${base}\n\n${block}` : block;
2817
+ const content = mergePatchContent((orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd().replace(/\[\s*\]\s*$/, "").trimEnd(), managedBlock(host, port), file);
2738
2818
  const mode = existsSync(file) ? statSync(file).mode & 511 : 384;
2739
2819
  mkdirSync(dirname(file), { recursive: true });
2740
2820
  const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
@@ -2999,6 +3079,20 @@ function pendingRestartOf(lanBind, desiredHost, liveHost) {
2999
3079
  return liveHost !== desiredHost;
3000
3080
  }
3001
3081
  /**
3082
+ * The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
3083
+ * explicit `profile` config wins; otherwise the launched profile the Host
3084
+ * publishes is authoritative, because the DSH Desktop client boots the
3085
+ * `desktop` profile without exporting `DSH_PROFILE` — an environment-only
3086
+ * fallback edited `profiles/web` there, so the toggle looked applied while
3087
+ * the running host never followed the block.
3088
+ * @param configured - the explicit `profile` plugin config, when set.
3089
+ * @param launched - the launched profile name the Host published, when available.
3090
+ * @param env - the DSH_PROFILE environment value, when set.
3091
+ */
3092
+ function resolveManagedProfile(configured, launched, env) {
3093
+ return configured ?? launched ?? env ?? "web";
3094
+ }
3095
+ /**
3002
3096
  * Whether the firewall rule must be (re)applied: only when the toggle or the
3003
3097
  * bound port moved since the last application. Keeps unrelated settings
3004
3098
  * saves from spawning netsh/firewall-cmd delete+add churn.
@@ -3071,9 +3165,16 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
3071
3165
  *
3072
3166
  * The cloudflared package's Tunnel is a thin spawn wrapper; this manager
3073
3167
  * owns the lifecycle policy (binary readiness, URL timeout, restart
3074
- * backoff) around it. All seams — the tunnel factory, binary readiness,
3075
- * timers — are injectable so the whole lifecycle is unit-testable without
3076
- * a real binary or network.
3168
+ * backoff, and the public-URL readiness watchdog) around it. All seams — the
3169
+ * tunnel factory, binary readiness, the URL probe, timers — are injectable
3170
+ * so the whole lifecycle is unit-testable without a real binary or network.
3171
+ *
3172
+ * 'running' is not a terminal state: the connector can lose its edge
3173
+ * registration while the process keeps living, which leaves a hostname that
3174
+ * no longer resolves. The watchdog below probes the public URL of a running
3175
+ * tunnel and reuses the crash-restart path when it stops answering, so the
3176
+ * relay is re-registered against a freshly minted URL instead of proxying a
3177
+ * dead one (issue #1723).
3077
3178
  */
3078
3179
  /** Compare two targets for the start idempotence check. */
3079
3180
  function sameTarget(left, right) {
@@ -3116,6 +3217,44 @@ function namedTunnelHandle(inner, publicUrl) {
3116
3217
  };
3117
3218
  return handle;
3118
3219
  }
3220
+ /**
3221
+ * Probe one public tunnel URL from the host. Resolves true when the URL is
3222
+ * served (any HTTP answer below 500 — a harness without credentials answers
3223
+ * 401/403, which is a healthy round trip), false for a DNS failure, a refused
3224
+ * connection, a timeout, or a Cloudflare 5xx (530 "Origin DNS error" / 1033)
3225
+ * — the shapes a tunnel that lost its edge registration produces.
3226
+ *
3227
+ * The probe must never reject: the watchdog reads a rejection as a dead
3228
+ * tunnel and restarts on it, so a local programming error would otherwise
3229
+ * recycle working tunnels forever.
3230
+ * @param url - the tunnel's public URL.
3231
+ * @param timeoutMs - how long one probe may take.
3232
+ * @returns whether the URL answered as a live origin.
3233
+ */
3234
+ async function probeTunnelUrl(url, timeoutMs) {
3235
+ const controller = new AbortController();
3236
+ const timer = setTimeout(() => {
3237
+ controller.abort();
3238
+ }, timeoutMs);
3239
+ try {
3240
+ const response = await fetch(url, {
3241
+ method: "GET",
3242
+ redirect: "manual",
3243
+ cache: "no-store",
3244
+ signal: controller.signal
3245
+ });
3246
+ await response.body?.cancel().catch(() => void 0);
3247
+ return response.status < 500;
3248
+ } catch {
3249
+ return false;
3250
+ } finally {
3251
+ clearTimeout(timer);
3252
+ }
3253
+ }
3254
+ /** Default probe: the real network round trip. */
3255
+ async function defaultProbe(url, timeoutMs) {
3256
+ return await probeTunnelUrl(url, timeoutMs);
3257
+ }
3119
3258
  /** Time box for the `--version` probe that validates a staged binary. */
3120
3259
  const BINARY_PROBE_TIMEOUT_MS = 1e4;
3121
3260
  /**
@@ -3261,6 +3400,10 @@ var TunnelManager = class {
3261
3400
  restartBaseMs;
3262
3401
  restartMaxMs;
3263
3402
  timer;
3403
+ probe;
3404
+ healthCheckIntervalMs;
3405
+ healthCheckFailures;
3406
+ probeTimeoutMs;
3264
3407
  phase = "stopped";
3265
3408
  url;
3266
3409
  error;
@@ -3268,6 +3411,8 @@ var TunnelManager = class {
3268
3411
  handle;
3269
3412
  urlTimer;
3270
3413
  restartTimer;
3414
+ healthTimer;
3415
+ healthFailures = 0;
3271
3416
  attempts = 0;
3272
3417
  generation = 0;
3273
3418
  stopping = false;
@@ -3283,6 +3428,10 @@ var TunnelManager = class {
3283
3428
  this.restartBaseMs = options.restartBaseMs ?? 5e3;
3284
3429
  this.restartMaxMs = options.restartMaxMs ?? 6e4;
3285
3430
  this.timer = options.timer ?? nodeTimer;
3431
+ this.probe = options.probe ?? defaultProbe;
3432
+ this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 6e4;
3433
+ this.healthCheckFailures = options.healthCheckFailures ?? 2;
3434
+ this.probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
3286
3435
  }
3287
3436
  /** The current status frame. */
3288
3437
  get info() {
@@ -3381,10 +3530,65 @@ var TunnelManager = class {
3381
3530
  this.error = void 0;
3382
3531
  this.attempts = 0;
3383
3532
  this.setPhase("running");
3533
+ this.startHealthWatch(value);
3384
3534
  for (const listener of this.urlListeners) try {
3385
3535
  listener(value);
3386
3536
  } catch {}
3387
3537
  }
3538
+ /**
3539
+ * Watch the readiness of a running tunnel. cloudflared can keep its process
3540
+ * (and its metrics port) alive while every edge connection is gone, leaving
3541
+ * a minted hostname that no longer resolves: the manager used to report
3542
+ * 'running' forever and the relay kept forwarding to that dead address, so
3543
+ * the phone hit Cloudflare 1016 instead of the relay's offline page
3544
+ * (issue #1723). A bounded number of consecutive probe failures ends the
3545
+ * attempt through the ordinary fail/backoff path, which mints a new URL and
3546
+ * re-announces the relay.
3547
+ */
3548
+ startHealthWatch(url) {
3549
+ this.stopHealthWatch();
3550
+ if (this.stopping || this.phase !== "running") return;
3551
+ if (this.healthCheckIntervalMs <= 0 || this.healthCheckFailures <= 0) return;
3552
+ this.healthFailures = 0;
3553
+ const schedule = () => {
3554
+ this.healthTimer = this.timer.setTimeout(() => {
3555
+ this.healthTimer = void 0;
3556
+ this.healthCheck(url);
3557
+ }, this.healthCheckIntervalMs);
3558
+ };
3559
+ schedule();
3560
+ }
3561
+ /** One probe round of the running tunnel's public URL. */
3562
+ async healthCheck(url) {
3563
+ if (this.stopping || this.phase !== "running" || this.url !== url) return;
3564
+ let alive = false;
3565
+ try {
3566
+ alive = await this.probe(url, this.probeTimeoutMs);
3567
+ } catch {
3568
+ alive = false;
3569
+ }
3570
+ if (this.stopping || this.phase !== "running" || this.url !== url) return;
3571
+ if (alive) this.healthFailures = 0;
3572
+ else {
3573
+ this.healthFailures += 1;
3574
+ if (this.healthFailures >= this.healthCheckFailures) {
3575
+ this.fail("the tunnel stopped answering on its public URL");
3576
+ return;
3577
+ }
3578
+ }
3579
+ this.healthTimer = this.timer.setTimeout(() => {
3580
+ this.healthTimer = void 0;
3581
+ this.healthCheck(url);
3582
+ }, this.healthCheckIntervalMs);
3583
+ }
3584
+ /** Cancel the readiness watchdog (teardown, restart, stop). */
3585
+ stopHealthWatch() {
3586
+ if (this.healthTimer !== void 0) {
3587
+ this.timer.clearTimeout(this.healthTimer);
3588
+ this.healthTimer = void 0;
3589
+ }
3590
+ this.healthFailures = 0;
3591
+ }
3388
3592
  handleExit() {
3389
3593
  if (this.stopping) return;
3390
3594
  this.fail("the tunnel process exited unexpectedly");
@@ -3401,6 +3605,7 @@ var TunnelManager = class {
3401
3605
  this.timer.clearTimeout(this.urlTimer);
3402
3606
  this.urlTimer = void 0;
3403
3607
  }
3608
+ this.stopHealthWatch();
3404
3609
  this.setPhase("failed");
3405
3610
  this.attempts += 1;
3406
3611
  const delay = Math.min(this.restartBaseMs * 2 ** (this.attempts - 1), this.restartMaxMs);
@@ -3412,6 +3617,7 @@ var TunnelManager = class {
3412
3617
  /** Stop the current process and cancel every pending timer (no phase change). */
3413
3618
  teardown() {
3414
3619
  this.stopping = true;
3620
+ this.stopHealthWatch();
3415
3621
  if (this.urlTimer !== void 0) {
3416
3622
  this.timer.clearTimeout(this.urlTimer);
3417
3623
  this.urlTimer = void 0;
@@ -3542,738 +3748,6 @@ var PublicBaseKeeper = class {
3542
3748
  }
3543
3749
  };
3544
3750
  //#endregion
3545
- //#region src/console-output.ts
3546
- /** Count the U+FFFD replacement characters one candidate decode produced. */
3547
- function replacementCount(text) {
3548
- let count = 0;
3549
- for (const character of text) if (character === "�") count += 1;
3550
- return count;
3551
- }
3552
- /**
3553
- * Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
3554
- * otherwise the bytes are not UTF-8 at all and the console wrote its own code
3555
- * page, so the candidate that loses the least text is taken.
3556
- * @param bytes - the captured bytes.
3557
- * @returns the decoded text.
3558
- */
3559
- function decodeConsoleBytes(bytes) {
3560
- try {
3561
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
3562
- } catch {
3563
- let best;
3564
- let bestLoss = Number.POSITIVE_INFINITY;
3565
- for (const label of ["gbk", "utf-8"]) {
3566
- let text;
3567
- try {
3568
- text = new TextDecoder(label).decode(bytes);
3569
- } catch {
3570
- continue;
3571
- }
3572
- const loss = replacementCount(text);
3573
- if (loss < bestLoss) {
3574
- best = text;
3575
- bestLoss = loss;
3576
- }
3577
- }
3578
- /* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
3579
- if (best === void 0) return Buffer.from(bytes).toString("utf8");
3580
- return best;
3581
- }
3582
- }
3583
- /**
3584
- * Create a capture bounded to a byte budget. Overflow drops from the FRONT
3585
- * (the tail is what carries a failure reason), and the cut never lands inside
3586
- * a character: leading UTF-8 continuation bytes are dropped with it, and a
3587
- * legacy code page's dangling lead byte — indistinguishable from a real byte
3588
- * without knowing the encoding until the whole buffer is decoded — is removed
3589
- * with the one replacement character it produced, so an over-long stream still
3590
- * reads cleanly at the seam.
3591
- * @param maxBytes - maximum retained bytes.
3592
- * @returns the capture.
3593
- */
3594
- function createOutputCapture(maxBytes) {
3595
- let buffer = Buffer.alloc(0);
3596
- let trimmed = false;
3597
- return {
3598
- push(chunk) {
3599
- buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)]);
3600
- if (buffer.length <= maxBytes) return;
3601
- let start = buffer.length - maxBytes;
3602
- while (start < buffer.length && (buffer[start] & 192) === 128) start += 1;
3603
- buffer = Buffer.from(buffer.subarray(start));
3604
- trimmed = true;
3605
- },
3606
- read() {
3607
- const text = decodeConsoleBytes(buffer);
3608
- return trimmed && text.startsWith("�") ? text.slice(1) : text;
3609
- }
3610
- };
3611
- }
3612
- /** The aggregate package that is the canonical update entry point. */
3613
- const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
3614
- /** Fallback anchor: this plugin's own package when the aggregate is absent. */
3615
- const SELF_PACKAGE = "@linxin666/dsh-remote-web-ui";
3616
- /** GitHub repository used to surface human-readable release notes. */
3617
- const UPDATE_RELEASE_REPO = "zhu1090093659/dsh-web";
3618
- /** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
3619
- const PROFILE_NAME_PREFIX = "dsh-profile-";
3620
- /** How many ancestor directories a profile search walks before giving up. */
3621
- const PROFILE_WALK_DEPTH = 12;
3622
- /**
3623
- * Parse a semantic version string (leading `v` tolerated, build metadata
3624
- * ignored). Returns undefined for unparseable input.
3625
- * @param value - the version string, e.g. `0.1.10` or `0.1.11-rc.1`.
3626
- * @returns the parsed parts, or undefined.
3627
- */
3628
- function parseSemver(value) {
3629
- const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
3630
- if (match === null) return void 0;
3631
- return {
3632
- major: Number(match[1]),
3633
- minor: Number(match[2]),
3634
- patch: Number(match[3]),
3635
- prerelease: match[4] === void 0 ? [] : match[4].split(".")
3636
- };
3637
- }
3638
- /**
3639
- * Compare two semantic versions per the semver precedence rules (a release
3640
- * outranks any of its prereleases; numeric prerelease identifiers compare
3641
- * numerically and sort below alphanumeric ones). An unparseable version sorts
3642
- * below every parseable one; two unparseable versions compare equal.
3643
- * @param a - first version.
3644
- * @param b - second version.
3645
- * @returns negative when a < b, 0 when equal, positive when a > b.
3646
- */
3647
- function compareVersions(a, b) {
3648
- const pa = parseSemver(a);
3649
- const pb = parseSemver(b);
3650
- if (pa === void 0 && pb === void 0) return 0;
3651
- if (pa === void 0) return -1;
3652
- if (pb === void 0) return 1;
3653
- for (const key of [
3654
- "major",
3655
- "minor",
3656
- "patch"
3657
- ]) if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
3658
- if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0;
3659
- if (pa.prerelease.length === 0) return 1;
3660
- if (pb.prerelease.length === 0) return -1;
3661
- for (let index = 0; index < Math.max(pa.prerelease.length, pb.prerelease.length); index++) {
3662
- const ra = pa.prerelease[index];
3663
- const rb = pb.prerelease[index];
3664
- if (ra === void 0) return -1;
3665
- if (rb === void 0) return 1;
3666
- if (ra === rb) continue;
3667
- const numericA = /^\d+$/.test(ra);
3668
- const numericB = /^\d+$/.test(rb);
3669
- if (numericA && numericB) return Number(ra) < Number(rb) ? -1 : 1;
3670
- if (numericA) return -1;
3671
- if (numericB) return 1;
3672
- return ra < rb ? -1 : 1;
3673
- }
3674
- return 0;
3675
- }
3676
- /** Read a package.json at a path, tolerating any parse/IO failure. */
3677
- function readManifest(path) {
3678
- try {
3679
- const parsed = JSON.parse(readFileSync(path, "utf8"));
3680
- return typeof parsed === "object" && parsed !== null ? parsed : void 0;
3681
- } catch {
3682
- return;
3683
- }
3684
- }
3685
- /**
3686
- * Locate the owning dsh profile by walking up from an installed package's
3687
- * manifest until a manifest named `dsh-profile-*` appears (the profile
3688
- * directory is the first ancestor whose package.json carries that name).
3689
- * @param anchorManifestPath - absolute path of the anchor package.json.
3690
- * @returns the profile name/dir, or undefined when not profile-installed.
3691
- */
3692
- function findProfile(anchorManifestPath) {
3693
- let dir = dirname(anchorManifestPath);
3694
- for (let depth = 0; depth < PROFILE_WALK_DEPTH; depth++) {
3695
- const name = readManifest(join(dir, "package.json"))?.name;
3696
- if (typeof name === "string" && name.startsWith(PROFILE_NAME_PREFIX)) return {
3697
- name: name.slice(12),
3698
- dir
3699
- };
3700
- const parent = dirname(dir);
3701
- if (parent === dir) return void 0;
3702
- dir = parent;
3703
- }
3704
- }
3705
- /** Whether a dependency spec is a local link/file/dev-mode install. */
3706
- function isLinkedSpec(spec) {
3707
- if (typeof spec !== "string") return false;
3708
- return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec);
3709
- }
3710
- /** Whether a direct local dependency overrides one of the aggregate's children. */
3711
- function hasLinkedFamilyOverride(anchorManifest, profileManifest) {
3712
- const dependencies = profileManifest?.dependencies;
3713
- if (typeof dependencies !== "object" || dependencies === null) return false;
3714
- return familyChildren(anchorManifest).some((name) => isLinkedSpec(dependencies[name]));
3715
- }
3716
- /**
3717
- * Resolve the anchor package's manifest path. The aggregate package is the
3718
- * canonical entry point; this plugin's own package is the fallback. Both a
3719
- * throwing resolve and an undefined return mean "not installed" and move on
3720
- * to the next candidate.
3721
- * @param resolve - a Node resolve implementation scoped to the host process.
3722
- * @returns the absolute manifest path, or undefined when neither is installed.
3723
- */
3724
- function resolveAnchorManifest(resolve) {
3725
- for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) try {
3726
- const path = resolve(name + "/package.json");
3727
- if (path !== void 0) return path;
3728
- } catch {}
3729
- }
3730
- /**
3731
- * Resolve what an update would touch: the owning profile directory and the
3732
- * family package list. Fails with an error code when the anchor is missing
3733
- * ('not-found') or is a local dev install ('link').
3734
- * @param deps - the anchor manifest path (resolveAnchorManifest output).
3735
- * @returns the target, or the failure code.
3736
- */
3737
- function resolveUpdateTarget(deps) {
3738
- const manifestPath = deps.anchorManifestPath;
3739
- if (manifestPath === void 0) return { error: "not-found" };
3740
- const manifest = readManifest(manifestPath);
3741
- if (manifest === void 0) return { error: "not-found" };
3742
- const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
3743
- if (anchor === void 0) return { error: "not-found" };
3744
- const profile = findProfile(manifestPath);
3745
- if (profile === void 0) return { error: "link" };
3746
- const profileManifest = readManifest(join(profile.dir, "package.json"));
3747
- const spec = (profileManifest?.dependencies)?.[anchor];
3748
- if (isLinkedSpec(spec) || hasLinkedFamilyOverride(manifest, profileManifest)) return { error: "link" };
3749
- return {
3750
- profileName: profile.name,
3751
- profileDir: profile.dir,
3752
- packages: familyUpdatePackages(anchor, manifest, profileManifest)
3753
- };
3754
- }
3755
- /** Family children of the anchor: its dependencies under the family scope. */
3756
- function familyChildren(anchorManifest) {
3757
- const dependencies = anchorManifest.dependencies;
3758
- if (typeof dependencies !== "object" || dependencies === null) return [];
3759
- const names = [];
3760
- for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
3761
- return names;
3762
- }
3763
- /** Registry-managed family packages covered by one update operation. */
3764
- function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
3765
- const names = /* @__PURE__ */ new Set([anchor, ...familyChildren(anchorManifest)]);
3766
- const dependencies = profileManifest?.dependencies;
3767
- if (typeof dependencies !== "object" || dependencies === null) return [...names];
3768
- for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string" && !isLinkedSpec(spec)) names.add(name);
3769
- return [...names];
3770
- }
3771
- /**
3772
- * Probe the npm registry for one package's latest release.
3773
- * @param name - the package name (scope slash URL-encoded).
3774
- * @param fetchImpl - the fetch implementation (global fetch in the host).
3775
- * @param timeoutMs - probe timeout.
3776
- * @returns the latest version string, or undefined on any failure.
3777
- */
3778
- async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
3779
- try {
3780
- const controller = new AbortController();
3781
- const timer = setTimeout(() => {
3782
- controller.abort();
3783
- }, timeoutMs);
3784
- try {
3785
- const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", withIdentityEncoding({ signal: controller.signal }));
3786
- if (!response.ok) return void 0;
3787
- const body = await response.json();
3788
- if (typeof body !== "object" || body === null) return void 0;
3789
- const version = body.version;
3790
- return typeof version === "string" ? version : void 0;
3791
- } finally {
3792
- clearTimeout(timer);
3793
- }
3794
- } catch {
3795
- return;
3796
- }
3797
- }
3798
- /** Normalize one release-note bullet. */
3799
- function cleanNoteItem(text) {
3800
- return text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/^[-*]\s+/, "").trim();
3801
- }
3802
- /** Determine the section kind for a Markdown heading line. */
3803
- function noteSectionOf(line) {
3804
- const heading = /^#{1,6}\s+(.+)$/.exec(line.trim());
3805
- if (heading === null) return void 0;
3806
- const title = heading[1].replace(/[::].*$/, "").trim().toLowerCase();
3807
- if (title === "新功能" || title === "new features") return "features";
3808
- if (title === "修复" || title === "bug fixes" || title === "bugfixes") return "fixes";
3809
- if (title === "其他改动" || title === "other changes") return "other";
3810
- }
3811
- /**
3812
- * Parse the GitHub Release body into the three conventional sections.
3813
- *
3814
- * The committed release notes are bilingual: Chinese is the default view and
3815
- * English lives inside a `<details>` block. Prefer the Chinese sections when
3816
- * present and fall back to the whole body for English-only releases. Bullets
3817
- * are escaped as plain text for the panel (no Markdown renderer).
3818
- */
3819
- function parseReleaseNotesSource(version, source) {
3820
- const notes = {
3821
- version,
3822
- features: [],
3823
- fixes: [],
3824
- other: []
3825
- };
3826
- let current;
3827
- for (const rawLine of source.split(/\r?\n/)) {
3828
- const line = rawLine.trim();
3829
- const section = noteSectionOf(line);
3830
- if (section !== void 0) {
3831
- current = section;
3832
- continue;
3833
- }
3834
- if (current === void 0 || !/^-\s+/.test(line)) continue;
3835
- const item = cleanNoteItem(line);
3836
- if (item !== "") notes[current].push(item);
3837
- }
3838
- if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return void 0;
3839
- return notes;
3840
- }
3841
- function parseReleaseNotesBody(version, body) {
3842
- return parseReleaseNotesSource(version, body.split("<details>")[0] ?? body) ?? parseReleaseNotesSource(version, body);
3843
- }
3844
- /**
3845
- * Fetch and parse the GitHub Release for one `dsh-web` version.
3846
- * @param version - the npm release version (without the `v` prefix).
3847
- * @param fetchImpl - injected fetch; global `fetch` in the host.
3848
- * @param timeoutMs - hard timeout for the GitHub API probe.
3849
- * @returns structured notes, or undefined when unavailable.
3850
- */
3851
- async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1e4) {
3852
- const controller = new AbortController();
3853
- const timer = setTimeout(() => {
3854
- controller.abort();
3855
- }, timeoutMs);
3856
- try {
3857
- const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
3858
- headers: {
3859
- accept: "application/vnd.github+json",
3860
- "user-agent": "dsh-web-update"
3861
- },
3862
- signal: controller.signal
3863
- }));
3864
- if (!response.ok) return void 0;
3865
- const body = await response.json();
3866
- if (typeof body !== "object" || body === null) return void 0;
3867
- const text = body.body;
3868
- return typeof text === "string" ? parseReleaseNotesBody(version, text) : void 0;
3869
- } catch {
3870
- return;
3871
- } finally {
3872
- clearTimeout(timer);
3873
- }
3874
- }
3875
- /**
3876
- * Sentinel for an installed version that could not be read (missing manifest
3877
- * or resolve failure). It is a real-looking version so `checkUpdates` can
3878
- * render the affected row, but the verified-update comparison must never
3879
- * treat it as evidence — a read failure is not a version that "moved".
3880
- */
3881
- const VERSION_UNKNOWN = "0.0.0";
3882
- /** The resolved current version of one family package (probe failure tolerated). */
3883
- function readInstalledVersion(resolve, name, profileDir) {
3884
- try {
3885
- const path = resolve(name + "/package.json");
3886
- const version = path === void 0 ? void 0 : readManifest(path)?.version;
3887
- if (typeof version === "string") return version;
3888
- } catch {}
3889
- if (profileDir === void 0 || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN;
3890
- const version = readManifest(join(profileDir, "node_modules", ...name.split("/"), "package.json"))?.version;
3891
- return typeof version === "string" ? version : VERSION_UNKNOWN;
3892
- }
3893
- /**
3894
- * Build the update status: locate the anchor, detect the install mode, and
3895
- * compare every family package against the npm registry.
3896
- * @param deps - manifest resolution + registry probe seams.
3897
- * @returns the status snapshot.
3898
- */
3899
- async function checkUpdates(deps) {
3900
- const manifestPath = deps.anchorManifestPath;
3901
- if (manifestPath === void 0) return {
3902
- mode: "missing",
3903
- packages: [],
3904
- outdated: false
3905
- };
3906
- const manifest = readManifest(manifestPath);
3907
- if (manifest === void 0) return {
3908
- mode: "missing",
3909
- packages: [],
3910
- outdated: false
3911
- };
3912
- const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
3913
- if (anchor === void 0) return {
3914
- mode: "missing",
3915
- packages: [],
3916
- outdated: false
3917
- };
3918
- const profile = findProfile(manifestPath);
3919
- const profileManifest = profile === void 0 ? void 0 : readManifest(join(profile.dir, "package.json"));
3920
- const linked = profile === void 0 || isLinkedSpec((profileManifest?.dependencies)?.[anchor]) || hasLinkedFamilyOverride(manifest, profileManifest);
3921
- if (profile === void 0) return {
3922
- mode: "link",
3923
- packages: [],
3924
- outdated: false
3925
- };
3926
- const names = familyUpdatePackages(anchor, manifest, profileManifest);
3927
- const latestList = await Promise.all(names.map((name) => deps.fetchLatest(name)));
3928
- const packages = [];
3929
- let probeFailures = 0;
3930
- names.forEach((name, index) => {
3931
- const latest = latestList[index];
3932
- if (latest === void 0) probeFailures++;
3933
- const current = readInstalledVersion(deps.resolve, name, profile.dir);
3934
- packages.push({
3935
- name,
3936
- current,
3937
- latest,
3938
- outdated: latest !== void 0 && latest !== current && compareVersions(latest, current) > 0
3939
- });
3940
- });
3941
- const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
3942
- const targetVersion = packages.find((packageStatus) => packageStatus.latest !== void 0)?.latest;
3943
- const notes = targetVersion !== void 0 && deps.fetchReleaseNotes !== void 0 ? await deps.fetchReleaseNotes(targetVersion).catch(() => void 0) : void 0;
3944
- return {
3945
- mode: linked ? "link" : "npm",
3946
- profileName: profile.name,
3947
- anchor,
3948
- packages,
3949
- outdated: packages.some((packageStatus) => packageStatus.outdated),
3950
- ...notes !== void 0 ? { notes } : {},
3951
- ...error !== void 0 ? { error } : {}
3952
- };
3953
- }
3954
- /** Cap on captured pnpm output (keeps error payloads bounded). */
3955
- const OUTPUT_CAP = 16 * 1024;
3956
- /**
3957
- * Characters a package name may contain before it is handed to the update
3958
- * command line. Deliberately looser than the strict family shape (the tests
3959
- * and future callers use short names) but it admits no whitespace, quotes,
3960
- * shell metacharacters, or path separators beyond the scope slash.
3961
- */
3962
- const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/;
3963
- /**
3964
- * Windows cmd command-not-found stderr. With shell:true a missing shim
3965
- * exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
3966
- * detects this message instead of the spawn error event. Supports both English
3967
- * and localized (e.g. Chinese) cmd error messages.
3968
- */
3969
- const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
3970
- /**
3971
- * Fold one decoded candidate output into the running total under the display
3972
- * cap (the tail carries the failure reason).
3973
- * @param output - the accumulated text.
3974
- * @param text - the candidate's decoded text.
3975
- */
3976
- function capOutput(output, text) {
3977
- const combined = output + text;
3978
- return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
3979
- }
3980
- /**
3981
- * Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
3982
- * silently skips same-day releases — `pnpm update --latest` then exits 0
3983
- * without moving anything and the post-run verification would misreport a
3984
- * stale no-op. The update here is explicitly user-initiated and the panel
3985
- * shows exactly which versions are being installed, so the gate only adds a
3986
- * confusing silent no-op; override it per-invocation instead of asking every
3987
- * user to edit the profile's pnpm-workspace.yaml.
3988
- */
3989
- const MIN_RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
3990
- /**
3991
- * Run the update inside the profile directory. Tries pnpm first, falls back
3992
- * to corepack and then npx when the previous command is missing (ENOENT);
3993
- * all candidates share one hard timeout and keep accumulating output.
3994
- * @param deps - profile dir, package list, and spawn/timeout seams.
3995
- * @returns the outcome with captured output.
3996
- */
3997
- function runUpdate(deps) {
3998
- return new Promise((resolve) => {
3999
- const spawnImpl = deps.spawnImpl ?? spawn;
4000
- const platform = deps.platform ?? process.platform;
4001
- const packages = deps.packages.filter((name) => SHELL_SAFE_PACKAGE_RE.test(name));
4002
- const spawnOptions = {
4003
- cwd: deps.profileDir,
4004
- stdio: [
4005
- "ignore",
4006
- "pipe",
4007
- "pipe"
4008
- ],
4009
- ...platform === "win32" ? { shell: true } : { detached: true }
4010
- };
4011
- const killImpl = deps.killImpl ?? process.kill;
4012
- const candidates = [
4013
- {
4014
- command: "pnpm",
4015
- args: [
4016
- "update",
4017
- "--latest",
4018
- MIN_RELEASE_AGE_OVERRIDE,
4019
- ...packages
4020
- ]
4021
- },
4022
- {
4023
- command: "corepack",
4024
- args: [
4025
- "pnpm",
4026
- "update",
4027
- "--latest",
4028
- MIN_RELEASE_AGE_OVERRIDE,
4029
- ...packages
4030
- ]
4031
- },
4032
- {
4033
- command: "npx",
4034
- args: [
4035
- "--yes",
4036
- "pnpm",
4037
- "update",
4038
- "--latest",
4039
- MIN_RELEASE_AGE_OVERRIDE,
4040
- ...packages
4041
- ]
4042
- }
4043
- ];
4044
- let output = "";
4045
- let capture = createOutputCapture(OUTPUT_CAP);
4046
- let candidateFolded = false;
4047
- const append = (chunk) => {
4048
- capture.push(chunk);
4049
- };
4050
- /** This candidate's decoded text (idempotent). */
4051
- const candidateText = () => capture.read();
4052
- /** Fold this candidate's text into the running output exactly once. */
4053
- const foldCandidate = () => {
4054
- if (candidateFolded) return;
4055
- candidateFolded = true;
4056
- output = capOutput(output, capture.read());
4057
- };
4058
- let currentChild;
4059
- let finished = false;
4060
- const finish = (result) => {
4061
- if (finished) return;
4062
- finished = true;
4063
- clearTimeout(timer);
4064
- resolve(result);
4065
- };
4066
- const timer = setTimeout(() => {
4067
- if (finished) return;
4068
- if (platform === "win32") {
4069
- const pid = currentChild?.pid;
4070
- if (pid !== void 0 && pid > 0) try {
4071
- spawnImpl("taskkill", [
4072
- "/pid",
4073
- String(pid),
4074
- "/t",
4075
- "/f"
4076
- ], { stdio: "ignore" }).on?.("error", () => {});
4077
- } catch {}
4078
- } else {
4079
- const pid = currentChild?.pid;
4080
- if (pid !== void 0 && pid > 0) try {
4081
- killImpl(-pid, "SIGTERM");
4082
- } catch {
4083
- try {
4084
- currentChild?.kill("SIGTERM");
4085
- } catch {}
4086
- }
4087
- else currentChild?.kill("SIGTERM");
4088
- }
4089
- foldCandidate();
4090
- finish({
4091
- ok: false,
4092
- exitCode: null,
4093
- output,
4094
- error: "update timed out; install process killed",
4095
- errorCode: "timeout"
4096
- });
4097
- }, deps.timeoutMs ?? 10 * 6e4);
4098
- const runCandidate = (index) => {
4099
- if (finished) return;
4100
- if (index >= candidates.length) {
4101
- finish({
4102
- ok: false,
4103
- exitCode: null,
4104
- output,
4105
- error: "pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app",
4106
- errorCode: "pnpm-missing"
4107
- });
4108
- return;
4109
- }
4110
- const candidate = candidates[index];
4111
- capture = createOutputCapture(OUTPUT_CAP);
4112
- candidateFolded = false;
4113
- const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
4114
- currentChild = child;
4115
- let settled = false;
4116
- const once = (fn) => {
4117
- if (settled) return;
4118
- settled = true;
4119
- fn();
4120
- };
4121
- child.stdout?.on("data", append);
4122
- child.stderr?.on("data", append);
4123
- child.on("error", (error) => {
4124
- once(() => {
4125
- foldCandidate();
4126
- if (error.code === "ENOENT") runCandidate(index + 1);
4127
- else finish({
4128
- ok: false,
4129
- exitCode: null,
4130
- output,
4131
- error: error.message,
4132
- errorCode: void 0
4133
- });
4134
- });
4135
- });
4136
- child.on("close", (code) => {
4137
- if (settled || finished) return;
4138
- if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(candidateText()))) {
4139
- foldCandidate();
4140
- runCandidate(index + 1);
4141
- return;
4142
- }
4143
- settled = true;
4144
- foldCandidate();
4145
- finish({
4146
- ok: code === 0,
4147
- exitCode: code,
4148
- output,
4149
- error: code === 0 ? void 0 : `${candidate.command} exited with code ` + String(code),
4150
- ...code === 0 ? {} : { errorCode: "pnpm-failed" }
4151
- });
4152
- });
4153
- };
4154
- runCandidate(0);
4155
- });
4156
- }
4157
- /**
4158
- * Run the update, then verify the installed versions actually moved. pnpm
4159
- * exits 0 even when it silently kept the installed versions — runUpdate
4160
- * overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
4161
- * `--config.minimumReleaseAge=0`, but an older pnpm without the override or
4162
- * another silent no-op can still keep versions in place — so a green exit
4163
- * alone must not report a misleading "update complete". Re-read the
4164
- * installed versions afterwards and surface a `stale` failure (with the
4165
- * captured pnpm output) when nothing moved, so the panel can tell the user
4166
- * how to unblock the gate instead of claiming success.
4167
- *
4168
- * The stale decision anchors on the pre-run installed versions, not on the
4169
- * registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
4170
- * latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
4171
- * path is re-resolved rather than reused from boot time: pnpm removes the old
4172
- * version's .pnpm directory on update, so a boot-time captured path would
4173
- * fail to read and collapse a successful update into a bogus 'missing'.
4174
- *
4175
- * A green exit whose verification has nothing to compare (registry probe
4176
- * outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
4177
- * must never read as success. A green exit where no package moved but the
4178
- * post-run check could not prove the install is fully current (a partial
4179
- * probe failure hides whether a gate kept something back) is also
4180
- * `verify-failed`: it must not collapse into a success either.
4181
- * @param deps - the run and check seams.
4182
- * @returns the run result; `stale` when exit 0 left every version in place,
4183
- * `verify-failed` when the post-run check could not verify anything.
4184
- */
4185
- async function runUpdateVerified(deps) {
4186
- const before = /* @__PURE__ */ new Map();
4187
- for (const name of deps.run.packages) {
4188
- const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
4189
- if (version !== VERSION_UNKNOWN) before.set(name, version);
4190
- }
4191
- const result = await runUpdate(deps.run);
4192
- if (!result.ok) return result;
4193
- const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath;
4194
- const status = await checkUpdates({
4195
- ...deps.check,
4196
- anchorManifestPath
4197
- });
4198
- if (status.error !== void 0 || status.mode !== "npm") return {
4199
- ...result,
4200
- ok: false,
4201
- errorCode: "verify-failed",
4202
- error: "pnpm exited 0 but the post-run version check could not verify the install"
4203
- };
4204
- if (status.packages.some((packageStatus) => {
4205
- const beforeVersion = before.get(packageStatus.name);
4206
- if (beforeVersion === void 0 || packageStatus.current === VERSION_UNKNOWN) return false;
4207
- return packageStatus.current !== beforeVersion;
4208
- })) return result;
4209
- if (status.outdated) return {
4210
- ...result,
4211
- ok: false,
4212
- errorCode: "stale",
4213
- error: "pnpm exited 0 but the installed versions did not change"
4214
- };
4215
- if (status.packages.some((packageStatus) => packageStatus.latest === void 0)) return {
4216
- ...result,
4217
- ok: false,
4218
- errorCode: "verify-failed",
4219
- error: "pnpm exited 0 but the post-run version check could not verify the install"
4220
- };
4221
- return result;
4222
- }
4223
- //#endregion
4224
- //#region src/update-routes.ts
4225
- /** Route paths (exact matches under /api). */
4226
- const UPDATE_PATHS = {
4227
- status: "/api/update/status",
4228
- run: "/api/update/run"
4229
- };
4230
- /**
4231
- * Build the /api/update route family.
4232
- * @param deps - fence + check/run seams.
4233
- * @returns the exact routes to register on webServer.
4234
- */
4235
- function makeUpdateRoutes(deps) {
4236
- const handleStatus = async (req, res) => {
4237
- if (req.method !== "GET") {
4238
- res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
4239
- res.end("method not allowed");
4240
- return;
4241
- }
4242
- if (!deps.fence(req)) {
4243
- writeJson(res, 403, {
4244
- ok: false,
4245
- code: "forbidden"
4246
- });
4247
- return;
4248
- }
4249
- writeJson(res, 200, await deps.check());
4250
- };
4251
- const handleRun = async (req, res) => {
4252
- if (req.method !== "POST") {
4253
- res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
4254
- res.end("method not allowed");
4255
- return;
4256
- }
4257
- if (!deps.fence(req)) {
4258
- writeJson(res, 403, {
4259
- ok: false,
4260
- code: "forbidden"
4261
- });
4262
- return;
4263
- }
4264
- writeJson(res, 200, await deps.run());
4265
- };
4266
- return [{
4267
- kind: "exact",
4268
- path: UPDATE_PATHS.status,
4269
- handler: handleStatus
4270
- }, {
4271
- kind: "exact",
4272
- path: UPDATE_PATHS.run,
4273
- handler: handleRun
4274
- }];
4275
- }
4276
- //#endregion
4277
3751
  //#region src/mount-once.ts
4278
3752
  /**
4279
3753
  * Host single-instance guard shared by the plugin family. The family bundle
@@ -4281,38 +3755,111 @@ function makeUpdateRoutes(deps) {
4281
3755
  * the loader accepts a standalone install of the same package side by side;
4282
3756
  * without this guard the second instance would still re-register the same
4283
3757
  * webserver routes, tools, settings namespaces, and system-prompt sections
4284
- * and fail the boot. mountOnce makes the second host apply a no-op for the
4285
- * lifetime of the first instance (the browser half is already deduped by
4286
- * package name in the client module host).
3758
+ * and fail the boot. mountOnce makes a mount of an already-mounted package a
3759
+ * no-op for as long as the first instance lives (the browser half is already
3760
+ * deduped by package name in the client module host).
3761
+ *
3762
+ * A no-op is only safe while the holder is ALIVE. The holder can be disposed
3763
+ * long after the refused mount ran its course: the Host reloads a profile by
3764
+ * creating the new loader entries before the old ones are torn down (a
3765
+ * plugin-manager enable/disable/install write, a settings-driven row reload,
3766
+ * HMR), so the new aggregate shell entry mounts its family plugin while the
3767
+ * previous entry still owns the name. Dropping that refused mount lost the
3768
+ * plugin for good - the previous entry then disposed its own mount, releasing
3769
+ * the name with nobody left to take it, and the family row stayed listed as
3770
+ * active while its host routes 404ed (the task-board panel showed
3771
+ * "board.hostError.notMounted", no degraded record appeared, and only a Host
3772
+ * restart recovered it).
3773
+ *
3774
+ * The refused mount is therefore QUEUED, not dropped, and replayed the moment
3775
+ * the holder releases the name - if the waiting fiber is still alive then. The
3776
+ * single-instance guarantee is unchanged: exactly one mount is live per
3777
+ * package name, and the replay re-enters the guard so a later mount still
3778
+ * dedupes against it.
4287
3779
  *
4288
3780
  * The registry rides a global symbol so two module instances of the same
4289
- * package (npm copy vs repository link) still share one verdict. cordis
4290
- * `ctx.effect` runs its callback immediately and treats the callback's
3781
+ * package (npm copy vs repository link) still share one verdict. That symbol
3782
+ * is a CROSS-REPOSITORY contract, not this file's private state: the four
3783
+ * satellite packages (dsh-skins / dsh-pet / dsh-presets /
3784
+ * dsh-community-plugins) are separate repositories carrying their own copy of
3785
+ * this guard, rebuilt on their own schedule, so the value under `MOUNTED`
3786
+ * must keep the shape every published copy reads (a `Set` of package names
3787
+ * with `has`/`add`/`delete`). The wait queues this guard added therefore live
3788
+ * under their own additive key, and the registry reads back a `Set` even when
3789
+ * some other build left a different value there. Changing `MOUNTED`'s shape
3790
+ * in place broke that contract once: a satellite's legacy copy created a
3791
+ * `Set`, the family's new copy read it as a `Map`, and every family row
3792
+ * mounted after it failed with "claims.get is not a function".
3793
+ *
3794
+ * cordis `ctx.effect` runs its callback immediately and treats the callback's
4291
3795
  * return value as the fiber disposer, so the unmarker is returned, not run.
4292
3796
  */
3797
+ /** Published cross-repository contract: package names currently mounted. */
4293
3798
  const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
3799
+ /** Additive key this guard owns: refused mounts waiting for the name. */
3800
+ const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
3801
+ /**
3802
+ * The shared name registry, always a `Set` whatever another build stored here:
3803
+ * a foreign value (an interim shape, a hand-written global) must not take every
3804
+ * family plugin down with it.
3805
+ * @returns the process-wide set of mounted package names.
3806
+ */
4294
3807
  function mountedSet() {
4295
3808
  const registry = globalThis;
4296
- return registry[MOUNTED] ??= /* @__PURE__ */ new Set();
3809
+ const existing = registry[MOUNTED];
3810
+ if (existing instanceof Set) return existing;
3811
+ const created = /* @__PURE__ */ new Set();
3812
+ registry[MOUNTED] = created;
3813
+ return created;
3814
+ }
3815
+ /** Queue per package name for mounts refused while a holder was alive. */
3816
+ function mountWaiters() {
3817
+ const registry = globalThis;
3818
+ return registry[WAITERS] ??= /* @__PURE__ */ new Map();
4297
3819
  }
4298
3820
  /**
4299
3821
  * Wrap a cordis plugin apply so the package runs at most once per process.
4300
- * The first mount registers normally and unmarks when its fiber disposes;
4301
- * any later mount of the same package name is a no-op.
3822
+ * The first mount registers normally and releases the name when its fiber
3823
+ * disposes; a mount refused while that name is held waits for the release and
3824
+ * then runs, unless its own fiber disposes first.
4302
3825
  * @param packageName - npm package identity shared by every install source.
4303
3826
  * @param fn - the original plugin apply.
4304
3827
  * @returns an apply of the same shape.
4305
3828
  */
4306
3829
  function mountOnce(packageName, fn) {
4307
- return ((...args) => {
3830
+ const mount = (...args) => {
4308
3831
  const mounted = mountedSet();
4309
- if (mounted.has(packageName)) return;
3832
+ const ctx = args[0];
3833
+ if (mounted.has(packageName)) {
3834
+ const waiters = mountWaiters();
3835
+ const queue = waiters.get(packageName) ?? [];
3836
+ let alive = true;
3837
+ const pending = { run: () => {
3838
+ if (alive) mount(...args);
3839
+ } };
3840
+ ctx?.effect?.(() => () => {
3841
+ alive = false;
3842
+ const index = queue.indexOf(pending);
3843
+ if (index >= 0) queue.splice(index, 1);
3844
+ });
3845
+ queue.push(pending);
3846
+ waiters.set(packageName, queue);
3847
+ return;
3848
+ }
4310
3849
  mounted.add(packageName);
4311
- args[0]?.effect?.(() => () => {
3850
+ ctx?.effect?.(() => () => {
4312
3851
  mounted.delete(packageName);
3852
+ const waiters = mountWaiters();
3853
+ const queue = waiters.get(packageName);
3854
+ if (queue === void 0) return;
3855
+ waiters.delete(packageName);
3856
+ for (const waiter of queue.splice(0)) queueMicrotask(() => {
3857
+ waiter.run();
3858
+ });
4313
3859
  });
4314
3860
  return fn(...args);
4315
- });
3861
+ };
3862
+ return mount;
4316
3863
  }
4317
3864
  //#endregion
4318
3865
  //#region src/remote-channel-boot.ts
@@ -4355,7 +3902,7 @@ function mountOnce(packageName, fn) {
4355
3902
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
4356
3903
  const json = JSON.stringify(rules);
4357
3904
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
4358
- return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;var R=" + json + ";try{if(w[R.hostGrantGlobal]===true){if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}}catch(e){}function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);function uf(u,init){var raw=typeof u===\"string\"?u:u.href;if(typeof raw!==\"string\")return of.call(w,u,init||{});var q=new URL(raw,loc.href);if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});return of.call(w,u,init||{})}try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
3905
+ return "(function(){try{var w=window,loc=w.location,h=loc.hostname;var R=" + json + ";if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;var pr=loc.protocol;if(typeof pr===\"string\"&&pr!==\"\"&&R.webProtocols.indexOf(pr)===-1)return;try{if(w.__DSH_TRANSPORT__&&w.__DSH_TRANSPORT__.ownsHost===true&&h.indexOf(\".\")===-1&&h.indexOf(\":\")===-1)return}catch(e){}try{if(w[R.hostGrantGlobal]===true){if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}}catch(e){}function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);function uf(u,init){var raw=typeof u===\"string\"?u:u.href;if(typeof raw!==\"string\")return of.call(w,u,init||{});var q=new URL(raw,loc.href);if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});return of.call(w,u,init||{})}try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
4359
3906
  }
4360
3907
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
4361
3908
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
@@ -4435,31 +3982,64 @@ const inject = [
4435
3982
  * Host package.
4436
3983
  */
4437
3984
  const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
3985
+ /**
3986
+ * Plugin config schema. Under the 0.1.7 settings model this schema IS the
3987
+ * entry's settings page: the Host derives one form per profile entry from it
3988
+ * and serves that form only when at least one field is `volatile()`. The marker
3989
+ * is also what admits a write and what keeps the edit on the live path — the
3990
+ * Loader commits the new value into the field's reference and announces
3991
+ * `loader/volatile-update` on this fiber instead of remounting the row, so the
3992
+ * pairing service, its device sessions, the tunnel and the route registrations
3993
+ * survive a settings save (see {@link applyImpl}'s sync).
3994
+ *
3995
+ * The schema is left to inference rather than annotated with `z<Config>`: a
3996
+ * volatile field's parsed output is a live reference while its accepted input
3997
+ * stays the plain value, so the two sides no longer share one shape and the
3998
+ * annotation would reject the schema the Host must be given.
3999
+ *
4000
+ * The deployment-level fields (`trustedHosts`, `devicesFile`, `profile`) are
4001
+ * deliberately NOT volatile: they belong in the profile patch, so the form
4002
+ * leaves them to the operator instead of offering a card control that a
4003
+ * document write could not honor.
4004
+ */
4438
4005
  const Config = z.object({
4439
- tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
4440
- offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
4441
- maxDevices: z.number().step(1).min(1).max(64).default(4),
4442
- idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS),
4443
- cookieName: z.string().min(1).default("dsh_pair"),
4444
- requirePairingForLan: z.boolean().default(true),
4445
- publicBaseUrl: z.string(),
4006
+ tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4).volatile(),
4007
+ offlineAfterMs: z.number().step(1).min(5e3).default(25e3).volatile(),
4008
+ maxDevices: z.number().step(1).min(1).max(64).default(4).volatile(),
4009
+ idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS).volatile(),
4010
+ cookieName: z.string().min(1).default("dsh_pair").volatile(),
4011
+ requirePairingForLan: z.boolean().default(true).volatile(),
4012
+ publicBaseUrl: z.string().volatile(),
4446
4013
  trustedHosts: z.array(z.string()),
4447
4014
  devicesFile: z.string(),
4448
- autoTunnel: z.boolean().default(false),
4449
- tunnelToken: z.string().role("secret"),
4450
- relay: z.boolean().default(true),
4451
- lanBind: z.boolean(),
4015
+ autoTunnel: z.boolean().default(false).volatile(),
4016
+ tunnelToken: z.string().role("secret").volatile(),
4017
+ relay: z.boolean().default(true).volatile(),
4018
+ lanBind: z.boolean().volatile(),
4452
4019
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
4453
- enabled: z.boolean().default(true)
4020
+ enabled: z.boolean().default(true).volatile()
4454
4021
  });
4455
4022
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
4456
4023
  const SWEEP_INTERVAL_MS = 1e4;
4024
+ /** Read one resolved config field, following the live reference the schema produces. */
4025
+ function readConfigField(field, fallback) {
4026
+ if (field === void 0) return fallback;
4027
+ if (typeof field === "object" && field !== null && typeof field.get === "function") {
4028
+ const value = field.get();
4029
+ return value === void 0 ? fallback : value;
4030
+ }
4031
+ return field;
4032
+ }
4457
4033
  /**
4458
- * How long one update-status probe answer is reused. The probe fans out one
4459
- * registry GET per family package plus a GitHub call, and the sidebar update
4460
- * entry asks for the status on every GUI page load.
4034
+ * Read one optional resolved config field. A volatile reference is read at
4035
+ * call time, so an unset field stays `undefined` rather than falling back to a
4036
+ * schema default (the distinction the LAN toggle and the tunnel plan depend on).
4461
4037
  */
4462
- const UPDATE_STATUS_TTL_MS = 6e4;
4038
+ function readOptionalConfigField(field) {
4039
+ if (field === void 0) return void 0;
4040
+ if (typeof field === "object" && field !== null && typeof field.get === "function") return field.get();
4041
+ return field;
4042
+ }
4463
4043
  /**
4464
4044
  * The single mapping from resolved plugin config to the pairing service
4465
4045
  * config. Both the constructed service and every later apply of this row
@@ -4494,10 +4074,23 @@ const DEFAULTS = {
4494
4074
  tunnelToken: void 0,
4495
4075
  relay: true,
4496
4076
  lanBind: void 0,
4497
- profile: process.env.DSH_PROFILE ?? "web",
4077
+ profile: resolveManagedProfile(void 0, void 0, process.env.DSH_PROFILE),
4498
4078
  enabled: true
4499
4079
  };
4500
4080
  /**
4081
+ * The launched profile the Host publishes on its `profileContext` service
4082
+ * (`{ name, dir, patchPath, … }`). The Desktop client boots the "desktop"
4083
+ * profile without exporting DSH_PROFILE, so the environment alone resolves
4084
+ * the wrong profile there and the LAN bind toggle would edit a profile that
4085
+ * is not running.
4086
+ * @param ctx - host plugin context.
4087
+ * @returns the profile name when the Host publishes one; undefined otherwise.
4088
+ */
4089
+ function launchedProfileName(ctx) {
4090
+ const fact = ctx.get("profileContext");
4091
+ return typeof fact?.name === "string" && fact.name.length > 0 ? fact.name : void 0;
4092
+ }
4093
+ /**
4501
4094
  * Mount the pairing service, routes, gate listener, and presence sweep.
4502
4095
  * @param ctx - host plugin context carrying webServer.
4503
4096
  * @param config - resolved plugin config (schema defaults applied by the loader).
@@ -4505,25 +4098,24 @@ const DEFAULTS = {
4505
4098
  const apply = mountOnce("@linxin666/dsh-remote-web-ui", applyImpl);
4506
4099
  function applyImpl(ctx, config) {
4507
4100
  const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || void 0;
4508
- const resolved = {
4509
- tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
4510
- offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
4511
- maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
4512
- idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
4513
- cookieName: config?.cookieName ?? DEFAULTS.cookieName,
4514
- requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
4515
- publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
4516
- trustedHosts: config?.trustedHosts,
4517
- devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
4518
- autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
4519
- tunnelToken: config?.tunnelToken,
4520
- relay: config?.relay ?? DEFAULTS.relay,
4521
- lanBind: config?.lanBind,
4522
- profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
4523
- enabled: config?.enabled ?? DEFAULTS.enabled
4524
- };
4525
- const resolve = () => resolved;
4526
- const service = new PairingService(pairingConfigOf(resolved));
4101
+ const resolve = () => ({
4102
+ tokenTtlMs: readConfigField(config?.tokenTtlMs, DEFAULTS.tokenTtlMs),
4103
+ offlineAfterMs: readConfigField(config?.offlineAfterMs, DEFAULTS.offlineAfterMs),
4104
+ maxDevices: readConfigField(config?.maxDevices, DEFAULTS.maxDevices),
4105
+ idleExpireMs: readConfigField(config?.idleExpireMs, DEFAULTS.idleExpireMs),
4106
+ cookieName: readConfigField(config?.cookieName, DEFAULTS.cookieName),
4107
+ requirePairingForLan: readConfigField(config?.requirePairingForLan, DEFAULTS.requirePairingForLan),
4108
+ publicBaseUrl: readOptionalConfigField(config?.publicBaseUrl) ?? envPublicBase,
4109
+ trustedHosts: readOptionalConfigField(config?.trustedHosts),
4110
+ devicesFile: readConfigField(config?.devicesFile, DEFAULTS.devicesFile),
4111
+ autoTunnel: readConfigField(config?.autoTunnel, DEFAULTS.autoTunnel),
4112
+ tunnelToken: readOptionalConfigField(config?.tunnelToken),
4113
+ relay: readConfigField(config?.relay, DEFAULTS.relay),
4114
+ lanBind: readOptionalConfigField(config?.lanBind),
4115
+ profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
4116
+ enabled: readConfigField(config?.enabled, DEFAULTS.enabled)
4117
+ });
4118
+ const service = new PairingService(pairingConfigOf(resolve()));
4527
4119
  const tunnel = new TunnelManager();
4528
4120
  let relayRegistrar;
4529
4121
  /**
@@ -4564,7 +4156,7 @@ function applyImpl(ctx, config) {
4564
4156
  relayAnnouncedFor = target;
4565
4157
  registrar.announce(target);
4566
4158
  };
4567
- let tunnelMode = resolved.autoTunnel ? "quick" : "off";
4159
+ let tunnelMode = resolve().autoTunnel ? "quick" : "off";
4568
4160
  tunnel.onPhase((info) => {
4569
4161
  if (tunnelMode === "off") return;
4570
4162
  if (info.phase === "running" && info.url !== void 0) {
@@ -4601,80 +4193,6 @@ function applyImpl(ctx, config) {
4601
4193
  }
4602
4194
  let disposeRoutes;
4603
4195
  let disposeSweep;
4604
- const requireFromHost = createRequire(import.meta.url);
4605
- /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
4606
- const hostResolve = (specifier) => {
4607
- try {
4608
- return requireFromHost.resolve(specifier);
4609
- } catch {
4610
- return;
4611
- }
4612
- };
4613
- const resolveAnchorPath = () => resolveAnchorManifest(hostResolve);
4614
- const releaseNotesCache = /* @__PURE__ */ new Map();
4615
- const fetchReleaseNotesCached = async (version) => {
4616
- const cached = releaseNotesCache.get(version);
4617
- if (cached !== void 0 && Date.now() - cached.at < 6e5) return cached.notes;
4618
- const notes = await fetchGitHubReleaseNotes(version, fetch);
4619
- releaseNotesCache.set(version, {
4620
- at: Date.now(),
4621
- notes
4622
- });
4623
- return notes;
4624
- };
4625
- let updateStatusCache;
4626
- let updateStatusInFlight;
4627
- const checkStatusCached = () => {
4628
- const cached = updateStatusCache;
4629
- if (cached !== void 0 && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value);
4630
- if (updateStatusInFlight !== void 0) return updateStatusInFlight;
4631
- updateStatusInFlight = checkUpdates({
4632
- anchorManifestPath: resolveAnchorPath(),
4633
- resolve: hostResolve,
4634
- fetchLatest: (name) => fetchLatestVersion(name, fetch),
4635
- fetchReleaseNotes: fetchReleaseNotesCached
4636
- }).then((value) => {
4637
- updateStatusCache = {
4638
- at: Date.now(),
4639
- value
4640
- };
4641
- return value;
4642
- }).finally(() => {
4643
- updateStatusInFlight = void 0;
4644
- });
4645
- return updateStatusInFlight;
4646
- };
4647
- const updateRoutes = makeUpdateRoutes({
4648
- fence: (request) => isTrustedApiRequest(request, []),
4649
- check: () => checkStatusCached(),
4650
- run: async () => {
4651
- const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
4652
- if ("error" in target) {
4653
- const code = target.error;
4654
- return {
4655
- ok: false,
4656
- exitCode: null,
4657
- output: "",
4658
- error: code === "not-found" ? "dsh-web aggregate not installed" : "local link install — update unavailable",
4659
- errorCode: code
4660
- };
4661
- }
4662
- const result = await runUpdateVerified({
4663
- run: {
4664
- profileDir: target.profileDir,
4665
- packages: target.packages
4666
- },
4667
- check: {
4668
- anchorManifestPath: resolveAnchorPath(),
4669
- resolve: hostResolve,
4670
- fetchLatest: (name) => fetchLatestVersion(name, fetch),
4671
- fetchReleaseNotes: fetchReleaseNotesCached
4672
- }
4673
- });
4674
- updateStatusCache = void 0;
4675
- return result;
4676
- }
4677
- });
4678
4196
  let lastKnownPort;
4679
4197
  let lastFirewallApplied;
4680
4198
  const lanBindStatus = () => {
@@ -4735,36 +4253,32 @@ function applyImpl(ctx, config) {
4735
4253
  return;
4736
4254
  }
4737
4255
  };
4738
- const routes = [
4739
- ...makeRoutes({
4740
- service,
4741
- requirePairingForLan: () => resolve().requirePairingForLan,
4742
- lanBindStatus,
4743
- indexDocument: fetchAppShell,
4744
- trustedHosts: () => {
4745
- const list = [];
4746
- const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS;
4747
- if (typeof envHosts === "string" && envHosts.trim() !== "") for (const item of envHosts.split(",")) {
4256
+ const routes = [...makeRoutes({
4257
+ service,
4258
+ requirePairingForLan: () => resolve().requirePairingForLan,
4259
+ lanBindStatus,
4260
+ indexDocument: fetchAppShell,
4261
+ trustedHosts: () => {
4262
+ const list = [];
4263
+ const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS;
4264
+ if (typeof envHosts === "string" && envHosts.trim() !== "") for (const item of envHosts.split(",")) {
4265
+ const trimmed = item.trim();
4266
+ if (trimmed !== "") list.push(trimmed);
4267
+ }
4268
+ const cfgHosts = resolve().trustedHosts;
4269
+ if (Array.isArray(cfgHosts)) {
4270
+ for (const item of cfgHosts) if (typeof item === "string") {
4748
4271
  const trimmed = item.trim();
4749
4272
  if (trimmed !== "") list.push(trimmed);
4750
4273
  }
4751
- const cfgHosts = resolve().trustedHosts;
4752
- if (Array.isArray(cfgHosts)) {
4753
- for (const item of cfgHosts) if (typeof item === "string") {
4754
- const trimmed = item.trim();
4755
- if (trimmed !== "") list.push(trimmed);
4756
- }
4757
- }
4758
- return list;
4759
4274
  }
4760
- }),
4761
- ...makeRemoteApiRoutes({
4762
- service,
4763
- port: ctx.webServer.port,
4764
- auth: innerAuth
4765
- }),
4766
- ...updateRoutes
4767
- ];
4275
+ return list;
4276
+ }
4277
+ }), ...makeRemoteApiRoutes({
4278
+ service,
4279
+ port: ctx.webServer.port,
4280
+ auth: innerAuth
4281
+ })];
4768
4282
  const upgrades = makeRemoteApiUpgradeRoutes({
4769
4283
  service,
4770
4284
  port: ctx.webServer.port,
@@ -4941,6 +4455,9 @@ function applyImpl(ctx, config) {
4941
4455
  text: REMOTE_CHANNEL_BOOT_SCRIPT
4942
4456
  });
4943
4457
  }), "remote-web-ui: remote channel boot patch");
4458
+ ctx.on("loader/volatile-update", () => {
4459
+ sync();
4460
+ });
4944
4461
  sync();
4945
4462
  }
4946
4463
  //#endregion