@restregular/dsh-powerup 0.2.3 → 0.3.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.
Files changed (4) hide show
  1. package/README.md +35 -12
  2. package/client.js +466 -49
  3. package/index.js +161 -14
  4. package/package.json +2 -2
package/README.md CHANGED
@@ -41,20 +41,31 @@ Open the built-in **Plugins** page. Every third-party card under **Installed
41
41
  plugins** gains a small update icon in its action area. Clicking it opens the
42
42
  update dialog:
43
43
 
44
- 1. The dialog reads the package's published versions from the same registry the
45
- **Add plugin** dialog uses — the remembered choice, or the Host's configured
46
- registry, with npmmirror as a configured fallback. Type a version or pick one
47
- from the dropdown.
48
- 2. Confirming removes the installed copy, checks the new spec the way **Add
44
+ 1. The dialog reads the package's published versions from the Host's configured
45
+ registries — the same list the **Add plugin** dialog offers, including the
46
+ npmmirror fallback. Star any registry to make it Power-Up's default for
47
+ later updates; otherwise the list starts from the Add dialog's remembered
48
+ choice.
49
+ 2. Type a version or pick one from the floating version list. The current
50
+ installed version is tagged **Current**; the newest is tagged **latest**, and
51
+ a version that is both uses one **Current · latest** tag.
52
+ 3. Confirming removes the installed copy, checks the new spec the way **Add
49
53
  plugin** does, and installs the selected version without enabling it.
50
- 3. The dialog then asks whether to enable the new version.
51
- 4. After enabling, it asks whether to restart DSH. On the desktop app the
54
+ 4. The dialog then asks whether to enable the new version.
55
+ 5. After enabling, it asks whether to restart DSH. On the desktop app the
52
56
  `lib/restart-helper.cjs` helper relaunches the exact executable after the old
53
57
  Host exits; under `dsh web` the dialog explains that the terminal owns the
54
58
  process.
59
+ 6. A management call that reports `restart-required` keeps a **Restart needed**
60
+ tag on the card and adds the plugin to the count beside the **Installed
61
+ plugins** heading, where a **Restart DSH** button confirms and performs the
62
+ same restart.
55
63
 
56
- Power-Up does not update itself through this button: removing the plugin would
57
- unload the dialog mid-flow. Use **Add plugin** with the new package version.
64
+ Power-Up updates itself through the same dialog. Because removing the plugin
65
+ unloads this panel, the Host answers first and then hands `removeBundle` →
66
+ `inspect` → `installBundle(enabled: true)` to the built-in manager service,
67
+ which keeps running after the Power-Up Host fiber is unloaded. The panel closes;
68
+ refresh the page when the install finishes.
58
69
 
59
70
  ## Design notes
60
71
 
@@ -78,9 +89,21 @@ unload the dialog mid-flow. Use **Add plugin** with the new package version.
78
89
  no per-card slot, so the overlay injects one button into every
79
90
  `[data-plugin-group="bundles"] [data-plugin-package]` card and re-asserts it
80
91
  after React renders. Version listing is a plain registry `GET`; removal and
81
- installation use the same `remote.pluginManager` calls as **Add plugin**. Only
82
- the process restart needs Host code: a loopback-only
83
- `/dsh-powerup/api/restart` route.
92
+ installation use the same `remote.pluginManager` calls as **Add plugin**. The
93
+ Host adds two loopback-only routes: `/dsh-powerup/api/restart` for the
94
+ process restart, and `/dsh-powerup/api/self-update` for the self-update
95
+ handoff.
96
+ - **Self-update outlives its own unload.** The Host route sends its answer, then
97
+ a timer starts `removeBundle` → `inspect` → `installBundle(enabled: true)`
98
+ through the built-in manager service. Removing the bundle unloads Power-Up's
99
+ own Host fiber, but the manager service and the pending promise stay in the
100
+ Host process, so the reinstall finishes; failures are appended to
101
+ `~/.dsh/powerup/self-update.log`.
102
+ - **Restart status lives in this browser session.** A management result that
103
+ says `restart-required` is written to `sessionStorage`; the card gets a
104
+ **Restart needed** tag and the group heading gets a count plus a restart
105
+ button. A real app restart clears the session, while a page refresh keeps the
106
+ markers.
84
107
  - **The dialog mirrors the host modal.** The backdrop is
85
108
  `--dsw-alias-bg-mask-1` with the shared mask blur, the surface is
86
109
  `--dsw-alias-bg-layer-2` with `--dsw-radius-panel` and
package/client.js CHANGED
@@ -44,7 +44,7 @@
44
44
 
45
45
  const NS = 'dsh-powerup';
46
46
  const SETTINGS_KEY = 'dsh-powerup:settings:v1';
47
- const VERSION = '0.2.3';
47
+ const VERSION = '0.3.0';
48
48
  const MENU_GAP = 8;
49
49
  const MENU_WIDTH = 248;
50
50
 
@@ -154,6 +154,20 @@
154
154
  'update.restartUnsupported': '当前运行方式无法自动重启:{reason}',
155
155
  'update.restarting': 'DSH 正在重启,窗口即将关闭…',
156
156
  'update.restartFailed': '重启请求失败:{message}',
157
+ 'update.selfUpdateWarning': '更新 Power-Up 会在后台卸载并安装新版本;本面板会关闭,完成后刷新页面。',
158
+ 'update.selfUpdating': 'Power-Up 正在后台更新…',
159
+ 'update.selfRefresh': '完成后请刷新页面。',
160
+ 'update.setDefault': '设为默认',
161
+ 'update.defaultTag': '默认',
162
+ 'update.defaultHint': '更新列表默认使用此安装源',
163
+ 'restart.tag': '需重启',
164
+ 'restart.tagHint': '该插件已更新,重启 DSH 后生效',
165
+ 'restart.pending': '{count} 个插件待重启',
166
+ 'restart.action': '重启 DSH',
167
+ 'restart.title': '重启 DSH?',
168
+ 'restart.desc': '重启会关闭并重新打开 DSH,正在运行的任务会中断。',
169
+ 'restart.cancel': '取消',
170
+ 'restart.confirm': '立即重启',
157
171
  'update.doneTitle': '更新完成',
158
172
  'update.doneInstalled': '{name} 已更新到 {version}。',
159
173
  'update.doneDisabled': '{name} 已安装 {version},尚未启用。',
@@ -276,6 +290,20 @@
276
290
  'update.restartUnsupported': 'This runtime cannot restart itself: {reason}',
277
291
  'update.restarting': 'DSH is restarting; this window will close…',
278
292
  'update.restartFailed': 'Restart request failed: {message}',
293
+ 'update.selfUpdateWarning': 'Updating Power-Up removes and reinstalls it in the background; this panel closes, then refresh the page.',
294
+ 'update.selfUpdating': 'Power-Up is updating in the background…',
295
+ 'update.selfRefresh': 'Refresh the page when it finishes.',
296
+ 'update.setDefault': 'Set as default',
297
+ 'update.defaultTag': 'Default',
298
+ 'update.defaultHint': 'The update list starts from this registry',
299
+ 'restart.tag': 'Restart needed',
300
+ 'restart.tagHint': 'Updated; restart DSH to apply',
301
+ 'restart.pending': '{count} plugins need a restart',
302
+ 'restart.action': 'Restart DSH',
303
+ 'restart.title': 'Restart DSH?',
304
+ 'restart.desc': 'Restarting closes and reopens DSH; running tasks are interrupted.',
305
+ 'restart.cancel': 'Cancel',
306
+ 'restart.confirm': 'Restart now',
279
307
  'update.doneTitle': 'Update complete',
280
308
  'update.doneInstalled': '{name} is now on {version}.',
281
309
  'update.doneDisabled': '{name} {version} is installed but not enabled.',
