HackChance macOS DMG 打包记录

项目架构:Electron + Next.js (前端) + Python/FastAPI (后端)  |  目标:生成自包含的 macOS .dmg 安装包

目录

  1. 项目架构与打包方案
  2. 打包命令速查
  3. 问题 1:创建应用图标 icon.icns
  4. 问题 2:TypeScript 类型不匹配导致 Next.js 构建失败
  5. 问题 3:electron-builder universal 架构合并失败
  6. 问题 4:hdiutil 创建 DMG 失败
  7. 问题 5:打包后无法加载本地资源 (file:// 协议)
  8. 问题 6:macOS 隐私弹窗(访问下载、文档等文件夹)
  9. 问题 7:前端路由 file:///chat/ 加载失败
  10. 问题 8:Python 后端未启动 (Failed to fetch)
  11. 问题 9:Python 3.14 与 PyInstaller 不兼容
  12. 问题 10:build-python.sh 中 set -e 导致构建中断
  13. 问题 11:ASR 找不到 ffmpeg
  14. 问题 12:让 ffmpeg 完全自包含(含所有 dylib)
  15. 关键文件清单

1. 项目架构与打包方案

技术打包工具输出
桌面外壳Electronelectron-builder.dmg 安装包
前端 UINext.js (静态导出)next buildout/嵌入 Electron ASAR
后端 APIPython / FastAPI / UvicornPyInstaller单文件可执行 dist/main
外部依赖ffmpegotool + install_name_tool自包含二进制 + dylib

打包流程:build:pythonbuild:nextbuild:electronelectron-builder --mac

2. 打包命令速查

完整打包

npm run dist:mac

等同于 npm run build:all && electron-builder --mac,会依次执行:

  1. bash scripts/build-python.sh — PyInstaller 打包 Python 后端 + ffmpeg 及其 dylib
  2. next build — 静态导出前端到 out/
  3. tsc -p main/tsconfig.json — 编译 Electron 主进程 TS
  4. electron-builder --mac — 生成 .dmg

仅打包 Python 后端

npm 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

问题 1:创建应用图标 icon.icns

错误 electron-builder 找不到图标文件

解决:使用 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

问题 2:TypeScript 类型不匹配

错误 Type '"skill"' is not assignable to type '"search" | "text" | "download" | "asr" | undefined'

文件:components/chat/MessageBubble.tsx

原因:MessageBubblePropstype 联合类型缺少 'skill',与 stores/chat-store.ts 中的 Message 类型不一致。

解决:MessageBubbleProps 中添加 'skill' 类型和 skillSteps 属性。

问题 3:electron-builder universal 架构合并失败

错误 Contents/Resources/python/main 在 x64 和 arm64 中重复

原因:electron-builder.ymlmac.target.arch 设置为 universal,需要同时合并两个架构的二进制。但 PyInstaller 生成的 python/main 不是 Mach-O fat binary,无法合并。

解决:只构建当前架构(arm64):

# electron-builder.yml
mac:
  target:
    - target: dmg
      arch:
        - arm64

问题 4:hdiutil 创建 DMG 失败

错误 ERR_ELECTRON_BUILDER_CANNOT_EXECUTE — hdiutil process failed

原因:之前构建失败留下的残留文件导致 DMG 创建冲突。

解决:清理 dist/ 目录后重新构建:

rm -rf dist/
npm run dist:mac

问题 5:打包后无法加载本地资源

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

问题 6:macOS 隐私弹窗

现象 打开应用后不断提示"想访问下载文件夹""文档文件夹""照片文件夹"等

原因: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

问题 7:前端路由加载失败

错误 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
}

问题 8:Python 后端未启动

错误 请求失败:Failed to fetch / net::ERR_CONNECTION_REFUSED

原因: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 */ }
}

问题 9:Python 3.14 兼容性

风险 开发环境使用 Python 3.14,PyInstaller 可能不兼容

原因: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

问题 10:构建脚本被 set -e 中断

现象 npm run dist 只生成了 python/dist/main,没有产生 DMG

原因:构建脚本末尾的健康检查中,kill $PIDwait $PID 因 SIGTERM (退出码 143) 返回非零值,被 set -e 捕获导致脚本提前退出。

解决:对 kill/wait 命令添加 || true 抑制错误:

kill $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

问题 11:ASR 找不到 ffmpeg

错误 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-builderextraResources 打包进应用。

问题 12:让 ffmpeg 完全自包含(含所有 dylib)

问题 Homebrew 的 ffmpeg 是动态链接的,无 Homebrew 的机器无法运行

目标:用户无需安装 Homebrew 和 ffmpeg,安装 HackChance 即可直接使用 ASR 功能。

思路

  1. otool -L 递归查找 ffmpeg 及其所有非系统 dylib 依赖
  2. 处理 @rpath 引用 — 通过搜索 Homebrew lib 目录解析实际文件
  3. install_name_tool -change 将所有路径重写为 @executable_path/lib/
  4. codesign --force --sign - 重新签名(Apple Silicon 修改二进制后必需)
  5. 运行 ffmpeg 时额外设置 DYLD_LIBRARY_PATH 双重保障

遇到的子问题

最终脚本核心逻辑

# 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 端调用 ffmpeg 时设置 DYLD_LIBRARY_PATH

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

结果

关键文件清单

文件用途
scripts/build-python.shPython 后端 + ffmpeg 打包脚本(含 bundle_ffmpeg
python/main.specPyInstaller 配置(hiddenimports 列表)
python/main.pyFastAPI 入口(兼容 frozen 环境)
python/services/asr.pyASR 服务(含 _find_ffmpeg 智能查找)
main/main.tsElectron 主进程(app:// 协议、Python 启动)
main/python-manager.tsPython 进程管理(路径解析、端口分配)
electron-builder.ymlelectron-builder 配置(extraResources、arch)
next.config.mjsNext.js 配置(静态导出、trailingSlash)
package.json构建脚本定义(dist:mac, build:all 等)
resources/icon.icnsmacOS 应用图标

生成时间:2025-04-09  |  基于开发过程中的实际调试记录