Zum Inhalt

macOS-Capture-Parität + iPad-Thermik — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: macOS bekommt Capture-Parität (Save-to-Photos, Datei-Export, Live-Photo-Save/Share, Temp-Cleanup) und iPad eine eigene, thermisch sichere Workload-Tier (512/1280/250k + frühere FPS-Drossel + gedeckelte Capture-Auflösungen).

Architecture: Reine, testbare Funktionen für Device-Klassen-Logik (DeviceIdiom, fieldTier(for:), offered*, staleCaptureFiles, exportType) plus dünne SwiftUI-Verdrahtung in CaptureSheet/PhysarumApp. Keine Engine-/Shader-Änderung, Web-App unberührt.

Tech Stack: Swift 5, SwiftUI, XcodeGen (project.yml ist SSOT, .xcodeproj generiert/gitignored), XCTest, Metal (nur mittelbar).

Spec: docs/superpowers/specs/2026-06-13-macos-capture-ipad-thermal-design.md

Konventionen für jede Aufgabe: - Arbeitsverzeichnis ist apple/PhysarumApp/ (sofern nicht anders angegeben). - Nach dem Anlegen einer neuen Datei muss xcodegen generate laufen, damit das .xcodeproj sie aufnimmt (Sources/Tests sind folder-/glob-basiert). - App-Tests laufen via xcodebuild test. Schnellster Lauf für eine Klasse: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/<Klasse> 2>&1 | tail -25 - Commits: Conventional Commits ohne Emoji. Nicht pushen (Push bleibt Johannes' direktem Befehl vorbehalten).


Task 1: iPad .tablet FieldTier + idiom-mapping (B1)

Files: - Create: apple/PhysarumApp/Sources/Model/DeviceIdiom.swift - Modify: apple/PhysarumApp/Sources/Model/SimFormat.swift (lines 1–5 imports, 33–52 FieldTier) - Test: apple/PhysarumApp/Tests/SimFormatTests.swift

  • [ ] Step 1: Write the failing tests

In Tests/SimFormatTests.swift, add inside the FieldTier MARK section (after testDesktopTierKeepsPreTierValues):

func testTabletTierIsConservative() {
    XCTAssertEqual(FieldTier.tablet.base, 512)
    XCTAssertEqual(FieldTier.tablet.maxLong, 1280)
    XCTAssertEqual(FieldTier.tablet.agentCap, 250_000)
}

func testFieldTierMapsIdiomToTier() {
    XCTAssertEqual(fieldTier(for: .phone), .phone)
    XCTAssertEqual(fieldTier(for: .pad), .tablet)
    XCTAssertEqual(fieldTier(for: .mac), .desktop)
}
  • [ ] Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/SimFormatTests 2>&1 | tail -25 Expected: BUILD FAILED — "cannot find 'fieldTier' in scope" / "type 'FieldTier' has no member 'tablet'".

  • [ ] Step 3: Create the DeviceIdiom unit

Create Sources/Model/DeviceIdiom.swift:

import Foundation
#if os(iOS)
import UIKit
#endif

/// Device class for live-path workload + capture-ceiling decisions.
enum DeviceIdiom: Equatable { case phone, pad, mac }

/// The running device's class. iPad is its own class (not "desktop").
var currentDeviceIdiom: DeviceIdiom {
    #if os(macOS)
    return .mac
    #else
    return UIDevice.current.userInterfaceIdiom == .pad ? .pad : .phone
    #endif
}
  • [ ] Step 4: Add the .tablet tier + pure mapping, rewrite .current

In Sources/Model/SimFormat.swift, delete the iOS UIKit import block (lines 3–5):

#if os(iOS)
import UIKit
#endif

Then replace the static var current block (currently lines 42–51) so the FieldTier struct reads:

    static let phone = FieldTier(base: 512, maxLong: 1280, agentCap: 250_000)
    static let tablet = FieldTier(base: 512, maxLong: 1280, agentCap: 250_000)  // own seam for later iPad tuning
    static let desktop = FieldTier(base: 720, maxLong: 1600, agentCap: 500_000)

    static var current: FieldTier { fieldTier(for: currentDeviceIdiom) }
}

