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) { 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? }