@linxin666/dsh-remote-web-ui 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -7
  3. package/README.zh.md +9 -7
  4. package/lib/client.js +282 -619
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +418 -901
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +9 -10
  9. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  10. package/lib/types/client/RemoteSettingsCard.js +1 -1
  11. package/lib/types/client/index.d.ts +32 -3
  12. package/lib/types/client/index.d.ts.map +1 -1
  13. package/lib/types/client/index.js +56 -11
  14. package/lib/types/client/locales.d.ts +0 -84
  15. package/lib/types/client/locales.d.ts.map +1 -1
  16. package/lib/types/client/locales.js +2 -86
  17. package/lib/types/client/pair-api.d.ts +4 -0
  18. package/lib/types/client/pair-api.d.ts.map +1 -1
  19. package/lib/types/client/pair-api.js +11 -7
  20. package/lib/types/client/remote-channel.d.ts +27 -7
  21. package/lib/types/client/remote-channel.d.ts.map +1 -1
  22. package/lib/types/client/remote-channel.js +25 -17
  23. package/lib/types/client/settings-entry-form.d.ts +50 -0
  24. package/lib/types/client/settings-entry-form.d.ts.map +1 -0
  25. package/lib/types/client/settings-entry-form.js +98 -0
  26. package/lib/types/index.d.ts +99 -7
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +104 -137
  29. package/lib/types/lan-bind-plan.d.ts +14 -0
  30. package/lib/types/lan-bind-plan.d.ts.map +1 -1
  31. package/lib/types/lan-bind-plan.js +16 -0
  32. package/lib/types/lan-bind.d.ts +5 -1
  33. package/lib/types/lan-bind.d.ts.map +1 -1
  34. package/lib/types/lan-bind.js +65 -2
  35. package/lib/types/mount-once.d.ts +38 -7
  36. package/lib/types/mount-once.d.ts.map +1 -1
  37. package/lib/types/mount-once.js +96 -12
  38. package/lib/types/pairing.d.ts +15 -2
  39. package/lib/types/pairing.d.ts.map +1 -1
  40. package/lib/types/pairing.js +26 -14
  41. package/lib/types/pairing.persist.test.js +43 -3
  42. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  43. package/lib/types/remote-channel-boot.js +13 -3
  44. package/lib/types/remote-channel-rules.d.ts +74 -0
  45. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  46. package/lib/types/remote-channel-rules.js +104 -0
  47. package/lib/types/tunnel.d.ts +60 -3
  48. package/lib/types/tunnel.d.ts.map +1 -1
  49. package/lib/types/tunnel.js +136 -3
  50. package/package.json +21 -20
  51. package/src/client/RemoteEntry.tsx +8 -10
  52. package/src/client/RemoteSettingsCard.tsx +0 -2
  53. package/src/client/index.ts +58 -12
  54. package/src/client/locales.ts +2 -86
  55. package/src/client/pair-api.ts +11 -7
  56. package/src/client/remote-channel.ts +38 -15
  57. package/src/client/remote.module.css +61 -193
  58. package/src/client/settings-entry-form.ts +142 -0
  59. package/src/index.ts +156 -152
  60. package/src/lan-bind-plan.ts +22 -0
  61. package/src/lan-bind.ts +63 -2
  62. package/src/mount-once.ts +107 -13
  63. package/src/pairing.persist.test.ts +47 -3
  64. package/src/pairing.ts +26 -12
  65. package/src/remote-channel-boot.ts +13 -3
  66. package/src/remote-channel-rules.ts +113 -0
  67. package/src/tunnel.ts +142 -3
  68. package/lib/types/client/UpdateEntry.d.ts +0 -14
  69. package/lib/types/client/UpdateEntry.d.ts.map +0 -1
  70. package/lib/types/client/UpdateEntry.js +0 -112
  71. package/lib/types/client/UpdatePanel.d.ts +0 -42
  72. package/lib/types/client/UpdatePanel.d.ts.map +0 -1
  73. package/lib/types/client/UpdatePanel.js +0 -101
  74. package/lib/types/client/update-api.d.ts +0 -29
  75. package/lib/types/client/update-api.d.ts.map +0 -1
  76. package/lib/types/client/update-api.js +0 -47
  77. package/lib/types/console-output.d.ts +0 -41
  78. package/lib/types/console-output.d.ts.map +0 -1
  79. package/lib/types/console-output.js +0 -89
  80. package/lib/types/update-routes.d.ts +0 -30
  81. package/lib/types/update-routes.d.ts.map +0 -1
  82. package/lib/types/update-routes.js +0 -47
  83. package/lib/types/update.d.ts +0 -272
  84. package/lib/types/update.d.ts.map +0 -1
  85. package/lib/types/update.js +0 -744
  86. package/src/client/UpdateEntry.tsx +0 -144
  87. package/src/client/UpdatePanel.tsx +0 -256
  88. package/src/client/update-api.ts +0 -49
  89. package/src/console-output.ts +0 -97
  90. package/src/update-routes.ts +0 -65
  91. package/src/update.ts +0 -882
