dsh-pighub 0.9.64 → 0.9.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/lib/client.js +61 -16
- package/lib/index.js +106 -2
- package/lib/themes.js +41 -0
- package/package.json +3 -3
- package/test/themes.test.mjs +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -102,6 +102,32 @@ T2 对 10 s 取模 271/301 ms —— 渲染与停止窗口都解释不了这种
|
|
|
102
102
|
- 连点两次 disable 无异常;enable 4 次都能拉回桌宠;自愈实测通过;
|
|
103
103
|
- 新增 `test/lifecycle.test.mjs`(4 条)→ 全量 **107 PASS / 0 FAIL**。
|
|
104
104
|
|
|
105
|
+
## [0.9.66] — 2026-11-14
|
|
106
|
+
|
|
107
|
+
### Fixed — zip 主题包导入
|
|
108
|
+
|
|
109
|
+
- 导入改走**状态路由上的 action**(`{action:'theme-import', zipBase64}`):宿主的 exact 路由按前缀先命中,
|
|
110
|
+
单独注册的 `/pighub/theme-import` 会被状态路由抢走当 JSON 解析(实测 `Unexpected token 'P'`,P 就是 zip 魔数);
|
|
111
|
+
- 状态路由 body 上限 64 KB → **24 MB**(base64 后的 zip 约 1 MB,原来会被判 `body_too_large`);
|
|
112
|
+
- 真机往返实测:738 KB zip → 解压到 `~/.PigHub/themes/zipdemo/` → 出现在主题列表 → 自动切换 → 几何生效。
|
|
113
|
+
|
|
114
|
+
## [0.9.64] — 2026-11-14
|
|
115
|
+
|
|
116
|
+
### Added — 猪圈主题包
|
|
117
|
+
|
|
118
|
+
- 主题放 `~/.PigHub/themes/<name>/`:`theme.json` 声明 `geometry{grass,inner,box}`(百分比,逐字段回落 classic)
|
|
119
|
+
与 `fence{image,split,back,front}`,可选 `grassImage` / `house`;当前外观成为内置默认主题 **classic**;
|
|
120
|
+
- 宿主:`/pighub/theme/<name>/<file>` 逐张 exact 路由、快照带 `themes[]` / `activeTheme` / `prefs.theme`、action `{action:'theme',name}`;
|
|
121
|
+
- 客户端:按主题注入几何与围栏图,**三层兜底**(宿主逐字段回落 → 客户端 `themeRect()` 再校验 → 图片缺失回落包内 `pen-fence.png`),不许白屏;
|
|
122
|
+
- 设置面板:主题选择器(立即生效)+ **「更换主题包(.zip)…」**(隐藏 `input[type=file]` 唤起系统文件选择器);
|
|
123
|
+
- zip 导入:最小 ZIP 读取器(STORED/DEFLATE,`zlib.inflateRawSync`),四道上限(500 条 / 单文件 8 MB / 解压 40 MB / 报文 24 MB),
|
|
124
|
+
先写临时目录再原子 rename;失败不动现有主题。
|
|
125
|
+
|
|
126
|
+
### Added — 启动静默自动更新
|
|
127
|
+
|
|
128
|
+
- 插件 enable 后延迟 15s、每次启动只跑一次;GET registry 的 latest,**语义化版本比较**,仅远端严格更大才后台安装;
|
|
129
|
+
- **不通知用户、不提供开关**;失败只写 `~/.PigHub/logs/auto-update.log`;不自动重启宿主;网络错误只记一条。
|
|
130
|
+
|
|
105
131
|
## [0.9.57] — 2026-11-14
|
|
106
132
|
|
|
107
133
|
> 说明:0.9.48–0.9.56 的条目缺失(CHANGELOG 一度落后于 package.json),本轮从 0.9.57 起恢复记录。
|
package/lib/client.js
CHANGED
|
@@ -1552,11 +1552,29 @@ window.__ModuleLoader__.load({
|
|
|
1552
1552
|
* 设置浮层要画选择器、猪圈的栅栏与草地要吃几何,所以在这里定义一次、全文共用。
|
|
1553
1553
|
* 注意必须在使用之前——设置浮层比 poolAll 那段构造得更早,放后面会踩 const 的 TDZ。 */
|
|
1554
1554
|
const activeTheme = (data && data.activeTheme) || { name: 'classic' };
|
|
1555
|
+
/* 主题图片**可能加载不到**(典型case:刚用 zip 导入的主题,它的图片路由要等插件重载才注册)。
|
|
1556
|
+
* 这时不能整层消失——用一次预加载探一下,失败就回落到包内那张 pen-fence.png(用户 2026-11-14 报「栏杆哪去了」)。 */
|
|
1557
|
+
const [fenceFallback, setFenceFallback] = useState(false);
|
|
1558
|
+
useEffect(() => {
|
|
1559
|
+
const probe = activeTheme.fence && activeTheme.fence.image;
|
|
1560
|
+
if (!probe || activeTheme.name === 'classic') { setFenceFallback(false); return; }
|
|
1561
|
+
let alive = true;
|
|
1562
|
+
const img = new Image();
|
|
1563
|
+
img.onload = () => { if (alive) setFenceFallback(false); };
|
|
1564
|
+
img.onerror = () => { if (alive) setFenceFallback(true); };
|
|
1565
|
+
img.src = themeFenceUrl(activeTheme, 'back');
|
|
1566
|
+
return () => { alive = false; };
|
|
1567
|
+
}, [activeTheme.name, activeTheme.fence && activeTheme.fence.image]);
|
|
1568
|
+
const displayTheme = fenceFallback
|
|
1569
|
+
? Object.assign({}, activeTheme, { fence: Object.assign({}, activeTheme.fence, { image: '', back: '', front: '' }) })
|
|
1570
|
+
: activeTheme;
|
|
1555
1571
|
|
|
1556
1572
|
/* 设置浮层。星级显示三档改的是 prefs,落盘在 Host 半,所以刷新/换机器后仍然生效。 */
|
|
1557
1573
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
1558
1574
|
/** 图鉴:列出池子全部猪猪,未获取的压暗 + 名字打码(用户口径 2026-11-13)。 */
|
|
1559
1575
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
|
1576
|
+
/** 主题二级菜单(用户口径 2026-11-14):设置主面板只放入口,点进去才是列表 + 删除 + 导入。 */
|
|
1577
|
+
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
|
1560
1578
|
/*
|
|
1561
1579
|
* hover 档:划过就记一个「到期时间」,面板每 400ms 重渲染一次来让胶囊自己消失。
|
|
1562
1580
|
* 用「到期时间 + 轮询」而不是「每只猪一个 setTimeout」:猪的数量是运行期才知道的,
|
|
@@ -1691,17 +1709,37 @@ window.__ModuleLoader__.load({
|
|
|
1691
1709
|
/* 猪圈主题(用户口径 2026-11-14):列出快照里的 themes(内置 classic 永远第一 + ~/.PigHub/themes 下的目录),
|
|
1692
1710
|
* 点一下走 {action:'theme'},宿主落 prefs;rev 令牌带 theme,所以切换后立刻重取快照、不用重载插件。 */
|
|
1693
1711
|
h('h4', null, '猪圈主题'),
|
|
1694
|
-
h('div', { className: '
|
|
1695
|
-
(
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
}, item.title || item.name),
|
|
1703
|
-
),
|
|
1712
|
+
h('div', { className: 'row' },
|
|
1713
|
+
h('span', null, '当前:' + (activeTheme.title || activeTheme.name)),
|
|
1714
|
+
h('button', {
|
|
1715
|
+
type: 'button',
|
|
1716
|
+
className: 'pighub-ghostbtn',
|
|
1717
|
+
'aria-expanded': themeMenuOpen ? 'true' : 'false',
|
|
1718
|
+
onClick: () => setThemeMenuOpen((open) => !open),
|
|
1719
|
+
}, themeMenuOpen ? '收起' : '更换…'),
|
|
1704
1720
|
),
|
|
1721
|
+
themeMenuOpen
|
|
1722
|
+
? h('div', { className: 'pighub-themelist' },
|
|
1723
|
+
((data && data.themes) || [{ name: 'classic', title: 'classic(默认)' }]).map((item) =>
|
|
1724
|
+
h('div', { key: 'theme-row-' + item.name, className: 'pighub-themerow' },
|
|
1725
|
+
h('button', {
|
|
1726
|
+
type: 'button',
|
|
1727
|
+
className: 'pighub-ghostbtn',
|
|
1728
|
+
'aria-pressed': activeTheme.name === item.name ? 'true' : 'false',
|
|
1729
|
+
onClick: () => { request({ action: 'theme', name: item.name }).then(apply).catch(() => {}); },
|
|
1730
|
+
}, (activeTheme.name === item.name ? '✓ ' : '') + (item.title || item.name)),
|
|
1731
|
+
item.name === 'classic'
|
|
1732
|
+
? h('span', { className: 'pighub-themedel is-disabled' }, '内置')
|
|
1733
|
+
: h('button', {
|
|
1734
|
+
type: 'button',
|
|
1735
|
+
className: 'pighub-themedel',
|
|
1736
|
+
title: '删除这个主题(当前主题会回落 classic)',
|
|
1737
|
+
onClick: () => { request({ action: 'theme-delete', name: item.name }).then(apply).catch(() => {}); },
|
|
1738
|
+
}, '删除'),
|
|
1739
|
+
),
|
|
1740
|
+
),
|
|
1741
|
+
)
|
|
1742
|
+
: null,
|
|
1705
1743
|
/* 「更换主题包」:用隐藏的 <input type=file accept=.zip> 唤起系统文件选择器(Electron 里就是原生对话框),
|
|
1706
1744
|
* 选中的 zip 原样 POST 到 /pighub/theme-import;宿主校验并解压后**自动切到新主题**,这里只负责刷新快照。 */
|
|
1707
1745
|
h('div', { className: 'row' },
|
|
@@ -1718,12 +1756,19 @@ window.__ModuleLoader__.load({
|
|
|
1718
1756
|
const file = input && input.files && input.files[0];
|
|
1719
1757
|
if (!file) return;
|
|
1720
1758
|
const finish = () => { if (input) input.value = ''; };
|
|
1759
|
+
// 走状态路由上的 action(不走单独的 /theme-import:宿主的 exact 路由按前缀先命中,
|
|
1760
|
+
// 二进制会被状态路由当 JSON 解析)。base64 多 33%,但一定命中。
|
|
1721
1761
|
file.arrayBuffer()
|
|
1722
|
-
.then((buffer) =>
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1762
|
+
.then((buffer) => {
|
|
1763
|
+
let binary = '';
|
|
1764
|
+
const bytes = new Uint8Array(buffer);
|
|
1765
|
+
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
|
1766
|
+
return fetch(ROUTE, {
|
|
1767
|
+
method: 'POST',
|
|
1768
|
+
headers: { 'content-type': 'application/json' },
|
|
1769
|
+
body: JSON.stringify({ action: 'theme-import', zipBase64: btoa(binary) }),
|
|
1770
|
+
});
|
|
1771
|
+
})
|
|
1727
1772
|
.then((response) => response.json())
|
|
1728
1773
|
.then((payload) => { if (payload && payload.ok) apply(payload); })
|
|
1729
1774
|
.catch(() => {})
|
|
@@ -1841,7 +1886,7 @@ window.__ModuleLoader__.load({
|
|
|
1841
1886
|
const ownedIds = {};
|
|
1842
1887
|
for (const pig of (data && data.pigs) || []) ownedIds[pig.id] = pig;
|
|
1843
1888
|
/* 当前主题(快照里的 activeTheme 已在宿主侧回落过 classic):养在这里,供栅栏/草地/小屋共用。 */
|
|
1844
|
-
const theme =
|
|
1889
|
+
const theme = displayTheme;
|
|
1845
1890
|
// 已获取的排前面(否则 256 格里很难找到自己的猪),未获取的保持原序
|
|
1846
1891
|
const poolAll = (data && data.pool) || [];
|
|
1847
1892
|
const pool = poolAll
|
package/lib/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
18
18
|
import { runAutoUpdate, makeFileLogger } from './auto-update.js';
|
|
19
19
|
import { THEME_ROUTE, CLASSIC_THEME_NAME, listThemes, readThemeDir, themeAssetPath } from './themes.js';
|
|
20
20
|
import { readZip, isSafeZipName } from './zip.js';
|
|
21
|
+
import { validateThemePack } from './themes.js';
|
|
21
22
|
import { extname } from 'node:path';
|
|
22
23
|
import { readAssetManifest, assetPool, assetSummary, assetUrlOf, IMAGE_DIR, ASSET_ROUTE, PEN_ROUTE, PEN_ART } from './assets.js';
|
|
23
24
|
import {
|
|
@@ -43,7 +44,7 @@ export const inject = ['tools'];
|
|
|
43
44
|
import { startDesktopPet, stopDesktopPet, syncDesktopPet, desktopPetState, sayOnDesktop, reclaimStaleSessions } from './desktop-pet.js';
|
|
44
45
|
|
|
45
46
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
46
|
-
const VERSION = '0.9.
|
|
47
|
+
const VERSION = '0.9.67';
|
|
47
48
|
/** 桌面桌宠的精灵边长(CSS px 基准,= manifest 里 sizeRatio 的中位数)。 */
|
|
48
49
|
const DESKTOP_PET_SIZE = 96;
|
|
49
50
|
/** 全局尺寸系数(用户口径 2026-11-13:「整体猪猪缩小到 75% 大小」)。三层一起乘。 */
|
|
@@ -490,9 +491,112 @@ export function apply(ctx, config) {
|
|
|
490
491
|
try {
|
|
491
492
|
if (req.method === 'GET') { sendJson(res, 200, snapshot(root)); return; }
|
|
492
493
|
if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method_not_allowed' }); return; }
|
|
493
|
-
|
|
494
|
+
/* 状态路由的 body 上限提到 24 MB:主题包导入是 base64(738 KB 的 zip → 约 1 MB 的报文),
|
|
495
|
+
* 默认 64 KB 会把它判成 body_too_large。这是个只监听回环的本地服务,放宽是安全的。 */
|
|
496
|
+
const body = await readBody(req, 24 * 1024 * 1024);
|
|
494
497
|
const action = typeof body.action === 'string' ? body.action : 'state';
|
|
495
498
|
|
|
499
|
+
/* 主题包导入走**状态路由上的 action**(用户口径 2026-11-14)。
|
|
500
|
+
* 为什么不另开 /pighub/theme-import:宿主的 exact 路由按前缀先命中,二进制 zip 会被
|
|
501
|
+
* 这条状态路由抢走当 JSON 解析(实测报 "Unexpected token 'P'",P 就是 zip 魔数 PK)。
|
|
502
|
+
* base64 多 33% 传输,换来路由一定命中。 */
|
|
503
|
+
if (action === 'theme-import') {
|
|
504
|
+
const b64 = typeof body.zipBase64 === 'string' ? body.zipBase64 : '';
|
|
505
|
+
if (!b64) { sendJson(res, 200, { ok: false, error: 'empty_body' }); return; }
|
|
506
|
+
let raw = null;
|
|
507
|
+
try { raw = Buffer.from(b64, 'base64'); } catch { raw = null; }
|
|
508
|
+
if (!raw || !raw.length) { sendJson(res, 200, { ok: false, error: 'bad_base64' }); return; }
|
|
509
|
+
const parsed = readZip(raw);
|
|
510
|
+
if (!parsed.ok) { sendJson(res, 200, { ok: false, error: parsed.reason }); return; }
|
|
511
|
+
const jsonEntry = parsed.files.find((file) => /(^|\/)theme\.json$/i.test(file.name));
|
|
512
|
+
if (!jsonEntry) { sendJson(res, 200, { ok: false, error: 'theme_json_missing' }); return; }
|
|
513
|
+
const prefix = jsonEntry.name.replace(/theme\.json$/i, '');
|
|
514
|
+
let meta;
|
|
515
|
+
try { meta = JSON.parse(jsonEntry.data.toString('utf8')); } catch { sendJson(res, 200, { ok: false, error: 'theme_json_invalid' }); return; }
|
|
516
|
+
// 准入闸门(用户口径 2026-11-14:不合法主题要拒绝):非法就明确拒绝,不许静默回落当成功
|
|
517
|
+
const verdict = validateThemePack(meta, parsed.files, prefix);
|
|
518
|
+
if (!verdict.ok) { sendJson(res, 200, { ok: false, error: verdict.reason }); return; }
|
|
519
|
+
const themeName = verdict.name;
|
|
520
|
+
const targetDir = join(userThemesDir, themeName);
|
|
521
|
+
const staging = join(userThemesDir, '.' + themeName + '.tmp-' + Date.now());
|
|
522
|
+
try {
|
|
523
|
+
rmSync(staging, { recursive: true, force: true });
|
|
524
|
+
mkdirSync(staging, { recursive: true });
|
|
525
|
+
for (const file of parsed.files) {
|
|
526
|
+
const rel = file.name.startsWith(prefix) ? file.name.slice(prefix.length) : file.name;
|
|
527
|
+
if (!rel || rel.endsWith('/')) continue;
|
|
528
|
+
if (!/\.(json|png|jpg|jpeg|webp|svg)$/i.test(rel)) continue;
|
|
529
|
+
if (!isSafeZipName(rel)) { sendJson(res, 200, { ok: false, error: 'unsafe_entry_name', name: file.name }); return; }
|
|
530
|
+
const target = join(staging, rel);
|
|
531
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
532
|
+
writeFileSync(target, file.data);
|
|
533
|
+
}
|
|
534
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
535
|
+
renameSync(staging, targetDir);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
try { rmSync(staging, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
538
|
+
sendJson(res, 200, { ok: false, error: 'write_failed', detail: String((error && error.message) || error).slice(0, 120) });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
/* **动态注册**这个主题的图片路由(用户口径 2026-11-14:「栏杆不再消失」):
|
|
542
|
+
* 启动时只扫过一次目录,导入进来的主题如果没有自己的路由,它的围栏图就会 404,
|
|
543
|
+
* 客户端只能回落到 classic 围栏。这里把刚写下去的每张图当场注册成 exact 路由。 */
|
|
544
|
+
if (webServer) {
|
|
545
|
+
for (const file2 of parsed.files) {
|
|
546
|
+
const rel2 = file2.name.startsWith(prefix) ? file2.name.slice(prefix.length) : file2.name;
|
|
547
|
+
if (!rel2 || rel2.endsWith('/')) continue;
|
|
548
|
+
if (!/\.(png|jpg|jpeg|webp|svg)$/i.test(rel2)) continue;
|
|
549
|
+
const routePath = THEME_ROUTE + '/' + themeName + '/' + rel2;
|
|
550
|
+
ctx.effect(
|
|
551
|
+
() =>
|
|
552
|
+
webServer.register({
|
|
553
|
+
kind: 'exact',
|
|
554
|
+
path: routePath,
|
|
555
|
+
handler: async (req, res) => {
|
|
556
|
+
try {
|
|
557
|
+
if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method_not_allowed' }); return; }
|
|
558
|
+
const abs = themeAssetPath({ name: themeName, file: rel2, builtinDir: join(themeRoot, CLASSIC_THEME_NAME), userThemesDir });
|
|
559
|
+
if (!abs) { sendJson(res, 404, { ok: false, error: 'theme_asset_not_found' }); return; }
|
|
560
|
+
const bin = readFileSync(abs);
|
|
561
|
+
res.writeHead(200, {
|
|
562
|
+
'content-type': themeMime(rel2),
|
|
563
|
+
'content-length': String(bin.length),
|
|
564
|
+
'cache-control': 'private, no-cache, must-revalidate',
|
|
565
|
+
'x-content-type-options': 'nosniff',
|
|
566
|
+
});
|
|
567
|
+
res.end(bin);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
const message = error && error.message ? String(error.message) : 'internal_error';
|
|
570
|
+
try { sendJson(res, 500, { ok: false, error: message }); } catch { /* 已结束 */ }
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
}),
|
|
574
|
+
'pighub-theme-' + themeName + '-' + rel2,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
const nextPrefs2 = writePrefs(root, { theme: themeName });
|
|
579
|
+
sendJson(res, 200, { ...snapshot(root), ok: true, theme: themeName, prefs: nextPrefs2 });
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* 删除主题(classic 不可删;删的是当前主题就回落 classic)。 */
|
|
584
|
+
if (action === 'theme-delete') {
|
|
585
|
+
const wanted = String(body.name || '').trim();
|
|
586
|
+
if (!/^[a-zA-Z0-9._-]{1,48}$/.test(wanted)) { sendJson(res, 200, { ok: false, error: 'theme_name_invalid' }); return; }
|
|
587
|
+
if (wanted === CLASSIC_THEME_NAME) { sendJson(res, 200, { ok: false, error: 'classic_is_builtin' }); return; }
|
|
588
|
+
const dir = join(userThemesDir, wanted);
|
|
589
|
+
if (!existsSync(dir)) { sendJson(res, 200, { ok: false, error: 'theme_not_found', name: wanted }); return; }
|
|
590
|
+
try { rmSync(dir, { recursive: true, force: true }); } catch (error) {
|
|
591
|
+
sendJson(res, 200, { ok: false, error: 'delete_failed', detail: String((error && error.message) || error).slice(0, 120) });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const wasActive = (readPrefs(root).theme || CLASSIC_THEME_NAME) === wanted;
|
|
595
|
+
const nextPrefs3 = wasActive ? writePrefs(root, { theme: CLASSIC_THEME_NAME }) : readPrefs(root);
|
|
596
|
+
sendJson(res, 200, { ...snapshot(root), ok: true, deleted: wanted, prefs: nextPrefs3 });
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
496
600
|
if (action === 'state') { sendJson(res, 200, snapshot(root)); return; }
|
|
497
601
|
|
|
498
602
|
/*
|
package/lib/themes.js
CHANGED
|
@@ -116,3 +116,44 @@ export function themeAssetPath({ name, file, builtinDir, userThemesDir }) {
|
|
|
116
116
|
const builtin = join(builtinDir || '', String(file));
|
|
117
117
|
return existsSync(builtin) ? builtin : null;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 严格校验一个**待导入**的主题包(用户口径 2026-11-14:「不合法主题要拒绝」)。
|
|
122
|
+
* 与 normaliseTheme 的区别:normaliseTheme 是给**已存在**的主题做逐字段兜底(宁可显示 classic 也不崩);
|
|
123
|
+
* 这里是**准入闸门**——不合规就直接拒绝并说明原因,绝不悄悄当成成功。
|
|
124
|
+
*/
|
|
125
|
+
export function validateThemePack(meta, files, prefix) {
|
|
126
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return { ok: false, reason: 'theme_json_not_object' };
|
|
127
|
+
const name = String(meta.name || prefix.replace(/\/$/, '')).trim();
|
|
128
|
+
if (!/^[a-zA-Z0-9._-]{1,48}$/.test(name)) return { ok: false, reason: 'theme_name_invalid:' + name };
|
|
129
|
+
if (name === CLASSIC_THEME_NAME) return { ok: false, reason: 'theme_name_reserved:classic' };
|
|
130
|
+
const rel = files.map((f) => (f.name.startsWith(prefix) ? f.name.slice(prefix.length) : f.name)).filter((n) => n && !n.endsWith('/'));
|
|
131
|
+
const hasImage = rel.some((n) => /\.(png|jpg|jpeg|webp|svg)$/i.test(n));
|
|
132
|
+
const geo = meta.geometry;
|
|
133
|
+
let hasGeometry = false;
|
|
134
|
+
if (geo !== undefined) {
|
|
135
|
+
if (!geo || typeof geo !== 'object' || Array.isArray(geo)) return { ok: false, reason: 'geometry_not_object' };
|
|
136
|
+
for (const key of ['grass', 'inner', 'box']) {
|
|
137
|
+
const value = geo[key];
|
|
138
|
+
if (value === undefined) continue;
|
|
139
|
+
if (!Array.isArray(value) || value.length !== 4 || value.some((n) => !Number.isFinite(Number(n)))) {
|
|
140
|
+
return { ok: false, reason: 'geometry_invalid:' + key };
|
|
141
|
+
}
|
|
142
|
+
const [l, t, r, b] = value.map(Number);
|
|
143
|
+
if (!(l >= 0 && t >= 0 && r <= 100 && b <= 100)) return { ok: false, reason: 'geometry_out_of_range:' + key };
|
|
144
|
+
if (!(l < r) || !(t < b)) return { ok: false, reason: 'geometry_inverted:' + key };
|
|
145
|
+
hasGeometry = true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (!hasImage && !hasGeometry) return { ok: false, reason: 'theme_has_nothing:需要至少一张图片或一段合法 geometry' };
|
|
149
|
+
const fence = meta.fence;
|
|
150
|
+
if (fence && typeof fence === 'object' && fence.image) {
|
|
151
|
+
const img = String(fence.image);
|
|
152
|
+
if (!/\.(png|jpg|jpeg|webp|svg)$/i.test(img)) return { ok: false, reason: 'fence_image_not_image:' + img };
|
|
153
|
+
if (rel.indexOf(img) < 0) return { ok: false, reason: 'fence_image_missing:' + img };
|
|
154
|
+
}
|
|
155
|
+
for (const one of rel) {
|
|
156
|
+
if (!/\.(json|png|jpg|jpeg|webp|svg)$/i.test(one)) return { ok: false, reason: 'unexpected_file:' + one };
|
|
157
|
+
}
|
|
158
|
+
return { ok: true, reason: '', name };
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-pighub",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.67",
|
|
4
4
|
"description": "猪猪库 PigHub 的 DeepSeek Harness 插件:侧边栏猪圈面板、每日一只猪猪、重复获得升星、可拖出的置顶桌宠,数据落在 ~/.PigHub。不内嵌任何上游图片。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
"node": ">=22.12.0"
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
|
-
"test": "node test/smoke.mjs && node test/store.test.mjs && node test/host.test.mjs && node test/imaging.test.mjs && node test/client.test.mjs && node test/lifecycle.test.mjs && node test/auto-update.test.mjs",
|
|
52
|
+
"test": "node test/smoke.mjs && node test/store.test.mjs && node test/host.test.mjs && node test/imaging.test.mjs && node test/client.test.mjs && node test/lifecycle.test.mjs && node test/auto-update.test.mjs && node test/themes.test.mjs",
|
|
53
53
|
"test:client": "node test/client.test.mjs",
|
|
54
54
|
"assets:build": "node scripts/build-assets.mjs",
|
|
55
55
|
"assets:adopt": "node scripts/adopt-cards.mjs",
|
|
56
56
|
"cards:make": "node scripts/make-cards.mjs",
|
|
57
57
|
"cards:report": "node scripts/cards-report.mjs",
|
|
58
58
|
"cards:select": "node scripts/cards-selection.mjs",
|
|
59
|
-
"prepublishOnly": "node test/smoke.mjs && node test/store.test.mjs && node test/host.test.mjs && node test/imaging.test.mjs && node test/client.test.mjs && node test/lifecycle.test.mjs && node test/auto-update.test.mjs"
|
|
59
|
+
"prepublishOnly": "node test/smoke.mjs && node test/store.test.mjs && node test/host.test.mjs && node test/imaging.test.mjs && node test/client.test.mjs && node test/lifecycle.test.mjs && node test/auto-update.test.mjs && node test/themes.test.mjs"
|
|
60
60
|
},
|
|
61
61
|
"license": "MIT",
|
|
62
62
|
"author": {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** 主题包准入闸门 + 归一化的离线用例(用户口径 2026-11-14:不合法主题要拒绝)。 */
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { validateThemePack, normaliseTheme, CLASSIC_GEOMETRY } from '../lib/themes.js';
|
|
5
|
+
|
|
6
|
+
let pass = 0, fail = 0;
|
|
7
|
+
const check = (name, fn) => { try { fn(); pass++; console.log('PASS ' + name); } catch (e) { fail++; console.log('FAIL ' + name + ' :: ' + (e && e.message)); } };
|
|
8
|
+
const rejects = (meta, files, prefix, expect) => {
|
|
9
|
+
const r = validateThemePack(meta, files, prefix || '');
|
|
10
|
+
assert.equal(r.ok, false, '应当拒绝:' + JSON.stringify(meta));
|
|
11
|
+
assert.ok(r.reason.indexOf(expect) === 0, '原因应为 ' + expect + ',实际 ' + r.reason);
|
|
12
|
+
};
|
|
13
|
+
const img = [{ name: 'fence.png', data: Buffer.alloc(1) }];
|
|
14
|
+
const meta = [{ name: 'theme.json', data: Buffer.alloc(1) }];
|
|
15
|
+
|
|
16
|
+
check('主题名非法 → 拒绝', () => rejects({ name: '../x' }, img.concat(meta), '', 'theme_name_invalid'));
|
|
17
|
+
check('主题名保留字 classic → 拒绝', () => rejects({ name: 'classic' }, img.concat(meta), '', 'theme_name_reserved'));
|
|
18
|
+
check('geometry 非 4 数字 → 拒绝', () => rejects({ name: 'a', geometry: { grass: [1, 2, 3] } }, img.concat(meta), '', 'geometry_invalid'));
|
|
19
|
+
check('geometry left≥right → 拒绝', () => rejects({ name: 'a', geometry: { grass: [80, 20, 10, 90] } }, img.concat(meta), '', 'geometry_inverted'));
|
|
20
|
+
check('geometry 越界 → 拒绝', () => rejects({ name: 'a', geometry: { grass: [-5, 20, 80, 90] } }, img.concat(meta), '', 'geometry_out_of_range'));
|
|
21
|
+
check('既无图片又无 geometry → 拒绝', () => rejects({ name: 'a' }, meta, '', 'theme_has_nothing'));
|
|
22
|
+
check('fence.image 不在包里 → 拒绝', () => rejects({ name: 'a', fence: { image: 'nope.png' } }, img.concat(meta), '', 'fence_image_missing'));
|
|
23
|
+
check('包里混入非 json/图片 → 拒绝', () => rejects({ name: 'a' }, img.concat(meta, [{ name: 'run.exe', data: Buffer.alloc(1) }]), '', 'unexpected_file'));
|
|
24
|
+
check('合法包 → 通过且名字取自 theme.json', () => {
|
|
25
|
+
const r = validateThemePack({ name: 'ok', geometry: { grass: [10, 20, 90, 95] } }, img.concat(meta), '');
|
|
26
|
+
assert.equal(r.ok, true); assert.equal(r.name, 'ok');
|
|
27
|
+
});
|
|
28
|
+
check('normaliseTheme 对坏 geometry 逐字段回落 classic', () => {
|
|
29
|
+
const t2 = normaliseTheme({ name: 'x', geometry: { grass: 'bad', inner: [1, 2, 3, 4] } }, 'x');
|
|
30
|
+
assert.deepEqual(t2.geometry.grass, CLASSIC_GEOMETRY.grass);
|
|
31
|
+
assert.deepEqual(t2.geometry.inner, [1, 2, 3, 4]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
console.log('\n' + pass + ' PASS / ' + fail + ' FAIL — themes');
|
|
35
|
+
process.exit(fail === 0 ? 0 : 1);
|