@@ -597,6 +625,15 @@
597
625
  );
598
626
  }
599
627
 
628
+ function StarIcon({ size = 12 }) {
629
+ return h(Icon, { size },
630
+ h('path', {
631
+ d: 'm8 2.6 1.6 3.3 3.6.5-2.6 2.5.6 3.6L8 10.8l-3.2 1.7.6-3.6L2.8 6.4l3.6-.5L8 2.6Z',
632
+ fill: 'currentColor',
633
+ }),
634
+ );
635
+ }
636
+
600
637
  function WarnIcon({ size = 16 }) {
601
638
  return h(Icon, { size },
602
639
  h('path', {
@@ -667,6 +704,15 @@
667
704
  @keyframes dsh-powerup-toast-in{from{opacity:0;transform:translate(-50%,5px)}to{opacity:1;transform:translate(-50%,0)}}
668
705
  .dsh-powerup-update-button{flex:none;display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-caption);cursor:pointer}
669
706
  .dsh-powerup-update-button:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));color:var(--dsw-alias-label-secondary)}
707
+ .dsh-powerup-restart-tag{flex:none;display:inline-flex;align-items:center;height:18px;padding:0 6px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 32%,transparent);border-radius:999px;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);font-size:10px;line-height:16px;white-space:nowrap}
708
+ .dsh-powerup-restart-bar{display:flex;flex:none;align-items:center;gap:8px;margin-left:8px}
709
+ .dsh-powerup-restart-count{color:var(--dsw-alias-state-warn-label);font-size:11.5px;line-height:18px;white-space:nowrap}
710
+ .dsh-powerup-restart-action{height:26px;padding:0 10px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 36%,transparent);border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);font:inherit;font-size:11.5px;line-height:18px;cursor:pointer}
711
+ .dsh-powerup-restart-action:hover{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 14%,transparent)}
712
+ .dsh-powerup-update-default{flex:none;display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-caption);cursor:pointer}
713
+ .dsh-powerup-update-default:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-warn-label)}
714
+ .dsh-powerup-update-default[aria-pressed=true]{color:var(--dsw-alias-state-warn-label)}
715
+ .dsh-powerup-update-default-tag{flex:none;padding:0 6px;border:.5px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 32%,transparent);border-radius:999px;color:var(--dsw-alias-state-warn-label);font-size:10px;line-height:16px;white-space:nowrap}
670
716
  .dsh-powerup-update-mask{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;padding:24px;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur,blur(3px));pointer-events:auto;animation:dsh-powerup-update-fade .16s ease-out}
671
717
  @keyframes dsh-powerup-update-fade{from{opacity:0}to{opacity:1}}
672
718
  .dsh-powerup-update-dialog{box-sizing:border-box;position:relative;display:flex;flex-direction:column;width:min(560px,100%);max-height:min(760px,calc(100vh - 48px));max-height:min(760px,calc(100dvh - 48px));border-radius:var(--dsw-radius-panel,14px);background:var(--dsw-alias-bg-layer-2);box-shadow:var(--dsw-elevation-prominent);color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;overflow:hidden;animation:dsh-powerup-update-rise .18s cubic-bezier(.2,.8,.3,1)}
@@ -751,7 +797,7 @@
751
797
  .dsh-powerup-update-ghost{height:36px;padding:0 14px;border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:14px;line-height:22px;cursor:pointer}
752
798
  .dsh-powerup-update-ghost:hover{background:var(--dsw-alias-interactive-bg-hover)}
753
799
  .dsh-powerup-update-ghost:active{background:var(--dsw-alias-interactive-bg-active)}
754
- .dsh-powerup-update-close:focus-visible,.dsh-powerup-update-toggle:focus-visible,.dsh-powerup-update-version:focus-visible,.dsh-powerup-update-select-trigger:focus-visible,.dsh-powerup-update-option:focus-visible,.dsh-powerup-update-primary:focus-visible,.dsh-powerup-update-ghost:focus-visible,.dsh-powerup-update-button:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
800
+ .dsh-powerup-update-close:focus-visible,.dsh-powerup-update-toggle:focus-visible,.dsh-powerup-update-version:focus-visible,.dsh-powerup-update-select-trigger:focus-visible,.dsh-powerup-update-option:focus-visible,.dsh-powerup-update-primary:focus-visible,.dsh-powerup-update-ghost:focus-visible,.dsh-powerup-update-button:focus-visible,.dsh-powerup-restart-action:focus-visible,.dsh-powerup-update-default:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:1px}
755
801
  @media (max-width:680px){.dsh-powerup-page{padding:20px 16px 42px}.dsh-powerup-actions{grid-template-columns:minmax(0,1fr)}.dsh-powerup-hero-art{width:54px;height:54px}.dsh-powerup-hero h1{font-size:19px}.dsh-powerup-card-head{flex-wrap:wrap}.dsh-powerup-switch{margin-left:auto}.dsh-powerup-update-mask{padding:12px}.dsh-powerup-update-dialog{max-height:calc(100dvh - 24px)}.dsh-powerup-update-head{padding:18px 12px 10px 18px}.dsh-powerup-update-body{padding:0 18px 4px}.dsh-powerup-update-foot{padding:14px 18px 18px}}
