Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Scarriffle c6f0a5a848 Vorschau liest macOS' eigene Hintergrundverwaltung
NSWorkspace.desktopImageURL ist die alte Schnittstelle: sie meldet das letzte
Bild, das als Datei gesetzt wurde — auch dann noch, wenn es längst gelöscht
ist. Genau das war hier der Fall, der Pfad zeigt auf eine Datei, die es nicht
mehr gibt. Seit macOS 14 führt das System die Auswahl in
com.apple.wallpaper/Store/Index.plist, je Bildschirm und je Schreibtisch, und
Anbieter wie Fotos hinterlegen dort ihre Zwischenspeicher.

Die Datei ist doppelt verpackt: die Konfiguration je Bildschirm steckt als
eigene Property-Liste in einem Datenfeld der äußeren. Ohne den zweiten
Durchgang findet man gar nichts.

Gelesen wird nur der Eintrag dieses Bildschirms, nachgeschlagen über die
Display-Kennung. In der Datei stehen auch alte Bildschirme, der
Bildschirmschoner und alles, was einmal gesetzt war — das Neueste von allem zu
nehmen führt zuverlässig zu einem Bild, das mit dem Schreibtisch nichts zu tun
hat. Ausprobiert: dabei kam ein weißes Platzhalterbild aus einem Kurzbefehl
heraus. Fehlt der Eintrag oder die Datei, ist ein Systembild die ehrlichere
Antwort als ein Rateergebnis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 00:08:44 +02:00

351 lines
15 KiB
Swift

