Zum Inhalt

macOS-DMG-Cutover → native App (v2.0.0) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans (recommended here) or superpowers:subagent-driven-development. This is a build/packaging/config increment — verification-gated (builds + a real notarised DMG), not unit-TDD. Steps use checkbox (- [ ]) syntax.

Goal: Ship the native SwiftUI/Metal macOS app as the official Developer-ID-signed, notarised DMG (v2.0.0), retiring the WKWebView wrapper. The web PWA is untouched.

Architecture: Repurpose scripts/build-macos-app.sh to archive→export→notarise→DMG the native apple/PhysarumApp macOS target; release-signing (Developer ID + Hardened Runtime + a macOS entitlements file) is driven entirely by xcodebuild CLI overrides + scripts/ExportOptions.plist, so project.yml dev-signing stays ad-hoc. Version 2.0.0 in both package.json and project.yml. Delete the wrapper; keep assets/icon-macos.svg (still used by the viewer build).

Tech Stack: xcodebuild (archive/exportArchive), xcodegen, notarytool, stapler, spctl, hdiutil, bash.


Reference: facts & commands

  • macOS app builds today: cd apple/PhysarumApp && xcodebuild -scheme Physarum -destination 'platform=macOS' build
  • macOS tests (174): xcodebuild -scheme Physarum -destination 'platform=macOS' test
  • Signing creds (verified available): PHYSARUM_SIGN_IDENTITY=auto (cert Developer ID Application: Johannes Kaindl (U9X7M39R56)), PHYSARUM_NOTARY_PROFILE=jkaindl (notarytool profile — auth confirmed).
  • Native bundle id is already de.jkaindl.physarum; project.yml MARKETING_VERSION is 0.1.0; package.json version is 1.5.0.
  • Wrapper to retire: macos/Physarum.swift. KEEP assets/icon-macos.svg (referenced by apple/scripts/build-viewer-app.sh:18). KEEP macos/INSTALL.txt.
  • Commit convention: Conventional Commits, no emoji, English. Never --no-verify. Commit to main (authorized). Do NOT push or publish without explicit instruction.

File Structure

  • Create: apple/PhysarumApp/Physarum.macOS.entitlements — unsandboxed + com.apple.security.device.audio-input.
  • Create: scripts/ExportOptions.plist — method=developer-id, teamID=U9X7M39R56, signingStyle=manual.
  • Rewrite: scripts/build-macos-app.sh — native archive→export→notarise→DMG.
  • Delete: macos/Physarum.swift.
  • Modify: apple/PhysarumApp/project.yml (MARKETING_VERSION → 2.0.0), package.json (version → 2.0.0).
  • Modify (docs): docs/MACOS-APP.md, README.md, macos/INSTALL.txt, CHANGELOG.md.

Task 1: Version bump → 2.0.0

Files: Modify package.json, apple/PhysarumApp/project.yml

  • [ ] Step 1: Bump package.json

Change "version": "1.5.0" → "version": "2.0.0".

  • [ ] Step 2: Bump project.yml MARKETING_VERSION

In apple/PhysarumApp/project.yml change MARKETING_VERSION: "0.1.0" → MARKETING_VERSION: "2.0.0".

  • [ ] Step 3: Verify the macOS app still builds + tests pass

Run: cd apple/PhysarumApp && xcodegen generate && xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "Executed 17|TEST (SUCCEEDED|FAILED)" | tail -3 Expected: Executed 174 tests, with 0 failures · ** TEST SUCCEEDED **.

  • [ ] Step 4: Commit
git add package.json apple/PhysarumApp/project.yml
git commit -m "chore(release): bump version to 2.0.0 (web + native)"

Task 2: macOS release entitlements + ExportOptions

Files: Create apple/PhysarumApp/Physarum.macOS.entitlements, scripts/ExportOptions.plist

  • [ ] Step 1: Create the macOS entitlements file

