@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +610 -74
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +676 -35
  7. package/lib/mobile.js +10 -7
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +83 -33
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/client/settings-form.d.ts.map +1 -1
  35. package/lib/types/client/settings-form.js +9 -6
  36. package/lib/types/dsh-home.d.ts +17 -0
  37. package/lib/types/dsh-home.d.ts.map +1 -0
  38. package/lib/types/dsh-home.js +34 -0
  39. package/lib/types/gate.d.ts.map +1 -1
  40. package/lib/types/gate.js +5 -2
  41. package/lib/types/index.d.ts +19 -12
  42. package/lib/types/index.d.ts.map +1 -1
  43. package/lib/types/index.js +73 -5
  44. package/lib/types/loopback-proxy.d.ts +28 -0
  45. package/lib/types/loopback-proxy.d.ts.map +1 -0
  46. package/lib/types/loopback-proxy.js +112 -0
  47. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  48. package/lib/types/mobile/views/SessionListView.js +6 -1
  49. package/lib/types/mobile-api.d.ts.map +1 -1
  50. package/lib/types/mobile-api.js +6 -2
  51. package/lib/types/mobile-routes.js +1 -1
  52. package/lib/types/pairing.d.ts +63 -11
  53. package/lib/types/pairing.d.ts.map +1 -1
  54. package/lib/types/pairing.js +137 -20
  55. package/lib/types/pairing.persist.test.js +60 -5
  56. package/lib/types/posture.d.ts +75 -0
  57. package/lib/types/posture.d.ts.map +1 -0
  58. package/lib/types/posture.js +116 -0
  59. package/lib/types/remote-api.d.ts +60 -0
  60. package/lib/types/remote-api.d.ts.map +1 -0
  61. package/lib/types/remote-api.js +181 -0
  62. package/lib/types/remote-methods.d.ts +33 -0
  63. package/lib/types/remote-methods.d.ts.map +1 -0
  64. package/lib/types/remote-methods.js +54 -0
  65. package/lib/types/routes.d.ts +4 -0
  66. package/lib/types/routes.d.ts.map +1 -1
  67. package/lib/types/routes.js +33 -4
  68. package/lib/types/update.d.ts.map +1 -1
  69. package/lib/types/update.js +10 -2
  70. package/package.json +13 -13
  71. package/src/client/FenceNotice.tsx +43 -0
  72. package/src/client/RemoteEntry.tsx +19 -9
  73. package/src/client/RemotePanel.tsx +83 -10
  74. package/src/client/RemoteSettingsCard.tsx +16 -0
  75. package/src/client/device-name.ts +31 -0
  76. package/src/client/index.ts +46 -0
  77. package/src/client/locales.ts +83 -33
  78. package/src/client/pair-api.ts +60 -0
  79. package/src/client/remote-channel.ts +269 -0
  80. package/src/client/remote.module.css +240 -16
  81. package/src/client/settings-form.ts +8 -6
  82. package/src/dsh-home.ts +36 -0
  83. package/src/gate.ts +5 -2
  84. package/src/index.ts +89 -17
  85. package/src/loopback-proxy.ts +126 -0
  86. package/src/mobile/views/SessionListView.tsx +6 -1
  87. package/src/mobile-api.ts +6 -2
  88. package/src/mobile-routes.ts +1 -1
  89. package/src/pairing.persist.test.ts +64 -5
  90. package/src/pairing.ts +167 -20
  91. package/src/posture.ts +154 -0
  92. package/src/remote-api.ts +217 -0
  93. package/src/remote-methods.ts +62 -0
  94. package/src/routes.ts +33 -4
  95. package/src/update.ts +14 -1
package/src/index.ts CHANGED
@@ -9,18 +9,22 @@
9
9
  */
10
10
 
11
11
  import { createRequire } from 'node:module'
12
- import { setInterval as nodeSetInterval } from 'node:timers'
12
+ import { join } from 'node:path'
13
+ import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
13
14
  import type { IncomingMessage } from 'node:http'
14
15
  import type { Context } from '@deepseek-ai/cordis'