/// Pure idiom → tier mapping (testable without a real device).
func fieldTier(for idiom: DeviceIdiom) -> FieldTier {
    switch idiom {
    case .phone: return .phone
    case .pad:   return .tablet
    case .mac:   return .desktop
    }
}

Also update the struct's doc-comment (line ~36) so it no longer claims "Macs/iPads keep the larger field" — change to "Macs keep the larger field; iPad runs its own conservative tablet tier."

  • [ ] Step 5: Run tests to verify they pass

Run: xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/SimFormatTests 2>&1 | tail -25 Expected: ** TEST SUCCEEDED ** — all SimFormatTests pass, including the unchanged testCurrentTierOnMacIsDesktop.

  • [ ] Step 6: Commit
git add apple/PhysarumApp/Sources/Model/DeviceIdiom.swift apple/PhysarumApp/Sources/Model/SimFormat.swift apple/PhysarumApp/Tests/SimFormatTests.swift
git commit -m "feat(app): iPad-specific conservative FieldTier (512/1280/250k)"

Task 2: .fair FPS pre-emption + drawable-scale consistency (B2)

Files: - Modify: apple/PhysarumApp/Sources/Player/PlayerPolicy.swift (lines 14–26) - Test: apple/PhysarumApp/Tests/PlayerPolicyTests.swift (lines 19–37)

  • [ ] Step 1: Update the tests (the existing .fair == 60 assertion must move)

In Tests/PlayerPolicyTests.swift, replace testFullFrameRateWhenCoolAndUnconstrained and testHalfFrameRateUnderThermalPressure with:

func testFullFrameRateWhenCoolAndUnconstrained() {
    XCTAssertEqual(preferredFPS(thermalState: .nominal, lowPowerMode: false), 60)
}

func testHalfFrameRateUnderThermalPressure() {
    XCTAssertEqual(preferredFPS(thermalState: .fair, lowPowerMode: false), 30)
    XCTAssertEqual(preferredFPS(thermalState: .serious, lowPowerMode: false), 30)
    XCTAssertEqual(preferredFPS(thermalState: .critical, lowPowerMode: false), 30)
}

And replace testDrawableScaleCappedAt2xOnPhoneTier with:

func testDrawableScaleCappedAt2xOnNonDesktopTiers() {
    XCTAssertEqual(cappedDrawableScale(3.0, tier: .phone), 2.0)
    XCTAssertEqual(cappedDrawableScale(2.0, tier: .phone), 2.0)
    XCTAssertEqual(cappedDrawableScale(3.0, tier: .tablet), 2.0)
    XCTAssertEqual(cappedDrawableScale(3.0, tier: .desktop), 3.0)
}
  • [ ] Step 2: Run tests to verify they fail

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/PlayerPolicyTests 2>&1 | tail -25 Expected: TEST FAILED — testHalfFrameRateUnderThermalPressure expects 30 for .fair but gets 60.

  • [ ] Step 3: Implement the policy change

In Sources/Player/PlayerPolicy.swift, replace preferredFPS (lines 14–17):

/// Ambient frame-rate policy: full 60 only while the device is cool (`.nominal`)
/// and not power-constrained; 30 from `.fair` upward (pre-empting the system's own
/// throttle) and in Low Power Mode. For an always-on visual, halving FPS early is
/// the gentlest lever before the OS throttles everything.
func preferredFPS(thermalState: ProcessInfo.ThermalState, lowPowerMode: Bool) -> Int {
    if lowPowerMode || thermalState != .nominal { return 30 }
    return 60
}

And replace cappedDrawableScale (lines 24–26):

/// Drawable scale for the live composite: non-desktop tiers (phone/tablet) cap the
/// final pass at 2x. On phones this cuts ~55% composite fragment work vs 3x; on iPads
/// (already @2x) it is inert but keeps the policy consistent. Desktop keeps native scale.
/// (Capture/export is unaffected — it renders offscreen at the requested export size.)
func cappedDrawableScale(_ nativeScale: CGFloat, tier: FieldTier) -> CGFloat {
    tier == .desktop ? nativeScale : min(nativeScale, 2.0)
}
  • [ ] Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/PlayerPolicyTests 2>&1 | tail -25 Expected: ** TEST SUCCEEDED **.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Player/PlayerPolicy.swift apple/PhysarumApp/Tests/PlayerPolicyTests.swift
