macOS 使用 dmgbuild 打包 DMG

本文介绍如何使用 dmgbuild 替代脆弱的 appdmg,实现稳定可靠的 macOS DMG 自动化打包与 CI 集成。


在 macOS 平台的桌面应用分发中(无论是基于 Go、Tauri、Electron、Flutter 还是原生 Swift 开发),提供一个精致的 .dmg(Apple Disk Image)挂载镜像,通常是交付用户的首选标准方案。

一个体验优良的 DMG 镜像通常具备以下特征:

  • 挂载后桌面显示定制的磁盘卷图标(Volume Icon);
  • 打开后呈现固定尺寸的引导窗口与高保真背景图;
  • 窗口内左侧放置应用程序主体(.app),右侧放置系统 /Applications 目录的快捷替身(Symlink),引导用户直接拖拽完成安装;
  • 自动隐藏无关的系统临时文件夹与垃圾文件。

然而,在持续集成(CI/CD)流水线中自动化打包出这种具有精细布局的 DMG,却一直是个令不少开发者头疼的工程痛点。


为什么传统打包方案在 CI 上频繁翻车?

长期以来,社区中最常见的两种定制 DMG 方案分别是基于 Node.js 的 appdmg 以及基于 Shell 的 create-dmg。但在无界面的 CI/CD 环境(如 GitHub Actions、GitLab CI 的 macOS Runner)中,它们存在明显的致命缺陷:

1. 深度依赖 Finder GUI 与 AppleScript

appdmg 和 create-dmg 底层通常依赖 AppleScript 唤起 macOS 的 Finder 窗口,通过模拟界面操作来调整窗口大小、图标位置以及背景图,最后依赖 Finder 将这些视口状态异步持久化到 .DS_Store 二进制文件中。

但在无头(Headless)无物理显示器的 CI Runner 上:

  • Finder 进程经常没有初始化图形渲染上下文;
  • .DS_Store 的写入是异步且不可控的,常常发生构建脚本已经执行完毕、镜像已打包,但 Finder 还没来得及写盘;
  • 导致最终产出的 DMG 随机丢失背景图、图标位置重叠乱跳,产生极其隐蔽的偶发性构建失败。

2. 脆弱的预制 DS_Store 黑盒方案

为了规避 CI 上无法调用 Finder 的问题,部分项目选择在本地物理机上手动调整好一次布局,把生成的隐藏文件 .DS_Store 提取出来作为静态资源存入代码仓库,打包时直接覆盖注入。但这种方案不仅难以适应多分辨率窗口的调整,而且一旦需要更改应用图标尺寸或新增软链接,就必须重新在物理机上肉眼微调并重新提取,维护极其痛苦。

3. Node.js 原生扩展的编译包袱

appdmg 依赖底层 C++ 原生模块编译,每当 Node.js 主版本迭代或 macOS 系统架构(如 x86_64 与 arm64)切换时,经常在 CI 上因 node-gyp 编译失败而中断发布。


解决方案:纯代码构建的 dmgbuild

为了追求 100% 确定性和高可维护性,基于 Python 的 dmgbuild 成为了目前自动化构建 macOS DMG 的最佳选择。

核心工作原理

dmgbuild 之所以稳定,是因为它从根本上摆脱了对 Finder GUI 和 AppleScript 的依赖:

  1. 直接构造二进制元数据:借助纯 Python 库 ds_store 与 mac_alias,dmgbuild 直接在内存中解析并按苹果二进制规范精确构造 .DS_Store 文件与替身别名数据,不唤起任何图形窗口。
  2. 纯声明式配置:通过一个简单的 Python 脚本(dmgbuild-settings.py)声明窗口尺寸、图标坐标与背景路径,支持从命令行动态注入变量(如版本号、产物路径)。
  3. 原生工具压缩:布局写入完成后,调用 macOS 原生系统级工具 hdiutil 将其转换并压缩为标准只读 DMG 磁盘镜像。

⚠️ 运行平台限制
虽然 ds_store 和 mac_alias 是跨平台的 Python 库,但镜像的格式转换与压缩最终依赖苹果专有的 hdiutil 命令行工具。因此,dmgbuild 只能运行在 macOS 系统上(本地 macOS 或 GitHub Actions macos-latest runner),在 Linux 或 Windows 上即使安装了 Python 依赖也无法生成最终的 DMG 文件。


项目工程结构设计

为了让本地开发和 CI 构建共用一套确定性的流程,推荐在项目中采用如下清晰的目录结构:

