dsh-plugin-upgrader 1.0.1 → 1.1.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/CHANGELOG.md CHANGED
@@ -1,5 +1,129 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0 — 2026-10-27
4
+
5
+ The first published release since 1.0.2: the `1.0.3` entries below shipped in
6
+ this version, together with everything listed here.
7
+
8
+ - **Upgrading a theme plugin no longer leaves the page unstyled.** Replacing an
9
+ appearance plugin (`dsh-dream-skin`, `*-skin-*`, `dsh-client-ui-theme`) unloads
10
+ the code that injected its stylesheet, so the page snaps back to the host's
11
+ default look. The settings are NOT lost — `dsh-dream-skin` keeps them in
12
+ `$DSH_HOME/dream-skin.json`, outside the profile and written atomically, which
13
+ is why that file survives an upgrade untouched — but the injected CSS and the
14
+ `data-*` attributes on `<html>` lived only in the page, and the restarted
15
+ plugin has no way to put them back without being asked. A batch that actually
16
+ replaced such a plugin now says so and reloads the page once, after the plugin
17
+ has restarted, so the look comes back from that file. The reload is skipped for
18
+ failed steps, for steps that changed nothing, and for every ordinary plugin — a
19
+ failed upgrade must not also throw the page away.
20
+ - **The upgrader's own row explains itself, and can update itself.** Both halves
21
+ of "这个插件没法管理自己" were real, and they are different problems:
22
+ - *The row was blank.* Installing as `link:`/`file:` made the overview skip the
23
+ registry lookup entirely, so the row showed a version, an arrow with nothing
24
+ after it, and no history. A path install now still reads the published list
25
+ (the directory keeps its own version, which is what is actually loaded) and
26
+ says so, instead of looking like a package that was never published. It stays
27
+ non-upgradable: replacing a `link:` with a registry tarball is a different
28
+ install, not an upgrade.
29
+ - *It could not update itself.* A version change through the batch job starts
30
+ with `removeBundle`, and removing this bundle unloads the very code that
31
+ would run the install that follows — the batch dies with the plugin that was
32
+ driving it, leaving the profile without it. The row now offers the page's own
33
+ installer instead (`plugins.tab` owner prop `install`), which is owned by the
34
+ plugin manager and therefore survives the unload. The batch and "Select all"
35
+ skip this row for the same reason, so one click can no longer strand the
36
+ profile.
37
+ - **The version list no longer silently disappears.** Several plugins drew no
38
+ history button and no explanation, which looked like "this package has no
39
+ published versions" — it was a failed registry lookup being swallowed. Three
40
+ separate causes, all measured against `registry.npmmirror.com`:
41
+ - **The tab asked for every package at once.** Eleven concurrent connections
42
+ timed out at the 10 s deadline while the same eleven asked one at a time
43
+ answered in under 1.2 s. Lookups now go through a four-at-a-time cap.
44
+ - **The documents are large.** `billion-context` is a 26 MB packument and
45
+ 10 MB even abbreviated, so a healthy request could lose a 10 s race on its
46
+ own. Lookups now ask for npm's abbreviated form
47
+ (`application/vnd.npm.install-v1+json`, which keeps `time`, `engines` and
48
+ `deprecated`), the deadline moved to 20 s, and a transport failure is
49
+ retried once — a `UND_ERR_CONNECT_TIMEOUT` is socket contention, not a
50
+ verdict about the package.
51
+ - **The failure was invisible.** A row whose lookup failed is now drawn with
52
+ the reason (`版本列表读取失败,点「检查更新」可重试`) instead of looking
53
+ like a package with no history. A failed lookup is also cached for a shorter
54
+ window than a successful one, so one bad render does not make the next
55
+ mount slow too.
56
+ - **A mirror's stale `latest` tag no longer turns an upgrade into a downgrade.**
57
+ npmmirror serves `@deepseek-ai/dsh-subagent-codex` with `latest: 0.0.1-rc.1`
58
+ while the profile runs `0.2.1-rc.3`, so the row claimed the installed build
59
+ was newer than latest and offered a downgrade as an upgrade. A tag that sorts
60
+ below the installed version is now treated as broken and recovered from the
61
+ published list, preferring a stable release over a prerelease. A prerelease
62
+ that merely sorts above a healthy tag still does not steal `latest`.
63
+ - **An upgrade no longer dies on `bundle-in-use`.** Replacing a version means
64
+ remove + install, and `removeBundle` only unloads a bundle it reads as enabled.
65
+ A plugin that is disabled but still holds a live loader fiber therefore went
66
+ straight to the "in use" check and was refused, which made every upgrade of a
67
+ disabled plugin fail. The removal now clears that fiber and retries once.
68
+ - **The unload asks the right question.** 1.0.3's follow-up assumed
69
+ `setBundleEnabled(name, false)` reloads unconditionally, so it would dispose the
70
+ fiber. It does not: `selectBundle` returns early when the bundle is already
71
+ deselected, so the manifest does not change, so `reconcileProfile` has no
72
+ dropped row to dispose and the fiber survives — the retry then failed for
73
+ exactly the same reason. The removal now reads `enabled` first and, for a
74
+ bundle the manager already reads as disabled, re-selects it and deselects it
75
+ again, which is the only form of the toggle that produces a change a live
76
+ process can act on.
77
+ - **A refusal the host cannot fix says so.** A second `bundle-in-use` now comes
78
+ back with the manager's code *and* a bilingual diagnostic ("the loader still
79
+ holds its plugin row; restart DSH and upgrade again"), and the tab shows the
80
+ diagnostic instead of the bare code. Retrying in the same process cannot clear
81
+ it, so the panel has to name the one action that does. The re-select is also
82
+ undone on the way out, so a failed upgrade never leaves the plugin enabled.
83
+ - **The primary button follows the theme.** Its label colour comes from
84
+ `--dsw-alias-label-primary-inverted` instead of a hard-coded white, so it stays
85
+ readable when the host theme uses a light primary fill.
86
+ - **Tests**: `test/install.test.ts` covers the replacement path (the remove +
87
+ install fallback and its fresh request id, the unload retry, the
88
+ already-disabled re-select including a refused one, the enabled-state restore,
89
+ and the restart diagnostic on both refusal spellings); `test/registry.test.ts`
90
+ covers the abbreviated request, the single retry, the shorter failure cache and
91
+ the 404-is-not-contention rule; `test/overview.test.ts` fails if a page load
92
+ stops respecting the cap; `test/shape-packument.test.ts` pins the stale-tag
93
+ rule. `scripts/verify-overview-live.mjs` runs the built host half against the
94
+ real mirror.
95
+
96
+ ## 1.0.3 — unreleased as its own version
97
+
98
+ These changes were staged for 1.0.3, which was never published on its own; they
99
+ first shipped in 1.1.0.
100
+
101
+ - **Upgrades report what pnpm is doing.** The host subscribes to
102
+ `plugin-manager/install-log` and `plugin-manager/install-state`, so pnpm's own
103
+ output ("Verifying lockfile against supply-chain policies", `ETIMEDOUT` retries,
104
+ registry fallbacks) now streams into the job log while the step runs, instead of
105
+ only surfacing after it ends. An upgrade that sits for five minutes on the mirror
106
+ retry ladder finally looks like what it is.
107
+ - **The progress bar is honest about long steps.** While a step is in flight the
108
+ panel shows a live elapsed clock, the phase (`installing` / `applying` /
109
+ `cancelling`), the registry attempt when a fallback is in progress, and an
110
+ indeterminate bar. `done/total` only moves when a step really finishes, which
111
+ previously left it pinned at 0% for minutes.
112
+ - **Cancel works at every stage.** The second install of a remove + install used to
113
+ pass `requestId: undefined`, so that whole stretch was uncancellable; each install
114
+ now mints its own id and the job reports it back. Cancelling also explains what
115
+ happened: settled already, past the point of no return, or stopping now.
116
+ - **The heartbeat is a timer (2 s) instead of a per-step write.** A single step over
117
+ the 15-minute stale window was declared abandoned mid-work, which both killed a
118
+ healthy job and let a second batch start against the same profile.
119
+ - **A failed install no longer tries to restore the enabled state** of a package it
120
+ never added, which used to report a second `operation-error` on top of the real
121
+ failure.
122
+ - **The panel no longer sticks after a batch settles.** `submitJob` resolves only
123
+ after the whole batch finished, and the tab wrote a fresh `queued` snapshot over
124
+ the result, leaving the spinner and the disabled buttons behind until the tab was
125
+ remounted.
126
+
3
127
  ## 1.0.1 — 2026-10-23
