diff --git a/Brewfile b/Brewfile index 99a242881..02ca0c720 100644 --- a/Brewfile +++ b/Brewfile @@ -12,6 +12,9 @@ brew "cmake" brew "pkg-config" brew "ninja" +# Optional (developer): unit tests +brew "googletest" + # Core runtime/build dependencies (required by CMakeLists.txt) brew "qt" # Qt 6 (Core/Gui/Widgets/Network/Sql/Concurrent) brew "vulkan-headers" # helps Qt6Gui's WrapVulkanHeaders dependency on some setups @@ -33,6 +36,7 @@ tap "strawberry/local", "file://#{Dir.pwd}" brew "strawberry/local/kdsingleapplication-qt6" brew "strawberry/local/qtsparkle-qt6" # optional: QtSparkle integration brew "strawberry/local/sparkle-framework" # optional: Sparkle integration (framework) +brew "strawberry/local/macdeploycheck" # optional: enables CMake target 'deploycheck' (sanity checks deployed .app) # Recommended GStreamer plugin sets for broad codec support (matches README guidance) brew "gst-plugins-base" diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d3cd1d1f..009671407 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,18 @@ if(LINUX) endif() if(APPLE) + # Find Sparkle early so cmake/Dmg.cmake (deploy target) can bundle it into the app. + # Sparkle is optional; if not found, update functionality is disabled. + find_library(SPARKLE Sparkle + PATHS + /Library/Frameworks + /System/Library/Frameworks + /opt/homebrew/Frameworks + /opt/homebrew/opt/sparkle-framework/Frameworks + /usr/local/Frameworks + /usr/local/opt/sparkle-framework/Frameworks + PATH_SUFFIXES Frameworks + ) include(cmake/Dmg.cmake) endif() @@ -251,21 +263,6 @@ endif() find_package(KDSingleApplication-qt${QT_VERSION_MAJOR} 1.1.0 REQUIRED) -if(APPLE) - # Sparkle may be installed as a developer framework (e.g. via a package manager). - # Help CMake find it by searching typical Homebrew prefix locations as well. - find_library(SPARKLE Sparkle - PATHS - /Library/Frameworks - /System/Library/Frameworks - /opt/homebrew/Frameworks - /opt/homebrew/opt/sparkle-framework/Frameworks - /usr/local/Frameworks - /usr/local/opt/sparkle-framework/Frameworks - PATH_SUFFIXES Frameworks - ) -endif() - if(WIN32) find_package(getopt NAMES getopt getopt-win unofficial-getopt-win32 REQUIRED) if(TARGET getopt::getopt) diff --git a/Formula/macdeploycheck.rb b/Formula/macdeploycheck.rb new file mode 100644 index 000000000..6f5438293 --- /dev/null +++ b/Formula/macdeploycheck.rb @@ -0,0 +1,93 @@ +class Macdeploycheck < Formula + desc "Sanity checks a macOS .app bundle for accidental Homebrew runtime dependencies" + homepage "https://github.com/strawberrymusicplayer/strawberry" + url "file://#{__FILE__}" + version "0.1.0" + sha256 :no_check + license "MIT" + + depends_on :macos + + def install + (bin/"macdeploycheck").write <<~'EOS' + #!/usr/bin/env bash + set -euo pipefail + + app="${1:-}" + if [[ -z "$app" ]]; then + echo "Usage: macdeploycheck " >&2 + exit 2 + fi + if [[ ! -d "$app" ]]; then + echo "Error: app bundle not found: $app" >&2 + exit 2 + fi + if [[ ! -d "$app/Contents" ]]; then + echo "Error: not a macOS app bundle (missing Contents/): $app" >&2 + exit 2 + fi + + fail=0 + tmp="$(mktemp -t macdeploycheck.XXXXXX)" + trap 'rm -f "$tmp"' EXIT + + # Collect Mach-O files (executables + dylibs) inside the bundle. + while IFS= read -r -d '' f; do + if file "$f" | grep -q "Mach-O"; then + echo "$f" >>"$tmp" + fi + done < <(find "$app/Contents" -type f \( -perm -111 -o -name "*.dylib" -o -name "*.so" \) -print0 2>/dev/null) + + if [[ ! -s "$tmp" ]]; then + echo "Warning: no Mach-O files found under $app/Contents" >&2 + exit 0 + fi + + echo "macdeploycheck: scanning for external (Homebrew) runtime deps..." + while IFS= read -r f; do + # otool -L prints: + # : + # (compatibility version ..., current version ...) + deps="$(otool -L "$f" 2>/dev/null | tail -n +2 | awk '{print $1}' || true)" + while IFS= read -r dep; do + [[ -z "$dep" ]] && continue + + # Ignore system and rpath/loader/executable paths. + case "$dep" in + /System/*|/usr/lib/*|@rpath/*|@loader_path/*|@executable_path/*) continue ;; + esac + + # These are the common accidental runtime deps that will break distribution. + if [[ "$dep" == /opt/homebrew/* || "$dep" == /usr/local/* || "$dep" == /opt/local/* ]]; then + echo "ERROR: $f links to external path: $dep" >&2 + fail=1 + fi + done <<<"$deps" + done <"$tmp" + + if [[ "$fail" -ne 0 ]]; then + cat >&2 <<'EOM' + +One or more binaries in your .app link to a Homebrew (or MacPorts) path. +That usually means the bundle is not self-contained and will fail on other machines, +or will fail notarization/codesigning validation. + +Fix: re-run your deploy step (e.g. macdeployqt) so frameworks/dylibs are bundled and +their install names are rewritten to @rpath/@loader_path. +EOM + exit 1 + fi + + echo "OK: no external Homebrew/MacPorts runtime deps detected." + exit 0 + EOS + + chmod 0755, bin/"macdeploycheck" + end + + test do + # Basic smoke test: tool runs and prints usage. + system bin/"macdeploycheck" + end +end + diff --git a/Formula/macdeploycheck/README.md b/Formula/macdeploycheck/README.md new file mode 100644 index 000000000..b17b56c8e --- /dev/null +++ b/Formula/macdeploycheck/README.md @@ -0,0 +1,36 @@ +# `macdeploycheck` (local Homebrew formula) + +This repository includes a small helper tool called `macdeploycheck`, packaged as a local Homebrew formula. + +## What it does + +`macdeploycheck` scans a built `.app` bundle and flags common **accidental runtime dependencies** on: + +- Homebrew paths like `/opt/homebrew/...` or `/usr/local/...` +- MacPorts paths like `/opt/local/...` + +These dependencies usually mean the `.app` is **not self-contained** and may fail to run on other machines or fail notarization validation. + +## Install (via this repo's tap) + +From the Strawberry repo root: + +```bash +brew tap strawberry/local "file://$PWD" +brew install strawberry/local/macdeploycheck +``` + +Or use the repo `Brewfile`: + +```bash +brew bundle --file Brewfile +``` + +## Use + +```bash +macdeploycheck /path/to/Strawberry.app +``` + +It exits non-zero if it finds external runtime deps. + diff --git a/build_tools/macos/build_sign_notarize.sh b/build_tools/macos/build_sign_notarize.sh index bb0d13914..41b5d7c44 100755 --- a/build_tools/macos/build_sign_notarize.sh +++ b/build_tools/macos/build_sign_notarize.sh @@ -16,7 +16,8 @@ Common options: --run Perform build/sign/notarize (otherwise list identities/profiles) --release | --debug Build config (default: Release) --clean Clean build dir before build - --deploy Run CMake 'deploy' target before signing (recommended for distributing) + --deploy Run CMake 'deploy' target before signing (default: on) + --no-deploy Do not run 'deploy' (not recommended for distribution) --build-dir Override build directory Signing options: @@ -42,8 +43,10 @@ list_identities_and_profiles() { security find-identity -p codesigning -v || true echo - echo "==> [$(ts)] notarytool profiles (keychain profiles)" - xcrun notarytool list-profiles 2>/dev/null || echo "(none; create one with: xcrun notarytool store-credentials ...)" + echo "==> [$(ts)] notarytool credential profiles" + echo "Note: this Xcode notarytool version does not provide a 'list-profiles' command." + echo "If you forgot the profile name you created, check Keychain Access or re-run:" + echo " xcrun notarytool store-credentials \"\" --apple-id \"you@example.com\" --team-id \"TEAMID\"" echo echo "==> [$(ts)] Provisioning profiles (macOS)" @@ -69,7 +72,7 @@ fi do_run=0 config="Release" do_clean=0 -do_deploy=0 +do_deploy=1 build_dir="" identity="" entitlements="" @@ -83,6 +86,7 @@ while [[ $# -gt 0 ]]; do --debug) config="Debug"; shift ;; --clean) do_clean=1; shift ;; --deploy) do_deploy=1; shift ;; + --no-deploy) do_deploy=0; shift ;; --build-dir) build_dir="${2:-}"; shift 2 ;; --identity) identity="${2:-}"; shift 2 ;; --entitlements) entitlements="${2:-}"; shift 2 ;; @@ -139,12 +143,46 @@ if [[ -n "$entitlements" ]]; then codesign_args+=( --entitlements "$entitlements" ) fi -find "$app_path" -type f \( -name "*.dylib" -o -name "*.so" -o -perm -111 \) -print0 | while IFS= read -r -d '' f; do - codesign "${codesign_args[@]}" "$f" >/dev/null +# 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), +# because codesign expects frameworks to be signed as bundles and may error with +# "bundle format is ambiguous (could be app or framework)". + +# 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 \) \ + ! -path "*/Contents/Frameworks/*.framework/*" \ + ! -path "*/Contents/Frameworks/*.app/*" \ + ! -path "*/Contents/Frameworks/*.xpc/*" \ + ! -path "*/Contents/PlugIns/*.framework/*" \ + ! -path "*/Contents/PlugIns/*.app/*" \ + ! -path "*/Contents/PlugIns/*.xpc/*" \ + -print0 | while IFS= read -r -d '' f; do + codesign "${codesign_args[@]}" "$f" >/dev/null + done + +# 2) Sign nested helper apps and XPC services (Sparkle ships these inside its framework). +find "$app_path" -type d \( -name "*.xpc" -o -name "*.app" \) -print0 2>/dev/null | while IFS= read -r -d '' b; do + codesign "${codesign_args[@]}" "$b" >/dev/null done + +# 2b) Sparkle.framework contains a standalone helper executable "Autoupdate" under Versions/* that is +# not inside an .app or .xpc bundle. Notarization requires it be signed with Developer ID + timestamp. +sparkle_fw="$app_path/Contents/Frameworks/Sparkle.framework" +if [[ -d "$sparkle_fw" ]]; then + find "$sparkle_fw/Versions" -type f -perm -111 \ + ! -path "*/_CodeSignature/*" \ + -print0 2>/dev/null | while IFS= read -r -d '' f; do + codesign "${codesign_args[@]}" "$f" >/dev/null + done +fi + +# 3) Sign frameworks as bundles. find "$app_path/Contents/Frameworks" "$app_path/Contents/PlugIns" -type d -name "*.framework" -print0 2>/dev/null | while IFS= read -r -d '' fw; do codesign "${codesign_args[@]}" "$fw" >/dev/null done + +# 4) Finally sign the main app. codesign "${codesign_args[@]}" "$app_path" >/dev/null echo "==> [$(ts)] Verifying codesign" @@ -156,7 +194,25 @@ ditto -c -k --sequesterRsrc --keepParent "$app_path" "$zip_path" if [[ "$skip_notarize" -eq 0 ]]; then echo "==> [$(ts)] Notarizing" - xcrun notarytool submit "$zip_path" --keychain-profile "$notary_profile" --wait + # Use JSON output so we can reliably detect Invalid and fetch logs. + submit_json="$(xcrun notarytool submit "$zip_path" --keychain-profile "$notary_profile" --wait --output-format json --no-progress)" + submit_id="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' <<<"$submit_json" 2>/dev/null || true)" + submit_status="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' <<<"$submit_json" 2>/dev/null || true)" + + if [[ -z "$submit_id" ]]; then + echo "Error: could not parse notarization submission id. Raw output:" >&2 + echo "$submit_json" >&2 + exit 1 + fi + + echo "==> [$(ts)] Notary submission id: $submit_id" + echo "==> [$(ts)] Notary status: $submit_status" + + if [[ "$submit_status" != "Accepted" ]]; then + echo "Error: notarization failed with status '$submit_status'. Fetching log..." >&2 + xcrun notarytool log "$submit_id" --keychain-profile "$notary_profile" || true + exit 1 + fi echo "==> [$(ts)] Stapling" xcrun stapler staple "$app_path" diff --git a/build_tools/macos/install_brew_deps.sh b/build_tools/macos/install_brew_deps.sh index 504f05308..673f67bce 100755 --- a/build_tools/macos/install_brew_deps.sh +++ b/build_tools/macos/install_brew_deps.sh @@ -93,7 +93,7 @@ run_with_heartbeat "Refreshing strawberry/local tap clone" bash -lc ' git reset --hard -q "$default_ref" ' -for f in kdsingleapplication-qt6 qtsparkle-qt6 sparkle-framework libgpod; do +for f in kdsingleapplication-qt6 qtsparkle-qt6 sparkle-framework libgpod macdeploycheck; do if ! brew info "strawberry/local/${f}" >/dev/null 2>&1; then echo "Error: Missing formula strawberry/local/${f} in the tapped repo." >&2 echo "If you recently added/changed formulae, ensure they are committed, then refresh the tap:" >&2 diff --git a/cmake/Dmg.cmake b/cmake/Dmg.cmake index 837a83f72..dfc03054c 100644 --- a/cmake/Dmg.cmake +++ b/cmake/Dmg.cmake @@ -21,6 +21,36 @@ else() message(WARNING "Missing create-dmg executable.") endif() +set(_SPARKLE_FRAMEWORK_DIR "") +set(_SPARKLE_ORIGINAL_BIN_LINK "") +set(_SPARKLE_ORIGINAL_BIN_REAL "") +if(SPARKLE) + # SPARKLE may be either the framework directory or the framework binary path. + get_filename_component(_sparkle_link "${SPARKLE}" ABSOLUTE) + get_filename_component(_sparkle_real "${SPARKLE}" REALPATH) + if(_sparkle_link MATCHES "Sparkle\\.framework$") + set(_SPARKLE_FRAMEWORK_DIR "${_sparkle_real}") + set(_SPARKLE_ORIGINAL_BIN_LINK "${_sparkle_link}/Versions/B/Sparkle") + set(_SPARKLE_ORIGINAL_BIN_REAL "${_sparkle_real}/Versions/B/Sparkle") + else() + # Assume it's the framework binary path: + # .../Sparkle.framework/Versions/B/Sparkle + set(_SPARKLE_ORIGINAL_BIN_LINK "${_sparkle_link}") + set(_SPARKLE_ORIGINAL_BIN_REAL "${_sparkle_real}") + get_filename_component(_sparkle_b_dir "${_SPARKLE_ORIGINAL_BIN_REAL}" DIRECTORY) # .../Versions/B + get_filename_component(_sparkle_versions_dir "${_sparkle_b_dir}" DIRECTORY) # .../Versions + get_filename_component(_SPARKLE_FRAMEWORK_DIR "${_sparkle_versions_dir}" DIRECTORY) # .../Sparkle.framework + endif() + + if(NOT EXISTS "${_SPARKLE_FRAMEWORK_DIR}" OR NOT EXISTS "${_SPARKLE_ORIGINAL_BIN_REAL}") + set(_SPARKLE_FRAMEWORK_DIR "") + set(_SPARKLE_ORIGINAL_BIN_LINK "") + set(_SPARKLE_ORIGINAL_BIN_REAL "") + else() + message(STATUS "Sparkle.framework found: ${_SPARKLE_FRAMEWORK_DIR}") + endif() +endif() + if(MACDEPLOYQT_EXECUTABLE) if(APPLE_DEVELOPER_ID) @@ -31,12 +61,26 @@ if(MACDEPLOYQT_EXECUTABLE) set(CREATEDMG_SKIP_JENKINS_ARG "--skip-jenkins") endif() - add_custom_target(deploy - COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/{Frameworks,Resources} + set(_deploy_commands + COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Frameworks + COMMAND mkdir -p ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Resources COMMAND cp -v ${CMAKE_BINARY_DIR}/dist/macos/Info.plist ${CMAKE_BINARY_DIR}/strawberry.app/Contents/ COMMAND cp -v ${CMAKE_SOURCE_DIR}/dist/macos/strawberry.icns ${CMAKE_BINARY_DIR}/strawberry.app/Contents/Resources/ + ) + + if(_SPARKLE_FRAMEWORK_DIR) + list(APPEND _deploy_commands + COMMAND ${CMAKE_SOURCE_DIR}/dist/macos/bundle_sparkle.sh ${CMAKE_BINARY_DIR}/strawberry.app ${_SPARKLE_FRAMEWORK_DIR} ${_SPARKLE_ORIGINAL_BIN_LINK} ${_SPARKLE_ORIGINAL_BIN_REAL} + ) + endif() + + 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} + ) + + add_custom_target(deploy + ${_deploy_commands} WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS strawberry ) diff --git a/dist/macos/bundle_sparkle.sh b/dist/macos/bundle_sparkle.sh new file mode 100755 index 000000000..19c3e6651 --- /dev/null +++ b/dist/macos/bundle_sparkle.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +set -euo pipefail + +bundledir="${1:-}" +sparkle_framework_dir="${2:-}" +sparkle_bin_link="${3:-}" +sparkle_bin_real="${4:-}" + +if [[ -z "$bundledir" || -z "$sparkle_framework_dir" ]]; then + echo "Usage: $0 [sparkle_bin_link] [sparkle_bin_real]" >&2 + exit 2 +fi + +if [[ ! -d "$sparkle_framework_dir" ]]; then + echo "Sparkle.framework dir not found: $sparkle_framework_dir" >&2 + exit 1 +fi + +src_framework_dir="$sparkle_framework_dir" + +# Homebrew often provides /opt/homebrew/Frameworks/Sparkle.framework where Versions/* are symlinks +# pointing back into the Cellar. Copying that verbatim breaks inside an app bundle. +# Resolve to the real Cellar framework root via Versions/Current. +if [[ -e "${sparkle_framework_dir}/Versions/Current" ]]; then + current_real="$(cd "${sparkle_framework_dir}/Versions/Current" && pwd -P)" + # current_real is .../Sparkle.framework/Versions/B (or similar) + src_framework_dir="$(cd "${current_real}/../.." && pwd -P)" +fi + +dst_framework="${bundledir}/Contents/Frameworks/Sparkle.framework" +main_bin="${bundledir}/Contents/MacOS/strawberry" +qtsparkle_dylib="${bundledir}/Contents/Frameworks/libqtsparkle-qt6.dylib" + +mkdir -p "${bundledir}/Contents/Frameworks" + +echo "Bundling Sparkle.framework -> ${dst_framework}" +rm -rf "${dst_framework}" +# Use ditto to preserve the framework's internal symlinks/structure. +ditto "${src_framework_dir}" "${dst_framework}" + +# Prefer the canonical framework binary path. +dst_bin="${dst_framework}/Versions/Current/Sparkle" +if [[ ! -e "${dst_bin}" ]]; then + echo "Error: Sparkle binary missing at ${dst_bin}" >&2 + exit 1 +fi + +sparkle_rpath="@rpath/Sparkle.framework/Versions/Current/Sparkle" + +# Sanity check: top-level Sparkle entry should be a symlink (not a copied Mach-O file). +if [[ -e "${dst_framework}/Sparkle" && ! -L "${dst_framework}/Sparkle" ]]; then + echo "Warning: ${dst_framework}/Sparkle is not a symlink (unexpected). This can confuse codesign." >&2 +fi + +echo "Fixing Sparkle.framework install name" +install_name_tool -id "${sparkle_rpath}" "${dst_bin}" + +echo "Ensuring main binary has Frameworks rpath" +install_name_tool -add_rpath "@executable_path/../Frameworks" "${main_bin}" || true + +echo "Rewriting Sparkle.framework references to @rpath" +# Try to rewrite a few common Homebrew Sparkle install names as well, because the +# recorded install name may differ from the path returned by CMake's find_library. +old_candidates=( + "${sparkle_bin_link}" + "${sparkle_bin_real}" + "/opt/homebrew/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/A/Sparkle" + "/opt/homebrew/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/B/Sparkle" + "/opt/homebrew/Frameworks/Sparkle.framework/Versions/A/Sparkle" + "/opt/homebrew/Frameworks/Sparkle.framework/Versions/B/Sparkle" + "/usr/local/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/A/Sparkle" + "/usr/local/opt/sparkle-framework/Frameworks/Sparkle.framework/Versions/B/Sparkle" + "/usr/local/Frameworks/Sparkle.framework/Versions/A/Sparkle" + "/usr/local/Frameworks/Sparkle.framework/Versions/B/Sparkle" +) + +for old in "${old_candidates[@]}"; do + if [[ -n "${old}" ]]; then + install_name_tool -change "${old}" "${sparkle_rpath}" "${main_bin}" || true + if [[ -f "${qtsparkle_dylib}" ]]; then + install_name_tool -change "${old}" "${sparkle_rpath}" "${qtsparkle_dylib}" || true + fi + fi +done +