Files
onyx/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift
Scarriffle d90352a86b DMG für den eigenen Rechner
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>
2026-08-14 12:52:17 +02:00

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