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>
This commit is contained in:
@@ -139,8 +139,9 @@ private struct TriggerPreview: View {
|
|||||||
// Den Pfad auf dem Hauptthread bestimmen — `NSScreen` und
|
// Den Pfad auf dem Hauptthread bestimmen — `NSScreen` und
|
||||||
// `NSWorkspace` gehören dorthin —, das Bild daneben.
|
// `NSWorkspace` gehören dorthin —, das Bild daneben.
|
||||||
let own = Self.ownWallpaperURL()
|
let own = Self.ownWallpaperURL()
|
||||||
|
let display = Self.mainDisplayUUID()
|
||||||
let loaded = await Task.detached(priority: .userInitiated) {
|
let loaded = await Task.detached(priority: .userInitiated) {
|
||||||
Wallpaper(image: WallpaperLoader.load(own: own))
|
Wallpaper(image: WallpaperLoader.load(own: own, display: display))
|
||||||
}.value
|
}.value
|
||||||
Self.cache = loaded
|
Self.cache = loaded
|
||||||
wallpaper = loaded.image
|
wallpaper = loaded.image
|
||||||
@@ -178,6 +179,14 @@ private struct TriggerPreview: View {
|
|||||||
return NSWorkspace.shared.desktopImageURL(for: screen)
|
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.
|
/// Prüfung die App angehalten. Hier draußen gibt es die Bindung nicht.
|
||||||
enum WallpaperLoader {
|
enum WallpaperLoader {
|
||||||
|
|
||||||
static func load(own: URL?) -> NSImage? {
|
static func load(own: URL?, display: String?) -> NSImage? {
|
||||||
own.flatMap(thumbnail(of:)) ?? systemWallpaper()
|
// 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.
|
/// Ein Standardhintergrund von macOS.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user