dsh-pighub 0.9.61 → 0.9.66

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
@@ -285,6 +285,15 @@ sha256 `30b82f48…` 与用户给的文件逐字节相同),整幅由 `GET /p
285
285
  - **草地**:只铺在给定红框里(`8.37% / 91.47% × 18.26% / 96.65%`),红框外**不铺草**;猪圈自己 `background:transparent`、
286
286
  没有描边、没有铺满全圈的叠层,所以草地与围栏之外的区域 alpha=0,直接露宿主页面底色(亮/暗两种底色都实测过)。
287
287
  - 猪圈里其余的东西(仓库小屋、踩踏土、草簇)保持原样,只是被红框裁住。逐项读数与三次真机截图见 `archive/pen-fence-art.md`。
288
+ ## 猪圈主题包
289
+
290
+ 猪圈的草地与围栏(远侧/近侧两层)可以整体换掉,默认外观是内置主题 `classic`。
291
+
292
+ 主题放 `~/.PigHub/themes/<name>/`,一个目录一个主题:`theme.json`(`geometry{grass,inner,box}` 百分比 + `fence{image,split,back,front}`,缺项逐字段回落 classic)+ 可选的 `fence-back.png` / `fence-front.png` / `grass.png` / `house.png`。
293
+
294
+ 三种换法:设置里选(立即生效)、把目录丢进 `~/.PigHub/themes/` 后重载、或用设置里的 **「更换主题包(.zip)…」** 选一个 zip 导入(校验失败会给出可读原因且不动现有主题)。
295
+
296
+ 格式细节见 `archive/pen-themes.md`。
288
297
  ## 与其他桌宠插件共存
289
298
 
290
299
  插件市场上的桌宠(例如 [dsh-pet](https://www.npmjs.com/package/dsh-pet))都是页面级浮层,所以按同一套既有约定来:容器 `pointer-events: none`、自有 z-index 层、样式标签与类名全带 `dsh-pighub` / `.pighub-` 前缀、尊重 `prefers-reduced-motion`。
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 静默自动更新(用户口径 2026-11-14):
3
+ * 「启动自动静默检查 npm 最新并自主静默更新到最新版本,不通知用户,不提供自动更新开关。」
4
+ *
5
+ * 三条硬约束:
6
+ * 1. **不打扰**:没有任何 UI、没有开关;失败只写日志。
7
+ * 2. **不阻塞**:调用方延迟触发、本模块内部全部 try/catch,永远 resolve。
8
+ * 3. **不误判**:版本比较必须是语义化的(0.9.9 < 0.9.10),不能拿字符串比。
9
+ *
10
+ * 这个模块刻意做成「依赖注入」形状:fetch / exec / 日志写入都可以被测试替换,
11
+ * 所以三条分支(有新版 / 已最新 / 网络失败)能在离线测试里全部覆盖。
12
+ */
13
+ import { appendFileSync, mkdirSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { execFile as execFileCallback } from 'node:child_process';
16
+
17
+ export const PACKAGE_NAME = 'dsh-pighub';
18
+ export const REGISTRY_LATEST_URL = 'https://registry.npmjs.org/' + PACKAGE_NAME + '/latest';
19
+ export const INSTALL_TIMEOUT_MS = 180 * 1000;
20
+
21
+ /** 语义化版本比较:返回 -1 / 0 / 1。只比较数字段,忽略预发布后缀。 */
22
+ export function compareSemver(a, b) {
23
+ const parse = (value) => String(value || '')
24
+ .trim()
25
+ .replace(/^v/i, '')
26
+ .split('-')[0]
27
+ .split('.')
28
+ .map((part) => {
29
+ const n = Number.parseInt(part, 10);
30
+ return Number.isFinite(n) ? n : 0;
31
+ });
32
+ const left = parse(a);
33
+ const right = parse(b);
34
+ const len = Math.max(left.length, right.length, 3);
35
+ for (let index = 0; index < len; index += 1) {
36
+ const x = left[index] || 0;
37
+ const y = right[index] || 0;
38
+ if (x > y) return 1;
39
+ if (x < y) return -1;
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ /** 往 ~/.PigHub/logs/auto-update.log 追加一行;任何失败都吞掉(日志不能反过来搞坏功能)。 */
45
+ export function makeFileLogger(rootDir) {
46
+ const dir = join(rootDir, 'logs');
47
+ const file = join(dir, 'auto-update.log');
48
+ return (line) => {
49
+ try {
50
+ mkdirSync(dir, { recursive: true });
51
+ appendFileSync(file, new Date().toISOString() + ' ' + line + '\n', 'utf8');
52
+ } catch { /* ignore */ }
53
+ return file;
54
+ };
55
+ }
56
+
57
+ /** 读 registry 上的 latest 版本号;失败返回 null(调用方据此判「离线」)。 */
58
+ export async function fetchLatestVersion(fetchImpl) {
59
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
60
+ if (!doFetch) return null;
61
+ try {
62
+ const res = await doFetch(REGISTRY_LATEST_URL, { headers: { accept: 'application/json' } });
63
+ if (!res || !res.ok) return null;
64
+ const body = await res.json();
65
+ const version = body && typeof body.version === 'string' ? body.version.trim() : '';
66
+ return version || null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /** 在 profile 目录里装指定版本;用 npm(pnpm 的 profile 目录结构里 npm 也能装到 node_modules)。 */
73
+ function runInstall({ version, profileDir, execFileImpl }) {
74
+ const exec = execFileImpl || execFileCallback;
75
+ return new Promise((resolve) => {
76
+ try {
77
+ const child = exec(
78
+ process.platform === 'win32' ? 'npm.cmd' : 'npm',
79
+ ['install', PACKAGE_NAME + '@' + version, '--no-audit', '--no-fund', '--loglevel=error'],
80
+ { cwd: profileDir, timeout: INSTALL_TIMEOUT_MS, windowsHide: true, stdio: 'ignore' },
81
+ (error) => resolve({ ok: !error, error: error ? String(error.message || error).slice(0, 200) : '' }),
82
+ );
83
+ if (child && typeof child.unref === 'function') child.unref();
84
+ } catch (error) {
85
+ resolve({ ok: false, error: String((error && error.message) || error).slice(0, 200) });
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * 跑一次检查 + 可能的升级。永远 resolve,永远不抛。
92
+ * 返回 { status: 'current' | 'installed' | 'offline' | 'failed', ... } 供日志与测试断言。
93
+ */
94
+ export async function runAutoUpdate(options = {}) {
95
+ const currentVersion = String(options.currentVersion || '0.0.0');
96
+ const profileDir = String(options.profileDir || '');
97
+ const writeLog = typeof options.writeLog === 'function' ? options.writeLog : () => '';
98
+ const fetchImpl = options.fetchImpl;
99
+ const execFileImpl = options.execFileImpl;
100
+
101
+ const latest = await fetchLatestVersion(fetchImpl);
102
+ if (!latest) {
103
+ writeLog('check offline_or_failed current=' + currentVersion);
104
+ return { status: 'offline', currentVersion, latest: null };
105
+ }
106
+
107
+ const compare = compareSemver(latest, currentVersion);
108
+ if (compare <= 0) {
109
+ writeLog('check up_to_date current=' + currentVersion + ' latest=' + latest);
110
+ return { status: 'current', currentVersion, latest };
111
+ }
112
+
113
+ if (!profileDir) {
114
+ writeLog('skip install: no profile dir (current=' + currentVersion + ' latest=' + latest + ')');
115
+ return { status: 'failed', currentVersion, latest, reason: 'no_profile_dir' };
116
+ }
117
+
118
+ writeLog('install start current=' + currentVersion + ' -> ' + latest + ' profile=' + profileDir);
119
+ const result = await runInstall({ version: latest, profileDir, execFileImpl });
120
+ if (result.ok) {
121
+ writeLog('install ok latest=' + latest);
122
+ return { status: 'installed', currentVersion, latest };
123
+ }
124
+ writeLog('install failed latest=' + latest + ' error=' + result.error);
125
+ return { status: 'failed', currentVersion, latest, reason: result.error };
126
+ }
package/lib/client.js CHANGED
@@ -722,10 +722,33 @@ window.__ModuleLoader__.load({
722
722
  return Math.round(value * 100) / 100;
723
723
  }
724
724
 
725
+ /* ---------------- 主题包(用户口径 2026-11-14) ----------------
726
+ * 快照里的 activeTheme 已经在宿主侧逐字段回落过 classic,所以这里只做「取到就用、取不到就用常量」。
727
+ * geometry 存的是 [left, top, right, bottom](百分比),客户端内部用的是 {x0,x1,y0,y1}。 */
728
+ const CLASSIC_THEME_FALLBACK = { name: 'classic' };
729
+ function themeRect(list, fallback) {
730
+ if (!Array.isArray(list) || list.length !== 4) return fallback;
731
+ const [left, top, right, bottom] = list.map((n) => Number(n));
732
+ if (![left, top, right, bottom].every((n) => Number.isFinite(n)) || !(left < right) || !(top < bottom)) return fallback;
733
+ return { x0: left, x1: right, y0: top, y1: bottom };
734
+ }
735
+ /** 主题里某一张图的 URL;没有就用包内那张默认布景(观感与主题化之前完全一致)。 */
736
+ function themeFenceUrl(theme, which) {
737
+ const name = theme && theme.name ? String(theme.name) : 'classic';
738
+ const fence = (theme && theme.fence) || {};
739
+ const file = (which === 'front' ? fence.front : fence.back) || fence.image || '';
740
+ if (!file) return '/pighub/pen/pen-fence.png';
741
+ return '/pighub/theme/' + encodeURIComponent(name) + '/' + encodeURIComponent(String(file));
742
+ }
743
+
725
744
  /** 一层栅栏:裁剪盒只决定「看得到哪一半」,盒里的 shot 永远是整张图、贴在猪圈的位置上。 */
726
745
  function PenFence(props) {
727
746
  const front = !!(props && props.front);
728
- const split = penRound(PEN_FENCE_SPLIT);
747
+ const theme = (props && props.theme) || CLASSIC_THEME_FALLBACK;
748
+ const splitRatio = theme.fence && Number(theme.fence.split);
749
+ const split = penRound(Number.isFinite(splitRatio) && splitRatio > 0.05 && splitRatio < 0.95
750
+ ? splitRatio * 100
751
+ : PEN_FENCE_SPLIT);
729
752
  return h('div', {
730
753
  className: 'pighub-fence ' + (front ? 'is-front' : 'is-back'),
731
754
  style: front ? { top: split + '%', bottom: 0 } : { top: 0, height: split + '%' },
@@ -733,7 +756,7 @@ window.__ModuleLoader__.load({
733
756
  },
734
757
  h('div', {
735
758
  className: 'pighub-fence-shot',
736
- style: { backgroundImage: 'url("' + PEN_FENCE_URL + '")' },
759
+ style: { backgroundImage: 'url("' + themeFenceUrl(theme, front ? 'front' : 'back') + '")' },
737
760
  }),
738
761
  );
739
762
  }
@@ -772,9 +795,11 @@ window.__ModuleLoader__.load({
772
795
  return blade(-0.45, 1.5, 0.45) + ' ' + blade(0.05, 2.05, 0.9) + ' ' + blade(0.55, 1.25, 0.3);
773
796
  }
774
797
 
775
- function Meadow() {
776
- const G = PEN_GRASS;
777
- const I = PEN_INNER;
798
+ function Meadow(props) {
799
+ const theme = (props && props.theme) || CLASSIC_THEME_FALLBACK;
800
+ const geo = theme.geometry || {};
801
+ const G = themeRect(geo.grass, PEN_GRASS);
802
+ const I = themeRect(geo.inner, PEN_INNER);
778
803
  const gw = G.x1 - G.x0;
779
804
  const gh = G.y1 - G.y0;
780
805
  /* SVG 的坐标系仍然是「猪圈 0..100」(草簇/波浪的坐标不用改),
@@ -1523,6 +1548,10 @@ window.__ModuleLoader__.load({
1523
1548
  setTimeout(() => setSquished((list) => list.filter((item) => item !== id)), 600);
1524
1549
  };
1525
1550
  const starMode = (data && data.prefs && data.prefs.starDisplay) || 'hover';
1551
+ /* 当前主题(快照里的 activeTheme 已在宿主侧回落过 classic):
1552
+ * 设置浮层要画选择器、猪圈的栅栏与草地要吃几何,所以在这里定义一次、全文共用。
1553
+ * 注意必须在使用之前——设置浮层比 poolAll 那段构造得更早,放后面会踩 const 的 TDZ。 */
1554
+ const activeTheme = (data && data.activeTheme) || { name: 'classic' };
1526
1555
 
1527
1556
  /* 设置浮层。星级显示三档改的是 prefs,落盘在 Host 半,所以刷新/换机器后仍然生效。 */
1528
1557
  const [settingsOpen, setSettingsOpen] = useState(false);
@@ -1659,6 +1688,56 @@ window.__ModuleLoader__.load({
1659
1688
  ),
1660
1689
  ),
1661
1690
  h('div', { className: 'row' }, h('span', null, '默认:划过猪猪显示 5 秒;常驻:一直显示在它上方')),
1691
+ /* 猪圈主题(用户口径 2026-11-14):列出快照里的 themes(内置 classic 永远第一 + ~/.PigHub/themes 下的目录),
1692
+ * 点一下走 {action:'theme'},宿主落 prefs;rev 令牌带 theme,所以切换后立刻重取快照、不用重载插件。 */
1693
+ 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
+ ),
1704
+ ),
1705
+ /* 「更换主题包」:用隐藏的 <input type=file accept=.zip> 唤起系统文件选择器(Electron 里就是原生对话框),
1706
+ * 选中的 zip 原样 POST 到 /pighub/theme-import;宿主校验并解压后**自动切到新主题**,这里只负责刷新快照。 */
1707
+ h('div', { className: 'row' },
1708
+ h('span', null, '主题包放在 ' + ((data && data.root) || '~/.PigHub') + '\\themes 下,或从 zip 导入'),
1709
+ ),
1710
+ h('label', { className: 'pighub-ghostbtn', style: { display: 'inline-block', marginTop: '6px', cursor: 'pointer' } },
1711
+ '更换主题包(.zip)…',
1712
+ h('input', {
1713
+ type: 'file',
1714
+ accept: '.zip,application/zip',
1715
+ style: { display: 'none' },
1716
+ onChange: (event) => {
1717
+ const input = event && event.target;
1718
+ const file = input && input.files && input.files[0];
1719
+ if (!file) return;
1720
+ const finish = () => { if (input) input.value = ''; };
1721
+ // 走状态路由上的 action(不走单独的 /theme-import:宿主的 exact 路由按前缀先命中,
1722
+ // 二进制会被状态路由当 JSON 解析)。base64 多 33%,但一定命中。
1723
+ file.arrayBuffer()
1724
+ .then((buffer) => {
1725
+ let binary = '';
1726
+ const bytes = new Uint8Array(buffer);
1727
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
1728
+ return fetch(ROUTE, {
1729
+ method: 'POST',
1730
+ headers: { 'content-type': 'application/json' },
1731
+ body: JSON.stringify({ action: 'theme-import', zipBase64: btoa(binary) }),
1732
+ });
1733
+ })
1734
+ .then((response) => response.json())
1735
+ .then((payload) => { if (payload && payload.ok) apply(payload); })
1736
+ .catch(() => {})
1737
+ .then(finish);
1738
+ },
1739
+ }),
1740
+ ),
1662
1741
  // 客户端版本号:面板里能看到,用来确认浏览器半真的换上了新 bundle。
1663
1742
  h('div', { className: 'row' }, h('span', null, '插件版本:v' + ((data && data.version) || '?'))),
1664
1743
  h('h4', null, '素材库(本版本冻结)'),
@@ -1768,6 +1847,8 @@ window.__ModuleLoader__.load({
1768
1847
  /* 图鉴:池子里每一只都列出来;没拿到的是暗色剪影 + 「???」。 */
1769
1848
  const ownedIds = {};
1770
1849
  for (const pig of (data && data.pigs) || []) ownedIds[pig.id] = pig;
1850
+ /* 当前主题(快照里的 activeTheme 已在宿主侧回落过 classic):养在这里,供栅栏/草地/小屋共用。 */
1851
+ const theme = activeTheme;
1771
1852
  // 已获取的排前面(否则 256 格里很难找到自己的猪),未获取的保持原序
1772
1853
  const poolAll = (data && data.pool) || [];
1773
1854
  const pool = poolAll
@@ -1810,9 +1891,9 @@ window.__ModuleLoader__.load({
1810
1891
  revealLayer,
1811
1892
  h('div', { className: 'pighub-left' },
1812
1893
  h('div', { className: 'pighub-pen', ref: penRef },
1813
- h(Meadow, null),
1894
+ h(Meadow, { theme }),
1814
1895
  // 远侧栅栏(顶横梁 + 上角草石 + 侧栏上半):画在猪的后面(z1)
1815
- h(PenFence, { key: 'fence-back' }),
1896
+ h(PenFence, { key: 'fence-back', theme }),
1816
1897
  h('div', { className: 'pighub-house', ref: houseRef, title: '把小猪拖进来收进仓库(不再加载)' },
1817
1898
  h(HouseArt, null),
1818
1899
  ),
@@ -1881,7 +1962,7 @@ window.__ModuleLoader__.load({
1881
1962
  }),
1882
1963
  // 近侧栅栏(底横梁 + 下角草石 + 侧栏下半):最后画 → 压在猪身上(z4),
1883
1964
  // 走到底边的猪被挡住一截,这就是「景深」。
1884
- h(PenFence, { key: 'fence-front', front: true }),
1965
+ h(PenFence, { key: 'fence-front', front: true, theme }),
1885
1966
  ),
1886
1967
  ),
1887
1968
  h('div', { className: 'pighub-right' },
package/lib/index.js CHANGED
@@ -12,9 +12,12 @@
12
12
  * 服务依赖只声明 tools(工具表身份);webServer 按需查找——写进 inject 会让没有 Web 的
13
13
  * profile 整个启动失败(dsh: 1 entry did not activate),一个只少了面板的插件不该有那个权力。
14
14
  */
15
- import { readFileSync, existsSync } from 'node:fs';
15
+ import { readFileSync, existsSync, readdirSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'node:fs';
16
16
  import { dirname, join } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { runAutoUpdate, makeFileLogger } from './auto-update.js';
19
+ import { THEME_ROUTE, CLASSIC_THEME_NAME, listThemes, readThemeDir, themeAssetPath } from './themes.js';
20
+ import { readZip, isSafeZipName } from './zip.js';
18
21
  import { extname } from 'node:path';
19
22
  import { readAssetManifest, assetPool, assetSummary, assetUrlOf, IMAGE_DIR, ASSET_ROUTE, PEN_ROUTE, PEN_ART } from './assets.js';
20
23
  import {
@@ -40,7 +43,7 @@ export const inject = ['tools'];
40
43
  import { startDesktopPet, stopDesktopPet, syncDesktopPet, desktopPetState, sayOnDesktop, reclaimStaleSessions } from './desktop-pet.js';
41
44
 
42
45
  const here = dirname(fileURLToPath(import.meta.url));
43
- const VERSION = '0.9.61';
46
+ const VERSION = '0.9.66';
44
47
  /** 桌面桌宠的精灵边长(CSS px 基准,= manifest 里 sizeRatio 的中位数)。 */
45
48
  const DESKTOP_PET_SIZE = 96;
46
49
  /** 全局尺寸系数(用户口径 2026-11-13:「整体猪猪缩小到 75% 大小」)。三层一起乘。 */
@@ -195,7 +198,16 @@ function snapshot(root, { sort, note } = {}) {
195
198
  // 当前下发给原生窗口的猪圈屏幕矩形(物理像素);null 表示浏览器半还没报过。
196
199
  penRect: penRectOnScreen,
197
200
  sort: prefs.sort,
198
- prefs,
201
+ // prefs.theme 缺省即 classic(用户口径:现有外观是默认主题)
202
+ prefs: Object.assign({}, prefs, { theme: prefs.theme || CLASSIC_THEME_NAME }),
203
+ // 设置面板要的主题列表:内置 classic 永远第一,然后是 ~/.PigHub/themes 下的目录
204
+ themes: listThemes(join(here, '..', 'assets', 'themes'), join(root, 'themes')),
205
+ /* 当前生效的主题**完整定义**(几何 + 布景文件名):客户端据此注入 CSS 变量与图片 URL。
206
+ * 主题缺失/损坏时 readThemeDir 会逐字段回落 classic,所以这里拿到的一定是可用值。 */
207
+ activeTheme: readThemeDir(
208
+ join(root, 'themes', prefs.theme || CLASSIC_THEME_NAME),
209
+ prefs.theme || CLASSIC_THEME_NAME,
210
+ ),
199
211
  draws: state.draws,
200
212
  assets: assetSummary(manifest),
201
213
  note: note || '',
@@ -269,6 +281,56 @@ export function pickSayPet(ids, random = Math.random) {
269
281
  export function apply(ctx, config) {
270
282
  const root = (config && typeof config.root === 'string' && config.root.trim()) || resolveRoot();
271
283
 
284
+ /* ---------------- 主题包(用户口径 2026-11-14) ---------------- */
285
+ /** 包内主题目录(classic 在这里)与用户主题目录(~/.PigHub/themes)。 */
286
+ const themeRoot = join(here, '..', 'assets', 'themes');
287
+ const userThemesDir = join(root, 'themes');
288
+ const themeMime = (file) => {
289
+ const ext = extname(String(file || '')).toLowerCase();
290
+ if (ext === '.png') return 'image/png';
291
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
292
+ if (ext === '.webp') return 'image/webp';
293
+ if (ext === '.svg') return 'image/svg+xml';
294
+ return 'application/octet-stream';
295
+ };
296
+ /** 扫出「主题名 → 该主题目录下的图片文件名」,用于逐张注册 exact 路由。 */
297
+ const themeFileNames = () => {
298
+ const out = [];
299
+ const push = (name, dir) => {
300
+ const files = [];
301
+ try {
302
+ for (const file of readdirSync(dir)) {
303
+ if (/\.(png|jpg|jpeg|webp|svg)$/i.test(file)) files.push(file);
304
+ }
305
+ } catch { /* 目录不存在就当没有图 */ }
306
+ if (files.length) out.push({ name, files });
307
+ };
308
+ push(CLASSIC_THEME_NAME, join(themeRoot, CLASSIC_THEME_NAME));
309
+ // classic 也可以直接引用包内 assets/img 里的布景图(现有那张 pen-fence.png 就在那)
310
+ out.push({ name: CLASSIC_THEME_NAME, files: PEN_ART.slice() });
311
+ try {
312
+ for (const entry of readdirSync(userThemesDir)) {
313
+ if (entry === CLASSIC_THEME_NAME) continue;
314
+ push(entry, join(userThemesDir, entry));
315
+ }
316
+ } catch { /* 没有用户主题 */ }
317
+ // 去重(同一主题同一文件只注册一次,否则 exact 路由会重复)
318
+ const seen = new Set();
319
+ return out
320
+ .flatMap((group) => group.files.map((file) => ({ name: group.name, file })))
321
+ .filter(({ name, file }) => {
322
+ const key = name + '/' + file;
323
+ if (seen.has(key)) return false;
324
+ seen.add(key);
325
+ return true;
326
+ })
327
+ .reduce((acc, { name, file }) => {
328
+ const hit = acc.find((g) => g.name === name);
329
+ if (hit) hit.files.push(file); else acc.push({ name, files: [file] });
330
+ return acc;
331
+ }, []);
332
+ };
333
+
272
334
  // 原生桌宠开关:config.desktopPet === false 时完全不碰原生进程(离线测试/不想要桌宠的 profile 用)。
273
335
  const nativePetEnabled = !(config && config.desktopPet === false);
274
336
  const log = { warn: (message) => { try { ctx.logger?.warn?.(message); } catch { /* 日志失败不影响功能 */ } } };
@@ -286,6 +348,41 @@ export function apply(ctx, config) {
286
348
  /** 本次 apply 的 unload 旗子:置位后启动链/同步路径一律不再拉起进程(模块实例会跨 enable/disable 复用)。 */
287
349
  let stopped = false;
288
350
 
351
+ /* ---------------- 静默自动更新(用户口径 2026-11-14:不通知、不提供开关) ----------------
352
+ * 延迟 15s 跑一次(避开启动抢资源),每次 enable 只跑一次;失败静默,只写 ~/.PigHub/logs/auto-update.log。
353
+ * 不自动重启宿主:装好之后,下次宿主启动/插件重载自然生效。 */
354
+ const autoUpdateDelayMs = 15000;
355
+ let autoUpdateTimer = null;
356
+ const scheduleAutoUpdate = () => {
357
+ // 没有 profile 目录就无从安装;离线测试里 profileDir 为空时这一步直接跳过(只记日志)。
358
+ const profileDir = (() => {
359
+ try {
360
+ const candidate = typeof config?.profileDir === 'string' ? config.profileDir : '';
361
+ if (candidate) return candidate;
362
+ // 从插件自身路径反推:<profile>/node_modules/dsh-pighub/lib/index.js → <profile>
363
+ return join(here, '..', '..', '..');
364
+ } catch { return ''; }
365
+ })();
366
+ autoUpdateTimer = setTimeout(() => {
367
+ autoUpdateTimer = null;
368
+ if (stopped) return;
369
+ Promise.resolve()
370
+ .then(() => runAutoUpdate({
371
+ currentVersion: VERSION,
372
+ profileDir,
373
+ writeLog: makeFileLogger(root),
374
+ }))
375
+ .catch(() => { /* runAutoUpdate 自己不会抛;这里再兜一层 */ });
376
+ }, autoUpdateDelayMs);
377
+ if (autoUpdateTimer && typeof autoUpdateTimer.unref === 'function') autoUpdateTimer.unref();
378
+ };
379
+ ctx.effect(() => {
380
+ scheduleAutoUpdate();
381
+ return () => {
382
+ if (autoUpdateTimer) { clearTimeout(autoUpdateTimer); autoUpdateTimer = null; }
383
+ };
384
+ }, 'pighub-auto-update');
385
+
289
386
  /** 把当前 state 同步给原生窗口;没有桌面猪就停掉进程(不留在后台)。 */
290
387
  const syncNativePet = () => {
291
388
  if (!nativePetEnabled) return { ok: true, disabled: true };
@@ -393,9 +490,59 @@ export function apply(ctx, config) {
393
490
  try {
394
491
  if (req.method === 'GET') { sendJson(res, 200, snapshot(root)); return; }
395
492
  if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method_not_allowed' }); return; }
396
- const body = await readBody(req);
493
+ /* 状态路由的 body 上限提到 24 MB:主题包导入是 base64(738 KB 的 zip → 约 1 MB 的报文),
494
+ * 默认 64 KB 会把它判成 body_too_large。这是个只监听回环的本地服务,放宽是安全的。 */
495
+ const body = await readBody(req, 24 * 1024 * 1024);
397
496
  const action = typeof body.action === 'string' ? body.action : 'state';
398
497
 
498
+ /* 主题包导入走**状态路由上的 action**(用户口径 2026-11-14)。
499
+ * 为什么不另开 /pighub/theme-import:宿主的 exact 路由按前缀先命中,二进制 zip 会被
500
+ * 这条状态路由抢走当 JSON 解析(实测报 "Unexpected token 'P'",P 就是 zip 魔数 PK)。
501
+ * base64 多 33% 传输,换来路由一定命中。 */
502
+ if (action === 'theme-import') {
503
+ const b64 = typeof body.zipBase64 === 'string' ? body.zipBase64 : '';
504
+ if (!b64) { sendJson(res, 200, { ok: false, error: 'empty_body' }); return; }
505
+ let raw = null;
506
+ try { raw = Buffer.from(b64, 'base64'); } catch { raw = null; }
507
+ if (!raw || !raw.length) { sendJson(res, 200, { ok: false, error: 'bad_base64' }); return; }
508
+ const parsed = readZip(raw);
509
+ if (!parsed.ok) { sendJson(res, 200, { ok: false, error: parsed.reason }); return; }
510
+ const jsonEntry = parsed.files.find((file) => /(^|\/)theme\.json$/i.test(file.name));
511
+ if (!jsonEntry) { sendJson(res, 200, { ok: false, error: 'theme_json_missing' }); return; }
512
+ const prefix = jsonEntry.name.replace(/theme\.json$/i, '');
513
+ let meta;
514
+ 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
+ }
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
+ const nextPrefs2 = writePrefs(root, { theme: themeName });
542
+ sendJson(res, 200, { ...snapshot(root), ok: true, theme: themeName, prefs: nextPrefs2 });
543
+ return;
544
+ }
545
+
399
546
  if (action === 'state') { sendJson(res, 200, snapshot(root)); return; }
400
547
 
401
548
  /*
@@ -412,6 +559,20 @@ export function apply(ctx, config) {
412
559
  * **位置(pos)故意不进令牌**:猪圈里每只猪每 ~1.7 s 就写一次 pos,进令牌等于让客户端
413
560
  * 每 1.7 s 拉一张 38 KB 的快照;位置仍由客户端自己写、并由 10 s 的整张快照兜底对齐。
414
561
  */
562
+ /* 切换主题:只落 prefs,不碰 state;客户端下一次对账(rev 变化)就会重新取几何与图片。 */
563
+ if (action === 'theme') {
564
+ const wanted = String(body.name || '').trim();
565
+ const known = listThemes(join(here, '..', 'assets', 'themes'), join(root, 'themes'))
566
+ .some((item) => item.name === wanted);
567
+ if (!known) {
568
+ sendJson(res, 200, { ...snapshot(root), ok: false, error: 'unknown_theme', name: wanted });
569
+ return;
570
+ }
571
+ const nextPrefs = writePrefs(root, { theme: wanted });
572
+ sendJson(res, 200, { ...snapshot(root), ok: true, theme: wanted, prefs: nextPrefs });
573
+ return;
574
+ }
575
+
415
576
  if (action === 'rev') {
416
577
  const state = readState(root);
417
578
  const prefs = readPrefs(root);
@@ -430,6 +591,7 @@ export function apply(ctx, config) {
430
591
  pigs,
431
592
  prefs.sort || '',
432
593
  prefs.starDisplay || '',
594
+ prefs.theme || CLASSIC_THEME_NAME,
433
595
  currentManifest().assetVersion || '',
434
596
  desktopPetState().running ? 'run' : 'idle',
435
597
  ].join('|'),
@@ -611,6 +773,70 @@ export function apply(ctx, config) {
611
773
  effectName,
612
774
  );
613
775
  }
776
+ /* ---------------- 主题包导入(zip) ----------------
777
+ * 客户端把用户选中的 .zip 原样 POST 到这个 exact 路由(不走 JSON/base64,省一次 33% 膨胀)。
778
+ * 校验顺序:大小上限 → zip 结构 → theme.json 存在且可解析 → 文件名白名单 → 原子落盘。
779
+ * 任何一步失败都返回可读原因,并且**不动现有主题**。 */
780
+ if (webServer) {
781
+ const THEME_IMPORT_MAX = 12 * 1024 * 1024;
782
+ ctx.effect(
783
+ () =>
784
+ webServer.register({
785
+ kind: 'exact',
786
+ path: ROUTE + '/theme-import',
787
+ handler: async (req, res) => {
788
+ try {
789
+ if (req.method !== 'POST') { sendJson(res, 405, { ok: false, error: 'method_not_allowed' }); return; }
790
+ const body = await readBody(req, THEME_IMPORT_MAX);
791
+ if (!body || !body.length) { sendJson(res, 200, { ok: false, error: 'empty_body' }); return; }
792
+ const parsed = readZip(body);
793
+ if (!parsed.ok) { sendJson(res, 200, { ok: false, error: parsed.reason }); return; }
794
+ // theme.json 可能在根上,也可能在一个顶层目录里(Windows 的 Compress-Archive 会包一层)
795
+ const jsonEntry = parsed.files.find((file) => /(^|\/)theme\.json$/i.test(file.name));
796
+ if (!jsonEntry) { sendJson(res, 200, { ok: false, error: 'theme_json_missing' }); return; }
797
+ const prefix = jsonEntry.name.replace(/theme\.json$/i, '');
798
+ let raw;
799
+ try { raw = JSON.parse(jsonEntry.data.toString('utf8')); } catch { sendJson(res, 200, { ok: false, error: 'theme_json_invalid' }); return; }
800
+ const themeName = String(raw && raw.name ? raw.name : prefix.replace(/\/$/, '')).trim() || 'imported';
801
+ if (!/^[a-zA-Z0-9._-]{1,48}$/.test(themeName) || themeName === CLASSIC_THEME_NAME) {
802
+ sendJson(res, 200, { ok: false, error: 'theme_name_invalid', name: themeName });
803
+ return;
804
+ }
805
+ // 只落这个主题目录里的文件,且文件名必须安全
806
+ const targetDir = join(userThemesDir, themeName);
807
+ const staging = join(userThemesDir, '.' + themeName + '.tmp-' + Date.now());
808
+ try {
809
+ rmSync(staging, { recursive: true, force: true });
810
+ mkdirSync(staging, { recursive: true });
811
+ for (const file of parsed.files) {
812
+ const rel = file.name.startsWith(prefix) ? file.name.slice(prefix.length) : file.name;
813
+ if (!rel || rel.endsWith('/')) continue;
814
+ if (!/\.(json|png|jpg|jpeg|webp|svg)$/i.test(rel)) continue;
815
+ if (!isSafeZipName(rel)) { sendJson(res, 200, { ok: false, error: 'unsafe_entry_name', name: file.name }); return; }
816
+ const target = join(staging, rel);
817
+ mkdirSync(dirname(target), { recursive: true });
818
+ writeFileSync(target, file.data);
819
+ }
820
+ rmSync(targetDir, { recursive: true, force: true });
821
+ renameSync(staging, targetDir);
822
+ } catch (error) {
823
+ try { rmSync(staging, { recursive: true, force: true }); } catch { /* ignore */ }
824
+ sendJson(res, 200, { ok: false, error: 'write_failed', detail: String((error && error.message) || error).slice(0, 120) });
825
+ return;
826
+ }
827
+ // 成功后自动切到新主题(用户口径:成功后自动切换并立即可见)
828
+ const nextPrefs = writePrefs(root, { theme: themeName });
829
+ sendJson(res, 200, { ...snapshot(root), ok: true, theme: themeName, prefs: nextPrefs });
830
+ } catch (error) {
831
+ const message = error && error.message ? String(error.message) : 'internal_error';
832
+ try { sendJson(res, 500, { ok: false, error: message }); } catch { /* 已结束 */ }
833
+ }
834
+ },
835
+ }),
836
+ 'pighub-theme-import',
837
+ );
838
+ }
839
+
614
840
  if (webServer) {
615
841
  // 路由按**全部**素材注册:incomplete 的猪只是不进抽池,已拥有它的用户还要能看到它。
616
842
  for (const pig of fullPool.images) {
@@ -619,6 +845,44 @@ export function apply(ctx, config) {
619
845
  }
620
846
  // 猪圈布景(木栅栏):不是素材库的一部分,单独一条前缀,别让「每只猪一条路由」的断言变味。
621
847
  for (const artFile of PEN_ART) registerStaticImage(PEN_ROUTE, artFile, 'pighub-pen-art-' + artFile);
848
+ /* 主题包的图片:**逐张注册 exact 路由**(宿主只证实了 exact 这一种 kind)。
849
+ * 主题目录在启动时扫一遍;运行中新丢进来的主题要重载插件才认得——这一点写进 README。 */
850
+ for (const themeName of themeFileNames()) {
851
+ for (const file of themeName.files) {
852
+ const id = 'pighub-theme-' + themeName.name + '-' + file;
853
+ ctx.effect(
854
+ () =>
855
+ webServer.register({
856
+ kind: 'exact',
857
+ path: THEME_ROUTE + '/' + themeName.name + '/' + file,
858
+ handler: async (req, res) => {
859
+ try {
860
+ if (req.method !== 'GET') { sendJson(res, 405, { ok: false, error: 'method_not_allowed' }); return; }
861
+ const file2 = themeAssetPath({
862
+ name: themeName.name,
863
+ file,
864
+ builtinDir: join(themeRoot, CLASSIC_THEME_NAME),
865
+ userThemesDir,
866
+ });
867
+ if (!file2) { sendJson(res, 404, { ok: false, error: 'theme_asset_not_found' }); return; }
868
+ const body = readFileSync(file2);
869
+ res.writeHead(200, {
870
+ 'content-type': themeMime(file),
871
+ 'content-length': String(body.length),
872
+ 'cache-control': 'private, no-cache, must-revalidate',
873
+ 'x-content-type-options': 'nosniff',
874
+ });
875
+ res.end(body);
876
+ } catch (error) {
877
+ const message = error && error.message ? String(error.message) : 'internal_error';
878
+ try { sendJson(res, 500, { ok: false, error: message }); } catch { /* 已结束 */ }
879
+ }
880
+ },
881
+ }),
882
+ id,
883
+ );
884
+ }
885
+ }
622
886
  }
623
887
  ctx.effect(
624
888
  () =>
package/lib/store.js CHANGED
@@ -22,7 +22,7 @@ import { join, dirname } from 'node:path';
22
22
  * 为什么要有:素材会被补全/替换,但 URL 一直相同,浏览器就会一直显示旧图——
23
23
  * 用户 2026-11-13 报「猪圈里的斗鸡眼猪还是只有头、桌宠是全身」就是这个问题。
24
24
  * 每次发版改这里,旧的图片缓存自然失效。 */
25
- const ASSET_TAG = '0961'; // 与 package.json / index.js 的 VERSION 同步(发版 +1;0.9.56 之前漏改,仍是 0955)
25
+ const ASSET_TAG = '0962'; // 与 package.json / index.js 的 VERSION 同步(发版 +1;0.9.56 之前漏改,仍是 0955)
26
26
  /* 幂等:先去干净再挂一次。
27
27
  * 为什么不幂等会出事(2026-11-13 实测):readState() 每次读都调它,writeState() 又把结果落盘,
28
28
  * 于是每读一次 URL 就多一个 v=0955。真机 state.json 里三个 URL 各带 **352 个** v=0955、
@@ -386,7 +386,12 @@ export function listPigs(state, sort = 'time') {
386
386
  function normalisePrefs(raw) {
387
387
  const sort = raw && SORTS.includes(raw.sort) ? raw.sort : 'time';
388
388
  const starDisplay = raw && STAR_DISPLAY.includes(raw.starDisplay) ? raw.starDisplay : STAR_DISPLAY_DEFAULT;
389
- return { sort, starDisplay };
389
+ // 主题包(用户口径 2026-11-14):名字只允许安全字符;缺省 classic。
390
+ // 注意:这个函数是 prefs 的**唯一白名单**——不加进来,writePrefs({theme}) 会被静默丢弃。
391
+ const theme = raw && typeof raw.theme === 'string' && /^[a-zA-Z0-9._-]{1,48}$/.test(raw.theme)
392
+ ? raw.theme
393
+ : 'classic';
394
+ return { sort, starDisplay, theme };
390
395
  }
391
396
 
392
397
  export function readPrefs(root) {
package/lib/themes.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 猪圈主题包(用户口径 2026-11-14):
3
+ * 「将猪圈主题配置(草地、栏杆-前后左右)打包并开发自定义,在设置中更换,主题包保存在 .PigHub。」
4
+ *
5
+ * 结构:一个目录 = 一个主题
6
+ * ~/.PigHub/themes/<name>/
7
+ * theme.json { name?, title?, author?, version?, geometry?{grass,inner,box}, fence?{image?,split?,back?,front?}, grassImage?, house? }
8
+ * fence-back.png / fence-front.png / grass.png / house.png(都可以缺)
9
+ *
10
+ * 原则:
11
+ * 1. **内置 classic 必须永远可用**——外部主题缺字段、缺图、JSON 坏掉,一律回落它,绝不白屏;
12
+ * 2. geometry 是**百分比**(与 client 里 PEN_GRASS/PEN_INNER/PEN_BOX 同构),缺省就用 classic 的值;
13
+ * 3. 这个模块只做「读 + 校验 + 归一」,不碰 DOM、不碰路由(路由在 index.js)。
14
+ */
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ export const THEME_ROUTE = '/pighub/theme';
19
+ export const CLASSIC_THEME_NAME = 'classic';
20
+
21
+ /** classic 的几何:与 lib/client.js 的 PEN_GRASS / PEN_INNER / PEN_BOX 完全一致(改这里就必须同步改那边)。 */
22
+ export const CLASSIC_GEOMETRY = {
23
+ grass: [8.37, 18.26, 91.47, 96.65],
24
+ inner: [13.40, 19.70, 86.60, 81.74],
25
+ box: [24.04, 29.16, 75.96, 72.28],
26
+ };
27
+
28
+ /** classic 的布景:用包内那一张 pen-fence.png,按 50.68% 切成远/近两层。 */
29
+ export const CLASSIC_FENCE = { image: 'pen-fence.png', split: 0.5068 };
30
+
31
+ /** 主题里允许的图片文件名(防穿越:不许 ../、不许绝对路径)。 */
32
+ const SAFE_FILE = /^[a-zA-Z0-9._-]+\.(png|jpg|jpeg|webp|svg)$/;
33
+
34
+ function normaliseRect(value, fallback) {
35
+ if (!Array.isArray(value) || value.length !== 4) return fallback.slice();
36
+ const nums = value.map((n) => Number(n));
37
+ if (nums.some((n) => !Number.isFinite(n))) return fallback.slice();
38
+ // 百分比一律夹到 0..100,且保证 left<right / top<bottom,否则回落(坏数据不该把猪圈画崩)
39
+ const [left, top, right, bottom] = nums.map((n) => Math.min(100, Math.max(0, n)));
40
+ if (!(left < right) || !(top < bottom)) return fallback.slice();
41
+ return [left, top, right, bottom];
42
+ }
43
+
44
+ /** 把任意 theme.json 归一成「一定可用」的形状;坏字段逐项回落 classic。 */
45
+ export function normaliseTheme(raw, name) {
46
+ const source = raw && typeof raw === 'object' ? raw : {};
47
+ const geometry = source.geometry && typeof source.geometry === 'object' ? source.geometry : {};
48
+ const fence = source.fence && typeof source.fence === 'object' ? source.fence : {};
49
+ const split = Number(fence.split);
50
+ return {
51
+ name: String(source.name || name || CLASSIC_THEME_NAME),
52
+ title: String(source.title || name || CLASSIC_THEME_NAME),
53
+ author: typeof source.author === 'string' ? source.author : '',
54
+ version: typeof source.version === 'string' ? source.version : '',
55
+ geometry: {
56
+ grass: normaliseRect(geometry.grass, CLASSIC_GEOMETRY.grass),
57
+ inner: normaliseRect(geometry.inner, CLASSIC_GEOMETRY.inner),
58
+ box: normaliseRect(geometry.box, CLASSIC_GEOMETRY.box),
59
+ },
60
+ fence: {
61
+ image: SAFE_FILE.test(String(fence.image || '')) ? String(fence.image) : CLASSIC_FENCE.image,
62
+ back: SAFE_FILE.test(String(fence.back || '')) ? String(fence.back) : '',
63
+ front: SAFE_FILE.test(String(fence.front || '')) ? String(fence.front) : '',
64
+ split: Number.isFinite(split) && split > 0.05 && split < 0.95 ? split : CLASSIC_FENCE.split,
65
+ },
66
+ grassImage: SAFE_FILE.test(String(source.grassImage || '')) ? String(source.grassImage) : '',
67
+ houseImage: source.house && SAFE_FILE.test(String(source.house.image || '')) ? String(source.house.image) : '',
68
+ };
69
+ }
70
+
71
+ /** 读一个主题目录;theme.json 坏掉/缺失就返回 classic 的形状(不抛)。 */
72
+ export function readThemeDir(dir, name) {
73
+ const fallback = normaliseTheme({}, name);
74
+ try {
75
+ if (!dir || !existsSync(dir)) return fallback;
76
+ const jsonPath = join(dir, 'theme.json');
77
+ if (!existsSync(jsonPath)) return fallback;
78
+ const raw = JSON.parse(readFileSync(jsonPath, 'utf8'));
79
+ return normaliseTheme(raw, name);
80
+ } catch {
81
+ return fallback;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * 列主题:内置 classic 永远排第一,然后是 ~/.PigHub/themes 下的目录(按名字排序)。
87
+ * 每个条目只有 name/title,够设置面板渲染;完整定义由 readThemeDir 再读。
88
+ */
89
+ export function listThemes(builtinDir, userThemesDir) {
90
+ const seen = new Set([CLASSIC_THEME_NAME]);
91
+ const items = [{ name: CLASSIC_THEME_NAME, title: 'classic(默认)', builtin: true }];
92
+ try {
93
+ if (userThemesDir && existsSync(userThemesDir)) {
94
+ for (const entry of readdirSync(userThemesDir)) {
95
+ if (seen.has(entry)) continue;
96
+ const dir = join(userThemesDir, entry);
97
+ let isDir = false;
98
+ try { isDir = statSync(dir).isDirectory(); } catch { isDir = false; }
99
+ if (!isDir) continue;
100
+ seen.add(entry);
101
+ const theme = readThemeDir(dir, entry);
102
+ items.push({ name: entry, title: theme.title || entry, builtin: false });
103
+ }
104
+ }
105
+ } catch { /* 目录读不了就当没有外部主题 */ }
106
+ return items;
107
+ }
108
+
109
+ /** 主题图片的绝对路径;找不到返回 null(路由据此 404,客户端回落到 classic)。 */
110
+ export function themeAssetPath({ name, file, builtinDir, userThemesDir }) {
111
+ if (!SAFE_FILE.test(String(file || ''))) return null;
112
+ if (name && name !== CLASSIC_THEME_NAME) {
113
+ const candidate = join(userThemesDir || '', String(name), String(file));
114
+ if (userThemesDir && existsSync(candidate)) return candidate;
115
+ }
116
+ const builtin = join(builtinDir || '', String(file));
117
+ return existsSync(builtin) ? builtin : null;
118
+ }
package/lib/zip.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * 最小 ZIP 读取器(主题包以 zip 分发,用户口径 2026-11-14)。
3
+ *
4
+ * 不引依赖:包要能离线安装。只支持现实中会遇到的两种存储方式:
5
+ * 0 STORED(不压缩)、8 DEFLATE(node:zlib 的 inflateRawSync 正好对应裸 deflate)。
6
+ * 其余算法一律拒绝并给出可读原因。
7
+ *
8
+ * 安全:三道上限(条目数 / 单文件 / 解压总量)防 zip 炸弹;名字不许穿越或绝对路径。
9
+ */
10
+ import { inflateRawSync } from 'node:zlib';
11
+
12
+ export const ZIP_LIMITS = { entries: 500, fileBytes: 8 * 1024 * 1024, totalBytes: 40 * 1024 * 1024 };
13
+
14
+ function findEocd(buffer) {
15
+ const min = Math.max(0, buffer.length - 66000);
16
+ for (let offset = buffer.length - 22; offset >= min; offset -= 1) {
17
+ if (buffer.readUInt32LE(offset) === 0x06054b50) return offset;
18
+ }
19
+ return -1;
20
+ }
21
+
22
+ export function isSafeZipName(name) {
23
+ const value = String(name || '').replace(/\\/g, '/');
24
+ if (!value || value.startsWith('/') || /^[a-zA-Z]:/.test(value)) return false;
25
+ const parts = value.split('/').filter(Boolean);
26
+ return parts.length > 0 && parts.every((part) => part !== '..' && part !== '.');
27
+ }
28
+
29
+ export function readZip(buffer) {
30
+ try {
31
+ if (!buffer || buffer.length < 22) return { ok: false, reason: 'empty_or_too_small', files: [] };
32
+ const eocd = findEocd(buffer);
33
+ if (eocd < 0) return { ok: false, reason: 'not_a_zip', files: [] };
34
+ const count = buffer.readUInt16LE(eocd + 10);
35
+ const cdOffset = buffer.readUInt32LE(eocd + 16);
36
+ if (count === 0) return { ok: false, reason: 'zip_has_no_entries', files: [] };
37
+ if (count > ZIP_LIMITS.entries) return { ok: false, reason: 'too_many_entries', files: [] };
38
+ const files = [];
39
+ let total = 0;
40
+ let cursor = cdOffset;
41
+ for (let index = 0; index < count; index += 1) {
42
+ if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== 0x02014b50) {
43
+ return { ok: false, reason: 'corrupt_central_directory', files: [] };
44
+ }
45
+ const method = buffer.readUInt16LE(cursor + 10);
46
+ const compressedSize = buffer.readUInt32LE(cursor + 20);
47
+ const uncompressedSize = buffer.readUInt32LE(cursor + 24);
48
+ const nameLength = buffer.readUInt16LE(cursor + 28);
49
+ const extraLength = buffer.readUInt16LE(cursor + 30);
50
+ const commentLength = buffer.readUInt16LE(cursor + 32);
51
+ const localOffset = buffer.readUInt32LE(cursor + 42);
52
+ const name = buffer.slice(cursor + 46, cursor + 46 + nameLength).toString('utf8');
53
+ cursor += 46 + nameLength + extraLength + commentLength;
54
+ if (name.endsWith('/')) continue;
55
+ if (!isSafeZipName(name)) return { ok: false, reason: 'unsafe_entry_name:' + name, files: [] };
56
+ if (uncompressedSize > ZIP_LIMITS.fileBytes) return { ok: false, reason: 'entry_too_large:' + name, files: [] };
57
+ total += uncompressedSize;
58
+ if (total > ZIP_LIMITS.totalBytes) return { ok: false, reason: 'uncompressed_too_large', files: [] };
59
+ if (buffer.readUInt32LE(localOffset) !== 0x04034b50) {
60
+ return { ok: false, reason: 'corrupt_local_header:' + name, files: [] };
61
+ }
62
+ const localNameLength = buffer.readUInt16LE(localOffset + 26);
63
+ const localExtraLength = buffer.readUInt16LE(localOffset + 28);
64
+ const dataStart = localOffset + 30 + localNameLength + localExtraLength;
65
+ const raw = buffer.slice(dataStart, dataStart + compressedSize);
66
+ let data;
67
+ if (method === 0) data = raw;
68
+ else if (method === 8) {
69
+ try { data = inflateRawSync(raw); } catch { return { ok: false, reason: 'inflate_failed:' + name, files: [] }; }
70
+ } else return { ok: false, reason: 'unsupported_method:' + method + ':' + name, files: [] };
71
+ files.push({ name, data });
72
+ }
73
+ return { ok: true, reason: '', files };
74
+ } catch (error) {
75
+ return { ok: false, reason: 'zip_read_error:' + String((error && error.message) || error).slice(0, 120), files: [] };
76
+ }
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-pighub",
3
- "version": "0.9.61",
3
+ "version": "0.9.66",
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",
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",
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"
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"
60
60
  },
61
61
  "license": "MIT",
62
62
  "author": {
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 静默自动更新的三分支测试(离线,不联网、不装包):
4
+ * 高版本 → 发起安装并写日志 / 同版本 → 不动作 / 网络失败 → 不抛且只记一条。
5
+ * 依赖注入让这三条都能在本地跑出来。
6
+ */
7
+ import assert from 'node:assert/strict';
8
+ import { compareSemver, runAutoUpdate } from '../lib/auto-update.js';
9
+
10
+ let pass = 0;
11
+ let fail = 0;
12
+ const check = (name, fn) => {
13
+ try {
14
+ fn();
15
+ pass += 1;
16
+ console.log('PASS ' + name);
17
+ } catch (error) {
18
+ fail += 1;
19
+ console.log('FAIL ' + name + ' :: ' + (error && error.message));
20
+ }
21
+ };
22
+ const acheck = async (name, fn) => {
23
+ try {
24
+ await fn();
25
+ pass += 1;
26
+ console.log('PASS ' + name);
27
+ } catch (error) {
28
+ fail += 1;
29
+ console.log('FAIL ' + name + ' :: ' + (error && error.message));
30
+ }
31
+ };
32
+
33
+ check('版本比较是语义化的(0.9.9 < 0.9.10,不能按字符串比)', () => {
34
+ assert.equal(compareSemver('0.9.9', '0.9.10'), -1);
35
+ assert.equal(compareSemver('0.9.10', '0.9.9'), 1);
36
+ assert.equal(compareSemver('0.9.61', '0.9.61'), 0);
37
+ assert.equal(compareSemver('v0.9.61', '0.9.61'), 0);
38
+ assert.equal(compareSemver('1.0.0', '0.9.99'), 1);
39
+ assert.equal(compareSemver('0.9', '0.9.0'), 0);
40
+ });
41
+
42
+ await acheck('远端更高:发起一次安装,并在日志里写下 start/ok', async () => {
43
+ const logs = [];
44
+ const calls = [];
45
+ const out = await runAutoUpdate({
46
+ currentVersion: '0.9.61',
47
+ profileDir: 'C:/fake/profile',
48
+ writeLog: (line) => logs.push(line),
49
+ fetchImpl: async () => ({ ok: true, json: async () => ({ version: '0.9.62' }) }),
50
+ execFileImpl: (_file, args, _opts, cb) => { calls.push(args.join(' ')); cb(null); return { unref() {} }; },
51
+ });
52
+ assert.equal(out.status, 'installed');
53
+ assert.equal(calls.length, 1, '应当恰好安装一次');
54
+ assert.ok(calls[0].indexOf('dsh-pighub@0.9.62') >= 0, '安装目标版本要正确:' + calls[0]);
55
+ assert.ok(logs.some((l) => l.indexOf('install start') >= 0), '要有 start 日志');
56
+ assert.ok(logs.some((l) => l.indexOf('install ok') >= 0), '要有 ok 日志');
57
+ });
58
+
59
+ await acheck('远端同版本:一次安装都不发起', async () => {
60
+ const logs = [];
61
+ let installed = 0;
62
+ const out = await runAutoUpdate({
63
+ currentVersion: '0.9.61',
64
+ profileDir: 'C:/fake/profile',
65
+ writeLog: (line) => logs.push(line),
66
+ fetchImpl: async () => ({ ok: true, json: async () => ({ version: '0.9.61' }) }),
67
+ execFileImpl: () => { installed += 1; },
68
+ });
69
+ assert.equal(out.status, 'current');
70
+ assert.equal(installed, 0, '同版本不许安装');
71
+ assert.ok(logs.some((l) => l.indexOf('up_to_date') >= 0));
72
+ });
73
+
74
+ await acheck('远端更老:也不安装(只升不降)', async () => {
75
+ let installed = 0;
76
+ const out = await runAutoUpdate({
77
+ currentVersion: '0.9.61',
78
+ profileDir: 'C:/fake/profile',
79
+ writeLog: () => {},
80
+ fetchImpl: async () => ({ ok: true, json: async () => ({ version: '0.9.10' }) }),
81
+ execFileImpl: () => { installed += 1; },
82
+ });
83
+ assert.equal(out.status, 'current');
84
+ assert.equal(installed, 0);
85
+ });
86
+
87
+ await acheck('网络失败:不抛、不安装、只记一条 offline', async () => {
88
+ const logs = [];
89
+ const out = await runAutoUpdate({
90
+ currentVersion: '0.9.61',
91
+ profileDir: 'C:/fake/profile',
92
+ writeLog: (line) => logs.push(line),
93
+ fetchImpl: async () => { throw new Error('ENOTFOUND'); },
94
+ execFileImpl: () => { throw new Error('不该安装'); },
95
+ });
96
+ assert.equal(out.status, 'offline');
97
+ assert.equal(logs.length, 1, '只记一条,不要重试风暴');
98
+ assert.ok(logs[0].indexOf('offline') >= 0);
99
+ });
100
+
101
+ await acheck('安装失败:静默降级为 failed,不抛出', async () => {
102
+ const logs = [];
103
+ const out = await runAutoUpdate({
104
+ currentVersion: '0.9.61',
105
+ profileDir: 'C:/fake/profile',
106
+ writeLog: (line) => logs.push(line),
107
+ fetchImpl: async () => ({ ok: true, json: async () => ({ version: '0.9.62' }) }),
108
+ execFileImpl: (_f, _a, _o, cb) => { cb(new Error('EACCES')); return { unref() {} }; },
109
+ });
110
+ assert.equal(out.status, 'failed');
111
+ assert.ok(logs.some((l) => l.indexOf('install failed') >= 0));
112
+ });
113
+
114
+ await acheck('没有 profile 目录:不安装,记 skip', async () => {
115
+ const logs = [];
116
+ let installed = 0;
117
+ const out = await runAutoUpdate({
118
+ currentVersion: '0.9.61',
119
+ profileDir: '',
120
+ writeLog: (line) => logs.push(line),
121
+ fetchImpl: async () => ({ ok: true, json: async () => ({ version: '0.9.62' }) }),
122
+ execFileImpl: () => { installed += 1; },
123
+ });
124
+ assert.equal(out.status, 'failed');
125
+ assert.equal(out.reason, 'no_profile_dir');
126
+ assert.equal(installed, 0);
127
+ });
128
+
129
+ console.log('\n' + pass + ' PASS / ' + fail + ' FAIL — auto update');
130
+ process.exit(fail === 0 ? 0 : 1);
@@ -64,7 +64,10 @@ await acheck('GET 快照:素材库随包、今日未抽、偏好默认值', as
64
64
  assert.equal(json.assets.version, manifest.assetVersion);
65
65
  assert.ok(json.assets.bytes > 0, '素材体积应可统计');
66
66
  assert.equal(json.catalog, undefined, '运行时不再有上游目录');
67
- assert.deepEqual(json.prefs, { sort: 'time', starDisplay: 'hover' });
67
+ // prefs 现在多一个 theme(主题包,用户口径 2026-11-14);未设置时必须是 classic
68
+ assert.deepEqual(json.prefs, { sort: 'time', starDisplay: 'hover', theme: 'classic' });
69
+ assert.ok(Array.isArray(json.themes) && json.themes.length >= 1, '快照要带主题列表');
70
+ assert.equal(json.themes[0].name, 'classic', '内置 classic 永远排第一');
68
71
  assert.equal(json.today.drawn, false);
69
72
  // 新用户开局就带一只普通猪(标准猪)
70
73
  assert.deepEqual(json.pigs.map((pig) => pig.id), ['standard-pig']);
package/test/smoke.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * 任何一条不成立就非零退出——坏包发不出去。
10
10
  */
11
11
  import assert from 'node:assert/strict';
12
- import { readFileSync, existsSync } from 'node:fs';
12
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
13
13
  import { createHash } from 'node:crypto';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -200,9 +200,12 @@ check('注册了 pighub_about / pig-say 工具、状态路由与每张素材的
200
200
  });
201
201
  check('每个注册都包在 ctx.effect 里(可回收)', () => {
202
202
  const manifestCount = JSON.parse(readFileSync(new URL('../assets/manifest.json', here), 'utf8')).pigs.length;
203
- // 两个工具 + 状态路由 + 每张素材 + 猪圈布景图(PEN_ART)+ 原生桌面桌宠的生命周期 effect
204
- assert.equal(disposers.length, manifestCount + PEN_ART.length + 4,
205
- '两个工具 + 状态路由 + 每张素材 + 每张布景 + 桌面桌宠各一个 disposer');
203
+ // 两个工具 + 状态路由 + 每张素材 + 猪圈布景图(PEN_ART)+ 原生桌面桌宠 + 静默自动更新的定时器
204
+ // 主题包的图片也是逐张注册的(classic 目录里有几张就几条),这里一起算进去
205
+ const themeImageCount = readdirSync(new URL('../assets/themes/classic', here)).filter((f) => /\.(png|jpg|jpeg|webp|svg)$/i.test(f)).length;
206
+ // PEN_ART 会被注册两次:一次是 /pighub/pen(布景),一次是 /pighub/theme/classic(主题包里的同一张图)
207
+ assert.equal(disposers.length, manifestCount + PEN_ART.length * 2 + 6 + themeImageCount,
208
+ '两个工具 + 状态路由 + 每张素材 + 每张布景 + 桌面桌宠 + 自动更新定时器 + 主题导入路由 + 每张主题图,各一个 disposer');
206
209
  for (const dispose of disposers) assert.equal(typeof dispose, 'function');
207
210
  });
208
211
  check('pighub_about 的参数 schema 封闭、描述非空', () => {
@@ -210,12 +210,13 @@ check('运动开关:显式布尔可写、null 回到素材默认、落在 stat
210
210
  });
211
211
 
212
212
  check('prefs:排序与星级显示各自独立,非法值退回默认', () => {
213
- assert.deepEqual(mod.writePrefs(root, { sort: 'stars' }), { sort: 'stars', starDisplay: 'hover' });
213
+ // prefs 现在固定包含 theme(主题包,用户口径 2026-11-14),缺省 classic
214
+ assert.deepEqual(mod.writePrefs(root, { sort: 'stars' }), { sort: 'stars', starDisplay: 'hover', theme: 'classic' });
214
215
  assert.equal(mod.readPrefs(root).sort, 'stars');
215
216
  assert.equal(mod.writePrefs(root, { sort: 'nonsense' }).sort, 'time');
216
217
  // 只改星级显示,不能把排序重置掉(反之亦然)
217
- assert.deepEqual(mod.writePrefs(root, { starDisplay: 'always' }), { sort: 'time', starDisplay: 'always' });
218
- assert.deepEqual(mod.writePrefs(root, { sort: 'rarity' }), { sort: 'rarity', starDisplay: 'always' });
218
+ assert.deepEqual(mod.writePrefs(root, { starDisplay: 'always' }), { sort: 'time', starDisplay: 'always', theme: 'classic' });
219
+ assert.deepEqual(mod.writePrefs(root, { sort: 'rarity' }), { sort: 'rarity', starDisplay: 'always', theme: 'classic' });
219
220
  assert.equal(mod.writePrefs(root, { starDisplay: 'nope' }).starDisplay, 'hover');
220
221
  });
221
222