Zum Inhalt

macOS-Capture-Parität + iPad-Thermik-Sicherheit — Design

Datum: 2026-06-13 Scope: Native App (apple/PhysarumApp). Engine nur ggf. mittelbar; Web-App unberührt. Status: Approved (Brainstorm 2026-06-13)

Problem

Ein Verifikations-Durchgang (nativer macOS-Build + iPad-Sim gebaut, gestartet, gescreenshottet) plus ein adversarial verifizierter statischer Paritäts-Audit (25 bestätigte Findings) ergaben zwei zusammenhängende Lücken-Cluster zwischen der iOS-Referenz (die Johannes mag) und macOS/iPad:

  • Thema 1 — macOS Capture/Export ist gegenüber iOS beschnitten. „Save to Photos" ist hart per #if os(iOS) ausgeblendet, obwohl CaptureSaver (Photos/PHPhotoLibrary) plattformübergreifend ist und die nötigen Plist-Keys schon vorliegen; es gibt keinen nativen Datei-Export (nur ShareLink); das wählbare Live-Photo-Format hat auf macOS keinen Speicherpfad; ein geteiltes Live Photo verliert die Still-Hälfte; Capture-Temp-Dateien sammeln sich ohne Cleanup an.
  • Thema 2 — iPad trägt die volle Desktop-Last auf fanless Hardware. FieldTier.current mappt iPad in die .desktop-Tier (720/1600/500k) — ~2× die 512²-Tuning-Fläche plus Desktop-Agent-Cap, ohne aktive Kühlung. Die FPS-Drossel greift erst bei .serious, und die Capture-Decken sind device-class-blind (4K-Still, 2560/30 s auf dem iPad).

Beide sind Feinschliff, kein Neubau: die App ist schon universal (ein SwiftUI/Metal-Codebase, project.yml → macOS/iOS/tvOS), baut und rendert auf macOS und iPad korrekt.

Entscheidungen (aus dem Brainstorm)

  1. iPad-Tier konservativ. Eine einzige, vorsichtige iPad-Tier für alle Modelle (512-base, preset-treu + thermisch sicher), nicht modell-bewusst und nicht eine höhere Mittel-Tier. Passt zur Ambiance-Richtung (kühl/leise/langlaufend); M-Serie evtl. leicht unterausgelastet — bewusst, später am Gerät nachschärfbar.
  2. Datei-Export via .fileExporter (SwiftUI, cross-platform) statt NSSavePanel (AppKit, macOS-only) — idiomatischer und gibt iPad/iOS als Bonus einen „In Dateien sichern"-Pfad.
  3. A5-Cleanup und B3-Capture-Decken bleiben in dieser Runde (Johannes bestätigt).
  4. Ehrlichkeit über Nicht-Hebel: cappedDrawableScale ist auf iPad praktisch wirkungslos (alle iPads sind @2x), wird nur konsistent gemacht; der echte Dauerlast-Hebel ist die .fair-FPS-Drossel.

Thema 1 — macOS Capture/Export-Parität

A1 · Save-to-Photos auf macOS freischalten

CaptureSheet.swift (doneCard, ~Z. 150–153): das #if os(iOS) um den „Save to Photos"-PhysButton entfernen — der Button gilt dann auf allen Plattformen. Begründung (Audit-verifiziert): CaptureSaver.saveToPhotos/saveLivePhoto nutzen PHPhotoLibrary.requestAuthorization(for: .addOnly) + PHAssetCreationRequest (ab macOS 14 verfügbar), der Plist-Key NSPhotoLibraryAddUsageDescription ist vorhanden, und der macOS-Build läuft unsandboxed → es fehlt nur der UI-Einstieg. Die Erfolgs-Haptik in save() (~Z. 186–188) bleibt #if os(iOS) (auf macOS kein UINotificationFeedbackGenerator).

A2 · Nativer Datei-Export (.fileExporter)

Neuer „Export…"-Button im doneCard, primär für macOS (füllt die Lücke „save this render where I want"). Ein leichter FileDocument-Wrapper um die bereits gerenderte Temp-Datei:

struct ExportedCapture: FileDocument {
    static var readableContentTypes: [UTType] { [] }   // export-only
    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)   // kopiert die fertige Datei an den gewählten Ort
    }
}

UTType aus dem Output ableiten: .png/.heic für Stills, .quickTimeMovie (HEVC .mov) / .mpeg4Movie (H.264 .mp4) für Video; Live Photo exportiert das HEVC-.mov (Still separat via A1 in Fotos). .fileExporter(isPresented:document:contentType:defaultFilename:) mit dem out.primaryURL.lastPathComponent als Default-Name.

