dsh-custom-theme 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Served routes:
24
24
  | `GET /dsh-custom-theme/themes` | `{ themes: [{ id, bundled }], dir }` |
25
25
  | `GET /dsh-custom-theme/theme/<id>.css` | The stylesheet text |
26
26
  | `GET /dsh-custom-theme/backgrounds` | `{ backgrounds: [{ name, url }], dir }` |
27
+ | `POST /dsh-custom-theme/backgrounds?name=<file>` | Stores the body as a picture and answers `{ name, backgrounds, dir }` with the name it stored |
27
28
  | `GET /dsh-custom-theme/background/<name>` | The image bytes |
28
29
  | `GET /dsh-custom-theme/update` | The cached update state — see [Updates](#updates) |
29
30
  | `POST /dsh-custom-theme/update/check` | Asks the registries again, ignoring the cache |
@@ -173,9 +174,25 @@ other's. The page's selector falls back to its built-in entry to match the windo
173
174
 
174
175
  ## Background images
175
176
 
176
- Drop an image into the background directory (`$DSH_HOME/backgrounds` by default),
177
- press **Rescan**, and pick it in the **Background image** row. Served extensions
178
- are `.png .jpg .jpeg .webp .gif .avif .bmp`, up to 16 MiB each.
177
+ Two ways in: press **选择图片…** in the **背景图片** row and pick a file, or drop one
178
+ into the background directory (`$DSH_HOME/backgrounds` by default) and press
179
+ **Rescan**. A picked file is uploaded to the Host, which stores it in that same
180
+ directory, so it becomes the same kind of citizen as one dropped in by hand and every
181
+ zone can select it. Served extensions are `.png .jpg .jpeg .webp .gif .avif .bmp`,
182
+ up to 16 MiB each.
183
+
184
+ What gets stored is decided by the bytes, not by the file's name or its declared type:
185
+ the Host sniffs the leading bytes and refuses anything that is no served format. The
186
+ name is folded into the ASCII the directory whitelist accepts (`suite upload.png` →
187
+ `suite-upload.png`), and a name already in use stays with the picture that holds it —
188
+ picking the same file twice reuses it, a different picture takes the next free `-1`,
189
+ `-2`, … beside it.
190
+
191
+ Which zone the controls below edit is chosen in the **workbench**: a row of tabs naming
192
+ each zone, and a schematic of the window whose regions are clickable. Both mark a zone
193
+ that already carries a picture, so the panel answers at a glance what is set where. The
194
+ **整体** label belongs to the whole-window zone, which owns the frame the other regions
195
+ sit inside — dashed while it is empty, solid once it carries a picture.
179
196
 
180
197
  Each of six zones holds its own image, picture opacity, blur, fit and position:
181
198
 
@@ -392,10 +409,13 @@ button: no code is swapped until you click it.
392
409
  **Where it shows up.** Both surfaces read the same cached answer:
393
410
 
394
411
  - 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.
412
+ - Three contributions to the plugin manager's own page for this bundle, each with one
413
+ job: **升级** in the page's actions area beside the enable switch and uninstall, an
414
+ *Update available* badge beside the title, and a section under the page's content
415
+ carrying the version detail and **检查更新**. All three are drawn only when a newer
416
+ release exists, and every other plugin's page is untouched — an entry renders nothing
417
+ for a subject it has nothing to say about. The upgrade button lives in one place, so
418
+ the section does not repeat it.
399
419
 
400
420
  **Upgrading.** **升级** installs the exact version the check resolved, through the
401
421
  plugin manager's own `installBundle`, so the profile's lockfile and bundle list stay
@@ -426,21 +446,31 @@ diagnostic, and the upgrade stays on offer.
426
446
 
427
447
  Verified against `dsh` 0.2.0-rc.2 on Windows:
428
448
 
429
- - `node --test "test/**/*.test.mjs"` — 35 tests, all passing: id and image-name
449
+ - `node --test "test/**/*.test.mjs"` — 47 tests, all passing: id and image-name
430
450
  whitelists, ordering, directory resolution, seeding, re-sync on a new seed
431
451
  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.
437
- - `node test/browser/appearance.mjs` — 33 steps in a real headless Edge, all
452
+ rejection, image sniffing from the leading bytes, the upload-name fold and its
453
+ collision rule, and the upload route — that it stores the bytes it was handed, that
454
+ a name it cannot serve is folded rather than refused, that it cannot be made to name
455
+ a path outside the directory, and that an oversized, empty or non-image body is
456
+ refused — and the update surface: semver precedence including prerelease ordering,
457
+ registry-candidate order and the private-registry rule, the registry query falling
458
+ through a failure to the next candidate, the cache's success and failure windows, and
459
+ all three update routes, including that a failed install is reported as a failure
460
+ rather than as a pending restart.
461
+ - `node test/browser/appearance.mjs` — 35 steps in a real headless Edge, all
438
462
  passing. It boots the app, asserts the controls are absent from the chat view and
439
463
  still absent once Settings opens, then opens the plugin's own page from the nav
440
464
  and drives it. It asserts on rendered state: each bundled theme paints its light
441
465
  set and then follows the page's colour-scheme control into its dark set without
442
466
  being dropped, the empty option restores a built-in token, a saved theme and
443
- background both re-apply on boot before Settings is opened. For backgrounds it
467
+ background both re-apply on boot before Settings is opened. For the background
468
+ workbench it checks that every zone is reachable both as a tab and as a schematic
469
+ region, and that clicking the picture of a zone moves the very selection the tabs
470
+ report. For the file picker it puts a real `File` on the input — the state the
471
+ dialog leaves behind — and asserts the picture is uploaded, comes back under the
472
+ folded name, is added to the listing, is selected for the zone that was being
473
+ edited, and is genuinely painted on that zone. For backgrounds it
444
474
  checks that the picture really is on the layer and not on the surface element, that
445
475
  the layer's own alpha is the configured picture opacity while the panel fill stays
446
476
  at its per-zone percentage, that a blur lands on the picture layer and nowhere a
@@ -498,6 +528,12 @@ row removes the rows and the palette together.
498
528
  geometry match and the finished-turn label. The rotation is driven by a
499
529
  `setInterval` on the configured interval.
500
530
 
531
+ - **A picked file keeps its bytes, not its name.** The background directory's
532
+ whitelist is ASCII, and a stored file has to pass it to be listed or served at all,
533
+ so a name outside it is folded rather than refused: `我的壁纸.png` is stored as
534
+ `background.png`. The picture and its selection are unaffected. Allowing Unicode
535
+ names would mean relaxing that whitelist, which is the boundary guarding every read
536
+ from the directory, so it is left strict.
501
537
  - **The page cannot choose its nav icon.** `settings.section` has no icon field;
502
538
  the shell maps the entry id to a glyph and falls back to a generic settings gear
503
539
  for an id it does not know, which is what this page gets.
package/lib/client.js CHANGED
@@ -125,6 +125,41 @@ window.__ModuleLoader__.load({
125
125
  .dct-update-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1, currentColor); }
126
126
  .dct-update-section .dct-text { margin-bottom: 8px; }
127
127
  .dct-update-section .dct-control { justify-content: flex-start; }
128
+ /* The background workbench: a row of zone tabs above a schematic of the window. Both
129
+ decide which zone the controls below are editing, so both mark the same two states. */
130
+ .dct-file { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
131
+ .dct-zones { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
132
+ .dct-zone-tab { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding: 5px 10px; font: inherit; text-align: left; color: var(--dsw-alias-label-secondary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; cursor: pointer; }
133
+ .dct-zone-tab:hover { border-color: var(--dsw-alias-border-l2, currentColor); }
134
+ .dct-zone-tab.selected { color: var(--dsw-alias-label-primary, inherit); border-color: var(--dsw-alias-label-primary, currentColor); }
135
+ .dct-zone-tab:disabled { opacity: 0.5; cursor: default; }
136
+ .dct-zone-tab strong { font-size: 13px; font-weight: 500; }
137
+ .dct-zone-tab small { max-width: 130px; overflow: hidden; font-size: 11px; color: var(--dsw-alias-label-caption, inherit); text-overflow: ellipsis; white-space: nowrap; }
138
+ .dct-zone-tab.has-image strong::after { content: "●"; margin-left: 5px; font-size: 8px; vertical-align: middle; }
139
+ .dct-schematic { position: relative; isolation: isolate; display: grid; grid-template-columns: 96px minmax(0, 1fr) 84px; grid-template-rows: 26px minmax(0, 1fr); grid-template-areas: "windowbar windowbar windowbar" "sidebar main dock"; gap: 5px; min-height: 152px; margin-top: 8px; padding: 18px 12px 12px; border-radius: 8px; }
140
+ .dct-schematic button { min-width: 0; display: grid; place-items: center; padding: 5px; font: inherit; font-size: 11px; color: var(--dsw-alias-label-secondary, inherit); background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l1, currentColor); border-radius: 6px; cursor: pointer; }
141
+ .dct-schematic button:hover { border-color: var(--dsw-alias-border-l2, currentColor); }
142
+ .dct-schematic button.selected { color: var(--dsw-alias-label-primary, inherit); border-color: var(--dsw-alias-label-primary, currentColor); }
143
+ .dct-schematic button:disabled { opacity: 0.5; cursor: default; }
144
+ .dct-schematic button.has-image::before { content: "●"; margin-right: 4px; font-size: 8px; }
145
+ /* The whole-window zone is the frame the other regions sit inside rather than another
146
+ region beside them, so it owns the outer border: dashed while that zone is empty,
147
+ solid once it carries a picture, and highlighted when it is the one being edited.
148
+ Its label sits in the band the container's top padding leaves free, because every
149
+ other pixel of the frame is covered by a region.
150
+ These rules are written as button.dct-schematic-global on purpose: the shared
151
+ .dct-schematic button rule above is a class plus an element, so it outranks a
152
+ single class and would otherwise centre this label behind the regions. */
153
+ .dct-schematic button.dct-schematic-global { position: absolute; inset: 0; z-index: 0; display: flex; align-items: flex-start; justify-content: flex-end; padding: 3px 10px; border: 1px dashed var(--dsw-alias-border-l1, currentColor); border-radius: 8px; background: transparent; }
154
+ .dct-schematic button.dct-schematic-global.selected { color: var(--dsw-alias-label-primary, inherit); border-color: var(--dsw-alias-label-primary, currentColor); }
155
+ .dct-schematic button.dct-schematic-global.has-image { border-style: solid; }
156
+ /* The mark sits on the label's line rather than its own, since the frame lays its
157
+ contents out in a row. */
158
+ .dct-schematic button.dct-schematic-global.has-image::before { margin-top: 3px; }
159
+ .dct-schematic-windowbar { grid-area: windowbar; z-index: 1; position: relative; }
160
+ .dct-schematic-sidebar { grid-area: sidebar; z-index: 1; position: relative; }
161
+ .dct-schematic-dock { grid-area: dock; z-index: 1; position: relative; }
162
+ .dct-schematic-main { grid-area: main; z-index: 1; position: relative; display: grid; grid-template-rows: minmax(0, 1fr) 26px; gap: 5px; }
128
163
  `
129
164
 
130
165
  /** Read the persisted selection; an unreadable store means no selection. */
@@ -184,9 +219,15 @@ window.__ModuleLoader__.load({
184
219
  loading: '正在加载…',
185
220
  failed: '主题加载失败',
186
221
  bgTitle: '背景图片',
187
- bgHint: '从背景目录选择图片;也可直接向该目录放入图片文件',
222
+ bgHint: '选择图片文件,或直接向背景目录放入图片',
188
223
  bgConfigured: '部分区域已设置背景',
189
224
  bgZone: '区域',
225
+ bgZonesHint: '点击区域标签或下方示意图,选中要修改的位置',
226
+ bgSchematic: '布局示意图',
227
+ bgUnset: '未设置',
228
+ bgImport: '选择图片…',
229
+ bgImporting: '上传中…',
230
+ bgImportFailed: '图片未能上传,请换一张再试',
190
231
  bgImage: '图片',
191
232
  bgNone: '无',
192
233
  bgOpacity: '图片透明度',
@@ -254,9 +295,15 @@ window.__ModuleLoader__.load({
254
295
  loading: 'Loading…',
255
296
  failed: 'Theme failed to load',
256
297
  bgTitle: 'Background image',
257
- bgHint: 'Pick an image from the background directory; drop image files in to add them',
298
+ bgHint: 'Pick a picture file, or drop one into the background directory',
258
299
  bgConfigured: 'Some zones have a background',
259
300
  bgZone: 'Zone',
301
+ bgZonesHint: 'Pick a zone from the tabs, or click the layout below',
302
+ bgSchematic: 'Layout schematic',
303
+ bgUnset: 'Not set',
304
+ bgImport: 'Choose a picture…',
305
+ bgImporting: 'Uploading…',
306
+ bgImportFailed: 'The picture could not be uploaded; try another one',
260
307
  bgImage: 'Image',
261
308
  bgNone: 'None',
262
309
  bgOpacity: 'Image opacity',
@@ -1246,6 +1293,27 @@ window.__ModuleLoader__.load({
1246
1293
  return Array.isArray(payload.backgrounds) ? payload.backgrounds.map((entry) => entry.name) : []
1247
1294
  }
1248
1295
 
1296
+ /**
1297
+ * Hand one picture the user picked to the Host, which stores it in the background
1298
+ * directory beside the images dropped in by hand.
1299
+ *
1300
+ * The bytes go up as the body and the reported name as a query parameter: the Host
1301
+ * decides the stored name from the bytes and its own directory, so a name it cannot
1302
+ * serve is folded rather than refused.
1303
+ * @param file - The file the picker produced.
1304
+ * @returns The name the Host stored it under.
1305
+ */
1306
+ async function uploadBackground(file) {
1307
+ const response = await fetch(`${BACKGROUNDS_URL}?name=${encodeURIComponent(file.name)}`, {
1308
+ method: 'POST',
1309
+ cache: 'no-store',
1310
+ body: file,
1311
+ })
1312
+ const payload = await response.json().catch(() => ({}))
1313
+ if (!response.ok) throw new Error(payload.error ?? `background upload failed: ${response.status}`)
1314
+ return payload.name
1315
+ }
1316
+
1249
1317
  /*
1250
1318
  * Update state, shared by the two places that show it: this card's own row and
1251
1319
  * the entry contributed to the plugin manager's page. The Host caches its check,
@@ -1362,15 +1430,17 @@ window.__ModuleLoader__.load({
1362
1430
  }
1363
1431
 
1364
1432
  /**
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.
1433
+ * The update controls, rendered as a row on this card and as a section on the
1434
+ * plugin manager's page for this bundle.
1435
+ * @param props - `t`; `as`, the element and class the controls sit in; and
1436
+ * `primary`, which drops the upgrade button where the page's own actions area
1437
+ * already carries it, so the same button is never drawn twice on one page.
1368
1438
  */
1369
- function UpdateRow({ t, as = 'row' }) {
1439
+ function UpdateRow({ t, as = 'row', primary = true }) {
1370
1440
  const snapshot = useUpdate()
1371
1441
  const busy = snapshot.phase === 'loading' || snapshot.phase === 'applying'
1372
1442
  const controls = [
1373
- canUpgrade(snapshot) ? h('button', {
1443
+ primary && canUpgrade(snapshot) ? h('button', {
1374
1444
  key: 'upgrade',
1375
1445
  type: 'button',
1376
1446
  className: 'dct-button dct-upgrade',
@@ -1415,10 +1485,28 @@ window.__ModuleLoader__.load({
1415
1485
  return h('span', { className: 'dct-update-badge' }, `${t('updateBadge')} ${snapshot.state.latest}`)
1416
1486
  }
1417
1487
 
1488
+ /**
1489
+ * The upgrade button in the plugin page's own actions area, beside its enable
1490
+ * switch and uninstall. Drawn only for this bundle and only when a newer release
1491
+ * exists, so every other plugin's page is left exactly as the shell built it.
1492
+ */
1493
+ function PluginUpdateAction({ subject, t }) {
1494
+ const snapshot = useUpdate()
1495
+ if (!isOwnBundle(subject)) return null
1496
+ if (!canUpgrade(snapshot)) return null
1497
+ const busy = snapshot.phase === 'loading' || snapshot.phase === 'applying'
1498
+ return h('button', {
1499
+ type: 'button',
1500
+ className: 'dct-button dct-upgrade',
1501
+ disabled: busy,
1502
+ onClick: () => { applyUpdateNow() },
1503
+ }, snapshot.phase === 'applying' ? t('updateUpgrading') : `${t('updateUpgrade')} ${snapshot.state.latest}`)
1504
+ }
1505
+
1418
1506
  /** The update section under the plugin page's own content. */
1419
1507
  function PluginUpdateSection({ subject, t }) {
1420
1508
  if (!isOwnBundle(subject)) return null
1421
- return h(UpdateRow, { t, as: 'section' })
1509
+ return h(UpdateRow, { t, as: 'section', primary: false })
1422
1510
  }
1423
1511
 
1424
1512
  function ThemeRow({ t }) {
@@ -1428,6 +1516,8 @@ window.__ModuleLoader__.load({
1428
1516
  const [images, setImages] = React.useState([])
1429
1517
  const [backgrounds, setBackgrounds] = React.useState(readSavedBackgrounds)
1430
1518
  const [zone, setZone] = React.useState('global')
1519
+ const [importing, setImporting] = React.useState(false)
1520
+ const [importFailed, setImportFailed] = React.useState(false)
1431
1521
  const [preference, setPreference] = React.useState(() => ctx.theme.getTheme().preference)
1432
1522
  const [appearance, setAppearance] = React.useState(readSavedAppearance)
1433
1523
  const [fontSize, setFontSize] = React.useState(() => ctx.theme.getTheme().fontSize)
@@ -1490,6 +1580,33 @@ window.__ModuleLoader__.load({
1490
1580
  })
1491
1581
  }, [zone])
1492
1582
 
1583
+ /** The hidden picker the import button drives. */
1584
+ const fileInput = React.useRef(null)
1585
+
1586
+ /**
1587
+ * Store a picture the user picked, then select it for the zone being edited.
1588
+ *
1589
+ * The picker is cleared before anything else so choosing the same file twice still
1590
+ * fires a change, and the listing is re-read afterwards because the Host decides
1591
+ * the stored name — a name it cannot serve comes back folded.
1592
+ */
1593
+ const onPickFile = React.useCallback(async (event) => {
1594
+ const file = event.target.files && event.target.files[0]
1595
+ event.target.value = ''
1596
+ if (!file) return
1597
+ setImporting(true)
1598
+ setImportFailed(false)
1599
+ try {
1600
+ const stored = await uploadBackground(file)
1601
+ setImages(await listBackgrounds())
1602
+ updateZone({ name: stored })
1603
+ } catch {
1604
+ setImportFailed(true)
1605
+ } finally {
1606
+ setImporting(false)
1607
+ }
1608
+ }, [updateZone])
1609
+
1493
1610
  /** Persist one conversation-stream choice, then redeclare the overrides. */
1494
1611
  const updateAppearance = React.useCallback((patch) => {
1495
1612
  setAppearance((current) => {
@@ -1512,6 +1629,22 @@ window.__ModuleLoader__.load({
1512
1629
  const configured = ZONES.filter((item) => backgrounds[item.id].name !== '').length
1513
1630
  const busy = status === 'loading'
1514
1631
  const noImage = config.name === ''
1632
+ const active = ZONES.find((item) => item.id === zone)
1633
+
1634
+ /**
1635
+ * One clickable region of the layout schematic.
1636
+ *
1637
+ * A region marks itself when its zone already carries a picture, which is the
1638
+ * question the schematic exists to answer at a glance.
1639
+ */
1640
+ const region = (id, extra) => h('button', {
1641
+ type: 'button',
1642
+ className: `${extra}${zone === id ? ' selected' : ''}${backgrounds[id].name === '' ? '' : ' has-image'}`,
1643
+ 'data-dct-pick': id,
1644
+ 'aria-pressed': zone === id,
1645
+ disabled: busy,
1646
+ onClick: () => setZone(id),
1647
+ }, h('span', null, t(ZONES.find((item) => item.id === id).labelKey)))
1515
1648
 
1516
1649
  return h('div', { className: 'dct-page' },
1517
1650
  h('h2', { className: 'dct-heading' }, t('nav')),
@@ -1603,15 +1736,56 @@ window.__ModuleLoader__.load({
1603
1736
  h('div', { className: 'dct-row dct-sub' },
1604
1737
  h('div', { className: 'dct-text' },
1605
1738
  h('div', { className: 'dct-title' }, t('bgTitle')),
1606
- h('div', { className: 'dct-hint' }, configured > 0 ? t('bgConfigured') : t('bgHint'))),
1739
+ h('div', { className: 'dct-hint' },
1740
+ importFailed ? t('bgImportFailed') : (configured > 0 ? t('bgConfigured') : t('bgHint')))),
1741
+ h('div', { className: 'dct-control dct-wrap' },
1742
+ h('input', {
1743
+ ref: fileInput,
1744
+ className: 'dct-file',
1745
+ type: 'file',
1746
+ accept: 'image/*',
1747
+ 'aria-label': t('bgImport'),
1748
+ onChange: onPickFile,
1749
+ }),
1750
+ h('button', {
1751
+ type: 'button',
1752
+ className: 'dct-button dct-import',
1753
+ disabled: busy || importing,
1754
+ onClick: () => { if (fileInput.current) fileInput.current.click() },
1755
+ }, importing ? t('bgImporting') : t('bgImport')))),
1756
+ /*
1757
+ * The tabs and the schematic are two views of one choice, so either can decide
1758
+ * which zone the controls below edit: the tabs name every zone, the schematic
1759
+ * shows where each one sits in the window. Both mark a zone that already
1760
+ * carries a picture, since that is the question the panel exists to answer.
1761
+ */
1762
+ h('div', { className: 'dct-zones', role: 'group', 'aria-label': t('bgZonesHint') },
1763
+ ZONES.map((item) => h('button', {
1764
+ key: item.id,
1765
+ type: 'button',
1766
+ className: `dct-zone-tab${zone === item.id ? ' selected' : ''}${backgrounds[item.id].name === '' ? '' : ' has-image'}`,
1767
+ 'data-dct-tab': item.id,
1768
+ 'aria-pressed': zone === item.id,
1769
+ disabled: busy,
1770
+ onClick: () => setZone(item.id),
1771
+ },
1772
+ h('strong', null, t(item.labelKey)),
1773
+ h('small', null, backgrounds[item.id].name === '' ? t('bgUnset') : backgrounds[item.id].name)))),
1774
+ h('div', { className: 'dct-schematic', role: 'group', 'aria-label': t('bgSchematic') },
1775
+ // `global` covers the whole window, so it is drawn as the frame the rest of
1776
+ // the schematic sits inside rather than as another region beside them.
1777
+ region('global', 'dct-schematic-global'),
1778
+ region('windowbar', 'dct-schematic-windowbar'),
1779
+ region('sidebar', 'dct-schematic-sidebar'),
1780
+ h('div', { className: 'dct-schematic-main' },
1781
+ region('conversation', 'dct-schematic-conversation'),
1782
+ region('composer', 'dct-schematic-composer')),
1783
+ region('dock', 'dct-schematic-dock')),
1784
+ h('div', { className: 'dct-row dct-sub' },
1785
+ h('div', { className: 'dct-text' },
1786
+ h('div', { className: 'dct-title' }, `${t('bgZone')} · ${t(active.labelKey)}`),
1787
+ h('div', { className: 'dct-hint' }, noImage ? t('bgUnset') : config.name)),
1607
1788
  h('div', { className: 'dct-control dct-wrap' },
1608
- h('select', {
1609
- className: 'dct-select dct-zone',
1610
- value: zone,
1611
- disabled: busy,
1612
- 'aria-label': t('bgZone'),
1613
- onChange: (event) => setZone(event.target.value),
1614
- }, ZONES.map((item) => h('option', { key: item.id, value: item.id }, t(item.labelKey)))),
1615
1789
  h('select', {
1616
1790
  className: 'dct-select dct-image',
1617
1791
  value: config.name,
@@ -1695,6 +1869,14 @@ window.__ModuleLoader__.load({
1695
1869
  * newer release is worth mentioning. Both entries render null for any other
1696
1870
  * subject; the page's own version and switch stay untouched.
1697
1871
  */
1872
+ ctx.slots.inject('plugins.detail.actions', () => ctx.slots.register({
1873
+ name: 'plugins.detail.actions',
1874
+ id: 'dsh-custom-theme-update',
1875
+ order: 40,
1876
+ label: () => ctx.locale.bind(LOCALE_NS)('updateTitle'),
1877
+ locale: LOCALE_NS,
1878
+ }, PluginUpdateAction))
1879
+
1698
1880
  ctx.slots.inject('plugins.detail.badge', () => ctx.slots.register({
1699
1881
  name: 'plugins.detail.badge',
1700
1882
  id: 'dsh-custom-theme-update',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-custom-theme",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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
@@ -22,12 +22,15 @@ import {
22
22
  MAX_THEME_BYTES,
23
23
  SEED_VERSION,
24
24
  backgroundContentType,
25
+ backgroundNameFrom,
25
26
  backgroundsDirectory,
26
27
  isBackgroundName,
27
28
  isBundledThemeId,
28
29
  isThemeId,
29
30
  orderThemeIds,
31
+ sniffImageMediaType,
30
32
  themesDirectory,
33
+ uniqueBackgroundName,
31
34
  } from './themes.mjs'
32
35
  import { PACKAGE_NAME, checkForUpdate, createCache } from './update.mjs'
33
36
 
@@ -215,6 +218,70 @@ async function readBackground(directory, name) {
215
218
  return bytes.length > MAX_BACKGROUND_BYTES ? undefined : bytes
216
219
  }
217
220
 
221
+ /**
222
+ * The background listing the read route and an upload both answer with.
223
+ * @param directory - Background directory.
224
+ * @returns `{ backgrounds, dir }`, one served url per image.
225
+ */
226
+ async function backgroundListing(directory) {
227
+ const names = await scanBackgrounds(directory)
228
+ return {
229
+ backgrounds: names.map((name) => ({ name, url: `${ROUTE_PATH}/background/${encodeURIComponent(name)}` })),
230
+ dir: directory,
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Store one uploaded picture: read the body under the size cap, identify it from its own
236
+ * bytes, then write it under a name no existing picture uses.
237
+ *
238
+ * The bytes are what lands in the directory, so a picture picked from the file dialog is
239
+ * the same kind of citizen as one dropped in by hand: every zone can select it, and it
240
+ * outlives the browser that uploaded it.
241
+ * @param req - Request carrying the image bytes as its body.
242
+ * @param url - Parsed request URL; its `name` parameter carries the browser file name.
243
+ * @param directory - Background directory.
244
+ * @returns `{ status, body }`; a success body carries the stored name and the new listing.
245
+ */
246
+ async function receiveBackground(req, url, directory) {
247
+ const chunks = []
248
+ let size = 0
249
+ for await (const chunk of req) {
250
+ size += chunk.length
251
+ if (size > MAX_BACKGROUND_BYTES) {
252
+ // Stop reading rather than buffer the rest of a body this large.
253
+ req.destroy()
254
+ return { status: 413, body: { error: `the picture is larger than ${MAX_BACKGROUND_BYTES} bytes` } }
255
+ }
256
+ chunks.push(chunk)
257
+ }
258
+ if (size === 0) return { status: 400, body: { error: 'the request carried no picture' } }
259
+
260
+ const bytes = Buffer.concat(chunks)
261
+ const mediaType = sniffImageMediaType(bytes)
262
+ if (mediaType === undefined) {
263
+ return { status: 415, body: { error: 'the bytes are no png, jpeg, gif, webp, avif or bmp picture' } }
264
+ }
265
+
266
+ const desired = backgroundNameFrom(url.searchParams.get('name'), mediaType)
267
+ const taken = await scanBackgrounds(directory)
268
+ let name = desired
269
+ if (taken.includes(desired)) {
270
+ // Picking the same file twice should not pile up copies, but a different picture
271
+ // that happens to share a name must not overwrite the one already there.
272
+ const existing = await readFile(join(directory, desired)).catch(() => undefined)
273
+ if (existing !== undefined && existing.equals(bytes)) {
274
+ return { status: 200, body: { name: desired, ...await backgroundListing(directory) } }
275
+ }
276
+ name = uniqueBackgroundName(desired, taken)
277
+ if (name === undefined) return { status: 409, body: { error: `no free name is left beside ${desired}` } }
278
+ }
279
+
280
+ await mkdir(directory, { recursive: true })
281
+ await writeFile(join(directory, name), bytes)
282
+ return { status: 200, body: { name, ...await backgroundListing(directory) } }
283
+ }
284
+
218
285
  /**
219
286
  * Read one theme stylesheet under the id whitelist.
220
287
  * @param directory - Theme directory.
@@ -265,6 +332,14 @@ async function handleRoute(req, res, paths) {
265
332
  send(res, 200, 'application/json; charset=utf-8', JSON.stringify(answer))
266
333
  return
267
334
  }
335
+ // A picture the user picked is posted as the raw body, so it lands in the directory
336
+ // the same way a dropped-in file does and every zone can then select it. This is a
337
+ // POST because it writes, and it carries no JSON, so it is read as bytes.
338
+ if (rest === 'backgrounds' && method === 'POST') {
339
+ const answer = await receiveBackground(req, url, paths.backgrounds)
340
+ send(res, answer.status, 'application/json; charset=utf-8', JSON.stringify(answer.body))
341
+ return
342
+ }
268
343
  if (method !== 'GET' && method !== 'HEAD') {
269
344
  send(res, 405, 'text/plain; charset=utf-8', 'method not allowed')
270
345
  return
@@ -279,9 +354,7 @@ async function handleRoute(req, res, paths) {
279
354
  return
280
355
  }
281
356
  if (rest === 'backgrounds') {
282
- const names = await scanBackgrounds(paths.backgrounds)
283
- const backgrounds = names.map((name) => ({ name, url: `${ROUTE_PATH}/background/${encodeURIComponent(name)}` }))
284
- send(res, 200, 'application/json; charset=utf-8', JSON.stringify({ backgrounds, dir: paths.backgrounds }))
357
+ send(res, 200, 'application/json; charset=utf-8', JSON.stringify(await backgroundListing(paths.backgrounds)))
285
358
  return
286
359
  }
287
360
  const theme = /^theme\/([^/]+)\.css$/u.exec(rest)
package/src/themes.mjs CHANGED
@@ -107,6 +107,92 @@ export function backgroundContentType(name) {
107
107
  return BACKGROUND_TYPES.get(name.slice(name.lastIndexOf('.')).toLowerCase()) ?? 'application/octet-stream'
108
108
  }
109
109
 
110
+ /** The extension a stored upload of each accepted media type gets. */
111
+ const EXTENSION_BY_MEDIA_TYPE = new Map([
112
+ ['image/png', '.png'],
113
+ ['image/jpeg', '.jpg'],
114
+ ['image/gif', '.gif'],
115
+ ['image/webp', '.webp'],
116
+ ['image/avif', '.avif'],
117
+ ['image/bmp', '.bmp'],
118
+ ])
119
+
120
+ /**
121
+ * The image formats this plugin accepts, each identified by its leading bytes.
122
+ *
123
+ * Both the declared content type and the file-name extension arrive from the browser
124
+ * and neither is trustworthy, so an upload is stored under the format its own bytes
125
+ * show rather than the one it claims.
126
+ */
127
+ const IMAGE_SIGNATURES = [
128
+ { mediaType: 'image/png', test: (bytes) => bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a },
129
+ { mediaType: 'image/jpeg', test: (bytes) => bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff },
130
+ { mediaType: 'image/gif', test: (bytes) => bytes.length >= 6 && bytes.subarray(0, 4).toString('latin1') === 'GIF8' },
131
+ { mediaType: 'image/webp', test: (bytes) => bytes.length >= 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP' },
132
+ { mediaType: 'image/avif', test: (bytes) => bytes.length >= 12 && bytes.subarray(4, 8).toString('latin1') === 'ftyp' && ['avif', 'avis'].includes(bytes.subarray(8, 12).toString('latin1')) },
133
+ { mediaType: 'image/bmp', test: (bytes) => bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d },
134
+ ]
135
+
136
+ /**
137
+ * Identify an uploaded image from its leading bytes.
138
+ * @param bytes - The uploaded bytes.
139
+ * @returns The media type, or `undefined` when the bytes are no format this plugin serves.
140
+ */
141
+ export function sniffImageMediaType(bytes) {
142
+ if (!Buffer.isBuffer(bytes)) return undefined
143
+ for (const signature of IMAGE_SIGNATURES) {
144
+ if (signature.test(bytes)) return signature.mediaType
145
+ }
146
+ return undefined
147
+ }
148
+
149
+ /**
150
+ * Turn the file name a browser reported into one this plugin will serve.
151
+ *
152
+ * The background directory's whitelist is ASCII, and a stored file has to pass it to be
153
+ * listed or served at all, so everything outside it is folded into dashes here rather
154
+ * than refused — an upload the user asked for should not fail over its name. The
155
+ * extension comes from the sniffed bytes, never from the request.
156
+ * @param original - File name the browser reported, possibly as a full path.
157
+ * @param mediaType - Media type accepted by {@link sniffImageMediaType}.
158
+ * @returns A name accepted by {@link isBackgroundName}, or `undefined` for an unserved type.
159
+ */
160
+ export function backgroundNameFrom(original, mediaType) {
161
+ const extension = EXTENSION_BY_MEDIA_TYPE.get(mediaType)
162
+ if (extension === undefined) return undefined
163
+ // Some platforms report a full path, so only the last segment is a name.
164
+ const base = String(original ?? '').split(/[\\/]/u).pop() ?? ''
165
+ const stem = base
166
+ .replace(/\.[^.]*$/u, '')
167
+ .replace(/[^A-Za-z0-9]+/gu, '-')
168
+ .replace(/^-+|-+$/gu, '')
169
+ .slice(0, 48)
170
+ // A name cut mid-separator would otherwise end on a dash.
171
+ .replace(/-+$/gu, '')
172
+ return `${stem === '' ? 'background' : stem}${extension}`
173
+ }
174
+
175
+ /**
176
+ * Pick a name no existing entry uses, so an upload never overwrites a picture the user
177
+ * already had in the directory.
178
+ * @param name - Desired name, already accepted by {@link isBackgroundName}.
179
+ * @param taken - Names already present in the directory.
180
+ * @returns `name`, the same stem with the first free `-<n>` suffix, or `undefined` when none is free.
181
+ */
182
+ export function uniqueBackgroundName(name, taken) {
183
+ const used = new Set(taken)
184
+ if (!used.has(name)) return name
185
+ const dot = name.lastIndexOf('.')
186
+ if (dot <= 0) return undefined
187
+ const stem = name.slice(0, dot)
188
+ const extension = name.slice(dot)
189
+ for (let index = 1; index < 1000; index += 1) {
190
+ const candidate = `${stem}-${index}${extension}`
191
+ if (!used.has(candidate)) return candidate
192
+ }
193
+ return undefined
194
+ }
195
+
110
196
  /**
111
197
  * Resolve the background directory: an explicit `backgroundsDir` config value
112
198
  * wins, then `<home>/backgrounds`. Nothing is seeded here; the directory only