根本原因: 點穿狀態一直是 renderer 判定後再用 IPC 叫主程序切換。renderer 只要例外、 卡住或狀態錯亂(例如視窗永遠不取得焦點、Windows 對背景視窗的 capture 有限制, 放開的那一下落在視窗外就收不到 mouseup,pressed 從此卡在 true),主程序就 再也收不到切換指令,寵物永遠點不到。另外 2.0.8 起每 2 秒的置頂重宣告會在 右鍵選單開著時把寵物視窗抬到選單上面,2.0.10 改成整個方框接滑鼠後,被蓋住 的選單項目就點不到了。 影響: 偶發「不能拖曳、不能右鍵」,而且發生時沒有任何可查的狀態或紀錄。 修法: 1. 主程序每 50 ms 自己輪詢游標,對照 renderer 回報的人物方框與氣泡矩形 (pet:hit-rects)直接呼叫 setIgnoreMouseEvents;決策邏輯抽成 lib/hit-test.js。 renderer 沒回報或掛掉時用版面公式算方框當備援,並自動重載。 2. 拖曳不管因 mouseup、逾時(20 秒)或其他原因結束,都送 pet:drag-ended 讓 renderer 清掉按住/拖曳狀態。 3. 右鍵選單開著時暫停置頂重宣告。 4. /state 回傳 bounds 與 mouse 診斷(是否點穿、游標相對座標、是否在方框/ 氣泡、矩形來源、切換與輪詢次數、renderer 錯誤、拖曳結束原因);renderer 例外、拖曳開始/結束、選單開關寫入事件紀錄。 版號 2.0.11。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
751 lines
28 KiB
JavaScript
751 lines
28 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 { 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 寬度可調範圍是 80–224 px(ike() 的 clamp);以 126 為 100%,這幾檔都落在範圍內
|
||
const SCALES = [0.65, 0.8, 1, 1.25, 1.5, 1.75];
|
||
const TOPMOST_REASSERT_MS = 2000; // 多久重新宣告一次置頂(見 reassertTopmost)
|
||
const DRAG_MAX_MS = 20000; // 拖曳最長時間:放開事件若沒送到,別讓寵物一直跟著游標(見 endDrag)
|
||
const { inside, petBoxRect, wantIgnore } = require("./lib/hit-test");
|
||
|
||
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 };
|
||
// 點穿狀態由主程序自己決定與追蹤(見 updateIgnore);renderer 只回報它量到的矩形
|
||
let ignoring = true;
|
||
let hitRects = null; // renderer 回報的 { box, bubble }(視窗相對座標),null 表示還沒回報
|
||
let menuOpenedAt = 0; // 右鍵選單打開的時間,0 表示沒開
|
||
// 給 /state 看的診斷資料:下次「點不到寵物」時不用猜,直接看這裡
|
||
const mouseDiag = {
|
||
toggles: 0, lastToggleAt: null, polls: 0, lastPollAt: null,
|
||
rel: null, inBox: false, inBubble: false,
|
||
rendererErrors: 0, lastRendererError: null, dragEnds: [],
|
||
};
|
||
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; });
|
||
// renderer 掛了就記下來並重載;點穿判定在主程序,寵物不會因此卡成點不到
|
||
win.webContents.on("render-process-gone", (_e, details) => {
|
||
appendLog(`renderer gone: ${details?.reason || "unknown"}`);
|
||
hitRects = null;
|
||
setTimeout(() => { if (win && !win.isDestroyed()) win.webContents.reload(); }, 500);
|
||
});
|
||
win.webContents.on("unresponsive", () => appendLog("renderer unresponsive"));
|
||
win.webContents.on("responsive", () => appendLog("renderer responsive again"));
|
||
}
|
||
|
||
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 || win.isDestroyed()) return;
|
||
endDrag(false, "restart");
|
||
let last = screen.getCursorScreenPoint();
|
||
const startedAt = Date.now();
|
||
appendLog(`drag start offset=${offsetX},${offsetY}`);
|
||
applyIgnore(false);
|
||
dragTimer = setInterval(() => {
|
||
if (!win || win.isDestroyed()) return endDrag(false, "no-window");
|
||
if (Date.now() - startedAt > DRAG_MAX_MS) return endDrag(true, "timeout");
|
||
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);
|
||
}
|
||
|
||
// 視窗永遠不取得焦點(focusable:false),Windows 對背景視窗的滑鼠 capture 有限制:
|
||
// 放開的那一下若剛好落在視窗外,renderer 收不到 mouseup。所以結束拖曳的原因不只 mouseup,
|
||
// 而且不管誰結束的都要通知 renderer 清掉按住/拖曳狀態,免得它一直以為還在拖。
|
||
function endDrag(save, reason = "mouseup") {
|
||
if (!dragTimer) return;
|
||
clearInterval(dragTimer);
|
||
dragTimer = null;
|
||
if (save && win && !win.isDestroyed()) {
|
||
const [x, y] = win.getPosition();
|
||
config.position = { x, y };
|
||
saveConfig();
|
||
}
|
||
appendLog(`drag end reason=${reason} pos=${config.position ? `${config.position.x},${config.position.y}` : "?"}`);
|
||
mouseDiag.dragEnds.push({ at: Date.now(), reason });
|
||
if (mouseDiag.dragEnds.length > 10) mouseDiag.dragEnds.shift();
|
||
send("pet:drag-ended");
|
||
}
|
||
|
||
// ---------- 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() {
|
||
// 選單開著時不做:moveTop 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到了
|
||
if (!win || win.isDestroyed() || !config.alwaysOnTop || dragTimer || menuIsOpen()) 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 menuIsOpen() {
|
||
// popup 的 callback 關閉時一定會來;保險起見超過 60 秒就當它已經關了
|
||
return menuOpenedAt > 0 && Date.now() - menuOpenedAt < 60000;
|
||
}
|
||
|
||
function currentHitRects() {
|
||
if (hitRects) return hitRects;
|
||
if (!win || win.isDestroyed()) return { box: null, bubble: null };
|
||
// renderer 還沒回報(或掛了):用版面公式算人物方框,氣泡就當沒有
|
||
return { box: petBoxRect(win.getBounds(), config.scale, { petW: PET_W, petH: PET_H, bubbleH: BUBBLE_H }), bubble: null };
|
||
}
|
||
|
||
// 點穿只在這裡切換。以前是 renderer 判定再用 IPC 叫主程序切:renderer 一旦例外、卡住或狀態錯亂
|
||
// (例如放開事件沒送到、pressed 一直是 true),點穿就永遠不會再更新——這是「不能拖曳也不能右鍵」
|
||
// 最難查的一種成因。現在主程序拿自己輪詢到的游標、renderer 回報的矩形、是否拖曳中,自己決定。
|
||
function applyIgnore(ignore) {
|
||
if (!win || win.isDestroyed() || ignore === ignoring) return;
|
||
ignoring = ignore;
|
||
mouseDiag.toggles++;
|
||
mouseDiag.lastToggleAt = Date.now();
|
||
try { win.setIgnoreMouseEvents(ignore, { forward: true }); } catch { /* 視窗正在關閉 */ }
|
||
send("pet:ignore-state", ignore);
|
||
}
|
||
|
||
function updateIgnore(rel) {
|
||
const { box, bubble } = currentHitRects();
|
||
mouseDiag.rel = rel;
|
||
mouseDiag.inBox = inside(box, rel);
|
||
mouseDiag.inBubble = inside(bubble, rel);
|
||
applyIgnore(wantIgnore({ rel, box, bubble, dragging: !!dragTimer }));
|
||
}
|
||
|
||
function startCursorPolling() {
|
||
cursorTimer = setInterval(() => {
|
||
if (!win || win.isDestroyed()) return;
|
||
let p;
|
||
try { p = screen.getCursorScreenPoint(); } catch { return; }
|
||
mouseDiag.polls++;
|
||
mouseDiag.lastPollAt = Date.now();
|
||
const b = win.getBounds();
|
||
const rel = { x: p.x - b.x, y: p.y - b.y };
|
||
updateIgnore(rel);
|
||
if (dragTimer) return; // 拖曳中視窗跟著游標走,座標交給 drag loop
|
||
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
||
lastCursor = p;
|
||
// hover(跳三下)與拖曳起點仍由 renderer 處理,所以座標照送。視窗處於點穿狀態時
|
||
// Electron 的 setIgnoreMouseEvents(true, { forward: true }) 並不保證會把 mousemove 轉發進來
|
||
// (游標直接跳到寵物上常常收不到),主程序自己輪詢絕對不會漏。
|
||
send("pet:cursor-pos", rel);
|
||
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,
|
||
bounds: win && !win.isDestroyed() ? win.getBounds() : null,
|
||
mouse: {
|
||
ignoring,
|
||
dragging: !!dragTimer,
|
||
menuOpen: menuIsOpen(),
|
||
rects: { ...currentHitRects(), source: hitRects ? "renderer" : "fallback" },
|
||
...mouseDiag,
|
||
},
|
||
}));
|
||
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());
|
||
function sanitizeRect(r) {
|
||
if (!r || typeof r !== "object") return null;
|
||
const v = ["x", "y", "w", "h"].map((k) => Number(r[k]));
|
||
return v.every(Number.isFinite) ? { x: v[0], y: v[1], w: v[2], h: v[3] } : null;
|
||
}
|
||
ipcMain.on("pet:hit-rects", (_e, rects) => {
|
||
const box = sanitizeRect(rects?.box);
|
||
hitRects = box ? { box, bubble: sanitizeRect(rects?.bubble) } : null;
|
||
// 氣泡剛出現在靜止的游標底下這種情況,不等下一次游標移動就重算
|
||
if (win && !win.isDestroyed() && Number.isFinite(lastCursor.x)) {
|
||
const b = win.getBounds();
|
||
updateIgnore({ x: lastCursor.x - b.x, y: lastCursor.y - b.y });
|
||
}
|
||
});
|
||
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", () => {
|
||
if (!win || win.isDestroyed()) return;
|
||
menuOpenedAt = Date.now(); // 開著的期間暫停置頂重宣告,見 reassertTopmost
|
||
appendLog("menu open");
|
||
buildMenu().popup({ window: win, callback: () => { menuOpenedAt = 0; appendLog("menu closed"); } });
|
||
});
|
||
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) => {
|
||
const s = String(line);
|
||
if (s.includes("[renderer] error")) {
|
||
mouseDiag.rendererErrors++;
|
||
mouseDiag.lastRendererError = s.slice(0, 200);
|
||
}
|
||
appendLog(s);
|
||
});
|
||
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);
|
||
});
|
||
}
|