12 Commits
Author SHA1 Message Date
JianMiauandClaude Fable 5 eeba11e643 修正解鎖螢幕後不能拖曳、不能右鍵:互動不再依賴 mousedown
根本原因:
2.0.13 的事件紀錄證實:鎖定螢幕再解鎖後,renderer 只收得到 mouseup 與
mousemove,左右鍵的 mousedown 全部被吃掉,直到重啟才恢復(符合 Windows 對
永不啟用視窗的 WM_MOUSEACTIVATE 回傳 MA_NOACTIVATEANDEAT 的行為,只丟掉按下
那一則訊息)。而左鍵靠 mousedown 設 pressed、右鍵靠 mousedown 開選單,兩者
因此同時失效。

影響:
每次鎖定/解鎖後寵物就不能拖曳、不能右鍵,只能重啟。

修法:
右鍵選單改在 mouseup 開(也是 Windows 慣例);mousemove 的 buttons 位元帶著
「左鍵按著」而沒有 pressed 時補一個 synthetic pressed,拖曳照常;沒有
mousedown 的左鍵 mouseup 當成單擊。補上對應單元測試與 README 說明。
版號 2.0.14。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 15:24:12 +08:00
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
JianMiauandClaude Fable 5 30d3e481e4 加入滑鼠事件紀錄與鎖定/解鎖處理,追查解鎖後點不到寵物
根本原因:
使用者回報登出/鎖定螢幕再回來後就不能拖曳、不能右鍵。現場量測顯示 OS
這一層正常(點穿位元會切、WindowFromPoint 打到寵物視窗),但事件紀錄裡
完全沒有拖曳或選單的痕跡,代表問題在 renderer 有沒有收到滑鼠事件這一層,
而這一層目前沒有任何可觀測資料,無法判斷。

影響:
解鎖後偶發點不到寵物,且發生時查不出卡在哪裡。

修法:
1. renderer 把 mousedown/mouseup(含按鍵與座標、當時的 pressed/drag)與
   hover 進出寫進事件紀錄;pet:state 帶 rendererMouse(pressed/drag/hovered
   /overlay),/state 可直接查。
2. 主程序監聽 powerMonitor 的 lock-screen/unlock-screen/suspend/resume:
   記錄到事件紀錄與 /state.mouse.sessionEvents,解鎖或喚醒後重新套用目前的
   點穿狀態、showInactive 並重宣告置頂。
版號 2.0.12。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:56:59 +08:00
JianMiauandClaude Fable 5 bf059b19cf 點穿改由主程序決定並加入滑鼠診斷:修不能拖曳、不能右鍵
根本原因:
點穿狀態一直是 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>
2026-08-25 14:15:29 +08:00
JianMiauandClaude Fable 5 a9497cc123 修正又被穿透、不能拖曳也不能右鍵:點穿改用人物方框判定
根本原因:
點穿是逐像素讀 canvas 該點的 alpha 來決定,而 2.0.7 起每一幀都會重驗。
人物的手腳與邊緣會隨動畫在透明/不透明之間切換(實測小念 hover 時
idle+jumping 共 11 幀,只有 44% 的人物像素每一幀都不透明;running 也只有
56%),游標停在邊緣時點穿位元就跟著動畫高速切換,按下去那一瞬間剛好是
透明幀,滑鼠事件就穿到底下的視窗去了。hover 會觸發跳三下,正好是使用者
伸手要抓寵物的時候,最容易中。

影響:
游標明明在寵物身上,左鍵拖不動、右鍵沒有選單,點擊落到底下的視窗。

修法:
點穿與 hover 一樣改用人物方框(canvas 矩形)或氣泡矩形判定,狀態只取決於
游標位置,不再與動畫幀連動;移除 overSprite 與 tick 內的每幀重驗。這也是
Codex 的做法。README 對應段落與對照表同步更新。版號 2.0.10。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:06:32 +08:00
JianMiauandClaude Fable 5 4e4563e861 完成時氣泡改顯示最後一句回覆,不再延到下一輪才出現
根本原因:
Stop 事件時 main.js 已從 transcript 附上最後一句助理訊息(narration),
但 renderer 的 Stop 分支寫死顯示「完成!」沒使用它,也沒把 narrationId
標記為已顯示;下一輪第一個工具事件呼叫 sayActivity 時,transcript 裡
最新的助理訊息仍是上一輪結尾、id 又沒被消費過,就被當成新話顯示出來。

影響:
對話收尾只看得到「完成!」,看不到 AI 的最後一句回覆;等使用者送出
下一句指令後,寵物反而冒出上一輪的舊訊息,時序錯亂。

修法:
Stop 時若帶有 narration,氣泡直接顯示「 最後一句」並消費 narrationId,
沒有才退回「完成!」;UserPromptSubmit 也先把當下 narrationId 標記為已
顯示,雙重保險。另修 release.js 只挑檔名含當前版本的產物,避免 dist/
殘留的舊版檔案混進新 release。版號 2.0.9。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 16:40:32 +08:00
JianMiauandClaude Fable 5 7060571df1 修正被 VS Code 蓋住:定期重新宣告置頂
摘要:
每 2 秒重新宣告一次置頂,並在螢幕配置變動與啟動後各補一次。

根本原因:
Windows 會把視窗踢出 topmost band,卻留著 WS_EX_TOPMOST 樣式位元。
以 Win32 檢查實際情況:寵物視窗的 exStyle 是 0x08280028(含 WS_EX_TOPMOST),
但 z-order 排在 Z=4,而兩個 TOPMOST=false 的 VS Code 視窗排在 Z=1 與 Z=2,
也就是它們蓋在寵物上面。Electron 這邊仍以為自己是置頂的,
setAlwaysOnTop 已經在建立視窗時與勾選選單時都設過,所以不會自己修好。

影響:
寵物被 VS Code 之類的一般視窗蓋住,只能重啟或重新勾一次選單的「永遠置頂」。

修法:
- 新增 reassertTopmost():setAlwaysOnTop(true, "screen-saver") 之後再呼叫 moveTop()。
  單靠 setAlwaysOnTop 不夠——狀態沒變時它可能不會真的呼叫 SetWindowPos;
  moveTop() 會強制 SetWindowPos,對 WS_EX_TOPMOST 的視窗即為插回 topmost band 最上面。
  視窗是 WS_EX_NOACTIVATE + focusable:false(已用 Win32 確認),不會搶焦點。
- 每 TOPMOST_REASSERT_MS(2000)呼叫一次;display-metrics-changed / added / removed
  也各接一次,因為螢幕配置變動最容易觸發降級。
- ready-to-show 的 showInactive 後補一次;選單勾選置頂時也補一次。
- 拖曳中(dragTimer 存在)跳過,避免和每 16 ms 的位置更新打架。
- 結束時清掉 timer。

