Files
claude-pet/main.js
T
JianMiauandClaude Fable 5 a014f9a279 把 100% 的顯示尺寸對齊 Codex 桌面寵物(126 × 137)
摘要:
原本 100% 直接用 spritesheet 的單格尺寸 192 × 208 顯示,比並排的 Codex 桌面寵物大了一半,就算縮到 75% 還是明顯偏大。

根本原因:
把「spritesheet 來源格子大小」直接當成「畫面顯示大小」在用。Codex 是另外算的:mascot 以寬度為基準,高度用 ceil(寬 × 208/192) 求得,跟單格 192 × 208 無關。

影響:
兩隻並排時大小不一致;使用者得手動調到某個非整數的縮放才勉強接近,而每一級縮放又都以 192 × 208 為基礎,永遠對不準。

修法:
- 新增 PET_W / PET_H(126 × 137),與 CELL_W / CELL_H 分家:後者只負責從 spritesheet 取格子,前者才是畫到畫面上的大小。
- 高度沿用 Codex 的 ake() 公式 ceil(寬 × 208/192),長寬比與 Codex 一致。
- 尺寸取自實測:Codex 與本程式並排截圖比對,因 xiao-nian 每一格的人物輪廓都固定是 208 中的 198 px,兩者輪廓高度比即等於顯示尺寸比(與螢幕 DPI 無關),推得 Codex 為 137.3 × 126.7。
- 視窗寬度加上 MIN_WIN_W = 240 下限:寵物縮小後視窗跟著變窄會讓氣泡被截斷,Codex 的氣泡(315 px)同樣遠寬於寵物本身。
- 追視死區 56 → 38,維持與寵物尺寸相同的比例。
- 視窗高度與游標追視的頭部位置一併改用 PET_H。

驗證:
scale 1 時人物實高 130.4 px,實測 Codex 為 130.7 px,誤差 0.2%。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 10:53:08 +08:00

505 lines
16 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 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; // 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 的氣泡也遠寬於寵物本身)
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.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 {
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 + PET_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);
});
}