diff --git a/packages/desktop/README.md b/packages/desktop/README.md index cf7e1c17..b5259e81 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -213,10 +213,12 @@ and the Dock icon bounces. Anyway* in System Settings > Privacy & Security (it asks for an administrator password). On macOS 14 and earlier, right-click it and choose *Open* once. Either way, `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the - copy in Applications works too. Shipping it to other people needs a Developer - ID signature and notarization: - `SIGN_IDENTITY="Developer ID Application: ..." ./scripts/bundle-macos.sh --dmg`, - then `xcrun notarytool submit` and `xcrun stapler staple`. + copy in Applications works too. The `Codeman.dmg` on each GitHub release is + signed with a Developer ID and notarized by CI + (`.github/workflows/desktop-dmg.yml`), so it skips all of this. To do the + same by hand: + `SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= ./scripts/bundle-macos.sh --universal --notarize`, + where `scripts/sign-macos.sh` lists what it needs. - **Local Network permission (macOS 15 and later).** The first connection to an address on the local network (a LAN IP, a `.local` name) makes macOS ask whether Codeman may use the local network, and that first attempt can fail @@ -278,4 +280,4 @@ and the Dock icon bounces. - Trusting a self-signed server certificate by its fingerprint. - Asking before a download that a rendered HTML file or a web tab starts. - A way out of a page that keeps opening dialogs (a "block further dialogs" option). -- Windows and Linux builds, a CI job that builds the `.dmg`, notarization, auto-update. +- Windows and Linux builds, auto-update. diff --git a/packages/desktop/scripts/bundle-macos.sh b/packages/desktop/scripts/bundle-macos.sh index ac1b5e6d..6fbdabdf 100755 --- a/packages/desktop/scripts/bundle-macos.sh +++ b/packages/desktop/scripts/bundle-macos.sh @@ -4,6 +4,8 @@ # packages/desktop/scripts/bundle-macos.sh # this Mac's architecture # packages/desktop/scripts/bundle-macos.sh --universal --dmg # SIGN_IDENTITY="Developer ID Application: ..." packages/desktop/scripts/bundle-macos.sh --dmg +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE= \ +# packages/desktop/scripts/bundle-macos.sh --universal --notarize # # Without SIGN_IDENTITY the app is signed ad hoc: it runs on the Mac that built # it, and a copy downloaded on another Mac is stopped by Gatekeeper. On macOS 15 @@ -11,6 +13,15 @@ # Privacy & Security; on macOS 14 and earlier, right-click it and choose Open. # Or run `xattr -dr com.apple.quarantine /Applications/Codeman.app` on the copy # in /Applications. A Developer ID signature + notarization avoids all of this. +# +# --dmg writes dist/Codeman-.dmg, signed with SIGN_IDENTITY when it is +# set. --notarize hands the built app to scripts/sign-macos.sh, which signs it +# with SIGN_IDENTITY, has Apple notarize it, staples the ticket to it and does +# the same for dist/Codeman.dmg, the DMG built around it (so --dmg is implied). +# sign-macos.sh's header lists what it needs: SIGN_IDENTITY, and NOTARY_PROFILE +# or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID. The release workflow +# (.github/workflows/desktop-dmg.yml) runs this script ad hoc in one job and +# sign-macos.sh in another, so the job that runs cargo holds no secret. set -euo pipefail cd "$(dirname "$0")/.." @@ -18,14 +29,21 @@ cd "$(dirname "$0")/.." UNIVERSAL=0 DMG=0 +NOTARIZE=0 for arg in "$@"; do case "$arg" in --universal) UNIVERSAL=1 ;; --dmg) DMG=1 ;; + --notarize) NOTARIZE=1 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done +if [ "$NOTARIZE" = 1 ]; then + # Checked before the build, so a missing secret costs seconds, not a build. + ./scripts/sign-macos.sh --check +fi + VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -1) [ -n "$VERSION" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } @@ -98,13 +116,13 @@ codesign --verify --strict "$APP" echo "Built $(pwd)/$APP ($(du -sh "$APP" | cut -f1), version $VERSION, macOS $MACOSX_DEPLOYMENT_TARGET or later)" -if [ "$DMG" = 1 ]; then - STAGE="$WORK/dmg" - mkdir -p "$STAGE" - cp -R "$APP" "$STAGE/" - ln -s /Applications "$STAGE/Applications" +if [ "$NOTARIZE" = 1 ]; then + ./scripts/sign-macos.sh "$APP" dist +elif [ "$DMG" = 1 ]; then DMG_PATH="dist/Codeman-$VERSION.dmg" - rm -f "$DMG_PATH" - hdiutil create -quiet -volname "Codeman" -srcfolder "$STAGE" -ov -format UDZO "$DMG_PATH" + ./scripts/make-dmg.sh "$APP" "$DMG_PATH" + if [ -n "${SIGN_IDENTITY:-}" ]; then + codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH" + fi echo "Built $(pwd)/$DMG_PATH ($(du -sh "$DMG_PATH" | cut -f1))" fi diff --git a/packages/desktop/scripts/make-dmg.sh b/packages/desktop/scripts/make-dmg.sh new file mode 100755 index 00000000..22eea160 --- /dev/null +++ b/packages/desktop/scripts/make-dmg.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Put Codeman.app in a compressed disk image next to a link to /Applications, +# so opening the image shows the app and the folder to drag it into. +# +# packages/desktop/scripts/make-dmg.sh +# +# bundle-macos.sh (--dmg) and sign-macos.sh both call it, so a local test DMG +# and the release DMG are laid out the same way. It signs nothing. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "make-dmg.sh runs on macOS only" >&2; exit 1; } +[ $# -eq 2 ] || { echo "usage: $0 " >&2; exit 2; } + +APP=$1 +DMG=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +STAGE="$WORK/dmg" +mkdir -p "$STAGE" +# ditto keeps what cp -R can lose: extended attributes, and with them a +# stapled notarization ticket. +ditto "$APP" "$STAGE/$(basename "$APP")" +ln -s /Applications "$STAGE/Applications" + +# Hosted macOS runners now and then fail hdiutil with "Resource busy"; the +# same command a few seconds later passes. +rm -f "$DMG" +tries=1 +until hdiutil create -quiet -volname Codeman -srcfolder "$STAGE" -ov -format UDZO "$DMG"; do + if [ "$tries" -ge 3 ]; then + echo "hdiutil create failed $tries times" >&2 + exit 1 + fi + tries=$((tries + 1)) + echo "hdiutil create failed, trying again in 10 seconds" >&2 + rm -f "$DMG" + sleep 10 +done +hdiutil verify -quiet "$DMG" diff --git a/packages/desktop/scripts/sign-macos.sh b/packages/desktop/scripts/sign-macos.sh new file mode 100755 index 00000000..46266321 --- /dev/null +++ b/packages/desktop/scripts/sign-macos.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Sign Codeman.app with a Developer ID, have Apple notarize it and staple the +# ticket to it, then build Codeman.dmg around the stapled app and do the same +# for the DMG. On macOS. +# +# packages/desktop/scripts/sign-macos.sh +# packages/desktop/scripts/sign-macos.sh --check # only check the environment below +# +# is re-signed and stapled in place. receives +# Codeman.dmg and Codeman.dmg.sha256, the latter with the bare file name in it, +# so `shasum -a 256 -c Codeman.dmg.sha256` works next to a downloaded copy. Both +# appear only once every check passed; a failed run leaves neither, not even an +# older pair, so nothing half-done can be uploaded by mistake. +# +# The environment names who signs: +# SIGN_IDENTITY a "Developer ID Application: ..." identity in a keychain +# on the search list, by name or by the SHA-1 hash that +# `security find-identity -v -p codesigning` prints +# and how to reach Apple's notary service, either through a profile saved with +# `xcrun notarytool store-credentials --apple-id ... --team-id ...`: +# NOTARY_PROFILE the profile's name +# NOTARY_KEYCHAIN optional, the keychain that holds it (default: login) +# or with the Apple ID itself: +# APPLE_ID, APPLE_APP_PASSWORD (an app-specific password), APPLE_TEAM_ID +# +# By hand, on a Mac with the certificate in the login keychain: +# SIGN_IDENTITY="Developer ID Application: ..." NOTARY_PROFILE=codeman-notary \ +# packages/desktop/scripts/sign-macos.sh packages/desktop/dist/Codeman.app packages/desktop/dist +# +# The release workflow (.github/workflows/desktop-dmg.yml) runs it in a job that +# builds nothing: the job that runs cargo, and with it the build script of every +# crate in Cargo.lock, never holds a signing secret. +set -euo pipefail + +[ "$(uname -s)" = Darwin ] || { echo "sign-macos.sh runs on macOS only" >&2; exit 1; } + +DESKTOP=$(cd "$(dirname "$0")/.." && pwd) +ENTITLEMENTS="$DESKTOP/macos/entitlements.plist" + +# Fails with the name of what is missing, before any work, so a missing secret +# costs seconds rather than a build. +check_env() { + if [ -z "${SIGN_IDENTITY:-}" ]; then + echo "sign-macos.sh needs SIGN_IDENTITY" >&2 + exit 2 + fi + if [ -n "${NOTARY_PROFILE:-}" ]; then + return 0 + fi + local name + for name in APPLE_ID APPLE_APP_PASSWORD APPLE_TEAM_ID; do + if [ -z "$(eval "printf '%s' \"\${$name:-}\"")" ]; then + echo "sign-macos.sh needs NOTARY_PROFILE, or APPLE_ID, APPLE_APP_PASSWORD and APPLE_TEAM_ID ($name is empty)" >&2 + exit 2 + fi + done +} + +if [ "${1:-}" = --check ] && [ $# -eq 1 ]; then + check_env + exit 0 +fi +[ $# -eq 2 ] || { echo "usage: $0 | --check" >&2; exit 2; } +check_env + +APP=$1 +OUT=$2 +[ -d "$APP/Contents/MacOS" ] || { echo "$APP is not an app bundle" >&2; exit 2; } +mkdir -p "$OUT" +OUT=$(cd "$OUT" && pwd) +rm -f "$OUT/Codeman.dmg" "$OUT/Codeman.dmg.sha256" +NAME=$(basename "$APP" .app) + +if [ -n "${NOTARY_PROFILE:-}" ]; then + NOTARY_AUTH=(--keychain-profile "$NOTARY_PROFILE") + if [ -n "${NOTARY_KEYCHAIN:-}" ]; then + NOTARY_AUTH=("${NOTARY_AUTH[@]}" --keychain "$NOTARY_KEYCHAIN") + fi +else + # On notarytool's command line, which the job's log never shows. + NOTARY_AUTH=(--apple-id "$APPLE_ID" --password "$APPLE_APP_PASSWORD" --team-id "$APPLE_TEAM_ID") +fi + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Submit a file to the notary service and wait. Anything but Accepted stops the +# script with the submission id and Apple's log, which names the file and the +# reason (an unsigned binary, a missing timestamp, no hardened runtime...). +notarize() { + local file=$1 result="$WORK/notary.plist" rc=0 status id + echo "Submitting $(basename "$file") to Apple's notary service" + xcrun notarytool submit "$file" "${NOTARY_AUTH[@]}" --wait --timeout 45m \ + --output-format plist > "$result" || rc=$? + status=$(/usr/libexec/PlistBuddy -c 'Print :status' "$result" 2>/dev/null || true) + id=$(/usr/libexec/PlistBuddy -c 'Print :id' "$result" 2>/dev/null || true) + if [ "$status" != Accepted ]; then + echo "Notarization of $(basename "$file") ended as '${status:-unknown}' (submission ${id:-none}, notarytool exit $rc)" >&2 + cat "$result" >&2 || true + if [ -n "$id" ]; then + xcrun notarytool log "$id" "${NOTARY_AUTH[@]}" >&2 || true + fi + exit 1 + fi + echo "Notarized $(basename "$file") (submission $id)" +} + +# The app: hardened runtime (which notarization requires), a secure timestamp, +# and the entitlements the hardened runtime needs for microphone and camera. +codesign --force --options runtime --timestamp --entitlements "$ENTITLEMENTS" \ + --sign "$SIGN_IDENTITY" "$APP" +codesign --verify --strict --verbose=2 "$APP" +DETAILS=$(codesign --display --verbose=2 "$APP" 2>&1) +printf '%s\n' "$DETAILS" | grep -E '^(Authority|TeamIdentifier|Timestamp)=' || true +case "$DETAILS" in + *"Authority=Developer ID Application: "*) ;; + *) echo "$APP is not signed with a Developer ID Application certificate" >&2; exit 1 ;; +esac +case "$DETAILS" in + *"(runtime)"*) ;; + *) echo "$APP is signed without the hardened runtime" >&2; exit 1 ;; +esac + +ditto -c -k --keepParent "$APP" "$WORK/$NAME.zip" +notarize "$WORK/$NAME.zip" +xcrun stapler staple "$APP" +xcrun stapler validate "$APP" +spctl --assess --type execute --verbose=2 "$APP" + +# The DMG around the stapled app, signed and notarized in its own right, so the +# image opens without a warning and the app inside opens offline. +DMG="$WORK/Codeman.dmg" +"$DESKTOP/scripts/make-dmg.sh" "$APP" "$DMG" +codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG" +codesign --verify --strict --verbose=2 "$DMG" +notarize "$DMG" +xcrun stapler staple "$DMG" +xcrun stapler validate "$DMG" +spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG" + +(cd "$WORK" && shasum -a 256 Codeman.dmg > Codeman.dmg.sha256) +mv "$DMG" "$WORK/Codeman.dmg.sha256" "$OUT/" +echo "Signed and notarized $APP" +echo "Built $OUT/Codeman.dmg ($(du -h "$OUT/Codeman.dmg" | cut -f1)), sha256 $(cut -d' ' -f1 "$OUT/Codeman.dmg.sha256")"