15
16
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
17
  import z from 'schemastery'
17
18
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
- import { PairingService, type PairingConfig } from './pairing.ts'
19
+ import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
20
+ import { dshHome } from './dsh-home.ts'
19
21
  import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
20
22
  import { RemoteWebUiPairing } from './pairing-access.ts'
21
23
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
22
24
  import { makeMobileRoutes } from './mobile-routes.ts'
23
25
  import { makeMobileApiRoutes } from './mobile-api.ts'
26
+ import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
27
+ import { anyExposed, claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
24
28
  import { lanIPv4Addresses } from './lan.ts'
25
29
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
26
30
  import {
@@ -72,13 +76,19 @@ export interface Config {
72
76
  offlineAfterMs?: number
73
77
  /** Hard cap on paired device sessions (oldest evicted when full). */
74
78
  maxDevices?: number
79
+ /**
80
+ * Idle sessions older than this (ms) are deleted from memory and disk.
81
+ * Default is 7 days; a leftover cookie no longer authorizes after expiry.
82
+ */
83
+ idleExpireMs?: number
75
84
  /** Cookie name carrying the paired device id. */
76
85
  cookieName?: string
77
86
  /**
78
- * When true (default), every non-loopback /api request must carry a live
79
- * paired-device cookie — the QR is the only way into a LAN-exposed dsh
80
- * web, and stop() genuinely cuts paired devices off. Set false to keep
81
- * the fence's open-LAN behavior and use pairing only for tokens/status.
87
+ * When true (default), a desktop Web GUI opened at a non-loopback origin
88
+ * rides the gated `/remote/api` channel and must carry a live paired-device
89
+ * cookie — the QR is the only way into remote desktop, and stop() cuts
90
+ * paired devices off. Set false to keep the desktop on plain `/api`
91
+ * (only useful when that origin is already trusted for `/api`).
82
92
  */
83
93
  requirePairingForLan?: boolean
84
94
  /**
@@ -92,17 +102,16 @@ export interface Config {
92
102
  publicBaseUrl?: string
93
103
  /**
94
104
  * Absolute path to a JSON file where paired device sessions are persisted.
95
- * When set, a paired phone keeps its session across `dsh web` restarts
96
- * (the phone-side cookie already lives 365 days), so re-scanning the QR
97
- * after each restart is unnecessary. Unset keeps sessions memory-only
98
- * (previous behavior).
105
+ * Defaults to `$DSH_HOME/remote-web-ui-devices.json` so a paired device
106
+ * keeps its session across `dsh web` restarts (the cookie already lives
107
+ * 365 days). Override to another absolute path when needed.
99
108
  */
100
109
  devicesFile?: string
101
110
  /**
102
111
  * When true, the plugin runs its own Cloudflare quick tunnel (the
103
112
  * cloudflared binary ships with the package — no user-side install) and
104
- * feeds the minted public URL into both the QR base and the /api trust
105
- * fence dynamically, so phones anywhere can pair without any manual
113
+ * feeds the minted public URL into the QR base and the phone-facing
114
+ * pairing fence dynamically, so phones anywhere can pair without any manual
106
115
  * tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
107
116
  */
108
117
  autoTunnel?: boolean
@@ -121,6 +130,7 @@ export const Config: z<Config> = z.object({
121
130
  tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000),
122
131
  offlineAfterMs: z.number().step(1).min(5_000).default(25_000),
123
132
  maxDevices: z.number().step(1).min(1).max(64).default(4),
133
+ idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS),
124
134
  cookieName: z.string().min(1).default('dsh_pair'),
125
135
  requirePairingForLan: z.boolean().default(true),
126
136
  publicBaseUrl: z.string(),
@@ -140,7 +150,7 @@ const SWEEP_INTERVAL_MS = 10_000
140
150
  */
141
151
  type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
142
152
  publicBaseUrl: string | undefined
143
- devicesFile: string | undefined
153
+ devicesFile: string
144
154
  }
145
155
 
146
156
  /**
@@ -151,26 +161,33 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> &
151
161
  */
152
162
  export function pairingConfigOf(resolved: Pick<
153
163
  ResolvedConfig,
154
- 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
164
+ 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'idleExpireMs' | 'cookieName' | 'devicesFile'
155
165
  >): PairingConfig {
156
166
  return {
157
167
  tokenTtlMs: resolved.tokenTtlMs,
158
168
  offlineAfterMs: resolved.offlineAfterMs,
159
169
  maxDevices: resolved.maxDevices,
170
+ idleExpireMs: resolved.idleExpireMs,
160
171
  cookieName: resolved.cookieName,
161
172
  devicesFile: resolved.devicesFile,
162
173
  }
163
174
  }
164
175
 
176
+ /** Default paired-session store: `$DSH_HOME/remote-web-ui-devices.json`. */
177
+ export function defaultDevicesFile(home: string = dshHome()): string {
178
+ return join(home, 'remote-web-ui-devices.json')
179
+ }
180
+
165
181
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
166
182
  const DEFAULTS: ResolvedConfig = {
167
183
  tokenTtlMs: 10 * 60_000,
168
184
  offlineAfterMs: 25_000,
169
185
  maxDevices: 4,
186
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
170
187
  cookieName: 'dsh_pair',
171
188
  requirePairingForLan: true,
172
189
  publicBaseUrl: undefined,
173
- devicesFile: undefined,
190
+ devicesFile: defaultDevicesFile(),
174
191
  autoTunnel: false,
175
192
  mobileEnterToSend: true,
176
193
  enabled: true,
@@ -188,10 +205,11 @@ function applyImpl(ctx: Context, config?: Config): void {
188
205
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
189
206
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
190
207
  maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
208
+ idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
191
209
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
192
210
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
193
211
  publicBaseUrl: config?.publicBaseUrl,
194
- devicesFile: config?.devicesFile,
212
+ devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
195
213
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
196
214
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
197
215
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -206,6 +224,7 @@ function applyImpl(ctx: Context, config?: Config): void {
206
224
  tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
207
225
  offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
208
226
  maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
227
+ idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
209
228
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
210
229
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
211
230
  publicBaseUrl: value.publicBaseUrl,
@@ -230,6 +249,7 @@ function applyImpl(ctx: Context, config?: Config): void {
230
249
  if (info.phase === 'running' && info.url !== undefined) {
231
250
  service.setPublicBaseUrl(info.url)
232
251
  service.setTunnelStatus({ state: 'running', url: info.url })
252
+ runPostureProbe()
233
253
  } else if (info.phase === 'starting') {
234
254
  // A restart mints a NEW hostname: the previous URL dies with the old
235
255
  // process, so clear it now rather than advertising a dead link.
@@ -338,10 +358,56 @@ function applyImpl(ctx: Context, config?: Config): void {
338
358
  ...(apiProxy !== undefined
339
359
  ? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
340
360
  : []),
361
+ // The remote desktop channel: paired-cookie-gated `/remote` prefix that
362
+ // re-issues fenced paths to loopback (see remote-api.ts).
363
+ ...makeRemoteApiRoutes({ service, port: ctx.webServer.port }),
341
364
  ...updateRoutes,
342
365
  ]
366
+ const upgrades = makeRemoteApiUpgradeRoutes({ service, port: ctx.webServer.port })
343
367
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
344
368
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
369
+
370
+ // ── posture probe ─────────────────────────────────────────────────────────
371
+ // Guardrail for the one seam this plugin cannot mount a gate into: the
372
+ // connection plugin's /api Host fence. Forged-Host probes against every
373
+ // advertised origin (public base + LAN bases) make a re-opened /api (a
374
+ // re-added --trusted-host, or the SDK's LAN auto-trust under 0.0.0.0)
375
+ // visible on the panel and the log instead of silently trusted.
376
+ let postureKey: string | undefined
377
+ let postureWasExposed = false
378
+ const runPostureProbe = (): void => {
379
+ if (!resolve().enabled) return
380
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port)
381
+ if (targets.length === 0) {
382
+ postureKey = undefined
383
+ service.setPosture(undefined)
384
+ return
385
+ }
386
+ const key = targets.join('|')
387
+ const claim = claimPostureKey(postureKey, key)
388
+ if (!claim.run) return
389
+ postureKey = claim.next
390
+ void probePosture({ port: ctx.webServer.port, targets }).then((snapshot) => {
391
+ service.setPosture(snapshot)
392
+ const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host)
393
+ const exposed = exposedHosts.length > 0
394
+ if (exposed && !postureWasExposed) {
395
+ console.error(`remote-web-ui: CRITICAL — the /api fence is OPEN for [${exposedHosts.join(', ')}]: unpaired clients reach the full host API. Remove --trusted-host for these hosts (pairing covers them) or bind loopback.`)
396
+ } else if (!exposed && postureWasExposed) {
397
+ console.log('remote-web-ui: the /api posture probe is clean again (every advertised origin refused with 403).')
398
+ }
399
+ postureWasExposed = exposed
400
+ }).catch(() => {
401
+ // Keep the previous snapshot; drop the in-flight key so the same
402
+ // targets retry instead of sticking on a failed round.
403
+ postureKey = releasePostureKey(postureKey, key)
404
+ })
405
+ }
406
+ // The first round waits for the connection plugin's /api route: a probe
407
+ // before it mounts would read the SPA fallback and false-positive.
408
+ const initialPostureTimer = nodeSetTimeout(() => { runPostureProbe() }, 5_000)
409
+ initialPostureTimer.unref()
410
+ ctx.effect(() => () => { clearTimeout(initialPostureTimer) }, 'remote-web-ui: posture probe boot')
345
411
  // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
346
412
  // plugin is not installed; stop() / enabled=false still refuse cookies.
347
413
  new RemoteWebUiPairing(ctx, (request) => {
@@ -376,7 +442,10 @@ function applyImpl(ctx: Context, config?: Config): void {
376
442
  if (disposeRoutes === undefined && enabled) {
377
443
  disposeRoutes = ctx.effect(
378
444
  () => {
379
- const disposers = routes.map(route => ctx.webServer.register(route))
445
+ const disposers = [
446
+ ...routes.map(route => ctx.webServer.register(route)),
447
+ ...upgrades.map(route => ctx.webServer.registerUpgrade(route)),
448
+ ]
380
449
  return () => { for (const dispose of disposers) dispose() }
381
450
  },
382
451
  'remote-web-ui: pairing routes',
@@ -398,6 +467,9 @@ function applyImpl(ctx: Context, config?: Config): void {
398
467
  disposeSweep()
399
468
  disposeSweep = undefined
400
469
  }
470
+ // Settings changed the reachable posture (manual publicBaseUrl, bind):
471
+ // re-probe unless the target set is unchanged.
472
+ runPostureProbe()
401
473
  }
402
474
  installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
403
475
  setSource: (source) => {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Loopback-shaped reverse proxy used by the remote desktop channel: after
3
+ * the pairing cookie gate, traffic is re-issued to 127.0.0.1 so sibling
4
+ * plugin fences (socket + Host loopback) accept it. Origin, cookies, and
5
+ * caller-controlled Sec-Fetch markers are dropped. HTTP requests receive a
6
+ * synthetic same-origin marker after the pairing gate so sibling loopback
7
+ * routes that require a browser tripwire accept the authenticated proxy.
8
+ */
9
+
10
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
11
+ import { request as httpRequest } from 'node:http'
12
+ import { connect } from 'node:net'
13
+ import type { Duplex } from 'node:stream'
14
+ import { writeJson } from './http.ts'
15
+
16
+ /** WebSocket handshake headers forwarded to the loopback upstream. */
17
+ const WS_FORWARD_HEADERS = [
18
+ 'sec-websocket-key',
19
+ 'sec-websocket-version',
20
+ 'sec-websocket-extensions',
21
+ 'sec-websocket-protocol',
22
+ ] as const
23
+
24
+ /** Response headers copied from the loopback upstream (no hop-by-hop). */
25
+ const HTTP_FORWARD_RESPONSE_HEADERS = [
26
+ 'content-type',
27
+ 'content-length',
28
+ 'content-disposition',
29
+ 'cache-control',
30
+ 'etag',
31
+ 'last-modified',
32
+ ] as const
33
+
34
+ /**
35
+ * Pipe one HTTP request to loopback and stream the response back.
36
+ * @param req - the already-gated outer request.
37
+ * @param res - the outer response.
38
+ * @param port - local webServer port.
39
+ * @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
40
+ */
41
+ export function proxyLoopbackHttp(
42
+ req: IncomingMessage,
43
+ res: ServerResponse,
44
+ port: number,
45
+ upstreamPath: string,
46
+ ): void {
47
+ const headers: Record<string, string> = {
48
+ host: `127.0.0.1:${String(port)}`,
49
+ 'sec-fetch-site': 'same-origin',
50
+ }
51
+ const contentType = req.headers['content-type']
52
+ if (typeof contentType === 'string') headers['content-type'] = contentType
53
+ const contentLength = req.headers['content-length']
54
+ if (typeof contentLength === 'string') headers['content-length'] = contentLength
55
+ const accept = req.headers.accept
56
+ if (typeof accept === 'string') headers.accept = accept
57
+
58
+ const upstream = httpRequest({
59
+ host: '127.0.0.1',
60
+ port,
61
+ path: upstreamPath,
62
+ method: req.method,
63
+ headers,
64
+ }, (upstreamRes) => {
65
+ const out: OutgoingHttpHeaders = {}
66
+ for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
67
+ const value = upstreamRes.headers[name]
68
+ if (value !== undefined) out[name] = value
69
+ }
70
+ res.writeHead(upstreamRes.statusCode ?? 502, out)
71
+ upstreamRes.pipe(res)
72
+ })
73
+ upstream.on('error', () => {
74
+ if (!res.headersSent) {
75
+ writeJson(res, 502, { ok: false, error: { code: 'upstream-failure', message: 'upstream request failed' } })
76
+ return
77
+ }
78
+ res.destroy()
79
+ })
80
+ req.pipe(upstream)
81
+ }
82
+
83
+ /**
84
+ * Rebuild a WebSocket handshake as loopback-shaped and pipe both directions.
85
+ * @param req - the already-gated upgrade request.
86
+ * @param socket - the client duplex.
87
+ * @param head - bytes already read past the handshake.
88
+ * @param port - local webServer port.
89
+ * @param upstreamPath - path + query on 127.0.0.1.
90
+ */
91
+ export function proxyLoopbackUpgrade(
92
+ req: IncomingMessage,
93
+ socket: Duplex,
94
+ head: Buffer,
95
+ port: number,
96
+ upstreamPath: string,
97
+ ): void {
98
+ const lines = [
99
+ `GET ${upstreamPath} HTTP/1.1`,
100
+ `Host: 127.0.0.1:${String(port)}`,
101
+ 'Upgrade: websocket',
102
+ 'Connection: Upgrade',
103
+ ]
104
+ for (const name of WS_FORWARD_HEADERS) {
105
+ const value = req.headers[name]
106
+ if (value === undefined) continue
107
+ lines.push(`${name}: ${Array.isArray(value) ? value.join(', ') : value}`)
108
+ }
109
+ const handshake = `${lines.join('\r\n')}\r\n\r\n`
110
+
111
+ const upstream = connect(port, '127.0.0.1')
112
+ const tearDown = (): void => {
113
+ upstream.destroy()
114
+ socket.destroy()
115
+ }
116
+ upstream.on('error', tearDown)
117
+ socket.on('error', tearDown)
118
+ upstream.on('close', () => { socket.destroy() })
119
+ socket.on('close', () => { upstream.destroy() })
120
+ upstream.on('connect', () => {
121
+ upstream.write(handshake)
122
+ if (head.length > 0) upstream.write(head)
123
+ socket.pipe(upstream)
124
+ upstream.pipe(socket)
125
+ })
126
+ }
@@ -51,6 +51,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
51
51
  const [presetsLoading, setPresetsLoading] = useState(true)
52
52
  const cursorRef = useRef<string | undefined>(undefined)
53
53
  const busyRef = useRef(false)
54
+ // The freshest owned-id set: the mount effect re-reads the workspace roster
55
+ // so a session created since this row was captured still shows; loadMore
56
+ // filters later pages through this ref instead of the stale prop.
57
+ const workspaceRef = useRef(workspace)
54
58
 
55
59
  // First page on mount. The workspace roster is re-read alongside so the
56
60
  // owned-id set is fresh: a session created (or attached) since this
@@ -64,6 +68,7 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
64
68
  if (cancelled) return
65
69
  const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId)
66
70
  const current = fresh ?? workspace
71
+ workspaceRef.current = current
67
72
  setRows(ownedItems(page.items, current))
68
73
  cursorRef.current = page.nextCursor
69
74
  setHasMore(page.hasMore)
@@ -112,7 +117,7 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
112
117
  setLoading(false)
113
118
  cursorRef.current = page.nextCursor
114
119
  setHasMore(page.hasMore)
115
- setRows(previous => [...previous, ...ownedItems(page.items, workspace)])
120
+ setRows(previous => [...previous, ...ownedItems(page.items, workspaceRef.current)])
116
121
  },
117
122
  (reason: unknown) => {
118
123
  busyRef.current = false
package/src/mobile-api.ts CHANGED
@@ -33,7 +33,8 @@ import { readCookie } from './gate.ts'
33
33
  * note the paired-device cookie also passes the global api/gate for the full
34
34
  * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
35
35
  * the allowlist only constrains this /m/api proxy, not the cookie's reach.
36
- * stop() revokes every device; there is no per-device revocation yet.
36
+ * stop() revokes every device; the loopback panel can also revoke one
37
+ * device at a time.
37
38
  */
38
39
  const MOBILE_ALLOWLIST = new Set([
39
40
  'workspace.list',
@@ -259,7 +260,10 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
259
260
  const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
260
261
  if (method === 'session.list') {
261
262
  const full = await apiProxy.sessions.list(request as never)
262
- if (!full.result.ok) return full
263
+ // The error path must carry the same 'server-response' envelope the
264
+ // success path builds, or the phone's callUnary throws a transport error
265
+ // and masks the real business error.
266
+ if (!full.result.ok) return { type: 'server-response' as const, rpcId, result: full.result }
263
267
  const items = full.result.value.items as Array<{ updatedAt: number; sessionId: string }>
264
268
  const cursor = (payload as { cursor?: string } | undefined)?.cursor
265
269
  // Every call pages (the first call with no cursor IS the first page):
@@ -42,7 +42,7 @@ function pageHtml(bundleUrl: string): string {
42
42
  '<meta name="apple-mobile-web-app-title" content="DSH Remote">',
43
43
  '<link rel="manifest" href="/m/manifest.webmanifest">',
44
44
  '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
45
- '<title>移动端远程控制</title>',
45
+ '<title>远程访问</title>',
46
46
  '</head>',
47
47
  '<body>',
48
48
  '<div id="root"></div>',
@@ -1,20 +1,21 @@
1
1
  /**
2
2
  * PairingService device-session persistence: with `devicesFile` set, sessions
3
- * written on accept/stop survive a process restart (a new PairingService
4
- * instance), so a previously paired phone — whose cookie already lives 365
5
- * days — never needs to re-scan the QR after `dsh web` restarts.
3
+ * written on accept/stop/revoke/sweep survive a process restart (a new
4
+ * PairingService instance). lastSeenAt is flushed on sweep, not on every
5
+ * touch; idle sessions older than idleExpireMs are dropped on load.
6
6
  */
7
7
  import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
8
  import { tmpdir } from 'node:os'
9
9
  import { join } from 'node:path'
10
10
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
11
  import { pairingConfigOf } from './index.ts'
12
- import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
12
+ import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
13
13
 
14
14
  const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
15
15
  tokenTtlMs: 60_000,
16
16
  offlineAfterMs: 25_000,
17
17
  maxDevices: 4,
18
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
18
19
  cookieName: 'dsh_pair',
19
20
  }
20
21
 
@@ -53,7 +54,11 @@ describe('PairingService device persistence', () => {
53
54
  const deviceId = pairDevice(first)
54
55
  expect(readFileSync(file, 'utf8')).toContain(deviceId)
55
56
  // Device ids are session credentials: the persisted file is 0600.
56
- expect(statSync(file).mode & 0o777).toBe(0o600)
57
+ // win32: NTFS has no POSIX mode bits and Node ignores the writeFileSync
58
+ // mode option there, so the mode assertion only applies on POSIX (#772).
59
+ if (process.platform !== 'win32') {
60
+ expect(statSync(file).mode & 0o777).toBe(0o600)
61
+ }
57
62
 
58
63
  // Simulated restart: a brand-new service instance reading the same file.
59
64
  const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
@@ -66,6 +71,7 @@ describe('PairingService device persistence', () => {
66
71
  tokenTtlMs: 60_000,
67
72
  offlineAfterMs: 25_000,
68
73
  maxDevices: 4,
74
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
69
75
  cookieName: 'dsh_pair',
70
76
  devicesFile: file,
71
77
  })
@@ -81,6 +87,7 @@ describe('PairingService device persistence', () => {
81
87
  tokenTtlMs: 60_000,
82
88
  offlineAfterMs: 25_000,
83
89
  maxDevices: 4,
90
+ idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
84
91
  cookieName: 'dsh_pair',
85
92
  devicesFile: file,
86
93
  })
@@ -142,4 +149,56 @@ describe('PairingService device persistence', () => {
142
149
  const ghost = join(dir, 'does-not-exist.json')
143
150
  expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
144
151
  })
152
+
153
+ it('does not persist lastSeenAt on touch; sweep flushes it', () => {
154
+ const file = join(dir, 'devices.json')
155
+ const now = { value: 1_000_000 }
156
+ let n = 0
157
+ const clock: PairingClock = {
158
+ now: () => now.value,
159
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
160
+ }
161
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
162
+ const deviceId = pairDevice(first)
163
+ const afterAccept = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
164
+ expect(afterAccept[deviceId]?.lastSeenAt).toBe(1_000_000)
165
+ now.value = 1_500_000
166
+ expect(first.touchDevice(deviceId)).toBe(true)
167
+ const afterTouch = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
168
+ expect(afterTouch[deviceId]?.lastSeenAt).toBe(1_000_000)
169
+ first.sweep()
170
+ const afterSweep = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
171
+ expect(afterSweep[deviceId]?.lastSeenAt).toBe(1_500_000)
172
+
173
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
174
+ expect(second.hasDevice(deviceId)).toBe(true)
175
+ expect(second.snapshot().devices[0]?.lastSeenAt).toBe(1_500_000)
176
+ })
177
+
178
+ it('drops idle sessions on load instead of restoring them', () => {
179
+ const file = join(dir, 'devices.json')
180
+ writeFileSync(file, JSON.stringify({
181
+ stale: { createdAt: 1, lastSeenAt: 1 },
182
+ }))
183
+ const clock: PairingClock = {
184
+ now: () => 1 + DEFAULT_IDLE_EXPIRE_MS + 1,
185
+ randomToken: () => 'tok',
186
+ }
187
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
188
+ expect(service.hasDevice('stale')).toBe(false)
189
+ const saved = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
190
+ expect(saved).toEqual({})
191
+ })
192
+
193
+ it('persists a User-Agent captured at accept', () => {
194
+ const file = join(dir, 'devices.json')
195
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
196
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
197
+ const { token } = service.issue()
198
+ const result = service.accept(token, 'Mozilla/5.0 TestPhone')
199
+ expect(result.ok).toBe(true)
200
+ const saved = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { userAgent?: string }>
201
+ const deviceId = result.ok ? result.deviceId : ''
202
+ expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone')
203
+ })
145
204
  })