"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 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); }); }