@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.
Files changed (43) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -0
  3. package/README.zh.md +4 -0
  4. package/lib/client.js +193 -20
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +181 -34
  7. package/lib/types/client/index.d.ts.map +1 -1
  8. package/lib/types/client/index.js +13 -18
  9. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  10. package/lib/types/client/mobile-adapt.js +40 -6
  11. package/lib/types/client/plugin-card-seat.d.ts +97 -0
  12. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  13. package/lib/types/client/plugin-card-seat.js +141 -0
  14. package/lib/types/client/remote-channel.d.ts +35 -0
  15. package/lib/types/client/remote-channel.d.ts.map +1 -1
  16. package/lib/types/client/remote-channel.js +55 -0
  17. package/lib/types/console-output.d.ts +41 -0
  18. package/lib/types/console-output.d.ts.map +1 -0
  19. package/lib/types/console-output.js +89 -0
  20. package/lib/types/index.d.ts.map +1 -1
  21. package/lib/types/index.js +26 -22
  22. package/lib/types/public-base.d.ts +67 -0
  23. package/lib/types/public-base.d.ts.map +1 -0
  24. package/lib/types/public-base.js +110 -0
  25. package/lib/types/remote-channel-boot.d.ts +5 -2
  26. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  27. package/lib/types/remote-channel-boot.js +28 -2
  28. package/lib/types/remote-channel-rules.d.ts +4 -0
  29. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  30. package/lib/types/remote-channel-rules.js +2 -0
  31. package/lib/types/update.d.ts.map +1 -1
  32. package/lib/types/update.js +5 -12
  33. package/package.json +1 -1
  34. package/src/client/index.ts +13 -17
  35. package/src/client/mobile-adapt.ts +39 -5
  36. package/src/client/plugin-card-seat.ts +185 -0
  37. package/src/client/remote-channel.ts +64 -0
  38. package/src/console-output.ts +97 -0
  39. package/src/index.ts +26 -22
  40. package/src/public-base.ts +122 -0
  41. package/src/remote-channel-boot.ts +28 -2
  42. package/src/remote-channel-rules.ts +6 -0
  43. 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
- let relayUrl: string | undefined
347
- let rawTunnelUrl: string | undefined
347
+ /**
348
+ * The public base the pairing fence trusts. A tunnel reconnect must not
349
+ * strip the host the QR still shows: named and relay hosts never change,
350
+ * and a quick tunnel's host survives a bounded grace window (issue #1547).
351
+ */
352
+ const publicBase = new PublicBaseKeeper((base) => { service.setPublicBaseUrl(base) })
348
353
  /** The tunnel target the registrar last announced (dedupes sync re-runs). */
349
354
  let relayAnnouncedFor: string | undefined