Create apple/PhysarumApp/Physarum.macOS.entitlements (unsandboxed — no app-sandbox key; mic under Hardened Runtime):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>com.apple.security.device.audio-input</key>
  <true/>
</dict>
</plist>
  • [ ] Step 2: Create ExportOptions.plist

Create scripts/ExportOptions.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>method</key>
  <string>developer-id</string>
  <key>teamID</key>
  <string>U9X7M39R56</string>
  <key>signingStyle</key>
  <string>manual</string>
  <key>signingCertificate</key>
  <string>Developer ID Application</string>
</dict>
</plist>
  • [ ] Step 3: Validate both plists

Run: plutil -lint apple/PhysarumApp/Physarum.macOS.entitlements scripts/ExportOptions.plist Expected: both OK.

Note: Xcode 26 may warn that method: developer-id is deprecated in favour of newer keys. If xcodebuild -exportArchive (Task 3) rejects it, switch to the current key set (<key>destination</key><string>export</string> is NOT for developer-id; consult xcodebuild -help / the export error) and re-validate. Resolve at Task 3 build time.

  • [ ] Step 4: Commit
git add apple/PhysarumApp/Physarum.macOS.entitlements scripts/ExportOptions.plist
git commit -m "build(macos): add native macOS release entitlements + ExportOptions"

Task 3: Rewrite build-macos-app.sh for the native app + retire the wrapper

Files: Rewrite scripts/build-macos-app.sh; Delete macos/Physarum.swift

This task is iterative: write the script, then run it with creds and fix until the DMG notarises, staples, and spctl accepts. KEEP assets/icon-macos.svg.

  • [ ] Step 1: Replace scripts/build-macos-app.sh with the native pipeline

Overwrite scripts/build-macos-app.sh with:

#!/usr/bin/env bash
# Builds the native Physarum macOS app (apple/PhysarumApp) and a distributable .dmg.
#
# Output: dist-macos/Physarum.app and dist-macos/Physarum.dmg
# Requires: Xcode (xcodebuild), xcodegen, hdiutil. For a notarised release also a
# "Developer ID Application" identity + a notarytool keychain profile.
#
# Signing (opt-in via env):
#   PHYSARUM_SIGN_IDENTITY   "Developer ID Application: NAME (TEAMID)" or "auto"
#   PHYSARUM_NOTARY_PROFILE  notarytool keychain profile name
#   Both unset -> ad-hoc (local dev). Identity only -> signed, not notarised.
#   Both set -> Developer ID signed + hardened + notarised + stapled (app and dmg).
set -euo pipefail
cd "$(dirname "$0")/.."

APP_NAME="Physarum"
SCHEME="Physarum"
TEAM_ID="U9X7M39R56"
PROJ_DIR="apple/PhysarumApp"
PROJ="$PROJ_DIR/$APP_NAME.xcodeproj"
ENTITLEMENTS="$PROJ_DIR/Physarum.macOS.entitlements"
EXPORT_PLIST="scripts/ExportOptions.plist"
OUT="dist-macos"
ARCHIVE="$OUT/$APP_NAME.xcarchive"
APP="$OUT/$APP_NAME.app"

for tool in xcodebuild xcodegen hdiutil codesign; do
  command -v "$tool" >/dev/null 2>&1 || { echo "error: required tool not found: $tool" >&2; exit 1; }
done
[ -f "$ENTITLEMENTS" ] || { echo "error: missing $ENTITLEMENTS" >&2; exit 1; }
[ -f "$EXPORT_PLIST" ] || { echo "error: missing $EXPORT_PLIST" >&2; exit 1; }