git commit -m "feat(app): drop to 30fps at .fair thermal; cap drawable scale on tablet tier"

Task 3: device-class capture ceilings (B3, logic)

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureConfig.swift (after line 30) - Test: apple/PhysarumApp/Tests/CaptureCeilingTests.swift (create)

  • [ ] Step 1: Write the failing tests

Create Tests/CaptureCeilingTests.swift:

import XCTest
@testable import Physarum

final class CaptureCeilingTests: XCTestCase {
    func testMacAndPhoneKeepFullCeilings() {
        XCTAssertEqual(CaptureConfig.offeredStillSizes(idiom: .mac), CaptureConfig.stillSizes)
        XCTAssertEqual(CaptureConfig.offeredStillSizes(idiom: .phone), CaptureConfig.stillSizes)
        XCTAssertEqual(CaptureConfig.offeredVideoSizes(idiom: .phone), CaptureConfig.videoSizes)
        XCTAssertEqual(CaptureConfig.offeredVideoDurations(idiom: .phone), CaptureConfig.videoDurations)
    }

    func testIPadDropsHeaviestCombos() {
        XCTAssertEqual(CaptureConfig.offeredStillSizes(idiom: .pad), [1080, 2048])
        XCTAssertEqual(CaptureConfig.offeredVideoSizes(idiom: .pad), [1280, 1920])
        XCTAssertEqual(CaptureConfig.offeredVideoDurations(idiom: .pad), [5, 10, 15])
        XCTAssertFalse(CaptureConfig.offeredStillSizes(idiom: .pad).contains(4096))
    }

    func testIPadDefaultsRemainSelectable() {
        // The sheet's @State defaults must exist in the iPad lists (no orphaned picker selection).
        XCTAssertTrue(CaptureConfig.offeredStillSizes(idiom: .pad).contains(CaptureConfig.defaultStill.longEdge))
        XCTAssertTrue(CaptureConfig.offeredVideoSizes(idiom: .pad).contains(CaptureConfig.defaultVideo.longEdge))
        XCTAssertTrue(CaptureConfig.offeredVideoDurations(idiom: .pad).contains(10))
    }
}
  • [ ] Step 2: Run tests to verify they fail

Run: xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureCeilingTests 2>&1 | tail -25 Expected: BUILD FAILED — "type 'CaptureConfig' has no member 'offeredStillSizes'".

  • [ ] Step 3: Implement the offered-* functions

In Sources/Capture/CaptureConfig.swift, add inside struct CaptureConfig after line 30 (static let defaultVideo …):

    // iPad (fanless) drops the heaviest combos; macOS (cooling/RAM) and iPhone
    // (validated on device) keep the full ceilings. Capture is offline, but the
    // single heavy render still spikes thermals on a tablet.
    static func offeredStillSizes(idiom: DeviceIdiom) -> [Int] {
        idiom == .pad ? [1080, 2048] : stillSizes
    }
    static func offeredVideoSizes(idiom: DeviceIdiom) -> [Int] {
        idiom == .pad ? [1280, 1920] : videoSizes
    }
    static func offeredVideoDurations(idiom: DeviceIdiom) -> [Int] {
        idiom == .pad ? [5, 10, 15] : videoDurations
    }
  • [ ] Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureCeilingTests 2>&1 | tail -25 Expected: ** TEST SUCCEEDED **.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureConfig.swift apple/PhysarumApp/Tests/CaptureCeilingTests.swift
git commit -m "feat(app): clamp iPad capture resolution/duration ceilings"

Task 4: capture temp-file cleanup (A5)

Files: - Create: apple/PhysarumApp/Sources/Capture/CaptureCleanup.swift - Modify: apple/PhysarumApp/Sources/App/PhysarumApp.swift (add init) - Test: apple/PhysarumApp/Tests/CaptureCleanupTests.swift (create)

  • [ ] Step 1: Write the failing tests