4
128
 
5
129
  - **Rewritten in TypeScript.** The whole project is now typed; `pnpm run typecheck`
package/README.en.md CHANGED
@@ -18,6 +18,9 @@ English · [中文](./README.md)
18
18
 
19
19
  - **One-click upgrade** — bump every upgradable plugin to its newest version, with
20
20
  progress, per-plugin results and failure reasons.
21
+ - **Progress you can actually see** — the raw pnpm output (registry retries, lockfile
22
+ verification), a live elapsed clock per step, an indeterminate bar while a step
23
+ runs, and a cancel button that works.
21
24
  - **Multi-select upgrade** — tick any subset of plugins and upgrade only those.
22
25
  - **Version history / rollback** — expand any plugin to see every published version
23
26
  (with publish date and engine requirement) and switch to it in one click.
@@ -75,8 +78,8 @@ host half:
75
78
  | --- | --- |
76
79
  | `GET /api/plugin-upgrader/overview` | installed plugins plus their published versions (registry packument, 5-minute cache, 10-second timeout) |
77
80
  | `POST /api/plugin-upgrader/job` | queue a batch of upgrade / switch steps, return a job id immediately |
78
- | `GET /api/plugin-upgrader/job?id=…` | poll progress and per-plugin results |
79
- | `POST /api/plugin-upgrader/cancel` | abort the step in flight |
81
+ | `GET /api/plugin-upgrader/job?id=…` | poll progress, current phase, elapsed time, per-plugin results and the live log |
82
+ | `POST /api/plugin-upgrader/cancel` | abort the step in flight, including the second install of a remove + install |
80
83
 
81
84
  Every route is loopback-only: a batch installs packages, so a paired remote device
82
85
  or a cross-site page must never reach it.
@@ -94,6 +97,29 @@ Removing a plugin drops its enabled / disabled state, so each step re-applies
94
97
  `setBundleEnabled` with the state it had before the upgrade. A disabled plugin stays
95
98
  disabled after being upgraded.
96
99
 
100
+ ### Why an upgrade sometimes takes minutes
101
+
102
+ Not this plugin: pnpm. Before installing, pnpm 11 re-checks every lockfile entry
103
+ against the registry (`Verifying lockfile against supply-chain policies`), and a
104
+ mirror such as `registry.npmmirror.com` answers `ETIMEDOUT` for plenty of packages,
105
+ which starts a 10-second → 1-minute retry ladder. Upgrading an already-installed
106
+ package additionally costs two pnpm runs (remove, then install).
107
+
108
+ The old build showed nothing during that time and moved the bar once per completed
109
+ step, so it read as a hang. The host now subscribes to
110
+ `plugin-manager/install-log` and `plugin-manager/install-state` and streams pnpm's
111
+ own output, the phase and the retry count into the job log, while the panel shows an
112
+ elapsed clock and an indeterminate bar — a slow install looks like a retrying one
113
+ instead of a frozen 0%.
114
+
115
+ Two related bugs fixed with it:
116
+
117
+ - The heartbeat is a timer (2 s), no longer a write per completed step. A single step
118
+ lasting over 15 minutes used to be declared abandoned, cancelling a healthy job and
119
+ letting a second batch start on the same profile.
120
+ - The second install of a remove + install passed `requestId: undefined`, making that
121
+ stretch uncancellable. Every install now has its own id, so cancel works throughout.
122
+
97
123
  ### One batch at a time
