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