用 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:
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
dist/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -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 ──hook(stdin 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 → 擷取欄位 → POST;800 ms 逾時、永遠 exit 0 |
|
| `hook/claude-pet-hook.js` | Claude Code hook 端:讀 stdin → 擷取欄位 → POST;800 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\` 即可。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 117 KiB |
+4
-1
@@ -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,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 事件(對應到寵物狀態)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use strict";
|
||||||
|
// 開發時直接跑資料夾;打包後程式碼會在 resources/app.asar 裡面。
|
||||||
|
// 有些東西不能待在 asar 內:
|
||||||
|
// - hook/claude-pet-hook.js:Claude 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,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) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+3424
-1
File diff suppressed because it is too large
Load Diff
+49
-2
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user