@linxin666/dsh-remote-web-ui 0.4.0 → 0.4.2

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 (46) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +1 -1
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +132 -29
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +117 -15
  7. package/lib/types/client/RemoteEntry.js +1 -1
  8. package/lib/types/client/index.d.ts.map +1 -1
  9. package/lib/types/client/index.js +5 -3
  10. package/lib/types/client/locales.js +2 -2
  11. package/lib/types/client/pair-api.d.ts +4 -0
  12. package/lib/types/client/pair-api.d.ts.map +1 -1
  13. package/lib/types/client/pair-api.js +11 -7
  14. package/lib/types/client/remote-channel.d.ts +27 -7
  15. package/lib/types/client/remote-channel.d.ts.map +1 -1
  16. package/lib/types/client/remote-channel.js +25 -17
  17. package/lib/types/client/update-api.js +2 -2
  18. package/lib/types/lan-bind.d.ts +4 -0
  19. package/lib/types/lan-bind.d.ts.map +1 -1
  20. package/lib/types/lan-bind.js +64 -1
  21. package/lib/types/pairing.d.ts +15 -2
  22. package/lib/types/pairing.d.ts.map +1 -1
  23. package/lib/types/pairing.js +26 -14
  24. package/lib/types/pairing.persist.test.js +43 -3
  25. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  26. package/lib/types/remote-channel-boot.js +13 -3
  27. package/lib/types/remote-channel-rules.d.ts +74 -0
  28. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  29. package/lib/types/remote-channel-rules.js +104 -0
  30. package/lib/types/update.d.ts +10 -0
  31. package/lib/types/update.d.ts.map +1 -1
  32. package/lib/types/update.js +32 -3
  33. package/package.json +20 -19
  34. package/src/client/RemoteEntry.tsx +1 -1
  35. package/src/client/index.ts +5 -3
  36. package/src/client/locales.ts +2 -2
  37. package/src/client/pair-api.ts +11 -7
  38. package/src/client/remote-channel.ts +38 -15
  39. package/src/client/remote.module.css +18 -0
  40. package/src/client/update-api.ts +2 -2
  41. package/src/lan-bind.ts +62 -1
  42. package/src/pairing.persist.test.ts +47 -3
  43. package/src/pairing.ts +26 -12
  44. package/src/remote-channel-boot.ts +13 -3
  45. package/src/remote-channel-rules.ts +113 -0
  46. package/src/update.ts +36 -3
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
- "version": "0.4.0",
4
+ "version": "0.4.2",
5
5
  "icon": "icon.svg",
6
6
  "type": "module",
