twskin 0.5.3

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 (65) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/CONTRIBUTING.md +37 -0
  3. package/LICENSE +21 -0
  4. package/README.md +104 -0
  5. package/SECURITY.md +28 -0
  6. package/dist/bin/twskin.d.ts +3 -0
  7. package/dist/bin/twskin.d.ts.map +1 -0
  8. package/dist/bin/twskin.js +4 -0
  9. package/dist/bin/twskin.js.map +1 -0
  10. package/dist/commands.d.ts +41 -0
  11. package/dist/commands.d.ts.map +1 -0
  12. package/dist/commands.js +421 -0
  13. package/dist/commands.js.map +1 -0
  14. package/dist/context.d.ts +14 -0
  15. package/dist/context.d.ts.map +1 -0
  16. package/dist/context.js +51 -0
  17. package/dist/context.js.map +1 -0
  18. package/dist/errors.d.ts +9 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +20 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/lock.d.ts +3 -0
  23. package/dist/lock.d.ts.map +1 -0
  24. package/dist/lock.js +52 -0
  25. package/dist/lock.js.map +1 -0
  26. package/dist/main.d.ts +9 -0
  27. package/dist/main.d.ts.map +1 -0
  28. package/dist/main.js +92 -0
  29. package/dist/main.js.map +1 -0
  30. package/dist/output.d.ts +4 -0
  31. package/dist/output.d.ts.map +1 -0
  32. package/dist/output.js +27 -0
  33. package/dist/output.js.map +1 -0
  34. package/dist/persistent-cdp.d.ts +14 -0
  35. package/dist/persistent-cdp.d.ts.map +1 -0
  36. package/dist/persistent-cdp.js +140 -0
  37. package/dist/persistent-cdp.js.map +1 -0
  38. package/dist/system.d.ts +64 -0
  39. package/dist/system.d.ts.map +1 -0
  40. package/dist/system.js +234 -0
  41. package/dist/system.js.map +1 -0
  42. package/dist/theme-download.d.ts +9 -0
  43. package/dist/theme-download.d.ts.map +1 -0
  44. package/dist/theme-download.js +250 -0
  45. package/dist/theme-download.js.map +1 -0
  46. package/dist/themes.d.ts +9 -0
  47. package/dist/themes.d.ts.map +1 -0
  48. package/dist/themes.js +185 -0
  49. package/dist/themes.js.map +1 -0
  50. package/dist/types.d.ts +58 -0
  51. package/dist/types.d.ts.map +1 -0
  52. package/dist/types.js +2 -0
  53. package/dist/types.js.map +1 -0
  54. package/docs/publishing.md +45 -0
  55. package/docs/theme-distribution.md +62 -0
  56. package/package.json +74 -0
  57. package/runtime/component-map.mjs +200 -0
  58. package/runtime/injector.mjs +705 -0
  59. package/runtime/manifest.json +47 -0
  60. package/runtime/restore.sh +33 -0
  61. package/runtime/skin.js +1133 -0
  62. package/runtime/start.sh +86 -0
  63. package/runtime/styles/base.css +687 -0
  64. package/runtime/styles/manager.css +346 -0
  65. package/runtime/token-map.mjs +432 -0
