@linxin666/dsh-remote-web-ui 0.1.19 → 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 (144) 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 +57 -13
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +408 -130
  10. package/lib/mobile.js +296 -22
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/FooterRemoteEntry.js +1 -1
  13. package/lib/types/client/PluginSettingsCard.d.ts +7 -1
  14. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PluginSettingsCard.js +5 -3
  16. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  17. package/lib/types/client/RemoteEntry.js +43 -13
  18. package/lib/types/client/RemotePanel.d.ts +1 -0
  19. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  20. package/lib/types/client/RemotePanel.js +2 -2
  21. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  22. package/lib/types/client/RemoteSettingsCard.js +3 -3
  23. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  24. package/lib/types/client/UpdateEntry.js +37 -4
  25. package/lib/types/client/UpdatePanel.js +1 -1
  26. package/lib/types/client/deep-link.js +2 -2
  27. package/lib/types/client/index.js +7 -7
  28. package/lib/types/client/locales.d.ts +2 -0
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/locales.js +2 -0
  31. package/lib/types/client/settings-form.d.ts.map +1 -1
  32. package/lib/types/client/update-api.d.ts.map +1 -1
  33. package/lib/types/gate.d.ts +9 -0
  34. package/lib/types/gate.d.ts.map +1 -1
  35. package/lib/types/gate.js +16 -5
  36. package/lib/types/http.d.ts +14 -0
  37. package/lib/types/http.d.ts.map +1 -0
  38. package/lib/types/http.js +27 -0
  39. package/lib/types/index.d.ts +28 -1
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +41 -22
  42. package/lib/types/invariant.d.ts.map +1 -1
  43. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  44. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  45. package/lib/types/mobile/PairRequiredView.js +28 -0
  46. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  47. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  48. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  49. package/lib/types/mobile/api.js +1 -1
  50. package/lib/types/mobile/index.js +17 -4
  51. package/lib/types/mobile/markdown.d.ts +6 -4
  52. package/lib/types/mobile/markdown.d.ts.map +1 -1
  53. package/lib/types/mobile/markdown.js +8 -4
  54. package/lib/types/mobile/markdown.test.js +5 -1
  55. package/lib/types/mobile/messages.d.ts.map +1 -1
  56. package/lib/types/mobile/messages.js +14 -1
  57. package/lib/types/mobile/messages.test.js +1 -1
  58. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  59. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  60. package/lib/types/mobile/mobile-styles.js +48 -0
  61. package/lib/types/mobile/mobile-theme.test.js +2 -2
  62. package/lib/types/mobile/mux.d.ts.map +1 -1
  63. package/lib/types/mobile/mux.js +1 -1
  64. package/lib/types/mobile/mux.test.js +1 -1
  65. package/lib/types/mobile/pairing.d.ts +31 -0
  66. package/lib/types/mobile/pairing.d.ts.map +1 -0
  67. package/lib/types/mobile/pairing.js +62 -0
  68. package/lib/types/mobile/rpc.d.ts.map +1 -1
  69. package/lib/types/mobile/theme-toggle.js +1 -1
  70. package/lib/types/mobile/views/App.d.ts +12 -16
  71. package/lib/types/mobile/views/App.d.ts.map +1 -1
  72. package/lib/types/mobile/views/App.js +39 -12
  73. package/lib/types/mobile/views/App.test.d.ts +2 -0
  74. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  75. package/lib/types/mobile/views/App.test.js +52 -0
  76. package/lib/types/mobile/views/ChatView.js +6 -6
  77. package/lib/types/mobile/views/ChatView.test.js +3 -3
  78. package/lib/types/mobile/views/SessionListView.js +3 -3
  79. package/lib/types/mobile/views/SessionListView.test.js +2 -2
  80. package/lib/types/mobile/views/WorkspaceView.js +3 -3
  81. package/lib/types/mobile/views/WorkspaceView.test.js +3 -3
  82. package/lib/types/mobile-api.d.ts +1 -1
  83. package/lib/types/mobile-api.d.ts.map +1 -1
  84. package/lib/types/mobile-api.js +11 -18
  85. package/lib/types/mobile-pwa.d.ts +6 -0
  86. package/lib/types/mobile-pwa.d.ts.map +1 -0
  87. package/lib/types/mobile-pwa.js +17 -0
  88. package/lib/types/mobile-routes.d.ts +5 -8
  89. package/lib/types/mobile-routes.d.ts.map +1 -1
  90. package/lib/types/mobile-routes.js +130 -39
  91. package/lib/types/mount-once.d.ts +25 -0
  92. package/lib/types/mount-once.d.ts.map +1 -0
  93. package/lib/types/mount-once.js +42 -0
  94. package/lib/types/pairing-access.d.ts +28 -0
  95. package/lib/types/pairing-access.d.ts.map +1 -0
  96. package/lib/types/pairing-access.js +26 -0
  97. package/lib/types/pairing.d.ts +27 -0
  98. package/lib/types/pairing.d.ts.map +1 -1
  99. package/lib/types/pairing.js +68 -0
  100. package/lib/types/pairing.persist.test.d.ts +2 -0
  101. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  102. package/lib/types/pairing.persist.test.js +128 -0
  103. package/lib/types/routes.d.ts +6 -6
  104. package/lib/types/routes.d.ts.map +1 -1
  105. package/lib/types/routes.js +15 -20
  106. package/lib/types/tunnel.d.ts.map +1 -1
  107. package/lib/types/update-routes.d.ts +2 -2
  108. package/lib/types/update-routes.d.ts.map +1 -1
  109. package/lib/types/update-routes.js +1 -6
  110. package/lib/types/update.d.ts +3 -3
  111. package/lib/types/update.d.ts.map +1 -1
  112. package/lib/types/update.js +40 -14
  113. package/package.json +12 -12
  114. package/src/client/PluginSettingsCard.tsx +10 -0
  115. package/src/client/RemoteEntry.tsx +40 -8
  116. package/src/client/RemotePanel.tsx +1 -1
  117. package/src/client/RemoteSettingsCard.tsx +1 -0
  118. package/src/client/UpdateEntry.tsx +36 -4
  119. package/src/client/deep-link.ts +1 -1
  120. package/src/client/locales.ts +2 -0
  121. package/src/client/remote.module.css +26 -0
  122. package/src/gate.ts +15 -3
  123. package/src/http.ts +30 -0
  124. package/src/index.ts +50 -16
  125. package/src/mobile/PairRequiredView.test.tsx +28 -0
  126. package/src/mobile/PairRequiredView.tsx +58 -0
  127. package/src/mobile/index.tsx +15 -1
  128. package/src/mobile/markdown.test.ts +5 -0
  129. package/src/mobile/markdown.ts +7 -4
  130. package/src/mobile/messages.ts +14 -1
  131. package/src/mobile/mobile-styles.ts +48 -0
  132. package/src/mobile/pairing.ts +77 -0
  133. package/src/mobile/views/App.test.tsx +62 -0
  134. package/src/mobile/views/App.tsx +53 -18
  135. package/src/mobile-api.ts +9 -17
  136. package/src/mobile-pwa.ts +19 -0
  137. package/src/mobile-routes.ts +137 -38
  138. package/src/mount-once.ts +48 -0
  139. package/src/pairing-access.ts +36 -0
  140. package/src/pairing.persist.test.ts +145 -0
  141. package/src/pairing.ts +70 -1
  142. package/src/routes.ts +12 -18
  143. package/src/update-routes.ts +1 -7
  144. package/src/update.ts +46 -15
