初始提交:Codex Pets 相容的 Claude Code 桌面寵物
摘要: Electron 桌面寵物,透過 Claude Code hooks 即時反映工作狀態(思考、等待授權、失敗、完成),並支援 16 方向追視游標。 根本原因: 專案尚未納入版控,需要建立初始 repo 以便後續追蹤與協作。 影響: 建立 main 分支的第一個版本,包含完整可執行的程式碼與內附寵物「小念」。 修法: 納入以下內容: - main.js / preload.js:Electron 主程序、透明置頂視窗、系統匣選單、HTTP 事件伺服器、游標輪詢與拖曳 - renderer/:Codex V2 spritesheet 幀動畫播放器、session 狀態機、氣泡、alpha 點穿判定 - hook/claude-pet-hook.js:Claude Code hook 端,stdin → POST /event,永遠 exit 0 - lib/、scripts/:hooks 安裝/移除、Windows 開機自動啟動 - pets/xiao-nian:內附寵物包(pet.json + spritesheet.webp) - README.md、docs/states.png:使用說明與狀態總覽圖 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# Claude Pet
|
||||||
|
|
||||||
|
**Codex Pets 相容的 Claude Code 桌面寵物。**
|
||||||
|
把 Codex `/hatch` 產出的寵物包原封不動拿來用,讓它站在桌面上,即時反映 Claude Code 正在做什麼:思考、等你授權、失敗、完成……還會盯著你的滑鼠游標看。
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 上圖由左至右:SessionStart 揮手、工作中、等待授權、完成時跳躍、檢視成果。氣泡會標出是哪個專案的 session。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目錄
|
||||||
|
|
||||||
|
- [特色](#特色)
|
||||||
|
- [需求](#需求)
|
||||||
|
- [快速開始](#快速開始)
|
||||||
|
- [日常使用](#日常使用)
|
||||||
|
- [行為對照表](#行為對照表)
|
||||||
|
- [寵物包格式(Codex V2)](#寵物包格式codex-v2)
|
||||||
|
- [新增 / 切換寵物](#新增--切換寵物)
|
||||||
|
- [設定檔](#設定檔)
|
||||||
|
- [架構與檔案](#架構與檔案)
|
||||||
|
- [HTTP API](#http-api)
|
||||||
|
- [疑難排解](#疑難排解)
|
||||||
|
- [移除](#移除)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 特色
|
||||||
|
|
||||||
|
- **完整實作 Codex V2 pet contract** — 8 欄 × 11 列、192×208 cell 的 spritesheet,9 個標準動作列 + 16 個看向方向,**每一幀的時長都照 Codex 規格**,動起來跟在 Codex app 裡一模一樣。
|
||||||
|
- **16 方向追視** — 游標在哪,她就看哪(000 = 正上方、順時針 22.5° 一格)。游標靠太近或靜止 6 秒就回到 idle 呼吸動畫。
|
||||||
|
- **真正的桌面寵物** — 透明、無邊框、永遠置頂;透明區域**點穿**,只有壓在人物像素上滑鼠才會被吃掉,不擋你操作底下的視窗。
|
||||||
|
- **不搶焦點** — 視窗設為 non-focusable,點她不會讓終端機失焦。
|
||||||
|
- **拖曳 / 點擊 / 右鍵** — 拖曳時依方向播 running-left / running-right 並記住位置;點一下揮手;右鍵或系統匣開選單。
|
||||||
|
- **對話氣泡** — 完成、需要授權、有問題要問你、工具失敗、整理記憶中……一眼看出 Claude Code 在等什麼。
|
||||||
|
- **多 session** — 同時開好幾個 Claude Code 時,以 `waiting > running > review > idle` 的優先序顯示最需要你注意的那個。
|
||||||
|
- **Codex 寵物自動出現** — 預設也掃描 `~/.codex/pets`,Codex hatch 出來的寵物直接在選單裡切換。
|
||||||
|
- **Hook 零負擔** — hook 腳本約 100 ms 完成、寵物沒開也靜默 exit 0,不會拖慢或卡住 Claude Code。
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
| 項目 | 說明 |
|
||||||
|
|---|---|
|
||||||
|
| 作業系統 | Windows 10 / 11(透明視窗、點穿、系統匣、開機啟動皆以 Windows 實作) |
|
||||||
|
| Node.js | 18 以上(開發時為 24.19) |
|
||||||
|
| Claude Code | 2.1 以上(使用 `SessionStart`、`PermissionRequest`、`PostToolUseFailure`、`StopFailure`、`PreCompact` 等事件) |
|
||||||
|
| 寵物包 | 一個 Codex V2 寵物包(`pet.json` + `spritesheet.webp`)。本專案內附 `pets/xiao-nian`(小念) |
|
||||||
|
|
||||||
|
## 快速開始
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd E:\Project\Test\AI\Claude\claude-pet
|
||||||
|
npm install
|
||||||
|
npm run hooks:install # 把 13 個 hook 合併進 ~/.claude/settings.json(會先備份到 ~/.claude/backups)
|
||||||
|
npm start # 啟動寵物
|
||||||
|
npm run autostart:install # (可選)開機自動啟動
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**
|
||||||
|
> 1. 已開啟的 Claude Code session 不會重新讀 hooks,**重開一個新 session** 才會看到她反應。
|
||||||
|
> 2. 若 `npm install` 後 `node_modules\electron\dist\` 是空的,請看 [疑難排解](#electron-裝完沒有-electronexe)。
|
||||||
|
|
||||||
|
## 日常使用
|
||||||
|
|
||||||
|
| 操作 | 效果 |
|
||||||
|
|---|---|
|
||||||
|
| 左鍵點一下 | 揮手(若有非固定的氣泡,先關閉氣泡) |
|
||||||
|
| 左鍵拖曳 | 搬家;往左播 running-left、往右播 running-right;放開後位置寫入設定檔 |
|
||||||
|
| 右鍵 / 系統匣左鍵 | 開啟選單 |
|
||||||
|
| 滑鼠靠近 | 盯著游標看(可在選單關閉) |
|
||||||
|
|
||||||
|
**選單功能**
|
||||||
|
|
||||||
|
- **切換寵物** — 列出所有找到的寵物;v1(9 列)寵物會標示「無追視」
|
||||||
|
- **大小** — 50% / 75% / 100% / 125% / 150%,以腳底為基準縮放
|
||||||
|
- **跟著游標看**、**永遠置頂**、**開機自動啟動** — 勾選即生效
|
||||||
|
- **動作測試** — 不用真的跑 Claude Code 也能看每個動畫(idle / running / waiting / review / failed / jumping / waving)
|
||||||
|
- **Claude Code hooks** — 安裝 / 移除 hooks
|
||||||
|
- **回到預設位置** — 跑到螢幕外面時用
|
||||||
|
- **開啟設定檔** / **開啟事件紀錄**
|
||||||
|
- **結束**
|
||||||
|
|
||||||
|
## 行為對照表
|
||||||
|
|
||||||
|
| Claude Code hook | 條件 | 動作 | 氣泡 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SessionStart` | `source = startup` | `waving` 一次 → `idle` | 👋 嗨!(專案) |
|
||||||
|
| `UserPromptSubmit` | | `running` | (關閉舊氣泡) |
|
||||||
|
| `PreToolUse` | 一般工具 | `running` | |
|
||||||
|
| `PreToolUse` | `AskUserQuestion` | `waiting` | ❓ 有問題想問你 |
|
||||||
|
| `PreToolUse` | `ExitPlanMode` | `waiting` | 📋 計畫等你確認 |
|
||||||
|
| `PostToolUse` | | `running` | |
|
||||||
|
| `PostToolUseFailure` | | `failed` 一次 → `running` | 😵 〈工具〉失敗了 |
|
||||||
|
| `PermissionRequest` | | `waiting` | 🔐 需要你授權:〈工具〉 |
|
||||||
|
| `Notification` | `permission_prompt` | `waiting` | 🔐 需要你授權 |
|
||||||
|
| `Notification` | `idle_prompt` | `waiting` | 💤 在等你回覆 |
|
||||||
|
| `Notification` | `elicitation_dialog` | `waiting` | ❓ 有問題想問你 |
|
||||||
|
| `Notification` | 其他 | (不變) | 💬 訊息內容 |
|
||||||
|
| `Stop` | `stop_hook_active = false` | `jumping` 一次 → `review` 6 秒 → `idle` | ✅ 完成!(專案) |
|
||||||
|
| `StopFailure` | | `failed` 兩次 → `idle` | ❌ 錯誤訊息 |
|
||||||
|
| `SubagentStart` / `SubagentStop` | | `running` | |
|
||||||
|
| `PreCompact` | | `running` | 🗜️ 整理記憶中… |
|
||||||
|
| `SessionEnd` | | 移除該 session | 最後一個結束時 👋 掰掰 |
|
||||||
|
| (無事件) | 游標移動 | 16 方向 `look` | |
|
||||||
|
| (無事件) | 拖曳 | `running-left` / `running-right` | |
|
||||||
|
|
||||||
|
規則:
|
||||||
|
|
||||||
|
- **一次性動作**(waving / jumping / failed)播完自動回到該 session 的基礎狀態。
|
||||||
|
- **基礎狀態**依 session 彙整:`waiting(3) > running(2) > review(1) > idle(0)`,取最高者顯示。
|
||||||
|
- 30 分鐘沒有任何事件的 session 會被視為結束而忽略(避免沒送 `SessionEnd` 的終端機卡住狀態)。
|
||||||
|
- `waiting` 的氣泡是固定的,狀態離開 `waiting` 時自動消失。
|
||||||
|
|
||||||
|
## 寵物包格式(Codex V2)
|
||||||
|
|
||||||
|
與 Codex 完全相同,可直接互通。一個寵物是一個資料夾:
|
||||||
|
|
||||||
|
```
|
||||||
|
<pet-id>/
|
||||||
|
├── pet.json
|
||||||
|
└── spritesheet.webp (或 .png)
|
||||||
|
```
|
||||||
|
|
||||||
|
`pet.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "xiao-nian",
|
||||||
|
"displayName": "小念",
|
||||||
|
"description": "一句話描述",
|
||||||
|
"spriteVersionNumber": 2,
|
||||||
|
"spritesheetPath": "spritesheet.webp"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透明背景。
|
||||||
|
|
||||||
|
| 列 | 狀態 | 使用欄 | 每幀時長(ms) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | idle | 0–5 | 280, 110, 110, 140, 140, 320 |
|
||||||
|
| 1 | running-right | 0–7 | 120 ×7, 220 |
|
||||||
|
| 2 | running-left | 0–7 | 120 ×7, 220 |
|
||||||
|
| 3 | waving | 0–3 | 140 ×3, 280 |
|
||||||
|
| 4 | jumping | 0–4 | 140 ×4, 280 |
|
||||||
|
| 5 | failed | 0–7 | 140 ×7, 240 |
|
||||||
|
| 6 | waiting | 0–5 | 150 ×5, 260 |
|
||||||
|
| 7 | running(工作中,不是跑步) | 0–5 | 120 ×5, 220 |
|
||||||
|
| 8 | review | 0–5 | 150 ×5, 280 |
|
||||||
|
| 9 | look A | 0–7 | 000, 022.5, 045, 067.5, 090, 112.5, 135, 157.5 度 |
|
||||||
|
| 10 | look B | 0–7 | 180, 202.5, 225, 247.5, 270, 292.5, 315, 337.5 度 |
|
||||||
|
|
||||||
|
- look 列的 **000 是正上方(12 點鐘)**,順時針。正面/中立不在 16 格裡,那是游標死區,退回 idle。
|
||||||
|
- 列 0 第 6 欄若有圖會被當作「中立正面」,拿來做系統匣圖示。
|
||||||
|
- 沒有 `spriteVersionNumber: 2` 的舊版(8×9、1536×1872)寵物也能用,只是沒有追視。
|
||||||
|
|
||||||
|
> 規格來源:Codex 的 `hatch-pet` skill(`~/.codex/skills/hatch-pet/references/codex-pet-contract.md`、`animation-rows.md`)。
|
||||||
|
|
||||||
|
## 新增 / 切換寵物
|
||||||
|
|
||||||
|
1. **用 Codex hatch** — 在 Codex 裡 `/hatch`,完成後寵物會在 `%USERPROFILE%\.codex\pets\<id>\`,本程式預設掃描該資料夾,選單「切換寵物 → 重新掃描」就會出現。
|
||||||
|
2. **手動放入** — 把寵物資料夾複製到 `pets\<id>\`(選單「開啟寵物資料夾」可直接打開)。
|
||||||
|
3. **其他位置** — 在設定檔 `petSources` 加路徑。
|
||||||
|
|
||||||
|
切換後立即生效,不用重啟。
|
||||||
|
|
||||||
|
## 設定檔
|
||||||
|
|
||||||
|
位置:`%USERPROFILE%\.claude-pet\config.json`(第一次啟動後自動產生,選單「開啟設定檔」可直接開)。
|
||||||
|
|
||||||
|
| 欄位 | 預設 | 說明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `activePetId` | `"xiao-nian"` | 目前使用的寵物 id |
|
||||||
|
| `petSources` | `[<app>/pets, ~/.codex/pets]` | 掃描寵物的資料夾清單;程式自己的 `pets/` 永遠會被加在最前面 |
|
||||||
|
| `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5 |
|
||||||
|
| `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 |
|
||||||
|
| `alwaysOnTop` | `true` | 永遠置頂 |
|
||||||
|
| `followCursor` | `true` | 追視游標 |
|
||||||
|
| `port` | `17333` | 接收 hook 事件的本機埠(改了之後 hook 端要設環境變數 `CLAUDE_PET_PORT`) |
|
||||||
|
|
||||||
|
事件紀錄:`%USERPROFILE%\.claude-pet\events.log`(超過 2 MB 自動清空)。
|
||||||
|
|
||||||
|
## 架構與檔案
|
||||||
|
|
||||||
|
```
|
||||||
|
Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──POST /event──▶ 127.0.0.1:17333
|
||||||
|
│ Electron main(main.js)
|
||||||
|
│ 視窗 / 系統匣 / 游標輪詢 / 拖曳 / 設定
|
||||||
|
▼ IPC
|
||||||
|
renderer/renderer.js
|
||||||
|
狀態機 + 幀動畫 + 追視 + 氣泡 + alpha 點穿判定
|
||||||
|
```
|
||||||
|
|
||||||
|
| 檔案 | 職責 |
|
||||||
|
|---|---|
|
||||||
|
| `main.js` | Electron 主程序:透明置頂視窗、系統匣選單、HTTP 伺服器、游標輪詢(50 ms)、拖曳(60 fps)、設定讀寫、寵物掃描 |
|
||||||
|
| `preload.js` | 以 contextBridge 暴露白名單 IPC 頻道給 renderer(sandbox 模式) |
|
||||||
|
| `renderer/renderer.js` | Codex V2 幀時序播放器、session 狀態彙整、一次性動作、看向方向計算(含遲滯防抖)、氣泡、滑鼠 alpha 判定 |
|
||||||
|
| `renderer/index.html`、`style.css` | 版面與氣泡樣式(CSP 僅允許 self / data:) |
|
||||||
|
| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST;800 ms 逾時、永遠 exit 0 |
|
||||||
|
| `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) |
|
||||||
|
| `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值 |
|
||||||
|
| `scripts/*.js` | 上述兩者的 CLI 包裝 |
|
||||||
|
| `pets/` | 內附寵物 |
|
||||||
|
| `docs/` | README 用圖 |
|
||||||
|
|
||||||
|
設計重點:
|
||||||
|
|
||||||
|
- **點穿**:視窗預設 `setIgnoreMouseEvents(true, { forward: true })`,renderer 在 mousemove 時讀 canvas 該點 alpha(或是否在氣泡上)來即時切換,所以透明處永遠點得到底下的視窗。
|
||||||
|
- **拖曳**:由主程序用 `screen.getCursorScreenPoint()` 每 16 ms 更新視窗位置,游標離開視窗也不會掉;方向由水平位移決定。
|
||||||
|
- **Spritesheet 以 data URL 傳給 renderer**,避免 `file://` 跨來源污染 canvas 導致 `getImageData` 失敗。
|
||||||
|
- **hook 合併規則**:以指令中是否含 `claude-pet-hook` 辨識自己的項目,重裝只替換自己的、不動別人的。
|
||||||
|
|
||||||
|
## HTTP API
|
||||||
|
|
||||||
|
只監聽 `127.0.0.1`。
|
||||||
|
|
||||||
|
| 方法 | 路徑 | 說明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `POST` | `/event` | 接收事件(JSON,≤ 64 KB),回 `204` |
|
||||||
|
| `GET` | `/state` | 目前狀態:`{"anim","base","sessions","pet","scale"}` |
|
||||||
|
| `GET` | `/health` | 回 `ok` |
|
||||||
|
|
||||||
|
`POST /event` 的 payload(hook 腳本送出的格式):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "Stop",
|
||||||
|
"sessionId": "uuid",
|
||||||
|
"cwd": "E:\\Project\\foo",
|
||||||
|
"toolName": null,
|
||||||
|
"notificationType": null,
|
||||||
|
"message": null,
|
||||||
|
"error": null,
|
||||||
|
"source": null,
|
||||||
|
"stopHookActive": false,
|
||||||
|
"ts": 1787278568523
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
手動觸發範例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 模擬需要授權
|
||||||
|
curl -X POST http://127.0.0.1:17333/event -H "content-type: application/json" `
|
||||||
|
-d '{"event":"Notification","sessionId":"demo","cwd":"D:/demo","notificationType":"permission_prompt"}'
|
||||||
|
|
||||||
|
# 模擬完成
|
||||||
|
curl -X POST http://127.0.0.1:17333/event -H "content-type: application/json" `
|
||||||
|
-d '{"event":"Stop","sessionId":"demo","cwd":"D:/demo"}'
|
||||||
|
|
||||||
|
# 查狀態
|
||||||
|
curl http://127.0.0.1:17333/state
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以送 `{"event":"__test","anim":"waiting"}`(`idle|running|waiting|review|failed|jumping|waving|clear`)直接指定動畫,與選單「動作測試」相同。
|
||||||
|
|
||||||
|
## 疑難排解
|
||||||
|
|
||||||
|
### Electron 裝完沒有 `electron.exe`
|
||||||
|
|
||||||
|
`npm install` 成功但 `node_modules\electron\dist\` 是空的 → 你的 `~/.npmrc` 可能有 `allow-scripts=...`(npm 11 的供應鏈防護,只放行清單上套件的 postinstall)。兩種解法:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node node_modules\electron\install.js # 手動跑一次下載
|
||||||
|
# 或把 electron 加進白名單
|
||||||
|
npm config set allow-scripts=@anthropic-ai/claude-code,electron --location=user
|
||||||
|
```
|
||||||
|
|
||||||
|
### 寵物對 Claude Code 沒反應
|
||||||
|
|
||||||
|
1. 確認 hooks 已安裝:`npm run hooks:install`,並**重開 Claude Code session**。
|
||||||
|
2. 確認寵物在跑:`curl http://127.0.0.1:17333/health` 應回 `ok`。
|
||||||
|
3. 看 `%USERPROFILE%\.claude-pet\events.log` 有沒有事件進來。有事件但沒動 → renderer 問題;沒事件 → hook 沒被呼叫。
|
||||||
|
4. hook 指令是 `node E:/.../claude-pet-hook.js`,Claude Code 執行 hook 的環境要找得到 `node`(PATH)。
|
||||||
|
|
||||||
|
### 啟動時跳出「無法監聽 127.0.0.1:17333」
|
||||||
|
|
||||||
|
已經有一隻在跑(本程式有單一實例鎖,通常不會發生),或埠被別的程式佔用。改設定檔 `port`,並在 Claude Code 的環境設 `CLAUDE_PET_PORT` 為同一個值。
|
||||||
|
|
||||||
|
### 她跑到螢幕外 / 換了螢幕配置看不到
|
||||||
|
|
||||||
|
選單「回到預設位置」,或刪掉設定檔的 `position`。啟動時若座標不在任何螢幕的工作區內會自動拉回。
|
||||||
|
|
||||||
|
### 專案搬家了
|
||||||
|
|
||||||
|
重新執行 `npm run hooks:install` 與 `npm run autostart:install`(兩者都以目前路徑重寫),寵物資料夾會自動跟著程式走。
|
||||||
|
|
||||||
|
## 移除
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run hooks:uninstall # 從 ~/.claude/settings.json 移除所有 Claude Pet hook(先備份)
|
||||||
|
npm run autostart:uninstall # 移除開機啟動登錄值
|
||||||
|
```
|
||||||
|
|
||||||
|
然後刪掉專案資料夾與 `%USERPROFILE%\.claude-pet\` 即可。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 致謝
|
||||||
|
|
||||||
|
- 寵物規格與動畫時序來自 OpenAI Codex 的 `hatch-pet` skill(Codex V2 pet contract)。
|
||||||
|
- 內附寵物「小念」由 Codex `/hatch` 產生。
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 203 KiB |
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
// Claude Code hook → Claude Pet
|
||||||
|
// 讀 stdin 的 hook JSON,POST 到本機的寵物 app。任何失敗都靜默結束(exit 0),絕不阻塞 Claude Code。
|
||||||
|
|
||||||
|
const http = require("node:http");
|
||||||
|
|
||||||
|
const PORT = Number(process.env.CLAUDE_PET_PORT || 17333);
|
||||||
|
const TIMEOUT_MS = 800;
|
||||||
|
|
||||||
|
function trim(value, max) {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStdin() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (process.stdin.isTTY) return resolve({});
|
||||||
|
let data = "";
|
||||||
|
const timer = setTimeout(() => resolve(parse(data)), TIMEOUT_MS);
|
||||||
|
process.stdin.setEncoding("utf8");
|
||||||
|
process.stdin.on("data", (chunk) => { data += chunk; });
|
||||||
|
process.stdin.on("end", () => { clearTimeout(timer); resolve(parse(data)); });
|
||||||
|
process.stdin.on("error", () => { clearTimeout(timer); resolve(parse(data)); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text) {
|
||||||
|
try { return text.trim() ? JSON.parse(text) : {}; } catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload(input) {
|
||||||
|
const error = typeof input.error === "string" ? input.error : input.error?.message;
|
||||||
|
return {
|
||||||
|
event: input.hook_event_name || process.argv[2] || "Unknown",
|
||||||
|
sessionId: input.session_id ?? null,
|
||||||
|
cwd: input.cwd ?? null,
|
||||||
|
permissionMode: input.permission_mode ?? null,
|
||||||
|
toolName: input.tool_name ?? null,
|
||||||
|
notificationType: input.notification_type ?? null,
|
||||||
|
title: trim(input.title, 160),
|
||||||
|
message: trim(input.message, 240),
|
||||||
|
error: trim(error, 240),
|
||||||
|
source: input.source ?? null,
|
||||||
|
reason: input.reason ?? null,
|
||||||
|
trigger: input.trigger ?? null,
|
||||||
|
stopHookActive: input.stop_hook_active ?? null,
|
||||||
|
lastAssistantMessage: trim(input.last_assistant_message, 300),
|
||||||
|
prompt: trim(input.prompt, 120),
|
||||||
|
ts: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function post(payload) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const body = Buffer.from(JSON.stringify(payload), "utf8");
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: PORT,
|
||||||
|
method: "POST",
|
||||||
|
path: "/event",
|
||||||
|
headers: { "content-type": "application/json", "content-length": body.length },
|
||||||
|
},
|
||||||
|
(res) => { res.resume(); res.on("end", resolve); res.on("error", resolve); },
|
||||||
|
);
|
||||||
|
req.setTimeout(TIMEOUT_MS, () => req.destroy());
|
||||||
|
req.on("error", resolve);
|
||||||
|
req.end(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const guard = setTimeout(() => process.exit(0), TIMEOUT_MS * 2 + 500);
|
||||||
|
guard.unref();
|
||||||
|
try {
|
||||||
|
const input = await readStdin();
|
||||||
|
await post(buildPayload(input));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use strict";
|
||||||
|
// Windows 開機自動啟動:HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet
|
||||||
|
const path = require("node:path");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const { spawnSync } = require("node:child_process");
|
||||||
|
|
||||||
|
const APP_DIR = path.resolve(__dirname, "..");
|
||||||
|
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
|
||||||
|
const VALUE_NAME = "ClaudePet";
|
||||||
|
|
||||||
|
function electronExe() {
|
||||||
|
return path.join(APP_DIR, "node_modules", "electron", "dist", "electron.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
function command() {
|
||||||
|
return `"${electronExe()}" "${APP_DIR}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reg(args) {
|
||||||
|
const r = spawnSync("reg", args, { encoding: "utf8", windowsHide: true });
|
||||||
|
return { ok: r.status === 0, stdout: r.stdout || "", stderr: r.stderr || "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalled() {
|
||||||
|
if (process.platform !== "win32") return false;
|
||||||
|
return reg(["query", RUN_KEY, "/v", VALUE_NAME]).ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentValue() {
|
||||||
|
const r = reg(["query", RUN_KEY, "/v", VALUE_NAME]);
|
||||||
|
if (!r.ok) return null;
|
||||||
|
const m = r.stdout.match(/REG_SZ\s+(.+)$/m);
|
||||||
|
return m ? m[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
if (process.platform !== "win32") throw new Error("只支援 Windows");
|
||||||
|
if (!fs.existsSync(electronExe())) throw new Error(`找不到 Electron:${electronExe()}(先執行 npm install)`);
|
||||||
|
const r = reg(["add", RUN_KEY, "/v", VALUE_NAME, "/t", "REG_SZ", "/d", command(), "/f"]);
|
||||||
|
if (!r.ok) throw new Error(r.stderr || r.stdout || "reg add 失敗");
|
||||||
|
return command();
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstall() {
|
||||||
|
if (process.platform !== "win32") return false;
|
||||||
|
const r = reg(["delete", RUN_KEY, "/v", VALUE_NAME, "/f"]);
|
||||||
|
return r.ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { install, uninstall, isInstalled, currentValue, command, electronExe, VALUE_NAME, RUN_KEY };
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use strict";
|
||||||
|
// 把 Claude Pet 的 hook 合併進 ~/.claude/settings.json(保留使用者原本的設定與其他 hooks)
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const APP_DIR = path.resolve(__dirname, "..");
|
||||||
|
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
||||||
|
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
|
||||||
|
const BACKUP_DIR = path.join(CLAUDE_DIR, "backups");
|
||||||
|
const HOOK_SCRIPT = path.join(APP_DIR, "hook", "claude-pet-hook.js");
|
||||||
|
const MARKER = "claude-pet-hook";
|
||||||
|
|
||||||
|
// Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態)
|
||||||
|
const EVENTS = [
|
||||||
|
"SessionStart",
|
||||||
|
"SessionEnd",
|
||||||
|
"UserPromptSubmit",
|
||||||
|
"PreToolUse",
|
||||||
|
"PostToolUse",
|
||||||
|
"PostToolUseFailure",
|
||||||
|
"PermissionRequest",
|
||||||
|
"Notification",
|
||||||
|
"Stop",
|
||||||
|
"StopFailure",
|
||||||
|
"SubagentStart",
|
||||||
|
"SubagentStop",
|
||||||
|
"PreCompact",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hookCommand() {
|
||||||
|
const p = HOOK_SCRIPT.replace(/\\/g, "/");
|
||||||
|
return /\s/.test(p) ? `node "${p}"` : `node ${p}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
try {
|
||||||
|
const text = fs.readFileSync(SETTINGS_PATH, "utf8");
|
||||||
|
return text.trim() ? JSON.parse(text) : {};
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === "ENOENT") return {};
|
||||||
|
throw new Error(`無法解析 ${SETTINGS_PATH}:${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSettings(settings) {
|
||||||
|
fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
||||||
|
const tmp = `${SETTINGS_PATH}.${process.pid}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
|
||||||
|
fs.renameSync(tmp, SETTINGS_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backup() {
|
||||||
|
if (!fs.existsSync(SETTINGS_PATH)) return null;
|
||||||
|
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const dest = path.join(BACKUP_DIR, `settings.claude-pet.${stamp}.json`);
|
||||||
|
fs.copyFileSync(SETTINGS_PATH, dest);
|
||||||
|
return dest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPetHook(entry) {
|
||||||
|
try { return JSON.stringify(entry).includes(MARKER); } catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(list) {
|
||||||
|
if (Array.isArray(list)) return list;
|
||||||
|
if (list && typeof list === "object") return [list];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function install() {
|
||||||
|
const settings = readSettings();
|
||||||
|
const backupPath = backup();
|
||||||
|
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
||||||
|
for (const event of EVENTS) {
|
||||||
|
const kept = normalize(settings.hooks[event]).filter((e) => !isPetHook(e));
|
||||||
|
kept.push({ hooks: [{ type: "command", command: hookCommand(), timeout: 5 }] });
|
||||||
|
settings.hooks[event] = kept;
|
||||||
|
}
|
||||||
|
writeSettings(settings);
|
||||||
|
return { events: EVENTS, settingsPath: SETTINGS_PATH, backupPath, command: hookCommand() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function uninstall() {
|
||||||
|
const settings = readSettings();
|
||||||
|
if (!settings.hooks || typeof settings.hooks !== "object") return { removed: 0 };
|
||||||
|
const backupPath = backup();
|
||||||
|
let removed = 0;
|
||||||
|
for (const event of Object.keys(settings.hooks)) {
|
||||||
|
const before = normalize(settings.hooks[event]);
|
||||||
|
const after = before.filter((e) => !isPetHook(e));
|
||||||
|
removed += before.length - after.length;
|
||||||
|
if (after.length) settings.hooks[event] = after;
|
||||||
|
else delete settings.hooks[event];
|
||||||
|
}
|
||||||
|
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
||||||
|
writeSettings(settings);
|
||||||
|
return { removed, settingsPath: SETTINGS_PATH, backupPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInstalled() {
|
||||||
|
try {
|
||||||
|
const settings = readSettings();
|
||||||
|
return normalize(settings.hooks?.Stop).some(isPetHook);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { install, uninstall, isInstalled, hookCommand, EVENTS, SETTINGS_PATH };
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"use strict";
|
||||||
|
const { app, BrowserWindow, Tray, Menu, screen, ipcMain, nativeImage, shell, dialog } = require("electron");
|
||||||
|
const http = require("node:http");
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const os = require("node:os");
|
||||||
|
const autostart = require("./lib/autostart");
|
||||||
|
const hooksInstaller = require("./lib/hooks-installer");
|
||||||
|
|
||||||
|
const APP_DIR = __dirname;
|
||||||
|
const HOME = os.homedir();
|
||||||
|
const CONFIG_DIR = path.join(HOME, ".claude-pet");
|
||||||
|
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
|
||||||
|
const LOG_PATH = path.join(CONFIG_DIR, "events.log");
|
||||||
|
|
||||||
|
const CELL_W = 192;
|
||||||
|
const CELL_H = 208;
|
||||||
|
const BUBBLE_H = 72; // 氣泡保留高度
|
||||||
|
const PAD_X = 24; // 左右透明邊
|
||||||
|
const PAD_BOTTOM = 8;
|
||||||
|
const SCALES = [0.5, 0.75, 1, 1.25, 1.5];
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
activePetId: "xiao-nian",
|
||||||
|
petSources: [path.join(APP_DIR, "pets"), path.join(HOME, ".codex", "pets")],
|
||||||
|
scale: 1,
|
||||||
|
position: null,
|
||||||
|
alwaysOnTop: true,
|
||||||
|
followCursor: true,
|
||||||
|
port: 17333,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = loadConfig();
|
||||||
|
let win = null;
|
||||||
|
let tray = null;
|
||||||
|
let pets = [];
|
||||||
|
let dragTimer = null;
|
||||||
|
let cursorTimer = null;
|
||||||
|
let lastCursor = { x: NaN, y: NaN };
|
||||||
|
let currentState = { anim: "idle", base: "idle", sessions: 0 };
|
||||||
|
|
||||||
|
// ---------- config ----------
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
let saved = {};
|
||||||
|
try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ }
|
||||||
|
const merged = { ...DEFAULT_CONFIG, ...saved };
|
||||||
|
if (!Array.isArray(merged.petSources)) merged.petSources = [];
|
||||||
|
// 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到)
|
||||||
|
const ownPets = path.join(APP_DIR, "pets");
|
||||||
|
merged.petSources = [ownPets, ...merged.petSources.filter((p) => typeof p === "string" && path.resolve(p) !== ownPets && fs.existsSync(p))];
|
||||||
|
if (!merged.petSources.includes(DEFAULT_CONFIG.petSources[1]) && fs.existsSync(DEFAULT_CONFIG.petSources[1])) merged.petSources.push(DEFAULT_CONFIG.petSources[1]);
|
||||||
|
if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
|
||||||
|
merged.scale = Number(merged.scale);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("saveConfig failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLog(line) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
|
try { if (fs.statSync(LOG_PATH).size > 2 * 1024 * 1024) fs.truncateSync(LOG_PATH, 0); } catch { /* no file yet */ }
|
||||||
|
fs.appendFileSync(LOG_PATH, `${new Date().toISOString()} ${line}\n`, "utf8");
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- pets ----------
|
||||||
|
|
||||||
|
function discoverPets() {
|
||||||
|
const found = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const src of config.petSources) {
|
||||||
|
let entries = [];
|
||||||
|
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; }
|
||||||
|
for (const ent of entries) {
|
||||||
|
if (!ent.isDirectory()) continue;
|
||||||
|
const dir = path.join(src, ent.name);
|
||||||
|
let manifest;
|
||||||
|
try { manifest = JSON.parse(fs.readFileSync(path.join(dir, "pet.json"), "utf8")); } catch { continue; }
|
||||||
|
const id = String(manifest.id || ent.name);
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
const spritesheetPath = path.join(dir, manifest.spritesheetPath || "spritesheet.webp");
|
||||||
|
if (!fs.existsSync(spritesheetPath)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
found.push({
|
||||||
|
id,
|
||||||
|
displayName: manifest.displayName || id,
|
||||||
|
description: manifest.description || "",
|
||||||
|
dir,
|
||||||
|
spritesheetPath,
|
||||||
|
version: Number(manifest.spriteVersionNumber) === 2 ? 2 : 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePet() {
|
||||||
|
return pets.find((p) => p.id === config.activePetId) || pets[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function petPayload(pet) {
|
||||||
|
if (!pet) return null;
|
||||||
|
const ext = path.extname(pet.spritesheetPath).toLowerCase();
|
||||||
|
const mime = ext === ".png" ? "image/png" : "image/webp";
|
||||||
|
const data = fs.readFileSync(pet.spritesheetPath).toString("base64");
|
||||||
|
return {
|
||||||
|
id: pet.id,
|
||||||
|
displayName: pet.displayName,
|
||||||
|
description: pet.description,
|
||||||
|
version: pet.version,
|
||||||
|
dataUrl: `data:${mime};base64,${data}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- window ----------
|
||||||
|
|
||||||
|
function windowSizeFor(scale) {
|
||||||
|
return {
|
||||||
|
width: Math.round(CELL_W * scale) + PAD_X * 2,
|
||||||
|
height: Math.round(CELL_H * scale) + BUBBLE_H + PAD_BOTTOM,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPosition(size) {
|
||||||
|
const { workArea } = screen.getPrimaryDisplay();
|
||||||
|
return {
|
||||||
|
x: workArea.x + workArea.width - size.width - 24,
|
||||||
|
y: workArea.y + workArea.height - size.height - 4,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToDisplays(pos, size) {
|
||||||
|
const display = screen.getDisplayMatching({ x: pos.x, y: pos.y, width: size.width, height: size.height });
|
||||||
|
const wa = display.workArea;
|
||||||
|
return {
|
||||||
|
x: Math.round(Math.min(Math.max(pos.x, wa.x - size.width + 48), wa.x + wa.width - 48)),
|
||||||
|
y: Math.round(Math.min(Math.max(pos.y, wa.y - 16), wa.y + wa.height - 48)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const size = windowSizeFor(config.scale);
|
||||||
|
const pos = clampToDisplays(config.position || defaultPosition(size), size);
|
||||||
|
win = new BrowserWindow({
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
transparent: true,
|
||||||
|
frame: false,
|
||||||
|
alwaysOnTop: config.alwaysOnTop,
|
||||||
|
skipTaskbar: true,
|
||||||
|
resizable: false,
|
||||||
|
movable: false,
|
||||||
|
minimizable: false,
|
||||||
|
maximizable: false,
|
||||||
|
fullscreenable: false,
|
||||||
|
hasShadow: false,
|
||||||
|
focusable: false,
|
||||||
|
show: false,
|
||||||
|
title: "Claude Pet",
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(APP_DIR, "preload.js"),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
|
backgroundThrottling: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
win.setMenu(null);
|
||||||
|
if (config.alwaysOnTop) win.setAlwaysOnTop(true, "screen-saver");
|
||||||
|
win.setIgnoreMouseEvents(true, { forward: true });
|
||||||
|
win.loadFile(path.join(APP_DIR, "renderer", "index.html"));
|
||||||
|
win.once("ready-to-show", () => win.showInactive());
|
||||||
|
win.on("closed", () => { win = null; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(channel, data) {
|
||||||
|
if (win && !win.isDestroyed()) win.webContents.send(channel, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInit() {
|
||||||
|
send("pet:init", {
|
||||||
|
pet: petPayload(activePet()),
|
||||||
|
scale: config.scale,
|
||||||
|
followCursor: config.followCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- drag ----------
|
||||||
|
|
||||||
|
function startDrag(offsetX, offsetY) {
|
||||||
|
if (!win) return;
|
||||||
|
endDrag(false);
|
||||||
|
let last = screen.getCursorScreenPoint();
|
||||||
|
let lastDx = 0;
|
||||||
|
const startedAt = Date.now();
|
||||||
|
dragTimer = setInterval(() => {
|
||||||
|
if (!win || Date.now() - startedAt > 60000) return endDrag(true);
|
||||||
|
const p = screen.getCursorScreenPoint();
|
||||||
|
const dx = p.x - last.x;
|
||||||
|
last = p;
|
||||||
|
if (Math.abs(dx) >= 1) lastDx = dx;
|
||||||
|
win.setPosition(Math.round(p.x - offsetX), Math.round(p.y - offsetY), false);
|
||||||
|
send("pet:drag-move", { dx: lastDx });
|
||||||
|
}, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(save) {
|
||||||
|
if (dragTimer) {
|
||||||
|
clearInterval(dragTimer);
|
||||||
|
dragTimer = null;
|
||||||
|
}
|
||||||
|
if (save && win) {
|
||||||
|
const [x, y] = win.getPosition();
|
||||||
|
config.position = { x, y };
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- cursor look ----------
|
||||||
|
|
||||||
|
function startCursorPolling() {
|
||||||
|
cursorTimer = setInterval(() => {
|
||||||
|
if (!win || !config.followCursor || dragTimer) return;
|
||||||
|
const p = screen.getCursorScreenPoint();
|
||||||
|
if (p.x === lastCursor.x && p.y === lastCursor.y) return;
|
||||||
|
lastCursor = p;
|
||||||
|
const b = win.getBounds();
|
||||||
|
const cx = b.x + b.width / 2;
|
||||||
|
const cy = b.y + BUBBLE_H + CELL_H * config.scale * 0.3; // 大約頭部的高度
|
||||||
|
send("pet:cursor", { dx: p.x - cx, dy: p.y - cy });
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- HTTP:接收 Claude Code hook ----------
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
if (req.method === "POST" && req.url === "/event") {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
if (body.length > 65536) req.destroy();
|
||||||
|
});
|
||||||
|
req.on("end", () => {
|
||||||
|
let payload = null;
|
||||||
|
try { payload = JSON.parse(body || "{}"); } catch { /* bad json */ }
|
||||||
|
if (payload && typeof payload === "object") {
|
||||||
|
appendLog(JSON.stringify(payload));
|
||||||
|
send("pet:event", payload);
|
||||||
|
res.writeHead(204);
|
||||||
|
} else {
|
||||||
|
res.writeHead(400);
|
||||||
|
}
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET" && req.url === "/state") {
|
||||||
|
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
||||||
|
res.end(JSON.stringify({ ...currentState, pet: activePet()?.id || null, scale: config.scale }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === "GET" && req.url === "/health") {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
res.end("ok");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
server.on("error", (err) => {
|
||||||
|
dialog.showErrorBox("Claude Pet", `無法監聽 127.0.0.1:${config.port}\n${err.message}\n\n是不是已經有一隻在跑了?`);
|
||||||
|
});
|
||||||
|
server.listen(config.port, "127.0.0.1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- tray / menu ----------
|
||||||
|
|
||||||
|
function fallbackTrayIcon() {
|
||||||
|
const size = 16;
|
||||||
|
const buf = Buffer.alloc(size * size * 4);
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
const dx = x - 7.5;
|
||||||
|
const dy = y - 7.5;
|
||||||
|
const inside = dx * dx + dy * dy <= 6.5 * 6.5;
|
||||||
|
const i = (y * size + x) * 4;
|
||||||
|
buf[i] = 0x4a; buf[i + 1] = 0x7c; buf[i + 2] = 0xff; buf[i + 3] = inside ? 255 : 0; // BGRA
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nativeImage.createFromBitmap(buf, { width: size, height: size });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
tray = new Tray(fallbackTrayIcon());
|
||||||
|
tray.setToolTip("Claude Pet");
|
||||||
|
tray.on("click", () => tray.popUpContextMenu(buildMenu()));
|
||||||
|
refreshTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTray() {
|
||||||
|
if (!tray) return;
|
||||||
|
tray.setContextMenu(buildMenu());
|
||||||
|
const pet = activePet();
|
||||||
|
tray.setToolTip(`Claude Pet – ${pet ? pet.displayName : "無寵物"}\n狀態:${currentState.anim}(${currentState.sessions} 個 session)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSafely(fn) {
|
||||||
|
try { fn(); } catch (err) { dialog.showErrorBox("Claude Pet", String(err.message || err)); }
|
||||||
|
refreshTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(message) {
|
||||||
|
dialog.showMessageBox({ type: "info", title: "Claude Pet", message, buttons: ["OK"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMenu() {
|
||||||
|
const pet = activePet();
|
||||||
|
const TESTS = [
|
||||||
|
["idle", "待機"], ["running", "工作中"], ["waiting", "等待授權"], ["review", "檢視成果"],
|
||||||
|
["failed", "失敗"], ["jumping", "跳躍(完成)"], ["waving", "揮手"],
|
||||||
|
];
|
||||||
|
return Menu.buildFromTemplate([
|
||||||
|
{ label: pet ? `${pet.displayName}(${pet.id})` : "沒有可用的寵物", enabled: false },
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "切換寵物",
|
||||||
|
submenu: [
|
||||||
|
...pets.map((p) => ({
|
||||||
|
label: `${p.displayName}${p.version === 2 ? "" : "(v1,無追視)"}`,
|
||||||
|
type: "radio",
|
||||||
|
checked: p.id === pet?.id,
|
||||||
|
click: () => switchPet(p.id),
|
||||||
|
})),
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } },
|
||||||
|
{ label: "開啟寵物資料夾", click: () => shell.openPath(path.join(APP_DIR, "pets")) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "大小",
|
||||||
|
submenu: SCALES.map((s) => ({
|
||||||
|
label: `${Math.round(s * 100)}%`,
|
||||||
|
type: "radio",
|
||||||
|
checked: s === config.scale,
|
||||||
|
click: () => setScale(s),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "跟著游標看",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: config.followCursor,
|
||||||
|
click: (item) => {
|
||||||
|
config.followCursor = item.checked;
|
||||||
|
saveConfig();
|
||||||
|
send("pet:settings", { followCursor: config.followCursor });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "永遠置頂",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: config.alwaysOnTop,
|
||||||
|
click: (item) => {
|
||||||
|
config.alwaysOnTop = item.checked;
|
||||||
|
saveConfig();
|
||||||
|
win?.setAlwaysOnTop(item.checked, "screen-saver");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "開機自動啟動",
|
||||||
|
type: "checkbox",
|
||||||
|
checked: autostart.isInstalled(),
|
||||||
|
click: (item) => runSafely(() => (item.checked ? autostart.install() : autostart.uninstall())),
|
||||||
|
},
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "動作測試",
|
||||||
|
submenu: [
|
||||||
|
...TESTS.map(([anim, label]) => ({ label, click: () => send("pet:event", { event: "__test", anim }) })),
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: "清除測試狀態", click: () => send("pet:event", { event: "__test", anim: "clear" }) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Claude Code hooks",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: hooksInstaller.isInstalled() ? "已安裝 ✓(重新安裝)" : "安裝到 ~/.claude/settings.json",
|
||||||
|
click: () => runSafely(() => {
|
||||||
|
const r = hooksInstaller.install();
|
||||||
|
info(`已安裝 ${r.events.length} 個 hook 事件。\n\n已開啟的 Claude Code session 要重新啟動才會生效。`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "移除",
|
||||||
|
enabled: hooksInstaller.isInstalled(),
|
||||||
|
click: () => runSafely(() => {
|
||||||
|
const r = hooksInstaller.uninstall();
|
||||||
|
info(`已移除 ${r.removed} 個 hook。`);
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ label: "回到預設位置", click: resetPosition },
|
||||||
|
{ label: "開啟設定檔", click: () => { saveConfig(); shell.openPath(CONFIG_PATH); } },
|
||||||
|
{ label: "開啟事件紀錄", click: () => { appendLog("open log"); shell.openPath(LOG_PATH); } },
|
||||||
|
{ type: "separator" },
|
||||||
|
{ label: "結束", click: () => app.quit() },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchPet(id) {
|
||||||
|
if (!pets.some((p) => p.id === id)) return;
|
||||||
|
config.activePetId = id;
|
||||||
|
saveConfig();
|
||||||
|
send("pet:load", { pet: petPayload(activePet()) });
|
||||||
|
refreshTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScale(scale) {
|
||||||
|
if (!win) return;
|
||||||
|
const b = win.getBounds();
|
||||||
|
const size = windowSizeFor(scale);
|
||||||
|
const x = Math.round(b.x + (b.width - size.width) / 2);
|
||||||
|
const y = Math.round(b.y + (b.height - size.height));
|
||||||
|
win.setBounds({ x, y, width: size.width, height: size.height }, false);
|
||||||
|
config.scale = scale;
|
||||||
|
config.position = { x, y };
|
||||||
|
saveConfig();
|
||||||
|
send("pet:scale", { scale });
|
||||||
|
refreshTray();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPosition() {
|
||||||
|
if (!win) return;
|
||||||
|
const pos = defaultPosition(windowSizeFor(config.scale));
|
||||||
|
win.setPosition(pos.x, pos.y, false);
|
||||||
|
config.position = null;
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- IPC ----------
|
||||||
|
|
||||||
|
ipcMain.on("pet:ready", () => sendInit());
|
||||||
|
ipcMain.on("pet:ignore-mouse", (_e, ignore) => win?.setIgnoreMouseEvents(!!ignore, { forward: true }));
|
||||||
|
ipcMain.on("pet:drag-start", (_e, { offsetX, offsetY }) => startDrag(Number(offsetX) || 0, Number(offsetY) || 0));
|
||||||
|
ipcMain.on("pet:drag-end", () => endDrag(true));
|
||||||
|
ipcMain.on("pet:context-menu", () => buildMenu().popup({ window: win }));
|
||||||
|
ipcMain.on("pet:tray-icon", (_e, dataUrl) => {
|
||||||
|
try { tray?.setImage(nativeImage.createFromDataURL(dataUrl)); } catch (err) { console.error(err); }
|
||||||
|
});
|
||||||
|
ipcMain.on("pet:state", (_e, state) => {
|
||||||
|
currentState = { ...currentState, ...state };
|
||||||
|
refreshTray();
|
||||||
|
});
|
||||||
|
ipcMain.on("pet:log", (_e, line) => appendLog(String(line)));
|
||||||
|
|
||||||
|
// ---------- app ----------
|
||||||
|
|
||||||
|
app.setAppUserModelId("com.jianmiau.claude-pet");
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on("second-instance", () => {
|
||||||
|
if (win) win.showInactive();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
pets = discoverPets();
|
||||||
|
if (pets.length === 0) {
|
||||||
|
dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${config.petSources.join("\n")}`);
|
||||||
|
}
|
||||||
|
createWindow();
|
||||||
|
createTray();
|
||||||
|
startServer();
|
||||||
|
startCursorPolling();
|
||||||
|
appendLog(`started pet=${activePet()?.id || "none"} scale=${config.scale} port=${config.port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
// 留在系統匣,不結束
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
if (cursorTimer) clearInterval(cursorTimer);
|
||||||
|
endDrag(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
Generated
+174
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-pet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "claude-pet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^43.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron-internal/extract-zip": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@electron/get": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1",
|
||||||
|
"env-paths": "^3.0.0",
|
||||||
|
"graceful-fs": "^4.2.11",
|
||||||
|
"progress": "^2.0.3",
|
||||||
|
"semver": "^7.6.3",
|
||||||
|
"sumchecker": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"undici": "^7.24.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "24.13.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.18.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/electron": {
|
||||||
|
"version": "43.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/electron/-/electron-43.4.1.tgz",
|
||||||
|
"integrity": "sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@electron-internal/extract-zip": "^1.0.1",
|
||||||
|
"@electron/get": "^5.0.0",
|
||||||
|
"@types/node": "^24.9.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"electron": "cli.js",
|
||||||
|
"install-electron": "install.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 22.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/env-paths": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/progress": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sumchecker": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.18.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||||
|
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "claude-pet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Codex Pets 相容的 Claude Code 桌面寵物(spriteVersionNumber 2:9 個動作列 + 16 方向追視)",
|
||||||
|
"main": "main.js",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"hooks:install": "node scripts/install-hooks.js",
|
||||||
|
"hooks:uninstall": "node scripts/uninstall-hooks.js",
|
||||||
|
"autostart:install": "node scripts/autostart.js install",
|
||||||
|
"autostart:uninstall": "node scripts/autostart.js uninstall",
|
||||||
|
"autostart:status": "node scripts/autostart.js status"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^43.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"id": "xiao-nian",
|
||||||
|
"displayName": "小念",
|
||||||
|
"description": "一個阿呆",
|
||||||
|
"spriteVersionNumber": 2,
|
||||||
|
"spritesheetPath": "spritesheet.webp"
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
+35
@@ -0,0 +1,35 @@
|
|||||||
|
"use strict";
|
||||||
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
|
const SEND = new Set([
|
||||||
|
"pet:ready",
|
||||||
|
"pet:ignore-mouse",
|
||||||
|
"pet:drag-start",
|
||||||
|
"pet:drag-end",
|
||||||
|
"pet:context-menu",
|
||||||
|
"pet:tray-icon",
|
||||||
|
"pet:state",
|
||||||
|
"pet:log",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ON = new Set([
|
||||||
|
"pet:init",
|
||||||
|
"pet:load",
|
||||||
|
"pet:scale",
|
||||||
|
"pet:settings",
|
||||||
|
"pet:event",
|
||||||
|
"pet:cursor",
|
||||||
|
"pet:drag-move",
|
||||||
|
]);
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld("pet", {
|
||||||
|
send(channel, data) {
|
||||||
|
if (SEND.has(channel)) ipcRenderer.send(channel, data);
|
||||||
|
},
|
||||||
|
on(channel, callback) {
|
||||||
|
if (!ON.has(channel)) return () => {};
|
||||||
|
const listener = (_event, data) => callback(data);
|
||||||
|
ipcRenderer.on(channel, listener);
|
||||||
|
return () => ipcRenderer.removeListener(channel, listener);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-Hant">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'" />
|
||||||
|
<title>Claude Pet</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="stage">
|
||||||
|
<div id="bubble" class="hidden"><span id="bubble-text"></span></div>
|
||||||
|
<canvas id="sprite" width="192" height="208"></canvas>
|
||||||
|
</div>
|
||||||
|
<script src="renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
"use strict";
|
||||||
|
// Claude Pet renderer:Codex V2 pet contract 的動畫引擎 + 狀態機
|
||||||
|
// 規格來源:8 欄 x 11 列、192x208 cell,列 0-8 標準動作、列 9-10 為 16 個順時針看向方向(000 = 正上方)
|
||||||
|
|
||||||
|
const CELL_W = 192;
|
||||||
|
const CELL_H = 208;
|
||||||
|
|
||||||
|
const ROW = {
|
||||||
|
idle: 0,
|
||||||
|
"running-right": 1,
|
||||||
|
"running-left": 2,
|
||||||
|
waving: 3,
|
||||||
|
jumping: 4,
|
||||||
|
failed: 5,
|
||||||
|
waiting: 6,
|
||||||
|
running: 7,
|
||||||
|
review: 8,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每一幀的毫秒數(Codex V2 animation-rows 合約)
|
||||||
|
const TIMINGS = {
|
||||||
|
idle: [280, 110, 110, 140, 140, 320],
|
||||||
|
"running-right": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||||
|
"running-left": [120, 120, 120, 120, 120, 120, 120, 220],
|
||||||
|
waving: [140, 140, 140, 280],
|
||||||
|
jumping: [140, 140, 140, 140, 280],
|
||||||
|
failed: [140, 140, 140, 140, 140, 140, 140, 240],
|
||||||
|
waiting: [150, 150, 150, 150, 150, 260],
|
||||||
|
running: [120, 120, 120, 120, 120, 220],
|
||||||
|
review: [150, 150, 150, 150, 150, 280],
|
||||||
|
};
|
||||||
|
|
||||||
|
const LOOK_DEADZONE = 56; // 游標離寵物中心太近 → 回到 idle
|
||||||
|
const LOOK_IDLE_AFTER = 6000; // 游標靜止多久後不再盯著看
|
||||||
|
const REVIEW_DURATION = 6000; // Stop 後 review 多久回 idle
|
||||||
|
const SESSION_STALE = 30 * 60 * 1000;
|
||||||
|
const PRIORITY = { waiting: 3, running: 2, review: 1, idle: 0 };
|
||||||
|
|
||||||
|
const canvas = document.getElementById("sprite");
|
||||||
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
const bubble = document.getElementById("bubble");
|
||||||
|
const bubbleText = document.getElementById("bubble-text");
|
||||||
|
|
||||||
|
let sheet = null;
|
||||||
|
let petInfo = null;
|
||||||
|
let scale = 1;
|
||||||
|
let followCursor = true;
|
||||||
|
|
||||||
|
const sessions = new Map(); // sessionId → { state, updatedAt, cwd, reviewUntil }
|
||||||
|
let overlay = null; // 一次性動作:{ anim, loopsLeft, onDone }
|
||||||
|
let drag = null; // 拖曳中:{ dir: "left" | "right" }
|
||||||
|
const look = { idx: null, lastMoveAt: 0 };
|
||||||
|
let player = { anim: "idle", frame: 0, frameStart: performance.now() };
|
||||||
|
let prevBase = "idle";
|
||||||
|
|
||||||
|
let bubbleTimer = null;
|
||||||
|
let bubbleSticky = false;
|
||||||
|
let ignoringMouse = true;
|
||||||
|
let pressed = null;
|
||||||
|
let lastReported = "";
|
||||||
|
|
||||||
|
function log(msg) {
|
||||||
|
try { window.pet.send("pet:log", `[renderer] ${msg}`); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 載入 ----------
|
||||||
|
|
||||||
|
function loadPet(pet) {
|
||||||
|
petInfo = pet;
|
||||||
|
sheet = null;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
sheet = img;
|
||||||
|
makeTrayIcon();
|
||||||
|
log(`loaded ${pet.id} (v${pet.version}) ${img.naturalWidth}x${img.naturalHeight}`);
|
||||||
|
};
|
||||||
|
img.onerror = () => log(`spritesheet load failed for ${pet.id}`);
|
||||||
|
img.src = pet.dataUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyScale(s) {
|
||||||
|
scale = s;
|
||||||
|
canvas.width = Math.round(CELL_W * s);
|
||||||
|
canvas.height = Math.round(CELL_H * s);
|
||||||
|
canvas.style.width = `${canvas.width}px`;
|
||||||
|
canvas.style.height = `${canvas.height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellHasPixels(row, col) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 8; c.height = 8;
|
||||||
|
const g = c.getContext("2d");
|
||||||
|
g.drawImage(sheet, col * CELL_W + CELL_W / 2 - 24, row * CELL_H + CELL_H / 2 - 24, 48, 48, 0, 0, 8, 8);
|
||||||
|
const d = g.getImageData(0, 0, 8, 8).data;
|
||||||
|
for (let i = 3; i < d.length; i += 4) if (d[i] > 30) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeTrayIcon() {
|
||||||
|
if (!sheet) return;
|
||||||
|
try {
|
||||||
|
const col = petInfo.version === 2 && cellHasPixels(0, 6) ? 6 : 0;
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 32; c.height = 32;
|
||||||
|
const g = c.getContext("2d");
|
||||||
|
g.imageSmoothingEnabled = true;
|
||||||
|
g.imageSmoothingQuality = "high";
|
||||||
|
// 取上半身(頭部)做成方形 icon
|
||||||
|
const sw = 128, sh = 128;
|
||||||
|
g.drawImage(sheet, col * CELL_W + (CELL_W - sw) / 2, 4, sw, sh, 0, 0, 32, 32);
|
||||||
|
window.pet.send("pet:tray-icon", c.toDataURL("image/png"));
|
||||||
|
} catch (err) {
|
||||||
|
log(`tray icon failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 狀態 ----------
|
||||||
|
|
||||||
|
function session(id, cwd) {
|
||||||
|
const key = id || "default";
|
||||||
|
let s = sessions.get(key);
|
||||||
|
if (!s) {
|
||||||
|
s = { state: "idle", updatedAt: Date.now(), cwd: cwd || null, reviewUntil: 0 };
|
||||||
|
sessions.set(key, s);
|
||||||
|
}
|
||||||
|
if (cwd) s.cwd = cwd;
|
||||||
|
s.updatedAt = Date.now();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(s, state) {
|
||||||
|
s.state = state;
|
||||||
|
s.updatedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseState() {
|
||||||
|
const now = Date.now();
|
||||||
|
let best = "idle";
|
||||||
|
for (const [id, s] of sessions) {
|
||||||
|
if (now - s.updatedAt > SESSION_STALE) { sessions.delete(id); continue; }
|
||||||
|
if (s.state === "review" && now > s.reviewUntil) s.state = "idle";
|
||||||
|
if (PRIORITY[s.state] > PRIORITY[best]) best = s.state;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(anim, loops = 1, onDone = null) {
|
||||||
|
overlay = { anim, loopsLeft: loops, onDone };
|
||||||
|
player = { anim, frame: 0, frameStart: performance.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAnim() {
|
||||||
|
if (drag) return drag.dir === "left" ? "running-left" : "running-right";
|
||||||
|
if (overlay) return overlay.anim;
|
||||||
|
const base = baseState();
|
||||||
|
if (base === "idle" && followCursor && petInfo?.version === 2 && look.idx !== null) return "look";
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectName(cwd) {
|
||||||
|
if (!cwd) return "";
|
||||||
|
return cwd.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 氣泡 ----------
|
||||||
|
|
||||||
|
function say(text, ms, sticky = false) {
|
||||||
|
clearTimeout(bubbleTimer);
|
||||||
|
bubbleText.textContent = text;
|
||||||
|
bubble.title = text;
|
||||||
|
bubble.classList.remove("hidden");
|
||||||
|
bubbleSticky = sticky;
|
||||||
|
if (ms > 0) bubbleTimer = setTimeout(hideBubble, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideBubble() {
|
||||||
|
clearTimeout(bubbleTimer);
|
||||||
|
bubble.classList.add("hidden");
|
||||||
|
bubbleSticky = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 事件 ----------
|
||||||
|
|
||||||
|
function handleEvent(p) {
|
||||||
|
if (!p || typeof p !== "object") return;
|
||||||
|
if (p.event === "__test") return handleTest(p);
|
||||||
|
|
||||||
|
const s = session(p.sessionId, p.cwd);
|
||||||
|
const proj = projectName(s.cwd);
|
||||||
|
const suffix = proj ? `(${proj})` : "";
|
||||||
|
|
||||||
|
switch (p.event) {
|
||||||
|
case "SessionStart":
|
||||||
|
setState(s, "idle");
|
||||||
|
if (!p.source || p.source === "startup") {
|
||||||
|
play("waving", 1);
|
||||||
|
say(`👋 嗨!${suffix}`, 3000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "UserPromptSubmit":
|
||||||
|
setState(s, "running");
|
||||||
|
hideBubble();
|
||||||
|
break;
|
||||||
|
case "PreToolUse":
|
||||||
|
if (p.toolName === "AskUserQuestion") {
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||||
|
} else if (p.toolName === "ExitPlanMode") {
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`📋 計畫等你確認${suffix}`, 0, true);
|
||||||
|
} else {
|
||||||
|
setState(s, "running");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "PostToolUse":
|
||||||
|
setState(s, "running");
|
||||||
|
break;
|
||||||
|
case "PostToolUseFailure":
|
||||||
|
setState(s, "running");
|
||||||
|
play("failed", 1);
|
||||||
|
say(`😵 ${p.toolName || "工具"} 失敗了`, 4000);
|
||||||
|
break;
|
||||||
|
case "PermissionRequest":
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`🔐 需要你授權${p.toolName ? `:${p.toolName}` : ""}`, 0, true);
|
||||||
|
break;
|
||||||
|
case "Notification": {
|
||||||
|
const t = p.notificationType;
|
||||||
|
if (t === "permission_prompt") {
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`🔐 需要你授權${suffix}`, 0, true);
|
||||||
|
} else if (t === "idle_prompt") {
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`💤 在等你回覆${suffix}`, 0, true);
|
||||||
|
} else if (t === "elicitation_dialog") {
|
||||||
|
setState(s, "waiting");
|
||||||
|
say(`❓ 有問題想問你${suffix}`, 0, true);
|
||||||
|
} else if (p.message) {
|
||||||
|
say(`💬 ${p.message}`, 4000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "Stop":
|
||||||
|
if (p.stopHookActive) break;
|
||||||
|
setState(s, "review");
|
||||||
|
s.reviewUntil = Date.now() + REVIEW_DURATION;
|
||||||
|
play("jumping", 1, () => say(`✅ 完成!${suffix}`, 8000));
|
||||||
|
break;
|
||||||
|
case "StopFailure":
|
||||||
|
setState(s, "idle");
|
||||||
|
play("failed", 2);
|
||||||
|
say(`❌ ${p.error || "出錯了"}`, 8000);
|
||||||
|
break;
|
||||||
|
case "SubagentStart":
|
||||||
|
case "SubagentStop":
|
||||||
|
setState(s, "running");
|
||||||
|
break;
|
||||||
|
case "PreCompact":
|
||||||
|
setState(s, "running");
|
||||||
|
say("🗜️ 整理記憶中…", 3000);
|
||||||
|
break;
|
||||||
|
case "SessionEnd":
|
||||||
|
sessions.delete(p.sessionId || "default");
|
||||||
|
if (sessions.size === 0) {
|
||||||
|
hideBubble();
|
||||||
|
say("👋 掰掰", 2000);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTest(p) {
|
||||||
|
const anim = p.anim;
|
||||||
|
if (anim === "clear") {
|
||||||
|
sessions.delete("test");
|
||||||
|
overlay = null;
|
||||||
|
hideBubble();
|
||||||
|
} else if (anim === "waving" || anim === "jumping" || anim === "failed") {
|
||||||
|
play(anim, anim === "failed" ? 2 : 1);
|
||||||
|
} else if (anim in PRIORITY) {
|
||||||
|
const s = session("test");
|
||||||
|
setState(s, anim);
|
||||||
|
if (anim === "review") s.reviewUntil = Date.now() + 60000;
|
||||||
|
if (anim === "waiting") say("🔐(測試)需要你授權", 0, true);
|
||||||
|
else if (anim === "idle") hideBubble();
|
||||||
|
}
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
function report() {
|
||||||
|
const base = baseState();
|
||||||
|
const snapshot = JSON.stringify({ anim: player.anim, base, sessions: sessions.size, pet: petInfo?.id || null });
|
||||||
|
if (snapshot !== lastReported) {
|
||||||
|
lastReported = snapshot;
|
||||||
|
window.pet.send("pet:state", JSON.parse(snapshot));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 動畫主迴圈 ----------
|
||||||
|
|
||||||
|
function tick(now) {
|
||||||
|
const anim = resolveAnim();
|
||||||
|
if (anim !== player.anim) {
|
||||||
|
player = { anim, frame: 0, frameStart: now };
|
||||||
|
report();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anim !== "look") {
|
||||||
|
const t = TIMINGS[anim];
|
||||||
|
if (now - player.frameStart > 5000) player.frameStart = now; // 從休眠醒來,避免狂追幀
|
||||||
|
let guard = 0;
|
||||||
|
while (now - player.frameStart >= t[player.frame] && guard++ < 64) {
|
||||||
|
player.frameStart += t[player.frame];
|
||||||
|
player.frame += 1;
|
||||||
|
if (player.frame >= t.length) {
|
||||||
|
player.frame = 0;
|
||||||
|
if (overlay && overlay.anim === anim) {
|
||||||
|
overlay.loopsLeft -= 1;
|
||||||
|
if (overlay.loopsLeft <= 0) {
|
||||||
|
const done = overlay.onDone;
|
||||||
|
overlay = null;
|
||||||
|
if (done) done();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = baseState();
|
||||||
|
if (prevBase === "waiting" && base !== "waiting" && bubbleSticky) hideBubble();
|
||||||
|
prevBase = base;
|
||||||
|
|
||||||
|
draw(anim);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(anim) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (!sheet) return;
|
||||||
|
let row;
|
||||||
|
let col;
|
||||||
|
if (anim === "look") {
|
||||||
|
row = 9 + (look.idx >> 3);
|
||||||
|
col = look.idx & 7;
|
||||||
|
} else {
|
||||||
|
row = ROW[anim];
|
||||||
|
col = player.frame;
|
||||||
|
}
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
ctx.drawImage(sheet, col * CELL_W, row * CELL_H, CELL_W, CELL_H, 0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 看向游標 ----------
|
||||||
|
|
||||||
|
window.pet.on("pet:cursor", ({ dx, dy }) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const dist = Math.hypot(dx, dy);
|
||||||
|
if (dist < LOOK_DEADZONE * Math.max(scale, 0.75)) {
|
||||||
|
look.idx = null;
|
||||||
|
look.lastMoveAt = now;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 000 = 正上方,順時針
|
||||||
|
const angle = (Math.atan2(dx, -dy) * 180 / Math.PI + 360) % 360;
|
||||||
|
const candidate = Math.round(angle / 22.5) % 16;
|
||||||
|
if (look.idx === null) {
|
||||||
|
look.idx = candidate;
|
||||||
|
} else if (candidate !== look.idx) {
|
||||||
|
// 加一點遲滯,避免在邊界抖動
|
||||||
|
let diff = Math.abs(angle - look.idx * 22.5);
|
||||||
|
diff = Math.min(diff, 360 - diff);
|
||||||
|
if (diff > 11.25 + 3) look.idx = candidate;
|
||||||
|
}
|
||||||
|
look.lastMoveAt = now;
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (look.idx !== null && Date.now() - look.lastMoveAt > LOOK_IDLE_AFTER) look.idx = null;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// ---------- 滑鼠:點穿 / 拖曳 / 點擊 / 右鍵 ----------
|
||||||
|
|
||||||
|
function overOpaque(x, y) {
|
||||||
|
if (!bubble.classList.contains("hidden")) {
|
||||||
|
const r = bubble.getBoundingClientRect();
|
||||||
|
if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) return true;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setIgnore(ignore) {
|
||||||
|
if (ignore === ignoringMouse) return;
|
||||||
|
ignoringMouse = ignore;
|
||||||
|
window.pet.send("pet:ignore-mouse", ignore);
|
||||||
|
canvas.classList.toggle("grab", !ignore);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", (e) => {
|
||||||
|
if (pressed) {
|
||||||
|
if (!pressed.moved && Math.hypot(e.screenX - pressed.sx, e.screenY - pressed.sy) > 4) {
|
||||||
|
pressed.moved = true;
|
||||||
|
drag = { dir: "right" };
|
||||||
|
canvas.classList.add("grabbing");
|
||||||
|
window.pet.send("pet:drag-start", { offsetX: pressed.x, offsetY: pressed.y });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIgnore(!overOpaque(e.clientX, e.clientY));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseout", (e) => {
|
||||||
|
if (!e.relatedTarget && !pressed) setIgnore(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mousedown", (e) => {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("mouseup", (e) => {
|
||||||
|
if (e.button !== 0 || !pressed) return;
|
||||||
|
const wasDrag = pressed.moved;
|
||||||
|
pressed = null;
|
||||||
|
if (wasDrag) {
|
||||||
|
drag = null;
|
||||||
|
canvas.classList.remove("grabbing");
|
||||||
|
window.pet.send("pet:drag-end");
|
||||||
|
} else {
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
|
|
||||||
|
window.pet.on("pet:drag-move", ({ dx }) => {
|
||||||
|
if (drag && dx) drag.dir = dx < 0 ? "left" : "right";
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
if (!bubble.classList.contains("hidden") && !bubbleSticky) {
|
||||||
|
hideBubble();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!overlay) play("waving", 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 初始化 ----------
|
||||||
|
|
||||||
|
window.pet.on("pet:init", ({ pet, scale: s, followCursor: f }) => {
|
||||||
|
followCursor = f !== false;
|
||||||
|
applyScale(s || 1);
|
||||||
|
if (pet) loadPet(pet);
|
||||||
|
else log("no pet available");
|
||||||
|
});
|
||||||
|
window.pet.on("pet:load", ({ pet }) => { if (pet) loadPet(pet); });
|
||||||
|
window.pet.on("pet:scale", ({ scale: s }) => applyScale(s));
|
||||||
|
window.pet.on("pet:settings", (st) => {
|
||||||
|
if (st && "followCursor" in st) followCursor = !!st.followCursor;
|
||||||
|
});
|
||||||
|
window.pet.on("pet:event", handleEvent);
|
||||||
|
|
||||||
|
window.pet.send("pet:ready");
|
||||||
|
requestAnimationFrame(tick);
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
cursor: default;
|
||||||
|
font-family: "Segoe UI", "Microsoft JhengHei", "PingFang TC", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stage {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bubble {
|
||||||
|
position: relative;
|
||||||
|
max-width: calc(100% - 12px);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
background: rgba(255, 255, 255, 0.97);
|
||||||
|
color: #222;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.28);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bubble::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
bottom: -6px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.97);
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bubble.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sprite {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sprite.grab {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sprite.grabbing {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use strict";
|
||||||
|
const autostart = require("../lib/autostart");
|
||||||
|
|
||||||
|
const action = process.argv[2] || "status";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === "install") {
|
||||||
|
const cmd = autostart.install();
|
||||||
|
console.log(`已加入開機自動啟動(${autostart.RUN_KEY}\\${autostart.VALUE_NAME})`);
|
||||||
|
console.log(cmd);
|
||||||
|
} else if (action === "uninstall") {
|
||||||
|
console.log(autostart.uninstall() ? "已移除開機自動啟動。" : "原本就沒有設定開機自動啟動。");
|
||||||
|
} else {
|
||||||
|
const installed = autostart.isInstalled();
|
||||||
|
console.log(installed ? `開機自動啟動:已啟用\n${autostart.currentValue()}` : "開機自動啟動:未啟用");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use strict";
|
||||||
|
const installer = require("../lib/hooks-installer");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = installer.install();
|
||||||
|
console.log(`已安裝 ${r.events.length} 個 Claude Code hook 事件到 ${r.settingsPath}`);
|
||||||
|
console.log(`hook 指令:${r.command}`);
|
||||||
|
if (r.backupPath) console.log(`原設定備份:${r.backupPath}`);
|
||||||
|
console.log("注意:已開啟的 Claude Code session 要重新啟動才會載入新的 hooks。");
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use strict";
|
||||||
|
const installer = require("../lib/hooks-installer");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = installer.uninstall();
|
||||||
|
console.log(`已從 ${r.settingsPath} 移除 ${r.removed} 個 Claude Pet hook。`);
|
||||||
|
if (r.backupPath) console.log(`原設定備份:${r.backupPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message || err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user