diff --git a/.github/release/sign-desktop-macos.sh b/.github/release/sign-desktop-macos.sh index 0742b888271..17519546f8b 100755 --- a/.github/release/sign-desktop-macos.sh +++ b/.github/release/sign-desktop-macos.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # sign-desktop-macos.sh ...: signs each unsigned .app inside-out with hardened runtime, # notarizes them in one submission, staples them, and only then builds opencode-desktop-mac-.{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 diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 3e29318dcac..2898101bdb1 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -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", diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index d147662ffd3..cb88c04fa3b 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -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/", diff --git a/packages/desktop/icons/dmg/background.png b/packages/desktop/icons/dmg/background.png new file mode 100644 index 00000000000..5b4b8f77d51 Binary files /dev/null and b/packages/desktop/icons/dmg/background.png differ diff --git a/packages/desktop/icons/dmg/background@2x.png b/packages/desktop/icons/dmg/background@2x.png new file mode 100644 index 00000000000..10cf544eccb Binary files /dev/null and b/packages/desktop/icons/dmg/background@2x.png differ diff --git a/packages/desktop/script/pack.ts b/packages/desktop/script/pack.ts index 0950d953044..e9bdd7f3cc9 100644 --- a/packages/desktop/script/pack.ts +++ b/packages/desktop/script/pack.ts @@ -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") {