dsh-plugin-update 0.5.8 → 0.7.0

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/dist/entry.js CHANGED
@@ -3,7 +3,8 @@ import { DEFAULT_PREFIX, MIN_PANEL_POLL_MS, buildPhoneNames } from "./config.js"
3
3
  import {
4
4
  failureCodeOf,
5
5
  mountUpdatePanel,
6
- normalizePanelTheme
6
+ normalizePanelTheme,
7
+ themeTokensStyleFor
7
8
  } from "./panel.js";
8
9
  import { BILINGUAL_CSS, copyHTML, copyText } from "./bilingual.js";
9
10
  import { resolveLang, subscribeLang } from "./lang.js";
@@ -62,11 +63,51 @@ function entryLabelFor(state, lang) {
62
63
  const l = lang ?? resolveLang();
63
64
  return copyText(entryBilingualKeyFor(state), l, entryBilingualValuesFor(state));
64
65
  }
66
+ function entrySizingError(raw) {
67
+ return new Error(`[dsh-plugin-update] \u5165\u53E3\u4EF6\u5C3A\u5BF8\u53C2\u6570 sizing \u975E\u6CD5\uFF1A\u53EA\u6536 fontSize / padding / borderRadius\uFF08\u975E\u7A7A CSS \u503C\uFF09\u4E0E scale\uFF08\u5927\u4E8E 0 \u7684\u6709\u9650\u6570\uFF09\uFF08\u6536\u5230 ${JSON.stringify(raw ?? null)})`);
68
+ }
69
+ function isSafeEntryCssValue(value) {
70
+ const v = value.trim();
71
+ if (!v || v.length > 200) return false;
72
+ if (/[;"'<>`{}!&]/.test(v)) return false;
73
+ if (/url\s*\(/i.test(v)) return false;
74
+ if (/expression\s*\(/i.test(v)) return false;
75
+ if (/javascript\s*:/i.test(v)) return false;
76
+ return true;
77
+ }
78
+ function entrySizingStyleFor(sizing) {
79
+ if (sizing === void 0 || sizing === null) return "";
80
+ if (typeof sizing !== "object" || Array.isArray(sizing)) throw entrySizingError(sizing);
81
+ for (const key of Object.keys(sizing)) {
82
+ if (key !== "fontSize" && key !== "padding" && key !== "borderRadius" && key !== "scale") {
83
+ throw entrySizingError(sizing);
84
+ }
85
+ }
86
+ const parts = [];
87
+ const { fontSize, padding, borderRadius, scale } = sizing;
88
+ if (fontSize !== void 0) {
89
+ if (typeof fontSize !== "string" || !isSafeEntryCssValue(fontSize)) throw entrySizingError(sizing);
90
+ parts.push(`--dsh-update-entry-font-size:${fontSize.trim()}`);
91
+ }
92
+ if (padding !== void 0) {
93
+ if (typeof padding !== "string" || !isSafeEntryCssValue(padding)) throw entrySizingError(sizing);
94
+ parts.push(`--dsh-update-entry-padding:${padding.trim()}`);
95
+ }
96
+ if (borderRadius !== void 0) {
97
+ if (typeof borderRadius !== "string" || !isSafeEntryCssValue(borderRadius)) throw entrySizingError(sizing);
98
+ parts.push(`--dsh-update-entry-border-radius:${borderRadius.trim()}`);
99
+ }
100
+ if (scale !== void 0) {
101
+ if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0) throw entrySizingError(sizing);
102
+ parts.push(`--dsh-update-entry-scale:${String(scale)}`);
103
+ }
104
+ return parts.join(";");
105
+ }
65
106
  const UPDATE_ENTRY_CSS = [
66
- '.dsh-upd-entry{display:inline-flex;align-items:center;gap:8px;font:13px/1.6 system-ui,"Microsoft YaHei",sans-serif;color:var(--dsh-upd-fg,#1f2937)}',
67
- ".dsh-upd-entry-btn{font:inherit;border:1px solid var(--dsh-upd-line,#d1d5db);border-radius:6px;",
68
- "background:var(--dsh-upd-btn,#f9fafb);color:inherit;padding:4px 12px;cursor:pointer}",
69
- ".dsh-upd-entry-btn:hover{border-color:var(--dsh-upd-primary,#2563eb)}",
107
+ '.dsh-upd-entry{display:inline-flex;align-items:center;gap:8px;font:13px/1.6 var(--dsh-update-font-sans,system-ui,"Microsoft YaHei",sans-serif);font-size:var(--dsh-update-entry-font-size,13px);color:var(--dsh-update-text,#1f2937)}',
108
+ ".dsh-upd-entry-btn{font:inherit;border:1px solid var(--dsh-update-border,#d1d5db);border-radius:var(--dsh-update-entry-border-radius,6px);",
109
+ "background:var(--dsh-update-button-bg,#f9fafb);color:inherit;padding:var(--dsh-update-entry-padding,4px 12px);zoom:var(--dsh-update-entry-scale,1);cursor:pointer}",
110
+ ".dsh-upd-entry-btn:hover{border-color:var(--dsh-update-primary,#2563eb)}",
70
111
  ".dsh-upd-entry-btn,.dsh-upd-entry-dot{transition:background-color .15s ease,border-color .15s ease,color .15s ease,transform .06s ease}",
71
112
  ".dsh-upd-entry-btn:active:not(:disabled){transform:translateY(1px)}",
72
113
  ".dsh-upd-entry-btn:disabled,.dsh-upd-entry-dot:disabled{opacity:.55;cursor:wait}",
@@ -74,25 +115,26 @@ const UPDATE_ENTRY_CSS = [
74
115
  "border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:dsh-upd-entry-spin .8s linear infinite}",
75
116
  "@keyframes dsh-upd-entry-spin{to{transform:rotate(360deg)}}",
76
117
  '@media (prefers-reduced-motion: reduce){.dsh-upd-entry-btn,.dsh-upd-entry-dot{transition:none}.dsh-upd-entry-btn[aria-busy="true"]::after{animation:none}}',
77
- ".dsh-upd-entry-btn:focus-visible,.dsh-upd-entry-dot:focus-visible{outline:2px solid var(--dsh-upd-focus,#2563eb);outline-offset:1px}",
78
- '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-btn{border-color:var(--dsh-upd-ok-line,#059669);color:var(--dsh-upd-ok-line,#059669)}',
79
- '.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-btn{border-color:var(--dsh-upd-warn-line,#d97706);color:var(--dsh-upd-warn-line,#d97706)}',
80
- '.dsh-upd-entry[data-state="failed"] .dsh-upd-entry-btn{border-color:var(--dsh-upd-bad-line,#dc2626);color:var(--dsh-upd-bad-line,#dc2626)}',
81
- ".dsh-upd-entry-dot{width:10px;height:10px;padding:0;border:0;border-radius:50%;background:var(--dsh-upd-line,#9ca3af);cursor:pointer}",
82
- '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-dot{background:var(--dsh-upd-ok-line,#059669)}',
83
- '.dsh-upd-entry[data-state="busy"] .dsh-upd-entry-dot,.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-dot{background:var(--dsh-upd-warn-line,#d97706)}',
84
- '.dsh-upd-entry[data-state="failed"] .dsh-upd-entry-dot{background:var(--dsh-upd-bad-line,#dc2626)}',
118
+ ".dsh-upd-entry-btn:focus-visible,.dsh-upd-entry-dot:focus-visible{outline:2px solid var(--dsh-update-focus,#2563eb);outline-offset:1px}",
119
+ '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-btn{border-color:var(--dsh-update-ok-border,#059669);color:var(--dsh-update-ok-border,#059669)}',
120
+ '.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-btn{border-color:var(--dsh-update-warn-border,#d97706);color:var(--dsh-update-warn-border,#d97706)}',
121
+ '.dsh-upd-entry[data-state="failed"] .dsh-upd-entry-btn{border-color:var(--dsh-update-bad-border,#dc2626);color:var(--dsh-update-bad-border,#dc2626)}',
122
+ ".dsh-upd-entry-dot{width:10px;height:10px;padding:0;border:0;border-radius:50%;background:var(--dsh-update-border,#9ca3af);cursor:pointer}",
123
+ '.dsh-upd-entry[data-state="update"] .dsh-upd-entry-dot{background:var(--dsh-update-ok-border,#059669)}',
124
+ '.dsh-upd-entry[data-state="busy"] .dsh-upd-entry-dot,.dsh-upd-entry[data-state="restart"] .dsh-upd-entry-dot{background:var(--dsh-update-warn-border,#d97706)}',
125
+ '.dsh-upd-entry[data-state="failed"] .dsh-upd-entry-dot{background:var(--dsh-update-bad-border,#dc2626)}',
85
126
  // 小字自带底(深色宿主 + 浅色变量时也读得出;浅底宿主上只是多一圈细线,不抢戏)。
86
- ".dsh-upd-entry-note{font-size:12.5px;opacity:.9;background:var(--dsh-upd-bg,#ffffff);border:1px solid var(--dsh-upd-line,#e5e7eb);border-radius:4px;padding:1px 8px}",
87
- '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-note{background:var(--d5-card);border-color:var(--d5-line-strong);color:var(--d5-ink)}',
88
- '.dsh-upd-entry[data-theme="archive"]{--d5-ink:#1a1a1a;--d5-muted:#6f675a;--d5-line-strong:#c4b896;--d5-accent:#c8402a;--d5-card:#fffdf6;',
89
- 'font-family:Georgia,"Songti SC","STSong","SimSun",serif;color:var(--d5-ink)}',
127
+ ".dsh-upd-entry-note{font-size:12.5px;opacity:.9;background:var(--dsh-update-bg,#ffffff);border:1px solid var(--dsh-update-border,#e5e7eb);border-radius:var(--dsh-update-radius-badge,4px);padding:1px 8px}",
128
+ '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-note{background:var(--dsh-update-bg);border-color:var(--dsh-update-border-strong);color:var(--dsh-update-text)}',
129
+ '.dsh-upd-entry[data-theme="archive"]{--dsh-update-text:#1a1a1a;--dsh-update-text-muted:#6f675a;--dsh-update-border-strong:#c4b896;--dsh-update-primary:#c8402a;--dsh-update-bg:#fffdf6;',
130
+ '--dsh-update-font-serif:Georgia,"Songti SC","STSong","SimSun",serif;',
131
+ 'font-family:var(--dsh-update-font-serif,Georgia,"Songti SC","STSong","SimSun",serif);color:var(--dsh-update-text)}',
90
132
  // 按钮脸自己不透明(深色宿主 + 浅色系统变量时也读得出;hover 红在深浅底上都可见)。
91
- '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn{border-color:var(--d5-line-strong);background:var(--d5-card);color:var(--d5-ink);border-radius:3px}',
92
- '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn:hover{border-color:var(--d5-accent);color:var(--d5-accent)}',
93
- '@media (prefers-color-scheme: dark){.dsh-upd-entry[data-theme="archive"]{--d5-ink:#ece5d3;--d5-muted:#a89c83;--d5-line-strong:#5c4e3b;--d5-accent:#e0684e;--d5-card:#1e1a15}}',
133
+ '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn{border-color:var(--dsh-update-border-strong);background:var(--dsh-update-bg);color:var(--dsh-update-text);border-radius:var(--dsh-update-entry-border-radius,3px)}',
134
+ '.dsh-upd-entry[data-theme="archive"] .dsh-upd-entry-btn:hover{border-color:var(--dsh-update-primary);color:var(--dsh-update-primary)}',
135
+ '@media (prefers-color-scheme: dark){.dsh-upd-entry[data-theme="archive"]{--dsh-update-text:#ece5d3;--dsh-update-text-muted:#a89c83;--dsh-update-border-strong:#5c4e3b;--dsh-update-primary:#e0684e;--dsh-update-bg:#1e1a15}}',
94
136
  "@media (prefers-color-scheme: dark){.dsh-upd-entry{color:#e5e7eb}",
95
- ".dsh-upd-entry-btn{--dsh-upd-btn:#1f2937;--dsh-upd-line:#374151}}"
137
+ ".dsh-upd-entry-btn{--dsh-update-button-bg:#1f2937;--dsh-update-border:#374151}}"
96
138
  ].join("\n");
97
139
  const ENTRY_ATTR = "data-dsh-upd-entry";
98
140
  const ENTRY_SELECTOR = "[data-dsh-upd-entry]";
@@ -137,8 +179,8 @@ function mountUpdateEntry(container, options) {
137
179
  if (openOn !== "has-update" && openOn !== "always" && openOn !== "manual" && openOn !== "direct") {
138
180
  throw new Error(`[dsh-plugin-update] \u70B9\u51FB\u53BB\u5411\u975E\u6CD5\uFF1A\u53EA\u6536 has-update / always / manual / direct\uFF08\u6536\u5230 ${JSON.stringify(options.openOn)}\uFF09`);
139
181
  }
140
- if (options.theme !== void 0 && options.theme !== "default" && options.theme !== "archive" && options.theme !== "d5-paper") {
141
- throw new Error(`[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08d5-paper \u4E3A\u65E7\u522B\u540D\u4ECD\u53EF\u7528\uFF09\uFF08\u6536\u5230 ${JSON.stringify(options.theme)}\uFF09`);
182
+ if (options.theme !== void 0 && options.theme !== "default" && options.theme !== "archive") {
183
+ throw new Error(`[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08\u6536\u5230 ${JSON.stringify(options.theme)}\uFF09`);
142
184
  }
143
185
  let theme = normalizePanelTheme(options.theme ?? "default");
144
186
  const pollMs = options.pollMs;
@@ -146,6 +188,9 @@ function mountUpdateEntry(container, options) {
146
188
  throw new Error(`[dsh-plugin-update] \u9762\u677F\u8F6E\u8BE2\u95F4\u9694\u975E\u6CD5\uFF1A\u4E0D\u5F97\u5C0F\u4E8E 250 \u6BEB\u79D2\uFF08\u6536\u5230 ${JSON.stringify(options.pollMs)}\uFF09`);
147
189
  }
148
190
  const labelOverride = typeof options.label === "string" && options.label ? options.label : null;
191
+ const sizingStyle = entrySizingStyleFor(options.sizing ?? void 0);
192
+ themeTokensStyleFor(options.themeTokens ?? void 0);
193
+ let themeTokens = options.themeTokens;
149
194
  const localeOpt = options.locale ?? void 0;
150
195
  if (localeOpt !== void 0 && localeOpt !== null) {
151
196
  const isStr = typeof localeOpt === "string";
@@ -207,11 +252,14 @@ function mountUpdateEntry(container, options) {
207
252
  const labelText = labelOverride ?? (activating ? copyText("entry.action.checking", lang) : copyText(entryBilingualKeyFor(stateOf()), lang, entryBilingualValuesFor(stateOf())));
208
253
  const busyAttr = activating ? ' disabled aria-busy="true"' : "";
209
254
  const themeAttr = theme === "archive" ? ' data-theme="archive"' : "";
255
+ const tokensStyle = themeTokensStyleFor(themeTokens ?? void 0);
256
+ const styleBody = [sizingStyle, tokensStyle].filter((part) => part).join(";");
257
+ const styleAttr = styleBody ? ` style="${styleBody}"` : "";
210
258
  const control = variant === "badge" ? `<button type="button" class="dsh-upd-entry-dot" ${ENTRY_ATTR}="activate" title="${escapeHtml(labelText)}" aria-label="${escapeHtml(labelText)}"${busyAttr}></button>` : `<button type="button" class="dsh-upd-entry-btn" ${ENTRY_ATTR}="activate"${busyAttr}>${labelHTML}</button>`;
211
259
  const noteHTML = noteVersion ? `<span class="dsh-upd-entry-note" data-dsh-upd-note="1">${copyHTML("entry.note.up-to-date", lang, { version: noteVersion })}</span>` : "";
212
260
  return `<style>${UPDATE_ENTRY_CSS}
213
261
  ${BILINGUAL_CSS}</style>
214
- <span class="dsh-upd-entry" data-variant="${variant}" data-state="${kind}"${themeAttr}>${control}${noteHTML}</span>`;
262
+ <span class="dsh-upd-entry" data-variant="${variant}" data-state="${kind}"${themeAttr}${styleAttr}>${control}${noteHTML}</span>`;
215
263
  }
216
264
  function render() {
217
265
  if (!mounted) return;
@@ -256,6 +304,7 @@ ${BILINGUAL_CSS}</style>
256
304
  prefix,
257
305
  mode,
258
306
  theme,
307
+ themeTokens,
259
308
  pollMs,
260
309
  profileName,
261
310
  autoChangelog: options.autoChangelog,
@@ -316,13 +365,19 @@ ${BILINGUAL_CSS}</style>
316
365
  render();
317
366
  }
318
367
  function setTheme(next) {
319
- if (next !== "default" && next !== "archive" && next !== "d5-paper") {
320
- throw new Error(`[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08d5-paper \u4E3A\u65E7\u522B\u540D\u4ECD\u53EF\u7528\uFF09\uFF08\u6536\u5230 ${JSON.stringify(next)}\uFF09`);
368
+ if (next !== "default" && next !== "archive") {
369
+ throw new Error(`[dsh-plugin-update] \u4E3B\u9898\u975E\u6CD5\uFF1A\u53EA\u6536 default \u6216 archive\uFF08\u6536\u5230 ${JSON.stringify(next)}\uFF09`);
321
370
  }
322
371
  theme = normalizePanelTheme(next);
323
372
  if (panel) void panel.setTheme(theme);
324
373
  else render();
325
374
  }
375
+ function setThemeTokens(next) {
376
+ themeTokensStyleFor(next ?? void 0);
377
+ themeTokens = next ?? void 0;
378
+ if (panel) void panel.setThemeTokens(themeTokens);
379
+ else render();
380
+ }
326
381
  function onClick(ev) {
327
382
  if (!mounted) return;
328
383
  try {
@@ -381,7 +436,7 @@ ${BILINGUAL_CSS}</style>
381
436
  if (variant === "inline") mountPanel("embedded");
382
437
  else render();
383
438
  if (autoCheck === "mount") void refresh();
384
- return { refresh, open, close, label: currentLabel, setTheme, unmount };
439
+ return { refresh, open, close, label: currentLabel, setTheme, setThemeTokens, unmount };
385
440
  }
386
441
  export {
387
442
  UPDATE_ENTRY_CSS,
@@ -390,6 +445,7 @@ export {
390
445
  entryBilingualTextFor,
391
446
  entryBilingualValuesFor,
392
447
  entryLabelFor,
448
+ entrySizingStyleFor,
393
449
  entryStateKind,
394
450
  mountUpdateEntry
395
451
  };
@@ -19,10 +19,13 @@ import { createHostUpdate } from "./host.js";
19
19
  import { defaultHomeDir } from "./reader.js";
20
20
  import { compareReleaseVersions } from "./service.js";
21
21
  import {
22
- createBatchDiskPorts,
22
+ createBatchOwnerPorts,
23
23
  createSkipDiskPorts,
24
24
  createUpdateQueuePorts,
25
- isVersionSkipped
25
+ emptyBatchInventory,
26
+ isVersionSkipped,
27
+ normalizeBatchInventory,
28
+ normalizeBatchPrefs
26
29
  } from "./store.js";
27
30
  const DEFAULT_DRAIN_INTERVAL_MS = 5e3;
28
31
  const MIN_DRAIN_INTERVAL_MS = 1e3;
@@ -405,31 +408,56 @@ function createMultiHostUpdate(deps = {}, options) {
405
408
  return runtime;
406
409
  });
407
410
  const runtimeByKey = new Map(runtimes.map((rt) => [rt.spec.key, rt]));
408
- const batchPorts = batchScope && batchScope.profileDir ? createBatchDiskPorts(batchScope.homeDir, batchScope.profileDir) : null;
411
+ const ownerPorts = batchScope && batchScope.profileDir ? createBatchOwnerPorts(batchScope.homeDir, batchScope.profileDir, prefix) : null;
409
412
  let memSession = null;
413
+ let memInventory = null;
414
+ let memPrefs = {};
410
415
  let driveActive = false;
411
416
  let liveSession = null;
412
417
  let lastRun = null;
413
418
  let drainTimer = null;
414
419
  let disposed = false;
415
420
  async function readSession() {
416
- if (batchPorts) return await batchPorts.readBatch();
421
+ if (ownerPorts) return await ownerPorts.readBatch();
417
422
  return memSession ?? emptyBatchSession();
418
423
  }
419
424
  async function saveSession(session) {
420
- if (batchPorts) {
421
- await batchPorts.writeBatch(session);
425
+ if (ownerPorts) {
426
+ await ownerPorts.writeBatch(session);
422
427
  return;
423
428
  }
424
429
  memSession = session;
425
430
  }
426
431
  async function clearSession() {
427
- if (batchPorts) {
428
- await batchPorts.clearBatch();
432
+ if (ownerPorts) {
433
+ await ownerPorts.clearBatch();
429
434
  return;
430
435
  }
431
436
  memSession = emptyBatchSession();
432
437
  }
438
+ async function readInventory() {
439
+ if (ownerPorts) return await ownerPorts.readInventory();
440
+ return memInventory ?? emptyBatchInventory();
441
+ }
442
+ async function writeInventory(inventory) {
443
+ if (ownerPorts) {
444
+ await ownerPorts.writeInventory(inventory);
445
+ return;
446
+ }
447
+ memInventory = inventory;
448
+ }
449
+ async function readPrefs() {
450
+ if (ownerPorts) return await ownerPorts.readPrefs();
451
+ return memPrefs;
452
+ }
453
+ async function writePrefs(prefs) {
454
+ const normalized = normalizeBatchPrefs(prefs);
455
+ if (ownerPorts) {
456
+ await ownerPorts.writePrefs(normalized);
457
+ return;
458
+ }
459
+ memPrefs = normalized;
460
+ }
433
461
  function newSessionId() {
434
462
  const suffix = String(randomId() ?? "").replace(/[^A-Za-z0-9._~-]/g, "").slice(0, 24);
435
463
  return now().toString(36) + "-" + (suffix || hash8(String(now())));
@@ -499,7 +527,19 @@ function createMultiHostUpdate(deps = {}, options) {
499
527
  }
500
528
  async function table(session, refresh = true) {
501
529
  const built = await buildRows(session, refresh);
502
- return { ok: true, session: built.session, rows: built.rows, progress: progressOf(built.session) };
530
+ let inventory = emptyBatchInventory();
531
+ let prefs = {};
532
+ try {
533
+ inventory = await readInventory();
534
+ } catch {
535
+ inventory = emptyBatchInventory();
536
+ }
537
+ try {
538
+ prefs = await readPrefs();
539
+ } catch {
540
+ prefs = {};
541
+ }
542
+ return { ok: true, session: built.session, rows: built.rows, progress: progressOf(built.session), inventory, prefs };
503
543
  }
504
544
  function denied() {
505
545
  if (!scopeError) return null;
@@ -609,14 +649,42 @@ function createMultiHostUpdate(deps = {}, options) {
609
649
  if (refuse) return refuse;
610
650
  const session = await readSession();
611
651
  if (!driveActive) {
652
+ const at = now();
653
+ let prev = emptyBatchInventory();
654
+ try {
655
+ prev = await readInventory();
656
+ } catch {
657
+ prev = emptyBatchInventory();
658
+ }
659
+ const entries = { ...prev.entries };
612
660
  for (const rt of runtimes) {
613
661
  try {
614
662
  const outcome = await rt.check(rt.spec.key, rt.spec);
615
663
  rt.cache = { ...rt.cache, error: outcome.kind === "failed" ? outcome.error : null };
664
+ const snapshot = asRecord(rt.cache.snapshot);
665
+ const installed = firstText(snapshot["installedVersion"], snapshot["runningVersion"]);
666
+ const canInstallRaw = snapshot["canInstall"];
667
+ const canInstall = typeof canInstallRaw === "boolean" ? canInstallRaw : null;
668
+ if (outcome.kind === "update") {
669
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall, error: null };
670
+ } else if (outcome.kind === "current") {
671
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: installed, canInstall: false, error: null };
672
+ } else if (outcome.kind === "skipped") {
673
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: outcome.version, canInstall: false, error: null };
674
+ } else {
675
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: installed, latestVersion: firstText(snapshot["latestVersion"]), canInstall, error: outcome.error };
676
+ }
616
677
  } catch (error) {
617
- rt.cache = { ...rt.cache, error: errorPayloadOf(error).error };
678
+ const code = errorPayloadOf(error).error;
679
+ rt.cache = { ...rt.cache, error: code };
680
+ const snapshot = asRecord(rt.cache.snapshot);
681
+ entries[rt.spec.key] = { lastCheckedAt: at, installedVersion: firstText(snapshot["installedVersion"], snapshot["runningVersion"]), latestVersion: firstText(snapshot["latestVersion"]), canInstall: typeof snapshot["canInstall"] === "boolean" ? snapshot["canInstall"] : null, error: code };
618
682
  }
619
683
  }
684
+ try {
685
+ await writeInventory(normalizeBatchInventory({ version: 1, updatedAt: at, entries }, at));
686
+ } catch {
687
+ }
620
688
  }
621
689
  return await table(session, false);
622
690
  }
@@ -682,11 +750,12 @@ function createMultiHostUpdate(deps = {}, options) {
682
750
  if (refuse) return refuse;
683
751
  if (driveActive) return { ok: false, error: "update-busy", errorKind: "update-busy" };
684
752
  const disk = await readSession();
685
- if (disk.entries.length === 0) return await table(disk);
753
+ const hadUnfinished = disk.entries.length > 0 && !isBatchFinished(disk);
754
+ if (!hadUnfinished) return { ...await table(disk), resumed: false };
686
755
  const resumed = resumeBatchSession(disk, now());
687
756
  if (resumed !== disk) await saveSession(resumed);
688
757
  try {
689
- return await table(await drive(resumed, null, Number.POSITIVE_INFINITY));
758
+ return { ...await table(await drive(resumed, null, Number.POSITIVE_INFINITY)), resumed: true };
690
759
  } catch (error) {
691
760
  return { ok: false, ...errorPayloadOf(error) };
692
761
  }
@@ -727,12 +796,32 @@ function createMultiHostUpdate(deps = {}, options) {
727
796
  void tickDrain();
728
797
  }, drainIntervalMs);
729
798
  }
799
+ async function prefsPhone() {
800
+ const refuse = denied();
801
+ if (refuse) return refuse;
802
+ return { ok: true, prefs: await readPrefs() };
803
+ }
804
+ async function prefsSavePhone(args) {
805
+ const refuse = denied();
806
+ if (refuse) return refuse;
807
+ const current = await readPrefs();
808
+ const next = { ...current };
809
+ if ("checkOnOpen" in args) {
810
+ const v = args["checkOnOpen"];
811
+ if (typeof v !== "boolean") return { ok: false, error: "check-failed", errorKind: "check-failed" };
812
+ next.checkOnOpen = v;
813
+ }
814
+ await writePrefs(next);
815
+ return { ok: true, prefs: next };
816
+ }
730
817
  const phoneNames = {
731
818
  status: prefix + ".batchStatus",
732
819
  check: prefix + ".batchCheck",
733
820
  install: prefix + ".batchInstall",
734
821
  resume: prefix + ".batchResume",
735
- cancel: prefix + ".batchCancel"
822
+ cancel: prefix + ".batchCancel",
823
+ prefs: prefix + ".batchPrefs",
824
+ prefsSave: prefix + ".batchPrefsSave"
736
825
  };
737
826
  function phone(fn) {
738
827
  return async (args) => {
@@ -749,6 +838,8 @@ function createMultiHostUpdate(deps = {}, options) {
749
838
  handlers[phoneNames.install] = phone((args) => installPhone(args));
750
839
  handlers[phoneNames.resume] = phone(() => resumePhone());
751
840
  handlers[phoneNames.cancel] = phone(() => cancelPhone());
841
+ handlers[phoneNames.prefs] = phone(() => prefsPhone());
842
+ handlers[phoneNames.prefsSave] = phone((args) => prefsSavePhone(args));
752
843
  for (const rt of runtimes) {
753
844
  for (const name of Object.keys(rt.host.handlers)) {
754
845
  const handler = rt.host.handlers[name];
package/dist/http.d.ts CHANGED
@@ -11,8 +11,8 @@ export declare const DEFAULT_HTTP_STATUS_TIMEOUT_MS = 15000;
11
11
  /** 单插件电话动作名(旧三电话与 src/config.ts PHONE_ACTIONS 同字面,不另起字面;#38 加 updateChangelog)。 */
12
12
  export declare const HTTP_PHONE_ACTIONS: readonly ["updateStatus", "updateCheck", "updateInstall", "updateChangelog"];
13
13
  export type HttpPhoneAction = (typeof HTTP_PHONE_ACTIONS)[number];
14
- /** 批量五电话动作名(与 src/panel-batch.ts buildBatchPhoneNames 同字面,不另起字面;同一内核)。 */
15
- export declare const HTTP_BATCH_ACTIONS: readonly ["batchStatus", "batchCheck", "batchInstall", "batchResume", "batchCancel"];
14
+ /** 批量七电话动作名(与 src/panel-batch.ts buildBatchPhoneNames 同字面,不另起字面;同一内核)。 */
15
+ export declare const HTTP_BATCH_ACTIONS: readonly ["batchStatus", "batchCheck", "batchInstall", "batchResume", "batchCancel", "batchPrefs", "batchPrefsSave"];
16
16
  export type HttpBatchAction = (typeof HTTP_BATCH_ACTIONS)[number];
17
17
  /** 逐电话覆写表:键为全电话名(prefix.动作)或短动作名;未知键拒绝。 */
18
18
  export type HttpRoutes = Record<string, string>;
package/dist/http.js CHANGED
@@ -11,7 +11,7 @@ const DEFAULT_HTTP_CHECK_TIMEOUT_MS = 15e3;
11
11
  const DEFAULT_HTTP_INSTALL_TIMEOUT_MS = 16 * 6e4;
12
12
  const DEFAULT_HTTP_STATUS_TIMEOUT_MS = 15e3;
13
13
  const HTTP_PHONE_ACTIONS = ["updateStatus", "updateCheck", "updateInstall", "updateChangelog"];
14
- const HTTP_BATCH_ACTIONS = ["batchStatus", "batchCheck", "batchInstall", "batchResume", "batchCancel"];
14
+ const HTTP_BATCH_ACTIONS = ["batchStatus", "batchCheck", "batchInstall", "batchResume", "batchCancel", "batchPrefs", "batchPrefsSave"];
15
15
  function isAbsoluteUrl(value) {
16
16
  return /^https?:\/\//i.test(value);
17
17
  }
@@ -1,4 +1,4 @@
1
- import { type UpdatePanelTheme } from './panel.js';
1
+ import { type UpdatePanelTheme, type UpdateThemeTokens } from './panel.js';
2
2
  /** 一行要画的东西(宿主回包里的形状)。 */
3
3
  export interface BatchRowView {
4
4
  key: string;
@@ -32,6 +32,8 @@ export interface BatchPanelOptions {
32
32
  call: (name: string, args: Record<string, unknown>) => Promise<unknown>;
33
33
  /** 主题:与单插件面板同一套(缺省 default)。 */
34
34
  theme?: UpdatePanelTheme;
35
+ /** 主题变量覆盖:见 panel 的 UpdateThemeTokens;批量根 + 展开详情行全继承,不传即零回归。 */
36
+ themeTokens?: UpdateThemeTokens;
35
37
  /** 摆放形态:内嵌或弹窗(缺省 embedded)。 */
36
38
  mode?: BatchPanelMode;
37
39
  /** 轮询间隔(毫秒,缺省 1500,下限 250)。 */
@@ -55,13 +57,22 @@ export interface BatchPanelOptions {
55
57
  */
56
58
  onCloseRequested?: () => void | Promise<void>;
57
59
  /**
58
- * 语言覆盖(#60 窗口期占位:本票只加选项不消费,传了暂不生效,消费留给 #61-#66)。
60
+ * 语言覆盖(#59 起消费:新文案走集中字典单语渲染;既有散落文案保持基线不变)。
59
61
  * 形态与入口件一致:'zh' | 'en' | { getActive(): string; subscribe?(cb): () => void }。
60
62
  */
61
63
  locale?: unknown;
64
+ /**
65
+ * 打开面板自动查(#59 D3):缺省 true。false 即手动挡(行为回今天,只修文案)。
66
+ * 优先级:用户偏好(prefs.json)> 本选项 > 缺省。
67
+ */
68
+ checkOnOpen?: boolean;
69
+ /**
70
+ * 自动继续未终态轮次(#59 Q3:与显式按钮并存,常开)。false 可关(测试与特殊集成用)。
71
+ */
72
+ autoResume?: boolean;
62
73
  }
63
74
  /** 面板可点的动作(HTML 上 data-act 一一对应;测试走同一条路)。 */
64
- export type BatchPanelActionKind = 'check' | 'install' | 'resume' | 'cancel' | 'row-install' | 'row-skip' | 'row-resume-skip' | 'row-cancel-queue' | 'row-copy-manual' | 'row-copy-diag' | 'toggle-details' | 'restart' | 'close';
75
+ export type BatchPanelActionKind = 'check' | 'install' | 'resume' | 'cancel' | 'row-install' | 'row-skip' | 'row-resume-skip' | 'row-cancel-queue' | 'row-copy-manual' | 'row-copy-diag' | 'toggle-details' | 'restart' | 'close' | 'toggle-check-on-open';
65
76
  /** 挂载点:只要有 innerHTML 的容器即可(浏览器元素或测试替身都行)。 */
66
77
  export interface BatchPanelContainer {
67
78
  innerHTML: string;
@@ -73,23 +84,27 @@ export interface BatchPanelController {
73
84
  refresh(): Promise<void>;
74
85
  act(action: BatchPanelActionKind, key?: string): Promise<void>;
75
86
  setTheme(theme: UpdatePanelTheme): void;
87
+ /** 换一套主题变量(重绘;传 undefined 即清掉覆盖,回主题默认)。 */
88
+ setThemeTokens(tokens: UpdateThemeTokens | undefined): void;
76
89
  setMode(mode: BatchPanelMode): void;
77
90
  unmount(): void;
78
91
  }
79
- /** 批量五个电话名(与宿主侧 MultiHostUpdate.phoneNames 同一形状)。 */
92
+ /** 批量七个电话名(与宿主侧 MultiHostUpdate.phoneNames 同一形状)。 */
80
93
  export interface BatchPhoneNames {
81
94
  status: string;
82
95
  check: string;
83
96
  install: string;
84
97
  resume: string;
85
98
  cancel: string;
99
+ prefs: string;
100
+ prefsSave: string;
86
101
  }
87
102
  /** 批量面板轮询口径(默认 1.5 秒、下限 250 毫秒,与单插件面板同一套下限)。 */
88
103
  export declare const BATCH_PANEL_POLL: {
89
104
  readonly defaultMs: 1500;
90
105
  readonly minMs: 250;
91
106
  };
92
- /** 批量电话名拼法:前缀 + 五个固定动作名(前缀形状校验与单插件面板同一套)。 */
107
+ /** 批量电话名拼法:前缀 + 七个固定动作名(前缀形状校验与单插件面板同一套)。 */
93
108
  export declare function buildBatchPhoneNames(prefix: string): BatchPhoneNames;
94
109
  export interface BatchLedgerCounts {
95
110
  /** phase=ready:有新版、还没安装。 */
@@ -114,23 +129,42 @@ export interface BatchLedgerCounts {
114
129
  * 宿主忙时先写占位再回 update-busy,面板把它翻回「可更新」,免得总账把它报成失败。
115
130
  */
116
131
  export declare function batchLedgerCounts(rows: readonly BatchRowView[]): BatchLedgerCounts;
117
- /** 分类计数 → 一句话总账(只出现非零档,顺序固定:可更新 · 安装中 · 待查 · 待重启 · 失败 · 已跳过 · 已最新)。 */
118
- export declare function batchLedgerText(counts: BatchLedgerCounts): string;
132
+ /** 分类计数 → 一句话总账(只出现非零档,顺序固定:可更新 · 安装中 · 待查 · 待重启 · 失败 · 已跳过 · 已最新;#64 单语:缺省 zh 零回归)。 */
133
+ export declare function batchLedgerText(counts: BatchLedgerCounts, lang?: unknown): string;
119
134
  /**
120
135
  * 排队中的状态词:只回答「还要等多久」。位置从该行的队列视图来。
121
136
  * queue.ts 的 position 是 waiting 顺位(队首为 1、0 = 自己正在装),而正在装的那一家也压在这一家前面,
122
137
  * 所以「前方 N 个」= position N(队首那家前方正好 1 个:正在装的拥有者)。
123
138
  * 位置取不到(宿主只记了忙、没给占位读数)就说「等前面安装完」,不猜数字。
124
139
  */
125
- export declare function batchQueuedStatus(position: number | null): string;
140
+ export declare function batchQueuedStatus(position: number | null, lang?: unknown): string;
141
+ /** 知识账本里的一行(只做展示,不参与安装决策)。 */
142
+ export interface BatchKnowledgeEntry {
143
+ lastCheckedAt: number;
144
+ installedVersion: string | null;
145
+ latestVersion: string | null;
146
+ canInstall: boolean | null;
147
+ error: string | null;
148
+ }
149
+ /** 有未终态行(控制区出现条件:盘上有没做完的一轮)。 */
150
+ export declare function hasUnfinishedRows(rows: readonly BatchRowView[], session?: unknown): boolean;
151
+ export declare function unfinishedCount(rows: readonly BatchRowView[], session?: unknown): number;
126
152
  /**
127
153
  * 一行的状态词:只回答一个问题——这家的**下一步**是什么。
128
154
  * 中文可执行,不写相位英文(相位只留在 data-phase 属性上,给人看的这句永远是动作)。
155
+ * (#59 D2:调用方在无轮次 + 有知识时优先用 batchRowKnowledgeText;本函数语义冻结,旧快照不动。)
156
+ */
157
+ export declare function batchRowStatus(row: BatchRowView, skippedVersion?: string | null, lang?: unknown): string;
158
+ /**
159
+ * 无轮次时的知识行(#59 D2):pending + 有知识即按知识展示;有轮次未做完仍走执行态。
160
+ * 无知识(从没查过)回 null,调用方回退旧 pending 文案。
129
161
  */
130
- export declare function batchRowStatus(row: BatchRowView, skippedVersion?: string | null): string;
162
+ export declare function batchRowKnowledgeText(row: BatchRowView, entry: BatchKnowledgeEntry | null, lang?: unknown): string | null;
131
163
  export interface BatchPanelRenderInput {
132
164
  rows?: readonly BatchRowView[];
133
165
  theme?: UpdatePanelTheme;
166
+ /** 主题变量覆盖:见 panel 的 UpdateThemeTokens;写到批量根上,详情行经继承生效。 */
167
+ themeTokens?: UpdateThemeTokens;
134
168
  mode?: BatchPanelMode;
135
169
  /** 当前展开的那一家(键);null 即全部收起。 */
136
170
  expandedKey?: string | null;
@@ -151,15 +185,23 @@ export interface BatchPanelRenderInput {
151
185
  noticeKey?: string | null;
152
186
  /** 键 -> 该行已取到的日志全文(只传有文本的;取不到与没展开即中性提示,不挡安装)。 */
153
187
  changelogs?: Record<string, string | null>;
188
+ /** 轮次会话(有未终态行才画控制区;不传按行相位回退旧口径,兼容旧快照)。 */
189
+ session?: unknown;
190
+ /** 知识账本(无轮次时行按知识展示;只做展示,不参与安装决策)。 */
191
+ inventory?: unknown;
192
+ /** 偏好(底部勾选的受控状态;不传不画勾选)。 */
193
+ prefs?: unknown;
194
+ /** 语言(单语渲染新文案用;不传跟随全局信号)。 */
195
+ lang?: unknown;
154
196
  }
155
197
  /** 整面板 HTML(含样式;重绘即整体替换 innerHTML,故每次都带 style 也只留一份)。 */
156
198
  export declare function renderBatchPanelHTML(input: BatchPanelRenderInput): string;
157
199
  export declare const UPDATE_BATCH_PANEL_CSS: string;
158
200
  /**
159
- * 批量面板的 D5 皮肤(#20 同一套口径:只换颜色/字体/间距,不改顺序、不藏东西)。
201
+ * 批量面板的 Archive 皮肤(#20 同一套口径:只换颜色/字体/间距,不改顺序、不藏东西)。
160
202
  * 卷宗抬头右侧给大印章留 120px(与单插件面板档案头同一处理)。
161
203
  */
162
- export declare const UPDATE_BATCH_PANEL_D5_CSS: string;
204
+ export declare const UPDATE_BATCH_PANEL_ARCHIVE_CSS: string;
163
205
  /**
164
206
  * 挂批量面板(调用方一行即跑):
165
207
  * ```js