@linxin666/dsh-remote-web-ui 0.4.2 → 0.4.4
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/README.i18n.yaml +2 -2
- package/README.md +12 -10
- package/README.zh.md +12 -10
- package/lib/client.js +278 -599
- package/lib/client.js.map +1 -1
- package/lib/index.js +396 -913
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +8 -9
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/index.d.ts +32 -3
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +51 -8
- package/lib/types/client/locales.d.ts +0 -84
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +0 -84
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +142 -8
- package/lib/types/client/settings-entry-form.d.ts +50 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -0
- package/lib/types/client/settings-entry-form.js +98 -0
- package/lib/types/index.d.ts +99 -7
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +150 -149
- package/lib/types/lan-bind-plan.d.ts +14 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -1
- package/lib/types/lan-bind-plan.js +16 -0
- package/lib/types/lan-bind.d.ts +1 -1
- package/lib/types/lan-bind.js +1 -1
- package/lib/types/mount-once.d.ts +38 -7
- package/lib/types/mount-once.d.ts.map +1 -1
- package/lib/types/mount-once.js +96 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +43 -0
- package/lib/types/remote-api.d.ts +13 -1
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +14 -1
- package/lib/types/tunnel.d.ts +60 -3
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +136 -3
- package/package.json +20 -20
- package/src/client/RemoteEntry.tsx +7 -9
- package/src/client/RemoteSettingsCard.tsx +0 -2
- package/src/client/index.ts +53 -9
- package/src/client/locales.ts +0 -84
- package/src/client/mobile-adapt.ts +144 -5
- package/src/client/remote.module.css +50 -200
- package/src/client/settings-entry-form.ts +142 -0
- package/src/index.ts +202 -164
- package/src/lan-bind-plan.ts +22 -0
- package/src/lan-bind.ts +1 -1
- package/src/mount-once.ts +107 -13
- package/src/pairing.ts +44 -1
- package/src/remote-api.ts +14 -1
- package/src/tunnel.ts +142 -3
- package/lib/types/client/UpdateEntry.d.ts +0 -14
- package/lib/types/client/UpdateEntry.d.ts.map +0 -1
- package/lib/types/client/UpdateEntry.js +0 -112
- package/lib/types/client/UpdatePanel.d.ts +0 -42
- package/lib/types/client/UpdatePanel.d.ts.map +0 -1
- package/lib/types/client/UpdatePanel.js +0 -101
- package/lib/types/client/update-api.d.ts +0 -29
- package/lib/types/client/update-api.d.ts.map +0 -1
- package/lib/types/client/update-api.js +0 -47
- package/lib/types/console-output.d.ts +0 -41
- package/lib/types/console-output.d.ts.map +0 -1
- package/lib/types/console-output.js +0 -89
- package/lib/types/update-routes.d.ts +0 -30
- package/lib/types/update-routes.d.ts.map +0 -1
- package/lib/types/update-routes.js +0 -47
- package/lib/types/update.d.ts +0 -282
- package/lib/types/update.d.ts.map +0 -1
- package/lib/types/update.js +0 -773
- package/src/client/UpdateEntry.tsx +0 -144
- package/src/client/UpdatePanel.tsx +0 -256
- package/src/client/update-api.ts +0 -49
- package/src/console-output.ts +0 -97
- package/src/update-routes.ts +0 -65
- package/src/update.ts +0 -915
package/src/index.ts
CHANGED
|
@@ -8,13 +8,12 @@
|
|
|
8
8
|
* phone-side pair/accept + deep-link flow.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { createRequire } from 'node:module'
|
|
12
11
|
import { join } from 'node:path'
|
|
13
12
|
import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
|
|
14
13
|
import type { IncomingMessage } from 'node:http'
|
|
15
14
|
import type { Context } from '@deepseek-ai/cordis'
|
|
16
15
|
import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
17
|
-
import z from 'schemastery'
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
18
17
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
19
18
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
20
19
|
import type {} from '@deepseek-ai/dsh-commands'
|
|
@@ -34,29 +33,26 @@ import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firew
|
|
|
34
33
|
import { lanBindState, writeLanBind } from './lan-bind.ts'
|
|
35
34
|
import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
|
|
36
35
|
import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
|
|
37
|
-
import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
|
|
36
|
+
import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, resolveManagedProfile, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
|
|
38
37
|
import { createInnerAuth } from './inner-auth.ts'
|
|
39
38
|
import { withIdentityEncoding } from './http.ts'
|
|
40
39
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
41
40
|
import { PublicBaseKeeper } from './public-base.ts'
|
|
42
|
-
import {
|
|
43
|
-
checkUpdates,
|
|
44
|
-
fetchGitHubReleaseNotes,
|
|
45
|
-
fetchLatestVersion,
|
|
46
|
-
RELEASE_NOTES_CACHE_TTL_MS,
|
|
47
|
-
resolveAnchorManifest,
|
|
48
|
-
resolveUpdateTarget,
|
|
49
|
-
runUpdateVerified,
|
|
50
|
-
type UpdateReleaseNotes,
|
|
51
|
-
type UpdateRunResult,
|
|
52
|
-
} from './update.ts'
|
|
53
|
-
import { makeUpdateRoutes } from './update-routes.ts'
|
|
54
41
|
import { mountOnce } from './mount-once.ts'
|
|
55
42
|
import { REMOTE_CHANNEL_BOOT_SCRIPT } from './remote-channel-boot.ts'
|
|
56
43
|
import { UUID_POLYFILL_SCRIPT } from './uuid-polyfill.ts'
|
|
57
44
|
|
|
58
45
|
declare module '@deepseek-ai/cordis' {
|
|
59
46
|
interface Events {
|
|
47
|
+
/**
|
|
48
|
+
* Volatile config values were committed into the running fiber without a
|
|
49
|
+
* remount; dispatched to the owning fiber only. Spelled here because the
|
|
50
|
+
* Loader package is not a dependency of this plugin, with the Loader's own
|
|
51
|
+
* shape so the two declarations merge when a Host program carries both.
|
|
52
|
+
* @param paths - changed config paths as key arrays; every value is committed before dispatch.
|
|
53
|
+
* @mode emit
|
|
54
|
+
*/
|
|
55
|
+
'loader/volatile-update'(paths: readonly (readonly string[])[]): void
|
|
60
56
|
/**
|
|
61
57
|
* Waterfall seam on the /api transport fence: the connection plugin
|
|
62
58
|
* fires this per /api request before bridging to the API proxy on
|
|
@@ -199,44 +195,60 @@ export interface Config {
|
|
|
199
195
|
*/
|
|
200
196
|
lanBind?: boolean
|
|
201
197
|
/**
|
|
202
|
-
* The profile whose cordis.patch.yml the LAN bind toggle manages.
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
198
|
+
* The profile whose cordis.patch.yml the LAN bind toggle manages. Defaults
|
|
199
|
+
* to the profile the Host booted (its `profileContext`), then to the
|
|
200
|
+
* DSH_PROFILE environment variable, then "web" — the Desktop client boots
|
|
201
|
+
* "desktop" without exporting DSH_PROFILE, so the environment alone names
|
|
202
|
+
* the wrong profile there. Must be a single safe path segment (the runtime
|
|
203
|
+
* and environment fallbacks bypass this schema, so the path builder asserts
|
|
204
|
+
* containment independently).
|
|
206
205
|
*/
|
|
207
206
|
profile?: string
|
|
208
207
|
/** Master switch for the plugin (browser half + host pairing surfaces). */
|
|
209
208
|
enabled?: boolean
|
|
210
209
|
}
|
|
211
210
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
211
|
+
/**
|
|
212
|
+
* Plugin config schema. Under the 0.1.7 settings model this schema IS the
|
|
213
|
+
* entry's settings page: the Host derives one form per profile entry from it
|
|
214
|
+
* and serves that form only when at least one field is `volatile()`. The marker
|
|
215
|
+
* is also what admits a write and what keeps the edit on the live path — the
|
|
216
|
+
* Loader commits the new value into the field's reference and announces
|
|
217
|
+
* `loader/volatile-update` on this fiber instead of remounting the row, so the
|
|
218
|
+
* pairing service, its device sessions, the tunnel and the route registrations
|
|
219
|
+
* survive a settings save (see {@link applyImpl}'s sync).
|
|
220
|
+
*
|
|
221
|
+
* The schema is left to inference rather than annotated with `z<Config>`: a
|
|
222
|
+
* volatile field's parsed output is a live reference while its accepted input
|
|
223
|
+
* stays the plain value, so the two sides no longer share one shape and the
|
|
224
|
+
* annotation would reject the schema the Host must be given.
|
|
225
|
+
*
|
|
226
|
+
* The deployment-level fields (`trustedHosts`, `devicesFile`, `profile`) are
|
|
227
|
+
* deliberately NOT volatile: they belong in the profile patch, so the form
|
|
228
|
+
* leaves them to the operator instead of offering a card control that a
|
|
229
|
+
* document write could not honor.
|
|
230
|
+
*/
|
|
231
|
+
export const Config = z.object({
|
|
232
|
+
tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000).volatile(),
|
|
233
|
+
offlineAfterMs: z.number().step(1).min(5_000).default(25_000).volatile(),
|
|
234
|
+
maxDevices: z.number().step(1).min(1).max(64).default(4).volatile(),
|
|
235
|
+
idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS).volatile(),
|
|
236
|
+
cookieName: z.string().min(1).default('dsh_pair').volatile(),
|
|
237
|
+
requirePairingForLan: z.boolean().default(true).volatile(),
|
|
238
|
+
publicBaseUrl: z.string().volatile(),
|
|
220
239
|
trustedHosts: z.array(z.string()),
|
|
221
240
|
devicesFile: z.string(),
|
|
222
|
-
autoTunnel: z.boolean().default(false),
|
|
223
|
-
tunnelToken: z.string().role('secret'),
|
|
224
|
-
relay: z.boolean().default(true),
|
|
225
|
-
lanBind: z.boolean(),
|
|
241
|
+
autoTunnel: z.boolean().default(false).volatile(),
|
|
242
|
+
tunnelToken: z.string().role('secret').volatile(),
|
|
243
|
+
relay: z.boolean().default(true).volatile(),
|
|
244
|
+
lanBind: z.boolean().volatile(),
|
|
226
245
|
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
227
|
-
enabled: z.boolean().default(true),
|
|
246
|
+
enabled: z.boolean().default(true).volatile(),
|
|
228
247
|
})
|
|
229
248
|
|
|
230
249
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
231
250
|
const SWEEP_INTERVAL_MS = 10_000
|
|
232
251
|
|
|
233
|
-
/**
|
|
234
|
-
* How long one update-status probe answer is reused. The probe fans out one
|
|
235
|
-
* registry GET per family package plus a GitHub call, and the sidebar update
|
|
236
|
-
* entry asks for the status on every GUI page load.
|
|
237
|
-
*/
|
|
238
|
-
const UPDATE_STATUS_TTL_MS = 60_000
|
|
239
|
-
|
|
240
252
|
/**
|
|
241
253
|
* Fully resolved config: every field non-optional except `publicBaseUrl`,
|
|
242
254
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
@@ -252,6 +264,60 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | '
|
|
|
252
264
|
profile: string
|
|
253
265
|
}
|
|
254
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The stable reference a `volatile()` config field resolves to; its owner
|
|
269
|
+
* updates it in place when the user saves.
|
|
270
|
+
*/
|
|
271
|
+
interface ConfigRef<T> {
|
|
272
|
+
/** @returns the value currently committed for the running instance. */
|
|
273
|
+
get(): T
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** One resolved config field: a live reference, or a plain value from a hand-built context. */
|
|
277
|
+
type ConfigField<T> = ConfigRef<T> | T
|
|
278
|
+
|
|
279
|
+
/** The config the Host hands {@link applyImpl} — the runtime face of {@link Config}. */
|
|
280
|
+
export interface ResolvedConfigFields {
|
|
281
|
+
tokenTtlMs?: ConfigField<number>
|
|
282
|
+
offlineAfterMs?: ConfigField<number>
|
|
283
|
+
maxDevices?: ConfigField<number>
|
|
284
|
+
idleExpireMs?: ConfigField<number>
|
|
285
|
+
cookieName?: ConfigField<string>
|
|
286
|
+
requirePairingForLan?: ConfigField<boolean>
|
|
287
|
+
publicBaseUrl?: ConfigField<string>
|
|
288
|
+
trustedHosts?: ConfigField<string[]>
|
|
289
|
+
devicesFile?: ConfigField<string>
|
|
290
|
+
autoTunnel?: ConfigField<boolean>
|
|
291
|
+
tunnelToken?: ConfigField<string>
|
|
292
|
+
relay?: ConfigField<boolean>
|
|
293
|
+
lanBind?: ConfigField<boolean>
|
|
294
|
+
profile?: ConfigField<string>
|
|
295
|
+
enabled?: ConfigField<boolean>
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Read one resolved config field, following the live reference the schema produces. */
|
|
299
|
+
function readConfigField<T>(field: ConfigField<T> | undefined, fallback: T): T {
|
|
300
|
+
if (field === undefined) return fallback
|
|
301
|
+
if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
|
|
302
|
+
const value = (field as ConfigRef<T>).get()
|
|
303
|
+
return value === undefined ? fallback : value
|
|
304
|
+
}
|
|
305
|
+
return field as T
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read one optional resolved config field. A volatile reference is read at
|
|
310
|
+
* call time, so an unset field stays `undefined` rather than falling back to a
|
|
311
|
+
* schema default (the distinction the LAN toggle and the tunnel plan depend on).
|
|
312
|
+
*/
|
|
313
|
+
function readOptionalConfigField<T>(field: ConfigField<T> | undefined): T | undefined {
|
|
314
|
+
if (field === undefined) return undefined
|
|
315
|
+
if (typeof field === 'object' && field !== null && typeof (field as ConfigRef<T>).get === 'function') {
|
|
316
|
+
return (field as ConfigRef<T>).get()
|
|
317
|
+
}
|
|
318
|
+
return field as T
|
|
319
|
+
}
|
|
320
|
+
|
|
255
321
|
/**
|
|
256
322
|
* The single mapping from resolved plugin config to the pairing service
|
|
257
323
|
* config. Both the constructed service and every later apply of this row
|
|
@@ -291,10 +357,24 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
291
357
|
tunnelToken: undefined,
|
|
292
358
|
relay: true,
|
|
293
359
|
lanBind: undefined,
|
|
294
|
-
profile: process.env.DSH_PROFILE
|
|
360
|
+
profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
|
|
295
361
|
enabled: true,
|
|
296
362
|
}
|
|
297
363
|
|
|
364
|
+
/**
|
|
365
|
+
* The launched profile the Host publishes on its `profileContext` service
|
|
366
|
+
* (`{ name, dir, patchPath, … }`). The Desktop client boots the "desktop"
|
|
367
|
+
* profile without exporting DSH_PROFILE, so the environment alone resolves
|
|
368
|
+
* the wrong profile there and the LAN bind toggle would edit a profile that
|
|
369
|
+
* is not running.
|
|
370
|
+
* @param ctx - host plugin context.
|
|
371
|
+
* @returns the profile name when the Host publishes one; undefined otherwise.
|
|
372
|
+
*/
|
|
373
|
+
function launchedProfileName(ctx: Context): string | undefined {
|
|
374
|
+
const fact = ctx.get('profileContext') as { name?: unknown } | undefined
|
|
375
|
+
return typeof fact?.name === 'string' && fact.name.length > 0 ? fact.name : undefined
|
|
376
|
+
}
|
|
377
|
+
|
|
298
378
|
/**
|
|
299
379
|
* Mount the pairing service, routes, gate listener, and presence sweep.
|
|
300
380
|
* @param ctx - host plugin context carrying webServer.
|
|
@@ -302,32 +382,31 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
302
382
|
*/
|
|
303
383
|
export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
|
|
304
384
|
|
|
305
|
-
function applyImpl(ctx: Context, config?:
|
|
385
|
+
function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
|
|
306
386
|
const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const
|
|
330
|
-
const service = new PairingService(pairingConfigOf(resolved))
|
|
387
|
+
// The settings are read through the volatile references at every call rather
|
|
388
|
+
// than captured once: a settings save commits into those references and
|
|
389
|
+
// announces `loader/volatile-update` (see the listener below), so resolving
|
|
390
|
+
// them here is what makes a live edit reach the running instance without a
|
|
391
|
+
// remount — and keeps the device sessions, the tunnel and the routes alive.
|
|
392
|
+
const resolve = (): ResolvedConfig => ({
|
|
393
|
+
tokenTtlMs: readConfigField(config?.tokenTtlMs, DEFAULTS.tokenTtlMs),
|
|
394
|
+
offlineAfterMs: readConfigField(config?.offlineAfterMs, DEFAULTS.offlineAfterMs),
|
|
395
|
+
maxDevices: readConfigField(config?.maxDevices, DEFAULTS.maxDevices),
|
|
396
|
+
idleExpireMs: readConfigField(config?.idleExpireMs, DEFAULTS.idleExpireMs),
|
|
397
|
+
cookieName: readConfigField(config?.cookieName, DEFAULTS.cookieName),
|
|
398
|
+
requirePairingForLan: readConfigField(config?.requirePairingForLan, DEFAULTS.requirePairingForLan),
|
|
399
|
+
publicBaseUrl: readOptionalConfigField(config?.publicBaseUrl) ?? envPublicBase,
|
|
400
|
+
trustedHosts: readOptionalConfigField(config?.trustedHosts),
|
|
401
|
+
devicesFile: readConfigField(config?.devicesFile, DEFAULTS.devicesFile),
|
|
402
|
+
autoTunnel: readConfigField(config?.autoTunnel, DEFAULTS.autoTunnel),
|
|
403
|
+
tunnelToken: readOptionalConfigField(config?.tunnelToken),
|
|
404
|
+
relay: readConfigField(config?.relay, DEFAULTS.relay),
|
|
405
|
+
lanBind: readOptionalConfigField(config?.lanBind),
|
|
406
|
+
profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
|
|
407
|
+
enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
|
|
408
|
+
})
|
|
409
|
+
const service = new PairingService(pairingConfigOf(resolve()))
|
|
331
410
|
|
|
332
411
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
333
412
|
// The minted public URL becomes the QR base (and the pairing fence's
|
|
@@ -394,7 +473,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
394
473
|
}
|
|
395
474
|
// 'off' until a sync pass turns a mode on; the phase listener only feeds
|
|
396
475
|
// the public base while a plugin-managed tunnel (quick or named) runs.
|
|
397
|
-
let tunnelMode: 'off' | 'quick' | 'named' =
|
|
476
|
+
let tunnelMode: 'off' | 'quick' | 'named' = resolve().autoTunnel ? 'quick' : 'off'
|
|
398
477
|
tunnel.onPhase((info: TunnelInfo) => {
|
|
399
478
|
if (tunnelMode === 'off') return
|
|
400
479
|
if (info.phase === 'running' && info.url !== undefined) {
|
|
@@ -450,89 +529,6 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
450
529
|
// request) instead of opening the fence.
|
|
451
530
|
let disposeRoutes: (() => void) | undefined
|
|
452
531
|
let disposeSweep: (() => void) | undefined
|
|
453
|
-
// ── remote update ────────────────────────────────────────────────────────
|
|
454
|
-
// The dsh-web self-update surface: probe the npm registry for family
|
|
455
|
-
// releases and run `pnpm update --latest` in the owning profile. Resolutions
|
|
456
|
-
// anchor on the host process's own module graph, so the update always
|
|
457
|
-
// targets the profile the running web GUI was booted from. The anchor path
|
|
458
|
-
// is re-resolved per operation: pnpm removes the old version's .pnpm
|
|
459
|
-
// directory on update, so a boot-time captured path would fail to read
|
|
460
|
-
// after a successful update; versions are re-read from disk per check.
|
|
461
|
-
const requireFromHost = createRequire(import.meta.url)
|
|
462
|
-
/** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
|
|
463
|
-
const hostResolve = (specifier: string): string | undefined => {
|
|
464
|
-
try {
|
|
465
|
-
return requireFromHost.resolve(specifier)
|
|
466
|
-
} catch {
|
|
467
|
-
return undefined
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(hostResolve)
|
|
471
|
-
|
|
472
|
-
const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
|
|
473
|
-
const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
|
|
474
|
-
const cached = releaseNotesCache.get(version)
|
|
475
|
-
if (cached !== undefined && Date.now() - cached.at < RELEASE_NOTES_CACHE_TTL_MS) return cached.notes
|
|
476
|
-
const notes = await fetchGitHubReleaseNotes(version, fetch)
|
|
477
|
-
releaseNotesCache.set(version, { at: Date.now(), notes })
|
|
478
|
-
return notes
|
|
479
|
-
}
|
|
480
|
-
// The status probe fans out one registry GET per family package (plus a
|
|
481
|
-
// GitHub release-notes call), and the sidebar update entry asks for it on
|
|
482
|
-
// every GUI page load — so the answer is memoized briefly and concurrent
|
|
483
|
-
// callers share one probe. A completed update invalidates it.
|
|
484
|
-
let updateStatusCache: { at: number; value: Awaited<ReturnType<typeof checkUpdates>> } | undefined
|
|
485
|
-
let updateStatusInFlight: Promise<Awaited<ReturnType<typeof checkUpdates>>> | undefined
|
|
486
|
-
const checkStatusCached = (): Promise<Awaited<ReturnType<typeof checkUpdates>>> => {
|
|
487
|
-
const cached = updateStatusCache
|
|
488
|
-
if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value)
|
|
489
|
-
if (updateStatusInFlight !== undefined) return updateStatusInFlight
|
|
490
|
-
updateStatusInFlight = checkUpdates({
|
|
491
|
-
anchorManifestPath: resolveAnchorPath(),
|
|
492
|
-
resolve: hostResolve,
|
|
493
|
-
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
494
|
-
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
495
|
-
}).then((value) => {
|
|
496
|
-
updateStatusCache = { at: Date.now(), value }
|
|
497
|
-
return value
|
|
498
|
-
}).finally(() => { updateStatusInFlight = undefined })
|
|
499
|
-
return updateStatusInFlight
|
|
500
|
-
}
|
|
501
|
-
const updateRoutes = makeUpdateRoutes({
|
|
502
|
-
// Control endpoints are host-surface only: a LAN/phone origin must never
|
|
503
|
-
// trigger a real install on this machine.
|
|
504
|
-
fence: request => isTrustedApiRequest(request, []),
|
|
505
|
-
check: () => checkStatusCached(),
|
|
506
|
-
run: async (): Promise<UpdateRunResult> => {
|
|
507
|
-
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
|
|
508
|
-
if ('error' in target) {
|
|
509
|
-
const code = target.error
|
|
510
|
-
return {
|
|
511
|
-
ok: false,
|
|
512
|
-
exitCode: null,
|
|
513
|
-
output: '',
|
|
514
|
-
error: code === 'not-found' ? 'dsh-web aggregate not installed' : 'local link install — update unavailable',
|
|
515
|
-
errorCode: code,
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
// Verify the versions actually moved after a green pnpm exit: the pnpm
|
|
519
|
-
// 11 minimumReleaseAge gate can silently keep the installed versions
|
|
520
|
-
// (same-day releases), which a plain exit-0 check would report as
|
|
521
|
-
// success — the user then restarts and nothing changed.
|
|
522
|
-
const result = await runUpdateVerified({
|
|
523
|
-
run: { profileDir: target.profileDir, packages: target.packages },
|
|
524
|
-
check: {
|
|
525
|
-
anchorManifestPath: resolveAnchorPath(),
|
|
526
|
-
resolve: hostResolve,
|
|
527
|
-
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
528
|
-
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
529
|
-
},
|
|
530
|
-
})
|
|
531
|
-
// The install moved (or failed) the versions the cached status reported.
|
|
532
|
-
updateStatusCache = undefined
|
|
533
|
-
return result
|
|
534
|
-
},
|
|
535
|
-
})
|
|
536
532
|
// LAN-bind facts for the settings card, re-read per request so a hot
|
|
537
533
|
// rebind (the patch watcher recomposes the process) and a fresh toggle
|
|
538
534
|
// round are both reflected without a restart.
|
|
@@ -653,7 +649,6 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
653
649
|
port: ctx.webServer.port,
|
|
654
650
|
auth: innerAuth,
|
|
655
651
|
}),
|
|
656
|
-
...updateRoutes,
|
|
657
652
|
]
|
|
658
653
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
659
654
|
service,
|
|
@@ -741,18 +736,22 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
741
736
|
console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
|
|
742
737
|
}
|
|
743
738
|
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
739
|
+
// LAN bind toggle: only write the managed patch block once the user has
|
|
740
|
+
// flipped it (undefined = untouched, never write). Desired host/port come
|
|
741
|
+
// from the CLI flags when given (flags win), else from the toggle and
|
|
742
|
+
// the currently bound port. The block takes effect on the next start, so
|
|
743
|
+
// the re-assert at every boot keeps it in sync with both the toggle and
|
|
744
|
+
// the flags.
|
|
745
|
+
//
|
|
746
|
+
// This work must leave the caller's async context first: a settings save
|
|
747
|
+
// runs inside the Host's hmr.runExclusive, and cordis.patch.yml is exactly
|
|
748
|
+
// the file the HMR config watcher refreshes from. A write issued on that
|
|
749
|
+
// same context makes the watcher's refresh re-enter runExclusive, which
|
|
750
|
+
// rejects with "HMR transactions cannot be nested" and fails the user's
|
|
751
|
+
// save (issue #1751). setImmediate starts a fresh AsyncLocalStorage store,
|
|
752
|
+
// so the assertion runs outside the transaction; it is idempotent, so the
|
|
753
|
+
// coalesced follow-up sync() re-reading the same block writes nothing.
|
|
754
|
+
const applyLanBindWork = (value: ResolvedConfig): void => {
|
|
756
755
|
if (value.lanBind !== undefined) {
|
|
757
756
|
const startup = ctx.get('webStartup') as StartupFacts | undefined
|
|
758
757
|
const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
|
|
@@ -790,6 +789,36 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
790
789
|
}
|
|
791
790
|
}
|
|
792
791
|
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// The LAN bind assertion never blocks the caller: one pending work item is
|
|
795
|
+
// enough, and it always re-reads the config through `resolve()` at run time
|
|
796
|
+
// so a toggle flipped twice before the tick lands on the last value.
|
|
797
|
+
let lanBindWorkPending = false
|
|
798
|
+
const scheduleLanBindWork = (): void => {
|
|
799
|
+
if (lanBindWorkPending) return
|
|
800
|
+
lanBindWorkPending = true
|
|
801
|
+
setImmediate(() => {
|
|
802
|
+
lanBindWorkPending = false
|
|
803
|
+
try {
|
|
804
|
+
applyLanBindWork(resolve())
|
|
805
|
+
} catch (error) {
|
|
806
|
+
// A deferred assertion must never surface as an unhandled rejection:
|
|
807
|
+
// the settings save that triggered it already answered, and the card
|
|
808
|
+
// reads the live block state back on its own poll.
|
|
809
|
+
console.error(`remote-web-ui: the deferred lan-bind assertion failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
810
|
+
}
|
|
811
|
+
})
|
|
812
|
+
}
|
|
813
|
+
ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
|
|
814
|
+
|
|
815
|
+
// Apply the effective configuration to the running surfaces once per
|
|
816
|
+
// activation: the service tunables, the LAN-bind block/firewall, the tunnel
|
|
817
|
+
// plan, the pairing routes, the presence sweep, and the posture probe.
|
|
818
|
+
const sync = (): void => {
|
|
819
|
+
const value = resolve()
|
|
820
|
+
service.config = pairingConfigOf(value)
|
|
821
|
+
scheduleLanBindWork()
|
|
793
822
|
// The plugin-managed tunnels own the public base while one runs: the URL
|
|
794
823
|
// lands in the service through the tunnel's phase listener (the minted
|
|
795
824
|
// quick URL, or the named tunnel's fixed public hostname). The manual
|
|
@@ -917,8 +946,17 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
917
946
|
table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
|
|
918
947
|
}), 'remote-web-ui: remote channel boot patch')
|
|
919
948
|
|
|
949
|
+
// The settings write path. The Host commits the new values into this row's
|
|
950
|
+
// volatile references and announces it here instead of remounting the row, so
|
|
951
|
+
// `sync` is what re-applies them: it re-reads every field through those
|
|
952
|
+
// references (see `resolve`) and is idempotent, so a save that changes
|
|
953
|
+
// nothing observable is a no-op while a disable, a bind flip or a tunnel
|
|
954
|
+
// change lands on the live instance without dropping the pairing service,
|
|
955
|
+
// its device sessions or the route registrations.
|
|
956
|
+
ctx.on('loader/volatile-update', () => { sync() })
|
|
957
|
+
|
|
920
958
|
// No settings registration: the Host derives this plugin's settings page
|
|
921
|
-
// from the exported `Config` schema
|
|
922
|
-
//
|
|
959
|
+
// from the exported `Config` schema, whose volatile fields are what make the
|
|
960
|
+
// page writable and what route a save through the live path above.
|
|
923
961
|
sync()
|
|
924
962
|
}
|
package/src/lan-bind-plan.ts
CHANGED
|
@@ -64,6 +64,28 @@ export interface AppliedFirewallState {
|
|
|
64
64
|
port: number
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/** The managed profile when neither the config nor the runtime names one. */
|
|
68
|
+
export const DEFAULT_MANAGED_PROFILE = 'web'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The profile whose `cordis.patch.yml` the LAN bind toggle manages. An
|
|
72
|
+
* explicit `profile` config wins; otherwise the launched profile the Host
|
|
73
|
+
* publishes is authoritative, because the DSH Desktop client boots the
|
|
74
|
+
* `desktop` profile without exporting `DSH_PROFILE` — an environment-only
|
|
75
|
+
* fallback edited `profiles/web` there, so the toggle looked applied while
|
|
76
|
+
* the running host never followed the block.
|
|
77
|
+
* @param configured - the explicit `profile` plugin config, when set.
|
|
78
|
+
* @param launched - the launched profile name the Host published, when available.
|
|
79
|
+
* @param env - the DSH_PROFILE environment value, when set.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveManagedProfile(
|
|
82
|
+
configured: string | undefined,
|
|
83
|
+
launched: string | undefined,
|
|
84
|
+
env: string | undefined,
|
|
85
|
+
): string {
|
|
86
|
+
return configured ?? launched ?? env ?? DEFAULT_MANAGED_PROFILE
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Whether the firewall rule must be (re)applied: only when the toggle or the
|
|
69
91
|
* bound port moved since the last application. Keeps unrelated settings
|
package/src/lan-bind.ts
CHANGED
|
@@ -35,7 +35,7 @@ export type LanBindHost = '0.0.0.0' | '127.0.0.1'
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* The absolute path of the profile patch file this toggle manages. The value
|
|
38
|
-
* is config-controlled (and the
|
|
38
|
+
* is config-controlled (and the runtime/environment fallbacks bypass schema
|
|
39
39
|
* validation entirely), so the path is guarded twice: the profile must be a
|
|
40
40
|
* single safe path segment, and the resolved file must stay under the
|
|
41
41
|
* profiles directory.
|