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, obwohlCaptureSaver(Photos/PHPhotoLibrary) plattformübergreifend ist und die nötigen Plist-Keys schon vorliegen; es gibt keinen nativen Datei-Export (nurShareLink); 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.currentmappt 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)¶
- 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.
- Datei-Export via
.fileExporter(SwiftUI, cross-platform) stattNSSavePanel(AppKit, macOS-only) — idiomatischer und gibt iPad/iOS als Bonus einen „In Dateien sichern"-Pfad. - A5-Cleanup und B3-Capture-Decken bleiben in dieser Runde (Johannes bestätigt).
- Ehrlichkeit über Nicht-Hebel:
cappedDrawableScaleist 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).
A4 · ShareLink verliert die Still-Hälfte (beide Plattformen)¶
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 FunktionstaleCaptureFiles(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:.tabletdeckelt auf 2.0;.desktopbleibt nativ (Regression.phone).offered*(idiom): iPad-Listen ohne 4096/2560/30; macOS+iPhone voll.staleCaptureFiles: filtertphysarum-*ä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 inSimFormat.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).