驗證:
- 先從外部對執行中的視窗呼叫 SetWindowPos(HWND_TOPMOST) 確認這正是有效解:
  寵物由 Z=4 移到 Z=0,兩個 VS Code 視窗退到 Z=5、Z=6。
- 再以改好的版本實測守護行為,用 SetWindowPos(HWND_BOTTOM) 模擬 Windows 的降級:
  基準 petZ=0 TOPMOST=true 上方 0 個;強制降級後 petZ=17 TOPMOST=false 上方 4 個
  (VS Code 兩個、CM 等);1 秒後回到 petZ=0 TOPMOST=true 上方 0 個;3 秒後維持。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.8 與八項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:36:36 +08:00
JianMiauandClaude Fable 5 cd4b618be0 補上遺漏的氣泡標題列
摘要:
整理記憶中、需要你授權、工具失敗、錯誤訊息、一般通知、掰掰這六個氣泡補上第一行標題。

根本原因:
上一版把氣泡改成兩行時,是以「原本帶(專案名)尾綴」為線索去改呼叫點,
但這六個訊息本來就沒有尾綴,所以整批漏掉,顯示出來只有單行、沒有專案或對話名稱。

影響:
壓縮記憶、等授權、出錯這些最需要知道是哪個 session 的時候,反而看不到來源。

修法:
- handleEvent 內六個 say() 補上 title 參數。
- 選單「動作測試」的氣泡給標題「動作測試」,才分得出是測試不是真的事件。
- first-awake 的「嗨,我是〈寵物名〉」維持沒有標題:它不屬於任何 session,
  訊息本身已含寵物名,CSS 的 :empty 會把空標題整行收起來,不會留空白。

驗證:
以 DOM stub 載入完整 renderer.js 驅動 handleEvent,11 項全過:
PreCompact 的標題與訊息、其餘五個補上的事件都帶標題、原本就有的「完成」仍正常、
對話名稱優先於專案名、無 cwd 也無對話名時標題為空字串、滑鼠停留提示為兩行。
另以靜態掃描確認 handleEvent 內全部 13 個 say() 都帶 title,
並在打包後的 app.asar 上重跑同一項掃描。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 14:06:50 +08:00
JianMiauandClaude Fable 5 42083acec5 修正有時候卡住無法拖曳:點穿狀態改由主程序輪詢驅動
摘要:
點穿的命中判定不再只依賴 renderer 的 mousemove,改由主程序每 50 ms 的游標輪詢一起驅動,
並在每格畫完後用最後已知座標重驗一次。

根本原因:
視窗預設 setIgnoreMouseEvents(true, { forward: true }),而唯一會把它切回可互動的地方
是 renderer 的 mousemove。問題是視窗處於點穿狀態時,Electron 的 forward 不保證會把
mousemove 轉發進來——本次開發過程中已實測到:游標直接跳到寵物上收不到事件,
慢慢掃過去才收得到。
於是只要在「游標還停在寵物身上」時進入點穿(例如游標停在某一點,動畫換格後
該點的像素變成透明),就再也收不到 mousemove,ignoringMouse 永遠是 true,
滑鼠按下也傳不進視窗,就變成按不動也拖不動,而且不會自己好。
另外 startCursorPolling 的內容原本被 `!config.followCursor` 整段擋掉,
而追視預設為關,所以主程序根本沒有在追游標,沒有任何備援路徑。

影響:
寵物有時候完全無法拖曳或點擊,只能重啟;游標停在人物邊緣或半透明處時特別容易發生。

修法:
- startCursorPolling 的守衛從 `!win || !config.followCursor || dragTimer` 改為
  `!win || dragTimer`,輪詢一律執行;只有送 pet:cursor(追視用)那一行仍看 followCursor。
- 主程序新增 pet:cursor-pos,送出視窗相對座標;preload 開放該頻道。
- renderer 把命中判定集中成 refreshHitTest(),mousemove 與 pet:cursor-pos 走同一條路,
  並在 pressed / drag 期間不動點穿狀態,避免拖曳中途鬆手。
- tick 畫完後若最後已知座標落在人物方框內就重驗一次,處理「游標沒動但換格導致
  該點像素改變」的情況。
- mouseout 時清掉 lastPoint。
- README 的設計重點補上為何不能只靠 mousemove。

驗證:
- 實機加暫時診斷確認 pet:cursor-pos 確實送達 renderer,且回報的 canvasRect
  (57,72,183,209)與依視窗尺寸手算的結果一致;確認後移除診斷碼。
- 以 DOM stub 載入完整 renderer.js 並讓 getImageData 的 alpha 可切換,14 項全過:
  壓在人物上會解除點穿並觸發 hover 跳躍、有通知主程序、方框內但透明維持點穿、
  方框外點穿且非 hover、拖曳中與按住中都不改點穿、pet:cursor-pos 有註冊且
  直接跳到人物上也能解除點穿、以及最關鍵的一組——先進入點穿狀態,
  再由 pet:cursor-pos 在同一點換格後把它救回來,這正是原本卡死的情境。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.6 與九項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 13:51:28 +08:00
JianMiauandClaude Fable 5 93f635dd01 氣泡改成兩行,第一行顯示對話名稱或專案名
摘要:
氣泡上方加一行標題(/rename 設的對話名稱,沒設就用專案資料夾名),訊息移到第二行。

根本原因:
原本把專案名以「(專案名)」的形式接在訊息尾巴,訊息本身就短,
加了括號後更容易被氣泡的單行省略號截掉,而且同時開多個 session 時
要看到最後才知道是哪一個在叫你。

影響:
多 session 並行時不容易分辨氣泡是哪個專案發出的;訊息也被括號擠掉。

修法:
- index.html 氣泡內加 #bubble-title;style.css 把單行限制從 #bubble 移到
  #bubble-title 與 #bubble-text 各自身上,兩行各自省略,
  並以 :empty 讓沒有標題時整行收起來,不留空白。
- say() 增加第四個參數 title,事件處理處的八個呼叫點改傳標題、移除 suffix。
- main.js 的 latestNarration 改名為 readTranscript,同一次反向掃描同時取出
  助理說明與 custom-title(/rename 會把它寫進 transcript),兩者都拿到就提早結束;
  收到事件時把 sessionTitle 一併併進 payload。
- 標題優先序:對話名稱 > 專案資料夾名 > 不顯示。

驗證:
- 單元測試以真實 transcript 執行 readTranscript:取到對話名稱 "claude-pet"、
  同時取到助理說明、快取一致、路徑不存在或為 null 時回 null,6 項全過。
- 實機截圖確認兩行版面正確。截圖當下剛好收到另一個 session 的真實事件,
  標題顯示 "telegram-codex-bot"、第二行是該 session 的助理說明,
  等於用實際流量驗證了多 session 下的標題辨識。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar,確認版本 2.0.5 與十項改動都在出貨檔案裡,
  包含舊的 suffix 已完全移除。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 12:09:01 +08:00