350
- const setPublicBase = (): void => {
351
- service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl)
352
- }
353
355
  const disposeRelayRegistrar = (unregister: boolean = false): void => {
354
356
  const registrar = relayRegistrar
355
357
  relayRegistrar = undefined
356
- relayUrl = undefined
358
+ publicBase.setRelay(undefined)
357
359
  relayAnnouncedFor = undefined
358
360
  if (registrar === undefined) return
359
361
  // Toggle-off removes the registry row so the stable origin stops
@@ -371,16 +373,15 @@ function applyImpl(ctx: Context, config?: Config): void {
371
373
  relayRegistrar = new RelayRegistrar(identity, (state: RelayState) => {
372
374
  service.setRelayStatus(state.state === 'off' ? undefined : state)
373
375
  if (state.state === 'running') {
374
- relayUrl = state.url
376
+ publicBase.setRelay(state.url)
375
377
  } else if (state.state === 'off') {
376
- relayUrl = undefined
378
+ publicBase.setRelay(undefined)
377
379
  } else if (state.state === 'failed') {
378
380
  // Keep the last relay URL on failures: the phone origin only
379
381
  // breaks when the mapping itself goes stale, not when one
380
382
  // refresh call fails. The registrar retries with backoff.
381
383
  console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`)
382
384
  }
383
- setPublicBase()
384
385
  })
385
386
  } catch (error) {
386
387
  console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`)
@@ -399,27 +400,28 @@ function applyImpl(ctx: Context, config?: Config): void {
399
400
  tunnel.onPhase((info: TunnelInfo) => {
400
401
  if (tunnelMode === 'off') return
401
402
  if (info.phase === 'running' && info.url !== undefined) {
402
- rawTunnelUrl = info.url
403
- setPublicBase()
403
+ publicBase.markRunning(info.url)
404
404
  service.setTunnelStatus({ state: 'running', url: info.url })
405
405
  const registrar = tunnelMode === 'quick' ? ensureRelayRegistrar() : undefined
406
406
  if (registrar !== undefined) announceRelay(registrar, info.url)
407
407
  runPostureProbe()
408
408
  } else if (info.phase === 'starting') {
409
- // A restart mints a NEW hostname: the previous URL dies with the old
410
- // process, so clear it now rather than advertising a dead link.
411
- rawTunnelUrl = undefined
412
- relayUrl = undefined
413
- setPublicBase()
409
+ // A quick-tunnel restart mints a new hostname, but the old one is not
410
+ // dropped at once: the edge may still deliver a connection the phone
411
+ // already opened, and the reconnect usually lands inside the grace
412
+ // window (issue #1547). A named tunnel keeps its fixed hostname, and a
413
+ // registered relay its stable subdomain, so neither is ever dropped
414
+ // here — the previous code cleared the relay base on every restart.
415
+ publicBase.markReconnecting()
414
416
  service.setTunnelStatus({ state: 'starting' })
415
417
  } else if (info.phase === 'failed') {
416
- rawTunnelUrl = undefined
417
- setPublicBase()
418
+ publicBase.markReconnecting()
418
419
  service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error })
419
420
  }
420
421
  })
421
422
  ctx.effect(() => () => {
422
423
  disposeRelayRegistrar()
424
+ publicBase.dispose()
423
425
  tunnel.dispose()
424
426
  }, 'remote-web-ui: auto tunnel')
425
427
  // The bind facts are known by now (webServer is an inject edge): the LAN
@@ -768,22 +770,24 @@ function applyImpl(ctx: Context, config?: Config): void {
768
770
  // publicBaseUrl applies only when no tunnel runs.
769
771
  const plan = tunnelPlanOf(value, ctx.webServer.port)
770
772
  tunnelMode = plan.mode
773
+ publicBase.setMode(plan.mode)
774
+ const liveTunnelUrl = publicBase.quickUrl()
771
775
  if (plan.mode !== 'quick') {
772
776
  // The relay only fronts the quick tunnel; named mode owns its fixed
773
777
  // dashboard hostname and the off mode has no public base at all.
774
778
  disposeRelayRegistrar()
775
- if (plan.mode !== 'named') setPublicBase()
779
+ if (plan.mode !== 'named') publicBase.refresh()
776
780
  } else if (value.relay === false) {
777
781
  // The relay toggle is off: no stable origin, the raw quick URL is the
778
782
  // QR base exactly as before the relay existed.
779
783
  disposeRelayRegistrar(true)
780
- setPublicBase()
781
- } else if (rawTunnelUrl !== undefined) {
784
+ publicBase.refresh()
785
+ } else if (liveTunnelUrl !== undefined) {
782
786
  // The relay just turned on (or the registrar is new) while the tunnel
783
787
  // already runs: announce now — no phase event will fire for an
784
788
  // unchanged target.
785
789
  const registrar = ensureRelayRegistrar()
786
- if (registrar !== undefined) announceRelay(registrar, rawTunnelUrl)
790
+ if (registrar !== undefined) announceRelay(registrar, liveTunnelUrl)
787
791
  }
788
792
  if (plan.mode === 'quick') {
789
793
  for (const ignored of plan.ignored) {
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The public base the pairing fence trusts while a tunnel is not running
3
+ * (issue #1547).
4
+ *
5
+ * The fence reads exactly one public host: `service.publicBaseUrl`. Dropping
6
+ * it the instant the tunnel leaves `running` makes every public request from
7
+ * the address still printed in the QR code answer 403 — including
8
+ * `/api/pair/status`, which exists to be callable before pairing. Two of the
9
+ * three tunnel modes never lose their host: a named tunnel keeps its fixed
10
+ * dashboard hostname, and a registered relay keeps its stable subdomain. A
11
+ * quick tunnel does mint a new hostname on restart, so its previous host is
12
+ * kept only for a bounded grace window: Cloudflare's edge can still deliver a
13
+ * connection the phone already opened, and a mobile network reconnects
14
+ * constantly, so an instant drop turns the phone's retry into a silent 403.
15
+ * @module @linxin666/dsh-remote-web-ui/public-base
16
+ */
17
+
18
+ /** How long a quick-tunnel host stays trusted after the tunnel stops running. */
19
+ export const PUBLIC_BASE_GRACE_MS = 60_000
20
+
21
+ /** Tunnel ownership of the public base, as resolved from the settings document. */
22
+ export type TunnelBaseMode = 'off' | 'quick' | 'named'
23
+
24
+ /**
25
+ * Owns the trusted public base across tunnel and relay transitions.
26
+ *
27
+ * The keeper never decides on its own to widen trust: it only postpones
28
+ * dropping an address the operator (named/relay) or the tunnel (quick) had
29
+ * already published, and it forgets it once the tunnel comes back with the
30
+ * address that actually serves the QR.
31
+ */
32
+ export class PublicBaseKeeper {
33
+ private mode: TunnelBaseMode = 'off'
34
+ private tunnelUrl: string | undefined
35
+ private relayUrl: string | undefined
36
+ private graceTimer: ReturnType<typeof setTimeout> | undefined
37
+
38
+ /**
39
+ * @param onChange - receives the base the fence should trust from now on.
40
+ * @param graceMs - how long a quick-tunnel host outlives its tunnel.
41
+ */
42
+ constructor(
43
+ private readonly onChange: (base: string | undefined) => void,
44
+ private readonly graceMs: number = PUBLIC_BASE_GRACE_MS,
45
+ ) {}
46
+
47
+ /** The base a fresh fence read should trust: the stable relay origin wins. */
48
+ current(): string | undefined {
49
+ return this.relayUrl ?? this.tunnelUrl
50
+ }
51
+
52
+ /** The mode that currently owns the public base. */
53
+ setMode(mode: TunnelBaseMode): void {
54
+ this.mode = mode
55
+ }
56
+
57
+ /** The last quick-tunnel URL, for callers that must announce it downstream. */
58
+ quickUrl(): string | undefined {
59
+ return this.tunnelUrl
60
+ }
61
+
62
+ /** A relay registration result; undefined withdraws the stable origin. */
63
+ setRelay(url: string | undefined): void {
64
+ this.relayUrl = url
65
+ this.emit()
66
+ }
67
+
68
+ /** The tunnel reported a live URL: it supersedes any pending grace. */
69
+ markRunning(url: string): void {
70
+ this.cancelGrace()
71
+ this.tunnelUrl = url
72
+ this.emit()
73
+ }
74
+
75
+ /**
76
+ * The tunnel left `running` (starting again, or failed). A named tunnel's
77
+ * fixed hostname stays trusted; a quick tunnel's previous host stays
78
+ * trusted for the grace window, then is dropped if nothing replaced it.
79
+ */
80
+ markReconnecting(): void {
81
+ if (this.mode === 'named') return
82
+ this.startGrace()
83
+ }
84
+
85
+ /** Re-publish the current base after a branch change that did not move it. */
86
+ refresh(): void {
87
+ this.emit()
88
+ }
89
+
90
+ /** Forget everything (teardown, or a mode change that stops the tunnel). */
91
+ reset(): void {
92
+ this.cancelGrace()
93
+ this.tunnelUrl = undefined
94
+ this.relayUrl = undefined
95
+ this.emit()
96
+ }
97
+
98
+ /** Cancel the pending grace timer; the keeper keeps its last base. */
99
+ dispose(): void {
100
+ this.cancelGrace()
101
+ }
102
+
103
+ private startGrace(): void {
104
+ if (this.tunnelUrl === undefined) return
105
+ if (this.graceTimer !== undefined) return
106
+ this.graceTimer = setTimeout(() => {
107
+ this.graceTimer = undefined
108
+ this.tunnelUrl = undefined
109
+ this.emit()
110
+ }, this.graceMs)
111
+ }
112
+
113
+ private cancelGrace(): void {
114
+ if (this.graceTimer === undefined) return
115
+ clearTimeout(this.graceTimer)
116
+ this.graceTimer = undefined
117
+ }
118
+
119
+ private emit(): void {
120
+ this.onChange(this.current())
121
+ }
122
+ }
@@ -17,8 +17,11 @@
17
17
  * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
18
18
  * connection plugin reads it: the paired remote desktop gets the full
19
19
  * settings/credentials/presets surface, and every call still rides the
20
- * gated /remote channel. The script self-skips on loopback origins and
21
- * never throws.
20
+ * gated /remote channel. It finally publishes the official pre-Cordis
21
+ * upload hook (`__DSH_FILE_UPLOAD__`), because the background upload
22
+ * transport otherwise runs inside a Web Worker whose own globals the
23
+ * main-thread rewrite cannot reach (issue #1580). The script self-skips on
24
+ * loopback origins and never throws.
22
25
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
23
26
  */
24
27
 
@@ -142,6 +145,29 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
142
145
  'Object.defineProperty(C.prototype,"src",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});' +
143
146
  'restores.push(function(){Object.defineProperty(C.prototype,"src",d)})}' +
144
147
  'patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);' +
148
+ // Background uploads must stay on the patched main-thread fetch
149
+ // (issue #1580). Without this hook @deepseek-ai/dsh-client-file-upload
150
+ // runs its carrier in a Web Worker, whose own globals no main-thread
151
+ // patch reaches: the worker's XHR goes straight to <origin>/api/...
152
+ // without the /remote rewrite and without the device credential, so the
153
+ // harness browser-auth fence answers 401 and every upload from a paired
154
+ // browser fails. The hook is the official pre-Cordis seam (the runtime
155
+ // reads it once in its constructor) and rewriting the worker URL is
156
+ // provably insufficient - a worker context carries neither the pairing
157
+ // cookie nor the device header. Hand the runtime the patched fetch
158
+ // instead: it takes the absolute route URL and a RequestInit, and hands
159
+ // both to the patched w.fetch, which owns the /remote rewrite AND the
160
+ // cookieless device header. Delegating the whole decision is deliberate:
161
+ // rewriting the path here first would make w.fetch see an already-gated
162
+ // /remote/api path, skip its own rewrite branch, and drop the device
163
+ // credential that the fence requires.
164
+ 'function uf(u,init){' +
165
+ 'var raw=typeof u==="string"?u:u.href;' +
166
+ 'if(typeof raw!=="string")return of.call(w,u,init||{});' +
167
+ 'var q=new URL(raw,loc.href);' +
168
+ 'if(so(q)&&q.pathname===R.uploadPath)return w.fetch(raw,init||{});' +
169
+ 'return of.call(w,u,init||{})}' +
170
+ 'try{if(w[R.uploadHookGlobal]===undefined)w[R.uploadHookGlobal]={fetch:uf}}catch(e){}' +
145
171
  'seat.restore=function(){' +
146
172
  'w.fetch=of;' +
147
173
  'w.WebSocket=OW;' +
@@ -33,6 +33,10 @@ export interface RemoteChannelRules {
33
33
  readonly deviceKey: string
34
34
  /** Query parameter carrying it on WebSocket upgrades. */
35
35
  readonly deviceQuery: string
36
+ /** Raw upload route the boot hook keeps on the gated channel. */
37
+ readonly uploadPath: string
38
+ /** Page global the pre-Cordis upload hook is published under. */
39
+ readonly uploadHookGlobal: string
36
40
  }
37
41
 
38
42
  /** The live rule set. */
@@ -57,6 +61,8 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
57
61
  deviceHeader: REMOTE_DEVICE_HEADER,
58
62
  deviceKey: 'dsh-remote-device',
59
63
  deviceQuery: REMOTE_DEVICE_QUERY,
64
+ uploadPath: '/api/session/uploadFileBinary',
65
+ uploadHookGlobal: '__DSH_FILE_UPLOAD__',
60
66
  }
61
67
 
62
68
  /** The window global the boot patch publishes its seat under. */
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 GBK via TextDecoder.
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
- try {
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
  /**