@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0

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 (116) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -10
  3. package/README.zh.md +13 -10
  4. package/icon.svg +3 -0
  5. package/lib/client.js +396 -132
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +604 -262
  8. package/lib/types/client/FenceNotice.d.ts.map +1 -1
  9. package/lib/types/client/FenceNotice.js +0 -3
  10. package/lib/types/client/PluginSettingsCard.d.ts +4 -0
  11. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.js +3 -2
  13. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  14. package/lib/types/client/RemoteEntry.js +14 -9
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +13 -4
  17. package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +14 -8
  20. package/lib/types/client/UpdateEntry.js +2 -2
  21. package/lib/types/client/UpdatePanel.js +2 -2
  22. package/lib/types/client/deep-link.js +2 -2
  23. package/lib/types/client/index.d.ts +16 -8
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +56 -32
  26. package/lib/types/client/locales.d.ts +0 -2
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +6 -8
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -1
  30. package/lib/types/client/mobile-adapt.js +143 -28
  31. package/lib/types/client/pair-api.d.ts +32 -3
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +44 -6
  34. package/lib/types/client/plugin-card-seat.d.ts +104 -0
  35. package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
  36. package/lib/types/client/plugin-card-seat.js +143 -0
  37. package/lib/types/client/remote-channel.d.ts.map +1 -1
  38. package/lib/types/client/remote-channel.js +18 -5
  39. package/lib/types/client/settings-form.d.ts +17 -17
  40. package/lib/types/client/settings-form.d.ts.map +1 -1
  41. package/lib/types/client/settings-form.js +30 -27
  42. package/lib/types/console-output.d.ts +41 -0
  43. package/lib/types/console-output.d.ts.map +1 -0
  44. package/lib/types/console-output.js +89 -0
  45. package/lib/types/http.d.ts +18 -1
  46. package/lib/types/http.d.ts.map +1 -1
  47. package/lib/types/http.js +22 -1
  48. package/lib/types/index.d.ts +19 -11
  49. package/lib/types/index.d.ts.map +1 -1
  50. package/lib/types/index.js +91 -76
  51. package/lib/types/inner-auth.d.ts +3 -2
  52. package/lib/types/inner-auth.d.ts.map +1 -1
  53. package/lib/types/inner-auth.js +3 -2
  54. package/lib/types/pair-grant.d.ts +66 -0
  55. package/lib/types/pair-grant.d.ts.map +1 -0
  56. package/lib/types/pair-grant.js +95 -0
  57. package/lib/types/pairing.d.ts +44 -12
  58. package/lib/types/pairing.d.ts.map +1 -1
  59. package/lib/types/pairing.js +69 -20
  60. package/lib/types/pairing.persist.test.js +26 -0
  61. package/lib/types/posture.d.ts +11 -3
  62. package/lib/types/posture.d.ts.map +1 -1
  63. package/lib/types/posture.js +8 -3
  64. package/lib/types/remote-api.d.ts +10 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +34 -34
  67. package/lib/types/remote-channel-boot.d.ts +6 -1
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +16 -8
  70. package/lib/types/remote-channel-rules.d.ts +14 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +8 -0
  73. package/lib/types/remote-methods.d.ts +1 -1
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +1 -0
  76. package/lib/types/routes.d.ts +47 -5
  77. package/lib/types/routes.d.ts.map +1 -1
  78. package/lib/types/routes.js +160 -58
  79. package/lib/types/tunnel.d.ts +21 -6
  80. package/lib/types/tunnel.d.ts.map +1 -1
  81. package/lib/types/tunnel.js +58 -11
  82. package/lib/types/update.d.ts +4 -0
  83. package/lib/types/update.d.ts.map +1 -1
  84. package/lib/types/update.js +74 -33
  85. package/package.json +23 -20
  86. package/src/client/FenceNotice.tsx +0 -2
  87. package/src/client/PluginSettingsCard.tsx +7 -2
  88. package/src/client/RemoteEntry.tsx +14 -9
  89. package/src/client/RemotePanel.tsx +17 -7
  90. package/src/client/RemoteSettingsCard.tsx +16 -9
  91. package/src/client/UpdateEntry.tsx +2 -2
  92. package/src/client/UpdatePanel.tsx +3 -3
  93. package/src/client/deep-link.ts +2 -2
  94. package/src/client/index.ts +70 -40
  95. package/src/client/locales.ts +6 -8
  96. package/src/client/mobile-adapt.ts +136 -25
  97. package/src/client/pair-api.ts +47 -7
  98. package/src/client/plugin-card-seat.ts +192 -0
  99. package/src/client/remote-channel.ts +19 -4
  100. package/src/client/remote.module.css +38 -0
  101. package/src/client/settings-form.ts +34 -31
  102. package/src/console-output.ts +97 -0
  103. package/src/http.ts +23 -1
  104. package/src/index.ts +102 -79
  105. package/src/inner-auth.ts +3 -2
  106. package/src/pair-grant.ts +131 -0
  107. package/src/pairing.persist.test.ts +36 -0
  108. package/src/pairing.ts +75 -24
  109. package/src/posture.ts +12 -4
  110. package/src/remote-api.ts +34 -41
  111. package/src/remote-channel-boot.ts +16 -8
  112. package/src/remote-channel-rules.ts +17 -0
  113. package/src/remote-methods.ts +1 -0
  114. package/src/routes.ts +165 -57
  115. package/src/tunnel.ts +56 -11
  116. package/src/update.ts +74 -29
