Live Activities unter der Notch, nicht daneben
Neben ihr wären die Arme zwangsläufig gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware. Ein Symbol links und ein Balken rechts hieße dann 132 Punkte Schwarz für ein Symbol. Darunter hängt die Fläche frei und wird so breit, wie ihr Inhalt es braucht. Lautstärke, Strom, Titelwechsel und Helligkeit. Gemeldet wird nur, was sich **geändert** hat: `PowerWatcher` schweigt beim ersten gelesenen Zustand, sonst begrüßte Onyx jeden Start mit „am Netz". Die Helligkeit kostete einen Spike. Von drei Wegen trägt nur `DisplayServices`, und nur für den eingebauten Bildschirm. `CoreDisplay` — der Weg, den jede Anleitung empfiehlt — gibt für alle Bildschirme 1.0 zurück, auch für den, der auf 62 % stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c05ccb8ae1
commit
9b600de9a9
@@ -0,0 +1,315 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxNotch
|
||||
import ActivityProvider
|
||||
import MetricsProvider
|
||||
import WeatherProvider
|
||||
|
||||
/// Was links und rechts der Notch steht, solange das Panel offen ist.
|
||||
///
|
||||
/// **Warum dort und nicht in einer Kachel.** Der Streifen auf Menüleistenhöhe
|
||||
/// war bisher durchsichtiges, ungenutztes Fenster — bei fünf Kacheln je 376
|
||||
/// Punkte. Eine Kachel dafür zu opfern hieße, ein Zwölftel des Panels für zwei
|
||||
/// Zahlen herzugeben. Hier kosten sie nichts.
|
||||
///
|
||||
/// **Zwei Werte, nicht zehn.** Der Streifen ist so hoch wie die Notch, also
|
||||
/// rund 38 Punkte. Was hier steht, muss im Vorbeisehen lesbar sein; eine Reihe
|
||||
/// aus sechs Kürzeln wäre eine zweite Menüleiste, und eine hat man schon.
|
||||
struct WeatherAccessory: View {
|
||||
|
||||
let model: WeatherModel
|
||||
|
||||
var body: some View {
|
||||
if case .ready(let snapshot) = model.state {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
Text(snapshot.temperature.onyxFormatted)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
}
|
||||
// Abstand zur Bildschirmkante links, und Luft zur Notch rechts:
|
||||
// ein Wert, der die schwarze Hardware berührt, sieht aus, als wäre
|
||||
// er abgeschnitten.
|
||||
.padding(.leading, Onyx.Space.xl)
|
||||
.padding(.trailing, Onyx.Space.lg)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct BatteryAccessory: View {
|
||||
|
||||
let model: MetricsModel
|
||||
|
||||
var body: some View {
|
||||
if let battery = model.snapshot.battery {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("\(Int((battery.charge * 100).rounded())) %")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
BatteryGlyphView(charge: battery.charge,
|
||||
isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
height: 13)
|
||||
}
|
||||
.padding(.leading, Onyx.Space.lg)
|
||||
.padding(.trailing, Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Live Activities
|
||||
|
||||
/// Was beim Regeln der Lautstärke unter der Notch steht.
|
||||
///
|
||||
/// Kurz und ohne Anspruch: 1,6 Sekunden. Länger stünde sie noch da, wenn man
|
||||
/// längst weiterarbeitet — und eine Anzeige, die nach der Handlung übrig
|
||||
/// bleibt, liest man als Fehler.
|
||||
struct VolumeActivity: NotchActivity {
|
||||
|
||||
let id = "volume"
|
||||
let rank = ActivityRank.response
|
||||
let duration: TimeInterval? = 1.6
|
||||
|
||||
let level: Double
|
||||
let isMuted: Bool
|
||||
|
||||
/// Symbol, Schiene, Zahl — in einer Zeile unter der Notch.
|
||||
var size: CGSize { CGSize(width: 196, height: 34) }
|
||||
|
||||
private var symbol: String {
|
||||
if isMuted { return "speaker.slash.fill" }
|
||||
switch level {
|
||||
case ..<0.001: return "speaker.fill"
|
||||
case ..<0.34: return "speaker.wave.1.fill"
|
||||
case ..<0.67: return "speaker.wave.2.fill"
|
||||
default: return "speaker.wave.3.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
let track: CGFloat = 96
|
||||
let fraction = isMuted ? 0 : min(max(level, 0), 1)
|
||||
return AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.frame(width: 16)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.track)
|
||||
.frame(width: track, height: 5)
|
||||
Capsule().fill(Onyx.Color.textPrimary)
|
||||
.frame(width: track * fraction, height: 5)
|
||||
}
|
||||
|
||||
Text("\(Int((min(max(level, 0), 1) * 100).rounded()))")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
// Feste Breite: sonst wandert die Schiene, wenn aus 9 %
|
||||
// eine 10 wird.
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Arm des Bandes — nur ausgefahren, und nur das Zubehör.
|
||||
///
|
||||
/// Die Live Activities hängen unter der Notch und nicht hier: neben ihr müssten
|
||||
/// beide Arme gleich breit sein, und ein Symbol links neben einem Balken rechts
|
||||
/// hieße über hundert Punkte Schwarz für das Symbol.
|
||||
struct NotchBandSlot<Content: View>: View {
|
||||
|
||||
let presentation: NotchPresentation
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
var body: some View {
|
||||
if presentation.isExpanded { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Live Activity unter der Notch.
|
||||
///
|
||||
/// Als eigene View und nicht als Verzweigung im Aufrufer: der Presenter ist
|
||||
/// beobachtbar, und nur innerhalb eines `body` merkt SwiftUI die Abhängigkeit.
|
||||
struct NotchAmbientSlot: View {
|
||||
|
||||
let presenter: ActivityPresenter
|
||||
|
||||
var body: some View {
|
||||
if let activity = presenter.current {
|
||||
activity.makeContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Die Helligkeit des eingebauten Bildschirms.
|
||||
///
|
||||
/// Dieselbe Form wie die Lautstärke — Symbol, Schiene, Zahl. Das ist kein
|
||||
/// Sparen an Gestaltung, sondern die Aussage: es ist dasselbe Ding. Zwei
|
||||
/// verschiedene Darstellungen für „ein Regler hat sich bewegt" wären eine
|
||||
/// Behauptung, dass es zwei verschiedene Vorgänge sind.
|
||||
struct BrightnessActivity: NotchActivity {
|
||||
|
||||
let id = "brightness"
|
||||
let rank = ActivityRank.response
|
||||
let duration: TimeInterval? = 1.6
|
||||
|
||||
let level: Double
|
||||
|
||||
var size: CGSize { CGSize(width: 196, height: 34) }
|
||||
|
||||
private var symbol: String {
|
||||
// Die Sonne füllt sich mit — dasselbe Symbol, das macOS benutzt.
|
||||
level < 0.5 ? "sun.min.fill" : "sun.max.fill"
|
||||
}
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
let track: CGFloat = 96
|
||||
let fraction = min(max(level, 0), 1)
|
||||
return AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.frame(width: 16)
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Onyx.Color.track)
|
||||
.frame(width: track, height: 5)
|
||||
Capsule().fill(Onyx.Color.textPrimary)
|
||||
.frame(width: track * fraction, height: 5)
|
||||
}
|
||||
|
||||
Text("\(Int((fraction * 100).rounded()))")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was im Band stehen darf
|
||||
|
||||
/// Die Elemente, die links und rechts der Notch sitzen können.
|
||||
///
|
||||
/// **Eine Wahl je Seite, keine Liste.** Der Arm ist bei fünf Kacheln rund 200
|
||||
/// Punkte breit und liegt auf Menüleistenhöhe — dort passt ein Wert, nicht drei.
|
||||
/// Eine Mehrfachauswahl anzubieten hieße, ein Gedränge zu ermöglichen, das man
|
||||
/// hinterher wieder wegräumen muss.
|
||||
enum BandAccessory: String, Codable, CaseIterable, Sendable {
|
||||
case none
|
||||
case clock
|
||||
case weather
|
||||
case battery
|
||||
case cpu
|
||||
case memory
|
||||
case temperature
|
||||
|
||||
var localizedName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .none: "band.none"
|
||||
case .clock: "band.clock"
|
||||
case .weather: "band.weather"
|
||||
case .battery: "band.battery"
|
||||
case .cpu: "band.cpu"
|
||||
case .memory: "band.memory"
|
||||
case .temperature: "band.temperature"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein Bandplatz, der zeigt, was dafür eingestellt ist.
|
||||
///
|
||||
/// Liest die Einstellung **im Rumpf** und nicht beim Erzeugen: der Panelinhalt
|
||||
/// wird je Bildschirm einmal gebaut, und ein beim Bauen gelesener Wert bliebe
|
||||
/// für immer der, der beim Start galt.
|
||||
struct BandSlotView: View {
|
||||
|
||||
let model: AppModel
|
||||
let edge: HorizontalAlignment
|
||||
let weather: WeatherModel?
|
||||
let metrics: MetricsModel?
|
||||
|
||||
private var kind: BandAccessory {
|
||||
edge == .leading ? model.bandLeading : model.bandTrailing
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.padding(.leading, edge == .leading ? Onyx.Space.xl : Onyx.Space.lg)
|
||||
.padding(.trailing, edge == .leading ? Onyx.Space.lg : Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, alignment: edge == .leading ? .leading : .trailing)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch kind {
|
||||
case .none:
|
||||
EmptyView()
|
||||
case .clock:
|
||||
// Die eine Angabe, die keine Quelle braucht.
|
||||
TimelineView(.everyMinute) { context in
|
||||
value(context.date.formatted(date: .omitted, time: .shortened),
|
||||
symbol: nil)
|
||||
}
|
||||
case .weather:
|
||||
if let weather, case .ready(let snapshot) = weather.state {
|
||||
value(snapshot.temperature.onyxFormatted,
|
||||
symbol: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
|
||||
}
|
||||
case .battery:
|
||||
if let battery = metrics?.snapshot.battery {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
Text("\(Int((battery.charge * 100).rounded())) %")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
BatteryGlyphView(charge: battery.charge,
|
||||
isCharging: battery.isCharging,
|
||||
isPluggedIn: battery.isPluggedIn,
|
||||
height: 13)
|
||||
}
|
||||
}
|
||||
case .cpu:
|
||||
if let metrics {
|
||||
value(MetricFormat.percent(metrics.snapshot.cpu.total), symbol: "cpu")
|
||||
}
|
||||
case .memory:
|
||||
if let metrics {
|
||||
value(MetricFormat.percent(metrics.snapshot.memory.usedFraction),
|
||||
symbol: "memorychip")
|
||||
}
|
||||
case .temperature:
|
||||
// Der wärmste Fühler: einer von zwanzig auszuwählen wäre eine
|
||||
// Einstellung in einer Einstellung.
|
||||
if let hottest = metrics?.snapshot.sensors.map(\.celsius).max() {
|
||||
value("\(Int(hottest.rounded())) °C", symbol: "thermometer.medium")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol und Wert — dieselbe Form für alles, was hier stehen kann.
|
||||
///
|
||||
/// Damit ist die Seite austauschbar: was links passt, passt auch rechts.
|
||||
private func value(_ text: String, symbol: String?) -> some View {
|
||||
HStack(spacing: Onyx.Space.sm) {
|
||||
if let symbol {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
Text(text)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import SwiftUI
|
||||
import ActivityProvider
|
||||
import MetricsProvider
|
||||
import OnyxDesign
|
||||
|
||||
/// Was am Strom geschieht — Netzteil dran, ab, Limit erreicht, Akku fast leer.
|
||||
///
|
||||
/// **Nicht bei jedem Messwert, sondern bei jedem Wechsel.** Der Ladestand
|
||||
/// ändert sich ständig; eine Notch, die das begleitet, wäre eine Leuchtreklame.
|
||||
/// Angemeldet wird nur, wenn sich der *Zustand* ändert.
|
||||
struct PowerActivity: NotchActivity {
|
||||
|
||||
/// Was gemeldet wird.
|
||||
enum Kind: Equatable {
|
||||
case charging
|
||||
case plugged
|
||||
case unplugged
|
||||
case atLimit(Int)
|
||||
case low
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .charging: String(localized: "activity.power.charging")
|
||||
case .plugged: String(localized: "activity.power.plugged")
|
||||
case .unplugged: String(localized: "activity.power.unplugged")
|
||||
case .atLimit: String(localized: "activity.power.atLimit")
|
||||
case .low: String(localized: "activity.power.low")
|
||||
}
|
||||
}
|
||||
|
||||
/// „Fast leer" ist das eine, das man nicht verpassen darf.
|
||||
var rank: Int { self == .low ? ActivityRank.alert : ActivityRank.notice }
|
||||
|
||||
/// Eine Warnung darf länger stehen als eine Beiläufigkeit.
|
||||
var duration: TimeInterval { self == .low ? 4 : 2.2 }
|
||||
}
|
||||
|
||||
let id = "power"
|
||||
let kind: Kind
|
||||
let charge: Double
|
||||
let isCharging: Bool
|
||||
let isPluggedIn: Bool
|
||||
|
||||
var rank: Int { kind.rank }
|
||||
var duration: TimeInterval? { kind.duration }
|
||||
var size: CGSize { CGSize(width: 232, height: 34) }
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
BatteryGlyphView(charge: charge,
|
||||
isCharging: isCharging,
|
||||
isPluggedIn: isPluggedIn,
|
||||
height: 13)
|
||||
|
||||
Text(kind.text)
|
||||
.font(Onyx.Font.footnote)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer(minLength: Onyx.Space.sm)
|
||||
|
||||
Text("\(Int((min(max(charge, 0), 1) * 100).rounded())) %")
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(kind == .low ? Onyx.Color.critical
|
||||
: Onyx.Color.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Was gerade spielt — beim Wechsel des Titels.
|
||||
///
|
||||
/// Ohne Fortschritt und ohne Knöpfe: das steht im Panel. Hier geht es um die
|
||||
/// eine Frage, die man beim Wechsel hat — was ist das jetzt?
|
||||
struct MediaActivity: NotchActivity {
|
||||
|
||||
let id = "media"
|
||||
let rank = ActivityRank.notice
|
||||
let duration: TimeInterval? = 3
|
||||
var size: CGSize { CGSize(width: 264, height: 40) }
|
||||
|
||||
let title: String
|
||||
let artist: String
|
||||
let artwork: Data?
|
||||
|
||||
func makeContent() -> AnyView {
|
||||
AnyView(
|
||||
HStack(spacing: Onyx.Space.lg) {
|
||||
Group {
|
||||
if let artwork, let image = NSImage(data: artwork) {
|
||||
Image(nsImage: image).resizable()
|
||||
} else {
|
||||
// Kein Cover ist kein Fehler — Radiostreams haben keins.
|
||||
Onyx.Color.elevated
|
||||
.overlay {
|
||||
Image(systemName: "music.note")
|
||||
.font(Onyx.Font.micro)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 26, height: 26)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.chip, style: .continuous))
|
||||
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
Text(title)
|
||||
.font(Onyx.Font.micro)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
if !artist.isEmpty {
|
||||
Text(artist)
|
||||
.font(Onyx.Font.micro)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, Onyx.Space.xl)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import SwiftUI
|
||||
import OnyxDesign
|
||||
import OnyxNotch
|
||||
|
||||
/// Führt die Live Activities und sagt der Notch, was gerade gilt.
|
||||
///
|
||||
/// Die Entscheidung selbst trifft `ActivityQueue` — hier steht nur, was mit
|
||||
/// Zeit und Bildschirm zu tun hat: den Wecker für die nächste ablaufende
|
||||
/// stellen und den Controllern melden, ob überhaupt etwas anliegt.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class ActivityPresenter {
|
||||
|
||||
/// Die Activity, die gerade gezeichnet wird.
|
||||
///
|
||||
/// Bleibt nach dem Ende noch stehen, solange die Fläche einfährt — sonst
|
||||
/// wäre die View im selben Bild weg, in dem sich die Notch zurückziehen
|
||||
/// soll, und es gäbe nichts mehr zu sehen. Was **gilt**, sagt
|
||||
/// `isPresenting`.
|
||||
public private(set) var current: (any NotchActivity)?
|
||||
|
||||
/// Ob die Activity noch gilt oder nur noch ausblendet.
|
||||
public private(set) var isPresenting = false
|
||||
|
||||
/// Wird nach jeder Änderung gerufen — mit dem, was jetzt gilt.
|
||||
///
|
||||
/// **Nicht nur bei „etwas / nichts".** Löst ein zweiter Lautstärkeschritt
|
||||
/// die erste Activity ab, bleibt „etwas liegt an" gleich, aber die Arme
|
||||
/// werden vielleicht anders breit. Wer nur auf den Wechsel von nichts zu
|
||||
/// etwas hört, verpasst das.
|
||||
@ObservationIgnored public var onChange: (((any NotchActivity)?) -> Void)?
|
||||
|
||||
@ObservationIgnored private var queue = ActivityQueue()
|
||||
@ObservationIgnored private var activities: [String: any NotchActivity] = [:]
|
||||
@ObservationIgnored private var alarm: DispatchWorkItem?
|
||||
@ObservationIgnored private var retirement: DispatchWorkItem?
|
||||
@ObservationIgnored private let clock: () -> Date
|
||||
|
||||
public init(clock: @escaping () -> Date = Date.init) {
|
||||
self.clock = clock
|
||||
}
|
||||
|
||||
/// Meldet eine Activity an. Eine bereits stehende mit derselben Kennung
|
||||
/// wird ersetzt — samt neuer Standzeit.
|
||||
public func raise(_ activity: any NotchActivity) {
|
||||
activities[activity.id] = activity
|
||||
queue.raise(ActivityTicket(id: activity.id,
|
||||
rank: activity.rank,
|
||||
raised: clock(),
|
||||
duration: activity.duration))
|
||||
settle()
|
||||
}
|
||||
|
||||
public func withdraw(id: String) {
|
||||
activities[id] = nil
|
||||
queue.withdraw(id: id)
|
||||
settle()
|
||||
}
|
||||
|
||||
/// Die Fläche, die die eingeklappte Notch dafür unter sich aufspannt.
|
||||
///
|
||||
/// `nil`, sobald die Activity nicht mehr gilt — auch wenn sie noch
|
||||
/// gezeichnet wird. Genau daran fährt die Fläche ein und schneidet den
|
||||
/// Inhalt dabei weg.
|
||||
public var contentSize: CGSize? { isPresenting ? current?.size : nil }
|
||||
|
||||
private func settle() {
|
||||
let now = clock()
|
||||
queue.prune(now: now)
|
||||
|
||||
let next = queue.current(now: now).flatMap { activities[$0.id] }
|
||||
if let next {
|
||||
retirement?.cancel()
|
||||
retirement = nil
|
||||
current = next
|
||||
isPresenting = true
|
||||
} else if isPresenting {
|
||||
isPresenting = false
|
||||
scheduleRetirement()
|
||||
}
|
||||
onChange?(isPresenting ? current : nil)
|
||||
|
||||
scheduleAlarm(after: now)
|
||||
}
|
||||
|
||||
/// Weckt genau dann, wenn die nächste abläuft.
|
||||
///
|
||||
/// Nicht abtasten: eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
|
||||
/// vorbei ist, hält den Rechner wach — und zwar den ganzen Tag, für ein
|
||||
/// Ereignis alle paar Minuten.
|
||||
private func scheduleAlarm(after now: Date) {
|
||||
alarm?.cancel()
|
||||
alarm = nil
|
||||
guard let next = queue.nextExpiry(after: now) else { return }
|
||||
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
MainActor.assumeIsolated { self?.settle() }
|
||||
}
|
||||
alarm = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + max(next.timeIntervalSince(now), 0),
|
||||
execute: work)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ActivityPresenter {
|
||||
|
||||
/// Räumt die ausgeblendete Activity weg, wenn die Fläche eingefahren ist.
|
||||
///
|
||||
/// Nicht sofort: solange die Notch schrumpft, muss noch etwas darin stehen.
|
||||
/// Die Dauer kommt aus dem Bewegungsprofil, damit sie bei „ruhig" mitwächst
|
||||
/// — eine feste Zahl schnitte den Inhalt dort mittendrin ab.
|
||||
func scheduleRetirement() {
|
||||
retirement?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self, !self.isPresenting else { return }
|
||||
self.current = nil
|
||||
}
|
||||
}
|
||||
retirement = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + OnyxTheme.shared.motion.collapseDuration,
|
||||
execute: work)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import Foundation
|
||||
|
||||
/// Ein Anspruch auf die eingeklappte Notch.
|
||||
///
|
||||
/// Bewusst ohne Darstellung: was eine Live Activity *zeigt*, ist eine View und
|
||||
/// gehört nicht in die Entscheidung, **welche** gerade gilt. So lässt sich die
|
||||
/// Regel ohne Bildschirm prüfen — wie `NotchStateMachine` und `LayoutEngine`.
|
||||
public struct ActivityTicket: Equatable, Sendable, Identifiable {
|
||||
|
||||
public let id: String
|
||||
/// Höher verdrängt niedriger.
|
||||
public let rank: Int
|
||||
/// Wann angemeldet.
|
||||
public let raised: Date
|
||||
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
|
||||
public let duration: TimeInterval?
|
||||
|
||||
public init(id: String, rank: Int, raised: Date, duration: TimeInterval?) {
|
||||
self.id = id
|
||||
self.rank = rank
|
||||
self.raised = raised
|
||||
self.duration = duration
|
||||
}
|
||||
|
||||
/// Wann sie von selbst geht. `nil` bei dauerhaften.
|
||||
public var expiry: Date? {
|
||||
duration.map { raised.addingTimeInterval($0) }
|
||||
}
|
||||
|
||||
public func hasExpired(at now: Date) -> Bool {
|
||||
guard let expiry else { return false }
|
||||
return now >= expiry
|
||||
}
|
||||
}
|
||||
|
||||
/// Wer die eingeklappte Notch gerade bekommt.
|
||||
///
|
||||
/// **Warum überhaupt eine Warteschlange.** Die Anlässe kommen unabhängig
|
||||
/// voneinander: die Lautstärke ändert sich, während ein Titel wechselt, während
|
||||
/// das Netzteil angesteckt wird. Ohne Regel gewinnt, wer zuletzt kam — und dann
|
||||
/// verschluckt ein beiläufiger Lautstärkeschritt die Meldung, dass der Akku
|
||||
/// fast leer ist.
|
||||
///
|
||||
/// **Die Regel ist Rang vor Aktualität.** Bei gleichem Rang gilt die jüngere:
|
||||
/// zwei Lautstärkeschritte hintereinander sollen den zweiten zeigen, nicht den
|
||||
/// ersten stehen lassen.
|
||||
public struct ActivityQueue: Equatable, Sendable {
|
||||
|
||||
private var tickets: [ActivityTicket] = []
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Alles, was gerade anliegt — ungefiltert, in Anmeldereihenfolge.
|
||||
public var all: [ActivityTicket] { tickets }
|
||||
|
||||
/// Meldet einen Anspruch an. Ein bereits vorhandener mit derselben Kennung
|
||||
/// wird **ersetzt**, nicht ergänzt.
|
||||
///
|
||||
/// Das ist der Normalfall und keine Ausnahme: wer die Lautstärke dreimal
|
||||
/// verstellt, meldet dreimal dieselbe Activity an und will dreimal die
|
||||
/// volle Standzeit — nicht drei Einträge in einer Schlange.
|
||||
public mutating func raise(_ ticket: ActivityTicket) {
|
||||
tickets.removeAll { $0.id == ticket.id }
|
||||
tickets.append(ticket)
|
||||
}
|
||||
|
||||
public mutating func withdraw(id: String) {
|
||||
tickets.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// Wirft weg, was abgelaufen ist.
|
||||
public mutating func prune(now: Date) {
|
||||
tickets.removeAll { $0.hasExpired(at: now) }
|
||||
}
|
||||
|
||||
/// Welche gerade gilt.
|
||||
public func current(now: Date) -> ActivityTicket? {
|
||||
tickets
|
||||
.filter { !$0.hasExpired(at: now) }
|
||||
// `last` statt `max(by:)`: bei gleichem Rang gewinnt die zuletzt
|
||||
// angemeldete, und `tickets` steht in Anmeldereihenfolge.
|
||||
.reduce(into: ActivityTicket?.none) { best, candidate in
|
||||
guard let current = best else { best = candidate; return }
|
||||
if candidate.rank >= current.rank { best = candidate }
|
||||
}
|
||||
}
|
||||
|
||||
/// Wann das nächste Mal etwas von selbst verschwindet.
|
||||
///
|
||||
/// Der Presenter stellt darauf seinen Wecker. Ohne diese Auskunft müsste er
|
||||
/// abtasten — und eine Notch, die zehnmal je Sekunde nachfragt, ob etwas
|
||||
/// abgelaufen ist, hält den Rechner wach.
|
||||
public func nextExpiry(after now: Date) -> Date? {
|
||||
tickets.compactMap(\.expiry).filter { $0 > now }.min()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Was die eingeklappte Notch zeigt.
|
||||
///
|
||||
/// **Unter der Notch, nicht daneben.** Neben ihr wären die Arme zwangsläufig
|
||||
/// gleich breit — die Silhouette zeichnet den Steg mittig um die Hardware, und
|
||||
/// verschieden breite Arme schöben die Lücke aus ihr heraus. Ein Symbol links
|
||||
/// und ein Balken rechts hieße dann 132 Punkte Schwarz für ein Symbol. Darunter
|
||||
/// hängt die Fläche frei und wird genau so breit, wie ihr Inhalt es braucht.
|
||||
///
|
||||
/// **Die Größe steht im Protokoll, sie wird nicht gemessen.** Eine Notch, die
|
||||
/// sich an ihren Inhalt anpasst, müsste ihn erst zeichnen, seine Größe
|
||||
/// zurückmelden und sich dann bewegen — das ist ein Bild zu spät und sieht als
|
||||
/// Ruckeln aus. Wer eine Activity schreibt, weiß, wie groß sie wird.
|
||||
@MainActor
|
||||
public protocol NotchActivity: Sendable {
|
||||
|
||||
/// Stabil: eine erneute Anmeldung mit derselben Kennung ersetzt die alte,
|
||||
/// statt sich anzureihen.
|
||||
var id: String { get }
|
||||
|
||||
/// Höher verdrängt niedriger.
|
||||
var rank: Int { get }
|
||||
|
||||
/// Wie lange sie steht. `nil` heißt: bis sie zurückgezogen wird.
|
||||
var duration: TimeInterval? { get }
|
||||
|
||||
/// Die Fläche **unter** der Notch. Ist sie schmaler als die Notch, gibt
|
||||
/// diese das Maß vor — sonst hinge unter dem Steg ein schmalerer Zipfel.
|
||||
var size: CGSize { get }
|
||||
|
||||
func makeContent() -> AnyView
|
||||
}
|
||||
|
||||
/// Die Ränge, damit sie an einer Stelle stehen und nicht als Zahlen im Code.
|
||||
///
|
||||
/// Die Ordnung ist keine Geschmacksfrage: was der Nutzer **selbst gerade
|
||||
/// ausgelöst** hat, muss er sehen — sonst wirkt der Regler kaputt. Was ihn
|
||||
/// warnt, steht darüber, weil er es sonst verpasst. Was nur begleitet, steht
|
||||
/// ganz unten.
|
||||
public enum ActivityRank {
|
||||
/// Läuft im Hintergrund mit, wenn sonst nichts ansteht.
|
||||
public static let ambient = 10
|
||||
/// Ein Zustand, der sich von selbst geändert hat — Titelwechsel, Kopplung.
|
||||
public static let notice = 30
|
||||
/// Eine Reaktion auf einen Handgriff — Lautstärke, Helligkeit.
|
||||
public static let response = 50
|
||||
/// Etwas, das man nicht verpassen darf — Akku fast leer.
|
||||
public static let alert = 90
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import AppKit
|
||||
import Observation
|
||||
import os
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Brightness")
|
||||
|
||||
/// Die Helligkeit des eingebauten Bildschirms — gelesen, nicht gesetzt.
|
||||
///
|
||||
/// **Nur der eingebaute.** Ein Spike über drei Wege hat ergeben: von
|
||||
/// `DisplayServices` bekommt man für externe Monitore Fehler 1000, `CoreDisplay`
|
||||
/// liefert für jeden Bildschirm stur 1,0 und ist damit wertlos, und
|
||||
/// `IODisplayConnect` gibt es auf Apple Silicon gar nicht mehr. Siehe
|
||||
/// `Spikes/brightness-probe.swift`.
|
||||
///
|
||||
/// **Und nicht abgetastet, sondern gemeldet.** Eine Notch, die zehnmal je
|
||||
/// Sekunde nach der Helligkeit fragt, hält den Rechner wach — den ganzen Tag,
|
||||
/// für ein Ereignis alle paar Stunden. `DisplayServices` kann benachrichtigen;
|
||||
/// gäbe es das nicht, wäre diese Anzeige den Preis nicht wert gewesen.
|
||||
///
|
||||
/// **Private API, und deshalb mit Netz.** Fehlt eines der Symbole — bei einem
|
||||
/// Systemwechsel jederzeit möglich —, bleibt `level` auf `nil` und Onyx zeigt
|
||||
/// schlicht keine Helligkeit an. Nichts anzuzeigen ist ein hinnehmbarer
|
||||
/// Ausfall; abzustürzen wäre keiner.
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class DisplayBrightness {
|
||||
|
||||
/// Der zuletzt gemeldete Stand, 0 bis 1. `nil` heißt: nicht lesbar.
|
||||
public private(set) var level: Double?
|
||||
|
||||
/// Ob dieser Rechner überhaupt eine lesbare Helligkeit hat.
|
||||
public var isAvailable: Bool { display != nil }
|
||||
|
||||
@ObservationIgnored private let display: CGDirectDisplayID?
|
||||
@ObservationIgnored private let read: GetBrightness?
|
||||
|
||||
private typealias GetBrightness =
|
||||
@convention(c) (CGDirectDisplayID, UnsafeMutablePointer<Float>) -> Int32
|
||||
private typealias Callback =
|
||||
@convention(c) (UnsafeRawPointer?, CGDirectDisplayID, CFString?, Double) -> Void
|
||||
private typealias Register =
|
||||
@convention(c) (CGDirectDisplayID, UnsafeRawPointer?, Callback) -> Int32
|
||||
|
||||
/// Der Rückruf ist ein reiner C-Zeiger und kann nichts einfangen. Die eine
|
||||
/// laufende Instanz steht deshalb hier — schwach, damit sie sich beenden
|
||||
/// lässt.
|
||||
@ObservationIgnored private nonisolated(unsafe) static weak var current: DisplayBrightness?
|
||||
|
||||
private static let frameworkPath =
|
||||
"/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
|
||||
|
||||
public init() {
|
||||
display = NSScreen.screens
|
||||
.compactMap { $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")]
|
||||
as? CGDirectDisplayID }
|
||||
.first { CGDisplayIsBuiltin($0) != 0 }
|
||||
|
||||
guard let handle = dlopen(Self.frameworkPath, RTLD_LAZY) else {
|
||||
log.notice("DisplayServices nicht ladbar — keine Helligkeitsanzeige")
|
||||
read = nil
|
||||
return
|
||||
}
|
||||
read = dlsym(handle, "DisplayServicesGetBrightness")
|
||||
.map { unsafeBitCast($0, to: GetBrightness.self) }
|
||||
|
||||
Self.current = self
|
||||
refresh()
|
||||
subscribe(handle)
|
||||
}
|
||||
|
||||
/// Liest den aktuellen Stand einmal.
|
||||
public func refresh() {
|
||||
guard let display, let read else { level = nil; return }
|
||||
var value: Float = 0
|
||||
// Status ungleich null heißt: dieser Bildschirm hat keine Helligkeit,
|
||||
// die man lesen könnte. Kein Fehler, nur keine Antwort.
|
||||
guard read(display, &value) == 0 else { level = nil; return }
|
||||
level = Double(min(max(value, 0), 1))
|
||||
}
|
||||
|
||||
private func subscribe(_ handle: UnsafeMutableRawPointer) {
|
||||
guard let display,
|
||||
let symbol = dlsym(handle,
|
||||
"DisplayServicesRegisterForBrightnessChangeNotifications")
|
||||
else {
|
||||
log.notice("Keine Helligkeitsmeldungen — Anzeige bleibt beim Startwert")
|
||||
return
|
||||
}
|
||||
|
||||
let callback: Callback = { _, _, _, value in
|
||||
// Der Rückruf kommt aus einem fremden Faden.
|
||||
Task { @MainActor in
|
||||
DisplayBrightness.current?.level = min(max(value, 0), 1)
|
||||
}
|
||||
}
|
||||
let status = unsafeBitCast(symbol, to: Register.self)(display, nil, callback)
|
||||
if status != 0 {
|
||||
log.notice("Anmeldung für Helligkeitsmeldungen fehlgeschlagen: \(status, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
import SwiftUI
|
||||
import OnyxNotch
|
||||
@testable import ActivityProvider
|
||||
|
||||
@Suite("Warteschlange der Live Activities")
|
||||
struct ActivityQueueTests {
|
||||
|
||||
private let now = Date(timeIntervalSince1970: 1_780_000_000)
|
||||
|
||||
private func ticket(_ id: String, rank: Int, at offset: TimeInterval = 0,
|
||||
for duration: TimeInterval? = 2) -> ActivityTicket {
|
||||
ActivityTicket(id: id, rank: rank,
|
||||
raised: now.addingTimeInterval(offset), duration: duration)
|
||||
}
|
||||
|
||||
@Test("Leer heißt: nichts zu zeigen")
|
||||
func emptyShowsNothing() {
|
||||
#expect(ActivityQueue().current(now: now) == nil)
|
||||
}
|
||||
|
||||
/// Ohne diese Regel verschluckt ein beiläufiger Lautstärkeschritt die
|
||||
/// Meldung, dass der Akku fast leer ist.
|
||||
@Test("Rang schlägt Aktualität")
|
||||
func rankBeatsRecency() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("battery", rank: 90))
|
||||
queue.raise(ticket("volume", rank: 10, at: 1))
|
||||
|
||||
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "battery")
|
||||
}
|
||||
|
||||
@Test("Bei gleichem Rang gilt die jüngere")
|
||||
func sameRankPrefersNewest() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("volume", rank: 10))
|
||||
queue.raise(ticket("brightness", rank: 10, at: 1))
|
||||
|
||||
#expect(queue.current(now: now.addingTimeInterval(1))?.id == "brightness")
|
||||
}
|
||||
|
||||
/// Wer die Lautstärke dreimal verstellt, will dreimal die volle Standzeit —
|
||||
/// nicht drei Einträge in einer Schlange.
|
||||
@Test("Dieselbe Kennung ersetzt, statt sich anzureihen")
|
||||
func raisingAgainReplaces() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("volume", rank: 10))
|
||||
queue.raise(ticket("volume", rank: 10, at: 1))
|
||||
|
||||
#expect(queue.all.count == 1)
|
||||
#expect(queue.current(now: now.addingTimeInterval(1))?.raised
|
||||
== now.addingTimeInterval(1))
|
||||
}
|
||||
|
||||
@Test("Abgelaufene zählen nicht mehr")
|
||||
func expiredIsGone() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("volume", rank: 10, for: 2))
|
||||
|
||||
#expect(queue.current(now: now.addingTimeInterval(1)) != nil)
|
||||
#expect(queue.current(now: now.addingTimeInterval(2)) == nil)
|
||||
}
|
||||
|
||||
/// Genau dafür gibt es dauerhafte: etwas, das steht, wenn sonst nichts
|
||||
/// ansteht — das Wetter etwa.
|
||||
@Test("Eine dauerhafte läuft nie ab")
|
||||
func persistentNeverExpires() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("weather", rank: 1, for: nil))
|
||||
|
||||
#expect(queue.current(now: now.addingTimeInterval(86_400))?.id == "weather")
|
||||
}
|
||||
|
||||
@Test("Nach dem Ablauf der kurzen kommt die dauerhafte zurück")
|
||||
func persistentReturns() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("weather", rank: 1, for: nil))
|
||||
queue.raise(ticket("volume", rank: 10, for: 2))
|
||||
|
||||
#expect(queue.current(now: now)?.id == "volume")
|
||||
#expect(queue.current(now: now.addingTimeInterval(3))?.id == "weather")
|
||||
}
|
||||
|
||||
@Test("Zurückziehen wirkt sofort")
|
||||
func withdrawWorks() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("media", rank: 20, for: nil))
|
||||
queue.withdraw(id: "media")
|
||||
|
||||
#expect(queue.current(now: now) == nil)
|
||||
}
|
||||
|
||||
/// Eine Notch, die zehnmal je Sekunde nachfragt, ob etwas abgelaufen ist,
|
||||
/// hält den Rechner wach. Deshalb rechnet die Schlange den Weckzeitpunkt aus.
|
||||
@Test("Der nächste Ablauf ist der früheste noch offene")
|
||||
func nextExpiryIsEarliest() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("volume", rank: 10, for: 5))
|
||||
queue.raise(ticket("charge", rank: 50, for: 2))
|
||||
queue.raise(ticket("weather", rank: 1, for: nil))
|
||||
|
||||
#expect(queue.nextExpiry(after: now) == now.addingTimeInterval(2))
|
||||
}
|
||||
|
||||
@Test("Ohne offene Fristen gibt es keinen Weckzeitpunkt")
|
||||
func noExpiryWithoutDurations() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("weather", rank: 1, for: nil))
|
||||
|
||||
#expect(queue.nextExpiry(after: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Aufräumen entfernt genau die abgelaufenen")
|
||||
func pruneRemovesExpiredOnly() {
|
||||
var queue = ActivityQueue()
|
||||
queue.raise(ticket("volume", rank: 10, for: 1))
|
||||
queue.raise(ticket("weather", rank: 1, for: nil))
|
||||
queue.prune(now: now.addingTimeInterval(2))
|
||||
|
||||
#expect(queue.all.map(\.id) == ["weather"])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Fläche unter der Notch")
|
||||
@MainActor
|
||||
struct AmbientSizeTests {
|
||||
|
||||
private struct Row: NotchActivity {
|
||||
let id = "volume"
|
||||
let rank = ActivityRank.response
|
||||
let duration: TimeInterval? = 2
|
||||
var size: CGSize { CGSize(width: 196, height: 34) }
|
||||
func makeContent() -> AnyView { AnyView(EmptyView()) }
|
||||
}
|
||||
|
||||
@Test("Die angemeldete Activity gibt die Fläche vor")
|
||||
func sizeComesFromActivity() {
|
||||
let presenter = ActivityPresenter()
|
||||
presenter.raise(Row())
|
||||
|
||||
#expect(presenter.contentSize == CGSize(width: 196, height: 34))
|
||||
}
|
||||
|
||||
@Test("Ohne Activity gibt es keine Fläche")
|
||||
func noActivityNoSize() {
|
||||
#expect(ActivityPresenter().contentSize == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Testing
|
||||
@testable import MetricsProvider
|
||||
|
||||
@Suite("Strom: was gemeldet wird")
|
||||
struct PowerWatcherTests {
|
||||
|
||||
@Test("Der erste gelesene Zustand meldet nichts")
|
||||
func firstReadingIsSilent() {
|
||||
var watcher = PowerWatcher()
|
||||
// Beim Start ist „am Netz" kein Ereignis, sondern die Lage.
|
||||
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
|
||||
charge: 0.8, limit: nil) == nil)
|
||||
#expect(watcher.current == .plugged)
|
||||
}
|
||||
|
||||
@Test("Derselbe Zustand meldet sich nicht noch einmal")
|
||||
func repeatedStateIsSilent() {
|
||||
var watcher = PowerWatcher()
|
||||
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
|
||||
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
|
||||
charge: 0.51, limit: nil) == nil)
|
||||
#expect(watcher.observe(isCharging: true, isPluggedIn: true,
|
||||
charge: 0.99, limit: nil) == nil)
|
||||
}
|
||||
|
||||
@Test("Netzteil abgezogen: das ist ein Ereignis")
|
||||
func unplugging() {
|
||||
var watcher = PowerWatcher()
|
||||
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: nil)
|
||||
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
|
||||
charge: 0.5, limit: nil) == .unplugged)
|
||||
}
|
||||
|
||||
@Test("Fast leer schlägt „am Akku\"")
|
||||
func lowBeatsUnplugged() {
|
||||
var watcher = PowerWatcher()
|
||||
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
|
||||
// Beides trifft zu — gemeldet wird das, was man nicht verpassen darf.
|
||||
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
|
||||
charge: 0.15, limit: nil) == .low)
|
||||
}
|
||||
|
||||
@Test("Von fast leer zurück auf normal ist wieder ein Wechsel")
|
||||
func recoveringFromLow() {
|
||||
var watcher = PowerWatcher()
|
||||
_ = watcher.observe(isCharging: false, isPluggedIn: false, charge: 0.1, limit: nil)
|
||||
#expect(watcher.observe(isCharging: false, isPluggedIn: false,
|
||||
charge: 0.5, limit: nil) == .unplugged)
|
||||
}
|
||||
|
||||
@Test("Die Schwelle liegt bei zwanzig Prozent, einschließlich")
|
||||
func thresholdIsInclusive() {
|
||||
var atThreshold = PowerWatcher()
|
||||
_ = atThreshold.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
|
||||
#expect(atThreshold.observe(isCharging: false, isPluggedIn: false,
|
||||
charge: 0.2, limit: nil) == .low)
|
||||
|
||||
var above = PowerWatcher()
|
||||
_ = above.observe(isCharging: true, isPluggedIn: true, charge: 0.9, limit: nil)
|
||||
#expect(above.observe(isCharging: false, isPluggedIn: false,
|
||||
charge: 0.21, limit: nil) == .unplugged)
|
||||
}
|
||||
|
||||
@Test("Am Ladelimit ist ein eigener Zustand, nicht „am Netz\"")
|
||||
func limitIsItsOwnState() {
|
||||
var watcher = PowerWatcher()
|
||||
_ = watcher.observe(isCharging: true, isPluggedIn: true, charge: 0.5, limit: 80)
|
||||
#expect(watcher.observe(isCharging: false, isPluggedIn: true,
|
||||
charge: 0.80, limit: 80) == .atLimit(80))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import AppKit
|
||||
|
||||
// Lässt sich auf Helligkeitsänderungen *hören*, statt sie abzufragen?
|
||||
//
|
||||
// Eine Notch, die zehnmal je Sekunde nachfragt, hält den Rechner wach. Wenn es
|
||||
// keine Benachrichtigung gibt, ist die Helligkeits-Activity nicht zu haben —
|
||||
// dann lieber gar nicht als mit einem Dauerwecker.
|
||||
//
|
||||
// Eigener Prozess, weil die Signatur geraten ist: stimmt sie nicht, stürzt hier
|
||||
// etwas ab und sonst nichts.
|
||||
|
||||
typealias BrightnessCallback = @convention(c) (UnsafeRawPointer?, CGDirectDisplayID,
|
||||
CFString?, Double) -> Void
|
||||
typealias Register = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?,
|
||||
BrightnessCallback) -> Int32
|
||||
|
||||
let path = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
|
||||
guard let handle = dlopen(path, RTLD_LAZY),
|
||||
let sym = dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") else {
|
||||
print("nicht vorhanden"); exit(1)
|
||||
}
|
||||
|
||||
let register = unsafeBitCast(sym, to: Register.self)
|
||||
|
||||
let callback: BrightnessCallback = { _, display, key, value in
|
||||
print(" → Bildschirm \(display), Schlüssel \(key.map { $0 as String } ?? "—"), Wert \(value)")
|
||||
}
|
||||
|
||||
// Nur das eingebaute Display kann Helligkeit; die externen melden Fehler.
|
||||
let builtIn = NSScreen.screens.compactMap {
|
||||
$0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
|
||||
}.first { CGDisplayIsBuiltin($0) != 0 }
|
||||
|
||||
guard let builtIn else { print("kein eingebautes Display"); exit(1) }
|
||||
|
||||
let status = register(builtIn, nil, callback)
|
||||
print("Anmeldung für Bildschirm \(builtIn): status \(status) \(status == 0 ? "(erfolgreich)" : "(FEHLER)")")
|
||||
print("Lausche 12 Sekunden — jede Helligkeitsänderung erscheint hier.")
|
||||
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(12))
|
||||
print("fertig")
|
||||
@@ -0,0 +1,81 @@
|
||||
import AppKit
|
||||
import IOKit
|
||||
|
||||
// Gibt es einen benutzbaren Weg, die Bildschirmhelligkeit zu *lesen* — und
|
||||
// besser noch: auf ihre Änderung zu *hören*?
|
||||
//
|
||||
// Öffentlich gibt es keinen. Geprüft werden deshalb drei private bzw.
|
||||
// halböffentliche Wege, jeder einzeln, damit hinterher klar ist, welcher trägt
|
||||
// und welcher nur auf manchen Geräten funktioniert.
|
||||
|
||||
let displays: [CGDirectDisplayID] = NSScreen.screens.compactMap {
|
||||
$0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID
|
||||
}
|
||||
print("Bildschirme: \(displays)\n")
|
||||
|
||||
// MARK: - Weg 1: DisplayServices
|
||||
|
||||
typealias GetBrightness = @convention(c) (CGDirectDisplayID, UnsafeMutablePointer<Float>) -> Int32
|
||||
typealias RegisterNotify = @convention(c) (CGDirectDisplayID, UnsafeRawPointer?, UnsafeRawPointer?) -> Int32
|
||||
|
||||
let dsPath = "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices"
|
||||
if let handle = dlopen(dsPath, RTLD_LAZY) {
|
||||
print("DisplayServices: geladen")
|
||||
if let sym = dlsym(handle, "DisplayServicesGetBrightness") {
|
||||
let fn = unsafeBitCast(sym, to: GetBrightness.self)
|
||||
for id in displays {
|
||||
var value: Float = -1
|
||||
let status = fn(id, &value)
|
||||
print(" GetBrightness(\(id)) → status \(status), Wert \(value)")
|
||||
}
|
||||
} else {
|
||||
print(" DisplayServicesGetBrightness: nicht vorhanden")
|
||||
}
|
||||
print(" RegisterForBrightnessChangeNotifications: " +
|
||||
(dlsym(handle, "DisplayServicesRegisterForBrightnessChangeNotifications") != nil
|
||||
? "vorhanden" : "FEHLT"))
|
||||
} else {
|
||||
print("DisplayServices: nicht ladbar — \(String(cString: dlerror()))")
|
||||
}
|
||||
|
||||
// MARK: - Weg 2: CoreDisplay
|
||||
|
||||
typealias UserBrightness = @convention(c) (CGDirectDisplayID) -> Double
|
||||
|
||||
let cdPath = "/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay"
|
||||
if let handle = dlopen(cdPath, RTLD_LAZY) {
|
||||
print("\nCoreDisplay: geladen")
|
||||
if let sym = dlsym(handle, "CoreDisplay_Display_GetUserBrightness") {
|
||||
let fn = unsafeBitCast(sym, to: UserBrightness.self)
|
||||
for id in displays {
|
||||
print(" GetUserBrightness(\(id)) → \(fn(id))")
|
||||
}
|
||||
} else {
|
||||
print(" CoreDisplay_Display_GetUserBrightness: nicht vorhanden")
|
||||
}
|
||||
} else {
|
||||
print("\nCoreDisplay: nicht ladbar")
|
||||
}
|
||||
|
||||
// MARK: - Weg 3: IOKit
|
||||
|
||||
print("\nIOKit IODisplayGetFloatParameter:")
|
||||
var iterator = io_iterator_t()
|
||||
let matching = IOServiceMatching("IODisplayConnect")
|
||||
if IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS {
|
||||
var found = false
|
||||
var service = IOIteratorNext(iterator)
|
||||
while service != 0 {
|
||||
found = true
|
||||
var brightness: Float = -1
|
||||
let status = IODisplayGetFloatParameter(service, 0, kIODisplayBrightnessKey as CFString,
|
||||
&brightness)
|
||||
print(" Dienst \(service) → status \(status), Wert \(brightness)")
|
||||
IOObjectRelease(service)
|
||||
service = IOIteratorNext(iterator)
|
||||
}
|
||||
if !found { print(" kein IODisplayConnect gefunden (auf Apple Silicon üblich)") }
|
||||
IOObjectRelease(iterator)
|
||||
} else {
|
||||
print(" IOServiceGetMatchingServices fehlgeschlagen")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# Spike H — Helligkeit mitlesen
|
||||
|
||||
**Frage.** Wie liest man die Bildschirmhelligkeit, ohne Bedienungshilfen-Berechtigung?
|
||||
Was kostet Abtasten?
|
||||
|
||||
**Gemessen am 05.09.2026**, `Spikes/brightness-probe.swift` und
|
||||
`Spikes/brightness-notify-probe.swift`. Vier Bildschirme: ein eingebauter, drei externe.
|
||||
|
||||
## Ergebnis
|
||||
|
||||
| Weg | Befund |
|
||||
|---|---|
|
||||
| `DisplayServices` (privat) | **trägt — aber nur für den eingebauten Bildschirm.** Extern: Status 1000, kein Wert |
|
||||
| `CoreDisplay_Display_GetUserBrightness` | liefert für **jeden** Bildschirm stur `1.0` — wertlos |
|
||||
| IOKit `IODisplayGetFloatParameter` | **kein `IODisplayConnect` vorhanden** — auf Apple Silicon nicht mehr da |
|
||||
|
||||
`DisplayServicesRegisterForBrightnessChangeNotifications` ist vorhanden und die
|
||||
Anmeldung gibt Status 0 zurück. Damit ist **kein Abtasten nötig** — was die Frage
|
||||
nach den Kosten erledigt.
|
||||
|
||||
## Was das für die Bauform heißt
|
||||
|
||||
- Nur der eingebaute Bildschirm bekommt eine Helligkeitsanzeige. Auf einem Mini am
|
||||
Monitor gibt es keine, und dann erscheint auch nichts.
|
||||
- Private API, also mit Netz: fehlt ein Symbol nach einem Systemwechsel, bleibt der
|
||||
Wert leer und Onyx zeigt schlicht nichts an. Nichts anzuzeigen ist ein hinnehmbarer
|
||||
Ausfall, abzustürzen wäre keiner.
|
||||
- Kleine Änderungen werden verworfen: die automatische Anpassung ans Umgebungslicht
|
||||
kriecht in winzigen Schritten, und jede davon als Ereignis zu zeigen wäre eine
|
||||
Leuchtreklame.
|
||||
|
||||
## Sackgasse
|
||||
|
||||
`CoreDisplay` sieht in jeder Anleitung im Netz wie der richtige Weg aus. Auf diesem
|
||||
Gerät gibt es für alle vier Bildschirme `1.0` zurück — auch für den eingebauten, der
|
||||
tatsächlich auf 62 % stand. Wer den Wert ungeprüft übernimmt, baut eine Anzeige, die
|
||||
immer „100 %" sagt.
|
||||
Reference in New Issue
Block a user