dsh-custom-theme 0.1.0 → 0.1.1

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # dsh-custom-theme
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/dsh-custom-theme.svg)](https://www.npmjs.com/package/dsh-custom-theme)
4
+ [![license](https://img.shields.io/npm/l/dsh-custom-theme.svg)](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 a tarball, or from npm
292
-
293
- Both ship the same prebuilt code:
301
+ ### From npm
294
302
 
295
303
  ```sh
296
- pnpm pack # -> dsh-custom-theme-0.1.0.tgz
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
- dsh plugin --profile <name> add dsh-custom-theme # after an npm release
312
+ pnpm pack # -> dsh-custom-theme-<version>.tgz
313
+ dsh plugin --profile <name> add ./dsh-custom-theme-<version>.tgz
302
314
  ```
303
315
 
304
- For the maintainer, publishing a release is:
316
+ ### Releasing (maintainer)
305
317
 
306
318
  ```sh
307
- git tag v0.1.0 && git push origin v0.1.0
308
- gh release create v0.1.0 --title v0.1.0 --notes-file CHANGELOG.md
309
- pnpm publish --access public # optional; needs npm auth
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,60 @@ 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
+ - Two contributions to the plugin manager's own page for this bundle — a badge beside
396
+ the title and a section under the page's content, both drawn only when a newer
397
+ release exists. Every other plugin's page is untouched: an entry renders nothing for
398
+ a subject it has nothing to say about.
399
+
400
+ **Upgrading.** **升级** installs the exact version the check resolved, through the
401
+ plugin manager's own `installBundle`, so the profile's lockfile and bundle list stay
402
+ consistent with anything you install from the Plugins page. Replacing a package cannot
403
+ hot-swap the Host module, so an upgrade always ends with *restart DeepSeek Harness*;
404
+ the row then says so instead of offering the same upgrade again.
405
+
406
+ **Which registry.** The check asks the profile's plugin-manager configuration, in its
407
+ order: the configured registry, then the one pnpm resolves, then the configured
408
+ fallbacks. A mirror or private registry therefore stays authoritative and is never
409
+ silently widened to the public one. With nothing configured, that resolves to
410
+ `https://registry.npmjs.org/`.
411
+
412
+ **Routes**, under the plugin's prefix and reachable with the page's own token:
413
+
414
+ | Route | Method | Answers |
415
+ | --- | --- | --- |
416
+ | `/dsh-custom-theme/update` | `GET` | The cached state: running version, latest, whether it is an upgrade, and the registry that answered. |
417
+ | `/dsh-custom-theme/update/check` | `POST` | Asks the registries again, ignoring the cache. |
418
+ | `/dsh-custom-theme/update/apply` | `POST` | Installs the release the last check resolved. |
419
+
420
+ Only the two actions answer `POST`: a `GET` stays safe for a link, a prefetch or an
421
+ `<img>`, none of which may start an install. A run that does not end in `applied` or
422
+ `restart-required` is reported as an error carrying the plugin manager's own
423
+ diagnostic, and the upgrade stays on offer.
424
+
359
425
  ## Verify
360
426
 
361
427
  Verified against `dsh` 0.2.0-rc.2 on Windows:
362
428
 
363
- - `node --test "test/**/*.test.mjs"` — 14 tests, all passing: id and image-name
429
+ - `node --test "test/**/*.test.mjs"` — 35 tests, all passing: id and image-name
364
430
  whitelists, ordering, directory resolution, seeding, re-sync on a new seed
365
- generation, both asset routes, both listings, and traversal, extension and
366
- method rejection.
431
+ generation, both asset routes, both listings, traversal, extension and method
432
+ rejection, and the update surface — semver precedence including prerelease
433
+ ordering, registry-candidate order and the private-registry rule, the registry
434
+ query falling through a failure to the next candidate, the cache's success and
435
+ failure windows, and all three update routes, including that a failed install is
436
+ reported as a failure rather than as a pending restart.
367
437
  - `node test/browser/appearance.mjs` — 33 steps in a real headless Edge, all
368
438
  passing. It boots the app, asserts the controls are absent from the chat view and
369
439
  still absent once Settings opens, then opens the plugin's own page from the nav
@@ -379,7 +449,14 @@ Verified against `dsh` 0.2.0-rc.2 on Windows:
379
449
  every injected layer rule. Finally, with the panel closed, since the panel is
380
450
  portalled over the whole window, it asserts that the image is what
381
451
  `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.
452
+ the negative-`z-index` layer from silently disappearing behind a surface. That
453
+ sampling is the one thing DSH's own UI can disturb: a freshly created profile opens
454
+ the `预览版说明` first-run notice, whose backdrop covers the window and answers every
455
+ `elementFromPoint` query, so those four visibility steps read nothing even though the
456
+ assertions before them — which read the tagged surfaces and the layers beside them
457
+ directly — still pass. The suite dismisses the notice after boot and again before
458
+ sampling, so it is verified both on a freshly created profile and on a long-lived
459
+ one.
383
460
  - `node test/browser/working-row.mjs` — 5 steps against a session that already has
384
461
  turns. It reads the shipped row's computed geometry, asserts the replacement is
385
462
  absent while no phrase is configured, configures one, then compares the
@@ -465,6 +542,17 @@ row removes the rows and the palette together.
465
542
  `::before` on one of these surfaces would collide with it; the plugin's rule sets
466
543
  `content`, `position`, `inset`, `z-index` and the picture, so the shell's own
467
544
  `::before` content would be replaced rather than merged.
545
+ - **An upgrade needs a restart.** Replacing a package cannot hot-swap the Host module,
546
+ so after an upgrade the new code is on disk while the running process keeps the old
547
+ one. The row says a restart is pending rather than offering the same upgrade again;
548
+ the Browser half comes back with that restart too.
549
+ - **The update check needs the plugin-manager service.** Without it the row reports
550
+ that updates cannot be checked rather than guessing. That service names the
551
+ registries to ask and performs the install, which is why the check follows its
552
+ configuration instead of a hard-coded URL.
553
+ - **A check answers from a six-hour cache.** The boot check and the first page open
554
+ share one answer, so a release published in between waits for the next window or for
555
+ **检查更新**.
468
556
  - **No Schemastery `Config` schema**, so `config` is read defensively and never
469
557
  validated. That is also why the package carries no peer dependencies at all.
470
558
  - **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,181 @@ 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 both as a row on this card and as a section on
1366
+ * the plugin manager's page for this bundle.
1367
+ * @param props - `t` plus `as`, the element and class the controls sit in.
1368
+ */
1369
+ function UpdateRow({ t, as = 'row' }) {
1370
+ const snapshot = useUpdate()
1371
+ const busy = snapshot.phase === 'loading' || snapshot.phase === 'applying'
1372
+ const controls = [
1373
+ canUpgrade(snapshot) ? h('button', {
1374
+ key: 'upgrade',
1375
+ type: 'button',
1376
+ className: 'dct-button dct-upgrade',
1377
+ disabled: busy,
1378
+ onClick: () => { applyUpdateNow() },
1379
+ }, snapshot.phase === 'applying' ? t('updateUpgrading') : `${t('updateUpgrade')} ${snapshot.state.latest}`) : null,
1380
+ h('button', {
1381
+ key: 'check',
1382
+ type: 'button',
1383
+ className: 'dct-button dct-check-update',
1384
+ disabled: busy,
1385
+ onClick: () => { loadUpdate({ force: true }) },
1386
+ }, snapshot.phase === 'loading' ? t('updateChecking') : t('updateCheck')),
1387
+ ]
1388
+ const body = [
1389
+ h('div', { key: 'text', className: 'dct-text' },
1390
+ h('div', { className: 'dct-title' }, t('updateTitle')),
1391
+ h('div', { className: 'dct-hint' }, updateText(snapshot, t))),
1392
+ h('div', { key: 'control', className: 'dct-control' }, controls),
1393
+ ]
1394
+ return as === 'section'
1395
+ ? h('section', { className: 'dct-update-section' }, body)
1396
+ : h('div', { className: 'dct-row dct-update-row' }, body)
1397
+ }
1398
+
1399
+ /** Whether a detail-page subject is this plugin's own bundle. */
1400
+ function isOwnBundle(subject) {
1401
+ return subject !== null && subject !== undefined
1402
+ && subject.kind === 'bundle'
1403
+ && subject.pkg !== undefined
1404
+ && subject.pkg.name === PLUGIN_PACKAGE
1405
+ }
1406
+
1407
+ /**
1408
+ * The badge beside the plugin's title, drawn only when a newer release exists.
1409
+ * Every other subject renders nothing, which is what the slot expects.
1410
+ */
1411
+ function PluginUpdateBadge({ subject, t }) {
1412
+ const snapshot = useUpdate()
1413
+ if (!isOwnBundle(subject)) return null
1414
+ if (!canUpgrade(snapshot)) return null
1415
+ return h('span', { className: 'dct-update-badge' }, `${t('updateBadge')} ${snapshot.state.latest}`)
1416
+ }
1417
+
1418
+ /** The update section under the plugin page's own content. */
1419
+ function PluginUpdateSection({ subject, t }) {
1420
+ if (!isOwnBundle(subject)) return null
1421
+ return h(UpdateRow, { t, as: 'section' })
1422
+ }
1423
+
1214
1424
  function ThemeRow({ t }) {
1215
1425
  const [themes, setThemes] = React.useState([])
1216
1426
  const [selected, setSelected] = React.useState(readSaved)
@@ -1465,6 +1675,7 @@ window.__ModuleLoader__.load({
1465
1675
  disabled: noImage,
1466
1676
  onClick: () => updateZone({ name: '' }),
1467
1677
  }, t('bgClear')))),
1678
+ h(UpdateRow, { t }),
1468
1679
  status === 'failed' ? h('div', { className: 'dct-error', role: 'status' }, t('failed')) : null)
1469
1680
  }
1470
1681
 
@@ -1478,6 +1689,28 @@ window.__ModuleLoader__.load({
1478
1689
  locale: LOCALE_NS,
1479
1690
  }, ThemeRow))
1480
1691
 
1692
+ /*
1693
+ * The plugin manager's own page declares these list slots so a bundle can
1694
+ * speak about itself where users manage plugins, which is the one place a
1695
+ * newer release is worth mentioning. Both entries render null for any other
1696
+ * subject; the page's own version and switch stay untouched.
1697
+ */
1698
+ ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({
1699
+ name: 'plugins.detail.badge',
1700
+ id: 'dsh-custom-theme-update',
1701
+ order: 40,
1702
+ label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
1703
+ locale: LOCALE_NS,
1704
+ }, PluginUpdateBadge))
1705
+
1706
+ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
1707
+ name: 'plugins.detail.section',
1708
+ id: 'dsh-custom-theme-update',
1709
+ order: 40,
1710
+ label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
1711
+ locale: LOCALE_NS,
1712
+ }, PluginUpdateSection))
1713
+
1481
1714
  /*
1482
1715
  * The running label is `chat.deepDiving`, owned by the shell's `chat`
1483
1716
  * namespace. `ctx.locale.register` throws for a namespace and locale that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-theme",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "User-editable CSS themes and background images for the DSH Web GUI and Desktop app, with pickers in Settings",
5
5
  "license": "MIT",
6
6
  "author": "Sparrived",
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 background routes. Every other path under
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, plus `ready`, which
207
- * settles once the initial seeding pass finished, so a request that arrives
208
- * during startup never scans a directory that does not exist yet.
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
+ }