HackChance macOS DMG Build Notes

Architecture: Electron + Next.js (Frontend) + Python/FastAPI (Backend)  |  Goal: Generate a self-contained macOS .dmg installer

Table of Contents

  1. Project Architecture & Build Strategy
  2. Build Commands Quick Reference
  3. Issue 1: Creating the App Icon icon.icns
  4. Issue 2: TypeScript Type Mismatch Causing Next.js Build Failure
  5. Issue 3: electron-builder Universal Architecture Merge Failure
  6. Issue 4: hdiutil DMG Creation Failure
  7. Issue 5: Unable to Load Local Resources After Packaging (file:// Protocol)
  8. Issue 6: macOS Privacy Prompts (Accessing Downloads, Documents, etc.)
  9. Issue 7: Frontend Route file:///chat/ Fails to Load
  10. Issue 8: Python Backend Not Starting (Failed to fetch)
  11. Issue 9: Python 3.14 Incompatible with PyInstaller
  12. Issue 10: set -e in build-python.sh Interrupts Build
  13. Issue 11: ASR Cannot Find ffmpeg
  14. Issue 12: Making ffmpeg Fully Self-Contained (Including All dylibs)
  15. Key Files Reference

1. Project Architecture & Build Strategy

LayerTechnologyBuild ToolOutput
Desktop ShellElectronelectron-builder.dmg installer
Frontend UINext.js (static export)next buildout/Embedded in Electron ASAR
Backend APIPython / FastAPI / UvicornPyInstallerSingle-file executable dist/main
External Dependencyffmpegotool + install_name_toolSelf-contained binary + dylibs

Build pipeline: build:pythonbuild:nextbuild:electronelectron-builder --mac

2. Build Commands Quick Reference

Full Build

npm run dist:mac

Equivalent to npm run build:all && electron-builder --mac, which executes sequentially:

  1. bash scripts/build-python.sh — PyInstaller packages Python backend + ffmpeg and its dylibs
  2. next build — Static export of frontend to out/
  3. tsc -p main/tsconfig.json — Compile Electron main process TypeScript
  4. electron-builder --mac — Generate .dmg

Python Backend Only

npm run build:python
# 或直接:bash scripts/build-python.sh

Clean Rebuild

rm -rf dist/ python/dist/ python/build/ python/.venv-build/
npm run dist:mac

Development Mode

# 终端 1:前端 + Electron
npm run dev

# 终端 2:Python 后端
cd python && python3 main.py --port 8000

Issue 1: Creating the App Icon icon.icns

Error electron-builder cannot find the icon file

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

Issue 2: TypeScript Type Mismatch

Error 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.

Issue 3: electron-builder Universal Architecture Merge Failure

Error Contents/Resources/python/main duplicated in x64 and arm64

Cause: 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

Issue 4: hdiutil DMG Creation Failure

Error ERR_ELECTRON_BUILDER_CANNOT_EXECUTE — hdiutil process failed

Cause: 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

Issue 5: Unable to Load Local Resources After Packaging

Error Not allowed to load local resource: file:///...app.asar/main/out/index.html

Cause: 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://-/')

Issue 6: macOS Privacy Prompts

Symptom After launching the app, repeated prompts appear asking to access Downloads, Documents, Photos folders, etc.

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

Issue 7: Frontend Route Fails to Load

Error 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
}

Issue 8: Python Backend Not Starting

Error Request failed: Failed to fetch / net::ERR_CONNECTION_REFUSED

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 */ }
}

Issue 9: Python 3.14 Compatibility

Risk Development environment uses Python 3.14; PyInstaller may not be compatible

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

Issue 10: Build Script Interrupted by set -e

Symptom npm run dist only produced python/dist/main but no DMG

Cause: 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

Issue 11: ASR Cannot Find ffmpeg

Error 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.

Issue 12: Making ffmpeg Fully Self-Contained (Including All dylibs)

Problem Homebrew's ffmpeg is dynamically linked; machines without Homebrew cannot run it

Goal: Users should not need to install Homebrew or ffmpeg — installing HackChance should be sufficient to use the ASR feature.

Approach

  1. Use otool -L to recursively find ffmpeg and all its non-system dylib dependencies
  2. Handle @rpath references — resolve actual files by searching Homebrew lib directories
  3. Use install_name_tool -change to rewrite all paths to @executable_path/lib/
  4. Re-sign with codesign --force --sign - (required on Apple Silicon after modifying binaries)
  5. Additionally set DYLD_LIBRARY_PATH when running ffmpeg as a safety net

Sub-Issues Encountered

Final Script Core Logic

# 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 重新签名所有二进制
}

Setting DYLD_LIBRARY_PATH When Calling ffmpeg from Python

# 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)

Results

Key Files Reference

FilePurpose
scripts/build-python.shPython backend + ffmpeg build script (includes bundle_ffmpeg)
python/main.specPyInstaller configuration (hiddenimports list)
python/main.pyFastAPI entry point (frozen environment compatible)
python/services/asr.pyASR service (includes _find_ffmpeg smart lookup)
main/main.tsElectron main process (app:// protocol, Python startup)
main/python-manager.tsPython process manager (path resolution, port allocation)
electron-builder.ymlelectron-builder configuration (extraResources, arch)
next.config.mjsNext.js configuration (static export, trailingSlash)
package.jsonBuild script definitions (dist:mac, build:all, etc.)
resources/icon.icnsmacOS application icon

Generated: 2025-04-09  |  Based on actual debugging records during development