@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.
- package/README.i18n.yaml +2 -2
- package/README.md +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
package/src/client/pair-api.ts
CHANGED
|
@@ -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' | '
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
215
|
-
private readonly
|
|
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
|
|
221
|
+
/** @param scope - the bound configuration form for this card's namespace. */
|
|
222
222
|
constructor(
|
|
223
|
-
private readonly scope:
|
|
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.
|
|
227
|
+
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Release the
|
|
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.
|
|
237
|
+
this.disposeForm()
|
|
238
238
|
this.listeners.clear()
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Publish a projection of this form, rebuilt whenever the
|
|
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
|
|
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
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* settled snapshot back
|
|
307
|
-
* fails the whole save. A
|
|
308
|
-
*
|
|
309
|
-
* message. A save that did not land keeps its drafts, so
|
|
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
|
|
327
|
-
//
|
|
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
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// judge every planned write against the settled
|
|
341
|
-
// atomic, so one missed write fails the
|
|
342
|
-
|
|
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
|
|
349
|
-
// generic failure copy; a rejecting
|
|
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
|
|
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():
|
|
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,
|
|
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
|