1
2
3
4
5
6
7
8
9
10
11
my-project/
├─ dist/ # 构建输出目录
│ └─ MyApp-v1.0.0-macOS.dmg
├─ MyApp.app/ # 前序步骤编译生成的完整 macOS App
├─ assets/
│ └─ dmg-background.png # DMG 拖拽引导背景图
├─ native_macos/
│ └─ AppIcon.icns # DMG 磁盘挂载后的卷图标(必须是 .icns)
└─ scripts/
├─ dmgbuild-settings.py # dmgbuild 布局配置文件
└─ package-dmg.sh # 自动化打包与自检校验脚本

如果你之前使用的是 appdmg,以下历史遗留文件可以直接安全清理:

  • scripts/dmg-config.json(不再需要)
  • assets/dmg-ds-store(不再需要预制缓存)

核心配置:编写 dmgbuild-settings.py

dmgbuild 的配置文件本身是一个合法的 Python 脚本。我们可以在脚本中通过 os.environ 接收外部传入的自定义参数,实现高度动态化的流水线集成。

新建 scripts/dmgbuild-settings.py:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# -*- coding: utf-8 -*-
"""
dmgbuild-settings.py
用于声明 macOS DMG 挂载视口大小、背景图、图标位置及卷属性。
支持通过命令行 `-D key=value` 动态注入参数。
"""
import os

# 1. 动态接收命令行 -D 传入的变量,设置默认回退值
app_path = os.environ.get("app", "./MyApp.app")
version = os.environ.get("ver", "0.0.1")

# 2. 基础元信息
# 用户挂载 DMG 时看到的磁盘卷名称
volume_name = f"MyApp {version}"

# DMG 挂载后在桌面和访达侧边栏显示的磁盘图标(必须为 .icns 格式)
volume_icon = "native_macos/AppIcon.icns"

# 背景图片相对路径(建议根据窗口尺寸做 1:1 或 Retina @2x 设计)
background = "assets/dmg-background.png"

# 3. 窗口视口配置:((起始 x, 起始 y), (窗口宽, 窗口高))
# 坐标原点为屏幕左上角,此处建议居中或设置适中尺寸
window_rect = ((120, 120), (800, 520))

# 图标显示尺寸(macOS 推荐 128 或 144)
icon_size = 128

# 4. 图标布局位置声明
# 坐标系统以 DMG 窗口内容区的左上角为原点 (0, 0)
# rect: ((x, y), (w, h)),这里 x, y 为图标中心点或包围框左上角
contents = [
# 应用程序本身(左侧)
{
"path": app_path,
"type": "file",
"rect": ((180, 200), (128, 128))
},
# Applications 目录的替身/软链接(右侧)
{
"path": "/Applications",
"type": "link",
"rect": ((540, 200), (128, 128))
}
]

# 5. 过滤无关系统文件,保持 DMG 内部整洁
hidden_files = [
".Trashes",
".Spotlight-V100",
".fseventsd",
".VolumeIcon.icns"
]

# 6. DMG 压缩策略
# UDZO 为标准 zlib 压缩格式,在解压速度与镜像体积之间有良好平衡
format = "UDZO"
compression_level = 9

关键配置项说明

  • window_rect:定义 DMG 双击打开时的默认窗口大小。建议设计背景图时与该尺寸严格保持一致(例如 800 × 520)。
  • contents:设置左侧 .app 与右侧 /Applications 软链接的坐标。微调该参数可以使图标恰好对准背景图中的引导箭头。
  • format = "UDZO":macOS 最通用的只读压缩磁盘映像格式,兼顾压缩率与挂载性能。

自动化打包与校验脚本:package-dmg.sh

在实际工程中,绝不能“执行了打包命令就不管”。我们需要一份严谨的 Shell 脚本,完成前置资源强校验、构建调用,并在构建完成后自动挂载 DMG 验证 .DS_Store 是否真实生成,形成完整的质量卡点。