@@ -0,0 +1,705 @@
1
+ #!/usr/bin/env node
2
+ // TRAE Work Skin — CDP 注入器
3
+ // 通过本机回环 CDP (127.0.0.1) 把 skin.js 注入运行中的 Electron 页面。
4
+ // 用法:
5
+ // node injector.mjs --once --port 19527 一次性注入所有页面 target
6
+ // node injector.mjs --watch --port 19527 常驻守护:新 target/导航/被清除时自动重注
7
+ // node injector.mjs --list --port 19527 列出 CDP target
8
+ // node injector.mjs --stop --port 19527 恢复原生外观并移除页面控制
9
+ // node injector.mjs --eval '<js>' --port 19527 在第一个页面 target 里执行表达式并打印结果
10
+ // node injector.mjs --manager-status --port 19527 在全部页面 target 中检查 Theme Manager
11
+ // node injector.mjs --shot out.png --port 19527 对第一个页面 target 截图
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
18
+ const VERSION = "0.5.3";
19
+ const DATA_DIR = process.env.TWSKIN_DATA_DIR || path.join(os.homedir(), ".trae-work-skin");
20
+
21
+ function parseArgs(argv) {
22
+ const args = {
23
+ mode: "once",
24
+ port: 0,
25
+ themesDir: process.env.TWSKIN_THEMES_DIR || path.join(DATA_DIR, "themes"),
26
+ defaultTheme: null,
27
+ applyId: null,
28
+ evalExpr: null,
29
+ shotPath: null,
30
+ timeoutMs: 8000,
31
+ };
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ switch (argv[i]) {
34
+ case "--watch": args.mode = "watch"; break;
35
+ case "--once": args.mode = "once"; break;
36
+ case "--list": args.mode = "list"; break;
37
+ case "--current": args.mode = "current"; break;
38
+ case "--apply": args.mode = "apply"; args.applyId = argv[++i]; break;
39
+ case "--stop": args.mode = "stop"; break;
40
+ case "--eval": args.mode = "eval"; args.evalExpr = argv[++i]; break;
41
+ case "--manager-status": args.mode = "manager-status"; break;
42
+ case "--shot": args.mode = "shot"; args.shotPath = argv[++i]; break;
43
+ case "--port": args.port = Number(argv[++i]); break;
44
+ case "--themes": args.themesDir = argv[++i]; break;
45
+ case "--default-theme": args.defaultTheme = argv[++i]; break;
46
+ case "--timeout-ms": args.timeoutMs = Number(argv[++i]); break;
47
+ default: throw new Error(`unknown argument: ${argv[i]}`);
48
+ }
49
+ }
50
+ if (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535) {
51
+ throw new Error("--port is required (1024-65535)");
52
+ }
53
+ return args;
54
+ }
55
+
56
+ function log(...parts) {
57
+ console.log(`${new Date().toISOString()}`, ...parts);
58
+ }
59
+
60
+ const STATE_DIR = process.env.TWSKIN_STATE_DIR || path.join(DATA_DIR, "run");
61
+ const THEME_CONF = path.join(STATE_DIR, "theme.conf");
62
+ const PID_FILE = path.join(STATE_DIR, "injector.pid");
63
+ const MAX_THEME_CSS_BYTES = 1024 * 1024;
64
+
65
+ function buildCatalog(themesDir) {
66
+ const catalog = [];
67
+ const dataUri = (file) => {
68
+ const ext = path.extname(file).toLowerCase();
69
+ const mime = ext === ".svg" ? "image/svg+xml" : ext === ".png" ? "image/png" : "image/jpeg";
70
+ return `data:${mime};base64,${fs.readFileSync(file).toString("base64")}`;
71
+ };
72
+ for (const entry of fs.readdirSync(themesDir, { withFileTypes: true })) {
73
+ if (!entry.isDirectory()) continue;
74
+ const dir = path.join(themesDir, entry.name);
75
+ let meta = { id: entry.name, name: entry.name, desc: "" };
76
+ try {
77
+ meta = { ...meta, ...JSON.parse(fs.readFileSync(path.join(dir, "theme.json"), "utf8")) };
78
+ } catch {}
79
+ const bgFile = ["background.svg", "background.png", "background.jpg", "background.jpeg"]
80
+ .map((f) => path.join(dir, f))
81
+ .find((f) => fs.existsSync(f));
82
+ if (!bgFile) continue;
83
+ const customCssFile = path.join(dir, "theme.css");
84
+ let customCss = "";
85
+ if (fs.existsSync(customCssFile)) {
86
+ const size = fs.statSync(customCssFile).size;
87
+ if (size > MAX_THEME_CSS_BYTES) {
88
+ throw new Error(`theme.css exceeds 1 MB: ${customCssFile}`);
89
+ }
90
+ customCss = fs.readFileSync(customCssFile, "utf8");
91
+ }
92
+ const assets = {};
93
+ for (const [name, filenames] of Object.entries({
94
+ leftSidebar: ["left-sidebar.png", "left-sidebar.jpg", "left-sidebar.jpeg"],
95
+ rightPanel: ["right-panel.png", "right-panel.jpg", "right-panel.jpeg"],
96
+ })) {
97
+ const file = filenames.map((filename) => path.join(dir, filename))
98
+ .find((candidate) => fs.existsSync(candidate));
99
+ if (file) assets[name] = dataUri(file);
100
+ }
101
+ const optionalNumber = (value, min, max) => {
102
+ const number = Number(value);
103
+ return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : null;
104
+ };
105
+ const settings = {};
106
+ for (const key of [
107
+ "schemaVersion",
108
+ "appearance",
109
+ "background",
110
+ "layout",
111
+ "colors",
112
+ "typography",
113
+ "shape",
114
+ "elevation",
115
+ "scrollbar",
116
+ "components",
117
+ "workbench",
118
+ "extensions",
119
+ "tokens",
120
+ "surfaces",
121
+ "effects",
122
+ "icons",
123
+ "decorations",
124
+ ]) {
125
+ if (meta[key] != null) settings[key] = meta[key];
126
+ }
127
+ // icons.overrides: 解析 icons/ 目录下的素材文件为 data URL
128
+ if (settings.icons?.overrides && typeof settings.icons.overrides === "object") {
129
+ const assets = {};
130
+ for (const [key, spec] of Object.entries(settings.icons.overrides)) {
131
+ const file = typeof spec === "string" ? spec : spec?.src;
132
+ if (!file || !/\.(svg|png|jpe?g)$/i.test(file)) continue;
133
+ const full = path.join(dir, "icons", file);
134
+ if (fs.existsSync(full)) assets[key] = dataUri(full);
135
+ }
136
+ if (Object.keys(assets).length) {
137
+ settings.icons = { ...settings.icons, assets };
138
+ }
139
+ }
140
+ catalog.push({
141
+ id: String(meta.id),
142
+ name: String(meta.name),
143
+ desc: String(meta.desc || ""),
144
+ category: String(meta.category || "其他"),
145
+ surfaceLight: optionalNumber(meta.surfaceLight, 0, 1),
146
+ surfaceDark: optionalNumber(meta.surfaceDark, 0, 1),
147
+ blurPx: optionalNumber(meta.blurPx, 0, 40),
148
+ settings,
149
+ art: dataUri(bgFile),
150
+ assets,
151
+ customCss,
152
+ });
153
+ }
154
+ return catalog;
155
+ }
156
+
157
+ function readThemeConf() {
158
+ try { return fs.readFileSync(THEME_CONF, "utf8").trim() || null; } catch { return null; }
159
+ }
160
+
161
+ function resolveDefaultTheme(catalog, requested) {
162
+ for (const id of [requested, readThemeConf(), "aurora", catalog[0]?.id]) {
163
+ if (id && catalog.some((t) => t.id === id)) return id;
164
+ }
165
+ return catalog[0]?.id ?? "";
166
+ }
167
+
168
+ export function buildPayload(args) {
169
+ const skin = fs.readFileSync(path.join(ROOT, "skin.js"), "utf8");
170
+ const tokenMap = fs.readFileSync(path.join(ROOT, "token-map.mjs"), "utf8");
171
+ const componentMap = fs.readFileSync(path.join(ROOT, "component-map.mjs"), "utf8");
172
+ const baseCss = fs.readFileSync(path.join(ROOT, "styles/base.css"), "utf8");
173
+ const managerCss = fs.readFileSync(path.join(ROOT, "styles/manager.css"), "utf8");
174
+ const catalog = buildCatalog(args.themesDir);
175
+ if (catalog.length === 0) throw new Error(`no themes found in ${args.themesDir}`);
176
+ // 函数式替换,避免替换文本里的 $ 序列被当成特殊模式
177
+ return skin
178
+ .replaceAll("__TOKEN_MAP__", () => tokenMap)
179
+ .replaceAll("__COMPONENT_MAP__", () => componentMap)
180
+ .replaceAll("__BASE_CSS__", () => JSON.stringify(baseCss))
181
+ .replaceAll("__MANAGER_CSS__", () => JSON.stringify(managerCss))
182
+ .replaceAll("__CATALOG__", () => JSON.stringify(catalog))
183
+ .replaceAll("__DEFAULT_THEME__", () => JSON.stringify(resolveDefaultTheme(catalog, args.defaultTheme)))
184
+ .replaceAll("__VERSION__", () => JSON.stringify(VERSION));
185
+ }
186
+
187
+ class CdpSession {
188
+ constructor(ws) {
189
+ this.ws = ws;
190
+ this.nextId = 1;
191
+ this.pending = new Map();
192
+ this.handlers = new Map();
193
+ this.closed = false;
194
+ }
195
+
196
+ static async connect(wsUrl, timeoutMs) {
197
+ const ws = new WebSocket(wsUrl);
198
+ const session = new CdpSession(ws);
199
+ ws.addEventListener("message", (ev) => {
200
+ let msg;
201
+ try {
202
+ msg = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data));
203
+ } catch { return; }
204
+ if (msg.id) {
205
+ const p = session.pending.get(msg.id);
206
+ if (p) {
207
+ session.pending.delete(msg.id);
208
+ clearTimeout(p.timer);
209
+ msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
210
+ }
211
+ } else if (msg.method) {
212
+ session.handlers.get(msg.method)?.(msg.params ?? {});
213
+ }
214
+ });
215
+ ws.addEventListener("close", () => {
216
+ session.closed = true;
217
+ for (const p of session.pending.values()) {
218
+ clearTimeout(p.timer);
219
+ p.reject(new Error("websocket closed"));
220
+ }
221
+ session.pending.clear();
222
+ });
223
+ await new Promise((resolve, reject) => {
224
+ const timer = setTimeout(() => reject(new Error("websocket connect timeout")), timeoutMs);
225
+ ws.addEventListener("open", () => { clearTimeout(timer); resolve(); }, { once: true });
226
+ ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("websocket connect error")); }, { once: true });
227
+ });
228
+ return session;
229
+ }
230
+
231
+ send(method, params = {}, timeoutMs = 10000) {
232
+ if (this.closed) return Promise.reject(new Error("websocket closed"));
233
+ const id = this.nextId;
234
+ this.nextId += 1;
235
+ return new Promise((resolve, reject) => {
236
+ const timer = setTimeout(() => {
237
+ this.pending.delete(id);
238
+ reject(new Error(`cdp timeout: ${method}`));
239
+ }, timeoutMs);
240
+ this.pending.set(id, { resolve, reject, timer });
241
+ this.ws.send(JSON.stringify({ id, method, params }));
242
+ });
243
+ }
244
+
245
+ on(method, handler) { this.handlers.set(method, handler); }
246
+
247
+ close() { try { this.ws.close(); } catch {} }
248
+ }
249
+
250
+ async function listPageTargets(port, timeoutMs) {
251
+ const res = await fetch(`http://127.0.0.1:${port}/json/list`, {
252
+ signal: AbortSignal.timeout(timeoutMs),
253
+ });
254
+ if (!res.ok) throw new Error(`/json/list http ${res.status}`);
255
+ const list = await res.json();
256
+ return list.filter((t) => t.type === "page" && !String(t.url).startsWith("devtools://"));
257
+ }
258
+
259
+ async function injectInto(session, payload) {
260
+ const result = await session.send("Runtime.evaluate", {
261
+ expression: payload,
262
+ returnByValue: true,
263
+ });
264
+ if (result.exceptionDetails) {
265
+ const desc = result.exceptionDetails.exception?.description || result.exceptionDetails.text;
266
+ throw new Error(`payload exception: ${desc}`);
267
+ }
268
+ return result.result?.value;
269
+ }
270
+
271
+ async function getStamp(session) {
272
+ try {
273
+ const r = await session.send(
274
+ "Runtime.evaluate",
275
+ { expression: "window.__TRAE_DREAM_SKIN__ || null", returnByValue: true },
276
+ 4000,
277
+ );
278
+ return r.result?.value ?? null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ async function cmdList(args) {
285
+ const targets = await listPageTargets(args.port, args.timeoutMs);
286
+ for (const t of targets) {
287
+ console.log(`[${t.type}] ${t.id}\n title: ${t.title}\n url: ${t.url}`);
288
+ }
289
+ if (targets.length === 0) console.log("(no page targets)");
290
+ }
291
+
292
+ async function cmdEval(args) {
293
+ const targets = await listPageTargets(args.port, args.timeoutMs);
294
+ if (targets.length === 0) throw new Error("no page targets");
295
+ const session = await CdpSession.connect(targets[0].webSocketDebuggerUrl, args.timeoutMs);
296
+ try {
297
+ const r = await session.send("Runtime.evaluate", {
298
+ expression: args.evalExpr,
299
+ returnByValue: true,
300
+ awaitPromise: true,
301
+ });
302
+ if (r.exceptionDetails) {
303
+ console.error("exception:", r.exceptionDetails.exception?.description || r.exceptionDetails.text);
304
+ process.exitCode = 1;
305
+ return;
306
+ }
307
+ console.log(JSON.stringify(r.result?.value, null, 2));
308
+ } finally {
309
+ session.close();
310
+ }
311
+ }
312
+
313
+ async function cmdStop(args) {
314
+ const targets = await listPageTargets(args.port, args.timeoutMs);
315
+ const expression = `(() => {
316
+ const prefix = "trae-dream-skin:";
317
+ const disabledKey = prefix + "disabled";
318
+ const saved = [];
319
+ try {
320
+ for (let index = 0; index < localStorage.length; index += 1) {
321
+ const key = localStorage.key(index);
322
+ if (key && key.startsWith(prefix) && key !== disabledKey) {
323
+ saved.push([key, localStorage.getItem(key)]);
324
+ }
325
+ }
326
+ } catch {}
327
+
328
+ try {
329
+ window.__TRAE_DREAM_SKIN_GALLERY__?.restoreNative?.();
330
+ } catch {}
331
+
332
+ try {
333
+ for (const [key, value] of saved) {
334
+ if (value !== null) localStorage.setItem(key, value);
335
+ }
336
+ localStorage.setItem(disabledKey, "true");
337
+ } catch {}
338
+
339
+ for (const observerKey of [
340
+ "__TRAE_DREAM_SKIN_OBS__",
341
+ "__TRAE_DREAM_SKIN_APPEARANCE_OBS__",
342
+ ]) {
343
+ try {
344
+ window[observerKey]?.disconnect?.();
345
+ } catch {}
346
+ window[observerKey] = null;
347
+ }
348
+
349
+ if (Array.isArray(window.__TRAE_DREAM_SKIN_HTML_VARS__)) {
350
+ for (const name of window.__TRAE_DREAM_SKIN_HTML_VARS__) {
351
+ document.documentElement.style.removeProperty(name);
352
+ }
353
+ window.__TRAE_DREAM_SKIN_HTML_VARS__ = null;
354
+ }
355
+ document.documentElement.style.removeProperty("--trae-skin-art");
356
+
357
+ const originalAppearance = window.__TRAE_DREAM_SKIN_ORIG_APPEARANCE__;
358
+ if (originalAppearance) {
359
+ if (originalAppearance.dataTheme == null) {
360
+ document.documentElement.removeAttribute("data-theme");
361
+ } else {
362
+ document.documentElement.setAttribute("data-theme", originalAppearance.dataTheme);
363
+ }
364
+ }
365
+ window.__TRAE_DREAM_SKIN_ORIG_APPEARANCE__ = null;
366
+
367
+ if (document.body) {
368
+ const semanticPrefixes = ["--bg-bg-", "--text-text-", "--icon-icon-", "--border-border-"];
369
+ for (const name of [...document.body.style]) {
370
+ if (name.startsWith("--trae-skin-") || semanticPrefixes.some((prefix) => name.startsWith(prefix))) {
371
+ document.body.style.removeProperty(name);
372
+ }
373
+ }
374
+ for (const className of [...document.body.classList]) {
375
+ if (className.startsWith("trae-skin-theme-")) document.body.classList.remove(className);
376
+ }
377
+ document.body.classList.remove(
378
+ "trae-skin-v2",
379
+ "trae-skin-v3",
380
+ "trae-skin-appearance-dark",
381
+ "trae-skin-effects-max",
382
+ "trae-skin-blur-disabled",
383
+ );
384
+ if (originalAppearance) {
385
+ document.body.classList.toggle("light", originalAppearance.light);
386
+ document.body.classList.toggle("vs-dark", originalAppearance.vsDark);
387
+ }
388
+ for (const key of [
389
+ "traeSkinLandingBanner",
390
+ "traeSkinModeTabs",
391
+ "traeSkinModeTabIcons",
392
+ "traeSkinModeTabCodes",
393
+ ]) delete document.body.dataset[key];
394
+ }
395
+
396
+ for (const id of [
397
+ "trae-dream-skin-style",
398
+ "trae-dream-skin-ui-style",
399
+ "trae-dream-skin-theme-style",
400
+ "trae-dream-skin-icons-style",
401
+ "trae-dream-skin-scope-style",
402
+ "trae-dream-skin-panel",
403
+ "trae-dream-skin-button",
404
+ ]) {
405
+ document.getElementById(id)?.remove();
406
+ }
407
+
408
+ window.__TRAE_DREAM_SKIN__ = null;
409
+ window.__TRAE_DREAM_SKIN_GALLERY__ = null;
410
+ return { ok: true };
411
+ })()`;
412
+
413
+ let restoredTargets = 0;
414
+ for (const target of targets) {
415
+ const session = await CdpSession.connect(target.webSocketDebuggerUrl, args.timeoutMs);
416
+ try {
417
+ const result = await session.send(
418
+ "Runtime.evaluate",
419
+ { expression, returnByValue: true, awaitPromise: true },
420
+ 4000,
421
+ );
422
+ if (result.exceptionDetails) {
423
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
424
+ }
425
+ if (result.result?.value?.ok) restoredTargets += 1;
426
+ } finally {
427
+ session.close();
428
+ }
429
+ }
430
+ console.log(JSON.stringify({ ok: true, targets: targets.length, restoredTargets }));
431
+ }
432
+
433
+ async function cmdManagerStatus(args) {
434
+ const targets = await listPageTargets(args.port, args.timeoutMs);
435
+ const expression = `(() => {
436
+ const api = window.__TRAE_DREAM_SKIN_GALLERY__;
437
+ const button = document.getElementById("trae-dream-skin-button");
438
+ return { ready: Boolean(api && button), version: api?.version || null };
439
+ })()`;
440
+ for (const target of targets) {
441
+ const session = await CdpSession.connect(target.webSocketDebuggerUrl, args.timeoutMs);
442
+ try {
443
+ const result = await session.send("Runtime.evaluate", { expression, returnByValue: true }, 4000);
444
+ const status = result.result?.value;
445
+ if (status?.ready) {
446
+ console.log(JSON.stringify({ ready: true, version: status.version || null }));
447
+ return;
448
+ }
449
+ } catch {
450
+ // A transient or privileged target must not hide a healthy manager in another page.
451
+ } finally {
452
+ session.close();
453
+ }
454
+ }
455
+ console.log(JSON.stringify({ ready: false, version: null }));
456
+ }
457
+
458
+ async function cmdShot(args) {
459
+ const targets = await listPageTargets(args.port, args.timeoutMs);
460
+ if (targets.length === 0) throw new Error("no page targets");
461
+ const session = await CdpSession.connect(targets[0].webSocketDebuggerUrl, args.timeoutMs);
462
+ try {
463
+ await session.send("Page.enable");
464
+ const shot = await session.send("Page.captureScreenshot", {
465
+ format: "png",
466
+ fromSurface: true,
467
+ }, 20000);
468
+ fs.writeFileSync(args.shotPath, Buffer.from(shot.data, "base64"));
469
+ console.log(args.shotPath);
470
+ } finally {
471
+ session.close();
472
+ }
473
+ }
474
+
475
+ const LS_EXPR = 'localStorage.getItem("trae-dream-skin:theme")';
476
+ const ENABLE_EXPR = 'localStorage.removeItem("trae-dream-skin:disabled")';
477
+
478
+ function writeThemeConf(id) {
479
+ fs.mkdirSync(path.dirname(THEME_CONF), { recursive: true });
480
+ fs.writeFileSync(THEME_CONF, `${id}\n`, { mode: 0o600 });
481
+ }
482
+
483
+ async function readPageTheme(session) {
484
+ try {
485
+ const r = await session.send("Runtime.evaluate", { expression: LS_EXPR, returnByValue: true }, 4000);
486
+ return r.result?.value ?? null;
487
+ } catch { return null; }
488
+ }
489
+
490
+ async function cmdApply(args, payload) {
491
+ const targets = await listPageTargets(args.port, args.timeoutMs);
492
+ if (targets.length === 0) throw new Error("no page targets");
493
+ const session = await CdpSession.connect(targets[0].webSocketDebuggerUrl, args.timeoutMs);
494
+ try {
495
+ await session.send("Runtime.evaluate", {
496
+ expression: ENABLE_EXPR,
497
+ returnByValue: true,
498
+ });
499
+ await injectInto(session, payload);
500
+ const expr = `window.__TRAE_DREAM_SKIN_GALLERY__
501
+ ? window.__TRAE_DREAM_SKIN_GALLERY__.apply(${JSON.stringify(args.applyId)})
502
+ : { ok: false, error: "gallery not ready (run --once first)" }`;
503
+ const r = await session.send("Runtime.evaluate", { expression: expr, returnByValue: true });
504
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
505
+ const value = r.result?.value;
506
+ console.log(JSON.stringify(value));
507
+ if (!value?.ok) { process.exitCode = 1; return; }
508
+ writeThemeConf(args.applyId);
509
+ } finally {
510
+ session.close();
511
+ }
512
+ }
513
+
514
+ async function cmdCurrent(args) {
515
+ const targets = await listPageTargets(args.port, args.timeoutMs);
516
+ if (targets.length === 0) throw new Error("no page targets");
517
+ const session = await CdpSession.connect(targets[0].webSocketDebuggerUrl, args.timeoutMs);
518
+ try {
519
+ console.log((await readPageTheme(session)) ?? "(none)");
520
+ } finally {
521
+ session.close();
522
+ }
523
+ }
524
+
525
+ async function cmdOnce(args, payload) {
526
+ const targets = await listPageTargets(args.port, args.timeoutMs);
527
+ if (targets.length === 0) throw new Error("no page targets");
528
+ let ok = 0;
529
+ for (const t of targets) {
530
+ try {
531
+ const session = await CdpSession.connect(t.webSocketDebuggerUrl, args.timeoutMs);
532
+ await session.send("Runtime.evaluate", {
533
+ expression: ENABLE_EXPR,
534
+ returnByValue: true,
535
+ });
536
+ const r = await injectInto(session, payload);
537
+ session.close();
538
+ ok += 1;
539
+ log(`injected into ${t.id} -> ${JSON.stringify(r)}`);
540
+ } catch (error) {
541
+ log(`skip ${t.id}: ${error.message}`);
542
+ }
543
+ }
544
+ log(`injected ${ok}/${targets.length} target(s)`);
545
+ }
546
+
547
+ async function cmdWatch(args, payload) {
548
+ fs.mkdirSync(path.dirname(PID_FILE), { recursive: true });
549
+ fs.writeFileSync(PID_FILE, `${process.pid}\n`, { mode: 0o600 });
550
+ process.on("exit", () => {
551
+ try {
552
+ if (fs.readFileSync(PID_FILE, "utf8").trim() === String(process.pid)) {
553
+ fs.unlinkSync(PID_FILE);
554
+ }
555
+ } catch {}
556
+ });
557
+
558
+ const sessions = new Map(); // targetId -> { session, target }
559
+ let cdpFailures = 0;
560
+ let enableOnStartup = true;
561
+ let lastReloadRequest = null;
562
+ let reloadPrimed = false;
563
+
564
+ const injectTarget = async (target) => {
565
+ const session = await CdpSession.connect(target.webSocketDebuggerUrl, args.timeoutMs);
566
+ if (enableOnStartup) {
567
+ await session.send("Runtime.evaluate", {
568
+ expression: ENABLE_EXPR,
569
+ returnByValue: true,
570
+ });
571
+ }
572
+ await session.send("Page.enable").catch(() => {});
573
+ let navTimer = null;
574
+ session.on("Page.frameNavigated", () => {
575
+ clearTimeout(navTimer);
576
+ navTimer = setTimeout(() => {
577
+ injectInto(session, payload)
578
+ .then((r) => log(`re-injected after navigation ${target.id} -> ${JSON.stringify(r)}`))
579
+ .catch((e) => log(`re-inject failed ${target.id}: ${e.message}`));
580
+ }, 400);
581
+ });
582
+ const r = await injectInto(session, payload);
583
+ sessions.set(target.id, { session, target });
584
+ log(`injected into ${target.id} (${target.url}) -> ${JSON.stringify(r)}`);
585
+ };
586
+
587
+ const tick = async () => {
588
+ // 单实例约定:pid 文件被其它注入器接管时自动退出,
589
+ // 避免多个守护进程同时注入、互相覆盖(后者启动者胜)
590
+ try {
591
+ const recorded = fs.readFileSync(PID_FILE, "utf8").trim();
592
+ if (recorded && recorded !== String(process.pid)) {
593
+ log(`injector pid ${recorded} took over per pid file; exiting`);
594
+ process.exit(0);
595
+ }
596
+ } catch {}
597
+ let targets;
598
+ try {
599
+ targets = await listPageTargets(args.port, args.timeoutMs);
600
+ if (cdpFailures > 0) log("cdp reachable again; resuming theme injection");
601
+ cdpFailures = 0;
602
+ } catch (error) {
603
+ cdpFailures += 1;
604
+ if (cdpFailures === 1 || cdpFailures % 300 === 0) {
605
+ log(`cdp unreachable; waiting for TRAE to restart (${cdpFailures}): ${error.message}`);
606
+ }
607
+ return;
608
+ }
609
+
610
+ const liveIds = new Set(targets.map((t) => t.id));
611
+ for (const [id, entry] of sessions) {
612
+ if (!liveIds.has(id) || entry.session.closed) {
613
+ entry.session.close();
614
+ sessions.delete(id);
615
+ }
616
+ }
617
+
618
+ for (const target of targets) {
619
+ const entry = sessions.get(target.id);
620
+ if (!entry) {
621
+ try {
622
+ await injectTarget(target);
623
+ } catch (error) {
624
+ log(`connect/inject failed ${target.id}: ${error.message}`);
625
+ }
626
+ continue;
627
+ }
628
+ const stamp = await getStamp(entry.session);
629
+ if (stamp !== VERSION) {
630
+ try {
631
+ const r = await injectInto(entry.session, payload);
632
+ log(`re-injected (stale stamp) ${target.id} -> ${JSON.stringify(r)}`);
633
+ } catch (error) {
634
+ log(`re-inject failed ${target.id}: ${error.message}`);
635
+ entry.session.close();
636
+ sessions.delete(target.id);
637
+ }
638
+ }
639
+ // 画廊面板里的选择同步回 theme.conf(CLI 与 App 内选择保持一致)
640
+ const selected = await readPageTheme(entry.session);
641
+ if (selected && selected !== readThemeConf()) {
642
+ writeThemeConf(selected);
643
+ log(`theme selection synced: ${selected}`);
644
+ }
645
+ }
646
+
647
+ // 页面画廊请求重载主题目录(新主题热加载)
648
+ const firstEntry = sessions.values().next().value;
649
+ if (firstEntry && !firstEntry.session.closed) {
650
+ let request = null;
651
+ try {
652
+ const r = await firstEntry.session.send("Runtime.evaluate", {
653
+ expression: "window.__TRAE_DREAM_SKIN_RELOAD_REQUEST__ || null",
654
+ returnByValue: true,
655
+ }, 4000);
656
+ request = r.result?.value ?? null;
657
+ } catch {}
658
+ if (!reloadPrimed) {
659
+ // 守护进程启动时不触发重载,只记录页面里可能存在的旧请求
660
+ reloadPrimed = true;
661
+ lastReloadRequest = request;
662
+ } else if (request && request !== lastReloadRequest) {
663
+ lastReloadRequest = request;
664
+ try {
665
+ payload = buildPayload(args);
666
+ for (const entry of sessions.values()) {
667
+ if (entry.session.closed) continue;
668
+ try {
669
+ await injectInto(entry.session, payload);
670
+ } catch (error) {
671
+ log(`reload re-inject failed ${entry.target.id}: ${error.message}`);
672
+ }
673
+ }
674
+ await firstEntry.session.send("Runtime.evaluate", {
675
+ expression: "window.__TRAE_DREAM_SKIN_GALLERY__?.open?.()",
676
+ returnByValue: true,
677
+ }, 4000).catch(() => {});
678
+ log("theme catalog reloaded on page request");
679
+ } catch (error) {
680
+ log(`theme reload failed: ${error.message}`);
681
+ }
682
+ }
683
+ }
684
+ };
685
+
686
+ log(`watching port ${args.port}, skin version ${VERSION}`);
687
+ await tick();
688
+ enableOnStartup = false;
689
+ setInterval(tick, 2000);
690
+ }
691
+
692
+ if (path.resolve(process.argv[1] || "") === fileURLToPath(import.meta.url)) {
693
+ const args = parseArgs(process.argv.slice(2));
694
+ switch (args.mode) {
695
+ case "list": await cmdList(args); break;
696
+ case "eval": await cmdEval(args); break;
697
+ case "stop": await cmdStop(args); break;
698
+ case "manager-status": await cmdManagerStatus(args); break;
699
+ case "shot": await cmdShot(args); break;
700
+ case "apply": await cmdApply(args, buildPayload(args)); break;
701
+ case "current": await cmdCurrent(args); break;
702
+ case "once": await cmdOnce(args, buildPayload(args)); break;
703
+ case "watch": await cmdWatch(args, buildPayload(args)); break;
704
+ }
705
+ }