package/src/lan-bind.ts CHANGED
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
23
23
  import { dirname, join, resolve, sep } from 'node:path'
24
+ import { parseDocument } from 'yaml'
24
25
  import { dshHome } from './dsh-home.ts'
25
26
 
26
27
  /** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
@@ -34,7 +35,7 @@ export type LanBindHost = '0.0.0.0' | '127.0.0.1'
34
35
 
35
36
  /**
36
37
  * The absolute path of the profile patch file this toggle manages. The value
37
- * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
38
+ * is config-controlled (and the runtime/environment fallbacks bypass schema
38
39
  * validation entirely), so the path is guarded twice: the profile must be a
39
40
  * single safe path segment, and the resolved file must stay under the
40
41
  * profiles directory.
@@ -69,6 +70,62 @@ export function stripManagedBlock(content: string): string {
69
70
  return content.replace(pattern, '\n')
70
71
  }
71
72
 
73
+ /**
74
+ * Render the managed block for one bind state. The values are static: the
75
+ * user patch layer has no reliable lazy service evaluation, and the plugin
76
+ * re-asserts the block at every boot so CLI flags (--port, --host) win by
77
+ * rewriting it before the next start.
78
+ */
79
+ /**
80
+ * Whether the base opens a flow collection at the root. That is the shape the
81
+ * plugin manager's YAML document round-trip leaves behind when it appends rows
82
+ * to the profile's placeholder `[]`: `[ { id: … }, … ]`. Every other base
83
+ * (empty, block sequence, comment-only prefix) keeps the historical string
84
+ * concatenation, so the common paths stay byte-identical.
85
+ */
86
+ function isFlowRoot(base: string): boolean {
87
+ return base.trimStart().startsWith('[')
88
+ }
89
+
90
+ /** Force one parsed collection (and its item collections) into block style. */
91
+ function toBlockStyle(node: unknown): void {
92
+ if (typeof node !== 'object' || node === null) return
93
+ const collection = node as { flow?: boolean; items?: unknown[] }
94
+ if (!Array.isArray(collection.items) && typeof collection.flow !== 'boolean') return
95
+ collection.flow = false
96
+ for (const item of collection.items ?? []) toBlockStyle(item)
97
+ }
98
+
99
+ /** Plain-value view of one parsed node (YAML nodes keep their own accessors). */
100
+ function plainOf(node: unknown): unknown {
101
+ if (typeof node !== 'object' || node === null) return node
102
+ const json = (node as { toJSON?: () => unknown }).toJSON
103
+ return typeof json === 'function' ? json.call(node) : node
104
+ }
105
+
106
+ /**
107
+ * Produce the file text for one bind state. A flow-style base is re-emitted as
108
+ * a block sequence before the managed block is appended, so the file stays
109
+ * exactly one valid YAML document; concatenating the block onto a non-empty
110
+ * flow array produced two root documents and the profile failed to parse at
111
+ * `dsh web` startup (#1675). A base that cannot be parsed is refused with the
112
+ * file path rather than written half-valid.
113
+ */
114
+ function mergePatchContent(base: string, block: string, file: string): string {
115
+ if (base.length === 0) return block
116
+ if (!isFlowRoot(base)) return `${base}\n\n${block}`
117
+ const doc = parseDocument(base, { uniqueKeys: false })
118
+ if (doc.errors.length > 0 || doc.contents === null) {
119
+ throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: ${doc.errors[0]?.message ?? 'the patch file is not a YAML document'}`)
120
+ }
121
+ if (!('items' in (doc.contents as { items?: unknown[] }))) {
122
+ throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: the patch file is not a top-level patch list`)
123
+ }
124
+ toBlockStyle(doc.contents)
125
+ const rendered = doc.toString({ lineWidth: 0 }).trimEnd()
126
+ return `${rendered}\n\n${block}`
127
+ }
128
+
72
129
  /**
73
130
  * Render the managed block for one bind state. The values are static: the
74
131
  * user patch layer has no reliable lazy service evaluation, and the plugin
@@ -130,6 +187,10 @@ export function lanBindState(profile: string, home: string = dshHome()): { block
130
187
  * unterminated block (BEGIN marker without END, which stripManagedBlock
131
188
  * cannot match) is truncated at its BEGIN marker first so the rewrite can
132
189
  * never stack a second webserver row onto the orphan.
190
+ *
191
+ * The written file is always exactly one valid YAML document: a base the
192
+ * plugin manager left in flow style is re-emitted as a block sequence before
193
+ * the managed block is appended (see mergePatchContent).
133
194
  */
