@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.
package/lib/index.js CHANGED
@@ -3158,6 +3158,114 @@ var TunnelManager = class {
3158
3158
  } catch {}
3159
3159
  }
3160
3160
  };
3161
+ //#endregion
3162
+ //#region src/public-base.ts
3163
+ /**
3164
+ * The public base the pairing fence trusts while a tunnel is not running
3165
+ * (issue #1547).
3166
+ *
3167
+ * The fence reads exactly one public host: `service.publicBaseUrl`. Dropping
3168
+ * it the instant the tunnel leaves `running` makes every public request from
3169
+ * the address still printed in the QR code answer 403 — including
3170
+ * `/api/pair/status`, which exists to be callable before pairing. Two of the
3171
+ * three tunnel modes never lose their host: a named tunnel keeps its fixed
3172
+ * dashboard hostname, and a registered relay keeps its stable subdomain. A
3173
+ * quick tunnel does mint a new hostname on restart, so its previous host is
3174
+ * kept only for a bounded grace window: Cloudflare's edge can still deliver a
3175
+ * connection the phone already opened, and a mobile network reconnects
3176
+ * constantly, so an instant drop turns the phone's retry into a silent 403.
3177
+ * @module @linxin666/dsh-remote-web-ui/public-base
3178
+ */
3179
+ /** How long a quick-tunnel host stays trusted after the tunnel stops running. */
3180
+ const PUBLIC_BASE_GRACE_MS = 6e4;
3181
+ /**
3182
+ * Owns the trusted public base across tunnel and relay transitions.
3183
+ *
3184
+ * The keeper never decides on its own to widen trust: it only postpones
3185
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
3186
+ * already published, and it forgets it once the tunnel comes back with the
3187
+ * address that actually serves the QR.
3188
+ */
3189
+ var PublicBaseKeeper = class {
3190
+ onChange;
3191
+ graceMs;
3192
+ mode = "off";
3193
+ tunnelUrl;
3194
+ relayUrl;
3195
+ graceTimer;
3196
+ /**
3197
+ * @param onChange - receives the base the fence should trust from now on.
3198
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
3199
+ */
3200
+ constructor(onChange, graceMs = PUBLIC_BASE_GRACE_MS) {
3201
+ this.onChange = onChange;
3202
+ this.graceMs = graceMs;
3203
+ }
3204
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
3205
+ current() {
3206
+ return this.relayUrl ?? this.tunnelUrl;
3207
+ }
3208
+ /** The mode that currently owns the public base. */
3209
+ setMode(mode) {
3210
+ this.mode = mode;
3211
+ }
3212
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
3213
+ quickUrl() {
3214
+ return this.tunnelUrl;
3215
+ }
3216
+ /** A relay registration result; undefined withdraws the stable origin. */
3217
+ setRelay(url) {
3218
+ this.relayUrl = url;
3219
+ this.emit();
3220
+ }
3221
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
3222
+ markRunning(url) {
3223
+ this.cancelGrace();
3224
+ this.tunnelUrl = url;
3225
+ this.emit();
3226
+ }
3227
+ /**
3228
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
3229
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
3230
+ * trusted for the grace window, then is dropped if nothing replaced it.
3231
+ */
3232
+ markReconnecting() {
3233
+ if (this.mode === "named") return;
3234
+ this.startGrace();
3235
+ }
3236
+ /** Re-publish the current base after a branch change that did not move it. */
3237
+ refresh() {
3238
+ this.emit();
3239
+ }
3240
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
3241
+ reset() {
3242
+ this.cancelGrace();
3243
+ this.tunnelUrl = void 0;
3244
+ this.relayUrl = void 0;
3245
+ this.emit();
3246
+ }
3247
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
3248
+ dispose() {
3249
+ this.cancelGrace();
3250
+ }
3251
+ startGrace() {
3252
+ if (this.tunnelUrl === void 0) return;
3253
+ if (this.graceTimer !== void 0) return;
3254
+ this.graceTimer = setTimeout(() => {
3255
+ this.graceTimer = void 0;
3256
+ this.tunnelUrl = void 0;
3257
+ this.emit();
3258
+ }, this.graceMs);
3259
+ }
3260
+ cancelGrace() {
3261
+ if (this.graceTimer === void 0) return;
3262
+ clearTimeout(this.graceTimer);
3263
+ this.graceTimer = void 0;
3264
+ }
3265
+ emit() {
3266
+ this.onChange(this.current());
3267
+ }
3268
+ };
3161
3269
  /** The aggregate package that is the canonical update entry point. */
