501 lines
16 KiB
JavaScript
501 lines
16 KiB
JavaScript
"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 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");
|
|||
|
|
|
|||
|
|
const CELL_W = 192;
|
|||
|
|
const CELL_H = 208;
|
|||
|
|
const BUBBLE_H = 72; // 氣泡保留高度
|
|||
|
|
const PAD_X = 24; // 左右透明邊
|
|||
|
|
const PAD_BOTTOM = 8;
|
|||
|
|
const SCALES = [0.5, 0.75, 1, 1.25, 1.5];
|
|||
|
|
|
|||
|
|
const DEFAULT_CONFIG = {
|
|||
|
|
activePetId: "xiao-nian",
|
|||
|
|
petSources: [path.join(APP_DIR, "pets"), path.join(HOME, ".codex", "pets")],
|
|||
|
|
scale: 1,
|
|||
|
|
position: null,
|
|||
|
|
alwaysOnTop: true,
|
|||
|
|
followCursor: true,
|
|||
|
|
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 };
|
|||
|
|
if (!Array.isArray(merged.petSources)) merged.petSources = [];
|
|||
|
|
// 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到)
|
|||
|
|
const ownPets = path.join(APP_DIR, "pets");
|
|||
|
|
merged.petSources = [ownPets, ...merged.petSources.filter((p) => typeof p === "string" && path.resolve(p) !== ownPets && fs.existsSync(p))];
|
|||
|
|
if (!merged.petSources.includes(DEFAULT_CONFIG.petSources[1]) && fs.existsSync(DEFAULT_CONFIG.petSources[1])) merged.petSources.push(DEFAULT_CONFIG.petSources[1]);
|
|||
|
|
if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
|
|||
|
|
merged.scale = Number(merged.scale);
|
|||
|
|
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 */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- pets ----------
|
|||
|
|
|
|||
|
|
function discoverPets() {
|
|||
|
|
const found = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
for (const src of config.petSources) {
|
|||
|
|
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,
|
|||
|
|
dataUrl: `data:${mime};base64,${data}`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- window ----------
|
|||
|
|
|
|||
|
|
function windowSizeFor(scale) {
|
|||
|
|
return {
|
|||
|
|
width: Math.round(CELL_W * scale) + PAD_X * 2,
|
|||
|
|
height: Math.round(CELL_H * scale) + BUBBLE_H + PAD_BOTTOM,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function defaultPosition(size) {
|
|||
|
|
const { workArea } = screen.getPrimaryDisplay();
|
|||
|
|
return {
|
|||
|
|
x: workArea.x + workArea.width - size.width - 24,
|
|||
|
|
y: workArea.y + workArea.height - size.height - 4,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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();
|
|||
|
|
let lastDx = 0;
|
|||
|
|
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;
|
|||
|
|
if (Math.abs(dx) >= 1) lastDx = dx;
|
|||
|
|
win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false);
|
|||
|
|
send("pet:drag-move", { dx: lastDx });
|
|||
|
|
}, 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 || !config.followCursor || dragTimer) return;
|
|||
|
|
const p = screen.getCursorScreenPoint();
|
|||
|
|
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
|||
|
|
lastCursor = p;
|
|||
|
|
const b = win.getBounds();
|
|||
|
|
const cx = b.x + b.width / 2;
|
|||
|
|
const cy = b.y + BUBBLE_H + CELL_H * config.scale * 0.3; // 大約頭部的高度
|
|||
|
|
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));
|
|||
|
|
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", "失敗"], ["jumping", "跳躍(完成)"], ["waving", "揮手"],
|
|||
|
|
];
|
|||
|
|
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: () => shell.openPath(path.join(APP_DIR, "pets")) },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
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)));
|
|||
|
|
|
|||
|
|
// ---------- app ----------
|
|||
|
|
|
|||
|
|
app.setAppUserModelId("com.jianmiau.claude-pet");
|
|||
|
|
|
|||
|
|
if (!app.requestSingleInstanceLock()) {
|
|||
|
|
app.quit();
|
|||
|
|
} else {
|
|||
|
|
app.on("second-instance", () => {
|
|||
|
|
if (win) win.showInactive();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
app.whenReady().then(() => {
|
|||
|
|
pets = discoverPets();
|
|||
|
|
if (pets.length === 0) {
|
|||
|
|
dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${config.petSources.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);
|
|||
|
|
});
|
|||
|
|
}
|