134
195
  export function writeLanBind(host: LanBindHost, port: number, profile: string, home: string = dshHome()): void {
135
196
  const file = profilePatchFile(profile, home)
@@ -138,7 +199,7 @@ export function writeLanBind(host: LanBindHost, port: number, profile: string, h
138
199
  const rawBase = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()
139
200
  const base = rawBase.replace(/\[\s*\]\s*$/, '').trimEnd()
140
201
  const block = managedBlock(host, port)
141
- const content = base.length > 0 ? `${base}\n\n${block}` : block
202
+ const content = mergePatchContent(base, block, file)
142
203
  const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600
143
204
  mkdirSync(dirname(file), { recursive: true })
144
205
  const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
package/src/mount-once.ts CHANGED
@@ -5,44 +5,138 @@
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 the second host apply a no-op for the
9
- * lifetime of the first instance (the browser half is already deduped by
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. cordis
14
- * `ctx.effect` runs its callback immediately and treats the callback's
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
  */
17
47
 
48
+ /** Published cross-repository contract: package names currently mounted. */
18
49
  const MOUNTED = Symbol.for('dsh-web.mounted-plugins')
19
50
 
51
+ /** Additive key this guard owns: refused mounts waiting for the name. */
52
+ const WAITERS = Symbol.for('dsh-web.mounted-plugins.waiters')
53
+
54
+ /** The slice of a cordis context this guard touches. */
55
+ interface MountContext {
56
+ effect?: (effect: () => unknown) => unknown
57
+ }
58
+
59
+ /** One mount that was refused while another fiber owned the package name. */
60
+ interface PendingMount {
61
+ /** Replay the refused mount; a no-op once its own fiber has been disposed. */
62
+ run(): void
63
+ }
64
+
20
65
  interface MountRegistry {
21
- [MOUNTED]?: Set<string>
66
+ [MOUNTED]?: unknown
67
+ [WAITERS]?: Map<string, PendingMount[]>
22
68
  }
23
69
 
70
+ /**
71
+ * The shared name registry, always a `Set` whatever another build stored here:
72
+ * a foreign value (an interim shape, a hand-written global) must not take every
73
+ * family plugin down with it.
74
+ * @returns the process-wide set of mounted package names.
75
+ */
24
76
  function mountedSet(): Set<string> {
25
77
  const registry = globalThis as MountRegistry
26
- return (registry[MOUNTED] ??= new Set())
78
+ const existing = registry[MOUNTED]
79
+ if (existing instanceof Set) return existing as Set<string>
80
+ const created = new Set<string>()
81
+ registry[MOUNTED] = created
82
+ return created
83
+ }
84
+
85
+ /** Queue per package name for mounts refused while a holder was alive. */
86
+ function mountWaiters(): Map<string, PendingMount[]> {
87
+ const registry = globalThis as MountRegistry
88
+ return (registry[WAITERS] ??= new Map())
27
89
  }
28
90
 
29
91
  /**
30
92
  * Wrap a cordis plugin apply so the package runs at most once per process.
31
- * The first mount registers normally and unmarks when its fiber disposes;
32
- * any later mount of the same package name is a no-op.
93
+ * The first mount registers normally and releases the name when its fiber
94
+ * disposes; a mount refused while that name is held waits for the release and
95
+ * then runs, unless its own fiber disposes first.
33
96
  * @param packageName - npm package identity shared by every install source.
34
97
  * @param fn - the original plugin apply.
35
98
  * @returns an apply of the same shape.
36
99
  */
37
100
  export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
- return ((...args: unknown[]) => {
101
+ const mount = (...args: unknown[]): unknown => {
39
102
  const mounted = mountedSet()
40
- if (mounted.has(packageName)) return
103
+ const ctx = args[0] as MountContext | undefined
104
+ if (mounted.has(packageName)) {
105
+ // Another fiber (the aggregate row, a standalone install, or a copy built
106
+ // from an older revision of this guard) owns the name. Queue instead of
107
+ // dropping: dropping lost the plugin for the rest of the process when the
108
+ // holder was disposed by a reload.
109
+ const waiters = mountWaiters()
110
+ const queue = waiters.get(packageName) ?? []
111
+ let alive = true
112
+ const pending: PendingMount = {
113
+ run: () => {
114
+ if (alive) mount(...args)
115
+ },
116
+ }
117
+ // A fiber that dies while it waits must not be replayed afterwards: its
118
+ // context is disposed and registering effects on it would throw.
119
+ ctx?.effect?.(() => () => {
120
+ alive = false
121
+ const index = queue.indexOf(pending)
122
+ if (index >= 0) queue.splice(index, 1)
123
+ })
124
+ queue.push(pending)
125
+ waiters.set(packageName, queue)
126
+ return
127
+ }
41
128
  mounted.add(packageName)
42
- const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
129
  ctx?.effect?.(() => () => {
44
130
  mounted.delete(packageName)
131
+ const waiters = mountWaiters()
132
+ const queue = waiters.get(packageName)
133
+ if (queue === undefined) return
134
+ waiters.delete(packageName)
135
+ // Defer the handover one microtask so the disposing mount's own effects
136
+ // (routes, ledger, timers) are torn down before the next one registers.
137
+ for (const waiter of queue.splice(0)) queueMicrotask(() => { waiter.run() })
45
138
  })
46
139
  return fn(...args)
47
- }) as T
140
+ }
141
+ return mount as T
48
142
  }
@@ -96,17 +96,61 @@ describe('PairingService device persistence', () => {
96
96
  expect(restarted.hasDevice(deviceId)).toBe(true)
97
97
  })
98
98
 
99
- it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
99
+ // #1696: the constructor runs before the saved settings row is applied, so
100
+ // its cap can still be the schema default. Restoring must not enforce it —
101
+ // trimming there silently revokes authorizations the user never revoked,
102
+ // and the trimmed table reaches disk on the next heartbeat/sweep write.
103
+ it('operator: every valid persisted session survives a restart under the startup cap', () => {
100
104
  const file = join(dir, 'devices.json')
105
+ // Given a store holding three devices while the startup cap reads 4
106
+ // (the schema default) and the saved settings value is 12.
101
107
  writeFileSync(file, JSON.stringify({
102
108
  dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
103
109
  dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
104
110
  dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
105
111
  }))
106
- const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
107
- expect(service.hasDevice('dev3')).toBe(true)
112
+ // When a new process restores the store.
113
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock())
114
+ // Then no paired device lost its authorization to the transient cap.
115
+ expect(service.hasDevice('dev1')).toBe(true)
108
116
  expect(service.hasDevice('dev2')).toBe(true)
117
+ expect(service.hasDevice('dev3')).toBe(true)
118
+ })
119
+
120
+ it('operator: a restored over-cap table is not trimmed by a heartbeat and sweep', () => {
121
+ const file = join(dir, 'devices.json')
122
+ // Given twelve persisted devices restored under the default cap of 4.
123
+ const rows = Object.fromEntries(
124
+ Array.from({ length: 12 }, (_, i) => [`dev${i}`, { createdAt: 1_000 + i, lastSeenAt: 1_000 + i }]),
125
+ )
126
+ writeFileSync(file, JSON.stringify(rows))
127
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock())
128
+ // When the saved cap of 12 arrives and normal traffic writes the store.
129
+ service.config = { ...service.config, maxDevices: 12 }
130
+ service.heartbeat('dev0')
131
+ service.sweep()
132
+ // Then the file still names all twelve devices.
133
+ const persisted = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
134
+ expect(Object.keys(persisted)).toHaveLength(12)
135
+ expect(service.deviceCount()).toBe(12)
136
+ })
137
+
138
+ it('operator: the cap is enforced when a device is admitted, not before', () => {
139
+ const file = join(dir, 'devices.json')
140
+ // Given three persisted devices restored under a cap of 2.
141
+ writeFileSync(file, JSON.stringify({
142
+ dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
143
+ dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
144
+ dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
145
+ }))
146
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
147
+ expect(service.deviceCount()).toBe(3)
148
+ // When one more device pairs.
149
+ const paired = pairDevice(service)
150
+ // Then the table is trimmed to the cap, oldest first, keeping the newcomer.
151
+ expect(service.deviceCount()).toBe(2)
109
152
  expect(service.hasDevice('dev1')).toBe(false)
153
+ expect(service.hasDevice(paired)).toBe(true)
110
154
  })
