用 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
+6 -3
View File
@@ -6,8 +6,11 @@ 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 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");
@@ -26,7 +29,7 @@ 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")],
petSources: [PETS_DIR, path.join(HOME, ".codex", "pets")],
scale: 1,
position: null,
alwaysOnTop: true,
@@ -51,7 +54,7 @@ function loadConfig() {
const merged = { ...DEFAULT_CONFIG, ...saved };
if (!Array.isArray(merged.petSources)) merged.petSources = [];
// 永遠包含 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))];
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;
@@ -349,7 +352,7 @@ function buildMenu() {
})),
{ type: "separator" },
{ label: "重新掃描", click: () => { pets = discoverPets(); if (!activePet()) return; refreshTray(); } },
{ label: "開啟寵物資料夾", click: () => shell.openPath(path.join(APP_DIR, "pets")) },
{ label: "開啟寵物資料夾", click: () => shell.openPath(PETS_DIR) },
],
},
{