Files
claude-pet/main.js
T
JianMiauandClaude Fable 5 42083acec5 修正有時候卡住無法拖曳:點穿狀態改由主程序輪詢驅動
摘要:
點穿的命中判定不再只依賴 renderer 的 mousemove,改由主程序每 50 ms 的游標輪詢一起驅動,
並在每格畫完後用最後已知座標重驗一次。

根本原因:
視窗預設 setIgnoreMouseEvents(true, { forward: true }),而唯一會把它切回可互動的地方
是 renderer 的 mousemove。問題是視窗處於點穿狀態時,Electron 的 forward 不保證會把
mousemove 轉發進來——本次開發過程中已實測到:游標直接跳到寵物上收不到事件,
慢慢掃過去才收得到。
於是只要在「游標還停在寵物身上」時進入點穿(例如游標停在某一點,動畫換格後
該點的像素變成透明),就再也收不到 mousemove,ignoringMouse 永遠是 true,
滑鼠按下也傳不進視窗,就變成按不動也拖不動,而且不會自己好。
另外 startCursorPolling 的內容原本被 `!config.followCursor` 整段擋掉,
而追視預設為關,所以主程序根本沒有在追游標,沒有任何備援路徑。

影響:
寵物有時候完全無法拖曳或點擊,只能重啟;游標停在人物邊緣或半透明處時特別容易發生。

修法:
- startCursorPolling 的守衛從 `!win || !config.followCursor || dragTimer` 改為
  `!win || dragTimer`,輪詢一律執行;只有送 pet:cursor(追視用)那一行仍看 followCursor。
- 主程序新增 pet:cursor-pos,送出視窗相對座標;preload 開放該頻道。
- renderer 把命中判定集中成 refreshHitTest(),mousemove 與 pet:cursor-pos 走同一條路,
  並在 pressed / drag 期間不動點穿狀態,避免拖曳中途鬆手。
- tick 畫完後若最後已知座標落在人物方框內就重驗一次,處理「游標沒動但換格導致
  該點像素改變」的情況。
- mouseout 時清掉 lastPoint。
- README 的設計重點補上為何不能只靠 mousemove。

驗證:
- 實機加暫時診斷確認 pet:cursor-pos 確實送達 renderer,且回報的 canvasRect
  (57,72,183,209)與依視窗尺寸手算的結果一致;確認後移除診斷碼。
- 以 DOM stub 載入完整 renderer.js 並讓 getImageData 的 alpha 可切換,14 項全過:
  壓在人物上會解除點穿並觸發 hover 跳躍、有通知主程序、方框內但透明維持點穿、
  方框外點穿且非 hover、拖曳中與按住中都不改點穿、pet:cursor-pos 有註冊且
  直接跳到人物上也能解除點穿、以及最關鍵的一組——先進入點穿狀態,
  再由 pet:cursor-pos 在同一點換格後把它救回來,這正是原本卡死的情境。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.6 與九項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:51:28 +08:00

