@linxin666/dsh-remote-web-ui 0.3.24 → 0.4.1
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 +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +263 -129
- package/lib/client.js.map +1 -1
- package/lib/index.js +565 -256
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +47 -18
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +103 -22
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +23 -16
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +16 -14
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -26
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +61 -27
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +97 -20
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +24 -17
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -24
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
|
3
3
|
import { setInterval, setTimeout as setTimeout$1 } from "node:timers";
|
|
4
4
|
import z from "schemastery";
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { homedir, networkInterfaces } from "node:os";
|
|
8
8
|
import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
|
|
9
9
|
import { Service } from "@deepseek-ai/cordis";
|
|
@@ -23,9 +23,9 @@ import { Tunnel, bin, install } from "cloudflared";
|
|
|
23
23
|
* Security invariants:
|
|
24
24
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
25
25
|
* immediately invalidates the previous link.
|
|
26
|
-
* - A token
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* - A token is a bearer credential for its whole window: it is refused only
|
|
27
|
+
* when unknown, expired, or after `stop()`, and the same link may pair
|
|
28
|
+
* repeatedly within that window (each accept mints a fresh device session).
|
|
29
29
|
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
30
30
|
* in-app browser to the system browser), and the later context must be
|
|
31
31
|
* able to complete its own pairing from the same link.
|
|
@@ -75,6 +75,7 @@ const defaultClock = {
|
|
|
75
75
|
var PairingService = class {
|
|
76
76
|
config;
|
|
77
77
|
clock;
|
|
78
|
+
fs;
|
|
78
79
|
tokens = /* @__PURE__ */ new Map();
|
|
79
80
|
devices = /* @__PURE__ */ new Map();
|
|
80
81
|
listeners = /* @__PURE__ */ new Set();
|
|
@@ -97,9 +98,10 @@ var PairingService = class {
|
|
|
97
98
|
* the current one.
|
|
98
99
|
* @param clock - clock/entropy source (injectable for tests).
|
|
99
100
|
*/
|
|
100
|
-
constructor(config, clock = defaultClock) {
|
|
101
|
+
constructor(config, clock = defaultClock, fs = {}) {
|
|
101
102
|
this.config = config;
|
|
102
103
|
this.clock = clock;
|
|
104
|
+
this.fs = fs;
|
|
103
105
|
this.loadPersisted();
|
|
104
106
|
}
|
|
105
107
|
/**
|
|
@@ -126,7 +128,7 @@ var PairingService = class {
|
|
|
126
128
|
});
|
|
127
129
|
}
|
|
128
130
|
this.clampToMaxDevices();
|
|
129
|
-
if (this.evictIdle()) this.
|
|
131
|
+
if (this.evictIdle()) this.persistRevocation();
|
|
130
132
|
} catch {}
|
|
131
133
|
}
|
|
132
134
|
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
@@ -159,7 +161,7 @@ var PairingService = class {
|
|
|
159
161
|
*/
|
|
160
162
|
persist() {
|
|
161
163
|
const file = this.config.devicesFile;
|
|
162
|
-
if (file === void 0) return;
|
|
164
|
+
if (file === void 0) return true;
|
|
163
165
|
try {
|
|
164
166
|
mkdirSync(dirname(file), { recursive: true });
|
|
165
167
|
const temp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
@@ -168,8 +170,31 @@ var PairingService = class {
|
|
|
168
170
|
writeFileSync(temp, JSON.stringify(payload), { mode: 384 });
|
|
169
171
|
renameSync(temp, file);
|
|
170
172
|
this.dirty = false;
|
|
173
|
+
return true;
|
|
171
174
|
} catch (error) {
|
|
172
175
|
console.error("remote-web-ui: failed to persist paired devices", error);
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Persist a table change that REVOKES access (stop, per-device revoke, idle
|
|
181
|
+
* eviction). A failed write would leave the revoked session on disk, and
|
|
182
|
+
* {@link loadPersisted} restores that file verbatim on the next start — the
|
|
183
|
+
* device's still-valid cookie would authorize again after an explicit
|
|
184
|
+
* revocation. When the write cannot be made durable, the stale store is
|
|
185
|
+
* removed instead (a missing file already loads as an empty table), so the
|
|
186
|
+
* failure costs a re-pair rather than silently undoing the revocation.
|
|
187
|
+
*/
|
|
188
|
+
persistRevocation() {
|
|
189
|
+
if (this.persist()) return;
|
|
190
|
+
const file = this.config.devicesFile;
|
|
191
|
+
if (file === void 0) return;
|
|
192
|
+
try {
|
|
193
|
+
if (this.fs.removeFile !== void 0) this.fs.removeFile(file);
|
|
194
|
+
else rmSync(file, { force: true });
|
|
195
|
+
console.error("remote-web-ui: the device revocation could not be persisted; removed the stale device store so a restart cannot restore it");
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error("remote-web-ui: the device revocation could not be persisted and the stale device store could not be removed", error);
|
|
173
198
|
}
|
|
174
199
|
}
|
|
175
200
|
/** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
|
|
@@ -193,9 +218,14 @@ var PairingService = class {
|
|
|
193
218
|
get publicBaseUrl() {
|
|
194
219
|
return this.publicBase;
|
|
195
220
|
}
|
|
196
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Set or clear the public base URL (a tunnel in front of this server). The
|
|
223
|
+
* value is canonicalized to its origin: a trailing slash (what a browser
|
|
224
|
+
* address-bar copy produces) or a path would mint a dead `//pair-accept`
|
|
225
|
+
* link, which WHATWG resolves as an authority rather than a path.
|
|
226
|
+
*/
|
|
197
227
|
setPublicBaseUrl(url) {
|
|
198
|
-
this.publicBase = url;
|
|
228
|
+
this.publicBase = url === void 0 ? void 0 : canonicalBaseUrl(url);
|
|
199
229
|
this.notify();
|
|
200
230
|
}
|
|
201
231
|
/** Set or clear the auto-tunnel status frame (undefined when the feature is off). */
|
|
@@ -237,7 +267,6 @@ var PairingService = class {
|
|
|
237
267
|
id: `t${this.tokenSerial}`,
|
|
238
268
|
issuedAt: now,
|
|
239
269
|
expiresAt: now + this.config.tokenTtlMs,
|
|
240
|
-
consumed: false,
|
|
241
270
|
...workspaceId !== void 0 ? { workspaceId } : {},
|
|
242
271
|
...address !== void 0 ? { address } : {}
|
|
243
272
|
});
|
|
@@ -248,9 +277,11 @@ var PairingService = class {
|
|
|
248
277
|
};
|
|
249
278
|
}
|
|
250
279
|
/**
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
280
|
+
* Redeem a token and bind a device session. The token is a bearer
|
|
281
|
+
* credential for its whole window, not a single-use nonce: it is refused
|
|
282
|
+
* only when unknown, past its expiry, or after stop(), and every successful
|
|
283
|
+
* call — including a repeat within the same window — mints a fresh device
|
|
284
|
+
* session (see the module doc for the mobile cookie-context rationale).
|
|
254
285
|
* @param token - the token secret from the QR link.
|
|
255
286
|
* @param userAgent - optional User-Agent header captured at accept.
|
|
256
287
|
* @returns the new device id, or a refusal code.
|
|
@@ -261,7 +292,6 @@ var PairingService = class {
|
|
|
261
292
|
ok: false,
|
|
262
293
|
code: "invalid"
|
|
263
294
|
};
|
|
264
|
-
record.consumed = true;
|
|
265
295
|
const deviceId = this.clock.randomToken();
|
|
266
296
|
const now = this.clock.now();
|
|
267
297
|
if (this.devices.size >= this.config.maxDevices) {
|
|
@@ -293,7 +323,7 @@ var PairingService = class {
|
|
|
293
323
|
stop() {
|
|
294
324
|
this.tokens.clear();
|
|
295
325
|
this.devices.clear();
|
|
296
|
-
this.
|
|
326
|
+
this.persistRevocation();
|
|
297
327
|
this.stopped = true;
|
|
298
328
|
this.notify();
|
|
299
329
|
}
|
|
@@ -306,7 +336,7 @@ var PairingService = class {
|
|
|
306
336
|
revoke(deviceId) {
|
|
307
337
|
if (this.stopped) return false;
|
|
308
338
|
if (!this.devices.delete(deviceId)) return false;
|
|
309
|
-
this.
|
|
339
|
+
this.persistRevocation();
|
|
310
340
|
this.notify();
|
|
311
341
|
return true;
|
|
312
342
|
}
|
|
@@ -341,7 +371,8 @@ var PairingService = class {
|
|
|
341
371
|
* actually changed.
|
|
342
372
|
*/
|
|
343
373
|
sweep() {
|
|
344
|
-
if (this.evictIdle()
|
|
374
|
+
if (this.evictIdle()) this.persistRevocation();
|
|
375
|
+
else if (this.dirty) this.persist();
|
|
345
376
|
this.notify();
|
|
346
377
|
}
|
|
347
378
|
/** The current snapshot (fresh object per call — stable between emits). */
|
|
@@ -399,7 +430,7 @@ var PairingService = class {
|
|
|
399
430
|
const limit = this.config.idleExpireMs ?? 2592e6;
|
|
400
431
|
if (this.clock.now() - session.lastSeenAt > limit) {
|
|
401
432
|
this.devices.delete(deviceId);
|
|
402
|
-
this.
|
|
433
|
+
this.persistRevocation();
|
|
403
434
|
this.notify();
|
|
404
435
|
return;
|
|
405
436
|
}
|
|
@@ -459,6 +490,19 @@ function relayEqual(a, b) {
|
|
|
459
490
|
function sameStrings(a, b) {
|
|
460
491
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
461
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* Canonical origin of a public base URL. An unparsable value is returned
|
|
495
|
+
* verbatim so the caller's malformed-base warning stays the single owner of
|
|
496
|
+
* that case.
|
|
497
|
+
* @param url - the configured public base.
|
|
498
|
+
*/
|
|
499
|
+
function canonicalBaseUrl(url) {
|
|
500
|
+
try {
|
|
501
|
+
return new URL(url).origin;
|
|
502
|
+
} catch {
|
|
503
|
+
return url;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
462
506
|
/** Strip control characters and cap the User-Agent stored with a session. */
|
|
463
507
|
function sanitizeUserAgent(raw) {
|
|
464
508
|
if (raw === void 0) return void 0;
|
|
@@ -673,6 +717,28 @@ function isJsonObject(value) {
|
|
|
673
717
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
674
718
|
}
|
|
675
719
|
/**
|
|
720
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
721
|
+
*
|
|
722
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
723
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
724
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
725
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
726
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
727
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
728
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
729
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
730
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
731
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
732
|
+
*/
|
|
733
|
+
function withIdentityEncoding(init = {}) {
|
|
734
|
+
const headers = new Headers(init.headers);
|
|
735
|
+
headers.set("accept-encoding", "identity");
|
|
736
|
+
return {
|
|
737
|
+
...init,
|
|
738
|
+
headers
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
676
742
|
* Write one JSON response. Default headers are the family defaults
|
|
677
743
|
* (content-type and referrer-policy); caller headers are appended or
|
|
678
744
|
* override them.
|
|
@@ -685,6 +751,169 @@ function writeJson(res, status, body, headers = {}) {
|
|
|
685
751
|
});
|
|
686
752
|
res.end(payload);
|
|
687
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Build the grant table. Pure TypeScript with injected clock/randomness so the
|
|
756
|
+
* whole one-time semantics are unit-testable without a server.
|
|
757
|
+
*/
|
|
758
|
+
function createPairGrantStore(options = {}) {
|
|
759
|
+
const ttlMs = options.ttlMs ?? 6e4;
|
|
760
|
+
const now = options.now ?? (() => Date.now());
|
|
761
|
+
const createGrant = options.createGrant ?? (() => randomBytes(32).toString("base64url"));
|
|
762
|
+
const entries = /* @__PURE__ */ new Map();
|
|
763
|
+
const purgeExpired = (at) => {
|
|
764
|
+
for (const [grant, entry] of entries) if (entry.expiresAt <= at) entries.delete(grant);
|
|
765
|
+
};
|
|
766
|
+
return {
|
|
767
|
+
issue(deviceId) {
|
|
768
|
+
const issuedAt = now();
|
|
769
|
+
purgeExpired(issuedAt);
|
|
770
|
+
while (entries.size >= 256) {
|
|
771
|
+
const oldest = entries.keys().next().value;
|
|
772
|
+
if (oldest === void 0) break;
|
|
773
|
+
entries.delete(oldest);
|
|
774
|
+
}
|
|
775
|
+
const grant = createGrant();
|
|
776
|
+
const expiresAt = issuedAt + ttlMs;
|
|
777
|
+
entries.set(grant, {
|
|
778
|
+
deviceId,
|
|
779
|
+
expiresAt
|
|
780
|
+
});
|
|
781
|
+
return {
|
|
782
|
+
grant,
|
|
783
|
+
expiresAt
|
|
784
|
+
};
|
|
785
|
+
},
|
|
786
|
+
consume(grant) {
|
|
787
|
+
if (grant === void 0 || grant === "") return void 0;
|
|
788
|
+
const consumedAt = now();
|
|
789
|
+
const entry = entries.get(grant);
|
|
790
|
+
entries.delete(grant);
|
|
791
|
+
purgeExpired(consumedAt);
|
|
792
|
+
if (entry === void 0 || entry.expiresAt <= consumedAt) return void 0;
|
|
793
|
+
return entry.deviceId;
|
|
794
|
+
},
|
|
795
|
+
peek(grant) {
|
|
796
|
+
if (grant === void 0 || grant === "") return void 0;
|
|
797
|
+
const at = now();
|
|
798
|
+
const entry = entries.get(grant);
|
|
799
|
+
if (entry === void 0) return void 0;
|
|
800
|
+
if (entry.expiresAt <= at) {
|
|
801
|
+
entries.delete(grant);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
return entry.deviceId;
|
|
805
|
+
},
|
|
806
|
+
get size() {
|
|
807
|
+
return entries.size;
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
//#region src/remote-methods.ts
|
|
813
|
+
/**
|
|
814
|
+
* Remote desktop channel constants — SDK-independent so tests and the
|
|
815
|
+
* client half can pin them without importing the host SDK graph.
|
|
816
|
+
*
|
|
817
|
+
* Access model on the 0.1.2-alpha.2 line: the host /api surface has no
|
|
818
|
+
* per-method privilege pin — the "configuration plane is local" behavior
|
|
819
|
+
* lives in the browser (client plugins branch on connection.isLoopback), and
|
|
820
|
+
* the paired remote desktop flips into host mode via the transport hook
|
|
821
|
+
* (ownsHost) while every call rides this gated channel as a loopback-shaped
|
|
822
|
+
* request. A paired device is therefore a full-control credential by
|
|
823
|
+
* design; the only paths that stay physically local are the control planes
|
|
824
|
+
* below (pairing control, self-update, plugin install/remove, host power).
|
|
825
|
+
*/
|
|
826
|
+
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
827
|
+
const REMOTE_PREFIX$1 = "/remote";
|
|
828
|
+
/** Connection-plugin method prefix under the gated channel. */
|
|
829
|
+
const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
|
|
830
|
+
/**
|
|
831
|
+
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
832
|
+
* exact path, not prefix). Query strings ride on the request URL.
|
|
833
|
+
*/
|
|
834
|
+
const REMOTE_UPGRADE_PATHS = [
|
|
835
|
+
{ mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
|
|
836
|
+
`${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
|
|
837
|
+
`${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
|
|
838
|
+
`${REMOTE_PREFIX$1}/sidebar/ws/agent-opens`,
|
|
839
|
+
`${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
|
|
840
|
+
];
|
|
841
|
+
/** Plugin-manager HTTP prefix: install/remove stay physically local. */
|
|
842
|
+
const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
|
|
843
|
+
/**
|
|
844
|
+
* The cookieless device credential: the boot patch reads the device id from
|
|
845
|
+
* the /pair-app URL, keeps it in sessionStorage, and attaches it to every
|
|
846
|
+
* gated HTTP call as this header (and to WebSocket upgrades as the `device`
|
|
847
|
+
* query parameter - WS handshakes cannot carry headers from the Web API).
|
|
848
|
+
* The channel gate accepts it exactly like the device cookie, so the mobile
|
|
849
|
+
* flow works even with browser cookies fully blocked; the cookie remains the
|
|
850
|
+
* primary credential on normal browsers.
|
|
851
|
+
*/
|
|
852
|
+
const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
|
|
853
|
+
const REMOTE_DEVICE_QUERY = "device";
|
|
854
|
+
/**
|
|
855
|
+
* Path prefixes that stay physically local even for a paired device. A
|
|
856
|
+
* paired remote desktop may use the full host API (chat, sessions,
|
|
857
|
+
* settings, credentials, presets — it is a full-control credential), but it
|
|
858
|
+
* must not reach the machine-control planes: pairing control itself, the
|
|
859
|
+
* dsh-web self-update installer, and plugin install/remove.
|
|
860
|
+
*/
|
|
861
|
+
const LOCAL_ONLY_PREFIXES = [
|
|
862
|
+
"/api/pair",
|
|
863
|
+
"/api/update",
|
|
864
|
+
PLUGIN_MANAGER_PATH
|
|
865
|
+
];
|
|
866
|
+
/**
|
|
867
|
+
* Whether a paired inner path must stay physically local.
|
|
868
|
+
* @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
|
|
869
|
+
* @returns a denial message, or undefined when the path may be proxied.
|
|
870
|
+
*/
|
|
871
|
+
function localOnlyDenial(innerPath) {
|
|
872
|
+
for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/remote-channel-rules.ts
|
|
876
|
+
/**
|
|
877
|
+
* The remote-channel rewrite contract as pure data (issue #987): both the
|
|
878
|
+
* browser patch (client/remote-channel.ts) and the parse-time boot patch
|
|
879
|
+
* (remote-channel-boot.ts, inlined into index.html by the host) decide from
|
|
880
|
+
* these tables, so the two can never drift apart.
|
|
881
|
+
* @module @linxin666/dsh-remote-web-ui/remote-channel-rules
|
|
882
|
+
*/
|
|
883
|
+
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
884
|
+
const REMOTE_PREFIX = "/remote";
|
|
885
|
+
/**
|
|
886
|
+
* The window global the device-gated app landing publishes to grant host mode.
|
|
887
|
+
* Set by the /pair-app capture script, read by the parse-time boot patch: the
|
|
888
|
+
* transport hook (ownsHost) is server-granted, not asserted from the origin.
|
|
889
|
+
*/
|
|
890
|
+
const REMOTE_HOST_GRANT_GLOBAL = "__DSH_REMOTE_HOST_GRANT__";
|
|
891
|
+
/** The live rule set. */
|
|
892
|
+
const REMOTE_CHANNEL_RULES = {
|
|
893
|
+
remotePrefix: REMOTE_PREFIX,
|
|
894
|
+
apiPrefix: "/api/",
|
|
895
|
+
pairPrefix: "/api/pair/",
|
|
896
|
+
updatePrefix: "/api/update/",
|
|
897
|
+
settingsBridgePrefix: "/api/dsh-web-ui-settings",
|
|
898
|
+
sidebarPrefix: "/sidebar/",
|
|
899
|
+
gitPrefix: "/git/",
|
|
900
|
+
petPrefix: "/pet/",
|
|
901
|
+
wsPaths: [
|
|
902
|
+
"/api/remote.mux",
|
|
903
|
+
"/sidebar/ws/terminal",
|
|
904
|
+
"/sidebar/ws/agent-terminals",
|
|
905
|
+
"/sidebar/ws/agent-opens",
|
|
906
|
+
"/api/dsh-ssh/terminal"
|
|
907
|
+
],
|
|
908
|
+
deviceHeader: REMOTE_DEVICE_HEADER,
|
|
909
|
+
deviceKey: "dsh-remote-device",
|
|
910
|
+
deviceQuery: REMOTE_DEVICE_QUERY,
|
|
911
|
+
uploadPath: "/api/session/uploadFileBinary",
|
|
912
|
+
uploadHookGlobal: "__DSH_FILE_UPLOAD__",
|
|
913
|
+
hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL
|
|
914
|
+
};
|
|
915
|
+
/** The window global the boot patch publishes its seat under. */
|
|
916
|
+
const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
688
917
|
//#endregion
|
|
689
918
|
//#region src/routes.ts
|
|
690
919
|
/**
|
|
@@ -733,7 +962,12 @@ function isTrustedHost(request, trustedHosts) {
|
|
|
733
962
|
}
|
|
734
963
|
const hostname = hostUrl.hostname;
|
|
735
964
|
return isLoopbackClient(request) || trustedHosts.some((entry) => {
|
|
736
|
-
|
|
965
|
+
let entryUrl;
|
|
966
|
+
try {
|
|
967
|
+
entryUrl = new URL(`http://${entry}`);
|
|
968
|
+
} catch {
|
|
969
|
+
return false;
|
|
970
|
+
}
|
|
737
971
|
return entryUrl.port === "" ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host;
|
|
738
972
|
});
|
|
739
973
|
}
|
|
@@ -816,17 +1050,29 @@ function privateLanHostOf(request, navigation = false) {
|
|
|
816
1050
|
const MAX_BODY_BYTES = 4096;
|
|
817
1051
|
/**
|
|
818
1052
|
* The rate-limit bucket key for one accept attempt (pure; unit-tested).
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
*
|
|
1053
|
+
* A forwarded hop separates buckets behind the auto-tunnel (every internet
|
|
1054
|
+
* client arrives from 127.0.0.1 there) — but only for loopback peers, since a
|
|
1055
|
+
* direct LAN client can rotate the header freely. The caller passes the hop it
|
|
1056
|
+
* trusts (the edge-appended LAST one; see rateLimitAccept), and the hop is
|
|
1057
|
+
* truncated so one oversized header cannot mint a huge key.
|
|
822
1058
|
* @param socketIp - the socket peer address.
|
|
823
|
-
* @param forwarded - the
|
|
1059
|
+
* @param forwarded - the trusted XFF hop, already trimmed, if any.
|
|
824
1060
|
* @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
|
|
825
1061
|
*/
|
|
826
1062
|
function acceptLimitKey(socketIp, forwarded, bucket) {
|
|
827
|
-
if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded}`;
|
|
1063
|
+
if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded.slice(0, MAX_FORWARDED_KEY_CHARS)}`;
|
|
828
1064
|
return `${bucket}|${socketIp}`;
|
|
829
1065
|
}
|
|
1066
|
+
/** Longest forwarded hop kept in a bucket key (the header is caller-sized). */
|
|
1067
|
+
const MAX_FORWARDED_KEY_CHARS = 64;
|
|
1068
|
+
/**
|
|
1069
|
+
* Hard cap on live accept buckets. The key is caller-influenced (a forwarded
|
|
1070
|
+
* hop), so a rotating flood must not grow the table without bound: past the
|
|
1071
|
+
* cap the oldest bucket is evicted, the same FIFO stance as
|
|
1072
|
+
* {@link addBounded}. A legitimate client that loses its bucket merely starts
|
|
1073
|
+
* a fresh window.
|
|
1074
|
+
*/
|
|
1075
|
+
const MAX_ACCEPT_BUCKETS = 1024;
|
|
830
1076
|
/**
|
|
831
1077
|
* Reasons a phone-facing entry page refused a request, logged once per shape
|
|
832
1078
|
* so a real device failure is diagnosable from the host console without a
|
|
@@ -897,15 +1143,24 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
|
|
|
897
1143
|
const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
|
|
898
1144
|
function appShellCaptureScript(deviceId) {
|
|
899
1145
|
const safeId = JSON.stringify(deviceId);
|
|
1146
|
+
const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL);
|
|
900
1147
|
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
|
|
901
|
-
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});
|
|
1148
|
+
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()<\/script>`;
|
|
902
1149
|
}
|
|
903
|
-
/**
|
|
1150
|
+
/**
|
|
1151
|
+
* Patch the official index document with the device-capture script. The script
|
|
1152
|
+
* goes immediately after the opening <head> tag, ahead of the harness-injected
|
|
1153
|
+
* parse-time channel boot patch: that patch installs the transport host-mode
|
|
1154
|
+
* hook only when this server-served marker is already set (see
|
|
1155
|
+
* remote-channel-boot.ts), so host mode is granted by the device-gated landing
|
|
1156
|
+
* instead of being asserted from the origin.
|
|
1157
|
+
*/
|
|
904
1158
|
function patchAppShell(html, deviceId) {
|
|
905
1159
|
const script = appShellCaptureScript(deviceId);
|
|
906
|
-
const
|
|
907
|
-
if (
|
|
908
|
-
|
|
1160
|
+
const head = /<head[^>]*>/i.exec(html);
|
|
1161
|
+
if (head === null) return script + html;
|
|
1162
|
+
const end = head.index + head[0].length;
|
|
1163
|
+
return html.slice(0, end) + script + html.slice(end);
|
|
909
1164
|
}
|
|
910
1165
|
/**
|
|
911
1166
|
* The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
|
|
@@ -1102,6 +1357,7 @@ var PairingEventsStream = class {
|
|
|
1102
1357
|
function makeRoutes(deps) {
|
|
1103
1358
|
const { service, requirePairingForLan = true } = deps;
|
|
1104
1359
|
const pairingRequired = () => typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan;
|
|
1360
|
+
const grants = deps.grants ?? createPairGrantStore();
|
|
1105
1361
|
const events = new PairingEventsStream(service);
|
|
1106
1362
|
const dynamicTrustedHosts = /* @__PURE__ */ new Set();
|
|
1107
1363
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
@@ -1127,10 +1383,14 @@ function makeRoutes(deps) {
|
|
|
1127
1383
|
if (privateLanHost !== void 0) {
|
|
1128
1384
|
const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1129
1385
|
let queryDeviceId = null;
|
|
1386
|
+
let queryGrant;
|
|
1130
1387
|
try {
|
|
1131
|
-
|
|
1388
|
+
const params = new URL(req.url ?? "/", "http://pair.invalid").searchParams;
|
|
1389
|
+
queryDeviceId = params.get("device");
|
|
1390
|
+
queryGrant = params.get("grant") ?? void 0;
|
|
1132
1391
|
} catch {}
|
|
1133
|
-
const
|
|
1392
|
+
const granted = grants.peek(queryGrant);
|
|
1393
|
+
const deviceId = cookieDeviceId ?? queryDeviceId ?? granted ?? void 0;
|
|
1134
1394
|
if (deviceId !== void 0 && service.hasDevice(deviceId)) {
|
|
1135
1395
|
addBounded(dynamicTrustedHosts, privateLanHost, 64);
|
|
1136
1396
|
return true;
|
|
@@ -1153,14 +1413,38 @@ function makeRoutes(deps) {
|
|
|
1153
1413
|
* count separately: a QR re-scan (page navigation) must not consume a
|
|
1154
1414
|
* brute-force budget that belongs to token guessing (and vice versa).
|
|
1155
1415
|
*/
|
|
1416
|
+
/**
|
|
1417
|
+
* Drop expired buckets from the FRONT of the insertion-ordered table, then
|
|
1418
|
+
* FIFO-evict past the hard cap. The front always holds the oldest window
|
|
1419
|
+
* because a re-armed bucket is re-inserted at the tail, so this is amortized
|
|
1420
|
+
* O(1) per request: every iteration deletes an entry that was inserted once.
|
|
1421
|
+
* It replaces a full-table scan that ran on every pairing request once the
|
|
1422
|
+
* table passed 256 entries (and deleted nothing while all windows were
|
|
1423
|
+
* fresh).
|
|
1424
|
+
*/
|
|
1425
|
+
const pruneAcceptAttempts = (nowMs) => {
|
|
1426
|
+
while (acceptAttempts.size > 0) {
|
|
1427
|
+
const oldest = acceptAttempts.keys().next().value;
|
|
1428
|
+
if (oldest === void 0) break;
|
|
1429
|
+
const attempt = acceptAttempts.get(oldest);
|
|
1430
|
+
if (attempt !== void 0 && nowMs - attempt.windowStart <= ACCEPT_WINDOW_MS) break;
|
|
1431
|
+
acceptAttempts.delete(oldest);
|
|
1432
|
+
}
|
|
1433
|
+
while (acceptAttempts.size > MAX_ACCEPT_BUCKETS) {
|
|
1434
|
+
const oldest = acceptAttempts.keys().next().value;
|
|
1435
|
+
if (oldest === void 0) break;
|
|
1436
|
+
acceptAttempts.delete(oldest);
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1156
1439
|
const rateLimitAccept = (req, bucket) => {
|
|
1157
|
-
const
|
|
1440
|
+
const socketIp = req.socket?.remoteAddress ?? "unknown";
|
|
1441
|
+
const hops = typeof req.headers["x-forwarded-for"] === "string" ? req.headers["x-forwarded-for"].split(",") : [];
|
|
1442
|
+
const ip = acceptLimitKey(socketIp, (hops[hops.length - 1] ?? "").trim(), bucket);
|
|
1158
1443
|
const nowMs = Date.now();
|
|
1159
|
-
|
|
1160
|
-
for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
|
|
1161
|
-
}
|
|
1444
|
+
pruneAcceptAttempts(nowMs);
|
|
1162
1445
|
const entry = acceptAttempts.get(ip);
|
|
1163
1446
|
if (entry === void 0 || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
|
|
1447
|
+
acceptAttempts.delete(ip);
|
|
1164
1448
|
acceptAttempts.set(ip, {
|
|
1165
1449
|
count: 1,
|
|
1166
1450
|
windowStart: nowMs
|
|
@@ -1251,7 +1535,7 @@ function makeRoutes(deps) {
|
|
|
1251
1535
|
});
|
|
1252
1536
|
return;
|
|
1253
1537
|
}
|
|
1254
|
-
writeJson(res,
|
|
1538
|
+
writeJson(res, 404, {
|
|
1255
1539
|
ok: false,
|
|
1256
1540
|
code: result.code
|
|
1257
1541
|
});
|
|
@@ -1261,7 +1545,7 @@ function makeRoutes(deps) {
|
|
|
1261
1545
|
writeJson(res, 200, {
|
|
1262
1546
|
ok: true,
|
|
1263
1547
|
deviceId: result.deviceId
|
|
1264
|
-
}, { "set-cookie": [
|
|
1548
|
+
}, { "set-cookie": [deviceCookie(req, service.config.cookieName, result.deviceId)] });
|
|
1265
1549
|
};
|
|
1266
1550
|
const handleStop = async (req, res) => {
|
|
1267
1551
|
if (!requireMethod(req, res, "POST")) return;
|
|
@@ -1357,12 +1641,12 @@ function makeRoutes(deps) {
|
|
|
1357
1641
|
}
|
|
1358
1642
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1359
1643
|
const paired = deviceId !== void 0 && service.hasDevice(deviceId);
|
|
1644
|
+
const local = isLoopbackClient(req);
|
|
1360
1645
|
const snapshot = service.snapshot();
|
|
1361
1646
|
const { devices: _devices, ...rest } = snapshot;
|
|
1362
|
-
const visible = paired ? rest : {
|
|
1647
|
+
const visible = paired || local ? rest : {
|
|
1363
1648
|
phase: snapshot.phase,
|
|
1364
|
-
lanAvailable: snapshot.lanAvailable
|
|
1365
|
-
lanAddresses: snapshot.lanAddresses
|
|
1649
|
+
lanAvailable: snapshot.lanAvailable
|
|
1366
1650
|
};
|
|
1367
1651
|
writeJson(res, 200, {
|
|
1368
1652
|
ok: true,
|
|
@@ -1435,8 +1719,9 @@ function makeRoutes(deps) {
|
|
|
1435
1719
|
}
|
|
1436
1720
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1437
1721
|
if (deviceId !== void 0 && service.hasDevice(deviceId)) {
|
|
1722
|
+
const { grant } = grants.issue(deviceId);
|
|
1438
1723
|
res.writeHead(303, {
|
|
1439
|
-
location: `${appOrigin(req)}/pair-app?
|
|
1724
|
+
location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
|
|
1440
1725
|
"cache-control": "no-store",
|
|
1441
1726
|
"referrer-policy": "no-referrer"
|
|
1442
1727
|
});
|
|
@@ -1452,19 +1737,21 @@ function makeRoutes(deps) {
|
|
|
1452
1737
|
return;
|
|
1453
1738
|
}
|
|
1454
1739
|
if (privateLanHost !== void 0) addBounded(dynamicTrustedHosts, privateLanHost, 64);
|
|
1740
|
+
const { grant } = grants.issue(result.deviceId);
|
|
1455
1741
|
res.writeHead(303, {
|
|
1456
|
-
location: `${appOrigin(req)}/pair-app?
|
|
1742
|
+
location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
|
|
1457
1743
|
"cache-control": "no-store",
|
|
1458
1744
|
"referrer-policy": "no-referrer",
|
|
1459
|
-
"set-cookie": [
|
|
1745
|
+
"set-cookie": [deviceCookie(req, service.config.cookieName, result.deviceId)]
|
|
1460
1746
|
});
|
|
1461
1747
|
res.end();
|
|
1462
1748
|
};
|
|
1463
1749
|
/**
|
|
1464
|
-
* The cookieless app landing. A paired device (
|
|
1465
|
-
* pairing cookie) receives the official
|
|
1466
|
-
* device-capture script; the shell itself is
|
|
1467
|
-
* carries no data, so serving it needs only the
|
|
1750
|
+
* The cookieless app landing. A paired device (a one-time grant minted by
|
|
1751
|
+
* /pair-accept, or a live pairing cookie on a reopen) receives the official
|
|
1752
|
+
* index shell patched with the device-capture script; the shell itself is
|
|
1753
|
+
* the official document and carries no data, so serving it needs only the
|
|
1754
|
+
* device credential.
|
|
1468
1755
|
*/
|
|
1469
1756
|
const handleAppPage = async (req, res) => {
|
|
1470
1757
|
if (!requireMethod(req, res, "GET")) return;
|
|
@@ -1479,9 +1766,10 @@ function makeRoutes(deps) {
|
|
|
1479
1766
|
res.end("rate limited");
|
|
1480
1767
|
return;
|
|
1481
1768
|
}
|
|
1482
|
-
const
|
|
1769
|
+
const url = new URL(req.url ?? "/", "http://pair.invalid");
|
|
1770
|
+
const grantedDevice = grants.consume(url.searchParams.get("grant") ?? void 0);
|
|
1483
1771
|
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
|
|
1484
|
-
const id =
|
|
1772
|
+
const id = grantedDevice !== void 0 && service.touchDevice(grantedDevice) ? grantedDevice : cookieDevice !== void 0 && service.touchDevice(cookieDevice) ? cookieDevice : void 0;
|
|
1485
1773
|
if (id === void 0) {
|
|
1486
1774
|
res.writeHead(200, {
|
|
1487
1775
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -1504,7 +1792,7 @@ function makeRoutes(deps) {
|
|
|
1504
1792
|
"content-type": "text/html; charset=utf-8",
|
|
1505
1793
|
"cache-control": "no-store",
|
|
1506
1794
|
"referrer-policy": "no-referrer",
|
|
1507
|
-
"set-cookie": [
|
|
1795
|
+
"set-cookie": [deviceCookie(req, service.config.cookieName, id)]
|
|
1508
1796
|
});
|
|
1509
1797
|
res.end(patchAppShell(html, id));
|
|
1510
1798
|
};
|
|
@@ -1590,11 +1878,31 @@ function makeRoutes(deps) {
|
|
|
1590
1878
|
}
|
|
1591
1879
|
return routes;
|
|
1592
1880
|
}
|
|
1881
|
+
/**
|
|
1882
|
+
* Whether the request reached this process over TLS. The harness itself serves
|
|
1883
|
+
* plain HTTP; a tunnel edge terminates TLS and stamps `x-forwarded-proto`
|
|
1884
|
+
* (the same signal {@link appOrigin} trusts when it rebuilds a redirect).
|
|
1885
|
+
*/
|
|
1886
|
+
function isHttpsRequest(req) {
|
|
1887
|
+
const forwarded = req.headers["x-forwarded-proto"];
|
|
1888
|
+
if (typeof forwarded === "string") {
|
|
1889
|
+
if (forwarded.split(",")[0]?.trim().toLowerCase() === "https") return true;
|
|
1890
|
+
}
|
|
1891
|
+
return req.socket.encrypted === true;
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* The device cookie for one request. `Secure` is added only when the request
|
|
1895
|
+
* arrived over TLS: LAN pairing runs on plain HTTP, where a Secure cookie is
|
|
1896
|
+
* simply dropped by the browser and the phone would silently lose its session.
|
|
1897
|
+
*/
|
|
1898
|
+
function deviceCookie(req, cookieName, deviceId) {
|
|
1899
|
+
const secure = isHttpsRequest(req) ? "; Secure" : "";
|
|
1900
|
+
return `${cookieName}=${deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}${secure}`;
|
|
1901
|
+
}
|
|
1593
1902
|
/** The request origin (https when a tunnel edge says so). */
|
|
1594
1903
|
function appOrigin(req) {
|
|
1595
|
-
const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
|
|
1596
1904
|
const origin = `http://${req.headers.host ?? "127.0.0.1"}`;
|
|
1597
|
-
return
|
|
1905
|
+
return isHttpsRequest(req) ? origin.replace("http://", "https://") : origin;
|
|
1598
1906
|
}
|
|
1599
1907
|
//#endregion
|
|
1600
1908
|
//#region src/loopback-proxy.ts
|
|
@@ -1729,68 +2037,6 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath, cookie) {
|
|
|
1729
2037
|
});
|
|
1730
2038
|
}
|
|
1731
2039
|
//#endregion
|
|
1732
|
-
//#region src/remote-methods.ts
|
|
1733
|
-
/**
|
|
1734
|
-
* Remote desktop channel constants — SDK-independent so tests and the
|
|
1735
|
-
* client half can pin them without importing the host SDK graph.
|
|
1736
|
-
*
|
|
1737
|
-
* Access model on the 0.1.2-alpha.2 line: the host /api surface has no
|
|
1738
|
-
* per-method privilege pin — the "configuration plane is local" behavior
|
|
1739
|
-
* lives in the browser (client plugins branch on connection.isLoopback), and
|
|
1740
|
-
* the paired remote desktop flips into host mode via the transport hook
|
|
1741
|
-
* (ownsHost) while every call rides this gated channel as a loopback-shaped
|
|
1742
|
-
* request. A paired device is therefore a full-control credential by
|
|
1743
|
-
* design; the only paths that stay physically local are the control planes
|
|
1744
|
-
* below (pairing control, self-update, plugin install/remove, host power).
|
|
1745
|
-
*/
|
|
1746
|
-
/** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
|
|
1747
|
-
const REMOTE_PREFIX$1 = "/remote";
|
|
1748
|
-
/** Connection-plugin method prefix under the gated channel. */
|
|
1749
|
-
const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
|
|
1750
|
-
/**
|
|
1751
|
-
* Exact upgrade paths registered on webServer (the SDK matches upgrades by
|
|
1752
|
-
* exact path, not prefix). Query strings ride on the request URL.
|
|
1753
|
-
*/
|
|
1754
|
-
const REMOTE_UPGRADE_PATHS = [
|
|
1755
|
-
{ mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
|
|
1756
|
-
`${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
|
|
1757
|
-
`${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
|
|
1758
|
-
`${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
|
|
1759
|
-
];
|
|
1760
|
-
/** Plugin-manager HTTP prefix: install/remove stay physically local. */
|
|
1761
|
-
const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
|
|
1762
|
-
/**
|
|
1763
|
-
* The cookieless device credential: the boot patch reads the device id from
|
|
1764
|
-
* the /pair-app URL, keeps it in sessionStorage, and attaches it to every
|
|
1765
|
-
* gated HTTP call as this header (and to WebSocket upgrades as the `device`
|
|
1766
|
-
* query parameter - WS handshakes cannot carry headers from the Web API).
|
|
1767
|
-
* The channel gate accepts it exactly like the device cookie, so the mobile
|
|
1768
|
-
* flow works even with browser cookies fully blocked; the cookie remains the
|
|
1769
|
-
* primary credential on normal browsers.
|
|
1770
|
-
*/
|
|
1771
|
-
const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
|
|
1772
|
-
const REMOTE_DEVICE_QUERY = "device";
|
|
1773
|
-
/**
|
|
1774
|
-
* Path prefixes that stay physically local even for a paired device. A
|
|
1775
|
-
* paired remote desktop may use the full host API (chat, sessions,
|
|
1776
|
-
* settings, credentials, presets — it is a full-control credential), but it
|
|
1777
|
-
* must not reach the machine-control planes: pairing control itself, the
|
|
1778
|
-
* dsh-web self-update installer, and plugin install/remove.
|
|
1779
|
-
*/
|
|
1780
|
-
const LOCAL_ONLY_PREFIXES = [
|
|
1781
|
-
"/api/pair",
|
|
1782
|
-
"/api/update",
|
|
1783
|
-
PLUGIN_MANAGER_PATH
|
|
1784
|
-
];
|
|
1785
|
-
/**
|
|
1786
|
-
* Whether a paired inner path must stay physically local.
|
|
1787
|
-
* @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
|
|
1788
|
-
* @returns a denial message, or undefined when the path may be proxied.
|
|
1789
|
-
*/
|
|
1790
|
-
function localOnlyDenial(innerPath) {
|
|
1791
|
-
for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
|
|
1792
|
-
}
|
|
1793
|
-
//#endregion
|
|
1794
2040
|
//#region src/remote-api.ts
|
|
1795
2041
|
const ALLOWED_METHODS = /* @__PURE__ */ new Set([
|
|
1796
2042
|
"GET",
|
|
@@ -1862,18 +2108,16 @@ function pairedDeviceIdOf(req, service) {
|
|
|
1862
2108
|
}
|
|
1863
2109
|
/**
|
|
1864
2110
|
* Build the remote desktop channel HTTP routes.
|
|
1865
|
-
* @param deps - pairing service + local port +
|
|
2111
|
+
* @param deps - pairing service + local port + inner credential.
|
|
1866
2112
|
* @returns the routes to register on webServer.
|
|
1867
2113
|
*/
|
|
1868
2114
|
function makeRemoteApiRoutes(deps) {
|
|
1869
|
-
const { service, port
|
|
2115
|
+
const { service, port } = deps;
|
|
1870
2116
|
const handler = (req, res) => {
|
|
1871
|
-
if (
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
2117
|
+
if (pairedDeviceIdOf(req, service) === void 0) {
|
|
2118
|
+
req.resume();
|
|
2119
|
+
envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
|
|
2120
|
+
return;
|
|
1877
2121
|
}
|
|
1878
2122
|
const method = req.method ?? "GET";
|
|
1879
2123
|
if (!ALLOWED_METHODS.has(method)) {
|
|
@@ -1924,18 +2168,16 @@ function upgradeInnerPath(reqUrl, fallbackPath) {
|
|
|
1924
2168
|
* @returns the upgrade routes to register on webServer.
|
|
1925
2169
|
*/
|
|
1926
2170
|
function makeRemoteApiUpgradeRoutes(deps) {
|
|
1927
|
-
const { service, port
|
|
2171
|
+
const { service, port } = deps;
|
|
1928
2172
|
const handlerFor = (fallbackPath) => (req, socket, head) => {
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
return;
|
|
1938
|
-
}
|
|
2173
|
+
let queryDevice;
|
|
2174
|
+
try {
|
|
2175
|
+
queryDevice = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("device") ?? void 0;
|
|
2176
|
+
} catch {}
|
|
2177
|
+
if ((pairedDeviceIdOf(req, service) ?? (queryDevice !== void 0 && service.touchDevice(queryDevice) ? queryDevice : void 0)) === void 0) {
|
|
2178
|
+
socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
|
2179
|
+
socket.destroy();
|
|
2180
|
+
return;
|
|
1939
2181
|
}
|
|
1940
2182
|
const inner = upgradeInnerPath(req.url, fallbackPath);
|
|
1941
2183
|
if (loopbackOnlyDenial(inner.split("?")[0] ?? inner) !== void 0) {
|
|
@@ -2085,7 +2327,7 @@ async function probeHost(port, hostHeader, request, timeoutMs) {
|
|
|
2085
2327
|
},
|
|
2086
2328
|
timeout: timeoutMs
|
|
2087
2329
|
}, (status) => {
|
|
2088
|
-
finish(status !== 403);
|
|
2330
|
+
finish(status !== 403 && status !== 401);
|
|
2089
2331
|
});
|
|
2090
2332
|
const timer = setTimeout(() => {
|
|
2091
2333
|
finish(false);
|
|
@@ -2911,14 +3153,23 @@ function createBinaryReadiness(executable, seams = {}) {
|
|
|
2911
3153
|
const runs = seams.runs ?? binaryRuns;
|
|
2912
3154
|
const installBinary = seams.install ?? install;
|
|
2913
3155
|
const validated = /* @__PURE__ */ new Set();
|
|
3156
|
+
let installs = 0;
|
|
3157
|
+
let lastFailure;
|
|
2914
3158
|
return async () => {
|
|
2915
3159
|
if (validated.has(executable)) return;
|
|
3160
|
+
if (installs >= 3 && lastFailure !== void 0) throw lastFailure;
|
|
2916
3161
|
if (exists(executable) && await runs(executable)) {
|
|
2917
3162
|
validated.add(executable);
|
|
2918
3163
|
return;
|
|
2919
3164
|
}
|
|
2920
|
-
|
|
2921
|
-
|
|
3165
|
+
installs += 1;
|
|
3166
|
+
try {
|
|
3167
|
+
await installBinary(executable);
|
|
3168
|
+
if (!await runs(executable)) throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`);
|
|
3169
|
+
} catch (error) {
|
|
3170
|
+
lastFailure = error instanceof Error ? error : new Error(String(error));
|
|
3171
|
+
throw lastFailure;
|
|
3172
|
+
}
|
|
2922
3173
|
validated.add(executable);
|
|
2923
3174
|
};
|
|
2924
3175
|
}
|
|
@@ -2948,27 +3199,51 @@ function quickTunnelFlags(originHostHeader) {
|
|
|
2948
3199
|
};
|
|
2949
3200
|
}
|
|
2950
3201
|
/**
|
|
2951
|
-
* Command-line arguments for a named tunnel
|
|
2952
|
-
*
|
|
2953
|
-
*
|
|
2954
|
-
*
|
|
2955
|
-
*
|
|
3202
|
+
* Command-line arguments for a named tunnel. The account token is deliberately
|
|
3203
|
+
* NOT part of the argv: `--token <t>` would expose an account credential to any
|
|
3204
|
+
* local user through ps/proc for the whole tunnel lifetime, while the flag's own
|
|
3205
|
+
* environment variable (TUNNEL_TOKEN, see cloudflared's tunnel run flags) is not
|
|
3206
|
+
* readable that way. Note: `--no-autoupdate` and `--protocol` are flags for the
|
|
3207
|
+
* `tunnel` command and must appear BEFORE the `run` subcommand. Putting them
|
|
3208
|
+
* after `run` causes cloudflared to exit immediately with "flag provided but not
|
|
3209
|
+
* defined: -no-autoupdate" (issues #1432, #1433).
|
|
2956
3210
|
*/
|
|
2957
|
-
function namedTunnelArgs(
|
|
3211
|
+
function namedTunnelArgs() {
|
|
2958
3212
|
return [
|
|
2959
3213
|
"tunnel",
|
|
2960
3214
|
"--no-autoupdate",
|
|
2961
3215
|
"--protocol",
|
|
2962
3216
|
"http2",
|
|
2963
|
-
"run"
|
|
2964
|
-
"--token",
|
|
2965
|
-
token
|
|
3217
|
+
"run"
|
|
2966
3218
|
];
|
|
2967
3219
|
}
|
|
3220
|
+
/**
|
|
3221
|
+
* Run one spawn call with TUNNEL_TOKEN set, restoring the previous value
|
|
3222
|
+
* afterwards. The cloudflared package's Tunnel spawns with the inherited
|
|
3223
|
+
* environment inside its constructor, so the variable only has to exist for
|
|
3224
|
+
* that call; the child keeps its own copy of the environment.
|
|
3225
|
+
* @param token - the named-tunnel token.
|
|
3226
|
+
* @param spawnTunnel - the call that creates the child process.
|
|
3227
|
+
* @returns whatever the spawn call returns.
|
|
3228
|
+
*/
|
|
3229
|
+
function withTunnelTokenEnv(token, spawnTunnel) {
|
|
3230
|
+
const previous = process.env.TUNNEL_TOKEN;
|
|
3231
|
+
process.env.TUNNEL_TOKEN = token;
|
|
3232
|
+
try {
|
|
3233
|
+
return spawnTunnel();
|
|
3234
|
+
} finally {
|
|
3235
|
+
if (previous === void 0) delete process.env.TUNNEL_TOKEN;
|
|
3236
|
+
else process.env.TUNNEL_TOKEN = previous;
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
/** Spawn one named tunnel without putting its token in the child's argv. */
|
|
3240
|
+
function spawnNamedTunnel(token) {
|
|
3241
|
+
return withTunnelTokenEnv(token, () => new Tunnel(namedTunnelArgs()));
|
|
3242
|
+
}
|
|
2968
3243
|
/** Default factory: the cloudflared package's quick and named tunnels. */
|
|
2969
3244
|
function defaultFactory(target) {
|
|
2970
3245
|
if (target.kind === "quick") return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader));
|
|
2971
|
-
return namedTunnelHandle(
|
|
3246
|
+
return namedTunnelHandle(spawnNamedTunnel(target.token), target.publicUrl);
|
|
2972
3247
|
}
|
|
2973
3248
|
/** Node timers. */
|
|
2974
3249
|
const nodeTimer = {
|
|
@@ -3305,6 +3580,35 @@ function decodeConsoleBytes(bytes) {
|
|
|
3305
3580
|
return best;
|
|
3306
3581
|
}
|
|
3307
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
|
+
}
|
|
3308
3612
|
/** The aggregate package that is the canonical update entry point. */
|
|
3309
3613
|
const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
|
|
3310
3614
|
/** Fallback anchor: this plugin's own package when the aggregate is absent. */
|
|
@@ -3478,7 +3782,7 @@ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
|
|
|
3478
3782
|
controller.abort();
|
|
3479
3783
|
}, timeoutMs);
|
|
3480
3784
|
try {
|
|
3481
|
-
const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", { signal: controller.signal });
|
|
3785
|
+
const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest", withIdentityEncoding({ signal: controller.signal }));
|
|
3482
3786
|
if (!response.ok) return void 0;
|
|
3483
3787
|
const body = await response.json();
|
|
3484
3788
|
if (typeof body !== "object" || body === null) return void 0;
|
|
@@ -3550,13 +3854,13 @@ async function fetchGitHubReleaseNotes(version, fetchImpl = fetch, timeoutMs = 1
|
|
|
3550
3854
|
controller.abort();
|
|
3551
3855
|
}, timeoutMs);
|
|
3552
3856
|
try {
|
|
3553
|
-
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, {
|
|
3857
|
+
const response = await fetchImpl(`https://api.github.com/repos/${UPDATE_RELEASE_REPO}/releases/tags/v${version}`, withIdentityEncoding({
|
|
3554
3858
|
headers: {
|
|
3555
3859
|
accept: "application/vnd.github+json",
|
|
3556
3860
|
"user-agent": "dsh-web-update"
|
|
3557
3861
|
},
|
|
3558
3862
|
signal: controller.signal
|
|
3559
|
-
});
|
|
3863
|
+
}));
|
|
3560
3864
|
if (!response.ok) return void 0;
|
|
3561
3865
|
const body = await response.json();
|
|
3562
3866
|
if (typeof body !== "object" || body === null) return void 0;
|
|
@@ -3650,6 +3954,13 @@ async function checkUpdates(deps) {
|
|
|
3650
3954
|
/** Cap on captured pnpm output (keeps error payloads bounded). */
|
|
3651
3955
|
const OUTPUT_CAP = 16 * 1024;
|
|
3652
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
|
+
/**
|
|
3653
3964
|
* Windows cmd command-not-found stderr. With shell:true a missing shim
|
|
3654
3965
|
* exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
|
|
3655
3966
|
* detects this message instead of the spawn error event. Supports both English
|
|
@@ -3657,14 +3968,14 @@ const OUTPUT_CAP = 16 * 1024;
|
|
|
3657
3968
|
*/
|
|
3658
3969
|
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i;
|
|
3659
3970
|
/**
|
|
3660
|
-
*
|
|
3661
|
-
*
|
|
3662
|
-
*
|
|
3663
|
-
*
|
|
3664
|
-
* accumulator — decoding per \`data\` event would split multi-byte characters.
|
|
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.
|
|
3665
3975
|
*/
|
|
3666
|
-
function
|
|
3667
|
-
|
|
3976
|
+
function capOutput(output, text) {
|
|
3977
|
+
const combined = output + text;
|
|
3978
|
+
return combined.length > OUTPUT_CAP ? combined.slice(combined.length - OUTPUT_CAP) : combined;
|
|
3668
3979
|
}
|
|
3669
3980
|
/**
|
|
3670
3981
|
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
@@ -3686,8 +3997,8 @@ const MIN_RELEASE_AGE_OVERRIDE = "--config.minimumReleaseAge=0";
|
|
|
3686
3997
|
function runUpdate(deps) {
|
|
3687
3998
|
return new Promise((resolve) => {
|
|
3688
3999
|
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
3689
|
-
const packages = deps.packages;
|
|
3690
4000
|
const platform = deps.platform ?? process.platform;
|
|
4001
|
+
const packages = deps.packages.filter((name) => SHELL_SAFE_PACKAGE_RE.test(name));
|
|
3691
4002
|
const spawnOptions = {
|
|
3692
4003
|
cwd: deps.profileDir,
|
|
3693
4004
|
stdio: [
|
|
@@ -3695,8 +4006,9 @@ function runUpdate(deps) {
|
|
|
3695
4006
|
"pipe",
|
|
3696
4007
|
"pipe"
|
|
3697
4008
|
],
|
|
3698
|
-
...platform === "win32" ? { shell: true } : {}
|
|
4009
|
+
...platform === "win32" ? { shell: true } : { detached: true }
|
|
3699
4010
|
};
|
|
4011
|
+
const killImpl = deps.killImpl ?? process.kill;
|
|
3700
4012
|
const candidates = [
|
|
3701
4013
|
{
|
|
3702
4014
|
command: "pnpm",
|
|
@@ -3730,13 +4042,18 @@ function runUpdate(deps) {
|
|
|
3730
4042
|
}
|
|
3731
4043
|
];
|
|
3732
4044
|
let output = "";
|
|
3733
|
-
let
|
|
4045
|
+
let capture = createOutputCapture(OUTPUT_CAP);
|
|
4046
|
+
let candidateFolded = false;
|
|
3734
4047
|
const append = (chunk) => {
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
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());
|
|
3740
4057
|
};
|
|
3741
4058
|
let currentChild;
|
|
3742
4059
|
let finished = false;
|
|
@@ -3756,9 +4073,20 @@ function runUpdate(deps) {
|
|
|
3756
4073
|
String(pid),
|
|
3757
4074
|
"/t",
|
|
3758
4075
|
"/f"
|
|
3759
|
-
], { stdio: "ignore" });
|
|
4076
|
+
], { stdio: "ignore" }).on?.("error", () => {});
|
|
3760
4077
|
} catch {}
|
|
3761
|
-
} else
|
|
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();
|
|
3762
4090
|
finish({
|
|
3763
4091
|
ok: false,
|
|
3764
4092
|
exitCode: null,
|
|
@@ -3780,7 +4108,8 @@ function runUpdate(deps) {
|
|
|
3780
4108
|
return;
|
|
3781
4109
|
}
|
|
3782
4110
|
const candidate = candidates[index];
|
|
3783
|
-
|
|
4111
|
+
capture = createOutputCapture(OUTPUT_CAP);
|
|
4112
|
+
candidateFolded = false;
|
|
3784
4113
|
const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
|
|
3785
4114
|
currentChild = child;
|
|
3786
4115
|
let settled = false;
|
|
@@ -3793,6 +4122,7 @@ function runUpdate(deps) {
|
|
|
3793
4122
|
child.stderr?.on("data", append);
|
|
3794
4123
|
child.on("error", (error) => {
|
|
3795
4124
|
once(() => {
|
|
4125
|
+
foldCandidate();
|
|
3796
4126
|
if (error.code === "ENOENT") runCandidate(index + 1);
|
|
3797
4127
|
else finish({
|
|
3798
4128
|
ok: false,
|
|
@@ -3805,11 +4135,13 @@ function runUpdate(deps) {
|
|
|
3805
4135
|
});
|
|
3806
4136
|
child.on("close", (code) => {
|
|
3807
4137
|
if (settled || finished) return;
|
|
3808
|
-
if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(
|
|
4138
|
+
if (platform === "win32" && (code === 9009 || code !== 0 && WIN_CMD_MISSING_RE.test(candidateText()))) {
|
|
4139
|
+
foldCandidate();
|
|
3809
4140
|
runCandidate(index + 1);
|
|
3810
4141
|
return;
|
|
3811
4142
|
}
|
|
3812
4143
|
settled = true;
|
|
4144
|
+
foldCandidate();
|
|
3813
4145
|
finish({
|
|
3814
4146
|
ok: code === 0,
|
|
3815
4147
|
exitCode: code,
|
|
@@ -3982,30 +4314,6 @@ function mountOnce(packageName, fn) {
|
|
|
3982
4314
|
return fn(...args);
|
|
3983
4315
|
});
|
|
3984
4316
|
}
|
|
3985
|
-
/** The live rule set. */
|
|
3986
|
-
const REMOTE_CHANNEL_RULES = {
|
|
3987
|
-
remotePrefix: "/remote",
|
|
3988
|
-
apiPrefix: "/api/",
|
|
3989
|
-
pairPrefix: "/api/pair/",
|
|
3990
|
-
updatePrefix: "/api/update/",
|
|
3991
|
-
settingsBridgePrefix: "/api/dsh-web-ui-settings",
|
|
3992
|
-
sidebarPrefix: "/sidebar/",
|
|
3993
|
-
gitPrefix: "/git/",
|
|
3994
|
-
petPrefix: "/pet/",
|
|
3995
|
-
wsPaths: [
|
|
3996
|
-
"/api/remote.mux",
|
|
3997
|
-
"/sidebar/ws/terminal",
|
|
3998
|
-
"/sidebar/ws/agent-terminals",
|
|
3999
|
-
"/api/dsh-ssh/terminal"
|
|
4000
|
-
],
|
|
4001
|
-
deviceHeader: REMOTE_DEVICE_HEADER,
|
|
4002
|
-
deviceKey: "dsh-remote-device",
|
|
4003
|
-
deviceQuery: REMOTE_DEVICE_QUERY,
|
|
4004
|
-
uploadPath: "/api/session/uploadFileBinary",
|
|
4005
|
-
uploadHookGlobal: "__DSH_FILE_UPLOAD__"
|
|
4006
|
-
};
|
|
4007
|
-
/** The window global the boot patch publishes its seat under. */
|
|
4008
|
-
const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
4009
4317
|
//#endregion
|
|
4010
4318
|
//#region src/remote-channel-boot.ts
|
|
4011
4319
|
/**
|
|
@@ -4027,7 +4335,12 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
|
4027
4335
|
* transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
|
|
4028
4336
|
* connection plugin reads it: the paired remote desktop gets the full
|
|
4029
4337
|
* settings/credentials/presets surface, and every call still rides the
|
|
4030
|
-
* gated /remote channel.
|
|
4338
|
+
* gated /remote channel. Host mode is server-granted, not origin-asserted:
|
|
4339
|
+
* the hook is installed only when the device-gated app landing (/pair-app)
|
|
4340
|
+
* published the grant marker (REMOTE_HOST_GRANT_GLOBAL) ahead of this
|
|
4341
|
+
* script, so a shell served to an unpaired browser - a fence-open
|
|
4342
|
+
* deployment - never presents itself as the machine owner. It finally
|
|
4343
|
+
* publishes the official pre-Cordis
|
|
4031
4344
|
* upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
|
|
4032
4345
|
* transport otherwise runs inside a Web Worker whose own globals the
|
|
4033
4346
|
* main-thread rewrite cannot reach (issue #1580). The script self-skips on
|
|
@@ -4042,7 +4355,7 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
|
|
|
4042
4355
|
function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
|
|
4043
4356
|
const json = JSON.stringify(rules);
|
|
4044
4357
|
const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
|
|
4045
|
-
return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}
|
|
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){}})();";
|
|
4046
4359
|
}
|
|
4047
4360
|
/** Boot-watchdog latch: one self-reload per session while the boot is broken. */
|
|
4048
4361
|
const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
|
|
@@ -4114,9 +4427,12 @@ const inject = [
|
|
|
4114
4427
|
"connection"
|
|
4115
4428
|
];
|
|
4116
4429
|
/**
|
|
4117
|
-
* Settings namespace of the remote-control capability
|
|
4118
|
-
* settings
|
|
4119
|
-
*
|
|
4430
|
+
* Settings namespace of the remote-control capability. Under the 0.1.7
|
|
4431
|
+
* settings model the namespace IS the Host profile entry id, so this is also
|
|
4432
|
+
* the id the browser half asks `ctx.configForms` for (the family binder
|
|
4433
|
+
* resolves the same value to the owning entry). Spelled here rather than
|
|
4434
|
+
* imported: the browser half spells the same value and must not depend on a
|
|
4435
|
+
* Host package.
|
|
4120
4436
|
*/
|
|
4121
4437
|
const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
|
|
4122
4438
|
const Config = z.object({
|
|
@@ -4139,10 +4455,15 @@ const Config = z.object({
|
|
|
4139
4455
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
4140
4456
|
const SWEEP_INTERVAL_MS = 1e4;
|
|
4141
4457
|
/**
|
|
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.
|
|
4461
|
+
*/
|
|
4462
|
+
const UPDATE_STATUS_TTL_MS = 6e4;
|
|
4463
|
+
/**
|
|
4142
4464
|
* The single mapping from resolved plugin config to the pairing service
|
|
4143
|
-
* config. Both the constructed service and every
|
|
4144
|
-
* it, so no field can be silently dropped
|
|
4145
|
-
* pushes a new value into the running service.
|
|
4465
|
+
* config. Both the constructed service and every later apply of this row
|
|
4466
|
+
* reuse it, so no field can be silently dropped.
|
|
4146
4467
|
*/
|
|
4147
4468
|
function pairingConfigOf(resolved) {
|
|
4148
4469
|
return {
|
|
@@ -4201,27 +4522,7 @@ function applyImpl(ctx, config) {
|
|
|
4201
4522
|
profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
4202
4523
|
enabled: config?.enabled ?? DEFAULTS.enabled
|
|
4203
4524
|
};
|
|
4204
|
-
|
|
4205
|
-
const resolve = () => {
|
|
4206
|
-
const value = current();
|
|
4207
|
-
return {
|
|
4208
|
-
tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
4209
|
-
offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
4210
|
-
maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
|
|
4211
|
-
idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
4212
|
-
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
4213
|
-
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
4214
|
-
publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
|
|
4215
|
-
trustedHosts: value.trustedHosts,
|
|
4216
|
-
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
4217
|
-
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
4218
|
-
tunnelToken: value.tunnelToken,
|
|
4219
|
-
relay: value.relay ?? DEFAULTS.relay,
|
|
4220
|
-
lanBind: value.lanBind,
|
|
4221
|
-
profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
4222
|
-
enabled: value.enabled ?? DEFAULTS.enabled
|
|
4223
|
-
};
|
|
4224
|
-
};
|
|
4525
|
+
const resolve = () => resolved;
|
|
4225
4526
|
const service = new PairingService(pairingConfigOf(resolved));
|
|
4226
4527
|
const tunnel = new TunnelManager();
|
|
4227
4528
|
let relayRegistrar;
|
|
@@ -4321,14 +4622,31 @@ function applyImpl(ctx, config) {
|
|
|
4321
4622
|
});
|
|
4322
4623
|
return notes;
|
|
4323
4624
|
};
|
|
4324
|
-
|
|
4325
|
-
|
|
4326
|
-
|
|
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({
|
|
4327
4632
|
anchorManifestPath: resolveAnchorPath(),
|
|
4328
4633
|
resolve: hostResolve,
|
|
4329
4634
|
fetchLatest: (name) => fetchLatestVersion(name, fetch),
|
|
4330
4635
|
fetchReleaseNotes: fetchReleaseNotesCached
|
|
4331
|
-
})
|
|
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(),
|
|
4332
4650
|
run: async () => {
|
|
4333
4651
|
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() });
|
|
4334
4652
|
if ("error" in target) {
|
|
@@ -4341,7 +4659,7 @@ function applyImpl(ctx, config) {
|
|
|
4341
4659
|
errorCode: code
|
|
4342
4660
|
};
|
|
4343
4661
|
}
|
|
4344
|
-
|
|
4662
|
+
const result = await runUpdateVerified({
|
|
4345
4663
|
run: {
|
|
4346
4664
|
profileDir: target.profileDir,
|
|
4347
4665
|
packages: target.packages
|
|
@@ -4353,6 +4671,8 @@ function applyImpl(ctx, config) {
|
|
|
4353
4671
|
fetchReleaseNotes: fetchReleaseNotesCached
|
|
4354
4672
|
}
|
|
4355
4673
|
});
|
|
4674
|
+
updateStatusCache = void 0;
|
|
4675
|
+
return result;
|
|
4356
4676
|
}
|
|
4357
4677
|
});
|
|
4358
4678
|
let lastKnownPort;
|
|
@@ -4400,8 +4720,11 @@ function applyImpl(ctx, config) {
|
|
|
4400
4720
|
if (appShellCache !== void 0 && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html;
|
|
4401
4721
|
const cookie = await innerAuth.ready();
|
|
4402
4722
|
try {
|
|
4403
|
-
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, { headers: cookie !== void 0 ? { cookie } : void 0 });
|
|
4404
|
-
if (!response.ok)
|
|
4723
|
+
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, withIdentityEncoding({ headers: cookie !== void 0 ? { cookie } : void 0 }));
|
|
4724
|
+
if (!response.ok) {
|
|
4725
|
+
if (response.status === 401 || response.status === 403) innerAuth.invalidate();
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4405
4728
|
const html = await response.text();
|
|
4406
4729
|
appShellCache = {
|
|
4407
4730
|
at: Date.now(),
|
|
@@ -4438,7 +4761,6 @@ function applyImpl(ctx, config) {
|
|
|
4438
4761
|
...makeRemoteApiRoutes({
|
|
4439
4762
|
service,
|
|
4440
4763
|
port: ctx.webServer.port,
|
|
4441
|
-
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
4442
4764
|
auth: innerAuth
|
|
4443
4765
|
}),
|
|
4444
4766
|
...updateRoutes
|
|
@@ -4446,7 +4768,6 @@ function applyImpl(ctx, config) {
|
|
|
4446
4768
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
4447
4769
|
service,
|
|
4448
4770
|
port: ctx.webServer.port,
|
|
4449
|
-
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
4450
4771
|
auth: innerAuth
|
|
4451
4772
|
});
|
|
4452
4773
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
|
|
@@ -4557,12 +4878,14 @@ function applyImpl(ctx, config) {
|
|
|
4557
4878
|
targetUrl: plan.targetUrl,
|
|
4558
4879
|
originHostHeader
|
|
4559
4880
|
});
|
|
4560
|
-
} else if (plan.mode === "named")
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
|
|
4565
|
-
|
|
4881
|
+
} else if (plan.mode === "named") {
|
|
4882
|
+
publicBase.markRunning(plan.publicUrl);
|
|
4883
|
+
tunnel.start({
|
|
4884
|
+
kind: "named",
|
|
4885
|
+
token: plan.token,
|
|
4886
|
+
publicUrl: plan.publicUrl
|
|
4887
|
+
});
|
|
4888
|
+
} else {
|
|
4566
4889
|
tunnel.stop();
|
|
4567
4890
|
if (value.tunnelToken !== void 0 && value.tunnelToken !== "") console.warn("remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it");
|
|
4568
4891
|
if (value.publicBaseUrl !== void 0 && !isHttpUrl(value.publicBaseUrl)) {
|
|
@@ -4571,7 +4894,12 @@ function applyImpl(ctx, config) {
|
|
|
4571
4894
|
} else service.setPublicBaseUrl(value.publicBaseUrl);
|
|
4572
4895
|
}
|
|
4573
4896
|
const enabled = value.enabled;
|
|
4574
|
-
if (!enabled)
|
|
4897
|
+
if (!enabled) {
|
|
4898
|
+
service.stop();
|
|
4899
|
+
tunnel.stop();
|
|
4900
|
+
disposeRelayRegistrar();
|
|
4901
|
+
publicBase.reset();
|
|
4902
|
+
}
|
|
4575
4903
|
if (disposeRoutes === void 0 && enabled) disposeRoutes = ctx.effect(() => {
|
|
4576
4904
|
const disposers = [...routes.map((route) => ctx.webServer.register(route)), ...upgrades.map((route) => ctx.webServer.registerUpgrade(route))];
|
|
4577
4905
|
return () => {
|
|
@@ -4613,25 +4941,6 @@ function applyImpl(ctx, config) {
|
|
|
4613
4941
|
text: REMOTE_CHANNEL_BOOT_SCRIPT
|
|
4614
4942
|
});
|
|
4615
4943
|
}), "remote-web-ui: remote channel boot patch");
|
|
4616
|
-
ctx.inject(["settings"], (settingsCtx) => {
|
|
4617
|
-
try {
|
|
4618
|
-
if (typeof settingsCtx.settings?.installSection === "function") settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
4619
|
-
setSource: (source) => {
|
|
4620
|
-
current = source;
|
|
4621
|
-
sync();
|
|
4622
|
-
},
|
|
4623
|
-
onChange: sync
|
|
4624
|
-
});
|
|
4625
|
-
else if (typeof settingsCtx.settings?.register === "function") {
|
|
4626
|
-
const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} });
|
|
4627
|
-
current = () => scope?.get?.() ?? config ?? {};
|
|
4628
|
-
scope?.watch?.(() => {
|
|
4629
|
-
sync();
|
|
4630
|
-
});
|
|
4631
|
-
sync();
|
|
4632
|
-
}
|
|
4633
|
-
} catch {}
|
|
4634
|
-
});
|
|
4635
4944
|
sync();
|
|
4636
4945
|
}
|
|
4637
4946
|
//#endregion
|