用 electron-builder 打包成 Windows exe

摘要:
加上 electron-builder 設定,`npm run dist` 產出 NSIS 安裝檔與免安裝版,並修正打包後才會出現的路徑問題。

根本原因:
原本三處路徑都假設程式是攤在資料夾裡跑的,打包成 asar 後會壞:
1. hook 腳本路徑會落在 app.asar 內,而 Claude Code 是用 `node <路徑>` 執行它,node 讀不到 asar 裡的檔案,hook 會直接失敗。
2. pets 資料夾同樣在 asar 內,使用者無法自己丟寵物進去,選單「開啟寵物資料夾」也開不起來。
3. 開機自動啟動寫入的是 `electron.exe + 專案路徑`,打包後沒有 node_modules,登錄值會指向不存在的檔案。

影響:
沒有可散佈的執行檔;直接打包的話 hook、換寵物、開機啟動三個功能都會壞掉。

修法:
- 新增 lib/paths.js:unpacked() 把 app.asar 換成 app.asar.unpacked,isPackaged() 以 process.defaultApp 判斷是否為打包版。
- build.asarUnpack 把 hook/ 與 pets/ 解到 asar 外,hooks-installer 與 main.js 的 PETS_DIR 都改走 unpacked()。
- autostart 打包後改用 process.execPath,並跳過開發版才需要的 electron.exe 存在檢查。
- NSIS 設為 per-user、可改安裝路徑、建立桌面與開始選單捷徑,解除安裝不刪 ~/.claude-pet。
- 新增 build/icon.ico(16–256,七種尺寸),取自小念 spritesheet 第 0 列第 6 欄的頭肩方形裁切。
- dist/ 加入 .gitignore;README 補上「打包成 exe」一節與安裝檔的使用方式。

