@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.0

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 +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +37 -10
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +290 -53
  10. package/lib/mobile.js +278 -17
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +5 -3
  15. package/lib/types/client/RemoteEntry.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/RemoteSettingsCard.js +1 -1
  18. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  19. package/lib/types/client/UpdateEntry.js +36 -3
  20. package/lib/types/client/deep-link.js +1 -1
  21. package/lib/types/client/locales.d.ts +2 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +2 -0
  24. package/lib/types/gate.d.ts +9 -0
  25. package/lib/types/gate.d.ts.map +1 -1
  26. package/lib/types/gate.js +15 -4
  27. package/lib/types/index.d.ts +25 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +30 -13
  30. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  31. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  32. package/lib/types/mobile/PairRequiredView.js +28 -0
  33. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  34. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  35. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  36. package/lib/types/mobile/index.js +14 -1
  37. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  38. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  39. package/lib/types/mobile/mobile-styles.js +48 -0
  40. package/lib/types/mobile/pairing.d.ts +31 -0
  41. package/lib/types/mobile/pairing.d.ts.map +1 -0
  42. package/lib/types/mobile/pairing.js +62 -0
  43. package/lib/types/mobile/views/App.d.ts +12 -5
  44. package/lib/types/mobile/views/App.d.ts.map +1 -1
  45. package/lib/types/mobile/views/App.js +34 -7
  46. package/lib/types/mobile/views/App.test.d.ts +2 -0
  47. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  48. package/lib/types/mobile/views/App.test.js +52 -0
  49. package/lib/types/mobile-pwa.d.ts +6 -0
  50. package/lib/types/mobile-pwa.d.ts.map +1 -0
  51. package/lib/types/mobile-pwa.js +17 -0
  52. package/lib/types/mobile-routes.d.ts +5 -8
  53. package/lib/types/mobile-routes.d.ts.map +1 -1
  54. package/lib/types/mobile-routes.js +115 -30
  55. package/lib/types/pairing-access.d.ts +28 -0
  56. package/lib/types/pairing-access.d.ts.map +1 -0
  57. package/lib/types/pairing-access.js +26 -0
  58. package/lib/types/pairing.d.ts +27 -0
  59. package/lib/types/pairing.d.ts.map +1 -1
  60. package/lib/types/pairing.js +68 -0
  61. package/lib/types/pairing.persist.test.d.ts +2 -0
  62. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  63. package/lib/types/pairing.persist.test.js +128 -0
  64. package/lib/types/routes.js +1 -1
  65. package/lib/types/update.d.ts +3 -3
  66. package/lib/types/update.d.ts.map +1 -1
  67. package/lib/types/update.js +34 -11
  68. package/package.json +12 -12
  69. package/src/client/PluginSettingsCard.tsx +10 -0
  70. package/src/client/RemoteEntry.tsx +2 -2
  71. package/src/client/RemoteSettingsCard.tsx +1 -0
  72. package/src/client/UpdateEntry.tsx +36 -4
  73. package/src/client/deep-link.ts +1 -1
  74. package/src/client/locales.ts +2 -0
  75. package/src/client/remote.module.css +26 -0
  76. package/src/gate.ts +15 -3
  77. package/src/index.ts +46 -15
  78. package/src/mobile/PairRequiredView.test.tsx +28 -0
  79. package/src/mobile/PairRequiredView.tsx +58 -0
  80. package/src/mobile/index.tsx +15 -1
  81. package/src/mobile/mobile-styles.ts +48 -0
  82. package/src/mobile/pairing.ts +77 -0
  83. package/src/mobile/views/App.test.tsx +62 -0
  84. package/src/mobile/views/App.tsx +53 -6
  85. package/src/mobile-pwa.ts +19 -0
  86. package/src/mobile-routes.ts +123 -30
  87. package/src/pairing-access.ts +36 -0
  88. package/src/pairing.persist.test.ts +145 -0
  89. package/src/pairing.ts +70 -1
  90. package/src/routes.ts +1 -1
  91. package/src/update.ts +40 -12
