@linxin666/dsh-remote-web-ui 0.3.22 → 0.3.23

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.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The public base the pairing fence trusts while a tunnel is not running
3
+ * (issue #1547).
4
+ *
5
+ * The fence reads exactly one public host: `service.publicBaseUrl`. Dropping
6
+ * it the instant the tunnel leaves `running` makes every public request from
7
+ * the address still printed in the QR code answer 403 — including
8
+ * `/api/pair/status`, which exists to be callable before pairing. Two of the
9
+ * three tunnel modes never lose their host: a named tunnel keeps its fixed
10
+ * dashboard hostname, and a registered relay keeps its stable subdomain. A
11
+ * quick tunnel does mint a new hostname on restart, so its previous host is
12
+ * kept only for a bounded grace window: Cloudflare's edge can still deliver a
13
+ * connection the phone already opened, and a mobile network reconnects
14
+ * constantly, so an instant drop turns the phone's retry into a silent 403.
15
+ * @module @linxin666/dsh-remote-web-ui/public-base
16
+ */
17
+ /** How long a quick-tunnel host stays trusted after the tunnel stops running. */
18
+ export const PUBLIC_BASE_GRACE_MS = 60_000;
19
+ /**
20
+ * Owns the trusted public base across tunnel and relay transitions.
21
+ *
22
+ * The keeper never decides on its own to widen trust: it only postpones
23
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
24
+ * already published, and it forgets it once the tunnel comes back with the
25
+ * address that actually serves the QR.
26
+ */
27
+ export class PublicBaseKeeper {
28
+ onChange;
29
+ graceMs;
30
+ mode = 'off';
31
+ tunnelUrl;
32
+ relayUrl;
33
+ graceTimer;
34
+ /**
35
+ * @param onChange - receives the base the fence should trust from now on.
36
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
37
+ */
38
+ constructor(onChange, graceMs = PUBLIC_BASE_GRACE_MS) {
39
+ this.onChange = onChange;
40
+ this.graceMs = graceMs;
41
+ }
42
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
43
+ current() {
44
+ return this.relayUrl ?? this.tunnelUrl;
45
+ }
46
+ /** The mode that currently owns the public base. */
47
+ setMode(mode) {
48
+ this.mode = mode;
49
+ }
50
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
51
+ quickUrl() {
52
+ return this.tunnelUrl;
53
+ }
54
+ /** A relay registration result; undefined withdraws the stable origin. */
55
+ setRelay(url) {
56
+ this.relayUrl = url;
57
+ this.emit();
58
+ }
59
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
60
+ markRunning(url) {
61
+ this.cancelGrace();
62
+ this.tunnelUrl = url;
63
+ this.emit();
64
+ }
65
+ /**
66
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
67
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
68
+ * trusted for the grace window, then is dropped if nothing replaced it.
69
+ */
70
+ markReconnecting() {
71
+ if (this.mode === 'named')
72
+ return;
73
+ this.startGrace();
74
+ }
75
+ /** Re-publish the current base after a branch change that did not move it. */
76
+ refresh() {
77
+ this.emit();
78
+ }
79
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
80
+ reset() {
81
+ this.cancelGrace();
82
+ this.tunnelUrl = undefined;
83
+ this.relayUrl = undefined;
84
+ this.emit();
85
+ }
86
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
87
+ dispose() {
88
+ this.cancelGrace();
89
+ }
90
+ startGrace() {
91
+ if (this.tunnelUrl === undefined)
92
+ return;
93
+ if (this.graceTimer !== undefined)
94
+ return;
95
+ this.graceTimer = setTimeout(() => {
96
+ this.graceTimer = undefined;
97
+ this.tunnelUrl = undefined;
98
+ this.emit();
99
+ }, this.graceMs);
100
+ }
101
+ cancelGrace() {
102
+ if (this.graceTimer === undefined)
103
+ return;
104
+ clearTimeout(this.graceTimer);
105
+ this.graceTimer = undefined;
106
+ }
107
+ emit() {
108
+ this.onChange(this.current());
109
+ }
110
+ }
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
  import { type RemoteChannelRules } from './remote-channel-rules.ts';
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CA8HrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAgB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
1
+ {"version":3,"file":"remote-channel-boot.d.ts","sourceRoot":"","sources":["../../src/remote-channel-boot.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,EAAoD,KAAK,kBAAkB,EAAE,MAAM,2BAA2B,CAAA;AAErH;;;;GAIG;AACH,wBAAgB,4BAA4B,CAAC,KAAK,GAAE,kBAAyC,GAAG,MAAM,CAqJrG;AAED,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,2BAA2B,CAAA;AAQzD;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,SAAwB,EAAE,MAAM,SAAwB,GAAG,MAAM,CAgB9G;AAED,0EAA0E;AAC1E,eAAO,MAAM,0BAA0B,EAAE,MAAuC,CAAA"}
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
  import { REMOTE_CHANNEL_BOOT_GLOBAL, REMOTE_CHANNEL_RULES } from "./remote-channel-rules.js";
@@ -140,6 +143,29 @@ export function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
140
143
  'Object.defineProperty(C.prototype,"src",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});' +
141
144
  'restores.push(function(){Object.defineProperty(C.prototype,"src",d)})}' +
142
145
  'patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);' +
146
+ // Background uploads must stay on the patched main-thread fetch
147
+ // (issue #1580). Without this hook @deepseek-ai/dsh-client-file-upload
148
+ // runs its carrier in a Web Worker, whose own globals no main-thread
149
+ // patch reaches: the worker's XHR goes straight to <origin>/api/...
150
+ // without the /remote rewrite and without the device credential, so the
151
+ // harness browser-auth fence answers 401 and every upload from a paired
152
+ // browser fails. The hook is the official pre-Cordis seam (the runtime
153
+ // reads it once in its constructor) and rewriting the worker URL is
154
+ // provably insufficient - a worker context carries neither the pairing
155
+ // cookie nor the device header. Hand the runtime the patched fetch
156
+ // instead: it takes the absolute route URL and a RequestInit, and hands
157
+ // both to the patched w.fetch, which owns the /remote rewrite AND the
158
+ // cookieless device header. Delegating the whole decision is deliberate:
159
+ // rewriting the path here first would make w.fetch see an already-gated
160
+ // /remote/api path, skip its own rewrite branch, and drop the device
161
+ // credential that the fence requires.
162
+ 'function uf(u,init){' +
163
+ 'var raw=typeof u==="string"?u:u.href;' +
164
+ 'if(typeof raw!=="string")return of.call(w,u,init||{});' +
165
+ 'var q=new URL(raw,loc.href);' +
166
+ 'if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});' +
167
+ 'return of.call(w,u,init||{})}' +
168
+ 'try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}' +
143
169
  'seat.restore=function(){' +
144
170
  'w.fetch=of;' +
145
171
  'w.WebSocket=OW;' +
@@ -28,6 +28,10 @@ export interface RemoteChannelRules {
28
28
  readonly deviceKey: string;
29
29
  /** Query parameter carrying it on WebSocket upgrades. */
30
30
  readonly deviceQuery: string;
31
+ /** Raw upload route the boot hook keeps on the gated channel. */
32
+ readonly uploadPath: string;
33
+ /** Page global the pre-Cordis upload hook is published under. */
34
+ readonly uploadHookGlobal: string;
31
35
  }
32
36
  /** The live rule set. */
33
37
  export declare const REMOTE_CHANNEL_RULES: RemoteChannelRules;
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;CAC7B;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBAqBlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
1
+ {"version":3,"file":"remote-channel-rules.d.ts","sourceRoot":"","sources":["../../src/remote-channel-rules.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kEAAkE;AAClE,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAA;AAE/E,OAAO,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,CAAA;AAEpD,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,6EAA6E;AAC7E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,oBAAoB,EAAE,MAAM,CAAA;IACrC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;IACnC,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2EAA2E;IAC3E,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,yDAAyD;IACzD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAA;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,iEAAiE;IACjE,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAA;CAClC;AAED,yBAAyB;AACzB,eAAO,MAAM,oBAAoB,EAAE,kBAuBlC,CAAA;AAED,iEAAiE;AACjE,eAAO,MAAM,0BAA0B,gCAAgC,CAAA;AAEvE;;;;GAIG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC/B,QAAQ,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAA;IAC7B,eAAe,EAAE,OAAO,CAAA;IACxB,OAAO,IAAI,IAAI,CAAA;CAChB"}
@@ -33,6 +33,8 @@ export const REMOTE_CHANNEL_RULES = {
33
33
  deviceHeader: REMOTE_DEVICE_HEADER,
34
34
  deviceKey: 'dsh-remote-device',
35
35
  deviceQuery: REMOTE_DEVICE_QUERY,
36
+ uploadPath: '/api/session/uploadFileBinary',
37
+ uploadHookGlobal: '__DSH_FILE_UPLOAD__',
36
38
  };
37
39
  /** The window global the boot patch publishes its seat under. */
38
40
  export const REMOTE_CHANNEL_BOOT_GLOBAL = '__DSH_REMOTE_CHANNEL_BOOT__';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
- "version": "0.3.22",
4
+ "version": "0.3.23",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -207,6 +207,9 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
207
207
  const originalFetch = window.fetch
208
208
  const OriginalWebSocket = window.WebSocket
209
209
  const OriginalEventSource = window.EventSource
210
+ // Publish the official upload hook before the file-upload service reads it
211
+ // (issue #1580); removed again by the restore below.
212
+ const restoreUploadHook = installFileUploadHook(window as unknown as UploadHookWindow)
210
213
 
211
214
  const sameOrigin = (url: URL): boolean => url.origin === window.location.origin
212
215
  const rewrite = (raw: string): string => rewriteRawUrl(raw, window.location.href, window.location.origin)
@@ -301,6 +304,67 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
301
304
  window.WebSocket = OriginalWebSocket
302
305
  if (OriginalEventSource !== undefined) window.EventSource = OriginalEventSource
303
306
  for (const restore of restoreSrc) restore()
307
+ restoreUploadHook()
308
+ }
309
+ }
310
+
311
+ /** Page global the official pre-Cordis upload hook is published under. */
312
+ export const FILE_UPLOAD_HOOK_GLOBAL = '__DSH_FILE_UPLOAD__'
313
+
314
+ /** The subset of window the upload hook needs (injectable for tests). */
315
+ export interface UploadHookWindow {
316
+ fetch: typeof globalThis.fetch
317
+ location: { href: string; origin: string }
318
+ /** Pre-existing hook owner; left untouched when present. */
319
+ __DSH_FILE_UPLOAD__?: {
320
+ fetch: (input: string | URL, init?: RequestInit) => Promise<Response>
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Publish the official pre-Cordis upload hook so background uploads keep
326
+ * riding the patched main-thread fetch (issue #1580).
327
+ *
328
+ * `@deepseek-ai/dsh-client-file-upload` reads `globalThis.__DSH_FILE_UPLOAD__`
329
+ * once when its runtime is constructed; without it the carrier is a Web
330
+ * Worker, whose own globals no main-thread patch reaches. That worker's XHR
331
+ * goes straight to `<origin>/api/session/uploadFileBinary` with neither the
332
+ * `/remote` rewrite nor the cookieless device credential, so the harness
333
+ * browser-auth fence answers 401 and every upload from a paired browser
334
+ * fails. Rewriting the worker URL cannot fix it: a worker context carries
335
+ * neither the pairing cookie nor the device header.
336
+ *
337
+ * The hook hands the runtime the same transport the rest of the page uses -
338
+ * the boot script publishes an identical one (remote-channel-boot.ts), and
339
+ * this is the fallback for pages served without it.
340
+ *
341
+ * @param window - the browser window (or a test double), BEFORE the channel patch.
342
+ * @returns a function retiring the hook (a pre-existing one is left alone).
343
+ */
344
+ export function installFileUploadHook(window: UploadHookWindow): () => void {
345
+ if (window.__DSH_FILE_UPLOAD__ !== undefined) return () => {}
346
+ const originalFetch = window.fetch
347
+ const hook = {
348
+ fetch: (input: string | URL, init?: RequestInit): Promise<Response> => {
349
+ const raw = typeof input === 'string' ? input : input.href
350
+ let url: URL
351
+ try {
352
+ url = new URL(raw, window.location.href)
353
+ } catch {
354
+ return originalFetch.call(window, input, init)
355
+ }
356
+ // Delegate the whole decision to the patched fetch: rewriting the path
357
+ // here first would make it skip its own rewrite branch and drop the
358
+ // device credential the fence requires.
359
+ if (url.origin === window.location.origin && url.pathname === RULES.uploadPath) {
360
+ return window.fetch.call(window, raw, init)
361
+ }
362
+ return originalFetch.call(window, input, init)
363
+ },
364
+ }
365
+ window.__DSH_FILE_UPLOAD__ = hook
366
+ return () => {
367
+ if (window.__DSH_FILE_UPLOAD__ === hook) delete window.__DSH_FILE_UPLOAD__
304
368
  }
305
369
  }
306
370
 
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-regi
37
37
  import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
38
38
  import { createInnerAuth } from './inner-auth.ts'
39
39
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
40
+ import { PublicBaseKeeper } from './public-base.ts'
40
41
  import {
41
42
  checkUpdates,
42
43
  fetchGitHubReleaseNotes,
@@ -343,17 +344,18 @@ function applyImpl(ctx: Context, config?: Config): void {
343
344
  // raw quick URL is used, exactly as before. Named tunnels keep their fixed
344
345
  // dashboard hostname and never touch the relay.
345
346
  let relayRegistrar: RelayRegistrar | undefined
346
- let relayUrl: string | undefined
347
- let rawTunnelUrl: string | undefined
347
+ /**
348
+ * The public base the pairing fence trusts. A tunnel reconnect must not
349
+ * strip the host the QR still shows: named and relay hosts never change,
350
+ * and a quick tunnel's host survives a bounded grace window (issue #1547).
351
+ */
352
+ const publicBase = new PublicBaseKeeper((base) => { service.setPublicBaseUrl(base) })
348
353
  /** The tunnel target the registrar last announced (dedupes sync re-runs). */
349
354
  let relayAnnouncedFor: string | undefined
350
- const setPublicBase = (): void => {
351
- service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl)
352
- }
353
355
  const disposeRelayRegistrar = (unregister: boolean = false): void => {
354
356
  const registrar = relayRegistrar
355
357
  relayRegistrar = undefined
356
- relayUrl = undefined
358
+ publicBase.setRelay(undefined)
357
359
  relayAnnouncedFor = undefined
358
360
  if (registrar === undefined) return
359
361
  // Toggle-off removes the registry row so the stable origin stops
@@ -371,16 +373,15 @@ function applyImpl(ctx: Context, config?: Config): void {
371
373
  relayRegistrar = new RelayRegistrar(identity, (state: RelayState) => {
372
374
  service.setRelayStatus(state.state === 'off' ? undefined : state)
373
375
  if (state.state === 'running') {
374
- relayUrl = state.url
376
+ publicBase.setRelay(state.url)
375
377
  } else if (state.state === 'off') {
376
- relayUrl = undefined
378
+ publicBase.setRelay(undefined)
377
379
  } else if (state.state === 'failed') {
378
380
  // Keep the last relay URL on failures: the phone origin only
379
381
  // breaks when the mapping itself goes stale, not when one
380
382
  // refresh call fails. The registrar retries with backoff.
381
383
  console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`)
382
384
  }
383
- setPublicBase()
384
385
  })
385
386
  } catch (error) {
386
387
  console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`)
@@ -399,27 +400,28 @@ function applyImpl(ctx: Context, config?: Config): void {
399
400
  tunnel.onPhase((info: TunnelInfo) => {
400
401
  if (tunnelMode === 'off') return
401
402
  if (info.phase === 'running' && info.url !== undefined) {
402
- rawTunnelUrl = info.url
403
- setPublicBase()
403
+ publicBase.markRunning(info.url)
404
404
  service.setTunnelStatus({ state: 'running', url: info.url })
405
405
  const registrar = tunnelMode === 'quick' ? ensureRelayRegistrar() : undefined
406
406
  if (registrar !== undefined) announceRelay(registrar, info.url)
407
407
  runPostureProbe()
408
408
  } else if (info.phase === 'starting') {
409
- // A restart mints a NEW hostname: the previous URL dies with the old
410
- // process, so clear it now rather than advertising a dead link.
411
- rawTunnelUrl = undefined
412
- relayUrl = undefined
413
- setPublicBase()
409
+ // A quick-tunnel restart mints a new hostname, but the old one is not
410
+ // dropped at once: the edge may still deliver a connection the phone
411
+ // already opened, and the reconnect usually lands inside the grace
412
+ // window (issue #1547). A named tunnel keeps its fixed hostname, and a
413
+ // registered relay its stable subdomain, so neither is ever dropped
414
+ // here — the previous code cleared the relay base on every restart.
415
+ publicBase.markReconnecting()
414
416
  service.setTunnelStatus({ state: 'starting' })
415
417
  } else if (info.phase === 'failed') {
416
- rawTunnelUrl = undefined
417
- setPublicBase()
418
+ publicBase.markReconnecting()
418
419
  service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error })
419
420
  }
420
421
  })
421
422
  ctx.effect(() => () => {
422
423
  disposeRelayRegistrar()
424
+ publicBase.dispose()
423
425
  tunnel.dispose()
424
426
  }, 'remote-web-ui: auto tunnel')
425
427
  // The bind facts are known by now (webServer is an inject edge): the LAN
@@ -768,22 +770,24 @@ function applyImpl(ctx: Context, config?: Config): void {
768
770
  // publicBaseUrl applies only when no tunnel runs.
769
771
  const plan = tunnelPlanOf(value, ctx.webServer.port)
770
772
  tunnelMode = plan.mode
773
+ publicBase.setMode(plan.mode)
774
+ const liveTunnelUrl = publicBase.quickUrl()
771
775
  if (plan.mode !== 'quick') {
772
776
  // The relay only fronts the quick tunnel; named mode owns its fixed
773
777
  // dashboard hostname and the off mode has no public base at all.
774
778
  disposeRelayRegistrar()
775
- if (plan.mode !== 'named') setPublicBase()
779
+ if (plan.mode !== 'named') publicBase.refresh()
776
780
  } else if (value.relay === false) {
777
781
  // The relay toggle is off: no stable origin, the raw quick URL is the
778
782
  // QR base exactly as before the relay existed.
779
783
  disposeRelayRegistrar(true)
780
- setPublicBase()
781
- } else if (rawTunnelUrl !== undefined) {
784
+ publicBase.refresh()
785
+ } else if (liveTunnelUrl !== undefined) {
782
786
  // The relay just turned on (or the registrar is new) while the tunnel
783
787
  // already runs: announce now — no phase event will fire for an
784
788
  // unchanged target.
785
789
  const registrar = ensureRelayRegistrar()
786
- if (registrar !== undefined) announceRelay(registrar, rawTunnelUrl)
790
+ if (registrar !== undefined) announceRelay(registrar, liveTunnelUrl)
787
791
  }
788
792
  if (plan.mode === 'quick') {
789
793
  for (const ignored of plan.ignored) {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The public base the pairing fence trusts while a tunnel is not running
3
+ * (issue #1547).
4
+ *
5
+ * The fence reads exactly one public host: `service.publicBaseUrl`. Dropping
6
+ * it the instant the tunnel leaves `running` makes every public request from
7
+ * the address still printed in the QR code answer 403 — including
8
+ * `/api/pair/status`, which exists to be callable before pairing. Two of the
9
+ * three tunnel modes never lose their host: a named tunnel keeps its fixed
10
+ * dashboard hostname, and a registered relay keeps its stable subdomain. A
11
+ * quick tunnel does mint a new hostname on restart, so its previous host is
12
+ * kept only for a bounded grace window: Cloudflare's edge can still deliver a
13
+ * connection the phone already opened, and a mobile network reconnects
14
+ * constantly, so an instant drop turns the phone's retry into a silent 403.
15
+ * @module @linxin666/dsh-remote-web-ui/public-base
16
+ */
17
+
18
+ /** How long a quick-tunnel host stays trusted after the tunnel stops running. */
19
+ export const PUBLIC_BASE_GRACE_MS = 60_000
20
+
21
+ /** Tunnel ownership of the public base, as resolved from the settings document. */
22
+ export type TunnelBaseMode = 'off' | 'quick' | 'named'
23
+
24
+ /**
25
+ * Owns the trusted public base across tunnel and relay transitions.
26
+ *
27
+ * The keeper never decides on its own to widen trust: it only postpones
28
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
29
+ * already published, and it forgets it once the tunnel comes back with the
30
+ * address that actually serves the QR.
31
+ */
32
+ export class PublicBaseKeeper {
33
+ private mode: TunnelBaseMode = 'off'
34
+ private tunnelUrl: string | undefined
35
+ private relayUrl: string | undefined
36
+ private graceTimer: ReturnType<typeof setTimeout> | undefined
37
+
38
+ /**
39
+ * @param onChange - receives the base the fence should trust from now on.
40
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
41
+ */
42
+ constructor(
43
+ private readonly onChange: (base: string | undefined) => void,
44
+ private readonly graceMs: number = PUBLIC_BASE_GRACE_MS,
45
+ ) {}
46
+
47
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
48
+ current(): string | undefined {
49
+ return this.relayUrl ?? this.tunnelUrl
50
+ }
51
+
52
+ /** The mode that currently owns the public base. */
53
+ setMode(mode: TunnelBaseMode): void {
54
+ this.mode = mode
55
+ }
56
+
57
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
58
+ quickUrl(): string | undefined {
59
+ return this.tunnelUrl
60
+ }
61
+
62
+ /** A relay registration result; undefined withdraws the stable origin. */
63
+ setRelay(url: string | undefined): void {
64
+ this.relayUrl = url
65
+ this.emit()
66
+ }
67
+
68
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
69
+ markRunning(url: string): void {
70
+ this.cancelGrace()
71
+ this.tunnelUrl = url
72
+ this.emit()
73
+ }
74
+
75
+ /**
76
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
77
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
78
+ * trusted for the grace window, then is dropped if nothing replaced it.
79
+ */
80
+ markReconnecting(): void {
81
+ if (this.mode === 'named') return
82
+ this.startGrace()
83
+ }
84
+
85
+ /** Re-publish the current base after a branch change that did not move it. */
86
+ refresh(): void {
87
+ this.emit()
88
+ }
89
+
90
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
91
+ reset(): void {
92
+ this.cancelGrace()
93
+ this.tunnelUrl = undefined
94
+ this.relayUrl = undefined
95
+ this.emit()
96
+ }
97
+
98
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
99
+ dispose(): void {
100
+ this.cancelGrace()
101
+ }
102
+
103
+ private startGrace(): void {
104
+ if (this.tunnelUrl === undefined) return
105
+ if (this.graceTimer !== undefined) return
106
+ this.graceTimer = setTimeout(() => {
107
+ this.graceTimer = undefined
108
+ this.tunnelUrl = undefined
109
+ this.emit()
110
+ }, this.graceMs)
111
+ }
112
+
113
+ private cancelGrace(): void {
114
+ if (this.graceTimer === undefined) return
115
+ clearTimeout(this.graceTimer)
116
+ this.graceTimer = undefined
117
+ }
118
+
119
+ private emit(): void {
120
+ this.onChange(this.current())
121
+ }
122
+ }
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
 
@@ -142,6 +145,29 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
142
145
  'Object.defineProperty(C.prototype,"src",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});' +
143
146
  'restores.push(function(){Object.defineProperty(C.prototype,"src",d)})}' +
144
147
  'patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);' +
148
+ // Background uploads must stay on the patched main-thread fetch
149
+ // (issue #1580). Without this hook @deepseek-ai/dsh-client-file-upload
150
+ // runs its carrier in a Web Worker, whose own globals no main-thread
151
+ // patch reaches: the worker's XHR goes straight to <origin>/api/...
152
+ // without the /remote rewrite and without the device credential, so the
153
+ // harness browser-auth fence answers 401 and every upload from a paired
154
+ // browser fails. The hook is the official pre-Cordis seam (the runtime
155
+ // reads it once in its constructor) and rewriting the worker URL is
156
+ // provably insufficient - a worker context carries neither the pairing
157
+ // cookie nor the device header. Hand the runtime the patched fetch
158
+ // instead: it takes the absolute route URL and a RequestInit, and hands
159
+ // both to the patched w.fetch, which owns the /remote rewrite AND the
160
+ // cookieless device header. Delegating the whole decision is deliberate:
161
+ // rewriting the path here first would make w.fetch see an already-gated
162
+ // /remote/api path, skip its own rewrite branch, and drop the device
163
+ // credential that the fence requires.
164
+ 'function uf(u,init){' +
165
+ 'var raw=typeof u==="string"?u:u.href;' +
166
+ 'if(typeof raw!=="string")return of.call(w,u,init||{});' +
167
+ 'var q=new URL(raw,loc.href);' +
168
+ 'if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});' +
169
+ 'return of.call(w,u,init||{})}' +
170
+ 'try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}' +
145
171
  'seat.restore=function(){' +
146
172
  'w.fetch=of;' +
147
173
  'w.WebSocket=OW;' +
@@ -33,6 +33,10 @@ export interface RemoteChannelRules {
33
33
  readonly deviceKey: string
34
34
  /** Query parameter carrying it on WebSocket upgrades. */
35
35
  readonly deviceQuery: string
36
+ /** Raw upload route the boot hook keeps on the gated channel. */
37
+ readonly uploadPath: string
38
+ /** Page global the pre-Cordis upload hook is published under. */
39
+ readonly uploadHookGlobal: string
36
40
  }
37
41
 
38
42
  /** The live rule set. */
@@ -57,6 +61,8 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
57
61
  deviceHeader: REMOTE_DEVICE_HEADER,
58
62
  deviceKey: 'dsh-remote-device',
59
63
  deviceQuery: REMOTE_DEVICE_QUERY,
64
+ uploadPath: '/api/session/uploadFileBinary',
65
+ uploadHookGlobal: '__DSH_FILE_UPLOAD__',
60
66
  }
61
67
 
62
68
  /** The window global the boot patch publishes its seat under. */