@linxin666/dsh-remote-web-ui 0.4.5 → 0.4.7

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 (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -13
  3. package/README.zh.md +13 -13
  4. package/lib/client.js +25 -92
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +408 -53
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +3 -15
  9. package/lib/types/client/RemotePanel.d.ts +1 -9
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +7 -4
  12. package/lib/types/client/locales.d.ts +4 -8
  13. package/lib/types/client/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.js +4 -8
  15. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  16. package/lib/types/client/mobile-adapt.js +30 -3
  17. package/lib/types/client/plugin-card-seat.d.ts +5 -0
  18. package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
  19. package/lib/types/client/plugin-card-seat.js +2 -0
  20. package/lib/types/client/settings-entry-form.d.ts.map +1 -1
  21. package/lib/types/client/settings-entry-form.js +0 -1
  22. package/lib/types/detached-work.d.ts +7 -7
  23. package/lib/types/detached-work.d.ts.map +1 -1
  24. package/lib/types/detached-work.js +9 -8
  25. package/lib/types/index.d.ts +14 -0
  26. package/lib/types/index.d.ts.map +1 -1
  27. package/lib/types/index.js +67 -17
  28. package/lib/types/lan-source.d.ts +65 -0
  29. package/lib/types/lan-source.d.ts.map +1 -0
  30. package/lib/types/lan-source.js +113 -0
  31. package/lib/types/lan.d.ts +49 -3
  32. package/lib/types/lan.d.ts.map +1 -1
  33. package/lib/types/lan.js +120 -6
  34. package/lib/types/remote-api.d.ts +7 -2
  35. package/lib/types/remote-api.d.ts.map +1 -1
  36. package/lib/types/remote-api.js +17 -2
  37. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  38. package/lib/types/remote-channel-boot.js +4 -1
  39. package/lib/types/routes.d.ts +71 -9
  40. package/lib/types/routes.d.ts.map +1 -1
  41. package/lib/types/routes.js +157 -27
  42. package/lib/types/tunnel-plan.d.ts +8 -2
  43. package/lib/types/tunnel-plan.d.ts.map +1 -1
  44. package/lib/types/tunnel-plan.js +9 -1
  45. package/package.json +20 -25
  46. package/src/client/RemoteEntry.tsx +2 -18
  47. package/src/client/RemotePanel.tsx +9 -43
  48. package/src/client/locales.ts +4 -8
  49. package/src/client/mobile-adapt.ts +30 -3
  50. package/src/client/plugin-card-seat.ts +7 -0
  51. package/src/client/remote.module.css +2 -48
  52. package/src/client/settings-entry-form.ts +0 -1
  53. package/src/detached-work.ts +9 -8
  54. package/src/index.ts +77 -17
  55. package/src/lan-source.ts +132 -0
  56. package/src/lan.ts +126 -6
  57. package/src/remote-api.ts +24 -4
  58. package/src/remote-channel-boot.ts +4 -1
  59. package/src/routes.ts +178 -27
  60. package/src/tunnel-plan.ts +9 -2
  61. package/lib/invariant.js +0 -21
  62. package/lib/types/invariant.d.ts +0 -16
  63. package/lib/types/invariant.d.ts.map +0 -1
  64. package/lib/types/invariant.js +0 -23
  65. package/src/invariant.ts +0 -32
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * The mobile remote-control panel body: status card (state text + badge),
3
- * the QR code, the open-on-phone hint with the link text, and the three
4
- * actions (stop / refresh / copy). Pure presentation — all state and
3
+ * the QR code, the open-on-phone hint with the link text, and the two
4
+ * actions (stop / refresh / copy). The advertised network is not a choice:
5
+ * the QR names the public base and the landing page decides at connect time
6
+ * whether to move to a LAN origin (lan-source.ts), so the panel only states
7
+ * which source the current link carries. Pure presentation — all state and
5
8
  * actions arrive through props from the entry's behavior component.
6
9
  */
7
10
  import clsx from 'clsx'
@@ -47,10 +50,6 @@ export type PanelState =
47
50
  onlineCount: number
48
51
  /** Authorized devices for the roster under the QR card. */
49
52
  devices: DeviceFrame[]
50
- /** The LAN literal the current QR was built from. */
51
- address: string
52
- /** Every constructible LAN literal (interface order). */
53
- lanAddresses: string[]
54
53
  /** The active pairing token itself (useful in Docker/reverse proxy topologies). */
55
54
  token?: string
56
55
  /** Whether this QR is built on the configured public (tunneled) base. */
@@ -76,10 +75,6 @@ export interface RemotePanelProps {
76
75
  onRefresh(): void
77
76
  onCopy(url: string): void
78
77
  onCopyToken?(token: string): void
79
- /** Re-mint the QR against a different LAN address. */
80
- onPickAddress(address: string): void
81
- /** Re-mint the QR against the configured public (tunneled) base. */
82
- onPickPublic(): void
83
78
  /** Revoke one paired device. */
84
79
  onRevoke(deviceId: string): void
85
80
  }
@@ -113,8 +108,6 @@ export function RemotePanel({
113
108
  onRefresh,
114
109
  onCopy,
115
110
  onCopyToken,
116
- onPickAddress,
117
- onPickPublic,
118
111
  onRevoke,
119
112
  }: RemotePanelProps) {
120
113
  return (
@@ -221,37 +214,10 @@ export function RemotePanel({
221
214
  </p>
222
215
  )}
223
216
 
224
- {(state.publicBaseUrl !== undefined || state.lanAddresses.length > 1) && (
225
- <fieldset className={css.addresses}>
226
- <legend>{t('address.label')}</legend>
227
- {state.publicBaseUrl !== undefined && (
228
- <label key="public" className={css.address}>
229
- <input
230
- type="radio"
231
- name="lan-address"
232
- aria-label={t('address.public')}
233
- checked={state.public}
234
- onChange={onPickPublic}
235
- />
236
- <span>{t('address.public')}</span>
237
- <code className={css.addressValue}>{state.publicBaseUrl}</code>
238
- </label>
239
- )}
240
- {state.lanAddresses.map(address => (
241
- <label key={address} className={css.address}>
242
- <input
243
- type="radio"
244
- name="lan-address"
245
- aria-label={address}
246
- checked={!state.public && address === state.address}
247
- onChange={() => onPickAddress(address)}
248
- />
249
- <span>{t('address.lan')}</span>
250
- <code className={css.addressValue}>{address}</code>
251
- </label>
252
- ))}
253
- <p className={css.addressHint}>{t('address.hint')}</p>
254
- </fieldset>
217
+ {(state.publicBaseUrl !== undefined || !state.public) && (
218
+ <p className={css.addressHint}>
219
+ {state.public ? t('address.auto') : t('address.pinnedLan')}
220
+ </p>
255
221
  )}
256
222
 
257
223
  <div className={css.actions}>
@@ -20,10 +20,8 @@ export const zh = {
20
20
  'status.unreachableHint': '请刷新页面后重试。',
21
21
  'pair.hint': '无法扫码?可直接打开下方配对链接',
22
22
  'pair.publicHint': '公网链接:设备无需与本机处于同一网络',
23
- 'address.label': '选择二维码指向的网络',
24
- 'address.public': '公网地址',
25
- 'address.lan': '局域网',
26
- 'address.hint': '远程设备不在同一网络时请使用公网地址;局域网地址仅限同一网络内使用。',
23
+ 'address.auto': '二维码固定指向公网地址;手机与本机处于同一网络时会自动切换到局域网地址。',
24
+ 'address.pinnedLan': '二维码固定指向局域网地址。',
27
25
  'pair.expires': '二维码有效至 {time}',
28
26
  'pair.expired': '二维码已过期,请刷新',
29
27
  'pair.linkLabel': '配对链接',
@@ -149,10 +147,8 @@ export const en = {
149
147
  'status.unreachableHint': 'Refresh the page and try again.',
150
148
  'pair.hint': 'Cannot scan? Open one of the pairing links below',
151
149
  'pair.publicHint': 'Public links work when the device is not on the same network',
152
- 'address.label': 'Network the QR code points to',
153
- 'address.public': 'Public',
154
- 'address.lan': 'LAN',
155
- 'address.hint': 'Use the public address when the remote device is on another network; LAN addresses only work on the same network.',
150
+ 'address.auto': 'The QR code always points at the public address; a phone on the same network switches to the LAN address by itself.',
151
+ 'address.pinnedLan': 'The QR code points at a LAN address.',
156
152
  'pair.expires': 'QR code valid until {time}',
157
153
  'pair.expired': 'QR code expired — refresh it',
158
154
  'pair.linkLabel': 'Pairing link',
@@ -173,14 +173,41 @@ const ADAPT_CSS: readonly string[] = [
173
173
  '[class$="_headerUtilities"]{display:none}',
174
174
  // v52 composer: the two lines (permission / model) stay stacked with zero
175
175
  // row gap; the command, context-meter and send/stop buttons float at the
176
- // left/right edges vertically centered over both lines.
176
+ // left/right edges vertically centered over both lines. "Both lines" is what
177
+ // makes _row the containing block for those three floats: _row is the one box
178
+ // that wraps the lines, so nothing between a float and _row may claim it.
177
179
  '[class$="_composerSeat"] [class$="_card"]{margin-bottom:1px}',
178
180
  '[class$="_composerSeat"] [class$="_row"]{flex-wrap:wrap;row-gap:0;padding:2px 8px 1px;position:relative}',
179
- '[class$="_composerSeat"] [class$="_add"]{position:absolute;left:8px;top:50%;transform:translateY(-50%)}',
181
+ // The attach button is the only file-picker entry on the portrait composer,
182
+ // and taking it out of flow costs it the paint order it had inline: a
183
+ // positioned sibling painted after it (the trailing line, see below) covers
184
+ // it and eats the taps on the covered half. Its own z-index makes the hit
185
+ // layer a property of the button instead of a side effect of a sibling
186
+ // (#1829). It clears the composer's own stacking ladder and stays far below
187
+ // the bottom-sheet menus (2147482000), which own the overlay above it.
188
+ '[class$="_composerSeat"] [class$="_add"]{position:absolute;left:8px;top:50%;transform:translateY(-50%);z-index:10}',
180
189
  '[class$="_composerSeat"] [class$="_modes"]{min-width:0;padding-left:38px}',
181
190
  // Model line left-aligned with the permission line (same command-button
182
191
  // clearance), rows stay tightly stacked.
183
- '[class$="_composerSeat"] [class$="_trailing"]{flex-basis:100%;position:relative;min-height:32px;justify-content:flex-start;padding-left:38px;padding-right:78px}',
192
+ // The trailing line is forced onto its own row at flex-basis:100% and carries
193
+ // 116px of side padding (38 left for the command button, 78 right for the
194
+ // send button). Under the default content-box that padding is ADDED to the
195
+ // 100% basis, so the line's own box overflowed the card by exactly 116px and
196
+ // dragged the absolutely-positioned send button out with it: on a 393px phone
197
+ // the card ended at x=367 while the button sat at x=433..467, past the
198
+ // viewport's right edge, which is the unreachable button the reporter
199
+ // measured (#1818). border-box contains the padding inside the basis, so the
200
+ // line stays within the card and the button lands back inside it.
201
+ //
202
+ // The line stays UNPOSITIONED on purpose. position:relative arrived here with
203
+ // the compact picker and outlived it, but this box is the LAST line only: as
204
+ // the nearest positioned ancestor it took the containing block from _row, so
205
+ // the send button and the context meter centered inside the trailing line
206
+ // instead of over both lines - the send button rode the input's bottom edge,
207
+ // out of line with the attach button - and, painted after the out-of-flow
208
+ // attach button, the line covered that button's lower half and swallowed the
209
+ // taps there (#1829).
210
+ '[class$="_composerSeat"] [class$="_trailing"]{box-sizing:border-box;flex-basis:100%;min-height:32px;justify-content:flex-start;padding-left:38px;padding-right:78px}',
184
211
  '[class$="_composerSeat"] [class$="_trailing"] *{font-size:12px}',
185
212
  // v54: smaller permission/model buttons (font + height). v79: the
186
213
  // permission trigger collapses to its shield icon on phones — the label
@@ -86,6 +86,11 @@ export interface PluginCardSeat {
86
86
  label?: () => string
87
87
  /** Locale namespace the card renders with. */
88
88
  locale: string
89
+ /**
90
+ * Child slots this card declares and renders. Forwarded to whichever seat
91
+ * the host renders; absent for a card that declares none.
92
+ */
93
+ children?: Record<string, unknown>
89
94
  /** Business-face factory of the registration. */
90
95
  inject?: () => object
91
96
  /**
@@ -162,12 +167,14 @@ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat):
162
167
  ...(seat.order === undefined ? {} : { order: seat.order }),
163
168
  ...(seat.label === undefined ? {} : { label: seat.label }),
164
169
  locale: seat.locale,
170
+ ...(seat.children === undefined ? {} : { children: seat.children }),
165
171
  ...(seat.inject === undefined ? {} : { inject }),
166
172
  }
167
173
  : {
168
174
  name: OFFICIAL_PLUGIN_CARD_SEAT,
169
175
  key: seat.bundle,
170
176
  locale: seat.locale,
177
+ ...(seat.children === undefined ? {} : { children: seat.children }),
171
178
  ...(seat.inject === undefined ? {} : { inject }),
172
179
  }) as never, component) as () => void
173
180
  current = target
@@ -573,53 +573,8 @@
573
573
  line-height: 1.55;
574
574
  }
575
575
 
576
- /* Multi-homed address picker: one radio per constructible LAN literal. */
577
- .addresses {
578
- margin: 12px 0 0;
579
- padding: 0;
580
- border: none;
581
- }
582
-
583
- .addresses legend {
584
- padding: 0;
585
- color: var(--dsw-alias-label-secondary);
586
- font-size: 13px;
587
- }
588
-
589
- .address {
590
- display: flex;
591
- align-items: center;
592
- gap: 8px;
593
- margin-top: 6px;
594
- padding: 4px 6px;
595
- border-radius: 6px;
596
- color: var(--dsw-alias-label-primary);
597
- font-size: 13px;
598
- font-variant-numeric: tabular-nums;
599
- cursor: pointer;
600
- transition: background-color 120ms ease;
601
- }
602
-
603
- .address:hover {
604
- background: var(--dsw-alias-interactive-bg-hover);
605
- }
606
-
607
- .address input:focus-visible {
608
- outline: none;
609
- box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);
610
- border-radius: 50%;
611
- }
612
-
613
- .addressValue {
614
- flex: 1;
615
- min-width: 0;
616
- overflow: hidden;
617
- text-overflow: ellipsis;
618
- white-space: nowrap;
619
- color: var(--dsw-alias-label-secondary);
620
- font-size: 12px;
621
- }
622
-
576
+ /* Which network source the current QR link carries. Not a control: the landing
577
+ page decides the source at connect time, so the panel only states the fact. */
623
578
  .addressHint {
624
579
  margin: 6px 0 0;
625
580
  color: var(--dsw-alias-label-tertiary);
@@ -728,7 +683,6 @@
728
683
  .trigger,
729
684
  .close,
730
685
  .action,
731
- .address,
732
686
  .deviceRevoke {
733
687
  transition: none;
734
688
  }
@@ -52,7 +52,6 @@ function pendingSnapshot<T>(): ConfigFormSnapshot<T> {
52
52
  user: undefined,
53
53
  revision: undefined,
54
54
  writable: false,
55
- mode: 'host',
56
55
  }
57
56
  }
58
57
 
@@ -1,12 +1,13 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/detached-work.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
3
  * Run one piece of work outside the caller's async context.
3
4
  *
4
- * The Host performs a settings mutation inside `hmr.runExclusive`, which marks
5
- * the running async context with an AsyncLocalStorage store. Side effects that
6
- * live on that path - writing `cordis.patch.yml`, the very file the HMR config
7
- * watcher refreshes from - must not run under that mark, or the watcher's
8
- * refresh can re-enter the exclusive transaction and be refused with "HMR
9
- * transactions cannot be nested" (#1751, #1754).
5
+ * A Host mutation that runs inside `hmr.runExclusive` marks the running async
6
+ * context with an AsyncLocalStorage store. Side effects on that path that write
7
+ * a file the HMR config watcher refreshes from - `cordis.patch.yml` above all -
8
+ * must not run under that mark, or the watcher's refresh re-enters the
9
+ * exclusive transaction and is refused with "HMR transactions cannot be nested"
10
+ * (#1751, #1754, #1816).
10
11
  *
11
12
  * Deferring is NOT enough, and the earlier belt that this one replaces was
12
13
  * built on a disproven premise: Node propagates an AsyncLocalStorage store into
@@ -28,7 +29,7 @@
28
29
  * // -> undefined
29
30
  * ```
30
31
  *
31
- * @module
32
+ * @module dsh-web-shared/host/detached-work
32
33
  */
33
34
 
34
35
  import { AsyncResource } from 'node:async_hooks'
@@ -39,7 +40,7 @@ import { AsyncResource } from 'node:async_hooks'
39
40
  * instance is the only one the module ever needs. It carries no context of its
40
41
  * own, so every job run through it starts from the process's root context.
41
42
  */
42
- const DETACHED = new AsyncResource('dsh-remote-web-ui:detached-work', { triggerAsyncId: 0 })
43
+ const DETACHED = new AsyncResource('dsh-web:detached-work', { triggerAsyncId: 0 })
43
44
 
44
45
  /**
45
46
  * Run `work` outside the caller's AsyncLocalStorage context.
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pa
24
24
  import { dshHome } from './dsh-home.ts'
25
25
  import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
26
26
  import { RemoteWebUiPairing } from './pairing-access.ts'
27
- import { isTrustedApiRequest, makeRoutes } from './routes.ts'
27
+ import { isTrustedApiRequest, makeRoutes, PAIRING_SOURCE_OPTIONS, type PairingSourcePreference } from './routes.ts'
28
28
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
29
29
  import { startRemotePresencePet, type PresencePetSeam } from './remote-presence-pet.ts'
30
30
  import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
@@ -207,6 +207,16 @@ export interface Config {
207
207
  profile?: string
208
208
  /** Master switch for the plugin (browser half + host pairing surfaces). */
209
209
  enabled?: boolean
210
+ /**
211
+ * The manual override of the automatic pairing-network choice, for the case
212
+ * where the phone's own probe misjudges (see `PairingSourcePreference` in
213
+ * routes.ts). `auto` (default) names the public base in the QR link and lets
214
+ * the landing page switch to a LAN origin when one answers; `public` pins the
215
+ * public base and disables the probe; `lan` pins the first LAN literal and
216
+ * skips the public hop entirely. Set through the profile patch — the settings
217
+ * card carries no control for it, so a save from that card leaves it alone.
218
+ */
219
+ pairingSource?: PairingSourcePreference
210
220
  }
211
221
 
212
222
  /**
@@ -245,11 +255,18 @@ export const Config = z.object({
245
255
  lanBind: z.boolean().volatile(),
246
256
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
247
257
  enabled: z.boolean().default(true).volatile(),
258
+ pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default('auto').volatile(),
248
259
  })
249
260
 
250
261
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
251
262
  const SWEEP_INTERVAL_MS = 10_000
252
263
 
264
+ /**
265
+ * Re-plan retry while the quick tunnel waits for the webServer port to bind
266
+ * (a recompose pass can run this apply ahead of the listener's bind).
267
+ */
268
+ const PORT_SETTLE_RETRY_MS = 1_000
269
+
253
270
  /**
254
271
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
255
272
  * which legitimately resolves to `undefined` when unset (the schema keeps it
@@ -294,6 +311,7 @@ export interface ResolvedConfigFields {
294
311
  lanBind?: ConfigField<boolean>
295
312
  profile?: ConfigField<string>
296
313
  enabled?: ConfigField<boolean>
314
+ pairingSource?: ConfigField<PairingSourcePreference>
297
315
  }
298
316
 
299
317
  /** Read one resolved config field, following the live reference the schema produces. */
@@ -360,6 +378,7 @@ const DEFAULTS: ResolvedConfig = {
360
378
  lanBind: undefined,
361
379
  profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
362
380
  enabled: true,
381
+ pairingSource: 'auto',
363
382
  }
364
383
 
365
384
  /**
@@ -406,6 +425,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
406
425
  lanBind: readOptionalConfigField(config?.lanBind),
407
426
  profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
408
427
  enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
428
+ pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource),
409
429
  })
410
430
  const service = new PairingService(pairingConfigOf(resolve()))
411
431
 
@@ -475,6 +495,14 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
475
495
  // 'off' until a sync pass turns a mode on; the phase listener only feeds
476
496
  // the public base while a plugin-managed tunnel (quick or named) runs.
477
497
  let tunnelMode: 'off' | 'quick' | 'named' = resolve().autoTunnel ? 'quick' : 'off'
498
+ /**
499
+ * Retry timer while the quick tunnel waits for the webServer port to bind
500
+ * (undefined when no deferral is pending). Unref'd: never keeps the
501
+ * process alive on its own.
502
+ */
503
+ let portSettleTimer: ReturnType<typeof nodeSetTimeout> | undefined
504
+ /** Dedupes the unbound-port warning to one line per unsettled episode. */
505
+ let portSettleWarned = false
478
506
  tunnel.onPhase((info: TunnelInfo) => {
479
507
  if (tunnelMode === 'off') return
480
508
  if (info.phase === 'running' && info.url !== undefined) {
@@ -501,6 +529,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
501
529
  disposeRelayRegistrar()
502
530
  publicBase.dispose()
503
531
  tunnel.dispose()
532
+ if (portSettleTimer !== undefined) clearTimeout(portSettleTimer)
504
533
  }, 'remote-web-ui: auto tunnel')
505
534
  // The bind facts are known by now (webServer is an inject edge): the LAN
506
535
  // bases are frozen per process, matching the CLI's once-per-invocation
@@ -616,6 +645,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
616
645
  ...makeRoutes({
617
646
  service,
618
647
  requirePairingForLan: () => resolve().requirePairingForLan,
648
+ pairingSource: () => resolve().pairingSource,
619
649
  lanBindStatus,
620
650
  indexDocument: fetchAppShell,
621
651
  trustedHosts: () => {
@@ -647,13 +677,13 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
647
677
  // credential to everything it forwards (issue #1665).
648
678
  ...makeRemoteApiRoutes({
649
679
  service,
650
- port: ctx.webServer.port,
680
+ port: () => ctx.webServer.port,
651
681
  auth: innerAuth,
652
682
  }),
653
683
  ]
654
684
  const upgrades = makeRemoteApiUpgradeRoutes({
655
685
  service,
656
- port: ctx.webServer.port,
686
+ port: () => ctx.webServer.port,
657
687
  auth: innerAuth,
658
688
  })
659
689
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
@@ -669,7 +699,12 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
669
699
  let postureWasExposed = false
670
700
  const runPostureProbe = (): void => {
671
701
  if (!resolve().enabled) return
672
- const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port)
702
+ // The probe and the LAN targets need the bound port; before it settles
703
+ // (a recompose pass) there is nothing honest to probe — keep the previous
704
+ // snapshot and let the next trigger retry.
705
+ const port = ctx.webServer.port
706
+ if (port === undefined) return
707
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port)
673
708
  if (targets.length === 0) {
674
709
  postureKey = undefined
675
710
  service.setPosture(undefined)
@@ -679,7 +714,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
679
714
  const claim = claimPostureKey(postureKey, key)
680
715
  if (!claim.run) return
681
716
  postureKey = claim.next
682
- void probePosture({ port: ctx.webServer.port, targets }).then((snapshot) => {
717
+ void probePosture({ port, targets }).then((snapshot) => {
683
718
  service.setPosture(snapshot)
684
719
  const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host)
685
720
  const exposed = exposedHosts.length > 0
@@ -833,18 +868,13 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
833
868
  }
834
869
  ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
835
870
 
836
- // Apply the effective configuration to the running surfaces once per
837
- // activation: the service tunables, the LAN-bind block/firewall, the tunnel
838
- // plan, the pairing routes, the presence sweep, and the posture probe.
839
- const sync = (): void => {
840
- const value = resolve()
841
- service.config = pairingConfigOf(value)
842
- scheduleLanBindWork()
843
- // The plugin-managed tunnels own the public base while one runs: the URL
844
- // lands in the service through the tunnel's phase listener (the minted
845
- // quick URL, or the named tunnel's fixed public hostname). The manual
846
- // publicBaseUrl applies only when no tunnel runs.
847
- const plan = tunnelPlanOf(value, ctx.webServer.port)
871
+ // Apply one tunnel plan to the running surfaces. The plugin-managed tunnels
872
+ // own the public base while one runs: the URL lands in the service through
873
+ // the tunnel's phase listener (the minted quick URL, or the named tunnel's
874
+ // fixed public hostname). The manual publicBaseUrl applies only when no
875
+ // tunnel runs.
876
+ const applyTunnelPlan = (value: ResolvedConfig, port: number | undefined): void => {
877
+ const plan = tunnelPlanOf(value, port)
848
878
  tunnelMode = plan.mode
849
879
  publicBase.setMode(plan.mode)
850
880
  const liveTunnelUrl = publicBase.quickUrl()
@@ -903,6 +933,36 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
903
933
  service.setPublicBaseUrl(value.publicBaseUrl)
904
934
  }
905
935
  }
936
+ }
937
+
938
+ // Apply the effective configuration to the running surfaces once per
939
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
940
+ // plan, the pairing routes, the presence sweep, and the posture probe.
941
+ const sync = (): void => {
942
+ const value = resolve()
943
+ service.config = pairingConfigOf(value)
944
+ scheduleLanBindWork()
945
+ // The quick tunnel's loopback target URL needs the bound port. A recompose
946
+ // pass can run this apply before the listener binds: defer the tunnel
947
+ // branch (and re-plan on a short timer) instead of minting an
948
+ // http://127.0.0.1:undefined target; everything else in sync applies.
949
+ const livePort = ctx.webServer.port
950
+ if (livePort === undefined && value.autoTunnel === true) {
951
+ if (!portSettleWarned) {
952
+ portSettleWarned = true
953
+ console.warn('remote-web-ui: autoTunnel is on but the web server port is not bound yet — the quick tunnel starts once the port settles')
954
+ }
955
+ if (portSettleTimer === undefined) {
956
+ portSettleTimer = nodeSetTimeout(() => {
957
+ portSettleTimer = undefined
958
+ sync()
959
+ }, PORT_SETTLE_RETRY_MS)
960
+ portSettleTimer.unref()
961
+ }
962
+ } else {
963
+ portSettleWarned = false
964
+ applyTunnelPlan(value, livePort)
965
+ }
906
966
  const enabled = value.enabled
907
967
  if (!enabled) {
908
968
  service.stop()
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The landing page's network-source decision: which origin a paired phone
3
+ * should keep using.
4
+ *
5
+ * The QR link can only name one base, and the server cannot know whether the
6
+ * scanning phone sits on this machine's own network: the public (relayed)
7
+ * base is the only one that is always reachable, so the QR names it and the
8
+ * phone decides at connect time. The device-gated landing (/pair-app) is the
9
+ * plugin's own page already rendered ahead of the official shell's boot, so it
10
+ * is the natural place to probe: after the page is served, a short
11
+ * cross-origin `fetch` per LAN candidate (a no-cors GET at
12
+ * {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
13
+ * and the first one that does becomes the new origin. A probe that finds
14
+ * nothing, or that runs out of budget, changes nothing — the page the phone
15
+ * already holds stays paired and usable, so the probe can never block pairing.
16
+ *
17
+ * Browsers do not block this probe. An https page may not normally fetch
18
+ * `http://` subresources, but a host that is an IP literal is exempt from both
19
+ * the block and the automatic upgrade (verified against Chrome: the console
20
+ * reports "This request was not upgraded to HTTPS because its URL's host is an
21
+ * IP address" and delivers the response), which is exactly the shape every LAN
22
+ * candidate has.
23
+ *
24
+ * @module @linxin666/dsh-remote-web-ui/lan-source
25
+ */
26
+
27
+ /** sessionStorage key holding this session's source decision. */
28
+ export const LAN_SOURCE_STORAGE_KEY = 'dsh-remote-lan-source'
29
+
30
+ /**
31
+ * Probe budget for the whole candidate set. Long enough for a LAN round trip,
32
+ * short enough that a phone on another network never waits on it — the probe
33
+ * is fire-and-forget and the page it runs on is already usable.
34
+ */
35
+ export const LAN_SOURCE_PROBE_TIMEOUT_MS = 800
36
+
37
+ /**
38
+ * How recently a LAN hop must have been taken for a return to a non-LAN origin
39
+ * to read as "the hop did not stick". A phone that fails to load the LAN page
40
+ * re-scans the QR (which always names the public base) within seconds, and the
41
+ * landing then has to choose the public source instead of hopping again into
42
+ * the same black hole; a deliberate return much later is a fresh decision and
43
+ * re-probes.
44
+ */
45
+ export const LAN_SOURCE_RETRY_WINDOW_MS = 120_000
46
+
47
+ /** One probe payload: everything the injected script needs, JSON-serializable. */
48
+ export interface LanSourceHop {
49
+ /**
50
+ * The LAN origins the probe may hop to, normalized (`https://host:port` with
51
+ * a default port dropped) so they compare equal to `location.origin`.
52
+ */
53
+ candidates: readonly string[]
54
+ /** The path the hop targets on the winning origin. */
55
+ path: string
56
+ /** The path the probe itself requests, on each candidate. */
57
+ pingPath: string
58
+ /** The one-time grant the hop consumes on the winning origin. */
59
+ grant: string
60
+ }
61
+
62
+ /**
63
+ * Build the inline script that runs the source decision on the landing page.
64
+ * Reads every browser global through `window`, so a test can run it against
65
+ * one fake object (the same seam the parse-time boot patch uses).
66
+ * @param hop - the probe payload for this landing render.
67
+ * @returns the `<script>` element to splice into the landing's head.
68
+ */
69
+ export function appShellLanSwitchScript(hop: LanSourceHop): string {
70
+ const payload = JSON.stringify({
71
+ key: LAN_SOURCE_STORAGE_KEY,
72
+ candidates: hop.candidates,
73
+ path: hop.path,
74
+ ping: hop.pingPath,
75
+ grant: hop.grant,
76
+ timeout: LAN_SOURCE_PROBE_TIMEOUT_MS,
77
+ retry: LAN_SOURCE_RETRY_WINDOW_MS,
78
+ })
79
+ const body = '(function(){try{' +
80
+ 'var w=window,loc=w.location;' +
81
+ 'var P=' + payload + ';' +
82
+ 'var S=w.sessionStorage;' +
83
+ // The candidate set is the decision's identity: a host whose interfaces
84
+ // changed hands the landing a different signature, which forces a re-probe.
85
+ 'P.sig=P.candidates.join(",");' +
86
+ // The stored decision: which source this session settled on, the candidate
87
+ // set it was made against (so a host whose interfaces changed re-decides),
88
+ // and when. Anything unreadable is treated as "no decision".
89
+ 'function rd(){try{' +
90
+ 'var raw=S.getItem(P.key);' +
91
+ 'if(typeof raw!=="string"||raw==="")return undefined;' +
92
+ 'var d=JSON.parse(raw);' +
93
+ 'if(typeof d!=="object"||d===null)return undefined;' +
94
+ 'if(d.src!=="lan"&&d.src!=="public")return undefined;' +
95
+ 'if(typeof d.sig!=="string"||typeof d.at!=="number")return undefined;' +
96
+ 'return d' +
97
+ '}catch(e){return undefined}}' +
98
+ 'function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}' +
99
+ // Already on a LAN candidate: nothing to decide, and a hop back to it
100
+ // would be the same origin. Recording the fact keeps a reload from probing.
101
+ 'function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}' +
102
+ 'function go(base){try{loc.replace(base+P.path+"?grant="+encodeURIComponent(P.grant))}catch(e){}}' +
103
+ 'function probe(){' +
104
+ 'if(typeof w.fetch!=="function")return;' +
105
+ 'var settled=false;' +
106
+ 'var timer=w.setTimeout(function(){finish(undefined)},P.timeout);' +
107
+ 'function finish(base){' +
108
+ 'if(settled)return;' +
109
+ 'settled=true;' +
110
+ 'try{w.clearTimeout(timer)}catch(e){}' +
111
+ // The first answer wins: one reachable candidate is all the hop needs, and
112
+ // the losers keep running their own no-cors GETs harmlessly.
113
+ 'if(base===undefined){wr({src:"public",sig:P.sig,at:Date.now()});return}' +
114
+ 'wr({src:"lan",sig:P.sig,base:base,at:Date.now()});' +
115
+ 'go(base)}' +
116
+ 'for(var i=0;i<P.candidates.length;i++){(function(base){' +
117
+ 'var sent;' +
118
+ 'try{sent=w.fetch(base+P.ping,{mode:"no-cors",cache:"no-store",credentials:"omit",referrerPolicy:"no-referrer"})}catch(e){sent=undefined}' +
119
+ 'if(sent===undefined||typeof sent.then!=="function")return;' +
120
+ 'sent.then(function(){finish(base)},function(){})' +
121
+ '})(P.candidates[i])}}' +
122
+ 'var d=rd();' +
123
+ 'if(own()){if(d===undefined||d.sig!==P.sig)wr({src:"lan",sig:P.sig,base:loc.origin,at:Date.now()});return}' +
124
+ 'if(d!==undefined&&d.sig===P.sig){' +
125
+ 'if(d.src==="public")return;' +
126
+ // A LAN decision taken moments ago, on a page that is not a LAN candidate,
127
+ // is a hop that did not stick. Fall back rather than hop again.
128
+ 'if(Date.now()-d.at<P.retry){wr({src:"public",sig:P.sig,at:Date.now()});return}}' +
129
+ 'probe()' +
130
+ '}catch(e){}})();'
131
+ return `<script>${body}</script>`
132
+ }