@linxin666/dsh-remote-web-ui 0.1.1

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 (118) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +346 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +2817 -0
  5. package/lib/index.js +1455 -0
  6. package/lib/invariant.js +21 -0
  7. package/lib/types/client/PairFailedNotice.d.ts +12 -0
  8. package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
  9. package/lib/types/client/PhoneIcon.d.ts +19 -0
  10. package/lib/types/client/PhoneIcon.d.ts.map +1 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts +78 -0
  12. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  13. package/lib/types/client/RemoteEntry.d.ts +10 -0
  14. package/lib/types/client/RemoteEntry.d.ts.map +1 -0
  15. package/lib/types/client/RemotePanel.d.ts +50 -0
  16. package/lib/types/client/RemotePanel.d.ts.map +1 -0
  17. package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
  19. package/lib/types/client/deep-link.d.ts +40 -0
  20. package/lib/types/client/deep-link.d.ts.map +1 -0
  21. package/lib/types/client/index.d.ts +54 -0
  22. package/lib/types/client/index.d.ts.map +1 -0
  23. package/lib/types/client/locales.d.ts +144 -0
  24. package/lib/types/client/locales.d.ts.map +1 -0
  25. package/lib/types/client/pair-api.d.ts +100 -0
  26. package/lib/types/client/pair-api.d.ts.map +1 -0
  27. package/lib/types/client/settings-form.d.ts +119 -0
  28. package/lib/types/client/settings-form.d.ts.map +1 -0
  29. package/lib/types/gate.d.ts +58 -0
  30. package/lib/types/gate.d.ts.map +1 -0
  31. package/lib/types/index.d.ts +78 -0
  32. package/lib/types/index.d.ts.map +1 -0
  33. package/lib/types/invariant.d.ts +16 -0
  34. package/lib/types/invariant.d.ts.map +1 -0
  35. package/lib/types/lan.d.ts +15 -0
  36. package/lib/types/lan.d.ts.map +1 -0
  37. package/lib/types/mobile/api.d.ts +64 -0
  38. package/lib/types/mobile/api.d.ts.map +1 -0
  39. package/lib/types/mobile/index.d.ts +10 -0
  40. package/lib/types/mobile/index.d.ts.map +1 -0
  41. package/lib/types/mobile/messages.d.ts +103 -0
  42. package/lib/types/mobile/messages.d.ts.map +1 -0
  43. package/lib/types/mobile/messages.test.d.ts +2 -0
  44. package/lib/types/mobile/messages.test.d.ts.map +1 -0
  45. package/lib/types/mobile/mobile-styles.d.ts +6 -0
  46. package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
  47. package/lib/types/mobile/mobile-theme.d.ts +22 -0
  48. package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
  49. package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
  50. package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
  51. package/lib/types/mobile/mux.d.ts +52 -0
  52. package/lib/types/mobile/mux.d.ts.map +1 -0
  53. package/lib/types/mobile/rpc.d.ts +38 -0
  54. package/lib/types/mobile/rpc.d.ts.map +1 -0
  55. package/lib/types/mobile/theme-toggle.d.ts +12 -0
  56. package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
  57. package/lib/types/mobile/views/App.d.ts +59 -0
  58. package/lib/types/mobile/views/App.d.ts.map +1 -0
  59. package/lib/types/mobile/views/ChatView.d.ts +39 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
  61. package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
  62. package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
  63. package/lib/types/mobile/views/SessionListView.d.ts +27 -0
  64. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
  65. package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
  66. package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
  67. package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
  68. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
  69. package/lib/types/mobile-api.d.ts +41 -0
  70. package/lib/types/mobile-api.d.ts.map +1 -0
  71. package/lib/types/mobile-routes.d.ts +16 -0
  72. package/lib/types/mobile-routes.d.ts.map +1 -0
  73. package/lib/types/pairing.d.ts +215 -0
  74. package/lib/types/pairing.d.ts.map +1 -0
  75. package/lib/types/routes.d.ts +58 -0
  76. package/lib/types/routes.d.ts.map +1 -0
  77. package/lib/types/tunnel.d.ts +99 -0
  78. package/lib/types/tunnel.d.ts.map +1 -0
  79. package/package.json +104 -0
  80. package/src/client/PairFailedNotice.tsx +33 -0
  81. package/src/client/PhoneIcon.tsx +31 -0
  82. package/src/client/PluginSettingsCard.tsx +207 -0
  83. package/src/client/RemoteEntry.tsx +202 -0
  84. package/src/client/RemotePanel.tsx +186 -0
  85. package/src/client/RemoteSettingsCard.tsx +220 -0
  86. package/src/client/deep-link.ts +137 -0
  87. package/src/client/index.ts +167 -0
  88. package/src/client/locales.ts +149 -0
  89. package/src/client/pair-api.ts +176 -0
  90. package/src/client/remote.module.css +422 -0
  91. package/src/client/settings-card.module.css +296 -0
  92. package/src/client/settings-form.ts +294 -0
  93. package/src/css-modules.d.ts +6 -0
  94. package/src/gate.ts +121 -0
  95. package/src/index.ts +299 -0
  96. package/src/invariant.ts +32 -0
  97. package/src/lan.ts +21 -0
  98. package/src/mobile/api.ts +106 -0
  99. package/src/mobile/index.tsx +27 -0
  100. package/src/mobile/messages.test.ts +222 -0
  101. package/src/mobile/messages.ts +555 -0
  102. package/src/mobile/mobile-styles.ts +853 -0
  103. package/src/mobile/mobile-theme.test.ts +84 -0
  104. package/src/mobile/mobile-theme.ts +76 -0
  105. package/src/mobile/mux.ts +126 -0
  106. package/src/mobile/rpc.ts +90 -0
  107. package/src/mobile/theme-toggle.tsx +47 -0
  108. package/src/mobile/views/App.tsx +152 -0
  109. package/src/mobile/views/ChatView.test.tsx +243 -0
  110. package/src/mobile/views/ChatView.tsx +650 -0
  111. package/src/mobile/views/SessionListView.test.tsx +139 -0
  112. package/src/mobile/views/SessionListView.tsx +186 -0
  113. package/src/mobile/views/WorkspaceView.tsx +108 -0
  114. package/src/mobile-api.ts +271 -0
  115. package/src/mobile-routes.ts +77 -0
  116. package/src/pairing.ts +390 -0
  117. package/src/routes.ts +346 -0
  118. package/src/tunnel.ts +264 -0