Create Tests/CaptureCleanupTests.swift:

import XCTest
@testable import Physarum

final class CaptureCleanupTests: XCTestCase {
    private func tmpDir() throws -> URL {
        let d = FileManager.default.temporaryDirectory.appendingPathComponent("cleanup-test-" + UUID().uuidString)
        try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true)
        return d
    }

    func testFiltersOnlyOldPhysarumCaptures() throws {
        let dir = try tmpDir(); defer { try? FileManager.default.removeItem(at: dir) }
        let fm = FileManager.default
        let old = dir.appendingPathComponent("physarum-aurora-20260101-000000.png")
        let recent = dir.appendingPathComponent("physarum-aurora-20260613-000000.mov")
        let foreign = dir.appendingPathComponent("notes.txt")
        for u in [old, recent, foreign] { fm.createFile(atPath: u.path, contents: Data()) }
        let now = Date()
        try fm.setAttributes([.modificationDate: now.addingTimeInterval(-7200)], ofItemAtPath: old.path)
        try fm.setAttributes([.modificationDate: now.addingTimeInterval(-60)], ofItemAtPath: recent.path)
        let stale = CaptureCleanup.staleCaptureFiles(in: dir, now: now, olderThan: 3600)
        XCTAssertEqual(stale.map(\.lastPathComponent), ["physarum-aurora-20260101-000000.png"])
    }

    func testSweepRemovesStaleKeepsRecent() throws {
        let dir = try tmpDir(); defer { try? FileManager.default.removeItem(at: dir) }
        let fm = FileManager.default
        let old = dir.appendingPathComponent("physarum-x-20260101-000000.png")
        let recent = dir.appendingPathComponent("physarum-x-20260613-000000.png")
        for u in [old, recent] { fm.createFile(atPath: u.path, contents: Data()) }
        let now = Date()
        try fm.setAttributes([.modificationDate: now.addingTimeInterval(-7200)], ofItemAtPath: old.path)
        try fm.setAttributes([.modificationDate: now.addingTimeInterval(-60)], ofItemAtPath: recent.path)
        CaptureCleanup.sweep(now: now, olderThan: 3600, dir: dir)
        XCTAssertFalse(fm.fileExists(atPath: old.path))
        XCTAssertTrue(fm.fileExists(atPath: recent.path))
    }
}
  • [ ] Step 2: Run tests to verify they fail

Run: xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureCleanupTests 2>&1 | tail -25 Expected: BUILD FAILED — "cannot find 'CaptureCleanup' in scope".

  • [ ] Step 3: Implement the cleanup unit

Create Sources/Capture/CaptureCleanup.swift:

import Foundation

/// Capture outputs are written to the temporary directory as `physarum-*` and
/// only egress via Share/Save/Export. Nothing deleted them. Sweep orphaned ones
/// at launch (prior-session leftovers, aborted sheets).
enum CaptureCleanup {
    static let prefix = "physarum-"
    static let captureExtensions: Set<String> = ["png", "heic", "mov", "mp4"]

    static func staleCaptureFiles(in dir: URL, now: Date, olderThan: TimeInterval,
                                  fm: FileManager = .default) -> [URL] {
        guard let items = try? fm.contentsOfDirectory(
            at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return [] }
        return items.filter { url in
            guard url.lastPathComponent.hasPrefix(prefix),
                  captureExtensions.contains(url.pathExtension.lowercased()) else { return false }
            let mod = (try? url.resourceValues(forKeys: [.contentModificationDateKey])
                .contentModificationDate) ?? .distantPast
            return now.timeIntervalSince(mod) > olderThan
        }
    }

    static func sweep(now: Date = Date(), olderThan: TimeInterval = 3600,
                      dir: URL = FileManager.default.temporaryDirectory, fm: FileManager = .default) {
        for url in staleCaptureFiles(in: dir, now: now, olderThan: olderThan, fm: fm) {
            try? fm.removeItem(at: url)
        }
    }
}
  • [ ] Step 4: Run tests to verify they pass

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/CaptureCleanupTests 2>&1 | tail -25 Expected: ** TEST SUCCEEDED **.

  • [ ] Step 5: Wire the launch sweep

