@linxin666/dsh-remote-web-ui 0.1.14 → 0.1.16

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 (45) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +9 -5
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +84 -23
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +173 -18
  7. package/lib/mobile.js +44 -25
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/PluginSettingsCard.d.ts +26 -11
  10. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  11. package/lib/types/client/PluginSettingsCard.js +30 -8
  12. package/lib/types/client/UpdatePanel.js +2 -0
  13. package/lib/types/client/locales.d.ts +4 -0
  14. package/lib/types/client/locales.d.ts.map +1 -1
  15. package/lib/types/client/locales.js +4 -0
  16. package/lib/types/client/settings-form.d.ts +76 -5
  17. package/lib/types/client/settings-form.d.ts.map +1 -1
  18. package/lib/types/client/settings-form.js +97 -20
  19. package/lib/types/index.d.ts.map +1 -1
  20. package/lib/types/index.js +36 -10
  21. package/lib/types/mobile/mux.d.ts +13 -4
  22. package/lib/types/mobile/mux.d.ts.map +1 -1
  23. package/lib/types/mobile/mux.js +50 -30
  24. package/lib/types/mobile/mux.test.js +75 -6
  25. package/lib/types/mobile-api.d.ts +2 -0
  26. package/lib/types/mobile-api.d.ts.map +1 -1
  27. package/lib/types/mobile-api.js +4 -1
  28. package/lib/types/routes.d.ts +17 -0
  29. package/lib/types/routes.d.ts.map +1 -1
  30. package/lib/types/routes.js +49 -10
  31. package/lib/types/update.d.ts +44 -4
  32. package/lib/types/update.d.ts.map +1 -1
  33. package/lib/types/update.js +118 -8
  34. package/package.json +1 -1
  35. package/src/client/PluginSettingsCard.tsx +90 -18
  36. package/src/client/UpdatePanel.tsx +2 -0
  37. package/src/client/locales.ts +4 -0
  38. package/src/client/settings-card.module.css +9 -0
  39. package/src/client/settings-form.ts +153 -19
  40. package/src/index.ts +34 -10
  41. package/src/mobile/mux.test.ts +84 -6
  42. package/src/mobile/mux.ts +46 -25
  43. package/src/mobile-api.ts +6 -1
  44. package/src/routes.ts +51 -10
  45. package/src/update.ts +128 -9
@@ -2,7 +2,7 @@
2
2
  * Remote update support for the dsh-web-ui family — host half. Detects the
3
3
  * installed aggregate package (@linxin666/dsh-web-ui-all) and its family
4
4
  * children, probes the npm registry for newer releases, and runs the actual
5
- * update as `pnpm update` inside the owning dsh profile directory.
5
+ * update as `pnpm update --latest` inside the owning dsh profile directory.
6
6
  *
7
7
  * Pure logic with injected seams (manifest reading, registry fetches, process
8
8
  * spawning) so the whole surface is unit-testable without touching disk,
@@ -130,14 +130,18 @@ export function isLinkedSpec(spec) {
130
130
  }
131
131
  /**
132
132
  * Resolve the anchor package's manifest path. The aggregate package is the
133
- * canonical entry point; this plugin's own package is the fallback.
133
+ * canonical entry point; this plugin's own package is the fallback. Both a
134
+ * throwing resolve and an undefined return mean "not installed" and move on
135
+ * to the next candidate.
134
136
  * @param resolve - a Node resolve implementation scoped to the host process.
135
137
  * @returns the absolute manifest path, or undefined when neither is installed.
136
138
  */
137
139
  export function resolveAnchorManifest(resolve) {
138
140
  for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) {
139
141
  try {
140
- return resolve(name + '/package.json');
142
+ const path = resolve(name + '/package.json');
143
+ if (path !== undefined)
144
+ return path;
141
145
  }
142
146
  catch {
143
147
  // Not installed — try the next candidate.
@@ -216,15 +220,22 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
216
220
  return undefined;
217
221
  }
218
222
  }
223
+ /**
224
+ * Sentinel for an installed version that could not be read (missing manifest
225
+ * or resolve failure). It is a real-looking version so `checkUpdates` can
226
+ * render the affected row, but the verified-update comparison must never
227
+ * treat it as evidence — a read failure is not a version that "moved".
228
+ */
229
+ const VERSION_UNKNOWN = '0.0.0';
219
230
  /** The resolved current version of one family package (probe failure tolerated). */