@@ -53,7 +53,7 @@ export async function readPairGatePolicy(): Promise<PairGatePolicy> {
53
53
  }
54
54
 
55
55
  /** accept() refusal codes. */
56
- export type AcceptFailure = { ok: false; code: 'invalid' | 'used' | 'forbidden' }
56
+ export type AcceptFailure = { ok: false; code: 'invalid' | 'forbidden' }
57
57
 
58
58
  /** One auto-tunnel status frame (absent while the feature is off). */
59
59
  export interface TunnelStatusFrame {
@@ -145,8 +145,9 @@ export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFa
145
145
  body: JSON.stringify({ token }),
146
146
  })
147
147
  if (response.ok) return { ok: true }
148
- if (response.status === 404) return { ok: false, code: 'invalid' }
149
- if (response.status === 409) return { ok: false, code: 'used' }
148
+ // 409 is the legacy "already used" answer a pre-bearer-token host could send;
149
+ // it means the same thing to the caller as a 404.
150
+ if (response.status === 404 || response.status === 409) return { ok: false, code: 'invalid' }
150
151
  return { ok: false, code: 'forbidden' }
151
152
  }
152
153
 
@@ -170,9 +171,25 @@ export async function revokePair(deviceId: string): Promise<void> {
170
171
  if (!response.ok) throw new Error(`remote-web-ui: revoke failed with ${String(response.status)}`)
171
172
  }
172
173
 