In Sources/App/PhysarumApp.swift, add an init() to the struct (after line 4 struct PhysarumApp: App {):

    init() {
        Task.detached(priority: .utility) { CaptureCleanup.sweep() }
    }
  • [ ] Step 6: Build to verify the wiring compiles

Run: xcodebuild -scheme Physarum -configuration Debug -destination 'platform=macOS' -derivedDataPath build/DD-macos build 2>&1 | tail -5 Expected: ** BUILD SUCCEEDED **.

  • [ ] Step 7: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureCleanup.swift apple/PhysarumApp/Sources/App/PhysarumApp.swift apple/PhysarumApp/Tests/CaptureCleanupTests.swift
git commit -m "feat(app): sweep orphaned capture temp files at launch"

Task 5: file-export document + UTType helper (A2, logic)

Files: - Create: apple/PhysarumApp/Sources/Capture/ExportedCapture.swift - Test: apple/PhysarumApp/Tests/ExportedCaptureTests.swift (create)

  • [ ] Step 1: Write the failing test

Create Tests/ExportedCaptureTests.swift:

import XCTest
import UniformTypeIdentifiers
@testable import Physarum

final class ExportedCaptureTests: XCTestCase {
    func testExportTypeFromExtension() {
        XCTAssertEqual(exportType(for: .file(URL(fileURLWithPath: "/t/a.png"), isVideo: false)), .png)
        XCTAssertEqual(exportType(for: .file(URL(fileURLWithPath: "/t/a.heic"), isVideo: false)), .heic)
        XCTAssertEqual(exportType(for: .file(URL(fileURLWithPath: "/t/a.mov"), isVideo: true)), .quickTimeMovie)
        XCTAssertEqual(exportType(for: .file(URL(fileURLWithPath: "/t/a.mp4"), isVideo: true)), .mpeg4Movie)
    }

    func testLivePhotoExportsMotionClip() {
        let out = CaptureOutput.livePhoto(still: URL(fileURLWithPath: "/t/s.heic"),
                                          video: URL(fileURLWithPath: "/t/v.mov"))
        XCTAssertEqual(exportType(for: out), .quickTimeMovie)
    }
}
  • [ ] Step 2: Run test to verify it fails

Run: xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ExportedCaptureTests 2>&1 | tail -25 Expected: BUILD FAILED — "cannot find 'exportType' in scope".

  • [ ] Step 3: Implement the export document + helper

Create Sources/Capture/ExportedCapture.swift:

import SwiftUI
import UniformTypeIdentifiers

/// UTType for a finished capture, derived from its file extension.
/// Live Photo exports the HEVC motion clip (the paired still goes to Photos via Save).
func exportType(for out: CaptureOutput) -> UTType {
    switch out {
    case .file(let url, _):
        switch url.pathExtension.lowercased() {
        case "png":  return .png
        case "heic": return .heic
        case "mov":  return .quickTimeMovie
        case "mp4":  return .mpeg4Movie
        default:     return .data
        }
    case .livePhoto:
        return .quickTimeMovie
    }
}

/// Export-only FileDocument that copies an already-rendered capture file to a
/// user-chosen location via `.fileExporter`.
struct ExportedCapture: FileDocument {
    static var readableContentTypes: [UTType] { [] }
    let url: URL
    let type: UTType

    init(url: URL, type: UTType) { self.url = url; self.type = type }
    init(configuration: ReadConfiguration) throws { throw CocoaError(.fileReadUnsupportedScheme) }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        try FileWrapper(url: url)
    }
}
  • [ ] Step 4: Run test to verify it passes

Run: xcodebuild test -scheme Physarum -destination 'platform=macOS' -only-testing:PhysarumAppTests/ExportedCaptureTests 2>&1 | tail -25 Expected: ** TEST SUCCEEDED **.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/ExportedCapture.swift apple/PhysarumApp/Tests/ExportedCaptureTests.swift
git commit -m "feat(app): ExportedCapture FileDocument + UTType mapping"

