@harness-mix/cli 0.1.9 → 0.1.10

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +25 -30
  3. package/output/native-build/desktop-controller.mjs +1 -1
  4. package/output/native-build/renderer-extension.js +703 -41
  5. package/package.json +9 -8
  6. package/scripts/native-config-test.cjs +32 -2
  7. package/scripts/native-ui-smoke.cjs +39 -4
  8. package/scripts/pets-test.cjs +146 -0
  9. package/src/main/native/launcher.js +49 -2
  10. package/src/main/native/pets.js +176 -15
  11. package/src/main/native/protocol.js +9 -8
  12. package/src/native-ui/desktop-control/dist/renderer-draft-prewarm-runtime.js +4 -4
  13. package/src/native-ui/desktop-control/dist/renderer-draft-prewarm-runtime.js.map +1 -1
  14. package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
  15. package/src/native-ui/desktop-control/src/renderer-draft-prewarm-runtime.ts +4 -4
  16. package/src/native-ui/desktop-control/test/renderer-draft-prewarm-policy.test.ts +7 -0
  17. package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts +1 -0
  18. package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts.map +1 -1
  19. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts +1 -0
  20. package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
  21. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +2 -0
  22. package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
  23. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +2 -1
  24. package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
  25. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
  26. package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
  27. package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
  28. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
  29. package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
  30. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +5 -0
  31. package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -0
  32. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +57 -0
  33. package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -0
  34. package/src/native-ui/renderer-extension/dist/types/settings/shell.d.ts.map +1 -1
  35. package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
  36. package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
  37. package/src/native-ui/renderer-extension/src/agent-selection-state.ts +45 -14
  38. package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +69 -27
  39. package/src/native-ui/renderer-extension/src/renderer-model-client.ts +6 -0
  40. package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +3 -0
  41. package/src/native-ui/renderer-extension/src/settings/icons.ts +15 -12
  42. package/src/native-ui/renderer-extension/src/settings/localization.ts +2 -0
  43. package/src/native-ui/renderer-extension/src/settings/pages.ts +74 -66
  44. package/src/native-ui/renderer-extension/src/settings/pet-market.css +310 -0
  45. package/src/native-ui/renderer-extension/src/settings/pet-market.ts +447 -0
  46. package/src/native-ui/renderer-extension/src/settings/pets-client.ts +74 -0
  47. package/src/native-ui/renderer-extension/src/settings/shell.css +27 -8
  48. package/src/native-ui/renderer-extension/src/settings/shell.ts +4 -3
  49. package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +234 -3
  50. package/src/native-ui/renderer-extension/test/agent-selection-state.test.ts +58 -0
  51. package/src/native-ui/renderer-extension/test/renderer-binding-probe.test.ts +12 -0
  52. package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +1 -0
  53. package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -0
  54. package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
  55. package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +28 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harness-mix/cli",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Harness Mix native Codex UI bridge for Codex, Pi, Claude Code, DeepSeek Harness, Antigravity, CodeBuddy, Kiro CLI, Cursor CLI and other native coding harnesses.",
6
6
  "keywords": [
@@ -82,7 +82,8 @@
82
82
  "test:docs": "node scripts/docs-command-test.cjs",
83
83
  "typecheck:native-ui": "tsc -b src/native-ui/shared-contracts/tsconfig.json src/native-ui/desktop-control/tsconfig.json src/native-ui/renderer-extension/tsconfig.json --pretty false",
84
84
  "test:native-ui": "vitest run --config vitest.config.mjs",
85
- "test:ci": "npm run test:docs && npm run check && npm run typecheck:native-ui && npm run test:native-ui && npm run test:core-all && npm run test:native-protocol && npm run test:native-compatibility && npm run test:platform",
85
+ "test:ci": "npm run test:docs && npm run check && npm run typecheck:native-ui && npm run test:native-ui && npm run test:core-all && npm run test:native-protocol && npm run test:native-compatibility && npm run test:platform && npm run test:pets",
86
+ "test:pets": "node scripts/pets-test.cjs",
86
87
  "test:storage-verification": "node scripts/storage-verification-test.cjs",
87
88
  "test:collaboration": "node scripts/collaboration-test.cjs",
88
89
  "test:switch-harness": "node scripts/switch-harness-test.cjs",
@@ -178,11 +179,11 @@
178
179
  "zod": "4.4.3"
179
180
  },
180
181
  "optionalDependencies": {
181
- "@harness-mix/native-win32-x64": "0.1.6",
182
- "@harness-mix/native-win32-arm64": "0.1.6",
183
- "@harness-mix/native-darwin-x64": "0.1.6",
184
- "@harness-mix/native-darwin-arm64": "0.1.6",
185
- "@harness-mix/native-linux-x64": "0.1.6",
186
- "@harness-mix/native-linux-arm64": "0.1.6"
182
+ "@harness-mix/native-win32-x64": "0.1.9",
183
+ "@harness-mix/native-win32-arm64": "0.1.9",
184
+ "@harness-mix/native-darwin-x64": "0.1.9",
185
+ "@harness-mix/native-darwin-arm64": "0.1.9",
186
+ "@harness-mix/native-linux-x64": "0.1.9",
187
+ "@harness-mix/native-linux-arm64": "0.1.9"
187
188
  }
188
189
  }
@@ -3,6 +3,7 @@ const fs = require('node:fs');
3
3
  const os = require('node:os');
4
4
  const path = require('node:path');
5
5
  const { nativeEnvironment, nativePaths, saveNativeSettings } = require('../src/main/native/config');
6
+ const { isCodexTaskEnvironment, readLiveHostInstance } = require('../src/main/native/launcher');
6
7
 