驗證:
以 ELECTRON_RUN_AS_NODE 執行打包後的 exe(不開視窗)載入打包內的 lib,確認 isPackaged 為 true、hook 路徑指到 app.asar.unpacked 且檔案存在、autostart 指到 exe 本身、pets 掃得到 xiao-nian、renderer 讀得到 asar 內的 index.html;另確認 exe 版本資訊與七種尺寸圖示皆已嵌入。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:03:11 +08:00
co-authored by Claude Fable 5
parent a014f9a279
commit 56fd8448ba
9 changed files with 3545 additions and 10 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/ node_modules/
dist/
*.log *.log
+37 -2
View File
@@ -21,6 +21,7 @@
- [設定檔](#設定檔) - [設定檔](#設定檔)
- [架構與檔案](#架構與檔案) - [架構與檔案](#架構與檔案)
- [HTTP API](#http-api) - [HTTP API](#http-api)
- [打包成 exe](#打包成-exe)
- [疑難排解](#疑難排解) - [疑難排解](#疑難排解)
- [移除](#移除) - [移除](#移除)
@@ -50,6 +51,12 @@
## 快速開始 ## 快速開始
### 用安裝檔(一般使用)
執行 `dist\Claude Pet-<版本>-setup.exe`(見[打包成 exe](#打包成-exe)),裝在 `%LOCALAPPDATA%\Programs\Claude Pet`,不需要系統管理員權限。裝完從開始選單開啟,再用系統匣選單的 **Claude Code hooks → 安裝** 接上 Claude Code。
### 從原始碼跑(開發)
```powershell ```powershell
cd E:\Project\Test\AI\Claude\claude-pet cd E:\Project\Test\AI\Claude\claude-pet
npm install npm install
@@ -200,10 +207,12 @@ Claude Code ──hookstdin JSON)──▶ hook/claude-pet-hook.js ──PO
| `renderer/index.html``style.css` | 版面與氣泡樣式(CSP 僅允許 self / data: | | `renderer/index.html``style.css` | 版面與氣泡樣式(CSP 僅允許 self / data: |
| `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` 登錄值 | | `lib/autostart.js` | `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClaudePet` 登錄值;開發時指向 `electron.exe + 專案路徑`,打包後直接指向那顆 exe |
| `lib/paths.js` | 開發/打包兩種情境的路徑解析(`app.asar``app.asar.unpacked`、是否為打包版) |
| `scripts/*.js` | 上述兩者的 CLI 包裝 | | `scripts/*.js` | 上述兩者的 CLI 包裝 |
| `pets/` | 內附寵物 | | `pets/` | 內附寵物 |
| `docs/` | README 用圖 | | `docs/` | README 用圖 |
| `build/icon.ico` | exe 與安裝檔的圖示,取自小念 spritesheet 第 0 列第 6 欄(中立正面)的頭肩方形裁切 |
設計重點: 設計重點:
@@ -257,6 +266,30 @@ curl http://127.0.0.1:17333/state
也可以送 `{"event":"__test","anim":"waiting"}``idle|running|waiting|review|failed|jumping|waving|clear`)直接指定動畫,與選單「動作測試」相同。 也可以送 `{"event":"__test","anim":"waiting"}``idle|running|waiting|review|failed|jumping|waving|clear`)直接指定動畫,與選單「動作測試」相同。
## 打包成 exe
```powershell
npm run dist # 安裝檔 + 免安裝版
npm run pack # 只產免安裝版,快很多,開發時驗證用
```
產物在 `dist\`
| 檔案 | 說明 |
|---|---|
| `Claude Pet-<版本>-setup.exe` | NSIS 安裝檔(約 100 MB)。per-user 安裝、不需要系統管理員權限,預設裝到 `%LOCALAPPDATA%\Programs\Claude Pet`,可自選路徑,會建立桌面與開始選單捷徑 |
| `win-unpacked\Claude Pet.exe` | 免安裝版,整個資料夾複製走就能直接執行 |
第一次建置時 electron-builder 會從 GitHub 抓 NSIS 與相關工具到 `%LOCALAPPDATA%\electron-builder\Cache`,需要網路,之後就會走快取。
**打包後和原始碼跑有三個地方不一樣**(都已經處理好,改程式時要留意):
- **`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`)時會自動加引號。
換圖示就換掉 `build/icon.ico`(要含 256×256)。目前這顆是從 `pets/xiao-nian/spritesheet.webp` 第 0 列第 6 欄(中立正面)取頭肩方形裁切產生的。
## 疑難排解 ## 疑難排解
### Electron 裝完沒有 `electron.exe` ### Electron 裝完沒有 `electron.exe`
@@ -290,12 +323,14 @@ npm config set allow-scripts=@anthropic-ai/claude-code,electron --location=user
## 移除 ## 移除
先在系統匣選單關掉 **開機自動啟動**、並用 **Claude Code hooks → 移除** 拆掉 hooks,然後結束程式。從原始碼跑的話也可以下指令:
```powershell ```powershell
npm run hooks:uninstall # 從 ~/.claude/settings.json 移除所有 Claude Pet hook(先備份) npm run hooks:uninstall # 從 ~/.claude/settings.json 移除所有 Claude Pet hook(先備份)
npm run autostart:uninstall # 移除開機啟動登錄值 npm run autostart:uninstall # 移除開機啟動登錄值
``` ```
後刪掉專案資料夾與 `%USERPROFILE%\.claude-pet\` 即可。 裝過安裝檔的話,到「設定 → 應用程式 → 已安裝的應用程式」移除 **Claude Pet**(解除安裝不會動到 `%USERPROFILE%\.claude-pet\`)。最後刪掉專案資料夾與 `%USERPROFILE%\.claude-pet\` 即可。
--- ---
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

+4 -1
View File
@@ -3,6 +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 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";
@@ -13,6 +14,8 @@ function electronExe() {
} }
function command() { function command() {
// 打包後就是那顆 exe 本身;開發時要用 node_modules 裡的 electron 帶上專案路徑
if (isPackaged()) return `"${process.execPath}"`;
return `"${electronExe()}" "${APP_DIR}"`; return `"${electronExe()}" "${APP_DIR}"`;
} }
@@ -35,7 +38,7 @@ function currentValue() {
function install() { function install() {
if (process.platform !== "win32") throw new Error("只支援 Windows"); if (process.platform !== "win32") throw new Error("只支援 Windows");
if (!fs.existsSync(electronExe())) throw new Error(`找不到 Electron${electronExe()}(先執行 npm install`); if (!isPackaged() && !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"]); 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 失敗"); if (!r.ok) throw new Error(r.stderr || r.stdout || "reg add 失敗");
return command(); return command();
+3 -1
View File
@@ -3,12 +3,14 @@
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 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");
const HOOK_SCRIPT = path.join(APP_DIR, "hook", "claude-pet-hook.js"); // node 執行不了 asar 內的檔案,打包後要指到解開的那份
const HOOK_SCRIPT = unpacked(path.join(APP_DIR, "hook", "claude-pet-hook.js"));
const MARKER = "claude-pet-hook"; const MARKER = "claude-pet-hook";
// Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態) // Claude Code 2.1.x 支援的 hook 事件(對應到寵物狀態)
+21
View File
@@ -0,0 +1,21 @@
"use strict";
// 開發時直接跑資料夾;打包後程式碼會在 resources/app.asar 裡面。
// 有些東西不能待在 asar 內:
// - hook/claude-pet-hook.jsClaude Code 會用 `node <路徑>` 執行它,node 讀不到 asar 內的檔案
// - pets/:使用者要能自己丟寵物進去,選單也要能開這個資料夾
// 這兩個在 build 設定裡以 asarUnpack 解開到 resources/app.asar.unpacked,路徑要跟著換過去。
const path = require("node:path");
const PACKED = `${path.sep}app.asar${path.sep}`;
const UNPACKED = `${path.sep}app.asar.unpacked${path.sep}`;
function unpacked(p) {
return p.includes(PACKED) ? p.replace(PACKED, UNPACKED) : p;
}
// 在 Electron 裡跑,而且不是 `electron .` 的開發模式(開發模式 process.defaultApp 為 true
function isPackaged() {
return Boolean(process.versions.electron) && !process.defaultApp;
}
module.exports = { unpacked, isPackaged };
+6 -3
View File
@@ -6,8 +6,11 @@ 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 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");
@@ -26,7 +29,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: [path.join(APP_DIR, "pets"), path.join(HOME, ".codex", "pets")], petSources: [PETS_DIR, path.join(HOME, ".codex", "pets")],
scale: 1, scale: 1,
position: null, position: null,
alwaysOnTop: true, alwaysOnTop: true,
@@ -51,7 +54,7 @@ function loadConfig() {
const merged = { ...DEFAULT_CONFIG, ...saved }; const merged = { ...DEFAULT_CONFIG, ...saved };
if (!Array.isArray(merged.petSources)) merged.petSources = []; if (!Array.isArray(merged.petSources)) merged.petSources = [];
// 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到) // 永遠包含 app 自己的 pets 資料夾(就算專案搬家了也找得到)
const ownPets = path.join(APP_DIR, "pets"); const ownPets = PETS_DIR;
merged.petSources = [ownPets, ...merged.petSources.filter((p) => typeof p === "string" && path.resolve(p) !== ownPets && fs.existsSync(p))]; 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 (!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; if (!SCALES.includes(Number(merged.scale))) merged.scale = 1;
@@ -349,7 +352,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(path.join(APP_DIR, "pets")) }, { label: "開啟寵物資料夾", click: () => shell.openPath(PETS_DIR) },
], ],
}, },
{ {
+3424 -1
View File
File diff suppressed because it is too large Load Diff
+49 -2
View File
@@ -10,9 +10,56 @@
"hooks:uninstall": "node scripts/uninstall-hooks.js", "hooks:uninstall": "node scripts/uninstall-hooks.js",
"autostart:install": "node scripts/autostart.js install", "autostart:install": "node scripts/autostart.js install",
"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",
"pack": "electron-builder --win --dir"
}, },
"devDependencies": { "devDependencies": {
"electron": "^43.4.1" "electron": "^43.4.1",
"electron-builder": "^26.0.12"
},
"author": "JianMiau",
"build": {
"appId": "com.jianmiau.claude-pet",
"productName": "Claude Pet",
"copyright": "JianMiau",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"preload.js",
"renderer/**/*",
"lib/**/*",
"hook/**/*",
"pets/**/*",
"package.json"
],
"asarUnpack": [
"hook/**/*",
"pets/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
}
],
"icon": "build/icon.ico",
"artifactName": "${productName}-${version}-setup.${ext}"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "Claude Pet",
"deleteAppDataOnUninstall": false
}
} }
} }