Auslösefläche: die Vorschau zeigt wieder einen Bildschirm

Dein Hintergrundbild liegt in ~/Desktop, und dorthin lässt macOS keine App
ohne Freigabe. NSWorkspace gibt den Pfad trotzdem heraus, NSImage gibt danach
still nil zurück — und die Vorschau blieb schwarz, ohne jeden Hinweis worauf.
Gemessen: isReadableFile meldet false, das Laden schlägt fehl.

Um eine Ordnerfreigabe zu bitten, nur um ein Vorschaubildchen zu malen, wäre
die falsche Frage. Also wird jetzt vorher geprüft, ob sich die Datei lesen
lässt, und wenn nicht, kommt ein Standardhintergrund von macOS —
/System/Library/Desktop Pictures, von neu nach alt durchgesehen, hier landet
es bei Sonoma. Die Bilder liegen auf jedem Mac und werden nur gelesen.

Geladen über CGImageSource statt NSImage: die Vorlagen sind 6016 Punkte im
Quadrat und zwanzig Megabyte schwer, gebraucht werden tausend Punkte. Und ein
Hintergrundbild kann mehrere Bilder enthalten, hell und dunkel im selben HEIC.

Das Dekodieren dauert gut hundert Millisekunden, deshalb läuft es neben dem
Zeichnen statt davor — sonst hätte das Fenster beim Öffnen so lange gestanden.
Danach liegt das Bild im Cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 23:30:43 +02:00
parent 2aa60eedf9
commit 6c92e7fed6
4 changed files with 2606 additions and 2462 deletions

View File

@@ -1,4 +1,5 @@
import SwiftUI
import ImageIO
import OnyxDesign
/// Einstellung der Auslösefläche mit Vorschau.
@@ -60,13 +61,15 @@ public struct TriggerAreaSettings: View {
/// Der nachgebildete Bildschirm.
///
/// Mit dem echten Schreibtischhintergrund: Apples Bilder lassen sich nicht
/// mitliefern Lizenz, und ein Tahoe-Hintergrund wiegt zweistellige Megabyte.
/// Der eigene ist ohnehin der bessere Anker, weil man genau ihn wiedererkennt.
/// Fehlt er, bleibt ein Verlauf in Onyx-Farben.
/// 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
@@ -84,7 +87,7 @@ private struct TriggerPreview: View {
let areaHeight = min(notchHeight + trigger.extraHeight * scale, height)
ZStack(alignment: .top) {
wallpaper
background
// Die Menüleiste als heller Streifen: sie erklärt, warum die
// Fläche nicht beliebig breit werden sollte.
@@ -117,11 +120,30 @@ private struct TriggerPreview: View {
.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 loaded = await Task.detached(priority: .userInitiated) {
Wallpaper(image: own.flatMap { Self.thumbnail(of: $0) } ?? Self.systemWallpaper())
}.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 wallpaper: some View {
if let image = Self.desktopImage {
private var background: some View {
if let image = wallpaper {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
@@ -131,14 +153,84 @@ private struct TriggerPreview: View {
}
}
/// Der Schreibtischhintergrund des Hauptbildschirms.
/// Der eigene Schreibtischhintergrund wenn er sich denn lesen lässt.
///
/// Einmal geladen und behalten: es ist eine Datei von mehreren Megabyte,
/// und die Vorschau zeichnet sich bei jedem Zug am Regler neu.
private static let desktopImage: NSImage? = {
/// Die Prüfung ist der eigentliche Punkt: liegt das Bild in Schreibtisch,
/// Dokumente oder Downloads, verweigert macOS den Zugriff, bis der Nutzer
/// den Ordner freigibt. `NSWorkspace` liefert den Pfad trotzdem, und
/// `NSImage` gibt dann still `nil` zurück die Vorschau blieb schwarz, und
/// zwar ohne jeden Hinweis, warum. Genau dieser Fall lag hier vor.
///
/// Um eine Ordnerfreigabe zu bitten, nur um ein Vorschaubildchen zu malen,
/// wäre die falsche Frage. Also: lesbar oder nicht, und sonst weiter.
@MainActor
private static func ownWallpaperURL() -> URL? {
guard let screen = NSScreen.main,
let url = NSWorkspace.shared.desktopImageURL(for: screen)
let url = NSWorkspace.shared.desktopImageURL(for: screen),
FileManager.default.isReadableFile(atPath: url.path)
else { return nil }
return NSImage(contentsOf: url)
}()
return url
}
/// Ein Standardhintergrund von macOS.
///
/// Die Vorschaubilder aus `.thumbnails` statt der Originale: dieselben
/// Motive in ein paar Dutzend Kilobyte statt in zwanzig Megabyte. 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.
private static func systemWallpaper() -> NSImage? {
let directory = "/System/Library/Desktop Pictures"
let preferred = ["Tahoe", "Sequoia", "Sonoma", "Ventura", "Monterey",
"Big Sur Mountains", "Catalina Coast"]
let thumbnails = directory + "/.thumbnails"
let available = (try? FileManager.default.contentsOfDirectory(atPath: thumbnails)) ?? []
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: thumbnails + "/" + match)) {
return image
}
}
// Nichts Bekanntes dabei: dann das erste Bild, das sich lesen lässt.
for name in available.sorted() where name.hasSuffix(".heic") {
if let image = thumbnail(of: URL(fileURLWithPath: thumbnails + "/" + 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.
private 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?
}