加上免安裝版,寵物來源改成執行目錄的 pets

摘要:
新增 portable 單檔與 zip 資料夾兩種免安裝產物;寵物一律從執行目錄的 pets/ 掃,設定檔不再累積程式自己算出來的路徑。

根本原因:
1. 只有 NSIS 安裝檔,沒有點兩下就能跑的版本。
2. 內附寵物被 asarUnpack 埋在 resources/app.asar.unpacked/pets,使用者要新增寵物得先鑽進 resources 資料夾。
3. loadConfig 會把算出來的來源路徑寫回 config.json,程式一搬家或在安裝版與 portable 之間切換,petSources 就越積越多失效的絕對路徑(實際已累積出 dist/win-unpacked/resources/app.asar.unpacked/pets 這種殘留)。
4. CODEX_PETS_DIR 用到 HOME,但 const HOME 宣告在它下面,會在啟動時就 TDZ ReferenceError。

影響:
沒有免安裝選項;新增寵物的路徑不直覺;設定檔會長出一堆死路徑;且上一版打包後的程式其實一啟動就會崩潰。

修法:
- win target 加上 portable 與 zip;portable 設 unpackDirName=ClaudePet、requestExecutionLevel=user。
- pets/ 改用 extraFiles 放到 exe 旁邊(不再進 asar),APP_ROOT 打包後取 process.execPath 的目錄。
- portable 的 NSIS 外殼每次啟動都重解壓、結束就刪整個目錄,因此:hook 腳本另外複製到 ~/.claude-pet/hook/ 並註冊該穩定路徑(否則寵物沒開時 Claude Code 每次觸發 hook 都找不到檔案);使用者寵物放那顆 exe 旁邊;開機自動啟動改用 PORTABLE_EXECUTABLE_FILE。
- petSources 語意改為「只存使用者自己加的額外路徑」;執行目錄的 pets 與 ~/.codex/pets 為隱含來源,由 petSearchDirs() 組出實際掃描清單,載入時剔除隱含項與不存在的路徑。同 id 以使用者自己放的優先。
- 把 HOME/CONFIG_DIR 移到常數區最前面,修掉 TDZ。
- 內附第二隻寵物 xiao-nian-realistic。

