@linxin666/dsh-remote-web-ui 0.3.22 → 0.3.24
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 +193 -20
- package/lib/client.js.map +1 -1
- package/lib/index.js +181 -34
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +13 -18
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +40 -6
- package/lib/types/client/plugin-card-seat.d.ts +97 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +141 -0
- 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/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -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/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +5 -12
- package/package.json +1 -1
- package/src/client/index.ts +13 -17
- package/src/client/mobile-adapt.ts +39 -5
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/client/remote-channel.ts +64 -0
- package/src/console-output.ts +97 -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
- package/src/update.ts +5 -10
|
@@ -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
|
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Generated by scripts/sync-shared.mjs from shared/host/console-output.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
|
|
2
|
+
/**
|
|
3
|
+
* Console-output capture shared by the family's Host halves.
|
|
4
|
+
*
|
|
5
|
+
* A child process's stdout/stderr are raw bytes, and on Windows a console
|
|
6
|
+
* program writes the OEM code page (CP936/GBK on a zh-CN system), not UTF-8.
|
|
7
|
+
* Decoding each chunk on its own corrupts the text twice over: the
|
|
8
|
+
* encoding is guessed wrong, and a multi-byte character split across two reads
|
|
9
|
+
* decodes into two replacement characters. This capture accumulates raw bytes
|
|
10
|
+
* under a byte budget and decodes exactly once, at read time.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Bounded byte accumulator for one child process's interleaved stdout/stderr. */
|
|
14
|
+
export interface OutputCapture {
|
|
15
|
+
/** Append one raw chunk (the bytes are copied into the budget). */
|
|
16
|
+
push(chunk: Uint8Array): void
|
|
17
|
+
/**
|
|
18
|
+
* Decode everything captured so far. Idempotent, so a caller may read the
|
|
19
|
+
* tail both after a failure branch and at settlement.
|
|
20
|
+
*/
|
|
21
|
+
read(): string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Count the U+FFFD replacement characters one candidate decode produced. */
|
|
25
|
+
function replacementCount(text: string): number {
|
|
26
|
+
let count = 0
|
|
27
|
+
for (const character of text) {
|
|
28
|
+
if (character === '\uFFFD') count += 1
|
|
29
|
+
}
|
|
30
|
+
return count
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
|
|
35
|
+
* otherwise the bytes are not UTF-8 at all and the console wrote its own code
|
|
36
|
+
* page, so the candidate that loses the least text is taken.
|
|
37
|
+
* @param bytes - the captured bytes.
|
|
38
|
+
* @returns the decoded text.
|
|
39
|
+
*/
|
|
40
|
+
export function decodeConsoleBytes(bytes: Uint8Array): string {
|
|
41
|
+
try {
|
|
42
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
43
|
+
} catch {
|
|
44
|
+
// Not valid UTF-8. Prefer whichever candidate leaves the fewest
|
|
45
|
+
// replacement characters so real UTF-8 text carrying one bad byte is not
|
|
46
|
+
// rewritten wholesale as GBK gibberish; without the legacy code page
|
|
47
|
+
// (small-ICU builds) the lenient UTF-8 pass is all that is left.
|
|
48
|
+
let best: string | undefined
|
|
49
|
+
let bestLoss = Number.POSITIVE_INFINITY
|
|
50
|
+
for (const label of ['gbk', 'utf-8']) {
|
|
51
|
+
let text: string
|
|
52
|
+
try {
|
|
53
|
+
text = new TextDecoder(label).decode(bytes)
|
|
54
|
+
} catch {
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
const loss = replacementCount(text)
|
|
58
|
+
if (loss < bestLoss) {
|
|
59
|
+
best = text
|
|
60
|
+
bestLoss = loss
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
|
|
64
|
+
if (best === undefined) return Buffer.from(bytes).toString('utf8')
|
|
65
|
+
return best
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create a capture bounded to a byte budget. Overflow drops from the FRONT
|
|
71
|
+
* (the tail is what carries a failure reason), and the cut never lands inside
|
|
72
|
+
* a character: leading UTF-8 continuation bytes are dropped with it, and a
|
|
73
|
+
* legacy code page's dangling lead byte — indistinguishable from a real byte
|
|
74
|
+
* without knowing the encoding until the whole buffer is decoded — is removed
|
|
75
|
+
* with the one replacement character it produced, so an over-long stream still
|
|
76
|
+
* reads cleanly at the seam.
|
|
77
|
+
* @param maxBytes - maximum retained bytes.
|
|
78
|
+
* @returns the capture.
|
|
79
|
+
*/
|
|
80
|
+
export function createOutputCapture(maxBytes: number): OutputCapture {
|
|
81
|
+
let buffer = Buffer.alloc(0)
|
|
82
|
+
let trimmed = false
|
|
83
|
+
return {
|
|
84
|
+
push(chunk: Uint8Array): void {
|
|
85
|
+
buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)])
|
|
86
|
+
if (buffer.length <= maxBytes) return
|
|
87
|
+
let start = buffer.length - maxBytes
|
|
88
|
+
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1
|
|
89
|
+
buffer = Buffer.from(buffer.subarray(start))
|
|
90
|
+
trimmed = true
|
|
91
|
+
},
|
|
92
|
+
read(): string {
|
|
93
|
+
const text = decodeConsoleBytes(buffer)
|
|
94
|
+
return trimmed && text.startsWith('\uFFFD') ? text.slice(1) : text
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
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. */
|
package/src/update.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
|
+
import { decodeConsoleBytes } from './console-output.ts'
|
|
15
16
|
|
|
16
17
|
/** npm registry base used for version probes. */
|
|
17
18
|
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
@@ -573,18 +574,12 @@ const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|
|
|
|
573
574
|
/**
|
|
574
575
|
* Decode process output with tolerant decoding:
|
|
575
576
|
* Defaults to UTF-8; if invalid byte sequences are encountered (common on
|
|
576
|
-
* Windows consoles using code page 936/GBK), falls back to
|
|
577
|
+
* Windows consoles using code page 936/GBK), falls back to the code page that
|
|
578
|
+
* loses the least text. Bytes are decoded once per whole chunk by the caller's
|
|
579
|
+
* accumulator — decoding per \`data\` event would split multi-byte characters.
|
|
577
580
|
*/
|
|
578
581
|
function decodeProcessChunk(chunk: Buffer): string {
|
|
579
|
-
|
|
580
|
-
return new TextDecoder('utf-8', { fatal: true }).decode(chunk)
|
|
581
|
-
} catch {
|
|
582
|
-
try {
|
|
583
|
-
return new TextDecoder('gbk').decode(chunk)
|
|
584
|
-
} catch {
|
|
585
|
-
return chunk.toString('utf8')
|
|
586
|
-
}
|
|
587
|
-
}
|
|
582
|
+
return decodeConsoleBytes(chunk)
|
|
588
583
|
}
|
|
589
584
|
|
|
590
585
|
/**
|