@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.
- package/LICENSE +28 -0
- package/README.md +346 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +2817 -0
- package/lib/index.js +1455 -0
- package/lib/invariant.js +21 -0
- package/lib/types/client/PairFailedNotice.d.ts +12 -0
- package/lib/types/client/PairFailedNotice.d.ts.map +1 -0
- package/lib/types/client/PhoneIcon.d.ts +19 -0
- package/lib/types/client/PhoneIcon.d.ts.map +1 -0
- package/lib/types/client/PluginSettingsCard.d.ts +78 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
- package/lib/types/client/RemoteEntry.d.ts +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -0
- package/lib/types/client/RemotePanel.d.ts +50 -0
- package/lib/types/client/RemotePanel.d.ts.map +1 -0
- package/lib/types/client/RemoteSettingsCard.d.ts +75 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -0
- package/lib/types/client/deep-link.d.ts +40 -0
- package/lib/types/client/deep-link.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +54 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +144 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/pair-api.d.ts +100 -0
- package/lib/types/client/pair-api.d.ts.map +1 -0
- package/lib/types/client/settings-form.d.ts +119 -0
- package/lib/types/client/settings-form.d.ts.map +1 -0
- package/lib/types/gate.d.ts +58 -0
- package/lib/types/gate.d.ts.map +1 -0
- package/lib/types/index.d.ts +78 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/lan.d.ts +15 -0
- package/lib/types/lan.d.ts.map +1 -0
- package/lib/types/mobile/api.d.ts +64 -0
- package/lib/types/mobile/api.d.ts.map +1 -0
- package/lib/types/mobile/index.d.ts +10 -0
- package/lib/types/mobile/index.d.ts.map +1 -0
- package/lib/types/mobile/messages.d.ts +103 -0
- package/lib/types/mobile/messages.d.ts.map +1 -0
- package/lib/types/mobile/messages.test.d.ts +2 -0
- package/lib/types/mobile/messages.test.d.ts.map +1 -0
- package/lib/types/mobile/mobile-styles.d.ts +6 -0
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.d.ts +22 -0
- package/lib/types/mobile/mobile-theme.d.ts.map +1 -0
- package/lib/types/mobile/mobile-theme.test.d.ts +2 -0
- package/lib/types/mobile/mobile-theme.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.d.ts +52 -0
- package/lib/types/mobile/mux.d.ts.map +1 -0
- package/lib/types/mobile/rpc.d.ts +38 -0
- package/lib/types/mobile/rpc.d.ts.map +1 -0
- package/lib/types/mobile/theme-toggle.d.ts +12 -0
- package/lib/types/mobile/theme-toggle.d.ts.map +1 -0
- package/lib/types/mobile/views/App.d.ts +59 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.d.ts +39 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -0
- package/lib/types/mobile/views/ChatView.test.d.ts +2 -0
- package/lib/types/mobile/views/ChatView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.d.ts +27 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts +2 -0
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +1 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts +19 -0
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -0
- package/lib/types/mobile-api.d.ts +41 -0
- package/lib/types/mobile-api.d.ts.map +1 -0
- package/lib/types/mobile-routes.d.ts +16 -0
- package/lib/types/mobile-routes.d.ts.map +1 -0
- package/lib/types/pairing.d.ts +215 -0
- package/lib/types/pairing.d.ts.map +1 -0
- package/lib/types/routes.d.ts +58 -0
- package/lib/types/routes.d.ts.map +1 -0
- package/lib/types/tunnel.d.ts +99 -0
- package/lib/types/tunnel.d.ts.map +1 -0
- package/package.json +104 -0
- package/src/client/PairFailedNotice.tsx +33 -0
- package/src/client/PhoneIcon.tsx +31 -0
- package/src/client/PluginSettingsCard.tsx +207 -0
- package/src/client/RemoteEntry.tsx +202 -0
- package/src/client/RemotePanel.tsx +186 -0
- package/src/client/RemoteSettingsCard.tsx +220 -0
- package/src/client/deep-link.ts +137 -0
- package/src/client/index.ts +167 -0
- package/src/client/locales.ts +149 -0
- package/src/client/pair-api.ts +176 -0
- package/src/client/remote.module.css +422 -0
- package/src/client/settings-card.module.css +296 -0
- package/src/client/settings-form.ts +294 -0
- package/src/css-modules.d.ts +6 -0
- package/src/gate.ts +121 -0
- package/src/index.ts +299 -0
- package/src/invariant.ts +32 -0
- package/src/lan.ts +21 -0
- package/src/mobile/api.ts +106 -0
- package/src/mobile/index.tsx +27 -0
- package/src/mobile/messages.test.ts +222 -0
- package/src/mobile/messages.ts +555 -0
- package/src/mobile/mobile-styles.ts +853 -0
- package/src/mobile/mobile-theme.test.ts +84 -0
- package/src/mobile/mobile-theme.ts +76 -0
- package/src/mobile/mux.ts +126 -0
- package/src/mobile/rpc.ts +90 -0
- package/src/mobile/theme-toggle.tsx +47 -0
- package/src/mobile/views/App.tsx +152 -0
- package/src/mobile/views/ChatView.test.tsx +243 -0
- package/src/mobile/views/ChatView.tsx +650 -0
- package/src/mobile/views/SessionListView.test.tsx +139 -0
- package/src/mobile/views/SessionListView.tsx +186 -0
- package/src/mobile/views/WorkspaceView.tsx +108 -0
- package/src/mobile-api.ts +271 -0
- package/src/mobile-routes.ts +77 -0
- package/src/pairing.ts +390 -0
- package/src/routes.ts +346 -0
- 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
|
+
}
|