SIGN_IDENTITY="${PHYSARUM_SIGN_IDENTITY:-}"
NOTARY_PROFILE="${PHYSARUM_NOTARY_PROFILE:-}"
SIGN_MODE="none"
if [ -n "$SIGN_IDENTITY" ] || [ -n "$NOTARY_PROFILE" ]; then
  [ -n "$SIGN_IDENTITY" ] || { echo "error: PHYSARUM_NOTARY_PROFILE set but PHYSARUM_SIGN_IDENTITY missing" >&2; exit 1; }
  for t in xcrun ditto spctl; do command -v "$t" >/dev/null 2>&1 || { echo "error: required tool not found: $t" >&2; exit 1; }; done
  if [ "$SIGN_IDENTITY" = auto ]; then
    SIGN_IDENTITY="$(security find-identity -v -p codesigning | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' | head -1)"
    [ -n "$SIGN_IDENTITY" ] || { echo "error: no 'Developer ID Application' identity in keychain (auto)" >&2; exit 1; }
    echo "→ signing identity (auto): $SIGN_IDENTITY"
  fi
  if [ -n "$NOTARY_PROFILE" ]; then SIGN_MODE="notarize"; else SIGN_MODE="sign"; fi
fi

rm -rf "$OUT"; mkdir -p "$OUT"

echo "→ xcodegen generate"
( cd "$PROJ_DIR" && xcodegen generate >/dev/null )

echo "→ archiving (Release, arm64 + x86_64)"
ARCHIVE_ARGS=(-project "$PROJ" -scheme "$SCHEME" -configuration Release
  -destination 'generic/platform=macOS' -archivePath "$ARCHIVE"
  ARCHS="arm64 x86_64" ONLY_ACTIVE_ARCH=NO)
if [ "$SIGN_MODE" = none ]; then
  xcodebuild "${ARCHIVE_ARGS[@]}" \
    CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="-" CODE_SIGNING_REQUIRED=NO archive
else
  xcodebuild "${ARCHIVE_ARGS[@]}" \
    CODE_SIGN_STYLE=Manual "CODE_SIGN_IDENTITY=$SIGN_IDENTITY" DEVELOPMENT_TEAM="$TEAM_ID" \
    CODE_SIGNING_REQUIRED=YES ENABLE_HARDENED_RUNTIME=YES \
    OTHER_CODE_SIGN_FLAGS="--timestamp" CODE_SIGN_ENTITLEMENTS="$ENTITLEMENTS" archive
fi

if [ "$SIGN_MODE" = none ]; then
  cp -R "$ARCHIVE/Products/Applications/$APP_NAME.app" "$APP"
else
  echo "→ exporting (Developer ID)"
  xcodebuild -exportArchive -archivePath "$ARCHIVE" \
    -exportOptionsPlist "$EXPORT_PLIST" -exportPath "$OUT" >/dev/null
fi
[ -d "$APP" ] || { echo "error: $APP not produced" >&2; exit 1; }
echo "  archs: $(lipo -archs "$APP/Contents/MacOS/$APP_NAME" 2>/dev/null || echo '?')"

if [ "$SIGN_MODE" = notarize ]; then
  echo "→ notarising + stapling app (waits for Apple)"
  ditto -c -k --keepParent "$APP" "$OUT/$APP_NAME.app.zip"
  xcrun notarytool submit "$OUT/$APP_NAME.app.zip" -p "$NOTARY_PROFILE" --wait
  xcrun stapler staple "$APP"
  rm -f "$OUT/$APP_NAME.app.zip"
fi

echo "→ creating DMG"
STAGE="$OUT/.dmg-stage"; mkdir -p "$STAGE"
cp -R "$APP" "$STAGE/"
ln -s /Applications "$STAGE/Applications"
[ -f macos/INSTALL.txt ] && cp macos/INSTALL.txt "$STAGE/"
hdiutil create -volname "$APP_NAME" -srcfolder "$STAGE" -ov -format UDZO "$OUT/$APP_NAME.dmg" >/dev/null
rm -rf "$STAGE"