173
- /** Presence heartbeat from a paired phone (unpaired heartbeats 401 harmlessly). */
174
- export async function sendHeartbeat(): Promise<void> {
175
- await fetch('/api/pair/heartbeat', { method: 'POST' })
174
+ /**
175
+ * Presence heartbeat from a paired phone.
176
+ * @returns the response status, so the caller can stop polling once the server
177
+ * proves this page is not paired (see {@link shouldStopHeartbeat}).
178
+ */
179
+ export async function sendHeartbeat(): Promise<number> {
180
+ const response = await fetch('/api/pair/heartbeat', { method: 'POST' })
181
+ return response.status
182
+ }
183
+
184
+ /**
185
+ * Whether a heartbeat answer means "this page can never be accepted again" and
186
+ * the 10 s wake source should stop: 401 (unpaired, or the device was revoked)
187
+ * and 403 (the fence refused it) are permanent for this page, while a network
188
+ * error or a 5xx is transient and keeps the cadence.
189
+ * @param status - the heartbeat response status.
190
+ */
191
+ export function shouldStopHeartbeat(status: number): boolean {
192
+ return status === 401 || status === 403
176
193
  }
177
194
 
178
195
  /** Whether the current page URL carries a pairing token. */
@@ -210,13 +227,36 @@ export interface LanBindFrame {
210
227
  pendingRestart?: boolean
211
228
  }
212
229
 
230
+ /** A failed LAN-bind read, carrying the HTTP status when the server answered. */
231
+ export class LanBindStatusError extends Error {
232
+ /**
233
+ * @param status - the response status, or undefined when the request failed
234
+ * before a response (network error).
235
+ * @param message - the diagnostic message.
236
+ */
237
+ constructor(public readonly status: number | undefined, message: string) {
238
+ super(message)
239
+ this.name = 'LanBindStatusError'
240
+ }
241
+ }
242
+
213
243
  /** Read the LAN-bind facts (loopback-only endpoint). */
214
244
  export async function readLanBindStatus(): Promise<LanBindFrame> {
215
245
  const response = await fetch('/api/pair/lan-bind')
216
- if (!response.ok) throw new Error(`remote-web-ui: lan-bind status failed with ${String(response.status)}`)
246
+ if (!response.ok) throw new LanBindStatusError(response.status, `remote-web-ui: lan-bind status failed with ${String(response.status)}`)
217
247
  return await response.json() as LanBindFrame
218
248
  }
219
249
 
250
+ /**
251
+ * Whether a LAN-bind status failure means this origin can never read the
252
+ * endpoint (401/403 from the loopback-only fence), so a poll should stop
253
+ * instead of retrying a known refusal; a transient error keeps the cadence.
254
+ * @param status - the response status, or undefined for a network failure.
255
+ */
256
+ export function shouldStopLanBindPoll(status: number | undefined): boolean {
257
+ return status === 401 || status === 403
258
+ }
259
+
220
260
  /** Human-readable expiry clock, e.g. "10:35". */
221
261
  export function formatClock(epochMs: number): string {
222
262
  const date = new Date(epochMs)
@@ -0,0 +1,192 @@
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
+ * - `plugins.bundle.config` — the official keyed seat of the harness's plugin
11
+ * manager page, keyed by the bundle's package name and rendered on that
12
+ * bundle's page. alpha.2 removed the `settings.plugin.item` keyed seat of the
13
+ * `ui-settings-plugins` tab that this helper used before, so a card keyed by
14
+ * its settings namespace has no seat to land in any more.
15
+ *
16
+ * SEAT SELECTION IS NOT A DECLARATION PROBE. The official plugin surface
17
+ * belongs to the harness bundle and declares its seats before any external
18
+ * plugin's `apply()` runs, so "is the official seat declared?" answers yes even
19
+ * in the deployment whose whole point is the family group. Choosing on that
20
+ * probe sends every family card to the official page and leaves the group's own
21
+ * section permanently empty — the family of reports where the section renders
22
+ * its heading and zero cards.
23
+ *
24
+ * The signal that actually distinguishes the two deployments is whether
25
+ * dsh-web-settings is loaded: it is the package that owns the group section and
26
+ * it publishes the `webUiSettings` service during `apply()`, which every
27
+ * family plugin already reads for its settings form. Group loaded -> the family
28
+ * seat; group absent -> the official seat.
29
+ *
30
+ * The decision is re-evaluated on every `slots/changed` because the group may
31
+ * apply after this plugin (the family aggregate orders it first, a profile that
32
+ * installs the group separately need not): the initial contribution goes to the
33
+ * official seat, then moves to the family seat the moment the group's section
34
+ * registers. The entry is disposed before the replacement is registered, so a
35
+ * card is never in two seats at once.
36
+ *
37
+ * The shared tree has no client-SDK dependency, so this module reads its
38
+ * context through the structural shape below; callers pass the plugin's own
39
+ * `ctx`.
40
+ */
41
+
42
+ /** The family list seat key. */
43
+ export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
44
+
45
+ /** The official keyed plugin-card seat key (the alpha.2 bundle-configuration seat). */
46
+ export const OFFICIAL_PLUGIN_CARD_SEAT = 'plugins.bundle.config'
47
+
48
+ /** The service dsh-web-settings publishes while it is loaded. */
49
+ export const FAMILY_GROUP_SERVICE = 'webUiSettings'
50
+
51
+ /** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
52
+ export interface PluginCardSlots {
53
+ /** Contribution of one card entry. */
54
+ register(options: never, component: never): unknown
55
+ }
56
+
57
+ /** The slice of the client context a card contribution needs. */
58
+ export interface PluginCardContext {
59
+ slots: PluginCardSlots
60
+ /** Service lookup; absent on a context double that only models the slots. */
61
+ get?(name: string): unknown
62
+ /** Event subscription seat; absent on an event-less test double. */
63
+ on?(event: string, listener: (...args: never[]) => void): unknown
64
+ }
65
+
66
+ /** Owner share of a family list-seat card (that seat supplies nothing). */
67
+ export interface SettingsPluginItemOwnerProps {
68
+ /** Marker field: card owner props are intentionally empty. */
69
+ children?: never
70
+ }
71
+
72
+ /** One family plugin's card contribution. */
73
+ export interface PluginCardSeat {
74
+ /**
75
+ * Npm package name of the bundle this card configures; the official keyed
76
+ * seat dispatches on it. Every family package ships its own bundle patch, so
77
+ * a standalone install lists that package as the bundle whose page renders
78
+ * this card.
79
+ */
80
+ bundle: string
81
+ /** Family list-seat entry id. */
82
+ id: string
83
+ /** Family list-seat sort order. */
84
+ order?: number
85
+ /** Family list-seat display label; the official keyed seat carries none. */
86
+ label?: () => string
87
+ /** Locale namespace the card renders with. */
88
+ locale: string
89
+ /** Business-face factory of the registration. */
90
+ inject?: () => object
91
+ /**
92
+ * The card component. Its props type is the seat's composed shape, which is
93
+ * chosen at runtime, so the helper takes it erased; the registrant keeps its
94
+ * own precise typing at the call site.
95
+ */
96
+ component: unknown
97
+ }
98
+
99
+ /**
100
+ * Whether the family group (dsh-web-settings) is loaded in this page. The
101
+ * service is the group package's own contract, so the probe cannot be fooled
102
+ * by a harness release that starts declaring the official seat differently.
103
+ */
104
+ export function familyGroupLoaded(ctx: PluginCardContext): boolean {
105
+ const get = ctx.get
106
+ if (typeof get !== 'function') return false
107
+ try {
108
+ return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
109
+ } catch {
110
+ // A context that refuses the lookup: treat the group as absent, which
111
+ // falls back to the official seat the harness declares.
112
+ return false
113
+ }
114
+ }
115
+
116
+ /** Report a refused registration instead of leaving the user with no card. */
117
+ function warnRefusedSeat(seat: string, error: unknown): void {
118
+ try {
119
+ console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
120
+ } catch {
121
+ // Best-effort console write; a failed warning must not break the plugin.
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Contribute one family plugin card to the seat this host renders, following
127
+ * the group if it loads later. The entry is disposed and re-registered on a
128
+ * seat change, never duplicated.
129
+ * @param ctx - client context (its slot registry decides the seat).
130
+ * @param seat - the card contribution.
131
+ */
132
+ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
133
+ const slots = ctx.slots
134
+ const component = seat.component as never
135
+ const inject = seat.inject as never
136
+
137
+ let dispose: (() => void) | undefined
138
+ let current: string | undefined
139
+ /**
140
+ * Re-entrancy latch. The registry emits a change event synchronously from
141
+ * inside both `register` and the previous entry's disposer, so an unguarded
142
+ * reconcile would re-enter itself mid-move and register the card twice into
143
+ * the seat it is leaving ("already has an entry for key ...").
144
+ */
145
+ let reconciling = false
146
+
147
+ /** Reconcile the contribution with the currently live seat (no-op when unchanged). */
148
+ const reconcile = (): void => {
149
+ if (reconciling) return
150
+ const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
151
+ if (current === target) return
152
+ reconciling = true
153
+ const previous = dispose
154
+ dispose = undefined
155
+ current = undefined
156
+ previous?.()
157
+ try {
158
+ dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
159
+ ? {
160
+ name: FAMILY_PLUGIN_CARD_SEAT,
161
+ id: seat.id,
162
+ ...(seat.order === undefined ? {} : { order: seat.order }),
163
+ ...(seat.label === undefined ? {} : { label: seat.label }),
164
+ locale: seat.locale,
165
+ ...(seat.inject === undefined ? {} : { inject }),
166
+ }
167
+ : {
168
+ name: OFFICIAL_PLUGIN_CARD_SEAT,
169
+ key: seat.bundle,
170
+ locale: seat.locale,
171
+ ...(seat.inject === undefined ? {} : { inject }),
172
+ }) as never, component) as () => void
173
+ current = target
174
+ } catch (error) {
175
+ warnRefusedSeat(target, error)
176
+ } finally {
177
+ reconciling = false
178
+ }
179
+ }
180
+
181
+ if (typeof ctx.on === 'function') {
182
+ try {
183
+ ctx.on('slots/changed', () => { reconcile() })
184
+ } catch {
185
+ // An event seat that refuses the subscription leaves the initial
186
+ // decision in place; the card still renders in the seat chosen below.
187
+ }
188
+ }
189
+ // The initial decision: the group, when it is already loaded (the aggregate
190
+ // order), otherwise the official seat the harness declares.
191
+ reconcile()
192
+ }
@@ -62,7 +62,11 @@ export function remoteChannelRequired(
62
62
  * @returns true for localhost, IPv6 loopback, or any 127/8 literal.
63
63
  */
64
64
  export function isLoopbackHostname(hostname: string): boolean {
65
- if (hostname === 'localhost' || hostname === '::1') return true
65
+ // WHATWG location.hostname keeps IPv6 literals bracketed ("[::1]"); the
66
+ // host-side shared predicate accepts that spelling, so this browser copy must
67
+ // too, or an IPv6-loopback origin is judged remote and rewrites every call
68
+ // onto the gated channel where it can never pair.
69
+ if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
66
70
  const parts = hostname.split('.')
67
71
  return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
68
72
  }
@@ -228,8 +232,16 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
228
232
  if (device === null) return init
229
233
  const headers = init?.headers
230
234
  if (typeof Headers !== 'undefined' && headers instanceof Headers) {
231
- try { headers.set(RULES.deviceHeader, device) } catch { /* ignore */ }
232
- return init
235
+ // Copy instead of mutating: the instance belongs to the caller, which may
236
+ // reuse it for a request this channel does not rewrite (the device
237
+ // credential must not ride along there).
238
+ try {
239
+ const copy = new Headers(headers)
240
+ copy.set(RULES.deviceHeader, device)
241
+ return { ...init, headers: copy }
242
+ } catch {
243
+ return init
244
+ }
233
245
  }
234
246
  if (typeof headers === 'object' && headers !== null) {
235
247
  return { ...init, headers: { ...(headers as Record<string, string>), [RULES.deviceHeader]: device } } as RequestInit
@@ -253,7 +265,10 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
253
265
  ? rewritten.toString()
254
266
  : new Request(rewritten, input)
255
267
  return Promise.resolve(originalFetch.call(window, next, attach(init))).then(async (response) => {
256
- if (await isUnpairedDenied(response.clone())) options.onUnpaired?.()
268
+ // The clone tees the response body; only the 403 branch inspects it, so
269
+ // every other status skips the copy (a large upload or session dump
270
+ // would otherwise be duplicated per gated request).
271
+ if (response.status === 403 && await isUnpairedDenied(response.clone())) options.onUnpaired?.()
257
272
  else options.onPaired?.()
258
273
  return response
259
274
  })
@@ -784,6 +784,44 @@
784
784
  gap: 4px;
785
785
  }
786
786
 
787
+ /* ── Wide foot row ─────────────────────────────────────────────────────────
788
+
789
+ The official Settings trigger owns a whole row while the footer actions
790
+ stack above it, so the trigger's right half sits empty. On the wide column
791
+ the two share one line (the Settings trigger, then the update and remote
792
+ triggers) and every other foot
793
+ child (the usage glance card, another plugin's block) keeps a full row of
794
+ its own above them. The rail (data-sidebar-collapsed on the frame) keeps
795
+ the shell's stacked foot, where the circles stack by design. The shell owns
796
+ the container classes; the semantic suffixes survive its rebuilds. */
797
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] {
798
+ flex-direction: row;
799
+ flex-wrap: wrap;
800
+ align-items: center;
801
+ }
802
+
803
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] > :not([class*='settingsArea']):not([class*='footerActions']) {
804
+ flex: 1 1 100%;
805
+ min-width: 0;
806
+ }
807
+
808
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='settingsArea'] {
809
+ order: 1;
810
+ /* Content-sized basis: the trigger's own width forces it onto the line after
811
+ a full-width sibling, then flex-shrink gives the actions their space. */
812
+ flex: 1 1 auto;
813
+ width: auto;
814
+ min-width: 0;
815
+ }
816
+
817
+ :global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] {
818
+ order: 2;
819
+ flex: none;
820
+ width: auto;
821
+ min-width: 0;
822
+ align-items: center;
823
+ }
824
+
787
825
  /* ── update panel extras ─────────────────────────────────────────────── */