3162
3270
  const AGGREGATE_PACKAGE = "@linxin666/dsh-web-all";
3163
3271
  /** Fallback anchor: this plugin's own package when the aggregate is absent. */
@@ -3859,7 +3967,9 @@ const REMOTE_CHANNEL_RULES = {
3859
3967
  ],
3860
3968
  deviceHeader: REMOTE_DEVICE_HEADER,
3861
3969
  deviceKey: "dsh-remote-device",
3862
- deviceQuery: REMOTE_DEVICE_QUERY
3970
+ deviceQuery: REMOTE_DEVICE_QUERY,
3971
+ uploadPath: "/api/session/uploadFileBinary",
3972
+ uploadHookGlobal: "__DSH_FILE_UPLOAD__"
3863
3973
  };
3864
3974
  /** The window global the boot patch publishes its seat under. */
3865
3975
  const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
@@ -3884,8 +3994,11 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3884
3994
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
3885
3995
  * connection plugin reads it: the paired remote desktop gets the full
3886
3996
  * settings/credentials/presets surface, and every call still rides the
3887
- * gated /remote channel. The script self-skips on loopback origins and
3888
- * never throws.
3997
+ * gated /remote channel. It finally publishes the official pre-Cordis
3998
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
3999
+ * transport otherwise runs inside a Web Worker whose own globals the
4000
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
4001
+ * loopback origins and never throws.
3889
4002
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
3890
4003
  */
3891
4004
  /**
@@ -3896,7 +4009,7 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3896
4009
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
3897
4010
  const json = JSON.stringify(rules);
3898
4011
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
3899
- return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
4012
+ return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);function uf(u,init){var raw=typeof u===\"string\"?u:u.href;if(typeof raw!==\"string\")return of.call(w,u,init||{});var q=new URL(raw,loc.href);if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});return of.call(w,u,init||{})}try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
3900
4013
  }
3901
4014
  /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
3902
4015
  const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
@@ -4079,17 +4192,20 @@ function applyImpl(ctx, config) {
4079
4192
  const service = new PairingService(pairingConfigOf(resolved));
4080
4193
  const tunnel = new TunnelManager();
4081
4194
  let relayRegistrar;
4082
- let relayUrl;
4083
- let rawTunnelUrl;
4195
+ /**
4196
+ * The public base the pairing fence trusts. A tunnel reconnect must not
4197
+ * strip the host the QR still shows: named and relay hosts never change,
4198
+ * and a quick tunnel's host survives a bounded grace window (issue #1547).
4199
+ */
4200
+ const publicBase = new PublicBaseKeeper((base) => {
4201
+ service.setPublicBaseUrl(base);
4202
+ });
4084
4203
  /** The tunnel target the registrar last announced (dedupes sync re-runs). */
4085
4204
  let relayAnnouncedFor;