JianMiauandClaude Fable 5 95c9b97eb7 工作中氣泡顯示工具動作與助理說明
摘要:
running 狀態的氣泡改成有東西就播報:優先顯示助理剛說那句話,否則顯示正在用的工具與對象。

根本原因:
使用者希望氣泡能像 telegram bot 那樣顯示 AI 的思考過程。
實際查證後思考文字拿不到——Claude Code 從 2.1.238 起不再把 thinking 寫進 transcript,
只留加密簽章(本 session 169 個 thinking 區塊文字全為空字串);
比對同樣用 claude-opus-5 但版本為 2.1.237 的舊 session 則存得到,
確認是 Claude Code 版本差異而非模型差異。Claude Code 也沒有對外事件串流,
所以 hook 拿不到思考內容,這點與 Codex CLI 的 --json reasoning 事件不同。
而參考的 cluemarket-tg-bot 其實也沒顯示思考:它的變數雖名為 lastThinkingText,
抓的卻是 text 區塊,另外兩個是寫死的「思考中」字串與工具標籤。
原本 running 狀態除了關閉舊氣泡外沒有任何資訊。

影響:
工作期間看不出 Claude Code 正在做什麼,只知道她在忙。

修法:
- hook 補送 toolTarget(依 command / file_path / pattern / url 等順序取一個欄位並截斷,
  避免 Write 這類工具把整份檔案內容塞進 payload)、agentType 與 transcriptPath。
- main.js 新增 latestNarration:由 transcript 尾端 192 KB 反向找最後一個非空的
  assistant text 區塊,依檔案大小快取,transcript 長到數 MB 也不會變慢;
  收到事件時把 narration 與其 uuid 併進 payload 再轉給 renderer。
- renderer 新增 sayActivity:有新的助理說明就顯示第一句,否則顯示工具標籤加對象。
  同一段說明只播報一次;waiting 時不播報以免蓋掉固定氣泡。
  另加 stripMarkdown,否則助理文字的 **粗體**、反引號、標題與連結語法會直接出現在氣泡裡。
- README 新增「能顯示什麼、不能顯示什麼」一節說明思考文字為何拿不到。

驗證:
- 單元測試以 DOM stub 載入完整 renderer.js 並抽出 main.js 的 latestNarration,15 項全過:
  對真實 transcript 取得助理說明與 uuid、快取一致、路徑不存在或為 null 時回 null 不拋錯、
  firstSentence 取第一句與忽略空行、Bash 取指令前段、檔案路徑只取檔名、過長截斷、
  新說明更新 id、同段不重複、waiting 時不播報、空 payload 不拋錯。
- markdown 清理另 7 項全過:粗體、反引號、標題、清單、連結、程式碼區塊、無標記純文字。
- 實機啟動並以真實事件驗證:events.log 顯示 hook 確實帶出 toolTarget 與 transcriptPath;
  截圖確認氣泡顯示「⚙️ 執行 npm run dist」,以及傳入真實 transcript 後顯示「💬 助理說明」。
  markdown 殘留問題就是在這一步的截圖中發現並修掉的。
- 以 ELECTRON_RUN_AS_NODE 讀打包後 app.asar 與 app.asar.unpacked,確認版本 2.0.4
  與九項改動都在出貨檔案裡。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 10:16:43 +08:00
