diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml new file mode 100644 index 0000000..3c1c58a --- /dev/null +++ b/.github/workflows/appstore.yml @@ -0,0 +1,130 @@ +name: App Store + +on: + workflow_dispatch: + inputs: + tag: + description: "Tag to build, e.g. v0.1.18. Defaults to the latest release." + required: false + type: string + upload: + description: "Upload to App Store Connect. Off means build and sign only." + required: false + default: true + type: boolean + +permissions: + contents: read + +jobs: + build: + runs-on: macos-26 + steps: + - name: Resolve the tag + id: tag + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + TAG="${{ inputs.tag }}" + if [ -z "$TAG" ]; then + TAG=$(gh release view --repo "$REPO" --json tagName --jq .tagName) + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "Building $TAG for the App Store" + + - uses: actions/checkout@v7 + with: + ref: ${{ steps.tag.outputs.tag }} + + - uses: actions/setup-node@v6 + with: + node-version: 26 + + - uses: pnpm/action-setup@v6 + with: + version: 10 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + + - uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: Install frontend dependencies + run: pnpm install --frozen-lockfile + + - name: Provision Google credentials + env: + GOOGLE_CREDENTIALS: ${{ secrets.GOOGLE_CREDENTIALS }} + run: | + if [ -n "$GOOGLE_CREDENTIALS" ]; then + printf '%s' "$GOOGLE_CREDENTIALS" > google-credentials.json + else + echo "::error::GOOGLE_CREDENTIALS is not set; an App Store build with placeholder credentials would ship a broken backup feature." + exit 1 + fi + + - name: Import the App Store certificates + env: + APP_CERT: ${{ secrets.MAS_APP_CERTIFICATE }} + APP_CERT_PASSWORD: ${{ secrets.MAS_APP_CERTIFICATE_PASSWORD }} + INSTALLER_CERT: ${{ secrets.MAS_INSTALLER_CERTIFICATE }} + INSTALLER_CERT_PASSWORD: ${{ secrets.MAS_INSTALLER_CERTIFICATE_PASSWORD }} + PROFILE: ${{ secrets.MAS_PROVISION_PROFILE }} + run: | + keychain="$RUNNER_TEMP/appstore.keychain-db" + password=$(uuidgen) + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 3600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + + import_p12() { + printf '%s' "$1" | base64 --decode > "$RUNNER_TEMP/cert.p12" + security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$2" \ + -T /usr/bin/codesign -T /usr/bin/productbuild + rm -f "$RUNNER_TEMP/cert.p12" + } + import_p12 "$APP_CERT" "$APP_CERT_PASSWORD" + import_p12 "$INSTALLER_CERT" "$INSTALLER_CERT_PASSWORD" + + # Without this, codesign on a headless runner blocks on a keychain prompt nobody can + # answer and the job hangs until it times out. + security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" > /dev/null + security list-keychains -d user -s "$keychain" login.keychain-db + + printf '%s' "$PROFILE" | base64 --decode > "$RUNNER_TEMP/margin.provisionprofile" + security find-identity -v "$keychain" + + - name: Build the sandboxed bundle + run: | + # No APPLE_SIGNING_IDENTITY here on purpose: mas-package.sh signs, because the + # provisioning profile has to be inside the bundle before codesign runs. + pnpm tauri build --target universal-apple-darwin --config src-tauri/tauri.appstore.conf.json --bundles app + + - name: Sign, package and upload + env: + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MAS_APP_IDENTITY: ${{ secrets.MAS_APP_IDENTITY }} + MAS_INSTALLER_IDENTITY: ${{ secrets.MAS_INSTALLER_IDENTITY }} + MAS_PROVISION_PROFILE: ${{ runner.temp }}/margin.provisionprofile + MAS_BUILD_NUMBER: ${{ github.run_number }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + MAS_UPLOAD: ${{ inputs.upload && '1' || '' }} + run: | + mkdir -p ~/private_keys + printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8 + chmod 600 ~/private_keys/AuthKey_$APPLE_API_KEY_ID.p8 + ./scripts/mas-package.sh + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: margin-appstore-pkg + path: target-mas/*.pkg + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 7095c04..4b15b77 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,10 @@ src-tauri/target/ # Screenshots & Playwright MCP artifacts .playwright-mcp/ /*.png + +# App Store packaging output +target-mas/ + +# Beta app review contact details. Apple requires a real phone number and this repo is public. +appstore/metadata/review_phone.txt +appstore/metadata/review_email.txt diff --git a/scripts/mas-package.sh b/scripts/mas-package.sh new file mode 100755 index 0000000..9e467a4 --- /dev/null +++ b/scripts/mas-package.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Turn the .app that `tauri build` produced into a signed .pkg the App Store will accept, and +# optionally hand it to App Store Connect. +# +# Tauri has no App Store target, so everything after the bundle is done here: the provisioning +# profile goes in before signing (codesign hashes it), the entitlements carry the team identifier, +# and productbuild wraps the result. Tauri's own signing is deliberately not used, because it +# cannot embed a profile and would sign the app before the profile was in place. +set -euo pipefail + +# CI builds universal; a local check against a single-arch build only needs to override this. +app="${APP_PATH:-src-tauri/target/universal-apple-darwin/release/bundle/macos/Margin.app}" +pkg="${PKG_PATH:-target-mas/Margin.pkg}" + +: "${APPLE_TEAM_ID:?set APPLE_TEAM_ID to the 10-character team identifier}" +: "${MAS_PROVISION_PROFILE:?set MAS_PROVISION_PROFILE to the .provisionprofile path}" +: "${MAS_APP_IDENTITY:?set MAS_APP_IDENTITY, e.g. '3rd Party Mac Developer Application: Priyanshu Jain (TEAMID)'}" +: "${MAS_INSTALLER_IDENTITY:?set MAS_INSTALLER_IDENTITY, e.g. '3rd Party Mac Developer Installer: Priyanshu Jain (TEAMID)'}" + +[ -d "$app" ] || { echo "mas-package: $app does not exist; run the build first." >&2; exit 1; } + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$(dirname "$pkg")" + +# App Store Connect rejects an upload whose CFBundleVersion it has already seen, so a rejected +# build has to come back with a higher one. The marketing version stays put. +if [ -n "${MAS_BUILD_NUMBER:-}" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $MAS_BUILD_NUMBER" "$app/Contents/Info.plist" +fi + +cp "$MAS_PROVISION_PROFILE" "$app/Contents/embedded.provisionprofile" + +# The profile is kept owner-only where it lives, because it sits next to signing keys, and cp +# carries that mode across. Apple rejects a package containing anything a non-root user cannot +# read, since the code signature could not then be verified at launch. Widen everything rather +# than just the profile, and only ever add permission bits, never remove one. +find "$app" -type d -exec chmod go+rx {} + +find "$app" -type f -exec chmod go+r {} + +sed "s/__TEAM_ID__/$APPLE_TEAM_ID/g" src-tauri/entitlements.mas.plist > "$work/entitlements.plist" +plutil -lint "$work/entitlements.plist" > /dev/null + +# Nested code has to be signed before the bundle that contains it, and --deep is the wrong tool +# for signing (it applies the outer entitlements to everything inside). Tauri bundles carry no +# frameworks today, so this loop is usually empty, and it stays here so that stops being silent +# the day one appears. +while IFS= read -r -d '' nested; do + codesign --force --timestamp --options runtime --sign "$MAS_APP_IDENTITY" "$nested" +done < <(find "$app/Contents/Frameworks" "$app/Contents/XPCServices" -maxdepth 1 -mindepth 1 -print0 2>/dev/null) + +codesign --force --timestamp --options runtime \ + --sign "$MAS_APP_IDENTITY" \ + --entitlements "$work/entitlements.plist" \ + "$app" + +codesign --verify --deep --strict --verbose=2 "$app" +echo "Entitlements on the signed bundle:" +codesign --display --entitlements - --xml "$app" | plutil -convert xml1 -o - - + +productbuild --component "$app" /Applications --sign "$MAS_INSTALLER_IDENTITY" "$pkg" +pkgutil --check-signature "$pkg" +echo "mas-package: wrote $pkg" + +# The upload needs the .p8 where altool looks for it; the caller places it and sets these. +# altool exits 0 even when it has just printed UPLOAD FAILED, so its exit status cannot be +# trusted and the transcript is the only reliable signal. +run_altool() { + local action="$1" output + output=$(xcrun altool "$action" -f "$pkg" -t macos \ + --apiKey "$APPLE_API_KEY_ID" --apiIssuer "$APPLE_API_ISSUER" 2>&1) || true + printf '%s\n' "$output" + if printf '%s' "$output" | grep -qE "VERIFY FAILED|UPLOAD FAILED|ERROR:"; then + echo "mas-package: $action failed, see the errors above." >&2 + return 1 + fi +} + +if [ -n "${MAS_UPLOAD:-}" ]; then + : "${APPLE_API_KEY_ID:?}" "${APPLE_API_ISSUER:?}" + run_altool --validate-app + run_altool --upload-app + echo "mas-package: uploaded to App Store Connect; the build appears once processing finishes." +fi diff --git a/scripts/mas-upload-local.sh b/scripts/mas-upload-local.sh new file mode 100755 index 0000000..9d1674d --- /dev/null +++ b/scripts/mas-upload-local.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Sign, package and upload an App Store build from this machine, using the certificates in +# ~/.margin-signing rather than the ones in CI. +# +# The certificates go into a keychain that exists only for the length of this run, and the login +# keychain is never written to. That keeps a machine that has signed once from quietly being able +# to sign forever, and it means this leaves nothing behind to go stale. +# +# ./scripts/mas-upload-local.sh # build, sign, package, upload +# MAS_UPLOAD= ./scripts/mas-upload-local.sh # stop after the .pkg +set -euo pipefail + +cd "$(dirname "$0")/.." + +DIR="${MARGIN_SIGNING_DIR:-$HOME/.margin-signing}" +BUNDLE_ID="${BUNDLE_ID:-studio.margin.app}" +KEYCHAIN="$(mktemp -d)/margin-mas.keychain-db" + +for f in "$BUNDLE_ID.env" "$BUNDLE_ID.provisionprofile" apple-distribution.p12 mac-installer.p12; do + [ -f "$DIR/$f" ] || { echo "mas-upload-local: $DIR/$f is missing; run apple-provision.rb first." >&2; exit 1; } +done + +# shellcheck source=/dev/null +set -a; . "$DIR/$BUNDLE_ID.env"; set +a +export MAS_PROVISION_PROFILE="$DIR/$BUNDLE_ID.provisionprofile" + +# App Store Connect refuses an upload whose build number it has already seen, and seconds since the +# epoch is both unique and monotonic without needing anything to be remembered between runs. +export MAS_BUILD_NUMBER="${MAS_BUILD_NUMBER:-$(date +%s)}" +export MAS_UPLOAD="${MAS_UPLOAD-1}" + +if [ -n "${MAS_UPLOAD:-}" ]; then + # shellcheck source=/dev/null + set -a; . "$DIR/AuthKey.env"; set +a + mkdir -p ~/private_keys + cp "$DIR/AuthKey.p8" ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8" + chmod 600 ~/private_keys/"AuthKey_$APPLE_API_KEY_ID.p8" +fi + +original_keychains=$(security list-keychains -d user | sed 's/^ *"//;s/"$//') +restore() { + # shellcheck disable=SC2086 + security list-keychains -d user -s $original_keychains + security delete-keychain "$KEYCHAIN" 2>/dev/null || true +} +trap restore EXIT + +security create-keychain -p margin "$KEYCHAIN" +security unlock-keychain -p margin "$KEYCHAIN" +for cert in apple-distribution mac-installer; do + security import "$DIR/$cert.p12" -k "$KEYCHAIN" -P "$(cat "$DIR/$cert.p12.pass")" \ + -T /usr/bin/codesign -T /usr/bin/productbuild > /dev/null +done +# Without this, codesign stops on a keychain prompt rather than signing. +security set-key-partition-list -S apple-tool:,apple: -k margin "$KEYCHAIN" > /dev/null +# shellcheck disable=SC2086 +security list-keychains -d user -s "$KEYCHAIN" $original_keychains + +if [ -z "${SKIP_BUILD:-}" ]; then + pnpm tauri build --target universal-apple-darwin \ + --config src-tauri/tauri.appstore.conf.json --bundles app +fi + +./scripts/mas-package.sh diff --git a/src-tauri/entitlements.mas.plist b/src-tauri/entitlements.mas.plist new file mode 100644 index 0000000..15d5fa8 --- /dev/null +++ b/src-tauri/entitlements.mas.plist @@ -0,0 +1,29 @@ + + + + + com.apple.security.app-sandbox + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.files.user-selected.read-write + + + com.apple.application-identifier + __TEAM_ID__.studio.margin.app + com.apple.developer.team-identifier + __TEAM_ID__ + + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f867d7..69c5052 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -30,8 +30,13 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result let export_epub = MenuItemBuilder::with_id("export-epub", "Export as EPUB…") .accelerator("CmdOrCtrl+Shift+E") .build(handle)?; - let check_updates = - MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)?; + let check_updates = handle + .config() + .plugins + .0 + .contains_key("updater") + .then(|| MenuItemBuilder::with_id("check-updates", "Check for Updates…").build(handle)) + .transpose()?; let settings = MenuItemBuilder::with_id("settings", "Settings…") .accelerator("CmdOrCtrl+,") .build(handle)?; @@ -93,9 +98,13 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result #[cfg(target_os = "macos")] { if let Some(app_submenu) = submenus.first() { - app_submenu.insert(&check_updates, 1)?; - app_submenu.insert(&settings, 3)?; - app_submenu.insert(&PredefinedMenuItem::separator(handle)?, 4)?; + let mut settings_at = 2; + if let Some(check_updates) = &check_updates { + app_submenu.insert(check_updates, 1)?; + settings_at += 1; + } + app_submenu.insert(&settings, settings_at)?; + app_submenu.insert(&PredefinedMenuItem::separator(handle)?, settings_at + 1)?; } if let Some(window) = find_submenu("Window") { let show_window = MenuItemBuilder::with_id("show-window", "Open Window") @@ -126,7 +135,9 @@ fn build_menu(handle: &tauri::AppHandle) -> tauri::Result #[cfg(not(target_os = "macos"))] { if let Some(file) = find_submenu("File") { - file.append_items(&[&PredefinedMenuItem::separator(handle)?, &check_updates])?; + if let Some(check_updates) = &check_updates { + file.append_items(&[&PredefinedMenuItem::separator(handle)?, check_updates])?; + } } if let Some(edit) = find_submenu("Edit") { edit.append_items(&[&settings])?; diff --git a/src-tauri/tauri.appstore.conf.json b/src-tauri/tauri.appstore.conf.json new file mode 100644 index 0000000..a5d44e6 --- /dev/null +++ b/src-tauri/tauri.appstore.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "targets": ["app"], + "macOS": { + "hardenedRuntime": true + } + } +}