@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
@@ -0,0 +1,141 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/plugin-card-seat.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Family plugin-card seat.
4
+ *
5
+ * A family plugin contributes its settings card to whichever plugin-card seat
6
+ * the running host actually renders:
7
+ *
8
+ * - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
9
+ * group section (this family's own first-level "Web UI plugins" section);
10
+ * - `settings.plugin.item` — the official keyed seat of the harness's
11
+ * `ui-settings-plugins` tab, keyed by the settings namespace the card edits.
12
+ *
13
+ * SEAT SELECTION IS NOT A DECLARATION PROBE. The official `ui-settings-plugins`
14
+ * row belongs to the harness bundle and its `configurable` tab always declares
15
+ * `settings.plugin.item` before any external plugin's `apply()` runs, so
16
+ * "is the official seat declared?" answers yes even in the deployment whose
17
+ * whole point is the family group. Choosing on that probe sends every family
18
+ * card to the official Plugins tab and leaves the group's own section
19
+ * permanently empty — the family of reports where the section renders its
20
+ * heading and zero cards.
21
+ *
22
+ * The signal that actually distinguishes the two deployments is whether
23
+ * dsh-web-settings is loaded: it is the package that owns the group section and
24
+ * it publishes the `webUiSettings` service during `apply()`, which every
25
+ * family plugin already reads for its settings scope. Group loaded -> the family
26
+ * seat; group absent -> the official seat.
27
+ *
28
+ * The decision is re-evaluated on every `slots/changed` because the group may
29
+ * apply after this plugin (the family aggregate orders it first, a profile that
30
+ * installs the group separately need not): the initial contribution goes to the
31
+ * official seat, then moves to the family seat the moment the group's section
32
+ * registers. The entry is disposed before the replacement is registered, so a
33
+ * card is never in two seats at once.
34
+ *
35
+ * The shared tree has no client-SDK dependency, so this module reads its
36
+ * context through the structural shape below; callers pass the plugin's own
37
+ * `ctx`.
38
+ */
39
+ /** The family list seat key. */
40
+ export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item';
41
+ /** The official keyed plugin-card seat key. */
42
+ export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item';
43
+ /** The service dsh-web-settings publishes while it is loaded. */
44
+ export const FAMILY_GROUP_SERVICE = 'webUiSettings';
45
+ /**
46
+ * Whether the family group (dsh-web-settings) is loaded in this page. The
47
+ * service is the group package's own contract, so the probe cannot be fooled
48
+ * by a harness release that starts declaring the official seat differently.
49
+ */
50
+ export function familyGroupLoaded(ctx) {
51
+ const get = ctx.get;
52
+ if (typeof get !== 'function')
53
+ return false;
54
+ try {
55
+ return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined;
56
+ }
57
+ catch {
58
+ // A context that refuses the lookup: treat the group as absent, which
59
+ // falls back to the official seat the harness declares.
60
+ return false;
61
+ }
62
+ }
63
+ /** Report a refused registration instead of leaving the user with no card. */
64
+ function warnRefusedSeat(seat, error) {
65
+ try {
66
+ console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error);
67
+ }
68
+ catch {
69
+ // Best-effort console write; a failed warning must not break the plugin.
70
+ }
71
+ }
72
+ /**
73
+ * Contribute one family plugin card to the seat this host renders, following
74
+ * the group if it loads later. The entry is disposed and re-registered on a
75
+ * seat change, never duplicated.
76
+ * @param ctx - client context (its slot registry decides the seat).
77
+ * @param seat - the card contribution.
78
+ */
79
+ export function installPluginCard(ctx, seat) {
80
+ const slots = ctx.slots;
81
+ const component = seat.component;
82
+ const inject = seat.inject;
83
+ let dispose;
84
+ let current;
85
+ /**
86
+ * Re-entrancy latch. The registry emits a change event synchronously from
87
+ * inside both `register` and the previous entry's disposer, so an unguarded
88
+ * reconcile would re-enter itself mid-move and register the card twice into
89
+ * the seat it is leaving ("already has an entry for key ...").
90
+ */
91
+ let reconciling = false;
92
+ /** Reconcile the contribution with the currently live seat (no-op when unchanged). */
93
+ const reconcile = () => {
94
+ if (reconciling)
95
+ return;
96
+ const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT;
97
+ if (current === target)
98
+ return;
99
+ reconciling = true;
100
+ const previous = dispose;
101
+ dispose = undefined;
102
+ current = undefined;
103
+ previous?.();
104
+ try {
105
+ dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
106
+ ? {
107
+ name: FAMILY_PLUGIN_CARD_SEAT,
108
+ id: seat.id,
109
+ ...(seat.order === undefined ? {} : { order: seat.order }),
110
+ ...(seat.label === undefined ? {} : { label: seat.label }),
111
+ locale: seat.locale,
112
+ ...(seat.inject === undefined ? {} : { inject }),
113
+ }
114
+ : {
115
+ name: OFFICIAL_PLUGIN_CARD_SEAT,
116
+ key: seat.namespace,
117
+ locale: seat.locale,
118
+ ...(seat.inject === undefined ? {} : { inject }),
119
+ }), component);
120
+ current = target;
121
+ }
122
+ catch (error) {
123
+ warnRefusedSeat(target, error);
124
+ }
125
+ finally {
126
+ reconciling = false;
127
+ }
128
+ };
129
+ if (typeof ctx.on === 'function') {
130
+ try {
131
+ ctx.on('slots/changed', () => { reconcile(); });
132
+ }
133
+ catch {
134
+ // An event seat that refuses the subscription leaves the initial
135
+ // decision in place; the card still renders in the seat chosen below.
136
+ }
137
+ }
138
+ // The initial decision: the group, when it is already loaded (the aggregate
139
+ // order), otherwise the official seat the harness declares.
140
+ reconcile();
141
+ }
@@ -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":"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,CAuG1G;AAED,0EAA0E;AAC1E,eAAO,MAAM,uBAAuB,wBAAwB,CAAA;AAE5D,yEAAyE;AACzE,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,OAAO,UAAU,CAAC,KAAK,CAAA;IAC9B,QAAQ,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAA;IAC1C,4DAA4D;IAC5D,mBAAmB,CAAC,EAAE;QACpB,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAA;KACtE,CAAA;CACF;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,gBAAgB,GAAG,MAAM,IAAI,CAyB1E;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"}
@@ -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
  /**
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Console-output capture shared by the family's Host halves.
3
+ *
4
+ * A child process's stdout/stderr are raw bytes, and on Windows a console
5
+ * program writes the OEM code page (CP936/GBK on a zh-CN system), not UTF-8.
6
+ * Decoding each chunk on its own corrupts the text twice over: the
7
+ * encoding is guessed wrong, and a multi-byte character split across two reads
8
+ * decodes into two replacement characters. This capture accumulates raw bytes
9
+ * under a byte budget and decodes exactly once, at read time.
10
+ */
11
+ /** Bounded byte accumulator for one child process's interleaved stdout/stderr. */
12
+ export interface OutputCapture {
13
+ /** Append one raw chunk (the bytes are copied into the budget). */
14
+ push(chunk: Uint8Array): void;
15
+ /**
16
+ * Decode everything captured so far. Idempotent, so a caller may read the
17
+ * tail both after a failure branch and at settlement.
18
+ */
19
+ read(): string;
20
+ }
21
+ /**
22
+ * Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
23
+ * otherwise the bytes are not UTF-8 at all and the console wrote its own code
24
+ * page, so the candidate that loses the least text is taken.
25
+ * @param bytes - the captured bytes.
26
+ * @returns the decoded text.
27
+ */
28
+ export declare function decodeConsoleBytes(bytes: Uint8Array): string;
29
+ /**
30
+ * Create a capture bounded to a byte budget. Overflow drops from the FRONT
31
+ * (the tail is what carries a failure reason), and the cut never lands inside
32
+ * a character: leading UTF-8 continuation bytes are dropped with it, and a
33
+ * legacy code page's dangling lead byte — indistinguishable from a real byte
34
+ * without knowing the encoding until the whole buffer is decoded — is removed
35
+ * with the one replacement character it produced, so an over-long stream still
36
+ * reads cleanly at the seam.
37
+ * @param maxBytes - maximum retained bytes.
38
+ * @returns the capture.
39
+ */
40
+ export declare function createOutputCapture(maxBytes: number): OutputCapture;
41
+ //# sourceMappingURL=console-output.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"console-output.d.ts","sourceRoot":"","sources":["../../src/console-output.ts"],"names":[],"mappings":"AACA;;;;;;;;;GASG;AAEH,kFAAkF;AAClF,MAAM,WAAW,aAAa;IAC5B,mEAAmE;IACnE,IAAI,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAAA;IAC7B;;;OAGG;IACH,IAAI,IAAI,MAAM,CAAA;CACf;AAWD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CA2B5D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,CAiBnE"}
@@ -0,0 +1,89 @@
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
+ /** Count the U+FFFD replacement characters one candidate decode produced. */
13
+ function replacementCount(text) {
14
+ let count = 0;
15
+ for (const character of text) {
16
+ if (character === '\uFFFD')
17
+ count += 1;
18
+ }
19
+ return count;
20
+ }
21
+ /**
22
+ * Decode one console byte buffer. Strict UTF-8 wins whenever it is valid;
23
+ * otherwise the bytes are not UTF-8 at all and the console wrote its own code
24
+ * page, so the candidate that loses the least text is taken.
25
+ * @param bytes - the captured bytes.
26
+ * @returns the decoded text.
27
+ */
28
+ export function decodeConsoleBytes(bytes) {
29
+ try {
30
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
31
+ }
32
+ catch {
33
+ // Not valid UTF-8. Prefer whichever candidate leaves the fewest
34
+ // replacement characters so real UTF-8 text carrying one bad byte is not
35
+ // rewritten wholesale as GBK gibberish; without the legacy code page
36
+ // (small-ICU builds) the lenient UTF-8 pass is all that is left.
37
+ let best;
38
+ let bestLoss = Number.POSITIVE_INFINITY;
39
+ for (const label of ['gbk', 'utf-8']) {
40
+ let text;
41
+ try {
42
+ text = new TextDecoder(label).decode(bytes);
43
+ }
44
+ catch {
45
+ continue;
46
+ }
47
+ const loss = replacementCount(text);
48
+ if (loss < bestLoss) {
49
+ best = text;
50
+ bestLoss = loss;
51
+ }
52
+ }
53
+ /* v8 ignore next 3 -- reachable only without the legacy code page AND without the lenient pass */
54
+ if (best === undefined)
55
+ return Buffer.from(bytes).toString('utf8');
56
+ return best;
57
+ }
58
+ }
59
+ /**
60
+ * Create a capture bounded to a byte budget. Overflow drops from the FRONT
61
+ * (the tail is what carries a failure reason), and the cut never lands inside
62
+ * a character: leading UTF-8 continuation bytes are dropped with it, and a
63
+ * legacy code page's dangling lead byte — indistinguishable from a real byte
64
+ * without knowing the encoding until the whole buffer is decoded — is removed
65
+ * with the one replacement character it produced, so an over-long stream still
66
+ * reads cleanly at the seam.
67
+ * @param maxBytes - maximum retained bytes.
68
+ * @returns the capture.
69
+ */
70
+ export function createOutputCapture(maxBytes) {
71
+ let buffer = Buffer.alloc(0);
72
+ let trimmed = false;
73
+ return {
74
+ push(chunk) {
75
+ buffer = buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([buffer, Buffer.from(chunk)]);
76
+ if (buffer.length <= maxBytes)
77
+ return;
78
+ let start = buffer.length - maxBytes;
79
+ while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
80
+ start += 1;
81
+ buffer = Buffer.from(buffer.subarray(start));
82
+ trimmed = true;
83
+ },
84
+ read() {
85
+ const text = decodeConsoleBytes(buffer);
86
+ return trimmed && text.startsWith('\uFFFD') ? text.slice(1) : text;
87
+ },
88
+ };
89
+ }
@@ -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"}