diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index ae8e2744..069436a5 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,90 @@ 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"
+
+ 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" \
+ -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_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"
+ # 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'
+ 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 +376,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 +490,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 +546,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 +754,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 +775,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..55905d63
--- /dev/null
+++ b/docs/macos-release.md
@@ -0,0 +1,161 @@
+# 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)`. 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, 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
+
+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..fc4a2cf4
--- /dev/null
+++ b/tests/scripts/macos_release_scripts_test.sh
@@ -0,0 +1,69 @@
+#!/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"
+package_workflow="$repo_root/.github/workflows/package.yml"
+
+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"
+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
+ 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
+ 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"