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(certDeveloper ID Application: Johannes Kaindl (U9X7M39R56)),PHYSARUM_NOTARY_PROFILE=jkaindl(notarytool profile — auth confirmed). - Native bundle id is already
de.jkaindl.physarum;project.yml MARKETING_VERSIONis0.1.0;package.jsonversion is1.5.0. - Wrapper to retire:
macos/Physarum.swift. KEEPassets/icon-macos.svg(referenced byapple/scripts/build-viewer-app.sh:18). KEEPmacos/INSTALL.txt. - Commit convention: Conventional Commits, no emoji, English. Never
--no-verify. Commit tomain(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-idis deprecated in favour of newer keys. Ifxcodebuild -exportArchive(Task 3) rejects it, switch to the current key set (<key>destination</key><string>export</string>is NOT for developer-id; consultxcodebuild -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
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"
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).
- [ ] 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
- [ ] 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
accepted / source=Notarized Developer ID; arm64 x86_64.
- [ ] Step 3: Tag v2.0.0
- [ ] 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/exportArchiveinvocation for a notarised Developer-ID app sometimes needs a tweak (ExportOptions keys, entitlements path); run with creds and fix untilstapler validate+spctlare clean.