diff --git a/build_tools/macos/build_app.sh b/build_tools/macos/build_app.sh index fde58a336..254dcdcbf 100755 --- a/build_tools/macos/build_app.sh +++ b/build_tools/macos/build_app.sh @@ -7,6 +7,87 @@ lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "${script_dir}/../.." && pwd)" +current_cmd_pid="" +current_hb_pid="" + +kill_tree() { + local pid="$1" + [[ -z "${pid}" ]] && return 0 + # Recurse into children first (best-effort). + local child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + kill_tree "$child" + done + kill -TERM "$pid" >/dev/null 2>&1 || true +} + +cleanup() { + # Never fail cleanup on errors. + set +e + if [[ -n "${current_hb_pid}" ]]; then + kill "${current_hb_pid}" >/dev/null 2>&1 || true + wait "${current_hb_pid}" >/dev/null 2>&1 || true + current_hb_pid="" + fi + if [[ -n "${current_cmd_pid}" ]]; then + # If still running, terminate process tree. + kill -0 "${current_cmd_pid}" >/dev/null 2>&1 && kill_tree "${current_cmd_pid}" + current_cmd_pid="" + fi +} + +trap 'cleanup; exit 130' INT TERM +trap 'cleanup' EXIT + +run_with_heartbeat() { + local desc="$1" + shift + + local start now elapsed hb_pid + start="$(date +%s)" + + echo "==> [$(ts)] ${desc}" + + # Run the command in the background so we can reliably clean it up on Ctrl-C. + set +e + "$@" & + local cmd_pid=$! + set -e + current_cmd_pid="$cmd_pid" + + ( + while kill -0 "$cmd_pid" >/dev/null 2>&1; do + sleep 20 + now="$(date +%s)" + elapsed="$((now - start))" + echo " [$(ts)] ... still working (${elapsed}s elapsed) ..." + done + ) & + hb_pid="$!" + current_hb_pid="$hb_pid" + + set +e + wait "$cmd_pid" + local rc=$? + set -e + + # Clear globals before stopping heartbeat to avoid cleanup double-kill. + current_cmd_pid="" + kill "$hb_pid" >/dev/null 2>&1 || true + wait "$hb_pid" >/dev/null 2>&1 || true + current_hb_pid="" + + now="$(date +%s)" + elapsed="$((now - start))" + + if [[ $rc -ne 0 ]]; then + echo "Error: '${desc}' failed after ${elapsed}s (exit $rc)." >&2 + return "$rc" + fi + + echo "==> [$(ts)] Done: ${desc} (${elapsed}s)" +} + usage() { cat <<'EOF' Usage: @@ -87,11 +168,41 @@ echo "==> [$(ts)] Config: ${config}" if [[ "$do_clean" -eq 1 ]]; then echo "==> [$(ts)] Cleaning build dir" - rm -rf "$build_dir" + # macOS 26+ can apply provenance metadata that blocks deletion even when permissions look normal. + # Clear common xattrs and immutable flags before deleting. + xattr -dr com.apple.provenance "$build_dir" >/dev/null 2>&1 || true + xattr -dr com.apple.quarantine "$build_dir" >/dev/null 2>&1 || true + chflags -R nouchg,noschg "$build_dir" >/dev/null 2>&1 || true + rm -rf "$build_dir" || { + echo "Error: failed to remove build dir: $build_dir" >&2 + echo "This is usually due to macOS provenance/flags. Try:" >&2 + echo " xattr -dr com.apple.provenance \"$build_dir\"" >&2 + echo " chflags -R nouchg,noschg \"$build_dir\"" >&2 + echo " rm -rf \"$build_dir\"" >&2 + exit 1 + } fi mkdir -p "$build_dir" +# If you've run a previously-built app directly from the build directory, macOS can apply provenance +# metadata that makes the bundle effectively immutable (even when permissions look normal). +# That breaks CMake because it needs to update strawberry.app/Contents/Info.plist during configure/build. +app_bundle="${build_dir}/strawberry.app" +if [[ -d "${app_bundle}/Contents" ]]; then + # Try to clear provenance/quarantine metadata first (best effort). + xattr -dr com.apple.provenance "${app_bundle}" >/dev/null 2>&1 || true + xattr -dr com.apple.quarantine "${app_bundle}" >/dev/null 2>&1 || true + + # If the bundle is still not writable, remove it so CMake can recreate it. + if ! ( : > "${app_bundle}/Contents/.cmake_write_test" ) 2>/dev/null; then + echo "==> [$(ts)] Existing ${app_bundle} is not writable (likely macOS provenance). Removing it." + rm -rf "${app_bundle}" + else + rm -f "${app_bundle}/Contents/.cmake_write_test" >/dev/null 2>&1 || true + fi +fi + # Make pkg-config more reliable with Homebrew. export PKG_CONFIG_PATH="${brew_prefix}/lib/pkgconfig:${brew_prefix}/share/pkgconfig:${PKG_CONFIG_PATH:-}" @@ -100,7 +211,6 @@ export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-12.0}" cmake_prefix_path="${qt_prefix};${brew_prefix}" -echo "==> [$(ts)] Configuring (CMAKE_PREFIX_PATH=${cmake_prefix_path})" cmake_extra_args=() # Optional: override Sparkle update feed / key for your own published builds. @@ -114,16 +224,17 @@ if [[ -n "${SPARKLE_PUBLIC_ED25519_KEY:-}" ]]; then cmake_extra_args+=("-DSPARKLE_PUBLIC_ED25519_KEY=${SPARKLE_PUBLIC_ED25519_KEY}") fi -cmake -S "$repo_root" -B "$build_dir" -G Ninja \ - -DCMAKE_BUILD_TYPE="$config" \ - -DCMAKE_PREFIX_PATH="$cmake_prefix_path" \ - -DCMAKE_FRAMEWORK_PATH="${brew_prefix}/Frameworks;${brew_prefix}/opt/sparkle-framework/Frameworks" \ - -DOPTIONAL_COMPONENTS_MISSING_DEPS_ARE_FATAL=OFF \ - ${cmake_extra_args+"${cmake_extra_args[@]}"} \ - ${icu_prefix:+-DICU_ROOT="$icu_prefix"} +run_with_heartbeat "Configuring (CMAKE_PREFIX_PATH=${cmake_prefix_path})" \ + cmake -S "$repo_root" -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE="$config" \ + -DCMAKE_PREFIX_PATH="$cmake_prefix_path" \ + -DCMAKE_FRAMEWORK_PATH="${brew_prefix}/Frameworks;${brew_prefix}/opt/sparkle-framework/Frameworks" \ + -DOPTIONAL_COMPONENTS_MISSING_DEPS_ARE_FATAL=OFF \ + ${cmake_extra_args+"${cmake_extra_args[@]}"} \ + ${icu_prefix:+-DICU_ROOT="$icu_prefix"} -echo "==> [$(ts)] Building" -cmake --build "$build_dir" --parallel +run_with_heartbeat "Building" \ + cmake --build "$build_dir" --parallel if [[ "$do_deploy" -eq 1 ]]; then echo "==> [$(ts)] Preparing env for 'deploy' target (GIO/GStreamer)" @@ -140,13 +251,13 @@ if [[ "$do_deploy" -eq 1 ]]; then fi fi - echo "==> [$(ts)] Running: deploy" - cmake --build "$build_dir" --target deploy + run_with_heartbeat "Running: deploy" \ + cmake --build "$build_dir" --target deploy fi if [[ "$do_dmg" -eq 1 ]]; then - echo "==> [$(ts)] Running: dmg" - cmake --build "$build_dir" --target dmg + run_with_heartbeat "Running: dmg" \ + cmake --build "$build_dir" --target dmg fi echo "==> [$(ts)] Done" diff --git a/build_tools/macos/build_sign_notarize.sh b/build_tools/macos/build_sign_notarize.sh index 58460d534..db1fa4191 100755 --- a/build_tools/macos/build_sign_notarize.sh +++ b/build_tools/macos/build_sign_notarize.sh @@ -118,9 +118,10 @@ bin_path="${app_path}/Contents/MacOS/strawberry" zip_path="${build_dir}/strawberry-notarize.zip" dmg_path="" -notarize_and_staple() { +notarize_and_maybe_staple() { local file_path="$1" local label="$2" + local do_staple="${3:-1}" echo "==> [$(ts)] Notarizing ${label}" local out @@ -146,8 +147,10 @@ notarize_and_staple() { exit 1 fi - echo "==> [$(ts)] Stapling ${label}" - xcrun stapler staple "$file_path" + if [[ "$do_staple" -eq 1 ]]; then + echo "==> [$(ts)] Stapling ${label}" + xcrun stapler staple "$file_path" + fi } if [[ -z "$identity" ]]; then @@ -167,9 +170,6 @@ if [[ "$do_clean" -eq 1 ]]; then build_args+=( "--clean" ); fi if [[ -n "$build_dir" ]]; then build_args+=( "--build-dir" "$build_dir" ); fi if [[ "$do_deploy" -eq 1 ]]; then build_args+=( "--deploy" ); fi -# Let CMake (deploy/macdeployqt/create-dmg) know the signing identity if it wants it. -export APPLE_DEVELOPER_ID="$identity" - "${repo_root}/build_tools/macos/build_app.sh" "${build_args[@]}" if [[ ! -x "$bin_path" ]]; then @@ -183,6 +183,16 @@ if [[ -n "$entitlements" ]]; then codesign_args+=( --entitlements "$entitlements" ) fi +# Clean up any leftover codesign temp files from previous interrupted runs. +# codesign may create *.cstemp alongside binaries while updating signatures. +find "$app_path" -name "*.cstemp" -print0 2>/dev/null | while IFS= read -r -d '' f; do + rm -f "$f" || true +done + +# Clear macOS provenance/quarantine metadata which can interfere with modifying files in-place. +xattr -dr com.apple.provenance "$app_path" >/dev/null 2>&1 || true +xattr -dr com.apple.quarantine "$app_path" >/dev/null 2>&1 || true + # Sign nested code first, then frameworks, then the main app bundle. # # Important: do NOT codesign individual files *inside* a .framework bundle (e.g. Sparkle.framework/Sparkle), @@ -191,6 +201,7 @@ fi # 1) Sign dylibs and standalone executables that are NOT inside a .framework/.app/.xpc bundle. find "$app_path" -type f \( -name "*.dylib" -o -name "*.so" -o -perm -111 \) \ + ! -name "*.cstemp" \ ! -path "*/Contents/Frameworks/*.framework/*" \ ! -path "*/Contents/Frameworks/*.app/*" \ ! -path "*/Contents/Frameworks/*.xpc/*" \ @@ -211,6 +222,7 @@ done sparkle_fw="$app_path/Contents/Frameworks/Sparkle.framework" if [[ -d "$sparkle_fw" ]]; then find "$sparkle_fw/Versions" -type f -perm -111 \ + ! -name "*.cstemp" \ ! -path "*/_CodeSignature/*" \ -print0 2>/dev/null | while IFS= read -r -d '' f; do codesign "${codesign_args[@]}" "$f" >/dev/null @@ -233,15 +245,44 @@ rm -f "$zip_path" ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_path" if [[ "$skip_notarize" -eq 0 ]]; then - notarize_and_staple "$zip_path" "ZIP" + # ZIP archives cannot be stapled; notarization is still useful for distribution and Sparkle. + notarize_and_maybe_staple "$zip_path" "ZIP" 0 echo "==> [$(ts)] Stapling app" xcrun stapler staple "$app_path" fi if [[ "$do_dmg" -eq 1 ]]; then - echo "==> [$(ts)] Building DMG" - cmake --build "$build_dir" --target dmg - dmg_path="$(ls -1t "$build_dir"/strawberry-*.dmg 2>/dev/null | head -n 1 || true)" + echo "==> [$(ts)] Building DMG (from already-signed app; no redeploy)" + if ! command -v create-dmg >/dev/null 2>&1; then + echo "Error: create-dmg not found. Install it with Homebrew (it's in Brewfile):" >&2 + echo " ./build_tools/macos/install_brew_deps.sh" >&2 + exit 1 + fi + + # Build a versioned DMG name using Info.plist (falls back to Strawberry version constant). + plist="${app_path}/Contents/Info.plist" + bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleVersion' "$plist" 2>/dev/null || true)" + if [[ -z "${bundle_version}" ]]; then + bundle_version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist" 2>/dev/null || true)" + fi + if [[ -z "${bundle_version}" ]]; then + bundle_version="unknown" + fi + arch="$(uname -m)" + dmg_path="${build_dir}/strawberry-${bundle_version}-${arch}.dmg" + + rm -f "$dmg_path" + ( + cd "$build_dir" + create-dmg \ + --volname strawberry \ + --background "${repo_root}/dist/macos/dmg_background.png" \ + --app-drop-link 450 218 \ + --icon strawberry.app 150 218 \ + --window-size 600 450 \ + "$(basename "$dmg_path")" \ + strawberry.app + ) if [[ -z "$dmg_path" ]]; then echo "Error: DMG was not created in $build_dir" >&2 exit 1 @@ -251,7 +292,7 @@ if [[ "$do_dmg" -eq 1 ]]; then codesign --force --timestamp --sign "$identity" "$dmg_path" if [[ "$skip_notarize" -eq 0 ]]; then - notarize_and_staple "$dmg_path" "DMG" + notarize_and_maybe_staple "$dmg_path" "DMG" 1 fi fi diff --git a/cmake/Dmg.cmake b/cmake/Dmg.cmake index ba042ac57..83916f3fb 100644 --- a/cmake/Dmg.cmake +++ b/cmake/Dmg.cmake @@ -53,16 +53,9 @@ endif() if(MACDEPLOYQT_EXECUTABLE) - # Allow build scripts to provide a codesign identity via environment variable. - # This is used to optionally pass -codesign=... to macdeployqt and --codesign to create-dmg. - if(NOT APPLE_DEVELOPER_ID AND DEFINED ENV{APPLE_DEVELOPER_ID}) - set(APPLE_DEVELOPER_ID "$ENV{APPLE_DEVELOPER_ID}") - endif() - - if(APPLE_DEVELOPER_ID) - set(MACDEPLOYQT_CODESIGN -codesign=${APPLE_DEVELOPER_ID}) - set(CREATEDMG_CODESIGN --codesign ${APPLE_DEVELOPER_ID}) - endif() + # Note: We intentionally do NOT codesign during the CMake 'deploy'/'dmg' targets. + # macdeployqt can optionally sign, but passing identities safely through Ninja's /bin/sh wrapper is brittle. + # This repo's signing/notarization pipeline is handled in build_tools/macos/build_sign_notarize.sh instead. if(CREATEDMG_SKIP_JENKINS) set(CREATEDMG_SKIP_JENKINS_ARG "--skip-jenkins") endif() @@ -82,14 +75,30 @@ if(MACDEPLOYQT_EXECUTABLE) list(APPEND _deploy_commands COMMAND ${CMAKE_SOURCE_DIR}/dist/macos/macgstcopy.sh ${CMAKE_BINARY_DIR}/strawberry.app - COMMAND ${MACDEPLOYQT_EXECUTABLE} strawberry.app -verbose=3 -executable=${CMAKE_BINARY_DIR}/strawberry.app/Contents/PlugIns/gst-plugin-scanner ${MACDEPLOYQT_CODESIGN} + COMMAND ${MACDEPLOYQT_EXECUTABLE} strawberry.app -verbose=3 -executable=${CMAKE_BINARY_DIR}/strawberry.app/Contents/PlugIns/gst-plugin-scanner ) - add_custom_target(deploy + # Make 'deploy' incremental: + # - add_custom_target() is always out-of-date, so it reruns every time. + # - using a stamp file makes Ninja/Make skip deploy when inputs haven't changed. + set(_deploy_stamp "${CMAKE_BINARY_DIR}/deploy_app_bundle.stamp") + + add_custom_command( + OUTPUT "${_deploy_stamp}" ${_deploy_commands} + COMMAND ${CMAKE_COMMAND} -E touch "${_deploy_stamp}" + COMMENT "Deploying app bundle (bundling Sparkle/GStreamer + macdeployqt)" WORKING_DIRECTORY ${CMAKE_BINARY_DIR} - DEPENDS strawberry + DEPENDS + strawberry + "${CMAKE_BINARY_DIR}/dist/macos/Info.plist" + "${CMAKE_SOURCE_DIR}/dist/macos/strawberry.icns" + "${CMAKE_SOURCE_DIR}/dist/macos/macgstcopy.sh" + "${CMAKE_SOURCE_DIR}/dist/macos/bundle_sparkle.sh" ) + + add_custom_target(deploy DEPENDS "${_deploy_stamp}") + if(MACDEPLOYCHECK_EXECUTABLE) add_custom_target(deploycheck COMMAND ${MACDEPLOYCHECK_EXECUTABLE} strawberry.app @@ -97,8 +106,9 @@ if(MACDEPLOYQT_EXECUTABLE) endif() if(CREATEDMG_EXECUTABLE) add_custom_target(dmg - COMMAND ${CREATEDMG_EXECUTABLE} --volname strawberry --background "${CMAKE_SOURCE_DIR}/dist/macos/dmg_background.png" --app-drop-link 450 218 --icon strawberry.app 150 218 --window-size 600 450 ${CREATEDMG_CODESIGN} ${CREATEDMG_SKIP_JENKINS_ARG} strawberry-${STRAWBERRY_VERSION_PACKAGE}-${CMAKE_HOST_SYSTEM_PROCESSOR}.dmg strawberry.app + COMMAND ${CREATEDMG_EXECUTABLE} --volname strawberry --background "${CMAKE_SOURCE_DIR}/dist/macos/dmg_background.png" --app-drop-link 450 218 --icon strawberry.app 150 218 --window-size 600 450 ${CREATEDMG_SKIP_JENKINS_ARG} strawberry-${STRAWBERRY_VERSION_PACKAGE}-${CMAKE_HOST_SYSTEM_PROCESSOR}.dmg strawberry.app WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + DEPENDS deploy ) endif() endif()