111
155
 
112
156
  it('keeps sessions memory-only when devicesFile is unset', () => {
package/src/pairing.ts CHANGED
@@ -248,6 +248,14 @@ export class PairingService {
248
248
  * Restore device sessions persisted by a previous process run. A corrupt
249
249
  * or missing file is tolerated (an empty device table, never a throw) —
250
250
  * persistence is an availability convenience, not a security boundary.
251
+ *
252
+ * Every still-valid session is restored, deliberately without applying
253
+ * `maxDevices`: the constructor runs with whatever cap the configuration
254
+ * layer has delivered so far (the schema default until the saved settings
255
+ * row is applied), and trimming here would permanently drop authorizations
256
+ * the user never revoked — the trimmed table reaches disk on the next
257
+ * heartbeat/sweep write. The cap is enforced where a device is admitted
258
+ * (see `accept`), against the configuration in force at that moment.
251
259
  */
252
260
  private loadPersisted(): void {
253
261
  const file = this.config.devicesFile
@@ -271,21 +279,12 @@ export class PairingService {
271
279
  ...(label !== undefined ? { userAgent: label } : {}),
272
280
  })
273
281
  }
274
- this.clampToMaxDevices()
275
282
  if (this.evictIdle()) this.persistRevocation()
276
283
  } catch {
277
284
  // Unreadable/corrupt: start empty rather than refusing to boot.
278
285
  }
279
286
  }