7
7
  "engines": {
@@ -38,7 +38,7 @@
38
38
  "license": "Apache-2.0",
39
39
  "dsh": {
40
40
  "engines": {
41
- "dsh": ">=0.1.7-rc.1"
41
+ "dsh": ">=0.1.7-rc.2"
42
42
  },
43
43
  "bundle": {
44
44
  "patch": "./cordis.patch.yml"
@@ -57,31 +57,32 @@
57
57
  "clsx": "^2.1.1",
58
58
  "qrcode.react": "^4.2.0",
59
59
  "schemastery": "^3.18.0",
60
+ "yaml": "^2.8.2",
60
61
  "zod": "^4.4.3"
61
62
  },
62
63
  "peerDependencies": {
63
- "@deepseek-ai/dsh": ">=0.1.7-rc.1",
64
+ "@deepseek-ai/dsh": ">=0.1.7-rc.2",
64
65
  "react": "^18.2.0",
65
66
  "react-dom": "^18.2.0"
66
67
  },
67
68
  "devDependencies": {
68
69
  "@deepseek-ai/cordis": "^4.0.4",
69
- "@deepseek-ai/dsh-agent": "^0.1.7-rc.1",
70
- "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.1",
71
- "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.1",
72
- "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.1",
73
- "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.1",
74
- "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.1",
75
- "@deepseek-ai/dsh-client-store": "^0.1.7-rc.1",
76
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.1",
77
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1",
78
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.1",
79
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.1",
80
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.1",
81
- "@deepseek-ai/dsh-commands": "^0.1.7-rc.1",
82
- "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.1",
83
- "@deepseek-ai/dsh-invariants": "^0.1.7-rc.1",
84
- "@deepseek-ai/dsh-settings": "^0.1.7-rc.1",
70
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
71
+ "@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
72
+ "@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
73
+ "@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
74
+ "@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
75
+ "@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
76
+ "@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
77
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2",
78
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
79
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
80
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
81
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
82
+ "@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
83
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
84
+ "@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
85
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
85
86
  "@testing-library/dom": "^10.4.1",
86
87
  "@testing-library/react": "^16.3.2",
87
88
  "@types/node": "^22.20.0",
@@ -128,7 +128,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
128
128
  // unreachable origins are fenced out of the events endpoint, so opening
129
129
  // it there would just start a doomed reconnect loop.
130
130
  if (next.kind !== 'ready' && next.kind !== 'lan-required') return
131
- const source = new EventSource('/api/pair/events')
131
+ const source = new EventSource('api/pair/events')
132
132
  eventSource.current = source
133
133
  source.onmessage = (event) => {
134
134
  try {
@@ -31,11 +31,12 @@ import { readPairGatePolicy, sendHeartbeat, shouldStopHeartbeat } from './pair-a
31
31
  import {
32
32
  channelTransition,
33
33
  installRemoteChannel,
34
- isLoopbackHostname,
34
+ pageOriginFacts,
35
35
  remoteChannelRequired,
36
36
  REMOTE_CHANNEL_BOOT_GLOBAL,
37
37
  type RemoteChannelBootSeat,
38
38
  } from './remote-channel.ts'
39
+ import { isLocalPage } from '../remote-channel-rules.ts'
39
40
  import { FenceNotice } from './FenceNotice.tsx'
40
41
  import { reportDailyHeartbeat } from './telemetry.ts'
41
42
  import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
@@ -333,7 +334,7 @@ export function apply(ctx: ClientContext): void {
333
334
  showFenceNotice()
334
335
  }
335
336
  const channelActive = (): boolean => remoteChannelRequired(
336
- window.location.hostname,
337
+ pageOriginFacts(window),
337
338
  settingsForm.getSnapshot(),
338
339
  hostPairingPolicy,
339
340
  )
@@ -387,7 +388,8 @@ export function apply(ctx: ClientContext): void {
387
388
  }
388
389
  settingsForm.subscribe(syncChannel)
389
390
  syncChannel()
390
- if (!isLoopbackHostname(window.location.hostname) && settingsForm.getSnapshot().status !== 'ready') {
391
+ const pageOrigin = pageOriginFacts(window)
392
+ if (!isLocalPage(pageOrigin.hostname, pageOrigin.protocol, pageOrigin.transportOwnsHost) && settingsForm.getSnapshot().status !== 'ready') {
391
393
  void readPairGatePolicy().then((policy) => {
392
394
  hostPairingPolicy = policy.requirePairingForLan
393
395
  syncChannel()
@@ -15,7 +15,7 @@ export const zh = {
15
15
  'status.disconnected': '已配对设备离线',
16
16
  'status.stopped': '已停止远程访问',
17
17
  'status.lanRequired': '此功能需要局域网绑定或公网地址才能使用',
18
- 'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在「设置 → Web 插件 → 远程访问设置」卡片中打开“局域网访问”,或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
18
+ 'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问设置」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
19
19
  'status.loopbackRequired': '配对面板仅限本机使用',
20
20
  'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
21
21
  'status.unreachable': '无法连接配对服务',
@@ -188,7 +188,7 @@ export const en = {
188
188
  'status.disconnected': 'Paired devices offline',
189
189
  'status.stopped': 'Remote access stopped',
190
190
  'status.lanRequired': 'This feature needs a LAN bind or a public address',
191
- 'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in the settings card under Settings → Web Plugins → Remote access, or restart with dsh web --host 0.0.0.0, or set the tunneled public address.',
191
+ 'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in this plugin settings card (under Settings → Web Plugins → Remote access when the full family is installed, or on this plugin row under Settings → Plugins for a standalone install), or restart with dsh web --host 0.0.0.0, or set the tunneled public address.',
192
192
  'status.loopbackRequired': 'The pairing panel works on this machine only',
193
193
  'status.loopbackRequiredHint': 'Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.',
194
194
  'status.unreachable': 'Cannot reach the pairing service',
@@ -2,6 +2,10 @@
2
2
  * Browser-side wire helpers for the /api/pair surface. Plain fetch over
3
3
  * same-origin /api (like the connection client); JSON bodies, string
4
4
  * responses for the error codes.
5
+ *
6
+ * The paths are DOCUMENT-RELATIVE (issue #1707): the GUI is served with
7
+ * `<base href="./">`, so a sub-path deployment resolves them against its entry
8
+ * directory instead of escaping to the origin root.
5
9
  */
6
10
 
7
11
  /** issue() response. */
@@ -43,7 +47,7 @@ export interface PairGatePolicy {
43
47
 
44
48
  /** Read the host-authoritative desktop pairing policy. */
45
49
  export async function readPairGatePolicy(): Promise<PairGatePolicy> {
46
- const response = await fetch('/api/pair/status')
50
+ const response = await fetch('api/pair/status')
47
51
  if (!response.ok) throw new Error(`remote-web-ui: status failed with ${String(response.status)}`)
48
52
  const value = await response.json() as { requirePairingForLan?: unknown }
49
53
  if (typeof value.requirePairingForLan !== 'boolean') {
@@ -116,7 +120,7 @@ export interface DeviceFrame {
116
120
  * origin — the panel is a desktop control endpoint).
117
121
  */
118
122
  export async function issuePair(address?: string): Promise<IssueResponse> {
119
- const response = await fetch('/api/pair/issue', {
123
+ const response = await fetch('api/pair/issue', {
120
124
  method: 'POST',
121
125
  headers: { 'content-type': 'application/json' },
122
126
  body: JSON.stringify({
@@ -139,7 +143,7 @@ export async function issuePair(address?: string): Promise<IssueResponse> {
139
143
  * @returns the wire result.
140
144
  */
141
145
  export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFailure> {
142
- const response = await fetch('/api/pair/accept', {
146
+ const response = await fetch('api/pair/accept', {
143
147
  method: 'POST',
144
148
  headers: { 'content-type': 'application/json' },
145
149
  body: JSON.stringify({ token }),
@@ -153,7 +157,7 @@ export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFa
153
157
 
154
158
  /** Revoke mobile access (paired devices + the current token). */
155
159
  export async function stopPair(): Promise<void> {
156
- const response = await fetch('/api/pair/stop', { method: 'POST' })
160
+ const response = await fetch('api/pair/stop', { method: 'POST' })
157
161
  if (!response.ok) throw new Error(`remote-web-ui: stop failed with ${String(response.status)}`)
158
162
  }
159
163
 
@@ -162,7 +166,7 @@ export async function stopPair(): Promise<void> {
162
166
  * @param deviceId - the session id of the row to drop.
163
167
  */
164
168
  export async function revokePair(deviceId: string): Promise<void> {
165
- const response = await fetch('/api/pair/revoke', {
169
+ const response = await fetch('api/pair/revoke', {
166
170
  method: 'POST',
167
171
  headers: { 'content-type': 'application/json' },
168
172
  body: JSON.stringify({ deviceId }),
@@ -177,7 +181,7 @@ export async function revokePair(deviceId: string): Promise<void> {
177
181
  * proves this page is not paired (see {@link shouldStopHeartbeat}).
178
182
  */
179
183
  export async function sendHeartbeat(): Promise<number> {
180
- const response = await fetch('/api/pair/heartbeat', { method: 'POST' })
184
+ const response = await fetch('api/pair/heartbeat', { method: 'POST' })
181
185
  return response.status
182
186
  }
183
187
 
@@ -242,7 +246,7 @@ export class LanBindStatusError extends Error {
242
246
 
243
247
  /** Read the LAN-bind facts (loopback-only endpoint). */
244
248
  export async function readLanBindStatus(): Promise<LanBindFrame> {
245
- const response = await fetch('/api/pair/lan-bind')
249
+ const response = await fetch('api/pair/lan-bind')
246
250
  if (!response.ok) throw new LanBindStatusError(response.status, `remote-web-ui: lan-bind status failed with ${String(response.status)}`)
247
251
  return await response.json() as LanBindFrame
248
252
  }
@@ -23,6 +23,8 @@
23
23
  */
24
24
 
25
25
  import {
26
+ isLoopbackHostname as isLoopbackName,
27
+ isLocalPage,
26
28
  REMOTE_API_PREFIX,
27
29
  REMOTE_CHANNEL_RULES,
28
30
  REMOTE_PREFIX,
@@ -40,13 +42,41 @@ export interface RemoteChannelSettingsSnapshot {
40
42
  value?: { enabled?: boolean; requirePairingForLan?: boolean }
41
43
  }
42
44
 
45
+ /**
46
+ * Facts about the live page that decide whether it is local. Defaults come
47
+ * from `window` so ordinary call sites stay argument-free; tests pass them
48
+ * explicitly.
49
+ */
50
+ export interface PageOriginFacts {
51
+ hostname: string
52
+ protocol?: string
53
+ transportOwnsHost?: boolean
54
+ }
55
+
56
+ /**
57
+ * Read the page-origin facts from a window-like object: the hostname and
58
+ * protocol of the page itself plus the official transport's host-owner flag
59
+ * (`__DSH_TRANSPORT__.ownsHost`, set by the desktop shell before any boot
60
+ * entry, and by this plugin's own paired landing).
61
+ * @param win - the window to read (defaults to the real one).
62
+ * @returns the facts `isLocalPage`/`remoteChannelRequired` decide from.
63
+ */
64
+ export function pageOriginFacts(win: Pick<Window, 'location'> & Partial<Pick<Window, 'navigator'>> = window): PageOriginFacts {
65
+ const transport = (win as unknown as { __DSH_TRANSPORT__?: { ownsHost?: unknown } }).__DSH_TRANSPORT__
66
+ return {
67
+ hostname: win.location.hostname,
68
+ protocol: win.location.protocol,
69
+ ...(transport?.ownsHost === true ? { transportOwnsHost: true } : {}),
70
+ }
71
+ }
72
+
43
73
  /** Decide whether a remote desktop channel is required from local or host policy. */
44
74
  export function remoteChannelRequired(
45
- hostname: string,
75
+ origin: PageOriginFacts,
46
76
  snapshot: RemoteChannelSettingsSnapshot,
47
77
  hostPairingPolicy: boolean | undefined,
48
78
  ): boolean {
49
- if (isLoopbackHostname(hostname)) return false
79
+ if (isLocalPage(origin.hostname, origin.protocol, origin.transportOwnsHost)) return false
50
80
  if (snapshot.status === 'ready') {
51
81
  return (snapshot.value?.enabled ?? true) && (snapshot.value?.requirePairingForLan ?? true)
52
82
  }
@@ -56,20 +86,13 @@ export function remoteChannelRequired(
56
86
  }
57
87
 
58
88
  /**
59
- * Browser-safe loopback classification for the page origin (the SDK client
60
- * exports its own; this copy keeps the module dependency-free).
61
- * @param hostname - a location hostname (IPv6 without brackets).
62
- * @returns true for localhost, IPv6 loopback, or any 127/8 literal.
89
+ * Browser-safe loopback classification for the page origin. The definition
90
+ * lives once in the rules module, which the inlined boot script mirrors
91
+ * verbatim, so the browser half and the parse-time patch can never disagree
92
+ * (an IPv6-loopback origin judged remote would rewrite every call onto a
93
+ * channel it can never pair on).
63
94
  */
64
- export function isLoopbackHostname(hostname: string): boolean {
65
- // WHATWG location.hostname keeps IPv6 literals bracketed ("[::1]"); the
66
- // host-side shared predicate accepts that spelling, so this browser copy must
67
- // too, or an IPv6-loopback origin is judged remote and rewrites every call
68
- // onto the gated channel where it can never pair.
69
- if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
70
- const parts = hostname.split('.')
71
- return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
72
- }
95
+ export const isLoopbackHostname = isLoopbackName
73
96
 
74
97
  /**
75
98
  * Whether one same-origin path must ride the gated channel (fetch, EventSource,
@@ -822,6 +822,24 @@
822
822
  align-items: center;
823
823
  }
824
824
 
825
+ /* ── Collapsed rail foot seat ────────────────────────────────────────────
826
+
827
+ The shell renders `sidebar.footer.action` as a centered horizontal row
828
+ in both column states. In the 56px rail that row is only as wide as its
829
+ occupants, so a second registrar overflows the icon column on both sides:
830
+ with two 36px circles the seat measures 78px against a 35px column, and
831
+ the outer buttons land at x=-11 and x=31 while every other rail row sits
832
+ at x=10 (issue #1678). Stack the seat vertically whenever a rail-marked
833
+ occupant is inside it, so each circle keeps the 36px rail column the
834
+ shell uses elsewhere; the wide column never matches this rule. The anchor
835
+ is this plugin's own `data-rail` marker, so the rule is an equal-value
836
+ no-op for a shell that already stacks its foot. */
837
+ :global([data-dsh-frame][data-sidebar-collapsed]) [class*='footerActions']:has([data-rail='rail']) {
838
+ flex-direction: column;
839
+ align-items: center;
840
+ gap: 4px;
841
+ }
842
+
825
843
  /* ── update panel extras ─────────────────────────────────────────────── */
826
844
 
827
845
  .updateStatus {
@@ -28,7 +28,7 @@ export class UpdateStatusError extends Error {
28
28
  export async function fetchUpdateStatus(): Promise<UpdateStatus> {
29
29
  let response: Response
30
30
  try {
31
- response = await fetch('/api/update/status')
31
+ response = await fetch('api/update/status')
32
32
  } catch {
33
33
  // Network-level failure: nothing came back at all.
34
34
  throw new UpdateStatusError(0)
@@ -43,7 +43,7 @@ export async function fetchUpdateStatus(): Promise<UpdateStatus> {
43
43
  * @returns the run outcome.
44
44
  */
45
45
  export async function runUpdate(): Promise<UpdateRunResult> {
46
- const response = await fetch('/api/update/run', { method: 'POST' })
46
+ const response = await fetch('api/update/run', { method: 'POST' })
47
47
  if (!response.ok) throw new Error('update run unavailable')
48
48
  return await response.json() as UpdateRunResult
49
49
  }
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. */
@@ -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)}`
@@ -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