756
802
  `;
757
803
 
@@ -1139,7 +1185,10 @@
1139
1185
  // Plugin update: entry button on the built-in Plugins page + dialog
1140
1186
  // ------------------------------------------------------------------
1141
1187
  const PLUGIN_REGISTRY_MEMORY_KEY = 'dsh.plugin-manager.install-registry';
1188
+ const POWERUP_REGISTRY_KEY = 'dsh-powerup:update-registry:v1';
1189
+ const PENDING_RESTART_KEY = 'dsh-powerup:pending-restart:v1';
1142
1190
  const UPDATE_API = '/dsh-powerup/api/restart';
1191
+ const SELF_UPDATE_API = '/dsh-powerup/api/self-update';
1143
1192
  const OFFICIAL_REGISTRY = 'https://registry.npmjs.org/';
1144
1193
  const REGISTRY_HOSTS = new Map([
1145
1194
  ['registry.npmjs.org', 'update.registryDefault'],
@@ -1188,16 +1237,75 @@
1188
1237
  }
1189
1238
  }
1190
1239
 
1240
+ /**
1241
+ * Read the registry the user starred as Power-Up's list default.
1242
+ * @returns {{ key?: string, value?: string | null, url?: string, label?: string, hint?: string } | null}
1243
+ */
1244
+ function readDefaultRegistry() {
1245
+ try {
1246
+ const raw = localStorage.getItem(POWERUP_REGISTRY_KEY);
1247
+ if (typeof raw !== 'string' || raw === '') return null;
1248
+ const parsed = JSON.parse(raw);
1249
+ if (parsed === null || typeof parsed !== 'object') return null;
1250
+ return parsed;
1251
+ } catch {
1252
+ return null;
1253
+ }
1254
+ }
1255
+
1256
+ /**
1257
+ * Persist the registry the update list should start from.
1258
+ * @param {{ key: string, value: string | null, url: string, label: string, hint: string }} choice
1259
+ */
1260
+ function writeDefaultRegistry(choice) {
1261
+ try {
1262
+ localStorage.setItem(POWERUP_REGISTRY_KEY, JSON.stringify({
1263
+ key: choice.key,
1264
+ value: choice.value,
1265
+ url: choice.url,
1266
+ label: choice.label,
1267
+ hint: choice.hint,
1268
+ }));
1269
+ } catch {
1270
+ /* A full or private store keeps the in-memory selection. */
1271
+ }
1272
+ }
1273
+
1274
+ /** Pending-restart records for this browser session (cleared by a real restart). */
1275
+ function readPendingRestarts() {
1276
+ try {
1277
+ const raw = sessionStorage.getItem(PENDING_RESTART_KEY);
1278
+ if (typeof raw !== 'string' || raw === '') return [];
1279
+ const parsed = JSON.parse(raw);
1280
+ return Array.isArray(parsed) ? parsed.filter((entry) => entry !== null && typeof entry === 'object' && typeof entry.name === 'string') : [];
1281
+ } catch {
1282
+ return [];
1283
+ }
1284
+ }
1285
+
1286
+ /**
1287
+ * @param {Array<{ name: string, version?: string, reason?: string, at?: number }>} entries
1288
+ */
1289
+ function writePendingRestarts(entries) {
1290
+ try {
1291
+ sessionStorage.setItem(PENDING_RESTART_KEY, JSON.stringify(entries));
1292
+ } catch {
1293
+ /* The badge is a hint; failing to persist it must not break updates. */
1294
+ }
1295
+ }
1296
+
1191
1297
  /**
1192
1298
  * The registry choices the update dialog offers: the Host's configured
1193
1299
  * default (with the URL pnpm actually names), each configured fallback,
1194
- * and a remembered custom address.
1300
+ * the built-in Add dialog's remembered custom address, and Power-Up's
1301
+ * own starred default.
1195
1302
  * @param {{ registry: string | null, fallbackRegistries: string[], resolved: string | null } | null} registries
1196
1303
  * @param {object | null} remembered
1304
+ * @param {object | null} powerupDefault
1197
1305
  * @param {(key: string) => string} t
1198
1306
  * @returns {Array<{ key: string, label: string, hint: string, value: string | null, url: string }>}
1199
1307
  */
1200
- function buildRegistryChoices(registries, remembered, t) {
1308
+ function buildRegistryChoices(registries, remembered, powerupDefault, t) {
1201
1309
  const choices = [];
1202
1310
  const seen = new Set();
1203
1311
  const add = (choice) => {
@@ -1246,6 +1354,18 @@
1246
1354
  url: normalizeRegistry(rememberedOffered),
1247
1355
  });
1248
1356
  }
1357
+ if (powerupDefault !== null && typeof powerupDefault === 'object') {
1358
+ const value = powerupDefault.value === undefined ? null : powerupDefault.value;
1359
+ const url = powerupDefault.url ?? value ?? OFFICIAL_REGISTRY;
1360
+ add({
1361
+ label: typeof powerupDefault.label === 'string'
1362
+ ? powerupDefault.label
1363
+ : (value === null ? t('update.registryDefault') : t('update.registryCustom')),
1364
+ hint: typeof powerupDefault.hint === 'string' ? powerupDefault.hint : registryHost(url),
1365
+ value,
1366
+ url: normalizeRegistry(url),
1367
+ });
1368
+ }
1249
1369
  if (choices.length === 0) {
1250
1370
  add({
1251
1371
  label: t('update.registryDefault'),
@@ -1258,12 +1378,35 @@
1258
1378
  }
1259
1379
 
1260
1380
  /**
1261
- * Choose the offered registry matching what the Add dialog remembered.
1381
+ * Find the offered registry matching a persisted Power-Up default.
1382
+ * @param {Array<{ key: string, value: string | null, url: string }>} choices
1383
+ * @param {{ key?: string, value?: string | null, url?: string } | null} stored
1384
+ * @returns {object | undefined}
1385
+ */
1386
+ function matchStoredChoice(choices, stored) {
1387
+ if (stored === null || typeof stored !== 'object') return undefined;
1388
+ if (typeof stored.key === 'string') {
1389
+ const byKey = choices.find((choice) => choice.key === stored.key);
1390
+ if (byKey !== undefined) return byKey;
1391
+ }
1392
+ const value = stored.value === undefined ? null : stored.value;
1393
+ if (value === null) return choices.find((choice) => choice.value === null);
1394
+ const wanted = normalizeRegistry(value ?? stored.url ?? OFFICIAL_REGISTRY);
1395
+ return choices.find((choice) => choice.value !== null && normalizeRegistry(choice.value) === wanted)
1396
+ ?? choices.find((choice) => normalizeRegistry(choice.url) === wanted);
1397
+ }
1398
+
1399
+ /**
1400
+ * Choose the registry the update list starts from: Power-Up's starred
1401
+ * default, then what the Add dialog remembered, then the configured one.
1262
1402
  * @param {Array<{ key: string, value: string | null, url: string }>} choices
1263
1403
  * @param {object | null} remembered
1404
+ * @param {object | null} powerupDefault
1264
1405
  * @returns {object}
1265
1406
  */
1266
- function pickRegistryChoice(choices, remembered) {
1407
+ function pickRegistryChoice(choices, remembered, powerupDefault) {
1408
+ const starred = matchStoredChoice(choices, powerupDefault);
1409
+ if (starred !== undefined) return starred;
1267
1410
  if (remembered !== null && typeof remembered === 'object') {
1268
1411
  if (remembered.kind === 'offered') {
1269
1412
  const wanted = remembered.registry === null ? null : normalizeRegistry(remembered.registry);
@@ -1462,20 +1605,23 @@
1462
1605
 
1463
1606
  /** Tooltip/accessible label for one injected update button. */
1464
1607
  function updateButtonLabel(name, t) {
1465
- return name === MODULE_ID ? t('update.selfTitle') : `${t('update.button')} · ${name}`;
1608
+ return `${t('update.button')} · ${name}`;
1466
1609
  }
1467
1610
 
1468
1611
  /**
1469
1612
  * Ensure every third-party card in the built-in "Installed plugins"
1470
- * group carries one update button. Idempotent, so React re-renders and
1471
- * the observer can call it repeatedly.
1613
+ * group carries one update button and, when its update waits for a
1614
+ * restart, a small restart tag. Idempotent, so React re-renders and the
1615
+ * observer can call it repeatedly.
1472
1616
  * @param {Document} doc
1473
1617
  * @param {(name: string) => void} open
1618
+ * @param {string[]} pendingNames
1474
1619
  * @param {(key: string, params?: object) => string} t
1475
1620
  * @param {Set<Element>} buttons
1476
1621
  */
1477
- function ensureUpdateButtons(doc, open, t, buttons) {
1622
+ function ensureUpdateButtons(doc, open, pendingNames, t, buttons) {
1478
1623
  if (doc === null || doc === undefined || typeof doc.querySelectorAll !== 'function') return;
1624
+ const pending = new Set(Array.isArray(pendingNames) ? pendingNames : []);
1479
1625
  const cards = doc.querySelectorAll('[data-plugin-group="bundles"] [data-plugin-package]');
1480
1626
  for (const card of cards) {
1481
1627
  const name = card.getAttribute('data-plugin-package');
@@ -1501,10 +1647,78 @@
1501
1647
  const label = updateButtonLabel(name, t);
1502
1648
  if (button.title !== label) button.title = label;
1503
1649
  if (button.getAttribute('aria-label') !== label) button.setAttribute('aria-label', label);
1650
+
1651
+ let tag = card.querySelector('[data-dsh-powerup-restart-tag]');
1652
+ if (pending.has(name)) {
1653
+ if (tag === null || tag === undefined) {
1654
+ const head = card.firstElementChild;
1655
+ const main = head === null || head === undefined ? null : head.children?.[1];
1656
+ const titleRow = main === null || main === undefined ? null : main.firstElementChild;
1657
+ if (titleRow !== null && titleRow !== undefined) {
1658
+ tag = doc.createElement('span');
1659
+ tag.className = 'dsh-powerup-restart-tag';
1660
+ tag.setAttribute('data-dsh-powerup-restart-tag', '');
1661
+ titleRow.appendChild(tag);
1662
+ }
1663
+ }
1664
+ if (tag !== null && tag !== undefined) {
1665
+ const text = t('restart.tag');
1666
+ if (tag.textContent !== text) tag.textContent = text;
1667
+ if (tag.title !== t('restart.tagHint')) tag.title = t('restart.tagHint');
1668
+ }
1669
+ } else if (tag !== null && tag !== undefined) {
1670
+ tag.remove();
1671
+ }
1504
1672
  }
1505
1673
  for (const button of [...buttons]) if (button.isConnected !== true) buttons.delete(button);
1506
1674
  }
1507
1675
 
1676
+ /**
1677
+ * Put "N plugins need a restart" and a restart button beside the
1678
+ * Installed plugins heading. The group head owns the h3; the injected bar
1679
+ * sits at the end of that row and is removed when nothing is pending.
1680
+ * @param {Document} doc
1681
+ * @param {number} count
1682
+ * @param {() => void} onRestart
1683
+ * @param {(key: string, params?: object) => string} t
1684
+ */
1685
+ function ensureRestartBar(doc, count, onRestart, t) {
1686
+ if (doc === null || doc === undefined || typeof doc.querySelector !== 'function') return;
1687
+ const group = doc.querySelector('[data-plugin-group="bundles"]');
1688
+ if (group === null || group === undefined || count <= 0) {
1689
+ const stale = group?.querySelector?.('[data-dsh-powerup-restart-bar]');
1690
+ stale?.remove?.();
1691
+ return;
1692
+ }
1693
+ const head = group.firstElementChild;
1694
+ if (head === null || head === undefined) return;
1695
+ let bar = group.querySelector('[data-dsh-powerup-restart-bar]');
1696
+ if (bar === null || bar === undefined) {
1697
+ bar = doc.createElement('div');
1698
+ bar.className = 'dsh-powerup-restart-bar';
1699
+ bar.setAttribute('data-dsh-powerup-restart-bar', '');
1700
+ const label = doc.createElement('span');
1701
+ label.className = 'dsh-powerup-restart-count';
1702
+ const button = doc.createElement('button');
1703
+ button.type = 'button';
1704
+ button.className = 'dsh-powerup-restart-action';
1705
+ button.addEventListener('click', (event) => {
1706
+ event.preventDefault();
1707
+ event.stopPropagation();
1708
+ onRestart();
1709
+ });
1710
+ bar.appendChild(label);
1711
+ bar.appendChild(button);
1712
+ head.appendChild(bar);
1713
+ }
1714
+ const label = bar.firstElementChild;
1715
+ const button = bar.lastElementChild;
1716
+ const text = t('restart.pending', { count });
1717
+ if (label !== null && label !== undefined && label.textContent !== text) label.textContent = text;
1718
+ const action = t('restart.action');
1719
+ if (button !== null && button !== undefined && button.textContent !== action) button.textContent = action;
1720
+ }
1721
+
1508
1722
  /**
1509
1723
  * Place a floating panel under an anchor, flipping above when the space
1510
1724
  * below is short, and clamp it to the viewport.
@@ -1542,6 +1756,11 @@
1542
1756
  function PluginUpdateOverlay({ manager, remote, ctx }) {
1543
1757
  const [view, setView] = useState(null);
1544
1758
  const [localeTick, forceLocale] = useState(0);
1759
+ const [pending, setPending] = useState(() => readPendingRestarts());
1760
+ const [restartAsk, setRestartAsk] = useState(false);
1761
+ const [restartCapability, setRestartCapability] = useState(null);
1762
+ const [restartBusy, setRestartBusy] = useState(false);
1763
+ const [restartError, setRestartError] = useState(null);
1545
1764
  const tokenRef = useRef(null);
1546
1765
  const abortRef = useRef(null);
1547
1766
  const buttonsRef = useRef(new Set());
@@ -1551,6 +1770,7 @@
1551
1770
  const registryRef = useRef(null);
1552
1771
  const registryPanelRef = useRef(null);
1553
1772
  const versionSeq = useRef(0);
1773
+ const openRestartRef = useRef(() => {});
1554
1774
  const tRef = useRef((key, params) => translateCurrent(key, params));
1555
1775
  tRef.current = (key, params) => translateCurrent(key, params);
1556
1776
 
@@ -1570,7 +1790,7 @@
1570
1790
 
1571
1791
  const close = useCallback(() => {
1572
1792
  const current = view;
1573
- if (current !== null && (current.phase === 'working' || current.phase === 'restarting' || current.enabling === true)) return;
1793
+ if (current !== null && (current.phase === 'working' || current.phase === 'self-updating' || current.phase === 'restarting' || current.enabling === true)) return;
1574
1794
  tokenRef.current = null;
1575
1795
  try {
1576
1796
  abortRef.current?.abort?.();
@@ -1592,6 +1812,25 @@
1592
1812
  });
1593
1813
  }, []);
1594
1814
 
1815
+ /** Remember that one updated plugin waits for a whole-app restart. */
1816
+ const recordPending = useCallback((entry) => {
1817
+ setPending((current) => {
1818
+ const next = current.filter((item) => item.name !== entry.name);
1819
+ next.push({ ...entry, at: Date.now() });
1820
+ writePendingRestarts(next);
1821
+ return next;
1822
+ });
1823
+ }, []);
1824
+
1825
+ /** Drop a pending marker when its update never started. */
1826
+ const removePending = useCallback((name) => {
1827
+ setPending((current) => {
1828
+ const next = current.filter((item) => item.name !== name);
1829
+ writePendingRestarts(next);
1830
+ return next;
1831
+ });
1832
+ }, []);
1833
+
1595
1834
  const loadVersions = useCallback(async (token, name, choice, choices, resetVersion) => {
1596
1835
  const t = tRef.current;
1597
1836
  const seq = versionSeq.current + 1;
@@ -1640,11 +1879,13 @@
1640
1879
  setView({
1641
1880
  name,
1642
1881
  title: name,
1882
+ self: name === MODULE_ID,
1643
1883
  currentVersion: null,
1644
1884
  phase: 'loading',
1645
1885
  step: null,
1646
1886
  choices: [],
1647
1887
  registryKey: '',
1888
+ defaultRegistryKey: null,
1648
1889
  versions: [],
1649
1890
  versionsState: 'loading',
1650
1891
  versionsError: null,
@@ -1660,10 +1901,6 @@
1660
1901
  enabling: false,
1661
1902
  restarting: false,
1662
1903
  });
1663
- if (name === MODULE_ID) {
1664
- patch(token, { phase: 'self' });
1665
- return;
1666
- }
1667
1904
  try {
1668
1905
  const [bundles, registries] = await Promise.all([manager.listBundles(), manager.registries()]);
1669
1906
  if (tokenRef.current !== token) return;
@@ -1680,8 +1917,10 @@
1680
1917
  return;
1681
1918
  }
1682
1919
  const remembered = readRememberedRegistry();
1683
- const choices = buildRegistryChoices(registries.ok ? registries.value : null, remembered, t);
1684
- const choice = pickRegistryChoice(choices, remembered);
1920
+ const powerupDefault = readDefaultRegistry();
1921
+ const choices = buildRegistryChoices(registries.ok ? registries.value : null, remembered, powerupDefault, t);
1922
+ const choice = pickRegistryChoice(choices, remembered, powerupDefault);
1923
+ const starred = matchStoredChoice(choices, powerupDefault);
1685
1924
  patch(token, {
1686
1925
  title: packageDisplayTitle(pkg),
1687
1926
  currentVersion: pkg.version ?? null,
@@ -1689,6 +1928,7 @@
1689
1928
  phase: 'choose',
1690
1929
  choices,
1691
1930
  registryKey: choice.key,
1931
+ defaultRegistryKey: starred === undefined ? null : starred.key,
1692
1932
  versionsState: 'loading',
1693
1933
  });
1694
1934
  await loadVersions(token, name, choice, choices, false);
@@ -1710,7 +1950,9 @@
1710
1950
  const ensure = () => {
1711
1951
  scheduled = false;
1712
1952
  if (stopped) return;
1713
- ensureUpdateButtons(document, (name) => openRef.current(name), tRef.current, buttons);
1953
+ const names = pending.map((entry) => entry.name);
1954
+ ensureUpdateButtons(document, (name) => openRef.current(name), names, tRef.current, buttons);
1955
+ ensureRestartBar(document, pending.length, () => openRestartRef.current(), tRef.current);
1714
1956
  };
1715
1957
  const schedule = () => {
1716
1958
  if (scheduled || stopped) return;
@@ -1733,7 +1975,7 @@
1733
1975
  }
1734
1976
  buttons.clear();
1735
1977
  };
1736
- }, [localeTick]);
1978
+ }, [localeTick, pending]);
1737
1979
 
1738
1980
  // Keep the selected version input focused when the chooser opens.
1739
1981
  useEffect(() => {
@@ -1824,6 +2066,19 @@
1824
2066
  };
1825
2067
  }, [remote]);
1826
2068
 
2069
+ // Escape dismisses the list-header restart confirmation unless a
2070
+ // restart request is already in flight.
2071
+ useEffect(() => {
2072
+ if (!restartAsk) return undefined;
2073
+ const onKeyDown = (event) => {
2074
+ if (event.key !== 'Escape' || restartBusy) return;
2075
+ event.stopPropagation();
2076
+ setRestartAsk(false);
2077
+ };
2078
+ document.addEventListener('keydown', onKeyDown, true);
2079
+ return () => document.removeEventListener('keydown', onKeyDown, true);
2080
+ }, [restartAsk, restartBusy]);
2081
+
1827
2082
  const chooseVersion = (version) => {
1828
2083
  if (tokenRef.current === null) return;
1829
2084
  setView((current) => current === null ? current : { ...current, version, listOpen: false, listPopover: null, formError: null });
@@ -1840,6 +2095,19 @@
1840
2095
  loadVersions(token, current.name, choice, current.choices, true);
1841
2096
  };
1842
2097
 
2098
+ /** Star one registry as Power-Up's default and select it for this update. */
2099
+ const setDefaultRegistry = (choice) => {
2100
+ if (choice === null || choice === undefined) return;
2101
+ const current = view;
2102
+ writeDefaultRegistry(choice);
2103
+ setView((state) => state === null ? state : {
2104
+ ...state,
2105
+ defaultRegistryKey: choice.key,
2106
+ registryKey: choice.key,
2107
+ });
2108
+ if (current !== null) loadVersions(tokenRef.current, current.name, choice, current.choices, true);
2109
+ };
2110
+
1843
2111
  /** Open or close the floating version list under the input row. */
1844
2112
  const toggleVersionList = () => {
1845
2113
  const current = view;
@@ -1892,7 +2160,29 @@
1892
2160
  const registry = registryValue(choice);
1893
2161
  const spec = `${current.name}@${version}`;
1894
2162
  const requestId = newRequestId();
1895
- patch(token, { phase: 'working', step: 'removing', error: null, formError: null, log: '', logLabel: spec });
2163
+ patch(token, { phase: 'working', step: current.self === true ? 'installing' : 'removing', error: null, formError: null, log: '', logLabel: spec });
2164
+ if (current.self === true) {
2165
+ // Power-Up cannot reinstall itself from its own client: removing
2166
+ // the bundle unloads this dialog. The Host route receives the
2167
+ // request, answers, then hands remove+inspect+install to the
2168
+ // built-in manager service, which keeps running in the Host
2169
+ // process after this plugin unloads.
2170
+ recordPending({ name: MODULE_ID, version, reason: 'self-update' });
2171
+ try {
2172
+ const response = await fetch(SELF_UPDATE_API, {
2173
+ method: 'POST',
2174
+ headers: { 'content-type': 'application/json' },
2175
+ body: JSON.stringify({ version, registry }),
2176
+ });
2177
+ const data = await response.json().catch(() => null);
2178
+ if (!response.ok || data?.ok !== true) throw new Error(data?.error ?? `HTTP ${response.status}`);
2179
+ patch(token, { phase: 'self-updating', step: null });
2180
+ } catch (error) {
2181
+ removePending(MODULE_ID);
2182
+ patch(token, { phase: 'error', step: null, error: errorText(error) || t('update.unknown') });
2183
+ }
2184
+ return;
2185
+ }
1896
2186
  try {
1897
2187
  // The update flow removes the old version first: the Host's
1898
2188
  // inspection refuses a package that is still installed, so the
@@ -1915,11 +2205,19 @@
1915
2205
  const value = installed.value;
1916
2206
  if (value.error !== undefined || value.application === 'failed') throw new Error(operationReason(value, t));
1917
2207
  if (value.application === 'cancelled') throw new Error(t('update.cancelled'));
2208
+ const restartRequired = value.application === 'restart-required';
2209
+ if (restartRequired) {
2210
+ recordPending({
2211
+ name: current.name,
2212
+ version: value.version ?? version,
2213
+ reason: value.restartReason ?? '',
2214
+ });
2215
+ }
1918
2216
  patch(token, {
1919
2217
  phase: 'enable',
1920
2218
  step: null,
1921
2219
  installedVersion: value.version ?? version,
1922
- restartRequired: value.application === 'restart-required',
2220
+ restartRequired,
1923
2221
  enabling: false,
1924
2222
  });
1925
2223
  } catch (error) {
@@ -1941,6 +2239,35 @@
1941
2239
  }
1942
2240
  };
1943
2241
 
2242
+ /** Open the list-header restart confirmation and read the capability. */
2243
+ const openRestart = () => {
2244
+ setRestartAsk(true);
2245
+ setRestartCapability(null);
2246
+ setRestartError(null);
2247
+ setRestartBusy(false);
2248
+ void readRestartCapability().then((capability) => setRestartCapability(capability));
2249
+ };
2250
+ openRestartRef.current = openRestart;
2251
+
2252
+ /** Confirm the list-header restart. */
2253
+ const confirmRestart = async () => {
2254
+ if (restartBusy) return;
2255
+ setRestartBusy(true);
2256
+ setRestartError(null);
2257
+ try {
2258
+ const response = await fetch(UPDATE_API, {
2259
+ method: 'POST',
2260
+ headers: { 'content-type': 'application/json' },
2261
+ body: JSON.stringify({ action: 'restart' }),
2262
+ });
2263
+ const data = await response.json().catch(() => null);
2264
+ if (!response.ok || data?.ok !== true) throw new Error(data?.error ?? `HTTP ${response.status}`);
2265
+ } catch (error) {
2266
+ setRestartBusy(false);
2267
+ setRestartError(errorText(error) || tRef.current('update.unknown'));
2268
+ }
2269
+ };
2270
+
1944
2271
  const enableNow = async () => {
1945
2272
  const token = tokenRef.current;
1946
2273
  const current = view;
@@ -1952,6 +2279,13 @@
1952
2279
  if (tokenRef.current !== token) return;
1953
2280
  if (!result.ok) throw new Error(result.error?.message ?? t('update.unknown'));
1954
2281
  if (result.value.error !== undefined) throw new Error(operationReason(result.value, t));
2282
+ if (result.value.application === 'restart-required' || current.restartRequired === true) {
2283
+ recordPending({
2284
+ name: current.name,
2285
+ version: current.installedVersion ?? '',
2286
+ reason: result.value.restartReason ?? '',
2287
+ });
2288
+ }
1955
2289
  const restart = await readRestartCapability();
1956
2290
  patch(token, { phase: 'restart', enabling: false, restart });
1957
2291
  } catch (error) {
@@ -1990,9 +2324,64 @@
1990
2324
  }
1991
2325
  };
1992
2326
 
1993
- if (view === null) return null;
1994
2327
  const t = (key, params) => translateCurrent(key, params);
1995
- const busy = view.phase === 'working' || view.phase === 'restarting' || view.enabling === true;
2328
+
2329
+ /** One tinted notice: the success/warn/error states share this chrome. */
2330
+ const notice = (tone, icon, title, message) => h('div', { className: `dsh-powerup-update-notice ${tone}` },
2331
+ icon,
2332
+ h('div', { className: 'dsh-powerup-update-notice-copy' },
2333
+ title === null || title === undefined ? null : h('span', { className: 'dsh-powerup-update-notice-title' }, title),
2334
+ message === null || message === undefined ? null : h('span', null, message),
2335
+ ),
2336
+ );
2337
+
2338
+ const restartPrompt = restartAsk ? h('div', {
2339
+ className: 'dsh-powerup-update-mask',
2340
+ onMouseDown: (event) => {
2341
+ if (event.target === event.currentTarget && !restartBusy) setRestartAsk(false);
2342
+ },
2343
+ },
2344
+ h('div', {
2345
+ className: 'dsh-powerup-update-dialog',
2346
+ style: { width: 'min(420px,100%)' },
2347
+ role: 'dialog',
2348
+ 'aria-modal': 'true',
2349
+ 'aria-labelledby': 'dsh-powerup-restart-title',
2350
+ },
2351
+ h('div', { className: 'dsh-powerup-update-head' },
2352
+ h('div', { className: 'dsh-powerup-update-heading' },
2353
+ h('h2', { id: 'dsh-powerup-restart-title' }, t('restart.title')),
2354
+ h('p', null, t('restart.pending', { count: pending.length })),
2355
+ ),
2356
+ restartBusy ? null : h('button', { type: 'button', className: 'dsh-powerup-update-close', 'aria-label': t('update.close'), onClick: () => setRestartAsk(false) }, '×'),
2357
+ ),
2358
+ h('div', { className: 'dsh-powerup-update-body' },
2359
+ h('p', { className: 'dsh-powerup-update-note' }, t('restart.desc')),
2360
+ restartCapability !== null && restartCapability.supported !== true
2361
+ ? notice('warn', h(WarnIcon, { size: 14 }), null, t('update.restartUnsupported', { reason: restartCapability.reason ?? '' }))
2362
+ : null,
2363
+ restartError === null ? null : notice('error', h(WarnIcon, { size: 14 }), null, restartError),
2364
+ restartBusy
2365
+ ? h('div', { className: 'dsh-powerup-update-status' },
2366
+ h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
2367
+ t('update.restarting'),
2368
+ )
2369
+ : null,
2370
+ ),
2371
+ h('div', { className: 'dsh-powerup-update-foot' },
2372
+ h('button', { type: 'button', className: 'dsh-powerup-update-ghost', disabled: restartBusy, onClick: () => setRestartAsk(false) }, t('restart.cancel')),
2373
+ h('button', {
2374
+ type: 'button',
2375
+ className: 'dsh-powerup-update-primary',
2376
+ disabled: restartBusy || restartCapability?.supported !== true,
2377
+ onClick: confirmRestart,
2378
+ }, restartBusy ? t('update.restarting') : t('restart.confirm')),
2379
+ ),
2380
+ ),
2381
+ ) : null;
2382
+
2383
+ if (view === null) return restartPrompt;
2384
+ const busy = view.phase === 'working' || view.phase === 'self-updating' || view.phase === 'restarting' || view.enabling === true;
1996
2385
  const canClose = !busy;
1997
2386
  const title = view.title === undefined || view.title === '' ? view.name : view.title;
1998
2387
  const showVersions = view.phase === 'choose' && view.listOpen === true;
@@ -2023,21 +2412,12 @@
2023
2412
  supported ? h('button', { key: 'now', type: 'button', className: 'dsh-powerup-update-primary', disabled: view.restarting === true, onClick: restartNow }, view.restarting === true ? t('update.restarting') : t('update.restartNow')) : null,
2024
2413
  ].filter(Boolean);
2025
2414
  }
2026
- if (view.phase === 'restarting' || view.phase === 'working' || view.phase === 'loading') return [];
2415
+ if (view.phase === 'restarting' || view.phase === 'working' || view.phase === 'self-updating' || view.phase === 'loading') return [];
2027
2416
  return [
2028
2417
  h('button', { key: 'done', type: 'button', className: 'dsh-powerup-update-primary', onClick: close }, view.phase === 'done' ? t('update.doneButton') : t('update.close')),
2029
2418
  ];
2030
2419
  })();
2031
2420
 
2032
- /** One tinted notice: the success/warn/error states share this chrome. */
2033
- const notice = (tone, icon, title, message) => h('div', { className: `dsh-powerup-update-notice ${tone}` },
2034
- icon,
2035
- h('div', { className: 'dsh-powerup-update-notice-copy' },
2036
- title === null || title === undefined ? null : h('span', { className: 'dsh-powerup-update-notice-title' }, title),
2037
- message === null || message === undefined ? null : h('span', null, message),
2038
- ),
2039
- );
2040
-
2041
2421
  const currentChoice = view.choices.find((item) => item.key === view.registryKey) ?? view.choices[0];
2042
2422
 
2043
2423
  /** Rows of the floating version list, including its loading/error states. */
@@ -2102,8 +2482,15 @@
2102
2482
  t('update.loading'),
2103
2483
  );
2104
2484
  }
2105
- if (view.phase === 'self') {
2106
- return notice('warn', h(WarnIcon, { size: 15 }), t('update.selfTitle'), t('update.selfDesc'));
2485
+ if (view.phase === 'self-updating') {
2486
+ return [
2487
+ h('div', { key: 'status', className: 'dsh-powerup-update-status' },
2488
+ h('span', { className: 'dsh-powerup-update-spinner', 'aria-hidden': 'true' }),
2489
+ t('update.selfUpdating'),
2490
+ ),
2491
+ h('p', { key: 'note', className: 'dsh-powerup-update-note' }, t('update.selfRefresh')),
2492
+ h('span', { key: 'name', className: 'dsh-powerup-update-name' }, view.name),
2493
+ ];
2107
2494
  }
2108
2495
  if (view.phase === 'blocked') {
2109
2496
  return notice('error', h(WarnIcon, { size: 15 }), t('update.unsupportedTitle'), view.error ?? t('update.notRemovable'));
@@ -2118,6 +2505,9 @@
2118
2505
  ),
2119
2506
  h('span', { className: 'dsh-powerup-update-name' }, view.name),
2120
2507
  ),
2508
+ view.self === true
2509
+ ? notice('warn', h(WarnIcon, { size: 14 }), null, t('update.selfUpdateWarning'))
2510
+ : null,
2121
2511
  h('div', { key: 'version', className: 'dsh-powerup-update-field' },
2122
2512
  h('label', { className: 'dsh-powerup-update-label', htmlFor: 'dsh-powerup-update-version' }, t('update.versionLabel')),
2123
2513
  h('div', { className: 'dsh-powerup-update-row', ref: versionRef },
@@ -2289,20 +2679,42 @@
2289
2679
  role: 'listbox',
2290
2680
  'aria-label': t('update.registry'),
2291
2681
  style: view.registryPopover ?? fallbackPopover,
2292
- }, view.choices.map((choice) => h('button', {
2293
- key: choice.key,
2294
- type: 'button',
2295
- role: 'option',
2296
- className: 'dsh-powerup-update-option',
2297
- 'aria-selected': choice.key === view.registryKey ? 'true' : 'false',
2298
- onClick: () => chooseRegistry(choice.key),
2299
- },
2300
- h('span', { className: 'dsh-powerup-update-option-copy' },
2301
- h('span', { className: 'dsh-powerup-update-option-title' }, choice.label),
2302
- h('span', { className: 'dsh-powerup-update-option-host' }, choice.hint),
2303
- ),
2304
- choice.key === view.registryKey ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
2305
- )))
2682
+ }, view.choices.map((choice) => {
2683
+ const isDefault = choice.key === view.defaultRegistryKey;
2684
+ const isChosen = choice.key === view.registryKey;
2685
+ return h('div', {
2686
+ key: choice.key,
2687
+ role: 'option',
2688
+ tabIndex: 0,
2689
+ className: 'dsh-powerup-update-option',
2690
+ 'aria-selected': isChosen ? 'true' : 'false',
2691
+ onClick: () => chooseRegistry(choice.key),
2692
+ onKeyDown: (event) => {
2693
+ if (event.key !== 'Enter' && event.key !== ' ') return;
2694
+ event.preventDefault();
2695
+ chooseRegistry(choice.key);
2696
+ },
2697
+ },
2698
+ h('span', { className: 'dsh-powerup-update-option-copy' },
2699
+ h('span', { className: 'dsh-powerup-update-option-title' }, choice.label),
2700
+ h('span', { className: 'dsh-powerup-update-option-host' }, choice.hint),
2701
+ ),
2702
+ isDefault
2703
+ ? h('span', { className: 'dsh-powerup-update-default-tag', title: t('update.defaultHint') }, t('update.defaultTag'))
2704
+ : h('button', {
2705
+ type: 'button',
2706
+ className: 'dsh-powerup-update-default',
2707
+ title: t('update.setDefault'),
2708
+ 'aria-label': `${t('update.setDefault')} ${choice.label}`,
2709
+ onClick: (event) => {
2710
+ event.preventDefault();
2711
+ event.stopPropagation();
2712
+ setDefaultRegistry(choice);
2713
+ },
2714
+ }, h(StarIcon, { size: 12 })),
2715
+ isChosen ? h('span', { className: 'dsh-powerup-update-check' }, h(CheckIcon, { size: 13 })) : null,
2716
+ );
2717
+ }))
2306
2718
  : null,
2307
2719
  );
2308
2720
  }
@@ -2677,6 +3089,7 @@
2677
3089
  PowerUpOverlay,
2678
3090
  PluginUpdateOverlay,
2679
3091
  ensureUpdateButtons,
3092
+ ensureRestartBar,
2680
3093
  updateButtonLabel,
2681
3094
  popoverPlacement,
2682
3095
  rowKeyParts,
@@ -2690,6 +3103,10 @@
2690
3103
  compareVersionsDesc,
2691
3104
  buildRegistryChoices,
2692
3105
  pickRegistryChoice,
3106
+ matchStoredChoice,
3107
+ readDefaultRegistry,
3108
+ writeDefaultRegistry,
3109
+ readPendingRestarts,
2693
3110
  lookupRegistries,
2694
3111
  fetchRegistryVersions,
2695
3112
  registryHost,
package/index.js CHANGED
@@ -1,23 +1,38 @@
1
1
  /**
2
2
  * Host half of dsh-powerup.
3
3
  *
4
- * The plugin is primarily a Client UI upgrade. This Host entry exists for one
5
- * operation the browser cannot perform itself: restarting the whole DSH
6
- * desktop application after a plugin update. It exposes a small loopback-only
7
- * route under `/dsh-powerup/api`:
4
+ * The plugin is primarily a Client UI upgrade. This Host entry owns the two
5
+ * operations the browser cannot perform itself:
8
6
  *
9
- * GET /dsh-powerup/api/restart → { ok, supported, reason? }
10
- * POST /dsh-powerup/api/restart → schedules the restart, then answers
7
+ * GET /dsh-powerup/api/restart → { ok, supported, reason? }
8
+ * POST /dsh-powerup/api/restart → schedules a whole-app restart
9
+ * POST /dsh-powerup/api/self-update → removes and reinstalls this plugin
10
+ * in the background, surviving the
11
+ * plugin's own unload
11
12
  *
12
- * The Client half drives plugin installation through the built-in
13
- * `pluginManager` Remote; only the process restart needs Host code.
13
+ * The Client half drives other plugins' installation through the built-in
14
+ * `pluginManager` Remote. Only the process restart and the self-update handoff
15
+ * need Host code.
14
16
  */
15
17
 
18
+ import { randomUUID } from 'node:crypto';
19
+ import { appendFileSync, mkdirSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { homedir } from 'node:os';
16
22
  import { describeRestart, requestRestart } from './lib/restart.js';
17
23
 
18
24
  /** Prefix owned by this plugin. */
19
25
  const ROUTE = '/dsh-powerup/api';
20
26
 
27
+ /** The bundle this pair of routes may replace while its own UI unloads. */
28
+ const MODULE_ID = '@restregular/dsh-powerup';
29
+
30
+ /** Let the HTTP response reach the browser before the plugin unloads. */
31
+ const SELF_UPDATE_DELAY_MS = 600;
32
+
33
+ /** Request body bound for the small JSON commands. */
34
+ const BODY_LIMIT = 16 * 1024;
35
+
21
36
  /**
22
37
  * @param {import('node:http').ServerResponse} res
23
38
  * @param {unknown} payload
@@ -34,6 +49,23 @@ function sendJson(res, payload, status = 200) {
34
49
  res.end(body);
35
50
  }
36
51
 
52
+ /**
53
+ * Read a bounded JSON request body.
54
+ * @param {import('node:http').IncomingMessage} req
55
+ * @returns {Promise<unknown>}
56
+ */
57
+ async function readJsonBody(req) {
58
+ const chunks = [];
59
+ let size = 0;
60
+ for await (const chunk of req) {
61
+ size += chunk.length;
62
+ if (size > BODY_LIMIT) throw new Error('body-too-large');
63
+ chunks.push(chunk);
64
+ }
65
+ if (size === 0) return {};
66
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
67
+ }
68
+
37
69
  /**
38
70
  * The request came from this machine.
39
71
  * @param {import('node:http').IncomingMessage} req
@@ -64,11 +96,89 @@ function sameOrigin(req) {
64
96
  }
65
97
  }
66
98
 
99
+ /**
100
+ * Append one line to the self-update log; logging never blocks the operation.
101
+ * @param {string} line
102
+ */
103
+ function appendSelfUpdateLog(line) {
104
+ try {
105
+ const path = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'powerup', 'self-update.log');
106
+ mkdirSync(dirname(path), { recursive: true });
107
+ appendFileSync(path, `${new Date().toISOString()} ${line}\n`);
108
+ } catch {
109
+ /* The diagnostic log is optional. */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * The concrete versions the update dialog accepts. The Client applies the same
115
+ * shape before it removes anything; this is the Host-side guard.
116
+ * @param {unknown} value
117
+ * @returns {string}
118
+ */
119
+ function normalizeVersion(value) {
120
+ const text = String(value ?? '').trim().replace(/^v/u, '');
121
+ if (text === '' || /\s/u.test(text) || text.includes('@')) return '';
122
+ return /^\d+(?:\.\d+)*(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(text) ? text : '';
123
+ }
124
+
125
+ /**
126
+ * Remove and reinstall this plugin through the built-in manager. The function
127
+ * is started after the HTTP response and keeps running in the Host process
128
+ * even when removing the bundle unloads this plugin's own Host fiber.
129
+ *
130
+ * @param {import('@deepseek-ai/cordis').Context} ctx
131
+ * @param {string} version
132
+ * @param {string | null} registry
133
+ * @param {{ manager?: unknown, log?: (line: string) => void }} [options]
134
+ * @returns {Promise<{ ok: boolean, stage?: string, error?: string }>}
135
+ */
136
+ export async function performSelfUpdate(ctx, version, registry, options = {}) {
137
+ const log = options.log ?? appendSelfUpdateLog;
138
+ const manager = options.manager ?? ctx?.get?.('pluginManager');
139
+ if (manager === undefined || typeof manager.installBundle !== 'function') {
140
+ log('self-update: pluginManager service is unavailable');
141
+ return { ok: false, stage: 'manager', error: 'pluginManager service is unavailable' };
142
+ }
143
+ const spec = `${MODULE_ID}@${version}`;
144
+ log(`self-update: start ${spec} registry=${String(registry)}`);
145
+ let stage = 'remove';
146
+ try {
147
+ const removed = await manager.removeBundle(MODULE_ID);
148
+ if (removed?.error !== undefined) {
149
+ throw new Error(`remove failed: ${removed.error.code ?? 'error'} ${removed.error.diagnostic ?? ''}`.trim());
150
+ }
151
+ log(`self-update: removed (application=${removed?.application ?? 'unknown'})`);
152
+ stage = 'inspect';
153
+ const checked = await manager.inspect(spec, { registry });
154
+ if (checked?.status !== 'accepted') {
155
+ throw new Error(`inspect refused: ${checked?.problem ?? 'unknown'} ${checked?.reason ?? ''}`.trim());
156
+ }
157
+ log(`self-update: inspected (bundle=${String(checked.bundle)})`);
158
+ stage = 'install';
159
+ const installed = await manager.installBundle(spec, {
160
+ enabled: true,
161
+ registry,
162
+ requestId: randomUUID(),
163
+ });
164
+ if (installed?.error !== undefined) {
165
+ throw new Error(`install failed: ${installed.error.code ?? 'error'} ${installed.error.diagnostic ?? ''}`.trim());
166
+ }
167
+ log(`self-update: installed (application=${installed?.application ?? 'unknown'}, version=${installed?.version ?? 'unknown'})`);
168
+ return { ok: true, stage: 'install' };
169
+ } catch (error) {
170
+ const message = String(error?.message ?? error);
171
+ log(`self-update: failed at ${stage}: ${message}${error?.stack === undefined ? '' : `\n${error.stack}`}`);
172
+ return { ok: false, stage, error: message };
173
+ }
174
+ }
175
+
67
176
  /**
68
177
  * @param {import('node:http').IncomingMessage} req
69
178
  * @param {import('node:http').ServerResponse} res
179
+ * @param {import('@deepseek-ai/cordis').Context} ctx
70
180
  */
71
- async function handle(req, res) {
181
+ async function handle(req, res, ctx) {
72
182
  if (!isLoopback(req) || !sameOrigin(req)) {
73
183
  sendJson(res, { ok: false, error: 'forbidden' }, 403);
74
184
  return;
@@ -76,11 +186,44 @@ async function handle(req, res) {
76
186
 
77
187
  const url = new URL(req.url ?? ROUTE, 'http://localhost');
78
188
  const op = url.pathname.slice(ROUTE.length).replace(/^\/+/u, '') || url.searchParams.get('op') || '';
79
- if (op !== '' && op !== 'restart') {
189
+ if (op !== '' && op !== 'restart' && op !== 'self-update') {
80
190
  sendJson(res, { ok: false, error: 'not-found' }, 404);
81
191
  return;
82
192
  }
83
193
 
194
+ if (op === 'self-update') {
195
+ if (req.method !== 'POST') {
196
+ sendJson(res, { ok: false, error: 'method-not-allowed' }, 405);
197
+ return;
198
+ }
199
+ let body;
200
+ try {
201
+ body = await readJsonBody(req);
202
+ } catch (error) {
203
+ sendJson(res, { ok: false, error: String(error?.message ?? error) }, 400);
204
+ return;
205
+ }
206
+ const version = normalizeVersion(body?.version);
207
+ if (version === '') {
208
+ sendJson(res, { ok: false, code: 'invalid-version', error: 'a concrete version is required' }, 400);
209
+ return;
210
+ }
211
+ const registry = typeof body?.registry === 'string' && body.registry.trim() !== '' ? body.registry.trim() : null;
212
+ const manager = ctx?.get?.('pluginManager');
213
+ if (manager === undefined || typeof manager.removeBundle !== 'function' || typeof manager.installBundle !== 'function' || typeof manager.inspect !== 'function') {
214
+ sendJson(res, { ok: false, code: 'manager-unavailable', error: 'pluginManager service is unavailable' }, 503);
215
+ return;
216
+ }
217
+ sendJson(res, { ok: true, started: true, version });
218
+ const timer = setTimeout(() => {
219
+ void performSelfUpdate(ctx, version, registry, { manager }).catch((error) => {
220
+ appendSelfUpdateLog(`self-update: uncaught ${String(error?.stack ?? error)}`);
221
+ });
222
+ }, SELF_UPDATE_DELAY_MS);
223
+ timer.unref?.();
224
+ return;
225
+ }
226
+
84
227
  if (req.method === 'GET') {
85
228
  sendJson(res, { ok: true, action: 'restart', ...describeRestart() });
86
229
  return;
@@ -100,7 +243,7 @@ async function handle(req, res) {
100
243
  }
101
244
 
102
245
  /**
103
- * Mount the restart route once the Web server exists.
246
+ * Mount the restart and self-update routes once the Web server exists.
104
247
  * @param {import('@deepseek-ai/cordis').Context} ctx
105
248
  */
106
249
  export function apply(ctx) {
@@ -109,11 +252,15 @@ export function apply(ctx) {
109
252
  const webServer = scope.get('webServer');
110
253
  if (webServer === undefined || typeof webServer.register !== 'function') return;
111
254
  scope.effect(
112
- () => webServer.register({ kind: 'prefix', path: ROUTE, handler: handle }),
113
- 'dsh-powerup: restart route',
255
+ () => webServer.register({
256
+ kind: 'prefix',
257
+ path: ROUTE,
258
+ handler: (req, res) => handle(req, res, ctx),
259
+ }),
260
+ 'dsh-powerup: restart and self-update routes',
114
261
  );
115
262
  });
116
263
  }
117
264
 
118
265
  /** Exported for tests only; the Host never reads this. */
119
- export const __test = { handle, isLoopback, sameOrigin, ROUTE };
266
+ export const __test = { handle, isLoopback, sameOrigin, performSelfUpdate, normalizeVersion, ROUTE, MODULE_ID };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@restregular/dsh-powerup",
3
- "version": "0.2.3",
4
- "description": "Power-Up — small, opt-in upgrades for DeepSeek Harness. Adds a right-click action menu to the Trajectory ledger and an update control for third-party plugins installed from the Plugins page, with version lookup, uninstall-and-reinstall through the Add plugin path, and an optional DSH restart. · 为 DeepSeek Harness 提供小型增强:在轨迹页面右键记录即可复制、引用、查看详情或从该记录 Fork;并在插件页为第三方已安装插件提供带版本查询的更新按钮,沿用「添加插件」的安装流程,并可重启 DSH 使更改生效。",
3
+ "version": "0.3.0",
4
+ "description": "Power-Up — small, opt-in upgrades for DeepSeek Harness. Adds a right-click action menu to the Trajectory ledger and an update control for third-party plugins installed from the Plugins page, with version lookup from the configured registries, a starred default source, uninstall-and-reinstall through the Add plugin path, restart-needed markers, a background self-update, and an optional DSH restart. · 为 DeepSeek Harness 提供小型增强:在轨迹页面右键记录即可复制、引用、查看详情或从该记录 Fork;并在插件页为已安装插件提供带版本查询的更新按钮,安装源可设默认,沿用「添加插件」的安装流程,支持待重启标记、后台自更新与一键重启。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
7
7
  "main": "index.js",