@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
package/src/index.ts ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Mobile remote control for the dsh web GUI — host half. Mounts the pairing
3
+ * service (one-time tokens, device sessions, revocation), the /api/pair
4
+ * route family (issue/accept/stop/heartbeat/status/events), the api/gate
5
+ * listener that enforces pairing on every other /api request from
6
+ * non-loopback hosts, and the presence sweep. The browser half (the
7
+ * `./client` entry) renders the sidebar entry, the pairing panel, and the
8
+ * phone-side pair/accept + deep-link flow.
9
+ */
10
+
11
+ import { setInterval as nodeSetInterval } from 'node:timers'
12
+ import type { IncomingMessage } from 'node:http'
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
15
+ import z from 'schemastery'
16
+ import type {} from '@deepseek-ai/dsh-host-webserver'
17
+ import { PairingService } from './pairing.ts'
18
+ import { makeGateListener } from './gate.ts'
19
+ import { makeRoutes } from './routes.ts'
20
+ import { makeMobileRoutes } from './mobile-routes.ts'
21
+ import { makeMobileApiRoutes } from './mobile-api.ts'
22
+ import { lanIPv4Addresses } from './lan.ts'
23
+ import { TunnelManager, type TunnelInfo } from './tunnel.ts'
24
+
25
+ declare module '@deepseek-ai/cordis' {
26
+ interface Events {
27
+ /**
28
+ * Waterfall seam on the /api transport fence: the connection plugin
29
+ * fires this per /api request before bridging to the API proxy on
30
+ * deployments that carry the pairing/revocation seam; call `next()` to
31
+ * delegate, return false (without calling it) to veto with 403.
32
+ */
33
+ 'api/gate'(
34
+ this: Context,
35
+ request: IncomingMessage,
36
+ method: string | undefined,
37
+ next: () => boolean | Promise<boolean>,
38
+ ): boolean | Promise<boolean>
39
+ }
40
+ }
41
+
42
+ /** Stable cordis plugin name. */
43
+ export const name = 'remote-web-ui'
44
+
45
+ /** Services required before the pairing surfaces can mount. */
46
+ export const inject = ['webServer', 'apiProxy']
47
+
48
+ /**
49
+ * Settings namespace of the remote-control capability — the section the web
50
+ * settings surface edits. Spelled here rather than imported: the browser
51
+ * half spells the same value and must not depend on a Host package.
52
+ */
53
+ export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = settingsNamespace('remote-web-ui')
54
+
55
+ /** Plugin config, validated by the same-named schemastery schema. */
56
+ export interface Config {
57
+ /** Token lifetime in ms; the QR link dies after this. */
58
+ tokenTtlMs?: number
59
+ /** A device is "online" while its lastSeenAt is newer than this (ms). */
60
+ offlineAfterMs?: number
61
+ /** Hard cap on paired device sessions (oldest evicted when full). */
62
+ maxDevices?: number
63
+ /** Cookie name carrying the paired device id. */
64
+ cookieName?: string
65
+ /**
66
+ * When true (default), every non-loopback /api request must carry a live
67
+ * paired-device cookie — the QR is the only way into a LAN-exposed dsh
68
+ * web, and stop() genuinely cuts paired devices off. Set false to keep
69
+ * the fence's open-LAN behavior and use pairing only for tokens/status.
70
+ */
71
+ requirePairingForLan?: boolean
72
+ /**
73
+ * Public base URL of a tunnel in front of this server (e.g. a Cloudflare
74
+ * Tunnel quick URL `https://xxx.trycloudflare.com` or a named-tunnel
75
+ * subdomain). When set, the QR link is built from it — a phone anywhere
76
+ * can pair — and its host is trusted by the phone-facing pairing fence.
77
+ * Leave unset for LAN-only usage. Malformed values are ignored with a
78
+ * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
79
+ */
80
+ publicBaseUrl?: string
81
+ /**
82
+ * When true, the plugin runs its own Cloudflare quick tunnel (the
83
+ * cloudflared binary ships with the package — no user-side install) and
84
+ * feeds the minted public URL into both the QR base and the /api trust
85
+ * fence dynamically, so phones anywhere can pair without any manual
86
+ * tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
87
+ */
88
+ autoTunnel?: boolean
89
+ /** Master switch for the plugin (browser half + host pairing surfaces). */
90
+ enabled?: boolean
91
+ }
92
+
93
+ export const Config: z<Config> = z.object({
94
+ tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000),
95
+ offlineAfterMs: z.number().step(1).min(5_000).default(25_000),
96
+ maxDevices: z.number().step(1).min(1).max(64).default(4),
97
+ cookieName: z.string().min(1).default('dsh_pair'),
98
+ requirePairingForLan: z.boolean().default(true),
99
+ publicBaseUrl: z.string(),
100
+ autoTunnel: z.boolean().default(false),
101
+ enabled: z.boolean().default(true),
102
+ })
103
+
104
+ /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
105
+ const SWEEP_INTERVAL_MS = 10_000
106
+
107
+ /**
108
+ * Fully resolved config: every field non-optional except `publicBaseUrl`,
109
+ * which legitimately resolves to `undefined` when unset (the schema keeps it
110
+ * optional, so `Required` alone would over-narrow it to `string`).
111
+ */
112
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl'>> & { publicBaseUrl: string | undefined }
113
+
114
+ /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
115
+ const DEFAULTS: ResolvedConfig = {
116
+ tokenTtlMs: 10 * 60_000,
117
+ offlineAfterMs: 25_000,
118
+ maxDevices: 4,
119
+ cookieName: 'dsh_pair',
120
+ requirePairingForLan: true,
121
+ publicBaseUrl: undefined,
122
+ autoTunnel: false,
123
+ enabled: true,
124
+ }
125
+
126
+ /**
127
+ * Mount the pairing service, routes, gate listener, and presence sweep.
128
+ * @param ctx - host plugin context carrying webServer.
129
+ * @param config - resolved plugin config (schema defaults applied by the loader).
130
+ */
131
+ export function apply(ctx: Context, config?: Config): void {
132
+ const resolved: ResolvedConfig = {
133
+ tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
134
+ offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
135
+ maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
136
+ cookieName: config?.cookieName ?? DEFAULTS.cookieName,
137
+ requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
138
+ publicBaseUrl: config?.publicBaseUrl,
139
+ autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
140
+ enabled: config?.enabled ?? DEFAULTS.enabled,
141
+ }
142
+ // The live source the pairing service and the gate read: the settings
143
+ // section once the web settings surface is served, the composition entry
144
+ // otherwise (installSettingsSection swaps it when the namespace registers).
145
+ let current: () => Config = () => config ?? {}
146
+ const resolve = (): ResolvedConfig => {
147
+ const value = current()
148
+ return {
149
+ tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
150
+ offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
151
+ maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
152
+ cookieName: value.cookieName ?? DEFAULTS.cookieName,
153
+ requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
154
+ publicBaseUrl: value.publicBaseUrl,
155
+ autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
156
+ enabled: value.enabled ?? DEFAULTS.enabled,
157
+ }
158
+ }
159
+ const service = new PairingService({
160
+ tokenTtlMs: resolved.tokenTtlMs,
161
+ offlineAfterMs: resolved.offlineAfterMs,
162
+ maxDevices: resolved.maxDevices,
163
+ cookieName: resolved.cookieName,
164
+ })
165
+
166
+ // ── auto tunnel ─────────────────────────────────────────────────────────
167
+ // The minted public URL becomes the QR base (and the pairing fence's
168
+ // trusted host). Phone /api traffic rides the plugin's own /m/api channel,
169
+ // which is NOT subject to the connection trust fence — so no fence
170
+ // mutation is needed here (a distributable plugin must not change the
171
+ // harness's connection plugin).
172
+ const tunnel = new TunnelManager()
173
+ let autoTunnel = resolved.autoTunnel
174
+ tunnel.onPhase((info: TunnelInfo) => {
175
+ if (!autoTunnel) return
176
+ if (info.phase === 'running' && info.url !== undefined) {
177
+ service.setPublicBaseUrl(info.url)
178
+ service.setTunnelStatus({ state: 'running', url: info.url })
179
+ } else if (info.phase === 'starting') {
180
+ // A restart mints a NEW hostname: the previous URL dies with the old
181
+ // process, so clear it now rather than advertising a dead link.
182
+ service.setPublicBaseUrl(undefined)
183
+ service.setTunnelStatus({ state: 'starting' })
184
+ } else if (info.phase === 'failed') {
185
+ service.setPublicBaseUrl(undefined)
186
+ service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error })
187
+ }
188
+ })
189
+ ctx.effect(() => () => {
190
+ tunnel.dispose()
191
+ }, 'remote-web-ui: auto tunnel')
192
+ // The bind facts are known by now (webServer is an inject edge): the LAN
193
+ // bases are frozen per process, matching the CLI's once-per-invocation
194
+ // sampling stance. The QR can only advertise addresses the fence accepts;
195
+ // every interface gets its own base URL so a multi-homed machine can pick
196
+ // the network the phone can actually reach.
197
+ const lanBases = ctx.webServer.host === '0.0.0.0'
198
+ ? lanIPv4Addresses().map(address => ({ address, base: `http://${address}:${String(ctx.webServer.port)}` }))
199
+ : []
200
+ service.setLanBases(lanBases)
201
+ const lanAddresses = lanBases.map(entry => entry.address)
202
+
203
+ // Push a committed settings section into the service and gate. The service
204
+ // config object is read per operation (token mint, touch, sweep), and the
205
+ // gate re-reads its fence flag per request, so a live edit takes effect
206
+ // without a restart. When `enabled` turns off, the pairing routes and
207
+ // sweep timer are dropped and all device/token state is revoked, but the
208
+ // gate listener stays mounted so a LAN-exposed /api stays behind pairing
209
+ // (now vetoing every non-loopback request) instead of opening the fence.
210
+ let disposeRoutes: (() => void) | undefined
211
+ let disposeSweep: (() => void) | undefined
212
+ // The phone's data channel: pairing routes + the /m page + the /m/api
213
+ // proxy (which needs the host ApiProxy service; the plugin injects it).
214
+ const apiProxy = ctx.get('apiProxy')
215
+ if (apiProxy === undefined) {
216
+ console.warn('remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled')
217
+ }
218
+ const routes = [
219
+ ...makeRoutes({ service, lanAddresses }),
220
+ ...makeMobileRoutes(),
221
+ ...(apiProxy !== undefined ? makeMobileApiRoutes({ service, apiProxy }) : []),
222
+ ]
223
+ const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
224
+ ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
225
+ const sync = (): void => {
226
+ const value = resolve()
227
+ service.config = {
228
+ tokenTtlMs: value.tokenTtlMs,
229
+ offlineAfterMs: value.offlineAfterMs,
230
+ maxDevices: value.maxDevices,
231
+ cookieName: value.cookieName,
232
+ }
233
+ // The auto tunnel owns the public base while enabled: the minted URL
234
+ // lands in the service through the tunnel's phase listener. The manual
235
+ // publicBaseUrl applies only when the auto tunnel is off.
236
+ autoTunnel = value.autoTunnel === true
237
+ if (autoTunnel) {
238
+ if (value.publicBaseUrl !== undefined) {
239
+ console.warn('remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl')
240
+ }
241
+ tunnel.start(`http://127.0.0.1:${String(ctx.webServer.port)}`)
242
+ } else {
243
+ tunnel.stop()
244
+ // A malformed public base is ignored with a warning — LAN-only behavior
245
+ // stays intact rather than silently minting unusable QR links.
246
+ if (value.publicBaseUrl !== undefined && !isHttpUrl(value.publicBaseUrl)) {
247
+ console.warn(`remote-web-ui: ignoring malformed publicBaseUrl ${JSON.stringify(value.publicBaseUrl)} (expected https://host[:port])`)
248
+ service.setPublicBaseUrl(undefined)
249
+ } else {
250
+ service.setPublicBaseUrl(value.publicBaseUrl)
251
+ }
252
+ }
253
+ const enabled = value.enabled
254
+ if (!enabled) service.stop()
255
+ if (disposeRoutes === undefined && enabled) {
256
+ disposeRoutes = ctx.effect(
257
+ () => {
258
+ const disposers = routes.map(route => ctx.webServer.register(route))
259
+ return () => { for (const dispose of disposers) dispose() }
260
+ },
261
+ 'remote-web-ui: pairing routes',
262
+ )
263
+ } else if (disposeRoutes !== undefined && !enabled) {
264
+ disposeRoutes()
265
+ disposeRoutes = undefined
266
+ }
267
+ if (disposeSweep === undefined && enabled) {
268
+ disposeSweep = ctx.effect(
269
+ () => {
270
+ const timer = nodeSetInterval(() => { service.sweep() }, SWEEP_INTERVAL_MS)
271
+ timer.unref()
272
+ return () => { clearInterval(timer) }
273
+ },
274
+ 'remote-web-ui: presence sweep',
275
+ )
276
+ } else if (disposeSweep !== undefined && !enabled) {
277
+ disposeSweep()
278
+ disposeSweep = undefined
279
+ }
280
+ }
281
+ installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
282
+ setSource: (source) => {
283
+ current = source
284
+ sync()
285
+ },
286
+ onChange: sync,
287
+ })
288
+ sync()
289
+ }
290
+
291
+ /** Whether a configured public base is a parseable http(s) URL with a host. */
292
+ function isHttpUrl(value: string): boolean {
293
+ try {
294
+ const url = new URL(value)
295
+ return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== ''
296
+ } catch {
297
+ return false
298
+ }
299
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
3
+ * @module @linxin666/dsh-remote-web-ui/invariant
4
+ */
5
+
6
+ /* jscpd:ignore-start */
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
9
+
10
+ const PACKAGE_NAME = '@linxin666/dsh-remote-web-ui'
11
+
12
+ /** Cordis companion plugin name. */
13
+ export const name = 'remote-web-ui-invariant'
14
+ /** Service required before the companion can reserve package ownership. */
15
+ export const inject = ['invariants']
16
+
17
+ /**
18
+ * No runtime invariant: this package owns no durable package-local event
19
+ * stream; its route-table and device-session relationships are asserted by
20
+ * the package's own specs (route register/dispose symmetry via the
21
+ * webServer disposer contract, token/revocation semantics on the service).
22
+ */
23
+ const install: InvariantInstaller = () => {}
24
+
25
+ /**
26
+ * Register this package's invariant companion.
27
+ * @param ctx - Cordis context carrying the invariant service.
28
+ * @returns the installed registration's disposer after setup succeeds.
29
+ */
30
+ export const apply = (ctx: Context): Promise<() => void> =>
31
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
32
+ /* jscpd:ignore-end */
package/src/lan.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * LAN address derivation for the pairing URLs. Mirrors the dsh CLI's
3
+ * boot-time sampling (apps/cli/src/app-cli-entry.ts `resolveLanTrust`): the
4
+ * pairing links may only name addresses the /api trust fence was configured
5
+ * with, so the same non-internal IPv4 derivation applies here — an external
6
+ * plugin cannot read the CLI's sampled snapshot, but the fence accepts
7
+ * exactly these literals, which is the property that matters.
8
+ */
9
+
10
+ import { networkInterfaces } from 'node:os'
11
+
12
+ /**
13
+ * Non-internal IPv4 interface addresses of this machine — the IP-literal
14
+ * authorities an all-interfaces bind is reachable by on the LAN.
15
+ * @returns the addresses in interface order (possibly empty).
16
+ */
17
+ export function lanIPv4Addresses(): string[] {
18
+ return Object.values(networkInterfaces()).flat()
19
+ .filter((iface): iface is NonNullable<typeof iface> => { return iface !== undefined && iface.family === 'IPv4' && !iface.internal })
20
+ .map(iface => iface.address)
21
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Mobile-surface business API: the handful of host RPC methods the
3
+ * simplified surface needs. Types come from the harness apiproxy contract
4
+ * (type-only imports; the wire schemas stay in the bundle only through the
5
+ * rpc/mux layers).
6
+ */
7
+
8
+ import type { WorkspaceView } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
9
+ import type { SessionSummary, SessionModels, SessionProjectionsBlock } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
10
+ import { callUnary } from './rpc.ts'
11
+
12
+ /** One session.list page. */
13
+ export interface SessionPage {
14
+ items: SessionSummary[]
15
+ /** Continuation cursor; undefined once the tail is reached. */
16
+ nextCursor?: string
17
+ hasMore: boolean
18
+ }
19
+
20
+ /** The session.create result (the id is the commit the caller navigates to). */
21
+ export interface CreatedSession {
22
+ sessionId: string
23
+ /** The composition the new session runs (echoed so the caller can label it). */
24
+ agentPreset?: string
25
+ }
26
+
27
+ /** One history page (already bounded to whole messages by the host). */
28
+ export interface HistoryPage {
29
+ events: import('@deepseek-ai/dsh-host-apiproxy/api/sessions').HistoryEntry[]
30
+ hasMore: boolean
31
+ /**
32
+ * Projection baseline riding the tail page (permissions select etc.);
33
+ * absent when the deployment mounts no projection registry.
34
+ */
35
+ projections?: SessionProjectionsBlock
36
+ }
37
+
38
+ /** The workspace roster (session ids come back per workspace). */
39
+ export async function listWorkspaces(): Promise<WorkspaceView[]> {
40
+ const { items } = await callUnary<{ items: WorkspaceView[] }>('workspace.list', {})
41
+ return items
42
+ }
43
+
44
+ /** One session.list page; omit the cursor for the first page. */
45
+ export async function listSessions(cursor?: string): Promise<SessionPage> {
46
+ return await callUnary<SessionPage>('session.list', cursor === undefined ? {} : { cursor })
47
+ }
48
+
49
+ /**
50
+ * Create a blank session (entity birth precedes the first message). Name a
51
+ * workspace to attach it there, or a cwd; omitting both uses the host cwd.
52
+ */
53
+ export async function createSession(
54
+ options: { workspaceId?: string; cwd?: string } = {},
55
+ ): Promise<CreatedSession> {
56
+ return await callUnary<CreatedSession>('session.create', options)
57
+ }
58
+
59
+ /** One history window; omit beforeSeq for the tail page. */
60
+ export async function history(
61
+ sessionId: string,
62
+ beforeSeq?: number,
63
+ maxMessages = 30,
64
+ ): Promise<HistoryPage> {
65
+ return await callUnary<HistoryPage>('session.history', {
66
+ sessionId,
67
+ maxMessages,
68
+ ...(beforeSeq !== undefined ? { beforeSeq } : {}),
69
+ })
70
+ }
71
+
72
+ /** Send one text prompt (queued: the agent picks it up in order). */
73
+ export async function prompt(sessionId: string, text: string): Promise<void> {
74
+ await callUnary<{ accepted: true }>('session.prompt', {
75
+ sessionId,
76
+ mode: 'queue',
77
+ content: [{ type: 'text', text }],
78
+ })
79
+ }
80
+
81
+ /** Send one slash command line (e.g. `/permission workspace-write`). */
82
+ export async function sendCommand(sessionId: string, line: string): Promise<unknown> {
83
+ return await callUnary<unknown>('session.prompt', {
84
+ sessionId,
85
+ mode: 'queue',
86
+ content: [{ type: 'text', text: line }],
87
+ })
88
+ }
89
+
90
+ /** Fresh advisory model directory for one session (current + groups + failures). */
91
+ export async function models(sessionId: string): Promise<SessionModels> {
92
+ return await callUnary<SessionModels>('session.models', { sessionId })
93
+ }
94
+
95
+ /** Select the complete model selection (provider/model/reasoning effort) for a session. */
96
+ export async function selectModel(
97
+ sessionId: string,
98
+ selection: { provider: string; model: string; reasoningEffort?: string },
99
+ ): Promise<{ selected: { provider: string; model: string; reasoningEffort?: string } }> {
100
+ return await callUnary<{ selected: { provider: string; model: string; reasoningEffort?: string } }>('session.selectModel', {
101
+ sessionId,
102
+ provider: selection.provider,
103
+ model: selection.model,
104
+ ...(selection.reasoningEffort !== undefined ? { reasoningEffort: selection.reasoningEffort } : {}),
105
+ })
106
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Mobile surface entry: the standalone phone UI served at /m. Boots its own
3
+ * React tree (no main-UI module loader), talks to the host over the shared
4
+ * /api transport with the paired-device cookie, and renders a deliberately
5
+ * thin three-level surface:
6
+ * workspaces (landing, no new-session homepage) → sessions (paged) →
7
+ * chat (history paged on demand + live mux stream + prompt input).
8
+ */
9
+
10
+ import { createRoot } from 'react-dom/client'
11
+ import { App } from './views/App.tsx'
12
+ import { mobileCss } from './mobile-styles.ts'
13
+ import { initMobileTheme } from './mobile-theme.ts'
14
+
15
+ // Apply the persisted (or default light) theme before first paint, so the
16
+ // page never flashes the wrong palette.
17
+ initMobileTheme()
18
+
19
+ // Inject the standalone stylesheet (the page has no shell to load it for us).
20
+ const style = document.createElement('style')
21
+ style.dataset.plugin = 'remote-web-ui/mobile'
22
+ style.textContent = mobileCss
23
+ document.head.appendChild(style)
24
+
25
+ const root = document.getElementById('root')
26
+ if (root === null) throw new Error('mobile: #root missing')
27
+ createRoot(root).render(<App />)