220
231
  function readInstalledVersion(resolve, name) {
221
232
  try {
222
233
  const path = resolve(name + '/package.json');
223
234
  const version = path === undefined ? undefined : readManifest(path)?.version;
224
- return typeof version === 'string' ? version : '0.0.0';
235
+ return typeof version === 'string' ? version : VERSION_UNKNOWN;
225
236
  }
226
237
  catch {
227
- return '0.0.0';
238
+ return VERSION_UNKNOWN;
228
239
  }
229
240
  }
230
241
  /**
@@ -313,10 +324,14 @@ export function runUpdate(deps) {
313
324
  };
314
325
  // Ordered fallback chain: each is tried only when the previous one is
315
326
  // missing on PATH (ENOENT on the spawn error event, never on close).
327
+ // `--latest` is required: dsh plugin writes exact-version specs (e.g.
328
+ // "0.1.12" without a range), and plain `pnpm update` treats an exact spec
329
+ // as pinned — it prints "Already up to date" and exits 0 without moving
330
+ // the installed version, so the panel would report a false success.
316
331
  const candidates = [
317
- { command: 'pnpm', args: ['update', ...packages] },
318
- { command: 'corepack', args: ['pnpm', 'update', ...packages] },
319
- { command: 'npx', args: ['--yes', 'pnpm', 'update', ...packages] },
332
+ { command: 'pnpm', args: ['update', '--latest', ...packages] },
333
+ { command: 'corepack', args: ['pnpm', 'update', '--latest', ...packages] },
334
+ { command: 'npx', args: ['--yes', 'pnpm', 'update', '--latest', ...packages] },
320
335
  ];
321
336
  // `output` accumulates across candidates for UI display; `currentOutput`
322
337
  // is reset per candidate and carries only that candidate's own diagnostics
@@ -427,3 +442,98 @@ export function runUpdate(deps) {
427
442
  runCandidate(0);
428
443
  });
429
444
  }
445
+ /**
446
+ * Run the update, then verify the installed versions actually moved. pnpm
447
+ * exits 0 even when it silently kept the installed versions — the pnpm 11
448
+ * `minimumReleaseAge` gate refuses same-day releases by default — so a green
449
+ * exit alone would report a misleading "update complete". Re-read the
450
+ * installed versions afterwards and surface a `stale` failure (with the
451
+ * captured pnpm output) when nothing moved, so the panel can tell the user
452
+ * how to unblock the gate instead of claiming success.
453
+ *
454
+ * The stale decision anchors on the pre-run installed versions, not on the
455
+ * registry latest: under a lenient gate pnpm may move 0.1.12 -> 0.1.13 while
456
+ * latest stays 0.1.15 — that is a real update, not stale. The post-run anchor
457
+ * path is re-resolved rather than reused from boot time: pnpm removes the old
458
+ * version's .pnpm directory on update, so a boot-time captured path would
459
+ * fail to read and collapse a successful update into a bogus 'missing'.
460
+ *
461
+ * A green exit whose verification has nothing to compare (registry probe
462
+ * outage, missing anchor, non-npm mode) is reported as `verify-failed` — it
463
+ * must never read as success. A green exit where no package moved but the
464
+ * post-run check could not prove the install is fully current (a partial
465
+ * probe failure hides whether a gate kept something back) is also
466
+ * `verify-failed`: it must not collapse into a success either.
467
+ * @param deps - the run and check seams.
468
+ * @returns the run result; `stale` when exit 0 left every version in place,
469
+ * `verify-failed` when the post-run check could not verify anything.
470
+ */
471
+ export async function runUpdateVerified(deps) {
472
+ // Pre-run snapshot: the installed versions the update starts from. Only
473
+ // successfully-read versions are recorded — a read failure (VERSION_UNKNOWN)
474
+ // is not a baseline and must not look like a version that "moved", or a
475
+ // green exit that left everything in place could be mistaken for success.
476
+ const before = new Map();
477
+ for (const name of deps.run.packages) {
478
+ const version = readInstalledVersion(deps.check.resolve, name);
479
+ if (version !== VERSION_UNKNOWN)
480
+ before.set(name, version);
481
+ }
482
+ const result = await runUpdate(deps.run);
483
+ if (!result.ok)
484
+ return result;
485
+ // Re-resolve the anchor after the run: pnpm removes the old version's
486
+ // .pnpm directory, so a boot-time captured path no longer reads. Fall back
487
+ // to the provided path only when nothing resolves now.
488
+ const anchorManifestPath = resolveAnchorManifest(deps.check.resolve) ?? deps.check.anchorManifestPath;
489
+ const status = await checkUpdates({ ...deps.check, anchorManifestPath });
490
+ // A green exit with no comparable result is not a success: registry probe
491
+ // outage, missing anchor, or a non-npm install mode all mean the post-run
492
+ // check could not verify anything.
493
+ if (status.error !== undefined || status.mode !== 'npm') {
494
+ return {
495
+ ...result,
496
+ ok: false,
497
+ errorCode: 'verify-failed',
498
+ error: 'pnpm exited 0 but the post-run version check could not verify the install',
499
+ };
500
+ }
501
+ // A package "moved" only when a known pre-run version differs from a known
502
+ // post-run version. A read failure on either side (VERSION_UNKNOWN) is not
503
+ // evidence of movement and must not turn a no-op update into a success; a
504
+ // package without a pre-run baseline is ignored — only the packages pnpm
505
+ // was told to update count as evidence.
506
+ const moved = status.packages.some(packageStatus => {
507
+ const beforeVersion = before.get(packageStatus.name);
508
+ if (beforeVersion === undefined || packageStatus.current === VERSION_UNKNOWN)
509
+ return false;
510
+ return packageStatus.current !== beforeVersion;
511
+ });
512
+ if (moved)
513
+ return result;
514
+ // Nothing moved. A green exit is only a true no-op when the post-run check
515
+ // proves the install is already current: every probe succeeded and nothing
516
+ // is outdated. If a probe failed we cannot tell "already up to date" from
517
+ // "a gate silently kept it back", so report verify-failed rather than a
518
+ // false success.
519
+ if (status.outdated) {
520
+ // stale: the check ran, a newer release exists, and no package moved
521
+ // (e.g. the minimumReleaseAge gate kept everything in place).
522
+ return {
523
+ ...result,
524
+ ok: false,
525
+ errorCode: 'stale',
526
+ error: 'pnpm exited 0 but the installed versions did not change',
527
+ };
528
+ }
529
+ const unverifiable = status.packages.some(packageStatus => packageStatus.latest === undefined);
530
+ if (unverifiable) {
531
+ return {
532
+ ...result,
533
+ ok: false,
534
+ errorCode: 'verify-failed',
535
+ error: 'pnpm exited 0 but the post-run version check could not verify the install',
536
+ };
537
+ }
538
+ return result;
539
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
4
- "version": "0.1.14",
4
+ "version": "0.1.16",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -1,25 +1,44 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/client/settings/PluginSettingsCard.tsx. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
1
2
  /**
2
- * Shared chrome for the plugin settings card: a disclosure header naming the
3
- * plugin and what its settings govern, the controls inside, and the save that
4
- * writes them. Renders nothing while the namespace is unavailable — a
3
+ * Family-shared chrome for plugin settings cards: a disclosure header naming
4
+ * the plugin and what its settings govern, the controls inside, and the save
5
+ * that writes them. Renders nothing while the namespace is unavailable — a
5
6
  * deployment that does not compose the owning plugin should show no trace of
6
- * it. Mirrors the official ui-plugin-config PluginCard in a self-contained
7
- * slice (this package must not depend on a sibling UI package).
7
+ * it. Inlined into each consumer's client bundle; mirrors the official
8
+ * ui-plugin-config PluginCard in a self-contained slice.
8
9
  */
9
10
 
10
11
  import { useState, type ReactNode } from 'react'
11
12
  import type { CardShell } from './settings-form.ts'
12
- import type { SettingsCardKey } from './locales.ts'
13
13
  import css from './settings-card.module.css'
14
14
 
15
+ /** Copy keys the card chrome itself reads; every consumer locale carries this shared vocabulary. */
16
+ export const CARD_COPY_KEYS = [
17
+ 'settings.collapse',
18
+ 'settings.expand',
19
+ 'settings.notExposed',
20
+ 'settings.unsaved',
21
+ 'settings.readOnly',
22
+ 'settings.saveFailed',
23
+ 'settings.discard',
24
+ 'settings.save',
25
+ 'settings.saving',
26
+ ] as const
27
+
28
+ /** Copy key the card chrome itself reads. */
29
+ export type CardCopyKey = (typeof CARD_COPY_KEYS)[number]
30
+
31
+ /** Key domain for the plugin's own copy (inferred per consumer). */
32
+ export type SettingsCardKey<TKey extends string = string> = TKey | CardCopyKey
33
+
15
34
  /** Card chrome shared by every plugin settings card. */
16
- export interface PluginSettingsCardProps {
35
+ export interface PluginSettingsCardProps<TKey extends string = string> {
17
36
  /** Locale reader for this card's copy. */
18
- t: (key: SettingsCardKey) => string
37
+ t: (key: SettingsCardKey<TKey>, params?: Record<string, string | number>) => string
19
38
  /** Locale key of the plugin's name. */
20
- titleKey: SettingsCardKey
39
+ titleKey: TKey
21
40
  /** Locale key of the line describing what this plugin's settings govern. */
22
- descriptionKey: SettingsCardKey
41
+ descriptionKey: TKey
23
42
  /** The card's form state: availability, writability, and what a save would do. */
24
43
  state: CardShell
25
44
  /** Write every staged edit. */
@@ -35,11 +54,12 @@ export interface PluginSettingsCardProps {
35
54
  * @param props - the plugin's copy keys, its form state, and its controls.
36
55
  * @returns the card, or nothing while the namespace is still loading.
37
56
  */
38
- export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
+ export function PluginSettingsCard<TKey extends string = string>(props: PluginSettingsCardProps<TKey>) {
39
58
  const [open, setOpen] = useState(false)
40
59
  const { state } = props
41
60
  if (!state.available) return null
42
61
  const title = props.t(props.titleKey)
62
+ const description = props.t(props.descriptionKey)
43
63
  const blocked = !state.dirty || state.invalid || state.saving
44
64
  const cardClass = open ? `${css.cardOpen} ${css.card}` : css.card
45
65
  // The namespace exists but the Host does not serve it to this client (the
@@ -57,8 +77,8 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
57
77
  onClick={() => { setOpen(!open) }}
58
78
  >
59
79
  <span className={css.headText}>
60
- <span className={css.name}>{title}</span>
61
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
80
+ <span className={css.name} title={title}>{title}</span>
81
+ <span className={css.description} title={description}>{description}</span>
62
82
  </span>
63
83
  <svg
64
84
  width="14"
@@ -94,10 +114,10 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
94
114
  onClick={() => { setOpen(!open) }}
95
115
  >
96
116
  <span className={css.headText}>
97
- <span className={css.name}>{title}</span>
98
- <span className={css.description}>{props.t(props.descriptionKey)}</span>
117
+ <span className={css.name} title={title}>{title}</span>
118
+ <span className={css.description} title={description}>{description}</span>
99
119
  </span>
100
- {state.dirty ? <span className={css.pending}>{props.t('settings.unsaved')}</span> : null}
120
+ {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
101
121
  <svg
102
122
  width="14"
103
123
  height="14"
@@ -118,7 +138,13 @@ export function PluginSettingsCard(props: PluginSettingsCardProps) {
118
138
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
119
139
  {props.children}
120
140
  <div className={css.footer}>
121
- {state.failed ? <p className={css.failed} role="status">{props.t('settings.saveFailed')}</p> : null}
141
+ {state.failed
142
+ ? (
143
+ <p className={css.failed} role="status">
144
+ {props.t('settings.saveFailed')}{state.failedReason ? ' - ' + state.failedReason : ''}
145
+ </p>
146
+ )
147
+ : null}
122
148
  <button
123
149
  type="button"
124
150
  className={css.discard}
@@ -259,4 +285,50 @@ export function BooleanField(props: FieldProps & {
259
285
  <p className={css.hint}>{props.hint}</p>
260
286
  </div>
261
287
  )
262
- }
288
+ }
289
+
290
+ /** A staged enumerated field rendered as a select. */
291
+ export function ChoiceField(props: FieldProps & {
292
+ /** Copy for the inherit option (draft text is the empty string). */
293
+ inheritLabel: string
294
+ /** Choices rendered in order; `value` is the draft/stored text. */
295
+ choices: ReadonlyArray<{ value: string; label: string }>
296
+ }) {
297
+ return (
298
+ <div className={css.field}>
299
+ <div className={css.head}>
300
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
301
+ {props.overridden
302
+ ? (
303
+ <span className={css.badges}>
304
+ <span className={css.badge}>{props.overriddenLabel}</span>
305
+ <button
306
+ type="button"
307
+ className={css.reset}
308
+ disabled={props.disabled}
309
+ onClick={props.onReset}
310
+ >
311
+ {props.resetLabel}
312
+ </button>
313
+ </span>
314
+ )
315
+ : null}
316
+ </div>
317
+ <select
318
+ id={props.id}
319
+ className={css.select}
320
+ value={props.text}
321
+ disabled={props.disabled}
322
+ onChange={(event) => { props.onEdit(event.target.value) }}
323
+ >
324
+ <option value="">{props.inheritLabel}</option>
325
+ {props.choices.map(choice => (
326
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
327
+ ))}
328
+ </select>
329
+ <p className={props.invalid ? css.invalid : css.hint}>
330
+ {props.invalid ? props.invalidLabel : props.hint}
331
+ </p>
332
+ </div>
333
+ )
334
+ }
@@ -198,6 +198,8 @@ function errorMessageOf(t: TranslateNS<"remote">, result: UpdateRunResult): stri
198
198
  case "not-found": return t("update.error.notFound")
199
199
  case "link": return t("update.error.link")
200
200
  case "pnpm-failed": return t("update.error.pnpmFailed", { code: String(result.exitCode ?? "?") })
201
+ case "stale": return t("update.error.stale")
202
+ case "verify-failed": return t("update.error.verifyFailed")
201
203
  default: return result.error ?? t("update.error.unknown")
202
204
  }
203
205
  }
@@ -102,6 +102,8 @@ export const zh = {
102
102
  'update.error.notFound': '未找到 dsh-web-ui 聚合包安装,无法更新。',
103
103
  'update.error.link': '当前为本地开发模式(链接安装),无法自动更新。',
104
104
  'update.error.pnpmFailed': '更新执行失败(pnpm 退出码 {code}),详见下方输出。',
105
+ 'update.error.stale': '更新命令已执行,但安装版本未变化。常见原因:pnpm 11 的 minimumReleaseAge 门禁会静默跳过发布不足 24 小时的新版本。请在 profile 目录(~/.dsh/profiles/<profile>)的 pnpm-workspace.yaml 中配置 minimumReleaseAgeExclude(例如 "@linxin666/*")或 minimumReleaseAge: 0 后重新点击更新。',
106
+ 'update.error.verifyFailed': '更新命令已执行,但无法核对安装后的版本(可能是 registry 探测失败或安装路径变化)。请查看下方输出,或稍后重试。',
105
107
  'update.error.unknown': '更新失败,请重试。',
106
108
  } satisfies Record<string, string>
107
109
 
@@ -213,5 +215,7 @@ export const en = {
213
215
  'update.error.notFound': 'The dsh-web-ui aggregate package is not installed; nothing to update.',
214
216
  'update.error.link': 'Local development mode (link install) — automatic update is unavailable.',
215
217
  'update.error.pnpmFailed': 'The update failed (pnpm exited with code {code}); see the output below.',
218
+ 'update.error.stale': 'The update command ran but the installed versions did not change. Likely cause: the pnpm 11 minimumReleaseAge gate silently skips releases published less than 24 hours ago. Add minimumReleaseAgeExclude (e.g. "@linxin666/*") or set minimumReleaseAge: 0 in pnpm-workspace.yaml under the profile directory (~/.dsh/profiles/<profile>), then run the update again.',
219
+ 'update.error.verifyFailed': 'The update command ran but the installed versions could not be verified afterwards (registry probe failed or the install path changed). Inspect the output below, or retry later.',
216
220
  'update.error.unknown': 'Update failed; try again.',
217
221
  } satisfies Record<RemoteKey, string>
@@ -1,3 +1,4 @@
1
+ /* Generated by scripts/sync-shared.mjs from shared/client/settings/settings-card.module.css. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs". */
1
2
  /* Plugin settings card chrome + staged form fields.
2
3
  * Aligned with the official ui-settings-plugins PluginCard / fields CSS:
3
4
  * same semantic tokens, radius, typography and states so family cards read
@@ -120,6 +121,9 @@
120
121
  margin: 0;
121
122
  font-size: 12px;
122
123
  line-height: 1.5;
124
+ text-overflow: ellipsis;
125
+ overflow: hidden;
126
+ white-space: nowrap;
123
127
  }
124
128
 
125
129
  .discard,
@@ -229,6 +233,11 @@
229
233
  outline-offset: 2px;
230
234
  }
231
235
 
236
+ .reset:focus-visible {
237
+ outline: 2px solid var(--dsw-alias-brand-primary);
238
+ outline-offset: 2px;
239
+ }
240
+
232
241
  .input,
233
242
  .select {
234
243
  border: 1px solid var(--dsw-alias-border-l2);