Files
claude-pet/main.js
T
JianMiauandClaude Fable 5 7060571df1 修正被 VS Code 蓋住:定期重新宣告置頂
摘要:
每 2 秒重新宣告一次置頂,並在螢幕配置變動與啟動後各補一次。

根本原因:
Windows 會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元。
以 Win32 檢查實際情況:寵物視窗的 exStyle 是 0x08280028(含 WS_EX_TOPMOST),
但 z-order 排在 Z=4,而兩個 TOPMOST=false 的 VS Code 視窗排在 Z=1 與 Z=2,
也就是它們蓋在寵物上面。Electron 這邊仍以為自己是置頂的,
setAlwaysOnTop 已經在建立視窗時與勾選選單時都設過,所以不會自己修好。

影響:
寵物被 VS Code 之類的一般視窗蓋住,只能重啟或重新勾一次選單的「永遠置頂」。

修法:
- 新增 reassertTopmost():setAlwaysOnTop(true, "screen-saver") 之後再呼叫 moveTop()。
  單靠 setAlwaysOnTop 不夠——狀態沒變時它可能不會真的呼叫 SetWindowPos;
  moveTop() 會強制 SetWindowPos,對 WS_EX_TOPMOST 的視窗即為插回 topmost band 最上面。
  視窗是 WS_EX_NOACTIVATE + focusable:false(已用 Win32 確認),不會搶焦點。
- 每 TOPMOST_REASSERT_MS(2000)呼叫一次;display-metrics-changed / added / removed
  也各接一次,因為螢幕配置變動最容易觸發降級。
- ready-to-show 的 showInactive 後補一次;選單勾選置頂時也補一次。
- 拖曳中(dragTimer 存在)跳過,避免和每 16 ms 的位置更新打架。
- 結束時清掉 timer。

驗證:
- 先從外部對執行中的視窗呼叫 SetWindowPos(HWND_TOPMOST) 確認這正是有效解:
  寵物由 Z=4 移到 Z=0,兩個 VS Code 視窗退到 Z=5、Z=6。
- 再以改好的版本實測守護行為,用 SetWindowPos(HWND_BOTTOM) 模擬 Windows 的降級:
  基準 petZ=0 TOPMOST=true 上方 0 個;強制降級後 petZ=17 TOPMOST=false 上方 4 個
  (VS Code 兩個、CM 等);1 秒後回到 petZ=0 TOPMOST=true 上方 0 個;3 秒後維持。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.8 與八項改動都在出貨檔案裡。

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

649 lines
23 KiB
JavaScript
Raw 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 TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost
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 topmostTimer = 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();
reassertTopmost();
});
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 ----------
// Windows 有時候會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元:
// 從外部檢查會看到 TOPMOST=true,但 z-order 排在一般視窗(例如 VS Code)之下,
// 於是寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。
// setAlwaysOnTop 在狀態沒變時可能不會真的呼叫 SetWindowPos,所以再補一個 moveTop()
// 強制重新插回 topmost band 的最上面(視窗是 WS_EX_NOACTIVATE + focusable:false,不會搶焦點)。
function reassertTopmost() {
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer) return;
try {
win.setAlwaysOnTop(true, "screen-saver");
win.moveTop();
} catch { /* 視窗正在關閉就算了 */ }
}
function startTopmostGuard() {
topmostTimer = setInterval(reassertTopmost, TOPMOST_REASSERT_MS);
// 螢幕配置變動最容易觸發降級,變動後立刻補一次
screen.on("display-metrics-changed", reassertTopmost);
screen.on("display-added", reassertTopmost);
screen.on("display-removed", reassertTopmost);
}
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");
if (item.checked) reassertTopmost();
},
},
{
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();
startTopmostGuard();
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);
if (topmostTimer) clearInterval(topmostTimer);
endDrag(false);
});
}