98
124
 
99
125
  The host runs one job at a time. A second submission gets `409 busy` with the id of
@@ -104,7 +130,7 @@ error:
104
130
  the running batch
105
131
  - `overview` carries an `active` field, so a freshly mounted tab reconnects to work
106
132
  in progress
107
- - a job writes a heartbeat every few seconds; a job left behind by a host restart is
133
+ - a job re-stamps a heartbeat every 2 seconds; a job left behind by a host restart is
108
134
  declared abandoned once the heartbeat goes stale, so it cannot block every later
109
135
  upgrade with a permanent `busy`
110
136
 
package/README.md CHANGED
@@ -15,6 +15,8 @@ DSH 自带的插件页能装、能卸、能看已安装列表,但缺两件事
15
15
  ## 功能
16
16
 
17
17
  - **一键升级** —— 一次把全部可升级插件升到最新版,带进度、逐项结果和失败原因。
18
+ - **看得见的进度** —— 升级时直接显示 pnpm 的原始输出(含换源重试、lockfile 校验),
19
+ 每步都有已用时计时和不确定态进度条,卡住时可以一键取消。
18
20
  - **多选升级** —— 勾选任意几个插件,只升级选中的这些。
19
21
  - **版本历史 / 回滚** —— 展开任意插件即可看到已发布的全部版本(含发布时间与引擎要求),点一下切到那个版本。
20
22
  - **一键跳 GitHub** —— 每行的 `GH` 按钮打开插件仓库主页;点版本号直接进该版本的 GitHub 发布页。
@@ -67,8 +69,8 @@ dsh plugin --profile web add "$PWD"
67
69
  | --- | --- |
68
70
  | `GET /api/plugin-upgrader/overview` | 已安装插件 + 各插件已发布版本(registry packument,5 分钟缓存,10 秒超时) |
69
71
  | `POST /api/plugin-upgrader/job` | 提交一批升级 / 切换步骤,立即返回 job id |
70
- | `GET /api/plugin-upgrader/job?id=…` | 轮询进度与逐项结果 |
71
- | `POST /api/plugin-upgrader/cancel` | 中断当前步骤 |
72
+ | `GET /api/plugin-upgrader/job?id=…` | 轮询进度、当前阶段、已用时、逐项结果与实时日志 |
73
+ | `POST /api/plugin-upgrader/cancel` | 中断当前步骤(含「先卸载再安装」的第二次安装) |
72
74
 
73
75
  所有路由仅限本机回环访问:批量操作会安装软件包,配对设备或跨站页面一律拒绝。
74
76
 
@@ -83,6 +85,25 @@ dsh plugin --profile web add "$PWD"
83
85
  卸载会丢掉插件原有的启用 / 禁用状态,因此每一步在安装完成后会按操作前的状态
84
86
  重新 `setBundleEnabled`,禁用中的插件升级后仍然是禁用状态。
85
87
 
88
+ ### 为什么升级有时候要好几分钟
89
+
90
+ 不是这个插件慢,是 pnpm 慢。pnpm 11 在 `install` 前会拿锁文件里的每个条目去 registry
91
+ 重新核对一次(`Verifying lockfile against supply-chain policies`),国内镜像
92
+ (`registry.npmmirror.com`)对不少包会 `ETIMEDOUT`,于是走 10 秒 → 1 分钟的重试阶梯。
93
+ 一次「升级」如果目标是已安装的包,还是 remove + install 两条 pnpm 命令,两段各等一次。
94
+
95
+ 旧版本在这里什么都不显示,进度条只在「整步完成」时动一格,所以看起来就是死住了。
96
+ 现在宿主端订阅了 `plugin-manager/install-log` 和 `plugin-manager/install-state`,
97
+ 把 pnpm 的原始输出、当前阶段、重试次数实时转进 job 日志,界面同时显示已用时和
98
+ 不确定态进度条:慢的时候你能看见它在重试,而不是盯着一个不动的 0%。
99
+
100
+ 顺带修掉的两个坑:
101
+
102
+ - 心跳改成定时器(2 秒),不再按「完成一步」写。单步超过 15 分钟以前会被误判成
103
+ 「job 已放弃」,于是一个健康的任务被作废,还能再并进来一批。
104
+ - 「先卸载再安装」的第二次安装以前用 `requestId: undefined`,导致那一段无法取消;
105
+ 现在每次安装都有自己的 id,取消在任何阶段都有效。
106
+
86
107
  ### 同一时刻只跑一批
87
108
 
88
109
  宿主端一次只允许一个 job。重复提交会拿到 `409 busy`(带正在跑的那个 job id),
@@ -90,7 +111,7 @@ dsh plugin --profile web add "$PWD"
90
111
 
91
112
  - 新开一个插件页标签、或在进度跑着时再点「一键升级」,都会看到正在进行的那一批
92
113
  - `overview` 里带 `active` 字段,标签页一挂载就能接上正在跑的任务
93
- - job 每隔几秒写一次心跳时间戳,宿主重启后遗留的「僵尸 job」会按心跳超时自动作废,
114
+ - job 每 2 秒刷一次心跳时间戳,宿主重启后遗留的「僵尸 job」会按心跳超时自动作废,
94
115
  不会永久占着位置让之后所有升级都撞 `busy`
95
116
 
