Der App Store ist für Onyx kein Weg: er verlangt die Sandbox, und ohne die gäbe es keinen privilegierten Helfer, keinen IOKit-Zugriff, keine Audio-Taps — also weder Lüftersteuerung noch Ladelimit, Hardwaremesswerte oder Mixer. Das steht so in der Planung, war aber nie als Skript hinterlegt, das man ohne Zertifikatsantrag laufen lassen kann. Scripts/local-dmg.sh füllt die Lücke: Release-Build mit dem Entwicklerzertifikat, dieselben drei Prüfungen wie beim Verteilen — Signatur, App-Group, Kennung des Helfers — und ein DMG mit Verweis auf den Programme-Ordner. Der Unterschied zu release.sh ist nicht die Verpackung, sondern die Signatur. Auf dem Mac, auf dem das Zertifikat zuhause ist, läuft es; auf einem fremden meldet Gatekeeper einen unbekannten Entwickler. Für die Weitergabe bleibt es bei Developer ID und Notarisierung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
351 lines
15 KiB
Swift
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?
|
|
}
|