feat(desktop): add a custom macOS DMG background

The build job packs the disk image layout (background, icon positions and
volume icon) around an empty app folder, and sign-desktop-macos.sh puts the
stapled app in its place instead of making a plain hdiutil image.
This commit is contained in:
Brendonovich
2026-11-03 14:44:43 +00:00
Unverified
parent 6d1c444b8c
commit db0446da9c
6 changed files with 57 additions and 11 deletions
+19 -7
View File
@@ -1,15 +1,16 @@
#!/usr/bin/env bash
# sign-desktop-macos.sh <out dir> <app tarball>...: signs each unsigned .app inside-out with hardened runtime,
# notarizes them in one submission, staples them, and only then builds opencode-desktop-mac-<arch>.{zip,dmg,app.tar.gz}
# from the stapled apps. Dry runs sign ad-hoc and skip notarization. Dev and beta trade larger files for faster
# packaging (zip and dmg at zlib level 1) and skip the .app.tar.gz, which only the retired Tauri updater feed read.
# from the stapled apps, putting each app into the disk image layout packed next to its tarball. Dry runs sign ad-hoc
# and skip notarization. Dev and beta trade larger files for faster packaging (zip and dmg at zlib level 1) and skip
# the .app.tar.gz, which only the retired Tauri updater feed read.
# Notarization reads APPLE_NOTARY_KEY (the .p8 contents), APPLE_NOTARY_KEY_ID and APPLE_NOTARY_ISSUER_ID.
. "$(dirname "$0")/lib.sh"
out=$1
shift
mkdir -p "$out"
apps=$(mktemp -d)
apps=$(mktemp -d) layouts=$(mktemp -d)
identity=- timestamp=--timestamp=none
zip_level=() dmg_level=() preview=false
case "$OPENCODE_CHANNEL" in dev | beta) preview=true ;; esac
@@ -36,6 +37,7 @@ for tarball in "$@"; do
die "Expected exactly one .app bundle in $tarball"
fi
app=$(bundle "$arch")
cp "${tarball%.app.tar}.layout.dmg" "$layouts/$arch.dmg"
# Every nested Mach-O file and bundle, deepest first, then the app itself.
list=$(mktemp)
@@ -79,10 +81,20 @@ for dir in "$apps"/*; do
spctl --assess --type execute --verbose=2 "$app"
fi
ditto -c -k ${zip_level[@]+"${zip_level[@]}"} --sequesterRsrc --keepParent "$app" "$out/opencode-desktop-mac-$arch.zip"
staging=$(mktemp -d)
ditto "$app" "$staging/$name"
ln -s /Applications "$staging/Applications"
hdiutil create -volname "${name%.app}" -srcfolder "$staging" -fs HFS+ -format UDZO ${dmg_level[@]+"${dmg_level[@]}"} -ov "$out/opencode-desktop-mac-$arch.dmg"
# The layout holds the background, icon positions and volume icon around an empty app folder. Only that folder
# and the Applications link may be visible, so the image ships nothing but the stapled app beside the link.
hdiutil convert "$layouts/$arch.dmg" -format UDRW -o "$layouts/$arch-rw.dmg"
volume=$(mktemp -d)
hdiutil attach "$layouts/$arch-rw.dmg" -nobrowse -noautoopen -mountpoint "$volume"
if [ "$(ls "$volume")" != "$(printf 'Applications\n%s' "$name")" ] || [ "$(readlink "$volume/Applications")" != /Applications ] ||
[ -n "$(ls -A "$volume/$name")" ]; then
die "Unexpected disk image layout: $(ls -A "$volume")"
fi
rmdir "$volume/$name"
ditto "$app" "$volume/$name"
hdiutil detach "$volume"
hdiutil resize -sectors min "$layouts/$arch-rw.dmg"
hdiutil convert "$layouts/$arch-rw.dmg" -format UDZO ${dmg_level[@]+"${dmg_level[@]}"} -ov -o "$out/opencode-desktop-mac-$arch.dmg"
if [ "$preview" = true ]; then continue; fi
COPYFILE_DISABLE=1 tar --no-mac-metadata -czf "$out/opencode-desktop-mac-$arch.app.tar.gz" -C "$dir" "$name"
done
@@ -88,12 +88,24 @@ test("legacy Linux launcher stays hidden", async () => {
expect(desktop).toContain("NoDisplay=true")
})
test.each(channels)("macOS bundle for $channel", ({ channel }) => {
test.each(channels)("macOS bundle for $channel", async ({ channel }) => {
const config = load(channel)
expect(config.mac?.icon).toBe(channel === "prod" ? "resources/icons/icon.icon" : "resources/icons/icon.icns")
expect(config.dmg?.icon).toBe(channel === "prod" ? "resources/icons/icon.icns" : undefined)
// The prod override must keep the rest of the shared macOS settings.
// The prod overrides must keep the rest of the shared macOS settings and disk image layout.
expect(config.mac?.target).toEqual(["dmg", "zip"])
expect(config.dmg).toMatchObject({
title: "${productName}",
background: "icons/dmg/background.png",
contents: [
{ x: 180, y: 210, type: "file" },
{ x: 480, y: 210, type: "link", path: "/Applications" },
],
})
// electron-builder combines the background with its @2x image only when both exist; otherwise Retina blurs it.
for (const file of ["icons/dmg/background.png", "icons/dmg/background@2x.png"])
expect(await Bun.file(path.join(import.meta.dirname, file)).exists(), file).toBe(true)
for (const key of [
"NSMicrophoneUsageDescription",
+14 -1
View File
@@ -172,6 +172,19 @@ const getBase = (appId: string): Configuration => {
},
target: ["dmg", "zip"],
},
// script/pack.ts builds this layout around an empty app folder, and sign-desktop-macos.sh puts the notarized app
// in its place. Finder draws the icons and their labels, always black, over the background, so it stays light.
dmg: {
title: "${productName}",
background: "icons/dmg/background.png",
iconSize: 128,
contents: [
{ x: 180, y: 210, type: "file" },
{ x: 480, y: 210, type: "link", path: "/Applications" },
],
// publish-desktop.sh computes the update feeds from the final files, so no blockmap.
writeUpdateInfo: false,
},
protocols: {
name: "OpenCode",
schemes: ["opencode"],
@@ -261,7 +274,7 @@ export function getConfig() {
// (Dock, Finder, system dialogs and the system icon styles). Older macOS keeps using the ICNS.
mac: { ...base.mac, icon: "resources/icons/icon.icon" },
// The DMG volume icon would otherwise use the same actool-generated ICNS.
dmg: { icon: "resources/icons/icon.icns" },
dmg: { ...base.dmg, icon: "resources/icons/icon.icns" },
publish: {
provider: "generic",
url: "https://opencode.ai/update/api/latest/desktop/opencode/",
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+10 -1
View File
@@ -6,7 +6,7 @@ import { getConfig } from "../electron-builder.config"
import { getCurrentCli, resolveChannel } from "./utils"
// Packages the built app (out/) for OPENCODE_TARGET into dist/desktop at the repository root, unsigned:
// macOS: the .app as a tarball. Windows: the unpacked app in app/, or with OPENCODE_DESKTOP_APP, the NSIS
// macOS: the .app as a tarball and the disk image layout. Windows: the unpacked app in app/, or with OPENCODE_DESKTOP_APP, the NSIS
// installer around that (signed) app. Linux: AppImage, deb and rpm. The release workflow signs afterwards.
const dir = path.resolve(import.meta.dir, "..")
@@ -53,6 +53,15 @@ if (!app && os === "mac") {
if (bundles.length !== 1) throw new Error(`Expected one .app bundle in ${parent}, found ${bundles.join(", ")}`)
await writeUpdateConfig(path.join(parent, bundles[0], "Contents", "Resources"))
await $`tar -cf ${path.join(out, `opencode-desktop-mac-${cli.cpu}.app.tar`)} -C ${parent} ${bundles[0]}`
// The disk image layout (background, icon positions and volume icon) around an empty app folder, with room
// for sign-desktop-macos.sh to put the notarized app in its place.
const placeholder = path.join("dist", "dmg", bundles[0])
await mkdir(placeholder, { recursive: true })
await $`bunx electron-builder ${flags} -c.mac.target=dmg -c.dmg.size=2g -c.dmg.shrink=false --prepackaged ${placeholder}`
await cp(
path.join("dist", `opencode-desktop-mac-${cli.cpu}.dmg`),
path.join(out, `opencode-desktop-mac-${cli.cpu}.layout.dmg`),
)
}
if (!app && os === "win") {