Task 6: CaptureSheet integration — macOS parity + iPad pickers (A1, A4, A2-UI, B3-UI)

Files: - Modify: apple/PhysarumApp/Sources/Capture/CaptureSheet.swift (lines 24, 62–78, 119–175)

This task is build-verified (SwiftUI wiring; the pure pieces are tested in Tasks 3 & 5).

  • [ ] Step 1: Add the exporter state

In CaptureSheet, after line 23 (@State private var photoFormat: PhotoFormat = .png) add:

    @State private var showExporter = false
  • [ ] Step 2: Point the picker rows at the device-clamped lists (B3-UI)

In form (lines 64–78), replace the three CaptureConfig.<list> references:

            if isVideo {
                pickerRow("Resolution", $videoLong, CaptureConfig.offeredVideoSizes(idiom: currentDeviceIdiom)) { "\($0)" }
                pickerRow("Duration", $duration, CaptureConfig.offeredVideoDurations(idiom: currentDeviceIdiom)) { "\($0) s" }
                Picker("Start", selection: $videoFromScratch) {
                    Text("From current").tag(false)
                    Text("From start").tag(true)
                }
                .pickerStyle(.segmented)
                pickerRow("Codec", $codec, VideoCodec.allCases) { $0.label }
            } else {
                pickerRow("Resolution", $stillLong, CaptureConfig.offeredStillSizes(idiom: currentDeviceIdiom)) { px in
                    px >= 3840 ? "4K · \(px)" : "\(px)"
                }
                pickerRow("Format", $photoFormat, PhotoFormat.allCases) { $0.label }
            }
  • [ ] Step 3: Un-gate Save-to-Photos (A1) and share both Live-Photo halves (A4) + add Export (A2-UI)

In doneCard (lines 119–175), make three changes.

(a) Remove the #if os(iOS) / #endif around the Save button — lines 150 and 153 — so it reads:

            PhysButton(title: saveState == .saving ? "Saving…" : (saveState == .saved ? "Saved" : "Save to Photos"), kind: .ember) { save(out) }
                .disabled(saveState == .saving || saveState == .saved)

            #if os(macOS)
            PhysButton(title: "Export…", kind: .ghost) { showExporter = true }
            #endif

(b) Replace the ShareLink(item: out.primaryURL) { … } (lines 156–170) with a switch that shares both halves for a Live Photo. Extract the label into a local @ViewBuilder to avoid duplication — add this method to CaptureSheet:

    private var shareLabel: some View {
        Text("Share".uppercased())
            .font(Theme.Fonts.label)
            .tracking(1.2)
            .padding(.horizontal, Theme.Space.s4)
            .padding(.vertical, Theme.Space.s2)
            .frame(maxWidth: .infinity)
            .foregroundStyle(Theme.Colors.fgSecondary)
            .background(Color.clear)
            .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md))
            .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md)
                .strokeBorder(Theme.Colors.border, lineWidth: 1))
    }

and replace the HStack body's ShareLink with:

            HStack(spacing: Theme.Space.s3) {
                switch out {
                case .livePhoto(let still, let video):
                    ShareLink(items: [still, video]) { shareLabel }
                case .file(let url, _):
                    ShareLink(item: url) { shareLabel }
                }
                PhysButton(title: "Done", kind: .ghost) { isPresented = false }
            }

(c) Attach the .fileExporter to the doneCard VStack. Replace the existing trailing modifier .task(id: out.primaryURL) { thumb = await makeThumbnail(out) } (line 174) with:

        .task(id: out.primaryURL) { thumb = await makeThumbnail(out) }
        .fileExporter(isPresented: $showExporter,
                      document: ExportedCapture(url: out.primaryURL, type: exportType(for: out)),
                      contentType: exportType(for: out),
                      defaultFilename: out.primaryURL.deletingPathExtension().lastPathComponent) { _ in }
  • [ ] Step 4: Build both platforms to verify the wiring compiles

Run:

