dsh-custom-theme 0.1.0 → 0.1.2
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/README.md +106 -15
- package/lib/client.js +261 -0
- package/package.json +1 -1
- package/src/index.mjs +185 -10
- package/src/update.mjs +241 -0
package/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# dsh-custom-theme
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-custom-theme)
|
|
4
|
+
[](https://github.com/Sparrived/dsh-custom-theme/blob/main/LICENSE)
|
|
5
|
+
|
|
3
6
|
User-editable CSS themes for the DeepSeek Harness Web GUI and Desktop app.
|
|
4
7
|
|
|
5
8
|
This ports the one UI-customization capability DSH does not have: a theme
|
|
@@ -22,6 +25,13 @@ Served routes:
|
|
|
22
25
|
| `GET /dsh-custom-theme/theme/<id>.css` | The stylesheet text |
|
|
23
26
|
| `GET /dsh-custom-theme/backgrounds` | `{ backgrounds: [{ name, url }], dir }` |
|
|
24
27
|
| `GET /dsh-custom-theme/background/<name>` | The image bytes |
|
|
28
|
+
| `GET /dsh-custom-theme/update` | The cached update state — see [Updates](#updates) |
|
|
29
|
+
| `POST /dsh-custom-theme/update/check` | Asks the registries again, ignoring the cache |
|
|
30
|
+
| `POST /dsh-custom-theme/update/apply` | Installs the release the last check resolved |
|
|
31
|
+
|
|
32
|
+
The plugin also watches its own releases: it reports a newer one and upgrades to it on
|
|
33
|
+
request, from its settings card or from the plugin manager's page for this bundle. See
|
|
34
|
+
[Updates](#updates).
|
|
25
35
|
|
|
26
36
|
Adding a theme is dropping a `.css` file into the theme directory and pressing
|
|
27
37
|
**Rescan**; the file only needs to override `--dsw-alias-*` custom properties.
|
|
@@ -288,31 +298,44 @@ grant. **This package does not need either.** `src/index.mjs` (Host) and
|
|
|
288
298
|
load directly, so there is no build step for `prepare` to run and nothing to
|
|
289
299
|
allowlist: the install completes with no code-execution prompt.
|
|
290
300
|
|
|
291
|
-
### From
|
|
292
|
-
|
|
293
|
-
Both ship the same prebuilt code:
|
|
301
|
+
### From npm
|
|
294
302
|
|
|
295
303
|
```sh
|
|
296
|
-
|
|
297
|
-
dsh plugin --profile <name> add ./dsh-custom-theme-0.1.0.tgz
|
|
304
|
+
dsh plugin --profile <name> add dsh-custom-theme
|
|
298
305
|
```
|
|
299
306
|
|
|
307
|
+
### From a tarball
|
|
308
|
+
|
|
309
|
+
`pnpm pack` produces the same prebuilt code as a single file:
|
|
310
|
+
|
|
300
311
|
```sh
|
|
301
|
-
|
|
312
|
+
pnpm pack # -> dsh-custom-theme-<version>.tgz
|
|
313
|
+
dsh plugin --profile <name> add ./dsh-custom-theme-<version>.tgz
|
|
302
314
|
```
|
|
303
315
|
|
|
304
|
-
|
|
316
|
+
### Releasing (maintainer)
|
|
305
317
|
|
|
306
318
|
```sh
|
|
307
|
-
git tag
|
|
308
|
-
|
|
309
|
-
|
|
319
|
+
git tag v<version> && git push origin v<version>
|
|
320
|
+
pnpm pack
|
|
321
|
+
gh release create v<version> --title v<version> --notes-file CHANGELOG.md \
|
|
322
|
+
dsh-custom-theme-<version>.tgz
|
|
323
|
+
npm publish --access public
|
|
310
324
|
```
|
|
311
325
|
|
|
326
|
+
npm requires two-factor authentication for **every** publish. A passkey (Windows
|
|
327
|
+
Hello) has no 6-digit code to type, so the publishing token must carry the bypass:
|
|
328
|
+
create a **Granular Access Token** with **Bypass two-factor authentication (2FA)**
|
|
329
|
+
ticked and `Read and write` on packages. A token without that flag is refused with
|
|
330
|
+
`E403 ... granular access token with bypass 2fa enabled is required to publish`,
|
|
331
|
+
and weakening the account's 2FA mode to `auth-only` does not change that.
|
|
332
|
+
|
|
312
333
|
### Configuring the directories
|
|
313
334
|
|
|
314
335
|
`themesDir` and `backgroundsDir` can be overridden on the row; otherwise
|
|
315
|
-
`$DSH_HOME/themes` and `$DSH_HOME/backgrounds` are used:
|
|
336
|
+
`$DSH_HOME/themes` and `$DSH_HOME/backgrounds` are used. `updateCheck: false` skips
|
|
337
|
+
the update check at boot, while the settings card's own button and both routes keep
|
|
338
|
+
working:
|
|
316
339
|
|
|
317
340
|
```yaml
|
|
318
341
|
- id: custom-theme
|
|
@@ -320,6 +343,7 @@ pnpm publish --access public # optional; needs np
|
|
|
320
343
|
config:
|
|
321
344
|
themesDir: D:/themes/dsh
|
|
322
345
|
backgroundsDir: D:/wallpapers/dsh
|
|
346
|
+
updateCheck: true
|
|
323
347
|
```
|
|
324
348
|
|
|
325
349
|
### Desktop app
|
|
@@ -356,14 +380,63 @@ override the target, the debugging port and where screenshots land. The test
|
|
|
356
380
|
imports `test/browser/driver.mjs`, a small CDP driver over Node's built-in
|
|
357
381
|
`WebSocket`; no browser automation dependency is installed.
|
|
358
382
|
|
|
383
|
+
## Updates
|
|
384
|
+
|
|
385
|
+
The plugin checks npm for a newer release once per boot and caches the answer for six
|
|
386
|
+
hours; a failed check is cached for one minute, so a blip cannot hide an update for
|
|
387
|
+
the rest of the window. A manual **检查更新** ignores the cache.
|
|
388
|
+
|
|
389
|
+
Nothing is replaced behind your back. The check only reports, and the upgrade is a
|
|
390
|
+
button: no code is swapped until you click it.
|
|
391
|
+
|
|
392
|
+
**Where it shows up.** Both surfaces read the same cached answer:
|
|
393
|
+
|
|
394
|
+
- A row on **设置 → 主题与背景**, next to the theme and background pickers.
|
|
395
|
+
- Three contributions to the plugin manager's own page for this bundle, each with one
|
|
396
|
+
job: **升级** in the page's actions area beside the enable switch and uninstall, an
|
|
397
|
+
*Update available* badge beside the title, and a section under the page's content
|
|
398
|
+
carrying the version detail and **检查更新**. All three are drawn only when a newer
|
|
399
|
+
release exists, and every other plugin's page is untouched — an entry renders nothing
|
|
400
|
+
for a subject it has nothing to say about. The upgrade button lives in one place, so
|
|
401
|
+
the section does not repeat it.
|
|
402
|
+
|
|
403
|
+
**Upgrading.** **升级** installs the exact version the check resolved, through the
|
|
404
|
+
plugin manager's own `installBundle`, so the profile's lockfile and bundle list stay
|
|
405
|
+
consistent with anything you install from the Plugins page. Replacing a package cannot
|
|
406
|
+
hot-swap the Host module, so an upgrade always ends with *restart DeepSeek Harness*;
|
|
407
|
+
the row then says so instead of offering the same upgrade again.
|
|
408
|
+
|
|
409
|
+
**Which registry.** The check asks the profile's plugin-manager configuration, in its
|
|
410
|
+
order: the configured registry, then the one pnpm resolves, then the configured
|
|
411
|
+
fallbacks. A mirror or private registry therefore stays authoritative and is never
|
|
412
|
+
silently widened to the public one. With nothing configured, that resolves to
|
|
413
|
+
`https://registry.npmjs.org/`.
|
|
414
|
+
|
|
415
|
+
**Routes**, under the plugin's prefix and reachable with the page's own token:
|
|
416
|
+
|
|
417
|
+
| Route | Method | Answers |
|
|
418
|
+
| --- | --- | --- |
|
|
419
|
+
| `/dsh-custom-theme/update` | `GET` | The cached state: running version, latest, whether it is an upgrade, and the registry that answered. |
|
|
420
|
+
| `/dsh-custom-theme/update/check` | `POST` | Asks the registries again, ignoring the cache. |
|
|
421
|
+
| `/dsh-custom-theme/update/apply` | `POST` | Installs the release the last check resolved. |
|
|
422
|
+
|
|
423
|
+
Only the two actions answer `POST`: a `GET` stays safe for a link, a prefetch or an
|
|
424
|
+
`<img>`, none of which may start an install. A run that does not end in `applied` or
|
|
425
|
+
`restart-required` is reported as an error carrying the plugin manager's own
|
|
426
|
+
diagnostic, and the upgrade stays on offer.
|
|
427
|
+
|
|
359
428
|
## Verify
|
|
360
429
|
|
|
361
430
|
Verified against `dsh` 0.2.0-rc.2 on Windows:
|
|
362
431
|
|
|
363
|
-
- `node --test "test/**/*.test.mjs"` —
|
|
432
|
+
- `node --test "test/**/*.test.mjs"` — 35 tests, all passing: id and image-name
|
|
364
433
|
whitelists, ordering, directory resolution, seeding, re-sync on a new seed
|
|
365
|
-
generation, both asset routes, both listings,
|
|
366
|
-
|
|
434
|
+
generation, both asset routes, both listings, traversal, extension and method
|
|
435
|
+
rejection, and the update surface — semver precedence including prerelease
|
|
436
|
+
ordering, registry-candidate order and the private-registry rule, the registry
|
|
437
|
+
query falling through a failure to the next candidate, the cache's success and
|
|
438
|
+
failure windows, and all three update routes, including that a failed install is
|
|
439
|
+
reported as a failure rather than as a pending restart.
|
|
367
440
|
- `node test/browser/appearance.mjs` — 33 steps in a real headless Edge, all
|
|
368
441
|
passing. It boots the app, asserts the controls are absent from the chat view and
|
|
369
442
|
still absent once Settings opens, then opens the plugin's own page from the nav
|
|
@@ -379,7 +452,14 @@ Verified against `dsh` 0.2.0-rc.2 on Windows:
|
|
|
379
452
|
every injected layer rule. Finally, with the panel closed, since the panel is
|
|
380
453
|
portalled over the whole window, it asserts that the image is what
|
|
381
454
|
`elementFromPoint` actually finds at a point inside each zone — which is what keeps
|
|
382
|
-
the negative-`z-index` layer from silently disappearing behind a surface.
|
|
455
|
+
the negative-`z-index` layer from silently disappearing behind a surface. That
|
|
456
|
+
sampling is the one thing DSH's own UI can disturb: a freshly created profile opens
|
|
457
|
+
the `预览版说明` first-run notice, whose backdrop covers the window and answers every
|
|
458
|
+
`elementFromPoint` query, so those four visibility steps read nothing even though the
|
|
459
|
+
assertions before them — which read the tagged surfaces and the layers beside them
|
|
460
|
+
directly — still pass. The suite dismisses the notice after boot and again before
|
|
461
|
+
sampling, so it is verified both on a freshly created profile and on a long-lived
|
|
462
|
+
one.
|
|
383
463
|
- `node test/browser/working-row.mjs` — 5 steps against a session that already has
|
|
384
464
|
turns. It reads the shipped row's computed geometry, asserts the replacement is
|
|
385
465
|
absent while no phrase is configured, configures one, then compares the
|
|
@@ -465,6 +545,17 @@ row removes the rows and the palette together.
|
|
|
465
545
|
`::before` on one of these surfaces would collide with it; the plugin's rule sets
|
|
466
546
|
`content`, `position`, `inset`, `z-index` and the picture, so the shell's own
|
|
467
547
|
`::before` content would be replaced rather than merged.
|
|
548
|
+
- **An upgrade needs a restart.** Replacing a package cannot hot-swap the Host module,
|
|
549
|
+
so after an upgrade the new code is on disk while the running process keeps the old
|
|
550
|
+
one. The row says a restart is pending rather than offering the same upgrade again;
|
|
551
|
+
the Browser half comes back with that restart too.
|
|
552
|
+
- **The update check needs the plugin-manager service.** Without it the row reports
|
|
553
|
+
that updates cannot be checked rather than guessing. That service names the
|
|
554
|
+
registries to ask and performs the install, which is why the check follows its
|
|
555
|
+
configuration instead of a hard-coded URL.
|
|
556
|
+
- **A check answers from a six-hour cache.** The boot check and the first page open
|
|
557
|
+
share one answer, so a release published in between waits for the next window or for
|
|
558
|
+
**检查更新**.
|
|
468
559
|
- **No Schemastery `Config` schema**, so `config` is read defensively and never
|
|
469
560
|
validated. That is also why the package carries no peer dependencies at all.
|
|
470
561
|
- **No live file watching.** A new or edited file needs **Rescan**, and an edited
|
package/lib/client.js
CHANGED
|
@@ -22,6 +22,9 @@ window.__ModuleLoader__.load({
|
|
|
22
22
|
const CSS_URL = (id) => `/dsh-custom-theme/theme/${encodeURIComponent(id)}.css`
|
|
23
23
|
const BACKGROUNDS_URL = '/dsh-custom-theme/backgrounds'
|
|
24
24
|
const BACKGROUND_URL = (name) => `/dsh-custom-theme/background/${encodeURIComponent(name)}`
|
|
25
|
+
const UPDATE_URL = '/dsh-custom-theme/update'
|
|
26
|
+
const UPDATE_CHECK_URL = '/dsh-custom-theme/update/check'
|
|
27
|
+
const UPDATE_APPLY_URL = '/dsh-custom-theme/update/apply'
|
|
25
28
|
const STORAGE_KEY = 'dsh-custom-theme.selected'
|
|
26
29
|
const STORAGE_KEY_BACKGROUNDS = 'dsh-custom-theme.backgrounds'
|
|
27
30
|
const LOCALE_NS = 'dshCustomTheme'
|
|
@@ -116,6 +119,12 @@ window.__ModuleLoader__.load({
|
|
|
116
119
|
.dct-area { width: 260px; min-height: 54px; padding: 4px 8px; font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; resize: vertical; }
|
|
117
120
|
.dct-area::placeholder { color: var(--dsw-alias-label-caption, currentColor); opacity: 0.85; }
|
|
118
121
|
.dct-sub .dct-wrap .dct-input { width: 130px; }
|
|
122
|
+
/* The update row, the badge beside the plugin page's title, and the section that
|
|
123
|
+
page renders under its own content. */
|
|
124
|
+
.dct-update-badge { padding: 2px 8px; font-size: 12px; color: var(--dsw-alias-label-primary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 10px; }
|
|
125
|
+
.dct-update-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1, currentColor); }
|
|
126
|
+
.dct-update-section .dct-text { margin-bottom: 8px; }
|
|
127
|
+
.dct-update-section .dct-control { justify-content: flex-start; }
|
|
119
128
|
`
|
|
120
129
|
|
|
121
130
|
/** Read the persisted selection; an unreadable store means no selection. */
|
|
@@ -198,6 +207,19 @@ window.__ModuleLoader__.load({
|
|
|
198
207
|
posBottom: '底部',
|
|
199
208
|
posLeft: '左侧',
|
|
200
209
|
posRight: '右侧',
|
|
210
|
+
updateTitle: '插件更新',
|
|
211
|
+
updateHint: '从 npm 检查这个插件的新版本;升级会自动装好,重启后生效',
|
|
212
|
+
updateCurrent: '当前版本',
|
|
213
|
+
updateCheck: '检查更新',
|
|
214
|
+
updateChecking: '检查中…',
|
|
215
|
+
updateLatest: '最新版本',
|
|
216
|
+
updateUpgrade: '升级',
|
|
217
|
+
updateUpgrading: '升级中…',
|
|
218
|
+
updateRestart: '新版本已装好,重启 DeepSeek Harness 后生效',
|
|
219
|
+
updateUpToDate: '已是最新版本',
|
|
220
|
+
updateUnavailable: '插件管理器不可用,无法检查更新',
|
|
221
|
+
updateFailed: '检查更新失败',
|
|
222
|
+
updateBadge: '有更新',
|
|
201
223
|
},
|
|
202
224
|
en: {
|
|
203
225
|
nav: 'Theme & background',
|
|
@@ -255,6 +277,19 @@ window.__ModuleLoader__.load({
|
|
|
255
277
|
posBottom: 'Bottom',
|
|
256
278
|
posLeft: 'Left',
|
|
257
279
|
posRight: 'Right',
|
|
280
|
+
updateTitle: 'Plugin update',
|
|
281
|
+
updateHint: 'Checks npm for a newer release; upgrading installs it, and a restart makes it active',
|
|
282
|
+
updateCurrent: 'Installed',
|
|
283
|
+
updateCheck: 'Check for updates',
|
|
284
|
+
updateChecking: 'Checking…',
|
|
285
|
+
updateLatest: 'Latest',
|
|
286
|
+
updateUpgrade: 'Upgrade',
|
|
287
|
+
updateUpgrading: 'Upgrading…',
|
|
288
|
+
updateRestart: 'Installed; restart DeepSeek Harness to activate it',
|
|
289
|
+
updateUpToDate: 'Up to date',
|
|
290
|
+
updateUnavailable: 'The plugin manager is unavailable, so updates cannot be checked',
|
|
291
|
+
updateFailed: 'The update check failed',
|
|
292
|
+
updateBadge: 'Update available',
|
|
258
293
|
},
|
|
259
294
|
}))
|
|
260
295
|
|
|
@@ -1211,6 +1246,201 @@ window.__ModuleLoader__.load({
|
|
|
1211
1246
|
return Array.isArray(payload.backgrounds) ? payload.backgrounds.map((entry) => entry.name) : []
|
|
1212
1247
|
}
|
|
1213
1248
|
|
|
1249
|
+
/*
|
|
1250
|
+
* Update state, shared by the two places that show it: this card's own row and
|
|
1251
|
+
* the entry contributed to the plugin manager's page. The Host caches its check,
|
|
1252
|
+
* so both surfaces read one request instead of racing two.
|
|
1253
|
+
*/
|
|
1254
|
+
/** This package's name, which the detail-page subject is matched against. */
|
|
1255
|
+
const PLUGIN_PACKAGE = 'dsh-custom-theme'
|
|
1256
|
+
|
|
1257
|
+
/** Latest snapshot: `phase` is `idle`, `loading`, `applying` or `failed`. */
|
|
1258
|
+
let updateSnapshot = { phase: 'idle', state: null, reason: '', result: null }
|
|
1259
|
+
const updateListeners = new Set()
|
|
1260
|
+
|
|
1261
|
+
/** Publish a snapshot to every mounted surface. */
|
|
1262
|
+
function publishUpdate(next) {
|
|
1263
|
+
updateSnapshot = next
|
|
1264
|
+
for (const listener of updateListeners) listener(next)
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** The current snapshot without subscribing, for a mount that just needs it. */
|
|
1268
|
+
function readUpdate() {
|
|
1269
|
+
return updateSnapshot
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** The in-flight load, so two surfaces mounting together ask the Host once. */
|
|
1273
|
+
let updateLoad = null
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* Load the Host's update state.
|
|
1277
|
+
* @param options - `force` asks the Host to query the registries again rather
|
|
1278
|
+
* than answer from its cache.
|
|
1279
|
+
*/
|
|
1280
|
+
function loadUpdate({ force = false } = {}) {
|
|
1281
|
+
if (updateLoad !== null) return updateLoad
|
|
1282
|
+
const promise = (async () => {
|
|
1283
|
+
publishUpdate({ ...updateSnapshot, phase: 'loading', reason: '' })
|
|
1284
|
+
try {
|
|
1285
|
+
const response = force
|
|
1286
|
+
? await fetch(UPDATE_CHECK_URL, { method: 'POST', cache: 'no-store' })
|
|
1287
|
+
: await fetch(UPDATE_URL, { cache: 'no-store' })
|
|
1288
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
1289
|
+
publishUpdate({ phase: 'idle', state: await response.json(), reason: '', result: null })
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
publishUpdate({ ...updateSnapshot, phase: 'failed', reason: error.message })
|
|
1292
|
+
}
|
|
1293
|
+
})()
|
|
1294
|
+
updateLoad = promise
|
|
1295
|
+
const clear = () => { if (updateLoad === promise) updateLoad = null }
|
|
1296
|
+
promise.then(clear, clear)
|
|
1297
|
+
return promise
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/** Install the release the last check resolved, then re-read what the Host reports. */
|
|
1301
|
+
async function applyUpdateNow() {
|
|
1302
|
+
publishUpdate({ ...updateSnapshot, phase: 'applying', reason: '' })
|
|
1303
|
+
try {
|
|
1304
|
+
const response = await fetch(UPDATE_APPLY_URL, { method: 'POST', cache: 'no-store' })
|
|
1305
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
1306
|
+
const result = await response.json()
|
|
1307
|
+
if (result.status !== 'ok') {
|
|
1308
|
+
publishUpdate({ ...updateSnapshot, phase: 'failed', reason: result.reason ?? '' })
|
|
1309
|
+
return
|
|
1310
|
+
}
|
|
1311
|
+
// The Host now reports a pending restart in place of the same upgrade.
|
|
1312
|
+
await loadUpdate()
|
|
1313
|
+
publishUpdate({ ...readUpdate(), result })
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
publishUpdate({ ...updateSnapshot, phase: 'failed', reason: error.message })
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/** Subscribe to the shared snapshot, loading it once per page. */
|
|
1320
|
+
function useUpdate() {
|
|
1321
|
+
const [snapshot, setSnapshot] = React.useState(readUpdate)
|
|
1322
|
+
React.useEffect(() => {
|
|
1323
|
+
updateListeners.add(setSnapshot)
|
|
1324
|
+
setSnapshot(readUpdate())
|
|
1325
|
+
// A failed load leaves no state behind, so reopening a surface retries it.
|
|
1326
|
+
if (readUpdate().state === null) loadUpdate()
|
|
1327
|
+
return () => { updateListeners.delete(setSnapshot) }
|
|
1328
|
+
}, [])
|
|
1329
|
+
return snapshot
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* The line of state text under the update controls.
|
|
1334
|
+
* @param snapshot - The shared snapshot.
|
|
1335
|
+
* @param t - Translate function for this plugin's namespace.
|
|
1336
|
+
* @returns One sentence describing what the Host reported.
|
|
1337
|
+
*/
|
|
1338
|
+
function updateText(snapshot, t) {
|
|
1339
|
+
const state = snapshot.state
|
|
1340
|
+
// A failure outranks the state text: the state still describes the release it
|
|
1341
|
+
// found, so repeating that here would hide why the upgrade did not happen.
|
|
1342
|
+
if (snapshot.phase === 'failed') return `${t('updateFailed')}: ${snapshot.reason}`
|
|
1343
|
+
if (state === null) return t('updateChecking')
|
|
1344
|
+
// A finished upgrade outranks everything else the state could say: the files
|
|
1345
|
+
// are in place, and only a restart is left.
|
|
1346
|
+
if (state.pendingRestart !== undefined && state.pendingRestart !== null) {
|
|
1347
|
+
return `${t('updateRestart')} (${state.pendingRestart})`
|
|
1348
|
+
}
|
|
1349
|
+
if (state.status === 'unavailable') return t('updateUnavailable')
|
|
1350
|
+
if (state.status !== 'ok') return `${t('updateFailed')}: ${state.reason ?? ''}`
|
|
1351
|
+
if (state.updateAvailable === true) return `${t('updateLatest')} ${state.latest}`
|
|
1352
|
+
return `${t('updateUpToDate')} (${state.current})`
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/** Whether the shared snapshot justifies offering the upgrade control. */
|
|
1356
|
+
function canUpgrade(snapshot) {
|
|
1357
|
+
const state = snapshot.state
|
|
1358
|
+
return state !== null
|
|
1359
|
+
&& state.status === 'ok'
|
|
1360
|
+
&& state.updateAvailable === true
|
|
1361
|
+
&& (state.pendingRestart === undefined || state.pendingRestart === null)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* The update controls, rendered as a row on this card and as a section on the
|
|
1366
|
+
* plugin manager's page for this bundle.
|
|
1367
|
+
* @param props - `t`; `as`, the element and class the controls sit in; and
|
|
1368
|
+
* `primary`, which drops the upgrade button where the page's own actions area
|
|
1369
|
+
* already carries it, so the same button is never drawn twice on one page.
|
|
1370
|
+
*/
|
|
1371
|
+
function UpdateRow({ t, as = 'row', primary = true }) {
|
|
1372
|
+
const snapshot = useUpdate()
|
|
1373
|
+
const busy = snapshot.phase === 'loading' || snapshot.phase === 'applying'
|
|
1374
|
+
const controls = [
|
|
1375
|
+
primary && canUpgrade(snapshot) ? h('button', {
|
|
1376
|
+
key: 'upgrade',
|
|
1377
|
+
type: 'button',
|
|
1378
|
+
className: 'dct-button dct-upgrade',
|
|
1379
|
+
disabled: busy,
|
|
1380
|
+
onClick: () => { applyUpdateNow() },
|
|
1381
|
+
}, snapshot.phase === 'applying' ? t('updateUpgrading') : `${t('updateUpgrade')} ${snapshot.state.latest}`) : null,
|
|
1382
|
+
h('button', {
|
|
1383
|
+
key: 'check',
|
|
1384
|
+
type: 'button',
|
|
1385
|
+
className: 'dct-button dct-check-update',
|
|
1386
|
+
disabled: busy,
|
|
1387
|
+
onClick: () => { loadUpdate({ force: true }) },
|
|
1388
|
+
}, snapshot.phase === 'loading' ? t('updateChecking') : t('updateCheck')),
|
|
1389
|
+
]
|
|
1390
|
+
const body = [
|
|
1391
|
+
h('div', { key: 'text', className: 'dct-text' },
|
|
1392
|
+
h('div', { className: 'dct-title' }, t('updateTitle')),
|
|
1393
|
+
h('div', { className: 'dct-hint' }, updateText(snapshot, t))),
|
|
1394
|
+
h('div', { key: 'control', className: 'dct-control' }, controls),
|
|
1395
|
+
]
|
|
1396
|
+
return as === 'section'
|
|
1397
|
+
? h('section', { className: 'dct-update-section' }, body)
|
|
1398
|
+
: h('div', { className: 'dct-row dct-update-row' }, body)
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Whether a detail-page subject is this plugin's own bundle. */
|
|
1402
|
+
function isOwnBundle(subject) {
|
|
1403
|
+
return subject !== null && subject !== undefined
|
|
1404
|
+
&& subject.kind === 'bundle'
|
|
1405
|
+
&& subject.pkg !== undefined
|
|
1406
|
+
&& subject.pkg.name === PLUGIN_PACKAGE
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* The badge beside the plugin's title, drawn only when a newer release exists.
|
|
1411
|
+
* Every other subject renders nothing, which is what the slot expects.
|
|
1412
|
+
*/
|
|
1413
|
+
function PluginUpdateBadge({ subject, t }) {
|
|
1414
|
+
const snapshot = useUpdate()
|
|
1415
|
+
if (!isOwnBundle(subject)) return null
|
|
1416
|
+
if (!canUpgrade(snapshot)) return null
|
|
1417
|
+
return h('span', { className: 'dct-update-badge' }, `${t('updateBadge')} ${snapshot.state.latest}`)
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* The upgrade button in the plugin page's own actions area, beside its enable
|
|
1422
|
+
* switch and uninstall. Drawn only for this bundle and only when a newer release
|
|
1423
|
+
* exists, so every other plugin's page is left exactly as the shell built it.
|
|
1424
|
+
*/
|
|
1425
|
+
function PluginUpdateAction({ subject, t }) {
|
|
1426
|
+
const snapshot = useUpdate()
|
|
1427
|
+
if (!isOwnBundle(subject)) return null
|
|
1428
|
+
if (!canUpgrade(snapshot)) return null
|
|
1429
|
+
const busy = snapshot.phase === 'loading' || snapshot.phase === 'applying'
|
|
1430
|
+
return h('button', {
|
|
1431
|
+
type: 'button',
|
|
1432
|
+
className: 'dct-button dct-upgrade',
|
|
1433
|
+
disabled: busy,
|
|
1434
|
+
onClick: () => { applyUpdateNow() },
|
|
1435
|
+
}, snapshot.phase === 'applying' ? t('updateUpgrading') : `${t('updateUpgrade')} ${snapshot.state.latest}`)
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/** The update section under the plugin page's own content. */
|
|
1439
|
+
function PluginUpdateSection({ subject, t }) {
|
|
1440
|
+
if (!isOwnBundle(subject)) return null
|
|
1441
|
+
return h(UpdateRow, { t, as: 'section', primary: false })
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1214
1444
|
function ThemeRow({ t }) {
|
|
1215
1445
|
const [themes, setThemes] = React.useState([])
|
|
1216
1446
|
const [selected, setSelected] = React.useState(readSaved)
|
|
@@ -1465,6 +1695,7 @@ window.__ModuleLoader__.load({
|
|
|
1465
1695
|
disabled: noImage,
|
|
1466
1696
|
onClick: () => updateZone({ name: '' }),
|
|
1467
1697
|
}, t('bgClear')))),
|
|
1698
|
+
h(UpdateRow, { t }),
|
|
1468
1699
|
status === 'failed' ? h('div', { className: 'dct-error', role: 'status' }, t('failed')) : null)
|
|
1469
1700
|
}
|
|
1470
1701
|
|
|
@@ -1478,6 +1709,36 @@ window.__ModuleLoader__.load({
|
|
|
1478
1709
|
locale: LOCALE_NS,
|
|
1479
1710
|
}, ThemeRow))
|
|
1480
1711
|
|
|
1712
|
+
/*
|
|
1713
|
+
* The plugin manager's own page declares these list slots so a bundle can
|
|
1714
|
+
* speak about itself where users manage plugins, which is the one place a
|
|
1715
|
+
* newer release is worth mentioning. Both entries render null for any other
|
|
1716
|
+
* subject; the page's own version and switch stay untouched.
|
|
1717
|
+
*/
|
|
1718
|
+
ctx.slots.inject('plugins.detail.actions', () => ctx.slots.register({
|
|
1719
|
+
name: 'plugins.detail.actions',
|
|
1720
|
+
id: 'dsh-custom-theme-update',
|
|
1721
|
+
order: 40,
|
|
1722
|
+
label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
|
|
1723
|
+
locale: LOCALE_NS,
|
|
1724
|
+
}, PluginUpdateAction))
|
|
1725
|
+
|
|
1726
|
+
ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({
|
|
1727
|
+
name: 'plugins.detail.badge',
|
|
1728
|
+
id: 'dsh-custom-theme-update',
|
|
1729
|
+
order: 40,
|
|
1730
|
+
label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
|
|
1731
|
+
locale: LOCALE_NS,
|
|
1732
|
+
}, PluginUpdateBadge))
|
|
1733
|
+
|
|
1734
|
+
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
|
|
1735
|
+
name: 'plugins.detail.section',
|
|
1736
|
+
id: 'dsh-custom-theme-update',
|
|
1737
|
+
order: 40,
|
|
1738
|
+
label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
|
|
1739
|
+
locale: LOCALE_NS,
|
|
1740
|
+
}, PluginUpdateSection))
|
|
1741
|
+
|
|
1481
1742
|
/*
|
|
1482
1743
|
* The running label is `chat.deepDiving`, owned by the shell's `chat`
|
|
1483
1744
|
* namespace. `ctx.locale.register` throws for a namespace and locale that
|
package/package.json
CHANGED
package/src/index.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
orderThemeIds,
|
|
30
30
|
themesDirectory,
|
|
31
31
|
} from './themes.mjs'
|
|
32
|
+
import { PACKAGE_NAME, checkForUpdate, createCache } from './update.mjs'
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Prefix route owned by this plugin. `dsh-client-ui-theme` and the SPA dist use
|
|
@@ -43,8 +44,43 @@ const ROUTE_PATH = '/dsh-custom-theme'
|
|
|
43
44
|
/** Seeded stylesheets live beside the package root, one level above this module. */
|
|
44
45
|
const SEED_ROOT = fileURLToPath(new URL('../themes/', import.meta.url))
|
|
45
46
|
|
|
47
|
+
/**
|
|
48
|
+
* This package's manifest, read for the version the update check compares against.
|
|
49
|
+
* The manifest ships in the published tarball, so the lookup never depends on a
|
|
50
|
+
* checkout being present.
|
|
51
|
+
*/
|
|
52
|
+
const PACKAGE_JSON = fileURLToPath(new URL('../package.json', import.meta.url))
|
|
53
|
+
|
|
46
54
|
export const name = 'dsh-custom-theme'
|
|
47
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Read the version this build runs.
|
|
58
|
+
* @returns The version, or `undefined` when the manifest is missing or malformed;
|
|
59
|
+
* the update check then reports that it cannot compare rather than guessing.
|
|
60
|
+
*/
|
|
61
|
+
async function readOwnVersion() {
|
|
62
|
+
try {
|
|
63
|
+
const manifest = JSON.parse(await readFile(PACKAGE_JSON, 'utf8'))
|
|
64
|
+
return typeof manifest?.version === 'string' ? manifest.version : undefined
|
|
65
|
+
} catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One log line for an update state.
|
|
72
|
+
* @param state - A state from the update module.
|
|
73
|
+
* @returns Text for the startup log.
|
|
74
|
+
*/
|
|
75
|
+
function describeState(state) {
|
|
76
|
+
if (state.status === 'ok') {
|
|
77
|
+
return state.updateAvailable
|
|
78
|
+
? `${state.latest} is available (running ${state.current}, from ${state.registry})`
|
|
79
|
+
: `running the latest release (${state.current}, from ${state.registry})`
|
|
80
|
+
}
|
|
81
|
+
return `${state.status}: ${state.reason}`
|
|
82
|
+
}
|
|
83
|
+
|
|
48
84
|
/**
|
|
49
85
|
* Resolve the DSH home, honouring the environment override the runtime sets.
|
|
50
86
|
* @returns The home directory whose `themes` directory this plugin owns.
|
|
@@ -198,27 +234,45 @@ async function readTheme(directory, id) {
|
|
|
198
234
|
}
|
|
199
235
|
|
|
200
236
|
/**
|
|
201
|
-
* Answer the listing, stylesheet and
|
|
202
|
-
* the prefix is a 404; the route is `prefix`, so this handler owns the whole
|
|
237
|
+
* Answer the listing, stylesheet, background and update routes. Every other path
|
|
238
|
+
* under the prefix is a 404; the route is `prefix`, so this handler owns the whole
|
|
203
239
|
* subtree.
|
|
204
240
|
* @param req - Request from the application origin.
|
|
205
241
|
* @param res - Response owned by this handler.
|
|
206
|
-
* @param paths - `themes` and `backgrounds` directories,
|
|
207
|
-
*
|
|
208
|
-
*
|
|
242
|
+
* @param paths - `themes` and `backgrounds` directories, `ready`, which settles
|
|
243
|
+
* once the initial seeding pass finished, and `updates`, the update state's
|
|
244
|
+
* `state`, `check` and `apply` handlers.
|
|
209
245
|
*/
|
|
210
246
|
async function handleRoute(req, res, paths) {
|
|
211
247
|
await paths.ready
|
|
212
248
|
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
213
|
-
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
214
|
-
send(res, 405, 'text/plain; charset=utf-8', 'method not allowed')
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
249
|
const rest = decodePath(url.pathname.slice(ROUTE_PATH.length + 1))
|
|
218
250
|
if (rest === undefined) {
|
|
219
251
|
send(res, 400, 'text/plain; charset=utf-8', 'malformed path')
|
|
220
252
|
return
|
|
221
253
|
}
|
|
254
|
+
const method = req.method ?? 'GET'
|
|
255
|
+
|
|
256
|
+
// The two update actions change the profile, so they answer POST only. A GET has
|
|
257
|
+
// to stay safe for a link, a prefetch or an image, none of which may start an
|
|
258
|
+
// install; that also keeps a cross-site navigation from reaching them.
|
|
259
|
+
if (rest === 'update/check' || rest === 'update/apply') {
|
|
260
|
+
if (method !== 'POST') {
|
|
261
|
+
send(res, 405, 'text/plain; charset=utf-8', 'method not allowed')
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
const answer = rest === 'update/check' ? await paths.updates.check() : await paths.updates.apply()
|
|
265
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify(answer))
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
269
|
+
send(res, 405, 'text/plain; charset=utf-8', 'method not allowed')
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
if (rest === 'update') {
|
|
273
|
+
send(res, 200, 'application/json; charset=utf-8', JSON.stringify(await paths.updates.state()))
|
|
274
|
+
return
|
|
275
|
+
}
|
|
222
276
|
if (rest === 'themes') {
|
|
223
277
|
const themes = (await scanThemes(paths.themes)).map((id) => ({ id, bundled: isBundledThemeId(id) }))
|
|
224
278
|
send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ themes, dir: paths.themes }))
|
|
@@ -275,11 +329,132 @@ export function apply(ctx, config) {
|
|
|
275
329
|
ctx.logger.warn('dsh-custom-theme: preparing directories failed: %s', error.message)
|
|
276
330
|
})
|
|
277
331
|
|
|
332
|
+
/*
|
|
333
|
+
* The update check needs two things the row cannot demand: the version this build
|
|
334
|
+
* runs, and the profile's plugin-manager service for the registry list and the
|
|
335
|
+
* install. Both are read defensively, so a profile without the service still
|
|
336
|
+
* reports its own version and says why the check cannot run.
|
|
337
|
+
*/
|
|
338
|
+
let manager
|
|
339
|
+
let currentVersion
|
|
340
|
+
let pendingRestart
|
|
341
|
+
const cache = createCache()
|
|
342
|
+
const ownVersion = readOwnVersion().then((version) => {
|
|
343
|
+
currentVersion = version
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Run one check, sharing a check that is already running.
|
|
348
|
+
*
|
|
349
|
+
* An `unavailable` answer — no service mounted yet — is deliberately not cached:
|
|
350
|
+
* the injection below can settle after the first caller arrives, and caching the
|
|
351
|
+
* interim answer would report it as the result for the whole failure window.
|
|
352
|
+
* @returns The settled update state.
|
|
353
|
+
*/
|
|
354
|
+
let inFlight = null
|
|
355
|
+
function refresh() {
|
|
356
|
+
if (inFlight !== null) return inFlight
|
|
357
|
+
const promise = (async () => {
|
|
358
|
+
await ownVersion
|
|
359
|
+
const state = await checkForUpdate({ manager, current: currentVersion })
|
|
360
|
+
return state.status === 'unavailable' ? state : cache.write(state)
|
|
361
|
+
})().finally(() => {
|
|
362
|
+
if (inFlight === promise) inFlight = null
|
|
363
|
+
})
|
|
364
|
+
inFlight = promise
|
|
365
|
+
return promise
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
ctx.inject(['pluginManager'], (service) => {
|
|
369
|
+
manager = service.pluginManager
|
|
370
|
+
service.effect(() => () => {
|
|
371
|
+
manager = undefined
|
|
372
|
+
})
|
|
373
|
+
// The startup check runs from here rather than from `apply` itself: this is the
|
|
374
|
+
// moment the registry list and the install both become reachable, so a profile
|
|
375
|
+
// that mounts the service late still gets one check and no useless early answer.
|
|
376
|
+
if (config?.updateCheck !== false) {
|
|
377
|
+
refresh().then((state) => {
|
|
378
|
+
ctx.logger.info('dsh-custom-theme: update check %s', describeState(state))
|
|
379
|
+
}, (error) => {
|
|
380
|
+
ctx.logger.warn('dsh-custom-theme: update check failed: %s', error.message)
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
/** The update surface the route exposes to the browser half. */
|
|
386
|
+
const updates = {
|
|
387
|
+
/** The cached state, a running check, or a fresh check when neither exists. */
|
|
388
|
+
async state() {
|
|
389
|
+
const state = cache.read() ?? await refresh()
|
|
390
|
+
return { ...state, package: PACKAGE_NAME, pendingRestart }
|
|
391
|
+
},
|
|
392
|
+
/** Ask the registries again, ignoring the cache. */
|
|
393
|
+
async check() {
|
|
394
|
+
return { ...await refresh(), package: PACKAGE_NAME, pendingRestart }
|
|
395
|
+
},
|
|
396
|
+
/** Install the newer release the last check resolved. */
|
|
397
|
+
async apply() {
|
|
398
|
+
const state = cache.read() ?? cache.value
|
|
399
|
+
const base = { package: PACKAGE_NAME, current: currentVersion, pendingRestart }
|
|
400
|
+
if (state === null || state.status !== 'ok') {
|
|
401
|
+
return { ...base, status: 'error', reason: 'no release has been resolved yet; check for updates first' }
|
|
402
|
+
}
|
|
403
|
+
if (state.updateAvailable !== true) {
|
|
404
|
+
return { ...base, status: 'error', reason: 'the running build is already the latest release', latest: state.latest }
|
|
405
|
+
}
|
|
406
|
+
if (manager === undefined || typeof manager.installBundle !== 'function') {
|
|
407
|
+
return { ...base, status: 'error', reason: 'the plugin manager service is not mounted', latest: state.latest }
|
|
408
|
+
}
|
|
409
|
+
const spec = `${PACKAGE_NAME}@${state.latest}`
|
|
410
|
+
try {
|
|
411
|
+
const result = await manager.installBundle(spec, { enabled: true })
|
|
412
|
+
const application = result?.application
|
|
413
|
+
// `installBundle` resolves for a failed run too: the outcome lives in the
|
|
414
|
+
// result, not in whether the promise rejected. Only these two mean the files
|
|
415
|
+
// are in place; anything else left the profile as it was, and saying the
|
|
416
|
+
// upgrade succeeded would send the user looking for a restart that cannot help.
|
|
417
|
+
if (application !== 'applied' && application !== 'restart-required') {
|
|
418
|
+
const detail = result?.error?.diagnostic ?? result?.error?.code
|
|
419
|
+
return {
|
|
420
|
+
...base,
|
|
421
|
+
status: 'error',
|
|
422
|
+
latest: state.latest,
|
|
423
|
+
spec,
|
|
424
|
+
stage: result?.stage,
|
|
425
|
+
application,
|
|
426
|
+
restartRequired: false,
|
|
427
|
+
reason: detail === undefined
|
|
428
|
+
? `the ${result?.stage ?? 'install'} step did not complete (${application ?? 'no outcome'})`
|
|
429
|
+
: `the ${result?.stage ?? 'install'} step did not complete: ${detail}`,
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
pendingRestart = state.latest
|
|
433
|
+
cache.write({ ...state, updateAvailable: false, pendingRestart })
|
|
434
|
+
return {
|
|
435
|
+
status: 'ok',
|
|
436
|
+
package: PACKAGE_NAME,
|
|
437
|
+
current: currentVersion,
|
|
438
|
+
latest: state.latest,
|
|
439
|
+
spec,
|
|
440
|
+
stage: result?.stage,
|
|
441
|
+
application,
|
|
442
|
+
// Replacing a package cannot hot-swap the Host module: the new code is
|
|
443
|
+
// installed but the running process still holds the old one until restart.
|
|
444
|
+
restartRequired: application === 'restart-required',
|
|
445
|
+
pendingRestart,
|
|
446
|
+
}
|
|
447
|
+
} catch (error) {
|
|
448
|
+
return { ...base, status: 'error', reason: error?.message ?? String(error), latest: state.latest, spec }
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
}
|
|
452
|
+
|
|
278
453
|
ctx.inject(['webServer'], (child) => {
|
|
279
454
|
child.effect(() => child.webServer.register({
|
|
280
455
|
kind: 'prefix',
|
|
281
456
|
path: ROUTE_PATH,
|
|
282
|
-
handler: (req, res) => handleRoute(req, res, { themes, backgrounds, ready }).catch((error) => {
|
|
457
|
+
handler: (req, res) => handleRoute(req, res, { themes, backgrounds, ready, updates }).catch((error) => {
|
|
283
458
|
child.logger.warn('dsh-custom-theme: %s failed: %s', req.url, error.message)
|
|
284
459
|
if (!res.headersSent) send(res, 500, 'text/plain; charset=utf-8', 'theme read failed')
|
|
285
460
|
}),
|
package/src/update.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update detection and upgrade planning for dsh-custom-theme.
|
|
3
|
+
*
|
|
4
|
+
* Pure version arithmetic and the registry query, kept apart from the route so it
|
|
5
|
+
* can be exercised without a Host. Nothing here is imported from a package: the row
|
|
6
|
+
* has to stay loadable by a profile that links this package directly, with no peer
|
|
7
|
+
* resolution — the same reason the rest of the Host half imports nothing.
|
|
8
|
+
*
|
|
9
|
+
* The registry is asked directly rather than through `pluginManager.inspect`.
|
|
10
|
+
* `inspect` is the pre-install check for a spec the user typed, and it refuses an
|
|
11
|
+
* already-installed package with `already-installed` for every form of the spec —
|
|
12
|
+
* including a version that does not exist — so it can never answer "what is the
|
|
13
|
+
* latest release". The registry list it *does* expose, `registries()`, is used for
|
|
14
|
+
* the query instead, which keeps a configured mirror or private registry in charge.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The package this row is published as. */
|
|
18
|
+
export const PACKAGE_NAME = 'dsh-custom-theme'
|
|
19
|
+
|
|
20
|
+
/** The registry asked when the profile names neither a registry nor a resolved one. */
|
|
21
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/'
|
|
22
|
+
|
|
23
|
+
/** How long a successful answer is reused before the registry is asked again. */
|
|
24
|
+
export const CACHE_TTL_MS = 6 * 60 * 60 * 1000
|
|
25
|
+
|
|
26
|
+
/** How long a failed answer is reused, so a blip does not hide an update for hours. */
|
|
27
|
+
export const FAILURE_TTL_MS = 60 * 1000
|
|
28
|
+
|
|
29
|
+
/** Per-registry request budget. */
|
|
30
|
+
export const FETCH_TIMEOUT_MS = 15_000
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Parse a semver string into its comparable parts.
|
|
34
|
+
*
|
|
35
|
+
* Build metadata is ignored, as the specification requires: it never takes part in
|
|
36
|
+
* precedence. A leading `v` is tolerated because registries answer with one or
|
|
37
|
+
* without it depending on the tool.
|
|
38
|
+
* @param value - Candidate version text.
|
|
39
|
+
* @returns The parts, or `undefined` when the text is not a version.
|
|
40
|
+
*/
|
|
41
|
+
export function parseVersion(value) {
|
|
42
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(String(value ?? '').trim())
|
|
43
|
+
if (match === null) return undefined
|
|
44
|
+
return {
|
|
45
|
+
major: Number(match[1]),
|
|
46
|
+
minor: Number(match[2]),
|
|
47
|
+
patch: Number(match[3]),
|
|
48
|
+
prerelease: match[4] === undefined ? [] : match[4].split('.'),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compare two versions by semver precedence.
|
|
54
|
+
* @param left - Left version text.
|
|
55
|
+
* @param right - Right version text.
|
|
56
|
+
* @returns `-1`, `0` or `1`, or `undefined` when either text is not a version.
|
|
57
|
+
*/
|
|
58
|
+
export function compareVersions(left, right) {
|
|
59
|
+
const a = parseVersion(left)
|
|
60
|
+
const b = parseVersion(right)
|
|
61
|
+
if (a === undefined || b === undefined) return undefined
|
|
62
|
+
for (const part of ['major', 'minor', 'patch']) {
|
|
63
|
+
if (a[part] !== b[part]) return a[part] < b[part] ? -1 : 1
|
|
64
|
+
}
|
|
65
|
+
// A release outranks any prerelease of the same core version.
|
|
66
|
+
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
|
|
67
|
+
if (a.prerelease.length === 0) return 1
|
|
68
|
+
if (b.prerelease.length === 0) return -1
|
|
69
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length)
|
|
70
|
+
for (let index = 0; index < length; index += 1) {
|
|
71
|
+
const l = a.prerelease[index]
|
|
72
|
+
const r = b.prerelease[index]
|
|
73
|
+
// A shorter identifier list is lower when every preceding one is equal.
|
|
74
|
+
if (l === undefined) return -1
|
|
75
|
+
if (r === undefined) return 1
|
|
76
|
+
const lNumeric = /^\d+$/u.test(l)
|
|
77
|
+
const rNumeric = /^\d+$/u.test(r)
|
|
78
|
+
if (lNumeric && rNumeric) {
|
|
79
|
+
if (Number(l) !== Number(r)) return Number(l) < Number(r) ? -1 : 1
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
// Numeric identifiers always have lower precedence than alphanumeric ones.
|
|
83
|
+
if (lNumeric !== rNumeric) return lNumeric ? -1 : 1
|
|
84
|
+
if (l !== r) return l < r ? -1 : 1
|
|
85
|
+
}
|
|
86
|
+
return 0
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `latest` is a strictly newer release than `current`.
|
|
91
|
+
*
|
|
92
|
+
* A version that cannot be parsed never counts as an update: offering an upgrade to
|
|
93
|
+
* something unreadable is worse than staying quiet.
|
|
94
|
+
* @param latest - Version the registry answered with.
|
|
95
|
+
* @param current - Version this build runs.
|
|
96
|
+
* @returns True when an upgrade is warranted.
|
|
97
|
+
*/
|
|
98
|
+
export function isUpdateAvailable(latest, current) {
|
|
99
|
+
return compareVersions(latest, current) === 1
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The registries to ask, in order, without repeats.
|
|
104
|
+
*
|
|
105
|
+
* `pluginManager.registries()` reports the configured first registry (`null` when
|
|
106
|
+
* pnpm's own configuration decides), the registry that resolves to, and the
|
|
107
|
+
* configured fallbacks. A profile pointed at a private registry therefore asks only
|
|
108
|
+
* that one and its own fallbacks; the public registry is appended only when the
|
|
109
|
+
* profile names nothing at all.
|
|
110
|
+
* @param registries - The service's answer, read defensively.
|
|
111
|
+
* @returns Registry base URLs, each without a trailing slash.
|
|
112
|
+
*/
|
|
113
|
+
export function registryCandidates(registries) {
|
|
114
|
+
const seen = new Set()
|
|
115
|
+
const out = []
|
|
116
|
+
const push = (value) => {
|
|
117
|
+
if (typeof value !== 'string') return
|
|
118
|
+
const trimmed = value.trim().replace(/\/+$/u, '')
|
|
119
|
+
if (trimmed === '' || seen.has(trimmed)) return
|
|
120
|
+
seen.add(trimmed)
|
|
121
|
+
out.push(trimmed)
|
|
122
|
+
}
|
|
123
|
+
push(registries?.registry)
|
|
124
|
+
push(registries?.resolved)
|
|
125
|
+
for (const entry of Array.isArray(registries?.fallbackRegistries) ? registries.fallbackRegistries : []) push(entry)
|
|
126
|
+
if (out.length === 0) push(DEFAULT_REGISTRY)
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ask each candidate registry for this package's latest published version.
|
|
132
|
+
*
|
|
133
|
+
* A registry that is unreachable, refuses, or answers without a usable version
|
|
134
|
+
* passes the question to the next one, so a stale mirror does not read as "no
|
|
135
|
+
* update". Every failure is returned as a state rather than thrown, because the
|
|
136
|
+
* caller renders it: a profile with no network access must still show its own
|
|
137
|
+
* version and say why the check could not run.
|
|
138
|
+
* @param options - `registries` from the plugin manager, plus optional `current`,
|
|
139
|
+
* `packageName`, request `timeoutMs` and a `fetchImpl` for tests.
|
|
140
|
+
* @returns A state whose `status` is `ok`, `unavailable` or `error`.
|
|
141
|
+
*/
|
|
142
|
+
export async function fetchLatestVersion({
|
|
143
|
+
registries,
|
|
144
|
+
current,
|
|
145
|
+
packageName = PACKAGE_NAME,
|
|
146
|
+
timeoutMs = FETCH_TIMEOUT_MS,
|
|
147
|
+
fetchImpl = globalThis.fetch,
|
|
148
|
+
} = {}) {
|
|
149
|
+
if (typeof fetchImpl !== 'function') {
|
|
150
|
+
return { status: 'unavailable', current, reason: 'this runtime has no fetch' }
|
|
151
|
+
}
|
|
152
|
+
const candidates = registryCandidates(registries)
|
|
153
|
+
const asked = []
|
|
154
|
+
let reason = 'no registry answered'
|
|
155
|
+
for (const base of candidates) {
|
|
156
|
+
const url = `${base}/${packageName}/latest`
|
|
157
|
+
asked.push(base)
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetchImpl(url, {
|
|
160
|
+
headers: { accept: 'application/json' },
|
|
161
|
+
cache: 'no-store',
|
|
162
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
163
|
+
})
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
reason = `${base} answered HTTP ${response.status}`
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
const body = await response.json()
|
|
169
|
+
const latest = body?.version
|
|
170
|
+
if (parseVersion(latest) === undefined) {
|
|
171
|
+
reason = `${base} answered without a usable version`
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
status: 'ok',
|
|
176
|
+
current,
|
|
177
|
+
latest,
|
|
178
|
+
updateAvailable: isUpdateAvailable(latest, current),
|
|
179
|
+
registry: base,
|
|
180
|
+
description: typeof body?.description === 'string' ? body.description : undefined,
|
|
181
|
+
checkedAt: new Date().toISOString(),
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
reason = `${base} failed: ${error?.message ?? String(error)}`
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { status: 'error', current, reason, registries: asked, checkedAt: new Date().toISOString() }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Detect whether a newer release exists.
|
|
192
|
+
* @param options - `manager` is the Host's plugin-manager service; the rest is
|
|
193
|
+
* forwarded to {@link fetchLatestVersion}.
|
|
194
|
+
* @returns A state whose `status` is `ok`, `unavailable` or `error`.
|
|
195
|
+
*/
|
|
196
|
+
export async function checkForUpdate({ manager, current, ...rest } = {}) {
|
|
197
|
+
if (manager === undefined || manager === null || typeof manager.registries !== 'function') {
|
|
198
|
+
return { status: 'unavailable', current, reason: 'the plugin manager service is not mounted' }
|
|
199
|
+
}
|
|
200
|
+
let registries
|
|
201
|
+
try {
|
|
202
|
+
registries = await manager.registries()
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return { status: 'error', current, reason: error?.message ?? String(error) }
|
|
205
|
+
}
|
|
206
|
+
return fetchLatestVersion({ ...rest, registries, current })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Cache one update state.
|
|
211
|
+
*
|
|
212
|
+
* The check runs once at startup, so without this every page load would ask the
|
|
213
|
+
* registry again. A failure is cached for less time than a success, so a transient
|
|
214
|
+
* network problem does not hide an update for hours.
|
|
215
|
+
* @param options - `ttl` for a successful answer, `failureTtl` for a failed one.
|
|
216
|
+
* @returns `read`, `write`, `clear` and the current `value`.
|
|
217
|
+
*/
|
|
218
|
+
export function createCache({ ttl = CACHE_TTL_MS, failureTtl = FAILURE_TTL_MS } = {}) {
|
|
219
|
+
let entry = null
|
|
220
|
+
return {
|
|
221
|
+
/** The stored state, whether or not it is still fresh. */
|
|
222
|
+
get value() {
|
|
223
|
+
return entry === null ? null : entry.state
|
|
224
|
+
},
|
|
225
|
+
/** The stored state while it is still fresh, otherwise null. */
|
|
226
|
+
read() {
|
|
227
|
+
if (entry === null) return null
|
|
228
|
+
const age = Date.now() - entry.at
|
|
229
|
+
return age < (entry.state?.status === 'ok' ? ttl : failureTtl) ? entry.state : null
|
|
230
|
+
},
|
|
231
|
+
/** Store a state, stamped with the current time. */
|
|
232
|
+
write(state) {
|
|
233
|
+
entry = { state, at: Date.now() }
|
|
234
|
+
return state
|
|
235
|
+
},
|
|
236
|
+
/** Forget the stored state. */
|
|
237
|
+
clear() {
|
|
238
|
+
entry = null
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
}
|