From c17ab214e2609a7b94a613be85b08e4551bdb090 Mon Sep 17 00:00:00 2001 From: haozhi shao Date: Wed, 5 Aug 2026 12:12:48 -0700 Subject: [PATCH 1/3] Add signed macOS DMG release pipeline --- .github/workflows/package.yml | 182 ++++++++++++- README.md | 5 + README_CN.md | 5 + assets/macos/DMG_INSTALL.txt | 26 ++ cmake/acecode_desktop.cmake | 25 ++ cmake/macos/ACECodeUserInstallerInfo.plist.in | 35 +++ docs/macos-release.md | 157 +++++++++++ scripts/macos_codesign.sh | 44 ++- scripts/macos_create_dmg.sh | 129 +++++++++ scripts/macos_notarize.sh | 124 +++++++++ src/desktop/user_install_policy.cpp | 42 +++ src/desktop/user_install_policy.hpp | 27 ++ src/macos_installer/main.mm | 256 ++++++++++++++++++ tests/CMakeLists.txt | 9 + tests/desktop/user_install_policy_test.cpp | 61 +++++ tests/scripts/macos_release_scripts_test.sh | 60 ++++ 16 files changed, 1177 insertions(+), 10 deletions(-) create mode 100644 assets/macos/DMG_INSTALL.txt create mode 100644 cmake/macos/ACECodeUserInstallerInfo.plist.in create mode 100644 docs/macos-release.md create mode 100755 scripts/macos_create_dmg.sh create mode 100755 scripts/macos_notarize.sh create mode 100644 src/desktop/user_install_policy.cpp create mode 100644 src/desktop/user_install_policy.hpp create mode 100644 src/macos_installer/main.mm create mode 100644 tests/desktop/user_install_policy_test.cpp create mode 100755 tests/scripts/macos_release_scripts_test.sh diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index ae8e2744..7d8f67b0 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -113,6 +113,55 @@ jobs: with: submodules: recursive + - name: Resolve package version + id: package-version + shell: bash + run: | + declared_version="$(awk '$1 == "project(acecode" && $2 == "VERSION" { print $3; exit }' CMakeLists.txt)" + if [ -z "$declared_version" ]; then + echo "::error::Unable to read the ACECode version from CMakeLists.txt" + exit 1 + fi + if [[ "$GITHUB_REF" == refs/tags/v* && "${GITHUB_REF_NAME#v}" != "$declared_version" ]]; then + echo "::error::Tag $GITHUB_REF_NAME does not match CMake version $declared_version" + exit 1 + fi + echo "version=$declared_version" >> "$GITHUB_OUTPUT" + + - name: Check macOS release credentials + if: runner.os == 'macOS' + id: macos-release + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + missing=() + for name in \ + MACOS_CERTIFICATE_BASE64 \ + MACOS_CERTIFICATE_PASSWORD \ + APPLE_ID \ + APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD; do + if [ -z "${!name}" ]; then + missing+=("$name") + fi + done + + if [ "${#missing[@]}" -ne 0 ]; then + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS releases require secrets: ${missing[*]}" + exit 1 + fi + echo "::notice::macOS signing/notarization disabled; missing: ${missing[*]}" + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + - name: Configure MSVC if: runner.os == 'Windows' uses: ilammy/msvc-dev-cmd@v1 @@ -162,6 +211,11 @@ jobs: shell: bash run: cmake --build build --config MinSizeRel --target acecode-desktop + - name: Build current-user installer (macOS) + if: runner.os == 'macOS' + shell: bash + run: cmake --build build --config MinSizeRel --target acecode-user-installer + - name: Extract debug symbols (Linux) if: runner.os == 'Linux' shell: bash @@ -191,6 +245,7 @@ jobs: run: | app_exec="build/ACECode.app/Contents/MacOS/ACECode" app_daemon="build/ACECode.app/Contents/MacOS/acecode-daemon" + installer_exec="build/Install ACECode.app/Contents/MacOS/Install ACECode" if [ ! -f "$app_exec" ]; then echo "Missing desktop app executable: $app_exec" >&2 exit 1 @@ -199,10 +254,71 @@ jobs: echo "Missing desktop app daemon: $app_daemon" >&2 exit 1 fi + if [ ! -f "$installer_exec" ]; then + echo "Missing current-user installer: $installer_exec" >&2 + exit 1 + fi dsymutil "$app_exec" -o "build/ACECode.app.dSYM" strip "$app_exec" dsymutil "$app_daemon" -o "build/acecode-daemon.dSYM" strip "$app_daemon" + dsymutil "$installer_exec" -o "build/Install ACECode.app.dSYM" + strip "$installer_exec" + + - name: Import Developer ID certificate (macOS) + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-keychain + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_CODESIGN_IDENTITY: ${{ vars.MACOS_CODESIGN_IDENTITY }} + run: | + set +x + certificate_path="$RUNNER_TEMP/acecode-developer-id.p12" + keychain_path="$RUNNER_TEMP/acecode-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + + printf '%s' "$MACOS_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$certificate_path" + chmod 600 "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + identity="$MACOS_CODESIGN_IDENTITY" + if [ -z "$identity" ]; then + identity="$(security find-identity -v -p codesigning "$keychain_path" | + awk -F '"' '/Developer ID Application:/ { print $2; exit }')" + fi + if [ -z "$identity" ]; then + echo "::error::No Developer ID Application identity found in the imported certificate" + exit 1 + fi + + echo "keychain=$keychain_path" >> "$GITHUB_OUTPUT" + echo "identity=$identity" >> "$GITHUB_OUTPUT" + + - name: Sign macOS release payloads + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + run: | + scripts/macos_codesign.sh \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" \ + --binary "build/${{ matrix.executable }}" \ + --bundle "build/Install ACECode.app" \ + --app "build/ACECode.app" - name: Package (Unix) if: runner.os != 'Windows' @@ -241,6 +357,53 @@ jobs: fi tar -C dist -czf "acecode-${{ matrix.id }}.tar.gz" "acecode-${{ matrix.id }}" + - name: Create macOS DMG + if: runner.os == 'macOS' + id: macos-dmg + shell: bash + run: | + unsigned_suffix="" + if [ "${{ steps.macos-release.outputs.enabled }}" != "true" ]; then + unsigned_suffix="-unsigned" + fi + dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}${unsigned_suffix}.dmg" + scripts/macos_create_dmg.sh \ + --app "build/ACECode.app" \ + --installer "build/Install ACECode.app" \ + --output "$dmg_path" \ + --volume-name "ACECode ${{ steps.package-version.outputs.version }}" + echo "path=$dmg_path" >> "$GITHUB_OUTPUT" + + - name: Sign macOS DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + run: | + /usr/bin/codesign \ + --force \ + --timestamp \ + --sign "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" \ + "${{ steps.macos-dmg.outputs.path }}" + /usr/bin/codesign --verify --strict --verbose=2 \ + "${{ steps.macos-dmg.outputs.path }}" + + - name: Notarize and validate macOS DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: scripts/macos_notarize.sh --file "${{ steps.macos-dmg.outputs.path }}" + + - name: Clean up macOS signing material + if: always() && runner.os == 'macOS' + shell: bash + run: | + set +e + security delete-keychain "$RUNNER_TEMP/acecode-signing.keychain-db" 2>/dev/null + rm -f -- "$RUNNER_TEMP/acecode-developer-id.p12" + - name: Package (Windows) if: runner.os == 'Windows' shell: pwsh @@ -308,6 +471,14 @@ jobs: path: acecode-${{ matrix.id }}.${{ matrix.archive_extension }} if-no-files-found: error + - name: Upload macOS DMG + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dmg + path: ${{ steps.macos-dmg.outputs.path }} + if-no-files-found: error + - name: Upload PDB if: runner.os == 'Windows' uses: actions/upload-artifact@v4 @@ -356,6 +527,7 @@ jobs: path: | build/ACECode.app.dSYM build/acecode-daemon.dSYM + build/Install ACECode.app.dSYM if-no-files-found: warn build-linux-old-package: @@ -563,7 +735,7 @@ jobs: run: | mkdir -p release-assets - find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print0 | + find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.dmg' \) -print0 | while IFS= read -r -d '' file; do cp "$file" "release-assets/$(basename "$file")" done @@ -584,6 +756,14 @@ jobs: tar -czf "release-assets/[dev_only]${artifact_name}-${dsym_name}.tar.gz" -C "$(dirname "$dsym")" "$dsym_name" done + ( + cd release-assets + LC_ALL=C find . -maxdepth 1 -type f ! -name 'SHA256SUMS.txt' -print0 | + sort -z | + xargs -0 sha256sum | + sed 's# \./# #' > SHA256SUMS.txt + ) + ls -lh release-assets/ - name: Create GitHub Release diff --git a/README.md b/README.md index c09cbf16..bc8de919 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,11 @@ Windows builds use UTF-8 compilation flags and default to a static vcpkg triplet Download a prebuilt binary from [Releases](https://github.com/shaohaozhi286/acecode/releases), then run: +For the macOS desktop app, download the DMG matching the Mac architecture, open +it, and double-click `Install ACECode.app`. The installer copies ACECode only to +`~/Applications/ACECode.app` and never requests administrator privileges. See +the [macOS release and installation guide](docs/macos-release.md). + ```bash ./acecode configure cd /path/to/your/project diff --git a/README_CN.md b/README_CN.md index 5d431574..17d51d7e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -188,6 +188,11 @@ Windows 构建会启用 UTF-8 编译选项;使用 vcpkg toolchain 且未指定 从 [Releases](https://github.com/shaohaozhi286/acecode/releases) 下载预编译二进制,然后运行: +macOS 桌面版请下载与 Mac 架构匹配的 DMG,打开后双击 +`Install ACECode.app`。安装器只会复制到 +`~/Applications/ACECode.app`,全程不会请求管理员权限。详见 +[macOS 发布与安装说明](docs/macos-release.md)。 + ```bash ./acecode configure cd /path/to/your/project diff --git a/assets/macos/DMG_INSTALL.txt b/assets/macos/DMG_INSTALL.txt new file mode 100644 index 00000000..81aac15c --- /dev/null +++ b/assets/macos/DMG_INSTALL.txt @@ -0,0 +1,26 @@ +Install ACECode for the current user +==================================== + +1. Double-click "Install ACECode.app". +2. The installer copies ACECode to: + + ~/Applications/ACECode.app + +3. ACECode opens automatically after installation. + +The installer never writes to /Applications and never requests administrator +privileges. Quit ACECode before installing an update. + + +为当前用户安装 ACECode +====================== + +1. 双击“Install ACECode.app”。 +2. 安装器会把 ACECode 复制到: + + ~/Applications/ACECode.app + +3. 安装完成后 ACECode 会自动启动。 + +安装器绝不会写入系统级 /Applications,也不会请求管理员权限。更新前请先 +退出正在运行的 ACECode。 diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake index 90ffec01..6d103a5e 100644 --- a/cmake/acecode_desktop.cmake +++ b/cmake/acecode_desktop.cmake @@ -136,6 +136,31 @@ if(APPLE) COMMENT "Copying acecode daemon into ACECode.app bundle" VERBATIM ) + + add_executable(acecode-user-installer MACOSX_BUNDLE + ${CMAKE_SOURCE_DIR}/src/macos_installer/main.mm + ${CMAKE_SOURCE_DIR}/src/desktop/user_install_policy.cpp + ${CMAKE_SOURCE_DIR}/src/desktop/user_install_policy.hpp + ${ACECODE_MACOS_ICON} + ) + target_include_directories(acecode-user-installer PRIVATE + ${CMAKE_SOURCE_DIR}/src + ) + target_link_libraries(acecode-user-installer PRIVATE + "-framework AppKit" + "-framework Foundation" + ) + set_target_properties(acecode-user-installer PROPERTIES + RUNTIME_OUTPUT_NAME "Install ACECode" + MACOSX_BUNDLE_BUNDLE_NAME "Install ACECode" + MACOSX_BUNDLE_ICON_FILE "acecode.icns" + MACOSX_BUNDLE_GUI_IDENTIFIER "dev.acecode.installer" + MACOSX_BUNDLE_INFO_PLIST + "${CMAKE_SOURCE_DIR}/cmake/macos/ACECodeUserInstallerInfo.plist.in" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + MACOSX_BUNDLE_BUNDLE_VERSION "${ACECODE_BUILD_VERSION}" + MACOSX_BUNDLE_COPYRIGHT "ACECode contributors" + ) endif() if(MSVC) diff --git a/cmake/macos/ACECodeUserInstallerInfo.plist.in b/cmake/macos/ACECodeUserInstallerInfo.plist.in new file mode 100644 index 00000000..f9028629 --- /dev/null +++ b/cmake/macos/ACECodeUserInstallerInfo.plist.in @@ -0,0 +1,35 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundleExecutable + ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleIdentifier + ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleIconFile + ${MACOSX_BUNDLE_ICON_FILE} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleVersion + ${MACOSX_BUNDLE_BUNDLE_VERSION} + LSMinimumSystemVersion + 11.0 + LSMultipleInstancesProhibited + + NSHighResolutionCapable + + NSHumanReadableCopyright + ${MACOSX_BUNDLE_COPYRIGHT} + + diff --git a/docs/macos-release.md b/docs/macos-release.md new file mode 100644 index 00000000..9381b950 --- /dev/null +++ b/docs/macos-release.md @@ -0,0 +1,157 @@ +# macOS DMG Release Guide + +ACECode's `package` GitHub Actions workflow builds separate Intel (`macos-x64`) +and Apple silicon (`macos-arm64`) disk images. Tagged releases require a +Developer ID signature and Apple notarization; the workflow will fail instead +of publishing an unsigned DMG when any required credential is missing. + +## What Users Install + +Each DMG contains: + +- `ACECode.app` +- `Install ACECode.app` +- `Install Instructions 安装说明.txt` + +Opening a DMG only mounts it; macOS does not allow a DMG to silently run an +installer. The user double-clicks `Install ACECode.app`, which validates the +bundled ACECode application, copies it to +`~/Applications/ACECode.app`, and launches that installed copy. + +The installer is deliberately current-user only: + +- It never writes to the system `/Applications` directory. +- It never invokes `sudo`, Authorization Services, or an administrator prompt. +- It accepts only the exact normalized destination + `~/Applications/ACECode.app`. +- It refuses symlinked or redirected application directories and destinations. +- It asks the user to close a running ACECode instance before replacement. + +Users can remove ACECode by quitting it and deleting +`~/Applications/ACECode.app`. No privileged uninstaller is required. + +## One-Time GitHub Setup + +### 1. Export the Developer ID identity + +In Keychain Access, open the login keychain and select **My Certificates**. +Expand the `Developer ID Application` certificate and confirm that its private +key is present. Export the identity as a password-protected `.p12` file. A +`Developer ID Installer` certificate is not required because ACECode ships a +signed app inside a DMG rather than an Apple installer package (`.pkg`). + +Encode the exported file without line breaks: + +```bash +openssl base64 -A -in /path/to/ACECode-Developer-ID.p12 | pbcopy +``` + +Do not commit the `.p12` file or its password. After the GitHub setup is +verified, move the export to secure offline storage or delete it. + +### 2. Create an Apple app-specific password + +Sign in at [Apple Account](https://account.apple.com/), open **Sign-In and +Security**, then create an app-specific password named for the ACECode release +workflow. The Apple Account must have two-factor authentication enabled. + +### 3. Add repository secrets + +Open **GitHub repository > Settings > Secrets and variables > Actions** and add +these repository secrets: + +| Secret | Value | +| --- | --- | +| `MACOS_CERTIFICATE_BASE64` | The single-line base64 text copied above | +| `MACOS_CERTIFICATE_PASSWORD` | Password chosen while exporting the `.p12` | +| `APPLE_ID` | Apple Account email used for notarization | +| `APPLE_TEAM_ID` | Apple Developer Team ID, for example `T52GZCH73Y` | +| `APPLE_APP_SPECIFIC_PASSWORD` | The generated app-specific password | + +The existing tag workflow also starts `publish-npm`. Add `NPM_TOKEN` when npm +publishing is wanted; without it, the independent GitHub Release can still be +created, but the npm job reports a failure. This token is separate from macOS +signing. + +The optional Actions variable `MACOS_CODESIGN_IDENTITY` can contain the full +identity name, for example `Developer ID Application: Name (TEAMID)`. Leave it +unset when the exported `.p12` contains only one Developer ID Application +identity; the workflow discovers it automatically. + +## Local Notarization Credentials + +For local release testing, store notarization credentials in Keychain rather +than in a shell history: + +```bash +xcrun notarytool store-credentials "ACECode-notary" +xcrun notarytool history --keychain-profile "ACECode-notary" +``` + +Enter the Apple Account, Team ID, and app-specific password when prompted. This +profile is local-only; GitHub Actions uses repository secrets instead. + +Apple's current references are +[Developer ID certificates](https://developer.apple.com/help/account/certificates/create-developer-id-certificates), +[custom notarization workflows](https://developer.apple.com/documentation/security/customizing-the-notarization-workflow), +and [app-specific passwords](https://support.apple.com/zh-cn/102654). + +## Dry Run + +After the workflow changes are on GitHub, open **Actions > package > Run +workflow** and leave `npm_version` empty. + +- With all five macOS secrets configured, the two DMGs are signed, notarized, + stapled, Gatekeeper-checked, and uploaded as workflow artifacts. +- Without those secrets, a manual run still creates artifacts whose filenames + end in `-unsigned.dmg`, for packaging inspection only. +- A `v*` tag never permits the unsigned fallback. + +Download both DMG artifacts and test the matching architecture on a clean Mac +user account before creating the release tag. + +## Tagged Release + +The version in `CMakeLists.txt` is authoritative. The tag must match it exactly: + +```bash +git switch master +git pull --ff-only +git tag -a v0.8.7 -m "ACECode v0.8.7" +git push origin v0.8.7 +``` + +The tag starts the full package workflow. After every platform build succeeds, +GitHub creates a Release containing the versioned x64 and arm64 DMGs, platform +archives, debug symbols, and `SHA256SUMS.txt`. Do not reuse a failed public tag; +fix the cause, increment the version, and create a new tag. + +## Local Build And DMG Check + +Given an already configured macOS build directory: + +```bash +cmake --build build --target acecode acecode-desktop acecode-user-installer + +codesign_identity="Developer ID Application: Name (TEAMID)" +scripts/macos_codesign.sh \ + --identity "$codesign_identity" \ + --binary build/acecode \ + --bundle "build/Install ACECode.app" \ + --app build/ACECode.app + +scripts/macos_create_dmg.sh \ + --app build/ACECode.app \ + --installer "build/Install ACECode.app" \ + --output dist/ACECode-local.dmg + +codesign --force --timestamp --sign "$codesign_identity" \ + dist/ACECode-local.dmg +scripts/macos_notarize.sh \ + --file dist/ACECode-local.dmg \ + --keychain-profile "ACECode-notary" +``` + +The notarization helper waits for Apple's result, requires `Accepted`, staples +and validates the ticket, and runs a Gatekeeper assessment. GitHub Actions runs +the same helper using secrets. diff --git a/scripts/macos_codesign.sh b/scripts/macos_codesign.sh index ae07fe28..5b3c27d0 100755 --- a/scripts/macos_codesign.sh +++ b/scripts/macos_codesign.sh @@ -5,10 +5,11 @@ usage() { cat <<'USAGE' Usage: scripts/macos_codesign.sh --identity [--keychain ] \ - [--binary ...] [--app ] + [--binary ...] [--bundle ...] [--app ] Signs macOS command-line binaries and app bundles with hardened runtime enabled. -Nested app executables are signed before the app bundle itself. +Generic bundles must not contain unsigned nested code. ACECode nested executables +are signed before the app bundle itself. USAGE } @@ -16,6 +17,7 @@ identity="" keychain="" app_path="" declare -a binaries=() +declare -a bundles=() while [[ $# -gt 0 ]]; do case "$1" in @@ -43,6 +45,14 @@ while [[ $# -gt 0 ]]; do binaries+=("$2") shift 2 ;; + --bundle) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --bundle" >&2 + exit 2 + fi + bundles+=("$2") + shift 2 + ;; --app) if [[ $# -lt 2 || -z "${2:-}" ]]; then echo "Missing value for --app" >&2 @@ -74,8 +84,8 @@ if [[ -z "$identity" ]]; then exit 2 fi -if [[ ${#binaries[@]} -eq 0 && -z "$app_path" ]]; then - echo "Nothing to sign. Pass at least one --binary or --app." >&2 +if [[ ${#binaries[@]} -eq 0 && ${#bundles[@]} -eq 0 && -z "$app_path" ]]; then + echo "Nothing to sign. Pass at least one --binary, --bundle, or --app." >&2 usage >&2 exit 2 fi @@ -120,6 +130,13 @@ verify_app() { /usr/bin/codesign --verify --deep --strict --verbose=2 "$path" } +verify_bundle() { + local path="$1" + + echo "Verifying $path" + /usr/bin/codesign --verify --deep --strict --verbose=2 "$path" +} + sign_app_executable() { local path="$1" @@ -152,10 +169,19 @@ sign_extra_app_code() { ) } -for binary in "${binaries[@]}"; do - sign_path "$binary" - verify_binary "$binary" -done +if [[ ${#binaries[@]} -gt 0 ]]; then + for binary in "${binaries[@]}"; do + sign_path "$binary" + verify_binary "$binary" + done +fi + +if [[ ${#bundles[@]} -gt 0 ]]; then + for bundle in "${bundles[@]}"; do + sign_path "$bundle" + verify_bundle "$bundle" + done +fi if [[ -n "$app_path" ]]; then if [[ ! -d "$app_path" ]]; then @@ -165,10 +191,10 @@ if [[ -n "$app_path" ]]; then app_main="$app_path/Contents/MacOS/ACECode" app_daemon="$app_path/Contents/MacOS/acecode-daemon" - sign_app_executable "$app_main" sign_app_executable "$app_daemon" sign_extra_app_code "$app_path/Contents/MacOS" "$app_main" "$app_daemon" sign_extra_app_code "$app_path/Contents/Frameworks" + sign_app_executable "$app_main" sign_path "$app_path" verify_app "$app_path" fi diff --git a/scripts/macos_create_dmg.sh b/scripts/macos_create_dmg.sh new file mode 100755 index 00000000..4e4a98d8 --- /dev/null +++ b/scripts/macos_create_dmg.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'USAGE' +Usage: + scripts/macos_create_dmg.sh --app \ + --installer --output \ + [--volume-name ] [--instructions ] + +Creates a compressed read-only DMG containing ACECode.app, the current-user +installer, and bilingual instructions. The image never contains a link to the +system /Applications directory. +USAGE +} + +app_path="" +installer_path="" +output_path="" +volume_name="ACECode" +instructions_path="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --app) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --app" >&2; exit 2; } + app_path="$2" + shift 2 + ;; + --installer) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --installer" >&2; exit 2; } + installer_path="$2" + shift 2 + ;; + --output) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --output" >&2; exit 2; } + output_path="$2" + shift 2 + ;; + --volume-name) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --volume-name" >&2; exit 2; } + volume_name="$2" + shift 2 + ;; + --instructions) + [[ $# -ge 2 && -n "${2:-}" ]] || { echo "Missing value for --instructions" >&2; exit 2; } + instructions_path="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "DMG creation must run on macOS." >&2 + exit 1 +fi + +if [[ -z "$app_path" || -z "$installer_path" || -z "$output_path" ]]; then + echo "--app, --installer, and --output are required." >&2 + usage >&2 + exit 2 +fi + +if [[ "$output_path" != *.dmg ]]; then + echo "DMG output must end in .dmg: $output_path" >&2 + exit 2 +fi + +if [[ ! -d "$app_path" ]]; then + echo "Missing ACECode app bundle: $app_path" >&2 + exit 1 +fi +if [[ ! -d "$installer_path" ]]; then + echo "Missing installer app bundle: $installer_path" >&2 + exit 1 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ -z "$instructions_path" ]]; then + instructions_path="$script_dir/../assets/macos/DMG_INSTALL.txt" +fi +if [[ ! -f "$instructions_path" ]]; then + echo "Missing DMG instructions: $instructions_path" >&2 + exit 1 +fi + +output_parent="$(dirname "$output_path")" +mkdir -p "$output_parent" + +temporary_base="${TMPDIR:-/tmp}" +temporary_base="${temporary_base%/}" +temporary_root="$(mktemp -d "${temporary_base}/acecode-dmg.XXXXXX")" +cleanup() { + if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then + rm -rf -- "$temporary_root" + fi +} +trap cleanup EXIT + +staging_root="$temporary_root/root" +mkdir -p "$staging_root" + +/usr/bin/ditto "$app_path" "$staging_root/ACECode.app" +/usr/bin/ditto "$installer_path" "$staging_root/Install ACECode.app" +/usr/bin/ditto "$instructions_path" "$staging_root/Install Instructions 安装说明.txt" + +if [[ -e "$staging_root/Applications" || -L "$staging_root/Applications" ]]; then + echo "Refusing to package a system /Applications link." >&2 + exit 1 +fi + +/usr/bin/hdiutil create \ + -volname "$volume_name" \ + -srcfolder "$staging_root" \ + -format UDZO \ + -ov \ + "$output_path" + +/usr/bin/hdiutil verify "$output_path" +echo "Created $output_path" diff --git a/scripts/macos_notarize.sh b/scripts/macos_notarize.sh new file mode 100755 index 00000000..e97b541f --- /dev/null +++ b/scripts/macos_notarize.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +usage() { + cat <<'USAGE' +Usage: + scripts/macos_notarize.sh --file [--keychain-profile ] + +Authentication: + Local: pass --keychain-profile, or set NOTARYTOOL_PROFILE. + CI: set APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_SPECIFIC_PASSWORD. + +The command waits for Apple, requires an Accepted response, staples and validates +the ticket, then performs a Gatekeeper assessment of the DMG. +USAGE +} + +file_path="" +profile_name="${NOTARYTOOL_PROFILE:-}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --file) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --file" >&2 + exit 2 + fi + file_path="$2" + shift 2 + ;; + --keychain-profile) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "Missing value for --keychain-profile" >&2 + exit 2 + fi + profile_name="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS notarization must run on Darwin." >&2 + exit 1 +fi +if [[ -z "$file_path" ]]; then + echo "--file is required." >&2 + usage >&2 + exit 2 +fi +if [[ ! -f "$file_path" ]]; then + echo "Missing notarization input: $file_path" >&2 + exit 1 +fi +if [[ "$file_path" != *.dmg ]]; then + echo "This release helper accepts a signed DMG: $file_path" >&2 + exit 2 +fi + +declare -a authentication_args +if [[ -n "$profile_name" ]]; then + authentication_args=(--keychain-profile "$profile_name") +else + if [[ -z "${APPLE_ID:-}" || -z "${APPLE_TEAM_ID:-}" || + -z "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]]; then + echo "Provide --keychain-profile or set APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_SPECIFIC_PASSWORD." >&2 + exit 2 + fi + authentication_args=( + --apple-id "$APPLE_ID" + --team-id "$APPLE_TEAM_ID" + --password "$APPLE_APP_SPECIFIC_PASSWORD" + ) +fi + +temporary_base="${TMPDIR:-/tmp}" +temporary_base="${temporary_base%/}" +result_file="$(mktemp "${temporary_base}/acecode-notary.XXXXXX.json")" +cleanup() { + if [[ -n "${result_file:-}" && -f "$result_file" ]]; then + rm -f -- "$result_file" + fi +} +trap cleanup EXIT + +echo "Submitting $file_path to the Apple notary service" +xcrun notarytool submit "$file_path" \ + "${authentication_args[@]}" \ + --wait \ + --output-format json | tee "$result_file" + +status="$(/usr/bin/plutil -extract status raw -o - "$result_file" 2>/dev/null || true)" +submission_id="$(/usr/bin/plutil -extract id raw -o - "$result_file" 2>/dev/null || true)" +if [[ "$status" != "Accepted" ]]; then + echo "Apple notarization was not accepted (status: ${status:-unknown})." >&2 + if [[ -n "$submission_id" ]]; then + xcrun notarytool log "$submission_id" "${authentication_args[@]}" || true + fi + exit 1 +fi + +echo "Stapling notarization ticket to $file_path" +xcrun stapler staple "$file_path" +xcrun stapler validate "$file_path" +/usr/bin/codesign --verify --strict --verbose=2 "$file_path" + +echo "Assessing $file_path with Gatekeeper" +/usr/sbin/spctl --assess \ + --type open \ + --context context:primary-signature \ + --verbose=4 \ + "$file_path" + +echo "Notarized and validated $file_path" diff --git a/src/desktop/user_install_policy.cpp b/src/desktop/user_install_policy.cpp new file mode 100644 index 00000000..af6fab8f --- /dev/null +++ b/src/desktop/user_install_policy.cpp @@ -0,0 +1,42 @@ +#include "user_install_policy.hpp" + +namespace acecode::desktop { + +namespace { + +namespace fs = std::filesystem; + +fs::path normalize_absolute(const fs::path& path) { + if (path.empty() || !path.is_absolute()) return {}; + return path.lexically_normal(); +} + +} // namespace + +UserInstallPaths macos_user_install_paths(const fs::path& home_directory) { + UserInstallPaths paths; + paths.home = normalize_absolute(home_directory); + if (paths.home.empty()) return paths; + + paths.applications = paths.home / "Applications"; + paths.destination = paths.applications / "ACECode.app"; + return paths; +} + +bool macos_user_install_destination_is_safe( + const fs::path& resolved_home, + const fs::path& resolved_applications, + const fs::path& resolved_destination) { + const fs::path home = normalize_absolute(resolved_home); + const fs::path applications = normalize_absolute(resolved_applications); + const fs::path destination = normalize_absolute(resolved_destination); + if (home.empty() || applications.empty() || destination.empty()) { + return false; + } + + const UserInstallPaths expected = macos_user_install_paths(home); + return applications == expected.applications && + destination == expected.destination; +} + +} // namespace acecode::desktop diff --git a/src/desktop/user_install_policy.hpp b/src/desktop/user_install_policy.hpp new file mode 100644 index 00000000..d6dbf458 --- /dev/null +++ b/src/desktop/user_install_policy.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include + +namespace acecode::desktop { + +struct UserInstallPaths { + std::filesystem::path home; + std::filesystem::path applications; + std::filesystem::path destination; +}; + +// Return the only supported macOS installation layout. The caller supplies +// NSHomeDirectory() as a filesystem path so this policy remains portable and +// unit-testable on every CI platform. +UserInstallPaths macos_user_install_paths( + const std::filesystem::path& home_directory); + +// Validate paths after the caller has resolved filesystem symlinks. Exact path +// equality is intentional: a redirected ~/Applications directory must not +// silently turn a per-user installation into a write somewhere else. +bool macos_user_install_destination_is_safe( + const std::filesystem::path& resolved_home, + const std::filesystem::path& resolved_applications, + const std::filesystem::path& resolved_destination); + +} // namespace acecode::desktop diff --git a/src/macos_installer/main.mm b/src/macos_installer/main.mm new file mode 100644 index 00000000..fc19f2bb --- /dev/null +++ b/src/macos_installer/main.mm @@ -0,0 +1,256 @@ +#import +#import + +#include "desktop/user_install_policy.hpp" + +#include +#include + +namespace { + +namespace fs = std::filesystem; + +NSString* const kAcecodeBundleIdentifier = @"dev.acecode.desktop"; + +void show_alert(NSAlertStyle style, NSString* message, NSString* detail) { + NSAlert* alert = [[NSAlert alloc] init]; + [alert setAlertStyle:style]; + [alert setMessageText:message]; + [alert setInformativeText:detail ?: @""]; + [alert addButtonWithTitle:@"OK"]; + [alert runModal]; +} + +void show_error(NSString* detail) { + show_alert(NSAlertStyleCritical, + @"ACECode could not be installed / 无法安装 ACECode", + detail); +} + +NSURL* file_url(const fs::path& path, BOOL is_directory) { + const std::string bytes = path.string(); + NSString* value = [[NSFileManager defaultManager] + stringWithFileSystemRepresentation:bytes.c_str() + length:bytes.size()]; + if (!value) return nil; + return [NSURL fileURLWithPath:value isDirectory:is_directory]; +} + +fs::path filesystem_path(NSURL* url) { + if (!url) return {}; + const char* value = [[url path] fileSystemRepresentation]; + return value ? fs::path(value) : fs::path{}; +} + +bool resource_flag(NSURL* url, + NSURLResourceKey key, + bool* value, + NSError** error) { + id resource_value = nil; + if (![url getResourceValue:&resource_value forKey:key error:error]) { + return false; + } + if (![resource_value isKindOfClass:[NSNumber class]]) { + if (error) { + *error = [NSError errorWithDomain:@"dev.acecode.installer" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey: + @"Unexpected filesystem metadata." + }]; + } + return false; + } + *value = [(NSNumber*)resource_value boolValue] == YES; + return true; +} + +NSString* error_detail(NSString* message, NSError* error) { + if (!error) return message; + return [NSString stringWithFormat:@"%@\n\n%@", message, + [error localizedDescription]]; +} + +bool bundle_identifier_matches(NSURL* bundle_url) { + NSBundle* bundle = [NSBundle bundleWithURL:bundle_url]; + return bundle && [[bundle bundleIdentifier] + isEqualToString:kAcecodeBundleIdentifier]; +} + +} // namespace + +int main() { + @autoreleasepool { + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; + [NSApp activateIgnoringOtherApps:YES]; + + NSFileManager* file_manager = [NSFileManager defaultManager]; + NSURL* installer_url = [[NSBundle mainBundle] bundleURL]; + NSURL* image_root = [installer_url URLByDeletingLastPathComponent]; + NSURL* source_url = [image_root URLByAppendingPathComponent:@"ACECode.app" + isDirectory:YES]; + + BOOL source_is_directory = NO; + if (![file_manager fileExistsAtPath:[source_url path] + isDirectory:&source_is_directory] || + source_is_directory == NO) { + show_error(@"ACECode.app is missing beside the installer. Reopen the original DMG.\n\n安装器旁缺少 ACECode.app,请重新打开原始 DMG。"); + return 1; + } + + NSError* error = nil; + bool source_is_symlink = false; + if (!resource_flag(source_url, NSURLIsSymbolicLinkKey, + &source_is_symlink, &error) || source_is_symlink) { + show_error(error_detail( + @"The ACECode payload is not a safe application bundle.\n\nACECode 安装文件不是安全的应用包。", error)); + return 1; + } + if (!bundle_identifier_matches(source_url)) { + show_error(@"The ACECode payload has an unexpected bundle identifier.\n\nACECode 安装文件的 Bundle ID 不正确。"); + return 1; + } + + NSArray* running = + [NSRunningApplication runningApplicationsWithBundleIdentifier: + kAcecodeBundleIdentifier]; + if ([running count] != 0) { + show_error(@"Quit ACECode, then open this installer again.\n\n请先退出 ACECode,然后重新打开安装器。"); + return 1; + } + + const char* home_bytes = [NSHomeDirectory() fileSystemRepresentation]; + if (!home_bytes) { + show_error(@"The current user's home directory is unavailable.\n\n无法读取当前用户的主目录。"); + return 1; + } + const auto paths = acecode::desktop::macos_user_install_paths( + fs::path(home_bytes)); + if (paths.home.empty()) { + show_error(@"The current user's home directory is invalid.\n\n当前用户的主目录无效。"); + return 1; + } + + NSURL* home_url = file_url(paths.home, YES); + NSURL* applications_url = file_url(paths.applications, YES); + NSURL* destination_url = file_url(paths.destination, YES); + if (!home_url || !applications_url || !destination_url) { + show_error(@"The user installation path could not be represented safely.\n\n无法安全表示用户安装路径。"); + return 1; + } + + BOOL applications_is_directory = NO; + const BOOL applications_exists = + [file_manager fileExistsAtPath:[applications_url path] + isDirectory:&applications_is_directory]; + if (applications_exists) { + bool applications_is_symlink = false; + error = nil; + if (!resource_flag(applications_url, NSURLIsSymbolicLinkKey, + &applications_is_symlink, &error) || + applications_is_symlink || applications_is_directory == NO) { + show_error(error_detail( + @"~/Applications must be a real directory inside your home folder.\n\n~/Applications 必须是主目录中的真实文件夹,不能是符号链接。", + error)); + return 1; + } + } else { + error = nil; + if (![file_manager createDirectoryAtURL:applications_url + withIntermediateDirectories:YES + attributes:nil + error:&error]) { + show_error(error_detail( + @"The installer could not create ~/Applications.\n\n安装器无法创建 ~/Applications。", error)); + return 1; + } + } + + NSURL* resolved_home = [home_url URLByResolvingSymlinksInPath]; + NSURL* resolved_applications = + [applications_url URLByResolvingSymlinksInPath]; + NSURL* resolved_destination = + [resolved_applications URLByAppendingPathComponent:@"ACECode.app" + isDirectory:YES]; + if (!acecode::desktop::macos_user_install_destination_is_safe( + filesystem_path(resolved_home), + filesystem_path(resolved_applications), + filesystem_path(resolved_destination))) { + show_error(@"The resolved installation path is outside ~/Applications.\n\n解析后的安装路径不在 ~/Applications 中,安装已停止。"); + return 1; + } + destination_url = resolved_destination; + + BOOL destination_is_directory = NO; + const BOOL destination_exists = + [file_manager fileExistsAtPath:[destination_url path] + isDirectory:&destination_is_directory]; + if (destination_exists) { + bool destination_is_symlink = false; + error = nil; + if (!resource_flag(destination_url, NSURLIsSymbolicLinkKey, + &destination_is_symlink, &error) || + destination_is_symlink || destination_is_directory == NO) { + show_error(error_detail( + @"The existing ~/Applications/ACECode.app is not a replaceable application bundle.\n\n现有的 ~/Applications/ACECode.app 不是可安全替换的应用包。", + error)); + return 1; + } + } + + NSString* temporary_name = [NSString stringWithFormat: + @".ACECode-%@.installing.app", [[NSUUID UUID] UUIDString]]; + NSURL* temporary_url = + [resolved_applications URLByAppendingPathComponent:temporary_name + isDirectory:YES]; + error = nil; + if (![file_manager copyItemAtURL:source_url + toURL:temporary_url + error:&error]) { + show_error(error_detail( + @"The installer could not copy ACECode into ~/Applications.\n\n安装器无法将 ACECode 复制到 ~/Applications。", error)); + return 1; + } + + bool installed = false; + if (destination_exists) { + NSURL* resulting_url = nil; + error = nil; + installed = [file_manager replaceItemAtURL:destination_url + withItemAtURL:temporary_url + backupItemName:nil + options:NSFileManagerItemReplacementUsingNewMetadataOnly + resultingItemURL:&resulting_url + error:&error] == YES; + } else { + error = nil; + installed = [file_manager moveItemAtURL:temporary_url + toURL:destination_url + error:&error] == YES; + } + + if (!installed) { + NSError* cleanup_error = nil; + [file_manager removeItemAtURL:temporary_url error:&cleanup_error]; + show_error(error_detail( + @"The installer could not finish replacing ACECode.\n\n安装器无法完成 ACECode 的替换。", error)); + return 1; + } + + if (!bundle_identifier_matches(destination_url)) { + show_error(@"The installed application did not pass the final bundle check.\n\n安装后的应用未通过最终 Bundle 检查。"); + return 1; + } + + show_alert(NSAlertStyleInformational, + @"ACECode installed / ACECode 已安装", + @"Installed for the current user at ~/Applications/ACECode.app. No administrator access was requested.\n\n已为当前用户安装到 ~/Applications/ACECode.app,全程未请求管理员权限。ACECode 即将启动。"); + + if (![[NSWorkspace sharedWorkspace] openURL:destination_url]) { + show_error(@"ACECode was installed, but macOS could not open it automatically. Open ~/Applications/ACECode.app manually.\n\nACECode 已安装,但 macOS 无法自动启动。请手动打开 ~/Applications/ACECode.app。"); + return 1; + } + return 0; + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index da2643fb..72b76ee6 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,14 @@ find_package(GTest CONFIG REQUIRED) +if(UNIX) + add_test( + NAME macos_release_scripts_contract + COMMAND /bin/bash + ${CMAKE_CURRENT_SOURCE_DIR}/scripts/macos_release_scripts_test.sh + ) + set_tests_properties(macos_release_scripts_contract PROPERTIES LABELS "unit") +endif() + file(GLOB_RECURSE ACECODE_TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/*_test.cpp ) diff --git a/tests/desktop/user_install_policy_test.cpp b/tests/desktop/user_install_policy_test.cpp new file mode 100644 index 00000000..649230a5 --- /dev/null +++ b/tests/desktop/user_install_policy_test.cpp @@ -0,0 +1,61 @@ +#include "desktop/user_install_policy.hpp" + +#include + +#include + +namespace { + +namespace fs = std::filesystem; + +TEST(UserInstallPolicy, UsesCurrentUsersApplicationsDirectory) { + const auto paths = acecode::desktop::macos_user_install_paths( + fs::path("/Users/alice")); + + EXPECT_EQ(paths.home, fs::path("/Users/alice")); + EXPECT_EQ(paths.applications, fs::path("/Users/alice/Applications")); + EXPECT_EQ(paths.destination, + fs::path("/Users/alice/Applications/ACECode.app")); + EXPECT_TRUE(acecode::desktop::macos_user_install_destination_is_safe( + paths.home, paths.applications, paths.destination)); +} + +TEST(UserInstallPolicy, NormalizesDotSegmentsWithinExpectedLayout) { + EXPECT_TRUE(acecode::desktop::macos_user_install_destination_is_safe( + fs::path("/Users/alice/./"), + fs::path("/Users/alice/tmp/../Applications"), + fs::path("/Users/alice/Applications/./ACECode.app"))); +} + +TEST(UserInstallPolicy, RejectsRelativeAndEmptyHomeDirectories) { + EXPECT_TRUE(acecode::desktop::macos_user_install_paths({}).home.empty()); + EXPECT_TRUE(acecode::desktop::macos_user_install_paths("Users/alice").home.empty()); + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "Users/alice", "Users/alice/Applications", + "Users/alice/Applications/ACECode.app")); +} + +TEST(UserInstallPolicy, RejectsSystemWideApplicationsDirectory) { + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "/Users/alice", "/Applications", "/Applications/ACECode.app")); +} + +TEST(UserInstallPolicy, RejectsRedirectedApplicationsDirectory) { + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "/Users/alice", "/Users/alice/AlternateApps", + "/Users/alice/AlternateApps/ACECode.app")); + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "/Users/alice", "/Volumes/External/Applications", + "/Volumes/External/Applications/ACECode.app")); +} + +TEST(UserInstallPolicy, RejectsUnexpectedDestinationNamesAndLocations) { + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "/Users/alice", "/Users/alice/Applications", + "/Users/alice/Applications/Other.app")); + EXPECT_FALSE(acecode::desktop::macos_user_install_destination_is_safe( + "/Users/alice", "/Users/alice/Applications", + "/Users/alice/ACECode.app")); +} + +} // namespace diff --git a/tests/scripts/macos_release_scripts_test.sh b/tests/scripts/macos_release_scripts_test.sh new file mode 100755 index 00000000..65212e08 --- /dev/null +++ b/tests/scripts/macos_release_scripts_test.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +sign_script="$repo_root/scripts/macos_codesign.sh" +dmg_script="$repo_root/scripts/macos_create_dmg.sh" +notarize_script="$repo_root/scripts/macos_notarize.sh" + +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/acecode-release-scripts.XXXXXX")" +cleanup() { + if [[ -n "${temporary_root:-}" && -d "$temporary_root" ]]; then + rm -rf -- "$temporary_root" + fi +} +trap cleanup EXIT + +expect_status() { + local expected="$1" + local label="$2" + shift 2 + + local output="$temporary_root/output.txt" + local actual=0 + "$@" >"$output" 2>&1 || actual=$? + if [[ "$actual" -ne "$expected" ]]; then + echo "$label: expected exit $expected, got $actual" >&2 + sed -n '1,120p' "$output" >&2 + exit 1 + fi +} + +for script in "$sign_script" "$dmg_script" "$notarize_script"; do + bash -n "$script" + expect_status 0 "help for $(basename "$script")" bash "$script" --help + expect_status 2 "unknown argument for $(basename "$script")" \ + bash "$script" --definitely-unknown +done + +grep -Fq -- '--bundle ' "$sign_script" +grep -Fq -- '--keychain-profile ' "$notarize_script" +grep -Fq 'Install ACECode.app' "$dmg_script" + +if grep -Eq 'ln[[:space:]].*/Applications|ln[[:space:]]+-s[[:space:]]+/Applications' "$dmg_script"; then + echo "DMG helper must not create a system /Applications link" >&2 + exit 1 +fi + +if [[ "$(uname -s)" == "Darwin" ]]; then + expect_status 2 "missing DMG arguments" bash "$dmg_script" + expect_status 2 "missing notarization file" bash "$notarize_script" + expect_status 2 "missing signing identity" bash "$sign_script" + + touch "$temporary_root/unsigned.dmg" + expect_status 2 "missing notarization credentials" \ + env -u NOTARYTOOL_PROFILE -u APPLE_ID -u APPLE_TEAM_ID \ + -u APPLE_APP_SPECIFIC_PASSWORD \ + bash "$notarize_script" --file "$temporary_root/unsigned.dmg" +fi + +echo "macOS release script contract checks passed" From 1ae5b65600487aac8404327be4ad49e650d04260 Mon Sep 17 00:00:00 2001 From: haozhi shao Date: Thu, 6 Aug 2026 05:27:14 -0700 Subject: [PATCH 2/3] Fix macOS signing identity lookup --- .github/workflows/package.yml | 19 ++++++++++++------- docs/macos-release.md | 9 ++++++--- tests/scripts/macos_release_scripts_test.sh | 8 ++++++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 7d8f67b0..542c9c66 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -296,18 +296,23 @@ jobs: -k "$keychain_password" \ "$keychain_path" - identity="$MACOS_CODESIGN_IDENTITY" - if [ -z "$identity" ]; then - identity="$(security find-identity -v -p codesigning "$keychain_path" | - awk -F '"' '/Developer ID Application:/ { print $2; exit }')" - fi - if [ -z "$identity" ]; then + identity_record="$(security find-identity -v -p codesigning "$keychain_path" | + awk '/Developer ID Application:/ { print; exit }')" + identity_fingerprint="$(printf '%s\n' "$identity_record" | awk '{ print $2 }')" + identity_name="$(printf '%s\n' "$identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$identity_name" ]; then echo "::error::No Developer ID Application identity found in the imported certificate" exit 1 fi + if [ -n "$MACOS_CODESIGN_IDENTITY" ] && [ "$identity_name" != "$MACOS_CODESIGN_IDENTITY" ]; then + echo "::error::Imported signing identity does not match MACOS_CODESIGN_IDENTITY" + exit 1 + fi echo "keychain=$keychain_path" >> "$GITHUB_OUTPUT" - echo "identity=$identity" >> "$GITHUB_OUTPUT" + # Sign by the certificate fingerprint. codesign can fail to resolve a + # human-readable identity name inside an isolated temporary keychain. + echo "identity=$identity_fingerprint" >> "$GITHUB_OUTPUT" - name: Sign macOS release payloads if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' diff --git a/docs/macos-release.md b/docs/macos-release.md index 9381b950..4c2395fb 100644 --- a/docs/macos-release.md +++ b/docs/macos-release.md @@ -74,9 +74,12 @@ created, but the npm job reports a failure. This token is separate from macOS signing. The optional Actions variable `MACOS_CODESIGN_IDENTITY` can contain the full -identity name, for example `Developer ID Application: Name (TEAMID)`. Leave it -unset when the exported `.p12` contains only one Developer ID Application -identity; the workflow discovers it automatically. +identity name, for example `Developer ID Application: Name (TEAMID)`. When it is +set, the workflow uses it to verify that the imported `.p12` is the expected +identity. Signing itself always uses the unique certificate fingerprint found +in the temporary keychain, avoiding name-resolution failures in `codesign`. +Leave the variable unset when the exported `.p12` contains only one Developer +ID Application identity and this extra guard is not needed. ## Local Notarization Credentials diff --git a/tests/scripts/macos_release_scripts_test.sh b/tests/scripts/macos_release_scripts_test.sh index 65212e08..a84bbb67 100755 --- a/tests/scripts/macos_release_scripts_test.sh +++ b/tests/scripts/macos_release_scripts_test.sh @@ -5,6 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" sign_script="$repo_root/scripts/macos_codesign.sh" dmg_script="$repo_root/scripts/macos_create_dmg.sh" notarize_script="$repo_root/scripts/macos_notarize.sh" +package_workflow="$repo_root/.github/workflows/package.yml" temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/acecode-release-scripts.XXXXXX")" cleanup() { @@ -39,6 +40,13 @@ done grep -Fq -- '--bundle ' "$sign_script" grep -Fq -- '--keychain-profile ' "$notarize_script" grep -Fq 'Install ACECode.app' "$dmg_script" +grep -Fq 'identity_fingerprint=' "$package_workflow" +grep -Fq 'echo "identity=$identity_fingerprint"' "$package_workflow" + +if grep -Fq 'identity="$MACOS_CODESIGN_IDENTITY"' "$package_workflow"; then + echo "macOS signing must use the imported identity fingerprint, not a configured name" >&2 + exit 1 +fi if grep -Eq 'ln[[:space:]].*/Applications|ln[[:space:]]+-s[[:space:]]+/Applications' "$dmg_script"; then echo "DMG helper must not create a system /Applications link" >&2 From 85c5138f300262ebd06cf6f0d0e94dcc9b4ba799 Mon Sep 17 00:00:00 2001 From: haozhi shao Date: Thu, 6 Aug 2026 05:35:53 -0700 Subject: [PATCH 3/3] Add signing keychain to search list --- .github/workflows/package.yml | 14 ++++++++++++++ docs/macos-release.md | 7 ++++--- tests/scripts/macos_release_scripts_test.sh | 1 + 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 542c9c66..069436a5 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -284,6 +284,20 @@ jobs: security create-keychain -p "$keychain_password" "$keychain_path" security set-keychain-settings -lut 21600 "$keychain_path" security unlock-keychain -p "$keychain_password" "$keychain_path" + + existing_user_keychains=() + while IFS= read -r existing_keychain; do + if [ -n "$existing_keychain" ]; then + existing_user_keychains+=("$existing_keychain") + fi + done < <( + security list-keychains -d user | + sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' + ) + security list-keychains -d user -s \ + "$keychain_path" \ + "${existing_user_keychains[@]}" + security import "$certificate_path" \ -k "$keychain_path" \ -P "$MACOS_CERTIFICATE_PASSWORD" \ diff --git a/docs/macos-release.md b/docs/macos-release.md index 4c2395fb..55905d63 100644 --- a/docs/macos-release.md +++ b/docs/macos-release.md @@ -77,9 +77,10 @@ The optional Actions variable `MACOS_CODESIGN_IDENTITY` can contain the full identity name, for example `Developer ID Application: Name (TEAMID)`. When it is set, the workflow uses it to verify that the imported `.p12` is the expected identity. Signing itself always uses the unique certificate fingerprint found -in the temporary keychain, avoiding name-resolution failures in `codesign`. -Leave the variable unset when the exported `.p12` contains only one Developer -ID Application identity and this extra guard is not needed. +in the temporary keychain, and the workflow adds that temporary keychain to the +runner user's keychain search list before invoking `codesign`. Leave the +variable unset when the exported `.p12` contains only one Developer ID +Application identity and this extra guard is not needed. ## Local Notarization Credentials diff --git a/tests/scripts/macos_release_scripts_test.sh b/tests/scripts/macos_release_scripts_test.sh index a84bbb67..fc4a2cf4 100755 --- a/tests/scripts/macos_release_scripts_test.sh +++ b/tests/scripts/macos_release_scripts_test.sh @@ -42,6 +42,7 @@ grep -Fq -- '--keychain-profile ' "$notarize_script" grep -Fq 'Install ACECode.app' "$dmg_script" grep -Fq 'identity_fingerprint=' "$package_workflow" grep -Fq 'echo "identity=$identity_fingerprint"' "$package_workflow" +grep -Fq 'security list-keychains -d user -s' "$package_workflow" if grep -Fq 'identity="$MACOS_CODESIGN_IDENTITY"' "$package_workflow"; then echo "macOS signing must use the imported identity fingerprint, not a configured name" >&2