@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +12 -10
- package/README.zh.md +12 -10
- package/lib/client.js +278 -599
- package/lib/client.js.map +1 -1
- package/lib/index.js +396 -913
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +8 -9
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/index.d.ts +32 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +51 -8
- package/lib/types/client/locales.d.ts +0 -84
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -84
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +142 -8
- package/lib/types/client/settings-entry-form.d.ts +50 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -0
- package/lib/types/client/settings-entry-form.js +98 -0
- package/lib/types/index.d.ts +99 -7
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +150 -149
- package/lib/types/lan-bind-plan.d.ts +14 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -1
- package/lib/types/lan-bind-plan.js +16 -0
- package/lib/types/lan-bind.d.ts +1 -1
- package/lib/types/lan-bind.js +1 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +43 -0
- package/lib/types/remote-api.d.ts +13 -1
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +14 -1
- package/lib/types/tunnel.d.ts +60 -3
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +136 -3
- package/package.json +20 -20
- package/src/client/RemoteEntry.tsx +7 -9
- package/src/client/RemoteSettingsCard.tsx +0 -2
- package/src/client/index.ts +53 -9
- package/src/client/locales.ts +0 -84
- package/src/client/mobile-adapt.ts +144 -5
- package/src/client/remote.module.css +50 -200
- package/src/client/settings-entry-form.ts +142 -0
- package/src/index.ts +202 -164
- package/src/lan-bind-plan.ts +22 -0
- package/src/lan-bind.ts +1 -1
- package/src/mount-once.ts +107 -13
- package/src/pairing.ts +44 -1
- package/src/remote-api.ts +14 -1
- package/src/tunnel.ts +142 -3
- package/lib/types/client/UpdateEntry.d.ts +0 -14
- package/lib/types/client/UpdateEntry.d.ts.map +0 -1
- package/lib/types/client/UpdateEntry.js +0 -112
- package/lib/types/client/UpdatePanel.d.ts +0 -42
- package/lib/types/client/UpdatePanel.d.ts.map +0 -1
- package/lib/types/client/UpdatePanel.js +0 -101
- package/lib/types/client/update-api.d.ts +0 -29
- package/lib/types/client/update-api.d.ts.map +0 -1
- package/lib/types/client/update-api.js +0 -47
- package/lib/types/console-output.d.ts +0 -41
- package/lib/types/console-output.d.ts.map +0 -1
- package/lib/types/console-output.js +0 -89
- package/lib/types/update-routes.d.ts +0 -30
- package/lib/types/update-routes.d.ts.map +0 -1
- package/lib/types/update-routes.js +0 -47
- package/lib/types/update.d.ts +0 -282
- package/lib/types/update.d.ts.map +0 -1
- package/lib/types/update.js +0 -773
- package/src/client/UpdateEntry.tsx +0 -144
- package/src/client/UpdatePanel.tsx +0 -256
- package/src/client/update-api.ts +0 -49
- package/src/console-output.ts +0 -97
- package/src/update-routes.ts +0 -65
- package/src/update.ts +0 -915
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";
|
|
@@ -480,7 +479,7 @@ var PairingService = class {
|
|
|
480
479
|
};
|
|
481
480
|
/** Structural equality over the snapshot's wire fields. */
|
|
482
481
|
function snapshotsEqual(a, b) {
|
|
483
|
-
return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && relayEqual(a.relay, b.relay) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
|
|
482
|
+
return a.phase === b.phase && a.lanAvailable === b.lanAvailable && sameStrings(a.lanAddresses, b.lanAddresses) && a.publicUrl === b.publicUrl && tunnelEqual(a.tunnel, b.tunnel) && relayEqual(a.relay, b.relay) && postureEqual(a.posture, b.posture) && a.tokenId === b.tokenId && a.tokenExpiresAt === b.tokenExpiresAt && a.deviceCount === b.deviceCount && a.onlineCount === b.onlineCount && devicesEqual(a.devices, b.devices);
|
|
484
483
|
}
|
|
485
484
|
/** Per-device roster equality (order is pairing time). */
|
|
486
485
|
function devicesEqual(a, b) {
|
|
@@ -497,6 +496,42 @@ function tunnelEqual(a, b) {
|
|
|
497
496
|
function relayEqual(a, b) {
|
|
498
497
|
return a === b || a !== void 0 && b !== void 0 && a.state === b.state && a.url === b.url && a.error === b.error;
|
|
499
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* Posture equality. A probe round can change this frame while every other field
|
|
501
|
+
* stays put — the common case, since the probe re-runs on a settings change and
|
|
502
|
+
* on a tunnel reaching running, and a quick-tunnel restart churns the probed
|
|
503
|
+
* host set. Leaving the frame out of the comparison suppressed those emits, so
|
|
504
|
+
* the loopback SSE stream and the desktop panel could sit on a stale "exposed"
|
|
505
|
+
* (or stale "clean") verdict for as long as the phase and roster stayed put.
|
|
506
|
+
*/
|
|
507
|
+
function postureEqual(a, b) {
|
|
508
|
+
if (a === b) return true;
|
|
509
|
+
if (!isPostureFrame(a) || !isPostureFrame(b)) return false;
|
|
510
|
+
return a.checkedAt === b.checkedAt && hostsEqual(a.hosts, b.hosts);
|
|
511
|
+
}
|
|
512
|
+
/** Whether a value has the shape a posture frame is compared on. */
|
|
513
|
+
function isPostureFrame(value) {
|
|
514
|
+
return typeof value === "object" && value !== null;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Per-host posture equality (a round's verdicts, in probe order). Tolerates a
|
|
518
|
+
* frame that is not the expected shape: this runs inside the emit path, outside
|
|
519
|
+
* the listener error containment, so a malformed snapshot handed in by another
|
|
520
|
+
* host-fiber caller must degrade to "changed" instead of throwing out of the
|
|
521
|
+
* setter that produced it.
|
|
522
|
+
*/
|
|
523
|
+
function hostsEqual(a, b) {
|
|
524
|
+
if (a === b) return true;
|
|
525
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false;
|
|
526
|
+
return a.length === b.length && a.every((host, index) => {
|
|
527
|
+
const other = b[index];
|
|
528
|
+
return isPostureHost(host) && isPostureHost(other) && host.host === other.host && host.exposed === other.exposed;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
/** Whether a value has the shape one probed host is compared on. */
|
|
532
|
+
function isPostureHost(value) {
|
|
533
|
+
return typeof value === "object" && value !== null;
|
|
534
|
+
}
|
|
500
535
|
/** Element-wise string list equality (interface order is meaningful). */
|
|
501
536
|
function sameStrings(a, b) {
|
|
502
537
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
@@ -2185,7 +2220,19 @@ function makeRemoteApiRoutes(deps) {
|
|
|
2185
2220
|
}];
|
|
2186
2221
|
}
|
|
2187
2222
|
/**
|
|
2188
|
-
* Map one outer upgrade URL onto the loopback path
|
|
2223
|
+
* Map one outer upgrade URL onto the loopback path, dropping the cookieless
|
|
2224
|
+
* device credential on the way in.
|
|
2225
|
+
*
|
|
2226
|
+
* The outer handshake carries the device id as `?device=<id>` because WebSocket
|
|
2227
|
+
* handshakes cannot carry headers from the Web API. That id is a live session
|
|
2228
|
+
* credential — it opens the full host API through this same channel — and the
|
|
2229
|
+
* inner leg is a request the rest of the loopback origin sees (on a WebSocket
|
|
2230
|
+
* leg, a raw handshake request line written to a loopback socket), so forwarding
|
|
2231
|
+
* it handed a working credential to every other observer of the local port for
|
|
2232
|
+
* no benefit: the gate above has already authenticated the caller, and no
|
|
2233
|
+
* upstream route reads the parameter (the gateway mux matches on path alone).
|
|
2234
|
+
* The rest of the query rides through unchanged, so a caller's own parameters
|
|
2235
|
+
* still reach the upstream.
|
|
2189
2236
|
*/
|
|
2190
2237
|
function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
2191
2238
|
if (reqUrl === void 0 || reqUrl === "") return fallbackPath;
|
|
@@ -2197,6 +2244,7 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
2197
2244
|
}
|
|
2198
2245
|
const inner = innerPathOf(url.pathname);
|
|
2199
2246
|
if (inner === void 0) return fallbackPath;
|
|
2247
|
+
url.searchParams.delete(REMOTE_DEVICE_QUERY);
|
|
2200
2248
|
return `${inner}${url.search}`;
|
|
2201
2249
|
}
|
|
2202
2250
|
/**
|
|
@@ -2682,7 +2730,7 @@ const LAN_BIND_BLOCK_BEGIN = "# --- remote-web-ui lan-bind block (managed - do n
|
|
|
2682
2730
|
const LAN_BIND_BLOCK_END = "# --- end remote-web-ui lan-bind block ---";
|
|
2683
2731
|
/**
|
|
2684
2732
|
* The absolute path of the profile patch file this toggle manages. The value
|
|
2685
|
-
* is config-controlled (and the
|
|
2733
|
+
* is config-controlled (and the runtime/environment fallbacks bypass schema
|
|
2686
2734
|
* validation entirely), so the path is guarded twice: the profile must be a
|
|
2687
2735
|
* single safe path segment, and the resolved file must stay under the
|
|
2688
2736
|
* profiles directory.
|
|
@@ -3080,6 +3128,20 @@ function pendingRestartOf(lanBind, desiredHost, liveHost) {
|
|
|
3080
3128
|
return liveHost !== desiredHost;
|
|
3081
3129
|
}
|
|
3082
3130
|
/**
|
|
3131
|
+
* The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
|
|
3132
|
+
* explicit `profile` config wins; otherwise the launched profile the Host
|
|
3133
|
+
* publishes is authoritative, because the DSH Desktop client boots the
|
|
3134
|
+
* `desktop` profile without exporting `DSH_PROFILE` — an environment-only
|
|
3135
|
+
* fallback edited `profiles/web` there, so the toggle looked applied while
|
|
3136
|
+
* the running host never followed the block.
|
|
3137
|
+
* @param configured - the explicit `profile` plugin config, when set.
|
|
3138
|
+
* @param launched - the launched profile name the Host published, when available.
|
|
3139
|
+
* @param env - the DSH_PROFILE environment value, when set.
|
|
3140
|
+
*/
|
|
3141
|
+
function resolveManagedProfile(configured, launched, env) {
|
|
3142
|
+
return configured ?? launched ?? env ?? "web";
|
|
3143
|
+
}
|
|
3144
|
+
/**
|
|
3083
3145
|
* Whether the firewall rule must be (re)applied: only when the toggle or the
|
|
3084
3146
|
* bound port moved since the last application. Keeps unrelated settings
|
|
3085
3147
|
* saves from spawning netsh/firewall-cmd delete+add churn.
|
|
@@ -3152,9 +3214,16 @@ function createInnerAuth(launchUrl, fetchImpl = fetch) {
|
|
|
3152
3214
|
*
|
|
3153
3215
|
* The cloudflared package's Tunnel is a thin spawn wrapper; this manager
|
|
3154
3216
|
* owns the lifecycle policy (binary readiness, URL timeout, restart
|
|
3155
|
-
* backoff) around it. All seams — the
|
|
3156
|
-
*
|
|
3157
|
-
* a real binary or network.
|
|
3217
|
+
* backoff, and the public-URL readiness watchdog) around it. All seams — the
|
|
3218
|
+
* tunnel factory, binary readiness, the URL probe, timers — are injectable
|
|
3219
|
+
* so the whole lifecycle is unit-testable without a real binary or network.
|
|
3220
|
+
*
|
|
3221
|
+
* 'running' is not a terminal state: the connector can lose its edge
|
|
3222
|
+
* registration while the process keeps living, which leaves a hostname that
|
|
3223
|
+
* no longer resolves. The watchdog below probes the public URL of a running
|
|
3224
|
+
* tunnel and reuses the crash-restart path when it stops answering, so the
|
|
3225
|
+
* relay is re-registered against a freshly minted URL instead of proxying a
|
|
3226
|
+
* dead one (issue #1723).
|
|
3158
3227
|
*/
|
|
3159
3228
|
/** Compare two targets for the start idempotence check. */
|
|
3160
3229
|
function sameTarget(left, right) {
|
|
@@ -3197,6 +3266,44 @@ function namedTunnelHandle(inner, publicUrl) {
|
|
|
3197
3266
|
};
|
|
3198
3267
|
return handle;
|
|
3199
3268
|
}
|
|
3269
|
+
/**
|
|
3270
|
+
* Probe one public tunnel URL from the host. Resolves true when the URL is
|
|
3271
|
+
* served (any HTTP answer below 500 — a harness without credentials answers
|
|
3272
|
+
* 401/403, which is a healthy round trip), false for a DNS failure, a refused
|
|
3273
|
+
* connection, a timeout, or a Cloudflare 5xx (530 "Origin DNS error" / 1033)
|
|
3274
|
+
* — the shapes a tunnel that lost its edge registration produces.
|
|
3275
|
+
*
|
|
3276
|
+
* The probe must never reject: the watchdog reads a rejection as a dead
|
|
3277
|
+
* tunnel and restarts on it, so a local programming error would otherwise
|
|
3278
|
+
* recycle working tunnels forever.
|
|
3279
|
+
* @param url - the tunnel's public URL.
|
|
3280
|
+
* @param timeoutMs - how long one probe may take.
|
|
3281
|
+
* @returns whether the URL answered as a live origin.
|
|
3282
|
+
*/
|
|
3283
|
+
async function probeTunnelUrl(url, timeoutMs) {
|
|
3284
|
+
const controller = new AbortController();
|
|
3285
|
+
const timer = setTimeout(() => {
|
|
3286
|
+
controller.abort();
|
|
3287
|
+
}, timeoutMs);
|
|
3288
|
+
try {
|
|
3289
|
+
const response = await fetch(url, {
|
|
3290
|
+
method: "GET",
|
|
3291
|
+
redirect: "manual",
|
|
3292
|
+
cache: "no-store",
|
|
3293
|
+
signal: controller.signal
|
|
3294
|
+
});
|
|
3295
|
+
await response.body?.cancel().catch(() => void 0);
|
|
3296
|
+
return response.status < 500;
|
|
3297
|
+
} catch {
|
|
3298
|
+
return false;
|
|
3299
|
+
} finally {
|
|
3300
|
+
clearTimeout(timer);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
/** Default probe: the real network round trip. */
|
|
3304
|
+
async function defaultProbe(url, timeoutMs) {
|
|
3305
|
+
return await probeTunnelUrl(url, timeoutMs);
|
|
3306
|
+
}
|
|
3200
3307
|
/** Time box for the `--version` probe that validates a staged binary. */
|
|
3201
3308
|
const BINARY_PROBE_TIMEOUT_MS = 1e4;
|
|
3202
3309
|
/**
|
|
@@ -3342,6 +3449,10 @@ var TunnelManager = class {
|
|
|
3342
3449
|
restartBaseMs;
|
|
3343
3450
|
restartMaxMs;
|
|
3344
3451
|
timer;
|
|
3452
|
+
probe;
|
|
3453
|
+
healthCheckIntervalMs;
|
|
3454
|
+
healthCheckFailures;
|
|
3455
|
+
probeTimeoutMs;
|
|
3345
3456
|
phase = "stopped";
|
|
3346
3457
|
url;
|
|
3347
3458
|
error;
|
|
@@ -3349,6 +3460,8 @@ var TunnelManager = class {
|
|
|
3349
3460
|
handle;
|
|
3350
3461
|
urlTimer;
|
|
3351
3462
|
restartTimer;
|
|
3463
|
+
healthTimer;
|
|
3464
|
+
healthFailures = 0;
|
|
3352
3465
|
attempts = 0;
|
|
3353
3466
|
generation = 0;
|
|
3354
3467
|
stopping = false;
|
|
@@ -3364,6 +3477,10 @@ var TunnelManager = class {
|
|
|
3364
3477
|
this.restartBaseMs = options.restartBaseMs ?? 5e3;
|
|
3365
3478
|
this.restartMaxMs = options.restartMaxMs ?? 6e4;
|
|
3366
3479
|
this.timer = options.timer ?? nodeTimer;
|
|
3480
|
+
this.probe = options.probe ?? defaultProbe;
|
|
3481
|
+
this.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 6e4;
|
|
3482
|
+
this.healthCheckFailures = options.healthCheckFailures ?? 2;
|
|
3483
|
+
this.probeTimeoutMs = options.probeTimeoutMs ?? 1e4;
|
|
3367
3484
|
}
|
|
3368
3485
|
/** The current status frame. */
|
|
3369
3486
|
get info() {
|
|
@@ -3462,10 +3579,65 @@ var TunnelManager = class {
|
|
|
3462
3579
|
this.error = void 0;
|
|
3463
3580
|
this.attempts = 0;
|
|
3464
3581
|
this.setPhase("running");
|
|
3582
|
+
this.startHealthWatch(value);
|
|
3465
3583
|
for (const listener of this.urlListeners) try {
|
|
3466
3584
|
listener(value);
|
|
3467
3585
|
} catch {}
|
|
3468
3586
|
}
|
|
3587
|
+
/**
|
|
3588
|
+
* Watch the readiness of a running tunnel. cloudflared can keep its process
|
|
3589
|
+
* (and its metrics port) alive while every edge connection is gone, leaving
|
|
3590
|
+
* a minted hostname that no longer resolves: the manager used to report
|
|
3591
|
+
* 'running' forever and the relay kept forwarding to that dead address, so
|
|
3592
|
+
* the phone hit Cloudflare 1016 instead of the relay's offline page
|
|
3593
|
+
* (issue #1723). A bounded number of consecutive probe failures ends the
|
|
3594
|
+
* attempt through the ordinary fail/backoff path, which mints a new URL and
|
|
3595
|
+
* re-announces the relay.
|
|
3596
|
+
*/
|
|
3597
|
+
startHealthWatch(url) {
|
|
3598
|
+
this.stopHealthWatch();
|
|
3599
|
+
if (this.stopping || this.phase !== "running") return;
|
|
3600
|
+
if (this.healthCheckIntervalMs <= 0 || this.healthCheckFailures <= 0) return;
|
|
3601
|
+
this.healthFailures = 0;
|
|
3602
|
+
const schedule = () => {
|
|
3603
|
+
this.healthTimer = this.timer.setTimeout(() => {
|
|
3604
|
+
this.healthTimer = void 0;
|
|
3605
|
+
this.healthCheck(url);
|
|
3606
|
+
}, this.healthCheckIntervalMs);
|
|
3607
|
+
};
|
|
3608
|
+
schedule();
|
|
3609
|
+
}
|
|
3610
|
+
/** One probe round of the running tunnel's public URL. */
|
|
3611
|
+
async healthCheck(url) {
|
|
3612
|
+
if (this.stopping || this.phase !== "running" || this.url !== url) return;
|
|
3613
|
+
let alive = false;
|
|
3614
|
+
try {
|
|
3615
|
+
alive = await this.probe(url, this.probeTimeoutMs);
|
|
3616
|
+
} catch {
|
|
3617
|
+
alive = false;
|
|
3618
|
+
}
|
|
3619
|
+
if (this.stopping || this.phase !== "running" || this.url !== url) return;
|
|
3620
|
+
if (alive) this.healthFailures = 0;
|
|
3621
|
+
else {
|
|
3622
|
+
this.healthFailures += 1;
|
|
3623
|
+
if (this.healthFailures >= this.healthCheckFailures) {
|
|
3624
|
+
this.fail("the tunnel stopped answering on its public URL");
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
this.healthTimer = this.timer.setTimeout(() => {
|
|
3629
|
+
this.healthTimer = void 0;
|
|
3630
|
+
this.healthCheck(url);
|
|
3631
|
+
}, this.healthCheckIntervalMs);
|
|
3632
|
+
}
|
|
3633
|
+
/** Cancel the readiness watchdog (teardown, restart, stop). */
|
|
3634
|
+
stopHealthWatch() {
|
|
3635
|
+
if (this.healthTimer !== void 0) {
|
|
3636
|
+
this.timer.clearTimeout(this.healthTimer);
|
|
3637
|
+
this.healthTimer = void 0;
|
|
3638
|
+
}
|
|
3639
|
+
this.healthFailures = 0;
|
|
3640
|
+
}
|
|
3469
3641
|
handleExit() {
|
|
3470
3642
|
if (this.stopping) return;
|
|
3471
3643
|
this.fail("the tunnel process exited unexpectedly");
|
|
@@ -3482,6 +3654,7 @@ var TunnelManager = class {
|
|
|
3482
3654
|
this.timer.clearTimeout(this.urlTimer);
|
|
3483
3655
|
this.urlTimer = void 0;
|
|
3484
3656
|
}
|
|
3657
|
+
this.stopHealthWatch();
|
|
3485
3658
|
this.setPhase("failed");
|
|
3486
3659
|
this.attempts += 1;
|
|
3487
3660
|
const delay = Math.min(this.restartBaseMs * 2 ** (this.attempts - 1), this.restartMaxMs);
|
|
@@ -3493,6 +3666,7 @@ var TunnelManager = class {
|
|
|
3493
3666
|
/** Stop the current process and cancel every pending timer (no phase change). */
|
|
3494
3667
|
teardown() {
|
|
3495
3668
|
this.stopping = true;
|
|
3669
|
+
this.stopHealthWatch();
|
|
3496
3670
|
if (this.urlTimer !== void 0) {
|
|
3497
3671
|
this.timer.clearTimeout(this.urlTimer);
|
|
3498
3672
|
this.urlTimer = void 0;
|
|
@@ -3623,759 +3797,6 @@ var PublicBaseKeeper = class {
|
|
|
3623
3797
|
}
|
|
3624
3798
|
};
|
|
3625
3799
|
//#endregion
|
|
3626
|
-
//#region src/console-output.ts
|
|
3627
|
-
/** Count the U+FFFD replacement characters one candidate decode produced. */
|
|
3628
|
-
function replacementCount(text) {
|
|
3629
|
-
let count = 0;
|
|
3630
|
-
for (const character of text) if (character === "�") count += 1;
|
|
3631
|
-
return count;
|
|
3632
|
-
}
|
|
3633
|
-
/**
|
|
3634
|
-
* Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
|
|
3635
|
-
* otherwise the bytes are not UTF-8 at all and the console wrote its own code
|
|
3636
|
-
* page, so the candidate that loses the least text is taken.
|
|
3637
|
-
* @param bytes - the captured bytes.
|
|
3638
|
-
* @returns the decoded text.
|
|
3639
|
-
*/
|
|
3640
|
-
function decodeConsoleBytes(bytes) {
|
|
3641
|
-
try {
|
|
3642
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
3643
|
-
} catch {
|
|
3644
|
-
let best;
|
|
3645
|
-
let bestLoss = Number.POSITIVE_INFINITY;
|
|
3646
|
-
for (const label of ["gbk", "utf-8"]) {
|
|
3647
|
-
let text;
|
|
3648
|
-
try {
|
|
3649
|
-
text = new TextDecoder(label).decode(bytes);
|
|
3650
|
-
} catch {
|
|
3651
|
-
continue;
|
|
3652
|
-
}
|
|
3653
|
-
const loss = replacementCount(text);
|
|
3654
|
-
if (loss < bestLoss) {
|
|
3655
|
-
best = text;
|
|
3656
|
-
bestLoss = loss;
|
|
3657
|
-
}
|
|
3658
|
-
}
|
|
3659
|
-
/* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
|
|
3660
|
-
if (best === void 0) return Buffer.from(bytes).toString("utf8");
|
|
3661
|
-
return best;
|
|
3662
|
-
}
|
|
3663
|
-
}
|
|
3664
|
-
/**
|
|
3665
|
-
* Create a capture bounded to a byte budget. Overflow drops from the FRONT
|
|
3666
|
-
* (the tail is what carries a failure reason), and the cut never lands inside
|
|
3667
|
-
* a character: leading UTF-8 continuation bytes are dropped with it, and a
|
|
3668
|
-
* legacy code page's dangling lead byte — indistinguishable from a real byte
|
|
3669
|
-
* without knowing the encoding until the whole buffer is decoded — is removed
|
|
3670
|
-
* with the one replacement character it produced, so an over-long stream still
|
|
3671
|
-
* reads cleanly at the seam.
|
|
3672
|
-
* @param maxBytes - maximum retained bytes.
|
|
3673
|
-
* @returns the capture.
|
|
3674
|
-
*/
|
|
3675
|
-
function createOutputCapture(maxBytes) {
|
|
3676
|
-
let buffer = Buffer.alloc(0);
|
|
3677
|
-
let trimmed = false;
|
|
3678
|
-
return {
|
|
3679
|
-
push(chunk) {
|
|
3680
|
-
buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)]);
|
|
3681
|
-
if (buffer.length <= maxBytes) return;
|
|
3682
|
-
let start = buffer.length - maxBytes;
|
|
3683
|
-
while (start < buffer.length && (buffer[start] & 192) === 128) start += 1;
|
|
3684
|
-
buffer = Buffer.from(buffer.subarray(start));
|
|
3685
|
-
trimmed = true;
|
|
3686
|
-
},
|
|
3687
|
-
read() {
|
|
3688
|
-
const text = decodeConsoleBytes(buffer);
|
|
3689
|
-
return trimmed && text.startsWith("�") ? text.slice(1) : text;
|
|
3690
|
-
}
|
|
3691
|
-
};
|
|
3692
|
-
}
|
|
3693
|
-
/** The aggregate package that is the canonical update entry point. */
|
|
3694
|
-
const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
|
|
3695
|
-
/** Fallback anchor: this plugin's own package when the aggregate is absent. */
|
|
3696
|
-
const SELF_PACKAGE = "@linxin666/dsh-remote-web-ui";
|
|
3697
|
-
/** GitHub repository used to surface human-readable release notes. */
|
|
3698
|
-
const UPDATE_RELEASE_REPO = "zhu1090093659/dsh-web";
|
|
3699
|
-
/** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
|
|
3700
|
-
const PROFILE_NAME_PREFIX = "dsh-profile-";
|
|
3701
|
-
/** How many ancestor directories a profile search walks before giving up. */
|
|
3702
|
-
const PROFILE_WALK_DEPTH = 12;
|
|
3703
|
-
/**
|
|
3704
|
-
* Parse a semantic version string (leading `v` tolerated, build metadata
|
|
3705
|
-
* ignored). Returns undefined for unparseable input.
|
|
3706
|
-
* @param value - the version string, e.g. `0.1.10` or `0.1.11-rc.1`.
|
|
3707
|
-
* @returns the parsed parts, or undefined.
|
|
3708
|
-
*/
|
|
3709
|
-
function parseSemver(value) {
|
|
3710
|
-
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
|
|
3711
|
-
if (match === null) return void 0;
|
|
3712
|
-
return {
|
|
3713
|
-
major: Number(match[1]),
|
|
3714
|
-
minor: Number(match[2]),
|
|
3715
|
-
patch: Number(match[3]),
|
|
3716
|
-
prerelease: match[4] === void 0 ? [] : match[4].split(".")
|
|
3717
|
-
};
|
|
3718
|
-
}
|
|
3719
|
-
/**
|
|
3720
|
-
* Compare two semantic versions per the semver precedence rules (a release
|
|
3721
|
-
* outranks any of its prereleases; numeric prerelease identifiers compare
|
|
3722
|
-
* numerically and sort below alphanumeric ones). An unparseable version sorts
|
|
3723
|
-
* below every parseable one; two unparseable versions compare equal.
|
|
3724
|
-
* @param a - first version.
|
|
3725
|
-
* @param b - second version.
|
|
3726
|
-
* @returns negative when a < b, 0 when equal, positive when a > b.
|
|
3727
|
-
*/
|
|
3728
|
-
function compareVersions(a, b) {
|
|
3729
|
-
const pa = parseSemver(a);
|
|
3730
|
-
const pb = parseSemver(b);
|
|
3731
|
-
if (pa === void 0 && pb === void 0) return 0;
|
|
3732
|
-
if (pa === void 0) return -1;
|
|
3733
|
-
if (pb === void 0) return 1;
|
|
3734
|
-
for (const key of [
|
|
3735
|
-
"major",
|
|
3736
|
-
"minor",
|
|
3737
|
-
"patch"
|
|
3738
|
-
]) if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
|
|
3739
|
-
if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0;
|
|
3740
|
-
if (pa.prerelease.length === 0) return 1;
|
|
3741
|
-
if (pb.prerelease.length === 0) return -1;
|
|
3742
|
-
for (let index = 0; index < Math.max(pa.prerelease.length, pb.prerelease.length); index++) {
|
|
3743
|
-
const ra = pa.prerelease[index];
|
|
3744
|
-
const rb = pb.prerelease[index];
|
|
3745
|
-
if (ra === void 0) return -1;
|
|
3746
|
-
if (rb === void 0) return 1;
|
|
3747
|
-
if (ra === rb) continue;
|
|
3748
|
-
const numericA = /^\d+$/.test(ra);
|
|
3749
|
-
const numericB = /^\d+$/.test(rb);
|
|
3750
|
-
if (numericA && numericB) return Number(ra) < Number(rb) ? -1 : 1;
|
|
3751
|
-
if (numericA) return -1;
|
|
3752
|
-
if (numericB) return 1;
|
|
3753
|
-
return ra < rb ? -1 : 1;
|
|
3754
|
-
}
|
|
3755
|
-
return 0;
|
|
3756
|
-
}
|
|
3757
|
-
/** Read a package.json at a path, tolerating any parse/IO failure. */
|
|
3758
|
-
function readManifest(path) {
|
|
3759
|
-
try {
|
|
3760
|
-
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
3761
|
-
return typeof parsed === "object" && parsed !== null ? parsed : void 0;
|
|
3762
|
-
} catch {
|
|
3763
|
-
return;
|
|
3764
|
-
}
|
|
3765
|
-
}
|
|
3766
|
-
/**
|
|
3767
|
-
* Locate the owning dsh profile by walking up from an installed package's
|
|
3768
|
-
* manifest until a manifest named `dsh-profile-*` appears (the profile
|
|
3769
|
-
* directory is the first ancestor whose package.json carries that name).
|
|
3770
|
-
* @param anchorManifestPath - absolute path of the anchor package.json.
|
|
3771
|
-
* @returns the profile name/dir, or undefined when not profile-installed.
|
|
3772
|
-
*/
|
|
3773
|
-
function findProfile(anchorManifestPath) {
|
|
3774
|
-
let dir = dirname(anchorManifestPath);
|
|
3775
|
-
for (let depth = 0; depth < PROFILE_WALK_DEPTH; depth++) {
|
|
3776
|
-
const name = readManifest(join(dir, "package.json"))?.name;
|
|
3777
|
-
if (typeof name === "string" && name.startsWith(PROFILE_NAME_PREFIX)) return {
|
|
3778
|
-
name: name.slice(12),
|
|
3779
|
-
dir
|
|
3780
|
-
};
|
|
3781
|
-
const parent = dirname(dir);
|
|
3782
|
-
if (parent === dir) return void 0;
|
|
3783
|
-
dir = parent;
|
|
3784
|
-
}
|
|
3785
|
-
}
|
|
3786
|
-
/** Whether a dependency spec is a local link/file/dev-mode install. */
|
|
3787
|
-
function isLinkedSpec(spec) {
|
|
3788
|
-
if (typeof spec !== "string") return false;
|
|
3789
|
-
return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec);
|
|
3790
|
-
}
|
|
3791
|
-
/** Whether a direct local dependency overrides one of the aggregate's children. */
|
|
3792
|
-
function hasLinkedFamilyOverride(anchorManifest, profileManifest) {
|
|
3793
|
-
const dependencies = profileManifest?.dependencies;
|
|
3794
|
-
if (typeof dependencies !== "object" || dependencies === null) return false;
|
|
3795
|
-
return familyChildren(anchorManifest).some((name) => isLinkedSpec(dependencies[name]));
|
|
3796
|
-
}
|
|
3797
|
-
/**
|
|
3798
|
-
* Resolve the anchor package's manifest path. The aggregate package is the
|
|
3799
|
-
* canonical entry point; this plugin's own package is the fallback. Both a
|
|
3800
|
-
* throwing resolve and an undefined return mean "not installed" and move on
|
|
3801
|
-
* to the next candidate.
|
|
3802
|
-
* @param resolve - a Node resolve implementation scoped to the host process.
|
|
3803
|
-
* @returns the absolute manifest path, or undefined when neither is installed.
|
|
3804
|
-
*/
|
|
3805
|
-
function resolveAnchorManifest(resolve) {
|
|
3806
|
-
for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) try {
|
|
3807
|
-
const path = resolve(name + "/package.json");
|
|
3808
|
-
if (path !== void 0) return path;
|
|
3809
|
-
} catch {}
|
|
3810
|
-
}
|
|
3811
|
-
/**
|
|
3812
|
-
* Resolve what an update would touch: the owning profile directory and the
|
|
3813
|
-
* family package list. Fails with an error code when the anchor is missing
|
|
3814
|
-
* ('not-found') or is a local dev install ('link').
|
|
3815
|
-
* @param deps - the anchor manifest path (resolveAnchorManifest output).
|
|
3816
|
-
* @returns the target, or the failure code.
|
|
3817
|
-
*/
|
|
3818
|
-
function resolveUpdateTarget(deps) {
|
|
3819
|
-
const manifestPath = deps.anchorManifestPath;
|
|
3820
|
-
if (manifestPath === void 0) return { error: "not-found" };
|
|
3821
|
-
const manifest = readManifest(manifestPath);
|
|
3822
|
-
if (manifest === void 0) return { error: "not-found" };
|
|
3823
|
-
const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
|
|
3824
|
-
if (anchor === void 0) return { error: "not-found" };
|
|
3825
|
-
const profile = findProfile(manifestPath);
|
|
3826
|
-
if (profile === void 0) return { error: "link" };
|
|
3827
|
-
const profileManifest = readManifest(join(profile.dir, "package.json"));
|
|
3828
|
-
const spec = (profileManifest?.dependencies)?.[anchor];
|
|
3829
|
-
if (isLinkedSpec(spec) || hasLinkedFamilyOverride(manifest, profileManifest)) return { error: "link" };
|
|
3830
|
-
return {
|
|
3831
|
-
profileName: profile.name,
|
|
3832
|
-
profileDir: profile.dir,
|
|
3833
|
-
packages: familyUpdatePackages(anchor, manifest, profileManifest)
|
|
3834
|
-
};
|
|
3835
|
-
}
|
|
3836
|
-
/** Family children of the anchor: its dependencies under the family scope. */
|
|
3837
|
-
function familyChildren(anchorManifest) {
|
|
3838
|
-
const dependencies = anchorManifest.dependencies;
|
|
3839
|
-
if (typeof dependencies !== "object" || dependencies === null) return [];
|
|
3840
|
-
const names = [];
|
|
3841
|
-
for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
|
|
3842
|
-
return names;
|
|
3843
|
-
}
|
|
3844
|
-
/** Registry-managed family packages covered by one update operation. */
|
|
3845
|
-
function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
|
|
3846
|
-
const names = /* @__PURE__ */ new Set([anchor, ...familyChildren(anchorManifest)]);
|
|
3847
|
-
const dependencies = profileManifest?.dependencies;
|
|
3848
|
-
if (typeof dependencies !== "object" || dependencies === null) return [...names];
|
|
3849
|
-
for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string" && !isLinkedSpec(spec)) names.add(name);
|
|
3850
|
-
return [...names];
|
|
3851
|
-
}
|
|
3852
|
-
/**
|
|
3853
|
-
* Probe every package's latest version with a bounded number of requests in
|
|
3854
|
-
* flight, preserving the caller's order in the result.
|
|
3855
|
-
* @param names - package names to probe.
|
|
3856
|
-
* @param fetchLatest - the probe for one package (returns undefined on failure).
|
|
3857
|
-
* @returns one result per name, in the input order.
|
|
3858
|
-
*/
|
|
3859
|
-
async function probeLatestVersions(names, fetchLatest) {
|
|
3860
|
-
const results = new Array(names.length);
|
|
3861
|
-
let next = 0;
|
|
3862
|
-
const worker = async () => {
|
|
3863
|
-
for (;;) {
|
|
3864
|
-
const index = next++;
|
|
3865
|
-
if (index >= names.length) return;
|
|
3866
|
-
results[index] = await fetchLatest(names[index]);
|
|
3867
|
-
}
|
|
3868
|
-
};
|
|
3869
|
-
const workers = Math.min(4, names.length);
|
|
3870
|
-
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
3871
|
-
return results;
|
|
3872
|
-
}
|
|
3873
|
-
/**
|
|
3874
|
-
* Probe the npm registry for one package's latest release.
|
|
3875
|
-
* @param name - the package name (scope slash URL-encoded).
|
|
3876
|
-
* @param fetchImpl - the fetch implementation (global fetch in the host).
|
|
3877
|
-
* @param timeoutMs - probe timeout.
|
|
3878
|
-
* @returns the latest version string, or undefined on any failure.
|
|
3879
|
-
*/
|
|
3880
|
-
async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
|
|
3881
|
-
try {
|
|
3882
|
-
const controller = new AbortController();
|
|
3883
|
-
const timer = setTimeout(() => {
|
|
3884
|
-
controller.abort();
|
|
3885
|
-
}, timeoutMs);
|
|
3886
|
-
try {
|
|
3887
|
-
const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", withIdentityEncoding({ signal: controller.signal }));
|
|
3888
|
-
if (!response.ok) return void 0;
|
|
3889
|
-
const body = await response.json();
|
|
3890
|
-
if (typeof body !== "object" || body === null) return void 0;
|
|
3891
|
-
const version = body.version;
|
|
3892
|
-
return typeof version === "string" ? version : void 0;
|
|
3893
|
-
} finally {
|
|
3894
|
-
clearTimeout(timer);
|
|
3895
|
-
}
|
|
3896
|
-
} catch {
|
|
3897
|
-
return;
|
|
3898
|
-
}
|
|
3899
|
-
}
|
|
3900
|
-
/** Normalize one release-note bullet. */
|
|
3901
|
-
function cleanNoteItem(text) {
|
|
3902
|
-
return text.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1").replace(/^[-*]\s+/, "").trim();
|
|
3903
|
-
}
|
|
3904
|
-
/** Determine the section kind for a Markdown heading line. */
|
|
3905
|
-
function noteSectionOf(line) {
|
|
3906
|
-
const heading = /^#{1,6}\s+(.+)$/.exec(line.trim());
|
|
3907
|
-
if (heading === null) return void 0;
|
|
3908
|
-
const title = heading[1].replace(/[::].*$/, "").trim().toLowerCase();
|
|
3909
|
-
if (title === "新功能" || title === "new features") return "features";
|
|
3910
|
-
if (title === "修复" || title === "bug fixes" || title === "bugfixes") return "fixes";
|
|
3911
|
-
if (title === "其他改动" || title === "other changes") return "other";
|
|
3912
|
-
}
|
|
3913
|
-
/**
|
|
3914
|
-
* Parse the GitHub Release body into the three conventional sections.
|
|
3915
|
-
*
|
|
3916
|
-
* The committed release notes are bilingual: Chinese is the default view and
|
|
3917
|
-
* English lives inside a `<details>` block. Prefer the Chinese sections when
|
|
3918
|
-
* present and fall back to the whole body for English-only releases. Bullets
|
|
3919
|
-
* are escaped as plain text for the panel (no Markdown renderer).
|
|
3920
|
-
*/
|
|
3921
|
-
function parseReleaseNotesSource(version, source) {
|
|
3922
|
-
const notes = {
|
|
3923
|
-
version,
|
|
3924
|
-
features: [],
|
|
3925
|
-
fixes: [],
|
|
3926
|
-
other: []
|
|
3927
|
-
};
|
|
3928
|
-
let current;
|
|
3929
|
-
for (const rawLine of source.split(/\r?\n/)) {
|
|
3930
|
-
const line = rawLine.trim();
|
|
3931
|
-
const section = noteSectionOf(line);
|
|
3932
|
-
if (section !== void 0) {
|
|
3933
|
-
current = section;
|
|
3934
|
-
continue;
|
|
3935
|
-
}
|
|
3936
|
-
if (current === void 0 || !/^-\s+/.test(line)) continue;
|
|
3937
|
-
const item = cleanNoteItem(line);
|
|
3938
|
-
if (item !== "") notes[current].push(item);
|
|
3939
|
-
}
|
|
3940
|
-
if (notes.features.length === 0 && notes.fixes.length === 0 && notes.other.length === 0) return void 0;
|
|
3941
|
-
return notes;
|
|
3942
|
-
}
|
|
3943
|
-
function parseReleaseNotesBody(version, body) {
|
|
3944
|
-
return parseReleaseNotesSource(version, body.split("<details>")[0] ?? body) ?? parseReleaseNotesSource(version, body);
|
|
3945
|
-
}
|
|
3946
|
-
/**
|
|
3947
|
-
* Fetch and parse the GitHub Release for one `dsh-web` version.
|
|
3948
|
-
* @param version - the npm release version (without the `v` prefix).
|
|
3949
|
-
* @param fetchImpl - injected fetch; global `fetch` in the host.
|
|
3950
|
-
* @param timeoutMs - hard timeout for the GitHub API probe.
|
|
3951
|
-
* @returns structured notes, or undefined when unavailable.
|
|
3952
|
-
*/
|
|
3953
|
-
async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1e4) {
|
|
3954
|
-
const controller = new AbortController();
|
|
3955
|
-
const timer = setTimeout(() => {
|
|
3956
|
-
controller.abort();
|
|
3957
|
-
}, timeoutMs);
|
|
3958
|
-
try {
|
|
3959
|
-
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
|
|
3960
|
-
headers: {
|
|
3961
|
-
accept: "application/vnd.github+json",
|
|
3962
|
-
"user-agent": "dsh-web-update"
|
|
3963
|
-
},
|
|
3964
|
-
signal: controller.signal
|
|
3965
|
-
}));
|
|
3966
|
-
if (!response.ok) return void 0;
|
|
3967
|
-
const body = await response.json();
|
|
3968
|
-
if (typeof body !== "object" || body === null) return void 0;
|
|
3969
|
-
const text = body.body;
|
|
3970
|
-
return typeof text === "string" ? parseReleaseNotesBody(version, text) : void 0;
|
|
3971
|
-
} catch {
|
|
3972
|
-
return;
|
|
3973
|
-
} finally {
|
|
3974
|
-
clearTimeout(timer);
|
|
3975
|
-
}
|
|
3976
|
-
}
|
|
3977
|
-
/**
|
|
3978
|
-
* Sentinel for an installed version that could not be read (missing manifest
|
|
3979
|
-
* or resolve failure). It is a real-looking version so `checkUpdates` can
|
|
3980
|
-
* render the affected row, but the verified-update comparison must never
|
|
3981
|
-
* treat it as evidence — a read failure is not a version that "moved".
|
|
3982
|
-
*/
|
|
3983
|
-
const VERSION_UNKNOWN = "0.0.0";
|
|
3984
|
-
/** The resolved current version of one family package (probe failure tolerated). */
|
|
3985
|
-
function readInstalledVersion(resolve, name, profileDir) {
|
|
3986
|
-
try {
|
|
3987
|
-
const path = resolve(name + "/package.json");
|
|
3988
|
-
const version = path === void 0 ? void 0 : readManifest(path)?.version;
|
|
3989
|
-
if (typeof version === "string") return version;
|
|
3990
|
-
} catch {}
|
|
3991
|
-
if (profileDir === void 0 || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN;
|
|
3992
|
-
const version = readManifest(join(profileDir, "node_modules", ...name.split("/"), "package.json"))?.version;
|
|
3993
|
-
return typeof version === "string" ? version : VERSION_UNKNOWN;
|
|
3994
|
-
}
|
|
3995
|
-
/**
|
|
3996
|
-
* Build the update status: locate the anchor, detect the install mode, and
|
|
3997
|
-
* compare every family package against the npm registry.
|
|
3998
|
-
* @param deps - manifest resolution + registry probe seams.
|
|
3999
|
-
* @returns the status snapshot.
|
|
4000
|
-
*/
|
|
4001
|
-
async function checkUpdates(deps) {
|
|
4002
|
-
const manifestPath = deps.anchorManifestPath;
|
|
4003
|
-
if (manifestPath === void 0) return {
|
|
4004
|
-
mode: "missing",
|
|
4005
|
-
packages: [],
|
|
4006
|
-
outdated: false
|
|
4007
|
-
};
|
|
4008
|
-
const manifest = readManifest(manifestPath);
|
|
4009
|
-
if (manifest === void 0) return {
|
|
4010
|
-
mode: "missing",
|
|
4011
|
-
packages: [],
|
|
4012
|
-
outdated: false
|
|
4013
|
-
};
|
|
4014
|
-
const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
|
|
4015
|
-
if (anchor === void 0) return {
|
|
4016
|
-
mode: "missing",
|
|
4017
|
-
packages: [],
|
|
4018
|
-
outdated: false
|
|
4019
|
-
};
|
|
4020
|
-
const profile = findProfile(manifestPath);
|
|
4021
|
-
const profileManifest = profile === void 0 ? void 0 : readManifest(join(profile.dir, "package.json"));
|
|
4022
|
-
const linked = profile === void 0 || isLinkedSpec((profileManifest?.dependencies)?.[anchor]) || hasLinkedFamilyOverride(manifest, profileManifest);
|
|
4023
|
-
if (profile === void 0) return {
|
|
4024
|
-
mode: "link",
|
|
4025
|
-
packages: [],
|
|
4026
|
-
outdated: false
|
|
4027
|
-
};
|
|
4028
|
-
const names = familyUpdatePackages(anchor, manifest, profileManifest);
|
|
4029
|
-
const latestList = await probeLatestVersions(names, deps.fetchLatest);
|
|
4030
|
-
const packages = [];
|
|
4031
|
-
let probeFailures = 0;
|
|
4032
|
-
names.forEach((name, index) => {
|
|
4033
|
-
const latest = latestList[index];
|
|
4034
|
-
if (latest === void 0) probeFailures++;
|
|
4035
|
-
const current = readInstalledVersion(deps.resolve, name, profile.dir);
|
|
4036
|
-
packages.push({
|
|
4037
|
-
name,
|
|
4038
|
-
current,
|
|
4039
|
-
latest,
|
|
4040
|
-
outdated: latest !== void 0 && latest !== current && compareVersions(latest, current) > 0
|
|
4041
|
-
});
|
|
4042
|
-
});
|
|
4043
|
-
const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
|
|
4044
|
-
const targetVersion = packages.find((packageStatus) => packageStatus.latest !== void 0)?.latest;
|
|
4045
|
-
const notes = targetVersion !== void 0 && deps.fetchReleaseNotes !== void 0 ? await deps.fetchReleaseNotes(targetVersion).catch(() => void 0) : void 0;
|
|
4046
|
-
return {
|
|
4047
|
-
mode: linked ? "link" : "npm",
|
|
4048
|
-
profileName: profile.name,
|
|
4049
|
-
anchor,
|
|
4050
|
-
packages,
|
|
4051
|
-
outdated: packages.some((packageStatus) => packageStatus.outdated),
|
|
4052
|
-
...notes !== void 0 ? { notes } : {},
|
|
4053
|
-
...error !== void 0 ? { error } : {}
|
|
4054
|
-
};
|
|
4055
|
-
}
|
|
4056
|
-
/** Cap on captured pnpm output (keeps error payloads bounded). */
|
|
4057
|
-
const OUTPUT_CAP = 16 * 1024;
|
|
4058
|
-
/**
|
|
4059
|
-
* Characters a package name may contain before it is handed to the update
|
|
4060
|
-
* command line. Deliberately looser than the strict family shape (the tests
|
|
4061
|
-
* and future callers use short names) but it admits no whitespace, quotes,
|
|
4062
|
-
* shell metacharacters, or path separators beyond the scope slash.
|
|
4063
|
-
*/
|
|
4064
|
-
const SHELL_SAFE_PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
4065
|
-
/**
|
|
4066
|
-
* Windows cmd command-not-found stderr. With shell:true a missing shim
|
|
4067
|
-
* exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
|
|
4068
|
-
* detects this message instead of the spawn error event. Supports both English
|
|
4069
|
-
* and localized (e.g. Chinese) cmd error messages.
|
|
4070
|
-
*/
|
|
4071
|
-
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
|
|
4072
|
-
/**
|
|
4073
|
-
* Fold one decoded candidate output into the running total under the display
|
|
4074
|
-
* cap (the tail carries the failure reason).
|
|
4075
|
-
* @param output - the accumulated text.
|
|
4076
|
-
* @param text - the candidate's decoded text.
|
|
4077
|
-
*/
|
|
4078
|
-
function capOutput(output, text) {
|
|
4079
|
-
const combined = output + text;
|
|
4080
|
-
return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
|
|
4081
|
-
}
|
|
4082
|
-
/**
|
|
4083
|
-
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
4084
|
-
* silently skips same-day releases — `pnpm update --latest` then exits 0
|
|
4085
|
-
* without moving anything and the post-run verification would misreport a
|
|
4086
|
-
* stale no-op. The update here is explicitly user-initiated and the panel
|
|
4087
|
-
* shows exactly which versions are being installed, so the gate only adds a
|
|
4088
|
-
* confusing silent no-op; override it per-invocation instead of asking every
|
|
4089
|
-
* user to edit the profile's pnpm-workspace.yaml.
|
|
4090
|
-
*/
|
|
4091
|
-
const MIN_RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
|
|
4092
|
-
/**
|
|
4093
|
-
* Run the update inside the profile directory. Tries pnpm first, falls back
|
|
4094
|
-
* to corepack and then npx when the previous command is missing (ENOENT);
|
|
4095
|
-
* all candidates share one hard timeout and keep accumulating output.
|
|
4096
|
-
* @param deps - profile dir, package list, and spawn/timeout seams.
|
|
4097
|
-
* @returns the outcome with captured output.
|
|
4098
|
-
*/
|
|
4099
|
-
function runUpdate(deps) {
|
|
4100
|
-
return new Promise((resolve) => {
|
|
4101
|
-
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
4102
|
-
const platform = deps.platform ?? process.platform;
|
|
4103
|
-
const packages = deps.packages.filter((name) => SHELL_SAFE_PACKAGE_RE.test(name));
|
|
4104
|
-
const spawnOptions = {
|
|
4105
|
-
cwd: deps.profileDir,
|
|
4106
|
-
stdio: [
|
|
4107
|
-
"ignore",
|
|
4108
|
-
"pipe",
|
|
4109
|
-
"pipe"
|
|
4110
|
-
],
|
|
4111
|
-
...platform === "win32" ? { shell: true } : { detached: true }
|
|
4112
|
-
};
|
|
4113
|
-
const killImpl = deps.killImpl ?? process.kill;
|
|
4114
|
-
const candidates = [
|
|
4115
|
-
{
|
|
4116
|
-
command: "pnpm",
|
|
4117
|
-
args: [
|
|
4118
|
-
"update",
|
|
4119
|
-
"--latest",
|
|
4120
|
-
MIN_RELEASE_AGE_OVERRIDE,
|
|
4121
|
-
...packages
|
|
4122
|
-
]
|
|
4123
|
-
},
|
|
4124
|
-
{
|
|
4125
|
-
command: "corepack",
|
|
4126
|
-
args: [
|
|
4127
|
-
"pnpm",
|
|
4128
|
-
"update",
|
|
4129
|
-
"--latest",
|
|
4130
|
-
MIN_RELEASE_AGE_OVERRIDE,
|
|
4131
|
-
...packages
|
|
4132
|
-
]
|
|
4133
|
-
},
|
|
4134
|
-
{
|
|
4135
|
-
command: "npx",
|
|
4136
|
-
args: [
|
|
4137
|
-
"--yes",
|
|
4138
|
-
"pnpm",
|
|
4139
|
-
"update",
|
|
4140
|
-
"--latest",
|
|
4141
|
-
MIN_RELEASE_AGE_OVERRIDE,
|
|
4142
|
-
...packages
|
|
4143
|
-
]
|
|
4144
|
-
}
|
|
4145
|
-
];
|
|
4146
|
-
let output = "";
|
|
4147
|
-
let capture = createOutputCapture(OUTPUT_CAP);
|
|
4148
|
-
let candidateFolded = false;
|
|
4149
|
-
const append = (chunk) => {
|
|
4150
|
-
capture.push(chunk);
|
|
4151
|
-
};
|
|
4152
|
-
/** This candidate's decoded text (idempotent). */
|
|
4153
|
-
const candidateText = () => capture.read();
|
|
4154
|
-
/** Fold this candidate's text into the running output exactly once. */
|
|
4155
|
-
const foldCandidate = () => {
|
|
4156
|
-
if (candidateFolded) return;
|
|
4157
|
-
candidateFolded = true;
|
|
4158
|
-
output = capOutput(output, capture.read());
|
|
4159
|
-
};
|
|
4160
|
-
let currentChild;
|
|
4161
|
-
let finished = false;
|
|
4162
|
-
const finish = (result) => {
|
|
4163
|
-
if (finished) return;
|
|
4164
|
-
finished = true;
|
|
4165
|
-
clearTimeout(timer);
|
|
4166
|
-
resolve(result);
|
|
4167
|
-
};
|
|
4168
|
-
const timer = setTimeout(() => {
|
|
4169
|
-
if (finished) return;
|
|
4170
|
-
if (platform === "win32") {
|
|
4171
|
-
const pid = currentChild?.pid;
|
|
4172
|
-
if (pid !== void 0 && pid > 0) try {
|
|
4173
|
-
spawnImpl("taskkill", [
|
|
4174
|
-
"/pid",
|
|
4175
|
-
String(pid),
|
|
4176
|
-
"/t",
|
|
4177
|
-
"/f"
|
|
4178
|
-
], { stdio: "ignore" }).on?.("error", () => {});
|
|
4179
|
-
} catch {}
|
|
4180
|
-
} else {
|
|
4181
|
-
const pid = currentChild?.pid;
|
|
4182
|
-
if (pid !== void 0 && pid > 0) try {
|
|
4183
|
-
killImpl(-pid, "SIGTERM");
|
|
4184
|
-
} catch {
|
|
4185
|
-
try {
|
|
4186
|
-
currentChild?.kill("SIGTERM");
|
|
4187
|
-
} catch {}
|
|
4188
|
-
}
|
|
4189
|
-
else currentChild?.kill("SIGTERM");
|
|
4190
|
-
}
|
|
4191
|
-
foldCandidate();
|
|
4192
|
-
finish({
|
|
4193
|
-
ok: false,
|
|
4194
|
-
exitCode: null,
|
|
4195
|
-
output,
|
|
4196
|
-
error: "update timed out; install process killed",
|
|
4197
|
-
errorCode: "timeout"
|
|
4198
|
-
});
|
|
4199
|
-
}, deps.timeoutMs ?? 10 * 6e4);
|
|
4200
|
-
const runCandidate = (index) => {
|
|
4201
|
-
if (finished) return;
|
|
4202
|
-
if (index >= candidates.length) {
|
|
4203
|
-
finish({
|
|
4204
|
-
ok: false,
|
|
4205
|
-
exitCode: null,
|
|
4206
|
-
output,
|
|
4207
|
-
error: "pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app",
|
|
4208
|
-
errorCode: "pnpm-missing"
|
|
4209
|
-
});
|
|
4210
|
-
return;
|
|
4211
|
-
}
|
|
4212
|
-
const candidate = candidates[index];
|
|
4213
|
-
capture = createOutputCapture(OUTPUT_CAP);
|
|
4214
|
-
candidateFolded = false;
|
|
4215
|
-
const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
|
|
4216
|
-
currentChild = child;
|
|
4217
|
-
let settled = false;
|
|
4218
|
-
const once = (fn) => {
|
|
4219
|
-
if (settled) return;
|
|
4220
|
-
settled = true;
|
|
4221
|
-
fn();
|
|
4222
|
-
};
|
|
4223
|
-
child.stdout?.on("data", append);
|
|
4224
|
-
child.stderr?.on("data", append);
|
|
4225
|
-
child.on("error", (error) => {
|
|
4226
|
-
once(() => {
|
|
4227
|
-
foldCandidate();
|
|
4228
|
-
if (error.code === "ENOENT") runCandidate(index + 1);
|
|
4229
|
-
else finish({
|
|
4230
|
-
ok: false,
|
|
4231
|
-
exitCode: null,
|
|
4232
|
-
output,
|
|
4233
|
-
error: error.message,
|
|
4234
|
-
errorCode: void 0
|
|
4235
|
-
});
|
|
4236
|
-
});
|
|
4237
|
-
});
|
|
4238
|
-
child.on("close", (code) => {
|
|
4239
|
-
if (settled || finished) return;
|
|
4240
|
-
if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(candidateText()))) {
|
|
4241
|
-
foldCandidate();
|
|
4242
|
-
runCandidate(index + 1);
|
|
4243
|
-
return;
|
|
4244
|
-
}
|
|
4245
|
-
settled = true;
|
|
4246
|
-
foldCandidate();
|
|
4247
|
-
finish({
|
|
4248
|
-
ok: code === 0,
|
|
4249
|
-
exitCode: code,
|
|
4250
|
-
output,
|
|
4251
|
-
error: code === 0 ? void 0 : `${candidate.command} exited with code ` + String(code),
|
|
4252
|
-
...code === 0 ? {} : { errorCode: "pnpm-failed" }
|
|
4253
|
-
});
|
|
4254
|
-
});
|
|
4255
|
-
};
|
|
4256
|
-
runCandidate(0);
|
|
4257
|
-
});
|
|
4258
|
-
}
|
|
4259
|
-
/**
|
|
4260
|
-
* Run the update, then verify the installed versions actually moved. pnpm
|
|
4261
|
-
* exits 0 even when it silently kept the installed versions — runUpdate
|
|
4262
|
-
* overrides pnpm 11's `minimumReleaseAge` gate (default 24 h) with
|
|
4263
|
-
* `--config.minimumReleaseAge=0`, but an older pnpm without the override or
|
|
4264
|
-
* another silent no-op can still keep versions in place — so a green exit
|
|
4265
|
-
* alone must not report a misleading "update complete". Re-read the
|
|
4266
|
-
* installed versions afterwards and surface a `stale` failure (with the
|
|
4267
|
-
* captured pnpm output) when nothing moved, so the panel can tell the user
|
|
4268
|
-
* how to unblock the gate instead of claiming success.
|
|
4269
|
-
*
|
|
4270
|
-
* The stale decision anchors on the pre-run installed versions, not on the
|
|
4271
|
-
* registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
|
|
4272
|
-
* latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
|
|
4273
|
-
* path is re-resolved rather than reused from boot time: pnpm removes the old
|
|
4274
|
-
* version's .pnpm directory on update, so a boot-time captured path would
|
|
4275
|
-
* fail to read and collapse a successful update into a bogus 'missing'.
|
|
4276
|
-
*
|
|
4277
|
-
* A green exit whose verification has nothing to compare (registry probe
|
|
4278
|
-
* outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
|
|
4279
|
-
* must never read as success. A green exit where no package moved but the
|
|
4280
|
-
* post-run check could not prove the install is fully current (a partial
|
|
4281
|
-
* probe failure hides whether a gate kept something back) is also
|
|
4282
|
-
* `verify-failed`: it must not collapse into a success either.
|
|
4283
|
-
* @param deps - the run and check seams.
|
|
4284
|
-
* @returns the run result; `stale` when exit 0 left every version in place,
|
|
4285
|
-
* `verify-failed` when the post-run check could not verify anything.
|
|
4286
|
-
*/
|
|
4287
|
-
async function runUpdateVerified(deps) {
|
|
4288
|
-
const before = /* @__PURE__ */ new Map();
|
|
4289
|
-
for (const name of deps.run.packages) {
|
|
4290
|
-
const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
|
|
4291
|
-
if (version !== VERSION_UNKNOWN) before.set(name, version);
|
|
4292
|
-
}
|
|
4293
|
-
const result = await runUpdate(deps.run);
|
|
4294
|
-
if (!result.ok) return result;
|
|
4295
|
-
const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath;
|
|
4296
|
-
const status = await checkUpdates({
|
|
4297
|
-
...deps.check,
|
|
4298
|
-
anchorManifestPath
|
|
4299
|
-
});
|
|
4300
|
-
if (status.error !== void 0 || status.mode !== "npm") return {
|
|
4301
|
-
...result,
|
|
4302
|
-
ok: false,
|
|
4303
|
-
errorCode: "verify-failed",
|
|
4304
|
-
error: "pnpm exited 0 but the post-run version check could not verify the install"
|
|
4305
|
-
};
|
|
4306
|
-
if (status.packages.some((packageStatus) => {
|
|
4307
|
-
const beforeVersion = before.get(packageStatus.name);
|
|
4308
|
-
if (beforeVersion === void 0 || packageStatus.current === VERSION_UNKNOWN) return false;
|
|
4309
|
-
return packageStatus.current !== beforeVersion;
|
|
4310
|
-
})) return result;
|
|
4311
|
-
if (status.outdated) return {
|
|
4312
|
-
...result,
|
|
4313
|
-
ok: false,
|
|
4314
|
-
errorCode: "stale",
|
|
4315
|
-
error: "pnpm exited 0 but the installed versions did not change"
|
|
4316
|
-
};
|
|
4317
|
-
if (status.packages.some((packageStatus) => packageStatus.latest === void 0)) return {
|
|
4318
|
-
...result,
|
|
4319
|
-
ok: false,
|
|
4320
|
-
errorCode: "verify-failed",
|
|
4321
|
-
error: "pnpm exited 0 but the post-run version check could not verify the install"
|
|
4322
|
-
};
|
|
4323
|
-
return result;
|
|
4324
|
-
}
|
|
4325
|
-
//#endregion
|
|
4326
|
-
//#region src/update-routes.ts
|
|
4327
|
-
/** Route paths (exact matches under /api). */
|
|
4328
|
-
const UPDATE_PATHS = {
|
|
4329
|
-
status: "/api/update/status",
|
|
4330
|
-
run: "/api/update/run"
|
|
4331
|
-
};
|
|
4332
|
-
/**
|
|
4333
|
-
* Build the /api/update route family.
|
|
4334
|
-
* @param deps - fence + check/run seams.
|
|
4335
|
-
* @returns the exact routes to register on webServer.
|
|
4336
|
-
*/
|
|
4337
|
-
function makeUpdateRoutes(deps) {
|
|
4338
|
-
const handleStatus = async (req, res) => {
|
|
4339
|
-
if (req.method !== "GET") {
|
|
4340
|
-
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
4341
|
-
res.end("method not allowed");
|
|
4342
|
-
return;
|
|
4343
|
-
}
|
|
4344
|
-
if (!deps.fence(req)) {
|
|
4345
|
-
writeJson(res, 403, {
|
|
4346
|
-
ok: false,
|
|
4347
|
-
code: "forbidden"
|
|
4348
|
-
});
|
|
4349
|
-
return;
|
|
4350
|
-
}
|
|
4351
|
-
writeJson(res, 200, await deps.check());
|
|
4352
|
-
};
|
|
4353
|
-
const handleRun = async (req, res) => {
|
|
4354
|
-
if (req.method !== "POST") {
|
|
4355
|
-
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
4356
|
-
res.end("method not allowed");
|
|
4357
|
-
return;
|
|
4358
|
-
}
|
|
4359
|
-
if (!deps.fence(req)) {
|
|
4360
|
-
writeJson(res, 403, {
|
|
4361
|
-
ok: false,
|
|
4362
|
-
code: "forbidden"
|
|
4363
|
-
});
|
|
4364
|
-
return;
|
|
4365
|
-
}
|
|
4366
|
-
writeJson(res, 200, await deps.run());
|
|
4367
|
-
};
|
|
4368
|
-
return [{
|
|
4369
|
-
kind: "exact",
|
|
4370
|
-
path: UPDATE_PATHS.status,
|
|
4371
|
-
handler: handleStatus
|
|
4372
|
-
}, {
|
|
4373
|
-
kind: "exact",
|
|
4374
|
-
path: UPDATE_PATHS.run,
|
|
4375
|
-
handler: handleRun
|
|
4376
|
-
}];
|
|
4377
|
-
}
|
|
4378
|
-
//#endregion
|
|
4379
3800
|
//#region src/mount-once.ts
|
|
4380
3801
|
/**
|
|
4381
3802
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
@@ -4383,38 +3804,111 @@ function makeUpdateRoutes(deps) {
|
|
|
4383
3804
|
* the loader accepts a standalone install of the same package side by side;
|
|
4384
3805
|
* without this guard the second instance would still re-register the same
|
|
4385
3806
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
4386
|
-
* and fail the boot. mountOnce makes
|
|
4387
|
-
*
|
|
4388
|
-
* package name in the client module host).
|
|
3807
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
3808
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
3809
|
+
* deduped by package name in the client module host).
|
|
3810
|
+
*
|
|
3811
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
3812
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
3813
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
3814
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
3815
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
3816
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
3817
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
3818
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
3819
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
3820
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
3821
|
+
* restart recovered it).
|
|
3822
|
+
*
|
|
3823
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
3824
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
3825
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
3826
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
3827
|
+
* dedupes against it.
|
|
4389
3828
|
*
|
|
4390
3829
|
* The registry rides a global symbol so two module instances of the same
|
|
4391
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
4392
|
-
*
|
|
3830
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
3831
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
3832
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
3833
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
3834
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
3835
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
3836
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
3837
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
3838
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
3839
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
3840
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
3841
|
+
* mounted after it failed with "claims.get is not a function".
|
|
3842
|
+
*
|
|
3843
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
4393
3844
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
4394
3845
|
*/
|
|
3846
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
4395
3847
|
const MOUNTED = Symbol.for("dsh-web.mounted-plugins");
|
|
3848
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
3849
|
+
const WAITERS = Symbol.for("dsh-web.mounted-plugins.waiters");
|
|
3850
|
+
/**
|
|
3851
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
3852
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
3853
|
+
* family plugin down with it.
|
|
3854
|
+
* @returns the process-wide set of mounted package names.
|
|
3855
|
+
*/
|
|
4396
3856
|
function mountedSet() {
|
|
4397
3857
|
const registry = globalThis;
|
|
4398
|
-
|
|
3858
|
+
const existing = registry[MOUNTED];
|
|
3859
|
+
if (existing instanceof Set) return existing;
|
|
3860
|
+
const created = /* @__PURE__ */ new Set();
|
|
3861
|
+
registry[MOUNTED] = created;
|
|
3862
|
+
return created;
|
|
3863
|
+
}
|
|
3864
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
3865
|
+
function mountWaiters() {
|
|
3866
|
+
const registry = globalThis;
|
|
3867
|
+
return registry[WAITERS] ??= /* @__PURE__ */ new Map();
|
|
4399
3868
|
}
|
|
4400
3869
|
/**
|
|
4401
3870
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
4402
|
-
* The first mount registers normally and
|
|
4403
|
-
*
|
|
3871
|
+
* The first mount registers normally and releases the name when its fiber
|
|
3872
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
3873
|
+
* then runs, unless its own fiber disposes first.
|
|
4404
3874
|
* @param packageName - npm package identity shared by every install source.
|
|
4405
3875
|
* @param fn - the original plugin apply.
|
|
4406
3876
|
* @returns an apply of the same shape.
|
|
4407
3877
|
*/
|
|
4408
3878
|
function mountOnce(packageName, fn) {
|
|
4409
|
-
|
|
3879
|
+
const mount = (...args) => {
|
|
4410
3880
|
const mounted = mountedSet();
|
|
4411
|
-
|
|
3881
|
+
const ctx = args[0];
|
|
3882
|
+
if (mounted.has(packageName)) {
|
|
3883
|
+
const waiters = mountWaiters();
|
|
3884
|
+
const queue = waiters.get(packageName) ?? [];
|
|
3885
|
+
let alive = true;
|
|
3886
|
+
const pending = { run: () => {
|
|
3887
|
+
if (alive) mount(...args);
|
|
3888
|
+
} };
|
|
3889
|
+
ctx?.effect?.(() => () => {
|
|
3890
|
+
alive = false;
|
|
3891
|
+
const index = queue.indexOf(pending);
|
|
3892
|
+
if (index >= 0) queue.splice(index, 1);
|
|
3893
|
+
});
|
|
3894
|
+
queue.push(pending);
|
|
3895
|
+
waiters.set(packageName, queue);
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
4412
3898
|
mounted.add(packageName);
|
|
4413
|
-
|
|
3899
|
+
ctx?.effect?.(() => () => {
|
|
4414
3900
|
mounted.delete(packageName);
|
|
3901
|
+
const waiters = mountWaiters();
|
|
3902
|
+
const queue = waiters.get(packageName);
|
|
3903
|
+
if (queue === void 0) return;
|
|
3904
|
+
waiters.delete(packageName);
|
|
3905
|
+
for (const waiter of queue.splice(0)) queueMicrotask(() => {
|
|
3906
|
+
waiter.run();
|
|
3907
|
+
});
|
|
4415
3908
|
});
|
|
4416
3909
|
return fn(...args);
|
|
4417
|
-
}
|
|
3910
|
+
};
|
|
3911
|
+
return mount;
|
|
4418
3912
|
}
|
|
4419
3913
|
//#endregion
|
|
4420
3914
|
//#region src/remote-channel-boot.ts
|
|
@@ -4537,31 +4031,64 @@ const inject = [
|
|
|
4537
4031
|
* Host package.
|
|
4538
4032
|
*/
|
|
4539
4033
|
const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
|
|
4034
|
+
/**
|
|
4035
|
+
* Plugin config schema. Under the 0.1.7 settings model this schema IS the
|
|
4036
|
+
* entry's settings page: the Host derives one form per profile entry from it
|
|
4037
|
+
* and serves that form only when at least one field is `volatile()`. The marker
|
|
4038
|
+
* is also what admits a write and what keeps the edit on the live path — the
|
|
4039
|
+
* Loader commits the new value into the field's reference and announces
|
|
4040
|
+
* `loader/volatile-update` on this fiber instead of remounting the row, so the
|
|
4041
|
+
* pairing service, its device sessions, the tunnel and the route registrations
|
|
4042
|
+
* survive a settings save (see {@link applyImpl}'s sync).
|
|
4043
|
+
*
|
|
4044
|
+
* The schema is left to inference rather than annotated with `z<Config>`: a
|
|
4045
|
+
* volatile field's parsed output is a live reference while its accepted input
|
|
4046
|
+
* stays the plain value, so the two sides no longer share one shape and the
|
|
4047
|
+
* annotation would reject the schema the Host must be given.
|
|
4048
|
+
*
|
|
4049
|
+
* The deployment-level fields (`trustedHosts`, `devicesFile`, `profile`) are
|
|
4050
|
+
* deliberately NOT volatile: they belong in the profile patch, so the form
|
|
4051
|
+
* leaves them to the operator instead of offering a card control that a
|
|
4052
|
+
* document write could not honor.
|
|
4053
|
+
*/
|
|
4540
4054
|
const Config = z.object({
|
|
4541
|
-
tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
|
|
4542
|
-
offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
|
|
4543
|
-
maxDevices: z.number().step(1).min(1).max(64).default(4),
|
|
4544
|
-
idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS),
|
|
4545
|
-
cookieName: z.string().min(1).default("dsh_pair"),
|
|
4546
|
-
requirePairingForLan: z.boolean().default(true),
|
|
4547
|
-
publicBaseUrl: z.string(),
|
|
4055
|
+
tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4).volatile(),
|
|
4056
|
+
offlineAfterMs: z.number().step(1).min(5e3).default(25e3).volatile(),
|
|
4057
|
+
maxDevices: z.number().step(1).min(1).max(64).default(4).volatile(),
|
|
4058
|
+
idleExpireMs: z.number().step(1).min(6e4).default(DEFAULT_IDLE_EXPIRE_MS).volatile(),
|
|
4059
|
+
cookieName: z.string().min(1).default("dsh_pair").volatile(),
|
|
4060
|
+
requirePairingForLan: z.boolean().default(true).volatile(),
|
|
4061
|
+
publicBaseUrl: z.string().volatile(),
|
|
4548
4062
|
trustedHosts: z.array(z.string()),
|
|
4549
4063
|
devicesFile: z.string(),
|
|
4550
|
-
autoTunnel: z.boolean().default(false),
|
|
4551
|
-
tunnelToken: z.string().role("secret"),
|
|
4552
|
-
relay: z.boolean().default(true),
|
|
4553
|
-
lanBind: z.boolean(),
|
|
4064
|
+
autoTunnel: z.boolean().default(false).volatile(),
|
|
4065
|
+
tunnelToken: z.string().role("secret").volatile(),
|
|
4066
|
+
relay: z.boolean().default(true).volatile(),
|
|
4067
|
+
lanBind: z.boolean().volatile(),
|
|
4554
4068
|
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
4555
|
-
enabled: z.boolean().default(true)
|
|
4069
|
+
enabled: z.boolean().default(true).volatile()
|
|
4556
4070
|
});
|
|
4557
4071
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
4558
4072
|
const SWEEP_INTERVAL_MS = 1e4;
|
|
4073
|
+
/** Read one resolved config field, following the live reference the schema produces. */
|
|
4074
|
+
function readConfigField(field, fallback) {
|
|
4075
|
+
if (field === void 0) return fallback;
|
|
4076
|
+
if (typeof field === "object" && field !== null && typeof field.get === "function") {
|
|
4077
|
+
const value = field.get();
|
|
4078
|
+
return value === void 0 ? fallback : value;
|
|
4079
|
+
}
|
|
4080
|
+
return field;
|
|
4081
|
+
}
|
|
4559
4082
|
/**
|
|
4560
|
-
*
|
|
4561
|
-
*
|
|
4562
|
-
*
|
|
4083
|
+
* Read one optional resolved config field. A volatile reference is read at
|
|
4084
|
+
* call time, so an unset field stays `undefined` rather than falling back to a
|
|
4085
|
+
* schema default (the distinction the LAN toggle and the tunnel plan depend on).
|
|
4563
4086
|
*/
|
|
4564
|
-
|
|
4087
|
+
function readOptionalConfigField(field) {
|
|
4088
|
+
if (field === void 0) return void 0;
|
|
4089
|
+
if (typeof field === "object" && field !== null && typeof field.get === "function") return field.get();
|
|
4090
|
+
return field;
|
|
4091
|
+
}
|
|
4565
4092
|
/**
|
|
4566
4093
|
* The single mapping from resolved plugin config to the pairing service
|
|
4567
4094
|
* config. Both the constructed service and every later apply of this row
|
|
@@ -4596,10 +4123,23 @@ const DEFAULTS = {
|
|
|
4596
4123
|
tunnelToken: void 0,
|
|
4597
4124
|
relay: true,
|
|
4598
4125
|
lanBind: void 0,
|
|
4599
|
-
profile: process.env.DSH_PROFILE
|
|
4126
|
+
profile: resolveManagedProfile(void 0, void 0, process.env.DSH_PROFILE),
|
|
4600
4127
|
enabled: true
|
|
4601
4128
|
};
|
|
4602
4129
|
/**
|
|
4130
|
+
* The launched profile the Host publishes on its `profileContext` service
|
|
4131
|
+
* (`{ name, dir, patchPath, … }`). The Desktop client boots the "desktop"
|
|
4132
|
+
* profile without exporting DSH_PROFILE, so the environment alone resolves
|
|
4133
|
+
* the wrong profile there and the LAN bind toggle would edit a profile that
|
|
4134
|
+
* is not running.
|
|
4135
|
+
* @param ctx - host plugin context.
|
|
4136
|
+
* @returns the profile name when the Host publishes one; undefined otherwise.
|
|
4137
|
+
*/
|
|
4138
|
+
function launchedProfileName(ctx) {
|
|
4139
|
+
const fact = ctx.get("profileContext");
|
|
4140
|
+
return typeof fact?.name === "string" && fact.name.length > 0 ? fact.name : void 0;
|
|
4141
|
+
}
|
|
4142
|
+
/**
|
|
4603
4143
|
* Mount the pairing service, routes, gate listener, and presence sweep.
|
|
4604
4144
|
* @param ctx - host plugin context carrying webServer.
|
|
4605
4145
|
* @param config - resolved plugin config (schema defaults applied by the loader).
|
|
@@ -4607,25 +4147,24 @@ const DEFAULTS = {
|
|
|
4607
4147
|
const apply = mountOnce("@linxin666/dsh-remote-web-ui", applyImpl);
|
|
4608
4148
|
function applyImpl(ctx, config) {
|
|
4609
4149
|
const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || void 0;
|
|
4610
|
-
const
|
|
4611
|
-
tokenTtlMs: config?.tokenTtlMs
|
|
4612
|
-
offlineAfterMs: config?.offlineAfterMs
|
|
4613
|
-
maxDevices: config?.maxDevices
|
|
4614
|
-
idleExpireMs: config?.idleExpireMs
|
|
4615
|
-
cookieName: config?.cookieName
|
|
4616
|
-
requirePairingForLan: config?.requirePairingForLan
|
|
4617
|
-
publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
|
|
4618
|
-
trustedHosts: config?.trustedHosts,
|
|
4619
|
-
devicesFile: config?.devicesFile
|
|
4620
|
-
autoTunnel: config?.autoTunnel
|
|
4621
|
-
tunnelToken: config?.tunnelToken,
|
|
4622
|
-
relay: config?.relay
|
|
4623
|
-
lanBind: config?.lanBind,
|
|
4624
|
-
profile: config?.profile
|
|
4625
|
-
enabled: config?.enabled
|
|
4626
|
-
};
|
|
4627
|
-
const
|
|
4628
|
-
const service = new PairingService(pairingConfigOf(resolved));
|
|
4150
|
+
const resolve = () => ({
|
|
4151
|
+
tokenTtlMs: readConfigField(config?.tokenTtlMs, DEFAULTS.tokenTtlMs),
|
|
4152
|
+
offlineAfterMs: readConfigField(config?.offlineAfterMs, DEFAULTS.offlineAfterMs),
|
|
4153
|
+
maxDevices: readConfigField(config?.maxDevices, DEFAULTS.maxDevices),
|
|
4154
|
+
idleExpireMs: readConfigField(config?.idleExpireMs, DEFAULTS.idleExpireMs),
|
|
4155
|
+
cookieName: readConfigField(config?.cookieName, DEFAULTS.cookieName),
|
|
4156
|
+
requirePairingForLan: readConfigField(config?.requirePairingForLan, DEFAULTS.requirePairingForLan),
|
|
4157
|
+
publicBaseUrl: readOptionalConfigField(config?.publicBaseUrl) ?? envPublicBase,
|
|
4158
|
+
trustedHosts: readOptionalConfigField(config?.trustedHosts),
|
|
4159
|
+
devicesFile: readConfigField(config?.devicesFile, DEFAULTS.devicesFile),
|
|
4160
|
+
autoTunnel: readConfigField(config?.autoTunnel, DEFAULTS.autoTunnel),
|
|
4161
|
+
tunnelToken: readOptionalConfigField(config?.tunnelToken),
|
|
4162
|
+
relay: readConfigField(config?.relay, DEFAULTS.relay),
|
|
4163
|
+
lanBind: readOptionalConfigField(config?.lanBind),
|
|
4164
|
+
profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
|
|
4165
|
+
enabled: readConfigField(config?.enabled, DEFAULTS.enabled)
|
|
4166
|
+
});
|
|
4167
|
+
const service = new PairingService(pairingConfigOf(resolve()));
|
|
4629
4168
|
const tunnel = new TunnelManager();
|
|
4630
4169
|
let relayRegistrar;
|
|
4631
4170
|
/**
|
|
@@ -4666,7 +4205,7 @@ function applyImpl(ctx, config) {
|
|
|
4666
4205
|
relayAnnouncedFor = target;
|
|
4667
4206
|
registrar.announce(target);
|
|
4668
4207
|
};
|
|
4669
|
-
let tunnelMode =
|
|
4208
|
+
let tunnelMode = resolve().autoTunnel ? "quick" : "off";
|
|
4670
4209
|
tunnel.onPhase((info) => {
|
|
4671
4210
|
if (tunnelMode === "off") return;
|
|
4672
4211
|
if (info.phase === "running" && info.url !== void 0) {
|
|
@@ -4703,80 +4242,6 @@ function applyImpl(ctx, config) {
|
|
|
4703
4242
|
}
|
|
4704
4243
|
let disposeRoutes;
|
|
4705
4244
|
let disposeSweep;
|
|
4706
|
-
const requireFromHost = createRequire(import.meta.url);
|
|
4707
|
-
/** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
|
|
4708
|
-
const hostResolve = (specifier) => {
|
|
4709
|
-
try {
|
|
4710
|
-
return requireFromHost.resolve(specifier);
|
|
4711
|
-
} catch {
|
|
4712
|
-
return;
|
|
4713
|
-
}
|
|
4714
|
-
};
|
|
4715
|
-
const resolveAnchorPath = () => resolveAnchorManifest(hostResolve);
|
|
4716
|
-
const releaseNotesCache = /* @__PURE__ */ new Map();
|
|
4717
|
-
const fetchReleaseNotesCached = async (version) => {
|
|
4718
|
-
const cached = releaseNotesCache.get(version);
|
|
4719
|
-
if (cached !== void 0 && Date.now() - cached.at < 6e5) return cached.notes;
|
|
4720
|
-
const notes = await fetchGitHubReleaseNotes(version, fetch);
|
|
4721
|
-
releaseNotesCache.set(version, {
|
|
4722
|
-
at: Date.now(),
|
|
4723
|
-
notes
|
|
4724
|
-
});
|
|
4725
|
-
return notes;
|
|
4726
|
-
};
|
|
4727
|
-
let updateStatusCache;
|
|
4728
|
-
let updateStatusInFlight;
|
|
4729
|
-
const checkStatusCached = () => {
|
|
4730
|
-
const cached = updateStatusCache;
|
|
4731
|
-
if (cached !== void 0 && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value);
|
|
4732
|
-
if (updateStatusInFlight !== void 0) return updateStatusInFlight;
|
|
4733
|
-
updateStatusInFlight = checkUpdates({
|
|
4734
|
-
anchorManifestPath: resolveAnchorPath(),
|
|
4735
|
-
resolve: hostResolve,
|
|
4736
|
-
fetchLatest: (name) => fetchLatestVersion(name, fetch),
|
|
4737
|
-
fetchReleaseNotes: fetchReleaseNotesCached
|
|
4738
|
-
}).then((value) => {
|
|
4739
|
-
updateStatusCache = {
|
|
4740
|
-
at: Date.now(),
|
|
4741
|
-
value
|
|
4742
|
-
};
|
|
4743
|
-
return value;
|
|
4744
|
-
}).finally(() => {
|
|
4745
|
-
updateStatusInFlight = void 0;
|
|
4746
|
-
});
|
|
4747
|
-
return updateStatusInFlight;
|
|
4748
|
-
};
|
|
4749
|
-
const updateRoutes = makeUpdateRoutes({
|
|
4750
|
-
fence: (request) => isTrustedApiRequest(request, []),
|
|
4751
|
-
check: () => checkStatusCached(),
|
|
4752
|
-
run: async () => {
|
|
4753
|
-
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
|
|
4754
|
-
if ("error" in target) {
|
|
4755
|
-
const code = target.error;
|
|
4756
|
-
return {
|
|
4757
|
-
ok: false,
|
|
4758
|
-
exitCode: null,
|
|
4759
|
-
output: "",
|
|
4760
|
-
error: code === "not-found" ? "dsh-web aggregate not installed" : "local link install — update unavailable",
|
|
4761
|
-
errorCode: code
|
|
4762
|
-
};
|
|
4763
|
-
}
|
|
4764
|
-
const result = await runUpdateVerified({
|
|
4765
|
-
run: {
|
|
4766
|
-
profileDir: target.profileDir,
|
|
4767
|
-
packages: target.packages
|
|
4768
|
-
},
|
|
4769
|
-
check: {
|
|
4770
|
-
anchorManifestPath: resolveAnchorPath(),
|
|
4771
|
-
resolve: hostResolve,
|
|
4772
|
-
fetchLatest: (name) => fetchLatestVersion(name, fetch),
|
|
4773
|
-
fetchReleaseNotes: fetchReleaseNotesCached
|
|
4774
|
-
}
|
|
4775
|
-
});
|
|
4776
|
-
updateStatusCache = void 0;
|
|
4777
|
-
return result;
|
|
4778
|
-
}
|
|
4779
|
-
});
|
|
4780
4245
|
let lastKnownPort;
|
|
4781
4246
|
let lastFirewallApplied;
|
|
4782
4247
|
const lanBindStatus = () => {
|
|
@@ -4837,36 +4302,32 @@ function applyImpl(ctx, config) {
|
|
|
4837
4302
|
return;
|
|
4838
4303
|
}
|
|
4839
4304
|
};
|
|
4840
|
-
const routes = [
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4305
|
+
const routes = [...makeRoutes({
|
|
4306
|
+
service,
|
|
4307
|
+
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
4308
|
+
lanBindStatus,
|
|
4309
|
+
indexDocument: fetchAppShell,
|
|
4310
|
+
trustedHosts: () => {
|
|
4311
|
+
const list = [];
|
|
4312
|
+
const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS;
|
|
4313
|
+
if (typeof envHosts === "string" && envHosts.trim() !== "") for (const item of envHosts.split(",")) {
|
|
4314
|
+
const trimmed = item.trim();
|
|
4315
|
+
if (trimmed !== "") list.push(trimmed);
|
|
4316
|
+
}
|
|
4317
|
+
const cfgHosts = resolve().trustedHosts;
|
|
4318
|
+
if (Array.isArray(cfgHosts)) {
|
|
4319
|
+
for (const item of cfgHosts) if (typeof item === "string") {
|
|
4850
4320
|
const trimmed = item.trim();
|
|
4851
4321
|
if (trimmed !== "") list.push(trimmed);
|
|
4852
4322
|
}
|
|
4853
|
-
const cfgHosts = resolve().trustedHosts;
|
|
4854
|
-
if (Array.isArray(cfgHosts)) {
|
|
4855
|
-
for (const item of cfgHosts) if (typeof item === "string") {
|
|
4856
|
-
const trimmed = item.trim();
|
|
4857
|
-
if (trimmed !== "") list.push(trimmed);
|
|
4858
|
-
}
|
|
4859
|
-
}
|
|
4860
|
-
return list;
|
|
4861
4323
|
}
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
|
|
4869
|
-
];
|
|
4324
|
+
return list;
|
|
4325
|
+
}
|
|
4326
|
+
}), ...makeRemoteApiRoutes({
|
|
4327
|
+
service,
|
|
4328
|
+
port: ctx.webServer.port,
|
|
4329
|
+
auth: innerAuth
|
|
4330
|
+
})];
|
|
4870
4331
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
4871
4332
|
service,
|
|
4872
4333
|
port: ctx.webServer.port,
|
|
@@ -4929,9 +4390,7 @@ function applyImpl(ctx, config) {
|
|
|
4929
4390
|
console.log(`remote-web-ui: the paired Web GUI is reachable on LAN at ${urls}`);
|
|
4930
4391
|
}
|
|
4931
4392
|
if (ctx.webServer.host === "0.0.0.0") console.warn("remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)");
|
|
4932
|
-
const
|
|
4933
|
-
const value = resolve();
|
|
4934
|
-
service.config = pairingConfigOf(value);
|
|
4393
|
+
const applyLanBindWork = (value) => {
|
|
4935
4394
|
if (value.lanBind !== void 0) {
|
|
4936
4395
|
const startup = ctx.get("webStartup");
|
|
4937
4396
|
const desiredHost = desiredBindHost(value.lanBind === true, startup?.host);
|
|
@@ -4957,6 +4416,27 @@ function applyImpl(ctx, config) {
|
|
|
4957
4416
|
else console.error("remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)");
|
|
4958
4417
|
}
|
|
4959
4418
|
}
|
|
4419
|
+
};
|
|
4420
|
+
let lanBindWorkPending = false;
|
|
4421
|
+
const scheduleLanBindWork = () => {
|
|
4422
|
+
if (lanBindWorkPending) return;
|
|
4423
|
+
lanBindWorkPending = true;
|
|
4424
|
+
setImmediate(() => {
|
|
4425
|
+
lanBindWorkPending = false;
|
|
4426
|
+
try {
|
|
4427
|
+
applyLanBindWork(resolve());
|
|
4428
|
+
} catch (error) {
|
|
4429
|
+
console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
4430
|
+
}
|
|
4431
|
+
});
|
|
4432
|
+
};
|
|
4433
|
+
ctx.effect(() => () => {
|
|
4434
|
+
lanBindWorkPending = false;
|
|
4435
|
+
}, "remote-web-ui: lan-bind work");
|
|
4436
|
+
const sync = () => {
|
|
4437
|
+
const value = resolve();
|
|
4438
|
+
service.config = pairingConfigOf(value);
|
|
4439
|
+
scheduleLanBindWork();
|
|
4960
4440
|
const plan = tunnelPlanOf(value, ctx.webServer.port);
|
|
4961
4441
|
tunnelMode = plan.mode;
|
|
4962
4442
|
publicBase.setMode(plan.mode);
|
|
@@ -5043,6 +4523,9 @@ function applyImpl(ctx, config) {
|
|
|
5043
4523
|
text: REMOTE_CHANNEL_BOOT_SCRIPT
|
|
5044
4524
|
});
|
|
5045
4525
|
}), "remote-web-ui: remote channel boot patch");
|
|
4526
|
+
ctx.on("loader/volatile-update", () => {
|
|
4527
|
+
sync();
|
|
4528
|
+
});
|
|
5046
4529
|
sync();
|
|
5047
4530
|
}
|
|
5048
4531
|
//#endregion
|