Wallpaper-Effizienz 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: Der --wallpaper-Modus (und mittelbar der Screensaver) der nativen macOS-Metal-App wird „unsichtbar im Alltag": keine Wegwerf-Wakeups, kein CPU/GPU-Serialisieren, Pause bei Unsichtbarkeit, adaptive Power-Policy, reduzierte Render-Auflösung.
Architecture: Spec: docs/specs/2026-07-14-wallpaper-efficiency-design.md. Drei Blöcke, einzeln messbar und committbar: §1 Frame-Pacing (CADisplayLink + async GPU-Timing), §2 PowerPolicy (pure Entscheidungstabelle in Core + Signal-Sammler in der App), §3 Render-Scale. Vorher Baseline-Messung; nach jedem Block dieselbe Messung + Golden-PNG-Vergleich.
Tech Stack: Swift (swiftc, kein Xcode), AppKit, Metal, CAMetalLayer, CADisplayLink (macOS 14+). Tests: assert-basiertes tests/main.swift via scripts/run-native-tests.sh; headless Golden-Renders via scripts/build-native-harness.sh.
Global Constraints¶
- Nur
native/macos/+scripts/anfassen — Web-/Windows-Targets bleiben unberührt (Spec „Nicht-Ziele"). - §1/§2 müssen pixelidentische Harness-Frames liefern (Golden-Hash-Vergleich); §3 ändert nur den Wallpaper-Pfad.
- macOS-Minimum wird 14.0 (Info.plist
LSMinimumSystemVersion+-target arm64-apple-macos14.0); kein#available-Doppelpfad, CVDisplayLink-Code wird gelöscht. - Der Vollbild-Screensaver darf durch die PowerPolicy nie pausiert werden (Policy greift nur auf
wallpaperViews). - Jede Task endet grün:
bash scripts/run-native-tests.sh&&bash scripts/smoke-test-native.sh&&bash scripts/build-native-app.sh. - Commits: Conventional Commits, Trailer
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>. - Messwerte in
.claude/logs/2026-07-15-wallpaper-messungen.mdsammeln (gitignored); Ergebnis-Tabelle am Ende in die Spec (Task 7). sudo powermetricskann nur Johannes ausführen — der Executor sammelt die CPU-%-Messung selbst und druckt das powermetrics-Kommando für Johannes.
Task 1: Mess-Skript + Baseline (CPU, Golden-Hashes)¶
Files:
- Create: scripts/measure-wallpaper.sh
- Create (gitignored, nur lokal): .claude/logs/2026-07-15-wallpaper-messungen.md
Interfaces:
- Produces: scripts/measure-wallpaper.sh [SECONDS] — druckt CPU avg X% max Y%; Golden-Baseline-Hashes unter /tmp/kuro-golden-base/hashes.txt (von Task 2/3 konsumiert).
- [ ] Step 1: Mess-Skript schreiben
#!/usr/bin/env bash
# Measure the wallpaper mode's CPU cost: launches `--wallpaper` with a fixed
# scene/preset (deterministic load), samples the process CPU% once per second,
# prints avg/max. GPU + package power need root → the matching powermetrics
# command is printed at the end for a manual sudo run.
#
# NOTE: stop any already-running wallpaper instance first (▦ menu → "Hintergrund
# beenden"), otherwise two instances render at once and the numbers are garbage.
#
# scripts/measure-wallpaper.sh [SECONDS] # default 60
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DUR="${1:-60}"
APP="$ROOT/native/macos/build/KuroMetalApp.app/Contents/MacOS/KuroMetalApp"
[ -x "$APP" ] || bash "$ROOT/scripts/build-native-app.sh"
"$APP" --wallpaper --scene terrain --preset toxic-haze &
PID=$!
trap 'kill "$PID" 2>/dev/null || true' EXIT
sleep 8 # settle: boot overlay + window setup
echo "sampling ${DUR}s (pid $PID)…"
samples="$(for _ in $(seq 1 "$DUR"); do ps -o %cpu= -p "$PID" 2>/dev/null || break; sleep 1; done)"
echo "$samples" | awk '{s+=$1; if ($1>m) m=$1; n++}
END {if (n==0) {print "no samples — app died?"; exit 1}
printf "CPU avg %.1f%% max %.1f%% (n=%d)\n", s/n, m, n}'
echo
echo "GPU/Power/Wakeups (manuell, braucht sudo — bitte Johannes):"
echo " sudo powermetrics -i 1000 -n 20 --samplers gpu_power,tasks 2>/dev/null | grep -E 'GPU Power|KuroMetalApp'"
Datei als scripts/measure-wallpaper.sh anlegen, dann: chmod +x scripts/measure-wallpaper.sh
- [ ] Step 2: Baseline-CPU-Messung laufen lassen
Run: bash scripts/measure-wallpaper.sh 60
Expected: eine Zeile wie CPU avg 12.3% max 18.0% (n=60) — Werte in .claude/logs/2026-07-15-wallpaper-messungen.md notieren (Abschnitt „Baseline"). Hinweis: --scene terrain --preset toxic-haze fixiert die Last; das Kommando für Johannes' powermetrics-Lauf ebenfalls ins Log kopieren.
- [ ] Step 3: Golden-Baseline rendern + Hashes sichern
Determinismus-Pins (Befund 2026-07-15): Der Harness ist ohne sie NICHT byte-stabil —
Terminal.init()seedet mitfreshSeed()(→threat-Jitter in den CRT-Uniforms), die HUD rendert die Wanduhr, die Persona ist zufällig.--threat 0 --no-hud --notermpinnt/versteckt alle drei Quellen (verifiziert: 3× identische Hashes). Alle Golden-Kommandos in diesem Plan tragen diese Flags.
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-base --scene terrain --at 8 --seed 1337 --threat 0 --no-hud --noterm
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-base-city --scene city --at 8 --seed 1337 --threat 0 --no-hud --noterm
(cd /tmp/kuro-golden-base && shasum -a 256 *.png) > /tmp/kuro-golden-base/hashes.txt
(cd /tmp/kuro-golden-base-city && shasum -a 256 *.png) >> /tmp/kuro-golden-base/hashes.txt
cat /tmp/kuro-golden-base/hashes.txt
Expected: zwei Hash-Zeilen. Diese Datei ist der Referenzpunkt für Task 2 + 3.
- [ ] Step 4: Commit
git add scripts/measure-wallpaper.sh
git commit -m "chore(native): add wallpaper CPU measurement script
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 2: Renderer — async GPU-Timing statt waitUntilCompleted (§1b)¶
Files:
- Modify: native/macos/KuroNativeSaver/Core/Renderer.swift (Felder ~Zeile 64, draw(into:) Zeilen 412–576)
- Modify: native/macos/KuroNativeSaver/harness/main.swift (~Zeile 180, nach Renderer-Init)
Interfaces:
- Produces: Renderer.synchronousDraws: Bool (default false) — Harness/Readback-Hosts setzen true, dann blockiert draw() wie bisher bis GPU-Ende. App-Pfad bleibt false.
- Konsumiert von Task 3: nichts Neues (MetalHostView ruft draw(into:) unverändert).
- [ ] Step 1: Felder ergänzen
In Renderer.swift direkt unter private(set) var qualityTier = 0 (Zeile 65) einfügen:
/// Harness/tests set this: draw() then blocks until the GPU finished, so a
/// texture readback right after draw() sees the completed frame. The app
/// leaves it false (async timing, no per-frame CPU⇄GPU serialisation).
var synchronousDraws = false
// Cap the CPU's lead over the GPU; signalled by each command buffer's
// completed handler. 2 = current frame encoding + one in flight.
private let inFlight = DispatchSemaphore(value: 2)
private let gpuMsLock = NSLock()
private var pendingGpuMs: Double = 0
- [ ] Step 2: draw() umbauen
In draw(into:) den Anfang (Zeilen 412–415) ändern von:
func draw(into target: MTLTexture) {
ensureTextures(target.width, target.height)
guard let sceneHDR, let depthTex else { return }
let cb = queue.makeCommandBuffer()!
zu (Semaphore NACH dem guard — ein early-return darf keinen Slot verlieren):
func draw(into target: MTLTexture) {
ensureTextures(target.width, target.height)
guard let sceneHDR, let depthTex else { return }
// Quality controller: consume the newest async GPU-time sample (written
// by a completed handler; one frame of latency is irrelevant for the
// hysteretic controller).
gpuMsLock.lock(); let gpuSample = pendingGpuMs; pendingGpuMs = 0; gpuMsLock.unlock()
if gpuSample > 0 { updateQuality(gpuMs: gpuSample) }
inFlight.wait()
let cb = queue.makeCommandBuffer()!
Und das Ende (Zeilen 573–575) ändern von:
zu:
// Semaphore captured directly (not self): a buffer completing after the
// renderer is gone must still signal, and the handler's strong reference
// keeps the semaphore alive until then (value back at 2 before dispose).
let sem = inFlight
cb.addCompletedHandler { [weak self] done in
sem.signal()
guard let self else { return }
let ms = (done.gpuEndTime - done.gpuStartTime) * 1000
self.gpuMsLock.lock(); self.pendingGpuMs = ms; self.gpuMsLock.unlock()
}
cb.commit()
if synchronousDraws { cb.waitUntilCompleted() }
- [ ] Step 3: Harness auf synchron stellen
In harness/main.swift nach der Renderer-Erzeugung (Zeile ~180, renderer = try Renderer(...)) einfügen:
(Exakte Stelle: direkt nach der try Renderer(…)-Zuweisung, vor der ersten draw-Nutzung. Falls die Variable dort let renderer in einem anderen Scope ist: die Zeile unmittelbar nach dem Zuweisungs-Statement einfügen.)
- [ ] Step 4: Tests + Smoke laufen lassen
Run: bash scripts/run-native-tests.sh && bash scripts/smoke-test-native.sh
Expected: alle ok - …-Zeilen, Smoke rendered … bytes, Exit 0.
- [ ] Step 5: Golden-Vergleich — muss pixelidentisch sein
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-t2 --scene terrain --at 8 --seed 1337 --threat 0 --no-hud --noterm
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-t2-city --scene city --at 8 --seed 1337 --threat 0 --no-hud --noterm
{ (cd /tmp/kuro-golden-t2 && shasum -a 256 *.png); (cd /tmp/kuro-golden-t2-city && shasum -a 256 *.png); } | diff /tmp/kuro-golden-base/hashes.txt -
Expected: kein Output (identische Hashes). Bei Diff: STOPP, Ursache finden (Task nicht committen).
- [ ] Step 6: Messung + Commit
Run: bash scripts/build-native-app.sh && bash scripts/measure-wallpaper.sh 60 — Werte ins Log (Abschnitt „nach Task 2").
git add native/macos/KuroNativeSaver/Core/Renderer.swift native/macos/KuroNativeSaver/harness/main.swift
git commit -m "perf(native): async GPU timing — drop per-frame waitUntilCompleted
Quality controller now consumes the completed-handler sample (1 frame
latency); 2-frames-in-flight semaphore bounds CPU lead. Harness opts back
into synchronous draws for its readback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 3: MetalHostView — CADisplayLink + macOS-14-Floor (§1)¶
Files:
- Modify (Komplett-Rewrite): native/macos/KuroMetalApp/MetalHostView.swift
- Modify: native/macos/KuroMetalApp/Info.plist (Zeile 24: 11.0 → 14.0)
- Modify: scripts/build-native-app.sh (swiftc-Aufruf: -target ergänzen)
- Modify: AGENTS.md (macOS-Abschnitt: Floor dokumentieren)
Interfaces:
- Produces (API unverändert für alle Hosts): MetalHostView(frame:settings:autoCycleSec:), start(), stop(), setPaused(Bool), setFrameCap(Double), cycleScene(by:). Rendering läuft jetzt auf dem Main-Thread.
- Konsumiert von Task 5/6: setPaused/setFrameCap (Task 5), Task 6 erweitert die Datei um setRenderScale.
- [ ] Step 1: MetalHostView.swift komplett ersetzen
// MetalHostView — an NSView that hosts the shared Core renderer in a CAMetalLayer,
// driven by a CADisplayLink (NSView.displayLink(target:selector:), macOS 14+).
// The system paces callbacks to preferredFrameRateRange (no manual vsync skipping,
// ProMotion can down-clock), and everything — start/stop/render — runs on the main
// thread, so the old display-thread teardown barrier is gone entirely.
//
// Invariant: hosts call stop() before dropping the view (CADisplayLink retains its
// target); viewDidMoveToWindow(nil) is the safety net.
import AppKit
import Metal
import QuartzCore
final class MetalHostView: NSView {
private let device = MTLCreateSystemDefaultDevice()
private var metalLayer: CAMetalLayer?
private var renderer: Renderer?
private var link: CADisplayLink?
private var lastTime: CFTimeInterval = 0
// Fallback cap for displays that don't honour the range hint exactly
// (fixed-rate external panels): skip callbacks that arrive early. dt stays
// correct (measured from the last *rendered* frame).
private var minFrameInterval: CFTimeInterval = 1.0 / 61.0
private var frameRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60)
private var paused = false
private let settings: Settings
private let autoCycleSec: Double
/// Wallpaper power policy: target frame rate (e.g. 30 on AC, 10 on battery).
func setFrameCap(_ fps: Double) {
let f = Float(max(1, fps))
frameRange = CAFrameRateRange(minimum: max(1, f / 3), maximum: f, preferred: f)
minFrameInterval = 1.0 / (Double(f) + 1)
link?.preferredFrameRateRange = frameRange
}
/// Power policy: pause the display link entirely (0 wakeups). The last
/// rendered frame stays on the layer (= the "frozen" still).
func setPaused(_ p: Bool) { paused = p; link?.isPaused = p }
/// Manual scene-switch hotkey (deliberate fullscreen only): +1 next, -1 previous.
func cycleScene(by delta: Int) { renderer?.cycleScene(by: delta) }
init(frame: NSRect, settings: Settings, autoCycleSec: Double) {
self.settings = settings
self.autoCycleSec = autoCycleSec
super.init(frame: frame)
wantsLayer = true
layer?.backgroundColor = NSColor.black.cgColor
setupMetal()
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }
private func setupMetal() {
guard let device else { return }
let ml = CAMetalLayer()
ml.device = device
ml.pixelFormat = .bgra8Unorm
ml.isOpaque = true
ml.frame = bounds
layer?.addSublayer(ml)
metalLayer = ml
let ctx = SceneContext(device: device, rng: LCG(seed: settings.seed ?? freshSeed()),
settings: settings, accent: settings.preset.accentRGB)
let scene = SceneRegistry.make(settings.scene, ctx: ctx)
do {
let r = try Renderer(device: device, settings: settings, scene: scene, targetFormat: .bgra8Unorm)
r.autoCycleSec = autoCycleSec
renderer = r
} catch {
// Don't hard-crash: leave renderer nil so start()/renderFrame() no-op
// (black layer) and the app stays alive with a logged reason.
NSLog("[Kuro] Renderer init failed — rendering disabled: \(error)")
renderer = nil
}
updateDrawableSize()
}
private func updateDrawableSize() {
guard let ml = metalLayer else { return }
let scale = window?.backingScaleFactor ?? 2.0
ml.frame = bounds
ml.contentsScale = scale
ml.drawableSize = CGSize(width: max(1, bounds.width * scale),
height: max(1, bounds.height * scale))
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil { stop() } else { updateDrawableSize() }
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
updateDrawableSize()
}
func start() {
guard link == nil, renderer != nil, metalLayer != nil else { return }
lastTime = 0
let l = displayLink(target: self, selector: #selector(tick))
l.preferredFrameRateRange = frameRange
l.isPaused = paused
l.add(to: .main, forMode: .common)
link = l
}
func stop() {
link?.invalidate() // removes it from the run loop + drops its target ref
link = nil
}
@objc private func tick() {
guard !paused, let ml = metalLayer, let renderer else { return }
let now = CACurrentMediaTime()
if lastTime != 0, now - lastTime < minFrameInterval { return }
if lastTime == 0 { lastTime = now }
let dt = min(0.05, max(0, now - lastTime))
lastTime = now
renderer.advance(dt: dt)
guard let drawable = ml.nextDrawable() else { return }
renderer.draw(into: drawable.texture)
drawable.present()
}
}
(Bewusst entfernt: runLock/isRunning-Barriere, sizeLock/pendingDrawableSize, autoreleasepool im Tick — der Main-RunLoop hat einen ambienten Pool pro Iteration.)
- [ ] Step 2: Info.plist-Floor anheben
In native/macos/KuroMetalApp/Info.plist: <string>11.0</string> → <string>14.0</string> (unter LSMinimumSystemVersion).
- [ ] Step 3: Build-Target festnageln
In scripts/build-native-app.sh den swiftc-Aufruf ändern von:
zu:
- [ ] Step 4: AGENTS.md dokumentieren
Im Abschnitt „## Native builds (native/)", macOS-Bullet: nach „Built with swiftc (no Xcode) via scripts/build-native-app.sh" einfügen: „ (deployment floor macOS 14 — the render loop uses NSView.displayLink/CADisplayLink)".
- [ ] Step 5: Bauen + Tests + Golden
Run: bash scripts/build-native-app.sh && bash scripts/run-native-tests.sh && bash scripts/smoke-test-native.sh
Expected: „codesign verify OK", Tests ok, Smoke ok.
Golden (Core unverändert — trotzdem prüfen):
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-t3 --scene terrain --at 8 --seed 1337 --threat 0 --no-hud --noterm
bash scripts/build-native-harness.sh --out /tmp/kuro-golden-t3-city --scene city --at 8 --seed 1337 --threat 0 --no-hud --noterm
{ (cd /tmp/kuro-golden-t3 && shasum -a 256 *.png); (cd /tmp/kuro-golden-t3-city && shasum -a 256 *.png); } | diff /tmp/kuro-golden-base/hashes.txt -
Expected: kein Output.
- [ ] Step 6: Funktionstest Wallpaper + Vollbild (kurz, lokal)
APP=native/macos/build/KuroMetalApp.app/Contents/MacOS/KuroMetalApp
"$APP" --wallpaper --scene terrain & P=$!; sleep 12; ps -p $P && kill $P # muss 12s leben, Desktop animiert
"$APP" --scene terrain & P=$!; sleep 6; ps -p $P && kill $P # Vollbild-Override startet
Expected: beide Prozesse laufen bis zum kill (kein Crash-Log in log stream-Ausgabe nötig; ps genügt). Danach Messung: bash scripts/measure-wallpaper.sh 60 → Log „nach Task 3" (Erwartung: avg-CPU sinkt gegenüber Task 2, da 120-Hz-Wegwerf-Wakeups entfallen).
- [ ] Step 7: Commit
git add native/macos/KuroMetalApp/MetalHostView.swift native/macos/KuroMetalApp/Info.plist scripts/build-native-app.sh AGENTS.md
git commit -m "perf(native): CADisplayLink frame pacing, macOS 14 floor
System-paced preferredFrameRateRange replaces the deprecated CVDisplayLink
(120Hz wakeups discarded manually at low caps). Main-thread loop: the
display-thread teardown barrier (runLock/sizeLock) is gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 4: Core RenderPolicy — pure Entscheidungstabelle + Tests (§2a)¶
Files:
- Create: native/macos/KuroNativeSaver/Core/RenderPolicy.swift
- Test: native/macos/KuroNativeSaver/tests/main.swift (Block ergänzen)
Interfaces:
- Produces (von Task 5 konsumiert):
- enum RenderState: Equatable { case hidden; case frozen; case animating(fps: Double) }
- struct RenderPolicyInputs (alle Felder var mit Defaults: occluded/screenLocked/screensAsleep/onBattery/lowPowerMode/thermalSerious/thermalCritical/animateOnBattery = false, acFps = 30, batteryFps = 10)
- func renderState(_ i: RenderPolicyInputs) -> RenderState
- [ ] Step 1: Failing Tests schreiben
In tests/main.swift vor dem abschließenden Failure-Summary-Block einfügen:
// --- wallpaper render policy (pure decision table) --------------------------
do {
var i = RenderPolicyInputs()
check(renderState(i) == .animating(fps: 30), "policy default AC → 30fps")
i.onBattery = true
check(renderState(i) == .frozen, "policy battery default → frozen")
i.animateOnBattery = true
check(renderState(i) == .animating(fps: 10), "policy battery opt-in → 10fps")
i = RenderPolicyInputs(); i.lowPowerMode = true
check(renderState(i) == .frozen, "policy low-power behaves like battery")
i.animateOnBattery = true
check(renderState(i) == .animating(fps: 10), "policy low-power opt-in → 10fps")
i = RenderPolicyInputs(); i.thermalSerious = true
check(renderState(i) == .animating(fps: 10), "policy thermal serious → 10fps on AC")
i.thermalCritical = true
check(renderState(i) == .frozen, "policy thermal critical → frozen")
i = RenderPolicyInputs(); i.occluded = true; i.animateOnBattery = true
check(renderState(i) == .hidden, "policy occluded beats everything")
i = RenderPolicyInputs(); i.screenLocked = true
check(renderState(i) == .hidden, "policy locked → hidden")
i = RenderPolicyInputs(); i.screensAsleep = true
check(renderState(i) == .hidden, "policy display sleep → hidden")
}
- [ ] Step 2: RED verifizieren
Run: bash scripts/run-native-tests.sh
Expected: FAIL — Compile-Fehler cannot find 'RenderPolicyInputs' in scope (der RED-Beweis bei compile-time-typed Sprachen).
- [ ] Step 3: Implementierung
native/macos/KuroNativeSaver/Core/RenderPolicy.swift anlegen:
// RenderPolicy — pure decision table for the wallpaper's power/visibility
// policy. Lives in Core (no AppKit) so the logic tests cover it; the app layer
// (PowerPolicy.swift) gathers the signals, the AppDelegate applies the result
// per wallpaper view. Priority: invisibility → thermal panic → battery/low-power
// preference → thermal throttle → normal animation.
import Foundation
enum RenderState: Equatable {
case hidden // stop the display link entirely (0 wakeups)
case frozen // keep the last frame as a still
case animating(fps: Double)
}
struct RenderPolicyInputs {
var occluded = false // this window is fully covered / other Space
var screenLocked = false
var screensAsleep = false
var onBattery = false
var lowPowerMode = false
var thermalSerious = false // ProcessInfo.thermalState == .serious
var thermalCritical = false // == .critical
var animateOnBattery = false // AppSettings.wallpaperOnBattery
var acFps: Double = 30
var batteryFps: Double = 10
}
func renderState(_ i: RenderPolicyInputs) -> RenderState {
if i.occluded || i.screenLocked || i.screensAsleep { return .hidden }
if i.thermalCritical { return .frozen }
let saving = i.onBattery || i.lowPowerMode
if saving && !i.animateOnBattery { return .frozen }
if i.thermalSerious { return .animating(fps: i.batteryFps) }
return .animating(fps: saving ? i.batteryFps : i.acFps)
}
- [ ] Step 4: GREEN verifizieren
Run: bash scripts/run-native-tests.sh
Expected: alle neuen ok - policy …-Zeilen, Exit 0.
- [ ] Step 5: Commit
git add native/macos/KuroNativeSaver/Core/RenderPolicy.swift native/macos/KuroNativeSaver/tests/main.swift
git commit -m "feat(native): pure render-policy decision table + tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 5: PowerPolicy-Signalsammler + AppDelegate-Verdrahtung (§2b)¶
Files:
- Create: native/macos/KuroMetalApp/PowerPolicy.swift
- Delete: native/macos/KuroMetalApp/PowerMonitor.swift
- Modify: native/macos/KuroMetalApp/main.swift (Zeile 30 Typ; startWallpaper Zeilen 65–74; buildWallpaperWindows Zeilen 76–99; applyPowerPolicy Zeilen 109–119; neuer occlusionChanged)
Interfaces:
- Consumes: RenderState/RenderPolicyInputs/renderState(_:) aus Task 4; setPaused(Bool)/setFrameCap(Double) aus Task 3.
- Produces: PowerPolicy mit var onChange: (() -> Void)?, func start(), func stop(), readonly onBattery/lowPowerMode/thermal/screenLocked/screensAsleep, static func isOnBattery() -> Bool.
- [ ] Step 1: PowerPolicy.swift anlegen
// PowerPolicy — gathers every system signal the wallpaper's power policy needs
// (battery, Low Power Mode, thermal pressure, screen lock, display sleep) and
// fires onChange on the main queue; the AppDelegate combines them with
// per-window occlusion and applies the pure Core decision (renderState) to each
// wallpaper view. Battery stays polled: plug/unplug latency is irrelevant for a
// wallpaper, and polling avoids the IOPS C-callback dance.
import AppKit
import IOKit.ps
final class PowerPolicy {
var onChange: (() -> Void)?
private(set) var onBattery = false
private(set) var lowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled
private(set) var thermal = ProcessInfo.processInfo.thermalState
private(set) var screenLocked = false
private(set) var screensAsleep = false
private var timer: Timer?
private var tokens: [(center: NotificationCenter, token: NSObjectProtocol)] = []
func start() {
onBattery = PowerPolicy.isOnBattery()
let t = Timer(timeInterval: 5, repeats: true) { [weak self] _ in self?.pollBattery() }
t.tolerance = 2 // coalesce: exact phase is irrelevant, save wakeups
RunLoop.main.add(t, forMode: .common); timer = t
observe(.default, .NSProcessInfoPowerStateDidChange) { [weak self] in
self?.lowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled
}
observe(.default, ProcessInfo.thermalStateDidChangeNotification) { [weak self] in
self?.thermal = ProcessInfo.processInfo.thermalState
}
let ws = NSWorkspace.shared.notificationCenter
observe(ws, NSWorkspace.screensDidSleepNotification) { [weak self] in self?.screensAsleep = true }
observe(ws, NSWorkspace.screensDidWakeNotification) { [weak self] in self?.screensAsleep = false }
let dnc = DistributedNotificationCenter.default()
observe(dnc, Notification.Name("com.apple.screenIsLocked")) { [weak self] in self?.screenLocked = true }
observe(dnc, Notification.Name("com.apple.screenIsUnlocked")) { [weak self] in self?.screenLocked = false }
}
func stop() {
timer?.invalidate(); timer = nil
tokens.forEach { $0.center.removeObserver($0.token) }
tokens.removeAll()
}
deinit { stop() }
/// Register on `center`, hop to main, apply the mutation, then fire onChange.
private func observe(_ center: NotificationCenter, _ name: Notification.Name,
_ apply: @escaping () -> Void) {
let token = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
apply(); self?.onChange?()
}
tokens.append((center, token))
}
private func pollBattery() {
let b = PowerPolicy.isOnBattery()
guard b != onBattery else { return }
onBattery = b
onChange?()
}
/// True when running on battery. A desktop Mac (no battery source) → false.
static func isOnBattery() -> Bool {
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let list = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef] else {
return false
}
for source in list {
guard let desc = IOPSGetPowerSourceDescription(blob, source)?.takeUnretainedValue() as? [String: Any],
let state = desc[kIOPSPowerSourceStateKey] as? String else { continue }
if state == kIOPSBatteryPowerValue { return true }
}
return false
}
}
Dann: git rm native/macos/KuroMetalApp/PowerMonitor.swift
- [ ] Step 2: main.swift umverdrahten
(a) Zeile 30: private var power: PowerMonitor? → private var power: PowerPolicy?
(b) In startWallpaper() (Zeilen 67–69) ersetzen:
let pm = PowerMonitor()
pm.onChange = { [weak self] _ in self?.applyPowerPolicy() }
pm.start(); power = pm
durch:
let pm = PowerPolicy()
pm.onChange = { [weak self] in self?.applyPowerPolicy() }
pm.start(); power = pm
(c) In buildWallpaperWindows() als erste Zeile (vor wallpaperViews.forEach):
wallpaperWindows.forEach { NotificationCenter.default.removeObserver(self,
name: NSWindow.didChangeOcclusionStateNotification, object: $0) }
und im Fenster-Loop nach win.orderFrontRegardless():
NotificationCenter.default.addObserver(self, selector: #selector(occlusionChanged),
name: NSWindow.didChangeOcclusionStateNotification, object: win)
(d) applyPowerPolicy() (Zeilen 109–119) komplett ersetzen — die Entscheidung
kommt jetzt aus der getesteten Core-Tabelle; hidden und frozen sind auf
View-Ebene identisch (Link pausiert, letzter Frame bleibt stehen):
private func applyPowerPolicy() {
guard let power else { return }
for (win, v) in zip(wallpaperWindows, wallpaperViews) {
var i = RenderPolicyInputs()
i.occluded = !win.occlusionState.contains(.visible)
i.screenLocked = power.screenLocked
i.screensAsleep = power.screensAsleep
i.onBattery = power.onBattery
i.lowPowerMode = power.lowPowerMode
i.thermalSerious = power.thermal == .serious
i.thermalCritical = power.thermal == .critical
i.animateOnBattery = AppSettings.wallpaperOnBattery
switch renderState(i) {
case .hidden, .frozen: v.setPaused(true)
case .animating(let fps): v.setPaused(false); v.setFrameCap(fps)
}
}
}
@objc private func occlusionChanged(_ note: Notification) { applyPowerPolicy() }
- [ ] Step 3: Bauen + Tests
Run: bash scripts/build-native-app.sh && bash scripts/run-native-tests.sh && bash scripts/smoke-test-native.sh
Expected: alles grün. (PowerPolicy liegt in der App-Schicht — die Logik dahinter ist die in Task 4 getestete Tabelle.)
- [ ] Step 4: Verhaltens-Smoke lokal
APP=native/macos/build/KuroMetalApp.app/Contents/MacOS/KuroMetalApp
"$APP" --wallpaper --scene terrain & PID=$!
sleep 10; ps -o %cpu= -p $PID # animiert: >0 %
# Jetzt manuell eine App in den Vollbild-Modus schalten (Desktop verdeckt), dann:
sleep 10; ps -o %cpu= -p $PID # occluded: → ~0.0
kill $PID
Expected: CPU fällt bei vollständiger Verdeckung auf ~0. (Vollständiger Testplan — Sperren, Deckel, Netzteil, Low Power — läuft als on-device-Abnahme durch Johannes, siehe Task 7.)
- [ ] Step 5: Messung + Commit
Run: bash scripts/measure-wallpaper.sh 60 → Log „nach Task 5".
git add native/macos/KuroMetalApp/PowerPolicy.swift native/macos/KuroMetalApp/main.swift
git rm --cached native/macos/KuroMetalApp/PowerMonitor.swift 2>/dev/null || true
git add -A native/macos/KuroMetalApp/
git commit -m "feat(native): wallpaper power policy — occlusion, lock, sleep, low-power, thermal
PowerMonitor → PowerPolicy: six signals feed the pure Core decision table;
per-window occlusion pauses the display link (0 wakeups when invisible).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 6: Render-Scale fürs Wallpaper (§3)¶
Files:
- Modify: native/macos/KuroMetalApp/AppSettings.swift (nach wallpaperOnBattery, Zeile 41)
- Modify: native/macos/KuroMetalApp/MetalHostView.swift (Feld + Setter + updateDrawableSize)
- Modify: native/macos/KuroMetalApp/main.swift (buildWallpaperWindows + neue Methode refreshWallpaperScale)
- Modify: native/macos/KuroMetalApp/ConfigWindowController.swift (Popup in „Anzeige & Automatik")
Interfaces:
- Consumes: MetalHostView aus Task 3.
- Produces: AppSettings.wallpaperRenderScale: Double (Default 0.66); MetalHostView.setRenderScale(_ s: CGFloat); AppDelegate.refreshWallpaperScale().
- [ ] Step 1: Setting ergänzen
In AppSettings.swift nach der wallpaperOnBattery-Zeile:
static var wallpaperRenderScale: Double { get { dbl("WallpaperScale", 0.66) } set { store.set(newValue, forKey: "WallpaperScale") } }
- [ ] Step 2: MetalHostView erweitern
Feld unter private var paused = false:
Setter unter setPaused:
/// Wallpaper: render at a fraction of the backing size; the CAMetalLayer
/// upscales to the window. The CRT look hides it, cost drops quadratically.
/// Fullscreen/preview never call this (stay at 1).
func setRenderScale(_ s: CGFloat) {
renderScale = min(1, max(0.25, s))
updateDrawableSize()
}
In updateDrawableSize() die Größenzeile ändern zu:
ml.drawableSize = CGSize(width: max(1, bounds.width * scale * renderScale),
height: max(1, bounds.height * scale * renderScale))
(ml.contentsScale = scale bleibt unverändert — die Layer-Geometrie bleibt voll, nur das Drawable schrumpft; CAMetalLayer streckt es aufs Layer.)
- [ ] Step 3: AppDelegate anwenden + Live-Refresh
In main.swift, buildWallpaperWindows(), nach der MetalHostView(...)-Erzeugung und vor win.contentView = view:
Neue Methode neben setWallpaperFromConfig():
/// Live-apply a changed wallpaper render scale from the config UI (no rebuild).
func refreshWallpaperScale() {
wallpaperViews.forEach { $0.setRenderScale(CGFloat(AppSettings.wallpaperRenderScale)) }
}
- [ ] Step 4: Config-UI-Popup
In ConfigWindowController.swift:
(a) Property-Zeile 7 ergänzen: wallScalePopup in die NSPopUpButton!-Liste aufnehmen.
(b) Daten neben termLayouts (Zeile 20):
private let wallScales: [(String, Double)] = [("Voll (100 %)", 1.0), ("75 %", 0.75), ("66 % – Standard", 0.66), ("50 %", 0.5)]
(c) In buildUI bei den anderen Popups (nach idlePopup, Zeile 124):
wallScalePopup = mkPopup(#selector(wallScaleChanged))
wallScalePopup.addItems(withTitles: wallScales.map { $0.0 })
wallScalePopup.selectItem(at: wallScales.firstIndex { $0.1 == AppSettings.wallpaperRenderScale } ?? 2)
wallScalePopup.toolTip = "Render-Auflösung des animierten Hintergrunds. Niedriger = sparsamer; die CRT-Optik kaschiert die Skalierung. Vollbild-Screensaver rendert immer voll."
(d) secBehavior-Grid (Zeile 161) erweitern:
grid([[lab("Wechsel:"), cyclePopup], [lab("Auto-Start:"), idlePopup],
[lab("Hintergrund:"), wallScalePopup]]), autostartCheck,
(e) Handler neben terminalChanged — eigene Action, denn ein Scale-Wechsel ist
kein Look-Eingriff (kein look = "custom") und braucht keinen Preview-Rebuild:
@objc private func wallScaleChanged() {
if wallScalePopup.indexOfSelectedItem >= 0 {
AppSettings.wallpaperRenderScale = wallScales[wallScalePopup.indexOfSelectedItem].1
}
(NSApp.delegate as? AppDelegate)?.refreshWallpaperScale()
}
- [ ] Step 5: Bauen + Funktionscheck + Messung
Run: bash scripts/build-native-app.sh && bash scripts/run-native-tests.sh && bash scripts/smoke-test-native.sh
Expected: grün. Dann:
APP=native/macos/build/KuroMetalApp.app/Contents/MacOS/KuroMetalApp
"$APP" --wallpaper --scene terrain & P=$!; sleep 12; ps -p $P && kill $P
bash scripts/measure-wallpaper.sh 60
Expected: Wallpaper läuft sichtbar; avg-CPU nochmals niedriger → Log „nach Task 6". Optik-Abnahme (0,66 vs. 1,0 auf dem XDR-Panel) macht Johannes on-device — nicht selbst entscheiden.
- [ ] Step 6: Commit
git add native/macos/KuroMetalApp/AppSettings.swift native/macos/KuroMetalApp/MetalHostView.swift native/macos/KuroMetalApp/main.swift native/macos/KuroMetalApp/ConfigWindowController.swift
git commit -m "feat(native): wallpaper render scale (default 0.66, config popup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Task 7: Abschlussmessung, Ergebnis-Tabelle, Übergabe an Johannes¶
Files:
- Modify: docs/specs/2026-07-14-wallpaper-efficiency-design.md (Ergebnis-Abschnitt anhängen)
Interfaces: —
- [ ] Step 1: Messreihe konsolidieren
Aus .claude/logs/2026-07-15-wallpaper-messungen.md die vier CPU-Messungen (Baseline, nach T2, T3, T6 — T5 optional, occlusionsfrei gemessen ändert sich dort wenig) als Tabelle an die Spec anhängen:
## Ergebnisse (gemessen 2026-07-15, M5 Pro, AC, terrain/toxic-haze, 60 s)
| Stand | CPU avg | CPU max | Bemerkung |
|---|---|---|---|
| Baseline (v0.7.1) | X % | X % | CVDisplayLink 120 Hz, waitUntilCompleted |
| + async GPU-Timing | X % | X % | |
| + CADisplayLink | X % | X % | |
| + Render-Scale 0,66 | X % | X % | |
GPU-Power/Wakeups (sudo powermetrics): steht aus — Kommando siehe
scripts/measure-wallpaper.sh, Ausführung Johannes.
(X durch echte Werte ersetzen — keine Platzhalter committen.)
- [ ] Step 2: Commit + on-device-Checkliste an Johannes
git add docs/specs/2026-07-14-wallpaper-efficiency-design.md
git commit -m "docs(specs): wallpaper efficiency measurement results
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"
Dann Johannes die offene on-device-Abnahme nennen (nicht selbst abhaken):
1. Optik Render-Scale 0,66 vs. 1,0 (Config-Popup „Hintergrund:").
2. Vollbild-App drüber / Space-Wechsel → Batterie-Menü: App verschwindet aus „Apps mit hohem Energieverbrauch".
3. Sperren/Entsperren, Deckel zu/auf → Wallpaper friert/kommt wieder.
4. Netzteil ziehen → Standbild (bzw. 10 fps bei „Auf Batterie animieren").
5. Low Power Mode an → wie Batterie.
6. Vollbild-Screensaver (Menü „Vollbild starten" + Idle-Agent) läuft flüssig, ←/→ funktioniert, jede Eingabe beendet.
7. sudo powermetrics-Lauf (Kommando am Ende von scripts/measure-wallpaper.sh).