diff --git a/README.md b/README.md index b87c945..de2a340 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,17 @@ ## 快速開始 -### 用安裝檔(一般使用) +### 直接用打包好的(一般使用) -執行 `dist\Claude Pet-<版本>-setup.exe`(見[打包成 exe](#打包成-exe)),裝在 `%LOCALAPPDATA%\Programs\Claude Pet`,不需要系統管理員權限。裝完從開始選單開啟,再用系統匣選單的 **Claude Code hooks → 安裝** 接上 Claude Code。 +三種都不需要系統管理員權限,選一種即可(見[打包成 exe](#打包成-exe)): + +| 方式 | 檔案 | 適合 | +|---|---|---| +| **免安裝資料夾**(推薦) | `Claude Pet-<版本>-win.zip` | 解壓到任何地方,點兩下裡面的 `Claude Pet.exe` 就跑。啟動快、寵物放旁邊的 `pets\`、適合開機自動啟動 | +| 安裝檔 | `Claude Pet-<版本>-setup.exe` | 裝到 `%LOCALAPPDATA%\Programs\Claude Pet`,有開始選單與桌面捷徑 | +| 免安裝單檔 | `Claude Pet-<版本>-portable.exe` | 只想帶一個檔案走。代價是**每次啟動都要重新解壓約 200 MB**,開得比較慢 | + +開起來之後用系統匣選單的 **Claude Code hooks → 安裝** 接上 Claude Code,然後**重開一個新的 Claude Code session**。 ### 從原始碼跑(開發) @@ -166,8 +174,10 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透 ## 新增 / 切換寵物 -1. **用 Codex hatch** — 在 Codex 裡 `/hatch`,完成後寵物會在 `%USERPROFILE%\.codex\pets\\`,本程式預設掃描該資料夾,選單「切換寵物 → 重新掃描」就會出現。 -2. **手動放入** — 把寵物資料夾複製到 `pets\\`(選單「開啟寵物資料夾」可直接打開)。 +掃描順序:**執行目錄的 `pets\`**(portable 版是那顆 exe 旁邊的,其次才是內附的)→ `~/.codex/pets` → 設定檔 `petSources` 裡的額外路徑。同一個 id 以先掃到的為準。 + +1. **手動放入**(最直接)— 把寵物資料夾丟到 exe 旁邊的 `pets\\`,選單「切換寵物 → 重新掃描」就會出現。選單的「開啟寵物資料夾」會直接開這一個。 +2. **用 Codex hatch** — 在 Codex 裡 `/hatch`,完成後寵物在 `%USERPROFILE%\.codex\pets\\`,本程式一律會掃這個資料夾。 3. **其他位置** — 在設定檔 `petSources` 加路徑。 切換後立即生效,不用重啟。 @@ -179,7 +189,7 @@ Spritesheet:**1536 × 2288**,8 欄 × 11 列,每格 **192 × 208**,透 | 欄位 | 預設 | 說明 | |---|---|---| | `activePetId` | `"xiao-nian"` | 目前使用的寵物 id | -| `petSources` | `[/pets, ~/.codex/pets]` | 掃描寵物的資料夾清單;程式自己的 `pets/` 永遠會被加在最前面 | +| `petSources` | `[]` | **額外**的寵物資料夾。兩個隱含來源永遠會被掃到且不寫進這裡:**執行目錄的 `pets/`**(exe 旁邊,開發時是專案資料夾,排最前面)與 `~/.codex/pets`。不存在的路徑會在載入時自動剔除 | | `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5。`1` 表示 126 × 137,與 Codex 桌面寵物同大小 | | `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 | | `alwaysOnTop` | `true` | 永遠置頂 | @@ -208,7 +218,7 @@ Claude Code ──hook(stdin JSON)──▶ hook/claude-pet-hook.js ──PO | `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` 登錄值;開發時指向 `electron.exe + 專案路徑`,打包後直接指向那顆 exe | -| `lib/paths.js` | 開發/打包兩種情境的路徑解析(`app.asar` → `app.asar.unpacked`、是否為打包版) | +| `lib/paths.js` | 開發/安裝版/portable 三種情境的路徑解析(`app.asar` → `app.asar.unpacked`、是否打包、portable 的真實 exe 位置) | | `scripts/*.js` | 上述兩者的 CLI 包裝 | | `pets/` | 內附寵物 | | `docs/` | README 用圖 | @@ -269,24 +279,47 @@ curl http://127.0.0.1:17333/state ## 打包成 exe ```powershell -npm run dist # 安裝檔 + 免安裝版 -npm run pack # 只產免安裝版,快很多,開發時驗證用 +npm run dist # 三種都做 +npm run dist:portable # 只做免安裝單檔 +npm run pack # 只產 win-unpacked 資料夾,最快,改程式時驗證用 ``` 產物在 `dist\`: -| 檔案 | 說明 | -|---|---| -| `Claude Pet-<版本>-setup.exe` | NSIS 安裝檔(約 100 MB)。per-user 安裝、不需要系統管理員權限,預設裝到 `%LOCALAPPDATA%\Programs\Claude Pet`,可自選路徑,會建立桌面與開始選單捷徑 | -| `win-unpacked\Claude Pet.exe` | 免安裝版,整個資料夾複製走就能直接執行 | +| 檔案 | 大小 | 說明 | +|---|---|---| +| `Claude Pet-<版本>-win.zip` | ~140 MB | **免安裝資料夾版**。解壓後點兩下 `Claude Pet.exe`。啟動最快,寵物就放旁邊的 `pets\` | +| `Claude Pet-<版本>-setup.exe` | ~100 MB | NSIS 安裝檔。per-user、可改安裝路徑、建立桌面與開始選單捷徑 | +| `Claude Pet-<版本>-portable.exe` | ~100 MB | **免安裝單檔**,點兩下直接跑 | +| `win-unpacked\` | ~200 MB | 上面 zip 的未壓縮版,整個資料夾複製走也能用 | -第一次建置時 electron-builder 會從 GitHub 抓 NSIS 與相關工具到 `%LOCALAPPDATA%\electron-builder\Cache`,需要網路,之後就會走快取。 +第一次建置時 electron-builder 會從 GitHub 抓 NSIS 與相關工具到 `%LOCALAPPDATA%\electron-builder\Cache`,需要網路,之後走快取。 -**打包後和原始碼跑有三個地方不一樣**(都已經處理好,改程式時要留意): +### portable 單檔版的代價 -- **`hook/` 和 `pets/` 不放進 asar**(`build.asarUnpack`)。前者是因為 Claude Code 用 `node <路徑>` 執行 hook,而 node 讀不到 asar 裡的檔案;後者是因為使用者要能自己丟寵物進去、選單「開啟寵物資料夾」也要打得開。程式裡由 `lib/paths.js` 的 `unpacked()` 把 `app.asar` 換成 `app.asar.unpacked`。 -- **開機自動啟動**寫入的指令是那顆 exe 本身(`process.execPath`),不再是 `electron.exe + 專案路徑`。判斷方式是 `lib/paths.js` 的 `isPackaged()`。 -- **hook 指令仍然需要 `node` 在 PATH 裡**。安裝路徑含空白(`Claude Pet`)時會自動加引號。 +NSIS 的 portable 外殼**每次啟動**都會把整包解壓到 `%TEMP%\ClaudePet`,**程式結束後又整個刪掉**(見 `app-builder-lib/templates/nsis/portable.nsi` 的 `RMDir /r $INSTDIR`)。所以: + +- 每次開都要重新解壓約 200 MB,比資料夾版慢好幾秒。**要開機自動啟動的話建議用資料夾版**。 +- 程式本體的位置是暫時的,因此 hook 腳本會另外複製一份到 `%USERPROFILE%\.claude-pet\hook\`,用那個穩定路徑去註冊。否則寵物沒開的時候 Claude Code 每次觸發 hook 都會找不到檔案而報錯。 +- 使用者自己的寵物要放在**那顆 exe 旁邊**的 `pets\`(程式第一次跑會自己建),放進解壓目錄的會跟著被刪掉。同 id 時以 exe 旁邊那份為準。 +- 程式被強制結束(工作管理員)時外殼來不及清理,`%TEMP%\ClaudePet` 會留下約 300 MB,可以手動刪。 + +### 打包後和原始碼跑不一樣的地方 + +都已經處理好,改程式時要留意: + +| 項目 | 開發 | 安裝/資料夾版 | portable 單檔 | +|---|---|---|---| +| 內附寵物 | `<專案>\pets` | `\pets` | `%TEMP%\ClaudePet\pets` | +| 使用者寵物 | 同上 | 同上 | `\pets` | +| hook 腳本 | `<專案>\hook\` | `resources\app.asar.unpacked\hook\` | `%USERPROFILE%\.claude-pet\hook\` | +| 開機自動啟動 | `electron.exe` + 專案路徑 | 那顆 exe | `PORTABLE_EXECUTABLE_FILE` | + +判斷邏輯都在 `lib/paths.js`:`isPackaged()` 用 `process.defaultApp`、`isPortable()` 用 `PORTABLE_EXECUTABLE_FILE`、`unpacked()` 把 `app.asar` 換成 `app.asar.unpacked`。 + +- **`pets/` 用 `extraFiles` 放到 exe 旁邊**(不是 `extraResources`,那會進 `resources\`),使用者直接把寵物資料夾丟進去就會出現,不用碰 asar。 +- **`hook/` 用 `asarUnpack`**,因為 Claude Code 是用 `node <路徑>` 執行它,而 node 讀不到 asar 裡的檔案。 +- **hook 指令仍然需要 `node` 在 PATH 裡**。路徑含空白時會自動加引號。 換圖示就換掉 `build/icon.ico`(要含 256×256)。目前這顆是從 `pets/xiao-nian/spritesheet.webp` 第 0 列第 6 欄(中立正面)取頭肩方形裁切產生的。 diff --git a/lib/autostart.js b/lib/autostart.js index dc9a39b..0e6899d 100644 --- a/lib/autostart.js +++ b/lib/autostart.js @@ -3,7 +3,7 @@ const path = require("node:path"); const fs = require("node:fs"); const { spawnSync } = require("node:child_process"); -const { isPackaged } = require("./paths"); +const { isPackaged, portableExe } = require("./paths"); const APP_DIR = path.resolve(__dirname, ".."); const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; @@ -14,8 +14,9 @@ function electronExe() { } function command() { - // 打包後就是那顆 exe 本身;開發時要用 node_modules 裡的 electron 帶上專案路徑 - if (isPackaged()) return `"${process.execPath}"`; + // 打包後就是那顆 exe 本身。portable 版的 process.execPath 會指到 %TEMP% 的解壓副本, + // 要用 PORTABLE_EXECUTABLE_FILE 才是使用者手上那顆 exe。 + if (isPackaged()) return `"${portableExe() || process.execPath}"`; return `"${electronExe()}" "${APP_DIR}"`; } diff --git a/lib/hooks-installer.js b/lib/hooks-installer.js index 03938a7..a2ebab9 100644 --- a/lib/hooks-installer.js +++ b/lib/hooks-installer.js @@ -3,14 +3,29 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { unpacked } = require("./paths"); +const { unpacked, isPortable } = require("./paths"); 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"); // node 執行不了 asar 內的檔案,打包後要指到解開的那份 -const HOOK_SCRIPT = unpacked(path.join(APP_DIR, "hook", "claude-pet-hook.js")); +const BUNDLED_HOOK_SCRIPT = unpacked(path.join(APP_DIR, "hook", "claude-pet-hook.js")); +// portable 版整包解在 %TEMP%、關掉程式就被刪,hook 路徑不能指在那裡(寵物沒開時 +// Claude Code 會找不到檔案而每次噴錯),所以複製一份到不會被清掉的地方。 +const STABLE_HOOK_SCRIPT = path.join(os.homedir(), ".claude-pet", "hook", "claude-pet-hook.js"); + +function hookScript() { + return isPortable() ? STABLE_HOOK_SCRIPT : BUNDLED_HOOK_SCRIPT; +} + +// 只有 portable 需要;每次安裝 hook 都重新複製,版本才不會走鐘 +function syncHookScript() { + if (!isPortable()) return; + fs.mkdirSync(path.dirname(STABLE_HOOK_SCRIPT), { recursive: true }); + fs.copyFileSync(BUNDLED_HOOK_SCRIPT, STABLE_HOOK_SCRIPT); +} + const MARKER = "claude-pet-hook"; // Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態) @@ -31,7 +46,7 @@ const EVENTS = [ ]; function hookCommand() { - const p = HOOK_SCRIPT.replace(/\\/g, "/"); + const p = hookScript().replace(/\\/g, "/"); return /\s/.test(p) ? `node "${p}"` : `node ${p}`; } @@ -72,6 +87,7 @@ function normalize(list) { } function install() { + syncHookScript(); const settings = readSettings(); const backupPath = backup(); settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {}; @@ -110,4 +126,4 @@ function isInstalled() { } } -module.exports = { install, uninstall, isInstalled, hookCommand, EVENTS, SETTINGS_PATH }; +module.exports = { install, uninstall, isInstalled, hookCommand, hookScript, EVENTS, SETTINGS_PATH }; diff --git a/lib/paths.js b/lib/paths.js index 37b3c21..3706c91 100644 --- a/lib/paths.js +++ b/lib/paths.js @@ -18,4 +18,18 @@ function isPackaged() { return Boolean(process.versions.electron) && !process.defaultApp; } -module.exports = { unpacked, isPackaged }; +// portable 單檔版:NSIS 外殼每次啟動都把整包解到 %TEMP%,程式結束後又整個刪掉, +// 所以「程式本身的位置」是暫時的,只有這兩個環境變數指得到那顆 exe 真正待的地方。 +function portableExe() { + return process.env.PORTABLE_EXECUTABLE_FILE || null; +} + +function portableDir() { + return process.env.PORTABLE_EXECUTABLE_DIR || null; +} + +function isPortable() { + return Boolean(portableExe()); +} + +module.exports = { unpacked, isPackaged, portableExe, portableDir, isPortable }; diff --git a/main.js b/main.js index 36ca5ea..faea5e1 100644 --- a/main.js +++ b/main.js @@ -6,16 +6,26 @@ const path = require("node:path"); const os = require("node:os"); const autostart = require("./lib/autostart"); const hooksInstaller = require("./lib/hooks-installer"); -const { unpacked } = require("./lib/paths"); +const { unpacked, portableDir, isPackaged } = require("./lib/paths"); const APP_DIR = __dirname; -// 打包後 pets 會被 asarUnpack 解到 app.asar.unpacked,才能讓使用者自己丟寵物進去、選單也開得起來 -const PETS_DIR = unpacked(path.join(APP_DIR, "pets")); const HOME = os.homedir(); const CONFIG_DIR = path.join(HOME, ".claude-pet"); const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); const LOG_PATH = path.join(CONFIG_DIR, "events.log"); +// 執行檔所在的資料夾:開發時是專案資料夾,打包後是 exe 旁邊 +// (portable 版這裡會是 %TEMP% 的解壓目錄,使用者那顆 exe 的位置要看 portableDir()) +const APP_ROOT = isPackaged() ? path.dirname(process.execPath) : APP_DIR; +// 寵物一律放執行目錄的 pets/,不埋在 asar 裡,使用者直接丟資料夾進去就會出現 +const BUNDLED_PETS_DIR = path.join(APP_ROOT, "pets"); +// portable 版整包解在 %TEMP%、關掉就被刪,使用者自己的寵物要放在那顆 exe 旁邊才留得住 +const USER_PETS_DIR = portableDir() ? path.join(portableDir(), "pets") : BUNDLED_PETS_DIR; +const CODEX_PETS_DIR = path.join(HOME, ".codex", "pets"); +// 一律會被掃到、且不寫進設定檔的來源。使用者自己放的排最前面,同 id 才不會被內附的蓋掉 +// (非 portable 時 USER 就等於 BUNDLED,去重後只剩一個) +const IMPLICIT_PET_DIRS = [...new Set([USER_PETS_DIR, BUNDLED_PETS_DIR, CODEX_PETS_DIR])]; + const CELL_W = 192; // spritesheet 單格來源尺寸 const CELL_H = 208; // 100% 時的顯示尺寸,對齊 Codex 桌面寵物:寬度為基準,高度用 Codex 的 ake() 公式 ceil(w * 208/192) @@ -29,7 +39,7 @@ const SCALES = [0.5, 0.75, 1, 1.25, 1.5]; const DEFAULT_CONFIG = { activePetId: "xiao-nian", - petSources: [PETS_DIR, path.join(HOME, ".codex", "pets")], + petSources: [], // 只放使用者自己加的額外路徑;執行目錄的 pets 與 ~/.codex/pets 一律隱含 scale: 1, position: null, alwaysOnTop: true, @@ -52,11 +62,13 @@ 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 = PETS_DIR; - 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]); + // petSources 只存「使用者自己加的」。隱含來源(執行目錄的 pets、~/.codex/pets)不寫進設定檔, + // 否則程式一搬家、或在安裝版與 portable 版之間換來換去,設定檔就會一直累積失效的絕對路徑。 + const implicit = new Set(IMPLICIT_PET_DIRS.map(dirKey)); + merged.petSources = (Array.isArray(merged.petSources) ? merged.petSources : []) + .filter((p) => typeof p === "string" && p.trim()) + .filter((p) => !implicit.has(dirKey(p))) + .filter((p) => fs.existsSync(p)); if (!SCALES.includes(Number(merged.scale))) merged.scale = 1; merged.scale = Number(merged.scale); return merged; @@ -81,10 +93,31 @@ function appendLog(line) { // ---------- pets ---------- +function dirKey(p) { + return path.resolve(p).toLowerCase(); +} + +// 實際要掃的資料夾:執行目錄的 pets 永遠排最前面,其次是 Codex 的,最後才是使用者自己加的 +function petSearchDirs() { + const seen = new Set(); + return [...IMPLICIT_PET_DIRS, ...config.petSources].filter((p) => { + const key = dirKey(p); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +// portable 版第一次跑時,在 exe 旁邊開一個 pets 資料夾給使用者放自己的寵物 +function ensureUserPetsDir() { + if (USER_PETS_DIR === BUNDLED_PETS_DIR) return; + try { fs.mkdirSync(USER_PETS_DIR, { recursive: true }); } catch { /* 唯讀路徑就算了 */ } +} + function discoverPets() { const found = []; const seen = new Set(); - for (const src of config.petSources) { + for (const src of petSearchDirs()) { let entries = []; try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; } for (const ent of entries) { @@ -352,7 +385,7 @@ function buildMenu() { })), { type: "separator" }, { label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } }, - { label: "開啟寵物資料夾", click: () => shell.openPath(PETS_DIR) }, + { label: "開啟寵物資料夾", click: () => { ensureUserPetsDir(); shell.openPath(USER_PETS_DIR); } }, ], }, { @@ -485,9 +518,10 @@ if (!app.requestSingleInstanceLock()) { }); app.whenReady().then(() => { + ensureUserPetsDir(); pets = discoverPets(); if (pets.length === 0) { - dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${config.petSources.join("\n")}`); + dialog.showErrorBox("Claude Pet", `找不到任何寵物。\n請把 Codex 寵物包(pet.json + spritesheet.webp)放到:\n${petSearchDirs().join("\n")}`); } createWindow(); createTray(); diff --git a/package.json b/package.json index dcf00ea..8128dcc 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "autostart:uninstall": "node scripts/autostart.js uninstall", "autostart:status": "node scripts/autostart.js status", "dist": "electron-builder --win", - "pack": "electron-builder --win --dir" + "pack": "electron-builder --win --dir", + "dist:portable": "electron-builder --win portable" }, "devDependencies": { "electron": "^43.4.1", @@ -33,12 +34,10 @@ "renderer/**/*", "lib/**/*", "hook/**/*", - "pets/**/*", "package.json" ], "asarUnpack": [ - "hook/**/*", - "pets/**/*" + "hook/**/*" ], "win": { "target": [ @@ -47,10 +46,21 @@ "arch": [ "x64" ] + }, + { + "target": "portable", + "arch": [ + "x64" + ] + }, + { + "target": "zip", + "arch": [ + "x64" + ] } ], - "icon": "build/icon.ico", - "artifactName": "${productName}-${version}-setup.${ext}" + "icon": "build/icon.ico" }, "nsis": { "oneClick": false, @@ -59,7 +69,19 @@ "createDesktopShortcut": true, "createStartMenuShortcut": true, "shortcutName": "Claude Pet", - "deleteAppDataOnUninstall": false - } + "deleteAppDataOnUninstall": false, + "artifactName": "${productName}-${version}-setup.${ext}" + }, + "portable": { + "artifactName": "${productName}-${version}-portable.${ext}", + "unpackDirName": "ClaudePet", + "requestExecutionLevel": "user" + }, + "extraFiles": [ + { + "from": "pets", + "to": "pets" + } + ] } } diff --git a/pets/xiao-nian-realistic/pet.json b/pets/xiao-nian-realistic/pet.json new file mode 100644 index 0000000..ef55841 --- /dev/null +++ b/pets/xiao-nian-realistic/pet.json @@ -0,0 +1,7 @@ +{ + "id": "xiao-nian-realistic", + "displayName": "小念・擬真", + "description": "一個阿呆", + "spriteVersionNumber": 2, + "spritesheetPath": "spritesheet.webp" +} diff --git a/pets/xiao-nian-realistic/spritesheet.webp b/pets/xiao-nian-realistic/spritesheet.webp new file mode 100644 index 0000000..6ac4eab Binary files /dev/null and b/pets/xiao-nian-realistic/spritesheet.webp differ