From b140cd6837c3a05d9f49343b9ac14fb905f5bd34 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 14 Jun 2026 02:38:47 +0200 Subject: [PATCH] feat(apple/macos): App Sandbox + entitlements, wire Mac App Store TestFlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mac App Store requires App Sandbox, which the macOS app didn't declare. App Sandbox is macOS-only (invalid on iOS/tvOS, fails upload validation), so the macOS target now uses a dedicated Config/Punktfunk-macOS.entitlements while iOS/tvOS keep the shared Config/Punktfunk.entitlements (unchanged). The single macOS app is sandboxed for BOTH channels — the Developer ID DMG is codesigned with the same file — so the local build equals what App Store users get. Entitlement set (verified against the code + Apple docs): - app-sandbox, network.client. - network.server: NOT optional despite the client being outbound-only — the sandbox gates the bind() syscall as network-bind, and quinn (quic.rs) + the raw-UDP plane (transport/udp.rs) both bind explicitly, so host->client datagrams never arrive without it (the classic QUIC-under-sandbox trap). - device.audio-input (mic uplink), device.bluetooth + device.usb (Xbox/DualSense controllers over BT/USB via GameController), keychain-access-groups (existing). Omitted: device.hid (undocumented), files.user-selected.* (no pickers), networking.multicast (Bonjour browse is exempt; requesting it breaks signing). CI (release.yml): add a macOS App Store archive+upload-to-TestFlight step mirroring the iOS lane (manual Apple Distribution signing + the 'Punktfunk macOS App Store Distribution' profile, app-store-connect/upload, installer-signed pkg), continue-on-error until the portal prereqs exist; point the Developer ID DMG codesign at the sandboxed entitlements. Docs (ci.md) + clients/apple README updated; the runner additionally needs the macOS platform on the App Store Connect record + the '3rd Party Mac Developer Installer' cert. Verified: signed Debug build embeds exactly the intended entitlements (codesign -d --entitlements), swift build green against the rebuilt xcframework. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/release.yml | 82 +++++++++++++++++-- .../apple/Config/Punktfunk-macOS.entitlements | 61 ++++++++++++++ .../apple/Punktfunk.xcodeproj/project.pbxproj | 4 +- clients/apple/README.md | 11 +++ docs-site/content/docs/ci.md | 33 +++++--- 5 files changed, 171 insertions(+), 20 deletions(-) create mode 100644 clients/apple/Config/Punktfunk-macOS.entitlements diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index a65e9d6..20ebb7a 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,15 +1,32 @@ # Production Apple client builds — runs on the macos-arm64 runner (home-mac-mini-1). # # Tag v* (or workflow_dispatch): -# macOS -> Developer ID signed + notarized + stapled .dmg, attached to a Gitea -# release on tag pushes +# macOS (Developer ID) -> sandboxed, signed, notarized + stapled .dmg, attached to a +# Gitea release on tag pushes +# macOS (App Store) -> archive + upload to TestFlight (App Store Connect) # iOS -> archive + upload straight to TestFlight (App Store Connect) -# tvOS -> not built: the Rust core needs tier-3 targets (nightly -Zbuild-std) -# macOS App Store/TestFlight -> deferred: needs App Sandbox entitlements first. +# tvOS -> archive + upload to TestFlight (Rust core built from tier-3 targets, +# nightly -Zbuild-std, in build-xcframework.sh) # # One App Store listing for all platforms (universal purchase): every target shares the # bundle ID io.unom.punktfunk. # +# The macOS app is App-SANDBOXED for both channels (Config/Punktfunk-macOS.entitlements — +# app-sandbox + network client/server + audio-input + bluetooth/usb device access; the +# shared Config/Punktfunk.entitlements stays iOS/tvOS-only, where app-sandbox is invalid). +# The Developer ID DMG is codesigned with the SAME macOS entitlements, so what we test +# locally equals what App Store users get. +# +# macOS App Store prerequisites (one-time, Apple portal — NOT done by this workflow; the +# step is continue-on-error until they exist): +# * App Store Connect: add the macOS platform to the io.unom.punktfunk app record +# (universal purchase). +# * A "Punktfunk macOS App Store Distribution" provisioning profile installed on the +# runner (under ~/Library/Developer/Xcode/UserData/Provisioning Profiles/). +# * The "3rd Party Mac Developer Installer" (Mac Installer Distribution) certificate in +# the runner's login keychain, in addition to "Apple Distribution" — the App Store +# .pkg is installer-signed with it. +# # Signing setup (NOT secret-based anymore): the runner is a LaunchAgent in the user's # logged-in Aqua session, so it uses the **login keychain** directly. Install the signing # identities there once via Xcode (Settings -> Accounts -> Manage Certificates): Developer @@ -103,7 +120,9 @@ jobs: # Archive UNSIGNED, then codesign with the Developer ID Application identity from the # login keychain. Unsigned archive sidesteps Xcode's keychain-access-groups # provisioning-profile gate; codesign just needs the (now valid) identity + the - # team-prefixed entitlement, no profile. Bundle is a single static binary. + # team-prefixed entitlements, no profile (App Sandbox + the network/device + # capabilities are self-asserted for Developer ID — no profile entry needed). + # Bundle is a single static binary. DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ -project "$PROJECT" -scheme Punktfunk \ -destination 'generic/platform=macOS' \ @@ -112,10 +131,11 @@ jobs: MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ CODE_SIGNING_ALLOWED=NO APP="$RUNNER_TEMP/Punktfunk-macos.xcarchive/Products/Applications/Punktfunk.app" - # codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix. + # Sandboxed Developer ID: sign with the SAME macOS entitlements the App Store build + # uses. codesign won't expand $(AppIdentifierPrefix) — resolve it to the team prefix. RESOLVED="$RUNNER_TEMP/macos.entitlements" sed "s/\$(AppIdentifierPrefix)/${TEAM_ID}./g" \ - clients/apple/Config/Punktfunk.entitlements > "$RESOLVED" + clients/apple/Config/Punktfunk-macOS.entitlements > "$RESOLVED" codesign --force --options runtime --timestamp \ --entitlements "$RESOLVED" \ --sign "Developer ID Application" "$APP" @@ -152,6 +172,54 @@ jobs: -F "attachment=@$DMG" >/dev/null echo "attached Punktfunk-$VERSION.dmg to release $GITHUB_REF_NAME" + - name: macOS App Store — archive + upload to TestFlight + if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' + # Best-effort until the App Store Connect record has the macOS platform + the + # "Punktfunk macOS App Store Distribution" profile and the "3rd Party Mac Developer + # Installer" cert are on the runner (see the header). The macOS app is sandboxed + # (Config/Punktfunk-macOS.entitlements) — mandatory for the Mac App Store. + continue-on-error: true + run: | + # Separate archive from the Developer ID one above: App Store needs a profile-signed + # archive (manual signing), not the unsigned-then-codesign DMG path. Same App-Manager + # ASC-key constraint as iOS/tvOS — MANUAL signing, NOT -allowProvisioningUpdates + # (cloud signing the key can't do). Quit Xcode so it can't prune the dropped profile. + osascript -e 'tell application "Xcode" to quit' >/dev/null 2>&1 || true + pkill -x Xcode 2>/dev/null || true + PROFILE="Punktfunk macOS App Store Distribution" + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild archive \ + -project "$PROJECT" -scheme Punktfunk \ + -destination 'generic/platform=macOS' \ + -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ + MARKETING_VERSION="$VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUM" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Distribution" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + PROVISIONING_PROFILE_SPECIFIER="$PROFILE" + cat > "$RUNNER_TEMP/export-macos-appstore.plist" < + + + + methodapp-store-connect + destinationupload + teamID$TEAM_ID + signingStylemanual + signingCertificateApple Distribution + installerSigningCertificate3rd Party Mac Developer Installer + provisioningProfiles + io.unom.punktfunk$PROFILE + + + EOF + DEVELOPER_DIR="$XCODE_DEV_DIR" xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/Punktfunk-macos-appstore.xcarchive" \ + -exportOptionsPlist "$RUNNER_TEMP/export-macos-appstore.plist" \ + -exportPath "$RUNNER_TEMP/export-macos-appstore" \ + -authenticationKeyPath "$RUNNER_TEMP/asc.p8" \ + -authenticationKeyID "${{ secrets.ASC_API_KEY_ID }}" \ + -authenticationKeyIssuerID "${{ secrets.ASC_API_ISSUER_ID }}" + - name: iOS — archive + upload to TestFlight if: gitea.event_name != 'workflow_dispatch' || inputs.testflight == 'true' # Best-effort until the App Store Connect app record for io.unom.punktfunk exists. diff --git a/clients/apple/Config/Punktfunk-macOS.entitlements b/clients/apple/Config/Punktfunk-macOS.entitlements new file mode 100644 index 0000000..02445b0 --- /dev/null +++ b/clients/apple/Config/Punktfunk-macOS.entitlements @@ -0,0 +1,61 @@ + + + + + + + + com.apple.security.app-sandbox + + + + com.apple.security.network.client + + + + com.apple.security.network.server + + + + com.apple.security.device.audio-input + + + + com.apple.security.device.bluetooth + + + + com.apple.security.device.usb + + + + keychain-access-groups + + $(AppIdentifierPrefix)io.unom.punktfunk + + + diff --git a/clients/apple/Punktfunk.xcodeproj/project.pbxproj b/clients/apple/Punktfunk.xcodeproj/project.pbxproj index bef8fdc..a110a62 100644 --- a/clients/apple/Punktfunk.xcodeproj/project.pbxproj +++ b/clients/apple/Punktfunk.xcodeproj/project.pbxproj @@ -355,7 +355,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -389,7 +389,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = punktfunk_Logo; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Config/Punktfunk.entitlements; + CODE_SIGN_ENTITLEMENTS = Config/Punktfunk-macOS.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; diff --git a/clients/apple/README.md b/clients/apple/README.md index 7b37214..312d836 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -137,6 +137,17 @@ The app target **Punktfunk** wraps the same sources as the `swift run` shell catalog) and links `PunktfunkKit` from the local package. Generated Info.plist, ad-hoc signing, bundle id `io.unom.punktfunk`. Notes: +- **Entitlements (sandbox)**: the macOS target uses + `Config/Punktfunk-macOS.entitlements`; iOS/tvOS use the shared + `Config/Punktfunk.entitlements`. The macOS app is **App-Sandboxed** (mandatory for the Mac + App Store/TestFlight, and used for the Developer ID DMG too so the local build matches what + ships): `com.apple.security.app-sandbox`, `network.client` + **`network.server`** (the + sandbox gates `bind()`; quinn + the raw-UDP plane both bind, so receive breaks without it), + `device.audio-input` (mic), `device.bluetooth` + `device.usb` (GameController over BT/USB), + and the existing `keychain-access-groups`. `app-sandbox` is macOS-only — keep it OUT of the + shared iOS/tvOS file (it fails upload validation there). Verify a build is sandboxed with + `codesign -d --entitlements :- `. Heads-up: `device.usb` draws some App Review + scrutiny — justify it in the review notes ("reads input from USB game controllers"). - **App icon**: `App/Assets.xcassets` ships an empty `AppIcon` slot. For an Icon Composer `.icon`: add the file to the project (target Punktfunk), set it as the App Icon in the target's General tab, and delete the placeholder `AppIcon.appiconset`. Heads-up: CLI diff --git a/docs-site/content/docs/ci.md b/docs-site/content/docs/ci.md index befa4c2..7ac27cc 100644 --- a/docs-site/content/docs/ci.md +++ b/docs-site/content/docs/ci.md @@ -13,7 +13,7 @@ CI runs on **Gitea Actions** (`git.unom.io`, org `unom`). Three workflows in | `ci.yml` | push to `main`, PRs | `ubuntu-24.04` | Rust workspace (fmt · clippy `-D warnings` · build · test · C-ABI harness · generated-header drift) inside the `punktfunk-rust-ci` image; `web/` and `docs-site/` build + typecheck in `oven/bun:1` | | `docker.yml` | push to `main`, `v*` tags, manual | `ubuntu-24.04` | Builds + pushes the three images below (`latest` + `sha-` tags) | | `apple.yml` | push to `main`, PRs, manual | `macos-arm64` | Rust core → `PunktfunkCore.xcframework` → `swift build` + `swift test` in `clients/apple` | -| `release.yml` | `v*` tags, manual | `macos-arm64` | Production Apple builds: Developer-ID-signed, notarized, stapled macOS `.dmg` attached to the Gitea release + iOS archive uploaded to TestFlight | +| `release.yml` | `v*` tags, manual | `macos-arm64` | Production Apple builds: sandboxed macOS `.dmg` (Developer ID, notarized, stapled) attached to the Gitea release + macOS/iOS/tvOS archives uploaded to TestFlight | ## Dockerized pieces @@ -60,20 +60,31 @@ ssh enricobuehler@192.168.1.135 GITEA_RUNNER_TOKEN= bash -s \ `release.yml` produces the production client builds on the Mac runner. All three app targets share the bundle ID **`io.unom.punktfunk`** (one App Store listing, universal -purchase — effectively unchangeable after first submission). Secrets: -`DEVID_CERT_P12_B64`/`DEVID_CERT_PASSWORD` (Developer ID Application certificate, only -creatable by the account holder) and `ASC_API_KEY_P8`/`ASC_API_KEY_ID`/`ASC_API_ISSUER_ID` -(App Store Connect API key — notarization, TestFlight upload, automatic-signing profile -fetch). Signing uses a per-run throwaway keychain; nothing persists on the runner. -Per-platform state: +purchase — effectively unchangeable after first submission). Signing is **not** secret-based: +the runner uses its **login keychain** directly, so install the **Developer ID Application**, +**Apple Distribution**, and (for the Mac App Store `.pkg`) **3rd Party Mac Developer +Installer** identities once via Xcode, with the WWDR intermediate present so they show as +valid. The only secrets are `ASC_API_KEY_P8`/`ASC_API_KEY_ID`/`ASC_API_ISSUER_ID` (App Store +Connect API key — notarization + TestFlight upload). Per-platform state: -- **macOS** — Developer ID export → `notarytool` → stapled `.dmg` on the Gitea release. - The Mac **App Store** lane is deferred: it requires App Sandbox entitlements - (network client + Bonjour) the app doesn't declare yet. +- **macOS (Developer ID)** — sandboxed app (`Config/Punktfunk-macOS.entitlements`) → export + → `notarytool` → stapled `.dmg` on the Gitea release. +- **macOS (App Store)** — manual-signed archive (Apple Distribution + the *Punktfunk macOS + App Store Distribution* profile) → upload to TestFlight. App Sandbox is **mandatory** here + and is now declared (app-sandbox + network client/server + audio-input + bluetooth/usb). + Prereqs (one-time, Apple portal): add the **macOS platform** to the App Store Connect app + record (universal purchase), install the Mac App Store distribution profile + the installer + cert above. `continue-on-error` until those exist. - **iOS** — archive + upload to TestFlight (`method: app-store-connect`, `destination: upload`). Crypto is declared exempt (`ITSAppUsesNonExemptEncryption`, `Config/Info.plist`) so builds don't stall on the compliance question. -- **tvOS** — not built: the Rust core needs tier-3 targets (nightly `-Zbuild-std`). +- **tvOS** — archive + upload to TestFlight (Rust core built from tier-3 targets, nightly + `-Zbuild-std` via `build-xcframework.sh`). + +Each macOS target uses its own entitlements: `Config/Punktfunk-macOS.entitlements` (App +Sandbox is macOS-only) for the macOS app, and the shared `Config/Punktfunk.entitlements` +(keychain-access-groups only) for iOS/tvOS — `com.apple.security.app-sandbox` is invalid on +iOS/tvOS and would fail upload validation. The runner needs a **release (non-beta) Xcode** — App Store processing rejects beta-SDK builds, and a beta is unusable for the Rust side too: a newer-than-OS ld emits dylibs the