flowchart TD
    A["执行 package-dmg.sh"] --> B{"前置资源强校验
(App/背景/图标/配置)"} B --"资源缺失"--> ERR1["❌ 立即中断 (Fail-Fast)"] B --"校验通过"--> C["调用 dmgbuild 注入 -D 动态参数"] C --> D["内存构造 .DS_Store
+ 调用 hdiutil 压缩 UDZO"] D --> E["hdiutil attach 静默挂载测试"] E --> F{"根目录 .DS_Store 是否存在?"} F --"否"--> ERR2["❌ 阻断: 视口元数据丢失"] F --"是"--> G["安全卸载 (带锁盘重试机制)"] G --> H["🎉 产出质量合格的 DMG 资产"]

新建 scripts/package-dmg.sh:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#!/usr/bin/env bash
# ==============================================================================
# package-dmg.sh - 基于 dmgbuild 的 macOS DMG 自动化打包与校验脚本
# 用法: bash scripts/package-dmg.sh [version]
# ==============================================================================

set -euo pipefail

# 1. 变量初始化与路径定义
VERSION="${1:-0.0.1}"
APP_NAME="MyApp"
APP_PATH="./${APP_NAME}.app"
SETTING_FILE="./scripts/dmgbuild-settings.py"
OUTPUT_DIR="./dist"
OUTPUT_DMG="${OUTPUT_DIR}/${APP_NAME}-v${VERSION}-macOS.dmg"
BG_IMAGE="./assets/dmg-background.png"
VOL_ICON="./native_macos/AppIcon.icns"

echo "🚀 开始构建 DMG 安装包,目标版本: v${VERSION}..."

# 2. 前置资源强校验(Fail-Fast 策略,避免耗费时间构建后才报错)
test -d "${APP_PATH}" || { echo "❌ 错误: 未找到应用包: ${APP_PATH}"; exit 1; }
test -f "${SETTING_FILE}" || { echo "❌ 错误: 未找到配置文件: ${SETTING_FILE}"; exit 1; }
test -f "${BG_IMAGE}" || { echo "❌ 错误: 未找到背景图: ${BG_IMAGE}"; exit 1; }
test -f "${VOL_ICON}" || { echo "❌ 错误: 未找到卷图标: ${VOL_ICON}"; exit 1; }

mkdir -p "${OUTPUT_DIR}"
rm -f "${OUTPUT_DMG}"

# 3. 检查并提示 Python 依赖
if ! python3 -c "import dmgbuild, ds_store, mac_alias" &> /dev/null; then
echo "🧰 未检测到 dmgbuild 相关依赖,正在安装..."
pip3 install --upgrade dmgbuild ds_store mac_alias
fi

# 4. 执行 dmgbuild 构建
# 使用 `python3 -m dmgbuild` 替代直接调用命令,避免虚拟环境 PATH 丢失
echo "📦 正在生成 DMG 镜像 -> ${OUTPUT_DMG}"
python3 -m dmgbuild \
-s "${SETTING_FILE}" \
-D app="${APP_PATH}" \
-D ver="${VERSION}" \
"${APP_NAME}" \
"${OUTPUT_DMG}"

# 5. 关键质量自检:挂载并检查 .DS_Store
# 没有 .DS_Store 代表视口与背景全部失效,必须拦截阻断
echo "🔍 挂载 DMG 校验视口元数据与布局完整性..."
TMP_MNT=$(mktemp -d /tmp/dmg-check.XXXXXX)

# -noverify 避免耗费时间进行哈希完整性全盘扫描,-nobrowse 防止 Finder 弹窗
hdiutil attach "${OUTPUT_DMG}" -mountpoint "${TMP_MNT}" -noverify -nobrowse > /dev/null

if [ ! -f "${TMP_MNT}/.DS_Store" ]; then
echo "❌ 严重构建故障: DMG 内部未找到 .DS_Store 文件,窗口布局及背景图失效!"
hdiutil detach "${TMP_MNT}" -force || true
rm -rf "${TMP_MNT}"
exit 1
fi

echo "✅ 元数据验证通过 (.DS_Store 存在)"

# 6. 安全卸载挂载盘
# 针对 Finder / Spotlight 偶尔锁盘导致的“Resource temporarily unavailable”设计重试
echo "🧹 卸载临时测试挂载点..."
if ! hdiutil detach "${TMP_MNT}" > /dev/null 2>&1; then
sleep 2
hdiutil detach "${TMP_MNT}" -force > /dev/null 2>&1 || true
fi
rm -rf "${TMP_MNT}"

echo "🎉 打包校验完成: ${OUTPUT_DMG}"

赋予脚本可执行权限并运行:

1
2
chmod +x scripts/package-dmg.sh
./scripts/package-dmg.sh 1.0.0

集成到 GitHub Actions CI/CD 流水线

通过将打包逻辑收敛在 scripts/package-dmg.sh 中,GitHub Actions 的工作流配置变得异常精简且易于维护。

以下是一个完整的 Release 发布流水线配置参考(.github/workflows/release.yml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
name: Release macOS App

on:
push:
tags:
- 'v*'

jobs:
build-dmg:
name: Build & Package DMG
runs-on: macos-14 # 必须使用 macOS runner,支持 Apple Silicon 环境
steps:
- name: Checkout Code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'

- name: Install dmgbuild dependencies
run: |
pip install --upgrade dmgbuild ds_store mac_alias

- name: Compile Application
run: |
# 此处执行你的前端/客户端编译命令
# 例如:npm run tauri build 或 go build 或 xcodebuild
# 产物输出为 ./MyApp.app
echo "Compiling application..."

- name: Extract Tag Version
id: get_version
run: |
VERSION="${GITHUB_REF#refs/tags/v}"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"

- name: Package & Validate DMG
run: |
bash scripts/package-dmg.sh "${{ steps.get_version.outputs.version }}"

- name: Upload Release Asset
uses: softprops/action-gh-release@v2
if: startsWith(github.ref, 'refs/tags/')
with:
files: dist/*.dmg
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

常见问题与高频踩坑清单(Gotchas)

在落地实际业务打包时,以下几个高频细节非常值得关注:

1. 背景图在 Retina(视网膜)屏幕上模糊或尺寸不匹配

  • 原因:DMG 的 window_rect = ((x, y), (w, h)) 使用的是逻辑点(Points),例如 800 × 520。
  • 最佳实践:制作背景图片时,推荐直接导出 2 倍物理像素 的高清图片(即 1600 × 1040,72 或 144 DPI 均可)。macOS 在高清屏下渲染挂载窗口时会自动进行高质量缩放,保证文字与引导箭头锐利清晰。

2. 挂载后图标与背景图箭头位置偏移

  • 不同的 macOS 主版本(Sonoma / Sequoia 等)对于字体字阶和应用图标的边缘判定可能存在轻微差异。
  • 建议初次配置时,在本地 macOS 机器运行脚本,生成后直接双击挂载打开,微调 dmgbuild-settings.py 中 contents 数组里的 rect 坐标参数,直到视觉上完全居中对齐。

3. hdiutil detach: Resource temporarily unavailable

  • 原因:在调用 hdiutil attach 后,macOS 系统的 Spotlight(索引服务)或 mds 进程可能会在后台尝试为刚挂载的临时磁盘建立索引,产生文件锁。
  • 解决方式:挂载时务必加上 -nobrowse 参数(防止 Finder 主动读取),卸载时如果失败,sleep 2 后使用 hdiutil detach -force 强制卸载即可。

4. 卷图标格式必须为标准的 .icns

  • volume_icon 必须是包含多种分辨率层级(16x16 至 512x512@2x)的标准苹果图标文件(.icns),直接传入 .png 格式会导致卷图标无法正常渲染。
  • 可使用 macOS 原生命令行工具 iconutil 将包含各尺寸 png 的 .iconset 目录转换为标准 .icns。

5. 关于 Gatekeeper、代码签名与公证(Notarization)

如果你需要公开发布应用供外部用户下载:

  • 单纯生成 DMG 是不够的,macOS Gatekeeper 安全机制会在用户打开未签名未公证的 DMG 时拦截并提示“已损坏,无法打开”;
  • 正确的签名顺序应当是:
    1. 先对应用程序内部二进制与 MyApp.app 进行 codesign 深度签名;
    2. 使用 dmgbuild 将已签名的 .app 打包为 .dmg;
    3. 对产出的 .dmg 整体进行 codesign 签名;
    4. 使用苹果官方命令行 xcrun notarytool submit 提交 DMG 进行公证;
    5. 公证成功后,执行 xcrun stapler staple 将票据装订到 DMG 上。

方案选型综合对比

方案稳定性 (CI)外部运行时依赖配置方式自定义外观能力推荐指数
dmgbuild⭐⭐⭐⭐⭐
(纯二进制写 DS_Store,无 GUI 依赖)
Python
(macOS 自带或 pip)
Python 脚本
(支持动态参数注入)
强
(背景、卷图标、坐标、软链接)
首选推荐
appdmg⭐⭐
(CI 频繁丢布局、易超时)
Node.js + C++ 编译依赖JSON 配置文件 + 缓存 DS_Store强
(但维护成本高)
已不推荐
create-dmg⭐⭐⭐
(依赖 AppleScript)
Bash + AppleScriptCLI 纯命令行传参中等
(简单场景可用)
备选
原生 hdiutil⭐⭐⭐⭐⭐
(系统原生)
无原生 Shell 命令弱
(仅支持纯白/裸盘,无布局)
仅适合极简分发

结语

从脆弱的 GUI 模拟与预制模板,转向代码化、确定性的声明式构建,是现代客户端工程交付的必然趋势。利用 dmgbuild 搭配参数化脚本与挂载自检,不仅彻底解决了 CI/CD 无头环境下 DMG 布局丢失的历史顽疾,也为团队沉淀了一套极低维护成本的 macOS 打包标准范式。


参考资料与延伸阅读


macOS 使用 dmgbuild 打包 DMG
https://bubao.github.io/posts/26aa0834.html
作者
一念
发布于
2026年11月5日
许可协议