Architecture: Electron + Next.js (Frontend) + Python/FastAPI (Backend) | Goal: Generate a self-contained macOS .dmg installer
| Layer | Technology | Build Tool | Output |
|---|---|---|---|
| Desktop Shell | Electron | electron-builder | .dmg installer |
| Frontend UI | Next.js (static export) | next build → out/ | Embedded in Electron ASAR |
| Backend API | Python / FastAPI / Uvicorn | PyInstaller | Single-file executable dist/main |
| External Dependency | ffmpeg | otool + install_name_tool | Self-contained binary + dylibs |
Build pipeline: build:python → build:next → build:electron → electron-builder --mac
npm run dist:mac
Equivalent to npm run build:all && electron-builder --mac, which executes sequentially:
bash scripts/build-python.sh — PyInstaller packages Python backend + ffmpeg and its dylibsnext build — Static export of frontend to out/tsc -p main/tsconfig.json — Compile Electron main process TypeScriptelectron-builder --mac — Generate .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
Solution: Use macOS built-in tools to generate .icns from PNG:
# 创建 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'File: components/chat/MessageBubble.tsx
Cause: The type union in MessageBubbleProps was missing 'skill', inconsistent with the Message type in stores/chat-store.ts.
Solution: Add the 'skill' type and skillSteps property to MessageBubbleProps.
Contents/Resources/python/main duplicated in x64 and arm64Cause: mac.target.arch in electron-builder.yml was set to universal, requiring merging of both architecture binaries. However, the PyInstaller-generated python/main is not a Mach-O fat binary and cannot be merged.
Solution: Build for the current architecture only (arm64):
# electron-builder.yml
mac:
target:
- target: dmg
arch:
- arm64
ERR_ELECTRON_BUILDER_CANNOT_EXECUTE — hdiutil process failedCause: Leftover files from a previous failed build caused DMG creation conflicts.
Solution: Clean the dist/ directory and rebuild:
rm -rf dist/
npm run dist:mac
Not allowed to load local resource: file:///...app.asar/main/out/index.htmlCause: Electron does not allow loading resources inside ASAR via the file:// protocol by default. The earlier mainWindow.loadFile() had an incorrect path calculation.
Attempt 1: Fix the loadFile path from path.join(__dirname, '..', 'out') to path.join(__dirname, '..', '..', 'out'). Partially worked
Attempt 2: Use the electron-serve library. Introduced new issues
Final solution: Use Electron's native protocol.handle to register an app:// custom protocol: Resolved
// 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://-/')
Cause: The Python code used paths like ~/Downloads/hackchance/ and ~/.hackchance/, triggering macOS TCC (Transparency, Consent, and Control) privacy protection.
Solution: Move all data storage paths to the macOS-recommended application data directory, which can be accessed without permissions:
# 修改前
~/Downloads/hackchance/
~/.hackchance/
# 修改后
~/Library/Application Support/HackChance/
Files affected: 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/Cause: After Next.js static export, SPA routes (e.g. /chat) cannot be correctly mapped to files under the file:// protocol.
Solution: Same approach as Issue 5. The fallback logic in protocol.handle redirects unmatched paths to index.html, enabling SPA routing support. Additionally, webSecurity: false is set to allow the app:// origin to access the Python backend at http://127.0.0.1:
// BrowserWindow 配置
webPreferences: {
webSecurity: false, // 允许 app:// 访问 http://127.0.0.1:PORT
}
Cause: In the PyInstaller-packaged environment, uvicorn.run("main:app", reload=True) cannot properly import the module.
Solution: Pass the app object directly instead of a string, and disable hot reload in the packaged environment:
# python/main.py
is_frozen = getattr(sys, 'frozen', False)
uvicorn.run(app, host='127.0.0.1', port=args.port, reload=not is_frozen)
Also grant execute permissions to the packaged binary in python-manager.ts:
// main/python-manager.ts
if (process.platform !== 'win32') {
try { fs.chmodSync(p, 0o755) } catch { /* ignore */ }
}
Cause: Python 3.14 is a preview release, and PyInstaller does not yet fully support it.
Solution: The build script automatically selects Python 3.12 to create an isolated build virtual environment:
# 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"
Rebuild the development virtual environment with 3.12:
cd python
rm -rf .venv
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
npm run dist only produced python/dist/main but no DMGCause: In the health check at the end of the build script, kill $PID and wait $PID returned non-zero exit codes due to SIGTERM (exit code 143), which was caught by set -e causing the script to exit prematurely.
Solution: Append || true to the kill/wait commands to suppress errors:
kill $PID 2>/dev/null || true
wait $PID 2>/dev/null || true
ASR异常: [Errno 2] No such file or directory: 'ffmpeg'Cause: After PyInstaller packaging, ffmpeg is not in the system PATH, and the code had the command name 'ffmpeg' hardcoded.
Solution: Implement a smart lookup function that searches in priority order:
# 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')
At build time, the system ffmpeg is also copied to python/dist/ and packaged into the app via electron-builder's extraResources.
Goal: Users should not need to install Homebrew or ffmpeg — installing HackChance should be sufficient to use the ASR feature.
otool -L to recursively find ffmpeg and all its non-system dylib dependencies@rpath references — resolve actual files by searching Homebrew lib directoriesinstall_name_tool -change to rewrite all paths to @executable_path/lib/codesign --force --sign - (required on Apple Silicon after modifying binaries)DYLD_LIBRARY_PATH when running ffmpeg as a safety netlocal -A (associative arrays) — macOS ships with bash 3.2. Replaced with file-based detection ([ -f "$LIB_DIR/$dep_name" ]) instead.@rpath references not handled — libwebp.7.dylib internally references @rpath/libsharpyuv.0.dylib, and the initial script skipped paths starting with @. Added a resolve_rpath_lib function to search for actual files in Homebrew directories.# 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/ directory is approximately 74 MB; dist/ totals approximately 97 MB./ffmpeg -version runs independently ✅@executable_path/lib/ with no remaining Homebrew paths ✅| File | Purpose |
|---|---|
scripts/build-python.sh | Python backend + ffmpeg build script (includes bundle_ffmpeg) |
python/main.spec | PyInstaller configuration (hiddenimports list) |
python/main.py | FastAPI entry point (frozen environment compatible) |
python/services/asr.py | ASR service (includes _find_ffmpeg smart lookup) |
main/main.ts | Electron main process (app:// protocol, Python startup) |
main/python-manager.ts | Python process manager (path resolution, port allocation) |
electron-builder.yml | electron-builder configuration (extraResources, arch) |
next.config.mjs | Next.js configuration (static export, trailingSlash) |
package.json | Build script definitions (dist:mac, build:all, etc.) |
resources/icon.icns | macOS application icon |
Generated: 2025-04-09 | Based on actual debugging records during development