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 +51 -15
- package/lib/client.js +198 -16
- package/package.json +1 -1
- package/src/index.mjs +76 -3
- package/src/themes.mjs +86 -0
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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"` —
|
|
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,
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
-
|
|
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
|
|
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
|
|
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
|
|
1366
|
-
*
|
|
1367
|
-
* @param props - `t
|
|
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' },
|
|
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
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
|
-
|
|
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
|