620 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog } = require("electron");
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const autostart = require("./lib/autostart");
const hooksInstaller = require("./lib/hooks-installer");
const { unpacked, portableDir, isPackaged } = require("./lib/paths");
const APP_DIR = __dirname;
const HOME = os.homedir();
const CONFIG_DIR = path.join(HOME, ".claude-pet");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
const LOG_PATH = path.join(CONFIG_DIR, "events.log");
// 執行檔所在的資料夾:開發時是專案資料夾,打包後是 exe 旁邊
// portable 版這裡會是 %TEMP% 的解壓目錄,使用者那顆 exe 的位置要看 portableDir()
const APP_ROOT = isPackaged() ? path.dirname(process.execPath) : APP_DIR;
// 寵物一律放執行目錄的 pets/,不埋在 asar 裡,使用者直接丟資料夾進去就會出現
const BUNDLED_PETS_DIR = path.join(APP_ROOT, "pets");
// portable 版整包解在 %TEMP%、關掉就被刪,使用者自己的寵物要放在那顆 exe 旁邊才留得住
const USER_PETS_DIR = portableDir() ? path.join(portableDir(), "pets") : BUNDLED_PETS_DIR;
const CODEX_PETS_DIR = path.join(HOME, ".codex", "pets");
// 一律會被掃到、且不寫進設定檔的來源。使用者自己放的排最前面,同 id 才不會被內附的蓋掉
// (非 portable 時 USER 就等於 BUNDLED,去重後只剩一個)
const IMPLICIT_PET_DIRS = [...new Set([USER_PETS_DIR, BUNDLED_PETS_DIR, CODEX_PETS_DIR])];
const CELL_W = 192; // spritesheet 單格來源尺寸
const CELL_H = 208;
// 100% 時的顯示尺寸,對齊 Codex 桌面寵物:寬度為基準,高度用 Codex 的 ake() 公式 ceil(w * 208/192)
const PET_W = 126;
const PET_H = Math.ceil((PET_W * CELL_H) / CELL_W); // 137
const BUBBLE_H = 72; // 氣泡保留高度
const PAD_X = 24; // 左右最小透明邊
const PAD_BOTTOM = 8;
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
// Codex 的 mascot 寬度可調範圍是 80224 pxike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
const DEFAULT_CONFIG = {
activePetId: "xiao-nian",
petSources: [], // 只放使用者自己加的額外路徑;執行目錄的 pets 與 ~/.codex/pets 一律隱含
scale: 1,
position: null,
alwaysOnTop: true,
followCursor: false, // Codex 的寵物不看滑鼠(它看的是 quick chat 文字游標/computer-use 游標);預設關
greetedPetIds: [], // 已經打過招呼的寵物(Codex 的 first-awake 每隻只做一次)
port: 17333,
};
let config = loadConfig();
let win = null;
let tray = null;
let pets = [];
let dragTimer = null;
let cursorTimer = null;
let lastCursor = { x: NaN, y: NaN };
let currentState = { anim: "idle", base: "idle", sessions: 0 };
// ---------- config ----------
function loadConfig() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ }
const merged = { ...DEFAULT_CONFIG, ...saved };
// petSources 只存「使用者自己加的」。隱含來源(執行目錄的 pets、~/.codex/pets)不寫進設定檔,
// 否則程式一搬家、或在安裝版與 portable 版之間換來換去,設定檔就會一直累積失效的絕對路徑。
const implicit = new Set(IMPLICIT_PET_DIRS.map(dirKey));
merged.petSources = (Array.isArray(merged.petSources) ? merged.petSources : [])
.filter((p) => typeof p === "string" && p.trim())
.filter((p) => !implicit.has(dirKey(p)))
.filter((p) => fs.existsSync(p));
if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
merged.scale = Number(merged.scale);
if (!Array.isArray(merged.greetedPetIds)) merged.greetedPetIds = [];
return merged;
}
function saveConfig() {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
} catch (err) {
console.error("saveConfig failed:", err);
}
}
function appendLog(line) {
try {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
try { if (fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) fs.truncateSync(LOG_PATH, 0); } catch { /* no file yet */ }
fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`, "utf8");
} catch { /* ignore */ }
}
// ---------- transcript:取最新的助理說明文字 ----------
// Claude Code 從 2.1.238 起不再把 thinking 的文字寫進 transcript(只剩加密簽章),
// 但助理的可見說明(text 區塊)還在。讀檔尾就夠了,transcript 會長到好幾 MB。
const TRANSCRIPT_TAIL = 192 * 1024;
const narrationCache = new Map(); // path → { size, value }
// 回傳 { narration: { id, text } | null, title: string | null }
// title 取自 /rename 寫進 transcript 的 custom-title,沒有就讓呼叫端退回專案名
function readTranscript(file) {
if (typeof file !== "string" || !file) return null;
let stat;
try { stat = fs.statSync(file); } catch { return null; }
const cached = narrationCache.get(file);
if (cached && cached.size === stat.size) return cached.value;
let narration = null;
let title = null;
try {
const start = Math.max(0, stat.size - TRANSCRIPT_TAIL);
const fd = fs.openSync(file, "r");
const buf = Buffer.alloc(stat.size - start);
fs.readSync(fd, buf, 0, buf.length, start);
fs.closeSync(fd);
const lines = buf.toString("utf8").split("\n");
if (start > 0) lines.shift(); // 第一行多半被切一半
// 由後往前找,先遇到的就是最新的;兩樣都拿到就停
for (let i = lines.length - 1; i >= 0 && !(narration && title); i--) {
const line = lines[i].trim();
if (!line) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (!title && entry.type === "custom-title" && typeof entry.customTitle === "string" && entry.customTitle.trim()) {
title = entry.customTitle.trim().slice(0, 60);
continue;
}
if (narration || entry.type !== "assistant") continue;
const content = entry.message?.content;
if (!Array.isArray(content)) continue;
for (const block of content) {
if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) {
narration = { id: entry.uuid || entry.timestamp || String(i), text: block.text.trim().slice(0, 400) };
break;
}
}
}
} catch { /* 讀不到就算了,寵物不該因此出錯 */ }
const value = { narration, title };
narrationCache.set(file, { size: stat.size, value });
if (narrationCache.size > 32) narrationCache.delete(narrationCache.keys().next().value);
return value;
}
// ---------- pets ----------
function dirKey(p) {
return path.resolve(p).toLowerCase();
}
// 實際要掃的資料夾:執行目錄的 pets 永遠排最前面,其次是 Codex 的,最後才是使用者自己加的
function petSearchDirs() {
const seen = new Set();
return [...IMPLICIT_PET_DIRS, ...config.petSources].filter((p) => {
const key = dirKey(p);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
// portable 版第一次跑時,在 exe 旁邊開一個 pets 資料夾給使用者放自己的寵物
function ensureUserPetsDir() {
if (USER_PETS_DIR === BUNDLED_PETS_DIR) return;
try { fs.mkdirSync(USER_PETS_DIR, { recursive: true }); } catch { /* 唯讀路徑就算了 */ }
}
function discoverPets() {
const found = [];
const seen = new Set();
for (const src of petSearchDirs()) {
let entries = [];
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; }
for (const ent of entries) {
if (!ent.isDirectory()) continue;
const dir = path.join(src, ent.name);
let manifest;
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "pet.json"), "utf8")); } catch { continue; }
const id = String(manifest.id || ent.name);
if (seen.has(id)) continue;
const spritesheetPath = path.join(dir, manifest.spritesheetPath || "spritesheet.webp");
if (!fs.existsSync(spritesheetPath)) continue;
seen.add(id);
found.push({
id,
displayName: manifest.displayName || id,
description: manifest.description || "",
dir,
spritesheetPath,
version: Number(manifest.spriteVersionNumber) === 2 ? 2 : 1,
});
}
}
return found;
}
function activePet() {
return pets.find((p) => p.id === config.activePetId) || pets[0] || null;
}
function petPayload(pet) {
if (!pet) return null;
const ext = path.extname(pet.spritesheetPath).toLowerCase();
const mime = ext === ".png" ? "image/png" : "image/webp";
const data = fs.readFileSync(pet.spritesheetPath).toString("base64");
return {
id: pet.id,
displayName: pet.displayName,
description: pet.description,
version: pet.version,
greeted: config.greetedPetIds.includes(pet.id),
dataUrl: `data:${mime};base64,${data}`,
};
}
// ---------- window ----------
function windowSizeFor(scale) {
return {
width: Math.max(Math.round(PET_W * scale) + PAD_X * 2, MIN_WIN_W),
height: Math.round(PET_H * scale) + BUBBLE_H + PAD_BOTTOM,
};
}
function defaultPosition(size) {
const { workArea } = screen.getPrimaryDisplay();
return {
// Codex 的 anchor:離工作區右邊與底邊各 24 px(z5 = 24
x: workArea.x + workArea.width - size.width - 24,
y: workArea.y + workArea.height - size.height - 24,
};
}
function clampToDisplays(pos, size) {
const display = screen.getDisplayMatching({ x: pos.x, y: pos.y, width: size.width, height: size.height });
const wa = display.workArea;
return {
x: Math.round(Math.min(Math.max(pos.x, wa.x - size.width + 48), wa.x + wa.width - 48)),
y: Math.round(Math.min(Math.max(pos.y, wa.y - 16), wa.y + wa.height - 48)),
};
}
function createWindow() {
const size = windowSizeFor(config.scale);
const pos = clampToDisplays(config.position || defaultPosition(size), size);
win = new BrowserWindow({
x: pos.x,
y: pos.y,
width: size.width,
height: size.height,
transparent: true,
frame: false,
alwaysOnTop: config.alwaysOnTop,
skipTaskbar: true,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
fullscreenable: false,
hasShadow: false,
focusable: false,
show: false,
title: "Claude Pet",
webPreferences: {
preload: path.join(APP_DIR, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
},
});
win.setMenu(null);
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
win.setIgnoreMouseEvents(true, { forward: true });
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
win.once("ready-to-show", () => win.showInactive());
win.on("closed", () => { win = null; });
}
function send(channel, data) {
if (win && !win.isDestroyed()) win.webContents.send(channel, data);
}
function sendInit() {
send("pet:init", {
pet: petPayload(activePet()),
scale: config.scale,
followCursor: config.followCursor,
});
}
// ---------- drag ----------
function startDrag(offsetX, offsetY) {
if (!win) return;
endDrag(false);
let last = screen.getCursorScreenPoint();
const startedAt = Date.now();
dragTimer = setInterval(() => {
if (!win || Date.now() - startedAt > 60000) return endDrag(true);
const p = screen.getCursorScreenPoint();
const dx = p.x - last.x;
last = p;
win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false);
send("pet:drag-move", { dx });
}, 16);
}
function endDrag(save) {
if (dragTimer) {
clearInterval(dragTimer);
dragTimer = null;
}
if (save && win) {
const [x, y] = win.getPosition();
config.position = { x, y };
saveConfig();
}
}
// ---------- cursor look ----------
function startCursorPolling() {
cursorTimer = setInterval(() => {
if (!win || dragTimer) return;
const p = screen.getCursorScreenPoint();
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
lastCursor = p;
const b = win.getBounds();
// 點穿判定不能只靠 renderer 的 mousemove。視窗處於點穿狀態時,Electron 的
// setIgnoreMouseEvents(true, { forward: true }) 並不保證一定會把 mousemove 轉發進來
// (游標直接跳到寵物上常常收不到,慢慢移過去才會)。一旦在游標還停在寵物身上時
// 進入點穿,就再也收不到事件、ignoringMouse 永遠是 true,滑鼠按下也傳不進視窗
// → 就是「有時候卡住無法拖曳」。主程序自己輪詢絕對不會漏,所以改由這裡把
// 視窗相對座標送過去,讓 renderer 重新做一次判定。
send("pet:cursor-pos", { x: p.x - b.x, y: p.y - b.y });
if (!config.followCursor) return;
const cx = b.x + b.width / 2;
const cy = b.y + BUBBLE_H + (PET_H * config.scale) / 2; // 人物方框的中心,與 Codex 相同
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
}, 50);
}
// ---------- HTTP:接收 Claude Code hook ----------
function startServer() {
const server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/event") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on("end", () => {
let payload = null;
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
if (payload && typeof payload === "object") {
appendLog(JSON.stringify(payload));
const info = readTranscript(payload.transcriptPath);
if (info) {
if (info.narration) {
payload.narration = info.narration.text;
payload.narrationId = info.narration.id;
}
if (info.title) payload.sessionTitle = info.title;
}
send("pet:event", payload);
res.writeHead(204);
} else {
res.writeHead(400);
}
res.end();
});
return;
}
if (req.method === "GET" && req.url === "/state") {
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify({ ...currentState, pet: activePet()?.id || null, scale: config.scale }));
return;
}
if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "text/plain" });
res.end("ok");
return;
}
res.writeHead(404);
res.end();
});
server.on("error", (err) => {
dialog.showErrorBox("Claude Pet", `無法監聽 127.0.0.1:${config.port}\n${err.message}\n\n是不是已經有一隻在跑了?`);
});
server.listen(config.port, "127.0.0.1");
}
// ---------- tray / menu ----------
function fallbackTrayIcon() {
const size = 16;
const buf = Buffer.alloc(size * size * 4);
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const dx = x - 7.5;
const dy = y - 7.5;
const inside = dx * dx + dy * dy <= 6.5 * 6.5;
const i = (y * size + x) * 4;
buf[i] = 0x4a; buf[i + 1] = 0x7c; buf[i + 2] = 0xff; buf[i + 3] = inside ? 255 : 0; // BGRA
}
}
return nativeImage.createFromBitmap(buf, { width: size, height: size });
}
function createTray() {
tray = new Tray(fallbackTrayIcon());
tray.setToolTip("Claude Pet");
tray.on("click", () => tray.popUpContextMenu(buildMenu()));
refreshTray();
}
function refreshTray() {
if (!tray) return;
tray.setContextMenu(buildMenu());
const pet = activePet();
tray.setToolTip(`Claude Pet ${pet ? pet.displayName : "無寵物"}\n狀態:${currentState.anim}${currentState.sessions} 個 session`);
}
function runSafely(fn) {
try { fn(); } catch (err) { dialog.showErrorBox("Claude Pet", String(err.message || err)); }
refreshTray();
}
function info(message) {
dialog.showMessageBox({ type: "info", title: "Claude Pet", message, buttons: ["OK"] });
}
function buildMenu() {
const pet = activePet();
const TESTS = [
["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果(完成)"],
["failed", "失敗"], ["waving", "揮手"], ["jumping", "跳躍"],
];
return Menu.buildFromTemplate([
{ label: pet ? `${pet.displayName}${pet.id}` : "沒有可用的寵物", enabled: false },
{ type: "separator" },
{
label: "切換寵物",
submenu: [
...pets.map((p) => ({
label: `${p.displayName}${p.version === 2 ? "" : "v1,無追視)"}`,
type: "radio",
checked: p.id === pet?.id,
click: () => switchPet(p.id),
})),
{ type: "separator" },
{ label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } },
{ label: "開啟寵物資料夾", click: () => { ensureUserPetsDir(); shell.openPath(USER_PETS_DIR); } },
],
},
{
label: "大小",
submenu: SCALES.map((s) => ({
label: `${Math.round(s * 100)}%`,
type: "radio",
checked: s === config.scale,
click: () => setScale(s),
})),
},
{
label: "跟著游標看",
type: "checkbox",
checked: config.followCursor,
click: (item) => {
config.followCursor = item.checked;
saveConfig();
send("pet:settings", { followCursor: config.followCursor });
},
},
{
label: "永遠置頂",
type: "checkbox",
checked: config.alwaysOnTop,
click: (item) => {
config.alwaysOnTop = item.checked;
saveConfig();
win?.setAlwaysOnTop(item.checked, "screen-saver");
},
},
{
label: "開機自動啟動",
type: "checkbox",
checked: autostart.isInstalled(),
click: (item) => runSafely(() => (item.checked ? autostart.install() : autostart.uninstall())),
},
{ type: "separator" },
{
label: "動作測試",
submenu: [
...TESTS.map(([anim, label]) => ({ label, click: () => send("pet:event", { event: "__test", anim }) })),
{ type: "separator" },
{ label: "清除測試狀態", click: () => send("pet:event", { event: "__test", anim: "clear" }) },
],
},
{
label: "Claude Code hooks",
submenu: [
{
label: hooksInstaller.isInstalled() ? "已安裝 ✓(重新安裝)" : "安裝到 ~/.claude/settings.json",
click: () => runSafely(() => {
const r = hooksInstaller.install();
info(`已安裝 ${r.events.length} 個 hook 事件。\n\n已開啟的 Claude Code session 要重新啟動才會生效。`);
}),
},
{
label: "移除",
enabled: hooksInstaller.isInstalled(),
click: () => runSafely(() => {
const r = hooksInstaller.uninstall();
info(`已移除 ${r.removed} 個 hook。`);
}),
},
],
},
{ label: "回到預設位置", click: resetPosition },
{ label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
{ label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
{ type: "separator" },
{ label: "結束", click: () => app.quit() },
]);
}
function switchPet(id) {
if (!pets.some((p) => p.id === id)) return;
config.activePetId = id;
saveConfig();
send("pet:load", { pet: petPayload(activePet()) });
refreshTray();
}
function setScale(scale) {
if (!win) return;
const b = win.getBounds();
const size = windowSizeFor(scale);
const x = Math.round(b.x + (b.width - size.width) / 2);
const y = Math.round(b.y + (b.height - size.height));
win.setBounds({ x, y, width: size.width, height: size.height }, false);
config.scale = scale;
config.position = { x, y };
saveConfig();
send("pet:scale", { scale });
refreshTray();
}
function resetPosition() {
if (!win) return;
const pos = defaultPosition(windowSizeFor(config.scale));
win.setPosition(pos.x, pos.y, false);
config.position = null;
saveConfig();
}
// ---------- IPC ----------
ipcMain.on("pet:ready", () => sendInit());
ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true }));
ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0));
ipcMain.on("pet:drag-end", () => endDrag(true));
ipcMain.on("pet:context-menu", () => buildMenu().popup({ window: win }));
ipcMain.on("pet:tray-icon", (_e, dataUrl) => {
try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); }
});
ipcMain.on("pet:state", (_e, state) => {
currentState = { ...currentState, ...state };
refreshTray();
});
ipcMain.on("pet:log", (_e, line) => appendLog(String(line)));
ipcMain.on("pet:greeted", (_e, id) => {
if (typeof id !== "string" || config.greetedPetIds.includes(id)) return;
config.greetedPetIds.push(id);
saveConfig();
});
// ---------- app ----------
app.setAppUserModelId("com.jianmiau.claude-pet");
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
if (win) win.showInactive();
});
app.whenReady().then(() => {
ensureUserPetsDir();
pets = discoverPets();
if (pets.length === 0) {
dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${petSearchDirs().join("\n")}`);
}
createWindow();
createTray();
startServer();
startCursorPolling();
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
});
app.on("window-all-closed", () => {
// 留在系統匣,不結束
});
app.on("before-quit", () => {
if (cursorTimer) clearInterval(cursorTimer);
endDrag(false);
});
}