@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.
- package/CHANGELOG.md +7 -0
- package/README.md +25 -30
- package/output/native-build/desktop-controller.mjs +1 -1
- package/output/native-build/renderer-extension.js +703 -41
- package/package.json +9 -8
- package/scripts/native-config-test.cjs +32 -2
- package/scripts/native-ui-smoke.cjs +39 -4
- package/scripts/pets-test.cjs +146 -0
- package/src/main/native/launcher.js +49 -2
- package/src/main/native/pets.js +176 -15
- package/src/main/native/protocol.js +9 -8
- package/src/native-ui/desktop-control/dist/renderer-draft-prewarm-runtime.js +4 -4
- package/src/native-ui/desktop-control/dist/renderer-draft-prewarm-runtime.js.map +1 -1
- package/src/native-ui/desktop-control/dist/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/desktop-control/src/renderer-draft-prewarm-runtime.ts +4 -4
- package/src/native-ui/desktop-control/test/renderer-draft-prewarm-policy.test.ts +7 -0
- package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/agent-selection-state.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +2 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +2 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +5 -0
- package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +57 -0
- package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/shell.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/agent-selection-state.ts +45 -14
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +69 -27
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +6 -0
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +3 -0
- package/src/native-ui/renderer-extension/src/settings/icons.ts +15 -12
- package/src/native-ui/renderer-extension/src/settings/localization.ts +2 -0
- package/src/native-ui/renderer-extension/src/settings/pages.ts +74 -66
- package/src/native-ui/renderer-extension/src/settings/pet-market.css +310 -0
- package/src/native-ui/renderer-extension/src/settings/pet-market.ts +447 -0
- package/src/native-ui/renderer-extension/src/settings/pets-client.ts +74 -0
- package/src/native-ui/renderer-extension/src/settings/shell.css +27 -8
- package/src/native-ui/renderer-extension/src/settings/shell.ts +4 -3
- package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +234 -3
- package/src/native-ui/renderer-extension/test/agent-selection-state.test.ts +58 -0
- package/src/native-ui/renderer-extension/test/renderer-binding-probe.test.ts +12 -0
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +1 -0
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -0
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
- 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.
|
|
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.
|
|
182
|
-
"@harness-mix/native-win32-arm64": "0.1.
|
|
183
|
-
"@harness-mix/native-darwin-x64": "0.1.
|
|
184
|
-
"@harness-mix/native-darwin-arm64": "0.1.
|
|
185
|
-
"@harness-mix/native-linux-x64": "0.1.
|
|
186
|
-
"@harness-mix/native-linux-arm64": "0.1.
|
|
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(
|
|
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:
|
|
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 = {
|
|
229
|
+
module.exports = {
|
|
230
|
+
inspect,
|
|
231
|
+
launch,
|
|
232
|
+
cacheCodexRuntime,
|
|
233
|
+
isCodexTaskEnvironment,
|
|
234
|
+
readLiveHostInstance,
|
|
235
|
+
};
|
package/src/main/native/pets.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
214
|
-
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 };
|