diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e55616e43f..c17649ea3c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -421,56 +421,123 @@ jobs: # }}} # {{{ macOS macOSArm: - runs-on: macos-latest + # Pinned, not macos-latest, so the toolchain and SDK behind a release are reproducible + # and a GitHub image rotation cannot silently change what ships. + # + # The minimum macOS of the .dmg no longer depends on this: the libraries Contour links + # are built from source by vcpkg against CMAKE_OSX_DEPLOYMENT_TARGET (13.3), not taken + # from the runner's Homebrew bottles. CONTOUR_MACOS_MIN_SUPPORTED enforces that. + runs-on: macos-15 steps: - uses: actions/checkout@v4 - name: set variables id: set_vars run: | ./scripts/ci-set-vars.sh - # Please read the following link on how to use MacOS code signing on Github CI: - # https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development - echo "CODE_SIGN_CERTIFICATE_ID=Developer ID Application: Christian Parpart (6T525MU9UR)" >> "$GITHUB_OUTPUT" + # Signing is gated on whether the secrets are actually reachable, not on the + # branch name. GitHub withholds secrets from fork pull requests, which is the + # real precondition; a branch-name test only approximates it, and approximated + # it badly in both directions -- fork PRs still asked codesign for a Developer ID + # that was in no keychain (breaking every PR run of this job), while a release + # branch's signing path went completely unexercised until it was already on + # master. Signing has to be proven before the merge, not after it. + if [[ -n "$HAS_SIGNING_SECRETS" ]]; then + echo "CODE_SIGN_CERTIFICATE_ID=Developer ID Application: Christian Parpart (6T525MU9UR)" >> "$GITHUB_OUTPUT" + echo "NOTARIZE=ON" >> "$GITHUB_OUTPUT" + else + echo "CODE_SIGN_CERTIFICATE_ID=-" >> "$GITHUB_OUTPUT" + echo "NOTARIZE=OFF" >> "$GITHUB_OUTPUT" + fi + # Stapled together with the image, never separately. A ticket on the .dmg alone + # covers the download, but the app dragged out of it carries none -- so its first + # launch depends on Gatekeeper reaching Apple, and a user who is offline, behind a + # captive portal or on a restricted network gets exactly the dialog this pipeline + # exists to prevent: "Apple could not verify ... is free of malware". Stapling the + # app makes the installed copy self-sufficient. It costs a second Apple round trip + # of a minute or two, which is not a price worth haggling over against that. + if [[ -n "$HAS_SIGNING_SECRETS" ]]; then + echo "STAPLE_APP=ON" >> "$GITHUB_OUTPUT" + else + echo "STAPLE_APP=OFF" >> "$GITHUB_OUTPUT" + fi env: REPOSITORY: ${{ github.event.repository.name }} - - name: Install the Apple certificate and provisioning profile - if: github.ref == 'refs/heads/master' || github.head_ref == 'release' + # Via the environment, not interpolated into the script: a branch name is + # attacker-controlled on a fork's pull request. + GH_REF: ${{ github.ref }} + GH_HEAD_REF: ${{ github.head_ref }} + # Empty on fork pull requests, where GitHub withholds secrets. Only the + # emptiness is observable here -- the value never reaches the log. + HAS_SIGNING_SECRETS: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} + - name: Install the Apple signing certificate + if: steps.set_vars.outputs.NOTARIZE == 'ON' env: BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }} P12_PASSWORD: ${{ secrets.P12_PASSWORD }} - BUILD_PROVISION_PROFILE_BASE64: ${{ secrets.BUILD_PROVISION_PROFILE_BASE64 }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | - # create variables + # @see https://docs.github.com/en/actions/deployment/deploying-xcode-applications/installing-an-apple-certificate-on-macos-runners-for-xcode-development + set -eu CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 - PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - # import certificate and provisioning profile from secrets echo -n "$BUILD_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH - echo -n "$BUILD_PROVISION_PROFILE_BASE64" | base64 --decode -o $PP_PATH - # create temporary keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # import certificate to keychain - security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + # -T grants codesign access to the imported key, and set-key-partition-list + # records that grant in the key's ACL. Without both, codesign blocks on a GUI + # confirmation prompt that never arrives on a runner, and the job hangs until + # its timeout rather than reporting anything useful. + security import $CERTIFICATE_PATH -P "$P12_PASSWORD" -A -t cert -f pkcs12 \ + -k $KEYCHAIN_PATH -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security list-keychain -d user -s $KEYCHAIN_PATH - # apply provisioning profile - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles + security find-identity -v -p codesigning $KEYCHAIN_PATH + - name: Store the notarization credentials + if: steps.set_vars.outputs.NOTARIZE == 'ON' + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: | + # An App Store Connect API key rather than an Apple ID and app-specific + # password: it carries no personal account, can be scoped to the Developer + # role, and is revocable on its own. + set -eu + KEY_PATH=$RUNNER_TEMP/notary-key.p8 + echo -n "$APPLE_API_KEY_P8" | base64 --decode -o $KEY_PATH + xcrun notarytool store-credentials contour-notary \ + --key "$KEY_PATH" \ + --key-id "$APPLE_API_KEY_ID" \ + --issuer "$APPLE_API_ISSUER_ID" + rm -f "$KEY_PATH" - name: ccache uses: hendrikmuhs/ccache-action@v1.2 with: key: ccache-macosArm-r1 max-size: 256M + - name: Install Qt + # The official Qt binaries, as on Linux and Windows -- not Homebrew's. Homebrew + # splits Qt across ~40 per-module prefixes and macdeployqt only follows one of + # them, so a brew-Qt bundle ships missing frameworks and unresolvable @rpath + # references. That is the defect this job used to hand to users as a .dmg. + uses: jurplel/install-qt-action@v4 + with: + version: 6.11.* + modules: qtmultimedia qt5compat qtshadertools qtspeech + cache: true - name: "Install dependencies" - # Sometimes, brew thinks it needs to install from source rather than binary. - # For Qt this may take ages (many many hours). Let's not waste our CPU credits here, - # and limit the run time. + # Sometimes, brew thinks it needs to install from source rather than binary, + # so cap the runtime rather than burn CI credits on a source build. + # + # The libraries Contour links are NOT taken from Homebrew here -- see the vcpkg + # steps below for why. install-deps.sh also brings the autotools that some vcpkg + # ports need on the build host. timeout-minutes: 15 run: | set -ex @@ -480,26 +547,53 @@ jobs: brew uninstall aws-sam-cli azure-cli ./scripts/install-deps.sh + - name: "Cache vcpkg" + uses: actions/cache@v4 + with: + path: | + ~/.cache/vcpkg/archives + key: vcpkg-macos-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json', 'cmake/vcpkg-triplets/arm64-osx-contour.cmake') }} + restore-keys: vcpkg-macos- + - name: "vcpkg: clone and bootstrap" + # The libraries Contour links (openssl, libssh2, yaml-cpp, freetype, harfbuzz, + # cairo) are built from source against CMAKE_OSX_DEPLOYMENT_TARGET rather than + # taken from Homebrew. Homebrew ships one prebuilt bottle per macOS release and + # installs the one matching the runner, which made the .dmg's minimum macOS equal + # to the runner image's -- an invisible compatibility cliff that moved whenever + # GitHub rotated the image. vcpkg puts that floor under our control; the triplet at + # cmake/vcpkg-triplets/arm64-osx-contour.cmake is where it is set. + run: | + set -ex + git clone --depth 1 https://github.com/microsoft/vcpkg.git "$RUNNER_TEMP/vcpkg" + "$RUNNER_TEMP/vcpkg/bootstrap-vcpkg.sh" -disableMetrics + echo "VCPKG_ROOT=$RUNNER_TEMP/vcpkg" >> "$GITHUB_ENV" - name: "install tmux (oracle for the tmux-interop tests)" # A CI-only tool, so it is here rather than in install-deps.sh, which lists what Contour # needs to BUILD. Without it the four tmux oracles self-skip, and switching this job to # ctest (below) would otherwise have reached them only to watch them do nothing -- macOS # is where the BSD socket and Apple-libc++ differences in that code path would show up. run: brew install tmux && tmux -V - - name: "Create build directory" - run: mkdir build - name: "Generate build files" run: | - cmake . \ - -DCMAKE_BUILD_TYPE=RelWithDebInfo \ - -DCONTOUR_TESTING=ON \ - -DCMAKE_PREFIX_PATH$="$(brew --prefix qt@6)" \ - -DMACDEPLOYQT_EXECUTABLE="$(brew --prefix qt6)/bin/macdeployqt" \ - -DMACDEPLOYQT_QML_WORKAROUND=ON \ + # The `macos-package` preset IS the definition of a shippable macOS build -- + # Qt prefix, deployment target, warning policy, what lands in the bundle. This + # job overrides only the two things a preset cannot know: which identity is + # available in this run's keychain, and whether notarization secrets exist. + # Everything else must come from the preset, or the .dmg users download would + # again be built by a configuration nothing else exercises. + # + # QT_ROOT_DIR is exported by install-qt-action and is what the preset's + # CMAKE_PREFIX_PATH reads. + # The preset carries CONTOUR_MACOS_MIN_SUPPORTED=13.3, and it is enforced: if the + # bundled dylibs demand anything newer, the package step fails rather than + # shipping a .dmg that cannot launch. Configuring also triggers the vcpkg + # manifest install, which builds those dylibs against that same target. + cmake --preset macos-package \ -DCODE_SIGN_CERTIFICATE_ID="${{ steps.set_vars.outputs.CODE_SIGN_CERTIFICATE_ID }}" \ - -B build/ + -DCONTOUR_MACOS_NOTARIZE="${{ steps.set_vars.outputs.NOTARIZE }}" \ + -DCONTOUR_MACOS_STAPLE_APP="${{ steps.set_vars.outputs.STAPLE_APP }}" - name: "Build" - run: cmake --build build/ + run: cmake --build --preset macos-package - name: "tests" # ctest, not a hand-written list of binaries: the previous four-line list predated # src/coro, src/net, src/vthost and src/vtworkspace, so those suites were built here and @@ -509,7 +603,7 @@ jobs: # 15 rather than 10: with tmux installed the four oracles now actually run instead of # skipping, and each drives a real tmux server over a pty. timeout-minutes: 15 - run: ctest --test-dir build/ --output-on-failure + run: ctest --preset macos-package - name: "verify the tmux oracles actually ran" # @see the identical assertion on the Linux matrix for why a green suite is not enough: # a Catch2 SKIP exits 0, so "no tmux, nothing ran" is indistinguishable from success. @@ -518,7 +612,7 @@ jobs: # a mystery failure the day the formula moves. run: | set -o pipefail - binary=build/src/vthost/vthost_test + binary=out/macos-package/src/vthost/vthost_test output=$("$binary" '[oracle]~[imsgserver]' 2>&1) || { printf '%s\n' "$output"; exit 1; } printf '%s\n' "$output" if printf '%s' "$output" | grep -q 'skipped'; then @@ -526,21 +620,75 @@ jobs: exit 1 fi - name: "Create Package(s)" + # cpack deploys, prunes unloadable plugins, verifies the bundle is self-contained, + # signs it inside-out, notarizes and staples both the app and the image. Each of + # those steps is fatal on failure now, so a broken bundle stops here instead of + # being uploaded. Notarization waits on Apple, hence the generous timeout. + timeout-minutes: 30 run: | set -ex echo killing...; sudo pkill -9 XProtect >/dev/null || true; # see https://github.com/actions/runner-images/issues/7522 echo waiting...; while pgrep XProtect; do sleep 3; done; - cd build; cpack -G DragNDrop --verbose; cd - - # cmake --build build --target package-verbose - # ls -hl build/ - # find build/src/contour/contour.app -print - BASENAME="contour-${{ steps.set_vars.outputs.version }}-macOS-arm" - mv -vf "build/Contour-${{ steps.set_vars.outputs.VERSION_STRING }}-Darwin.dmg" "${BASENAME}.dmg" + cpack --preset macos-package --verbose + BASENAME="contour-${{ steps.set_vars.outputs.VERSION_STRING }}-macOS-arm" + mv -vf "out/macos-package/Contour-${{ steps.set_vars.outputs.VERSION_STRING }}-Darwin.dmg" "${BASENAME}.dmg" + echo "DMG=${BASENAME}.dmg" >> "$GITHUB_ENV" + - name: "verify the DMG is something a user can actually open" + # The assertion that was missing all along: a green package step used to mean only + # that hdiutil succeeded. + # + # Scoped to what cpack could not already check. The image's own signature, its + # staple and `spctl --type install` are asserted fatally inside the packaging run + # (macos-bundle.py sign/notarize and cmake/MacOSSignDmg.cmake), and the .dmg here + # is the byte-identical file cpack produced -- only `mv`'d. What is genuinely new + # is the app *as mounted from the image*, a different object from the staged + # bundle that was signed. + run: | + set -ex + MOUNT=$(mktemp -d) + hdiutil attach -nobrowse -readonly -mountpoint "$MOUNT" "$DMG" + trap 'hdiutil detach "$MOUNT" || true' EXIT + + codesign --verify --deep --strict --verbose=4 "$MOUNT/contour.app" + + # The image ships the app and nothing else -- plus the /Applications symlink the + # DragNDrop generator adds for drag-installing. CONTOUR_INSTALL_TOOLS=OFF in the + # preset is what keeps bench-headless out of Contents/MacOS; assert it, because + # the day someone flips that option back on, the only visible symptom would be a + # developer benchmark tool shipping inside a user-facing release. + test "$(ls "$MOUNT" | sort | tr '\n' ' ')" = "Applications contour.app " + test "$(ls "$MOUNT/contour.app/Contents/MacOS")" = "contour" + + if [[ "${{ steps.set_vars.outputs.NOTARIZE }}" != 'ON' ]]; then + echo "::notice::unsigned build (no secrets); skipping Gatekeeper assertions" + exit 0 + fi + + # Reproduce what the user actually does, because that is where the + # "Apple could not verify ... is free of malware" dialog comes from: they drag + # the app OUT of the image and launch the copy. A ticket stapled only to the + # .dmg does not travel with that copy, leaving first launch dependent on + # Gatekeeper reaching Apple -- which fails offline, behind a captive portal, or + # on a restricted network. So the checks below run against the copy, not against + # the app sitting on the mounted image. + INSTALLED=$(mktemp -d)/contour.app + ditto "$MOUNT/contour.app" "$INSTALLED" + # A downloaded image carries this; the copy must clear Gatekeeper with it set. + xattr -w com.apple.quarantine "0081;00000000;Safari;" "$INSTALLED" + + # The ticket is embedded in the copy, so first launch needs no network at all. + xcrun stapler validate "$INSTALLED" + # The exact decision Gatekeeper makes when that copy is first launched. + spctl --assess --verbose=4 --type exec "$INSTALLED" + + # And the same for the image a browser just downloaded. + xcrun stapler validate "$DMG" + spctl --assess --verbose=4 --type install "$DMG" - name: upload to artifact store (DMG) uses: actions/upload-artifact@v4 with: - name: "contour-${{ steps.set_vars.outputs.version }}-macOS-arm.dmg" - path: "contour-${{ steps.set_vars.outputs.version }}-macOS-arm.dmg" + name: "contour-${{ steps.set_vars.outputs.VERSION_STRING }}-macOS-arm.dmg" + path: "contour-${{ steps.set_vars.outputs.VERSION_STRING }}-macOS-arm.dmg" retention-days: 7 # }}} # {{{ Windows @@ -1483,7 +1631,9 @@ jobs: - name: "fetch artifact: MacOS (ARM)" uses: actions/download-artifact@v4 with: - name: "contour-${{ steps.set_vars.outputs.version }}-macOS-arm.dmg" + # VERSION_STRING, matching the .deb above: `version` omits the `-prerelease` + # suffix, so a prerelease used to publish a .dmg whose name claimed otherwise. + name: "contour-${{ steps.set_vars.outputs.VERSION_STRING }}-macOS-arm.dmg" - name: "fetch artifact: Windows (MSI)" uses: actions/download-artifact@v4 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d2ba52004..f92af5e5e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,16 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(Version) GetVersionInformation(CONTOUR_VERSION CONTOUR_VERSION_STRING) file(WRITE "${CMAKE_BINARY_DIR}/version.txt" "${CONTOUR_VERSION_STRING}") + +# Before project(), which is where CMake consumes it. This is a property of the code, not +# of any one build configuration, so it lives here rather than being restated in every +# preset and CI job: 13.3 is the first macOS whose libc++ exports the floating-point +# std::to_chars that needs, and we use std::format throughout. Building against +# an older target fails to compile with "'to_chars' has been explicitly marked unavailable". +if(APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET) + set(CMAKE_OSX_DEPLOYMENT_TARGET "13.3" CACHE STRING "Minimum macOS version to build for") +endif() + project(contour VERSION "${CONTOUR_VERSION}" LANGUAGES CXX C) # setting defaults diff --git a/cmake/MacOSNotarizeApp.cmake b/cmake/MacOSNotarizeApp.cmake new file mode 100644 index 0000000000..1ff482f11c --- /dev/null +++ b/cmake/MacOSNotarizeApp.cmake @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# CPACK_PRE_BUILD_SCRIPTS hook: notarize and staple the staged contour.app before +# CPack hands the directory to hdiutil. +# +# Why here and not after the .dmg is built: stapling attaches the notarization ticket +# to the artifact itself. A ticket on the disk image alone covers the download, but the +# app the user drags to /Applications carries none, so its first launch depends on +# Gatekeeper reaching Apple over the network. Stapling the app *inside* the image makes +# the installed app self-sufficient. The image gets its own ticket in MacOSSignDmg.cmake. +# +# That costs a second round trip to Apple (a minute or more each), which only pays for +# itself on something people download, so it is gated on CONTOUR_MACOS_STAPLE_APP +# rather than on notarization as a whole. +# +# Variables come from the CPack config (see the APPLE branch of src/contour/CMakeLists.txt). + +if(NOT CPACK_CONTOUR_STAPLE_APP) + return() +endif() + +# Only the disk image carries an app bundle. Without this guard, `cpack -G TGZ` on macOS +# would submit whatever it staged to Apple. +if(NOT CPACK_GENERATOR STREQUAL "DragNDrop") + return() +endif() + +# CPack stages the bundle one directory deeper than the obvious path: this project defines +# an install component, so the layout is /ALL_IN_ONE/contour.app rather than +# /contour.app. Both are checked, because that is a CPack implementation detail +# and not something worth breaking a release over. +set(_candidates "") +foreach(_pattern "${CPACK_TEMPORARY_INSTALL_DIRECTORY}/contour.app" + "${CPACK_TEMPORARY_INSTALL_DIRECTORY}/*/contour.app") + file(GLOB _found "${_pattern}") + list(APPEND _candidates ${_found}) +endforeach() +list(REMOVE_DUPLICATES _candidates) +list(LENGTH _candidates _count) + +if(_count EQUAL 0) + message(FATAL_ERROR + "MacOSNotarizeApp: no contour.app found under " + "${CPACK_TEMPORARY_INSTALL_DIRECTORY} (searched it and its immediate " + "subdirectories). The app is notarized before hdiutil wraps it, so this has to " + "run after the install step has staged the bundle.") +elseif(_count GREATER 1) + message(FATAL_ERROR + "MacOSNotarizeApp: ambiguous staging layout, found several bundles: ${_candidates}") +endif() + +list(GET _candidates 0 _app) + +message(STATUS "Notarizing ${_app}") +execute_process( + COMMAND "${CPACK_CONTOUR_PYTHON}" "${CPACK_CONTOUR_BUNDLE_SCRIPT}" notarize "${_app}" + --keychain-profile "${CPACK_CONTOUR_NOTARY_PROFILE}" + COMMAND_ERROR_IS_FATAL ANY +) diff --git a/cmake/MacOSSignDmg.cmake b/cmake/MacOSSignDmg.cmake new file mode 100644 index 0000000000..d716c44786 --- /dev/null +++ b/cmake/MacOSSignDmg.cmake @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# CPACK_POST_BUILD_SCRIPTS hook: sign the finished disk image, then notarize and staple it. +# +# The signature the install step applies covers contour.app only. CPack wraps that bundle +# into a .dmg afterwards, and the image is a separate signable object -- an unsigned one +# is what Gatekeeper reports as "damaged" or "from an unidentified developer" the moment +# the download's quarantine attribute is evaluated. +# +# Variables come from the CPack config (see the APPLE branch of src/contour/CMakeLists.txt). +# CPACK_PACKAGE_FILES is set by CPack itself and lists the artifacts just produced. + +foreach(_package IN LISTS CPACK_PACKAGE_FILES) + if(NOT _package MATCHES "\\.dmg$") + continue() + endif() + + message(STATUS "Signing ${_package}") + execute_process( + COMMAND "${CPACK_CONTOUR_PYTHON}" "${CPACK_CONTOUR_BUNDLE_SCRIPT}" sign "${_package}" + --identity "${CPACK_CONTOUR_CODE_SIGN_IDENTITY}" + COMMAND_ERROR_IS_FATAL ANY + ) + + if(CPACK_CONTOUR_NOTARIZE) + message(STATUS "Notarizing ${_package}") + execute_process( + COMMAND "${CPACK_CONTOUR_PYTHON}" "${CPACK_CONTOUR_BUNDLE_SCRIPT}" notarize "${_package}" + --keychain-profile "${CPACK_CONTOUR_NOTARY_PROFILE}" + COMMAND_ERROR_IS_FATAL ANY + ) + + # The check a user's machine performs, run here so a broken image cannot reach a + # release: -t install is the assessment Gatekeeper applies to a downloaded archive. + execute_process( + COMMAND spctl --assess --verbose=4 --type install "${_package}" + COMMAND_ERROR_IS_FATAL ANY + ) + endif() +endforeach() diff --git a/cmake/Modules/MacOSXBundleInfo.plist.in b/cmake/Modules/MacOSXBundleInfo.plist.in deleted file mode 100644 index 440b841d36..0000000000 --- a/cmake/Modules/MacOSXBundleInfo.plist.in +++ /dev/null @@ -1,38 +0,0 @@ - - - - - CFBundleDevelopmentRegion - English - CFBundleExecutable - ${MACOSX_BUNDLE_EXECUTABLE_NAME} - CFBundleGetInfoString - ${MACOSX_BUNDLE_INFO_STRING} - CFBundleIconFile - ${MACOSX_BUNDLE_ICON_FILE} - CFBundleIdentifier - ${MACOSX_BUNDLE_GUI_IDENTIFIER} - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLongVersionString - ${MACOSX_BUNDLE_LONG_VERSION_STRING} - CFBundleName - ${MACOSX_BUNDLE_BUNDLE_NAME} - CFBundlePackageType - APPL - CFBundleShortVersionString - ${MACOSX_BUNDLE_SHORT_VERSION_STRING} - CFBundleSignature - ???? - CFBundleVersion - ${MACOSX_BUNDLE_BUNDLE_VERSION} - CSResourcesFileMapped - - NSHumanReadableCopyright - ${MACOSX_BUNDLE_COPYRIGHT} - NSPrincipalClass - NSApplication - NSHighResolutionCapable - True - - diff --git a/cmake/presets/os-macos.json b/cmake/presets/os-macos.json index 1c84cdbb09..7fe07d11d0 100644 --- a/cmake/presets/os-macos.json +++ b/cmake/presets/os-macos.json @@ -4,11 +4,20 @@ "common.json" ], "configurePresets": [ + { + "name": "macos-signing", + "hidden": true, + "description": "The project's macOS release identity, defined once.", + "cacheVariables": { + "CODE_SIGN_CERTIFICATE_ID": "Developer ID Application: Christian Parpart (6T525MU9UR)" + } + }, { "name": "appleclang-debug", "displayName": "Clang Debug", "inherits": [ "contour-common", + "macos-signing", "debug" ], "generator": "Ninja", @@ -19,7 +28,6 @@ }, "cacheVariables": { "LIBTERMINAL_BUILD_BENCH_HEADLESS": "OFF", - "CODE_SIGN_CERTIFICATE_ID": "Developer ID Application: Christian Parpart (6T525MU9UR)", "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt" } }, @@ -28,6 +36,7 @@ "displayName": "Clang Release", "inherits": [ "contour-common", + "macos-signing", "release" ], "generator": "Ninja", @@ -38,9 +47,36 @@ }, "cacheVariables": { "LIBTERMINAL_BUILD_BENCH_HEADLESS": "OFF", - "CODE_SIGN_CERTIFICATE_ID": "Developer ID Application: Christian Parpart (6T525MU9UR)", "CMAKE_PREFIX_PATH": "/opt/homebrew/opt/qt" } + }, + { + "name": "macos-package", + "displayName": "Clang Release (distributable .dmg)", + "description": "The one configuration that produces a shippable .dmg. CI uses this too.", + "inherits": [ + "contour-common", + "macos-signing", + "release" + ], + "generator": "Ninja", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "cacheVariables": { + "CONTOUR_INSTALL_TOOLS": "OFF", + "LIBTERMINAL_BUILD_BENCH_HEADLESS": "OFF", + "CONTOUR_MACOS_NOTARIZE": "ON", + "CONTOUR_MACOS_STAPLE_APP": "ON", + "CONTOUR_MACOS_NOTARY_PROFILE": "contour-notary", + "CONTOUR_MACOS_MIN_SUPPORTED": "13.3", + "CMAKE_PREFIX_PATH": "$env{QT_ROOT_DIR}", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "VCPKG_TARGET_TRIPLET": "arm64-osx-contour", + "VCPKG_OVERLAY_TRIPLETS": "${sourceDir}/cmake/vcpkg-triplets" + } } ], "buildPresets": [ @@ -53,6 +89,11 @@ "name": "appleclang-release", "displayName": "AppleClang - Release", "configurePreset": "appleclang-release" + }, + { + "name": "macos-package", + "displayName": "AppleClang - Release (distributable .dmg)", + "configurePreset": "macos-package" } ], "testPresets": [ @@ -66,12 +107,23 @@ "noTestsAction": "error", "stopOnFailure": true } + }, + { + "name": "macos-package", + "configurePreset": "macos-package", + "output": { + "outputOnFailure": true + }, + "execution": { + "noTestsAction": "error", + "stopOnFailure": true + } } ], "packagePresets": [ { - "name": "appleclang-release", - "configurePreset": "appleclang-release", + "name": "macos-package", + "configurePreset": "macos-package", "generators": [ "DragNDrop" ] diff --git a/cmake/vcpkg-triplets/arm64-osx-contour.cmake b/cmake/vcpkg-triplets/arm64-osx-contour.cmake new file mode 100644 index 0000000000..c7eb7ac206 --- /dev/null +++ b/cmake/vcpkg-triplets/arm64-osx-contour.cmake @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# vcpkg triplet for Contour's macOS release packaging. +# +# The point of this file is VCPKG_OSX_DEPLOYMENT_TARGET. Homebrew ships one prebuilt +# bottle per macOS release and installs the one matching the build machine, so a bundle +# assembled from Homebrew dylibs inherits the *builder's* macOS as its minimum -- macOS 26 +# on a developer's up-to-date Mac. Building the same libraries from source against a +# pinned deployment target puts the floor where we decide, and keeps it there no matter +# who builds. `scripts/macos-bundle.py verify --max-minimum-os` enforces the result. +# +# @see docs/internals/macos-code-signing.md + +set(VCPKG_TARGET_ARCHITECTURE arm64) +set(VCPKG_CRT_LINKAGE dynamic) + +# Dynamic, deliberately. Static linkage would make the floor a non-issue and shrink the +# bundle, but cairo is LGPL-2.1/MPL: statically linking it into an Apache-2.0 binary we +# distribute would oblige us to ship relinkable objects. Dynamic linking sidesteps that +# entirely, and the deployment target below already solves the floor. +set(VCPKG_LIBRARY_LINKAGE dynamic) + +set(VCPKG_CMAKE_SYSTEM_NAME Darwin) + +# Must stay in sync with CMAKE_OSX_DEPLOYMENT_TARGET in the top-level CMakeLists.txt -- +# 13.3 is the first macOS whose libc++ exports the floating-point std::to_chars that +# needs. +set(VCPKG_OSX_DEPLOYMENT_TARGET 13.3) +set(VCPKG_OSX_ARCHITECTURES arm64) diff --git a/docs/internals/macos-code-signing.md b/docs/internals/macos-code-signing.md new file mode 100644 index 0000000000..a2c54d9ae0 --- /dev/null +++ b/docs/internals/macos-code-signing.md @@ -0,0 +1,329 @@ +# macOS code signing, notarization and packaging + +Shipping a macOS app that opens on someone else's Mac takes four things, and missing any +one of them produces the same unhelpful Gatekeeper dialog: + +1. The bundle is **self-contained** — every library it loads is inside it. +2. Every Mach-O in it is **signed with a Developer ID**, inside-out, under the + **hardened runtime**. +3. The **disk image** is signed too. +4. Both the app and the image are **notarized** by Apple and have the ticket **stapled**. + +A correct signature alone is not enough. `spctl` says so plainly: + +``` +$ spctl --assess --type install Contour-0.7.0-Darwin.dmg +Contour-0.7.0-Darwin.dmg: rejected +source=Unnotarized Developer ID +``` + +All four steps run automatically from `cpack`. The mechanics live in +[`scripts/macos-bundle.py`](../../scripts/macos-bundle.py), which is driven by the +`install(CODE …)` block in `src/contour/CMakeLists.txt` and by the two CPack hooks +`cmake/MacOSNotarizeApp.cmake` and `cmake/MacOSSignDmg.cmake`. + +## Building a distributable .dmg + +The `macos-package` preset is the **only** configuration that produces a `.dmg`, and CI +uses it too — there is deliberately no second definition of "a shippable macOS build" to +drift out of sync: + +```sh +export QT_ROOT_DIR=$HOME/Qt/6.11.1/macos +export VCPKG_ROOT=$HOME/vcpkg # git clone microsoft/vcpkg && ./bootstrap-vcpkg.sh +cmake --preset macos-package # also builds the vcpkg dependencies, once +cmake --build --preset macos-package +ctest --preset macos-package # what CI runs, same configuration +cpack --preset macos-package +``` + +The first configure builds openssl, libssh2, yaml-cpp, freetype, harfbuzz and cairo from +source — about 5 minutes, cached in `~/.cache/vcpkg/archives` afterwards. `install-deps.sh` +provides the autotools those ports need on the build host. + +The image contains `contour.app` and the `/Applications` symlink, nothing else. That is +not luck: `contour-common` turns `CONTOUR_INSTALL_TOOLS` **on**, and `src/vtbackend` +installs `bench-headless` straight into `contour.app/Contents/MacOS` when it is set — so +the packaging preset turns it back off, and CI asserts the mounted image's contents. +Tests are built (`CONTOUR_TESTING=ON`, inherited) but no test target has an install rule, +so building them cannot affect what ships. + +> **Stale build directories lie.** Subproject test options are cached on first configure +> and are not revisited when `CONTOUR_TESTING` changes, so a directory first configured +> with tests off keeps most suites disabled even after the preset turns them on. If +> `ctest --preset macos-package` reports far fewer than the full suite, delete +> `out/macos-package/` and configure again. + +That runs, in order: `macdeployqt` → `prune` → `verify` → `sign` → notarize+staple the +app → build the image → sign the image → notarize+staple the image → `spctl`. Every step +is fatal on failure, so a broken bundle stops the build instead of becoming a release. + +Two knobs control the Apple round trips, which cost a minute or more each: + +| option | effect | +|---|---| +| `CONTOUR_MACOS_NOTARIZE` | Notarize and staple the **.dmg**. This is the one Gatekeeper demands on a downloaded file. | +| `CONTOUR_MACOS_STAPLE_APP` | Additionally notarize and staple the **.app inside** the image, so it launches offline after being dragged to /Applications. | + +Both are `ON` in the `macos-package` preset. CI enables the first for `master` and the +second only for `release`, so an ordinary push pays one wait instead of two. + +## Why not Homebrew's Qt + +Homebrew ships Qt as roughly forty separate per-module formulae (`qtbase`, +`qtdeclarative`, `qtmultimedia`, …), each in its own prefix. `macdeployqt` resolves QML +modules and plugins relative to a *single* Qt prefix, so it silently misses everything in +the other thirty-nine: it prints `ERROR: Cannot resolve rpath` to stderr, exits 0, and +leaves frameworks whose `LC_ID_DYLIB` still points into `/opt/homebrew`. Release builds +therefore use the official Qt binaries, which live under one prefix and are built to be +relocated — the same Qt the Linux and Windows CI jobs already use. + +Homebrew's Qt remains perfectly fine for the `appleclang-debug` / `appleclang-release` +dev presets, which are never packaged. `scripts/install-deps.sh` no longer installs it +by default; pass `CONTOUR_INSTALL_BREW_QT=ON` if you want it. + +Install the official Qt with: + +```sh +pip install aqtinstall +aqt install-qt mac desktop 6.11.1 clang_64 \ + -m qtmultimedia qt5compat qtshadertools qtspeech -O ~/Qt +``` + +## One-time credential setup + +Two independent credentials are needed: a **certificate** to sign with, and an **API key** +to notarize with. Neither can substitute for the other. + +### 1. Developer ID Application certificate (signing) + +Check whether you already have a usable one — an "identity" means the certificate *and* +its private key are present, which is exactly what is needed: + +```sh +security find-identity -v -p codesigning +# 1) 8E5F... "Developer ID Application: Christian Parpart (6T525MU9UR)" +``` + +If it is listed, nothing needs to be recreated for local signing. + +**If it is missing or expired**, create one at +: + +1. Keychain Access → menu *Certificate Assistant* → *Request a Certificate From a + Certificate Authority*. Enter your email and name, choose **Saved to disk**, and keep + the resulting `CertificateSigningRequest.certSigningRequest`. This also creates the + private key in your login keychain — the half Apple never sees and never sends back. +2. On the certificates page: **+** → **Developer ID Application** → *Profile Type: G2 Sub-CA* + → upload the CSR → **Download** the `.cer`. +3. Double-click the `.cer` to install it. `security find-identity -v -p codesigning` + should now list it. + +Note: creating Developer ID certificates requires the **Account Holder** role, and a team +is limited to a small number of them — so revoke an unused one rather than accumulating. + +**For CI**, export the identity as a `.p12` (certificate + private key in one file): + +1. Keychain Access → *login* keychain → category **My Certificates** → find + `Developer ID Application: …` → expand it and confirm a private key sits underneath. +2. Right-click → **Export "Developer ID Application: …"** → format **Personal Information + Exchange (.p12)** → choose a password. That password becomes the `P12_PASSWORD` secret. + +Then encode it and set three repository secrets: + +```sh +base64 -i DeveloperID.p12 | pbcopy # -> secret BUILD_CERTIFICATE_BASE64 +openssl rand -base64 24 # -> secret KEYCHAIN_PASSWORD (any random string) +``` + +| secret | value | +|---|---| +| `BUILD_CERTIFICATE_BASE64` | base64 of the `.p12` | +| `P12_PASSWORD` | the password chosen during export | +| `KEYCHAIN_PASSWORD` | any random string; it only protects the runner's throwaway keychain | + +The old `BUILD_PROVISION_PROFILE_BASE64` secret is no longer used — provisioning profiles +belong to App Store and ad-hoc distribution, not to Developer ID. It can be deleted. + +### 2. App Store Connect API key (notarization) + +Preferred over an Apple ID plus app-specific password: it is tied to no personal account, +is scoped by role, and can be revoked on its own. + +1. → **Users and Access** → **Integrations** tab → + **App Store Connect API** → **Team Keys** → **+**. +2. Name it (e.g. `contour-notary`), set Access to **Developer**, generate. +3. **Download `AuthKey_XXXXXXXXXX.p8` immediately — Apple allows it exactly once.** +4. Note the **Key ID** (10 characters, in the key's row) and the **Issuer ID** (a UUID + shown above the table, shared by all of the team's keys). + +Store it locally under the profile name the build expects: + +```sh +xcrun notarytool store-credentials contour-notary \ + --key ~/Downloads/AuthKey_XXXXXXXXXX.p8 \ + --key-id XXXXXXXXXX \ + --issuer aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee + +# confirm it works (empty history is a success; an auth error is not) +xcrun notarytool history --keychain-profile contour-notary +``` + +Override the profile name with `-DCONTOUR_MACOS_NOTARY_PROFILE=…` if you use another. + +**For CI**, set three more secrets: + +| secret | value | +|---|---| +| `APPLE_API_KEY_P8` | `base64 -i AuthKey_XXXXXXXXXX.p8 \| pbcopy` | +| `APPLE_API_KEY_ID` | the 10-character Key ID | +| `APPLE_API_ISSUER_ID` | the Issuer UUID | + +Keep the `.p8` somewhere safe. It cannot be downloaded again, and losing it means +generating a new key and updating the secrets. + +## Why not `codesign --deep` + +`--deep` is what the earlier attempts here used, and Apple documents it as unsuitable for +distribution signing. It applies the *top-level* entitlements to nested code, and it does +not guarantee that nested code is sealed before its container — so the outer seal can end +up covering a signature that was replaced afterwards. `macos-bundle.py sign` walks the +bundle deepest-first instead: loadable dylibs in `Frameworks/`, `PlugIns/` and +`Resources/qml/`, then each framework's versioned directory, then helper executables, then +the main executable, then the bundle. Only the outermost signature carries entitlements. + +`--deep` *is* right for verification, where it means "check every nested signature too": + +```sh +codesign --verify --deep --strict --verbose=4 contour.app +``` + +## Entitlements + +[`support/macOS/entitlements.plist`](../../support/macOS/entitlements.plist) is applied to +the app bundle only. It carries no explanatory comments, because the kernel's entitlements +parser (AMFI) rejects XML comments outright — `Failed to parse entitlements: +AMFIUnserializeXML: syntax error` — so the reasoning lives here: + +- `com.apple.security.cs.allow-jit` — the QML engine's V4 JIT needs `MAP_JIT`, which the + hardened runtime otherwise denies. +- `com.apple.security.cs.disable-library-validation` — insurance for Qt plugin loading. + Everything Contour bundles is re-signed with the same Team ID, which library validation + already permits, so this is a candidate for removal; verify a notarized build launches + and loads its plugins before dropping it. + +## Inspecting a signature + +```sh +# Identity, hardened runtime flag, timestamp, team ID +codesign -dv --verbose=4 contour.app + +# Hardened runtime shows as flags=0x10000(runtime). Its absence is invisible to --verify +# and is a hard notarization rejection. +codesign -dv --verbose=4 contour.app 2>&1 | grep -E 'Authority|flags=' + +# Entitlements actually embedded +codesign -d --entitlements - --xml contour.app | plutil -p - + +# What Gatekeeper will decide +spctl --assess --verbose=4 --type exec contour.app +spctl --assess --verbose=4 --type install contour.dmg +xcrun stapler validate contour.dmg +``` + +## Reproducing what a user sees + +`spctl` is an assessment, not the real thing: Gatekeeper only engages when a file carries +the quarantine attribute a browser sets. To test the actual download path: + +```sh +xattr -w com.apple.quarantine "0081;00000000;Safari;" contour.dmg +open contour.dmg # then drag Contour to /Applications and launch it +``` + +Launching again with networking disabled proves the *stapled* ticket works. Without a +staple, first launch silently depends on Gatekeeper reaching Apple — which is why the app +inside the image is notarized and stapled separately from the image itself. + +## Minimum macOS version + +`verify` reports the oldest macOS the bundle can run on, computed as the highest `minos` +across every bundled Mach-O: + +``` +macos-bundle: verify: bundle requires macOS 26.0 or newer, set by 14 binary/binaries +``` + +The floor is **13.3**, and it is enforced rather than hoped for. + +### Why the dependencies come from vcpkg, not Homebrew + +Homebrew ships one prebuilt bottle per macOS release and installs the one matching the +build machine. A bundle assembled from Homebrew dylibs therefore inherits *the builder's* +macOS as its minimum — on a developer's up-to-date Mac that meant a `.dmg` requiring +**macOS 26**, and in CI it meant the floor moved silently whenever GitHub rotated the +runner image. Nothing surfaced it: the app was correctly signed, correctly notarizable, and +simply refused to start. + +So the six libraries Contour links — openssl, libssh2, yaml-cpp, freetype, harfbuzz, cairo +— are built from source by vcpkg against `CMAKE_OSX_DEPLOYMENT_TARGET`. The whole mechanism +is one triplet file, [`cmake/vcpkg-triplets/arm64-osx-contour.cmake`](../../cmake/vcpkg-triplets/arm64-osx-contour.cmake), +whose `VCPKG_OSX_DEPLOYMENT_TARGET` is the single knob. This is not a new mechanism: the +Windows job has always built its dependencies with vcpkg from the same `vcpkg.json`. + +A side benefit: vcpkg's cairo does not drag in the X11 stack that Homebrew's does, so +`libX11`, `libXext`, `libXrender`, `libXau` and `libxcb` no longer ship at all. + +### Two macdeployqt details this depends on + +- Homebrew dylibs carry an *absolute* install name, so macdeployqt resolved them without + help. vcpkg's carry `@rpath/libfoo.dylib`, and macdeployqt expands `@rpath` using only + the binary's own `LC_RPATH` — its `-libpath` option is **not** consulted for this. The + vcpkg library directory is therefore added to `CMAKE_INSTALL_RPATH` for the deployment + step, and removed again afterwards so no build-machine path ships in a release binary. +- macdeployqt usually strips the rpaths it consumed itself, so that removal is conditional; + an unconditional `install_name_tool -delete_rpath` fails hard on a missing `LC_RPATH`. + +### The promise is checked + +`CONTOUR_MACOS_MIN_SUPPORTED` (13.3 in the `macos-package` preset) is compared against what +the bundled binaries actually demand. If anything exceeds it, `verify` fails and no package +is produced: + +``` +- the bundle requires macOS 26.0, but this build promises support down to 13.3 -- + users below 26.0 would be unable to launch it +``` + +It defaults to empty (report only) for the dev presets, which still use Homebrew and whose +floor is whatever the developer's bottles happened to be — a fact, not a promise. + +`LSMinimumSystemVersion` in `Contents/Info.plist` is the same number. Qt's Info.plist +template — which `qt_add_executable()` installs, in place of CMake's +`MacOSXBundleInfo.plist.in` — substitutes `CMAKE_OSX_DEPLOYMENT_TARGET` into it, and that is +what the vcpkg triplet pins. So the version macOS shows in a "requires a newer macOS" dialog +and the version `verify` measures across the bundled binaries come from one source and +cannot drift apart. Were they allowed to, a too-low declaration would be worse than none: +macOS would launch the app and let dyld fail on a missing symbol instead of refusing it +with a clear message. + +`NSHighResolutionCapable` is likewise absent from the shipped `Info.plist`, and that is the +enabled state, not a gap. Measured on a Retina display: an absent key and an explicit +`` both give an `NSWindow` a `backingScaleFactor` of 2.0, and only an explicit +`` drops it to 1.0. Apple's own bundled apps set the key nowhere, and Qt ships a +separate `Info.plist.disable_highdpi` precisely because opting *out* is the case that needs +saying. Declaring it would mean forking Qt's template — and forgoing whatever Qt adds to it +next — to restate a default. + +The CI runner image is pinned (`runs-on: macos-15`) for toolchain reproducibility. The +floor no longer depends on it. + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| `resource fork, Finder information, or similar detritus not allowed` | Extended attributes; `sign` runs `xattr -cr` first for this reason. | +| `The signature of the binary is invalid` from notarytool | Something was modified after signing — `install_name_tool` invalidates a signature, so any relinking must precede signing. | +| `The executable does not have the hardened runtime enabled` | Missing `--options=runtime` on some nested binary. | +| codesign hangs in CI | The key's ACL does not permit codesign; needs `security import -T /usr/bin/codesign` *and* `security set-key-partition-list`. | +| `ERROR: Cannot resolve rpath` from macdeployqt | Split-prefix Qt (see above). It exits 0 anyway; `verify` is what catches it. | diff --git a/mkdocs.yml b/mkdocs.yml index a2c9a914ee..8bc06c66c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,3 +135,4 @@ nav: - internals/vthost.md - internals/text-stack.md - internals/vt-conformance.md + - internals/macos-code-signing.md diff --git a/scripts/install-deps.sh b/scripts/install-deps.sh index 790f824656..8e6ad7fd8f 100755 --- a/scripts/install-deps.sh +++ b/scripts/install-deps.sh @@ -535,7 +535,33 @@ install_deps_darwin() { [ x$PREPARE_ONLY_EMBEDS = xON ] && return # NB: Also available in brew: mimalloc + # + # Qt is deliberately absent. Homebrew ships Qt as ~40 separate per-module formulae, + # each in its own prefix, and macdeployqt resolves QML modules and plugins relative + # to a single Qt prefix -- so it silently misses the cross-prefix ones and produces a + # bundle that cannot launch anywhere but the build machine. Release builds use the + # official Qt binaries instead (install-qt-action in CI, aqtinstall locally); see + # docs/internals/macos-code-signing.md. Set CONTOUR_INSTALL_BREW_QT=ON to get the + # Homebrew Qt anyway, which is fine for a dev build that is never packaged. + local qt_formula="" + if [ "x$CONTOUR_INSTALL_BREW_QT" = "xON" ]; then + qt_formula="qt$QTVER" + fi + + # autoconf/autoconf-archive/automake/libtool are build-host tools, not libraries: some + # vcpkg ports on the way to fontconfig (gperf) are autotools projects and fail to + # configure without them. Only the `macos-package` preset uses vcpkg, but installing + # four small tools unconditionally beats a confusing failure the first time someone + # tries to build a release .dmg. + # + # The libraries below serve the dev presets. Release packaging deliberately does NOT + # use them -- Homebrew ships one prebuilt bottle per macOS release, so a bundle built + # from them inherits the builder's macOS as its minimum. vcpkg builds the same + # libraries from source against a pinned deployment target instead. HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew install $SYSDEP_ASSUME_YES \ + autoconf \ + autoconf-archive \ + automake \ cairo \ catch2 \ cpp-gsl \ @@ -544,10 +570,11 @@ install_deps_darwin() { freetype \ harfbuzz \ libssh2 \ + libtool \ openssl \ pkg-config \ - qt$QTVER \ - yaml-cpp + yaml-cpp \ + $qt_formula } main() { diff --git a/scripts/macos-bundle.py b/scripts/macos-bundle.py new file mode 100755 index 0000000000..8ec2b5e6ff --- /dev/null +++ b/scripts/macos-bundle.py @@ -0,0 +1,656 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Verify, code-sign and notarize a macOS .app bundle or .dmg image. + +The four subcommands are what stands between a freshly ``macdeployqt``-ed bundle and +an artifact macOS will actually open: + +``verify`` + Walk every Mach-O in the bundle and resolve each of its dynamic library + references. A reference that points outside the bundle, or that cannot be + resolved at all, means the app will fail to launch on a machine that does not + happen to have the build host's libraries installed. Exits non-zero on any + finding, so a broken bundle breaks the build instead of shipping. Also reports + the oldest macOS the bundle can run on. + +``prune`` + Delete bundled plugins from a small allowlist of categories that macdeployqt is + known to over-deploy, when they reference libraries that exist only on the build + machine. macdeployqt deploys every plugin in a category once any module pulls + that category in, so a bundle picks up (say) the Mimer and PostgreSQL SQL drivers + along with SQLite. The allowlist is deliberate: an unloadable plugin *outside* it + is a deployment bug that should fail ``verify`` loudly, not vanish silently -- + quietly dropping, say, ``platforminputcontexts`` would cost IME input with no + diagnostic anywhere. + +``sign`` + Sign the bundle strictly inside-out with the hardened runtime enabled, then + assert the properties notarization requires. ``codesign --deep`` is deliberately + not used: Apple documents it as unsuitable for distribution signing because it + applies the top-level entitlements to nested code and does not guarantee the + nested-first order a valid seal requires. + +``notarize`` + Submit to Apple's notary service, wait for the verdict, and staple the resulting + ticket. Without a stapled ticket Gatekeeper blocks the artifact regardless of how + correct the signature is. + +``prune`` and ``verify`` both exist to compensate for macdeployqt: it over-deploys +plugin categories, and it reports unresolvable frameworks on stderr while still +exiting 0. Qt's own replacement, ``qt_generate_deploy_qml_app_script()``, resolves +the QML import closure through ``qmlimportscanner`` and dylibs through CMake's +``file(GET_RUNTIME_DEPENDENCIES)``, which would retire ``prune`` and reduce ``verify`` +to a belt-and-braces check. Migrating to it would retire most of this file. + +See docs/internals/macos-code-signing.md for how these fit into a release. +""" + +from __future__ import annotations + +import argparse +import json +import os +import plistlib +import subprocess +import sys +import tempfile +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path + +# Prefixes owned by the OS. References into these are expected and are left alone; +# anything else outside the bundle is a portability bug. +SYSTEM_PREFIXES = ('/usr/lib/', '/System/', '/Library/Apple/') + +# Plugin categories `prune` is allowed to delete from. Everything Contour needs to +# start or to render is outside this set, so a broken plugin elsewhere surfaces as a +# verify failure. Add a row when macdeployqt is found over-deploying another category. +PRUNABLE_PLUGIN_CATEGORIES = frozenset(( + # Deployed wholesale because QtQml pulls in QtQmlLocalStorage -> QtSql, even though + # nothing here uses SQL. The Mimer, ODBC and PostgreSQL drivers link against + # libraries that are not on an end user's machine (and often not on the builder's). + 'sqldrivers', +)) + +# otool accepts many files per invocation, which turns ~135 process spawns into one. +# Chunked so a bundle with very long paths cannot overflow the argument list. +_OTOOL_BATCH = 500 + + +class Failure(Exception): + """A user-facing error: printed without a traceback, exits non-zero.""" + + +def log(message: str) -> None: + print(f"macos-bundle: {message}", flush=True) + + +def run(argv: list[str]) -> str: + """Run a command and return its stdout, raising Failure if it fails.""" + result = subprocess.run(argv, capture_output=True, text=True, check=False) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + raise Failure(f"{argv[0]} failed ({result.returncode}): {detail}") + return result.stdout + + +# {{{ Mach-O inspection + +@dataclass(frozen=True) +class MachO: + """The load-command facts about one Mach-O file that deployment depends on. + + Universal binaries -- which official Qt's plugins are -- carry one load-command + section per architecture; the fields below merge them, since a reference that is + unresolvable for one slice is a bug for the whole file. + """ + + path: Path + install_id: str | None + dependencies: tuple[str, ...] + rpaths: tuple[str, ...] + minimum_os: tuple[int, ...] | None + + +# Load commands that name another library the loader has to find. +_DEPENDENCY_COMMANDS = frozenset(( + 'LC_LOAD_DYLIB', 'LC_LOAD_WEAK_DYLIB', 'LC_REEXPORT_DYLIB', + 'LC_LOAD_UPWARD_DYLIB', 'LC_LAZY_LOAD_DYLIB', +)) + + +def is_macho(path: Path) -> bool: + """Whether the file starts with a Mach-O (or universal binary) magic number.""" + try: + with path.open('rb') as f: + magic = f.read(4) + except OSError: + return False + return magic in ( + b'\xcf\xfa\xed\xfe', # MH_MAGIC_64, little endian + b'\xfe\xed\xfa\xcf', # MH_CIGAM_64 + b'\xca\xfe\xba\xbe', # FAT_MAGIC + b'\xbe\xba\xfe\xca', # FAT_CIGAM + ) + + +def find_binaries(root: Path) -> list[Path]: + """Every Mach-O file under root, symlinks excluded (they alias a real entry). + + Every candidate is probed by reading its magic number rather than filtered on a + suffix list: a suffix list that accidentally matches a real Mach-O would drop it + from signing, and unsigned nested code is a notarization rejection. + """ + binaries = [] + for dirpath, _dirnames, filenames in os.walk(root): + for filename in filenames: + path = Path(dirpath) / filename + if not path.is_symlink() and is_macho(path): + binaries.append(path) + return sorted(binaries) + + +def inspect_all(paths: list[Path]) -> dict[Path, MachO]: + """Read install name, dependencies, rpaths and minimum macOS for many binaries.""" + images: dict[Path, MachO] = {} + for start in range(0, len(paths), _OTOOL_BATCH): + chunk = paths[start:start + _OTOOL_BATCH] + output = run(['otool', '-l', *(str(path) for path in chunk)]) + images.update(_parse_load_commands(output, chunk)) + + missing = [path for path in paths if path not in images] + if missing: + raise Failure(f"otool produced no load commands for: {missing[0]} " + f"(and {len(missing) - 1} more)") + return images + + +def inspect(path: Path) -> MachO: + """Read one binary's load-command facts.""" + return inspect_all([path])[path] + + +def _parse_load_commands(output: str, paths: list[Path]) -> dict[Path, MachO]: + """Demultiplex `otool -l` output covering several files. + + Each file contributes a header line at column 0 ending in a colon -- either + `:` for a thin binary, or one ` (architecture ):` per slice of a + universal one. Fields from every slice of a file are merged into a single MachO. + """ + wanted = {str(path): path for path in paths} + accumulators: dict[Path, _Accumulator] = {} + current: _Accumulator | None = None + command: str | None = None + + for raw in output.splitlines(): + if raw and not raw[0].isspace() and raw.endswith(':'): + name = raw[:-1].split(' (architecture ')[0] + path = wanted.get(name) + if path is not None: + current = accumulators.setdefault(path, _Accumulator(path)) + command = None + continue + + if current is None: + continue + + line = raw.strip() + if line.startswith('cmd '): + command = line[len('cmd '):] + elif command is not None: + current.consume(command, line) + + return {path: acc.finish() for path, acc in accumulators.items()} + + +class _Accumulator: + """Collects the load-command fields of one file across its architecture slices.""" + + def __init__(self, path: Path) -> None: + self._path = path + self._install_id: str | None = None + self._dependencies: list[str] = [] + self._rpaths: list[str] = [] + self._minimum_os: tuple[int, ...] | None = None + + def consume(self, command: str, line: str) -> None: + # `name (offset N)` for the dylib commands, `path (offset N)` for + # LC_RPATH, `minos ` for LC_BUILD_VERSION. + if command in _DEPENDENCY_COMMANDS and line.startswith('name '): + _append_unique(self._dependencies, _strip_offset(line[len('name '):])) + elif command == 'LC_ID_DYLIB' and line.startswith('name '): + self._install_id = _strip_offset(line[len('name '):]) + elif command == 'LC_RPATH' and line.startswith('path '): + _append_unique(self._rpaths, _strip_offset(line[len('path '):])) + elif command == 'LC_BUILD_VERSION' and line.startswith('minos '): + version = _parse_version(line[len('minos '):].strip()) + if version is not None and (self._minimum_os is None + or version > self._minimum_os): + self._minimum_os = version + + def finish(self) -> MachO: + return MachO(self._path, self._install_id, tuple(self._dependencies), + tuple(self._rpaths), self._minimum_os) + + +def _append_unique(values: list[str], value: str) -> None: + if value not in values: + values.append(value) + + +def _strip_offset(value: str) -> str: + return value.split(' (offset ')[0].strip() + + +def _parse_version(text: str) -> tuple[int, ...] | None: + try: + return tuple(int(part) for part in text.split('.')) + except ValueError: + return None + +# }}} +# {{{ dyld reference resolution + + +def is_system_path(path: str) -> bool: + return path.startswith(SYSTEM_PREFIXES) + + +def is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def resolve(reference: str, image: MachO, executable: MachO) -> Path | None: + """Resolve one dylib reference the way dyld would, or None if nothing matches.""" + if reference.startswith('@rpath/'): + tail = reference[len('@rpath/'):] + # dyld expands @rpath against the LC_RPATHs of the referring image *and* those + # of the main executable. Qt relies on the second half: its plugins keep the + # build-tree rpath `@loader_path/../../lib`, which does not exist in a bundle, + # and are found only through the app's own `@executable_path/../Frameworks`. + # A verifier that checked the plugin's own rpaths alone would report every Qt + # plugin as broken. + for rpath in (*image.rpaths, *executable.rpaths): + candidate = expand(rpath, image.path, executable.path) + if candidate is not None and (candidate / tail).exists(): + return candidate / tail + return None + expanded = expand(reference, image.path, executable.path) + if expanded is None: + return None + return expanded if expanded.exists() else None + + +def expand(path: str, binary: Path, executable: Path) -> Path | None: + """Substitute dyld's @loader_path / @executable_path placeholders.""" + if path.startswith('@loader_path'): + return (binary.parent / path[len('@loader_path'):].lstrip('/')).resolve() + if path.startswith('@executable_path'): + return (executable.parent / path[len('@executable_path'):].lstrip('/')).resolve() + if path.startswith('@'): + return None # @rpath is handled by the caller; nothing else is expected. + return Path(path) + + +def unsatisfied(image: MachO, executable: MachO, bundle: Path) -> list[tuple[str, str]]: + """The references of one image that would not resolve, each with its reason. + + The single definition of "this cannot load", shared by `verify` (which formats the + reasons) and `prune` (which only asks whether the list is empty). Keeping them on + one predicate is what stops the two from drifting into disagreement, where prune + would delete a plugin that verify considered fine, or vice versa. + """ + findings = [] + identifier = image.install_id + if identifier and not identifier.startswith('@') and not is_system_path(identifier): + findings.append((identifier, 'install name points outside the bundle')) + + for dep in image.dependencies: + if is_system_path(dep): + continue + if not dep.startswith('@'): + findings.append((dep, 'absolute dependency outside the bundle')) + continue + target = resolve(dep, image, executable) + if target is None: + findings.append((dep, 'unresolvable dependency')) + elif not is_within(target, bundle): + findings.append((dep, f'dependency resolves outside the bundle ({target})')) + return findings + + +def main_executable(bundle: Path) -> Path: + """The bundle's CFBundleExecutable, as dyld's @executable_path anchor.""" + info = bundle / 'Contents' / 'Info.plist' + if not info.exists(): + raise Failure(f"not an app bundle (no Contents/Info.plist): {bundle}") + with info.open('rb') as f: + name = plistlib.load(f).get('CFBundleExecutable') + if not name: + raise Failure(f"Info.plist has no CFBundleExecutable: {info}") + executable = bundle / 'Contents' / 'MacOS' / name + if not executable.exists(): + raise Failure(f"CFBundleExecutable does not exist: {executable}") + return executable + +# }}} +# {{{ verify + + +def verify(bundle: Path, max_minimum_os: tuple[int, ...] | None = None) -> int: + """Report every dylib reference that would not resolve on a user's machine.""" + executable_path = main_executable(bundle) + paths = find_binaries(bundle) + log(f"verify: scanning {len(paths)} Mach-O binaries in {bundle.name}") + + images = inspect_all(paths) + executable = images[executable_path] + + findings = [f"{image.path.relative_to(bundle)}: {reason}: {reference}" + for image in images.values() + for reference, reason in unsatisfied(image, executable, bundle)] + + floor = report_minimum_os(bundle, images.values()) + if max_minimum_os is not None and floor is not None and floor > max_minimum_os: + findings.append( + f"the bundle requires macOS {_format_version(floor)}, but this build promises " + f"support down to {_format_version(max_minimum_os)} -- users below " + f"{_format_version(floor)} would be unable to launch it") + + if findings: + log(f"verify: FAILED with {len(findings)} finding(s):") + for finding in findings: + print(f" - {finding}", file=sys.stderr) + print("\nThe bundle is not self-contained: it would fail to launch on any machine\n" + "that does not happen to have the build host's libraries. This is a deployment\n" + "problem, not a signing problem -- signing a broken bundle just produces a\n" + "valid signature over something that cannot run.\n" + "\n" + "The usual cause is a Qt installed as separate per-module prefixes (Homebrew\n" + "splits Qt across ~40 formulae), which macdeployqt cannot follow: it reports\n" + "'Cannot resolve rpath' on stderr and then exits 0. Use the official Qt for\n" + "packaging -- see docs/internals/macos-code-signing.md.", + file=sys.stderr) + return 1 + + log(f"verify: OK, {bundle.name} is self-contained") + return 0 + + +def report_minimum_os(bundle: Path, images: Iterator[MachO]) -> tuple[int, ...] | None: + """Print and return the oldest macOS the bundle as a whole can run on. + + Reporting this matters because nothing else surfaces it -- an app whose own binary + targets macOS 13 still refuses to start on 13 if one bundled dylib wants 26. Whether + the number is acceptable is the caller's decision, via --max-minimum-os. + """ + declared = [image for image in images if image.minimum_os is not None] + if not declared: + return None + + highest = max(image.minimum_os for image in declared) + culprits = sorted(image.path for image in declared if image.minimum_os == highest) + log(f"verify: bundle requires macOS {_format_version(highest)} or newer, " + f"set by {len(culprits)} binary/binaries, e.g.:") + for path in culprits[:5]: + print(f" {path.relative_to(bundle)}") + return highest + + +def _format_version(version: tuple[int, ...]) -> str: + return '.'.join(str(part) for part in version) + +# }}} +# {{{ prune + + +def prune(bundle: Path) -> int: + """Remove unloadable plugins from the categories in PRUNABLE_PLUGIN_CATEGORIES.""" + plugins = bundle / 'Contents' / 'PlugIns' + if not plugins.exists(): + log("prune: no Contents/PlugIns, nothing to do") + return 0 + + executable = inspect(main_executable(bundle)) + candidates = [path for path in find_binaries(plugins) + if _category_of(path, plugins) in PRUNABLE_PLUGIN_CATEGORIES] + if not candidates: + log("prune: no plugins in a prunable category") + return 0 + + removed = 0 + for path, image in inspect_all(candidates).items(): + broken = unsatisfied(image, executable, bundle) + if not broken: + continue + reasons = ', '.join(reference for reference, _ in broken) + log(f"prune: removing {path.relative_to(bundle)} (cannot load: {reasons})") + path.unlink() + removed += 1 + + log(f"prune: removed {removed} unloadable plugin(s)") + return 0 + + +def _category_of(plugin: Path, plugins_dir: Path) -> str: + """The plugin category directory a bundled plugin sits in, e.g. 'sqldrivers'.""" + parts = plugin.relative_to(plugins_dir).parts + return parts[0] if len(parts) > 1 else '' + +# }}} +# {{{ sign + + +def signing_targets(bundle: Path) -> Iterator[Path]: + """Every item needing its own signature, in inside-out order. + + Nested code must be sealed before its container, otherwise the container's + seal covers a signature that is later replaced. + """ + contents = bundle / 'Contents' + executable = main_executable(bundle) + + # Loadable code, deepest first. Qt scatters this across three directories: + # Frameworks/ for the libraries, PlugIns/ for the platform and image plugins, + # and Resources/qml/ for the per-QML-module plugin dylibs. + for binary in find_binaries(contents): + # A framework's own binary is sealed by signing the framework, below; the main + # executable is sealed second-to-last, once everything it loads is done. + if '.framework/' in str(binary.relative_to(contents)) or binary == executable: + continue + yield binary + + # Frameworks are signed as bundles, not as bare Mach-O files, so that codesign seals + # their Info.plist and Resources too. Searched bundle-wide rather than under + # Frameworks/ alone, so a framework nested anywhere still gets a real signature + # instead of being silently left out of the seal. + for framework in sorted(contents.rglob('*.framework')): + # Versioned frameworks are signed at Versions/, unversioned at the root. + current = framework / 'Versions' / 'Current' + yield current.resolve() if current.exists() else framework + + yield executable + yield bundle # The bundle itself, last: its seal covers everything above. + + +def sign(bundle: Path, identity: str, entitlements: Path | None) -> int: + """Sign the bundle inside-out with the hardened runtime enabled.""" + if entitlements is not None and not entitlements.exists(): + raise Failure(f"entitlements file not found: {entitlements}") + + # Extended attributes (notably com.apple.quarantine, and the resource forks + # some build steps leave behind) make codesign fail with "resource fork, + # Finder information, or similar detritus not allowed". + run(['xattr', '-cr', str(bundle)]) + + targets = list(signing_targets(bundle)) + log(f"sign: signing {len(targets)} items as {identity!r}") + + for target in targets: + argv = ['codesign', '--force', '--timestamp', '--options=runtime'] + # Only the outermost signature carries entitlements. Applying them to + # nested code is what `--deep` gets wrong. + if target == bundle and entitlements is not None: + argv += ['--entitlements', str(entitlements)] + argv += ['--sign', identity, str(target)] + run(argv) + + # --deep is wrong for *signing* but is exactly right for verification: here it + # means "check every nested signature too". + run(['codesign', '--verify', '--deep', '--strict', '--verbose=2', str(bundle)]) + assert_distributable(bundle, identity) + log(f"sign: OK, {bundle.name} signed and verified") + return 0 + + +def assert_distributable(bundle: Path, identity: str) -> None: + """Check the two signature properties notarization requires but --verify ignores. + + A bundle can verify perfectly and still be rejected by Apple for lacking the + hardened runtime, so asserting it here -- rather than only in CI -- means a local + `cpack` is held to the same standard as a release build. + """ + # codesign writes its description to stderr, not stdout, so this cannot go through + # run(); --verbose is diagnostic output rather than a result. + described = subprocess.run( + ['codesign', '-dv', '--verbose=4', str(bundle)], + capture_output=True, text=True, check=False, + ) + if described.returncode != 0: + raise Failure(f"could not read the signature of {bundle.name}: " + f"{described.stderr.strip()}") + description = described.stderr + described.stdout + + if 'runtime' not in _flags_of(description): + raise Failure("the hardened runtime is not enabled on the signed bundle; " + "notarization would reject it") + if identity != '-' and 'Authority=Developer ID Application' not in description: + raise Failure(f"expected a Developer ID Application authority after signing with " + f"{identity!r}, got:\n{description}") + + +def _flags_of(description: str) -> str: + for line in description.splitlines(): + _, _, tail = line.partition('flags=') + if tail: + return tail + return '' + + +def sign_file(path: Path, identity: str, entitlements: Path | None) -> int: + """Sign a standalone file (a .dmg). No hardened runtime: it holds no code.""" + if entitlements is not None: + raise Failure(f"--entitlements applies to an .app bundle, not to {path.name}") + log(f"sign: signing {path.name} as {identity!r}") + run(['codesign', '--force', '--timestamp', '--sign', identity, str(path)]) + run(['codesign', '--verify', '--strict', '--verbose=2', str(path)]) + log(f"sign: OK, {path.name} signed") + return 0 + +# }}} +# {{{ notarize + + +def notarize(artifact: Path, profile: str) -> int: + """Submit to Apple's notary service, wait for the verdict, then staple the ticket.""" + with tempfile.TemporaryDirectory() as tmp: + if artifact.is_dir(): + # notarytool takes .zip, .dmg or .pkg. ditto is the only archiver that + # preserves the signature's symlinks and extended attributes intact. + payload = Path(tmp) / f"{artifact.name}.zip" + log(f"notarize: archiving {artifact.name}") + run(['ditto', '-c', '-k', '--keepParent', str(artifact), str(payload)]) + else: + payload = artifact + + log(f"notarize: submitting {payload.name} (this waits for Apple, typically 1-5 min)") + output = run([ + 'xcrun', 'notarytool', 'submit', str(payload), + '--keychain-profile', profile, '--wait', '--output-format', 'json', + ]) + + status, submission_id = parse_submission(output) + if status != 'Accepted': + if submission_id: + log(f"notarize: rejected, fetching log for submission {submission_id}") + print(subprocess.run( + ['xcrun', 'notarytool', 'log', submission_id, '--keychain-profile', profile], + capture_output=True, text=True, check=False, + ).stdout, file=sys.stderr) + raise Failure(f"notarization was not accepted (status: {status})") + + log(f"notarize: accepted (submission {submission_id})") + log(f"notarize: stapling ticket to {artifact.name}") + run(['xcrun', 'stapler', 'staple', str(artifact)]) + run(['xcrun', 'stapler', 'validate', str(artifact)]) + log(f"notarize: OK, {artifact.name} is notarized and stapled") + return 0 + + +def parse_submission(output: str) -> tuple[str, str]: + try: + payload = json.loads(output) + except json.JSONDecodeError as error: + raise Failure(f"could not parse notarytool output: {error}\n{output}") from error + return payload.get('status', 'Unknown'), payload.get('id', '') + +# }}} + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog='macos-bundle.py', description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = parser.add_subparsers(dest='command', required=True) + + p_verify = sub.add_parser('verify', help='check that the bundle is self-contained') + p_verify.add_argument('bundle', type=Path) + p_verify.add_argument('--max-minimum-os', default=None, metavar='VERSION', + help='fail if the bundle needs a macOS newer than this ' + '(e.g. 15.0), i.e. the oldest release this build promises') + + p_prune = sub.add_parser('prune', help='delete bundled plugins that cannot load') + p_prune.add_argument('bundle', type=Path) + + p_sign = sub.add_parser('sign', help='sign a bundle inside-out, or sign a .dmg') + p_sign.add_argument('target', type=Path, help='an .app bundle or a .dmg') + p_sign.add_argument('--identity', required=True, + help="signing identity, or '-' for an ad-hoc signature") + p_sign.add_argument('--entitlements', type=Path, default=None, + help='entitlements plist, applied to the .app only') + + p_notarize = sub.add_parser('notarize', help='notarize and staple an .app or .dmg') + p_notarize.add_argument('artifact', type=Path) + p_notarize.add_argument('--keychain-profile', required=True, + help='profile name from `xcrun notarytool store-credentials`') + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + if args.command == 'verify': + ceiling = None + if args.max_minimum_os: + ceiling = _parse_version(args.max_minimum_os) + if ceiling is None: + raise Failure(f"--max-minimum-os is not a version: {args.max_minimum_os}") + return verify(args.bundle, ceiling) + if args.command == 'prune': + return prune(args.bundle) + if args.command == 'sign': + if args.target.is_dir(): + return sign(args.target, args.identity, args.entitlements) + return sign_file(args.target, args.identity, args.entitlements) + return notarize(args.artifact, args.keychain_profile) + except Failure as error: + print(f"macos-bundle: error: {error}", file=sys.stderr) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/src/contour/CMakeLists.txt b/src/contour/CMakeLists.txt index 9f34385c60..78fe8349ef 100644 --- a/src/contour/CMakeLists.txt +++ b/src/contour/CMakeLists.txt @@ -91,6 +91,17 @@ endif() if(APPLE) #set(CMAKE_INSTALL_RPATH "@executable_path") set(CMAKE_INSTALL_RPATH "@executable_path/../Frameworks") + # vcpkg gives its dylibs an `@rpath/libfoo.dylib` install name, and macdeployqt expands + # @rpath using only the binary's own LC_RPATH -- its -libpath option is not consulted + # for this. Without a search path that resolves, macdeployqt reports "Cannot resolve + # rpath" and leaves openssl, freetype, harfbuzz, fontconfig and cairo out of the bundle. + # So the deployment step gets a resolvable rpath, and the install rules delete it again + # once macdeployqt has rewritten every reference to @executable_path/../Frameworks -- + # a build-machine path has no business shipping inside a release binary. + if(DEFINED VCPKG_TOOLCHAIN AND DEFINED VCPKG_INSTALLED_DIR AND DEFINED VCPKG_TARGET_TRIPLET) + set(CONTOUR_MACOS_DEPLOY_RPATH "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/lib") + list(APPEND CMAKE_INSTALL_RPATH "${CONTOUR_MACOS_DEPLOY_RPATH}") + endif() set(CMAKE_BUILD_RPATH "${CMAKE_INSTALL_RPATH}") set(CMAKE_MACOSX_RPATH ON) #set(CMAKE_BUILD_WITH_INSTALL_RPATH ON) @@ -367,7 +378,7 @@ set_target_properties(contour_core PROPERTIES AUTORCC ON) target_include_directories(contour_core PUBLIC "${CMAKE_CURRENT_BINARY_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/..") # }}} -add_executable(contour) +qt_add_executable(contour) target_sources(contour PRIVATE ${_main_source_files}) target_link_libraries(contour PRIVATE contour_core) @@ -427,20 +438,25 @@ if(WIN32) ) endif() elseif(APPLE) - set(CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../cmake/Modules" ${CMAKE_MODULE_PATH}) + # qt_add_executable() sets MACOSX_BUNDLE_INFO_PLIST, so Info.plist comes from Qt's + # template and only the properties it substitutes have any effect -- which excludes + # the deprecated CFBundleGetInfoString and CFBundleLongVersionString. It supplies + # LSMinimumSystemVersion from CMAKE_OSX_DEPLOYMENT_TARGET, and omits + # NSHighResolutionCapable, whose absence means enabled. See + # docs/internals/macos-code-signing.md. set_target_properties(contour PROPERTIES OUTPUT_NAME "contour" MACOSX_RPATH ON MACOSX_BUNDLE ON MACOSX_BUNDLE_BUNDLE_NAME "Contour" - MACOSX_BUNDLE_INFO_STRING "Contour Terminal Emulator" MACOSX_BUNDLE_GUI_IDENTIFIER "${AppId}" - MACOSX_BUNDLE_LONG_VERSION_STRING "${CONTOUR_VERSION_STRING}" MACOSX_BUNDLE_SHORT_VERSION_STRING "${CONTOUR_VERSION}" MACOSX_BUNDLE_BUNDLE_VERSION "${CONTOUR_VERSION}" XCODE_ATTRIBUTE_PRODUCT_NAME "Contour Terminal Emulator" - # TODO: MACOSX_BUNDLE_ICON_FILE "contour.icns" - # TODO: RESOURCE "images/icon.icns" + # Names the icon the install rules already place at Contents/Resources/contour.icns. + # Without it CFBundleIconFile renders empty and both the app and the disk image + # show the generic executable icon. + MACOSX_BUNDLE_ICON_FILE "contour.icns" ) endif() # }}} @@ -486,6 +502,7 @@ if(CONTOUR_FRONTEND_GUI) Qt6::Qml Qt6::QuickControls2 Qt6::Widgets + Qt6::QuickTemplates2 ) if(OPENBSD) @@ -734,6 +751,49 @@ endif() set(CMAKE_INSTALL_DEFAULT_COMPONENT_NAME "contour") +# {{{ macOS signing and notarization settings +# Declared here rather than next to their use in the install() rules below, because the +# CPack block needs them too and CMake reads this file top to bottom. +if(APPLE) + # "-" is an ad-hoc signature: enough to run locally, not enough to distribute. + # A release build passes the Developer ID; see cmake/presets/os-macos.json. + set(CODE_SIGN_CERTIFICATE_ID "-" CACHE STRING "macOS code signing identity, or '-' for ad-hoc") + option(CONTOUR_MACOS_NOTARIZE "Submit the macOS disk image to Apple's notary service" OFF) + # A second Apple round trip, so that the app dragged out of the image carries its own + # ticket and launches offline. Worth its minute on something users download; not worth + # paying on every push. @see cmake/MacOSNotarizeApp.cmake + option(CONTOUR_MACOS_STAPLE_APP "Also notarize and staple the app inside the image" OFF) + set(CONTOUR_MACOS_NOTARY_PROFILE "contour-notary" CACHE STRING + "notarytool keychain profile (see `xcrun notarytool store-credentials`)") + + # The oldest macOS the produced .dmg promises to run on, checked against what the + # bundled binaries actually demand. Empty means "report the floor, do not enforce it", + # which is right for a dev machine -- the floor there is whatever the developer's + # Homebrew bottles were built for and is nobody's promise. A release build sets it, so + # that an artifact users cannot launch fails the build instead of reaching them. + set(CONTOUR_MACOS_MIN_SUPPORTED "" CACHE STRING + "Oldest macOS the package must run on, e.g. 15.0 (empty: report only)") + + # Notarizing an ad-hoc-signed app is rejected by Apple, but only after a multi-minute + # wait -- so reject the combination here instead. + if((CONTOUR_MACOS_NOTARIZE OR CONTOUR_MACOS_STAPLE_APP) + AND CODE_SIGN_CERTIFICATE_ID STREQUAL "-") + message(FATAL_ERROR + "CONTOUR_MACOS_NOTARIZE requires a real CODE_SIGN_CERTIFICATE_ID; " + "Apple rejects ad-hoc signatures. Set a Developer ID identity, or turn " + "notarization off.") + endif() + + set(CONTOUR_MACOS_BUNDLE_SCRIPT "${CMAKE_SOURCE_DIR}/scripts/macos-bundle.py") + + # REQUIRED, unlike the QUIET lookup at the top level: scripts/macos-bundle.py is what + # verifies, signs and notarizes the bundle, so without an interpreter there is no way + # to produce a distributable artifact at all. Failing at configure time beats failing + # halfway through an install. + find_package(Python3 COMPONENTS Interpreter REQUIRED) +endif() +# }}} + # {{{ CPACK variable definitions if(NOT(CPACK_GENERATOR)) if(APPLE) @@ -774,12 +834,27 @@ if(WIN32) endif() if(APPLE) set(CPACK_PACKAGE_ICON "${CMAKE_CURRENT_SOURCE_DIR}/res/images/contour-logo.icns") + set(CPACK_DMG_VOLUME_NAME "Contour") + + # The disk image needs its own signature and its own notarization ticket: CPack + # assembles it *after* install(), so the signature the install step applies to + # contour.app does not cover the .dmg wrapping it. Both hooks read the settings + # below out of the CPack config, hence the CPACK_-prefixed names. + set(CPACK_CONTOUR_CODE_SIGN_IDENTITY "${CODE_SIGN_CERTIFICATE_ID}") + set(CPACK_CONTOUR_NOTARIZE "${CONTOUR_MACOS_NOTARIZE}") + set(CPACK_CONTOUR_STAPLE_APP "${CONTOUR_MACOS_STAPLE_APP}") + set(CPACK_CONTOUR_NOTARY_PROFILE "${CONTOUR_MACOS_NOTARY_PROFILE}") + set(CPACK_CONTOUR_BUNDLE_SCRIPT "${CONTOUR_MACOS_BUNDLE_SCRIPT}") + set(CPACK_CONTOUR_PYTHON "${Python3_EXECUTABLE}") + # Runs on the staged bundle before hdiutil, so the ticket ends up inside the image. + set(CPACK_PRE_BUILD_SCRIPTS "${CMAKE_SOURCE_DIR}/cmake/MacOSNotarizeApp.cmake") + # Runs on the finished .dmg. + set(CPACK_POST_BUILD_SCRIPTS "${CMAKE_SOURCE_DIR}/cmake/MacOSSignDmg.cmake") endif() # }}} # {{{ Qt bundle installation helpers set(INSTALLED_QT_VERSION 6) - function(_qt_get_plugin_name_with_version target out_var) string(REGEX REPLACE "^Qt::(.+)" "Qt${INSTALLED_QT_VERSION}::\\1" qt_plugin_with_version "${target}") if(TARGET "${qt_plugin_with_version}") @@ -788,23 +863,6 @@ function(_qt_get_plugin_name_with_version target out_var) set("${out_var}" "" PARENT_SCOPE) endif() endfunction() - -# if(APPLE) -# # Required when packaging, and set CMAKE_INSTALL_PREFIX to "/" -# set(CPACK_SET_DESTDIR TRUE) -# set(CMAKE_BUNDLE_NAME "contour") -# set(CMAKE_BUNDLE_LOCATION "/") -# # make sure CMAKE_INSTALL_PREFIX ends in / -# set(CMAKE_INSTALL_PREFIX "/${CMAKE_BUNDLE_NAME}.app/Contents") -# endif(APPLE) - - - -# get_property(_Qt_Core_LOCATION TARGET Qt${INSTALLED_QT_VERSION}::Core PROPERTY LOCATION) -# get_filename_component(Qt_BIN_DIR "${_Qt_Core_LOCATION}" PATH) -# if(APPLE) -# get_filename_component(Qt_BIN_DIR "${Qt_BIN_DIR}" PATH) -# endif() # }}} if(WIN32) @@ -825,40 +883,19 @@ if(WIN32) PATTERN "*" ) elseif(APPLE) - # {{{ NB: This would run macdeployqt after creating contour executable during build stage. - # This is currently disabled, because it seems like packaging would not work then, - # but instead, we invoke macdeployqt during cpack stage. - # - # include(DeployQt) - # add_custom_command( - # TARGET contour POST_BUILD - # COMMENT "Running ${MACDEPLOYQT_EXECUTABLE}" - # WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}" - # COMMAND ${MACDEPLOYQT_EXECUTABLE} contour.app -always-overwrite -verbose=3 -qmlimport=${CMAKE_CURRENT_SOURCE_DIR}/ui -qmldir=${CMAKE_CURRENT_SOURCE_DIR}/ui - # VERBATIM - # ) - # }}} - - qt_import_qml_plugins(contour) - - set(CPACK_COMPONENTS_ALL_IN_ONE_INSTALL TRUE) - include(InstallRequiredSystemLibraries) - # See: https://stackoverflow.com/questions/35612687/cmake-macos-x-bundle-with-bundleutiliies-for-qt-application/48035834#48035834 set(APP_NAME contour) set(App_Contents "${APP_NAME}.app/Contents") set(INSTALL_RUNTIME_DIR "${App_Contents}/MacOS") set(INSTALL_CMAKE_DIR "${App_Contents}/Resources") - - # Install application icon install(FILES "res/images/contour-logo.icns" DESTINATION "${INSTALL_CMAKE_DIR}" RENAME "contour.icns") install(DIRECTORY "${terminfo_basedir}" DESTINATION "${INSTALL_CMAKE_DIR}") install(DIRECTORY "shell-integration" DESTINATION "${INSTALL_CMAKE_DIR}") - #add_custom_target(Docs SOURCES README.md LICENSE.txt) - #TODO: install(TARGETS Docs ...) + # add_custom_target(Docs SOURCES README.md LICENSE.txt) + # TODO: install(TARGETS Docs ...) # Destination paths below are relative to ${CMAKE_INSTALL_PREFIX} install(TARGETS ${APP_NAME} @@ -866,41 +903,80 @@ elseif(APPLE) RUNTIME DESTINATION "${INSTALL_RUNTIME_DIR}" COMPONENT Runtime ) - # file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/qt.conf" "[Paths]\nPlugins = PlugIns\n") - # install(FILES "${CMAKE_CURRENT_BINARY_DIR}/qt.conf" DESTINATION "${INSTALL_CMAKE_DIR}") - - - - set(CODE_SIGN_CERTIFICATE_ID "-" CACHE STRING "macOS Code signature ID") # TODO: Use proper ID on CI include(DeployQt) # Just to get access to ${MACDEPLOYQT_EXECUTABLE} get_filename_component(_macdeployqt_path "${MACDEPLOYQT_EXECUTABLE}" PATH) - message(STATUS "macdeployqt path: ${_macdeployqt_path}") message(STATUS "macdeployqt location: ${MACDEPLOYQT_EXECUTABLE}") - set(MACDEPLOYQT_QML_WORKAROUND OFF CACHE BOOL "Workaround for macdeployqt not installing Qt QML files.") - if(MACDEPLOYQT_QML_WORKAROUND) - # Install Qt QML files - # This problem is specific to macdeployqt on Github CI, which does not seem to install - # the Qt QML files, so we do it manually here. - get_filename_component(Qt6_ROOT_DIR "${Qt6_DIR}/../../.." REALPATH) - message(STATUS "Using Qt6_ROOT_DIR: ${Qt6_ROOT_DIR}") - # Qt Qt5Compat QtMultimedia QtQml QtQuick - foreach(_name IN ITEMS Qt Qt5Compat QtMultimedia QtQml QtQuick) - get_filename_component(_dir "${Qt6_ROOT_DIR}/share/qt/qml/${_name}" REALPATH) - message(STATUS "Using _dir: ${_dir}") - install(DIRECTORY "${_dir}" DESTINATION ${App_Contents}/Resources/qml FILES_MATCHING PATTERN "*") - endforeach() - endif() - + # Applied to contour.app only, never to nested code -- entitlements belong to the + # outermost signature. Both keys it contains relax hardened-runtime restrictions: + # allow-jit for the QML engine's V4 JIT (MAP_JIT), and disable-library-validation as + # insurance for Qt plugin loading. The file itself carries no comments explaining + # this, because the kernel's entitlements parser (AMFI) rejects XML comments outright; + # docs/internals/macos-code-signing.md has the reasoning. + set(_entitlements "${CMAKE_SOURCE_DIR}/support/macOS/entitlements.plist") + + # Deploy, drop the plugins that cannot load, prove the result is self-contained, + # then sign it -- in that order, and with every step fatal on failure. macdeployqt + # reports unresolvable frameworks on stderr but still exits 0, so without the verify + # step a bundle missing half its Qt frameworks used to sail through the build and + # fail on the user's machine. install(CODE " + set(_app \"\${CMAKE_INSTALL_PREFIX}/contour.app\") + execute_process( - WORKING_DIRECTORY \"${_macdeployqt_path}/..\" # is this specific working dir really required? (others believe so) - COMMAND ${MACDEPLOYQT_EXECUTABLE} \"\${CMAKE_INSTALL_PREFIX}/contour.app\" + WORKING_DIRECTORY \"${_macdeployqt_path}/..\" # macdeployqt resolves its QML imports relative to this + COMMAND ${MACDEPLOYQT_EXECUTABLE} \"\${_app}\" -always-overwrite -verbose=1 -no-strip - -qmldir=${CMAKE_CURRENT_SOURCE_DIR}/ui - \"-codesign=${CODE_SIGN_CERTIFICATE_ID}\" + \"-qmldir=${CMAKE_CURRENT_SOURCE_DIR}/ui\" + COMMAND_ERROR_IS_FATAL ANY + ) + + # The deployment-only rpath from the top of this file has done its job; every + # reference now points into the bundle. Leaving it would ship an absolute path to + # the build machine's vcpkg tree inside the released binary. macdeployqt usually + # strips the rpaths it consumed itself, so this only has to handle the case where + # it did not -- hence the check rather than an unconditional delete, which fails + # hard on a missing LC_RPATH. + if(NOT \"${CONTOUR_MACOS_DEPLOY_RPATH}\" STREQUAL \"\") + execute_process( + COMMAND otool -l \"\${_app}/Contents/MacOS/contour\" + OUTPUT_VARIABLE _load_commands + COMMAND_ERROR_IS_FATAL ANY + ) + string(FIND \"\${_load_commands}\" \"${CONTOUR_MACOS_DEPLOY_RPATH}\" _deploy_rpath_at) + if(NOT _deploy_rpath_at EQUAL -1) + message(STATUS \"Removing deployment-only rpath from contour\") + execute_process( + COMMAND install_name_tool -delete_rpath \"${CONTOUR_MACOS_DEPLOY_RPATH}\" + \"\${_app}/Contents/MacOS/contour\" + COMMAND_ERROR_IS_FATAL ANY + ) + endif() + endif() + + execute_process( + COMMAND \"${Python3_EXECUTABLE}\" \"${CONTOUR_MACOS_BUNDLE_SCRIPT}\" prune \"\${_app}\" + COMMAND_ERROR_IS_FATAL ANY + ) + + set(_verify_args) # unset, not \"\": an empty string is a list element, and would + # reach the script as an empty argv entry + if(NOT \"${CONTOUR_MACOS_MIN_SUPPORTED}\" STREQUAL \"\") + list(APPEND _verify_args --max-minimum-os \"${CONTOUR_MACOS_MIN_SUPPORTED}\") + endif() + execute_process( + COMMAND \"${Python3_EXECUTABLE}\" \"${CONTOUR_MACOS_BUNDLE_SCRIPT}\" verify \"\${_app}\" + \${_verify_args} + COMMAND_ERROR_IS_FATAL ANY + ) + + execute_process( + COMMAND \"${Python3_EXECUTABLE}\" \"${CONTOUR_MACOS_BUNDLE_SCRIPT}\" sign \"\${_app}\" + --identity \"${CODE_SIGN_CERTIFICATE_ID}\" + --entitlements \"${_entitlements}\" + COMMAND_ERROR_IS_FATAL ANY ) ") else() diff --git a/src/text_shaper/CMakeLists.txt b/src/text_shaper/CMakeLists.txt index 0f18ccc7c9..ecd739248a 100644 --- a/src/text_shaper/CMakeLists.txt +++ b/src/text_shaper/CMakeLists.txt @@ -35,10 +35,16 @@ if(APPLE) find_package(Fontconfig REQUIRED) find_package(Freetype REQUIRED) pkg_check_modules(harfbuzz REQUIRED IMPORTED_TARGET harfbuzz) - execute_process( - COMMAND sh -c "brew --prefix harfbuzz | cut -d. -f1 | tr -d $'\n'" - OUTPUT_VARIABLE HARFBUZZ_APPLE_INCLUDE) - include_directories("${HARFBUZZ_APPLE_INCLUDE}/include") + if(NOT DEFINED VCPKG_TOOLCHAIN) + # Homebrew-only fallback. It must not run under vcpkg: `include_directories` is + # global and would put Homebrew's harfbuzz headers ahead of the vcpkg ones we + # actually link against, which is a header/library version mismatch waiting to + # happen. Release packaging goes through vcpkg precisely to avoid Homebrew. + execute_process( + COMMAND sh -c "brew --prefix harfbuzz | cut -d. -f1 | tr -d $'\n'" + OUTPUT_VARIABLE HARFBUZZ_APPLE_INCLUDE) + include_directories("${HARFBUZZ_APPLE_INCLUDE}/include") + endif() list(APPEND TEXT_SHAPER_LIBS Freetype::Freetype) list(APPEND TEXT_SHAPER_LIBS PkgConfig::harfbuzz) list(APPEND TEXT_SHAPER_LIBS Fontconfig::Fontconfig) diff --git a/support/macOS/entitlements.plist b/support/macOS/entitlements.plist new file mode 100644 index 0000000000..777b3abd95 --- /dev/null +++ b/support/macOS/entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.disable-library-validation + + +