@linxin666/dsh-remote-web-ui 0.3.21 → 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/README.i18n.yaml +2 -2
- package/README.md +4 -0
- package/README.zh.md +4 -0
- package/lib/client.js +47 -4
- package/lib/client.js.map +1 -1
- package/lib/index.js +138 -24
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/remote-channel.d.ts +35 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +55 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +26 -22
- package/lib/types/public-base.d.ts +67 -0
- package/lib/types/public-base.d.ts.map +1 -0
- package/lib/types/public-base.js +110 -0
- package/lib/types/remote-channel-boot.d.ts +5 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +28 -2
- package/lib/types/remote-channel-rules.d.ts +4 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +2 -0
- package/package.json +1 -1
- package/src/client/locales.ts +2 -2
- package/src/client/remote-channel.ts +64 -0
- package/src/index.ts +26 -22
- package/src/public-base.ts +122 -0
- package/src/remote-channel-boot.ts +28 -2
- package/src/remote-channel-rules.ts +6 -0
|
@@ -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.
|
|
21
|
-
*
|
|
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
|
|
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.
|
|
21
|
-
*
|
|
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;
|
|
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.
|
|
4
|
+
"version": "0.3.23",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/locales.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const zh = {
|
|
|
15
15
|
'status.disconnected': '已配对设备离线',
|
|
16
16
|
'status.stopped': '已停止远程访问',
|
|
17
17
|
'status.lanRequired': '此功能需要局域网绑定或公网地址才能使用',
|
|
18
|
-
'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1
|
|
18
|
+
'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在「设置 → Web 插件 → 远程访问设置」卡片中打开“局域网访问”,或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
|
|
19
19
|
'status.loopbackRequired': '配对面板仅限本机使用',
|
|
20
20
|
'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
|
|
21
21
|
'status.unreachable': '无法连接配对服务',
|
|
@@ -189,7 +189,7 @@ export const en = {
|
|
|
189
189
|
'status.disconnected': 'Paired devices offline',
|
|
190
190
|
'status.stopped': 'Remote access stopped',
|
|
191
191
|
'status.lanRequired': 'This feature needs a LAN bind or a public address',
|
|
192
|
-
'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in the settings card
|
|
192
|
+
'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in the settings card under Settings → Web Plugins → Remote access, or restart with dsh web --host 0.0.0.0, or set the tunneled public address.',
|
|
193
193
|
'status.loopbackRequired': 'The pairing panel works on this machine only',
|
|
194
194
|
'status.loopbackRequiredHint': 'Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.',
|
|
195
195
|
'status.unreachable': 'Cannot reach the pairing service',
|
|
@@ -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
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
+
publicBase.setRelay(state.url)
|
|
375
377
|
} else if (state.state === 'off') {
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
781
|
-
} else if (
|
|
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,
|
|
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.
|
|
21
|
-
*
|
|
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. */
|