@@ -0,0 +1,77 @@
1
+ /** Pairing helpers owned by the standalone /m mobile surface. */
2
+ export type PairFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
3
+
4
+ export interface MobilePairInput {
5
+ token: string
6
+ workspaceId?: string
7
+ }
8
+
9
+ export type MobilePairAccept =
10
+ | { ok: true }
11
+ | { ok: false; message: string }
12
+
13
+ export type MobilePairBootstrap =
14
+ | { kind: 'none' }
15
+ | { kind: 'accepted'; path: string }
16
+ | { kind: 'failed'; path: string; message: string }
17
+
18
+ const MOBILE_ROOT = '/m/'
19
+
20
+ /** Parse a pairing token or a copied pairing link without following its origin. */
21
+ export function parseMobilePairInput(value: string): MobilePairInput | undefined {
22
+ const trimmed = value.trim()
23
+ if (trimmed === '') return undefined
24
+
25
+ try {
26
+ const url = new URL(trimmed)
27
+ const token = url.searchParams.get('pair')
28
+ if (token === null || token === '') return undefined
29
+ const workspaceId = url.searchParams.get('workspace')
30
+ return {
31
+ token,
32
+ ...(workspaceId !== null && workspaceId !== '' ? { workspaceId } : {}),
33
+ }
34
+ } catch {
35
+ return { token: trimmed }
36
+ }
37
+ }
38
+
39
+ /** Build the safe, token-free mobile destination after successful pairing. */
40
+ export function mobilePairPath(workspaceId?: string): string {
41
+ return workspaceId === undefined ? MOBILE_ROOT : MOBILE_ROOT + '?workspace=' + encodeURIComponent(workspaceId)
42
+ }
43
+
44
+ /** Accept one pairing token on this exact browser or installed-web-app context. */
45
+ export async function acceptMobilePair(token: string, fetcher: PairFetch = fetch): Promise<MobilePairAccept> {
46
+ try {
47
+ const response = await fetcher('/api/pair/accept', {
48
+ method: 'POST',
49
+ headers: { 'content-type': 'application/json' },
50
+ body: JSON.stringify({ token }),
51
+ })
52
+ if (response.ok) return { ok: true }
53
+ if (response.status === 404) return { ok: false, message: '配对链接无效或已过期。' }
54
+ if (response.status === 409) return { ok: false, message: '配对链接已被使用。' }
55
+ return { ok: false, message: '此设备无法使用该配对链接。' }
56
+ } catch {
57
+ return { ok: false, message: '无法连接到配对服务。' }
58
+ }
59
+ }
60
+
61
+ /** Consume a QR pairing token before the mobile application starts making RPC calls. */
62
+ export async function consumeMobilePairUrl(href: string, fetcher: PairFetch = fetch): Promise<MobilePairBootstrap> {
63
+ let url: URL
64
+ try {
65
+ url = new URL(href)
66
+ } catch {
67
+ return { kind: 'none' }
68
+ }
69
+
70
+ const token = url.searchParams.get('pair')
71
+ if (token === null || token === '') return { kind: 'none' }
72
+
73
+ const workspaceId = url.searchParams.get('workspace')
74
+ const path = mobilePairPath(workspaceId === null || workspaceId === '' ? undefined : workspaceId)
75
+ const result = await acceptMobilePair(token, fetcher)
76
+ return result.ok ? { kind: 'accepted', path } : { kind: 'failed', path, message: result.message }
77
+ }
@@ -0,0 +1,62 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
4
+ import { RpcTransportError } from '../rpc.ts'
5
+
6
+ const api = vi.hoisted(() => ({
7
+ fetchMobilePreferences: vi.fn(),
8
+ history: vi.fn(),
9
+ listSessions: vi.fn(),
10
+ listWorkspaces: vi.fn(),
11
+ prompt: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('../api.ts', () => api)
15
+ vi.mock('../mux.ts', () => ({
16
+ MuxClient: class {
17
+ start(): void {}
18
+ stop(): void {}
19
+ observe(): void {}
20
+ },
21
+ }))
22
+ vi.mock('./WorkspaceView.tsx', () => ({ WorkspaceView: () => <div>workspace-ready</div> }))
23
+ vi.mock('./SessionListView.tsx', () => ({ SessionListView: () => <div>sessions-ready</div> }))
24
+ vi.mock('./ChatView.tsx', () => ({ ChatView: () => <div>chat-ready</div> }))
25
+
26
+ import { App, mobilePairStateForError } from './App.tsx'
27
+
28
+ afterEach(() => {
29
+ cleanup()
30
+ vi.clearAllMocks()
31
+ })
32
+
33
+ describe('mobile paired-device gate', () => {
34
+ it('distinguishes a missing paired cookie from a transport outage', () => {
35
+ expect(mobilePairStateForError(new RpcTransportError('HTTP 403'))).toBe('unpaired')
36
+ expect(mobilePairStateForError(new RpcTransportError('HTTP 503'))).toBe('unavailable')
37
+ expect(mobilePairStateForError(new Error('offline'))).toBe('unavailable')
38
+ })
39
+
40
+ it('keeps a valid paired context usable after a failed QR token', async () => {
41
+ api.fetchMobilePreferences.mockResolvedValue({ mobileEnterToSend: true })
42
+ render(<App initialPairError="配对链接已被使用。" />)
43
+
44
+ await waitFor(() => expect(screen.getByText('workspace-ready')).toBeDefined())
45
+ expect(screen.queryByRole('alert')).toBeNull()
46
+ })
47
+
48
+ it('shows the pairing screen only when the mobile gateway rejects the device', async () => {
49
+ api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 403'))
50
+ render(<App initialPairError="配对链接已被使用。" />)
51
+
52
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。'))
53
+ })
54
+
55
+ it('shows a retryable connection failure rather than mounting remote views on outage', async () => {
56
+ api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 503'))
57
+ render(<App />)
58
+
59
+ await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('无法连接到运行中的 DSH host。'))
60
+ expect(screen.queryByText('workspace-ready')).toBeNull()
61
+ })
62
+ })
@@ -7,12 +7,13 @@
7
7
 
8
8
  import { useCallback, useEffect, useRef, useState } from 'react'
9
9
  import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
- import { history as fetchHistory, listSessions, listWorkspaces, prompt } from '../api.ts'
10
+ import { fetchMobilePreferences, history as fetchHistory, listSessions, listWorkspaces, prompt } from '../api.ts'
11
11
  import { MuxClient } from '../mux.ts'
12
12
  import { RpcCallError, RpcTransportError } from '../rpc.ts'
13
13
  import { ChatView } from './ChatView.tsx'
14
14
  import { SessionListView } from './SessionListView.tsx'
15
15
  import { WorkspaceView as WorkspaceRoster } from './WorkspaceView.tsx'
16
+ import { PairRequiredView } from '../PairRequiredView.tsx'
16
17
 
17
18
  /** One navigation level. */
18
19
  type Route =
@@ -71,11 +72,57 @@ export function formatTime(epochMs: number): string {
71
72
  return `${String(date.getMonth() + 1)}月${String(date.getDate())}日 ${clock}`
72
73
  }
73
74
 
74
- /**
75
- * The surface root.
76
- * @returns the app tree.
77
- */
78
- export function App() {
75
+ /** Props accepted by the mobile root before it begins paired-device RPC calls. */
76
+ export interface AppProps {
77
+ initialPairError?: string
78
+ }
79
+
80
+ /** Whether the mobile gateway rejected this browser for lack of a paired cookie. */
81
+ export function isUnpairedMobileError(error: unknown): boolean {
82
+ return error instanceof RpcTransportError && error.message === 'HTTP 403'
83
+ }
84
+
85
+ /** The result of probing the paired-device-only mobile preference endpoint. */
86
+ export type MobilePairState = 'checking' | 'paired' | 'unpaired' | 'unavailable'
87
+
88
+ /** Classify an initial mobile preference failure without treating outage as authorization. */
89
+ export function mobilePairStateForError(error: unknown): Extract<MobilePairState, 'unpaired' | 'unavailable'> {
90
+ return isUnpairedMobileError(error) ? 'unpaired' : 'unavailable'
91
+ }
92
+
93
+ /** Gate the independent mobile bundle until its own browser context is paired. */
94
+ export function App({ initialPairError }: AppProps) {
95
+ const [pairState, setPairState] = useState<MobilePairState>('checking')
96
+
97
+ useEffect(() => {
98
+ let current = true
99
+ void fetchMobilePreferences().then(
100
+ () => { if (current) setPairState('paired') },
101
+ (error: unknown) => { if (current) setPairState(mobilePairStateForError(error)) },
102
+ )
103
+ return () => { current = false }
104
+ }, [])
105
+
106
+ if (pairState === 'checking') {
107
+ return <main className="mobile mobile-empty"><p className="mobile-muted">正在连接...</p></main>
108
+ }
109
+ if (pairState === 'unpaired') {
110
+ return <PairRequiredView initialError={initialPairError} onPaired={(path) => { window.location.replace(path) }} />
111
+ }
112
+ if (pairState === 'unavailable') {
113
+ return (
114
+ <main className="mobile mobile-empty">
115
+ <p className="mobile-error" role="alert">无法连接到运行中的 DSH host。</p>
116
+ <button className="mobile-new" type="button" onClick={() => { window.location.reload() }}>重试</button>
117
+ </main>
118
+ )
119
+ }
120
+
121
+ return <PairedApp />
122
+ }
123
+
124
+ /** The existing remote mobile surface, mounted only after device pairing succeeds. */
125
+ function PairedApp() {
79
126
  const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
80
127
  const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
81
128
  () => mobileWorkspaceTarget(window.location.search),
@@ -0,0 +1,19 @@
1
+ /** PWA registration for the standalone /m mobile surface. */
2
+ export const MOBILE_PWA_SCOPE = '/m/'
3
+ export const MOBILE_SERVICE_WORKER_URL = '/m/service-worker.js'
4
+
5
+ /** Register the mobile shell worker when the current browser supports it. */
6
+ export async function registerMobilePwa(
7
+ serviceWorker: Pick<ServiceWorkerContainer, 'register'> | undefined = typeof navigator === 'undefined' ? undefined : navigator.serviceWorker,
8
+ ): Promise<void> {
9
+ if (serviceWorker === undefined) return
10
+
11
+ try {
12
+ await serviceWorker.register(MOBILE_SERVICE_WORKER_URL, {
13
+ scope: MOBILE_PWA_SCOPE,
14
+ updateViaCache: 'none',
15
+ })
16
+ } catch {
17
+ // HTTP LAN origins cannot register workers; the online mobile UI still works.
18
+ }
19
+ }
@@ -1,11 +1,8 @@
1
1
  /**
2
- * The mobile surface's page routes: `/m` serves the standalone phone UI
3
- * (an independent bundle, built to lib/mobile.js by the mobile tsdown
4
- * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
5
- * host exclusively through the shared /api transport (paired-device cookie
6
- * already crosses the api/gate fence), so no host-side data plumbing is
7
- * needed here — only static serving, loopback+paired-fence via the normal
8
- * webserver route registration.
2
+ * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
3
+ * standalone phone UI. The page and worker live in the /m/ scope so the PWA
4
+ * can cache only its static shell; the paired-device data channel remains at
5
+ * /m/api and is never handled by the worker.
9
6
  */
10
7
 
11
8
  import { readFile } from 'node:fs/promises'
@@ -14,17 +11,23 @@ import { fileURLToPath } from 'node:url'
14
11
  import type { IncomingMessage, ServerResponse } from 'node:http'
15
12
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
13
 
14
+ const MOBILE_ROOT = '/m/'
15
+ const MOBILE_BUNDLE_URL = '/m/mobile.js'
16
+ const MOBILE_MANIFEST_URL = '/m/manifest.webmanifest'
17
+ const MOBILE_WORKER_URL = '/m/service-worker.js'
18
+ const MOBILE_OFFLINE_URL = '/m/offline.html'
19
+
17
20
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
18
21
  function mobileBundlePath(): string {
19
22
  return fileURLToPath(new URL('../lib/mobile.js', import.meta.url))
20
23
  }
21
24
 
22
- /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
23
- function appleTouchIconPath(): string {
24
- return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url))
25
+ /** A package asset available to the host after a registry or git install. */
26
+ function mobileAssetPath(name: string): string {
27
+ return fileURLToPath(new URL('../assets/' + name, import.meta.url))
25
28
  }
26
29
 
27
- /** The mobile page shell: minimal, offline-safe, no external assets. */
30
+ /** The mobile page shell: self-contained, with no document-level user data. */
28
31
  function pageHtml(bundleUrl: string): string {
29
32
  return [
30
33
  '<!doctype html>',
@@ -34,31 +37,78 @@ function pageHtml(bundleUrl: string): string {
34
37
  '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
35
38
  '<meta name="theme-color" content="#f3f5f9">',
36
39
  '<meta name="referrer" content="no-referrer">',
37
- // iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
38
- // the PNG ships with the package (opaque background — iOS composites
39
- // transparency onto black, so the icon avoids transparent regions).
40
+ '<meta name="mobile-web-app-capable" content="yes">',
41
+ '<meta name="apple-mobile-web-app-capable" content="yes">',
42
+ '<meta name="apple-mobile-web-app-title" content="DSH Remote">',
43
+ '<link rel="manifest" href="/m/manifest.webmanifest">',
40
44
  '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
41
45
  '<title>移动端远程控制</title>',
42
46
  '</head>',
43
47
  '<body>',
44
48
  '<div id="root"></div>',
45
- `<script type="module" src="${bundleUrl}"></script>`,
49
+ '<script type="module" src="' + bundleUrl + '"></script>',
46
50
  '</body>',
47
51
  '</html>',
48
52
  ].join('')
49
53
  }
50
54
 
51
- /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
55
+ /** The installable app identity and icon declarations for the /m scope. */
56
+ function manifestJson(): string {
57
+ return JSON.stringify({
58
+ id: MOBILE_ROOT,
59
+ name: 'DSH Remote',
60
+ short_name: 'DSH Remote',
61
+ start_url: MOBILE_ROOT,
62
+ scope: MOBILE_ROOT,
63
+ display: 'standalone',
64
+ background_color: '#151424',
65
+ theme_color: '#f3f5f9',
66
+ icons: [
67
+ { src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
68
+ { src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
69
+ ],
70
+ }, null, 2)
71
+ }
72
+
73
+ /** A static fallback that contains no paired-device, session, or workspace data. */
74
+ function offlineHtml(): string {
75
+ return [
76
+ '<!doctype html>',
77
+ '<html lang="zh-CN">',
78
+ '<head>',
79
+ '<meta charset="utf-8">',
80
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
81
+ '<meta name="theme-color" content="#151424">',
82
+ '<title>DSH Remote</title>',
83
+ '<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>',
84
+ '</head>',
85
+ '<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>',
86
+ '</html>',
87
+ ].join('')
88
+ }
89
+
90
+ /** Send a small static UTF-8 body with revalidation headers. */
52
91
  function writeStatic(res: ServerResponse, status: number, type: string, body: string): void {
53
92
  res.writeHead(status, {
54
- 'content-type': `${type}; charset=utf-8`,
93
+ 'content-type': type + '; charset=utf-8',
94
+ 'cache-control': 'no-cache',
95
+ 'referrer-policy': 'no-referrer',
96
+ })
97
+ res.end(body)
98
+ }
99
+
100
+ /** Send the worker with the same scope it registers for, without HTTP caching. */
101
+ function writeServiceWorker(res: ServerResponse, body: string): void {
102
+ res.writeHead(200, {
103
+ 'content-type': 'text/javascript; charset=utf-8',
55
104
  'cache-control': 'no-cache',
56
105
  'referrer-policy': 'no-referrer',
106
+ 'service-worker-allowed': MOBILE_ROOT,
57
107
  })
58
108
  res.end(body)
59
109
  }
60
110
 
61
- /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
111
+ /** Send a PNG body without decoding it to UTF-8. */
62
112
  function writePng(res: ServerResponse, status: number, body: Buffer): void {
63
113
  res.writeHead(status, {
64
114
  'content-type': 'image/png',
@@ -68,16 +118,34 @@ function writePng(res: ServerResponse, status: number, body: Buffer): void {
68
118
  res.end(body)
69
119
  }
70
120
 
121
+ /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
122
+ function redirectToMobileRoot(req: IncomingMessage, res: ServerResponse): void {
123
+ const url = new URL(req.url ?? '/m', 'http://x')
124
+ res.writeHead(308, {
125
+ location: MOBILE_ROOT + url.search,
126
+ 'cache-control': 'no-store',
127
+ 'referrer-policy': 'no-referrer',
128
+ })
129
+ res.end()
130
+ }
131
+
71
132
  /**
72
133
  * Build the mobile page routes.
73
- * @returns the two exact routes to register on webServer.
134
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
74
135
  */
75
136
  export function makeMobileRoutes(): WebRoute[] {
76
137
  const handlePage = (_req: IncomingMessage, res: ServerResponse): void => {
77
- writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'))
138
+ writeStatic(res, 200, 'text/html', pageHtml(MOBILE_BUNDLE_URL))
139
+ }
140
+ const handleManifest = (_req: IncomingMessage, res: ServerResponse): void => {
141
+ writeStatic(res, 200, 'application/manifest+json', manifestJson())
78
142
  }
143
+ const handleOffline = (_req: IncomingMessage, res: ServerResponse): void => {
144
+ writeStatic(res, 200, 'text/html', offlineHtml())
145
+ }
146
+
79
147
  // The bundle is immutable for the process lifetime (a rebuild requires a
80
- // host restart), so the ~456KB body is read from disk once, not per phone.
148
+ // host restart), so the body is read from disk once, not per phone.
81
149
  let bundleBody: string | undefined
82
150
  const handleBundle = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
83
151
  if (bundleBody === undefined) {
@@ -95,22 +163,47 @@ export function makeMobileRoutes(): WebRoute[] {
95
163
  }
96
164
  writeStatic(res, 200, 'text/javascript', bundleBody)
97
165
  }
98
- const handleIcon = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
99
- const path = appleTouchIconPath()
166
+
167
+ let workerBody: string | undefined
168
+ const handleWorker = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
169
+ if (workerBody === undefined) {
170
+ const path = mobileAssetPath('mobile-service-worker.js')
171
+ if (!existsSync(path)) {
172
+ writeStatic(res, 503, 'text/plain', 'mobile service worker not found')
173
+ return
174
+ }
175
+ try {
176
+ workerBody = await readFile(path, 'utf8')
177
+ } catch {
178
+ writeStatic(res, 500, 'text/plain', 'failed to read the mobile service worker')
179
+ return
180
+ }
181
+ }
182
+ writeServiceWorker(res, workerBody)
183
+ }
184
+
185
+ const makeIconHandler = (asset: string): WebRoute['handler'] => async (_req, res) => {
186
+ const path = mobileAssetPath(asset)
100
187
  if (!existsSync(path)) {
101
- writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found')
188
+ writeStatic(res, 404, 'text/plain', asset + ' not found')
102
189
  return
103
190
  }
104
191
  try {
105
- const body = await readFile(path)
106
- writePng(res, 200, body)
192
+ writePng(res, 200, await readFile(path))
107
193
  } catch {
108
- writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon')
194
+ writeStatic(res, 500, 'text/plain', 'failed to read ' + asset)
109
195
  }
110
196
  }
197
+
111
198
  return [
112
- { kind: 'exact', path: '/m', handler: handlePage },
113
- { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
114
- { kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
199
+ { kind: 'exact', path: '/m', handler: redirectToMobileRoot },
200
+ { kind: 'exact', path: MOBILE_ROOT, handler: handlePage },
201
+ { kind: 'exact', path: MOBILE_BUNDLE_URL, handler: handleBundle },
202
+ { kind: 'exact', path: MOBILE_MANIFEST_URL, handler: handleManifest },
203
+ { kind: 'exact', path: MOBILE_WORKER_URL, handler: handleWorker },
204
+ { kind: 'exact', path: MOBILE_OFFLINE_URL, handler: handleOffline },
205
+ { kind: 'exact', path: '/m/apple-touch-icon.png', handler: makeIconHandler('apple-touch-icon.png') },
206
+ { kind: 'exact', path: '/m/icon-192.png', handler: makeIconHandler('mobile-icon-192.png') },
207
+ { kind: 'exact', path: '/m/icon-512.png', handler: makeIconHandler('mobile-icon-512.png') },
115
208
  ]
116
209
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Optional cordis service other host plugins can ctx.get without taking a
3
+ * package dependency: a live paired-device cookie is an allow path on
4
+ * routes that sit outside /api (and therefore outside api/gate).
5
+ */
6
+ import type { IncomingMessage } from 'node:http'
7
+ import { Service, type Context } from '@deepseek-ai/cordis'
8
+
9
+ /** Named lookup key sibling plugins pass to ctx.get. */
10
+ export const REMOTE_WEB_UI_PAIRING = 'remoteWebUiPairing'
11
+
12
+ /**
13
+ * Pairing identity for one HTTP request. Structural: consumers must not
14
+ * import this class, only the method shape.
15
+ */
16
+ export class RemoteWebUiPairing extends Service {
17
+ /**
18
+ * @param ctx - host plugin context.
19
+ * @param check - live cookie + session predicate (re-read per request).
20
+ */
21
+ constructor(
22
+ ctx: Context,
23
+ private readonly check: (request: IncomingMessage) => boolean,
24
+ ) {
25
+ super(ctx, REMOTE_WEB_UI_PAIRING)
26
+ }
27
+
28
+ /**
29
+ * Whether the request carries a live paired-device cookie.
30
+ * @param request - the incoming HTTP request.
31
+ * @returns true when the session is live and was refreshed.
32
+ */
33
+ isPairedDevice(request: IncomingMessage): boolean {
34
+ return this.check(request)
35
+ }
36
+ }
@@ -0,0 +1,145 @@
1
+ /**
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.
6
+ */
7
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+ import { pairingConfigOf } from './index.ts'
12
+ import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
13
+
14
+ const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
15
+ tokenTtlMs: 60_000,
16
+ offlineAfterMs: 25_000,
17
+ maxDevices: 4,
18
+ cookieName: 'dsh_pair',
19
+ }
20
+
21
+ /** Deterministic clock: tokens are issued in a fixed, readable sequence. */
22
+ function makeClock(): PairingClock {
23
+ let n = 0
24
+ return {
25
+ now: () => 1_000_000 + n,
26
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
27
+ }
28
+ }
29
+
30
+ /** Pair a device on a service: issue a token and immediately accept it. */
31
+ function pairDevice(service: PairingService): string {
32
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
33
+ const { token } = service.issue()
34
+ const result = service.accept(token)
35
+ if (!result.ok) throw new Error(`pair failed: ${result.code}`)
36
+ return result.deviceId
37
+ }
38
+
39
+ describe('PairingService device persistence', () => {
40
+ let dir: string
41
+
42
+ beforeEach(() => {
43
+ dir = mkdtempSync(join(tmpdir(), 'remote-web-ui-persist-'))
44
+ })
45
+
46
+ afterEach(() => {
47
+ rmSync(dir, { recursive: true, force: true })
48
+ })
49
+
50
+ it('restores paired sessions across a process restart', () => {
51
+ const file = join(dir, 'devices.json')
52
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
53
+ const deviceId = pairDevice(first)
54
+ expect(readFileSync(file, 'utf8')).toContain(deviceId)
55
+ // Device ids are session credentials: the persisted file is 0600.
56
+ expect(statSync(file).mode & 0o777).toBe(0o600)
57
+
58
+ // Simulated restart: a brand-new service instance reading the same file.
59
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
60
+ expect(second.hasDevice(deviceId)).toBe(true)
61
+ })
62
+
63
+ it('carries devicesFile through the config mapping used by sync() (regression)', () => {
64
+ const file = join(dir, 'devices.json')
65
+ const mapped = pairingConfigOf({
66
+ tokenTtlMs: 60_000,
67
+ offlineAfterMs: 25_000,
68
+ maxDevices: 4,
69
+ cookieName: 'dsh_pair',
70
+ devicesFile: file,
71
+ })
72
+ expect(mapped.devicesFile).toBe(file)
73
+ })
74
+
75
+ it('keeps persisting after a sync-style service.config rebuild', () => {
76
+ const file = join(dir, 'devices.json')
77
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
78
+ // applyImpl/sync() reassigns service.config from the resolved config on
79
+ // mount and on every settings change; the rebuild must not drop devicesFile.
80
+ service.config = pairingConfigOf({
81
+ tokenTtlMs: 60_000,
82
+ offlineAfterMs: 25_000,
83
+ maxDevices: 4,
84
+ cookieName: 'dsh_pair',
85
+ devicesFile: file,
86
+ })
87
+ const deviceId = pairDevice(service)
88
+ const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
89
+ expect(restarted.hasDevice(deviceId)).toBe(true)
90
+ })
91
+
92
+ it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
93
+ const file = join(dir, 'devices.json')
94
+ writeFileSync(file, JSON.stringify({
95
+ dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
96
+ dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
97
+ dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
98
+ }))
99
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
100
+ expect(service.hasDevice('dev3')).toBe(true)
101
+ expect(service.hasDevice('dev2')).toBe(true)
102
+ expect(service.hasDevice('dev1')).toBe(false)
103
+ })
104
+
105
+ it('keeps sessions memory-only when devicesFile is unset', () => {
106
+ const first = new PairingService({ ...BASE_CONFIG }, makeClock())
107
+ const deviceId = pairDevice(first)
108
+ // No file was written; a fresh instance without the option knows nothing.
109
+ const second = new PairingService({ ...BASE_CONFIG }, makeClock())
110
+ expect(second.hasDevice(deviceId)).toBe(false)
111
+ })
112
+
113
+ it('persists revocation by stop() across a restart', () => {
114
+ const file = join(dir, 'devices.json')
115
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
116
+ const deviceId = pairDevice(first)
117
+ first.stop()
118
+
119
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
120
+ expect(second.hasDevice(deviceId)).toBe(false)
121
+ })
122
+
123
+ it('persists FIFO eviction when the device cap is reached', () => {
124
+ const file = join(dir, 'devices.json')
125
+ const first = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
126
+ const firstDevice = pairDevice(first)
127
+ const secondDevice = pairDevice(first)
128
+ const thirdDevice = pairDevice(first) // evicts firstDevice
129
+ expect(first.hasDevice(firstDevice)).toBe(false)
130
+
131
+ const second = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
132
+ expect(second.hasDevice(secondDevice)).toBe(true)
133
+ expect(second.hasDevice(thirdDevice)).toBe(true)
134
+ expect(second.hasDevice(firstDevice)).toBe(false)
135
+ })
136
+
137
+ it('tolerates a corrupt or missing file instead of refusing to boot', () => {
138
+ const file = join(dir, 'devices.json')
139
+ writeFileSync(file, '{ this is not json !!!')
140
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())).not.toThrow()
141
+
142
+ const ghost = join(dir, 'does-not-exist.json')
143
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
144
+ })
145
+ })