254 lines
9.7 KiB
Bash
Executable File
254 lines
9.7 KiB
Bash
Executable File
#!/bin/zsh
|
|
|
|
# SPDX-License-Identifier: LGPL-2.1-or-later
|
|
|
|
# Default values
|
|
SIGNING_KEY_ID="${FREECAD_SIGNING_KEY_ID}"
|
|
KEYCHAIN_PROFILE="FreeCAD"
|
|
CONTAINING_FOLDER="."
|
|
APP_NAME="FreeCAD.app"
|
|
VOLUME_NAME="FreeCAD"
|
|
DMG_NAME="FreeCAD-macOS-$(uname -m).dmg"
|
|
DMG_SETTINGS="dmg_settings.py"
|
|
|
|
# Function to display usage information
|
|
function usage {
|
|
echo "Usage: $0 [-k|--key-id <signing_key_id>] [-p|--keychain-profile <keychain_profile>]"
|
|
echo " [-d|--dir <containing_folder>] [-n|--app-name <app_name.app>]"
|
|
echo " [-v|--volume-name <volume_name>] [-o|--output <image_name.dmg>]"
|
|
echo " [-s|--dmg-settings <dmg_settings.py>]"
|
|
echo
|
|
echo "This script signs and notarizes a FreeCAD.app bundle. It expects that the bundle is in a folder"
|
|
echo "by itself (that folder will be used as the basis for the created disk image file, so anything"
|
|
echo "else in it will become part of the image). That folder should be located in the same folder as"
|
|
echo "this script."
|
|
echo
|
|
echo "If <signing_key_id> is not passed it defaults to env variable FREECAD_SIGNING_KEY_ID, it should"
|
|
echo "be a Developer ID Application certificate that has been installed into the login keychain."
|
|
echo "For a list of available keys see the output of"
|
|
echo " security find-identity -p basic -v"
|
|
echo "For instructions on how to configure the credentials for the tool for use before running this"
|
|
echo "script see the documentation for"
|
|
echo " xcrun notarytool store-credentials"
|
|
|
|
exit 1
|
|
}
|
|
|
|
# Parse command line arguments
|
|
while [[ "$#" -gt 0 ]]; do
|
|
case $1 in
|
|
-k|--key-id)
|
|
SIGNING_KEY_ID="$2"
|
|
shift 2
|
|
;;
|
|
-p|--keychain-profile)
|
|
KEYCHAIN_PROFILE="$2"
|
|
shift 2
|
|
;;
|
|
-d|--dir)
|
|
CONTAINING_FOLDER="$2"
|
|
shift 2
|
|
;;
|
|
-n|--app-name)
|
|
APP_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-v|--volume-name)
|
|
VOLUME_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-o|--output)
|
|
DMG_NAME="$2"
|
|
shift 2
|
|
;;
|
|
-s|--dmg-settings)
|
|
DMG_SETTINGS="$2"
|
|
shift 2
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
;;
|
|
*)
|
|
echo "Unknown parameter passed: $1"
|
|
usage
|
|
;;
|
|
esac
|
|
done
|
|
|
|
# Check if SIGNING_KEY_ID is set
|
|
if [ -z "$SIGNING_KEY_ID" ]; then
|
|
echo "Error: Signing key ID is required."
|
|
usage
|
|
fi
|
|
|
|
# Check for dmgbuild executable
|
|
if ! command -v dmgbuild &> /dev/null; then
|
|
echo 'Error: dmgbuild not installed. Please install it'
|
|
echo '- using pixi:'
|
|
echo 'pixi g install dmgbuild --with pyobjc-framework-Quartz'
|
|
echo '- using pip:'
|
|
echo 'pip3 install dmgbuild pyobjc-framework-Quartz'
|
|
exit 1
|
|
fi
|
|
|
|
function run_codesign {
|
|
echo "Signing $1"
|
|
/usr/bin/codesign --options runtime -f -s ${SIGNING_KEY_ID} --timestamp --entitlements entitlements.plist "$1"
|
|
}
|
|
|
|
function run_codesign_extension {
|
|
local target="$1"
|
|
local entitlements_file="$2"
|
|
echo "Signing extension $target with entitlements $entitlements_file"
|
|
/usr/bin/codesign --options runtime -f -s ${SIGNING_KEY_ID} --timestamp --entitlements "$entitlements_file" "$target"
|
|
}
|
|
|
|
IFS=$'\n'
|
|
dylibs=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -name "*.dylib"))
|
|
shared_objects=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -name "*.so"))
|
|
bundles=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -name "*.bundle"))
|
|
executables=($(/usr/bin/find "${CONTAINING_FOLDER}/${APP_NAME}" -type f -perm +111 -exec file {} + | grep "Mach-O 64-bit executable" | sed 's/:.*//g'))
|
|
IFS=$' \t\n' # The default
|
|
|
|
signed_files=("${dylibs[@]}" "${shared_objects[@]}" "${bundles[@]}" "${executables[@]}")
|
|
|
|
# This list of files is generated from:
|
|
# file `find . -type f -perm +111 -print` | grep "Mach-O 64-bit executable" | sed 's/:.*//g'
|
|
for exe in ${signed_files}; do
|
|
# Skip .appex executables as they will be signed separately with their bundles
|
|
if [[ "$exe" != */Contents/PlugIns/*.appex/* ]]; then
|
|
run_codesign "${exe}"
|
|
fi
|
|
done
|
|
|
|
# Two additional files that must be signed that aren't caught by the above searches:
|
|
run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/packages.txt"
|
|
run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/Library/QuickLook/QuicklookFCStd.qlgenerator/Contents/MacOS/QuicklookFCStd"
|
|
|
|
# Sign new Swift QuickLook extensions (macOS 15.0+) with their specific entitlements
|
|
# These must be signed before the app itself to avoid overriding the extension signatures
|
|
if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns" ]; then
|
|
# Find the entitlements files relative to script location
|
|
# Script is in package/scripts/, entitlements are in src/MacAppBundle/QuickLook/modern/
|
|
SCRIPT_DIR="${0:A:h}" # zsh equivalent of dirname with full path resolution
|
|
PREVIEW_ENTITLEMENTS="${SCRIPT_DIR}/../../src/MacAppBundle/QuickLook/modern/PreviewExtension.entitlements"
|
|
THUMBNAIL_ENTITLEMENTS="${SCRIPT_DIR}/../../src/MacAppBundle/QuickLook/modern/ThumbnailExtension.entitlements"
|
|
|
|
# Sign individual executables within .appex bundles first
|
|
if [ -f "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension" ]; then
|
|
run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex/Contents/MacOS/FreeCADThumbnailExtension"
|
|
fi
|
|
if [ -f "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension" ]; then
|
|
run_codesign "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex/Contents/MacOS/FreeCADPreviewExtension"
|
|
fi
|
|
|
|
# Then sign the .appex bundles themselves with extension-specific entitlements
|
|
if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex" ] && [ -f "$THUMBNAIL_ENTITLEMENTS" ]; then
|
|
run_codesign_extension "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADThumbnailExtension.appex" "$THUMBNAIL_ENTITLEMENTS"
|
|
fi
|
|
if [ -d "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex" ] && [ -f "$PREVIEW_ENTITLEMENTS" ]; then
|
|
run_codesign_extension "${CONTAINING_FOLDER}/${APP_NAME}/Contents/PlugIns/FreeCADPreviewExtension.appex" "$PREVIEW_ENTITLEMENTS"
|
|
fi
|
|
fi
|
|
|
|
# Finally, sign the app itself (must be done last)
|
|
run_codesign "${CONTAINING_FOLDER}/${APP_NAME}"
|
|
|
|
# Create a disk image from the folder
|
|
echo "Creating disk image ${DMG_NAME}"
|
|
dmgbuild -s ${DMG_SETTINGS} -Dcontaining_folder="${CONTAINING_FOLDER}" -Dapp_name="${APP_NAME}" "${VOLUME_NAME}" "${DMG_NAME}"
|
|
|
|
ID_FILE="${DMG_NAME}.notarization_id"
|
|
|
|
# Submit it for notarization (requires that an App Store API Key has been set up in the notarytool)
|
|
# This is a *very slow* process, and occasionally the GitHub runners lose the internet connection for a short time
|
|
# during the run. So in order to be fault-tolerant, this script polls, instead of using --wait
|
|
submit_notarization_request() {
|
|
if [[ -s "${ID_FILE}" ]]; then
|
|
cat "${ID_FILE}"
|
|
return
|
|
fi
|
|
local out
|
|
if ! out=$(xcrun notarytool submit --keychain-profile "${KEYCHAIN_PROFILE}" \
|
|
--output-format json --no-progress "${DMG_NAME}" 2>&1); then
|
|
print -r -- "$out" >&2
|
|
return 1
|
|
fi
|
|
# We asked for JSON output so we had something stable, but of course parsing JSON with ZSH is ugly, so a quick bit of
|
|
# Python does it instead...
|
|
local id
|
|
id=$(print -r -- "$out" |
|
|
/usr/bin/python3 -c 'import sys, json; print(json.load(sys.stdin).get("id",""))'
|
|
)
|
|
[[ -n "$id" ]] || { print -r -- "Could not parse submission id" >&2; return 1; }
|
|
print -r -- "$id" > "${ID_FILE}"
|
|
print -r -- "$id" # ID is a string here, not an integer, so I can't just return it
|
|
}
|
|
|
|
wait_for_notarization_result() {
|
|
local id="$1" attempt=0
|
|
while :; do
|
|
if xcrun notarytool wait "$id" --keychain-profile "${KEYCHAIN_PROFILE}" \
|
|
--timeout 10m --no-progress >/dev/null; then
|
|
return 0
|
|
fi
|
|
|
|
(( attempt++ ))
|
|
# If the failure was transient (timeout/HTTP/connection) just retry, but make sure to check to see if the problem
|
|
# was actually that the signing failed before retrying.
|
|
local tmp_json
|
|
tmp_json=$(mktemp)
|
|
trap 'rm -f "$tmp_json"' EXIT INT TERM
|
|
|
|
xcrun notarytool info "$id" --keychain-profile "${KEYCHAIN_PROFILE}" --output-format json 2>/dev/null > "$tmp_json"
|
|
/usr/bin/python3 - "$tmp_json" <<'PY'
|
|
import sys, json
|
|
try:
|
|
with open(sys.argv[1]) as f:
|
|
s = (json.load(f).get("status") or "").lower()
|
|
if s in ("invalid", "rejected"):
|
|
sys.exit(2)
|
|
else:
|
|
sys.exit(0)
|
|
except Exception:
|
|
sys.exit(1)
|
|
PY
|
|
rc=$?
|
|
|
|
rm -f "$tmp_json"
|
|
|
|
if [[ $rc == 2 ]]; then
|
|
print -r -- "Notarization was not accepted by Apple:" >&2
|
|
xcrun notarytool log "$id" --keychain-profile "${KEYCHAIN_PROFILE}" >&2
|
|
return 3
|
|
fi
|
|
|
|
if [[ $attempt -gt 120 ]]; then
|
|
print -r -- "🏳️ Notarization is taking too long, bailing out. 🏳️" >&2
|
|
return 4
|
|
fi
|
|
sleep $(( (attempt<6?2**attempt:60) + RANDOM%5 )) # Increasing timeout plus jitter for multi-run safety
|
|
done
|
|
}
|
|
|
|
if ! id="$(submit_notarization_request)"; then
|
|
print -r -- "❌ Failed to submit notarization request" >&2
|
|
exit 1
|
|
fi
|
|
if [[ -z "$id" ]]; then
|
|
print -r -- "❌ Submission succeeded but no ID was returned" >&2
|
|
exit 1
|
|
fi
|
|
print "Notarization submission ID: $id"
|
|
|
|
if wait_for_notarization_result "$id"; then
|
|
print "✅ Notarization succeeded. Stapling..."
|
|
xcrun stapler staple "${DMG_NAME}"
|
|
print "Stapled: ${DMG_NAME}"
|
|
rm -f "${ID_FILE}"
|
|
else
|
|
rc=$?
|
|
print "❌ Notarization failed (code $rc)." >&2
|
|
exit "$rc"
|
|
fi
|