280
287
 
281
- /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
282
- private clampToMaxDevices(): void {
283
- if (this.devices.size <= this.config.maxDevices) return
284
- const overflow = this.devices.size - this.config.maxDevices
285
- const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
286
- for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
287
- }
288
-
289
288
  /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
290
289
  private evictIdle(): boolean {
291
290
  const now = this.clock.now()
@@ -457,13 +456,18 @@ export class PairingService {
457
456
  }
458
457
  const deviceId = this.clock.randomToken()
459
458
  const now = this.clock.now()
460
- if (this.devices.size >= this.config.maxDevices) {
461
- // Evict the oldest session (FIFO) before binding a new device.
459
+ // FIFO-evict until a slot is free. The loop (not a single delete) covers a
460
+ // table that is already over the cap: restore keeps every persisted session
461
+ // (see loadPersisted), so the excess is collected here, once the store is
462
+ // actually asked to admit a new device, rather than at startup on a cap the
463
+ // configuration may not have delivered yet.
464
+ while (this.devices.size >= this.config.maxDevices) {
462
465
  let oldest: { id: string; createdAt: number } | undefined
463
466
  for (const [id, session] of this.devices) {
464
467
  if (oldest === undefined || session.createdAt < oldest.createdAt) oldest = { id, createdAt: session.createdAt }
465
468
  }
466
- if (oldest !== undefined) this.devices.delete(oldest.id)
469
+ if (oldest === undefined) break
470
+ this.devices.delete(oldest.id)
467
471
  }
468
472
  const label = sanitizeUserAgent(userAgent)
469
473
  this.devices.set(deviceId, {
@@ -570,6 +574,16 @@ export class PairingService {
570
574
  return this.liveSession(deviceId) !== undefined
571
575
  }
572
576
 
577
+ /**
578
+ * Number of device sessions in the table, including idle ones whose idle
579
+ * window has not been swept yet. Restore keeps every persisted session
580
+ * (see `loadPersisted`), so this can exceed the configured cap until the
581
+ * next `accept` trims the excess.
582
+ */
583
+ deviceCount(): number {
584
+ return this.devices.size
585
+ }
586
+
573
587
  /** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
574
588
  onState(listener: (snapshot: PairingSnapshot) => void): () => void {
575
589
  this.listeners.add(listener)
@@ -45,10 +45,20 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
45
45
  return '(function(){' +
46
46
  'try{' +
47
47
  'var w=window,loc=w.location,h=loc.hostname;' +
48
- // Loopback origins (including the bracketed IPv6 literal WHATWG returns)
49
- // keep the original paths (mirrors isLoopbackHostname).
50
- "if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
51
48
  'var R=' + json + ';' +
49
+ // A page the machine itself serves keeps the original paths and no
50
+ // channel: loopback names (including the bracketed IPv6 literal WHATWG
51
+ // returns), any scheme no web transport carries (an application on this
52
+ // machine delivered it - the desktop shell's own scheme, a file page, a
53
+ // scheme a newer shell introduces), or a shell the official transport
54
+ // already declared the host owner. This mirrors isLocalPage in
55
+ // remote-channel-rules.ts, which owns the decision.
56
+ "if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
57
+ 'var pr=loc.protocol;' +
58
+ 'if(typeof pr==="string"&&pr!==""&&R.webProtocols.indexOf(pr)===-1)return;' +
59
+ // ownsHost alone does not lift the fence: the plugin's own device-gated
60
+ // landing grants it to a paired LAN/tunnel page, which must stay gated.
61
+ 'try{if(w.__DSH_TRANSPORT__&&w.__DSH_TRANSPORT__.ownsHost===true&&h.indexOf(".")===-1&&h.indexOf(":")===-1)return}catch(e){}' +
52
62
  // Host mode is server-granted: only the device-gated app landing
53
63
  // publishes the grant marker (in a capture script that runs ahead of
54
64
  // this one), so a shell that merely sits on a non-loopback origin - an
@@ -11,6 +11,111 @@ export const REMOTE_PREFIX = '/remote'
11
11
 
12
12
  import { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY } from './remote-methods.ts'
13
13
 
14
+ /**
15
+ * The schemes that can deliver a page a remote party controls: the network
16
+ * transports, plus the documents a network page can mint. Every other scheme
17
+ * is registered and served by an application on this machine, so its page is
18
+ * the machine's own page whatever the application calls the scheme.
19
+ *
20
+ * The list is deliberately the *web* side rather than an allowlist of known
21
+ * desktop shells. The official DSH Desktop shell serves its Web GUI from
22
+ * `dsh-app://app/` (`location.hostname === 'app'`), which no hostname
23
+ * predicate can recognise; a scheme allowlist fixed that instance (#1682) but
24
+ * left the next shell — or a `file:` page — fenced behind a pairing page it
25
+ * can never complete, because a pairing link is reachable only over the
26
+ * network. Naming the web side instead makes an unknown application scheme
27
+ * local by construction.
28
+ *
29
+ * `blob:`, `data:`, `about:` and `filesystem:` stay on the web side: a
30
+ * network page mints those documents, so they must keep the fence.
31
+ */
32
+ export const WEB_PAGE_PROTOCOLS: readonly string[] = [
33
+ 'http:',
34
+ 'https:',
35
+ 'blob:',
36
+ 'data:',
37
+ 'about:',
38
+ 'filesystem:',
39
+ ]
40
+
41
+ /**
42
+ * Whether one page scheme can carry a document a remote party controls.
43
+ * @param protocol - `location.protocol` of the page (for example `https:`).
44
+ * @returns true for the network transports and the documents they mint.
45
+ */
46
+ export function isWebPageProtocol(protocol: string): boolean {
47
+ return WEB_PAGE_PROTOCOLS.includes(protocol)
48
+ }
49
+
50
+ /**
51
+ * Hostname-only loopback classification: localhost, the IPv6 loopback literal
52
+ * (WHATWG keeps its brackets) and any 127/8 IPv4 literal. Kept string-in,
53
+ * boolean-out and dependency-free so the browser half and the inlined boot
54
+ * script can both run it verbatim.
55
+ * @param hostname - a page/cookie hostname, IPv6 literals bracketed.
56
+ * @returns true for a loopback name or literal.
57
+ */
58
+ export function isLoopbackHostname(hostname: string): boolean {
59
+ if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
60
+ const parts = hostname.split('.')
61
+ return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
62
+ }
63
+
64
+ /**
65
+ * Whether the current page is served from the machine running the host, so
66
+ * its same-origin traffic must NOT be fenced behind the pairing channel.
67
+ * Three independent facts all describe the page itself and any one is enough:
68
+ *
69
+ * - the page's own hostname is loopback;
70
+ * - the page's protocol is not a {@link WEB_PAGE_PROTOCOLS} web scheme, so an
71
+ * application on this machine delivered it — the desktop shell's
72
+ * `dsh-app://app/` is one such page, and a scheme this build has never
73
+ * heard of is another;
74
+ * - the official connection transport already declared this shell the host
75
+ * owner (`__DSH_TRANSPORT__.ownsHost === true`) AND the page is not a
76
+ * network origin. The official client reads the same hook to derive
77
+ * `connection.isLoopback`, and the desktop shell sets it before any boot
78
+ * entry — without this term the plugin's predicate contradicts the host's
79
+ * own conclusion (#1682). The network-origin guard is deliberate: this
80
+ * plugin's own device-gated landing (a LAN or tunnel page) publishes the
81
+ * same hook to grant a paired remote the full UI, and that page must keep
82
+ * riding the gated channel — `ownsHost` buys the presentation, never an
83
+ * exemption from the pairing fence.
84
+ *
85
+ * The order matters: loopback is checked first so a loopback page keeps its
86
+ * exemption even on a scheme no list carries, and the scheme test runs before
87
+ * the hook test so a web page always reaches the network-origin guard. A
88
+ * missing or empty scheme is not evidence of a local page - the fence stays
89
+ * up - because a real document always reports its own scheme.
90
+ *
91
+ * @param hostname - `location.hostname` of the page.
92
+ * @param protocol - `location.protocol` of the page (for example `https:`).
93
+ * @param transportOwnsHost - `__DSH_TRANSPORT__?.ownsHost` as read by the caller.
94
+ * @returns true when the page is local and needs no remote channel.
95
+ */
96
+ export function isLocalPage(hostname: string, protocol?: string, transportOwnsHost?: boolean): boolean {
97
+ if (isLoopbackHostname(hostname)) return true
98
+ // A missing or empty scheme is not evidence of a local page: a real
99
+ // document always reports one, so the fence stays up when it is unreadable.
100
+ if (protocol !== undefined && protocol !== '' && !isWebPageProtocol(protocol)) return true
101
+ // A granted remote also sets ownsHost; only a page whose own origin is
102
+ // otherwise local may treat the hook as proof of a local page.
103
+ return transportOwnsHost === true && !isNetworkOrigin(hostname)
104
+ }
105
+
106
+ /**
107
+ * Whether a hostname can only be reached across a network, so a page on it is
108
+ * remote whatever transport hook it carries: any IPv4/IPv6 literal (a dot or a
109
+ * colon) or any dotted DNS name. A hostname with no dot and no colon is a
110
+ * scheme-local authority (the desktop shell's `app`), which is why this test,
111
+ * not a loopback test, is what the `ownsHost` term is gated on.
112
+ * @param hostname - the page hostname.
113
+ * @returns true when the page cannot be the machine's own local page.
114
+ */
115
+ function isNetworkOrigin(hostname: string): boolean {
116
+ return hostname.includes('.') || hostname.includes(':')
117
+ }
118
+
14
119
  export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY }
15
120
 
16
121
  /** Connection-plugin method prefix under the gated channel. */
@@ -44,6 +149,13 @@ export interface RemoteChannelRules {
44
149
  readonly uploadPath: string
45
150
  /** Page global the pre-Cordis upload hook is published under. */
46
151
  readonly uploadHookGlobal: string
152
+ /**
153
+ * Schemes that can carry a remotely controlled document (see
154
+ * WEB_PAGE_PROTOCOLS): a page on any other scheme is delivered by an
155
+ * application on this machine. Carried in the rules so the inlined boot
156
+ * script applies the same list.
157
+ */
158
+ readonly webProtocols: readonly string[]
47
159
  /**
48
160
  * Page global carrying the server-issued host-mode grant. Only the plugin's
49
161
  * own device-gated app landing (/pair-app) publishes it; the boot patch
@@ -79,6 +191,7 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
79
191
  deviceQuery: REMOTE_DEVICE_QUERY,
80
192
  uploadPath: '/api/session/uploadFileBinary',
81
193
  uploadHookGlobal: '__DSH_FILE_UPLOAD__',
194
+ webProtocols: WEB_PAGE_PROTOCOLS,
82
195
  hostGrantGlobal: REMOTE_HOST_GRANT_GLOBAL,
83
196
  }
84
197