cortico-world-image 0.2.0 → 0.3.1
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/ENV_PROMPT.md +2 -2
- package/README.md +77 -101
- package/describe.json +1 -1
- package/dist/webui.js +15 -13
- package/package.json +2 -2
- package/src/chat-image-patch.js +93 -262
- package/src/config.js +2 -2
- package/src/world.js +23 -11
- package/tools/check.mjs +67 -64
- package/tools/patch.mjs +178 -398
package/tools/patch.mjs
CHANGED
|
@@ -1,25 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* QQ 发图路径补丁的工具:自测 / 落盘 / 验证 / 重启。
|
|
4
4
|
*
|
|
5
|
-
* node tools/patch.mjs
|
|
5
|
+
* node tools/patch.mjs 在副本上自测(幂等、备份、还原、坏原文必须报失败、旧桌宠补丁能还原)
|
|
6
6
|
* node tools/patch.mjs --apply 打到真实安装上(默认自动找 resources/app)
|
|
7
|
-
* node tools/patch.mjs --restart
|
|
7
|
+
* node tools/patch.mjs --restart 请控制台重启,让 QQ 扩展那侧的补丁装载
|
|
8
8
|
* node tools/patch.mjs --backups 看每处补丁与它的 .coopanion-orig 备份
|
|
9
9
|
* node tools/patch.mjs --restore 按备份还原(撤销补丁;可逆,之后能再打)
|
|
10
10
|
*
|
|
11
|
+
* 0.3.0 起只改第三方 QQ 扩展的 `src/world.ts`(两处),桌宠包一个字节都不动:
|
|
12
|
+
* 贴图改用官方的 `pet_say.images`。早先版本打进桌宠包的旧补丁,会在每次启动时按备份还原。
|
|
13
|
+
*
|
|
11
14
|
* 补丁会在每次启动时自动检查/重打(程序自动更新覆写后自愈);
|
|
12
15
|
* `--restore` 是手动撤销,`--apply` 是手动重打,面板上也有对应按钮。
|
|
13
16
|
*
|
|
14
17
|
* `--app <目录>` 指定 `resources/app`,默认从本扩展所在的那份安装往上找;
|
|
15
18
|
* `--port <端口>` 指定控制台端口,默认读 `<安装>/data/home/companion/config.json` 的 web.port。
|
|
16
19
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
21
|
import { tmpdir } from 'node:os';
|
|
20
22
|
import { basename, join, resolve } from 'node:path';
|
|
21
23
|
|
|
22
|
-
import { applyChatImagePatches, consolePort, patchBackupStatus, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches, rulesFor } from '../src/chat-image-patch.js';
|
|
24
|
+
import { applyChatImagePatches, consolePort, patchBackupStatus, patchSpecs, requestConsoleRestart, resolveAppRoot, restoreChatImagePatches, rulesFor } from '../src/chat-image-patch.js';
|
|
23
25
|
|
|
24
26
|
const argv = process.argv.slice(2);
|
|
25
27
|
const flag = (name) => argv.includes(`--${name}`);
|
|
@@ -35,7 +37,7 @@ const wantBackups = flag('backups') || flag('backup');
|
|
|
35
37
|
const wantSelfTest = !wantApply && !wantRestart && !wantRestore && !wantBackups;
|
|
36
38
|
|
|
37
39
|
const PACKAGE_DIR = resolve(import.meta.dirname, '..');
|
|
38
|
-
const
|
|
40
|
+
const QQ_PKG = 'cortico-world-qq-better-alont1';
|
|
39
41
|
|
|
40
42
|
let pass = 0;
|
|
41
43
|
const failures = [];
|
|
@@ -60,93 +62,20 @@ function pristine(src) {
|
|
|
60
62
|
return existsSync(orig) ? orig : src;
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
function syntaxCheck(appRoot, file, loader) {
|
|
66
|
-
const bin = join(appRoot, 'node_modules', '@esbuild', 'win32-x64', 'esbuild.exe');
|
|
67
|
-
if (!existsSync(bin)) return { skipped: true };
|
|
68
|
-
syntaxSeq += 1;
|
|
69
|
-
const out = join(tmpdir(), `coopanion-esbuild-${process.pid}-${syntaxSeq}-${basename(file)}.js`);
|
|
70
|
-
const res = spawnSync(bin, [file, '--format=esm', `--outfile=${out}`, '--log-level=warning'], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
71
|
-
try {
|
|
72
|
-
rmSync(out, { force: true });
|
|
73
|
-
} catch { /* 忽略 */ }
|
|
74
|
-
return { code: res.status, signal: res.signal, error: res.error ? String(res.error) : '' };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* 真跑一遍打过补丁的 `tools()` 那句 `return …;`。
|
|
79
|
-
*
|
|
80
|
-
* 为什么必须真跑:那句 return 的原文是 `.map(…)`(本身就是数组),
|
|
81
|
-
* 曾经被包成 `[EXPR, TOOL]` → 嵌套数组,框架读出来的 6 个老工具名全变 null。
|
|
82
|
-
* 这里配桩用 node 真执行那句表达式,断言结果是**扁平**数组、每项都有名字、含 pet_show_image。
|
|
83
|
-
*/
|
|
84
|
-
function toolsReturnHarness(worldFile) {
|
|
85
|
-
const text = readFileSync(worldFile, 'utf8');
|
|
86
|
-
const at = /tools\(\): ToolDef\[\] \{/.exec(text);
|
|
87
|
-
if (!at) return { ok: false, reason: '没找到 tools() 方法' };
|
|
88
|
-
const seg = text.slice(at.index, at.index + 3000);
|
|
89
|
-
const rm = /^([ \t]*)return ([^\n]*);$/m.exec(seg);
|
|
90
|
-
if (!rm) return { ok: false, reason: '没找到 tools() 里的 return' };
|
|
91
|
-
// 那句 return 会引用方法体里前面声明的局部量(桌宠 0.1.23 起是 `const own = petToolDecls(…)`)。
|
|
92
|
-
// 桩里把它们都声明成「两个假工具」的数组:真跑时若还有没桩到的名字,报错才说明补丁真的坏了。
|
|
93
|
-
const locals = [...seg.slice(0, rm.index).matchAll(/^[ \t]*const ([A-Za-z_$][\w$]*)\s*=/gm)]
|
|
94
|
-
.map((m) => m[1])
|
|
95
|
-
.filter((name, i, all) => all.indexOf(name) === i);
|
|
96
|
-
const dir = mkdtempSync(join(tmpdir(), 'coopanion-tools-return-'));
|
|
97
|
-
const file = join(dir, 'run.mjs');
|
|
98
|
-
const report = join(dir, 'out.json');
|
|
99
|
-
writeFileSync(
|
|
100
|
-
file,
|
|
101
|
-
[
|
|
102
|
-
"import { writeFileSync } from 'node:fs';",
|
|
103
|
-
"const DESKTOP_PET_TOOL_DECLS = [{ name: 'pet_say' }, { name: 'pet_ask' }];",
|
|
104
|
-
"const petToolDecls = () => [{ name: 'pet_say' }, { name: 'pet_ask' }];",
|
|
105
|
-
'const handlers = { pet_say: () => {}, pet_ask: () => {}, pet_walk_to: () => {} };',
|
|
106
|
-
"const CHAT_IMAGE_TOOL = { name: 'pet_show_image' };",
|
|
107
|
-
...locals.map((name) => `const ${name} = petToolDecls('zh');`),
|
|
108
|
-
"const world = { appLanguage: 'zh', cuaOn: false, cua: { tools: () => [] }, showImage: () => ({ text: 'ok' }) };",
|
|
109
|
-
`const out = (function () { return ${rm[2]}; }).call(world);`,
|
|
110
|
-
'writeFileSync(process.argv[2], JSON.stringify({',
|
|
111
|
-
' isArray: Array.isArray(out),',
|
|
112
|
-
" names: Array.isArray(out) ? out.map((d) => (d && typeof d.name === 'string' ? d.name : null)) : String(out).slice(0, 80),",
|
|
113
|
-
'}));',
|
|
114
|
-
].join('\n'),
|
|
115
|
-
);
|
|
116
|
-
// 用文件而不是管道传结果:沙箱里子进程开管道会 EPERM。
|
|
117
|
-
const res = spawnSync(process.execPath, [file, report], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
118
|
-
let out = null;
|
|
119
|
-
try {
|
|
120
|
-
out = JSON.parse(readFileSync(report, 'utf8'));
|
|
121
|
-
} catch { /* 下面统一报错 */ }
|
|
122
|
-
rmSync(dir, { recursive: true, force: true });
|
|
123
|
-
if (!out) return { ok: false, reason: `桩没跑出结果(exit ${res.status})` };
|
|
124
|
-
if (!out.isArray) return { ok: false, reason: `tools() 返回的不是数组:${out.names}` };
|
|
125
|
-
const nameless = out.names.filter((n) => !n);
|
|
126
|
-
if (nameless.length) return { ok: false, reason: `有 ${nameless.length} 项没名字(工具被嵌套包了一层?):${JSON.stringify(out.names)}` };
|
|
127
|
-
if (!out.names.includes('pet_show_image')) return { ok: false, reason: `少了 pet_show_image:${JSON.stringify(out.names)}` };
|
|
128
|
-
return { ok: out.names.length >= 3, names: out.names, reason: out.names.length >= 3 ? '' : `工具太少:${JSON.stringify(out.names)}` };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* 副本自测里那棵「假 QQ 扩展」:真安装上是 2400 多行 TS,这里只留补丁要认的那几行
|
|
133
|
-
* (类声明、两处相对路径解析、三处工具说明)。自测不进网络、不碰真安装。
|
|
134
|
-
*/
|
|
135
|
-
const QQ_FIXTURE_TS = [
|
|
65
|
+
/** QQ 扩展 `src/world.ts` 里我们认得的那些行(真安装 0.1.17 的写法)。 */
|
|
66
|
+
const QQ_FIXTURE = [
|
|
136
67
|
"import { existsSync } from 'node:fs';",
|
|
137
68
|
"import { isAbsolute, join } from 'node:path';",
|
|
138
|
-
'
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
" parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }],",
|
|
146
|
-
'};',
|
|
69
|
+
'',
|
|
70
|
+
'const DECLS = [',
|
|
71
|
+
" { name: 'qq_send_image', description: '给某个会话发一张图。`image` 给本地文件路径或 http(s) 链接(本机下载后再发),二者挑一个。',",
|
|
72
|
+
" parameters: [{ name: 'image', description: '本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。' }] },",
|
|
73
|
+
" { name: 'qq_view_image', parameters: [{ name: 'url', description: '可选:直接给图片链接(事件里带的图)或本地文件路径;给了就不再翻缓冲。' }] },",
|
|
74
|
+
'];',
|
|
75
|
+
'',
|
|
147
76
|
'export class QQWorld implements World {',
|
|
148
77
|
" private packageDir = '.';",
|
|
149
|
-
|
|
78
|
+
" private config = { memoryDir: 'ws' };",
|
|
150
79
|
' view(url: string) {',
|
|
151
80
|
" const p = isAbsolute(url) ? url : join(this.packageDir || '.', url);",
|
|
152
81
|
' return p;',
|
|
@@ -159,309 +88,158 @@ const QQ_FIXTURE_TS = [
|
|
|
159
88
|
'',
|
|
160
89
|
].join('\n');
|
|
161
90
|
|
|
162
|
-
/**
|
|
163
|
-
function
|
|
164
|
-
const
|
|
165
|
-
mkdirSync(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
function qqResolveHarness(patchedText) {
|
|
176
|
-
const m = /\n(\/\*\* coopanion-chat-image[\s\S]*?\n\})/.exec(patchedText);
|
|
177
|
-
if (!m) return { ok: false, reason: '抽不出 resolveLocalImagePath' };
|
|
178
|
-
// 抽出来的是 TS,node 直接跑要先去掉类型标注(补丁里只有这一处 `: string`)。
|
|
179
|
-
const fnJs = m[1].replace(/:\s*string/g, '');
|
|
180
|
-
const dir = mkdtempSync(join(tmpdir(), 'coopanion-qq-resolve-'));
|
|
181
|
-
const ws = join(dir, 'ws');
|
|
182
|
-
const ext = join(dir, 'ext');
|
|
183
|
-
mkdirSync(join(ws, 'blobs'), { recursive: true });
|
|
184
|
-
mkdirSync(join(ext, 'blobs'), { recursive: true });
|
|
185
|
-
writeFileSync(join(ws, 'blobs', 'only-ws.jpg'), 'a');
|
|
186
|
-
writeFileSync(join(ext, 'blobs', 'only-ext.jpg'), 'b');
|
|
187
|
-
const file = join(dir, 'run.mjs');
|
|
188
|
-
const report = join(dir, 'out.json');
|
|
189
|
-
writeFileSync(
|
|
190
|
-
file,
|
|
191
|
-
[
|
|
192
|
-
"import { existsSync, writeFileSync } from 'node:fs';",
|
|
193
|
-
"import { isAbsolute, join } from 'node:path';",
|
|
194
|
-
m[1] && fnJs,
|
|
195
|
-
'writeFileSync(process.argv[2], JSON.stringify({',
|
|
196
|
-
" abs: resolveLocalImagePath('C:/somewhere/pic.png', process.argv[3], process.argv[4]),",
|
|
197
|
-
" onlyWs: resolveLocalImagePath('blobs/only-ws.jpg', process.argv[3], process.argv[4]),",
|
|
198
|
-
" onlyExt: resolveLocalImagePath('blobs/only-ext.jpg', process.argv[3], process.argv[4]),",
|
|
199
|
-
" none: resolveLocalImagePath('blobs/nope.jpg', process.argv[3], process.argv[4]),",
|
|
200
|
-
" noWs: resolveLocalImagePath('blobs/nope.jpg', '', process.argv[4]),",
|
|
201
|
-
'}));',
|
|
202
|
-
].join('\n'),
|
|
203
|
-
);
|
|
204
|
-
const res = spawnSync(process.execPath, [file, report, ws, ext], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
205
|
-
let out = null;
|
|
206
|
-
try {
|
|
207
|
-
out = JSON.parse(readFileSync(report, 'utf8'));
|
|
208
|
-
} catch { /* 下面统一报错 */ }
|
|
209
|
-
if (!out) return { ok: false, reason: `没跑出结果(exit ${res.status})` };
|
|
210
|
-
const done = { ok: true, ws, ext, ...out };
|
|
211
|
-
rmSync(dir, { recursive: true, force: true });
|
|
212
|
-
return done;
|
|
91
|
+
/** 临时搭一份「像 resources/app」的目录:框架标记 + 桌宠包 + 假 QQ 扩展。 */
|
|
92
|
+
function makeFakeApp(root, { qqText = QQ_FIXTURE } = {}) {
|
|
93
|
+
const app = join(root, 'resources', 'app');
|
|
94
|
+
mkdirSync(join(app, 'build', 'cortico', 'src'), { recursive: true });
|
|
95
|
+
writeFileSync(join(app, 'build', 'cortico', 'src', 'paths.ts'), 'export {};\n');
|
|
96
|
+
const petDir = join(app, 'node_modules', 'cortico-world-desktop-pet', 'src');
|
|
97
|
+
mkdirSync(petDir, { recursive: true });
|
|
98
|
+
writeFileSync(join(petDir, 'world.ts'), 'export class DesktopPetWorld {}\n');
|
|
99
|
+
const qqDir = join(app, 'data', 'extensions', 'node_modules', QQ_PKG, 'src');
|
|
100
|
+
mkdirSync(qqDir, { recursive: true });
|
|
101
|
+
const qq = join(qqDir, 'world.ts');
|
|
102
|
+
if (qqText !== null) writeFileSync(qq, qqText);
|
|
103
|
+
return { app, petDir, qq };
|
|
213
104
|
}
|
|
214
105
|
|
|
215
|
-
/**
|
|
216
|
-
function
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
const at = worldText.indexOf(head);
|
|
221
|
-
if (at < 0) return { ok: false, reason: '抽不到 showImage()(pet-show-image-tool 没打上?)' };
|
|
222
|
-
const stop = worldText.indexOf('\n }\n', at);
|
|
223
|
-
if (stop < 0) return { ok: false, reason: 'showImage() 的结尾找不到' };
|
|
224
|
-
const method = worldText.slice(at, stop + 4);
|
|
225
|
-
const sniffHead = 'function sniffImageMime(bytes: Buffer): string | null {';
|
|
226
|
-
const sniffAt = worldText.indexOf(sniffHead);
|
|
227
|
-
if (sniffAt < 0) return { ok: false, reason: '抽不到 sniffImageMime()' };
|
|
228
|
-
const sniffStop = worldText.indexOf('\n}\n', sniffAt);
|
|
229
|
-
if (sniffStop < 0) return { ok: false, reason: 'sniffImageMime() 的结尾找不到' };
|
|
230
|
-
const sniff = worldText.slice(sniffAt, sniffStop + 2);
|
|
231
|
-
|
|
232
|
-
const dir = mkdtempSync(join(tmpdir(), 'coopanion-show-image-'));
|
|
233
|
-
const ws = join(dir, 'ws');
|
|
234
|
-
const ext = join(dir, 'ext');
|
|
235
|
-
mkdirSync(join(ext, 'blobs'), { recursive: true });
|
|
236
|
-
const script = join(dir, 'run.mts');
|
|
237
|
-
const report = join(dir, 'report.json');
|
|
238
|
-
writeFileSync(
|
|
239
|
-
script,
|
|
240
|
-
`import { mkdirSync, writeFileSync } from 'node:fs';
|
|
241
|
-
import { basename, extname, isAbsolute, join } from 'node:path';
|
|
242
|
-
import { readFileSync } from 'node:fs';
|
|
243
|
-
|
|
244
|
-
type ToolOutcome = { text: string; failed?: boolean };
|
|
245
|
-
const IMAGE_MIME_BY_EXT: Record<string, string> = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
246
|
-
const IMAGE_MAX_BYTES = 8 * 1024 * 1024;
|
|
247
|
-
|
|
248
|
-
${sniff}
|
|
249
|
-
|
|
250
|
-
const WS = ${JSON.stringify(ws)};
|
|
251
|
-
const EXT = ${JSON.stringify(ext)};
|
|
252
|
-
|
|
253
|
-
class Pet {
|
|
254
|
-
host: unknown = null;
|
|
255
|
-
chat = { size: 0 };
|
|
256
|
-
opts: { workspaceDir?: () => string } = {};
|
|
257
|
-
constructor(opts: { workspaceDir?: () => string }) { this.opts = opts; }
|
|
258
|
-
private async record(..._args: unknown[]): Promise<number | null> { return 1; }
|
|
259
|
-
${method}
|
|
106
|
+
/** 让副本里也能有宿主配置(`worlds.<id>.enabled`)——补丁只为开着的世界打。 */
|
|
107
|
+
function writeWorldSwitch(app, worlds) {
|
|
108
|
+
const dir = join(app, '..', '..', 'data', 'home', 'companion');
|
|
109
|
+
mkdirSync(dir, { recursive: true });
|
|
110
|
+
writeFileSync(join(dir, 'config.json'), `${JSON.stringify({ web: { port: 17788 }, worlds }, null, 2)}\n`);
|
|
260
111
|
}
|
|
261
112
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
writeFileSync(join(WS, 'blobs', 'ok.png'), Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(16)]));
|
|
265
|
-
const withWs = () => new Pet({ workspaceDir: () => WS });
|
|
266
|
-
out.relMissing = (await withWs().showImage({ path: 'blobs/nope.jpg' })).text;
|
|
267
|
-
out.relFound = (await withWs().showImage({ path: 'blobs/ok.png' })).text;
|
|
268
|
-
out.abs = (await withWs().showImage({ path: join(WS, 'blobs', 'ok.png') })).text;
|
|
269
|
-
out.noWs = (await new Pet({}).showImage({ path: 'blobs/ok.png' })).text;
|
|
270
|
-
writeFileSync(${JSON.stringify(report)}, JSON.stringify(out, null, 2));
|
|
271
|
-
`,
|
|
272
|
-
);
|
|
273
|
-
// 用 esbuild 编成 JS 再让 node 跑:tsx 起 esbuild 服务要 spawn 管道,沙箱里会 EPERM。
|
|
274
|
-
const compiled = join(dir, 'run.mjs');
|
|
275
|
-
const built = spawnSync(bin, [script, '--format=esm', `--outfile=${compiled}`, '--log-level=error'], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
276
|
-
if (built.status !== 0) return { ok: false, reason: `esbuild 编不过抽出来的 showImage()(exit ${built.status})` };
|
|
277
|
-
const res = spawnSync(process.execPath, [compiled], { stdio: ['ignore', 'inherit', 'inherit'] });
|
|
278
|
-
let out = null;
|
|
113
|
+
function selfTest() {
|
|
114
|
+
const work = mkdtempSync(join(tmpdir(), 'image-patch-'));
|
|
279
115
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
ok(
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
ok(
|
|
344
|
-
ok(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const after3 = applyChatImagePatches(work3, { stateDir: state3 });
|
|
392
|
-
ok(after3.patched.length === 6 && after3.failed.length === 0, '还原之后还能重新打上(还原是可逆的)', JSON.stringify({ patched: after3.patched.length, failed: after3.failed }));
|
|
393
|
-
rmSync(work3, { recursive: true, force: true });
|
|
394
|
-
|
|
395
|
-
const world = readFileSync(join(petDst, 'world.ts'), 'utf8');
|
|
396
|
-
ok(world.includes('pet_show_image') && world.includes('sniffImageMime') && world.includes("tags: ['act']"), 'world.ts:pet_show_image 工具与实现');
|
|
397
|
-
ok(world.includes('workspaceDir?: () => string;'), 'world.ts:世界选项多了 workspaceDir(装配层注入)');
|
|
398
|
-
ok(world.includes(" let raw = typeof args.path === 'string' ? args.path.trim() : '';"), 'world.ts:路径那行改成 let(不然相对路径拼不上)');
|
|
399
|
-
ok(world.includes('raw = join(ws, raw);') && world.includes('this.opts.workspaceDir?.()'), 'world.ts:相对路径真的拼到桌宠工作区上');
|
|
400
|
-
ok(world.includes('blobs/xx.jpg'), 'world.ts:工具说明写明可以给相对工作区的路径');
|
|
401
|
-
ok(existsSync(join(petDst, 'world.ts.coopanion-orig')), '改写前留了 .coopanion-orig 备份');
|
|
402
|
-
ok(readFileSync(join(petDst, 'world.ts.coopanion-orig'), 'utf8') === readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8'), '备份是改写前的原文');
|
|
403
|
-
const worldTools = toolsReturnHarness(join(petDst, 'world.ts'));
|
|
404
|
-
ok(worldTools.ok, 'tools() 真跑一遍:扁平数组、每项都有名字、含 pet_show_image', worldTools.reason || JSON.stringify(worldTools.names));
|
|
405
|
-
|
|
406
|
-
const showRun = showImageHarness(appRoot, world);
|
|
407
|
-
if (showRun.skipped) console.log(` .. ${showRun.skipped}`);
|
|
408
|
-
else if (!showRun.ok) ok(false, '把 showImage() 抽出来真跑一遍', showRun.reason);
|
|
409
|
-
else {
|
|
410
|
-
ok(showRun.relMissing.includes(join(showRun.ws, 'blobs', 'nope.jpg')) && !showRun.relMissing.includes(showRun.ext), '相对路径按桌宠工作区解析(不是扩展目录)', showRun.relMissing);
|
|
411
|
-
ok(showRun.relFound.includes('没连着对话记录'), '工作区里真有那张图时读得到(走到「没连着对话记录」)', showRun.relFound);
|
|
412
|
-
ok(showRun.abs.includes('没连着对话记录'), '绝对路径照旧能用', showRun.abs);
|
|
413
|
-
ok(showRun.noWs.includes('相对路径没有工作区可拼'), '装配层没给工作区时明确报错', showRun.noWs);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const s2 = syntaxCheck(appRoot, join(petDst, 'world.ts'), 'ts');
|
|
417
|
-
const s4 = syntaxCheck(appRoot, join(petDst, 'definition.ts'), 'ts');
|
|
418
|
-
if (s2.skipped) console.log(' .. esbuild 不在,跳过语法检查');
|
|
419
|
-
else {
|
|
420
|
-
ok(s2.code === 0, 'world.ts 语法通过(esbuild)', s2.error || String(s2.code));
|
|
421
|
-
ok(s4.code === 0, 'definition.ts 语法通过(esbuild)', s4.error || String(s4.code));
|
|
116
|
+
head('补丁清单');
|
|
117
|
+
const specs = patchSpecs(join(work, 'resources', 'app'));
|
|
118
|
+
const ids = specs.map((s) => s.id);
|
|
119
|
+
ok(specs.length === 2, '只有 2 处补丁(桌宠包上不再打补丁)', String(specs.length));
|
|
120
|
+
ok(ids.includes('qq-image-relpaths') && ids.includes('qq-image-hints'), '两处都是 QQ 扩展的', ids.join(','));
|
|
121
|
+
ok(specs.every((s) => s.optional === true), '两处都是可选补丁(没装 QQ 扩展不算失败)');
|
|
122
|
+
ok(specs.every((s) => s.file.includes(QQ_PKG) && s.file.endsWith('world.ts')), '都落在 QQ 扩展的 src/world.ts', JSON.stringify(specs.map((s) => s.file)));
|
|
123
|
+
ok(specs.every((s) => s.worldId === 'qqbot'), '两处都挂在 world「qqbot」上(切到官方 QQ 扩展后自动跳过)', JSON.stringify(specs.map((s) => s.worldId)));
|
|
124
|
+
ok(specs.every((s) => typeof s.what === 'string' && s.what.length > 4), '每处都有说明');
|
|
125
|
+
|
|
126
|
+
head('改写规则(单文件)');
|
|
127
|
+
const rel = rulesFor('qq-image-relpaths');
|
|
128
|
+
ok(typeof rel?.done === 'function' && typeof rel?.apply === 'function', 'qq-image-relpaths 有 done/apply');
|
|
129
|
+
ok(Boolean(rel.apply('export const nope = 1;\n')?.error), '认不出原文时报失败,不瞎改');
|
|
130
|
+
const relOut = rel.apply(QQ_FIXTURE);
|
|
131
|
+
ok(!relOut?.error, '打卡片上的原文能改出来', relOut?.error ?? '');
|
|
132
|
+
ok((relOut?.text ?? '').includes('function resolveLocalImagePath(p: string, memoryDir: string, packageDir: string): string {'), '插进了按工作区解析的助手');
|
|
133
|
+
ok((relOut?.text ?? '').includes('const p = resolveLocalImagePath(image, this.config.memoryDir, this.packageDir);'), '发图那行改成按工作区解析');
|
|
134
|
+
ok((relOut?.text ?? '').includes('const p = resolveLocalImagePath(url, this.config.memoryDir, this.packageDir);'), '看图那行改成按工作区解析');
|
|
135
|
+
ok(rel.done(relOut?.text ?? ''), '改完认成「已打过」(幂等)');
|
|
136
|
+
ok((relOut?.text ?? '').includes("import { existsSync } from 'node:fs';") && (relOut?.text ?? '').includes("import { isAbsolute, join } from 'node:path';"), '本来就够的导入不动一行');
|
|
137
|
+
|
|
138
|
+
const hints = rulesFor('qq-image-hints');
|
|
139
|
+
ok(typeof hints?.done === 'function' && typeof hints?.apply === 'function', 'qq-image-hints 有 done/apply');
|
|
140
|
+
ok(Boolean(hints.apply('export const nope = 1;\n')?.error), '一句都认不出来时报失败');
|
|
141
|
+
const hintOut = hints.apply(QQ_FIXTURE);
|
|
142
|
+
ok(!hintOut?.error, '三处说明都能改出来', hintOut?.error ?? '');
|
|
143
|
+
ok((hintOut?.text ?? '').includes('本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接。'), '发图参数说明改成「绝对路径或相对工作区」');
|
|
144
|
+
ok((hintOut?.text ?? '').includes('或本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg);给了就不再翻缓冲。'), '看图参数说明同步');
|
|
145
|
+
ok((hintOut?.text ?? '').includes('`image` 给本地文件路径(绝对路径,或相对桌宠工作区的路径,如 blobs/xx.jpg)或 http(s) 链接'), '工具总说明同步');
|
|
146
|
+
ok(hints.done(hintOut?.text ?? ''), '改完认成「已打过」(幂等)');
|
|
147
|
+
// 上游把某一句整句换掉:跳过那一处,不算失败(否则每次启动都记一条失败)。
|
|
148
|
+
const reworded = QQ_FIXTURE.replace("'本地文件路径(如 /home/user/pics/a.png)或 http(s) 链接。'", "'图片本地路径或链接。'");
|
|
149
|
+
const partial = hints.apply(reworded);
|
|
150
|
+
ok(!partial?.error && (partial?.text ?? '').includes('给了就不再翻缓冲。'), '上游换掉一句时只跳过那一处,不算失败', partial?.error ?? '');
|
|
151
|
+
|
|
152
|
+
head('落盘 / 备份 / 还原(副本)');
|
|
153
|
+
const a1 = makeFakeApp(join(work, 'run1'));
|
|
154
|
+
const first = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
|
|
155
|
+
ok(first.patched.length === 2, '两处都打上了(文件形式)', JSON.stringify(first.results.map((r) => `${r.id}:${r.state}`)));
|
|
156
|
+
ok(first.failed.length === 0, '没有失败项', first.failed.join(','));
|
|
157
|
+
const onDisk = readFileSync(a1.qq, 'utf8');
|
|
158
|
+
ok(onDisk.includes('resolveLocalImagePath(image, this.config.memoryDir'), '磁盘上那份真的改了');
|
|
159
|
+
ok(existsSync(`${a1.qq}.coopanion-orig`) && readFileSync(`${a1.qq}.coopanion-orig`, 'utf8') === QQ_FIXTURE, '改写前留了原文备份(.coopanion-orig)');
|
|
160
|
+
ok(first.restartPending === true, '第一次改过文件 → 请求重启');
|
|
161
|
+
|
|
162
|
+
const second = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
|
|
163
|
+
ok(second.patched.length === 0 && second.failed.length === 0, '再跑一遍是幂等的(0 改动、0 失败)', JSON.stringify({ patched: second.patched, failed: second.failed }));
|
|
164
|
+
ok(second.results.every((r) => r.state === 'done'), '两处都记 done', JSON.stringify(second.results.map((r) => r.state)));
|
|
165
|
+
ok(second.restartPending === false, '没改东西就不请求重启');
|
|
166
|
+
|
|
167
|
+
const rows = patchBackupStatus(a1.app);
|
|
168
|
+
ok(rows.length === 2 && rows.every((r) => r.patched && r.hasBackup && !r.same), '备份状态:两处都「已打 + 有备份 + 与当前不同」', JSON.stringify(rows.map((r) => `${r.id}:${r.patched}/${r.hasBackup}/${r.same}`)));
|
|
169
|
+
|
|
170
|
+
const back = restoreChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
|
|
171
|
+
ok(back.restored.length === 1 && !back.failed.length, '按备份还原了 1 个文件', JSON.stringify({ restored: back.restored, failed: back.failed }));
|
|
172
|
+
ok(readFileSync(a1.qq, 'utf8') === QQ_FIXTURE, '还原后与原文逐字节一致');
|
|
173
|
+
const reapply = applyChatImagePatches(a1.app, { stateDir: join(work, 'state1') });
|
|
174
|
+
ok(reapply.patched.length === 2, '还原之后还能再打回去(可逆)', JSON.stringify(reapply.patched));
|
|
175
|
+
|
|
176
|
+
head('没装 / 改坏了 QQ 扩展');
|
|
177
|
+
const a2 = makeFakeApp(join(work, 'run2'), { qqText: null });
|
|
178
|
+
const noQq = applyChatImagePatches(a2.app, { stateDir: join(work, 'state2') });
|
|
179
|
+
ok(noQq.failed.length === 0 && noQq.results.every((r) => r.state === 'absent'), '没装 QQ 扩展时两处记 absent、不算失败', JSON.stringify(noQq.results.map((r) => `${r.id}:${r.state}`)));
|
|
180
|
+
ok(!noQq.restartPending, '没装 QQ 扩展时不请求重启');
|
|
181
|
+
|
|
182
|
+
const a3 = makeFakeApp(join(work, 'run3'), { qqText: 'export class QQWorld { /* 上游大改 */ }\n' });
|
|
183
|
+
const broken = applyChatImagePatches(a3.app, { stateDir: join(work, 'state3') });
|
|
184
|
+
ok(broken.failed.length === 2, '认不出原文时两处都记失败', JSON.stringify(broken.results.map((r) => `${r.id}:${r.state}`)));
|
|
185
|
+
ok(readFileSync(a3.qq, 'utf8') === 'export class QQWorld { /* 上游大改 */ }\n', '认不出就不动那个文件');
|
|
186
|
+
ok(existsSync(`${a3.qq}.coopanion-orig`) === false, '没改成也就不会留备份');
|
|
187
|
+
|
|
188
|
+
head('旧桌宠补丁的还原(0.3.0)');
|
|
189
|
+
const a4 = makeFakeApp(join(work, 'run4'));
|
|
190
|
+
const petFile = join(a4.petDir, 'world.ts');
|
|
191
|
+
const petPristine = 'export class DesktopPetWorld {}\n';
|
|
192
|
+
writeFileSync(petFile, `${petPristine}/* coopanion-chat-image: pet_show_image 的旧补丁 */\n`);
|
|
193
|
+
writeFileSync(`${petFile}.coopanion-orig`, petPristine);
|
|
194
|
+
const rev = applyChatImagePatches(a4.app, { stateDir: join(work, 'state4') });
|
|
195
|
+
ok(rev.reverted.some((r) => r.reverted), '桌宠包上的旧补丁被还原', JSON.stringify(rev.reverted));
|
|
196
|
+
ok(readFileSync(petFile, 'utf8') === petPristine, '桌宠包回到上游原样(一个字节都没留)');
|
|
197
|
+
ok(rev.restartPending === true, '还原也算「这次改了东西」→ 请求重启');
|
|
198
|
+
const revAgain = applyChatImagePatches(a4.app, { stateDir: join(work, 'state4') });
|
|
199
|
+
ok(revAgain.reverted.every((r) => !r.reverted), '还原过一次之后不再重复还原');
|
|
200
|
+
|
|
201
|
+
head('世界没开时跳过(切到官方 QQ 扩展)');
|
|
202
|
+
const a5 = makeFakeApp(join(work, 'run5'));
|
|
203
|
+
writeWorldSwitch(a5.app, { qqbot: { enabled: false }, qq: { enabled: true } });
|
|
204
|
+
const off = applyChatImagePatches(a5.app, { stateDir: join(work, 'state5') });
|
|
205
|
+
ok(off.results.length === 2 && off.results.every((r) => r.state === 'absent'), 'qqbot 关掉时两处都记 absent', JSON.stringify(off.results.map((r) => `${r.id}:${r.state}`)));
|
|
206
|
+
ok(off.results.every((r) => /worlds\.qqbot\.enabled=false/.test(r.error ?? '')), 'absent 里写明是「世界没开」', JSON.stringify(off.results.map((r) => r.error)));
|
|
207
|
+
ok(!readFileSync(a5.qq, 'utf8').includes('resolveLocalImagePath'), '文件一个字节都没动');
|
|
208
|
+
ok(!existsSync(`${a5.qq}.coopanion-orig`), '也不会平白留一份备份');
|
|
209
|
+
ok(off.restartPending === false, '不请求重启');
|
|
210
|
+
ok(patchBackupStatus(a5.app).length === 0, '状态页的备份行也不显示它');
|
|
211
|
+
|
|
212
|
+
const a6 = makeFakeApp(join(work, 'run6'));
|
|
213
|
+
writeWorldSwitch(a6.app, { qqbot: { enabled: true } });
|
|
214
|
+
const on = applyChatImagePatches(a6.app, { stateDir: join(work, 'state6') });
|
|
215
|
+
ok(on.patched.length === 2, 'qqbot 开着时照常打上', JSON.stringify(on.results.map((r) => `${r.id}:${r.state}`)));
|
|
216
|
+
ok(patchBackupStatus(a6.app).length === 2, '开着时备份状态两行');
|
|
217
|
+
|
|
218
|
+
head('控制台端口');
|
|
219
|
+
const cfgApp = join(work, 'port');
|
|
220
|
+
mkdirSync(join(cfgApp, 'data', 'home', 'companion'), { recursive: true });
|
|
221
|
+
writeFileSync(join(cfgApp, 'data', 'home', 'companion', 'config.json'), JSON.stringify({ web: { port: 18099 } }));
|
|
222
|
+
ok(consolePort({ appRoot: join(cfgApp, 'resources', 'app') }) === 18099, '端口从 <安装>/data/home/companion/config.json 读出来');
|
|
223
|
+
ok(consolePort({ appRoot: join(cfgApp, 'resources', 'app'), explicit: 12345 }) === 12345, '显式 --port 优先');
|
|
224
|
+
ok(consolePort({ appRoot: join(work, 'nope') }) === 17788, '读不到就回默认 17788');
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(work, { recursive: true, force: true });
|
|
422
227
|
}
|
|
423
|
-
|
|
424
|
-
// 反向用例:原文被改过时必须报失败,而不是硬猜着改。
|
|
425
|
-
const bad = join(work, 'bad');
|
|
426
|
-
const badPet = join(bad, 'node_modules', 'cortico-world-desktop-pet', 'src');
|
|
427
|
-
mkdirSync(badPet, { recursive: true });
|
|
428
|
-
mkdirSync(join(bad, 'build', 'cortico', 'src'), { recursive: true });
|
|
429
|
-
writeFileSync(join(bad, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
|
|
430
|
-
writeFileSync(join(badPet, 'world.ts'), readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8').replace(' private notConnected(tool: string): ToolOutcome {', ' private notConnectedRenamed(tool: string): ToolOutcome {'));
|
|
431
|
-
writeFileSync(join(badPet, 'definition.ts'), readFileSync(pristine(join(petSrc, 'definition.ts')), 'utf8'));
|
|
432
|
-
const third = applyChatImagePatches(bad, { stateDir: join(bad, 'state') });
|
|
433
|
-
ok(third.failed.includes('pet-show-image-tool'), '原文对不上时记失败:pet-show-image-tool', JSON.stringify(third.failed));
|
|
434
|
-
ok(third.results.find((r) => r.id === 'pet-show-image-tool')?.state === 'failed' && third.results.find((r) => r.id === 'pet-show-image-tool')?.error, '失败项状态是 failed 并给出原因', JSON.stringify(third.results.find((r) => r.id === 'pet-show-image-tool')));
|
|
435
|
-
ok(third.patched.length === 4, '同一份文件里别的补丁照常打上(world.ts 里不依赖 showImage() 的那几条)', JSON.stringify(third.patched));
|
|
436
|
-
|
|
437
|
-
// 上一版写坏的 `[EXPR, TOOL]`(嵌套数组)要能被认出来并修成扁平的。
|
|
438
|
-
const work4 = mkdtempSync(join(tmpdir(), 'coopanion-chat-image-nested-'));
|
|
439
|
-
const pet4 = join(work4, 'node_modules', 'cortico-world-desktop-pet', 'src');
|
|
440
|
-
mkdirSync(pet4, { recursive: true });
|
|
441
|
-
mkdirSync(join(work4, 'build', 'cortico', 'src'), { recursive: true });
|
|
442
|
-
writeFileSync(join(work4, 'build', 'cortico', 'src', 'paths.ts'), 'export function repoRoot(): string { return "stub"; }\n');
|
|
443
|
-
copyFileSync(pristine(join(petSrc, 'definition.ts')), join(pet4, 'definition.ts'));
|
|
444
|
-
const nestedWorld = (() => {
|
|
445
|
-
const src = readFileSync(pristine(join(petSrc, 'world.ts')), 'utf8');
|
|
446
|
-
const at4 = /tools\(\): ToolDef\[\] \{/.exec(src);
|
|
447
|
-
const seg4 = src.slice(at4.index, at4.index + 3000);
|
|
448
|
-
const rm4 = /^([ \t]*)return ([^\n]*);$/m.exec(seg4);
|
|
449
|
-
const line = `${rm4[1]}return [${rm4[2]}, { ...CHAT_IMAGE_TOOL, handler: (args) => this.showImage(args) }];`;
|
|
450
|
-
return src.slice(0, at4.index + rm4.index) + line + src.slice(at4.index + rm4.index + rm4[0].length);
|
|
451
|
-
})();
|
|
452
|
-
writeFileSync(join(pet4, 'world.ts'), nestedWorld);
|
|
453
|
-
const fixed4 = applyChatImagePatches(work4, { stateDir: join(work4, 'state') });
|
|
454
|
-
ok(fixed4.failed.length === 0 && fixed4.results.find((r) => r.id === 'pet-show-image-tool')?.state === 'patched', '旧的嵌套写法认成「没打」并重写(不是当成已完成)', JSON.stringify({ failed: fixed4.failed, state: fixed4.results.find((r) => r.id === 'pet-show-image-tool')?.state }));
|
|
455
|
-
const fixedTools = toolsReturnHarness(join(pet4, 'world.ts'));
|
|
456
|
-
ok(fixedTools.ok, '旧嵌套写法被修成扁平数组', fixedTools.reason || JSON.stringify(fixedTools.names));
|
|
457
|
-
const again4 = applyChatImagePatches(work4, { stateDir: join(work4, 'state') });
|
|
458
|
-
ok(again4.patched.length === 0 && again4.failed.length === 0, '修好之后再跑一遍是幂等的', JSON.stringify({ patched: again4.patched, failed: again4.failed }));
|
|
459
|
-
rmSync(work4, { recursive: true, force: true });
|
|
460
|
-
|
|
461
|
-
rmSync(work, { recursive: true, force: true });
|
|
462
228
|
return failures.length === 0;
|
|
463
229
|
}
|
|
464
230
|
|
|
231
|
+
/** 真安装上那两处补丁的现状(不改动任何东西,只读)。 */
|
|
232
|
+
function inspectReal(appRoot) {
|
|
233
|
+
const spec = patchSpecs(appRoot)[0];
|
|
234
|
+
if (!spec?.file || !existsSync(spec.file)) return;
|
|
235
|
+
const text = readFileSync(pristine(spec.file), 'utf8');
|
|
236
|
+
head(`真安装上的 QQ 扩展(${basename(spec.file)})`);
|
|
237
|
+
const rel = rulesFor('qq-image-relpaths');
|
|
238
|
+
const hints = rulesFor('qq-image-hints');
|
|
239
|
+
ok(rel.done(text) || rel.apply(text)?.text, 'qq-image-relpaths 认得这份原文(不认识的只是版本变了,记在状态页)');
|
|
240
|
+
ok(hints.done(text) || hints.apply(text)?.text, 'qq-image-hints 认得这份原文');
|
|
241
|
+
}
|
|
242
|
+
|
|
465
243
|
/** 控制台端口:优先命令行,其次安装里的 config.json。 */
|
|
466
244
|
function consolePortOf(appRoot) {
|
|
467
245
|
return consolePort({ appRoot, botDir: join(PACKAGE_DIR, '..', 'data', 'home', 'companion'), explicit: opt('port') });
|
|
@@ -471,9 +249,11 @@ function apply(appRoot) {
|
|
|
471
249
|
head(`落盘(${appRoot})`);
|
|
472
250
|
const res = applyChatImagePatches(appRoot, { stateDir: PACKAGE_DIR, log: { info: (m) => console.log(` .. ${m}`), warn: (m) => console.log(` !! ${m}`) } });
|
|
473
251
|
for (const r of res.results) console.log(` ${r.state.padEnd(8)} ${r.id} ${basename(r.file ?? '')}${r.error ? ` — ${r.error}` : ''}`);
|
|
252
|
+
for (const r of res.reverted ?? []) if (r.reverted) console.log(` 还原 ${basename(r.file)}(旧桌宠补丁)`);
|
|
474
253
|
ok(res.failed.length === 0, '没有失败项', res.failed.join(', '));
|
|
475
|
-
if (res.patched.length
|
|
476
|
-
|
|
254
|
+
if (res.patched.length || res.reverted.some((r) => r.reverted)) {
|
|
255
|
+
console.log(` 改了 ${res.patched.length} 处;QQ 扩展那侧的改动要重启进程才装载${res.restartPending ? '' : '(这次不自动重启)'}`);
|
|
256
|
+
} else console.log(' 没有要改的:补丁都在。');
|
|
477
257
|
if (res.stuck) console.log(' !! 上一轮已经为同一处请求过重启,这次仍然缺——先别重启,查文件是不是只读。');
|
|
478
258
|
return res;
|
|
479
259
|
}
|
|
@@ -482,18 +262,14 @@ function apply(appRoot) {
|
|
|
482
262
|
function backups(appRoot) {
|
|
483
263
|
head(`备份状态(${appRoot})`);
|
|
484
264
|
const rows = patchBackupStatus(appRoot);
|
|
485
|
-
if (!rows.length) return ok(false, '
|
|
265
|
+
if (!rows.length) return ok(false, '认不出这份安装里的补丁目标文件(QQ 扩展可能没装)');
|
|
486
266
|
for (const r of rows) {
|
|
487
267
|
const state = r.patched ? '已打' : '未打';
|
|
488
268
|
const b = r.hasBackup ? `备份 ${r.backupBytes}B${r.same ? '(=当前文件)' : `,覆盖于 ${r.backupAt}`}` : '没有备份';
|
|
489
269
|
console.log(` ${state} ${r.id.padEnd(20)} ${basename(r.file ?? '')} ${r.fileBytes}B ${b}${r.hasFile ? '' : ' ← 文件不在'}`);
|
|
490
270
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
ok(missing.length === 0, '所有目标文件都在', missing.map((r) => r.id).join(', '));
|
|
494
|
-
ok(noBackup.length === 0, '打过补丁的每一处都留着更新下发的原文备份', noBackup.map((r) => r.id).join(', '));
|
|
495
|
-
const neverPatched = rows.filter((r) => !r.patched && !r.hasBackup);
|
|
496
|
-
if (neverPatched.length) console.log(` .. 还没打过、因此没有备份(正常):${neverPatched.map((r) => r.id).join(', ')}`);
|
|
271
|
+
ok(rows.filter((r) => !r.hasFile).length === 0, '所有目标文件都在', rows.filter((r) => !r.hasFile).map((r) => r.id).join(', '));
|
|
272
|
+
ok(rows.filter((r) => r.patched && !r.hasBackup).length === 0, '打过补丁的每一处都留着更新下发的原文备份');
|
|
497
273
|
console.log(' 备份就在文件旁边:<文件>.coopanion-orig;还原只是拿它覆盖回去,备份不会删。');
|
|
498
274
|
return rows;
|
|
499
275
|
}
|
|
@@ -507,19 +283,23 @@ function restore(appRoot) {
|
|
|
507
283
|
});
|
|
508
284
|
for (const r of res.results) console.log(` ${r.state.padEnd(8)} ${r.id} ${basename(r.file ?? '')}${r.error ? ` — ${r.error}` : ''}`);
|
|
509
285
|
ok(res.failed.length === 0, '没有还原失败的文件', res.failed.join(', '));
|
|
510
|
-
if (res.restored.length) console.log(` 还原了 ${res.restored.length}
|
|
286
|
+
if (res.restored.length) console.log(` 还原了 ${res.restored.length} 个文件;重启控制台后 QQ 发图回到「只认扩展目录的相对路径」。`);
|
|
511
287
|
else console.log(' 没有要还原的:文件本身就是更新下发的原文。');
|
|
512
288
|
return res;
|
|
513
289
|
}
|
|
514
290
|
|
|
515
291
|
const appRoot = findAppRoot();
|
|
516
|
-
if (!appRoot) {
|
|
292
|
+
if (!appRoot && !wantSelfTest) {
|
|
517
293
|
console.error('找不到 Coopanion 的 resources/app;用 --app <目录> 指定。');
|
|
518
294
|
process.exit(2);
|
|
519
295
|
}
|
|
520
|
-
console.log(`安装:${appRoot}`);
|
|
296
|
+
if (appRoot) console.log(`安装:${appRoot}`);
|
|
521
297
|
|
|
522
|
-
if (wantSelfTest)
|
|
298
|
+
if (wantSelfTest) {
|
|
299
|
+
selfTest();
|
|
300
|
+
if (appRoot) inspectReal(appRoot);
|
|
301
|
+
else console.log('\n(没找到装在附近的 Coopanion,跳过真安装上的只读检查)');
|
|
302
|
+
}
|
|
523
303
|
if (wantBackups) backups(appRoot);
|
|
524
304
|
if (wantRestore) restore(appRoot);
|
|
525
305
|
if (wantApply) apply(appRoot);
|
|
@@ -534,5 +314,5 @@ if (wantRestart) {
|
|
|
534
314
|
}
|
|
535
315
|
}
|
|
536
316
|
|
|
537
|
-
console.log(`\n
|
|
317
|
+
console.log(`\n结果:${pass} 项通过,${failures.length} 项失败`);
|
|
538
318
|
process.exit(failures.length ? 1 : 0);
|