@linxin666/dsh-remote-web-ui 0.1.15 → 0.1.17

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": "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.15",
4
+ "version": "0.1.17",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -30,6 +30,7 @@
30
30
  "lib/**/*.d.ts",
31
31
  "lib/**/*.d.ts.map",
32
32
  "src",
33
+ "assets",
33
34
  "cordis.patch.yml"
34
35
  ],
35
36
  "license": "Apache-2.0",
@@ -138,7 +138,13 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
138
138
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
139
139
  {props.children}
140
140
  <div className={css.footer}>
141
- {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}
142
148
  <button
143
149
  type="button"
144
150
  className={css.discard}
@@ -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>
@@ -121,6 +121,9 @@
121
121
  margin: 0;
122
122
  font-size: 12px;
123
123
  line-height: 1.5;
124
+ text-overflow: ellipsis;
125
+ overflow: hidden;
126
+ white-space: nowrap;
124
127
  }
125
128
 
126
129
  .discard,
@@ -20,6 +20,14 @@ export type FieldWrite =
20
20
  export interface FieldSpec {
21
21
  /** Field name inside the namespace section. */
22
22
  field: string
23
+ /**
24
+ * Whether the Host treats this field as a secret and redacts its value from
25
+ * the read-back (role('secret') in the section schema). Redacted secrets are
26
+ * never compared against the draft on save; the field lands when the scope
27
+ * reports the write succeeded (its secret-set marker under the bridge), so
28
+ * a successful secret save is not misreported as failed.
29
+ */
30
+ secret?: boolean
23
31
  /** Render a stored value as draft text; the empty string when the section carries none. */
24
32
  format: (value: unknown) => string
25
33
  /**
@@ -61,6 +69,12 @@ export interface CardShell {
61
69
  saving: boolean
62
70
  /** Whether the last save did not land as staged; cleared by the next edit or save. */
63
71
  failed: boolean
72
+ /**
73
+ * The rejection code/message the Host returned for the last failed save,
74
+ * surfaced next to the generic failure text. Undefined while no save has
75
+ * failed (or the failure carried no server reason).
76
+ */
77
+ failedReason?: string
64
78
  }
65
79
 
66
80
  /** The write actions the card's slot entry injects. */