788
826
 
789
827
  .updateStatus {
@@ -8,7 +8,7 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
11
+ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
12
  import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
13
  import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
14
14
 
@@ -114,7 +114,7 @@ interface PlannedWrite {
114
114
  judge: (() => boolean) | undefined
115
115
  }
116
116
 
117
- /** One durable write inside the save's atomic scope mutation. */
117
+ /** One durable write inside the save's atomic form mutation. */
118
118
  export interface BatchedWrite {
119
119
  /** Field this entry writes. */
120
120
  field: string
@@ -211,34 +211,34 @@ export class CardForm<T> {
211
211
  private readonly specs: Map<string, FieldSpec>
212
212
  private readonly staged = new Map<string, StagedEdit>()
213
213
  private readonly listeners = new Set<() => void>()
214
- /** The scope subscription installed in the constructor; released by dispose(). */
215
- private readonly disposeScope: () => void
214
+ /** The form subscription installed in the constructor; released by dispose(). */
215
+ private readonly disposeForm: () => void
216
216
  private disposed = false
217
217
  private saving = false
218
218
  private failed = false
219
219
  private failedReason: string | undefined
220
220
 
221
- /** @param scope - the bound settings scope for this card's namespace. */
221
+ /** @param scope - the bound configuration form for this card's namespace. */
222
222
  constructor(
223
- private readonly scope: SettingsScope<T>,
223
+ private readonly scope: ConfigForm<T>,
224
224
  specs: FieldSpec[],
225
225
  ) {
226
226
  this.specs = new Map(specs.map(spec => [spec.field, spec]))
227
- this.disposeScope = scope.subscribe(() => { this.publish() })
227
+ this.disposeForm = scope.subscribe(() => { this.publish() })
228
228
  }
229
229
 
230
230
  /**
231
- * Release the scope subscription and every bound store listener. The card
231
+ * Release the form subscription and every bound store listener. The card
232
232
  * must call this on teardown; later calls are no-ops.
233
233
  */
234
234
  dispose(): void {
235
235
  if (this.disposed) return
236
236
  this.disposed = true
237
- this.disposeScope()
237
+ this.disposeForm()
238
238
  this.listeners.clear()
239
239
  }
240
240
 
241
- /** Publish a projection of this form, rebuilt whenever the scope or a draft changes. */
241
+ /** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
242
242
  bind<S>(project: () => S): SnapshotStore<S> {
243
243
  const store = createSnapshotStore(project())
244
244
  this.listeners.add(() => { store.set(project()) })
@@ -295,19 +295,19 @@ export class CardForm<T> {
295
295
  }
296
296
 
297
297
  /**
298
- * Write every staged edit in one atomic scope mutation, then re-seed from
298
+ * Write every staged edit in one atomic form mutation, then re-seed from
299
299
  * what the Host accepted.
300
300
  *
301
301
  * The whole batch rides one mutate, so cross-field validate hooks
302
302
  * (baseURL+model) judge it as a unit: the Host either applies every write
303
- * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
- * mutation — the scope recovers with a fresh Host view and resolves — so
305
- * resolution alone proves nothing: the outcome is judged by reading the
306
- * settled snapshot back, one planned write at a time, and one missed write
307
- * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
- * bridge scope) reports through the same failure path with its rejection
309
- * message. A save that did not land keeps its drafts, so the user can
310
- * correct them instead of retyping.
303
+ * or refuses the batch. The form contract answers a refusal or a skipped
304
+ * write with `false` (it recovers with a fresh Host view instead of
305
+ * throwing), so the outcome is judged twice: the answer itself, and then the
306
+ * settled snapshot read back one planned write at a time. One missed write
307
+ * fails the whole save. A transport that rejects instead (the dsh-web bridge
308
+ * controller on a dead connection) reports through the same failure path
309
+ * with its rejection message. A save that did not land keeps its drafts, so
310
+ * the user can correct them instead of retyping.
311
311
  * @returns settlement after the mutation and the read-back.
312
312
  */
313
313
  async save(): Promise<void> {
@@ -323,30 +323,33 @@ export class CardForm<T> {
323
323
  this.failed = false
324
324
  this.failedReason = undefined
325
325
  this.publish()
326
- // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
- // path operations, so the whole staged batch is validated, persisted, and
326
+ // One atomic namespace mutation: the form contract takes ordered path
327
+ // operations, so the whole staged batch is validated, persisted, and
328
328
  // recovered together — either every write lands or none does.
329
329
  const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
330
  ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
331
  : { op: 'unset', path: [item.field] })
332
332
  let failedReason: string | undefined
333
+ let accepted = false
333
334
  try {
334
- await this.scope.mutate(ops)
335
+ accepted = await this.scope.mutate(ops)
335
336
  } catch (error) {
336
337
  failedReason = error instanceof Error ? error.message : String(error)
337
338
  }
338
- // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
- // fresh view instead of throwing), so resolution alone proves nothing:
340
- // judge every planned write against the settled snapshot. The mutation is
341
- // atomic, so one missed write fails the whole save and keeps the drafts.
342
- const landed = failedReason === undefined && valid.every(item => item.judge())
339
+ // `false` is the contract's refusal/skip answer, and the form still
340
+ // resolves after a refusal (it recovers with a fresh view), so the answer
341
+ // alone is not enough: judge every planned write against the settled
342
+ // snapshot as well. The mutation is atomic, so one missed write fails the
343
+ // whole save and keeps the drafts.
344
+ const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
343
345
  for (const [field, before] of pending) {
344
346
  if (landed && this.staged.get(field) === before) this.staged.delete(field)
345
347
  }
346
348
  this.saving = false
347
349
  this.failed = !landed
348
- // A read-back failure carries no server reason: the card surfaces its
349
- // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ // A refused or unlanded batch carries no server reason: the card surfaces
351
+ // its generic failure copy; a rejecting transport (the bridge) adds its
352
+ // message.
350
353
  this.failedReason = failedReason
351
354
  this.publish()
352
355
  }
@@ -381,7 +384,7 @@ export class CardForm<T> {
381
384
  */
382
385
  private landedSet(field: string, value: unknown): boolean {
383
386
  // A redacted secret never appears in any wire view layer: the Host strips
384
- // role('secret') fields and reports them through a sidecar the scope
387
+ // role('secret') fields and reports them through a sidecar the form
385
388
  // snapshot does not expose, so there is nothing to compare the draft
386
389
  // against. Settling is the only signal the form has; the rest of the
387
390
  // batch, when one exists, still carries the atomic verdict by read-back.
@@ -412,7 +415,7 @@ export class CardForm<T> {
412
415
  return spec
413
416
  }
414
417
 
415
- private snapshotOf(): SettingsScopeSnapshot<T> {
418
+ private snapshotOf(): ConfigFormSnapshot<T> {
416
419
  return this.scope.getSnapshot()
417
420
  }
418
421
 
@@ -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/http.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  /**
3
3
  * Shared JSON body/response helpers for the host route families: one strict
4
4
  * bounded body reader, one lenient bounded body reader, one JSON object
5
- * narrow, and one JSON writer. Previously these were copy-pasted across the
5
+ * narrow, one JSON writer, and the outbound request-init helper that keeps a
6
+ * host fetch body decodable (withIdentityEncoding). Previously the readers
7
+ * were copy-pasted across the
6
8
  * package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
7
9
  * family's route module) with drifting contracts: body caps ranging 4 KiB to
8
10
  * 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
88
90
  return isJsonObject(value) ? value : undefined
89
91
  }
90
92
 
93
+ /**
94
+ * Request init that asks a remote origin for an uncompressed body.
95
+ *
96
+ * The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
97
+ * the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
98
+ * Node's built-in `fetch()` reads. That cross-major wrapper drops the
99
+ * `content-encoding` header and automatic decompression, so a plain fetch
100
+ * resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
101
+ * consumer can read. Every host fetch that parses a remote body must request
102
+ * identity encoding. Caller headers survive; `accept-encoding` is forced to
103
+ * `identity` because a caller value has no decoder behind it in this host.
104
+ * @param init - the caller's request init (signal, headers, method, ...).
105
+ * @returns a copy with `accept-encoding: identity` merged in.
106
+ */
107
+ export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
108
+ const headers = new Headers(init.headers)
109
+ headers.set('accept-encoding', 'identity')
110
+ return { ...init, headers }
111
+ }
112
+
91
113
  /**
92
114
  * Write one JSON response. Default headers are the family defaults
93
115
  * (content-type and referrer-policy); caller headers are appended or