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

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 (38) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +5 -2
  3. package/README.zh.md +1 -1
  4. package/lib/client.js +30 -17
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +177 -34
  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 +27 -7
  12. package/lib/types/client/locales.js +2 -2
  13. package/lib/types/client/settings-form.d.ts +14 -5
  14. package/lib/types/client/settings-form.d.ts.map +1 -1
  15. package/lib/types/client/settings-form.js +38 -10
  16. package/lib/types/mobile/mux.d.ts +13 -4
  17. package/lib/types/mobile/mux.d.ts.map +1 -1
  18. package/lib/types/mobile/mux.js +50 -30
  19. package/lib/types/mobile/mux.test.js +75 -6
  20. package/lib/types/mobile-api.d.ts +2 -0
  21. package/lib/types/mobile-api.d.ts.map +1 -1
  22. package/lib/types/mobile-api.js +4 -1
  23. package/lib/types/routes.d.ts +17 -0
  24. package/lib/types/routes.d.ts.map +1 -1
  25. package/lib/types/routes.js +49 -10
  26. package/lib/types/update.d.ts +5 -1
  27. package/lib/types/update.d.ts.map +1 -1
  28. package/lib/types/update.js +122 -26
  29. package/package.json +1 -1
  30. package/src/client/PluginSettingsCard.tsx +83 -17
  31. package/src/client/locales.ts +2 -2
  32. package/src/client/settings-card.module.css +6 -0
  33. package/src/client/settings-form.ts +41 -9
  34. package/src/mobile/mux.test.ts +84 -6
  35. package/src/mobile/mux.ts +46 -25
  36. package/src/mobile-api.ts +6 -1
  37. package/src/routes.ts +51 -10
  38. package/src/update.ts +116 -26