Sichtbarkeit: Der „Export…"-Button wird in dieser Runde nur auf macOS gezeigt (#if os(macOS)) — er füllt dort die Lücke, während iOS/iPad mit „Save to Photos" (A1) + „Share" bereits abgedeckt sind und das Sheet nicht überladen werden soll. Da der .fileExporter + FileDocument-Unterbau cross-platform ist, ist ein späteres „In Dateien sichern" auf iOS nur eine Änderung am Gate.

A3 · Live Photo auf macOS

Fällt aus A1 heraus: mit dem nun auf macOS sichtbaren Save-Button erreicht save() den saveLivePhoto-Pfad, und macOS-Fotos unterstützt Live Photos. Keine Code-Änderung über A1 hinaus — der Format-Picker bleibt unverändert, das Format ist jetzt überall speicherbar (kein Ausblenden, kein toter Pfad mehr).

CaptureOutput.primaryURL liefert für .livePhoto nur das Video (CaptureService.swift:11), ShareLink(item:) teilt also ein nacktes .mov und verwirft das HEIC-Still. Fix im doneCard: für .livePhoto beide Dateien teilen, sonst wie bisher.

switch out {
case .livePhoto(let still, let video): ShareLink(items: [still, video]) { shareLabel }
case .file(let url, _):                 ShareLink(item: url) { shareLabel }
}