if [ "$SIGN_MODE" != none ]; then
  echo "→ signing DMG"
  codesign --force --timestamp --sign "$SIGN_IDENTITY" "$OUT/$APP_NAME.dmg"
  if [ "$SIGN_MODE" = notarize ]; then
    echo "→ notarising + stapling DMG"
    xcrun notarytool submit "$OUT/$APP_NAME.dmg" -p "$NOTARY_PROFILE" --wait
    xcrun stapler staple "$OUT/$APP_NAME.dmg"
    echo "→ Gatekeeper check (ship only if accepted/valid)"
    xcrun stapler validate "$OUT/$APP_NAME.dmg"
    spctl -a -vvv -t open --context context:primary-signature "$OUT/$APP_NAME.dmg" || true
    spctl -a -vvv -t exec "$APP" || true
  fi
fi

echo; echo "✓ $APP"; echo "✓ $OUT/$APP_NAME.dmg  ($(du -h "$OUT/$APP_NAME.dmg" | cut -f1))"
  • [ ] Step 2: Delete the WKWebView wrapper

git rm macos/Physarum.swift
(Keep macos/INSTALL.txt and assets/icon-macos.svg.)

  • [ ] Step 3: Dev smoke (ad-hoc, no creds) — script wiring works

Run: ./scripts/build-macos-app.sh 2>&1 | tail -6 Expected: ✓ dist-macos/Physarum.app, ✓ dist-macos/Physarum.dmg, and the archs: line shows arm64 x86_64. (Ad-hoc, not notarised.) Fix any archive/export wiring errors and re-run until green.

  • [ ] Step 4: Real notarised release build (creds)

Run: PHYSARUM_SIGN_IDENTITY=auto PHYSARUM_NOTARY_PROFILE=jkaindl ./scripts/build-macos-app.sh 2>&1 | tail -20 Expected: notarytool status: Accepted (app + dmg), xcrun stapler validate … The validate action worked!, spctl … accepted / source=Notarized Developer ID. If exportArchive rejects ExportOptions.plist, fix per Task 2 Step 3 note and re-run.

  • [ ] Step 5: Commit

git add scripts/build-macos-app.sh
git rm macos/Physarum.swift  # if not already staged
git commit -m "build(macos): native app archive→notarise→DMG; retire WKWebView wrapper"
(The dist-macos/ output is gitignored — confirm with git status that the DMG is not staged.)


Task 4: Docs — native app, macOS 14+, Metal (no WebGPU/macOS-26)

Files: Modify docs/MACOS-APP.md, README.md, macos/INSTALL.txt, CHANGELOG.md

  • [ ] Step 1: Fix INSTALL.txt (system requirement)

In macos/INSTALL.txt, replace the "Systemvoraussetzung" block that says "macOS Tahoe 26 oder neuer. Die Animation nutzt WebGPU; auf älterem macOS startet die App, zeigt aber nur einen Hinweis statt der Simulation." with:

Systemvoraussetzung
   macOS 14 (Sonoma) oder neuer. Natives Metal-Rendering — kein WebGPU,
   kein Browser. Universal (Apple Silicon + Intel).
Keep the Gatekeeper "Trotzdem öffnen" fallback block.

  • [ ] Step 2: Fix the README macOS lines

In README.md: - Replace macOS app requires macOS Tahoe 26+ · the web app runs in any WebGPU browser with macOS app requires macOS 14+ (native Metal, universal) · the web app runs in any WebGPU browser. - Replace the "macOS app — a standalone .app / .dmg, a native Swift shell around a WKWebView (no Electron, no dependencies):" sentence with: **macOS app** — the native SwiftUI/Metal app (full authoring editor, audio-reactive, capture/export), Developer-ID-signed + notarised, universal (Apple Silicon + Intel): - Keep the ./scripts/build-macos-app.sh # → dist-macos/Physarum.dmg line and the Developer-ID/notarised note (still true).

  • [ ] Step 3: Rewrite docs/MACOS-APP.md

