diff --git a/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift index 5ecace8..e148a2e 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift @@ -139,8 +139,9 @@ private struct TriggerPreview: View { // 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)) + Wallpaper(image: WallpaperLoader.load(own: own, display: display)) }.value Self.cache = loaded wallpaper = loaded.image @@ -178,6 +179,14 @@ private struct TriggerPreview: View { 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? + } + } @@ -190,8 +199,82 @@ private struct TriggerPreview: View { /// Prüfung die App angehalten. Hier draußen gibt es die Bindung nicht. enum WallpaperLoader { - static func load(own: URL?) -> NSImage? { - own.flatMap(thumbnail(of:)) ?? systemWallpaper() + 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. diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/WallpaperIndexTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/WallpaperIndexTests.swift new file mode 100644 index 0000000..334a498 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/WallpaperIndexTests.swift @@ -0,0 +1,77 @@ +import Testing +import Foundation +@testable import OnyxNotch + +@Suite("Hintergrundbild aus macOS' Verwaltung") +struct WallpaperIndexTests { + + /// Baut einen Eintrag, wie ihn macOS ablegt. + private func entry(_ path: String) -> [String: Any] { + ["type": "imageFile", "url": ["relative": "file://" + path]] + } + + private func nested(_ value: Any) throws -> Data { + try PropertyListSerialization.data(fromPropertyList: value, format: .binary, options: 0) + } + + @Test("Ein Bild in der äußeren Liste wird gefunden") + func findsPlainEntry() { + var found: [URL] = [] + WallpaperLoader.collectImageFiles(["Desktop": entry("/tmp/a.jpg")], into: &found) + #expect(found.map(\.path) == ["/tmp/a.jpg"]) + } + + @Test("Ein Bild in einer verschachtelten Liste wird auch gefunden") + func findsNestedEntry() throws { + // Genau daran hängt es: die Konfiguration je Bildschirm steckt als + // eigene Property-Liste in einem Datenfeld der äußeren. + var found: [URL] = [] + let inner = try nested(entry("/tmp/b.jpg")) + WallpaperLoader.collectImageFiles( + ["Displays": ["ABC": ["Desktop": ["Configuration": inner]]]], into: &found) + #expect(found.map(\.path) == ["/tmp/b.jpg"]) + } + + @Test("Mehrere Bildschirme ergeben mehrere Bilder") + func findsAllScreens() throws { + var found: [URL] = [] + WallpaperLoader.collectImageFiles( + ["Displays": ["A": try nested(entry("/tmp/a.jpg")), + "B": try nested(entry("/tmp/b.jpg"))]], into: &found) + #expect(Set(found.map(\.path)) == ["/tmp/a.jpg", "/tmp/b.jpg"]) + } + + @Test("Einträge ohne Datei bleiben liegen") + func ignoresNonFileEntries() { + // Fotos und Aerials stehen als „asset" mit einer Kennung darin — daraus + // lässt sich keine Datei machen, und ein Eintrag ohne Datei darf nicht + // als Bild durchgehen. + var found: [URL] = [] + WallpaperLoader.collectImageFiles( + ["Idle": ["type": "asset", "identifier": "3316307E-788F"], + "Web": ["type": "imageFile", "url": ["relative": "https://example.com/a.jpg"]]], + into: &found) + #expect(found.isEmpty) + } + + @Test("Nur der eigene Bildschirm zählt") + func onlyOwnScreenCounts() throws { + // In der Datei stehen auch alte Bildschirme und der Bildschirmschoner. + // Das Neueste von allem zu nehmen führt zuverlässig zu einem Bild, das + // mit dem Schreibtisch nichts zu tun hat — gemessen war das hier ein + // weißes Platzhalterbild aus einem Kurzbefehl. + var found: [URL] = [] + let entry: [String: Any] = ["Desktop": ["Content": ["Choices": [ + ["Configuration": try nested(entry("/tmp/richtig.jpg"))]]]]] + WallpaperLoader.collectImageFiles(entry["Desktop"] as Any, into: &found) + #expect(found.map(\.path) == ["/tmp/richtig.jpg"]) + } + + @Test("Unbrauchbare Daten stürzen nicht ab") + func survivesGarbage() { + var found: [URL] = [] + WallpaperLoader.collectImageFiles( + ["A": Data("bplist00 aber kaputt".utf8), "B": 42, "C": Date()], into: &found) + #expect(found.isEmpty) + } +}