@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
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
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -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 />)
|