7
8
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-mix-config-'));
8
9
  try {
@@ -18,8 +19,37 @@ try {
18
19
  assert.equal(nativeEnvironment({ CODEXHOST_DATA_DIR: directory, CODEXHOST_PI_COMMAND: 'D:\\other\\pi.cmd' }).CODEXHOST_PI_COMMAND,
19
20
  'D:\\other\\pi.cmd', 'Explicit environment takes priority');
20
21
  for (const file of Object.values(nativePaths())) assert.ok(fs.statSync(file).isFile(), file);
22
+ const runtimeDirectory = path.join(directory, 'runtime');
23
+ fs.mkdirSync(runtimeDirectory, { recursive: true });
24
+ const instanceFile = path.join(runtimeDirectory, 'instance.json');
25
+ fs.writeFileSync(instanceFile, JSON.stringify({
26
+ pid: 4242,
27
+ beatAt: 1_000_000,
28
+ mode: 'native-host',
29
+ }));
30
+ assert.deepEqual(
31
+ readLiveHostInstance(directory, { now: 1_010_000, isPidAlive: pid => pid === 4242 }),
32
+ { pid: 4242, beatAt: 1_000_000, mode: 'native-host' },
33
+ 'A live Host makes repeated npm start idempotent',
34
+ );
35
+ assert.equal(
36
+ readLiveHostInstance(directory, { now: 1_020_000, isPidAlive: () => true }),
37
+ null,
38
+ 'A stale Host heartbeat does not block a real launch',
39
+ );
40
+ assert.equal(
41
+ readLiveHostInstance(directory, { now: 1_010_000, isPidAlive: () => false }),
42
+ null,
43
+ 'A dead Host PID does not block a real launch',
44
+ );
45
+ assert.equal(isCodexTaskEnvironment({ CODEX_THREAD_ID: 'thread-1' }), true);
46
+ assert.equal(isCodexTaskEnvironment({ CODEX_SESSION_ID: 'session-1' }), true);
47
+ assert.equal(
48
+ isCodexTaskEnvironment({ CODEX_INTERNAL_ORIGINATOR_OVERRIDE: 'Codex Desktop' }),
49
+ true,
50
+ );
51
+ assert.equal(isCodexTaskEnvironment({}), false);
21
52
  console.log('Native configuration, AppX environment recovery, credential exclusion and packaged resources passed');
22
53
  } finally {
23
- fs.rmSync(path.join(directory, 'harness-mix-settings.json'), { force: true });
24
- fs.rmdirSync(directory);
54
+ fs.rmSync(directory, { recursive: true, force: true });
25
55
  }
@@ -22,7 +22,7 @@ app.whenReady().then(async () => {
22
22
  lifecycle.dispose();
23
23
  return pageIds;
24
24
  })()`);
25
- assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'skins', 'updates', 'about']);
25
+ assert.deepEqual(liveSettingsPages, ['connections', 'accounts', 'mcp', 'skills', 'session-import', 'storage', 'skins', 'pets', 'updates', 'about']);
26
26
  const { NativeProtocol } = require('../src/main/native/protocol');
27
27
  const nativeThreads = ['pi', 'claude', 'dsh', 'antigravity'].map((harnessId, index) => ({ id: 'sidebar-' + index, harnessId, model: { id: 'model-' + index, provider: 'native' }, options: {} }));
28
28
  const protocol = new NativeProtocol({ threads: nativeThreads, getThread: id => nativeThreads.find(thread => thread.id === id), subscribe: () => () => {}, core: { subscribe: () => () => {} }, describe: async () => ({}), getCapabilities: () => ({}) }, () => {});
@@ -212,6 +212,36 @@ app.whenReady().then(async () => {
212
212
  assert.match(skinShowcase.logoBackground, /data:image\/webp/);
213
213
  assert.match(skinShowcase.polaroidBackground, /data:image\/webp/);
214
214
  fs.writeFileSync(path.join(out, 'skin-miku-full.png'), (await win.webContents.capturePage()).toPNG());
215
+ await win.setSize(1024, 716);
216
+ const responsiveHome = await win.webContents.executeJavaScript(`(async () => {
217
+ document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"></aside><main class="main-surface" data-app-shell-main-surface="default"><div data-app-shell-main-content-top-fade="full-bleed"></div><header data-pip-obstacle="app-shell-header"><div data-testid="app-shell-header-context-menu-surface"><div data-app-shell-page-header="true"><div data-app-shell-header-toolbar="true"><div class="thread-title-surface"><div>新任务</div></div><button class="header-action" aria-label="聊天操作" aria-haspopup="menu" data-state="open">...</button></div></div></div></header><div role="main" style="container-type:size;container-name:home-main-content;width:100%;height:100%;display:flex;flex-direction:column"><section class="_Hero_smoke_2"><h1 data-feature="game-source">你想让我们在 harness-mix 中构建什么?</h1></section><section class="[--thread-content-max-width:42rem]"><div data-composer-placement="home" class="composer-surface-chrome">随心输入<table><tbody><tr><td>响应式验证</td></tr></tbody></table></div></section></div></main></div>';
218
+ const fixtureStyle = document.createElement('style');
219
+ fixtureStyle.textContent = 'html,body,#root{width:100%;height:100%;margin:0}#root{display:flex}.app-shell-left-panel{flex:0 0 240px}.main-surface{position:relative;min-width:0;flex:1}.main-surface>header{position:absolute;z-index:2;width:100%;height:44px}.main-surface>header>div{display:flex;justify-content:space-between;padding:8px 12px}._Hero_smoke_2{display:flex;flex:0 0 44%;align-items:flex-end;justify-content:center;min-block-size:260px;padding-bottom:32px}h1{font-size:32px}.\\[--thread-content-max-width\\:42rem\\]{width:var(--thread-content-max-width);max-width:100%;margin:auto}.composer-surface-chrome{padding:24px}';
220
+ document.head.append(fixtureStyle);
221
+ NativeUI.applyRendererSkin('miku-488137', document);
222
+ await new Promise(resolve => setTimeout(resolve, 100));
223
+ const hero = getComputedStyle(document.querySelector('._Hero_smoke_2'));
224
+ const heading = getComputedStyle(document.querySelector('[data-feature="game-source"]'));
225
+ const header = getComputedStyle(document.querySelector('[data-pip-obstacle="app-shell-header"]'));
226
+ const headerAction = getComputedStyle(document.querySelector('.header-action'));
227
+ const titleSurface = getComputedStyle(document.querySelector('.thread-title-surface'));
228
+ const topFade = getComputedStyle(document.querySelector('[data-app-shell-main-content-top-fade]'));
229
+ const table = getComputedStyle(document.querySelector('table'));
230
+ return { flexBasis:hero.flexBasis, minBlockSize:parseFloat(hero.minBlockSize), fontSize:parseFloat(heading.fontSize), headerBackground:header.backgroundColor, headerActionBackground:headerAction.backgroundColor, titleBackground:titleSurface.backgroundColor, topFadeImage:topFade.backgroundImage, topFadeOpacity:topFade.opacity, tableBackground:table.backgroundColor };
231
+ })()`);
232
+ assert.equal(responsiveHome.flexBasis, 'auto');
233
+ assert.ok(responsiveHome.minBlockSize <= 200, `responsive hero remained too tall: ${responsiveHome.minBlockSize}px`);
234
+ assert.ok(responsiveHome.fontSize <= 28, `responsive heading remained too large: ${responsiveHome.fontSize}px`);
235
+ assert.equal(responsiveHome.headerBackground, 'rgba(0, 0, 0, 0)');
236
+ assert.equal(responsiveHome.headerActionBackground, 'rgba(0, 0, 0, 0)');
237
+ assert.equal(responsiveHome.titleBackground, 'rgba(0, 0, 0, 0)');
238
+ assert.equal(responsiveHome.topFadeImage, 'none');
239
+ assert.equal(responsiveHome.topFadeOpacity, '0');
240
+ assert.notEqual(responsiveHome.tableBackground, 'rgba(0, 0, 0, 0)');
241
+ fs.writeFileSync(path.join(out, 'skin-responsive-home.png'), (await win.webContents.capturePage()).toPNG());
242
+ await win.setSize(1920, 1000);
243
+ const wideThreadWidth = await win.webContents.executeJavaScript(`(() => getComputedStyle(document.querySelector('[class*="thread-content-max-width"]')).getPropertyValue('--thread-content-max-width').trim())()`);
244
+ assert.match(wideThreadWidth, /72rem/);
215
245
  await win.setSize(1020, 716);
216
246
  const readableConversation = await win.webContents.executeJavaScript(`(async () => {
217
247
  document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新建任务</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span>snipaste-pro</span><span>harness-mix</span><span class="active" data-app-action-sidebar-thread-active="true">查找换肤界面差异原因</span></nav><footer>Harness Mix</footer></aside><main class="main-surface chat" data-app-shell-main-surface="default"><header>查找换肤界面差异原因</header><div class="chat-scroll"><article data-local-conversation-final-assistant><div data-response-annotation-conversation><small>用时 19秒</small><p>是的,准确说是修改了 Codex UI 的运行时表现层。</p><p>它会:</p><ul><li>注入 CSS、替换颜色变量和背景图。</li><li>把部分原生面板变成半透明。</li><li>增加 Logo、拍立得等纯视觉装饰。</li><li>保留原有按钮、输入框、会话和交互逻辑。</li></ul><p>它不会修改 Codex 的安装包、模型调用、账号、权限或会话归属。</p></div></article></div><section class="composer-surface-chrome"><div>随心输入</div><footer>+ 完全访问 <span>GPT-5.6 Sol ↑</span></footer></section></main></div>';
@@ -229,9 +259,9 @@ app.whenReady().then(async () => {
229
259
  assert.equal(readableConversation.radius, '18px');
230
260
  fs.writeFileSync(path.join(out, 'skin-conversation-readable.png'), (await win.webContents.capturePage()).toPNG());
231
261
  const darkSurfaceResult = await win.webContents.executeJavaScript(`(async () => {
232
- document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新对话</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">暗色皮肤全界面适配</span></nav></aside><main class="main-surface dark-fixture" data-app-shell-main-surface="default"><header>暗色皮肤全界面适配</header><article data-response-annotation-conversation><p>正文、工具状态、评审卡和输入区都需要保持清晰。</p><section class="review-card"><div>已编辑 3 个文件</div><button class="review-action">审核</button><button class="review-row">src/settings/skin-runtime.ts</button><button class="review-row">scripts/native-ui-smoke.cjs</button></section></article><section class="composer-real" data-composer-layout="multiline"><input placeholder="随心输入"><button>完全访问</button><button>GPT-5.6 Sol</button></section></main></div>';
262
+ document.body.innerHTML = '<div id="root"><aside class="app-shell-left-panel"><button aria-haspopup="menu" aria-label="Open Codex menu"><span>Codex</span></button><nav><strong>新对话</strong><span>Pull Request</span><span>定时任务</span><span>插件</span><small>项目</small><span class="active" data-app-action-sidebar-thread-active="true">暗色皮肤全界面适配</span></nav></aside><main class="main-surface dark-fixture" data-app-shell-main-surface="default"><header>暗色皮肤全界面适配</header><div data-user-message-bubble="true"><div data-markdown-text-tone="user-message"><p data-markdown-han-text="true">暗色皮肤里的用户消息必须清晰可读。</p></div></div><article data-response-annotation-conversation><p>正文、工具状态、评审卡和输入区都需要保持清晰。</p><section class="review-card"><div>已编辑 3 个文件</div><button class="review-action">审核</button><button class="review-row">src/settings/skin-runtime.ts</button><button class="review-row">scripts/native-ui-smoke.cjs</button></section></article><section class="composer-real" data-composer-layout="multiline"><input placeholder="随心输入"><button>完全访问</button><button>GPT-5.6 Sol</button></section></main></div>';
233
263
  const darkFixtureStyle = document.createElement('style');
234
- darkFixtureStyle.textContent = 'html,body,#root{width:100%;height:100%;margin:0}.dark-fixture{position:relative;display:block;padding:24px}.dark-fixture>header{padding:12px 0}.dark-fixture article{max-width:700px;margin:80px auto}.review-card{margin-top:24px;padding:14px;border:1px solid var(--color-token-border-default);border-radius:12px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 50%,transparent)}.review-card button{color:var(--color-text-primary)}.review-action{float:right;background:var(--color-background-primary-soft-alpha)}.review-row{display:block;width:100%;margin-top:10px;padding:10px;text-align:left;border:0;background:color-mix(in srgb,var(--color-surface) 70%,transparent)}.composer-real{position:absolute;right:24px;bottom:16px;left:24px;padding:18px;border-radius:20px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 86%,transparent)}.composer-real input{width:70%;padding:8px;color:var(--color-text-primary);background:transparent;border:0}.composer-real button{margin-left:8px;color:var(--color-text-secondary);background:transparent;border:0}';
264
+ darkFixtureStyle.textContent = 'html,body,#root{width:100%;height:100%;margin:0}.dark-fixture{position:relative;display:block;padding:24px}.dark-fixture>header{padding:12px 0}.dark-fixture>[data-user-message-bubble]{max-width:520px;margin:24px 0 0 auto;padding:12px 16px;border-radius:16px}.dark-fixture article{max-width:700px;margin:60px auto}.review-card{margin-top:24px;padding:14px;border:1px solid var(--color-token-border-default);border-radius:12px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 50%,transparent)}.review-card button{color:var(--color-text-primary)}.review-action{float:right;background:var(--color-background-primary-soft-alpha)}.review-row{display:block;width:100%;margin-top:10px;padding:10px;text-align:left;border:0;background:color-mix(in srgb,var(--color-surface) 70%,transparent)}.composer-real{position:absolute;right:24px;bottom:16px;left:24px;padding:18px;border-radius:20px;background:color-mix(in srgb,var(--color-surface-elevated-secondary) 86%,transparent)}.composer-real input{width:70%;padding:8px;color:var(--color-text-primary);background:transparent;border:0}.composer-real button{margin-left:8px;color:var(--color-text-secondary);background:transparent;border:0}';
235
265
  document.head.append(darkFixtureStyle);
236
266
  NativeUI.applyRendererSkin('dragonball-nimbus', document);
237
267
  await new Promise(resolve => setTimeout(resolve, 50));
@@ -242,8 +272,10 @@ app.whenReady().then(async () => {
242
272
  const review = getComputedStyle(document.querySelector('.review-card'));
243
273
  const row = getComputedStyle(document.querySelector('.review-row'));
244
274
  const composer = getComputedStyle(document.querySelector('.composer-real'));
275
+ const userBubble = getComputedStyle(document.querySelector('[data-user-message-bubble]'));
276
+ const userText = getComputedStyle(document.querySelector('[data-markdown-text-tone="user-message"]'));
245
277
  const after = { buttons:document.querySelectorAll('button').length, inputs:document.querySelectorAll('input').length };
246
- return { before, after, active:document.documentElement.getAttribute('data-harness-mix-skin'), lightReviewBackground, reviewBackground:review.backgroundColor, rowBackground:row.backgroundColor, composerBackground:composer.backgroundColor, color:review.color };
278
+ return { before, after, active:document.documentElement.getAttribute('data-harness-mix-skin'), lightReviewBackground, reviewBackground:review.backgroundColor, rowBackground:row.backgroundColor, composerBackground:composer.backgroundColor, color:review.color, userBubbleColor:userBubble.color, userTextColor:userText.color, userBubbleBackground:userBubble.backgroundColor };
247
279
  })()`);
248
280
  assert.deepEqual(darkSurfaceResult.after, darkSurfaceResult.before);
249
281
  assert.equal(darkSurfaceResult.active, 'genshin-night');
@@ -252,6 +284,9 @@ app.whenReady().then(async () => {
252
284
  assert.doesNotMatch(darkSurfaceResult.rowBackground, /255, 255, 255/);
253
285
  assert.doesNotMatch(darkSurfaceResult.composerBackground, /255, 255, 255/);
254
286
  assert.equal(darkSurfaceResult.color, 'rgb(240, 230, 200)');
287
+ assert.equal(darkSurfaceResult.userBubbleColor, 'rgb(240, 230, 200)');
288
+ assert.equal(darkSurfaceResult.userTextColor, 'rgb(240, 230, 200)');
289
+ assert.notEqual(darkSurfaceResult.userBubbleBackground, 'rgba(0, 0, 0, 0)');
255
290
  fs.writeFileSync(path.join(out, 'skin-dark-all-surfaces.png'), (await win.webContents.capturePage()).toPNG());
256
291
  win.destroy();
257
292
  console.log('PASS: real Electron native picker, graphical Harness handoff, official Account/Usage, sharded Storage metrics, live About version and all project Harness/model SVGs load');
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { createPetMarket, readAsarHeader } = require('../src/main/native/pets');
8
+
9
+ function buildSyntheticAsar(targetPath, petName, blobContent) {
10
+ const tree = {
11
+ files: {
12
+ webview: {
13
+ files: {
14
+ assets: {
15
+ files: {
16
+ [`${petName}-spritesheet-v1-0123456789abcdef.webp`]: {
17
+ size: blobContent.length,
18
+ offset: '0',
19
+ },
20
+ },
21
+ },
22
+ },
23
+ },
24
+ },
25
+ };
26
+ const jsonBuf = Buffer.from(JSON.stringify(tree), 'utf8');
27
+ const jsonLen = jsonBuf.length;
28
+ const padLen = (4 - (jsonLen % 4)) % 4;
29
+ const pickleSize = 8 + jsonLen + padLen;
30
+ const head = Buffer.alloc(16);
31
+ head.writeUInt32LE(4, 0);
32
+ head.writeUInt32LE(pickleSize, 4);
33
+ head.writeUInt32LE(jsonLen + 4, 8);
34
+ head.writeUInt32LE(jsonLen, 12);
35
+ const asarBuf = Buffer.concat([head, jsonBuf, Buffer.alloc(padLen), blobContent]);
36
+ fs.writeFileSync(targetPath, asarBuf);
37
+ }
38
+
39
+ (async () => {
40
+ const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-mix-pets-test-'));
41
+ const fakeAsarDir = path.join(testRoot, 'resources');
42
+ fs.mkdirSync(fakeAsarDir, { recursive: true });
43
+ const fakeAsarPath = path.join(fakeAsarDir, 'app.asar');
44
+ const fakeCodexBin = path.join(fakeAsarDir, 'codex.exe');
45
+ fs.writeFileSync(fakeCodexBin, '');
46
+
47
+ const fakeSpriteBlob = Buffer.from('RIFF....WEBPVP8 ...fake-sprite-data...', 'utf8');
48
+ buildSyntheticAsar(fakeAsarPath, 'synthetic-buddy', fakeSpriteBlob);
49
+
50
+ const petsDir = path.join(testRoot, 'installed-pets');
51
+ fs.mkdirSync(petsDir, { recursive: true });
52
+
53
+ const env = {
54
+ CODEXHOST_STOCK_CODEX_PATH: fakeCodexBin,
55
+ HARNESS_MIX_PETS_DIR: petsDir,
56
+ };
57
+
58
+ try {
59
+ // 1. Verify readAsarHeader
60
+ const asarInfo = readAsarHeader(fakeAsarPath);
61
+ assert.ok(asarInfo.blobBase > 16, 'asar blobBase should be past header');
62
+ const assetFiles = asarInfo.tree?.files?.webview?.files?.assets?.files;
63
+ assert.ok(assetFiles && typeof assetFiles === 'object', 'asset tree found');
64
+
65
+ // 2. Test createPetMarket catalog()
66
+ const market = createPetMarket({ env });
67
+ const catalog = market.catalog();
68
+ assert.equal(catalog.officialAvailable, true, 'synthetic asar should be available');
69
+ assert.equal(catalog.petsDir, petsDir, 'petsDir matches env override');
70
+
71
+ const officialPet = catalog.data.find(p => p.id === 'synthetic-buddy');
72
+ assert.ok(officialPet, 'synthetic-buddy should be present in catalog');
73
+ assert.equal(officialPet.source, 'official');
74
+ assert.equal(officialPet.installed, false);
75
+ assert.equal(officialPet.displayName, 'Synthetic Buddy');
76
+
77
+ // Curated community pets should also be listed
78
+ const curatedSample = catalog.data.find(p => p.id === 'komi-shouko-pixel');
79
+ assert.ok(curatedSample, 'curated community pet komi-shouko-pixel should be present');
80
+ assert.equal(curatedSample.installed, false);
81
+
82
+ // 3. Test preview() for official pet
83
+ const previewRes = market.preview({ id: 'synthetic-buddy' });
84
+ assert.equal(previewRes.id, 'synthetic-buddy');
85
+ assert.equal(previewRes.mime, 'image/webp');
86
+ const previewBuf = Buffer.from(previewRes.dataBase64, 'base64');
87
+ assert.equal(previewBuf.toString('utf8'), fakeSpriteBlob.toString('utf8'), 'preview base64 should match sprite blob');
88
+
89
+ // Invalid pet id checks
90
+ assert.throws(() => market.preview({ id: '../bad-pet' }), /Invalid pet id/);
91
+ assert.throws(() => market.preview({ id: 'non-existent-pet' }), /Unknown pet/);
92
+
93
+ // 4. Test install() of official pet
94
+ const installRes = await market.install({ id: 'synthetic-buddy' });
95
+ assert.equal(installRes.id, 'synthetic-buddy');
96
+ assert.equal(installRes.installed, true);
97
+ assert.equal(installRes.alreadyInstalled, false);
98
+
99
+ const installedDir = path.join(petsDir, 'synthetic-buddy');
100
+ assert.ok(fs.existsSync(installedDir), 'pet directory created');
101
+ const petJsonPath = path.join(installedDir, 'pet.json');
102
+ const petSpritePath = path.join(installedDir, 'spritesheet.webp');
103
+ assert.ok(fs.existsSync(petJsonPath), 'pet.json exists');
104
+ assert.ok(fs.existsSync(petSpritePath), 'spritesheet.webp exists');
105
+
106
+ const meta = JSON.parse(fs.readFileSync(petJsonPath, 'utf8'));
107
+ assert.equal(meta.id, 'synthetic-buddy');
108
+ assert.equal(meta.displayName, 'Synthetic Buddy');
109
+ assert.equal(meta.spritesheetPath, 'spritesheet.webp');
110
+
111
+ // Installing again should be a no-op returning alreadyInstalled: true
112
+ const reinstallRes = await market.install({ id: 'synthetic-buddy' });
113
+ assert.equal(reinstallRes.alreadyInstalled, true);
114
+
115
+ // Catalog should now reflect installed: true
116
+ const updatedCatalog = market.catalog();
117
+ const installedEntry = updatedCatalog.data.find(p => p.id === 'synthetic-buddy');
118
+ assert.ok(installedEntry, 'installed pet present');
119
+ assert.equal(installedEntry.installed, true);
120
+ assert.equal(installedEntry.source, 'installed');
121
+
122
+ // 5. Test community() fallback and search
123
+ const communityRes = await market.community({ search: 'komi' });
124
+ assert.ok(communityRes.total >= 1);
125
+ assert.equal(communityRes.data[0].id, 'komi-shouko-pixel');
126
+
127
+ // 6. Test uninstall()
128
+ const uninstallRes = market.uninstall({ id: 'synthetic-buddy' });
129
+ assert.equal(uninstallRes.id, 'synthetic-buddy');
130
+ assert.equal(uninstallRes.removed, true);
131
+ assert.ok(!fs.existsSync(installedDir), 'pet directory should be deleted');
132
+
133
+ // Calling uninstall again should return removed: false
134
+ const uninstallAgain = market.uninstall({ id: 'synthetic-buddy' });
135
+ assert.equal(uninstallAgain.removed, false);
136
+
137
+ // Catalog after uninstall reflects installed: false
138
+ const catalogAfterUninstall = market.catalog();
139
+ const uninstalledEntry = catalogAfterUninstall.data.find(p => p.id === 'synthetic-buddy');
140
+ assert.equal(uninstalledEntry.installed, false);
141
+
142
+ console.log('Pets market native tests passed successfully!');
143
+ } finally {
144
+ fs.rmSync(testRoot, { recursive: true, force: true });
145
+ }
146
+ })();
@@ -13,6 +13,37 @@ const {
13
13
  } = require('./compatibility');
14
14
 
15
15
  const REPO_ROOT = path.resolve(__dirname, '../../..');
16
+ const LIVE_HOST_HEARTBEAT_MS = 15000;
17
+
18
+ function isCodexTaskEnvironment(env = process.env) {
19
+ return Boolean(
20
+ env.CODEX_THREAD_ID ||
21
+ env.CODEX_SESSION_ID ||
22
+ env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === 'Codex Desktop'
23
+ );
24
+ }
25
+
26
+ function readLiveHostInstance(dataDir, {
27
+ now = Date.now(),
28
+ isPidAlive = pidAlive,
29
+ maxAgeMs = LIVE_HOST_HEARTBEAT_MS,
30
+ } = {}) {
31
+ try {
32
+ const instance = JSON.parse(fs.readFileSync(path.join(dataDir, 'runtime', 'instance.json'), 'utf8'));
33
+ const age = now - Number(instance.beatAt || 0);
34
+ if (
35
+ instance.mode !== 'native-host' ||
36
+ !Number.isInteger(instance.pid) ||
37
+ instance.pid <= 0 ||
38
+ age < 0 ||
39
+ age > maxAgeMs ||
40
+ !isPidAlive(instance.pid)
41
+ ) return null;
42
+ return instance;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
16
47
 
17
48
  function cacheCodexRuntime(resources, cache) {
18
49
  fs.mkdirSync(cache, { recursive: true });
@@ -100,9 +131,12 @@ async function freePort() {
100
131
  async function launch(args = []) {
101
132
  const root = REPO_ROOT;
102
133
  const flags = new Set(args);
103
- const unknown = args.filter(arg => !['--check', '--update', '--no-update'].includes(arg));
134
+ const unknown = args.filter(arg => !['--check', '--update', '--no-update', '--restart'].includes(arg));
104
135
  if (unknown.length) throw new Error('Unsupported Launcher arguments');
105
136
  const dataDir = nativeEnvironment().CODEXHOST_DATA_DIR;
137
+ if (!flags.has('--check') && isCodexTaskEnvironment()) {
138
+ throw new Error('拒绝从 Codex 任务内部启动或重启 Harness Mix:这会终止当前 Codex Desktop 和正在执行的任务。请在 Codex 外部终端运行 Start-Codex.cmd 或 npm start。');
139
+ }
106
140
  if (flags.has('--update')) {
107
141
  const outcome = await runUpdateFlow({
108
142
  root, dataDir, mode: 'apply',
@@ -113,6 +147,13 @@ async function launch(args = []) {
113
147
  }
114
148
  return;
115
149
  }
150
+ if (!flags.has('--check') && !flags.has('--restart')) {
151
+ const live = readLiveHostInstance(dataDir);
152
+ if (live) {
153
+ console.log(`[Harness Mix] 已在运行(Host PID ${live.pid});不会重启当前 Codex Desktop。需要显式重启时使用 npm start -- --restart。`);
154
+ return;
155
+ }
156
+ }
116
157
  const installation = await inspect();
117
158
  const compatibility = installation.version === 'unknown'
118
159
  ? { state: 'unverified', desktopVersion: 'unknown', evidence: null }
@@ -185,4 +226,10 @@ async function launch(args = []) {
185
226
  controller.on('exit', () => clearTimeout(bootTimer));
186
227
  return controller;
187
228
  }
188
- module.exports = { inspect, launch, cacheCodexRuntime };
229
+ module.exports = {
230
+ inspect,
231
+ launch,
232
+ cacheCodexRuntime,
233
+ isCodexTaskEnvironment,
234
+ readLiveHostInstance,
235
+ };
@@ -75,6 +75,107 @@ function assetEntries(header) {
75
75
  return files && typeof files === 'object' ? files : {};
76
76
  }
77
77
 
78
+ const CURATED_COMMUNITY_PETS = [
79
+ {
80
+ id: 'komi-shouko-pixel',
81
+ displayName: '古见硝子·像素Q版',
82
+ description: 'Pixel chibi Komi Shouko with long purple-black hair, a navy school uniform and shy gestures. Unofficial fan art.',
83
+ spriteVersionNumber: 2,
84
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789522011437/komi-shouko-pixel/spritesheet.webp',
85
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789522011437/komi-shouko-pixel/preview.webp',
86
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789522011437/komi-shouko-pixel/poster.webp',
87
+ source: 'community',
88
+ },
89
+ {
90
+ id: 'rush',
91
+ displayName: 'Rush',
92
+ description: 'A confident training companion who meets every task with energy, focus, and a friendly smile.',
93
+ spriteVersionNumber: 2,
94
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789517766302/rush/spritesheet.webp',
95
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789517766302/rush/preview.webp',
96
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789517766302/rush/poster.webp',
97
+ source: 'community',
98
+ },
99
+ {
100
+ id: 'itasca',
101
+ displayName: 'Itasca',
102
+ description: 'A cute cat with a larger-than-life personality who loves nothing more than attention, going outside, and his little toy worm.',
103
+ spriteVersionNumber: 2,
104
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789484963415/itasca/spritesheet.webp',
105
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789484963415/itasca/preview.webp',
106
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789484963415/itasca/poster.webp',
107
+ source: 'community',
108
+ },
109
+ {
110
+ id: 'milk-mochi-soft',
111
+ displayName: 'Milk Mochi (牛奶麻薯)',
112
+ description: '말랑하게 걷고, 키보드로 일하고, 서류를 읽는 작은 우유떡 친구.',
113
+ spriteVersionNumber: 2,
114
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789458022939/milk-mochi-soft/spritesheet.webp',
115
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789458022939/milk-mochi-soft/preview.webp',
116
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789458022939/milk-mochi-soft/poster.webp',
117
+ source: 'community',
118
+ },
119
+ {
120
+ id: 'yuanyuan',
121
+ displayName: 'Yuanyuan (圆圆)',
122
+ description: 'A black-and-white Jack Russell puppy with a red collar.',
123
+ spriteVersionNumber: 1,
124
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789465085706/yuanyuan/spritesheet.webp',
125
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789465085706/yuanyuan/preview.webp',
126
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789465085706/yuanyuan/poster.webp',
127
+ source: 'community',
128
+ },
129
+ {
130
+ id: 'jaehyun-next-door',
131
+ displayName: 'Jaehyun Next Door',
132
+ description: 'An unofficial, fan-made desktop companion inspired by BOYNEXTDOOR Myung Jaehyun.',
133
+ spriteVersionNumber: 2,
134
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789469671052/jaehyun-next-door/spritesheet.webp',
135
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789469671052/jaehyun-next-door/preview.webp',
136
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789469671052/jaehyun-next-door/poster.webp',
137
+ source: 'community',
138
+ },
139
+ {
140
+ id: 'iu-beside-you',
141
+ displayName: 'IU Beside You',
142
+ description: 'An unofficial, fan-made desktop companion inspired by IU.',
143
+ spriteVersionNumber: 2,
144
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789469620585/iu-beside-you/spritesheet.webp',
145
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789469620585/iu-beside-you/preview.webp',
146
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789469620585/iu-beside-you/poster.webp',
147
+ source: 'community',
148
+ },
149
+ {
150
+ id: 'haaap',
151
+ displayName: 'Haaap',
152
+ description: '작업 중 바람을 계속 빨아들이는 분홍색 펫. 일반 동작 9개.',
153
+ spriteVersionNumber: 1,
154
+ spritesheetUrl: 'https://codex-pets.net/assets/pets/v/1789453516001/haaap/spritesheet.webp',
155
+ previewUrl: 'https://codex-pets.net/assets/pets/v/1789453516001/haaap/preview.webp',
156
+ posterUrl: 'https://codex-pets.net/assets/pets/v/1789453516001/haaap/poster.webp',
157
+ source: 'community',
158
+ },
159
+ ];
160
+
161
+ function getProxyDispatcher(env = process.env) {
162
+ const proxy = env.HTTPS_PROXY || env.HTTP_PROXY || env.ALL_PROXY;
163
+ if (!proxy) return undefined;
164
+ try {
165
+ const { ProxyAgent } = require('undici');
166
+ return new ProxyAgent(proxy);
167
+ } catch {
168
+ return undefined;
169
+ }
170
+ }
171
+
172
+ async function fetchWithProxy(url, options = {}, env = process.env) {
173
+ const dispatcher = getProxyDispatcher(env);
174
+ const opts = { ...options };
175
+ if (dispatcher) opts.dispatcher = dispatcher;
176
+ return fetch(url, opts);
177
+ }
178
+
78
179
  function createPetMarket({ env = process.env } = {}) {
79
180
  const headerCache = new Map(); // asarPath -> { mtimeMs, header }
80
181
  const previewCache = new Map(); // cacheKey -> { mime, dataBase64 }
@@ -130,6 +231,7 @@ function createPetMarket({ env = process.env } = {}) {
130
231
  displayName: typeof meta.displayName === 'string' && meta.displayName ? meta.displayName : titleCase(entry.name),
131
232
  description: typeof meta.description === 'string' ? meta.description : '',
132
233
  source: 'installed',
234
+ spriteVersionNumber: meta.spriteVersionNumber || 1,
133
235
  spritesheet: { name: spritesheet },
134
236
  });
135
237
  }
@@ -145,6 +247,7 @@ function createPetMarket({ env = process.env } = {}) {
145
247
  const data = [
146
248
  ...installed,
147
249
  ...official.pets.filter(pet => !installedIds.has(pet.id)).map(pet => ({ ...pet, installed: false })),
250
+ ...CURATED_COMMUNITY_PETS.filter(pet => !installedIds.has(pet.id) && !official.pets.some(o => o.id === pet.id)).map(pet => ({ ...pet, installed: false })),
148
251
  ];
149
252
  for (const pet of data) pet.installed = pet.installed ?? installedIds.has(pet.id);
150
253
  return {
@@ -154,6 +257,47 @@ function createPetMarket({ env = process.env } = {}) {
154
257
  };
155
258
  },
156
259
 
260
+ async community({ page = 1, pageSize = 24, sort = 'trending', search = '' } = {}) {
261
+ const installed = installedPets();
262
+ const installedIds = new Set(installed.map(p => p.id));
263
+ const queryParams = new URLSearchParams({
264
+ page: String(page),
265
+ pageSize: String(pageSize),
266
+ sort: String(sort || 'trending'),
267
+ });
268
+ if (search && String(search).trim()) queryParams.set('search', String(search).trim());
269
+ const apiUrl = `https://codex-pets.net/api/pets?${queryParams.toString()}`;
270
+ try {
271
+ const res = await fetchWithProxy(apiUrl, { signal: AbortSignal.timeout(8000) }, env);
272
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
273
+ const json = await res.json();
274
+ const pets = (json.pets || []).map(p => ({
275
+ id: p.id,
276
+ displayName: p.displayName || p.id,
277
+ description: p.description || '',
278
+ source: 'community',
279
+ spriteVersionNumber: p.spriteVersionNumber || 1,
280
+ spritesheetUrl: p.spritesheetUrl,
281
+ previewUrl: p.previewUrl,
282
+ posterUrl: p.posterUrl,
283
+ downloadUrl: p.downloadUrl,
284
+ installed: installedIds.has(p.id),
285
+ }));
286
+ return { data: pets, total: json.total || pets.length, source: 'online' };
287
+ } catch {
288
+ let filtered = CURATED_COMMUNITY_PETS;
289
+ if (search && String(search).trim()) {
290
+ const q = String(search).trim().toLowerCase();
291
+ filtered = filtered.filter(p => p.id.toLowerCase().includes(q) || p.displayName.toLowerCase().includes(q) || p.description.toLowerCase().includes(q));
292
+ }
293
+ return {
294
+ data: filtered.map(p => ({ ...p, installed: installedIds.has(p.id) })),
295
+ total: filtered.length,
296
+ source: 'curated-fallback',
297
+ };
298
+ }
299
+ },
300
+
157
301
  preview({ id } = {}) {
158
302
  if (!PET_ID_PATTERN.test(String(id || ''))) throw new Error('Invalid pet id');
159
303
  const cached = previewCache.get(id);
@@ -184,34 +328,51 @@ function createPetMarket({ env = process.env } = {}) {
184
328
  return result;
185
329
  },
186
330
 
187
- install({ id } = {}) {
331
+ async install({ id, displayName, description, spritesheetUrl, spriteVersionNumber } = {}) {
188
332
  if (!PET_ID_PATTERN.test(String(id || ''))) throw new Error('Invalid pet id');
189
- const official = officialPets();
190
- const pet = official.pets.find(entry => entry.id === id);
191
- if (!pet) throw new Error(official.asarPath ? `桌宠 ${id} 不在官方预载目录中` : '未找到 Codex Desktop 安装,无法获取官方桌宠');
192
333
  const targetDir = path.join(petsDirectory(env), id);
193
334
  const petJsonPath = path.join(targetDir, 'pet.json');
194
335
  const spritesheetPath = path.join(targetDir, 'spritesheet.webp');
195
336
  if (fs.existsSync(petJsonPath) && fs.existsSync(spritesheetPath)) {
196
337
  return { id, path: targetDir, installed: true, alreadyInstalled: true };
197
338
  }
198
- const fd = fs.openSync(official.asarPath, 'r');
339
+
340
+ const curated = CURATED_COMMUNITY_PETS.find(p => p.id === id);
341
+ const targetUrl = spritesheetUrl || curated?.spritesheetUrl;
342
+ const targetName = displayName || curated?.displayName || titleCase(id);
343
+ const targetDesc = description !== undefined ? description : (curated?.description || '');
344
+ const targetVer = spriteVersionNumber || curated?.spriteVersionNumber || 1;
345
+
199
346
  let buffer;
200
- try {
201
- buffer = Buffer.alloc(pet.spritesheet.size);
202
- const position = official.header.blobBase + Number(pet.spritesheet.offset);
203
- if (fs.readSync(fd, buffer, 0, pet.spritesheet.size, position) !== pet.spritesheet.size) {
204
- throw new Error('Spritesheet truncated');
347
+ if (targetUrl) {
348
+ const res = await fetchWithProxy(targetUrl, { signal: AbortSignal.timeout(30000) }, env);
349
+ if (!res.ok) throw new Error(`Download failed (${res.status})`);
350
+ const arrayBuf = await res.arrayBuffer();
351
+ buffer = Buffer.from(arrayBuf);
352
+ } else {
353
+ const official = officialPets();
354
+ const pet = official.pets.find(entry => entry.id === id);
355
+ if (!pet) throw new Error(official.asarPath ? `桌宠 ${id} 不在官方预载目录中` : '未找到 Codex Desktop 安装,无法获取官方桌宠');
356
+ const fd = fs.openSync(official.asarPath, 'r');
357
+ try {
358
+ buffer = Buffer.alloc(pet.spritesheet.size);
359
+ const position = official.header.blobBase + Number(pet.spritesheet.offset);
360
+ if (fs.readSync(fd, buffer, 0, pet.spritesheet.size, position) !== pet.spritesheet.size) {
361
+ throw new Error('Spritesheet truncated');
362
+ }
363
+ } finally {
364
+ fs.closeSync(fd);
205
365
  }
206
- } finally {
207
- fs.closeSync(fd);
208
366
  }
367
+
368
+ if (buffer.length > MAX_SPRITESHEET_BYTES) throw new Error('Spritesheet too large');
209
369
  fs.mkdirSync(targetDir, { recursive: true });
210
370
  fs.writeFileSync(spritesheetPath, buffer);
211
371
  fs.writeFileSync(petJsonPath, `${JSON.stringify({
212
372
  id,
213
- displayName: pet.displayName,
214
- description: pet.description,
373
+ displayName: targetName,
374
+ description: targetDesc,
375
+ spriteVersionNumber: targetVer,
215
376
  spritesheetPath: 'spritesheet.webp',
216
377
  }, null, 2)}\n`);
217
378
  previewCache.delete(id);
@@ -231,4 +392,4 @@ function createPetMarket({ env = process.env } = {}) {
231
392
  };
232
393
  }
233
394
 
234
- module.exports = { createPetMarket, petsDirectory, resolveAsarPath, readAsarHeader };
395
+ module.exports = { createPetMarket, petsDirectory, resolveAsarPath, readAsarHeader, CURATED_COMMUNITY_PETS };