96
117
  主进程重启会让正在跑的 job 中断,界面会把这种情况显示为失败而不是永远转圈。
package/lib/client.js CHANGED
@@ -61,6 +61,9 @@ window.__ModuleLoader__.load({
61
61
  upToDate: "\u5168\u90E8\u5DF2\u662F\u6700\u65B0",
62
62
  upgrade: "\u5347\u7EA7",
63
63
  upgrading: "\u5347\u7EA7\u4E2D\u2026",
64
+ selfUpdate: "\u7528\u9875\u9762\u5B89\u88C5\u5668\u5347\u7EA7",
65
+ selfUpdateHint: "\u63D2\u4EF6\u4E0D\u80FD\u5378\u8F7D\u81EA\u5DF1\u518D\u88C5\u56DE\u81EA\u5DF1\uFF0C\u6240\u4EE5\u8FD9\u4E00\u6B65\u4EA4\u7ED9\u63D2\u4EF6\u9875\u81EA\u5DF1\u7684\u5B89\u88C5\u6D41\u7A0B",
66
+ selfUpdateUnavailable: "\u8FD9\u4E00\u9875\u6CA1\u6709\u63D0\u4F9B\u5B89\u88C5\u5165\u53E3\uFF0C\u8BF7\u5728\u300C\u5DF2\u5B89\u88C5\u300D\u91CC\u7528\u5B98\u65B9\u5B89\u88C5\u5BF9\u8BDD\u6846\u5347\u7EA7\u672C\u63D2\u4EF6\u3002",
64
67
  history: "\u5386\u53F2\u7248\u672C",
65
68
  hideHistory: "\u6536\u8D77\u5386\u53F2",
66
69
  github: "GitHub \u4E3B\u9875",
@@ -82,19 +85,32 @@ window.__ModuleLoader__.load({
82
85
  openPage: "\u5728\u5DF2\u5B89\u88C5\u4E2D\u67E5\u770B",
83
86
  noHistory: "\u6CA1\u6709\u53EF\u7528\u7684\u7248\u672C\u8BB0\u5F55",
84
87
  noHistoryWhy: "\u8FD9\u4E2A\u5305\u4E0D\u662F\u4ECE npm \u6CE8\u518C\u8868\u5B89\u88C5\u7684\uFF0C\u6CA1\u6709\u53D1\u5E03\u5386\u53F2\u3002",
88
+ historyFailed: "\u7248\u672C\u5217\u8868\u8BFB\u53D6\u5931\u8D25\uFF0C\u70B9\u300C\u68C0\u67E5\u66F4\u65B0\u300D\u53EF\u91CD\u8BD5",
85
89
  versions: (n) => `${n} \u4E2A\u7248\u672C`,
86
90
  showingNewest: (n) => `\u5217\u51FA\u6700\u65B0 ${n} \u4E2A`,
87
91
  progress: (done, total) => `\u6B63\u5728\u5904\u7406 ${done}/${total}`,
92
+ elapsed: (text) => `\u5DF2\u7528\u65F6 ${text}`,
93
+ phaseInstalling: "\u6B63\u5728\u4E0B\u8F7D\u5E76\u5B89\u88C5",
94
+ phaseApplying: "\u6B63\u5728\u5199\u5165\u914D\u7F6E",
95
+ phaseCancelling: "\u6B63\u5728\u53D6\u6D88",
96
+ phaseWaiting: "\u51C6\u5907\u4E2D",
97
+ registryAttempt: (index, total) => `\u6CE8\u518C\u8868 ${index}/${total}\uFF0C\u4E0A\u4E00\u4E2A\u8D85\u65F6\u4F1A\u81EA\u52A8\u6362\u6E90`,
98
+ slowHint: "\u9996\u6B21\u5B89\u88C5\u6216\u9700\u8981\u6821\u9A8C\u4F9D\u8D56\u65F6\u4F1A\u6BD4\u8F83\u6162\uFF0C\u65E5\u5FD7\u91CC\u80FD\u770B\u5230\u771F\u5B9E\u8FDB\u5EA6\u3002",
99
+ cancelRequested: "\u5DF2\u8BF7\u6C42\u53D6\u6D88",
100
+ stalled: "\u8FD9\u4E00\u6B65\u5DF2\u7ECF\u5F88\u4E45\u6CA1\u6709\u8F93\u51FA\u4E86\uFF0C\u53EF\u4EE5\u53D6\u6D88\u540E\u91CD\u8BD5\u3002",
101
+ staleSnapshot: "\u72B6\u6001\u5DF2\u505C\u6B62\u66F4\u65B0\uFF0C\u8BF7\u68C0\u67E5\u66F4\u65B0",
88
102
  cancel: "\u53D6\u6D88",
89
103
  cancelling: "\u6B63\u5728\u53D6\u6D88\u2026",
90
104
  restart: "\u9700\u8981\u91CD\u542F DSH \u624D\u4F1A\u751F\u6548\u3002",
91
105
  restartHint: "\u5DF2\u4FDD\u5B58\u7684\u6539\u52A8\u5728\u4E0B\u6B21\u542F\u52A8\u65F6\u751F\u6548\u3002",
106
+ reloadForAppearance: "\u4E3B\u9898\u63D2\u4EF6\u5DF2\u66F4\u65B0\uFF0C\u6B63\u5728\u91CD\u65B0\u52A0\u8F7D\u9875\u9762\u4EE5\u6062\u590D\u4E3B\u9898\u5916\u89C2\uFF08\u4E3B\u9898\u914D\u7F6E\u4FDD\u5B58\u5728\u78C1\u76D8\u4E0A\uFF0C\u4E0D\u4F1A\u4E22\u5931\uFF09\u3002",
92
107
  done: "\u5B8C\u6210",
93
108
  failed: "\u5931\u8D25",
94
109
  failedSome: (n) => `${n} \u4E2A\u5931\u8D25`,
95
110
  jobDone: "\u6279\u91CF\u5347\u7EA7\u5B8C\u6210",
96
111
  jobCancelled: "\u5DF2\u53D6\u6D88",
97
112
  busyAdopted: "\u5DF2\u6709\u4E00\u6279\u5347\u7EA7\u6B63\u5728\u8FDB\u884C\uFF0C\u5DF2\u63A5\u7740\u663E\u793A\u5B83\u7684\u8FDB\u5EA6\u3002",
113
+ lostJob: "\u5347\u7EA7\u8FDB\u5EA6\u5DF2\u5931\u53BB\u8054\u7CFB\uFF08\u5BBF\u4E3B\u53EF\u80FD\u91CD\u542F\u8FC7\uFF09\u3002\u8BF7\u68C0\u67E5\u66F4\u65B0\uFF0C\u786E\u8BA4\u63D2\u4EF6\u5F53\u524D\u7248\u672C\u3002",
98
114
  log: "\u8FD0\u884C\u65E5\u5FD7",
99
115
  showLog: "\u67E5\u770B\u65E5\u5FD7",
100
116
  hideLog: "\u6536\u8D77\u65E5\u5FD7",
@@ -126,6 +142,9 @@ window.__ModuleLoader__.load({
126
142
  upToDate: "Everything is up to date",
127
143
  upgrade: "Upgrade",
128
144
  upgrading: "Upgrading\u2026",
145
+ selfUpdate: "Upgrade via the page",
146
+ selfUpdateHint: "A plugin cannot uninstall and reinstall itself, so this runs the plugin page own installer",
147
+ selfUpdateUnavailable: "This page offers no install entry; upgrade this plugin from the Installed tab instead.",
129
148
  history: "Version history",
130
149
  hideHistory: "Hide history",
131
150
  github: "GitHub page",
@@ -147,19 +166,32 @@ window.__ModuleLoader__.load({
147
166
  openPage: "Open in Installed",
148
167
  noHistory: "No published versions found",
149
168
  noHistoryWhy: "This package was not installed from an npm registry, so it has no published history.",
169
+ historyFailed: 'Could not read the version list; "Check for updates" retries it',
150
170
  versions: (n) => `${n} versions`,
151
171
  showingNewest: (n) => `showing newest ${n}`,
152
172
  progress: (done, total) => `Working on ${done}/${total}`,
173
+ elapsed: (text) => `${text} elapsed`,
174
+ phaseInstalling: "Downloading and installing",
175
+ phaseApplying: "Writing the profile",
176
+ phaseCancelling: "Cancelling",
177
+ phaseWaiting: "Preparing",
178
+ registryAttempt: (index, total) => `Registry ${index}/${total}; a timeout falls back to the next`,
179
+ slowHint: "A first install, or one that must verify dependencies, takes a while \u2014 the log shows what is happening.",
180
+ cancelRequested: "Cancellation requested",
181
+ stalled: "This step has produced no output for a long time; cancel it and retry.",
182
+ staleSnapshot: "status stopped updating \u2014 check for updates",
153
183
  cancel: "Cancel",
154
184
  cancelling: "Cancelling\u2026",
155
185
  restart: "Restart DSH to finish.",
156
186
  restartHint: "Saved changes take effect on the next start.",
187
+ reloadForAppearance: "The theme plugin was updated; reloading the page to restore its look (its settings are on disk and are not lost).",
157
188
  done: "done",
158
189
  failed: "failed",
159
190
  failedSome: (n) => `${n} failed`,
160
191
  jobDone: "Batch upgrade finished",
161
192
  jobCancelled: "Cancelled",
162
193
  busyAdopted: "A batch is already running; showing its progress instead.",
194
+ lostJob: "Lost contact with the upgrade (the host may have restarted). Check for updates to confirm the installed version.",
163
195
  log: "Run log",
164
196
  showLog: "Show log",
165
197
  hideLog: "Hide log",
@@ -205,6 +237,16 @@ window.__ModuleLoader__.load({
205
237
  window.open(url, "_blank", "noopener,noreferrer");
206
238
  }
207
239
  }
240
+ function formatElapsed(ms) {
241
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
242
+ const total = Math.floor(ms / 1e3);
243
+ if (total < 60) return `${total}s`;
244
+ const minutes = Math.floor(total / 60);
245
+ const seconds = total % 60;
246
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
247
+ const hours = Math.floor(minutes / 60);
248
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
249
+ }
208
250
  function resolveVersionLink(row, version) {
209
251
  const raw = typeof version.href === "string" ? version.href : typeof version.url === "string" && typeof row.repoUrl === "string" && version.url.startsWith("/") ? `${row.repoUrl}${version.url}` : version.url;
210
252
  return typeof raw === "string" && /^https?:\/\//i.test(raw) ? raw : void 0;
@@ -335,6 +377,16 @@ window.__ModuleLoader__.load({
335
377
  );
336
378
  return alive;
337
379
  }
380
+ var APPEARANCE_PATTERN = /(^|[/@-])(skin|theme|dream-skin|wallpaper|appearance)([/@.-]|$)/i;
381
+ function isAppearancePackage(name2) {
382
+ return APPEARANCE_PATTERN.test(name2);
383
+ }
384
+ function replacedAppearance(results) {
385
+ if (!Array.isArray(results)) return false;
386
+ return results.some(
387
+ (result) => result.ok === true && result.changed !== false && typeof result.name === "string" && isAppearancePackage(result.name)
388
+ );
389
+ }
338
390
 
339
391
  // src/client/styles.ts
340
392
  var STYLE_ID = "dsh-plugin-upgrader-style";
@@ -352,7 +404,7 @@ window.__ModuleLoader__.load({
352
404
  .dsh-pu-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 8px; font-size: 12px; font-weight: 500; cursor: pointer; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); transition: background .15s ease, border-color .15s ease, opacity .15s ease; font-family: inherit; }
353
405
  .dsh-pu-btn:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); border-color: var(--dsw-alias-border-l2); }
354
406
  .dsh-pu-btn:disabled { opacity: .5; cursor: default; }
355
- .dsh-pu-btn.is-primary { background: var(--dsw-alias-button-primary-fill); border-color: transparent; color: #fff; }
407
+ .dsh-pu-btn.is-primary { background: var(--dsw-alias-button-primary-fill); border-color: transparent; color: var(--dsw-alias-label-primary-inverted, #fff); }
356
408
  .dsh-pu-btn.is-primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
357
409
  .dsh-pu-btn.is-ghost { background: transparent; border-color: transparent; color: var(--dsw-alias-label-secondary); padding: 0 8px; }
358
410
  .dsh-pu-btn.is-ghost:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
@@ -384,6 +436,8 @@ window.__ModuleLoader__.load({
384
436
  .dsh-pu-name { font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 8px; min-width: 0; }
385
437
  .dsh-pu-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
386
438
  .dsh-pu-desc { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
439
+ .dsh-pu-note { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
440
+ .dsh-pu-note.is-err { color: var(--dsw-alias-state-error-primary); }
387
441
  .dsh-pu-mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; color: var(--dsw-alias-label-secondary); }
388
442
  .dsh-pu-tag { font-size: 10px; line-height: 1; padding: 3px 6px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l1); color: var(--dsw-alias-label-secondary); font-weight: 500; white-space: nowrap; }
389
443
  .dsh-pu-tag.is-new { border-color: var(--dsw-alias-brand-primary); color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 14%, transparent); }
@@ -412,7 +466,10 @@ window.__ModuleLoader__.load({
412
466
  .dsh-pu-job-head { display: flex; align-items: center; gap: 10px; font-size: 12px; }
413
467
  .dsh-pu-job-head .dsh-pu-grow { flex: 1; min-width: 0; }
414
468
  .dsh-pu-bar { height: 4px; border-radius: 999px; background: var(--dsw-alias-bg-layer-3); overflow: hidden; }
415
- .dsh-pu-bar div { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width .25s ease; }
469
+ .dsh-pu-bar-inner { height: 100%; border-radius: 999px; background: var(--dsw-alias-brand-primary); transition: width .25s ease; }
470
+ .dsh-pu-bar-inner.is-busy { width: 40%; transition: none; animation: dsh-pu-indeterminate 1.4s ease-in-out infinite; }
471
+ @keyframes dsh-pu-indeterminate { 0% { transform: translateX(-100%); } 100% { transform: translateX(250%); } }
472
+ .dsh-pu-hint { font-size: 11px; color: var(--dsw-alias-label-tertiary); }
416
473
  .dsh-pu-log { max-height: 160px; overflow: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10.5px; line-height: 1.55; color: var(--dsw-alias-label-secondary); white-space: pre-wrap; word-break: break-word; border-top: 1px dashed var(--dsw-alias-border-l1); padding-top: 8px; margin: 0; }
417
474
  .dsh-pu-banner { display: flex; align-items: center; gap: 8px; font-size: 11.5px; padding: 7px 10px; border-radius: 8px; border: 1px solid var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); }
418
475
  .dsh-pu-banner.is-warn { border-color: var(--dsw-alias-state-warn-primary); color: var(--dsw-alias-state-warn-primary); }
@@ -420,10 +477,23 @@ window.__ModuleLoader__.load({
420
477
  .dsh-pu-foot { display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--dsw-alias-label-tertiary); flex-wrap: wrap; }
421
478
  .dsh-pu-spin { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l2); border-top-color: var(--dsw-alias-brand-primary); animation: dsh-pu-spin .8s linear infinite; flex: none; }
422
479
  @keyframes dsh-pu-spin { to { transform: rotate(360deg); } }
423
- @media (prefers-reduced-motion: reduce) { .dsh-pu-spin { animation-duration: 2.4s; } }
480
+ @media (prefers-reduced-motion: reduce) { .dsh-pu-spin { animation-duration: 2.4s; } .dsh-pu-bar-inner.is-busy { animation-duration: 3s; } }
424
481
  `;
425
482
 
426
483
  // src/client/components/JobPanel.ts
484
+ var CLOCK_MS = 1e3;
485
+ var STALE_SNAPSHOT_MS = 20 * 1e3;
486
+ function useNow(running, from) {
487
+ const React = getReact();
488
+ const [now, setNow] = React.useState(() => Date.now());
489
+ React.useEffect(() => {
490
+ if (!running || from === void 0) return void 0;
491
+ setNow(Date.now());
492
+ const timer = setInterval(() => setNow(Date.now()), CLOCK_MS);
493
+ return () => clearInterval(timer);
494
+ }, [running, from]);
495
+ return now;
496
+ }
427
497
  function JobPanel(props) {
428
498
  const h = getH();
429
499
  const React = getReact();
@@ -434,6 +504,16 @@ window.__ModuleLoader__.load({
434
504
  const percent = total === 0 ? 0 : Math.round(done / total * 100);
435
505
  const running = job.status === "running" || job.status === "queued";
436
506
  const failed = job.failed ?? 0;
507
+ const inStep = running && job.current !== void 0;
508
+ const now = useNow(inStep, job.stepStartedAt);
509
+ const stamp = `${job.id}|${job.stepStartedAt ?? ""}|${job.done ?? 0}|${job.status}`;
510
+ const lastChange = React.useRef({ stamp, at: Date.now() });
511
+ if (lastChange.current.stamp !== stamp) lastChange.current = { stamp, at: Date.now() };
512
+ const stale = inStep && Date.now() - lastChange.current.at > STALE_SNAPSHOT_MS;
513
+ const stepMs = job.stepStartedAt === void 0 ? 0 : Math.max(0, now - job.stepStartedAt);
514
+ const phaseKey = job.phase === "installing" ? "phaseInstalling" : job.phase === "applying" ? "phaseApplying" : job.phase === "cancelling" ? "phaseCancelling" : "phaseWaiting";
515
+ const phaseText = job.attempt !== void 0 && job.attempt.total > 1 ? t("registryAttempt", job.attempt.index, job.attempt.total) : t(phaseKey);
516
+ const headline = running ? t("progress", done, total) : job.status === "cancelled" ? t("jobCancelled") : t("jobDone");
437
517
  return h(
438
518
  "div",
439
519
  { className: "dsh-pu-job" },
@@ -444,8 +524,17 @@ window.__ModuleLoader__.load({
444
524
  h(
445
525
  "span",
446
526
  { className: "dsh-pu-grow" },
447
- running ? t("progress", done, total) : job.status === "cancelled" ? t("jobCancelled") : t("jobDone"),
448
- job.current === void 0 ? null : h("span", { className: "dsh-pu-mono" }, ` ${job.current}`)
527
+ headline,
528
+ job.current === void 0 ? null : h("span", { className: "dsh-pu-mono" }, ` ${job.current}`),
529
+ inStep ? h(
530
+ "span",
531
+ { className: "dsh-pu-dim" },
532
+ ` \xB7 ${phaseText} \xB7 ${t("elapsed", formatElapsed(stepMs))}`
533
+ ) : null,
534
+ // Once the snapshot stops moving the clock is reporting a number nobody
535
+ // is updating, so say so instead of letting it climb.
536
+ stale ? h("span", { className: "dsh-pu-dim" }, ` \xB7 ${t("staleSnapshot")}`) : null,
537
+ job.abandoned === true ? h("span", { className: "dsh-pu-dim" }, ` \xB7 ${t("stalled")}`) : null
449
538
  ),
450
539
  failed > 0 ? h("span", { className: "dsh-pu-tag is-err" }, t("failedSome", failed)) : null,
451
540
  running ? h(
@@ -459,7 +548,12 @@ window.__ModuleLoader__.load({
459
548
  showLog ? t("hideLog") : t("showLog")
460
549
  )
461
550
  ),
462
- h("div", { className: "dsh-pu-bar" }, h("div", { style: { width: `${percent}%` } })),
551
+ h(
552
+ "div",
553
+ { className: "dsh-pu-bar" },
554
+ inStep ? h("div", { className: "dsh-pu-bar-inner is-busy" }) : h("div", { className: "dsh-pu-bar-inner", style: { width: `${percent}%` } })
555
+ ),
556
+ inStep ? h("div", { className: "dsh-pu-hint" }, t("slowHint")) : null,
463
557
  showLog && Array.isArray(job.log) ? h("pre", { className: "dsh-pu-log" }, job.log.join("\n")) : null
464
558
  );
465
559
  }
@@ -518,12 +612,17 @@ window.__ModuleLoader__.load({
518
612
  const h = getH();
519
613
  const React = getReact();
520
614
  const { row, t, busy, picked, onToggle, onUpgrade, onSwitch, onApprove, onOpenPage } = props;
615
+ const { self, onInstallSelf } = props;
616
+ const { onRetryHistory } = props;
521
617
  const [open, setOpen] = React.useState(false);
522
618
  const versions = Array.isArray(row.versions) ? row.versions : [];
523
619
  const hasHistory = versions.length > 0;
524
- const selectable = row.upgradable === true;
620
+ const selfUpdate = self === true && row.latest !== void 0 && row.latest !== row.version;
621
+ const selectable = row.upgradable === true && self !== true;
525
622
  const status = row.error !== void 0 && row.error !== null ? "err" : row.upgradable ? "new" : "idle";
526
- const versionNote = row.versionError !== void 0 ? row.versionError === "not a profile-managed package" ? t("sourcePath") : row.versionError : void 0;
623
+ const failure = row.versionError !== void 0 && row.versionError !== "" ? row.versionError : void 0;
624
+ const retriable = failure !== void 0 && /unreachable|timeout|registry \d|github/i.test(failure);
625
+ const versionNote = failure === void 0 ? void 0 : failure === "not a profile-managed package" ? t("sourcePath") : failure;
527
626
  const pendingBuilds = Array.isArray(row.pendingBuilds) ? row.pendingBuilds : [];
528
627
  return h(
529
628
  "div",
@@ -566,7 +665,22 @@ window.__ModuleLoader__.load({
566
665
  "div",
567
666
  { className: "dsh-pu-desc" },
568
667
  row.description !== void 0 && row.description !== "" ? row.description : versionNote ?? row.name
569
- )
668
+ ),
669
+ failure !== void 0 && retriable ? h(
670
+ "div",
671
+ { className: "dsh-pu-note is-err", title: failure },
672
+ `${t("historyFailed")}: ${failure}`,
673
+ typeof onRetryHistory === "function" ? h(
674
+ "button",
675
+ {
676
+ type: "button",
677
+ className: "dsh-pu-btn is-ghost is-tiny",
678
+ disabled: busy,
679
+ onClick: onRetryHistory
680
+ },
681
+ t("retry")
682
+ ) : null
683
+ ) : null
570
684
  ),
571
685
  h(
572
686
  "div",
@@ -587,6 +701,16 @@ window.__ModuleLoader__.load({
587
701
  onClick: () => onUpgrade(row)
588
702
  },
589
703
  t("upgrade")
704
+ ) : selfUpdate && typeof onInstallSelf === "function" ? h(
705
+ "button",
706
+ {
707
+ type: "button",
708
+ className: "dsh-pu-btn is-primary is-tiny",
709
+ disabled: busy,
710
+ title: `${t("selfUpdateHint")} \u2192${row.latest}`,
711
+ onClick: () => onInstallSelf(row, row.latest)
712
+ },
713
+ t("selfUpdate")
590
714
  ) : null,
591
715
  typeof row.repoUrl === "string" ? h(
592
716
  "button",
@@ -654,6 +778,8 @@ window.__ModuleLoader__.load({
654
778
  }
655
779
 
656
780
  // src/client/components/Tab.ts
781
+ var SELF_NAME = "dsh-plugin-upgrader";
782
+ var APPEARANCE_RELOAD_DELAY_MS = 1200;
657
783
  function Tab(props) {
658
784
  const h = getH();
659
785
  const React = getReact();
@@ -675,14 +801,27 @@ window.__ModuleLoader__.load({
675
801
  setBusyName(void 0);
676
802
  setCancelling(false);
677
803
  reload(false);
804
+ if (settled === void 0) {
805
+ setJob(void 0);
806
+ setBanner({ tone: "warn", text: t("lostJob") });
807
+ return settled;
808
+ }
678
809
  const failures = Array.isArray(settled?.results) ? settled.results.filter((result) => result.ok !== true) : [];
679
810
  if (failures.length > 0) {
680
811
  setBanner({
681
812
  tone: "err",
682
- text: failures.map((failure) => `${failure.name}: ${failure.message ?? failure.detail ?? "error"}`).join(" \xB7 ")
813
+ text: failures.map((failure) => {
814
+ const code = failure.message ?? "error";
815
+ return failure.detail === void 0 ? `${failure.name}: ${code}` : `${failure.name}: ${failure.detail} (${code})`;
816
+ }).join(" \xB7 ")
683
817
  });
684
818
  } else if (settled?.results?.some((result) => result.application === "restart-required") === true) {
685
819
  setBanner({ tone: "warn", text: t("restart") });
820
+ } else if (replacedAppearance(settled?.results)) {
821
+ setBanner({ tone: "info", text: t("reloadForAppearance") });
822
+ window.setTimeout(() => {
823
+ window.location.reload();
824
+ }, APPEARANCE_RELOAD_DELAY_MS);
686
825
  }
687
826
  return settled;
688
827
  },
@@ -703,31 +842,23 @@ window.__ModuleLoader__.load({
703
842
  if (steps.length === 0) return;
704
843
  setBanner(void 0);
705
844
  setBusyName(label);
845
+ setJob({ id: "", status: "queued", total: steps.length, done: 0, failed: 0, log: [] });
706
846
  const outcome = await submitJob(steps, watch);
707
847
  if (!alive.current) return;
708
- if (outcome.kind === "started") {
709
- setJob({
710
- id: outcome.id,
711
- status: "queued",
712
- total: steps.length,
713
- done: 0,
714
- failed: 0,
715
- log: []
716
- });
717
- return;
718
- }
719
- if (outcome.kind === "adopted") {
720
- setBanner({ tone: "warn", text: t("busyAdopted") });
848
+ if (outcome.kind === "started" || outcome.kind === "adopted") {
849
+ if (outcome.kind === "adopted") setBanner({ tone: "warn", text: t("busyAdopted") });
721
850
  return;
722
851
  }
723
852
  setBusyName(void 0);
853
+ setJob(void 0);
724
854
  const failure = outcome.error;
725
855
  setBanner({ tone: "err", text: failure instanceof Error ? failure.message : String(failure) });
726
856
  },
727
857
  [alive, t, watch]
728
858
  );
729
859
  const rows = Array.isArray(data?.bundles) ? data.bundles : [];
730
- const upgradable = rows.filter((row) => row.upgradable === true);
860
+ const batchable = (row) => row.upgradable === true && row.name !== SELF_NAME;
861
+ const upgradable = rows.filter(batchable);
731
862
  const needle = query.trim().toLowerCase();
732
863
  const filtered = needle === "" ? rows : rows.filter(
733
864
  (row) => row.name.toLowerCase().includes(needle) || String(row.title ?? "").toLowerCase().includes(needle)
@@ -751,6 +882,18 @@ window.__ModuleLoader__.load({
751
882
  const pendingBuilds = Array.isArray(row.pendingBuilds) ? row.pendingBuilds : [];
752
883
  void run([stepOf(row, row.latest ?? row.version ?? "", "upgrade", pendingBuilds)], row.name);
753
884
  };
885
+ const installSelf = React.useCallback(
886
+ (row, version) => {
887
+ const install = props.install;
888
+ if (typeof install !== "function") {
889
+ setBanner({ tone: "warn", text: t("selfUpdateUnavailable") });
890
+ return;
891
+ }
892
+ setBanner(void 0);
893
+ install({ spec: `${row.name}@${version}`, title: row.title, version });
894
+ },
895
+ [props.install, t]
896
+ );
754
897
  const upgradeAll = () => {
755
898
  void run(
756
899
  upgradable.map((row) => stepOf(row, row.latest ?? row.version ?? "", "upgrade")),
@@ -908,7 +1051,10 @@ window.__ModuleLoader__.load({
908
1051
  onUpgrade: upgradeOne,
909
1052
  onSwitch: switchOne,
910
1053
  onApprove: approveAndRetry,
911
- onOpenPage: typeof props.openPackage === "function" ? props.openPackage : void 0
1054
+ onOpenPage: typeof props.openPackage === "function" ? props.openPackage : void 0,
1055
+ self: row.name === SELF_NAME,
1056
+ onInstallSelf: installSelf,
1057
+ onRetryHistory: () => reload(true)
912
1058
  })
913
1059
  )
914
1060
  ),