11 changed files with 556 additions and 87 deletions
+51 -8
View File
@@ -20,6 +20,7 @@
- [新增 / 切換寵物](#新增--切換寵物)
- [設定檔](#設定檔)
- [架構與檔案](#架構與檔案)
- [能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)
- [與 Codex 的對照](#與-codex-的對照)
- [HTTP API](#http-api)
- [打包成 exe](#打包成-exe)
@@ -36,10 +37,11 @@
- **16 方向追視(選配,預設關)** — 開啟後、且目前是待機狀態時,游標在哪她就看哪(000 = 正上方、順時針 22.5° 一格),追視時是靜態擺姿;Claude Code 在工作時以狀態動畫優先。Codex 的寵物**不看滑鼠**——它看的是 quick chat 的文字游標與 computer-use 的虛擬游標,所以預設關閉以求一致;喜歡的話在選單打開。
- **自動修正圖檔缺陷** — Codex 的 hatch 產生器有時會把整列 look 格子畫得比 idle 小(內附的小念第 10 列就小了 22%,游標一往下移人物就縮水)。以整列中位數偵測、用腳底當支點放大回正確身高;單格的姿勢差異不動。Codex 本身沒有做這件事。
- **與 Codex 同尺寸** — 100% 就是 Codex 桌面寵物的大小(126 × 137),高度用 Codex 的 `ceil(寬 × 208/192)` 算,兩邊並排看起來一樣大。
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;人物方框以外的區域**點穿**,只有壓在人物或氣泡上滑鼠才會被吃掉,不擋你操作底下的視窗。
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼
- **對話氣泡(兩行)** — 第一行是**對話名稱**`/rename` 設的)或專案資料夾名,第二行才是訊息。同時開多個 session 時一眼就知道是哪一個在叫你
- **工作中即時播報** — 氣泡會顯示正在用的工具與對象(`⚙️ 執行 npm run dist``📖 讀 renderer.js`),以及助理剛說的那句話(`💬 …`)。**注意:這不是思考過程**,原因見[能顯示什麼、不能顯示什麼](#能顯示什麼不能顯示什麼)。
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`Codex hatch 出來的寵物直接在選單裡切換。
- **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。
@@ -100,6 +102,7 @@ npm run autostart:install # (可選)開機自動啟動
- **Claude Code hooks** — 安裝 / 移除 hooks
- **回到預設位置** — 跑到螢幕外面時用
- **開啟設定檔** / **開啟事件紀錄**
- **重啟**(portable 版會延遲兩秒重新執行原本的 `-portable.exe`,因為外殼在程式結束時會刪掉解壓目錄)
- **結束**
## 行為對照表
@@ -109,7 +112,7 @@ npm run autostart:install # (可選)開機自動啟動
| (程式啟動) | 該寵物第一次出現 | `waving` × 3 → 慢速 `idle` | 👋 嗨,我是〈寵物名〉(8 秒) |
| `SessionStart` | `source = startup` | `idle` | 👋 嗨!(專案) |
| `UserPromptSubmit` | | `running` | (關閉舊氣泡) |
| `PreToolUse` | 一般工具 | `running` | |
| `PreToolUse` | 一般工具 | `running` | 有新的助理說明就顯示 `💬 …`,否則顯示 `⚙️ 執行 …` 之類的工具動作 |
| `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 |
| `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 |
| `PostToolUse` | | `running` | |
@@ -209,6 +212,8 @@ Spritesheet**1536 × 2288**8 欄 × 11 列,每格 **192 × 208**,透
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
氣泡的第一行是對話名稱或專案名,第二行是訊息;標題為空時整行會收起來,不會留空白。
## 架構與檔案
```
@@ -217,14 +222,14 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
│ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定
▼ IPC
renderer/renderer.js
狀態機 + 幀動畫 + 追視 + 氣泡 + alpha 點穿判定
狀態機 + 幀動畫 + 追視 + 氣泡 + 矩形回報
```
| 檔案 | 職責 |
|---|---|
| `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 |
| `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderersandbox 模式) |
| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠 alpha 判定 |
| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、hover 判定與人物/氣泡矩形回報 |
| `renderer/index.html``style.css` | 版面與氣泡樣式(CSP 僅允許 self / data: |
| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST800 ms 逾時、永遠 exit 0 |
| `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) |
@@ -237,13 +242,50 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
設計重點:
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗
- **點穿由主程序決定**:視窗預設 `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 清掉按住/拖曳狀態。
- **右鍵選單開著時暫停置頂重宣告**:否則 `moveTop()` 會把寵物視窗抬到選單上面,人物方框整塊接滑鼠,被蓋住的選單項目就點不到。
- **診斷**`GET /state` 除了動畫狀態,還會回 `bounds``mouse`(目前是否點穿、游標的視窗相對座標、是否在方框/氣泡內、矩形來源是 renderer 還是備援、切換次數、輪詢次數、renderer 錯誤數、最近幾次拖曳結束的原因、鎖定/解鎖/睡眠/喚醒事件)與 `rendererMouse`renderer 這邊的 presseddraghoveredoverlay)。renderer 收到的每個 mousedownmouseup、hover 進出、例外,以及拖曳開始/結束、選單開關、鎖定/解鎖都會寫進事件紀錄——「點不到寵物」時先看紀錄裡有沒有 `mousedown`,就能分清是 OS 沒把事件送進來,還是 renderer 收到了但狀態卡住。
- **解鎖螢幕後 mousedown 會被吃掉**:實測(v2.0.13 的事件紀錄)鎖定再解鎖後,renderer 只收得到 mouseup 與 mousemove,左右鍵的 mousedown 全部消失,直到重啟才恢復——症狀就是「不能拖曳、不能右鍵」。行為符合 Windows 對永不啟用(`WS_EX_NOACTIVATE`)視窗的 `WM_MOUSEACTIVATE` 回傳 `MA_NOACTIVATEANDEAT`(只丟掉按下那一則訊息),確切觸發條件尚未定位。因應是讓所有互動都不依賴 mousedown:右鍵選單在 mouseup 開(也是 Windows 慣例);mousemove 的 `buttons` 帶著「左鍵按著」而沒有 pressed 時補一個 pressed,拖曳照常;沒有 mousedown 的左鍵 mouseup 當成單擊。
- **鎖定/解鎖後重新套用**:監聽 `powerMonitor``unlock-screen``resume`,解鎖或喚醒後重新套用目前的點穿狀態、`showInactive()` 並重宣告置頂(session 切換後 Windows 可能把視窗的輸入或 z-order 狀態弄掉)。
- **拖曳**:由主程序用 `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`,不會搶焦點。
- **行為邏輯以 Codex 為準**,細節見[與 Codex 的對照](#與-codex-的對照)。
- **顯示尺寸與 sprite 來源尺寸分開**:`CELL_W/CELL_H`192 × 208)只用來從 spritesheet 取格子,畫到畫面上的是 `PET_W/PET_H`126 × 137Codex 的 mascot 尺寸)。視窗寬度另有 `MIN_WIN_W = 240` 的下限,免得寵物變小後氣泡被截掉。
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
- **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。
## 能顯示什麼、不能顯示什麼
氣泡**顯示不了 Claude 的思考過程**。Claude Code 從 **2.1.238** 起就不再把 thinking 的文字寫進 transcript,只留加密簽章:
```json
{ "type": "thinking", "thinking": "", "signature": "CAQSwgQKEAgRGAI4AUII…" }
```
(2.1.237 以前有文字。這是 Claude Code 版本的差異,跟模型無關——同樣是 `claude-opus-5`
2.1.237 的 session 存得到、2.1.241 的存不到。)Claude Code 也沒有對外的事件串流,
所以 hook 拿不到思考內容。這點跟 Codex 不同:Codex CLI 的 `--json` 會直接送出 `reasoning` 事件。
拿得到的是這些,氣泡就顯示這些:
| 來源 | 內容 | 從哪來 |
|---|---|---|
| 工具動作 | `⚙️ 執行 npm run dist``📖 讀 renderer.js` | hook 的 `tool_name``tool_input` |
| 助理說明 | 助理剛講那段話的第一句 | 由 `transcript_path` 讀 transcript 尾端的 `text` 區塊 |
| 氣泡標題 | 對話名稱,沒設就用專案資料夾名 | transcript 的 `custom-title``/rename` 寫進去的),退回 `cwd` 的最後一段 |
氣泡長這樣:
```
claude-pet
💬 兩行氣泡正確運作,這是真實事件。
```
同一段說明只播報一次(以 transcript 的 uuid 判斷),markdown 標記會先清掉,
等待授權時不播報以免蓋掉固定氣泡。讀 transcript 只讀最後 192 KB 並依檔案大小快取,
不會因為 transcript 長到幾 MB 就變慢。
## 與 Codex 的對照
所有數值都是從 Codex app`WindowsApps\OpenAI.Codex_*\app\resources\app.asar`)的 `codex-pet-assets``avatar-overlay-native-frame` 與主程序讀出來的。
@@ -262,7 +304,7 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
| `jumping` | 定義在 `respondToHover ? "jumping" : state`,但整個 app 沒人開啟那個 prop | **選擇打開**:游標進入人物方框就播,最多 3 次 |
| hover 離開 | 顯示狀態立刻翻回 `state`,動畫重跑,不會把跳躍播完 | 相同(游標離開就中斷跳躍) |
| hover 版面 | 40 px 內進入、56 px 外退出;人物縮 0.9、下移 14 px、上方 24 px 控制鈕、200 ms | 未採用——控制鈕的功能就是選單,右鍵已經有了 |
| hover 判定 | 用矩形(`pet-area-hover-changed` 由 rect 算) | 相同(逐像素會因每格輪廓不同而抖動) |
| hover/點穿判定 | 用矩形(`pet-area-hover-changed` 由 rect 算) | 相同(逐像素會因每格輪廓不同而抖動;點穿若跟著抖,按下去的瞬間剛好透明就穿到底下去 |
| 追視來源 | quick chat 的文字游標/computer-use 游標 | 沒有對應來源;改為**選配**看滑鼠,預設關 |
| 追視優先序 | `lookFrame` 覆蓋所有狀態 | 只在 idle 時追視。Codex 的文字游標只有你在打字時才存在,滑鼠卻是一直都在,照搬會讓工作中的動畫永遠被靜態擺姿蓋掉 |
| 拖曳方向 | 單次取樣水平位移 ≥ 4 px | 相同 |
@@ -281,8 +323,9 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
| 方法 | 路徑 | 說明 |
|---|---|---|
| `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` |
| `POST` | `/restart` | 重啟寵物(與選單的「重啟」相同) |
`POST /event` 的 payloadhook 腳本送出的格式):
+17
View File
@@ -29,6 +29,19 @@ function parse(text) {
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
}
// 從 tool_input 挑一個最有資訊量的欄位當成「正在對什麼做事」。
// 整包 tool_input 可能很大(例如 Write 帶整份檔案內容),只取一小段。
const TARGET_KEYS = ["command", "file_path", "pattern", "path", "url", "query", "notebook_path", "description", "prompt"];
function toolTarget(toolInput) {
if (!toolInput || typeof toolInput !== "object") return undefined;
for (const key of TARGET_KEYS) {
const v = toolInput[key];
if (typeof v === "string" && v.trim()) return trim(v.trim().replace(/\s+/g, " "), 160);
}
return undefined;
}
function buildPayload(input) {
const error = typeof input.error === "string" ? input.error : input.error?.message;
return {
@@ -37,6 +50,10 @@ function buildPayload(input) {
cwd: input.cwd ?? null,
permissionMode: input.permission_mode ?? null,
toolName: input.tool_name ?? null,
toolTarget: toolTarget(input.tool_input),
agentType: input.agent_type ?? null,
// 助理的說明文字要從 transcript 撈;寵物那邊才讀,hook 只負責把路徑帶過去
transcriptPath: input.transcript_path ?? null,
notificationType: input.notification_type ?? null,
title: trim(input.title, 160),
message: trim(input.message, 240),
+23
View File
@@ -0,0 +1,23 @@
"use strict";
// 點穿決策的純邏輯,由主程序使用;抽出來是為了能在 node 直接單元測試(main.js 一載入就 require electron)。
function inside(r, p) {
return !!r && !!p && p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h;
}
// 人物方框在視窗內的位置,與 renderer 的版面一致(#stage 水平置中、上方保留氣泡高度、底部留白)。
// renderer 活著時會回報實際量到的矩形,這只是它還沒回報或掛掉時的備援。
function petBoxRect(bounds, scale, { petW, petH, bubbleH }) {
const w = Math.round(petW * scale);
const h = Math.round(petH * scale);
return { x: Math.round((bounds.width - w) / 2), y: bubbleH, w, h };
}
// 這一刻視窗該不該點穿:拖曳中一律接滑鼠;否則游標在人物方框或氣泡上才接,其餘穿透。
function wantIgnore({ rel, box, bubble, dragging }) {
if (dragging) return false;
if (!rel) return true;
return !(inside(box, rel) || inside(bubble, rel));
}
module.exports = { inside, petBoxRect, wantIgnore };
+266 -19
View File
@@ -1,12 +1,13 @@
"use strict";
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog } = require("electron");
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog, powerMonitor } = require("electron");
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { spawn } = require("node:child_process");
const autostart = require("./lib/autostart");
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 HOME = os.homedir();
@@ -37,6 +38,9 @@ const PAD_BOTTOM = 8;
const MIN_WIN_W = 240; // 視窗最小寬度,保留氣泡的可讀寬度(Codex 的氣泡也遠寬於寵物本身)
// Codex 的 mascot 寬度可調範圍是 80224 pxike() 的 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",
@@ -55,7 +59,19 @@ 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: [],
sessionEvents: [], // 鎖定/解鎖/睡眠/喚醒
};
let currentState = { anim: "idle", base: "idle", sessions: 0 };
// ---------- config ----------
@@ -94,6 +110,60 @@ function appendLog(line) {
} 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) {
@@ -225,8 +295,19 @@ function createWindow() {
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.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) {
@@ -244,12 +325,15 @@ function sendInit() {
// ---------- drag ----------
function startDrag(offsetX, offsetY) {
if (!win) return;
endDrag(false);
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 || Date.now() - startedAt > 60000) return endDrag(true);
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;
@@ -258,27 +342,117 @@ function startDrag(offsetX, offsetY) {
}, 16);
}
function endDrag(save) {
if (dragTimer) {
clearInterval(dragTimer);
dragTimer = null;
}
if (save && win) {
// 視窗永遠不取得焦點(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 { /* 視窗正在關閉就算了 */ }
}
// 鎖定/解鎖與睡眠喚醒:session 切換後 Windows 可能把視窗的輸入或 z-order 狀態弄掉,
// 記下來並把目前的點穿狀態與置頂重新套用一次
function watchSessionChanges() {
for (const ev of ["lock-screen", "unlock-screen", "suspend", "resume"]) {
powerMonitor.on(ev, () => {
appendLog(`power: ${ev}`);
mouseDiag.sessionEvents.push({ at: Date.now(), ev });
if (mouseDiag.sessionEvents.length > 10) mouseDiag.sessionEvents.shift();
if (ev !== "unlock-screen" && ev !== "resume") return;
if (!win || win.isDestroyed()) return;
try {
win.setIgnoreMouseEvents(ignoring, { forward: true });
win.showInactive();
} catch { /* 視窗正在關閉 */ }
reassertTopmost();
});
}
}
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 || !config.followCursor || dragTimer) return;
const p = screen.getCursorScreenPoint();
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;
const b = win.getBounds();
// 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 });
@@ -300,6 +474,14 @@ function startServer() {
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 {
@@ -311,7 +493,25 @@ function startServer() {
}
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 }));
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 === "POST" && req.url === "/restart") {
res.writeHead(204);
res.end();
setTimeout(restartApp, 100);
return;
}
if (req.method === "GET" && req.url === "/health") {
@@ -418,6 +618,7 @@ function buildMenu() {
config.alwaysOnTop = item.checked;
saveConfig();
win?.setAlwaysOnTop(item.checked, "screen-saver");
if (item.checked) reassertTopmost();
},
},
{
@@ -459,6 +660,7 @@ function buildMenu() {
{ label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
{ label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
{ type: "separator" },
{ label: "重啟", click: restartApp },
{ label: "結束", click: () => app.quit() },
]);
}
@@ -485,6 +687,23 @@ function setScale(scale) {
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() {
if (!win) return;
const pos = defaultPosition(windowSizeFor(config.scale));
@@ -496,10 +715,28 @@ function resetPosition() {
// ---------- IPC ----------
ipcMain.on("pet:ready", () => sendInit());
ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true }));
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", () => buildMenu().popup({ window: win }));
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); }
});
@@ -507,7 +744,14 @@ ipcMain.on("pet:state", (_e, state) => {
currentState = { ...currentState, ...state };
refreshTray();
});
ipcMain.on("pet:log", (_e, line) => appendLog(String(line)));
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);
@@ -535,6 +779,8 @@ if (!app.requestSingleInstanceLock()) {
createTray();
startServer();
startCursorPolling();
startTopmostGuard();
watchSessionChanges();
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
});
@@ -544,6 +790,7 @@ if (!app.requestSingleInstanceLock()) {
app.on("before-quit", () => {
if (cursorTimer) clearInterval(cursorTimer);
if (topmostTimer) clearInterval(topmostTimer);
endDrag(false);
});
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "claude-pet",
"version": "2.0.3",
"version": "2.0.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-pet",
"version": "2.0.3",
"version": "2.0.14",
"devDependencies": {
"electron": "^43.4.1",
"electron-builder": "^26.0.12"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claude-pet",
"version": "2.0.3",
"version": "2.0.14",
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 29 個動作列 + 16 方向追視)",
"main": "main.js",
"private": true,
+4 -1
View File
@@ -3,7 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron");
const SEND = new Set([
"pet:ready",
"pet:ignore-mouse",
"pet:hit-rects",
"pet:drag-start",
"pet:drag-end",
"pet:context-menu",
@@ -20,7 +20,10 @@ const ON = new Set([
"pet:settings",
"pet:event",
"pet:cursor",
"pet:cursor-pos",
"pet:drag-move",
"pet:drag-ended",
"pet:ignore-state",
]);
contextBridge.exposeInMainWorld("pet", {
+1 -1
View File
@@ -8,7 +8,7 @@
</head>
<body>
<div id="stage">
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
<div id="bubble" class="hidden"><span id="bubble-title"></span><span id="bubble-text"></span></div>
<canvas id="sprite" width="126" height="137"></canvas>
</div>
<script src="renderer.js"></script>
+170 -52
View File
@@ -55,10 +55,28 @@ const RUNNING_SILENCE = 30 * 60e3;
const IDLE_PRUNE = 30 * 60e3; // idle 的 session 閒置多久後從清單移除(純清理,不影響顯示)
const GREETING_MS = 8000; // first-awake 問候通知的存活時間(Pi = 8 s)
const ACTIVITY_MS = 6000; // 工作中氣泡(工具動作/助理說明)的存活時間
// 工具對照表;沒列到的就直接顯示工具名稱
const TOOL_LABELS = {
Bash: "⚙️ 執行",
Read: "📖 讀",
Write: "✏️ 寫",
Edit: "✏️ 改",
NotebookEdit: "✏️ 改",
Glob: "🔍 找檔案",
Grep: "🔍 搜尋",
WebFetch: "🌐 擷取",
WebSearch: "🔎 搜尋",
Task: "🤖 子代理",
TodoWrite: "📝 待辦",
Artifact: "📄 產出",
};
const canvas = document.getElementById("sprite");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const bubble = document.getElementById("bubble");
const bubbleTitle = document.getElementById("bubble-title");
const bubbleText = document.getElementById("bubble-text");
let sheet = null;
@@ -73,6 +91,7 @@ const look = { idx: null, lastMoveAt: 0 };
// 播放中的序列:frames = [{row, col, ms}]loopStart 為 null 表示播完就結束
let player = { anim: null, frames: [], loopStart: 0, step: 0, stepStart: performance.now() };
let prevBase = "idle";
let lastNarrationId = null; // 同一段說明只播報一次
let lookCells = null; // 16 個 look 格子的量測結果(含缺陷補正倍率)
// 一輪原始動作
@@ -129,8 +148,8 @@ function startAnim(anim, now) {
let bubbleTimer = null;
let bubbleSticky = false;
let ignoringMouse = true;
let hovered = false; // 游標是否壓在人物的不透明像素上
let hovered = false; // 游標是否在人物方框內
let lastPoint = null; // 最後已知的游標位置(視窗相對),主程序輪詢或 mousemove 都會更新
let pressed = null;
let lastReported = "";
@@ -138,6 +157,10 @@ function log(msg) {
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
}
// renderer 的例外以前只會出現在 DevTools,包成 exe 後根本看不到;寫進事件紀錄才查得到
window.addEventListener("error", (e) => log(`error: ${e.message} (${e.filename}:${e.lineno})`));
window.addEventListener("unhandledrejection", (e) => log(`error: unhandled rejection: ${e.reason?.message || e.reason}`));
// ---------- 載入 ----------
function loadPet(pet) {
@@ -166,6 +189,7 @@ function applyScale(s) {
canvas.height = Math.round(PET_H * s);
canvas.style.width = `${canvas.width}px`;
canvas.style.height = `${canvas.height}px`;
reportHitRects();
}
// 量一格的人物輪廓(用來偵測圖檔裡整列被畫小的缺陷)
@@ -301,6 +325,53 @@ function resolveAnim() {
return base;
}
// 助理的說明是 markdown,直接塞進氣泡會看到 ** 和反引號
function stripMarkdown(text) {
return String(text)
.replace(/```[\s\S]*?```/g, " ") // 整段程式碼
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") // 連結與圖片只留文字
.replace(/`([^`]*)`/g, "$1")
.replace(/\*\*([^*]*)\*\*/g, "$1")
.replace(/(^|\s)[*_]([^*_\s][^*_]*)[*_](?=\s|$)/g, "$1$2")
.replace(/^\s{0,3}#{1,6}\s+/gm, "") // 標題
.replace(/^\s{0,3}[-*+>|]\s+/gm, "") // 清單與引言
.replace(/[ \t]+/g, " ");
}
// 只取第一句,氣泡是單行,塞整段沒有意義
function firstSentence(text) {
const line = stripMarkdown(text).split(/\r?\n/).find((l) => l.trim()) || "";
const m = line.trim().match(/^[^。!?!?]{1,60}[。!?!?]?/);
return (m ? m[0] : line.trim()).slice(0, 60);
}
// 指令取前半段、路徑只取檔名,其餘截斷
function shortTarget(target, toolName) {
const t = String(target).trim();
if (toolName === "Bash") return t.slice(0, 44);
if (/[\\/]/.test(t) && !/\s/.test(t)) return t.split(/[\\/]/).pop().slice(0, 40);
return t.slice(0, 44);
}
// 氣泡第一行:對話名稱(/rename 設的)優先,其次專案資料夾名
function bubbleTitleOf(p, cwd) {
return p.sessionTitle || projectName(cwd) || "";
}
// 有什麼就顯示什麼:優先顯示新的助理說明,否則顯示正在用的工具
function sayActivity(p, title) {
if (baseState() === "waiting") return; // 別蓋掉在等授權的固定氣泡
if (p.narration && p.narrationId && p.narrationId !== lastNarrationId) {
lastNarrationId = p.narrationId;
say(`💬 ${firstSentence(p.narration)}`, ACTIVITY_MS, false, title);
return;
}
if (!p.toolName) return;
const label = TOOL_LABELS[p.toolName] || `🔧 ${p.toolName}`;
const detail = p.toolName === "Task" && p.agentType ? p.agentType : p.toolTarget;
say(detail ? `${label} ${shortTarget(detail, p.toolName)}` : label, ACTIVITY_MS, false, title);
}
function projectName(cwd) {
if (!cwd) return "";
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
@@ -308,12 +379,15 @@ function projectName(cwd) {
// ---------- 氣泡 ----------
function say(text, ms, sticky = false) {
// title 是第一行(對話名稱或專案名),text 是第二行
function say(text, ms, sticky = false, title = "") {
clearTimeout(bubbleTimer);
bubbleTitle.textContent = title || "";
bubbleText.textContent = text;
bubble.title = text;
bubble.title = title ? `${title}\n${text}` : text;
bubble.classList.remove("hidden");
bubbleSticky = sticky;
reportHitRects();
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
}
@@ -321,6 +395,7 @@ function hideBubble() {
clearTimeout(bubbleTimer);
bubble.classList.add("hidden");
bubbleSticky = false;
reportHitRects();
}
// ---------- 事件 ----------
@@ -330,28 +405,31 @@ function handleEvent(p) {
if (p.event === "__test") return handleTest(p);
const s = session(p.sessionId, p.cwd);
const proj = projectName(s.cwd);
const suffix = proj ? `${proj}` : "";
const title = bubbleTitleOf(p, s.cwd);
switch (p.event) {
case "SessionStart":
setState(s, "idle");
// Codex 的 waving 只在寵物第一次醒來(first-awake),新 thread 不會揮手;這裡只留資訊氣泡
if (!p.source || p.source === "startup") say(`👋 嗨!${suffix}`, 3000);
if (!p.source || p.source === "startup") say("👋 嗨!", 3000, false, title);
break;
case "UserPromptSubmit":
setState(s, "running");
// 這時 transcript 裡最新的助理訊息還是上一輪的結尾,
// 先標記成已顯示,免得接下來的工具事件把它當成新話講出來
if (p.narrationId) lastNarrationId = p.narrationId;
hideBubble();
break;
case "PreToolUse":
if (p.toolName === "AskUserQuestion") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
say("❓ 有問題想問你", 0, true, title);
} else if (p.toolName === "ExitPlanMode") {
setState(s, "waiting");
say(`📋 計畫等你確認${suffix}`, 0, true);
say("📋 計畫等你確認", 0, true, title);
} else {
setState(s, "running");
sayActivity(p, title);
}
break;
case "PostToolUse":
@@ -360,25 +438,25 @@ function handleEvent(p) {
case "PostToolUseFailure":
// Codex:單一工具失敗不會改變 turn 的狀態(turn 還在跑),寵物維持 running,只給個氣泡
setState(s, "running");
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000, false, title);
break;
case "PermissionRequest":
setState(s, "waiting");
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true);
say(`🔐 需要你授權${p.toolName ? `${p.toolName}` : ""}`, 0, true, title);
break;
case "Notification": {
const t = p.notificationType;
if (t === "permission_prompt") {
setState(s, "waiting");
say(`🔐 需要你授權${suffix}`, 0, true);
say("🔐 需要你授權", 0, true, title);
} else if (t === "idle_prompt") {
// Codex 的 waiting 只對應授權、提問、計畫確認;單純等你下指令不算,狀態不變
say(`💤 在等你回覆${suffix}`, 4000);
say("💤 在等你回覆", 4000, false, title);
} else if (t === "elicitation_dialog") {
setState(s, "waiting");
say(`❓ 有問題想問你${suffix}`, 0, true);
say("❓ 有問題想問你", 0, true, title);
} else if (p.message) {
say(`💬 ${p.message}`, 4000);
say(`💬 ${p.message}`, 4000, false, title);
}
break;
}
@@ -387,12 +465,18 @@ function handleEvent(p) {
// Codexturn 完成且未讀 → review(播 review 3 次後沉澱),直到使用者回到該 thread 才回 idle。
// 這裡對應成:維持 review 直到該 session 有下一個動作(UserPromptSubmit)、結束或 7 天到期。
setState(s, "review");
say(`✅ 完成!${suffix}`, 8000);
// 收尾時把最後一句回覆講出來,而不是只說「完成」
if (p.narration) {
lastNarrationId = p.narrationId || lastNarrationId;
say(`${firstSentence(p.narration)}`, 8000, false, title);
} else {
say("✅ 完成!", 8000, false, title);
}
break;
case "StopFailure":
// Codexturn 失敗 → failed 狀態(播 failed 3 次後沉澱),維持到下一個 turn 或 1 小時到期
setState(s, "failed");
say(`${p.error || "出錯了"}`, 8000);
say(`${p.error || "出錯了"}`, 8000, false, title);
break;
case "SubagentStart":
case "SubagentStop":
@@ -400,13 +484,13 @@ function handleEvent(p) {
break;
case "PreCompact":
setState(s, "running");
say("🗜️ 整理記憶中…", 3000);
say("🗜️ 整理記憶中…", 3000, false, title);
break;
case "SessionEnd":
sessions.delete(p.sessionId || "default");
if (sessions.size === 0) {
hideBubble();
say("👋 掰掰", 2000);
say("👋 掰掰", 2000, false, title);
}
break;
default:
@@ -426,7 +510,7 @@ function handleTest(p) {
} else if (anim in PRIORITY) {
const s = session("test");
setState(s, anim);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true, "動作測試");
else if (anim === "idle") hideBubble();
}
report();
@@ -434,7 +518,11 @@ function handleTest(p) {
function report() {
const base = baseState();
const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
const snapshot = JSON.stringify({
anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null,
// renderer 這邊的滑鼠狀態也露到 /state,查「點不到寵物」時才看得出是不是卡在這裡
rendererMouse: { pressed: !!pressed, drag: !!drag, hovered, overlay: overlay?.anim || null },
});
if (snapshot !== lastReported) {
lastReported = snapshot;
window.pet.send("pet:state", JSON.parse(snapshot));
@@ -543,27 +631,18 @@ setInterval(() => {
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
function overBubble(x, y) {
if (bubble.classList.contains("hidden")) return false;
const r = bubble.getBoundingClientRect();
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
// 主程序自己輪詢游標並決定點穿(見 main.js 的 updateIgnore),這裡只要回報「哪裡算是寵物」:
// 人物方框與(顯示中的)氣泡的視窗相對矩形。版面一變(縮放、氣泡出現/消失/改字)就重報一次。
// 用方框不用逐像素 alpha:sprite 每一格的輪廓不同,人物的手腳與邊緣會隨動畫在透明/不透明之間
// 切換,逐像素會讓點穿跟著動畫抖,按下去那一瞬間剛好是透明格就穿到底下去。Codex 也是用矩形。
function reportHitRects() {
const rect = (r) => ({ x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) });
const b = bubble.classList.contains("hidden") ? null : bubble.getBoundingClientRect();
window.pet.send("pet:hit-rects", { box: rect(canvas.getBoundingClientRect()), bubble: b ? rect(b) : null });
}
window.addEventListener("resize", reportHitRects);
// 壓在人物的不透明像素上(透明處要讓滑鼠穿過去
function overSprite(x, y) {
const r = canvas.getBoundingClientRect();
if (x < r.left || x >= r.right || y < r.top || y >= r.bottom) return false;
const px = Math.floor((x - r.left) * (canvas.width / r.width));
const py = Math.floor((y - r.top) * (canvas.height / r.height));
try {
return ctx.getImageData(px, py, 1, 1).data[3] > 20;
} catch {
return true;
}
}
// hover 判定用人物方框,不用逐像素 alpha:sprite 每一格的輪廓不同,逐像素會讓游標停著不動時
// 也在 true/false 之間高速抖動。Codex 的 pet-area-hover 同樣是用矩形算的。
// hover 判定同樣用人物方框(Codex 的 pet-area-hover 也是 rect
function overPetArea(x, y) {
const r = canvas.getBoundingClientRect();
return x >= r.left && x < r.right && y >= r.top && y < r.bottom;
@@ -575,6 +654,8 @@ function overPetArea(x, y) {
function setHovered(on) {
if (on === hovered) return;
hovered = on;
log(`hover ${on ? "enter" : "leave"} pressed=${!!pressed} drag=${!!drag} overlay=${overlay?.anim || "-"}`);
report();
if (on) {
// 只在「進入」時觸發一次;一次性動作或拖曳進行中不打斷
if (!overlay && !drag) {
@@ -588,14 +669,20 @@ function setHovered(on) {
if (overlay?.fromHover) overlay = null;
}
function setIgnore(ignore) {
if (ignore === ignoringMouse) return;
ignoringMouse = ignore;
window.pet.send("pet:ignore-mouse", ignore);
canvas.classList.toggle("grab", !ignore);
// 命中判定只剩 hover(跳三下):mousemove 與主程序輪詢都走同一條路
function refreshHitTest() {
if (!lastPoint || pressed || drag) return;
setHovered(overPetArea(lastPoint.x, lastPoint.y));
}
window.addEventListener("mousemove", (e) => {
// 鎖定螢幕再解鎖後,Windows/Chromium 會把送給這個視窗的每一個 mousedown 吃掉,
// mouseup 與 mousemove 卻照送(實測見事件紀錄)。mousemove 的 buttons 位元仍然帶著
// 「左鍵按著」,用它補一個 pressed,拖曳才不會因為少了 down 就失效。
if (!pressed && !drag && (e.buttons & 1)) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false, synthetic: true };
log(`mousedown missing; synthesized from buttons at ${e.clientX},${e.clientY}`);
}
if (pressed) {
if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) {
pressed.moved = true;
@@ -605,27 +692,40 @@ window.addEventListener("mousemove", (e) => {
}
return;
}
setHovered(overPetArea(e.clientX, e.clientY));
// 點穿判定仍看實際像素,透明處要能點到底下的視窗
setIgnore(!(overSprite(e.clientX, e.clientY) || overBubble(e.clientX, e.clientY)));
lastPoint = { x: e.clientX, y: e.clientY };
refreshHitTest();
});
document.addEventListener("mouseout", (e) => {
if (e.relatedTarget || pressed) return;
lastPoint = null;
setHovered(false);
setIgnore(true);
});
// 滑鼠按下/放開都寫進事件紀錄:這是「有沒有收到滑鼠事件」唯一的直接證據
window.addEventListener("mousedown", (e) => {
log(`mousedown b=${e.button} at ${e.clientX},${e.clientY} pressed=${!!pressed} drag=${!!drag}`);
if (e.button === 0) {
pressed = { x: e.clientX, y: e.clientY, sx: e.screenX, sy: e.screenY, moved: false };
} else if (e.button === 2) {
window.pet.send("pet:context-menu");
}
report();
});
// 點擊與右鍵都以 mouseup 為準:解鎖後 mousedown 會被吃掉(見上),mouseup 不會;
// 右鍵選單在放開時開也是 Windows 的慣例。
window.addEventListener("mouseup", (e) => {
if (e.button !== 0 || !pressed) return;
log(`mouseup b=${e.button} at ${e.clientX},${e.clientY} pressed=${!!pressed} moved=${!!pressed?.moved}`);
if (e.button === 2) {
window.pet.send("pet:context-menu");
return;
}
if (e.button !== 0) return;
if (!pressed) {
log("mouseup without mousedown; treating as click");
onClick();
report();
return;
}
const wasDrag = pressed.moved;
pressed = null;
if (wasDrag) {
@@ -635,15 +735,33 @@ window.addEventListener("mouseup", (e) => {
} else {
onClick();
}
report();
});
window.addEventListener("contextmenu", (e) => e.preventDefault());
// 主程序的游標輪詢:不管 mousemove 有沒有被轉發進來都會到
window.pet.on("pet:cursor-pos", ({ x, y }) => {
lastPoint = { x, y };
refreshHitTest();
});
window.pet.on("pet:drag-move", ({ dx }) => {
// Codex:單一取樣的水平位移 >= 4 才切 running-right<= -4 才切 running-left
if (drag && Math.abs(dx) >= 4) drag.dir = dx < 0 ? "left" : "right";
});
// 主程序結束了拖曳(mouseup、逾時或其他原因):不管這邊有沒有收到 mouseup,一律把狀態清掉
window.pet.on("pet:drag-ended", () => {
pressed = null;
drag = null;
canvas.classList.remove("grabbing");
report();
});
// 主程序切了點穿狀態:只負責換游標樣式
window.pet.on("pet:ignore-state", (ignore) => canvas.classList.toggle("grab", !ignore));
function onClick() {
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
hideBubble();
+19 -2
View File
@@ -30,11 +30,28 @@ html, body {
font-size: 13px;
line-height: 1.35;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
overflow: hidden;
transition: opacity 0.18s ease, transform 0.18s ease;
cursor: pointer;
}
/* 標題與內文各自佔一行,各自省略;沒有標題時整行收起來 */
#bubble-title,
#bubble-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.18s ease, transform 0.18s ease;
cursor: pointer;
}
#bubble-title {
font-size: 11px;
line-height: 1.3;
color: #6b7280;
}
#bubble-title:empty {
display: none;
}
#bubble::after {
+2 -1
View File
@@ -144,8 +144,9 @@ async function main() {
console.log(`Tag : ${TAG}`);
if (!fs.existsSync(DIST_DIR)) throw new Error(`找不到 ${DIST_DIR},先執行 npm run dist`);
// dist/ 可能留著前幾版的產物,只挑檔名含當前版本的,免得舊檔混進新 release
const files = fs.readdirSync(DIST_DIR)
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)))
.filter((f) => ASSET_PATTERNS.some((re) => re.test(f)) && f.includes(pkg.version))
.map((f) => path.join(DIST_DIR, f))
.filter((f) => fs.statSync(f).isFile());
if (!files.length) throw new Error(`${DIST_DIR} 裡沒有 .exe / .zip,先執行 npm run dist`);