xcodegen generate
xcodebuild -scheme Physarum -configuration Debug -destination 'platform=macOS' -derivedDataPath build/DD-macos build 2>&1 | tail -5
xcodebuild -scheme Physarum -configuration Debug -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath build/DD-ios build 2>&1 | tail -5
Expected: ** BUILD SUCCEEDED ** for both.

  • [ ] Step 5: Commit
git add apple/PhysarumApp/Sources/Capture/CaptureSheet.swift
git commit -m "feat(app): macOS Save-to-Photos + Export, Live-Photo share fix, iPad-clamped pickers"

Task 7: full-suite gate + visual verification

Files: none (verification only)

  • [ ] Step 1: Run the complete App test suite on macOS

Run: xcodegen generate && xcodebuild test -scheme Physarum -destination 'platform=macOS' 2>&1 | tail -30 Expected: ** TEST SUCCEEDED ** (all PhysarumAppTests green, incl. the updated PlayerPolicy/SimFormat tests).

  • [ ] Step 2: Run the App test suite on the iOS simulator

Run: xcodebuild test -scheme Physarum -destination 'platform=iOS Simulator,name=iPhone 17 Pro' 2>&1 | tail -30 Expected: ** TEST SUCCEEDED **.

  • [ ] Step 3: Engine regression gate (engine untouched, sanity only)

Run: cd ../PhysarumEngine && swift test -c release 2>&1 | tail -8 && swift run -c release EngineCheck 2>&1 | tail -5 Expected: engine tests pass; EngineCheck prints its OK summary.

  • [ ] Step 4: Visual check — macOS

Run:

cd ../PhysarumApp
open build/DD-macos/Build/Products/Debug/Physarum.app
Trigger a capture (topbar Share/Capture button → Capture), reach the done card, and confirm: "Save to Photos", "Export…", "Share", "Done" are all present. Tap "Export…" → a save panel appears. Tap "Save to Photos" → "Saved to Photos" appears.

  • [ ] Step 5: Visual check — iPad

Run:

xcrun simctl install "iPad Pro 11-inch (M5)" build/DD-ipad/Build/Products/Debug-iphonesimulator/Physarum.app 2>/dev/null || (xcodebuild -scheme Physarum -configuration Debug -destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M5)' -derivedDataPath build/DD-ipad build && xcrun simctl install "iPad Pro 11-inch (M5)" build/DD-ipad/Build/Products/Debug-iphonesimulator/Physarum.app)
xcrun simctl launch "iPad Pro 11-inch (M5)" de.jkaindl.physarum
Open the Capture sheet and confirm the Resolution picker tops out at 2048 (still) / 1920 (video) and Duration at 15 s — no 4K/2560/30 s on iPad.

  • [ ] Step 6: Final commit (if any uncommitted verification fixups)
git status --short
# only if fixes were needed during verification:
# git add -A && git commit -m "fix(app): <what the visual check surfaced>"

Notes / deliberately out of this plan

  • A3 (Live Photo on macOS) needs no separate task — un-gating the Save button (Task 6) makes save(out) reach CaptureSaver.saveLivePhoto on macOS; macOS Photos supports Live Photos. Verified in Task 7 step 4.
  • B3 optional ProcessInfo.thermalState guard (refuse heaviest combo under .serious) was marked Kür in the spec and is omitted — the device-class ceiling clamp (Task 3) is the core mitigation; add the runtime guard only if device testing shows iPad still overheats on a single heavy render.
  • A5 dismiss-purge (delete the just-shared file on Done) is omitted — the launch sweep covers hygiene, and purging an in-flight shared/exported file risks breaking the share. Revisit only if temp growth is observed within a session.
  • B-deferred: Split-View dynamic field scaling, rotation-rebuild guard (mitigated by the 512 tablet tier).
  • Tier C (macOS menu commands/undo/fullscreen), Tier D (iPad layout polish), Tier E (Hardened-Runtime/audio-input entitlement, desktop-Mac-no-mic, mic-denied feedback), tvOS — separate rounds.
  • Device sign-off (real iPhone/iPad/Mac: capture flow + Photos save + iPad thermals over a long run) stays with Johannes; GPU/Photos are not headless-tappable.