项目架构:Electron + Next.js (前端) + Python/FastAPI (后端) | 目标:生成自包含的 macOS .dmg 安装包
| 层 | 技术 | 打包工具 | 输出 |
|---|---|---|---|
| 桌面外壳 | Electron | electron-builder | .dmg 安装包 |
| 前端 UI | Next.js (静态导出) | next build → out/ | 嵌入 Electron ASAR |
| 后端 API | Python / FastAPI / Uvicorn | PyInstaller | 单文件可执行 dist/main |
| 外部依赖 | ffmpeg | otool + install_name_tool | 自包含二进制 + dylib |
打包流程:build:python → build:next → build:electron → electron-builder --mac
npm run dist:mac
等同于 npm run build:all && electron-builder --mac,会依次执行:
bash scripts/build-python.sh — PyInstaller 打包 Python 后端 + ffmpeg 及其 dylibnext build — 静态导出前端到 out/tsc -p main/tsconfig.json — 编译 Electron 主进程 TSelectron-builder --mac — 生成 .dmgnpm run build:python
# 或直接:bash scripts/build-python.sh
rm -rf dist/ python/dist/ python/build/ python/.venv-build/
npm run dist:mac
# 终端 1:前端 + Electron
npm run dev
# 终端 2:Python 后端
cd python && python3 main.py --port 8000
解决:使用 macOS 自带工具从 PNG 生成 .icns:
# 创建 iconset 目录,生成所有尺寸
mkdir icon.iconset
sips -z 16 16 icon.png --out icon.iconset/icon_16x16.png
sips -z 32 32 icon.png --out icon.iconset/icon_16x16@2x.png
sips -z 128 128 icon.png --out icon.iconset/icon_128x128.png
sips -z 256 256 icon.png --out icon.iconset/icon_128x128@2x.png
sips -z 256 256 icon.png --out icon.iconset/icon_256x256.png
sips -z 512 512 icon.png --out icon.iconset/icon_256x256@2x.png
sips -z 512 512 icon.png --out icon.iconset/icon_512x512.png
sips -z 1024 1024 icon.png --out icon.iconset/icon_512x512@2x.png
# 转换为 .icns
iconutil -c icns icon.iconset -o resources/icon.icns
Type '"skill"' is not assignable to type '"search" | "text" | "download" | "asr" | undefined'文件:components/chat/MessageBubble.tsx
原因:MessageBubbleProps 的 type 联合类型缺少 'skill',与 stores/chat-store.ts 中的 Message 类型不一致。
解决:在 MessageBubbleProps 中添加 'skill' 类型和 skillSteps 属性。
Contents/Resources/python/main 在 x64 和 arm64 中重复原因:electron-builder.yml 中 mac.target.arch 设置为 universal,需要同时合并两个架构的二进制。但 PyInstaller 生成的 python/main 不是 Mach-O fat binary,无法合并。
解决:只构建当前架构(arm64):
# electron-builder.yml
mac:
target:
- target: dmg
arch:
- arm64
ERR_ELECTRON_BUILDER_CANNOT_EXECUTE — hdiutil process failed原因:之前构建失败留下的残留文件导致 DMG 创建冲突。
解决:清理 dist/ 目录后重新构建:
rm -rf dist/
npm run dist:mac
Not allowed to load local resource: file:///...app.asar/main/out/index.html原因:Electron 默认不允许通过 file:// 协议加载 ASAR 内的资源。早期使用 mainWindow.loadFile() 路径计算错误。
尝试 1:修正 loadFile 路径,从 path.join(__dirname, '..', 'out') 改为 path.join(__dirname, '..', '..', 'out')。部分有效
尝试 2:使用 electron-serve 库。引入新问题
最终方案:使用 Electron 原生 protocol.handle 注册 app:// 自定义协议:解决
// main/main.ts
protocol.registerSchemesAsPrivileged([{
scheme: 'app',
privileges: { standard: true, secure: true, supportFetchAPI: true, corsEnabled: true }
}])
// app.whenReady() 中注册 handler
protocol.handle('app', (request) => {
const url = new URL(request.url)
let filePath = decodeURIComponent(url.pathname)
// ... 文件查找逻辑(含 index.html fallback)
return net.fetch(pathToFileURL(fullPath).toString())
})
// 加载页面
mainWindow.loadURL('app://-/')
原因:Python 代码中使用了 ~/Downloads/hackchance/ 和 ~/.hackchance/ 等路径,触发了 macOS TCC(Transparency, Consent, and Control)隐私保护。
解决:将所有数据存储路径改为 macOS 推荐的应用数据目录,无需权限即可访问:
# 修改前
~/Downloads/hackchance/
~/.hackchance/
# 修改后
~/Library/Application Support/HackChance/
涉及文件:database.py, routers/video.py, douyin_downloader.py, xiaohongshu_downloader.py, api_pattern_store.py, browser/manager.py
Not allowed to load local resource: file:///chat/原因:Next.js 静态导出后,SPA 路由(如 /chat)在 file:// 协议下无法正确映射到文件。
解决:与问题 5 同一方案。protocol.handle 的 fallback 逻辑会将未匹配的路径回退到 index.html,实现了 SPA 路由支持。同时设置 webSecurity: false 允许 app:// 域访问 http://127.0.0.1 的 Python 后端:
// BrowserWindow 配置
webPreferences: {
webSecurity: false, // 允许 app:// 访问 http://127.0.0.1:PORT
}
原因:PyInstaller 打包后的环境中,uvicorn.run("main:app", reload=True) 无法正确导入模块。
解决:直接传递 app 对象而非字符串,并在打包环境中禁用热重载:
# python/main.py
is_frozen = getattr(sys, 'frozen', False)
uvicorn.run(app, host='127.0.0.1', port=args.port, reload=not is_frozen)
同时在 python-manager.ts 中为打包后的二进制添加执行权限:
// main/python-manager.ts
if (process.platform !== 'win32') {
try { fs.chmodSync(p, 0o755) } catch { /* ignore */ }
}
原因:Python 3.14 是预览版本,PyInstaller 尚未完全支持。
解决:构建脚本自动选择 Python 3.12 创建独立的构建虚拟环境:
# scripts/build-python.sh
for candidate in python3.12 python3.11 python3.13 python3.10; do
if command -v "$candidate" &>/dev/null; then
PYTHON_BIN="$candidate"
break
fi
done
# 创建独立构建环境(不影响开发 .venv)
VENV_DIR="$PYTHON_DIR/.venv-build"
$PYTHON_BIN -m venv "$VENV_DIR"
开发虚拟环境重建为 3.12:
cd python
rm -rf .venv
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
npm run dist 只生成了 python/dist/main,没有产生 DMG原因:构建脚本末尾的健康检查中,kill $PID 和 wait $PID 因 SIGTERM (退出码 143) 返回非零值,被 set -e 捕获导致脚本提前退出。
解决:对 kill/wait 命令添加 || true 抑制错误:
kill $PID 2>/dev/null || true
wait $PID 2>/dev/null || true
ASR异常: [Errno 2] No such file or directory: 'ffmpeg'原因:PyInstaller 打包后,系统 PATH 中没有 ffmpeg,且代码中硬编码了 'ffmpeg' 命令名。
解决:实现智能查找函数,按优先级依次搜索:
# python/services/asr.py
def _find_ffmpeg() -> str:
candidates = []
# 1. 环境变量(Electron 主进程传入)
if os.environ.get('FFMPEG_PATH'):
candidates.append(os.environ['FFMPEG_PATH'])
# 2. 内置版本(与 PyInstaller 可执行文件同目录)
if getattr(sys, 'frozen', False):
candidates.append(os.path.join(os.path.dirname(sys.executable), 'ffmpeg'))
# 3. 常见 Homebrew 路径
candidates += ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for p in candidates:
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
# 4. 系统 PATH
found = shutil.which('ffmpeg')
if found:
return found
raise FileNotFoundError('ffmpeg 未找到。请安装: brew install ffmpeg')
同时在构建时将系统 ffmpeg 复制到 python/dist/,由 electron-builder 的 extraResources 打包进应用。
目标:用户无需安装 Homebrew 和 ffmpeg,安装 HackChance 即可直接使用 ASR 功能。
otool -L 递归查找 ffmpeg 及其所有非系统 dylib 依赖@rpath 引用 — 通过搜索 Homebrew lib 目录解析实际文件install_name_tool -change 将所有路径重写为 @executable_path/lib/codesign --force --sign - 重新签名(Apple Silicon 修改二进制后必需)DYLD_LIBRARY_PATH 双重保障local -A(关联数组) — macOS 自带 bash 是 3.2 版。改用文件检测([ -f "$LIB_DIR/$dep_name" ])代替。@rpath 引用未处理 — libwebp.7.dylib 内部引用 @rpath/libsharpyuv.0.dylib,初版脚本跳过了 @ 开头的路径。添加 resolve_rpath_lib 函数在 Homebrew 目录中搜索实际文件。# scripts/build-python.sh 中的 bundle_ffmpeg 函数
# 解析 @rpath 引用
resolve_rpath_lib() {
local lib_name="$1"
for dir in /opt/homebrew/lib /usr/local/lib /opt/homebrew/opt/*/lib; do
[ -f "$dir/$lib_name" ] && echo "$dir/$lib_name" && return 0
done
return 1
}
bundle_ffmpeg() {
# 1. 复制 ffmpeg 到 dist/
# 2. BFS 递归收集所有非系统 dylib 到 dist/lib/
# - 绝对路径 /opt/homebrew/... → 直接复制
# - @rpath/libXYZ.dylib → resolve_rpath_lib 查找
# - /usr/lib /System → 跳过(系统库)
# 3. install_name_tool 重写所有引用为 @executable_path/lib/
# 4. codesign 重新签名所有二进制
}
# python/services/asr.py — _convert_to_mp3()
env = os.environ.copy()
lib_dir = os.path.join(os.path.dirname(ffmpeg), 'lib')
if os.path.isdir(lib_dir):
env['DYLD_LIBRARY_PATH'] = lib_dir
result = subprocess.run(cmd, ..., env=env)
lib/ 目录约 74 MB,dist/ 总计约 97 MB./ffmpeg -version 可独立运行 ✅@executable_path/lib/,无残留 Homebrew 路径 ✅| 文件 | 用途 |
|---|---|
scripts/build-python.sh | Python 后端 + ffmpeg 打包脚本(含 bundle_ffmpeg) |
python/main.spec | PyInstaller 配置(hiddenimports 列表) |
python/main.py | FastAPI 入口(兼容 frozen 环境) |
python/services/asr.py | ASR 服务(含 _find_ffmpeg 智能查找) |
main/main.ts | Electron 主进程(app:// 协议、Python 启动) |
main/python-manager.ts | Python 进程管理(路径解析、端口分配) |
electron-builder.yml | electron-builder 配置(extraResources、arch) |
next.config.mjs | Next.js 配置(静态导出、trailingSlash) |
package.json | 构建脚本定义(dist:mac, build:all 等) |
resources/icon.icns | macOS 应用图标 |
生成时间:2025-04-09 | 基于开发过程中的实际调试记录