4086
- const setPublicBase = () => {
4087
- service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl);
4088
- };
4089
4205
  const disposeRelayRegistrar = (unregister = false) => {
4090
4206
  const registrar = relayRegistrar;
4091
4207
  relayRegistrar = void 0;
4092
- relayUrl = void 0;
4208
+ publicBase.setRelay(void 0);
4093
4209
  relayAnnouncedFor = void 0;
4094
4210
  if (registrar === void 0) return;
4095
4211
  if (unregister) registrar.unregister().catch(() => void 0);
@@ -4100,10 +4216,9 @@ function applyImpl(ctx, config) {
4100
4216
  if (relayRegistrar === void 0) try {
4101
4217
  relayRegistrar = new RelayRegistrar(loadRelayIdentity(resolve().profile), (state) => {
4102
4218
  service.setRelayStatus(state.state === "off" ? void 0 : state);
4103
- if (state.state === "running") relayUrl = state.url;
4104
- else if (state.state === "off") relayUrl = void 0;
4219
+ if (state.state === "running") publicBase.setRelay(state.url);
4220
+ else if (state.state === "off") publicBase.setRelay(void 0);
4105
4221
  else if (state.state === "failed") console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`);
4106
- setPublicBase();
4107
4222
  });
4108
4223
  } catch (error) {
4109
4224
  console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`);
@@ -4119,8 +4234,7 @@ function applyImpl(ctx, config) {
4119
4234
  tunnel.onPhase((info) => {
4120
4235
  if (tunnelMode === "off") return;
4121
4236
  if (info.phase === "running" && info.url !== void 0) {
4122
- rawTunnelUrl = info.url;
4123
- setPublicBase();
4237
+ publicBase.markRunning(info.url);
4124
4238
  service.setTunnelStatus({
4125
4239
  state: "running",
4126
4240
  url: info.url
@@ -4129,13 +4243,10 @@ function applyImpl(ctx, config) {
4129
4243
  if (registrar !== void 0) announceRelay(registrar, info.url);
4130
4244
  runPostureProbe();
4131
4245
  } else if (info.phase === "starting") {
4132
- rawTunnelUrl = void 0;
4133
- relayUrl = void 0;
4134
- setPublicBase();
4246
+ publicBase.markReconnecting();
4135
4247
  service.setTunnelStatus({ state: "starting" });
4136
4248
  } else if (info.phase === "failed") {
4137
- rawTunnelUrl = void 0;
4138
- setPublicBase();
4249
+ publicBase.markReconnecting();
4139
4250
  service.setTunnelStatus(info.error === void 0 ? { state: "failed" } : {
4140
4251
  state: "failed",
4141
4252
  error: info.error
@@ -4144,6 +4255,7 @@ function applyImpl(ctx, config) {
4144
4255
  });
4145
4256
  ctx.effect(() => () => {
4146
4257
  disposeRelayRegistrar();
4258
+ publicBase.dispose();
4147
4259
  tunnel.dispose();
4148
4260
  }, "remote-web-ui: auto tunnel");
4149
4261
  if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
@@ -4391,15 +4503,17 @@ function applyImpl(ctx, config) {
4391
4503
  }
4392
4504
  const plan = tunnelPlanOf(value, ctx.webServer.port);
4393
4505
  tunnelMode = plan.mode;
4506
+ publicBase.setMode(plan.mode);
4507
+ const liveTunnelUrl = publicBase.quickUrl();
4394
4508
  if (plan.mode !== "quick") {
4395
4509
  disposeRelayRegistrar();
4396
- if (plan.mode !== "named") setPublicBase();
4510
+ if (plan.mode !== "named") publicBase.refresh();
4397
4511
  } else if (value.relay === false) {
4398
4512
  disposeRelayRegistrar(true);
4399
- setPublicBase();
4400
- } else if (rawTunnelUrl !== void 0) {
4513
+ publicBase.refresh();
4514
+ } else if (liveTunnelUrl !== void 0) {
4401
4515
  const registrar = ensureRelayRegistrar();
4402
- if (registrar !== void 0) announceRelay(registrar, rawTunnelUrl);
4516
+ if (registrar !== void 0) announceRelay(registrar, liveTunnelUrl);
4403
4517
  }
4404
4518
  if (plan.mode === "quick") {
4405
4519
  for (const ignored of plan.ignored) console.warn(`remote-web-ui: autoTunnel is on — ignoring the configured ${ignored}`);
@@ -100,6 +100,41 @@ export declare function isUnpairedDenied(response: Response): Promise<boolean>;
100
100
  * @returns a function restoring the originals.
101
101
  */
102
102
  export declare function installRemoteChannel(window: ChannelWindow, options?: RemoteChannelOptions): () => void;
103
+ /** Page global the official pre-Cordis upload hook is published under. */
104
+ export declare const FILE_UPLOAD_HOOK_GLOBAL = "__DSH_FILE_UPLOAD__";
105
+ /** The subset of window the upload hook needs (injectable for tests). */
106
+ export interface UploadHookWindow {
107
+ fetch: typeof globalThis.fetch;
108
+ location: {
109
+ href: string;
110
+ origin: string;
111
+ };
112
+ /** Pre-existing hook owner; left untouched when present. */
113
+ __DSH_FILE_UPLOAD__?: {
114
+ fetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
115
+ };
116
+ }
117
+ /**
118
+ * Publish the official pre-Cordis upload hook so background uploads keep
119
+ * riding the patched main-thread fetch (issue #1580).
120
+ *
121
+ * `@deepseek-ai/dsh-client-file-upload` reads `globalThis.__DSH_FILE_UPLOAD__`
122
+ * once when its runtime is constructed; without it the carrier is a Web
123
+ * Worker, whose own globals no main-thread patch reaches. That worker's XHR
124
+ * goes straight to `<origin>/api/session/uploadFileBinary` with neither the
125
+ * `/remote` rewrite nor the cookieless device credential, so the harness
126
+ * browser-auth fence answers 401 and every upload from a paired browser
127
+ * fails. Rewriting the worker URL cannot fix it: a worker context carries
128
+ * neither the pairing cookie nor the device header.
129
+ *
130
+ * The hook hands the runtime the same transport the rest of the page uses -
131
+ * the boot script publishes an identical one (remote-channel-boot.ts), and
132
+ * this is the fallback for pages served without it.
133
+ *
134
+ * @param window - the browser window (or a test double), BEFORE the channel patch.
135
+ * @returns a function retiring the hook (a pre-existing one is left alone).
136
+ */
137
+ export declare function installFileUploadHook(window: UploadHookWindow): () => void;
103
138
  /**
104
139
  * The remote-channel lifecycle transition between two steady states:
105
140
  * running (active + installed) and retired (inactive + not installed).
@@ -1 +1 @@
1
- {"version":3,"file":"remote-channel.d.ts","sourceRoot":"","sources":["../../../src/client/remote-channel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EACL,iBAAiB,EAEjB,aAAa,EACd,MAAM,4BAA4B,CAAA;AAEnC,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,CAAA;AAC3C,YAAY,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAA;AACvE,OAAO,EAAE,0BAA0B,EAAE,MAAM,4BAA4B,CAAA;AAIvE,qEAAqE;AACrE,MAAM,WAAW,6BAA6B;IAC5C,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,aAAa,GAAG,MAAM,CAAA;IACpD,KAAK,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,oBAAoB,CAAC,EAAE,OAAO,CAAA;KAAE,CAAA;CAC9D;AAED,qFAAqF;AACrF,wBAAgB,qBAAqB,CACnC,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,6BAA6B,EACvC,iBAAiB,EAAE,OAAO,GAAG,SAAS,GACrC,OAAO,CAQT;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI5D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAShE;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED,yEAAyE;AACzE,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAcnF;AAED,wEAAwE;AACxE,UAAU,cAAc;IACtB,SAAS,EAAE,MAAM,CAAA;CAClB;AAED,qEAAqE;AACrE,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;IAC9B,SAAS,EAAE,OAAO,SAAS,CAAA;IAC3B,WAAW,CAAC,EAAE,OAAO,WAAW,CAAA;IAChC,gBAAgB,CAAC,EAAE,cAAc,CAAA;IACjC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,iBAAiB,CAAC,EAAE,cAAc,CAAA;IAClC,QAAQ,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1C,cAAc,CAAC,EAAE;QAAE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAA;KAAE,CAAA;CACzD;AAED,gDAAgD;AAChD,MAAM,WAAW,oBAAoB;IACnC,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAA;CACtB;AAoBD;;;GAGG;AACH,wBAAsB,gBAAgB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC,CAO3E;AA0BD;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,GAAE,oBAAyB,GAAG,MAAM,IAAI,CAmG1G;AAED;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,SAAS,GAAG,QAAQ,GAAG,MAAM,CAAA;AAE7D;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,iBAAiB,CAIxF"}
1
+ {"version":3,"file":"remote-channel.d.ts","sourceRoot":"","sources":["../../../src/client/remote-channel.ts"],"names":[],"mappings":"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"}
@@ -172,6 +172,9 @@ export function installRemoteChannel(window, options = {}) {
172
172
  const originalFetch = window.fetch;
173
173
  const OriginalWebSocket = window.WebSocket;
174
174
  const OriginalEventSource = window.EventSource;
175
+ // Publish the official upload hook before the file-upload service reads it
176
+ // (issue #1580); removed again by the restore below.
177
+ const restoreUploadHook = installFileUploadHook(window);
175
178
  const sameOrigin = (url) => url.origin === window.location.origin;
176
179
  const rewrite = (raw) => rewriteRawUrl(raw, window.location.href, window.location.origin);
177
180
  // The cookieless device credential (set by the /pair-app capture script):
@@ -266,6 +269,58 @@ export function installRemoteChannel(window, options = {}) {
266
269
  window.EventSource = OriginalEventSource;
267
270
  for (const restore of restoreSrc)
268
271
  restore();
272
+ restoreUploadHook();
273
+ };
274
+ }
275
+ /** Page global the official pre-Cordis upload hook is published under. */
276
+ export const FILE_UPLOAD_HOOK_GLOBAL = '__DSH_FILE_UPLOAD__';
277
+ /**
278
+ * Publish the official pre-Cordis upload hook so background uploads keep
279
+ * riding the patched main-thread fetch (issue #1580).
280
+ *
281
+ * `@deepseek-ai/dsh-client-file-upload` reads `globalThis.__DSH_FILE_UPLOAD__`
282
+ * once when its runtime is constructed; without it the carrier is a Web
283
+ * Worker, whose own globals no main-thread patch reaches. That worker's XHR
284
+ * goes straight to `<origin>/api/session/uploadFileBinary` with neither the
285
+ * `/remote` rewrite nor the cookieless device credential, so the harness
286
+ * browser-auth fence answers 401 and every upload from a paired browser
287
+ * fails. Rewriting the worker URL cannot fix it: a worker context carries
288
+ * neither the pairing cookie nor the device header.
289
+ *
290
+ * The hook hands the runtime the same transport the rest of the page uses -
291
+ * the boot script publishes an identical one (remote-channel-boot.ts), and
292
+ * this is the fallback for pages served without it.
293
+ *
294
+ * @param window - the browser window (or a test double), BEFORE the channel patch.
295
+ * @returns a function retiring the hook (a pre-existing one is left alone).
296
+ */
297
+ export function installFileUploadHook(window) {
298
+ if (window.__DSH_FILE_UPLOAD__ !== undefined)
299
+ return () => { };
300
+ const originalFetch = window.fetch;
301
+ const hook = {
302
+ fetch: (input, init) => {
303
+ const raw = typeof input === 'string' ? input : input.href;
304
+ let url;
305
+ try {
306
+ url = new URL(raw, window.location.href);
307
+ }
308
+ catch {
309
+ return originalFetch.call(window, input, init);
310
+ }
311
+ // Delegate the whole decision to the patched fetch: rewriting the path
312
+ // here first would make it skip its own rewrite branch and drop the
313
+ // device credential the fence requires.
314
+ if (url.origin === window.location.origin && url.pathname === RULES.uploadPath) {
315
+ return window.fetch.call(window, raw, init);
316
+ }
317
+ return originalFetch.call(window, input, init);
318
+ },
319
+ };
320
+ window.__DSH_FILE_UPLOAD__ = hook;
321
+ return () => {
322
+ if (window.__DSH_FILE_UPLOAD__ === hook)
323
+ delete window.__DSH_FILE_UPLOAD__;
269
324
  };
270
325
  }
271
326
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"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"}
@@ -27,6 +27,7 @@ import { loadRelayIdentity, RelayRegistrar } from "./relay-registry.js";
27
27
  import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf } from "./lan-bind-plan.js";
28
28
  import { createInnerAuth } from "./inner-auth.js";
29
29
  import { TunnelManager } from "./tunnel.js";
30
+ import { PublicBaseKeeper } from "./public-base.js";
30
31
  import { checkUpdates, fetchGitHubReleaseNotes, fetchLatestVersion, RELEASE_NOTES_CACHE_TTL_MS, resolveAnchorManifest, resolveUpdateTarget, runUpdateVerified, } from "./update.js";
31
32
  import { makeUpdateRoutes } from "./update-routes.js";
32
33
  import { mountOnce } from "./mount-once.js";
@@ -162,17 +163,18 @@ function applyImpl(ctx, config) {
162
163
  // raw quick URL is used, exactly as before. Named tunnels keep their fixed
163
164
  // dashboard hostname and never touch the relay.
164
165
  let relayRegistrar;
165
- let relayUrl;
166
- let rawTunnelUrl;
166
+ /**
167
+ * The public base the pairing fence trusts. A tunnel reconnect must not
168
+ * strip the host the QR still shows: named and relay hosts never change,
169
+ * and a quick tunnel's host survives a bounded grace window (issue #1547).
170
+ */
171
+ const publicBase = new PublicBaseKeeper((base) => { service.setPublicBaseUrl(base); });
167
172
  /** The tunnel target the registrar last announced (dedupes sync re-runs). */
168
173
  let relayAnnouncedFor;
169
- const setPublicBase = () => {
170
- service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl);
171
- };
172
174
  const disposeRelayRegistrar = (unregister = false) => {
173
175
  const registrar = relayRegistrar;
174
176
  relayRegistrar = undefined;
175
- relayUrl = undefined;
177
+ publicBase.setRelay(undefined);
176
178
  relayAnnouncedFor = undefined;
177
179
  if (registrar === undefined)
178
180
  return;
@@ -193,10 +195,10 @@ function applyImpl(ctx, config) {
193
195
  relayRegistrar = new RelayRegistrar(identity, (state) => {
194
196
  service.setRelayStatus(state.state === 'off' ? undefined : state);
195
197
  if (state.state === 'running') {
196
- relayUrl = state.url;
198
+ publicBase.setRelay(state.url);
197
199
  }
198
200
  else if (state.state === 'off') {
199
- relayUrl = undefined;
201
+ publicBase.setRelay(undefined);
200
202
  }
201
203
  else if (state.state === 'failed') {
202
204
  // Keep the last relay URL on failures: the phone origin only
@@ -204,7 +206,6 @@ function applyImpl(ctx, config) {
204
206
  // refresh call fails. The registrar retries with backoff.
205
207
  console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`);
206
208
  }
207
- setPublicBase();
208
209
  });
209
210
  }
210
211
  catch (error) {
@@ -226,8 +227,7 @@ function applyImpl(ctx, config) {
226
227
  if (tunnelMode === 'off')
227
228
  return;
228
229
  if (info.phase === 'running' && info.url !== undefined) {
229
- rawTunnelUrl = info.url;
230
- setPublicBase();
230
+ publicBase.markRunning(info.url);
231
231
  service.setTunnelStatus({ state: 'running', url: info.url });
232
232
  const registrar = tunnelMode === 'quick' ? ensureRelayRegistrar() : undefined;
233
233
  if (registrar !== undefined)
@@ -235,21 +235,23 @@ function applyImpl(ctx, config) {
235
235
  runPostureProbe();
236
236
  }
237
237
  else if (info.phase === 'starting') {
238
- // A restart mints a NEW hostname: the previous URL dies with the old
239
- // process, so clear it now rather than advertising a dead link.
240
- rawTunnelUrl = undefined;
241
- relayUrl = undefined;
242
- setPublicBase();
238
+ // A quick-tunnel restart mints a new hostname, but the old one is not
239
+ // dropped at once: the edge may still deliver a connection the phone
240
+ // already opened, and the reconnect usually lands inside the grace
241
+ // window (issue #1547). A named tunnel keeps its fixed hostname, and a
242
+ // registered relay its stable subdomain, so neither is ever dropped
243
+ // here — the previous code cleared the relay base on every restart.
244
+ publicBase.markReconnecting();
243
245
  service.setTunnelStatus({ state: 'starting' });
244
246
  }
245
247
  else if (info.phase === 'failed') {
246
- rawTunnelUrl = undefined;
247
- setPublicBase();
248
+ publicBase.markReconnecting();
248
249
  service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error });
249
250
  }
250
251
  });
251
252
  ctx.effect(() => () => {
252
253
  disposeRelayRegistrar();
254
+ publicBase.dispose();
253
255
  tunnel.dispose();
254
256
  }, 'remote-web-ui: auto tunnel');
255
257
  // The bind facts are known by now (webServer is an inject edge): the LAN
@@ -613,26 +615,28 @@ function applyImpl(ctx, config) {
613
615
  // publicBaseUrl applies only when no tunnel runs.
614
616
  const plan = tunnelPlanOf(value, ctx.webServer.port);
615
617
  tunnelMode = plan.mode;
618
+ publicBase.setMode(plan.mode);
619
+ const liveTunnelUrl = publicBase.quickUrl();
616
620
  if (plan.mode !== 'quick') {
617
621
  // The relay only fronts the quick tunnel; named mode owns its fixed
618
622
  // dashboard hostname and the off mode has no public base at all.
619
623
  disposeRelayRegistrar();
620
624
  if (plan.mode !== 'named')
621
- setPublicBase();
625
+ publicBase.refresh();
622
626
  }
623
627
  else if (value.relay === false) {
624
628
  // The relay toggle is off: no stable origin, the raw quick URL is the
625
629
  // QR base exactly as before the relay existed.
626
630
  disposeRelayRegistrar(true);
627
- setPublicBase();
631
+ publicBase.refresh();
628
632
  }
629
- else if (rawTunnelUrl !== undefined) {
633
+ else if (liveTunnelUrl !== undefined) {
630
634
  // The relay just turned on (or the registrar is new) while the tunnel
631
635
  // already runs: announce now — no phase event will fire for an
632
636
  // unchanged target.
633
637
  const registrar = ensureRelayRegistrar();
634
638
  if (registrar !== undefined)
635
- announceRelay(registrar, rawTunnelUrl);
639
+ announceRelay(registrar, liveTunnelUrl);
636
640
  }
637
641
  if (plan.mode === 'quick') {
638
642
  for (const ignored of plan.ignored) {
@@ -0,0 +1,67 @@
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 declare const PUBLIC_BASE_GRACE_MS = 60000;
19
+ /** Tunnel ownership of the public base, as resolved from the settings document. */
20
+ export type TunnelBaseMode = 'off' | 'quick' | 'named';
21
+ /**
22
+ * Owns the trusted public base across tunnel and relay transitions.
23
+ *
24
+ * The keeper never decides on its own to widen trust: it only postpones
25
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
26
+ * already published, and it forgets it once the tunnel comes back with the
27
+ * address that actually serves the QR.
28
+ */
29
+ export declare class PublicBaseKeeper {
30
+ private readonly onChange;
31
+ private readonly graceMs;
32
+ private mode;
33
+ private tunnelUrl;
34
+ private relayUrl;
35
+ private graceTimer;
36
+ /**
37
+ * @param onChange - receives the base the fence should trust from now on.
38
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
39
+ */
40
+ constructor(onChange: (base: string | undefined) => void, graceMs?: number);
41
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
42
+ current(): string | undefined;
43
+ /** The mode that currently owns the public base. */
44
+ setMode(mode: TunnelBaseMode): void;
45
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
46
+ quickUrl(): string | undefined;
47
+ /** A relay registration result; undefined withdraws the stable origin. */
48
+ setRelay(url: string | undefined): void;
49
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
50
+ markRunning(url: string): void;
51
+ /**
52
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
53
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
54
+ * trusted for the grace window, then is dropped if nothing replaced it.
55
+ */
56
+ markReconnecting(): void;
57
+ /** Re-publish the current base after a branch change that did not move it. */
58
+ refresh(): void;
59
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
60
+ reset(): void;
61
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
62
+ dispose(): void;
63
+ private startGrace;
64
+ private cancelGrace;
65
+ private emit;
66
+ }
67
+ //# sourceMappingURL=public-base.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"public-base.d.ts","sourceRoot":"","sources":["../../src/public-base.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,QAAS,CAAA;AAE1C,mFAAmF;AACnF,MAAM,MAAM,cAAc,GAAG,KAAK,GAAG,OAAO,GAAG,OAAO,CAAA;AAEtD;;;;;;;GAOG;AACH,qBAAa,gBAAgB;IAWzB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,OAAO;IAX1B,OAAO,CAAC,IAAI,CAAwB;IACpC,OAAO,CAAC,SAAS,CAAoB;IACrC,OAAO,CAAC,QAAQ,CAAoB;IACpC,OAAO,CAAC,UAAU,CAA2C;IAE7D;;;OAGG;gBAEgB,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,EAC5C,OAAO,GAAE,MAA6B;IAGzD,8EAA8E;IAC9E,OAAO,IAAI,MAAM,GAAG,SAAS;IAI7B,oDAAoD;IACpD,OAAO,CAAC,IAAI,EAAE,cAAc,GAAG,IAAI;IAInC,+EAA+E;IAC/E,QAAQ,IAAI,MAAM,GAAG,SAAS;IAI9B,0EAA0E;IAC1E,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAKvC,uEAAuE;IACvE,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAM9B;;;;OAIG;IACH,gBAAgB,IAAI,IAAI;IAKxB,8EAA8E;IAC9E,OAAO,IAAI,IAAI;IAIf,4EAA4E;IAC5E,KAAK,IAAI,IAAI;IAOb,sEAAsE;IACtE,OAAO,IAAI,IAAI;IAIf,OAAO,CAAC,UAAU;IAUlB,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,IAAI;CAGb"}