From f3035534e7e66c772e360565633321a4c513c18a Mon Sep 17 00:00:00 2001 From: Michael Sollami Date: Mon, 20 Jul 2026 19:06:04 -0400 Subject: [PATCH] Add Mac App Store distribution scaffolding Make the Tauri notebook shippable to the Mac App Store by making the C engine sidecar self-contained and adding the MAS build configuration. - build-sidecar-appstore.sh: bundles the Homebrew dylibs (gmp, mpfr, pcre2, raylib and transitive deps) next to the sidecar, rewrites all install names to @rpath/@loader_path, adds rpaths, and ad-hoc re-signs. Verified free of /opt/homebrew references and runs self-contained with Homebrew unreachable. - Entitlements.plist: App Sandbox, application/team identifiers, and user-selected file read-write (notebook open/save). No network use. - tauri.appstore.conf.json: overlay config wiring entitlements, bundled frameworks, and the provisioning profile without affecting .dmg builds. - docs/appstore.md: end-to-end runbook and pitfalls. - Changelog note for the week of 2026-07-20. --- docs/appstore.md | 228 ++++++++++++++++++++ docs/spec/changelog/2026-07-20.md | 35 +++ frontend/build-sidecar-appstore.sh | 159 ++++++++++++++ frontend/src-tauri/Entitlements.plist | 59 +++++ frontend/src-tauri/tauri.appstore.conf.json | 17 ++ 5 files changed, 498 insertions(+) create mode 100644 docs/appstore.md create mode 100755 frontend/build-sidecar-appstore.sh create mode 100644 frontend/src-tauri/Entitlements.plist create mode 100644 frontend/src-tauri/tauri.appstore.conf.json diff --git a/docs/appstore.md b/docs/appstore.md new file mode 100644 index 000000000..052b3cf0f --- /dev/null +++ b/docs/appstore.md @@ -0,0 +1,228 @@ +# Mac App Store distribution runbook + +This document describes how to build, sign, and upload the Mathilda Notebook +(the Tauri v2 app in `frontend/`) to the Mac App Store (MAS). + +Reference: + +> **Scope / status** +> +> - **arm64-only** for now. The bundled math libraries (GMP, MPFR, pcre2) are +> copied from the local arm64 Homebrew install; building x86_64 versions of +> these here is not feasible. Universal (arm64 + x86_64) is **future work** — +> it requires x86_64 builds of GMP/MPFR/pcre2/raylib and `lipo`-merged +> dylibs + a universal engine binary. +> - All four Homebrew dylibs are bundled (gmp, mpfr, pcre2, raylib). raylib is +> kept even though the notebook renders graphics via Plotly JSON, to avoid any +> regression risk in the engine's `USE_GRAPHICS` code paths. + +--- + +## 0. What is automated vs. what needs YOUR Apple account + +| Step | Automatable in-repo | Requires your Apple Developer account | +|------|:-------------------:|:-------------------------------------:| +| Self-contained sidecar (`build-sidecar-appstore.sh`) | ✅ | — | +| Entitlements / app-store Tauri config | ✅ | — | +| Apple Developer Program enrollment | — | ✅ | +| Register App ID + App Store Connect record | — | ✅ | +| Create Distribution + Installer certificates | — | ✅ | +| Create + download provisioning profile | — | ✅ | +| Signed `.app` / `.pkg` build | ✅ (once certs+profile are installed) | needs your identity | +| Upload to App Store Connect | — | ✅ (API key / Apple ID) | + +Everything marked "requires your Apple Developer account" **cannot be done in +this repo** — it needs a paid Apple Developer account, private signing keys, and +web actions on developer.apple.com / App Store Connect. + +--- + +## 1. Self-contained sidecar (already solved in-repo) + +MAS apps run inside the **App Sandbox**, which cannot load dylibs from +`/opt/homebrew`. The engine is spawned as a Tauri **sidecar** +(`frontend/src-tauri/binaries/mathilda-aarch64-apple-darwin`) and normally links +Homebrew dylibs. `frontend/build-sidecar-appstore.sh` fixes this: + +```sh +cd frontend +./build-sidecar-appstore.sh +``` + +It builds the engine, copies the sidecar, bundles the required Homebrew dylibs +(and any transitive Homebrew deps) into `src-tauri/binaries/libs/`, rewrites all +install names to `@rpath`/`@loader_path`, adds `LC_RPATH`s +(`@executable_path/../Frameworks` and `@executable_path/libs`), and ad-hoc +re-signs everything. It verifies at the end that **no `/opt/homebrew` paths +remain**. + +`tauri.appstore.conf.json` lists those dylibs under `bundle.macOS.frameworks`, +so Tauri copies them into `Mathilda.app/Contents/Frameworks/` at bundle time and +includes them in the final signature. The sidecar (in `Contents/MacOS/`) +resolves them via `@rpath` → `../Frameworks`. + +--- + +## 2. Apple Developer enrollment (requires your account) + +1. Enroll in the **Apple Developer Program** ($99/yr): + . +2. Note your **Team ID** (10 chars, e.g. `A1B2C3D4E5`) — Membership page. + +--- + +## 3. Register the App ID and App Store Connect record (requires your account) + +1. developer.apple.com → **Certificates, Identifiers & Profiles → Identifiers → + +** → **App IDs → App**. +2. **Bundle ID** must EXACTLY equal `tauri.conf.json > identifier`, currently + **`com.mathilda.notebook`**. (This is already a real reverse-DNS id — no fix + needed.) +3. App Store Connect → **Apps → +** → **New App**, select macOS, and pick the + same bundle id. + +--- + +## 4. Certificates (requires your account) + +Create and install (double-click the downloaded `.cer` to add to Keychain): + +- **Apple Distribution** — signs the `.app`. + Identity string: `Apple Distribution: YOUR NAME (TEAMID)` +- **Mac Installer Distribution** — signs the `.pkg` for upload. + Identity string: `3rd Party Mac Developer Installer: YOUR NAME (TEAMID)` + +Verify they are present: + +```sh +security find-identity -v -p codesigning # should list "Apple Distribution: ..." +security find-identity -v | grep "Installer" # should list "3rd Party Mac Developer Installer: ..." +``` + +--- + +## 5. Provisioning profile (requires your account) + +1. developer.apple.com → **Profiles → +** → **Mac App Store** distribution. +2. Select the `com.mathilda.notebook` App ID and the Apple Distribution cert. +3. Download the profile and copy it to the exact filename referenced by + `tauri.appstore.conf.json`: + + ```sh + cp ~/Downloads/Mathilda_MAS.provisionprofile \ + frontend/src-tauri/embedded.provisionprofile + ``` + + Tauri copies this to `Mathilda.app/Contents/embedded.provisionprofile`, where + macOS expects it. + +--- + +## 6. Fill in the entitlements + +`frontend/src-tauri/Entitlements.plist` ships with placeholders. Replace them: + +- `$IDENTIFIER` → `com.mathilda.notebook` (must match `tauri.conf.json`) +- `$TEAM_ID` → your 10-char Team ID + +Entitlements included (and why): + +| Entitlement | Value | Why | +|-------------|-------|-----| +| `com.apple.security.app-sandbox` | `true` | **Mandatory** for MAS. | +| `com.apple.application-identifier` | `com.mathilda.notebook` | Must match bundle id + profile. | +| `com.apple.developer.team-identifier` | `` | Must match cert + profile. | +| `com.apple.security.files.user-selected.read-write` | `true` | The notebook opens/saves `.lb` files via the dialog plugin; the sandbox only allows user-picked files with this entitlement. | + +**Not included:** `com.apple.security.network.*` — the engine talks to the app +over local stdio pipes only; there is no network use. Add `network.client` only +if a real network feature is introduced. + +--- + +## 7. Build the signed `.app` + +```sh +cd frontend +./build-sidecar-appstore.sh # (re)build the self-contained sidecar + +export APPLE_SIGNING_IDENTITY="Apple Distribution: YOUR NAME (TEAMID)" +npm run tauri build -- --bundles app --config src-tauri/tauri.appstore.conf.json +``` + +This produces `frontend/src-tauri/target/release/bundle/macos/Mathilda.app`, +signed with the App Sandbox entitlements and the embedded provisioning profile. + +> The `--config src-tauri/tauri.appstore.conf.json` overlay is merged onto the +> base `tauri.conf.json`. Normal `.dmg` builds (`npm run tauri build`) do **not** +> use it and are unaffected. + +--- + +## 8. Wrap into a signed `.pkg` + +MAS uploads require a `.pkg` signed with the **installer** identity: + +```sh +xcrun productbuild \ + --sign "3rd Party Mac Developer Installer: YOUR NAME (TEAMID)" \ + --component "src-tauri/target/release/bundle/macos/Mathilda.app" /Applications \ + Mathilda.pkg +``` + +--- + +## 9. Upload to App Store Connect (requires your account) + +Using an App Store Connect API key (recommended): + +```sh +xcrun altool --upload-app --type macos --file Mathilda.pkg \ + --apiKey --apiIssuer +``` + +(The newer `xcrun notarytool` is for notarization of Developer-ID/`.dmg` +distribution, not MAS uploads; MAS apps are reviewed, not notarized.) + +Then finish submission in App Store Connect (screenshots, metadata, review +notes) and submit for review. + +--- + +## 10. Common pitfalls + +- **"App sandbox not enabled" rejection.** Entitlements are applied at + *signing* time, not build time. You must build with signing enabled + (`APPLE_SIGNING_IDENTITY` set) and the `tauri.appstore.conf.json` overlay so + `bundle.macOS.entitlements` points at `Entitlements.plist`. A plain unsigned + build will be rejected. Verify: + + ```sh + codesign -d --entitlements - \ + src-tauri/target/release/bundle/macos/Mathilda.app + ``` + + should show `com.apple.security.app-sandbox = true`. + +- **Root-only-readable files (installer error 409 / "package ... not + readable").** Every file in the bundle must be world-readable and not + owned by root. `build-sidecar-appstore.sh` `chmod u+w`s the copied dylibs; + if you copy files manually, ensure `chmod -R a+r` and non-root ownership. + +- **Architecture mismatch.** This pipeline is arm64-only. Uploading an + arm64-only build is accepted, but the app will not run on Intel Macs. For + universal support, build x86_64 GMP/MPFR/pcre2/raylib, `lipo`-merge each dylib + and the engine binary, and re-run the install-name rewrites. + +- **`/opt/homebrew` sneaking back in.** After any engine rebuild, re-run + `build-sidecar-appstore.sh` — it re-verifies no Homebrew paths remain. Confirm + manually with: + + ```sh + otool -L src-tauri/binaries/mathilda-aarch64-apple-darwin | grep homebrew || echo clean + ``` + +- **Signature invalidated after `install_name_tool`.** Any post-signing edit to + a Mach-O invalidates its signature. The script re-signs after rewriting; the + real Tauri build re-signs everything again with your Distribution identity, so + order matters: build sidecar → tauri build (signs) → productbuild. diff --git a/docs/spec/changelog/2026-07-20.md b/docs/spec/changelog/2026-07-20.md index 7f20661fd..c9c444880 100644 --- a/docs/spec/changelog/2026-07-20.md +++ b/docs/spec/changelog/2026-07-20.md @@ -442,3 +442,38 @@ This is the same class of bug as the `HoldForm` LaTeX gap fixed earlier. Regression covered in `tests/test_series_latex` (`tests/test_print.c`), which asserts both the plain (`x - 1/6 x^3 + 1/120 x^5 + O[x]^6`) and LaTeX forms. + +## Mac App Store distribution scaffolding (2026-07-20) + +Packaging-only work to make the Tauri notebook (`frontend/`) shippable to the +Mac App Store (MAS). No C-engine changes. + +The blocker was that the compiled engine is spawned as a Tauri **sidecar** +(`frontend/src-tauri/binaries/mathilda-aarch64-apple-darwin`) that linked +Homebrew dylibs (`/opt/homebrew/opt/{gmp,mpfr,pcre2,raylib}`). Those paths do +not exist on user machines and are disallowed under the App Sandbox MAS +requires. + +- **`frontend/build-sidecar-appstore.sh`** — builds the engine, copies the + sidecar, recursively bundles the four Homebrew dylibs (and transitive deps — + only `mpfr → gmp` in practice) into `src-tauri/binaries/libs/`, rewrites all + install names via `install_name_tool` (dylib IDs and the sidecar's loads → + `@rpath/…`; inter-dylib refs → `@loader_path/…`), adds + `@executable_path/../Frameworks` and `@executable_path/libs` rpaths, and + ad-hoc re-signs everything. Verified: `otool -L` on the sidecar and every + bundled dylib shows no `/opt/homebrew` paths, and the sidecar runs + self-contained (Series/Factor over the stdio pipe protocol) in a temp bundle + layout with Homebrew scrubbed from `PATH` and `DYLD_*` cleared. arm64-only for + now; universal is future work. +- **`frontend/src-tauri/Entitlements.plist`** — App Sandbox (mandatory), + application-identifier / team-identifier placeholders, and + `files.user-selected.read-write` (the notebook opens/saves `.lb` files via the + dialog plugin). No network entitlement — sidecar comms are local stdio only. +- **`frontend/src-tauri/tauri.appstore.conf.json`** — overlay config (base + `.dmg` builds unaffected) setting `bundle.macOS.entitlements`, + `bundle.macOS.frameworks` (the bundled dylibs → `Contents/Frameworks`), and + `bundle.macOS.files` for `embedded.provisionprofile`. Base identifier + `com.mathilda.notebook` is already a real reverse-DNS id. +- **`docs/appstore.md`** — full runbook (enrollment, App ID, certs, + provisioning profile, signed `.app`/`.pkg` build, upload), clearly marking the + Apple-account-dependent steps and common pitfalls. diff --git a/frontend/build-sidecar-appstore.sh b/frontend/build-sidecar-appstore.sh new file mode 100755 index 000000000..7676cc627 --- /dev/null +++ b/frontend/build-sidecar-appstore.sh @@ -0,0 +1,159 @@ +#!/bin/sh +# build-sidecar-appstore.sh +# +# Builds a SELF-CONTAINED Mac App Store (MAS) sidecar for the Mathilda +# notebook. The standard build-sidecar.sh drops a binary that links against +# Homebrew dylibs under /opt/homebrew — those paths do not exist on end-user +# machines and are disallowed under the App Sandbox that MAS requires. +# +# This script: +# 1. Builds the C engine (make -j at repo root). +# 2. Copies the sidecar to src-tauri/binaries/mathilda-. +# 3. Copies every required Homebrew dylib (gmp, mpfr, pcre2, raylib) AND any +# transitive Homebrew deps into src-tauri/binaries/libs/. +# 4. Rewrites, with install_name_tool: +# (a) each dylib's own LC_ID_DYLIB -> @rpath/ +# (b) the sidecar's LC_LOAD_DYLIB -> @rpath/ +# (c) inter-dylib refs (mpfr->gmp) -> @loader_path/ +# and adds two LC_RPATHs to the sidecar so @rpath resolves both when the +# dylibs sit in Contents/Frameworks (MAS bundle) and in ./libs next to the +# sidecar (dev / manual runs). +# 5. Ad-hoc re-signs (codesign -f -s -) every dylib and the sidecar, because +# install_name_tool invalidates any existing signature. (The real MAS +# build re-signs everything with the Apple Distribution identity; ad-hoc +# signing here just keeps the artifacts loadable for local verification.) +# +# NOTE: arm64-only for now. Universal (arm64 + x86_64) requires x86_64 builds of +# GMP/MPFR/pcre2, which is future work (see docs/appstore.md). +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BINARIES_DIR="$SCRIPT_DIR/src-tauri/binaries" +LIBS_DIR="$BINARIES_DIR/libs" + +# --- target triple ----------------------------------------------------------- +TARGET=$(rustc -vV | grep host | awk '{print $2}') +if [ -z "$TARGET" ]; then + echo "ERROR: could not determine Rust target triple" >&2 + exit 1 +fi +case "$TARGET" in + aarch64-apple-darwin) ;; + *) + echo "ERROR: this script is arm64-only for now (got $TARGET)." >&2 + echo " Universal builds are documented as future work in docs/appstore.md." >&2 + exit 1 + ;; +esac + +SIDECAR="$BINARIES_DIR/mathilda-$TARGET" + +echo "==> Building Mathilda for target: $TARGET" +make -C "$REPO_ROOT" USE_ECM=0 -j4 + +mkdir -p "$BINARIES_DIR" "$LIBS_DIR" +cp "$REPO_ROOT/Mathilda" "$SIDECAR" +echo "==> Sidecar copied: $SIDECAR" + +# --- collect Homebrew dylibs, recursively (transitive deps) ------------------ +# Walk the dependency graph starting from the sidecar. Any load command whose +# path begins with /opt/homebrew is a dylib we must bundle. libSystem, libedit, +# /usr/lib/* and /System/Library/* frameworks stay as OS-provided references. +collect() { + # $1 = mach-o file to inspect + otool -L "$1" | tail -n +2 | awk '{print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*) + base=$(basename "$dep") + if [ ! -f "$LIBS_DIR/$base" ]; then + echo " bundling $base (from $dep)" + cp "$dep" "$LIBS_DIR/$base" + chmod u+w "$LIBS_DIR/$base" + # Recurse into the freshly copied dylib to catch its own + # Homebrew dependencies. + collect "$LIBS_DIR/$base" + fi + ;; + esac + done +} + +echo "==> Collecting Homebrew dylibs (recursively)" +collect "$SIDECAR" + +echo "==> Bundled dylibs:" +ls -1 "$LIBS_DIR" + +# --- rewrite install names --------------------------------------------------- +# (a) each dylib's own LC_ID_DYLIB -> @rpath/ +echo "==> Rewriting dylib IDs (LC_ID_DYLIB -> @rpath/...)" +for lib in "$LIBS_DIR"/*.dylib; do + base=$(basename "$lib") + install_name_tool -id "@rpath/$base" "$lib" +done + +# (c) inter-dylib references -> @loader_path/ +# For every bundled dylib, any load command still pointing at /opt/homebrew must +# point at a sibling in the same directory. @loader_path resolves relative to +# the dylib doing the loading, so it works wherever the libs/ dir is placed. +echo "==> Rewriting inter-dylib references (-> @loader_path/...)" +for lib in "$LIBS_DIR"/*.dylib; do + otool -L "$lib" | tail -n +2 | awk '{print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*) + base=$(basename "$dep") + install_name_tool -change "$dep" "@loader_path/$base" "$lib" + ;; + esac + done +done + +# (b) sidecar's LC_LOAD_DYLIB entries -> @rpath/, plus LC_RPATHs +echo "==> Rewriting sidecar load commands (-> @rpath/...)" +otool -L "$SIDECAR" | tail -n +2 | awk '{print $1}' | while read -r dep; do + case "$dep" in + /opt/homebrew/*) + base=$(basename "$dep") + install_name_tool -change "$dep" "@rpath/$base" "$SIDECAR" + ;; + esac +done + +# Add rpaths so @rpath resolves in both deployment layouts: +# - MAS bundle: sidecar in Contents/MacOS, dylibs in Contents/Frameworks +# - dev / manual: dylibs in ./libs next to the sidecar +add_rpath() { + # add $2 to $1 only if not already present (install_name_tool errors on dupes) + if ! otool -l "$1" | grep -A2 LC_RPATH | grep -q " $2$"; then + install_name_tool -add_rpath "$2" "$1" + fi +} +add_rpath "$SIDECAR" "@executable_path/../Frameworks" +add_rpath "$SIDECAR" "@executable_path/libs" + +# --- re-sign (ad-hoc) -------------------------------------------------------- +# install_name_tool invalidates any existing code signature. Re-sign ad-hoc so +# the artifacts load for local verification. The production MAS build re-signs +# with "Apple Distribution: ..." (see docs/appstore.md). +echo "==> Ad-hoc re-signing dylibs and sidecar" +for lib in "$LIBS_DIR"/*.dylib; do + codesign -f -s - "$lib" +done +codesign -f -s - "$SIDECAR" + +echo "" +echo "==> DONE. Verifying no /opt/homebrew paths remain:" +echo "--- sidecar ---" +otool -L "$SIDECAR" +for lib in "$LIBS_DIR"/*.dylib; do + echo "--- $(basename "$lib") ---" + otool -L "$lib" +done + +if otool -L "$SIDECAR" "$LIBS_DIR"/*.dylib | grep -q "/opt/homebrew"; then + echo "ERROR: /opt/homebrew references still present!" >&2 + exit 1 +fi +echo "" +echo "OK: sidecar and all bundled dylibs are free of /opt/homebrew references." diff --git a/frontend/src-tauri/Entitlements.plist b/frontend/src-tauri/Entitlements.plist new file mode 100644 index 000000000..ce06ca2a0 --- /dev/null +++ b/frontend/src-tauri/Entitlements.plist @@ -0,0 +1,59 @@ + + + + + + com.apple.security.app-sandbox + + + com.apple.application-identifier + $IDENTIFIER + + com.apple.developer.team-identifier + $TEAM_ID + + com.apple.security.files.user-selected.read-write + + + diff --git a/frontend/src-tauri/tauri.appstore.conf.json b/frontend/src-tauri/tauri.appstore.conf.json new file mode 100644 index 000000000..4c2d7cfb0 --- /dev/null +++ b/frontend/src-tauri/tauri.appstore.conf.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "macOS": { + "entitlements": "Entitlements.plist", + "frameworks": [ + "binaries/libs/libgmp.10.dylib", + "binaries/libs/libmpfr.6.dylib", + "binaries/libs/libpcre2-8.0.dylib", + "binaries/libs/libraylib.550.dylib" + ], + "files": { + "embedded.provisionprofile": "embedded.provisionprofile" + } + } + } +}