@linxin666/dsh-remote-web-ui 0.3.23 → 0.3.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +149 -19
- package/lib/client.js.map +1 -1
- package/lib/index.js +43 -10
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +13 -18
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +40 -6
- package/lib/types/client/plugin-card-seat.d.ts +97 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +141 -0
- package/lib/types/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/update.d.ts.map +1 -1
- package/lib/types/update.js +5 -12
- package/package.json +1 -1
- package/src/client/index.ts +13 -17
- package/src/client/mobile-adapt.ts +39 -5
- package/src/client/plugin-card-seat.ts +185 -0
- package/src/console-output.ts +97 -0
- package/src/update.ts +5 -10
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.24",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/client/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { FenceNotice } from './FenceNotice.tsx'
|
|
40
40
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
41
41
|
import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
|
|
42
|
+
import { installPluginCard } from './plugin-card-seat.ts'
|
|
42
43
|
|
|
43
44
|
// Portrait-touch adaptation of the official UI: installed under the plugin
|
|
44
45
|
// lifecycle (apply) so disabling the plugin in cordis patch (disabled: true)
|
|
@@ -232,25 +233,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
232
233
|
})
|
|
233
234
|
|
|
234
235
|
// Plugin configuration card: one staged form over the `remote-web-ui`
|
|
235
|
-
// settings namespace, contributed to
|
|
236
|
+
// settings namespace, contributed to whichever plugin-card seat the running
|
|
237
|
+
// host declares (the family group's list seat, or the official keyed seat of
|
|
238
|
+
// the plugin-configuration tab when dsh-web-settings is not installed —
|
|
239
|
+
// issue #1589).
|
|
236
240
|
const remoteSettings = new RemoteSettingsCardController(settingsScope)
|
|
237
|
-
ctx
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
inject: () => remoteSettings.inject(),
|
|
245
|
-
}, RemoteSettingsCard)
|
|
246
|
-
return () => {
|
|
247
|
-
remoteSettings.dispose()
|
|
248
|
-
unregister()
|
|
249
|
-
}
|
|
250
|
-
} catch {
|
|
251
|
-
return () => {}
|
|
252
|
-
}
|
|
241
|
+
installPluginCard(ctx, {
|
|
242
|
+
namespace: REMOTE_WEB_UI_NS,
|
|
243
|
+
id: 'remote-web-ui',
|
|
244
|
+
order: 90,
|
|
245
|
+
locale: NS,
|
|
246
|
+
inject: () => remoteSettings.inject(),
|
|
247
|
+
component: RemoteSettingsCard,
|
|
253
248
|
})
|
|
249
|
+
ctx.effect(() => () => { remoteSettings.dispose() }, 'remote-web-ui: settings card')
|
|
254
250
|
|
|
255
251
|
// Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
|
|
256
252
|
// heartbeat; the server ignores unpaired heartbeats anyway. Both run only
|
|
@@ -64,6 +64,12 @@ const EFFORT_BTN_ID = 'dshRemoteEffortPick'
|
|
|
64
64
|
const COMPACT_CLASS = 'dsh-remote-compact-picker'
|
|
65
65
|
/** Body class while the header actions are seated in the tabs row. */
|
|
66
66
|
const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
|
|
67
|
+
/**
|
|
68
|
+
* Width the seated header actions paint over the tabs row, as a CSS variable
|
|
69
|
+
* on <html>. The row caps its own width with it (see the v80 rules) instead of
|
|
70
|
+
* reserving the space with padding.
|
|
71
|
+
*/
|
|
72
|
+
const HEADER_RESERVE_VAR = '--dsh-remote-header-actions-reserve'
|
|
67
73
|
/** The two composer picker entries the compact buttons drill into. */
|
|
68
74
|
type PickerKind = 'model' | 'effort'
|
|
69
75
|
/** Locale-dependent fast path for the official picker cells (zh/en). */
|
|
@@ -254,6 +260,20 @@ const ADAPT_CSS: readonly string[] = [
|
|
|
254
260
|
// [class$="_tab"] misses the active tab (its class ends in "_tabActive")
|
|
255
261
|
// — containment so BOTH tabs match.
|
|
256
262
|
'[class$="_header"] [class$="_tabs"] [class*="_tab"]{font-size:12px;white-space:nowrap}',
|
|
263
|
+
// v80: the tabs row must give the painted actions their width up on its own.
|
|
264
|
+
// The v67 padding reservation could not do it: the row is a nowrap flex
|
|
265
|
+
// container, and once the tab labels plus their gaps overflow the reduced
|
|
266
|
+
// content box the flex items spill over the padding box, so the trailing
|
|
267
|
+
// tabs render underneath the actions (reported on a phone: a long agent
|
|
268
|
+
// preset label covered the last tabs). Cap the row against the reserve and
|
|
269
|
+
// let the labels scroll instead. The value rides a CSS variable on <html>
|
|
270
|
+
// because the update lands before React re-creates the row node, and an
|
|
271
|
+
// inline reservation on that node is dropped until the next sync tick.
|
|
272
|
+
// The 2px bottom padding keeps the active-tab underline inside the clipping
|
|
273
|
+
// box; the matching negative margin keeps the header height official.
|
|
274
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]{max-width:max(0px,calc(100% - var(${HEADER_RESERVE_VAR},0px)));overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-webkit-overflow-scrolling:touch;padding-bottom:2px;margin-bottom:-2px}`,
|
|
275
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]::-webkit-scrollbar{display:none}`,
|
|
276
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"] > [class*="_tab"]{flex:0 0 auto}`,
|
|
257
277
|
// Mobile scope: hide the plugin surfaces that do not fit a phone — the
|
|
258
278
|
// right-hand details column and every desktop-oriented tool surface. The
|
|
259
279
|
// list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
|
|
@@ -764,9 +784,14 @@ export function startMobileAdapt(): void {
|
|
|
764
784
|
let dx = translate !== null ? parseFloat(translate[1] ?? '0') : 0
|
|
765
785
|
let dy = translate !== null ? parseFloat(translate[2] ?? '0') : 0
|
|
766
786
|
const actionsRect = actions.getBoundingClientRect()
|
|
767
|
-
|
|
768
|
-
//
|
|
769
|
-
|
|
787
|
+
// Horizontal: the actions' right edge lands on the header's content edge,
|
|
788
|
+
// NOT on the tabs row's right edge. The row caps its own width against the
|
|
789
|
+
// reserve below (v80), so anchoring to the row would let the pair walk
|
|
790
|
+
// leftwards on every measurement.
|
|
791
|
+
const headerRect = header.getBoundingClientRect()
|
|
792
|
+
const headerStyle = getComputedStyle(header)
|
|
793
|
+
const contentRight = headerRect.right - (parseFloat(headerStyle.paddingRight) || 0) - (parseFloat(headerStyle.borderRightWidth) || 0)
|
|
794
|
+
const rightDiff = contentRight - actionsRect.right
|
|
770
795
|
if (Math.abs(rightDiff) >= 0.5) dx = Math.round((dx + rightDiff) * 10) / 10
|
|
771
796
|
// Vertical: the actions' text bottom lands on the tab text bottom.
|
|
772
797
|
const tabBottom = textBottom(tabBtn)
|
|
@@ -783,17 +808,26 @@ export function startMobileAdapt(): void {
|
|
|
783
808
|
}
|
|
784
809
|
const next = `translate(${dx}px, ${dy}px)`
|
|
785
810
|
if (actions.style.transform !== next) actions.style.transform = next
|
|
786
|
-
// Reserve the painted width so the tab labels never
|
|
811
|
+
// Reserve the painted width on the row itself, so the tab labels never
|
|
812
|
+
// slide under it. The value rides a CSS variable on <html>: React
|
|
813
|
+
// recreates the row node on re-render, which would drop an inline
|
|
814
|
+
// reservation until the next sync tick, and until then the labels render
|
|
815
|
+
// underneath the painted actions.
|
|
787
816
|
const reserve = `${Math.ceil(actionsRect.width) + 8}px`
|
|
788
|
-
|
|
817
|
+
const rootStyle = document.documentElement.style
|
|
818
|
+
if (rootStyle.getPropertyValue(HEADER_RESERVE_VAR) !== reserve) rootStyle.setProperty(HEADER_RESERVE_VAR, reserve)
|
|
789
819
|
}
|
|
790
820
|
|
|
791
821
|
function unseatHeaderActions(): void {
|
|
792
822
|
document.body.classList.remove(HEADER_SEATED_CLASS)
|
|
823
|
+
document.documentElement.style.removeProperty(HEADER_RESERVE_VAR)
|
|
793
824
|
const header = document.querySelector('[class$="_header"]')
|
|
794
825
|
const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
|
|
795
826
|
const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
|
|
796
827
|
if (actions instanceof HTMLElement) actions.style.transform = ''
|
|
828
|
+
// The v67 padding reservation is gone with the v80 cap; clearing it keeps a
|
|
829
|
+
// profile that hot-updated from an older build from keeping a stale inline
|
|
830
|
+
// width on the row.
|
|
797
831
|
if (tabs instanceof HTMLElement) tabs.style.paddingRight = ''
|
|
798
832
|
if (headerObserver !== null) {
|
|
799
833
|
headerObserver.disconnect()
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
|
|
40
|
+
/** The family list seat key. */
|
|
41
|
+
export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
|
|
42
|
+
|
|
43
|
+
/** The official keyed plugin-card seat key. */
|
|
44
|
+
export const OFFICIAL_PLUGIN_CARD_SEAT = 'settings.plugin.item'
|
|
45
|
+
|
|
46
|
+
/** The service dsh-web-settings publishes while it is loaded. */
|
|
47
|
+
export const FAMILY_GROUP_SERVICE = 'webUiSettings'
|
|
48
|
+
|
|
49
|
+
/** The slot-registry member this helper uses (structurally satisfied by ctx.slots). */
|
|
50
|
+
export interface PluginCardSlots {
|
|
51
|
+
/** Contribution of one card entry. */
|
|
52
|
+
register(options: never, component: never): unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The slice of the client context a card contribution needs. */
|
|
56
|
+
export interface PluginCardContext {
|
|
57
|
+
slots: PluginCardSlots
|
|
58
|
+
/** Service lookup; absent on a context double that only models the slots. */
|
|
59
|
+
get?(name: string): unknown
|
|
60
|
+
/** Event subscription seat; absent on an event-less test double. */
|
|
61
|
+
on?(event: string, listener: (...args: never[]) => void): unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Owner share of a plugin card (both seats supply nothing). */
|
|
65
|
+
export interface SettingsPluginItemOwnerProps {
|
|
66
|
+
/** Marker field: card owner props are intentionally empty. */
|
|
67
|
+
children?: never
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One family plugin's card contribution. */
|
|
71
|
+
export interface PluginCardSeat {
|
|
72
|
+
/** Settings namespace the card edits (the official seat's dispatch key). */
|
|
73
|
+
namespace: string
|
|
74
|
+
/** Family list-seat entry id. */
|
|
75
|
+
id: string
|
|
76
|
+
/** Family list-seat sort order. */
|
|
77
|
+
order?: number
|
|
78
|
+
/** Family list-seat display label; the official keyed seat carries none. */
|
|
79
|
+
label?: () => string
|
|
80
|
+
/** Locale namespace the card renders with. */
|
|
81
|
+
locale: string
|
|
82
|
+
/** Business-face factory of the registration. */
|
|
83
|
+
inject?: () => object
|
|
84
|
+
/**
|
|
85
|
+
* The card component. Its props type is the seat's composed shape, which is
|
|
86
|
+
* chosen at runtime, so the helper takes it erased; the registrant keeps its
|
|
87
|
+
* own precise typing at the call site.
|
|
88
|
+
*/
|
|
89
|
+
component: unknown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether the family group (dsh-web-settings) is loaded in this page. The
|
|
94
|
+
* service is the group package's own contract, so the probe cannot be fooled
|
|
95
|
+
* by a harness release that starts declaring the official seat differently.
|
|
96
|
+
*/
|
|
97
|
+
export function familyGroupLoaded(ctx: PluginCardContext): boolean {
|
|
98
|
+
const get = ctx.get
|
|
99
|
+
if (typeof get !== 'function') return false
|
|
100
|
+
try {
|
|
101
|
+
return get.call(ctx, FAMILY_GROUP_SERVICE) !== undefined
|
|
102
|
+
} catch {
|
|
103
|
+
// A context that refuses the lookup: treat the group as absent, which
|
|
104
|
+
// falls back to the official seat the harness declares.
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Report a refused registration instead of leaving the user with no card. */
|
|
110
|
+
function warnRefusedSeat(seat: string, error: unknown): void {
|
|
111
|
+
try {
|
|
112
|
+
console.warn(`[dsh-web] plugin card registration into "${seat}" was refused; the card will not render`, error)
|
|
113
|
+
} catch {
|
|
114
|
+
// Best-effort console write; a failed warning must not break the plugin.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Contribute one family plugin card to the seat this host renders, following
|
|
120
|
+
* the group if it loads later. The entry is disposed and re-registered on a
|
|
121
|
+
* seat change, never duplicated.
|
|
122
|
+
* @param ctx - client context (its slot registry decides the seat).
|
|
123
|
+
* @param seat - the card contribution.
|
|
124
|
+
*/
|
|
125
|
+
export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat): void {
|
|
126
|
+
const slots = ctx.slots
|
|
127
|
+
const component = seat.component as never
|
|
128
|
+
const inject = seat.inject as never
|
|
129
|
+
|
|
130
|
+
let dispose: (() => void) | undefined
|
|
131
|
+
let current: string | undefined
|
|
132
|
+
/**
|
|
133
|
+
* Re-entrancy latch. The registry emits a change event synchronously from
|
|
134
|
+
* inside both `register` and the previous entry's disposer, so an unguarded
|
|
135
|
+
* reconcile would re-enter itself mid-move and register the card twice into
|
|
136
|
+
* the seat it is leaving ("already has an entry for key ...").
|
|
137
|
+
*/
|
|
138
|
+
let reconciling = false
|
|
139
|
+
|
|
140
|
+
/** Reconcile the contribution with the currently live seat (no-op when unchanged). */
|
|
141
|
+
const reconcile = (): void => {
|
|
142
|
+
if (reconciling) return
|
|
143
|
+
const target = familyGroupLoaded(ctx) ? FAMILY_PLUGIN_CARD_SEAT : OFFICIAL_PLUGIN_CARD_SEAT
|
|
144
|
+
if (current === target) return
|
|
145
|
+
reconciling = true
|
|
146
|
+
const previous = dispose
|
|
147
|
+
dispose = undefined
|
|
148
|
+
current = undefined
|
|
149
|
+
previous?.()
|
|
150
|
+
try {
|
|
151
|
+
dispose = slots.register((target === FAMILY_PLUGIN_CARD_SEAT
|
|
152
|
+
? {
|
|
153
|
+
name: FAMILY_PLUGIN_CARD_SEAT,
|
|
154
|
+
id: seat.id,
|
|
155
|
+
...(seat.order === undefined ? {} : { order: seat.order }),
|
|
156
|
+
...(seat.label === undefined ? {} : { label: seat.label }),
|
|
157
|
+
locale: seat.locale,
|
|
158
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
159
|
+
}
|
|
160
|
+
: {
|
|
161
|
+
name: OFFICIAL_PLUGIN_CARD_SEAT,
|
|
162
|
+
key: seat.namespace,
|
|
163
|
+
locale: seat.locale,
|
|
164
|
+
...(seat.inject === undefined ? {} : { inject }),
|
|
165
|
+
}) as never, component) as () => void
|
|
166
|
+
current = target
|
|
167
|
+
} catch (error) {
|
|
168
|
+
warnRefusedSeat(target, error)
|
|
169
|
+
} finally {
|
|
170
|
+
reconciling = false
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (typeof ctx.on === 'function') {
|
|
175
|
+
try {
|
|
176
|
+
ctx.on('slots/changed', () => { reconcile() })
|
|
177
|
+
} catch {
|
|
178
|
+
// An event seat that refuses the subscription leaves the initial
|
|
179
|
+
// decision in place; the card still renders in the seat chosen below.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// The initial decision: the group, when it is already loaded (the aggregate
|
|
183
|
+
// order), otherwise the official seat the harness declares.
|
|
184
|
+
reconcile()
|
|
185
|
+
}
|
|
@@ -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/update.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import { dirname, join } from 'node:path'
|
|
14
14
|
import { spawn } from 'node:child_process'
|
|
15
|
+
import { decodeConsoleBytes } from './console-output.ts'
|
|
15
16
|
|
|
16
17
|
/** npm registry base used for version probes. */
|
|
17
18
|
export const NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
@@ -573,18 +574,12 @@ const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|
|
|
|
573
574
|
/**
|
|
574
575
|
* Decode process output with tolerant decoding:
|
|
575
576
|
* Defaults to UTF-8; if invalid byte sequences are encountered (common on
|
|
576
|
-
* Windows consoles using code page 936/GBK), falls back to
|
|
577
|
+
* Windows consoles using code page 936/GBK), falls back to the code page that
|
|
578
|
+
* loses the least text. Bytes are decoded once per whole chunk by the caller's
|
|
579
|
+
* accumulator — decoding per \`data\` event would split multi-byte characters.
|
|
577
580
|
*/
|
|
578
581
|
function decodeProcessChunk(chunk: Buffer): string {
|
|
579
|
-
|
|
580
|
-
return new TextDecoder('utf-8', { fatal: true }).decode(chunk)
|
|
581
|
-
} catch {
|
|
582
|
-
try {
|
|
583
|
-
return new TextDecoder('gbk').decode(chunk)
|
|
584
|
-
} catch {
|
|
585
|
-
return chunk.toString('utf8')
|
|
586
|
-
}
|
|
587
|
-
}
|
|
582
|
+
return decodeConsoleBytes(chunk)
|
|
588
583
|
}
|
|
589
584
|
|
|
590
585
|
/**
|