import SwiftUI
import ImageIO
import OnyxDesign
/// Einstellung der Auslösefläche mit Vorschau.
///
/// Zwei Zahlen in Punkten sagen niemandem etwas: 24 Punkte unter der Notch"
/// ist keine Vorstellung, die man im Kopf hat. Ein nachgebildeter Bildschirm
/// mit der Notch und der eingefärbten Fläche darüber schon man sieht sofort,
/// wie weit man greifen muss.
public struct TriggerAreaSettings: View {
@Binding private var trigger: TriggerArea
public init(trigger: Binding<TriggerArea>) {
self._trigger = trigger
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("trigger.hint", bundle: .module)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
TriggerPreview(trigger: trigger)
.frame(height: 150)
.frame(maxWidth: .infinity)
LabeledContent {
Slider(value: Binding(
get: { trigger.extraWidth },
set: { trigger = TriggerArea(extraWidth: $0,
extraHeight: trigger.extraHeight) }),
in: 0...TriggerArea.maximumExtraWidth)
} label: {
Text("trigger.width \(Int(trigger.extraWidth))", bundle: .module)
.monospacedDigit()
}
LabeledContent {
Slider(value: Binding(
get: { trigger.extraHeight },
set: { trigger = TriggerArea(extraWidth: trigger.extraWidth,
extraHeight: $0) }),
in: 0...TriggerArea.maximumExtraHeight)
} label: {
Text("trigger.height \(Int(trigger.extraHeight))", bundle: .module)
.monospacedDigit()
}
Button {
trigger = .standard
} label: {
Text("trigger.reset", bundle: .module)
}
.buttonStyle(.borderless)
}
}
}
/// Der nachgebildete Bildschirm.
///
/// Mit einem echten Hintergrundbild, damit man auf einen Blick sieht, dass hier
/// ein Mac-Bildschirm gemeint ist und nicht irgendein Rechteck. Zuerst der
/// eigene Schreibtischhintergrund den erkennt man am schnellsten wieder ,
/// sonst einer von macOS.
private struct TriggerPreview: View {
let trigger: TriggerArea
@State private var wallpaper: NSImage?
/// Maße eines MacBook Pro mit Notch, auf die Vorschau heruntergerechnet.
private let screenAspect: CGFloat = 1800 / 1169
private let notchWidthFraction: CGFloat = 220 / 1800
private let notchHeightFraction: CGFloat = 38 / 1169
var body: some View {
GeometryReader { geometry in
let height = min(geometry.size.height, geometry.size.width / screenAspect)
let width = height * screenAspect
let scale = width / 1800
let notchWidth = width * notchWidthFraction
let notchHeight = height * notchHeightFraction
let areaWidth = min(notchWidth + 2 * trigger.extraWidth * scale, width)
let areaHeight = min(notchHeight + trigger.extraHeight * scale, height)
ZStack(alignment: .top) {
// Auf Maß gebracht, bevor es in den Stapel kommt: ein
// skalierbares Bild meldet seine **eigene** Größe als
// Wunschgröße, und die tausend Bildpunkte der Vorlage haben den
// Stapel auf tausend Punkte aufgeblasen. Notch, Menüleiste und
// Auslösefläche saßen dann am Rand dieser unsichtbaren Fläche
// und lagen außerhalb des Ausschnitts das Bild war da, alles
// andere weg.
background
.frame(width: width, height: height)
.clipped()
// Die Menüleiste als heller Streifen: sie erklärt, warum die
// Fläche nicht beliebig breit werden sollte.
Rectangle()
.fill(.black.opacity(0.35))
.frame(height: notchHeight)
.frame(maxWidth: .infinity, alignment: .top)
// Die Auslösefläche.
Rectangle()
.fill(Onyx.Color.accent.opacity(0.35))
.overlay { Rectangle().strokeBorder(Onyx.Color.accent, lineWidth: 1) }
.frame(width: areaWidth, height: areaHeight)
// Die Notch selbst undurchsichtig, wie in echt.
UnevenRoundedRectangle(bottomLeadingRadius: 5, bottomTrailingRadius: 5,
style: .continuous)
.fill(.black)
.frame(width: notchWidth, height: notchHeight)
}
.frame(width: width, height: height)
// Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und
// Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus.
.clipShape(.rect(cornerRadius: 8, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.animation(Onyx.Motion.value, value: areaWidth)
.animation(Onyx.Motion.value, value: areaHeight)
}
// Nicht beim Zeichnen laden: das Dekodieren dauert gut hundert
// Millisekunden, und die hätte das Fenster beim Öffnen gestanden.
.task { await loadWallpaper() }
}
private func loadWallpaper() async {
if let cached = Self.cache { wallpaper = cached.image; return }
// Den Pfad auf dem Hauptthread bestimmen `NSScreen` und
// `NSWorkspace` gehören dorthin , das Bild daneben.
let own = Self.ownWallpaperURL()
let display = Self.mainDisplayUUID()
let loaded = await Task.detached(priority: .userInitiated) {
Wallpaper(image: WallpaperLoader.load(own: own, display: display))
}.value
Self.cache = loaded
wallpaper = loaded.image
}
/// Einmal geladen und behalten: die Vorschau zeichnet sich bei jedem Zug am
/// Regler neu.
@MainActor private static var cache: Wallpaper?
@ViewBuilder
private var background: some View {
if let image = wallpaper {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
LinearGradient(colors: [Onyx.Color.elevated, Onyx.Color.surface],
startPoint: .top, endPoint: .bottom)
}
}
/// Der Pfad des eigenen Schreibtischhintergrunds.
///
/// Liegt er in Schreibtisch, Dokumente oder Downloads, fragt macOS beim
/// ersten Lesen einmal nach deshalb wird hier nicht vorab geprüft,
/// sondern gleich gelesen. Eine Prüfung mit `isReadableFile` beantwortet
/// macOS in diesen Ordnern mit nein", ohne zu fragen; die Vorschau blieb
/// damit dauerhaft beim Ersatzbild, obwohl eine Freigabe möglich gewesen
/// wäre.
///
/// Wird die Freigabe verweigert, kommt ein Standardhintergrund von macOS.
@MainActor
private static func ownWallpaperURL() -> URL? {
guard let screen = NSScreen.main else { return nil }
return NSWorkspace.shared.desktopImageURL(for: screen)
}
/// Die Kennung des Hauptbildschirms unter ihr steht sein Hintergrundbild
/// in macOS' Verwaltung.
@MainActor
private static func mainDisplayUUID() -> String? {
guard let uuid = CGDisplayCreateUUIDFromDisplayID(CGMainDisplayID()) else { return nil }
return CFUUIDCreateString(nil, uuid.takeRetainedValue()) as String?
}
}
/// Lädt das Hintergrundbild der Vorschau.
///
/// **Ein eigener Typ, kein Teil der View.** Was in einer `View` steht, ist an
/// den Hauptthread gebunden die Konformität vererbt das, auch an statische
/// Methoden, die damit gar nichts zu tun haben. Aufgerufen wurde es trotzdem
/// nebenher; der Compiler hat das nur angemahnt, und zur Laufzeit hat die
/// Prüfung die App angehalten. Hier draußen gibt es die Bindung nicht.
enum WallpaperLoader {
static func load(own: URL?, display: String?) -> NSImage? {
// Der Reihe nach: was macOS als Pfad herausgibt, dann der Eintrag
// dieses Bildschirms in der Hintergrundverwaltung, dann ein Systembild.
if let own, let image = thumbnail(of: own) { return image }
if let indexed = indexedWallpaperURL(display: display),
let image = thumbnail(of: indexed) { return image }
return systemWallpaper()
}
/// Das Bild, das macOS für **diesen** Bildschirm verzeichnet hat.
///
/// `NSWorkspace.desktopImageURL` ist die alte Schnittstelle und meldet nur
/// das letzte Bild, das als Datei gesetzt wurde auch dann noch, wenn es
/// längst gelöscht ist. Seit macOS 14 führt das System die Auswahl
/// stattdessen in `com.apple.wallpaper/Store/Index.plist`, je Bildschirm und
/// je Schreibtisch.
///
/// Gelesen wird **nur** der Eintrag dieses Bildschirms. In der Datei stehen
/// auch alte Bildschirme, der Bildschirmschoner und alles, was einmal
/// gesetzt war; das Neueste davon zu nehmen führt zuverlässig zu einem
/// Bild, das mit dem Schreibtisch nichts zu tun hat. Fehlt der Eintrag oder
/// die Datei, ist ein Systembild die ehrlichere Antwort als ein Rateergebnis.
static func indexedWallpaperURL(display: String?) -> URL? {
let index = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist")
guard let data = try? Data(contentsOf: index),
let root = try? PropertyListSerialization.propertyList(from: data,
options: [], format: nil)
as? [String: Any]
else { return nil }
// Der eigene Bildschirm zuerst, sonst die Vorgabe für alle.
let displays = root["Displays"] as? [String: Any]
let candidates = [display.flatMap { displays?[$0] }, root["SystemDefault"]]
for candidate in candidates.compactMap({ $0 }) {
guard let entry = candidate as? [String: Any],
let desktop = entry["Desktop"] else { continue }
var found: [URL] = []
collectImageFiles(desktop, into: &found)
if let url = found.first(where: { FileManager.default.fileExists(atPath: $0.path) }) {
return url
}
}
return nil
}
/// Sammelt alle Bilddateien aus der verschachtelten Liste.
///
/// Verschachtelt heißt hier wörtlich: die Konfiguration je Bildschirm steckt
/// als **eigene** Property-Liste in einem Datenfeld der äußeren. Ohne den
/// zweiten Durchgang findet man gar nichts.
static func collectImageFiles(_ node: Any, into found: inout [URL]) {
switch node {
case let dictionary as [String: Any]:
if dictionary["type"] as? String == "imageFile",
let url = dictionary["url"] as? [String: Any],
let text = url["relative"] as? String,
let parsed = URL(string: text), parsed.isFileURL {
found.append(parsed)
}
for value in dictionary.values { collectImageFiles(value, into: &found) }
case let array as [Any]:
for value in array { collectImageFiles(value, into: &found) }
case let data as Data:
guard data.starts(with: Array("bplist00".utf8)),
let inner = try? PropertyListSerialization.propertyList(from: data,
options: [], format: nil)
else { return }
collectImageFiles(inner, into: &found)
default:
break
}
}
/// Ein Standardhintergrund von macOS.
///
/// Sie liegen auf jedem Mac und werden nur gelesen, nicht mitgeliefert.
///
/// Die Reihenfolge geht von neu nach alt und endet bei irgendein Foto":
/// welche Bilder installiert sind, hängt an der macOS-Version.
static func systemWallpaper() -> NSImage? {
let directory = "/System/Library/Desktop Pictures"
let preferred = ["Tahoe", "Sequoia", "Sonoma", "Ventura", "Monterey",
"Big Sur Mountains", "Catalina Coast"]
// Zuerst die Originale, dann die kleinen Bilder aus `.thumbnails`.
//
// Die Originale sind zwanzig Megabyte groß, werden aber nie ganz
// gelesen `CGImageSource` dekodiert gleich in der gebrauchten Größe.
// Die Vorschaubilder messen 214 Punkte in der Breite und wären auf
// einem Retina-Schirm sichtbar unscharf; sie sind die Reserve für die
// Motive, die macOS erst bei Bedarf nachlädt.
for folder in [directory, directory + "/.thumbnails"] {
let available = (try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? []
for name in preferred {
// Sonoma.heic" vor Sonoma Dark.heic": das schlichte Motiv ist
// das bekannte, die Varianten sind Abwandlungen davon.
let matches = available
.filter { $0.hasPrefix(name) && $0.hasSuffix(".heic") }
.sorted { $0.count < $1.count }
if let match = matches.first,
let image = thumbnail(of: URL(fileURLWithPath: folder + "/" + match)) {
return image
}
}
}
// Nichts Bekanntes dabei: dann das erste Bild, das sich lesen lässt.
for folder in [directory, directory + "/.thumbnails"] {
let available = ((try? FileManager.default.contentsOfDirectory(atPath: folder)) ?? [])
.filter { $0.hasSuffix(".heic") }.sorted()
for name in available {
if let image = thumbnail(of: URL(fileURLWithPath: folder + "/" + name)) {
return image
}
}
}
return nil
}
/// Lädt ein verkleinertes Bild.
///
/// Über `CGImageSource` statt `NSImage(contentsOf:)`: die Vorschau ist ein
/// paar hundert Bildpunkte breit, das Original hat sechstausend. Und ein
/// Hintergrundbild kann mehrere Bilder enthalten hell und dunkel im
/// selben HEIC , von denen hier nur das erste gebraucht wird.
static func thumbnail(of url: URL) -> NSImage? {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: 1000,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
else { return nil }
return NSImage(cgImage: image, size: .init(width: image.width, height: image.height))
}
}
/// `NSImage` über eine Aufgabengrenze zu reichen ist nicht von Haus aus
/// zugesichert. Hier ist es unbedenklich: das Bild wird einmal erzeugt und
/// danach nur noch gelesen.
private struct Wallpaper: @unchecked Sendable {
let image: NSImage?
}