@@ -0,0 +1,271 @@
1
+ /**
2
+ * The mobile surface's data channel: `/m/api` proxies the host ApiProxy
3
+ * service for the standalone phone page. The phone's RPC calls ride THIS
4
+ * prefix instead of the connection plugin's `/api` — so the tunneled Host
5
+ * never needs to enter the connection trust fence (a distributable plugin
6
+ * cannot change that fence), and this plugin's own pairing gate is the
7
+ * access control instead.
8
+ *
9
+ * Security model:
10
+ * - Every request must carry a live paired-device cookie (the same gate
11
+ * semantic as the LAN fence), enforced before any host call.
12
+ * - Only an explicit allowlist of methods is proxied; privileged domains
13
+ * (settings, credentials, host actions, goals, subagents, …) are never
14
+ * reachable from the phone.
15
+ * - `session.list` is paged here (the host API returns everything; this
16
+ * layer slices stable pages) so the phone never transfers the whole list.
17
+ * - The live mux stream is bridged over Server-Sent Events on the same
18
+ * prefix (one-directional push; answers to questions/approvals ride the
19
+ * unary channel), gated identically.
20
+ */
21
+
22
+ import type { IncomingMessage, ServerResponse } from 'node:http'
23
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
24
+ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
25
+ import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
26
+ import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
27
+ import type { PairingService } from './pairing.ts'
28
+ import { readCookie } from './gate.ts'
29
+
30
+ /** Methods the phone surface may call. Everything else is refused. */
31
+ const MOBILE_ALLOWLIST = new Set([
32
+ 'workspace.list',
33
+ 'session.create',
34
+ 'session.list',
35
+ 'session.history',
36
+ 'session.search',
37
+ 'session.prompt',
38
+ 'session.models',
39
+ 'session.selectModel',
40
+ 'session.rename',
41
+ ])
42
+
43
+ /** One session.list page (thin phones load incrementally). */
44
+ const SESSION_PAGE_SIZE = 20
45
+
46
+ /** Encode one list position as an opaque continuation cursor. */
47
+ function sessionListCursor(updatedAt: number, sessionId: string): string {
48
+ return `${updatedAt}:${sessionId}`
49
+ }
50
+
51
+ /** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
52
+ function parseSessionListCursor(cursor: string): { updatedAt: number; sessionId: string } | undefined {
53
+ const separator = cursor.indexOf(':')
54
+ if (separator < 0) return undefined
55
+ const updatedAt = Number(cursor.slice(0, separator))
56
+ if (!Number.isFinite(updatedAt)) return undefined
57
+ return { updatedAt, sessionId: cursor.slice(separator + 1) }
58
+ }
59
+
60
+ /** Whether a row comes strictly after the cursor position. */
61
+ function afterCursor(row: { updatedAt: number; sessionId: string }, position: { updatedAt: number; sessionId: string }): boolean {
62
+ return row.updatedAt < position.updatedAt
63
+ || (row.updatedAt === position.updatedAt && row.sessionId > position.sessionId)
64
+ }
65
+
66
+ /** Route-family dependencies. */
67
+ export interface MobileApiDeps {
68
+ /** The pairing service (device gate + cookie name). */
69
+ service: PairingService
70
+ /** The host ApiProxy service (injected by the plugin). */
71
+ apiProxy: ApiProxy
72
+ }
73
+
74
+ /** Mobile API route paths. */
75
+ export const MOBILE_API_PATHS = {
76
+ events: '/m/api/events.mux',
77
+ } as const
78
+
79
+ /** The mobile-api prefix (every other path under it is a method name). */
80
+ const MOBILE_API_PREFIX = '/m/api'
81
+ /** Method extraction: the prefix plus one slash. */
82
+ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
83
+
84
+ /**
85
+ * Build the mobile data-channel routes.
86
+ * @param deps - pairing service + apiProxy.
87
+ * @returns the routes to register on webServer.
88
+ */
89
+ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
90
+ const { service, apiProxy } = deps
91
+
92
+ /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
93
+ const gateOk = (req: IncomingMessage): boolean => {
94
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
95
+ return deviceId !== undefined && service.hasDevice(deviceId)
96
+ }
97
+
98
+ const writeJson = (res: ServerResponse, status: number, body: unknown): void => {
99
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
100
+ res.end(JSON.stringify(body))
101
+ }
102
+
103
+ const handleMethod = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
104
+ if (req.method !== 'POST') {
105
+ res.writeHead(405)
106
+ res.end()
107
+ return
108
+ }
109
+ if (!gateOk(req)) {
110
+ writeJson(res, 403, { ok: false, error: { code: 'unpaired', message: 'mobile session is not paired' } })
111
+ return
112
+ }
113
+ const pathname = new URL(req.url ?? '/', 'http://x').pathname
114
+ if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
115
+ writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown mobile api path' } })
116
+ return
117
+ }
118
+ const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length)
119
+ if (!MOBILE_ALLOWLIST.has(method)) {
120
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
121
+ return
122
+ }
123
+ let envelope: unknown
124
+ try {
125
+ envelope = await readJsonBody(req)
126
+ } catch {
127
+ writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } })
128
+ return
129
+ }
130
+ const parsed = envelope as { rpcId?: unknown; payload?: unknown }
131
+ const rpcId = typeof parsed?.rpcId === 'string' ? parsed.rpcId : ''
132
+ if (rpcId === '') {
133
+ writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing rpcId' } })
134
+ return
135
+ }
136
+ try {
137
+ const response = await dispatch(apiProxy, method, parsed?.payload, rpcId)
138
+ writeJson(res, 200, response)
139
+ } catch (error) {
140
+ const message = error instanceof Error ? error.message : String(error)
141
+ writeJson(res, 200, {
142
+ type: 'server-response',
143
+ rpcId,
144
+ result: { ok: false, error: { code: 'internal', message } },
145
+ })
146
+ }
147
+ }
148
+
149
+ /** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
150
+ const handleEvents = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
151
+ if (req.method !== 'GET') {
152
+ res.writeHead(405)
153
+ res.end()
154
+ return
155
+ }
156
+ if (!gateOk(req)) {
157
+ res.writeHead(403)
158
+ res.end('forbidden')
159
+ return
160
+ }
161
+ res.writeHead(200, {
162
+ 'content-type': 'text/event-stream; charset=utf-8',
163
+ 'cache-control': 'no-cache',
164
+ connection: 'keep-alive',
165
+ })
166
+ const controller = new AbortController()
167
+ let closed = false
168
+ const heartbeat = setInterval(() => {
169
+ if (closed) return
170
+ try {
171
+ res.write(': ping\n\n')
172
+ } catch {
173
+ // The write failed; the close handler tears the subscription down.
174
+ }
175
+ }, 15_000)
176
+ const onClose = (): void => {
177
+ if (closed) return
178
+ closed = true
179
+ controller.abort()
180
+ clearInterval(heartbeat)
181
+ }
182
+ res.on('close', onClose)
183
+ req.on('close', onClose)
184
+ try {
185
+ const frames = apiProxy.events.mux({ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`), payload: {} }, controller.signal)
186
+ for await (const frame of frames) {
187
+ if (closed) break
188
+ res.write(`data: ${JSON.stringify(frame)}\n\n`)
189
+ }
190
+ } catch {
191
+ // The stream ended or errored; the EventSource reconnects.
192
+ } finally {
193
+ controller.abort()
194
+ clearInterval(heartbeat)
195
+ }
196
+ if (!closed) res.end()
197
+ }
198
+
199
+ return [
200
+ { kind: 'prefix', path: MOBILE_API_PREFIX, handler: handleMethod },
201
+ { kind: 'exact', path: MOBILE_API_PATHS.events, handler: handleEvents },
202
+ ]
203
+ }
204
+
205
+ /** Read a request body as JSON (bounded). */
206
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
207
+ const chunks: Buffer[] = []
208
+ let size = 0
209
+ for await (const chunk of req) {
210
+ const buffer = chunk as Buffer
211
+ size += buffer.length
212
+ if (size > 64 * 1024) throw new Error('body too large')
213
+ chunks.push(buffer)
214
+ }
215
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
216
+ }
217
+
218
+ /** Dispatch one allowlisted method through the host ApiProxy. */
219
+ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string): Promise<unknown> {
220
+ const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
221
+ if (method === 'session.list') {
222
+ const full = await apiProxy.sessions.list(request as never)
223
+ if (!full.result.ok) return full
224
+ const items = full.result.value.items as Array<{ updatedAt: number; sessionId: string }>
225
+ const cursor = (payload as { cursor?: string } | undefined)?.cursor
226
+ // Every call pages (the first call with no cursor IS the first page):
227
+ // the phone must never transfer the whole session list at once.
228
+ // One stable page over (updatedAt desc, sessionId asc); pages never skip
229
+ // or repeat a row while the list changes between calls.
230
+ items.sort((a, b) => b.updatedAt - a.updatedAt
231
+ || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0))
232
+ const position = cursor === undefined ? undefined : parseSessionListCursor(cursor)
233
+ const from = position === undefined ? 0 : items.findIndex(row => afterCursor(row, position))
234
+ const start = from < 0 ? items.length : from
235
+ const page = items.slice(start, start + SESSION_PAGE_SIZE)
236
+ const last = page[page.length - 1]
237
+ const nextCursor = last !== undefined && start + page.length < items.length
238
+ ? sessionListCursor(last.updatedAt, last.sessionId)
239
+ : undefined
240
+ return {
241
+ type: 'server-response',
242
+ rpcId,
243
+ result: {
244
+ ok: true,
245
+ value: {
246
+ items: page,
247
+ hasMore: nextCursor !== undefined,
248
+ ...(nextCursor !== undefined ? { nextCursor } : {}),
249
+ },
250
+ },
251
+ }
252
+ }
253
+ // The ApiProxy unary methods resolve to the internal response shape
254
+ // ({ rpcId, result }) without the transport envelope the phone's callUnary
255
+ // requires — wrap every pass-through in the same 'server-response'
256
+ // envelope session.list builds above.
257
+ const wrap = (response: { rpcId: string; result: unknown }): unknown => ({
258
+ type: 'server-response' as const,
259
+ rpcId,
260
+ result: response.result,
261
+ })
262
+ if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
263
+ if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
264
+ if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
265
+ if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, new AbortController().signal))
266
+ if (method === 'session.prompt') return wrap(await apiProxy.sessions.prompt(request as never))
267
+ if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
268
+ if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
269
+ if (method === 'session.rename') return wrap(await apiProxy.sessions.rename(request as never))
270
+ throw new Error(`unhandled allowlisted method ${method}`)
271
+ }
@@ -0,0 +1,77 @@
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.
9
+ */
10
+
11
+ import { readFile } from 'node:fs/promises'
12
+ import { existsSync } from 'node:fs'
13
+ import { fileURLToPath } from 'node:url'
14
+ import type { IncomingMessage, ServerResponse } from 'node:http'
15
+ import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
+
17
+ /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
18
+ function mobileBundlePath(): string {
19
+ return fileURLToPath(new URL('../lib/mobile.js', import.meta.url))
20
+ }
21
+
22
+ /** The mobile page shell: minimal, offline-safe, no external assets. */
23
+ function pageHtml(bundleUrl: string): string {
24
+ return [
25
+ '<!doctype html>',
26
+ '<html lang="zh-CN">',
27
+ '<head>',
28
+ '<meta charset="utf-8">',
29
+ '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
30
+ '<meta name="theme-color" content="#f3f5f9">',
31
+ '<meta name="referrer" content="no-referrer">',
32
+ '<title>移动端远程控制</title>',
33
+ '</head>',
34
+ '<body>',
35
+ '<div id="root"></div>',
36
+ `<script type="module" src="${bundleUrl}"></script>`,
37
+ '</body>',
38
+ '</html>',
39
+ ].join('')
40
+ }
41
+
42
+ /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
43
+ function writeStatic(res: ServerResponse, status: number, type: string, body: string): void {
44
+ res.writeHead(status, {
45
+ 'content-type': `${type}; charset=utf-8`,
46
+ 'cache-control': 'no-cache',
47
+ 'referrer-policy': 'no-referrer',
48
+ })
49
+ res.end(body)
50
+ }
51
+
52
+ /**
53
+ * Build the mobile page routes.
54
+ * @returns the two exact routes to register on webServer.
55
+ */
56
+ export function makeMobileRoutes(): WebRoute[] {
57
+ const handlePage = (_req: IncomingMessage, res: ServerResponse): void => {
58
+ writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'))
59
+ }
60
+ const handleBundle = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
61
+ const path = mobileBundlePath()
62
+ if (!existsSync(path)) {
63
+ writeStatic(res, 503, 'text/plain', 'mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build')
64
+ return
65
+ }
66
+ try {
67
+ const body = await readFile(path, 'utf8')
68
+ writeStatic(res, 200, 'text/javascript', body)
69
+ } catch {
70
+ writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
71
+ }
72
+ }
73
+ return [
74
+ { kind: 'exact', path: '/m', handler: handlePage },
75
+ { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
76
+ ]
77
+ }