@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.
- package/README.i18n.yaml +2 -2
- package/README.md +13 -13
- package/README.zh.md +13 -13
- package/lib/client.js +25 -92
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -53
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +3 -15
- package/lib/types/client/RemotePanel.d.ts +1 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -4
- package/lib/types/client/locales.d.ts +4 -8
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +30 -3
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +2 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -1
- package/lib/types/client/settings-entry-form.js +0 -1
- package/lib/types/detached-work.d.ts +7 -7
- package/lib/types/detached-work.d.ts.map +1 -1
- package/lib/types/detached-work.js +9 -8
- package/lib/types/index.d.ts +14 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +67 -17
- package/lib/types/lan-source.d.ts +65 -0
- package/lib/types/lan-source.d.ts.map +1 -0
- package/lib/types/lan-source.js +113 -0
- package/lib/types/lan.d.ts +49 -3
- package/lib/types/lan.d.ts.map +1 -1
- package/lib/types/lan.js +120 -6
- package/lib/types/remote-api.d.ts +7 -2
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +17 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +4 -1
- package/lib/types/routes.d.ts +71 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +157 -27
- package/lib/types/tunnel-plan.d.ts +8 -2
- package/lib/types/tunnel-plan.d.ts.map +1 -1
- package/lib/types/tunnel-plan.js +9 -1
- package/package.json +20 -25
- package/src/client/RemoteEntry.tsx +2 -18
- package/src/client/RemotePanel.tsx +9 -43
- package/src/client/locales.ts +4 -8
- package/src/client/mobile-adapt.ts +30 -3
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/remote.module.css +2 -48
- package/src/client/settings-entry-form.ts +0 -1
- package/src/detached-work.ts +9 -8
- package/src/index.ts +77 -17
- package/src/lan-source.ts +132 -0
- package/src/lan.ts +126 -6
- package/src/remote-api.ts +24 -4
- package/src/remote-channel-boot.ts +4 -1
- package/src/routes.ts +178 -27
- package/src/tunnel-plan.ts +9 -2
- package/lib/invariant.js +0 -21
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.d.ts.map +0 -1
- package/lib/types/invariant.js +0 -23
- 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
|
|
4
|
-
* actions (stop / refresh / copy).
|
|
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.
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
|
|
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}>
|
package/src/client/locales.ts
CHANGED
|
@@ -20,10 +20,8 @@ export const zh = {
|
|
|
20
20
|
'status.unreachableHint': '请刷新页面后重试。',
|
|
21
21
|
'pair.hint': '无法扫码?可直接打开下方配对链接',
|
|
22
22
|
'pair.publicHint': '公网链接:设备无需与本机处于同一网络',
|
|
23
|
-
'address.
|
|
24
|
-
'address.
|
|
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.
|
|
153
|
-
'address.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
577
|
-
.
|
|
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
|
}
|
package/src/detached-work.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
+
}
|