Replace the WKWebView description with the native flow. The doc MUST state: - The DMG is the native apple/PhysarumApp macOS target (not a web wrapper); full feature set (editor, audio, capture/export). - Requirement: macOS 14+; native Metal; universal arm64+x86_64. - Build: ./scripts/build-macos-app.sh (ad-hoc dev) and PHYSARUM_SIGN_IDENTITY=auto PHYSARUM_NOTARY_PROFILE=<profile> ./scripts/build-macos-app.sh (notarised release). - Signing: Developer-ID + Hardened Runtime + Physarum.macOS.entitlements (audio-input); unsandboxed; notarised + stapled (app + DMG). - Drop all assets/icon-macos.svg → AppIcon.icns and macos/Physarum.swift references (icon now ships in Assets.xcassets). - Keep the Gatekeeper "Trotzdem öffnen" workflow section (4-spot notice).

  • [ ] Step 4: CHANGELOG [2.0.0] entry

Under ## [Unreleased], add a ## [2.0.0] — 2026-06-15 section. Content must cover: - macOS app is now the native app. The DMG ships the native SwiftUI/Metal app (full authoring editor, native audio-reactivity, capture/export incl. Live Photo & video, adaptive format, fullscreen) — replacing the former WKWebView wrapper. - Lower requirements: macOS 14+ (was macOS 26 + WebGPU); native Metal; universal (Apple Silicon + Intel); Developer-ID-signed + notarised. - Includes the aspect-correct kaleidoscope fix (round mandalas on non-square windows). - Note the web PWA is unchanged.

  • [ ] Step 5: Commit
git add docs/MACOS-APP.md README.md macos/INSTALL.txt CHANGELOG.md
git commit -m "docs(macos): document native-app DMG (macOS 14+, Metal) for 2.0.0"

Task 5: Final verification + tag + release

Files: none (git tag + Codeberg release)

  • [ ] Step 1: Full build matrix green

cd apple/PhysarumApp && xcodegen generate
xcodebuild -scheme Physarum -destination 'platform=macOS' test 2>&1 | grep -E "Executed 17|TEST (SUCCEEDED|FAILED)" | tail -2
xcodebuild -scheme Physarum -destination 'generic/platform=iOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" | tail -1
xcodebuild -scheme Physarum -destination 'generic/platform=tvOS Simulator' build 2>&1 | grep -E "BUILD (SUCCEEDED|FAILED)" | tail -1
Expected: 174 tests pass; iOS + tvOS sim builds SUCCEED. (Engine/iOS/tvOS unaffected by the macOS cutover.)

  • [ ] Step 2: Confirm the release DMG is notarised + universal

xcrun stapler validate dist-macos/Physarum.dmg
spctl -a -vvv -t exec dist-macos/Physarum.app 2>&1 | grep -E "accepted|source"
lipo -archs dist-macos/Physarum.app/Contents/MacOS/Physarum
Expected: validate worked; accepted / source=Notarized Developer ID; arm64 x86_64.

  • [ ] Step 3: Tag v2.0.0

git tag -a v2.0.0 -m "v2.0.0 — native macOS app as the official DMG"
(Push the tag only on explicit instruction.)

  • [ ] Step 4: Codeberg release (CREDS-GATED — hand off)

Needs a Codeberg API token with write:release (Forgejo API; see memory codeberg-release-api). Create the release for tag v2.0.0, attach dist-macos/Physarum.dmg, and put the DMG-Gatekeeper "Open Anyway" notice in the release body (4th notice spot). If the token isn't provided, STOP here and hand the notarised DMG + a ready release-body to Johannes.


Notes for the implementer

  • No engine/src/ changes. This is packaging/config/docs only. The web PWA and the engine stay byte-for-byte.
  • Push/publish only on explicit instruction. Commit to main; the tag push + Codeberg publish are outward-facing and gated.
  • dist-macos/ is build output (gitignored) — never commit the .app/.dmg/.xcarchive.
  • Iterate Task 3 like a build loop: the exact xcodebuild archive/exportArchive invocation for a notarised Developer-ID app sometimes needs a tweak (ExportOptions keys, entitlements path); run with creds and fix until stapler validate + spctl are clean.