@@ -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 =
@@ -30,18 +31,6 @@ export interface SessionView {
30
31
  blank: boolean
31
32
  }
32
33
 
33
- /** One rendered chat message (produced by the fold). */
34
- export interface RenderMessage {
35
- id: string
36
- kind: 'user' | 'assistant'
37
- text: string
38
- seq: number
39
- time: number
40
- pending?: boolean
41
- failed?: boolean
42
- toolSummary?: string
43
- }
44
-
45
34
  /** Read the optional workspace target carried from the pairing QR flow. */
46
35
  export function mobileWorkspaceTarget(search: string): string | undefined {
47
36
  const value = new URLSearchParams(search).get('workspace')
@@ -83,11 +72,57 @@ export function formatTime(epochMs: number): string {
83
72
  return `${String(date.getMonth() + 1)}月${String(date.getDate())}日 ${clock}`
84
73
  }
85
74
 
86
- /**
87
- * The surface root.
88
- * @returns the app tree.
89
- */
90
- 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() {
91
126
  const [route, setRoute] = useState<Route>({ kind: 'workspaces' })
92
127
  const [initialWorkspaceId, setInitialWorkspaceId] = useState<string | undefined>(
93
128
  () => mobileWorkspaceTarget(window.location.search),
package/src/mobile-api.ts CHANGED
@@ -25,6 +25,7 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
25
25
  import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
26
26
  import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
27
27
  import type { PairingService } from './pairing.ts'
28
+ import { readBoundedJson, writeJson } from './http.ts'
28
29
  import { readCookie } from './gate.ts'
29
30
 
30
31
  /** Methods the phone surface may call. Everything else is refused. */
@@ -122,11 +123,6 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
122
123
  return touchDeviceFor(req)
123
124
  }
124
125
 
125
- const writeJson = (res: ServerResponse, status: number, body: unknown): void => {
126
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
127
- res.end(JSON.stringify(body))
128
- }
129
-
130
126
  const handleMethod = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
131
127
  if (req.method !== 'POST') {
132
128
  res.writeHead(405)
@@ -170,7 +166,11 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
170
166
  return
171
167
  }
172
168
  try {
173
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId)
169
+ // Cancel the host-side work when the phone goes away mid-call (the
170
+ // response stream closing before we answer means nobody is listening).
171
+ const abort = new AbortController()
172
+ res.on('close', () => { if (!res.writableEnded) abort.abort() })
173
+ const response = await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal)
174
174
  writeJson(res, 200, response)
175
175
  } catch (error) {
176
176
  const message = error instanceof Error ? error.message : String(error)
@@ -244,19 +244,11 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
244
244
 
245
245
  /** Read a request body as JSON (bounded). */
246
246
  async function readJsonBody(req: IncomingMessage): Promise<unknown> {
247
- const chunks: Buffer[] = []
248
- let size = 0
249
- for await (const chunk of req) {
250
- const buffer = chunk as Buffer
251
- size += buffer.length
252
- if (size > 64 * 1024) throw new Error('body too large')
253
- chunks.push(buffer)
254
- }
255
- return JSON.parse(Buffer.concat(chunks).toString('utf8'))
247
+ return readBoundedJson(req, 64 * 1024)
256
248
  }
257
249
 
258
250
  /** Dispatch one allowlisted method through the host ApiProxy. */
259
- async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string): Promise<unknown> {
251
+ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string, signal?: AbortSignal): Promise<unknown> {
260
252
  const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
261
253
  if (method === 'session.list') {
262
254
  const full = await apiProxy.sessions.list(request as never)
@@ -302,7 +294,7 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
302
294
  if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
303
295
  if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
304
296
  if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
305
- if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, new AbortController().signal))
297
+ if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
306
298
  if (method === 'session.prompt') return wrap(await apiProxy.sessions.prompt(request as never))