Hinweis: Ein geteiltes Live Photo ist auf der Gegenseite kein gepaartes Live Photo — aber das Still geht nicht mehr verloren. (Der verlustfreie Pfad bleibt „Save to Photos".)

A5 · Temp-Cleanup

Alle Captures landen in FileManager.default.temporaryDirectory als physarum-* (eindeutige Zeitstempel-Namen, also keine Überschreibung). Zwei Mechanismen:

  • Launch-Sweep: Beim App-Start einmalig verwaiste physarum-*.{png,heic,mov,mp4} aus dem Temp-Verzeichnis löschen (robust, deckt Abstürze/abgebrochene Sheets ab). Reine Funktion staleCaptureFiles(in:now:olderThan:) für den Filter → testbar.
  • Dismiss-Purge: Optional die in der aktuellen Sheet-Session erzeugten URLs aufräumen, sobald gesichert/exportiert/geteilt und der „Done"-Tap erfolgt. (Launch-Sweep ist die Pflicht, Dismiss die Kür.)

Thema 2 — iPad Thermik-Sicherheit

B1 (Hauptlever) · Eigene konservative .tablet-Tier

SimFormat.swift — neue Tier plus testbares Idiom-Mapping:

static let tablet = FieldTier(base: 512, maxLong: 1280, agentCap: 250_000)  // heute = phone-Werte,
                                                                            // eigener Seam für späteres iPad-Tuning

enum DeviceIdiom { case phone, pad, mac }
func fieldTier(for idiom: DeviceIdiom) -> FieldTier {
    switch idiom {
    case .phone: return .phone
    case .pad:   return .tablet
    case .mac:   return .desktop
    }
}

static var current: FieldTier {
    #if os(iOS)
    return fieldTier(for: UIDevice.current.userInterfaceIdiom == .phone ? .phone : .pad)
    #else
    return fieldTier(for: .mac)
    #endif
}

Effekt: iPad-Live-Feld 720²→512² (≈ −49 % Fläche), agentCap 500k→250k. Das ist der echte Thermik-Hebel und preset-treu (Presets auf 512 getuned; höhere Auflösung wirkt dünner/blasser). Die eigene .tablet-Tier (statt iPad einfach auf .phone zu mappen) gibt einen benannten Seam, um iPad später am Gerät separat nachzuziehen, ohne iPhone zu berühren.

B2 · .fair-Thermik-Pre-emption

PlayerPolicy.swift — preferredFPS schon bei .fair (statt erst .serious) auf 30 fps drosseln:

func preferredFPS(thermalState: ProcessInfo.ThermalState, lowPowerMode: Bool) -> Int {
    if lowPowerMode || thermalState.rawValue >= ProcessInfo.ThermalState.fair.rawValue { return 30 }
    return 60
}

Das ist der wirksame Dauerlast-Lever für den großflächigen iPad-Composite — greift, bevor das System hart drosselt. Gilt für alle Plattformen (auch iPhone/Mac profitieren minimal; ungefährlich).

cappedDrawableScale wird nur konsistent gemacht (tier != .desktop ? min(scale, 2.0) : scale, schließt .tablet ein) — auf realer iPad-Hardware (@2x) ist das ein No-op und kein echter Hebel; die Composite-Kosten sind screen-getrieben, ein Sub-2x-Cap würde das scharfe Display weichzeichnen (bewusst nicht).

B3 · Capture-Decken auf iPad begrenzen

CaptureConfig.swift — device-class-abhängige Options-Listen (reine Funktionen, testbar):

func offeredStillSizes(idiom: DeviceIdiom)     -> [Int]   // iPad: kein 4096 (max 2048)
func offeredVideoSizes(idiom: DeviceIdiom)     -> [Int]   // iPad: kein 2560 (max 1920)
func offeredVideoDurations(idiom: DeviceIdiom) -> [Int]   // iPad: kein 30 s (max 15 s)

macOS (Kühlung/RAM) und iPhone (am Gerät validiert) behalten die volle Decke. CaptureSheet-Picker ziehen ihre Werte aus diesen Funktionen statt aus den rohen Konstanten. Optional (Kür): ein ProcessInfo.thermalState-Guard, der unter .serious die jeweils schwerste Kombi verweigert/warnt.

Bewusst verschoben (in dieser Runde nicht)

  • Split-View / Stage Manager dynamisches Feld-Scaling — durch die konservative 512-Tier ohnehin entschärft (das verkleinerte Fenster läuft nie heißer als Vollbild-512).
  • Rotation-Rebuild (jede iPad-Rotation re-erzeugt die Render-Engine) — durch den vorhandenen Debounce + das günstigere 512-Feld entschärft; ein „skip rebuild, wenn Ziel-Felddimensionen unverändert"-Guard wäre ein billiger Mini-Win, ist aber nicht Pflicht.

Komponenten-Übersicht

Unit Datei Rolle
Save-Button-Gating entfernen Capture/CaptureSheet.swift (~150) A1 macOS Save-to-Photos
ExportedCapture: FileDocument + Export-Button Capture/CaptureSheet.swift (+ ggf. eigene Datei) A2 Datei-Export
ShareLink für .livePhoto beide URLs Capture/CaptureSheet.swift (doneCard) A4
staleCaptureFiles(in:now:olderThan:) + Launch-Sweep Capture/CaptureCleanup.swift (neu) + App-Start A5
.tablet-Tier + fieldTier(for:) Model/SimFormat.swift B1
preferredFPS .fair-Fall + cappedDrawableScale non-desktop Player/PlayerPolicy.swift B2
offeredStillSizes/VideoSizes/VideoDurations(idiom:) Capture/CaptureConfig.swift + CaptureSheet B3

Testing (Muster: bestehende Tests/SimFormatTests.swift, PlayerPolicy-Tests, Host-GPU)

  • fieldTier(for:): .phone→phone, .pad→tablet (512/1280/250k), .mac→desktop.
  • preferredFPS: .fair→30, .nominal→60, Low-Power→30 (Regression der .serious/.critical).
  • cappedDrawableScale: .tablet deckelt auf 2.0; .desktop bleibt nativ (Regression .phone).
  • offered*(idiom): iPad-Listen ohne 4096/2560/30; macOS+iPhone voll.
  • staleCaptureFiles: filtert physarum-* älter als Schwelle, lässt Fremddateien in Ruhe.
  • A1/A3 (Gating): Compile-Zeit; Capture/Saver-Pipeline schon per GPU-XCTest bewiesen.
  • A4: .livePhoto-Share liefert beide URLs (Logik-Helfer testbar).
  • Gates: swift test -c release (Engine, falls berührt) + xcodebuild … test (App, macOS+iOS).

Bewusste Abweichungen (→ AGENTS.md nachziehen)

  • iPad ist nicht mehr Desktop-Tier: eigener .tablet-Tier (512/1280/250k). Die bisherige Notiz „Macs/iPads keep the larger field" (Kommentar in SimFormat.swift) gilt nur noch für Macs.
  • .fair-Drossel senkt die FPS auf allen Plattformen früher (30 fps schon bei .fair).
  • macOS hat jetzt Save-to-Photos + Datei-Export; die WKWebView-DMG-Ablöse rückt damit näher (Capture-Parität ist eine Cutover-Voraussetzung).

Bewusst nicht in Scope (YAGNI)

  • Modell-bewusste iPad-Tierung (A-Serie vs. M-Serie) — eine konservative Tier für alle.
  • Sub-2x-Composite-Scaling auf iPad (würde weichzeichnen).
  • Split-View-dynamisches Feld-Scaling & Rotation-Rebuild-Guard (verschoben, s.o.).
  • Tier C (macOS-Menübefehle/Undo/Fullscreen), Tier D (iPad-Layout-Polish), Tier E (Hardened-Runtime/audio-input-Entitlement, Desktop-Mac-ohne-Mic, Mic-denied-Feedback), tvOS.
  • iPhone-Capture-Decken senken (am Gerät validiert, bleibt voll).