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
.tablettier + pure mapping, rewrite.current
In Sources/Model/SimFormat.swift, delete the iOS UIKit import block (lines 3–5):
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 == 60assertion 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 {):
- [ ] 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:
- [ ] 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
** 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:
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
- [ ] 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)reachCaptureSaver.saveLivePhotoon macOS; macOS Photos supports Live Photos. Verified in Task 7 step 4. - B3 optional
ProcessInfo.thermalStateguard (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-inputentitlement, 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.