@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/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.23",
4
+ "version": "0.3.24",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -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 the Web UI plugin group.
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.slots.inject('web-ui.plugin.item', () => {
238
- try {
239
- const unregister = ctx.slots.register({
240
- name: 'web-ui.plugin.item',
241
- id: 'remote-web-ui',
242
- order: 90,
243
- locale: NS,
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
- const tabsRect = tabs.getBoundingClientRect()
768
- // Horizontal: the actions' right edge lands on the tabs row's right edge.
769
- const rightDiff = tabsRect.right - actionsRect.right
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 slide under it.
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
- if (tabs.style.paddingRight !== reserve) tabs.style.paddingRight = reserve
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 GBK via TextDecoder.
577
+ * Windows consoles using code page 936/GBK), falls back to the code page that
578
+ * loses the least text. Bytes are decoded once per whole chunk by the caller's
579
+ * accumulator — decoding per \`data\` event would split multi-byte characters.
577
580
  */
578
581
  function decodeProcessChunk(chunk: Buffer): string {
579
- try {
580
- return new TextDecoder('utf-8', { fatal: true }).decode(chunk)
581
- } catch {
582
- try {
583
- return new TextDecoder('gbk').decode(chunk)
584
- } catch {
585
- return chunk.toString('utf8')
586
- }
587
- }
582
+ return decodeConsoleBytes(chunk)
588
583
  }
589
584
 
590
585
  /**