dsh-pighub 0.9.66 → 0.9.68

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 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
@@ -127,6 +127,13 @@ window.__ModuleLoader__.load({
127
127
  'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-text-secondary,#9aa4b2)}',
128
128
  '.pighub-gallery-cell.is-locked .pighub-gallery-name{letter-spacing:.12em;color:var(--dsw-alias-text-tertiary,#6b7280)}',
129
129
  '.pighub-gallery-stars{font-size:10px;line-height:1;color:#e8b64c;letter-spacing:.06em}',
130
+ // 主题二级菜单:收起时高度 0,展开用 max-height + opacity 过渡(用户口径 2026-11-14:要平滑动画)
131
+ '.pighub-themelist{max-height:0;opacity:0;overflow:hidden;transition:max-height .2s ease,opacity .2s ease,margin .2s ease;margin:0}',
132
+ '.pighub-themelist.is-open{max-height:240px;opacity:1;margin:4px 0}',
133
+ '.pighub-themerow{display:flex;align-items:center;justify-content:space-between;gap:6px;margin:3px 0}',
134
+ '.pighub-themedel{font-size:11px;padding:2px 8px;border-radius:999px;cursor:pointer;color:#f0b7b7;',
135
+ 'background:transparent;border:1px solid color-mix(in oklab,#7a3b3b 70%,transparent)}',
136
+ '.pighub-themedel.is-disabled{color:var(--dsw-alias-text-tertiary,#6b7280);border-color:transparent;cursor:default}',
130
137
  // 图鉴用的次级按钮(不复用 pighub-iconbtn:那是设置齿轮的专属样式,测试也按它选)
131
138
  // 图鉴按钮排在设置之后(测试按第一个图标按钮选设置),靠 order 摆到左边
132
139
  '.pighub-settings .pighub-ghostbtn{order:-1}',
@@ -737,7 +744,10 @@ window.__ModuleLoader__.load({
737
744
  const name = theme && theme.name ? String(theme.name) : 'classic';
738
745
  const fence = (theme && theme.fence) || {};
739
746
  const file = (which === 'front' ? fence.front : fence.back) || fence.image || '';
740
- if (!file) return '/pighub/pen/pen-fence.png';
747
+ /* classic 的图**不在** assets/themes/classic 里,而是在包内 assets/img,由 /pighub/pen 服务。
748
+ * 早先这里统一拼 /pighub/theme/classic/pen-fence.png → 404 → 默认主题根本没有栏杆
749
+ * (用户 2026-11-14 报「默认的栏杆也没有」)。 */
750
+ if (!file || name === 'classic') return '/pighub/pen/' + encodeURIComponent(file || 'pen-fence.png');
741
751
  return '/pighub/theme/' + encodeURIComponent(name) + '/' + encodeURIComponent(String(file));
742
752
  }
743
753
 
@@ -1552,11 +1562,29 @@ window.__ModuleLoader__.load({
1552
1562
  * 设置浮层要画选择器、猪圈的栅栏与草地要吃几何,所以在这里定义一次、全文共用。
1553
1563
  * 注意必须在使用之前——设置浮层比 poolAll 那段构造得更早,放后面会踩 const 的 TDZ。 */
1554
1564
  const activeTheme = (data && data.activeTheme) || { name: 'classic' };
1565
+ /* 主题图片**可能加载不到**(典型case:刚用 zip 导入的主题,它的图片路由要等插件重载才注册)。
1566
+ * 这时不能整层消失——用一次预加载探一下,失败就回落到包内那张 pen-fence.png(用户 2026-11-14 报「栏杆哪去了」)。 */
1567
+ const [fenceFallback, setFenceFallback] = useState(false);
1568
+ useEffect(() => {
1569
+ const probe = activeTheme.fence && activeTheme.fence.image;
1570
+ if (!probe || activeTheme.name === 'classic') { setFenceFallback(false); return; }
1571
+ let alive = true;
1572
+ const img = new Image();
1573
+ img.onload = () => { if (alive) setFenceFallback(false); };
1574
+ img.onerror = () => { if (alive) setFenceFallback(true); };
1575
+ img.src = themeFenceUrl(activeTheme, 'back');
1576
+ return () => { alive = false; };
1577
+ }, [activeTheme.name, activeTheme.fence && activeTheme.fence.image]);
1578
+ const displayTheme = fenceFallback
1579
+ ? Object.assign({}, activeTheme, { fence: Object.assign({}, activeTheme.fence, { image: '', back: '', front: '' }) })
1580
+ : activeTheme;
1555
1581
 
1556
1582
  /* 设置浮层。星级显示三档改的是 prefs,落盘在 Host 半,所以刷新/换机器后仍然生效。 */
1557
1583
  const [settingsOpen, setSettingsOpen] = useState(false);
1558
1584
  /** 图鉴:列出池子全部猪猪,未获取的压暗 + 名字打码(用户口径 2026-11-13)。 */
1559
1585
  const [galleryOpen, setGalleryOpen] = useState(false);
1586
+ /** 主题二级菜单(用户口径 2026-11-14):设置主面板只放入口,点进去才是列表 + 删除 + 导入。 */
1587
+ const [themeMenuOpen, setThemeMenuOpen] = useState(false);
1560
1588
  /*
1561
1589
  * hover 档:划过就记一个「到期时间」,面板每 400ms 重渲染一次来让胶囊自己消失。
1562
1590
  * 用「到期时间 + 轮询」而不是「每只猪一个 setTimeout」:猪的数量是运行期才知道的,
@@ -1691,17 +1719,49 @@ window.__ModuleLoader__.load({
1691
1719
  /* 猪圈主题(用户口径 2026-11-14):列出快照里的 themes(内置 classic 永远第一 + ~/.PigHub/themes 下的目录),
1692
1720
  * 点一下走 {action:'theme'},宿主落 prefs;rev 令牌带 theme,所以切换后立刻重取快照、不用重载插件。 */
1693
1721
  h('h4', null, '猪圈主题'),
1694
- h('div', { className: 'pighub-seg' },
1695
- ((data && data.themes) || [{ name: 'classic', title: 'classic(默认)' }]).map((item) =>
1696
- h('button', {
1697
- key: 'theme-' + item.name,
1698
- type: 'button',
1699
- 'aria-pressed': activeTheme.name === item.name ? 'true' : 'false',
1700
- title: item.title || item.name,
1701
- onClick: () => { request({ action: 'theme', name: item.name }).then(apply).catch(() => {}); },
1702
- }, item.title || item.name),
1703
- ),
1722
+ h('div', { className: 'row' },
1723
+ h('span', null, '当前:' + (activeTheme.title || activeTheme.name)),
1724
+ h('button', {
1725
+ type: 'button',
1726
+ className: 'pighub-ghostbtn',
1727
+ 'aria-expanded': themeMenuOpen ? 'true' : 'false',
1728
+ onClick: () => setThemeMenuOpen((open) => !open),
1729
+ }, themeMenuOpen ? '收起' : '更换…'),
1704
1730
  ),
1731
+ h('div', { className: 'pighub-themelist' + (themeMenuOpen ? ' is-open' : '') },
1732
+ ((data && data.themes) || [{ name: 'classic', title: 'classic(默认)' }]).map((item) =>
1733
+ h('div', { key: 'theme-row-' + item.name, className: 'pighub-themerow' },
1734
+ h('button', {
1735
+ type: 'button',
1736
+ className: 'pighub-ghostbtn',
1737
+ 'aria-pressed': activeTheme.name === item.name ? 'true' : 'false',
1738
+ onClick: () => {
1739
+ request({ action: 'theme', name: item.name })
1740
+ .then((payload) => {
1741
+ if (payload && payload.ok === false) { set({ phase: 'error', error: String(payload.error || 'theme_failed') }); return; }
1742
+ return load();
1743
+ })
1744
+ .catch((error) => set({ phase: 'error', error: String((error && error.message) || error) }));
1745
+ },
1746
+ }, (activeTheme.name === item.name ? '✓ ' : '') + (item.title || item.name)),
1747
+ item.name === 'classic'
1748
+ ? h('span', { className: 'pighub-themedel is-disabled' }, '内置')
1749
+ : h('button', {
1750
+ type: 'button',
1751
+ className: 'pighub-themedel',
1752
+ title: '删除这个主题(当前主题会回落 classic)',
1753
+ onClick: () => {
1754
+ request({ action: 'theme-delete', name: item.name })
1755
+ .then((payload) => {
1756
+ if (payload && payload.ok === false) { set({ phase: 'error', error: String(payload.error || 'theme_delete_failed') }); return; }
1757
+ return load();
1758
+ })
1759
+ .catch((error) => set({ phase: 'error', error: String((error && error.message) || error) }));
1760
+ },
1761
+ }, '删除'),
1762
+ ),
1763
+ ),
1764
+ ),
1705
1765
  /* 「更换主题包」:用隐藏的 <input type=file accept=.zip> 唤起系统文件选择器(Electron 里就是原生对话框),
1706
1766
  * 选中的 zip 原样 POST 到 /pighub/theme-import;宿主校验并解压后**自动切到新主题**,这里只负责刷新快照。 */
1707
1767
  h('div', { className: 'row' },
@@ -1848,7 +1908,7 @@ window.__ModuleLoader__.load({
1848
1908
  const ownedIds = {};
1849
1909
  for (const pig of (data && data.pigs) || []) ownedIds[pig.id] = pig;
1850
1910
  /* 当前主题(快照里的 activeTheme 已在宿主侧回落过 classic):养在这里,供栅栏/草地/小屋共用。 */
1851
- const theme = activeTheme;
1911
+ const theme = displayTheme;
1852
1912
  // 已获取的排前面(否则 256 格里很难找到自己的猪),未获取的保持原序
1853
1913
  const poolAll = (data && data.pool) || [];
1854
1914
  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.66';
47
+ const VERSION = '0.9.68';
47
48
  /** 桌面桌宠的精灵边长(CSS px 基准,= manifest 里 sizeRatio 的中位数)。 */
48
49
  const DESKTOP_PET_SIZE = 96;
49
50
  /** 全局尺寸系数(用户口径 2026-11-13:「整体猪猪缩小到 75% 大小」)。三层一起乘。 */
@@ -512,11 +513,10 @@ export function apply(ctx, config) {
512
513
  const prefix = jsonEntry.name.replace(/theme\.json$/i, '');
513
514
  let meta;
514
515
  try { meta = JSON.parse(jsonEntry.data.toString('utf8')); } catch { sendJson(res, 200, { ok: false, error: 'theme_json_invalid' }); return; }
515
- const themeName = String(meta && meta.name ? meta.name : prefix.replace(/\/$/, '')).trim();
516
- if (!/^[a-zA-Z0-9._-]{1,48}$/.test(themeName) || themeName === CLASSIC_THEME_NAME) {
517
- sendJson(res, 200, { ok: false, error: 'theme_name_invalid', name: themeName });
518
- return;
519
- }
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
520
  const targetDir = join(userThemesDir, themeName);
521
521
  const staging = join(userThemesDir, '.' + themeName + '.tmp-' + Date.now());
522
522
  try {
@@ -538,11 +538,65 @@ export function apply(ctx, config) {
538
538
  sendJson(res, 200, { ok: false, error: 'write_failed', detail: String((error && error.message) || error).slice(0, 120) });
539
539
  return;
540
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
+ }
541
578
  const nextPrefs2 = writePrefs(root, { theme: themeName });
542
579
  sendJson(res, 200, { ...snapshot(root), ok: true, theme: themeName, prefs: nextPrefs2 });
543
580
  return;
544
581
  }
545
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
+
546
600
  if (action === 'state') { sendJson(res, 200, snapshot(root)); return; }
547
601
 
548
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.66",
3
+ "version": "0.9.68",
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);