@linxin666/dsh-remote-web-ui 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +9 -7
- package/README.zh.md +9 -7
- package/lib/client.js +282 -619
- package/lib/client.js.map +1 -1
- package/lib/index.js +418 -901
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +9 -10
- 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 +56 -11
- 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 +2 -86
- package/lib/types/client/pair-api.d.ts +4 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +11 -7
- package/lib/types/client/remote-channel.d.ts +27 -7
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +25 -17
- 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 +104 -137
- 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 +5 -1
- package/lib/types/lan-bind.d.ts.map +1 -1
- package/lib/types/lan-bind.js +65 -2
- 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 +15 -2
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +26 -14
- package/lib/types/pairing.persist.test.js +43 -3
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +13 -3
- package/lib/types/remote-channel-rules.d.ts +74 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +104 -0
- 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 +21 -20
- package/src/client/RemoteEntry.tsx +8 -10
- package/src/client/RemoteSettingsCard.tsx +0 -2
- package/src/client/index.ts +58 -12
- package/src/client/locales.ts +2 -86
- package/src/client/pair-api.ts +11 -7
- package/src/client/remote-channel.ts +38 -15
- package/src/client/remote.module.css +61 -193
- package/src/client/settings-entry-form.ts +142 -0
- package/src/index.ts +156 -152
- package/src/lan-bind-plan.ts +22 -0
- package/src/lan-bind.ts +63 -2
- package/src/mount-once.ts +107 -13
- package/src/pairing.persist.test.ts +47 -3
- package/src/pairing.ts +26 -12
- package/src/remote-channel-boot.ts +13 -3
- package/src/remote-channel-rules.ts +113 -0
- 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 -272
- package/lib/types/update.d.ts.map +0 -1
- package/lib/types/update.js +0 -744
- 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 -882
package/lib/types/mount-once.js
CHANGED
|
@@ -5,38 +5,122 @@
|
|
|
5
5
|
* the loader accepts a standalone install of the same package side by side;
|
|
6
6
|
* without this guard the second instance would still re-register the same
|
|
7
7
|
* webserver routes, tools, settings namespaces, and system-prompt sections
|
|
8
|
-
* and fail the boot. mountOnce makes
|
|
9
|
-
*
|
|
10
|
-
* package name in the client module host).
|
|
8
|
+
* and fail the boot. mountOnce makes a mount of an already-mounted package a
|
|
9
|
+
* no-op for as long as the first instance lives (the browser half is already
|
|
10
|
+
* deduped by package name in the client module host).
|
|
11
|
+
*
|
|
12
|
+
* A no-op is only safe while the holder is ALIVE. The holder can be disposed
|
|
13
|
+
* long after the refused mount ran its course: the Host reloads a profile by
|
|
14
|
+
* creating the new loader entries before the old ones are torn down (a
|
|
15
|
+
* plugin-manager enable/disable/install write, a settings-driven row reload,
|
|
16
|
+
* HMR), so the new aggregate shell entry mounts its family plugin while the
|
|
17
|
+
* previous entry still owns the name. Dropping that refused mount lost the
|
|
18
|
+
* plugin for good - the previous entry then disposed its own mount, releasing
|
|
19
|
+
* the name with nobody left to take it, and the family row stayed listed as
|
|
20
|
+
* active while its host routes 404ed (the task-board panel showed
|
|
21
|
+
* "board.hostError.notMounted", no degraded record appeared, and only a Host
|
|
22
|
+
* restart recovered it).
|
|
23
|
+
*
|
|
24
|
+
* The refused mount is therefore QUEUED, not dropped, and replayed the moment
|
|
25
|
+
* the holder releases the name - if the waiting fiber is still alive then. The
|
|
26
|
+
* single-instance guarantee is unchanged: exactly one mount is live per
|
|
27
|
+
* package name, and the replay re-enters the guard so a later mount still
|
|
28
|
+
* dedupes against it.
|
|
11
29
|
*
|
|
12
30
|
* The registry rides a global symbol so two module instances of the same
|
|
13
|
-
* package (npm copy vs repository link) still share one verdict.
|
|
14
|
-
*
|
|
31
|
+
* package (npm copy vs repository link) still share one verdict. That symbol
|
|
32
|
+
* is a CROSS-REPOSITORY contract, not this file's private state: the four
|
|
33
|
+
* satellite packages (dsh-skins / dsh-pet / dsh-presets /
|
|
34
|
+
* dsh-community-plugins) are separate repositories carrying their own copy of
|
|
35
|
+
* this guard, rebuilt on their own schedule, so the value under `MOUNTED`
|
|
36
|
+
* must keep the shape every published copy reads (a `Set` of package names
|
|
37
|
+
* with `has`/`add`/`delete`). The wait queues this guard added therefore live
|
|
38
|
+
* under their own additive key, and the registry reads back a `Set` even when
|
|
39
|
+
* some other build left a different value there. Changing `MOUNTED`'s shape
|
|
40
|
+
* in place broke that contract once: a satellite's legacy copy created a
|
|
41
|
+
* `Set`, the family's new copy read it as a `Map`, and every family row
|
|
42
|
+
* mounted after it failed with "claims.get is not a function".
|
|
43
|
+
*
|
|
44
|
+
* cordis `ctx.effect` runs its callback immediately and treats the callback's
|
|
15
45
|
* return value as the fiber disposer, so the unmarker is returned, not run.
|
|
16
46
|
*/
|
|
47
|
+
/** Published cross-repository contract: package names currently mounted. */
|
|
17
48
|
const MOUNTED = Symbol.for('dsh-web.mounted-plugins');
|
|
49
|
+
/** Additive key this guard owns: refused mounts waiting for the name. */
|
|
50
|
+
const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters');
|
|
51
|
+
/**
|
|
52
|
+
* The shared name registry, always a `Set` whatever another build stored here:
|
|
53
|
+
* a foreign value (an interim shape, a hand-written global) must not take every
|
|
54
|
+
* family plugin down with it.
|
|
55
|
+
* @returns the process-wide set of mounted package names.
|
|
56
|
+
*/
|
|
18
57
|
function mountedSet() {
|
|
19
58
|
const registry = globalThis;
|
|
20
|
-
|
|
59
|
+
const existing = registry[MOUNTED];
|
|
60
|
+
if (existing instanceof Set)
|
|
61
|
+
return existing;
|
|
62
|
+
const created = new Set();
|
|
63
|
+
registry[MOUNTED] = created;
|
|
64
|
+
return created;
|
|
65
|
+
}
|
|
66
|
+
/** Queue per package name for mounts refused while a holder was alive. */
|
|
67
|
+
function mountWaiters() {
|
|
68
|
+
const registry = globalThis;
|
|
69
|
+
return (registry[WAITERS] ??= new Map());
|
|
21
70
|
}
|
|
22
71
|
/**
|
|
23
72
|
* Wrap a cordis plugin apply so the package runs at most once per process.
|
|
24
|
-
* The first mount registers normally and
|
|
25
|
-
*
|
|
73
|
+
* The first mount registers normally and releases the name when its fiber
|
|
74
|
+
* disposes; a mount refused while that name is held waits for the release and
|
|
75
|
+
* then runs, unless its own fiber disposes first.
|
|
26
76
|
* @param packageName - npm package identity shared by every install source.
|
|
27
77
|
* @param fn - the original plugin apply.
|
|
28
78
|
* @returns an apply of the same shape.
|
|
29
79
|
*/
|
|
30
80
|
export function mountOnce(packageName, fn) {
|
|
31
|
-
|
|
81
|
+
const mount = (...args) => {
|
|
32
82
|
const mounted = mountedSet();
|
|
33
|
-
|
|
83
|
+
const ctx = args[0];
|
|
84
|
+
if (mounted.has(packageName)) {
|
|
85
|
+
// Another fiber (the aggregate row, a standalone install, or a copy built
|
|
86
|
+
// from an older revision of this guard) owns the name. Queue instead of
|
|
87
|
+
// dropping: dropping lost the plugin for the rest of the process when the
|
|
88
|
+
// holder was disposed by a reload.
|
|
89
|
+
const waiters = mountWaiters();
|
|
90
|
+
const queue = waiters.get(packageName) ?? [];
|
|
91
|
+
let alive = true;
|
|
92
|
+
const pending = {
|
|
93
|
+
run: () => {
|
|
94
|
+
if (alive)
|
|
95
|
+
mount(...args);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
// A fiber that dies while it waits must not be replayed afterwards: its
|
|
99
|
+
// context is disposed and registering effects on it would throw.
|
|
100
|
+
ctx?.effect?.(() => () => {
|
|
101
|
+
alive = false;
|
|
102
|
+
const index = queue.indexOf(pending);
|
|
103
|
+
if (index >= 0)
|
|
104
|
+
queue.splice(index, 1);
|
|
105
|
+
});
|
|
106
|
+
queue.push(pending);
|
|
107
|
+
waiters.set(packageName, queue);
|
|
34
108
|
return;
|
|
109
|
+
}
|
|
35
110
|
mounted.add(packageName);
|
|
36
|
-
const ctx = args[0];
|
|
37
111
|
ctx?.effect?.(() => () => {
|
|
38
112
|
mounted.delete(packageName);
|
|
113
|
+
const waiters = mountWaiters();
|
|
114
|
+
const queue = waiters.get(packageName);
|
|
115
|
+
if (queue === undefined)
|
|
116
|
+
return;
|
|
117
|
+
waiters.delete(packageName);
|
|
118
|
+
// Defer the handover one microtask so the disposing mount's own effects
|
|
119
|
+
// (routes, ledger, timers) are torn down before the next one registers.
|
|
120
|
+
for (const waiter of queue.splice(0))
|
|
121
|
+
queueMicrotask(() => { waiter.run(); });
|
|
39
122
|
});
|
|
40
123
|
return fn(...args);
|
|
41
|
-
}
|
|
124
|
+
};
|
|
125
|
+
return mount;
|
|
42
126
|
}
|
package/lib/types/pairing.d.ts
CHANGED
|
@@ -219,10 +219,16 @@ export declare class PairingService {
|
|
|
219
219
|
* Restore device sessions persisted by a previous process run. A corrupt
|
|
220
220
|
* or missing file is tolerated (an empty device table, never a throw) —
|
|
221
221
|
* persistence is an availability convenience, not a security boundary.
|
|
222
|
+
*
|
|
223
|
+
* Every still-valid session is restored, deliberately without applying
|
|
224
|
+
* `maxDevices`: the constructor runs with whatever cap the configuration
|
|
225
|
+
* layer has delivered so far (the schema default until the saved settings
|
|
226
|
+
* row is applied), and trimming here would permanently drop authorizations
|
|
227
|
+
* the user never revoked — the trimmed table reaches disk on the next
|
|
228
|
+
* heartbeat/sweep write. The cap is enforced where a device is admitted
|
|
229
|
+
* (see `accept`), against the configuration in force at that moment.
|
|
222
230
|
*/
|
|
223
231
|
private loadPersisted;
|
|
224
|
-
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
225
|
-
private clampToMaxDevices;
|
|
226
232
|
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
227
233
|
private evictIdle;
|
|
228
234
|
/**
|
|
@@ -339,6 +345,13 @@ export declare class PairingService {
|
|
|
339
345
|
snapshot(): PairingSnapshot;
|
|
340
346
|
/** Whether a cookie value names a currently live (non-idle) device session. */
|
|
341
347
|
hasDevice(deviceId: string): boolean;
|
|
348
|
+
/**
|
|
349
|
+
* Number of device sessions in the table, including idle ones whose idle
|
|
350
|
+
* window has not been swept yet. Restore keeps every persisted session
|
|
351
|
+
* (see `loadPersisted`), so this can exceed the configured cap until the
|
|
352
|
+
* next `accept` trims the excess.
|
|
353
|
+
*/
|
|
354
|
+
deviceCount(): number;
|
|
342
355
|
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
343
356
|
onState(listener: (snapshot: PairingSnapshot) => void): () => void;
|
|
344
357
|
private activeToken;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
|
package/lib/types/pairing.js
CHANGED
|
@@ -95,6 +95,14 @@ export class PairingService {
|
|
|
95
95
|
* Restore device sessions persisted by a previous process run. A corrupt
|
|
96
96
|
* or missing file is tolerated (an empty device table, never a throw) —
|
|
97
97
|
* persistence is an availability convenience, not a security boundary.
|
|
98
|
+
*
|
|
99
|
+
* Every still-valid session is restored, deliberately without applying
|
|
100
|
+
* `maxDevices`: the constructor runs with whatever cap the configuration
|
|
101
|
+
* layer has delivered so far (the schema default until the saved settings
|
|
102
|
+
* row is applied), and trimming here would permanently drop authorizations
|
|
103
|
+
* the user never revoked — the trimmed table reaches disk on the next
|
|
104
|
+
* heartbeat/sweep write. The cap is enforced where a device is admitted
|
|
105
|
+
* (see `accept`), against the configuration in force at that moment.
|
|
98
106
|
*/
|
|
99
107
|
loadPersisted() {
|
|
100
108
|
const file = this.config.devicesFile;
|
|
@@ -119,7 +127,6 @@ export class PairingService {
|
|
|
119
127
|
...(label !== undefined ? { userAgent: label } : {}),
|
|
120
128
|
});
|
|
121
129
|
}
|
|
122
|
-
this.clampToMaxDevices();
|
|
123
130
|
if (this.evictIdle())
|
|
124
131
|
this.persistRevocation();
|
|
125
132
|
}
|
|
@@ -127,15 +134,6 @@ export class PairingService {
|
|
|
127
134
|
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
|
-
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
131
|
-
clampToMaxDevices() {
|
|
132
|
-
if (this.devices.size <= this.config.maxDevices)
|
|
133
|
-
return;
|
|
134
|
-
const overflow = this.devices.size - this.config.maxDevices;
|
|
135
|
-
const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
136
|
-
for (const [id] of ordered.slice(0, overflow))
|
|
137
|
-
this.devices.delete(id);
|
|
138
|
-
}
|
|
139
137
|
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
140
138
|
evictIdle() {
|
|
141
139
|
const now = this.clock.now();
|
|
@@ -302,15 +300,20 @@ export class PairingService {
|
|
|
302
300
|
}
|
|
303
301
|
const deviceId = this.clock.randomToken();
|
|
304
302
|
const now = this.clock.now();
|
|
305
|
-
|
|
306
|
-
|
|
303
|
+
// FIFO-evict until a slot is free. The loop (not a single delete) covers a
|
|
304
|
+
// table that is already over the cap: restore keeps every persisted session
|
|
305
|
+
// (see loadPersisted), so the excess is collected here, once the store is
|
|
306
|
+
// actually asked to admit a new device, rather than at startup on a cap the
|
|
307
|
+
// configuration may not have delivered yet.
|
|
308
|
+
while (this.devices.size >= this.config.maxDevices) {
|
|
307
309
|
let oldest;
|
|
308
310
|
for (const [id, session] of this.devices) {
|
|
309
311
|
if (oldest === undefined || session.createdAt < oldest.createdAt)
|
|
310
312
|
oldest = { id, createdAt: session.createdAt };
|
|
311
313
|
}
|
|
312
|
-
if (oldest
|
|
313
|
-
|
|
314
|
+
if (oldest === undefined)
|
|
315
|
+
break;
|
|
316
|
+
this.devices.delete(oldest.id);
|
|
314
317
|
}
|
|
315
318
|
const label = sanitizeUserAgent(userAgent);
|
|
316
319
|
this.devices.set(deviceId, {
|
|
@@ -414,6 +417,15 @@ export class PairingService {
|
|
|
414
417
|
hasDevice(deviceId) {
|
|
415
418
|
return this.liveSession(deviceId) !== undefined;
|
|
416
419
|
}
|
|
420
|
+
/**
|
|
421
|
+
* Number of device sessions in the table, including idle ones whose idle
|
|
422
|
+
* window has not been swept yet. Restore keeps every persisted session
|
|
423
|
+
* (see `loadPersisted`), so this can exceed the configured cap until the
|
|
424
|
+
* next `accept` trims the excess.
|
|
425
|
+
*/
|
|
426
|
+
deviceCount() {
|
|
427
|
+
return this.devices.size;
|
|
428
|
+
}
|
|
417
429
|
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
418
430
|
onState(listener) {
|
|
419
431
|
this.listeners.add(listener);
|
|
@@ -86,17 +86,57 @@ describe('PairingService device persistence', () => {
|
|
|
86
86
|
const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
|
|
87
87
|
expect(restarted.hasDevice(deviceId)).toBe(true);
|
|
88
88
|
});
|
|
89
|
-
|
|
89
|
+
// #1696: the constructor runs before the saved settings row is applied, so
|
|
90
|
+
// its cap can still be the schema default. Restoring must not enforce it —
|
|
91
|
+
// trimming there silently revokes authorizations the user never revoked,
|
|
92
|
+
// and the trimmed table reaches disk on the next heartbeat/sweep write.
|
|
93
|
+
it('operator: every valid persisted session survives a restart under the startup cap', () => {
|
|
90
94
|
const file = join(dir, 'devices.json');
|
|
95
|
+
// Given a store holding three devices while the startup cap reads 4
|
|
96
|
+
// (the schema default) and the saved settings value is 12.
|
|
91
97
|
writeFileSync(file, JSON.stringify({
|
|
92
98
|
dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
|
|
93
99
|
dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
|
|
94
100
|
dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
|
|
95
101
|
}));
|
|
96
|
-
|
|
97
|
-
|
|
102
|
+
// When a new process restores the store.
|
|
103
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock());
|
|
104
|
+
// Then no paired device lost its authorization to the transient cap.
|
|
105
|
+
expect(service.hasDevice('dev1')).toBe(true);
|
|
98
106
|
expect(service.hasDevice('dev2')).toBe(true);
|
|
107
|
+
expect(service.hasDevice('dev3')).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('operator: a restored over-cap table is not trimmed by a heartbeat and sweep', () => {
|
|
110
|
+
const file = join(dir, 'devices.json');
|
|
111
|
+
// Given twelve persisted devices restored under the default cap of 4.
|
|
112
|
+
const rows = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`dev${i}`, { createdAt: 1_000 + i, lastSeenAt: 1_000 + i }]));
|
|
113
|
+
writeFileSync(file, JSON.stringify(rows));
|
|
114
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock());
|
|
115
|
+
// When the saved cap of 12 arrives and normal traffic writes the store.
|
|
116
|
+
service.config = { ...service.config, maxDevices: 12 };
|
|
117
|
+
service.heartbeat('dev0');
|
|
118
|
+
service.sweep();
|
|
119
|
+
// Then the file still names all twelve devices.
|
|
120
|
+
const persisted = JSON.parse(readFileSync(file, 'utf8'));
|
|
121
|
+
expect(Object.keys(persisted)).toHaveLength(12);
|
|
122
|
+
expect(service.deviceCount()).toBe(12);
|
|
123
|
+
});
|
|
124
|
+
it('operator: the cap is enforced when a device is admitted, not before', () => {
|
|
125
|
+
const file = join(dir, 'devices.json');
|
|
126
|
+
// Given three persisted devices restored under a cap of 2.
|
|
127
|
+
writeFileSync(file, JSON.stringify({
|
|
128
|
+
dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
|
|
129
|
+
dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
|
|
130
|
+
dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
|
|
131
|
+
}));
|
|
132
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock());
|
|
133
|
+
expect(service.deviceCount()).toBe(3);
|
|
134
|
+
// When one more device pairs.
|
|
135
|
+
const paired = pairDevice(service);
|
|
136
|
+
// Then the table is trimmed to the cap, oldest first, keeping the newcomer.
|
|
137
|
+
expect(service.deviceCount()).toBe(2);
|
|
99
138
|
expect(service.hasDevice('dev1')).toBe(false);
|
|
139
|
+
expect(service.hasDevice(paired)).toBe(true);
|
|
100
140
|
});
|
|
101
141
|
it('keeps sessions memory-only when devicesFile is unset', () => {
|
|
102
142
|
const first = new PairingService({ ...BASE_CONFIG }, makeClock());
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CAkKrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAgB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
|
|
@@ -43,10 +43,20 @@ export function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
|
|
|
43
43
|
return '(function(){' +
|
|
44
44
|
'try{' +
|
|
45
45
|
'var w=window,loc=w.location,h=loc.hostname;' +
|
|
46
|
-
// Loopback origins (including the bracketed IPv6 literal WHATWG returns)
|
|
47
|
-
// keep the original paths (mirrors isLoopbackHostname).
|
|
48
|
-
"if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
49
46
|
'var R=' + json + ';' +
|
|
47
|
+
// A page the machine itself serves keeps the original paths and no
|
|
48
|
+
// channel: loopback names (including the bracketed IPv6 literal WHATWG
|
|
49
|
+
// returns), any scheme no web transport carries (an application on this
|
|
50
|
+
// machine delivered it - the desktop shell's own scheme, a file page, a
|
|
51
|
+
// scheme a newer shell introduces), or a shell the official transport
|
|
52
|
+
// already declared the host owner. This mirrors isLocalPage in
|
|
53
|
+
// remote-channel-rules.ts, which owns the decision.
|
|
54
|
+
"if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
55
|
+
'var pr=loc.protocol;' +
|
|
56
|
+
'if(typeof pr==="string"&&pr!==""&&R.webProtocols.indexOf(pr)===-1)return;' +
|
|
57
|
+
// ownsHost alone does not lift the fence: the plugin's own device-gated
|
|
58
|
+
// landing grants it to a paired LAN/tunnel page, which must stay gated.
|
|
59
|
+
'try{if(w.__DSH_TRANSPORT__&&w.__DSH_TRANSPORT__.ownsHost===true&&h.indexOf(".")===-1&&h.indexOf(":")===-1)return}catch(e){}' +
|
|
50
60
|
// Host mode is server-granted: only the device-gated app landing
|
|
51
61
|
// publishes the grant marker (in a capture script that runs ahead of
|
|
52
62
|
// this one), so a shell that merely sits on a non-loopback origin - an
|
|
@@ -8,6 +8,73 @@
|
|
|
8
8
|
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
9
9
|
export declare const REMOTE_PREFIX = "/remote";
|
|
10
10
|
import { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY } from './remote-methods.ts';
|
|
11
|
+
/**
|
|
12
|
+
* The schemes that can deliver a page a remote party controls: the network
|
|
13
|
+
* transports, plus the documents a network page can mint. Every other scheme
|
|
14
|
+
* is registered and served by an application on this machine, so its page is
|
|
15
|
+
* the machine's own page whatever the application calls the scheme.
|
|
16
|
+
*
|
|
17
|
+
* The list is deliberately the *web* side rather than an allowlist of known
|
|
18
|
+
* desktop shells. The official DSH Desktop shell serves its Web GUI from
|
|
19
|
+
* `dsh-app://app/` (`location.hostname === 'app'`), which no hostname
|
|
20
|
+
* predicate can recognise; a scheme allowlist fixed that instance (#1682) but
|
|
21
|
+
* left the next shell — or a `file:` page — fenced behind a pairing page it
|
|
22
|
+
* can never complete, because a pairing link is reachable only over the
|
|
23
|
+
* network. Naming the web side instead makes an unknown application scheme
|
|
24
|
+
* local by construction.
|
|
25
|
+
*
|
|
26
|
+
* `blob:`, `data:`, `about:` and `filesystem:` stay on the web side: a
|
|
27
|
+
* network page mints those documents, so they must keep the fence.
|
|
28
|
+
*/
|
|
29
|
+
export declare const WEB_PAGE_PROTOCOLS: readonly string[];
|
|
30
|
+
/**
|
|
31
|
+
* Whether one page scheme can carry a document a remote party controls.
|
|
32
|
+
* @param protocol - `location.protocol` of the page (for example `https:`).
|
|
33
|
+
* @returns true for the network transports and the documents they mint.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isWebPageProtocol(protocol: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Hostname-only loopback classification: localhost, the IPv6 loopback literal
|
|
38
|
+
* (WHATWG keeps its brackets) and any 127/8 IPv4 literal. Kept string-in,
|
|
39
|
+
* boolean-out and dependency-free so the browser half and the inlined boot
|
|
40
|
+
* script can both run it verbatim.
|
|
41
|
+
* @param hostname - a page/cookie hostname, IPv6 literals bracketed.
|
|
42
|
+
* @returns true for a loopback name or literal.
|
|
43
|
+
*/
|
|
44
|
+
export declare function isLoopbackHostname(hostname: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the current page is served from the machine running the host, so
|
|
47
|
+
* its same-origin traffic must NOT be fenced behind the pairing channel.
|
|
48
|
+
* Three independent facts all describe the page itself and any one is enough:
|
|
49
|
+
*
|
|
50
|
+
* - the page's own hostname is loopback;
|
|
51
|
+
* - the page's protocol is not a {@link WEB_PAGE_PROTOCOLS} web scheme, so an
|
|
52
|
+
* application on this machine delivered it — the desktop shell's
|
|
53
|
+
* `dsh-app://app/` is one such page, and a scheme this build has never
|
|
54
|
+
* heard of is another;
|
|
55
|
+
* - the official connection transport already declared this shell the host
|
|
56
|
+
* owner (`__DSH_TRANSPORT__.ownsHost === true`) AND the page is not a
|
|
57
|
+
* network origin. The official client reads the same hook to derive
|
|
58
|
+
* `connection.isLoopback`, and the desktop shell sets it before any boot
|
|
59
|
+
* entry — without this term the plugin's predicate contradicts the host's
|
|
60
|
+
* own conclusion (#1682). The network-origin guard is deliberate: this
|
|
61
|
+
* plugin's own device-gated landing (a LAN or tunnel page) publishes the
|
|
62
|
+
* same hook to grant a paired remote the full UI, and that page must keep
|
|
63
|
+
* riding the gated channel — `ownsHost` buys the presentation, never an
|
|
64
|
+
* exemption from the pairing fence.
|
|
65
|
+
*
|
|
66
|
+
* The order matters: loopback is checked first so a loopback page keeps its
|
|
67
|
+
* exemption even on a scheme no list carries, and the scheme test runs before
|
|
68
|
+
* the hook test so a web page always reaches the network-origin guard. A
|
|
69
|
+
* missing or empty scheme is not evidence of a local page - the fence stays
|
|
70
|
+
* up - because a real document always reports its own scheme.
|
|
71
|
+
*
|
|
72
|
+
* @param hostname - `location.hostname` of the page.
|
|
73
|
+
* @param protocol - `location.protocol` of the page (for example `https:`).
|
|
74
|
+
* @param transportOwnsHost - `__DSH_TRANSPORT__?.ownsHost` as read by the caller.
|
|
75
|
+
* @returns true when the page is local and needs no remote channel.
|
|
76
|
+
*/
|
|
77
|
+
export declare function isLocalPage(hostname: string, protocol?: string, transportOwnsHost?: boolean): boolean;
|
|
11
78
|
export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY };
|
|
12
79
|
/** Connection-plugin method prefix under the gated channel. */
|
|
13
80
|
export declare const REMOTE_API_PREFIX = "/remote/api";
|
|
@@ -38,6 +105,13 @@ export interface RemoteChannelRules {
|
|
|
38
105
|
readonly uploadPath: string;
|
|
39
106
|
/** Page global the pre-Cordis upload hook is published under. */
|
|
40
107
|
readonly uploadHookGlobal: string;
|
|
108
|
+
/**
|
|
109
|
+
* Schemes that can carry a remotely controlled document (see
|
|
110
|
+
* WEB_PAGE_PROTOCOLS): a page on any other scheme is delivered by an
|
|
111
|
+
* application on this machine. Carried in the rules so the inlined boot
|
|
112
|
+
* script applies the same list.
|
|
113
|
+
*/
|
|
114
|
+
readonly webProtocols: readonly string[];
|
|
41
115
|
/**
|
|
42
116
|
* Page global carrying the server-issued host-mode grant. Only the plugin's
|
|
43
117
|
* own device-gated app landing (/pair-app) publishes it; the boot patch
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,
|
|
1
|
+
{"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,kBAAkB,EAAE,SAAS,MAAM,EAO/C,CAAA;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE3D;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,EAAE,iBAAiB,CAAC,EAAE,OAAO,GAAG,OAAO,CAQrG;AAeD,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,8BAA8B,CAAA;AAEnE,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;IACjC;;;;;OAKG;IACH,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IACxC;;;;;;OAMG;IACH,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAA;CACjC;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBA0BlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
|
|
@@ -8,6 +8,109 @@
|
|
|
8
8
|
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
9
9
|
export const REMOTE_PREFIX = '/remote';
|
|
10
10
|
import { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY } from "./remote-methods.js";
|
|
11
|
+
/**
|
|
12
|
+
* The schemes that can deliver a page a remote party controls: the network
|
|
13
|
+
* transports, plus the documents a network page can mint. Every other scheme
|
|
14
|
+
* is registered and served by an application on this machine, so its page is
|
|
15
|
+
* the machine's own page whatever the application calls the scheme.
|
|
16
|
+
*
|
|
17
|
+
* The list is deliberately the *web* side rather than an allowlist of known
|
|
18
|
+
* desktop shells. The official DSH Desktop shell serves its Web GUI from
|
|
19
|
+
* `dsh-app://app/` (`location.hostname === 'app'`), which no hostname
|
|
20
|
+
* predicate can recognise; a scheme allowlist fixed that instance (#1682) but
|
|
21
|
+
* left the next shell — or a `file:` page — fenced behind a pairing page it
|
|
22
|
+
* can never complete, because a pairing link is reachable only over the
|
|
23
|
+
* network. Naming the web side instead makes an unknown application scheme
|
|
24
|
+
* local by construction.
|
|
25
|
+
*
|
|
26
|
+
* `blob:`, `data:`, `about:` and `filesystem:` stay on the web side: a
|
|
27
|
+
* network page mints those documents, so they must keep the fence.
|
|
28
|
+
*/
|
|
29
|
+
export const WEB_PAGE_PROTOCOLS = [
|
|
30
|
+
'http:',
|
|
31
|
+
'https:',
|
|
32
|
+
'blob:',
|
|
33
|
+
'data:',
|
|
34
|
+
'about:',
|
|
35
|
+
'filesystem:',
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Whether one page scheme can carry a document a remote party controls.
|
|
39
|
+
* @param protocol - `location.protocol` of the page (for example `https:`).
|
|
40
|
+
* @returns true for the network transports and the documents they mint.
|
|
41
|
+
*/
|
|
42
|
+
export function isWebPageProtocol(protocol) {
|
|
43
|
+
return WEB_PAGE_PROTOCOLS.includes(protocol);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Hostname-only loopback classification: localhost, the IPv6 loopback literal
|
|
47
|
+
* (WHATWG keeps its brackets) and any 127/8 IPv4 literal. Kept string-in,
|
|
48
|
+
* boolean-out and dependency-free so the browser half and the inlined boot
|
|
49
|
+
* script can both run it verbatim.
|
|
50
|
+
* @param hostname - a page/cookie hostname, IPv6 literals bracketed.
|
|
51
|
+
* @returns true for a loopback name or literal.
|
|
52
|
+
*/
|
|
53
|
+
export function isLoopbackHostname(hostname) {
|
|
54
|
+
if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')
|
|
55
|
+
return true;
|
|
56
|
+
const parts = hostname.split('.');
|
|
57
|
+
return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Whether the current page is served from the machine running the host, so
|
|
61
|
+
* its same-origin traffic must NOT be fenced behind the pairing channel.
|
|
62
|
+
* Three independent facts all describe the page itself and any one is enough:
|
|
63
|
+
*
|
|
64
|
+
* - the page's own hostname is loopback;
|
|
65
|
+
* - the page's protocol is not a {@link WEB_PAGE_PROTOCOLS} web scheme, so an
|
|
66
|
+
* application on this machine delivered it — the desktop shell's
|
|
67
|
+
* `dsh-app://app/` is one such page, and a scheme this build has never
|
|
68
|
+
* heard of is another;
|
|
69
|
+
* - the official connection transport already declared this shell the host
|
|
70
|
+
* owner (`__DSH_TRANSPORT__.ownsHost === true`) AND the page is not a
|
|
71
|
+
* network origin. The official client reads the same hook to derive
|
|
72
|
+
* `connection.isLoopback`, and the desktop shell sets it before any boot
|
|
73
|
+
* entry — without this term the plugin's predicate contradicts the host's
|
|
74
|
+
* own conclusion (#1682). The network-origin guard is deliberate: this
|
|
75
|
+
* plugin's own device-gated landing (a LAN or tunnel page) publishes the
|
|
76
|
+
* same hook to grant a paired remote the full UI, and that page must keep
|
|
77
|
+
* riding the gated channel — `ownsHost` buys the presentation, never an
|
|
78
|
+
* exemption from the pairing fence.
|
|
79
|
+
*
|
|
80
|
+
* The order matters: loopback is checked first so a loopback page keeps its
|
|
81
|
+
* exemption even on a scheme no list carries, and the scheme test runs before
|
|
82
|
+
* the hook test so a web page always reaches the network-origin guard. A
|
|
83
|
+
* missing or empty scheme is not evidence of a local page - the fence stays
|
|
84
|
+
* up - because a real document always reports its own scheme.
|
|
85
|
+
*
|
|
86
|
+
* @param hostname - `location.hostname` of the page.
|
|
87
|
+
* @param protocol - `location.protocol` of the page (for example `https:`).
|
|
88
|
+
* @param transportOwnsHost - `__DSH_TRANSPORT__?.ownsHost` as read by the caller.
|
|
89
|
+
* @returns true when the page is local and needs no remote channel.
|
|
90
|
+
*/
|
|
91
|
+
export function isLocalPage(hostname, protocol, transportOwnsHost) {
|
|
92
|
+
if (isLoopbackHostname(hostname))
|
|
93
|
+
return true;
|
|
94
|
+
// A missing or empty scheme is not evidence of a local page: a real
|
|
95
|
+
// document always reports one, so the fence stays up when it is unreadable.
|
|
96
|
+
if (protocol !== undefined && protocol !== '' && !isWebPageProtocol(protocol))
|
|
97
|
+
return true;
|
|
98
|
+
// A granted remote also sets ownsHost; only a page whose own origin is
|
|
99
|
+
// otherwise local may treat the hook as proof of a local page.
|
|
100
|
+
return transportOwnsHost === true && !isNetworkOrigin(hostname);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Whether a hostname can only be reached across a network, so a page on it is
|
|
104
|
+
* remote whatever transport hook it carries: any IPv4/IPv6 literal (a dot or a
|
|
105
|
+
* colon) or any dotted DNS name. A hostname with no dot and no colon is a
|
|
106
|
+
* scheme-local authority (the desktop shell's `app`), which is why this test,
|
|
107
|
+
* not a loopback test, is what the `ownsHost` term is gated on.
|
|
108
|
+
* @param hostname - the page hostname.
|
|
109
|
+
* @returns true when the page cannot be the machine's own local page.
|
|
110
|
+
*/
|
|
111
|
+
function isNetworkOrigin(hostname) {
|
|
112
|
+
return hostname.includes('.') || hostname.includes(':');
|
|
113
|
+
}
|
|
11
114
|
export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY };
|
|
12
115
|
/** Connection-plugin method prefix under the gated channel. */
|
|
13
116
|
export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
|
|
@@ -42,6 +145,7 @@ export const REMOTE_CHANNEL_RULES = {
|
|
|
42
145
|
deviceQuery: REMOTE_DEVICE_QUERY,
|
|
43
146
|
uploadPath: '/api/session/uploadFileBinary',
|
|
44
147
|
uploadHookGlobal: '__DSH_FILE_UPLOAD__',
|
|
148
|
+
webProtocols: WEB_PAGE_PROTOCOLS,
|
|
45
149
|
hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL,
|
|
46
150
|
};
|
|
47
151
|
/** The window global the boot patch publishes its seat under. */
|