@@ -40,6 +40,8 @@ const MOBILE_ALLOWLIST = new Set([
40
40
  const MOBILE_PREFERENCES_METHOD = 'mobile.preferences';
41
41
  /** One session.list page (thin phones load incrementally). */
42
42
  const SESSION_PAGE_SIZE = 20;
43
+ /** SSE keep-alive ping cadence for the live mux stream (single connection). */
44
+ const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000;
43
45
  /** Encode one list position as an opaque continuation cursor. */
44
46
  function sessionListCursor(updatedAt, sessionId) {
45
47
  return `${updatedAt}:${sessionId}`;
@@ -74,6 +76,7 @@ const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
74
76
  */
75
77
  export function makeMobileApiRoutes(deps) {
76
78
  const { service, apiProxy, mobileEnterToSend } = deps;
79
+ const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
77
80
  /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
78
81
  const gateOk = (req) => {
79
82
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
@@ -167,7 +170,7 @@ export function makeMobileApiRoutes(deps) {
167
170
  catch {
168
171
  // The write failed; the close handler tears the subscription down.
169
172
  }
170
- }, 15_000);
173
+ }, eventsHeartbeatMs);
171
174
  const onClose = () => {
172
175
  if (closed)
173
176
  return;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { IncomingMessage, ServerResponse } from 'node:http';
11
11
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver';
12
+ import { z } from 'zod';
12
13
  import { type PairingService, type PairingSnapshot } from './pairing.ts';
13
14
  /**
14
15
  * Browser-trust fence for the /api/pair routes, mirroring the connection
@@ -43,6 +44,22 @@ export declare const PAIR_PATHS: {
43
44
  readonly status: "/api/pair/status";
44
45
  readonly events: "/api/pair/events";
45
46
  };
47
+ /**
48
+ * /api/pair request payload contracts. Each POST endpoint validates its body
49
+ * against one of these instead of reaching into a hand-parsed object: the
50
+ * control-plane endpoints that carry no meaningful payload use the permissive
51
+ * pairActionPayloadSchema so their smoke calls keep working unchanged, while
52
+ * issue/accept enforce their optional/required fields. Unknown (extra) keys
53
+ * are tolerated exactly as the previous manual reads ignored them.
54
+ */
55
+ export declare const issuePayloadSchema: z.ZodObject<{
56
+ workspaceId: z.ZodOptional<z.ZodString>;
57
+ address: z.ZodOptional<z.ZodString>;
58
+ }, z.core.$strip>;
59
+ export declare const acceptPayloadSchema: z.ZodObject<{
60
+ token: z.ZodDefault<z.ZodString>;
61
+ }, z.core.$strip>;
62
+ export declare const pairActionPayloadSchema: z.ZodObject<{}, z.core.$loose>;
46
63
  /** The SSE fan-out for desktop panel status. */
47
64
  export declare class PairingEventsStream {
48
65
  private readonly streams;
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAGhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;CAOb,CAAA;AAiCV,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAgK3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAGhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;CAOb,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA4CjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CA4K3D"}
@@ -7,6 +7,7 @@
7
7
  * cookie set on accept is the device identity the api/gate listener checks
8
8
  * on every other /api request.
9
9
  */
10
+ import { z } from 'zod';
10
11
  import { UnknownLanAddressError } from "./pairing.js";
11
12
  import { isLoopbackClient, readCookie } from "./gate.js";
12
13
  /**
@@ -90,6 +91,32 @@ export const PAIR_PATHS = {
90
91
  status: '/api/pair/status',
91
92
  events: '/api/pair/events',
92
93
  };
94
+ /**
95
+ * /api/pair request payload contracts. Each POST endpoint validates its body
96
+ * against one of these instead of reaching into a hand-parsed object: the
97
+ * control-plane endpoints that carry no meaningful payload use the permissive
98
+ * pairActionPayloadSchema so their smoke calls keep working unchanged, while
99
+ * issue/accept enforce their optional/required fields. Unknown (extra) keys
100
+ * are tolerated exactly as the previous manual reads ignored them.
101
+ */
102
+ export const issuePayloadSchema = z.object({
103
+ workspaceId: z.string().min(1).optional(),
104
+ address: z.string().min(1).optional(),
105
+ });
106
+ export const acceptPayloadSchema = z.object({
107
+ token: z.string().default(''),
108
+ });
109
+ export const pairActionPayloadSchema = z.object({}).passthrough();
110
+ /**
111
+ * Parse a pair request body through schema. A missing/empty or non-object
112
+ * body is treated as an empty object (the same way the previous manual reads
113
+ * yielded absent fields), and a value that fails the schema returns
114
+ * `undefined` so the caller can answer with the existing error shape.
115
+ */
116
+ function parsePairPayload(schema, body) {
117
+ const result = schema.safeParse(body ?? {});
118
+ return result.success ? result.data : undefined;
119
+ }
93
120
  /** One JSON response. */
94
121
  function writeJson(res, status, body) {
95
122
  const payload = JSON.stringify(body);
@@ -205,12 +232,12 @@ export function makeRoutes(deps) {
205
232
  return;
206
233
  }
207
234
  const body = await readJsonBody(req);
208
- const workspaceId = body === undefined || typeof body.workspaceId !== 'string' || body.workspaceId === ''
209
- ? undefined
210
- : body.workspaceId;
211
- const address = body === undefined || typeof body.address !== 'string' || body.address === ''
212
- ? undefined
213
- : body.address;
235
+ const payload = parsePairPayload(issuePayloadSchema, body);
236
+ if (payload === undefined) {
237
+ writeJson(res, 400, { ok: false, code: 'bad-payload' });
238
+ return;
239
+ }
240
+ const { workspaceId, address } = payload;
214
241
  try {
215
242
  const { token, expiresAt } = service.issue(workspaceId, address);
216
243
  // The default base is the public (tunneled) URL when configured — a
@@ -255,8 +282,12 @@ export function makeRoutes(deps) {
255
282
  return;
256
283
  }
257
284
  const body = await readJsonBody(req);
258
- const token = typeof body?.token === 'string' ? body.token : '';
259
- const result = service.accept(token);
285
+ const payload = parsePairPayload(acceptPayloadSchema, body);
286
+ if (payload === undefined) {
287
+ writeJson(res, 400, { ok: false, code: 'bad-payload' });
288
+ return;
289
+ }
290
+ const result = service.accept(payload.token);
260
291
  if (!result.ok) {
261
292
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
262
293
  return;
@@ -276,7 +307,11 @@ export function makeRoutes(deps) {
276
307
  writeJson(res, 403, { ok: false, code: 'forbidden' });
277
308
  return;
278
309
  }
279
- await readJsonBody(req);
310
+ const body = await readJsonBody(req);
311
+ if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
312
+ writeJson(res, 400, { ok: false, code: 'bad-payload' });
313
+ return;
314
+ }
280
315
  service.stop();
281
316
  writeJson(res, 200, { ok: true });
282
317
  };
@@ -287,7 +322,11 @@ export function makeRoutes(deps) {
287
322
  writeJson(res, 403, { ok: false, code: 'forbidden' });
288
323
  return;
289
324
  }
290
- await readJsonBody(req);
325
+ const body = await readJsonBody(req);
326
+ if (parsePairPayload(pairActionPayloadSchema, body) === undefined) {
327
+ writeJson(res, 400, { ok: false, code: 'bad-payload' });
328
+ return;
329
+ }
291
330
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
292
331
  if (deviceId === undefined || !service.heartbeat(deviceId)) {
293
332
  writeJson(res, 401, { ok: false, code: 'unpaired' });
@@ -180,9 +180,13 @@ export interface UpdateRunDeps {
180
180
  spawnImpl?: typeof spawn;
181
181
  /** Hard timeout; the child is killed on expiry. */
182
182
  timeoutMs?: number;
183
+ /** Platform override (defaults to process.platform; test seam). */
184
+ platform?: NodeJS.Platform;
183
185
  }
184
186
  /**
185
- * Run the update: `pnpm update <packages>` inside the profile directory.
187
+ * Run the update inside the profile directory. Tries pnpm first, falls back
188
+ * to corepack and then npx when the previous command is missing (ENOENT);
189
+ * all candidates share one hard timeout and keep accumulating output.
186
190
  * @param deps - profile dir, package list, and spawn/timeout seams.
187
191
  * @returns the outcome with captured output.
188
192
  */
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
@@ -286,48 +286,144 @@ export async function checkUpdates(deps) {
286
286
  /** Cap on captured pnpm output (keeps error payloads bounded). */
287
287
  const OUTPUT_CAP = 16 * 1024;
288
288
  /**
289
- * Run the update: `pnpm update <packages>` inside the profile directory.
289
+ * Windows cmd command-not-found stderr. With shell:true a missing shim
290
+ * exits with code 1 (cmd cannot report ENOENT), so the fallback chain
291
+ * detects this message instead of the spawn error event.
292
+ */
293
+ const WIN_CMD_MISSING_RE = /not recognized as an internal or external command/i;
294
+ /**
295
+ * Run the update inside the profile directory. Tries pnpm first, falls back
296
+ * to corepack and then npx when the previous command is missing (ENOENT);
297
+ * all candidates share one hard timeout and keep accumulating output.
290
298
  * @param deps - profile dir, package list, and spawn/timeout seams.
291
299
  * @returns the outcome with captured output.
292
300
  */
293
301
  export function runUpdate(deps) {
294
302
  return new Promise((resolve) => {
295
- const child = (deps.spawnImpl ?? spawn)('pnpm', ['update', ...deps.packages], {
303
+ const spawnImpl = deps.spawnImpl ?? spawn;
304
+ const packages = deps.packages;
305
+ const platform = deps.platform ?? process.platform;
306
+ // Windows ships pnpm/corepack/npx as .cmd shims; Node's spawn cannot
307
+ // start .cmd files directly (ENOENT even when installed), so route
308
+ // them through cmd.exe there. POSIX spawns stay shell-free.
309
+ const spawnOptions = {
296
310
  cwd: deps.profileDir,
297
311
  stdio: ['ignore', 'pipe', 'pipe'],
298
- });
312
+ ...(platform === 'win32' ? { shell: true } : {}),
313
+ };
314
+ // Ordered fallback chain: each is tried only when the previous one is
315
+ // missing on PATH (ENOENT on the spawn error event, never on close).
316
+ const candidates = [
317
+ { command: 'pnpm', args: ['update', ...packages] },
318
+ { command: 'corepack', args: ['pnpm', 'update', ...packages] },
319
+ { command: 'npx', args: ['--yes', 'pnpm', 'update', ...packages] },
320
+ ];
321
+ // `output` accumulates across candidates for UI display; `currentOutput`
322
+ // is reset per candidate and carries only that candidate's own diagnostics
323
+ // (its tail is subject to OUTPUT_CAP). The win32 missing-command test must
324
+ // run against `currentOutput` so a previous candidate's 'not recognized'
325
+ // stderr cannot misclassify a real failure in the next one.
299
326
  let output = '';
327
+ let currentOutput = '';
300
328
  const append = (chunk) => {
301
329
  output += chunk.toString('utf8');
302
330
  if (output.length > OUTPUT_CAP)
303
331
  output = output.slice(output.length - OUTPUT_CAP);
332
+ currentOutput += chunk.toString('utf8');
333
+ if (currentOutput.length > OUTPUT_CAP)
334
+ currentOutput = currentOutput.slice(currentOutput.length - OUTPUT_CAP);
335
+ };
336
+ let currentChild;
337
+ // Terminal guard: once a result is produced the promise is settled and no
338
+ // further candidate is started (a killed child's late close must not
339
+ // respawn anything after a timeout resolution).
340
+ let finished = false;
341
+ const finish = (result) => {
342
+ if (finished)
343
+ return;
344
+ finished = true;
345
+ clearTimeout(timer);
346
+ resolve(result);
304
347
  };
305
- child.stdout?.on('data', append);
306
- child.stderr?.on('data', append);
307
348
  const timer = setTimeout(() => {
308
- child.kill('SIGTERM');
309
- resolve({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' });
349
+ if (finished)
350
+ return;
351
+ if (platform === 'win32') {
352
+ // cmd.exe under shell:true only kills the shell wrapper; kill the whole
353
+ // process tree best-effort so pnpm/npx do not keep running.
354
+ const pid = currentChild?.pid;
355
+ if (pid !== undefined && pid > 0) {
356
+ try {
357
+ spawnImpl('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore' });
358
+ }
359
+ catch {
360
+ // Best-effort kill; fall through to the timeout result.
361
+ }
362
+ }
363
+ }
364
+ else {
365
+ currentChild?.kill('SIGTERM');
366
+ }
367
+ finish({ ok: false, exitCode: null, output, error: 'update timed out; install process killed', errorCode: 'timeout' });
310
368
  }, deps.timeoutMs ?? 10 * 60_000);
311
- child.on('error', (error) => {
312
- clearTimeout(timer);
313
- const missing = error.code === 'ENOENT';
314
- resolve({
315
- ok: false,
316
- exitCode: null,
317
- output,
318
- error: missing ? 'pnpm not found on PATH' : error.message,
319
- errorCode: missing ? 'pnpm-missing' : undefined,
369
+ const runCandidate = (index) => {
370
+ if (finished)
371
+ return;
372
+ if (index >= candidates.length) {
373
+ finish({
374
+ ok: false,
375
+ exitCode: null,
376
+ output,
377
+ error: 'pnpm not found on PATH (tried pnpm, corepack, npx); install pnpm and restart the app',
378
+ errorCode: 'pnpm-missing',
379
+ });
380
+ return;
381
+ }
382
+ const candidate = candidates[index];
383
+ currentOutput = '';
384
+ const child = spawnImpl(candidate.command, candidate.args, spawnOptions);
385
+ currentChild = child;
386
+ let settled = false;
387
+ const once = (fn) => {
388
+ if (settled)
389
+ return;
390
+ settled = true;
391
+ fn();
392
+ };
393
+ child.stdout?.on('data', append);
394
+ child.stderr?.on('data', append);
395
+ child.on('error', (error) => {
396
+ once(() => {
397
+ if (error.code === 'ENOENT') {
398
+ // Command missing: try the next candidate in the chain.
399
+ runCandidate(index + 1);
400
+ }
401
+ else {
402
+ finish({ ok: false, exitCode: null, output, error: error.message, errorCode: undefined });
403
+ }
404
+ });
320
405
  });
321
- });
322
- child.on('close', (code) => {
323
- clearTimeout(timer);
324
- resolve({
325
- ok: code === 0,
326
- exitCode: code,
327
- output,
328
- error: code === 0 ? undefined : 'pnpm exited with code ' + String(code),
329
- ...(code === 0 ? {} : { errorCode: 'pnpm-failed' }),
406
+ child.on('close', (code) => {
407
+ if (settled || finished)
408
+ return;
409
+ // Windows + shell: a missing command reports exit 1 with
410
+ // 'not recognized' instead of ENOENT — keep the chain going. Checked
411
+ // against this candidate's own output so a prior fallback's stderr
412
+ // cannot misclassify a real failure here.
413
+ if (platform === 'win32' && code !== 0 && WIN_CMD_MISSING_RE.test(currentOutput)) {
414
+ runCandidate(index + 1);
415
+ return;
416
+ }
417
+ settled = true;
418
+ finish({
419
+ ok: code === 0,
420
+ exitCode: code,
421
+ output,
422
+ error: code === 0 ? undefined : 'pnpm exited with code ' + String(code),
423
+ ...(code === 0 ? {} : { errorCode: 'pnpm-failed' }),
424
+ });
330
425
  });
331
- });
426
+ };
427
+ runCandidate(0);
332
428
  });
333
429
  }
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.13",
4
+ "version": "0.1.15",
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"
@@ -259,4 +279,50 @@ export function BooleanField(props: FieldProps & {
259
279
  <p className={css.hint}>{props.hint}</p>
260
280
  </div>
261
281
  )
262
- }
282
+ }
283
+
284
+ /** A staged enumerated field rendered as a select. */
285
+ export function ChoiceField(props: FieldProps & {
286
+ /** Copy for the inherit option (draft text is the empty string). */
287
+ inheritLabel: string
288
+ /** Choices rendered in order; `value` is the draft/stored text. */
289
+ choices: ReadonlyArray<{ value: string; label: string }>
290
+ }) {
291
+ return (
292
+ <div className={css.field}>
293
+ <div className={css.head}>
294
+ <label className={css.label} htmlFor={props.id}>{props.label}</label>
295
+ {props.overridden
296
+ ? (
297
+ <span className={css.badges}>
298
+ <span className={css.badge}>{props.overriddenLabel}</span>
299
+ <button
300
+ type="button"
301
+ className={css.reset}
302
+ disabled={props.disabled}
303
+ onClick={props.onReset}
304
+ >
305
+ {props.resetLabel}
306
+ </button>
307
+ </span>
308
+ )
309
+ : null}
310
+ </div>
311
+ <select
312
+ id={props.id}
313
+ className={css.select}
314
+ value={props.text}
315
+ disabled={props.disabled}
316
+ onChange={(event) => { props.onEdit(event.target.value) }}
317
+ >
318
+ <option value="">{props.inheritLabel}</option>
319
+ {props.choices.map(choice => (
320
+ <option key={choice.value} value={choice.value}>{choice.label}</option>
321
+ ))}
322
+ </select>
323
+ <p className={props.invalid ? css.invalid : css.hint}>
324
+ {props.invalid ? props.invalidLabel : props.hint}
325
+ </p>
326
+ </div>
327
+ )
328
+ }
@@ -97,7 +97,7 @@ export const zh = {
97
97
  'update.packages': '组件版本',
98
98
  'update.retry': '重新检查',
99
99
  'update.close': '关闭更新面板',
100
- 'update.error.pnpmMissing': '未找到 pnpm,请先安装 pnpm(brew install pnpm 或 corepack enable)。',
100
+ 'update.error.pnpmMissing': '未找到 pnpm,已尝试 pnpm / corepack / npx 仍不可用。请安装 pnpm 后重启应用(Windows: npm install -g pnpm 或 corepack enable;macOS: brew install pnpm 或 corepack enable)。',
101
101
  'update.error.timeout': '更新超时,已终止安装进程,请重试。',
102
102
  'update.error.notFound': '未找到 dsh-web-ui 聚合包安装,无法更新。',
103
103
  'update.error.link': '当前为本地开发模式(链接安装),无法自动更新。',
@@ -208,7 +208,7 @@ export const en = {
208
208
  'update.packages': 'Component versions',
209
209
  'update.retry': 'Check again',
210
210
  'update.close': 'Close update panel',
211
- 'update.error.pnpmMissing': 'pnpm was not found. Install it first (brew install pnpm or corepack enable).',
211
+ 'update.error.pnpmMissing': 'pnpm was not found (tried pnpm / corepack / npx). Install pnpm and restart the app (Windows: npm install -g pnpm or corepack enable; macOS: brew install pnpm or corepack enable).',
212
212
  'update.error.timeout': 'The update timed out and the install process was killed; try again.',
213
213
  'update.error.notFound': 'The dsh-web-ui aggregate package is not installed; nothing to update.',
214
214
  'update.error.link': 'Local development mode (link install) — automatic update is unavailable.',
@@ -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
@@ -229,6 +230,11 @@
229
230
  outline-offset: 2px;
230
231
  }
231
232
 
233
+ .reset:focus-visible {
234
+ outline: 2px solid var(--dsw-alias-brand-primary);
235
+ outline-offset: 2px;
236
+ }
237
+
232
238
  .input,
233
239
  .select {
234
240
  border: 1px solid var(--dsw-alias-border-l2);