307
299
  if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
308
300
  if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
@@ -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,43 +118,92 @@ 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
+
147
+ // The bundle is immutable for the process lifetime (a rebuild requires a
148
+ // host restart), so the body is read from disk once, not per phone.
149
+ let bundleBody: string | undefined
79
150
  const handleBundle = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
80
- const path = mobileBundlePath()
81
- if (!existsSync(path)) {
82
- writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
83
- return
151
+ if (bundleBody === undefined) {
152
+ const path = mobileBundlePath()
153
+ if (!existsSync(path)) {
154
+ writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
155
+ return
156
+ }
157
+ try {
158
+ bundleBody = await readFile(path, 'utf8')
159
+ } catch {
160
+ writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
161
+ return
162
+ }
84
163
  }
85
- try {
86
- const body = await readFile(path, 'utf8')
87
- writeStatic(res, 200, 'text/javascript', body)
88
- } catch {
89
- writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
164
+ writeStatic(res, 200, 'text/javascript', bundleBody)
165
+ }
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
+ }
90
181
  }
182
+ writeServiceWorker(res, workerBody)
91
183
  }
92
- const handleIcon = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
93
- const path = appleTouchIconPath()
184
+
185
+ const makeIconHandler = (asset: string): WebRoute['handler'] => async (_req, res) => {
186
+ const path = mobileAssetPath(asset)
94
187
  if (!existsSync(path)) {
95
- writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found')
188
+ writeStatic(res, 404, 'text/plain', asset + ' not found')
96
189
  return
97
190
  }
98
191
  try {
99
- const body = await readFile(path)
100
- writePng(res, 200, body)
192
+ writePng(res, 200, await readFile(path))
101
193
  } catch {
102
- writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon')
194
+ writeStatic(res, 500, 'text/plain', 'failed to read ' + asset)
103
195
  }
104
196
  }