@@ -87,10 +101,52 @@ interface StagedEdit {
87
101
  interface PlannedWrite {
88
102
  /** Field this entry writes. */
89
103
  field: string
104
+ /** The durable write this entry performs, described for a batched scope. */
105
+ op: BatchedWrite
90
106
  /** Perform the write and report whether the Host holds the staged value afterwards. */
91
107
  run: (() => Promise<boolean>) | undefined
92
108
  }
93
109
 
110
+ /** One durable write a batched settings scope performs. */
111
+ export interface BatchedWrite {
112
+ /** Field this entry writes. */
113
+ field: string
114
+ /** set stores a value; unset drops the leaf. */
115
+ op: 'set' | 'unset'
116
+ /** Value for op set (absent for unset). */
117
+ value?: unknown
118
+ }
119
+
120
+ /** Per-field outcome of one batched scope write. */
121
+ export interface BatchedFieldResult {
122
+ /** Field this entry writes. */
123
+ field: string
124
+ /** Whether the Host accepted this field's write (per the read-back view). */
125
+ landed: boolean
126
+ }
127
+
128
+ /**
129
+ * Result of a batched scope write. The bridge scope posts every planned write
130
+ * in one /mutate so the Host validate hook judges baseURL+model together; a
131
+ * batched refusal fails the whole save rather than per-field.
132
+ */
133
+ export interface BatchResult {
134
+ /** Whether the whole mutate was accepted. */
135
+ ok: boolean
136
+ /** Per-field success, in the request order (always present when ok). */
137
+ fields: BatchedFieldResult[]
138
+ /** Host rejection code (mutate refused). */
139
+ code?: string
140
+ /** Host rejection message (mutate refused). */
141
+ message?: string
142
+ }
143
+
144
+ /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
+ interface BatchedSettingsScope {
146
+ /** Write every operation in one scope mutation, reporting per-field success. */
147
+ mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
+ }
149
+
94
150
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
95
151
  export interface NumberConstraints {
96
152
  /** The accepted value must be a whole number. */
@@ -129,6 +185,16 @@ export function textField(field: string): FieldSpec {
129
185
  }
130
186
  }
131
187
 
188
+ /**
189
+ * A free-text field the Host treats as a secret and redacts from the read-back
190
+ * (role('secret') in the section schema). The card still edits it like text,
191
+ * but a save never compares the redacted value back and relies on the scope
192
+ * reporting the write landed.
193
+ */
194
+ export function secretField(field: string): FieldSpec {
195
+ return { ...textField(field), secret: true }
196
+ }
197
+
132
198
  /** A boolean field, edited through true/false draft text. */
133
199
  export function booleanField(field: string): FieldSpec {
134
200
  return {
@@ -170,6 +236,7 @@ export class CardForm<T> {
170
236
  private readonly listeners = new Set<() => void>()
171
237
  private saving = false
172
238
  private failed = false
239
+ private failedReason: string | undefined
173
240
 
174
241
  /** @param scope - the bound settings scope for this card's namespace. */
175
242
  constructor(
@@ -199,6 +266,7 @@ export class CardForm<T> {
199
266
  invalid: plan.some(item => item.run === undefined),
200
267
  saving: this.saving,
201
268
  failed: this.failed,
269
+ ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
202
270
  }
203
271
  }
204
272
 
@@ -229,6 +297,7 @@ export class CardForm<T> {
229
297
  if (this.staged.size === 0 && !this.failed) return
230
298
  this.staged.clear()
231
299
  this.failed = false
300
+ this.failedReason = undefined
232
301
  this.publish()
233
302
  },
234
303
  }
@@ -236,30 +305,57 @@ export class CardForm<T> {
236
305
 
237
306
  /**
238
307
  * Write every staged edit, then re-seed from what the Host accepted.
308
+ *
309
+ * When the scope carries the optional batch surface (the dsh-web-ui
310
+ * bridge scope), every planned write rides one mutation so cross-field
311
+ * validate hooks (baseURL+model) judge the batch as a unit instead of
312
+ * deadlocking on per-field writes. Otherwise the per-field loop runs.
313
+ * A field lands only when the Host reports it held the staged value; a
314
+ * landed field's draft is dropped, a failed one stays staged for the user.
239
315
  * @returns settlement after every write and the read-back.
240
316
  */
241
317
  async save(): Promise<void> {
242
318
  const plan = this.plan()
243
- const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run])
244
- if (plan.length === 0 || this.saving || writes.length !== plan.length) return
319
+ const valid = plan.filter(item => item.run !== undefined)
320
+ if (plan.length === 0 || this.saving || valid.length !== plan.length) return
321
+ const plannedWrites = valid.map(item => item.op)
245
322
  // Snapshot the fields this save writes, so edits staged while it is in
246
323
  // flight survive: only the staged keys this save actually wrote are cleared.
247
324
  const fields = new Set(plan.map(item => item.field))
248
325
  this.saving = true
249
326
  this.failed = false
327
+ this.failedReason = undefined
250
328
  this.publish()
251
- let landed = true
252
- for (const write of writes) {
253
- landed = await write() && landed
329
+ const landed = new Set<string>()
330
+ const batch = this.batchedScope()
331
+ if (batch !== undefined) {
332
+ const result = await batch.mutate(plannedWrites)
333
+ if (result.ok) {
334
+ for (const field of result.fields) {
335
+ if (field.landed) landed.add(field.field)
336
+ }
337
+ } else {
338
+ this.failedReason = result.message
339
+ }
340
+ } else {
341
+ for (const item of valid) {
342
+ if (await item.run!()) landed.add(item.field)
343
+ }
254
344
  }
255
- if (landed) {
256
- for (const field of fields) this.staged.delete(field)
345
+ for (const field of fields) {
346
+ if (landed.has(field)) this.staged.delete(field)
257
347
  }
258
348
  this.saving = false
259
- this.failed = !landed
349
+ this.failed = landed.size !== fields.size
260
350
  this.publish()
261
351
  }
262
352
 
353
+ /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
354
+ private batchedScope(): BatchedSettingsScope | undefined {
355
+ const candidate = this.scope as unknown as BatchedSettingsScope | undefined
356
+ return typeof candidate?.mutate === 'function' ? candidate : undefined
357
+ }
358
+
263
359
  /**
264
360
  * Every staged edit a save would write. An entry whose draft is not a value
265
361
  * its field accepts carries no write: the form is still dirty, and the save
@@ -272,14 +368,14 @@ export class CardForm<T> {
272
368
  for (const [field, staged] of this.staged) {
273
369
  const spec = this.specOf(field)
274
370
  if (staged.clear) {
275
- if (this.stored(field)) plan.push({ field, run: () => this.clear(field) })
371
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
276
372
  continue
277
373
  }
278
374
  if (staged.text === spec.format(this.sectionValue(field))) continue
279
375
  const write = spec.parse(staged.text)
280
- if (write === undefined) plan.push({ field, run: undefined })
281
- else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) })
282
- else plan.push({ field, run: () => this.store(field, write.value) })
376
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
377
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
378
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
283
379
  }
284
380
  return plan
285
381
  }
@@ -291,12 +387,18 @@ export class CardForm<T> {
291
387
 
292
388
  private async store(field: string, value: unknown): Promise<boolean> {
293
389
  await this.scope.set(field, value)
390
+ // A redacted secret never appears in the user layer read-back; judging it
391
+ // by value would misreport a successful secret save as failed. The bridge
392
+ // reports secret writes through its secret-set markers (batch path); on
393
+ // the per-field path the scope resolved, so the write is landed.
394
+ if (this.specOf(field).secret) return true
294
395
  return this.userLayer()?.[field] === value
295
396
  }
296
397
 
297
398
  private stage(field: string, edit: StagedEdit): void {
298
399
  this.staged.set(field, edit)
299
400
  this.failed = false
401
+ this.failedReason = undefined
300
402
  this.publish()
301
403
  }
302
404
 
package/src/index.ts CHANGED
@@ -27,7 +27,7 @@ import {
27
27
  fetchLatestVersion,
28
28
  resolveAnchorManifest,
29
29
  resolveUpdateTarget,
30
- runUpdate,
30
+ runUpdateVerified,
31
31
  type UpdateRunResult,
32
32
  } from './update.ts'
33
33
  import { makeUpdateRoutes } from './update-routes.ts'
@@ -238,19 +238,26 @@ export function apply(ctx: Context, config?: Config): void {
238
238
  }
239
239
  // ── remote update ────────────────────────────────────────────────────────
240
240
  // The dsh-web-ui self-update surface: probe the npm registry for family
241
- // releases and run `pnpm update` in the owning profile. Resolutions anchor
242
- // on the host process's own module graph, so the update always targets the
243
- // profile the running web GUI was booted from. The probe path resolves once
244
- // (the anchor stays the same package across updates); versions are re-read
245
- // from disk per check.
241
+ // releases and run `pnpm update --latest` in the owning profile. Resolutions
242
+ // anchor on the host process's own module graph, so the update always
243
+ // targets the profile the running web GUI was booted from. The anchor path
244
+ // is re-resolved per operation: pnpm removes the old version's .pnpm
245
+ // directory on update, so a boot-time captured path would fail to read
246
+ // after a successful update; versions are re-read from disk per check.
246
247
  const requireFromHost = createRequire(import.meta.url)
247
- const anchorManifestPath = resolveAnchorManifest(specifier => requireFromHost.resolve(specifier))
248
+ const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(specifier => {
249
+ try {
250
+ return requireFromHost.resolve(specifier)
251
+ } catch {
252
+ return undefined
253
+ }
254
+ })
248
255
  const updateRoutes = makeUpdateRoutes({
249
256
  // Control endpoints are host-surface only: a LAN/phone origin must never
250
257
  // trigger a real install on this machine.
251
258
  fence: request => isTrustedApiRequest(request, []),
252
259
  check: () => checkUpdates({
253
- anchorManifestPath,
260
+ anchorManifestPath: resolveAnchorPath(),
254
261
  resolve: specifier => {
255
262
  try {
256
263
  return requireFromHost.resolve(specifier)
@@ -261,7 +268,7 @@ export function apply(ctx: Context, config?: Config): void {
261
268
  fetchLatest: name => fetchLatestVersion(name, fetch),
262
269
  }),
263
270
  run: async (): Promise<UpdateRunResult> => {
264
- const target = resolveUpdateTarget({ anchorManifestPath })
271
+ const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
265
272
  if ('error' in target) {
266
273
  const code = target.error
267
274
  return {
@@ -272,7 +279,24 @@ export function apply(ctx: Context, config?: Config): void {
272
279
  errorCode: code,
273
280
  }
274
281
  }
275
- return runUpdate({ profileDir: target.profileDir, packages: target.packages })
282
+ // Verify the versions actually moved after a green pnpm exit: the pnpm
283
+ // 11 minimumReleaseAge gate can silently keep the installed versions
284
+ // (same-day releases), which a plain exit-0 check would report as
285
+ // success — the user then restarts and nothing changed.
286
+ return runUpdateVerified({
287
+ run: { profileDir: target.profileDir, packages: target.packages },
288
+ check: {
289
+ anchorManifestPath: resolveAnchorPath(),
290
+ resolve: specifier => {
291
+ try {
292
+ return requireFromHost.resolve(specifier)
293
+ } catch {
294
+ return undefined
295
+ }
296
+ },
297
+ fetchLatest: name => fetchLatestVersion(name, fetch),
298
+ },
299
+ })
276
300
  },
277
301
  })
278
302
  const routes = [
package/src/mobile-api.ts CHANGED
@@ -103,10 +103,23 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
103
103
  const { service, apiProxy, mobileEnterToSend } = deps
104
104
  const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS
105
105
 
106
+ /**
107
+ * Refresh the paired device's presence and report whether it is live.
108
+ * The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
109
+ * sender, so any activity on the mobile channel — a gated RPC, or the live
110
+ * SSE stream staying open — must count as presence. Without this, an
111
+ * idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
112
+ * wrongly reports it as disconnected.
113
+ */
114
+ const touchDeviceFor = (req: IncomingMessage): boolean => {
115
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
116
+ if (deviceId === undefined) return false
117
+ return service.touchDevice(deviceId)
118
+ }
119
+
106
120
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
107
121
  const gateOk = (req: IncomingMessage): boolean => {
108
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
109
- return deviceId !== undefined && service.hasDevice(deviceId)
122
+ return touchDeviceFor(req)
110
123
  }
111
124
 
112
125
  const writeJson = (res: ServerResponse, status: number, body: unknown): void => {
@@ -190,6 +203,10 @@ export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
190
203
  let closed = false
191
204
  const heartbeat = setInterval(() => {
192
205
  if (closed) return
206
+ // An open SSE stream proves the phone is still live even while the agent
207
+ // idles (no RPC traffic), so refresh presence alongside the transport
208
+ // keepalive — otherwise an idle phone drifts to "disconnected".
209
+ touchDeviceFor(req)
193
210
  try {
194
211
  res.write(': ping\n\n')
195
212
  } catch {
@@ -19,6 +19,11 @@ function mobileBundlePath(): string {
19
19
  return fileURLToPath(new URL('../lib/mobile.js', import.meta.url))
20
20
  }
21
21
 
22
+ /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
23
+ function appleTouchIconPath(): string {
24
+ return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url))
25
+ }
26
+
22
27
  /** The mobile page shell: minimal, offline-safe, no external assets. */
23
28
  function pageHtml(bundleUrl: string): string {
24
29
  return [
@@ -29,6 +34,10 @@ function pageHtml(bundleUrl: string): string {
29
34
  '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
30
35
  '<meta name="theme-color" content="#f3f5f9">',
31
36
  '<meta name="referrer" content="no-referrer">',
37
+ // iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
38
+ // the PNG ships with the package (opaque background — iOS composites
39
+ // transparency onto black, so the icon avoids transparent regions).
40
+ '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
32
41
  '<title>移动端远程控制</title>',
33
42
  '</head>',
34
43
  '<body>',
@@ -49,6 +58,16 @@ function writeStatic(res: ServerResponse, status: number, type: string, body: st
49
58
  res.end(body)
50
59
  }
51
60
 
61
+ /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
62
+ function writePng(res: ServerResponse, status: number, body: Buffer): void {
63
+ res.writeHead(status, {
64
+ 'content-type': 'image/png',
65
+ 'cache-control': 'public, max-age=31536000, immutable',
66
+ 'referrer-policy': 'no-referrer',
67
+ })
68
+ res.end(body)
69
+ }
70
+
52
71
  /**
53
72
  * Build the mobile page routes.
54
73
  * @returns the two exact routes to register on webServer.
@@ -70,8 +89,22 @@ export function makeMobileRoutes(): WebRoute[] {
70
89
  writeStatic(res, 500, 'text/plain', 'failed to read the mobile bundle')
71
90
  }
72
91
  }
92
+ const handleIcon = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
93
+ const path = appleTouchIconPath()
94
+ if (!existsSync(path)) {
95
+ writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found')
96
+ return
97
+ }
98
+ try {
99
+ const body = await readFile(path)
100
+ writePng(res, 200, body)
101
+ } catch {
102
+ writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon')
103
+ }
104
+ }
73
105
  return [
74
106
  { kind: 'exact', path: '/m', handler: handlePage },
75
107
  { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
108
+ { kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
76
109
  ]
77
110
  }