驗證:
以 ELECTRON_RUN_AS_NODE 執行打包後的 exe(不開視窗):
- 一般模式與模擬 portable 模式下的 pets 路徑、hook 路徑、autostart 指令皆正確。
- 用假的 USERPROFILE 實跑 install/uninstall:hook 腳本確實複製、指令含空白路徑會加引號、其他人的 Stop/PreToolUse hook 與 model 設定在安裝與移除後都完整保留、備份有產生。
- 帶殘留路徑的設定檔會被正確清理,掃描順序為「使用者 pets → 內附 pets → ~/.codex/pets → 額外路徑」。
- 從 app.asar 讀回 main.js 確認出貨的程式碼順序正確。
- 以純 node 載入 main.js,確認常數區與 loadConfig 不再有 TDZ。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 14:11:20 +08:00
co-authored by Claude Fable 5
parent 56fd8448ba
commit e591475d4c
8 changed files with 172 additions and 45 deletions
+50 -17
View File
@@ -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\<id>\`,本程式預設掃描該資料夾,選單「切換寵物 → 重新掃描」就會出現 掃描順序:**執行目錄的 `pets\`**portable 版是那顆 exe 旁邊的,其次才是內附的)→ `~/.codex/pets` → 設定檔 `petSources` 裡的額外路徑。同一個 id 以先掃到的為準
2. **手動放入** — 把寵物資料夾複製到 `pets\<id>\`(選單「開啟寵物資料夾」可直接打開)。
1. **手動放入**(最直接)— 把寵物資料夾丟到 exe 旁邊的 `pets\<id>\`,選單「切換寵物 → 重新掃描」就會出現。選單的「開啟寵物資料夾」會直接開這一個。
2. **用 Codex hatch** — 在 Codex 裡 `/hatch`,完成後寵物在 `%USERPROFILE%\.codex\pets\<id>\`,本程式一律會掃這個資料夾。
3. **其他位置** — 在設定檔 `petSources` 加路徑。 3. **其他位置** — 在設定檔 `petSources` 加路徑。
切換後立即生效,不用重啟。 切換後立即生效,不用重啟。
@@ -179,7 +189,7 @@ Spritesheet**1536 × 2288**8 欄 × 11 列,每格 **192 × 208**,透
| 欄位 | 預設 | 說明 | | 欄位 | 預設 | 說明 |
|---|---|---| |---|---|---|
| `activePetId` | `"xiao-nian"` | 目前使用的寵物 id | | `activePetId` | `"xiao-nian"` | 目前使用的寵物 id |
| `petSources` | `[<app>/pets, ~/.codex/pets]` | 掃描寵物資料夾清單;程式自己`pets/` 永遠會被加在最前面 | | `petSources` | `[]` | **額外**的寵物資料夾。兩個隱含來源永遠會被掃到且不寫進這裡:**執行目錄的 `pets/`**(exe 旁邊,開發時是專案資料夾,排最前面)與 `~/.codex/pets`。不存在的路徑會在載入時自動剔除 |
| `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5。`1` 表示 126 × 137,與 Codex 桌面寵物同大小 | | `scale` | `1` | 縮放:0.5 / 0.75 / 1 / 1.25 / 1.5。`1` 表示 126 × 137,與 Codex 桌面寵物同大小 |
| `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 | | `position` | `null` | 視窗左上角座標;`null` 為主螢幕右下角。若座標落在所有螢幕之外會自動拉回 |
| `alwaysOnTop` | `true` | 永遠置頂 | | `alwaysOnTop` | `true` | 永遠置頂 |
@@ -208,7 +218,7 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST800 ms 逾時、永遠 exit 0 | | `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST800 ms 逾時、永遠 exit 0 |
| `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) | | `lib/hooks-installer.js` | 安全地合併 / 移除 `~/.claude/settings.json` 的 hooks(保留其他設定與其他 hooks,先備份) |
| `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值;開發時指向 `electron.exe + 專案路徑`,打包後直接指向那顆 exe | | `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 包裝 | | `scripts/*.js` | 上述兩者的 CLI 包裝 |
| `pets/` | 內附寵物 | | `pets/` | 內附寵物 |
| `docs/` | README 用圖 | | `docs/` | README 用圖 |
@@ -269,24 +279,47 @@ curl http://127.0.0.1:17333/state
## 打包成 exe ## 打包成 exe
```powershell ```powershell
npm run dist # 安裝檔 + 免安裝版 npm run dist # 三種都做
npm run pack # 只免安裝版,快很多,開發時驗證用 npm run dist:portable # 只免安裝單檔
npm run pack # 只產 win-unpacked 資料夾,最快,改程式時驗證用
``` ```
產物在 `dist\` 產物在 `dist\`
| 檔案 | 說明 | | 檔案 | 大小 | 說明 |
|---|---| |---|---|---|
| `Claude Pet-<版本>-setup.exe` | NSIS 安裝檔(約 100 MB)。per-user 安裝、不需要系統管理員權限,預設裝到 `%LOCALAPPDATA%\Programs\Claude Pet`,可自選路徑,會建立桌面與開始選單捷徑 | | `Claude Pet-<版本>-win.zip` | ~140 MB | **免安裝資料夾版**。解壓後點兩下 `Claude Pet.exe`。啟動最快,寵物就放旁邊的 `pets\` |
| `win-unpacked\Claude Pet.exe` | 免安裝版,整個資料夾複製走就能直接執行 | | `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` NSIS 的 portable 外殼**每次啟動**都會把整包解壓到 `%TEMP%\ClaudePet`**程式結束後又整個刪掉**(見 `app-builder-lib/templates/nsis/portable.nsi` `RMDir /r $INSTDIR`)。所以:
- **開機自動啟動**寫入的指令是那顆 exe 本身(`process.execPath`),不再是 `electron.exe + 專案路徑`。判斷方式是 `lib/paths.js``isPackaged()`
- **hook 指令仍然需要 `node` 在 PATH 裡**。安裝路徑含空白(`Claude Pet`)時會自動加引號 - 每次開都要重新解壓約 200 MB,比資料夾版慢好幾秒。**要開機自動啟動的話建議用資料夾版**
- 程式本體的位置是暫時的,因此 hook 腳本會另外複製一份到 `%USERPROFILE%\.claude-pet\hook\`,用那個穩定路徑去註冊。否則寵物沒開的時候 Claude Code 每次觸發 hook 都會找不到檔案而報錯。
- 使用者自己的寵物要放在**那顆 exe 旁邊**的 `pets\`(程式第一次跑會自己建),放進解壓目錄的會跟著被刪掉。同 id 時以 exe 旁邊那份為準。
- 程式被強制結束(工作管理員)時外殼來不及清理,`%TEMP%\ClaudePet` 會留下約 300 MB,可以手動刪。
### 打包後和原始碼跑不一樣的地方
都已經處理好,改程式時要留意:
| 項目 | 開發 | 安裝/資料夾版 | portable 單檔 |
|---|---|---|---|
| 內附寵物 | `<專案>\pets` | `<exe 旁>\pets` | `%TEMP%\ClaudePet\pets` |
| 使用者寵物 | 同上 | 同上 | `<exe 旁>\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 欄(中立正面)取頭肩方形裁切產生的。 換圖示就換掉 `build/icon.ico`(要含 256×256)。目前這顆是從 `pets/xiao-nian/spritesheet.webp` 第 0 列第 6 欄(中立正面)取頭肩方形裁切產生的。
+4 -3
View File
@@ -3,7 +3,7 @@
const path = require("node:path"); const path = require("node:path");
const fs = require("node:fs"); const fs = require("node:fs");
const { spawnSync } = require("node:child_process"); const { spawnSync } = require("node:child_process");
const { isPackaged } = require("./paths"); const { isPackaged, portableExe } = require("./paths");
const APP_DIR = path.resolve(__dirname, ".."); const APP_DIR = path.resolve(__dirname, "..");
const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run"; const RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
@@ -14,8 +14,9 @@ function electronExe() {
} }
function command() { function command() {
// 打包後就是那顆 exe 本身;開發時要用 node_modules 裡的 electron 帶上專案路徑 // 打包後就是那顆 exe 本身。portable 版的 process.execPath 會指到 %TEMP% 的解壓副本,
if (isPackaged()) return `"${process.execPath}"`; // 要用 PORTABLE_EXECUTABLE_FILE 才是使用者手上那顆 exe。
if (isPackaged()) return `"${portableExe() || process.execPath}"`;
return `"${electronExe()}" "${APP_DIR}"`; return `"${electronExe()}" "${APP_DIR}"`;
} }
+20 -4
View File
@@ -3,14 +3,29 @@
const fs = require("node:fs"); const fs = require("node:fs");
const os = require("node:os"); const os = require("node:os");
const path = require("node:path"); const path = require("node:path");
const { unpacked } = require("./paths"); const { unpacked, isPortable } = require("./paths");
const APP_DIR = path.resolve(__dirname, ".."); const APP_DIR = path.resolve(__dirname, "..");
const CLAUDE_DIR = path.join(os.homedir(), ".claude"); const CLAUDE_DIR = path.join(os.homedir(), ".claude");
const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json"); const SETTINGS_PATH = path.join(CLAUDE_DIR, "settings.json");
const BACKUP_DIR = path.join(CLAUDE_DIR, "backups"); const BACKUP_DIR = path.join(CLAUDE_DIR, "backups");
// node 執行不了 asar 內的檔案,打包後要指到解開的那份 // 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"; const MARKER = "claude-pet-hook";
// Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態) // Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態)
@@ -31,7 +46,7 @@ const EVENTS = [
]; ];
function hookCommand() { function hookCommand() {
const p = HOOK_SCRIPT.replace(/\\/g, "/"); const p = hookScript().replace(/\\/g, "/");
return /\s/.test(p) ? `node "${p}"` : `node ${p}`; return /\s/.test(p) ? `node "${p}"` : `node ${p}`;
} }
@@ -72,6 +87,7 @@ function normalize(list) {
} }
function install() { function install() {
syncHookScript();
const settings = readSettings(); const settings = readSettings();
const backupPath = backup(); const backupPath = backup();
settings.hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {}; 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 };
+15 -1
View File
@@ -18,4 +18,18 @@ function isPackaged() {
return Boolean(process.versions.electron) && !process.defaultApp; 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 };
+46 -12
View File
@@ -6,16 +6,26 @@ const path = require("node:path");
const os = require("node:os"); const os = require("node:os");
const autostart = require("./lib/autostart"); const autostart = require("./lib/autostart");
const hooksInstaller = require("./lib/hooks-installer"); const hooksInstaller = require("./lib/hooks-installer");
const { unpacked } = require("./lib/paths"); const { unpacked, portableDir, isPackaged } = require("./lib/paths");
const APP_DIR = __dirname; const APP_DIR = __dirname;
// 打包後 pets 會被 asarUnpack 解到 app.asar.unpacked,才能讓使用者自己丟寵物進去、選單也開得起來
const PETS_DIR = unpacked(path.join(APP_DIR, "pets"));
const HOME = os.homedir(); const HOME = os.homedir();
const CONFIG_DIR = path.join(HOME, ".claude-pet"); const CONFIG_DIR = path.join(HOME, ".claude-pet");
const CONFIG_PATH = path.join(CONFIG_DIR, "config.json"); const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
const LOG_PATH = path.join(CONFIG_DIR, "events.log"); 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_W = 192; // spritesheet 單格來源尺寸
const CELL_H = 208; const CELL_H = 208;
// 100% 時的顯示尺寸,對齊 Codex 桌面寵物:寬度為基準,高度用 Codex 的 ake() 公式 ceil(w * 208/192) // 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 = { const DEFAULT_CONFIG = {
activePetId: "xiao-nian", activePetId: "xiao-nian",
petSources: [PETS_DIR, path.join(HOME, ".codex", "pets")], petSources: [], // 只放使用者自己加的額外路徑;執行目錄的 pets 與 ~/.codex/pets 一律隱含
scale: 1, scale: 1,
position: null, position: null,
alwaysOnTop: true, alwaysOnTop: true,
@@ -52,11 +62,13 @@ function loadConfig() {
let saved = {}; let saved = {};
try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ } try { saved = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")); } catch { /* first run */ }
const merged = { ...DEFAULT_CONFIG, ...saved }; const merged = { ...DEFAULT_CONFIG, ...saved };
if (!Array.isArray(merged.petSources)) merged.petSources = []; // petSources 只存「使用者自己加的」。隱含來源(執行目錄的 pets、~/.codex/pets)不寫進設定檔,
// 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到) // 否則程式一搬家、或在安裝版與 portable 版之間換來換去,設定檔就會一直累積失效的絕對路徑。
const ownPets = PETS_DIR; const implicit = new Set(IMPLICIT_PET_DIRS.map(dirKey));
merged.petSources = [ownPets, ...merged.petSources.filter((p) => typeof p === "string" && path.resolve(p) !== ownPets && fs.existsSync(p))]; merged.petSources = (Array.isArray(merged.petSources) ? merged.petSources : [])
if (!merged.petSources.includes(DEFAULT_CONFIG.petSources[1]) && fs.existsSync(DEFAULT_CONFIG.petSources[1])) merged.petSources.push(DEFAULT_CONFIG.petSources[1]); .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; if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
merged.scale = Number(merged.scale); merged.scale = Number(merged.scale);
return merged; return merged;
@@ -81,10 +93,31 @@ function appendLog(line) {
// ---------- pets ---------- // ---------- 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() { function discoverPets() {
const found = []; const found = [];
const seen = new Set(); const seen = new Set();
for (const src of config.petSources) { for (const src of petSearchDirs()) {
let entries = []; let entries = [];
try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; } try { entries = fs.readdirSync(src, { withFileTypes: true }); } catch { continue; }
for (const ent of entries) { for (const ent of entries) {
@@ -352,7 +385,7 @@ function buildMenu() {
})), })),
{ type: "separator" }, { type: "separator" },
{ label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } }, { 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(() => { app.whenReady().then(() => {
ensureUserPetsDir();
pets = discoverPets(); pets = discoverPets();
if (pets.length === 0) { 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(); createWindow();
createTray(); createTray();
+29 -7
View File
@@ -12,7 +12,8 @@
"autostart:uninstall": "node scripts/autostart.js uninstall", "autostart:uninstall": "node scripts/autostart.js uninstall",
"autostart:status": "node scripts/autostart.js status", "autostart:status": "node scripts/autostart.js status",
"dist": "electron-builder --win", "dist": "electron-builder --win",
"pack": "electron-builder --win --dir" "pack": "electron-builder --win --dir",
"dist:portable": "electron-builder --win portable"
}, },
"devDependencies": { "devDependencies": {
"electron": "^43.4.1", "electron": "^43.4.1",
@@ -33,12 +34,10 @@
"renderer/**/*", "renderer/**/*",
"lib/**/*", "lib/**/*",
"hook/**/*", "hook/**/*",
"pets/**/*",
"package.json" "package.json"
], ],
"asarUnpack": [ "asarUnpack": [
"hook/**/*", "hook/**/*"
"pets/**/*"
], ],
"win": { "win": {
"target": [ "target": [
@@ -47,10 +46,21 @@
"arch": [ "arch": [
"x64" "x64"
] ]
},
{
"target": "portable",
"arch": [
"x64"
]
},
{
"target": "zip",
"arch": [
"x64"
]
} }
], ],
"icon": "build/icon.ico", "icon": "build/icon.ico"
"artifactName": "${productName}-${version}-setup.${ext}"
}, },
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
@@ -59,7 +69,19 @@
"createDesktopShortcut": true, "createDesktopShortcut": true,
"createStartMenuShortcut": true, "createStartMenuShortcut": true,
"shortcutName": "Claude Pet", "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"
} }
]
} }
} }
+7
View File
@@ -0,0 +1,7 @@
{
"id": "xiao-nian-realistic",
"displayName": "小念・擬真",
"description": "一個阿呆",
"spriteVersionNumber": 2,
"spritesheetPath": "spritesheet.webp"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB