midi-harbor/packaging/macos/bundle.sh
2026-09-28 13:59:10 -05:00

196 lines
7.7 KiB
Bash
Executable file

#!/bin/sh
# Wraps a built macOS binary into "Midi Harbor.app" and a disk image holding it.
#
# packaging/macos/bundle.sh [--helper <headless binary>] <binary> <version> <output directory>
# [<disk image directory>]
#
# The app is built in the output directory, and the disk image goes beside it unless a directory
# is given for it; GoReleaser puts the image in dist/ with the other artifacts.
#
# The binary inside the bundle is the whole program: opened from Finder it shows the graphical
# interface, and `service install` run from it registers the bundled executable, so the daemon
# launchd starts carries the bundle's identity and its Bluetooth permission.
#
# On a Mac it signs with codesign and makes the image with hdiutil. It signs with the identity
# MIDI_HARBOR_SIGNING_IDENTITY names, or else the first Developer ID Application identity in the
# keychain. Elsewhere, which is how GoReleaser runs it, it signs with rcodesign and makes the image
# with xorriso and libdmg-hfsplus's dmg, signing with .signing/developer-id.p12 when it exists.
# Without a Developer ID certificate it signs ad hoc. With one, and an App Store Connect API key in
# .signing/notary-api-key.json, it notarizes the app and the disk image and staples both.
#
# --helper makes the App Store variant instead, on a Mac only: the sandboxed app, with the helper
# as the daemon it starts (Contents/MacOS/midi-harbor-daemon), requiring macOS 13 and starting
# without a Dock icon. It makes no disk image, since the App Store takes an installer package
# built with the owner's certificates (specs/014-mac-app-store-mode/contracts/bundle.md).
set -eu
helper=
if [ "${1:-}" = --helper ]; then
helper=$2
shift 2
fi
binary=$1
version=$2
out=$3
images=${4:-$out}
cd "$(dirname "$0")/../.."
app="$out/Midi Harbor.app"
dmg="$images/Midi-Harbor-$version.dmg"
staging="$out/dmg-staging"
p12=.signing/developer-id.p12
p12_password=.signing/developer-id.p12.password
api_key=.signing/notary-api-key.json
# Assemble the bundle. The bundle's version must be plain numbers, so a snapshot's suffix is
# dropped there and kept in the image's name.
rm -rf "$app" "$dmg" "$staging"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" "$images"
cp "$binary" "$app/Contents/MacOS/midi-harbor"
chmod 0755 "$app/Contents/MacOS/midi-harbor"
cp packaging/macos/AppIcon.icns "$app/Contents/Resources/"
sed "s/@VERSION@/${version%%-*}/g" packaging/macos/Info.plist > "$app/Contents/Info.plist"
# The App Store variant: the helper signed first, since signing the bundle seals it, then the
# app with the sandbox.
if [ -n "$helper" ]; then
identity=${MIDI_HARBOR_SIGNING_IDENTITY:--}
cp "$helper" "$app/Contents/MacOS/midi-harbor-daemon"
chmod 0755 "$app/Contents/MacOS/midi-harbor-daemon"
plutil -replace LSMinimumSystemVersion -string 13.0 "$app/Contents/Info.plist"
plutil -replace LSUIElement -bool true "$app/Contents/Info.plist"
plutil -lint "$app/Contents/Info.plist" >/dev/null
codesign --force --options runtime --sign "$identity" \
--entitlements packaging/macos/helper.entitlements \
"$app/Contents/MacOS/midi-harbor-daemon"
codesign --force --options runtime --sign "$identity" \
--entitlements packaging/macos/app-store.entitlements "$app"
codesign --verify --strict "$app"
echo "$app"
exit 0
fi
# Find the Developer ID certificate. The App Store variant above takes its identity only from
# MIDI_HARBOR_SIGNING_IDENTITY, since a Developer ID one is the wrong kind for it.
developer_id=
if [ "$(uname -s)" = Darwin ]; then
identity=${MIDI_HARBOR_SIGNING_IDENTITY:-}
if [ -z "$identity" ]; then
identity=$(security find-identity -v -p codesigning \
| sed -n 's/^.*"\(Developer ID Application: .*\)"$/\1/p' | head -n 1)
fi
if [ -n "$identity" ] && [ "$identity" != - ]; then
developer_id=yes
fi
elif [ -f "$p12" ]; then
if [ ! -f "$p12_password" ]; then
echo "$p12 needs its password in $p12_password" >&2
exit 1
fi
developer_id=yes
fi
notarize=
if [ -n "$developer_id" ] && [ -f "$api_key" ]; then
notarize=yes
elif [ -f "$api_key" ]; then
echo "not notarizing: $api_key needs a Developer ID certificate to sign with" >&2
fi
if [ -n "$developer_id" ]; then
echo "signing with a Developer ID certificate" >&2
else
echo "no Developer ID certificate found; signing ad hoc" >&2
fi
# sign <path> signs the app bundle, with the hardened runtime notarization requires, or the disk
# image, which carries no runtime flag. Without a Developer ID certificate the image is left
# unsigned, since an ad hoc signature on it proves nothing.
sign() {
case "$1" in
*.app) runtime=yes ;;
*) runtime= ;;
esac
if [ -z "$developer_id" ]; then
if [ -z "$runtime" ]; then
return
fi
if [ "$(uname -s)" = Darwin ]; then
codesign --force --options runtime --sign - "$1"
codesign --verify --strict "$1"
else
rcodesign sign --code-signature-flags runtime "$1" >/dev/null
fi
elif [ "$(uname -s)" = Darwin ]; then
codesign --force --timestamp ${runtime:+--options runtime} --sign "$identity" "$1"
codesign --verify --strict "$1"
else
rcodesign sign --p12-file "$p12" --p12-password-file "$p12_password" \
${runtime:+--code-signature-flags runtime --for-notarization} "$1" >/dev/null
fi
}
# notarize <path> submits the app bundle or disk image to Apple's notary service, waits for its
# verdict, and staples the ticket to it so it opens without a network connection.
notarize() {
if [ "$(uname -s)" = Darwin ]; then
# notarytool takes the key as a .p8 file and an app bundle only inside a zip.
work=$(mktemp -d)
{
echo "-----BEGIN PRIVATE KEY-----"
plutil -extract private_key raw "$api_key" | fold -w 64
echo "-----END PRIVATE KEY-----"
} > "$work/key.p8"
submission=$1
case "$1" in
*.app)
submission="$work/$(basename "$1").zip"
ditto -c -k --keepParent "$1" "$submission"
;;
esac
status=0
xcrun notarytool submit "$submission" --wait \
--key "$work/key.p8" \
--key-id "$(plutil -extract key_id raw "$api_key")" \
--issuer "$(plutil -extract issuer_id raw "$api_key")" >&2 || status=$?
rm -rf "$work"
if [ "$status" -ne 0 ]; then
return "$status"
fi
xcrun stapler staple "$1" >&2
else
rcodesign notary-submit --api-key-file "$api_key" --staple "$1" >&2
fi
}
# Sign the app, and notarize it so the copy dragged out of the disk image carries its own ticket.
# Bluetooth permission is granted to the signed bundle, not to a loose binary.
if [ "$(uname -s)" = Darwin ]; then
plutil -lint "$app/Contents/Info.plist" >/dev/null
fi
sign "$app"
if [ -n "$notarize" ]; then
notarize "$app"
fi
# Put it on a disk image beside a link to Applications, the usual way to install one, with the
# license and documentation the archives carry.
mkdir -p "$staging/docs"
cp -R "$app" "$staging/"
ln -s /Applications "$staging/Applications"
cp LICENSE.txt "$staging/"
cp docs/*.md "$staging/docs/"
if [ "$(uname -s)" = Darwin ]; then
hdiutil create -quiet -volname "Midi Harbor" -srcfolder "$staging" -ov -format UDZO "$dmg"
else
xorriso -as mkisofs -quiet -V "Midi Harbor" -r -D -no-pad -o "$out/uncompressed.iso" "$staging"
dmg dmg "$out/uncompressed.iso" "$dmg" >/dev/null
rm -f "$out/uncompressed.iso"
fi
rm -rf "$staging"
# Sign and notarize the disk image itself, which is what Gatekeeper checks first on download.
sign "$dmg"
if [ -n "$notarize" ]; then
notarize "$dmg"
fi
echo "$app"
echo "$dmg"