197
+
105
198
  return [
106
- { kind: 'exact', path: '/m', handler: handlePage },
107
- { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
108
- { 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') },
109
208
  ]
110
209
  }
@@ -0,0 +1,48 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/mount-once.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Host single-instance guard shared by the plugin family. The family bundle
4
+ * (dsh-web-ui-all / dsh-skins) namespaces every child row id (web-ui-*), so
5
+ * the loader accepts a standalone install of the same package side by side;
6
+ * without this guard the second instance would still re-register the same
7
+ * webserver routes, tools, settings namespaces, and system-prompt sections
8
+ * and fail the boot. mountOnce makes the second host apply a no-op for the
9
+ * lifetime of the first instance (the browser half is already deduped by
10
+ * package name in the client module host).
11
+ *
12
+ * The registry rides a global symbol so two module instances of the same
13
+ * package (npm copy vs repository link) still share one verdict. cordis
14
+ * `ctx.effect` runs its callback immediately and treats the callback's
15
+ * return value as the fiber disposer, so the unmarker is returned, not run.
16
+ */
17
+
18
+ const MOUNTED = Symbol.for('dsh-web-ui.mounted-plugins')
19
+
20
+ interface MountRegistry {
21
+ [MOUNTED]?: Set<string>
22
+ }
23
+
24
+ function mountedSet(): Set<string> {
25
+ const registry = globalThis as MountRegistry
26
+ return (registry[MOUNTED] ??= new Set())
27
+ }
28
+
29
+ /**
30
+ * Wrap a cordis plugin apply so the package runs at most once per process.
31
+ * The first mount registers normally and unmarks when its fiber disposes;
32
+ * any later mount of the same package name is a no-op.
33
+ * @param packageName - npm package identity shared by every install source.
34
+ * @param fn - the original plugin apply.
35
+ * @returns an apply of the same shape.
36
+ */
37
+ export function mountOnce<T extends (...args: any[]) => unknown>(packageName: string, fn: T): T {
38
+ return ((...args: unknown[]) => {
39
+ const mounted = mountedSet()
40
+ if (mounted.has(packageName)) return
41
+ mounted.add(packageName)
42
+ const ctx = args[0] as { effect?: (effect: () => unknown) => unknown } | undefined
43
+ ctx?.effect?.(() => () => {
44
+ mounted.delete(packageName)
45
+ })
46
+ return fn(...args)
47
+ }) as T
48
+ }
@@ -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
+ }