2 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 cea80daa06 README 補上重啟選單、/restart 端點與滑鼠診斷說明
根本原因:
2.0.12/2.0.13 新增的滑鼠事件紀錄、鎖定/解鎖處理、重啟功能還沒寫進文件。

影響:
讀 README 不知道 /state 多了哪些診斷欄位、點不到寵物時該看哪裡。

修法:
選單清單加「重啟」,端點表加 POST /restart 並更新 /state 欄位,設計重點補上
診斷資料與鎖定/解鎖重新套用的說明。純文件,不重包。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:05:33 +08:00
JianMiauandClaude Fable 5 35865b9e17 右鍵選單新增「重啟」
根本原因:
使用者要求選單能直接重啟寵物,不必手動結束再開。

影響:
更新或狀態異常時需要到系統匣結束再找 exe 重開。

修法:
選單加「重啟」。安裝版/開發模式用 app.relaunch();portable 版不能這樣:
relaunch 會重跑解壓到 %TEMP% 的那顆 exe,而 portable 外殼在程式結束時會刪掉
整個解壓目錄、舊程序還在跑時重新解壓也會被檔案鎖住,所以改成延遲兩秒後
重新執行原本的 -portable.exe(PORTABLE_EXECUTABLE_FILE)再結束自己。另加
本機專用的 POST /restart 端點供自動化驗證。版號 2.0.13。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:04:41 +08:00
4 changed files with 34 additions and 6 deletions
+5 -2
View File
@@ -102,6 +102,7 @@ npm run autostart:install # (可選)開機自動啟動
- **Claude Code hooks** — 安裝 / 移除 hooks - **Claude Code hooks** — 安裝 / 移除 hooks
- **回到預設位置** — 跑到螢幕外面時用 - **回到預設位置** — 跑到螢幕外面時用
- **開啟設定檔** / **開啟事件紀錄** - **開啟設定檔** / **開啟事件紀錄**
- **重啟**(portable 版會延遲兩秒重新執行原本的 `-portable.exe`,因為外殼在程式結束時會刪掉解壓目錄)
- **結束** - **結束**
## 行為對照表 ## 行為對照表
@@ -244,7 +245,8 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
- **點穿由主程序決定**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`;主程序每 50 ms 自己輪詢游標(`screen.getCursorScreenPoint()`),對照 renderer 回報的**人物方框**與(顯示中的)氣泡矩形,游標在上面就接滑鼠、否則穿透(`lib/hit-test.js`,純邏輯可單元測試)。決策不經過 renderer:以前是 renderer 判定再用 IPC 叫主程序切,renderer 一旦例外、卡住或狀態錯亂(例如放開事件沒送到、`pressed` 一直是 true),點穿就永遠不會再更新,寵物就點不到了。renderer 沒回報矩形或掛掉時,主程序用版面公式算方框當備援;renderer 掛掉會記錄並自動重載。判定用方框而不是讀 canvas 該點的 alpha:人物的手腳與邊緣會隨動畫在透明/不透明之間切換(以小念為例,hover 時只有 44% 的人物像素每一幀都不透明),逐像素判定會讓點穿狀態跟著動畫高速抖動,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了。主程序輪詢也是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到)。 - **點穿由主程序決定**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`;主程序每 50 ms 自己輪詢游標(`screen.getCursorScreenPoint()`),對照 renderer 回報的**人物方框**與(顯示中的)氣泡矩形,游標在上面就接滑鼠、否則穿透(`lib/hit-test.js`,純邏輯可單元測試)。決策不經過 renderer:以前是 renderer 判定再用 IPC 叫主程序切,renderer 一旦例外、卡住或狀態錯亂(例如放開事件沒送到、`pressed` 一直是 true),點穿就永遠不會再更新,寵物就點不到了。renderer 沒回報矩形或掛掉時,主程序用版面公式算方框當備援;renderer 掛掉會記錄並自動重載。判定用方框而不是讀 canvas 該點的 alpha:人物的手腳與邊緣會隨動畫在透明/不透明之間切換(以小念為例,hover 時只有 44% 的人物像素每一幀都不透明),逐像素判定會讓點穿狀態跟著動畫高速抖動,按下去那一瞬間剛好是透明格,滑鼠事件就穿到底下的視窗去了。主程序輪詢也是必要的——視窗處於點穿狀態時 Electron 的 `forward` 不保證會把 mousemove 轉發進來(游標直接跳到寵物上常常收不到)。
- **拖曳結束一定通知 renderer**:視窗永遠不取得焦點,Windows 對背景視窗的滑鼠 capture 有限制,放開的那一下若落在視窗外 renderer 收不到 mouseup。主程序不管因為 mouseup、逾時(20 秒)或其他原因結束拖曳,都送 `pet:drag-ended` 讓 renderer 清掉按住/拖曳狀態。 - **拖曳結束一定通知 renderer**:視窗永遠不取得焦點,Windows 對背景視窗的滑鼠 capture 有限制,放開的那一下若落在視窗外 renderer 收不到 mouseup。主程序不管因為 mouseup、逾時(20 秒)或其他原因結束拖曳,都送 `pet:drag-ended` 讓 renderer 清掉按住/拖曳狀態。
- **右鍵選單開著時暫停置頂重宣告**:否則 `moveTop()` 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到。 - **右鍵選單開著時暫停置頂重宣告**:否則 `moveTop()` 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到。
- **診斷**`GET /state` 除了動畫狀態,還會回 `bounds``mouse`(目前是否點穿、游標的視窗相對座標、是否在方框/氣泡內、矩形來源是 renderer 還是備援、切換次數、輪詢次數、renderer 錯誤數、最近幾次拖曳結束的原因);renderer 的例外、拖曳開始/結束、選單開關都會寫進事件紀錄。 - **診斷**`GET /state` 除了動畫狀態,還會回 `bounds``mouse`(目前是否點穿、游標的視窗相對座標、是否在方框/氣泡內、矩形來源是 renderer 還是備援、切換次數、輪詢次數、renderer 錯誤數、最近幾次拖曳結束的原因、鎖定/解鎖/睡眠/喚醒事件)與 `rendererMouse`renderer 這邊的 presseddraghoveredoverlay)。renderer 收到的每個 mousedownmouseup、hover 進出、例外,以及拖曳開始/結束、選單開關、鎖定/解鎖都會寫進事件紀錄——「點不到寵物」時先看紀錄裡有沒有 `mousedown`,就能分清是 OS 沒把事件送進來,還是 renderer 收到了但狀態卡住
- **鎖定/解鎖後重新套用**:監聽 `powerMonitor``unlock-screen``resume`,解鎖或喚醒後重新套用目前的點穿狀態、`showInactive()` 並重宣告置頂(session 切換後 Windows 可能把視窗的輸入或 z-order 狀態弄掉)。
- **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。 - **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
- **置頂要定期重新宣告**:Windows 有時候會把視窗踢出 topmost band,卻留著 `WS_EX_TOPMOST` 樣式位元——從外部檢查看到 `TOPMOST=true`,但 z-order 排在一般視窗(例如 VS Code)之下,寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。所以每 2 秒重新宣告一次,並在螢幕配置變動時立刻補一次。`setAlwaysOnTop` 在狀態沒變時可能不會真的呼叫 `SetWindowPos`,因此再補一個 `moveTop()` 強制插回 topmost band 最上面;視窗是 `WS_EX_NOACTIVATE` + `focusable: false`,不會搶焦點。 - **置頂要定期重新宣告**:Windows 有時候會把視窗踢出 topmost band,卻留著 `WS_EX_TOPMOST` 樣式位元——從外部檢查看到 `TOPMOST=true`,但 z-order 排在一般視窗(例如 VS Code)之下,寵物就被蓋住,而 Electron 這邊仍以為自己是置頂的,不會自己修好。所以每 2 秒重新宣告一次,並在螢幕配置變動時立刻補一次。`setAlwaysOnTop` 在狀態沒變時可能不會真的呼叫 `SetWindowPos`,因此再補一個 `moveTop()` 強制插回 topmost band 最上面;視窗是 `WS_EX_NOACTIVATE` + `focusable: false`,不會搶焦點。
- **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。 - **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。
@@ -320,8 +322,9 @@ claude-pet
| 方法 | 路徑 | 說明 | | 方法 | 路徑 | 說明 |
|---|---|---| |---|---|---|
| `POST` | `/event` | 接收事件(JSON,≤ 64 KB),回 `204` | | `POST` | `/event` | 接收事件(JSON,≤ 64 KB),回 `204` |
| `GET` | `/state` | 目前狀態:`{"anim","base","sessions","pet","scale"}` | | `GET` | `/state` | 目前狀態:`{"anim","base","sessions","pet","scale","bounds","mouse","rendererMouse"}` |
| `GET` | `/health` | 回 `ok` | | `GET` | `/health` | 回 `ok` |
| `POST` | `/restart` | 重啟寵物(與選單的「重啟」相同) |
`POST /event` 的 payloadhook 腳本送出的格式): `POST /event` 的 payloadhook 腳本送出的格式):
+26 -1
View File
@@ -4,9 +4,10 @@ const http = require("node:http");
const fs = require("node:fs"); const fs = require("node:fs");
const path = require("node:path"); const path = require("node:path");
const os = require("node:os"); const os = require("node:os");
const { spawn } = require("node:child_process");
const autostart = require("./lib/autostart"); const autostart = require("./lib/autostart");
const hooksInstaller = require("./lib/hooks-installer"); const hooksInstaller = require("./lib/hooks-installer");
const { unpacked, portableDir, isPackaged } = require("./lib/paths"); const { unpacked, portableDir, portableExe, isPackaged } = require("./lib/paths");
const APP_DIR = __dirname; const APP_DIR = __dirname;
const HOME = os.homedir(); const HOME = os.homedir();
@@ -507,6 +508,12 @@ function startServer() {
})); }));
return; return;
} }
if (req.method === "POST" && req.url === "/restart") {
res.writeHead(204);
res.end();
setTimeout(restartApp, 100);
return;
}
if (req.method === "GET" && req.url === "/health") { if (req.method === "GET" && req.url === "/health") {
res.writeHead(200, { "content-type": "text/plain" }); res.writeHead(200, { "content-type": "text/plain" });
res.end("ok"); res.end("ok");
@@ -653,6 +660,7 @@ function buildMenu() {
{ label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } }, { label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
{ label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } }, { label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
{ type: "separator" }, { type: "separator" },
{ label: "重啟", click: restartApp },
{ label: "結束", click: () => app.quit() }, { label: "結束", click: () => app.quit() },
]); ]);
} }
@@ -679,6 +687,23 @@ function setScale(scale) {
refreshTray(); refreshTray();
} }
// 重啟。安裝版/開發模式用 app.relaunch()(等目前這個結束後才啟動新的)。
// portable 版不能這樣:relaunch 會重跑解壓到 %TEMP% 的那顆 exe,而 portable 外殼在程式結束時會把
// 整個解壓目錄刪掉、下次啟動再重新解壓;舊程序還在跑時檔案被鎖住,解壓也會失敗。
// 所以改成延遲兩秒後重新執行原本的 -portable.exe,然後結束自己。
function restartApp() {
appendLog("restart requested");
const exe = portableExe();
if (exe) {
const child = spawn("cmd.exe", ["/c", `ping -n 3 127.0.0.1 >nul && start "" "${exe}"`],
{ detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true });
child.unref();
} else {
app.relaunch();
}
app.quit();
}
function resetPosition() { function resetPosition() {
if (!win) return; if (!win) return;
const pos = defaultPosition(windowSizeFor(config.scale)); const pos = defaultPosition(windowSizeFor(config.scale));
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "claude-pet", "name": "claude-pet",
"version": "2.0.12", "version": "2.0.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "claude-pet", "name": "claude-pet",
"version": "2.0.12", "version": "2.0.13",
"devDependencies": { "devDependencies": {
"electron": "^43.4.1", "electron": "^43.4.1",
"electron-builder": "^26.0.12" "electron-builder": "^26.0.12"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claude-pet", "name": "claude-pet",
"version": "2.0.12", "version": "2.0.13",
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)", "description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)",
"main": "main.js", "main": "main.js",
"private": true, "private": true,