Compare commits

...

80 Commits

Author SHA1 Message Date
Scarriffle
ee848b86cc Das Aufnahmesymbol kam vom Mixer, nicht vom Pegel
Gemessen über kAudioProcessPropertyIsRunningInput: mit laufendem Mixer nimmt
Onyx auf, ohne ihn nicht. Ein Process Tap ist für macOS eine Tonaufnahme —
auch wenn damit nur die Lautstärke geregelt wird. Solange der Mixer Chrome und
Firefox abgreift, steht das orange Symbol in der Leiste.

Der Mixer stand seit der Fehlersuche am Wochenende auf „automatisch starten",
deshalb war es dauerhaft. Das Häkchen steht jetzt wieder aus.

Ein Hinweis unter dem Häkchen sagt das jetzt, wo die Entscheidung fällt: wer
das Symbol ohne Erklärung sieht, sucht den Fehler bei sich oder hält die App
für unsauber.

Dazu ein echter Fund an derselben Stelle: der Mikrofonpegel hing an
`onDisappear` der View — und das bleibt bei einem NSPopover aus. Dessen
Controller lebt nach dem Schließen weiter, die View verschwindet nie aus der
Hierarchie. Die Messung wäre also beim ersten Öffnen angegangen und nie wieder
aus. Sie hängt jetzt am Popover selbst: `MenuBarModule` bekommt
`popoverDidOpen`/`popoverDidClose`, gemeldet auch dann, wenn das Popover von
selbst zugeht — beim Wechsel in eine andere App etwa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:20:07 +02:00
Scarriffle
c94c6166f5 Der Farbwechsel kommt jetzt auch an
Farbton und Symbolfarbe standen beim **Erzeugen** der Fenster, also außerhalb
jeder View. Damit wird der Wert genau einmal gelesen und SwiftUI erfährt nie,
dass er sich geändert hat: die Reiter, Knöpfe und Symbole blieben in der
Farbe stehen, die beim Öffnen galt, während der Farbwähler daneben schon die
neue zeigte.

Jetzt stehen beide im Rumpf der jeweiligen View — Panel, Einstellungen,
Einrichtung. Dort registriert SwiftUI den Zugriff und zeichnet neu.

Bei den Popovers blieb es beim Erzeugen: die entstehen bei jedem Öffnen neu,
lesen also ohnehin den aktuellen Wert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:18:06 +02:00
Scarriffle
1432ef3a6f Der Systemfarbknopf zeigt, welche Farbe er nimmt
Er tut, was draufsteht — nur weiß niemand auswendig, welche Farbe das System
gerade führt. Dann wirkt das Ergebnis wie ein Fehler statt wie eine Übernahme.
Jetzt sitzt der Farbfleck daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:00:56 +02:00
Scarriffle
9975694d6a Terminliste: an welchem Tag denn?
Zweimal „Nina arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und
nichts verriet welche. Die Uhrzeit beantwortet nur die halbe Frage.

Jetzt steht über jedem Tag eine Überschrift: „Heute", „Morgen", danach
Wochentag und Datum. Die Wörter für die nächsten beiden Tage, weil sie kürzer
sind als jedes Datum und sofort beantworten, was man wissen will. Der
Wochentag allein wäre in drei Wochen mehrdeutig, deshalb steht das Datum
dabei.

Ganztägige Termine stehen jetzt über den Uhrzeiten desselben Tages. Sie
beginnen um Mitternacht und standen deshalb zwischen den frühen Terminen —
dabei gelten sie für den ganzen Tag.

Die Entscheidung „heute/morgen/später" liegt getrennt von ihrer Beschriftung,
damit sie ohne Sprachkatalog prüfbar bleibt: im Testlauf steht sonst der rohe
Schlüssel statt des Wortes, und der Test prüft die Übersetzung statt der
Logik.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:52:16 +02:00
Scarriffle
0df566d531 Symbole folgen der Akzentfarbe, nicht der des Systems
SwiftUI zeichnet Symbole in Listen und Formularen auf macOS in der
Akzentfarbe des Systems — nicht in der des Fensters. `.tint` an der Wurzel
half deshalb nur der Reiterleiste; die Widget-Liste blieb systemgrün neben
einer blauen Leiste, obwohl beides derselben Einstellung folgen soll.

Ein eigener Label-Stil setzt die Farbe am Symbol. Er vererbt sich über die
Umgebung, also genügt eine Zeile an denselben vier Wurzeln, an denen auch der
Farbton gesetzt wird: Panel, Popovers, Einstellungen, Einrichtung.

Ein Symbol, das seine Farbe selbst mitbringt, behält sie — die innere Angabe
schlägt die äußere. Der grüne Haken für „erteilt" bringt sie jetzt am Symbol
mit statt am ganzen Label: Grün heißt dort „erteilt" und ist keine
Geschmacksfrage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:48:57 +02:00
Scarriffle
fde3e864c0 Zwei-Faktor-Anmeldung bei Calendarr
Deine Zugangsdaten stimmten. Der Fehler lag im gewählten Endpunkt:

  auth_router.py:81   if user.totp_enabled:
                          raise HTTPException(401, detail="2fa_required")

/api/auth/token nimmt nur ein Formular mit Benutzername und Passwort entgegen
und weist Konten mit zweitem Faktor grundsätzlich ab — mit demselben 401 wie
ein falsches Passwort. Deshalb stand da „Benutzername oder Passwort stimmen
nicht", obwohl beides stimmte.

Angemeldet wird jetzt über /api/auth/login: der Weg nimmt den Code entgegen
und kennt remember_me. Das ist hier keine Bequemlichkeit, sondern die
Bedingung — mit zweitem Faktor kann Onyx sich nicht selbst neu anmelden, den
Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
remember_me hält hundertachtzig.

Damit wandert auch das Token in den Schlüsselbund. Bei einem Konto mit
zweitem Faktor ist es das einzige, womit Onyx noch hineinkommt; ginge es beim
Beenden verloren, müsste man sich bei jedem Start neu anmelden.

Die drei Fälle sind jetzt auseinandergehalten und einzeln beschriftet:
falsches Passwort, fehlender Code, falscher Code. Der Unterschied ist der
zwischen „Passwort neu tippen" und „App aufmachen" — und er steckt beim
Server nur im Begleittext eines 401.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:44:26 +02:00
Scarriffle
e2b86c0216 Calendarr direkt vom Server
Bisher las Onyx nur den Schnappschuss aus dem gemeinsamen Ordner. Der ist so
aktuell wie der letzte Start der Calendarr-App — wer sie eine Woche nicht
öffnet, sieht wochenalte Termine, und Onyx kann nichts dafür und nichts
dagegen. Der Live-Weg stand im Plan als „bewusst weggelassen, später ohne
Umbau nachrüstbar". Das stimmte: das Quellenprotokoll hat gehalten.

Ein einziger Aufruf genügt. /api/caldav/events fasst CalDAV-Konten, lokale
Kalender, Geburtstage, iCal-Abos und Google bereits zusammen — nachgebaut wird
nichts.

Angemeldet wird mit Adresse, Benutzername und Passwort statt mit einem Token,
und der Grund steht im Backend:

  auth.py:17   ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7   # 7 days

Ein eingetragenes Token wäre nach einer Woche tot. Onyx holt sich seines
deshalb selbst und erneuert es, bevor es abläuft — die Gültigkeit liest es aus
dem Token, statt sieben Tage anzunehmen; die stehen heute so im Server und
morgen vielleicht nicht mehr. Kommt trotzdem ein 401, wird einmal neu
angemeldet und der Abruf wiederholt.

Adresse und Benutzername stehen in den Einstellungen, das Passwort im
Schlüsselbund — `defaults read` ist keine Stelle, an der ein Passwort
mitgelesen werden soll. Geprüft wird beim Verbinden, nicht erst beim nächsten
Laden: ein Tippfehler, den man am Folgetag bemerkt, ist ein vermeidbarer.

Der Schnappschuss bleibt das Netz darunter. Ohne Zugangsdaten ändert sich
nichts, und ohne Netzwerk hält er, was Calendarr zuletzt geschrieben hat.
Nachgesehen wird am Server alle fünf Minuten statt alle zwei: ein Dateizugriff
kostet nichts, eine Anfrage über das Netz schon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:34:57 +02:00
Scarriffle
3c36092712 Knopf zum Nachsehen bei Calendarr
Onyx holt sich nichts, es liest den Schnappschuss und erfährt neue über eine
Darwin-Nachricht. Wer Calendarr gerade erst gestartet hat, will aber nicht auf
den nächsten Blick warten, sondern jetzt nachsehen.

Der Knopf steht in jedem Zustand, nicht nur wenn Termine da sind: gerade wenn
„noch nichts geschrieben" oder „bitte anmelden" dasteht, will man es nach dem
Beheben sofort noch einmal versuchen. Beim Laden zeigt er einen Kreisel, in
fester Breite — sonst zuckt die Zeile, weil der Kreisel schmaler ist als der
Pfeil.

Dabei aufgefallen: die Statustexte für „noch nichts geschrieben", „abgemeldet"
und „zu neues Format" standen ohne Übersetzung im Katalog. Wer in einen dieser
Zustände geriet, las den rohen Schlüssel. Jetzt übersetzt, dazu die zwei
Zustände, die gar nicht behandelt waren — kein Kalenderzugriff und „wird
gelesen".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:23:34 +02:00
Scarriffle
c8f3054036 Calendarr: ein Blick alle zwei Minuten unter die Benachrichtigung
Onyx holt sich nichts, sondern liest den Schnappschuss, den Calendarr in den
gemeinsamen Ordner schreibt — kein Netzzugriff, kein zweiter Zwischenspeicher.
Gemeldet wird das über eine Darwin-Nachricht, und darauf hört Onyx.

Die Nachricht ist aber flüchtig: schreibt Calendarr, während Onyx nicht läuft
oder gerade startet, kommt sie nie an. Der Schnappschuss steht dann so lange
still, bis Calendarr das nächste Mal schreibt — und die Terminliste ist aus
unerfindlichen Gründen von gestern.

Also zusätzlich alle zwei Minuten nachsehen, solange Calendarr überhaupt
gefragt wird. Das kostet das Einlesen einer Datei von vierzehn Kilobyte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:15:58 +02:00
Scarriffle
102c870835 Helfer nach dem Umzug: die App repariert ihre Anmeldung selbst
macOS bindet einen privilegierten Dienst beim Anmelden an das Programm, das
ihn angemeldet hat. Zieht das Programm um — aus „Downloads" nach „Programme",
oder vom Bauverzeichnis in ein installiertes Bundle —, ist das ein anderes
Bundle. Der alte Dienst läuft weiter und redet nicht mit dem neuen Programm.

Von außen ist das die verwirrendste Kombination, die es gibt: der Zustand
meldet „eingerichtet", und trotzdem kommt bei jedem Zugriff „Kommunikation
mit der Hilfs-App nicht möglich". Ohne Reparatur bleibt nur, den Helfer von
Hand zu entfernen und neu einzurichten — und darauf muss man erst kommen.

Scheitert die Verbindung, während der Zustand „eingerichtet" meldet, meldet
die App den Dienst jetzt einmal ab und wieder an. Einmal je Programmlauf:
öfter wäre eine Schleife. Danach steht er auf „wartet auf Freigabe" — das
Verschieben eines Programms mit Root-Dienst darf niemand still hinnehmen,
auch diese App nicht.

Dazu ein Fehler im DMG-Bau, der beim Nachbauen auffiel: war ein gleichnamiges
Volume noch eingehängt, hängte macOS das neue als „Onyx 1" ein, während das
AppleScript weiter „Onyx" einrichtete. Das Abbild kam ohne Fenstergestaltung
heraus, ohne dass irgendetwas fehlschlug. Jetzt wird vorher abgehängt und der
Einhängepunkt aus der Ausgabe gelesen statt geraten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:08:45 +02:00
Scarriffle
928ee90b5e Das Installationsfenster sieht aus wie eines
Zwei Symbole auf grauem Grund sagen nicht, was zu tun ist. Jetzt: dunkler
Verlauf in den Onyx-Farben, Titel, ein Satz darunter, eine Haarlinie und ein
Pfeil, der vom Programm zum Programme-Ordner zeigt. Die Fassung steht in der
Fußzeile — damit man später weiß, was man installiert hat.

Der Hintergrund wird gezeichnet, nicht mitgeliefert: ein Bild im Verzeichnis
wäre eine Datei, die niemand mehr anfasst und die bei jeder Farbänderung
veraltet. So stehen die Onyx-Farben genau einmal, im selben Werkzeugkasten wie
der Rest.

Der Aufwand steckt nicht im Bild, sondern in der Reihenfolge: Fenstergröße,
Symbolplätze und Hintergrund leben in der .DS_Store des Abbilds. Die schreibt
nur der Finder, und nur in ein beschreibbares Abbild. Also erst UDRW, dann
über AppleScript einrichten, warten bis der Finder geschrieben hat, dann
zusammenpressen. Schlägt die Automatisierung fehl, bleibt das Fenster schlicht
statt das Skript abzubrechen.

Als Bibliothek, weil beide Wege dasselbe Fenster zeigen sollen — der lokale
wie der notarisierte. Und als TIFF mit beiden Auflösungen: als reines PNG wäre
der Hintergrund auf einem Retina-Bildschirm doppelt so groß wie das Fenster.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:57:51 +02:00
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
Scarriffle
c6f0a5a848 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>
2026-08-14 00:08:44 +02:00
Scarriffle
152d36c904 Vorschau: Notch zurück, und der eigene Hintergrund wird wirklich versucht
Die Notch verschwand, sobald zum ersten Mal ein Bild geladen wurde: 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. Vorher fiel es nicht auf, weil nie ein Bild
ankam. Das Bild wird jetzt auf Maß gebracht, bevor es in den Stapel geht.

Und der eigentliche Wunsch: dein Hintergrundbild statt eines Ersatzes. Es
liegt auf dem Schreibtisch, und dorthin fragt macOS beim ersten Lesen einmal
nach. Die Prüfung mit isReadableFile, die ich vorher davorgesetzt hatte,
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. Jetzt wird gelesen statt geprüft, mit den drei nötigen Erklärungen in
der Info.plist. Wird die Freigabe verweigert, bleibt es beim Systembild.

Ein Tahoe-Hintergrund als Ersatz ist nicht drin: unter den Systembildern liegt
davon nur „Tahoe Day.mov", ein Video von 170 Megabyte. Deshalb weiter Sonoma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:54:11 +02:00
Scarriffle
a2be84f4d5 Absturz beim Laden des Hintergrundbilds — und derselbe Fehler in der Ablage
Was in einer View steht, ist an den Hauptthread gebunden: die Konformität zu
`View` vererbt das, auch an statische Methoden, die damit nichts zu tun haben.
Das Laden lief absichtlich nebenher — und lief damit in die Isolationsprüfung,
die das Programm anhält. Der Compiler hat genau das gemeldet:

  warning: call to main actor-isolated static method 'systemWallpaper()' in a
  synchronous nonisolated context
  note: main actor isolation inferred from conformance to protocol 'View'

Eine Warnung, keine Fehlermeldung. Ich hatte den Bau nur nach `error:`
durchsucht und sie deshalb nicht gesehen.

Das Laden liegt jetzt in einem eigenen Typ neben der View, wo es die Bindung
nicht gibt. Dieselbe Suche über den ganzen Bau hat einen zweiten Fall
gefunden: `FileDropView.stage` wird aus dem Abschlussblock von
`loadFileRepresentation` gerufen, also von irgendeinem Thread, gehört aber zu
einem `ViewModifier`. Das hätte jeden Dateiabwurf auf die Ablage angehalten.

Dazu die Verbesserung, die beim letzten Mal wegen eines falschen
Arbeitsverzeichnisses nicht angekommen ist: gesucht wird zuerst unter den
Originalen und erst danach in `.thumbnails`. Die kleinen Bilder messen 214
Punkte in der Breite und sind auf einem Retina-Schirm sichtbar unscharf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:43:22 +02:00
Scarriffle
ca9e272ad8 Einstellungen: die Reiter schalten wieder, und die Leiste passt ins Fenster
Zwei Folgen der eigenen Reiterleiste, beide meine Schuld.

Der Reiter oben sprang um, der Inhalt blieb stehen: der gewählte Reiter liegt
im Fenster-Controller — er muss dort liegen, damit ein erneutes Öffnen ihn
setzen kann, etwa auf „Berechtigungen", wenn eine fehlt. Der Controller war
aber nicht beobachtbar. `TabView` fiel das nie auf, weil es seinen Zustand
selbst führte und die Bindung nur benachrichtigte. Ein `switch` im Rumpf
braucht dagegen eine Änderungsmeldung, und die kam nie. Jetzt ist der
Controller `@Observable`, und beide Richtungen funktionieren.

Zu breit war sie wegen `fixedSize`: sieben deutsche Beschriftungen brauchen
rund 880 Punkte, und die nahm sich die Leiste auch dann, wenn das Fenster 780
breit war — sie ragte links und rechts über die Kante hinaus, der erste und
der letzte Reiter waren nicht mehr erreichbar. Ohne feste Breite bleibt sie
im Fenster, und das Fenster ist jetzt 920 breit statt 780, mit 900 als
Mindestmaß. Darunter würde gestaucht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:38:40 +02:00
Scarriffle
2c3b3cdd55 Einstellungen: die Linie unter den Reitern ist weg, und eine Fläche für alles
Die Linie kam von `TabView` selbst: es zieht unter seiner Leiste einen Trenner
quer durchs Fenster, und der lässt sich nicht abschalten. Der Abstand nach
oben, den ich beim letzten Mal eingebaut habe, hat deshalb nichts geändert —
der Trenner ist mitgewandert. Jetzt ist die Reiterleiste selbst gebaut, ein
Auswahlfeld über dem Inhalt, dann gibt es die Linie gar nicht erst.

Der zweite Punkt hat dieselbe Wurzel: die Formulare bringen ihren eigenen
Untergrund mit, der Rest des Fensters hat den des Systems. In Dunkelgrau
liegen die beiden ein, zwei Prozent auseinander — sichtbar als Kante quer
durchs Fenster, genau dort, wo das Formular anfängt. Der Untergrund der
Formulare ist jetzt abgeschaltet, und das ganze Fenster hat einen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:34:47 +02:00
Scarriffle
6c92e7fed6 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>
2026-08-13 23:30:43 +02:00
Scarriffle
2aa60eedf9 Farben einstellbar — und erst mal überhaupt eine Farbe
Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und
`.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das
betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets
dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt.

Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe
mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu
das Paar für Herunter- und Hochladen samt Tauschknopf.

Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt
Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku,
Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören;
ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem,
sonst sucht man die Warnfarbe im Reiter und findet sie nirgends.

Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent`
ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`.
Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich
ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen,
Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen.

Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt
las sich das als englisches „J-E".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 23:24:40 +02:00
Scarriffle
7a3d2c1c39 Pegel auch für Ausgabe und Mikrofon
Die Programmzeilen hatten ihren mitlaufenden Ausschlag, die beiden Systemregler
nicht. Jetzt beide, in derselben Form: der Pegel läuft im Regler mit, nicht
daneben.

Der Ausgangspegel kommt über einen globalen Tap — denselben Weg wie der Mixer,
nur ohne Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die
der Mixer bereits abgreift: ein Tap sieht das Signal vor dem Stummschalten,
sie zählten sonst doppelt — einmal als Original, das niemand hört, und einmal
als das, was Onyx daraus macht. Ändert sich diese Liste, wird der Tap neu
aufgebaut.

Der Mikrofonpegel geht nur mit echter Aufnahme. Es gibt keine Eigenschaft am
Gerät, die den Ausschlag verrät — einen Pegel bekommt nur, wer das Signal
liest, und genau das meldet macOS mit dem orangen Punkt. Das ist richtig so,
also läuft die Messung nur, solange die Ansicht offen ist, und hört beim
Schließen sofort auf. Ein Zähler statt eines Schalters, weil Panel und Popover
gleichzeitig offen sein können.

Geräte, die keine Fließkommawerte liefern, bleiben ohne Pegel: als Fließkomma
gelesene Ganzzahlen ergeben keinen falschen Ausschlag, sondern Unsinn.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:17:40 +02:00
Scarriffle
9df2ddb80b Akku-Popover: „Lädt" sitzt auf der Höhe des Rings
Ein HStack zentriert über die volle Höhe seiner Inhalte. Die Beschriftung unter
dem Ring gehört dazu, also rutschte alles daneben um deren halbe Höhe nach
unten. Bei drei Zeilen fällt das kaum auf, bei einer einzelnen schon: „Lädt"
stand sichtbar tiefer als die Mitte, auf die man schaut.

Eine eigene Ausrichtung löst das an der Stelle, an der es entsteht: der Ring
meldet seine Mitte, die Spalte daneben richtet sich danach — unabhängig davon,
wie viele Zeilen sie hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:08:25 +02:00
Scarriffle
e732394c6c Akku-Popover: der Ladezustand steht neben dem Ring, nicht darunter
„Am Netzteil" stand rechts vom Ring, „Am Netz, lädt nicht" darunter — zweimal
dieselbe Auskunft an zwei Stellen. Und die zweite stand allein unter dem
Diagramm, wo sie aussah, als gehörte sie zu dessen Beschriftung.

Jetzt steht beides neben dem Ring: die Schätzung und darunter der Grund dafür.
Am Netz entfällt die Ersatzzeile „Am Netzteil" ganz — die Zeile darunter sagt
ohnehin, was los ist. Im Akkubetrieb ohne Schätzung bleibt „wird berechnet".

Dazu: der Rücksetzknopf im Mixer schaltet den Overdrive mit ab. Er ist keine
eigene Einstellung, sondern die Freigabe für einen Wert, den es danach nicht
mehr gibt — bliebe er stehen, zeigte die Zeile 100 % und der Regler stünde
trotzdem im Boost-Bereich. Der Knopf erscheint deshalb auch, wenn nur der
Overdrive an ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:05:20 +02:00
Scarriffle
ed8b095377 Overdrive je Programm: bis 400 %, aber nur auf Ansage
Der Regler ging bisher immer bis 200 %, ohne dass man das wollen musste. Jetzt
endet er bei 100 % — der Stellung, in der ein Programm klingt, wie es gemeint
war —, und ein Blitz in der Zeile gibt den Bereich darüber frei. Je Programm:
ein Browser braucht ihn für eine leise Konferenz, die Musik daneben nicht.

Oberhalb von 100 % gilt die Kennlinie nicht mehr. Dort steht die Zahl für den
Faktor selbst: 400 % sind das Vierfache der Amplitude. Mit der Kennlinie wären
daraus 4^1,66 ≈ 10,4 geworden — eine Beschriftung, die etwas anderes verspricht,
als der Regler tut. Bei genau 100 % stimmen beide Hälften überein, die Kurve hat
also keinen Sprung, nur einen Knick. Unterhalb bleibt alles wie es war.

Beim Abschalten wird die Lautstärke zurückgeholt. Sonst bliebe ein Programm
dreifach verstärkt, während sein Regler am rechten Anschlag steht und 100 %
anzeigt. Dasselbe beim Laden alter Einstellungen: ein gespeichertes 180 % aus
der Zeit ohne Freigabe fällt auf 100 %.

Die 100 %-Markierung im Regler erscheint nur noch mit Overdrive — ohne ihn läge
sie am rechten Anschlag und sagte dort nichts mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:02:43 +02:00
Scarriffle
d001e439a6 Mixer war still: er hat sich selbst abgegriffen
Der Mixer gibt den Ton der bearbeiteten Programme selbst aus — und steht damit
als Tonquelle in seiner eigenen Liste. Eine Zeile wird geregelt, sobald man ihren
Regler anfasst. Ein Klick auf Onyx' eigene Zeile genügte also.

Der Selbst-Tap läuft wie jeder andere mit mutedWhenTapped und schaltet damit
Onyx' gesamte Ausgabe stumm — die Wiedergabe *aller* bearbeiteten Programme.
Völlige Stille, während die Pegelanzeigen weiter ausschlagen, weil die Taps ja
Signal bekommen. Und der Zustand stand in den Einstellungen, überlebte also
jeden Neustart.

Gemessen (Spikes/mixer-path-probe.swift): Onyx' Ausgabepegel klettert von 0,388
auf 2,833, sobald er sich selbst bearbeitet. Der Weg an sich ist in Ordnung —
Tap, Aggregate-Gerät und IOProc liefern einzeln wie zu dritt volle Pegel, das
war der Hauptverdacht und ist widerlegt.

Onyx erscheint jetzt nicht mehr in der eigenen Liste, lässt sich nicht auswählen,
und ein gespeicherter Eintrag wird beim Laden entfernt und zurückgeschrieben.

Dazu ein zweiter Fund aus derselben Ecke: setVolume und setMuted griffen auf
taps[bundleID] zu. Die Taps liegen aber unter Kennung#Prozessnummer, weil ein
Programm mehrere Tonquellen haben kann — der Zugriff ging immer ins Leere. Der
Regler wirkte erst, wenn die Auffrischung drei Sekunden später nachzog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 17:57:15 +02:00
Scarriffle
ed5bd01482 Wetterkachel füllt die Kachel und ist lesbar
Alles stand im oberen Drittel, die untere Hälfte war leer — und die
Stundenwerte in acht Punkten, die man auf Armlänge nicht entziffert.

Der Aufbau folgt jetzt der Reihenfolge, in der man liest: erst was jetzt ist
— großes Symbol, große Temperatur, Höchst- und Tiefstwert daneben, darunter
der Ort und die gefühlte Temperatur —, dann getrennt durch eine Linie, was
noch kommt.

Vier Stunden statt fünf: bei 180 Punkten Breite bleiben so gut vierzig je
Spalte, und die Zahlen lassen sich in einer Größe setzen, die man auch liest.
Die fünfte Stunde war der Grund, warum alles zusammengedrückt war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:07:26 +02:00
Scarriffle
504c518cfb Akku-Popover sagt, was gerade geschieht
„Am Netz" beantwortet die Frage nicht, die man hat, wenn der Ladestand
stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist, oder
stimmt etwas nicht? Jetzt steht es dort — lädt, angehalten wegen Ladelimit
mit der Prozentzahl, am Netz ohne zu laden, oder Akkubetrieb.

Der Vergleich mit dem Limit hat zwei Punkte Toleranz: der Ladestand pendelt
um die Grenze, und auf das Prozent genau zu vergleichen hieße, den Grund die
halbe Zeit zu verschweigen. Weit unter dem Limit wird dagegen kein
Limit-Halt behauptet — dann steht das Laden aus einem anderen Grund, und
einen falschen zu nennen wäre schlimmer als keinen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:31:19 +02:00
Scarriffle
312514e6ce Eigene Aggregate-Geräte gehören nicht in die Geräteauswahl
Für jedes geregelte Programm legt Onyx ein Aggregate-Gerät an. Sie sind als
privat markiert — das verbirgt sie aber nur vor **fremden** Prozessen, nicht
vor Onyx selbst. In der Ausgabeauswahl standen sie deshalb mitten zwischen
den echten Geräten, und wer eines davon wählt, hängt seinen Ton in eine
Schleife: die Ausgabe eines Taps wird zur Quelle desselben Taps.

Erkannt werden sie über einen Vorsatz in der Gerätekennung, nicht über den
Namen. Der Name enthält den Programmnamen und ließe sich von einem echten
Gerät nicht sicher unterscheiden — die Kennung vergibt Onyx selbst.

Der Vorsatz nennt Onyx beim Namen: taucht so ein Gerät doch einmal irgendwo
auf, im Audio-MIDI-Setup etwa, sieht man, wer es angelegt hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:24:12 +02:00
Scarriffle
c990ea558b Blitz blendet zwischen zwei fertigen Fassungen über
Deine Idee war die bessere. Eine helle Fläche mit dunklem Rand ließ sich aus
einem einzelnen Symbol nur nachbauen, indem dasselbe Zeichen zweimal in
verschiedener Größe übereinander gezeichnet wurde — und die
Größenverhältnisse sahen nie stimmig aus, wie man an den drei Anläufen
sieht. Zwei fertige Fassungen ineinander zu blenden ist der geradere Weg:
gezeichnet wird genau das, was in der Datei steht.

Erst die dunkle voll deckend, dann die helle mit steigender Deckkraft
darüber. Zeichnete man beide anteilig, schiene dazwischen der Hintergrund
durch und der Blitz wirkte blass.

Ohne eigenen Rand bekommt der Blitz den vollen Innenraum des Akkus statt
Innenraum minus Rand — er ist damit wieder etwas größer, bleibt aber
innerhalb des Rahmens. Der Test hält die neue Grenze fest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:20:06 +02:00
Scarriffle
80b083259e Blitz misst sich am Innenraum, nicht an der Außenhöhe
Bezugsgröße war die volle Höhe des Akkus. Der Rahmen nimmt aber oben und
unten je einen Punkt, die Füllung sitzt zwei Punkte eingerückt, und um das
Zeichen kommt noch sein eigener Rand — so füllte der Blitz mitsamt Rand elf
von zwölf Punkten und stieß an den Rahmen.

Jetzt ist der Innenraum die Bezugsgröße: sechs Punkte Zeichen plus zwei Rand
in acht Punkten Innenraum. Ein Test hält fest, dass jedes Zeichen samt Rand
hineinpasst — und dass es nicht ins andere Extrem fällt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:13:41 +02:00
Scarriffle
2bf76eb060 Fehlendes return nachgetragen
Der vorige Commit war nicht übersetzbar: mit einer Zeile vor dem `switch`
ist er kein Ausdruck mehr und braucht ein ausdrückliches `return`. Meine
Prüfung lief in dem Moment gegen ein anderes Verzeichnis und hat den Fehler
nicht gemeldet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:10:37 +02:00
Scarriffle
782d2af4fd Blitz kleiner: der Rand braucht auch Platz
Das Zeichen bekam fast die volle Akkuhöhe, und der Rand kommt außen noch
dazu — dadurch stieß es oben und unten an den Rahmen. Die nutzbare Höhe
zieht den Rand jetzt ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:09:48 +02:00
Scarriffle
e2f2ea3427 Blitz gefüllt
Die Vorlage war reine Kontur (`fill:none`) — als Umriss im Akku war der
Blitz zu zurückhaltend. Jetzt trägt sie dieselbe Farbe auch als Fläche; der
Strich bleibt stehen und macht die Form kräftiger.

Gezeichnet wird weiter zweifach: einmal größer in der Gegenfarbe als Rand,
einmal in der fadenden Farbe darüber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:06:23 +02:00
Scarriffle
2490a90519 Blitz aus der mitgelieferten Vorlage, weiterhin fadend
`bolt.fill` aus SF Symbols ist eine massive Fläche; die neue Datei ist eine
Kontur mit kräftigem Strich und trifft die Form besser.

Gezeichnet wird sie zweifach wie zuvor: einmal etwas größer in der
Gegenfarbe als Rand, einmal in der fadenden Farbe darüber. Damit bleibt der
Blitz in jedem Moment des Fadens lesbar — über der hellen Füllung trägt ihn
der Rand, über dem leeren Teil er sich selbst.

Fehlt die Datei, bleibt das Systemsymbol. Dieselbe Vorlage auch in der
Akku-Kachel, damit nicht zwei verschiedene Blitze in einer App stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:05:34 +02:00
Scarriffle
49e1a46928 Widget-Optionen statt „Quellen", Reiterleiste von der Trennlinie herunter
Die Leiste saß mittig auf der Trennlinie unter dem Titelbalken.
`titlebarAppearsTransparent` allein reichte nicht — macOS zieht darunter
zusätzlich einen Trenner quer durchs Fenster. Der ist jetzt aus. Man setzt
keinen Titel auf eine Trennlinie.

„Quellen" war die Sicht des Programmierers: für den Nutzer ist die
Datenquelle eine Einstellung **des Kalenderwidgets** unter mehreren. Der
Bereich heißt jetzt „Widget-Optionen" und hat einen Abschnitt je Widget.

Kalender: woher die Daten kommen, Monatsraster oder Terminliste — und
endlich, welche Kalender überhaupt mitzählen. Gespeichert werden die
**abgewählten**, nicht die gewählten: kommt ein neuer Kalender dazu, ist er
damit automatisch dabei, statt dass man ihn suchen müsste, ohne zu wissen,
dass es ihn gibt. Der Filter lässt die Abdeckung unberührt — welche Kalender
man sehen will, sagt nichts darüber aus, für welchen Zeitraum Daten
vorliegen.

Wetter: dem Standort folgen oder einen festen Ort setzen, per Stadt oder
Postleitzahl. WeatherKit will Koordinaten, der Nutzer kennt Ortsnamen — die
Umsetzung passiert einmal beim Einstellen statt bei jedem Abruf. Der Ort
überlebt jetzt auch den Neustart; vorher stand nach jedem Start wieder der
Aufenthaltsort da.

Die Ablage-Einstellungen sind vom Widget-Reiter hierher gewandert, wo die
anderen Widget-Optionen stehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:00:48 +02:00
Scarriffle
ab96086db7 Reiterleiste liegt nicht mehr auf der Trennlinie
Sie saß genau auf der Kante unter dem Titelbalken. `TabView` in einem
gewöhnlichen Fenster bekommt den Abstand nicht von selbst — in Apples
`Settings`-Szene schon, und die benutzt Onyx bewusst nicht, weil sie über
einen privaten Selektor öffnet und bei Apps ohne Menüleiste unzuverlässig
ankommt.

Zwölf Punkte Abstand nach oben, und der Titelbalken geht durchsichtig in den
Inhalt über, statt ihn mit einer Linie abzuschneiden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:53:35 +02:00
Scarriffle
784b210bc2 Vorschau mit Schreibtischhintergrund, Fenster breit genug für die Reiter
Die Vorschau der Auslösefläche war eine schwarze Fläche — als Bildschirm
kaum zu erkennen. Jetzt liegt der echte Schreibtischhintergrund darunter.
Apples eigene 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.

Dabei fiel auf, dass Notch und Auslösefläche oberhalb des Bildschirms
schwebten statt in ihm zu sitzen: die Rundung lag auf dem Hintergrund
allein, nicht auf dem Ganzen. Sie schneidet jetzt Hintergrund, Menüleiste
und Fläche gemeinsam ab.

Und das Einstellungsfenster war zu schmal für sechs Reiter — die Leiste
wurde rechts abgeschnitten, „Berechtigungen" war nicht mehr erreichbar. 780
statt 620 Punkte, Mindestbreite 740. Beim Nachrechnen der reinen Textbreiten
kommt man auf 560 und liegt damit deutlich daneben: macOS legt je Reiter
Innenabstand und Trenner dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:51:37 +02:00
Scarriffle
090f570aba Programmname als Hinweis am Symbol
Seit die Namen aus den Zeilen verschwunden sind, trägt das Symbol die
Erkennung allein — und manche Programme erkennt man daran eben nicht.

Der Hinweis hängt am Symbol und nicht an der Zeile: die Zeile besteht zum
größten Teil aus Regler und Knöpfen, und die verschlucken ihn. Am Symbol
kommt er an, und dort sucht man ihn auch.

Bei mehreren Tonquellen steht ihre Anzahl dabei. Chrome erscheint als eine
Zeile, gibt seinen Ton aber über mehrere Hilfsprozesse aus — die Zahl
erklärt, warum ein Regler mehr als einen Prozess bewegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:48:51 +02:00
Scarriffle
cfbbf91068 Mixer kann beim Start von Onyx mitlaufen
Ein Häkchen im Mixer selbst. Standardmäßig aus: der Mixer greift in den
Tonweg der geregelten Programme ein, und das soll niemandem ungefragt
passieren — beim ersten Start soll nichts umgeleitet werden, was der Nutzer
nicht angeschaltet hat.

Das Häkchen steht in beiden Zuständen: eingeschaltet, um es wieder
abzuwählen, ausgeschaltet, um es gleich mit zu setzen. Sonst müsste man den
Mixer erst einschalten, um sagen zu können, dass er von selbst einschalten
soll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:47:17 +02:00
Scarriffle
d7125e28f6 Ablage und AirDrop teilen sich eine Kachel
Zwei eigene Kacheln waren zu viel des Guten: beide zeigen im Ruhezustand
nichts als eine leere Fläche mit einem Satz darin, und zusammen nahmen sie
ein Drittel des Panels.

Jetzt übereinander in einer Kachel — oben die Ablegefläche fürs Senden,
darunter die Ablage mit ihrer Liste. AirDrop steht dabei nebeneinander statt
übereinander: in 52 Punkten Höhe ist für Symbol über Text kein Platz.

Das eigenständige AirDrop-Widget entfällt. Gespeicherte Layouts, die es noch
nennen, verlieren den Eintrag beim Laden — dafür ist der Filter da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:42:39 +02:00
Scarriffle
57093cb63b Menüleiste: Prozentzeichen ohne Leerzeichen
Die Breite ist fest für „100 %" reserviert, damit nichts springt, wenn der
Wert eine Stelle mehr bekommt. Das Leerzeichen kostet in dieser Reservierung
drei Punkte je Element — in der Menüleiste ist Platz das knappste Gut.

Nur dort. Im Panel und in den Popovern bleibt das Leerzeichen stehen; da ist
Platz, und typografisch gehört es hin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:40:06 +02:00
Scarriffle
4c96aced5a Diagramme rechnen mit der vollen Länge, eigene Symbole für CPU und GPU
Frisch gestartet hat der Verlauf fünf Messwerte statt sechzig. Gerechnet
wurde aber „Breite geteilt durch Anzahl" — also war jeder Balken ein
Fünftel breit, und mit jedem weiteren wurden sie schmaler. Das Diagramm sah
nach einem Ausschlag aus und bedeutete nichts.

Jetzt kommt die Schrittweite aus der vollen Länge. Fehlende Werte bleiben
als freie Fläche links stehen; der Verlauf wächst nach links weg, wie bei
jedem Zeitdiagramm.

Das steckte an **drei** Stellen, alle mit derselben Rechnung: dem
Balkendiagramm im Netzwerk-Popover, der Sparkline in den Kacheln und der
Linie in den Menüleisten-Modulen. Die gemeinsame Rechnung liegt jetzt in
`GraphLayout` und ist geprüft, statt dreimal danebenzugehen.

Nebenbei: die Fläche unter der Sparkline begann am linken Rand statt am
ersten Messwert — ein Keil über die ganze Breite, wo noch nichts gemessen
war.

CPU und GPU bekommen eigene Symbole aus chip.svg und graphic-card.svg. SF
Symbols hat für beides nur denselben Chip, und „cpu" neben „cpu.fill" ist
kein Unterschied, den man in der Menüleiste erkennt.

Die Beschriftung unter den Ringen klebte am Rand und sah aus, als gehörte
sie noch hinein — sechs Punkte Abstand statt zwei. Und die Popover für CPU
und GPU tragen jetzt einen Titel: bei drei gleich aussehenden Ringen sieht
man sonst nicht, vor welchem man steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:33:28 +02:00
Scarriffle
43ce40676b Eigenes Symbol für den Mixer in der Menüleiste
Die Datei lag im Projektstamm; von dort wird nichts mitgeliefert. Jetzt in
Onyx/ und damit im Bundle.

Gezeichnet wird sie in `labelColor`, nicht in Weiß: unter einer hellen
Menüleiste wäre ein weißes Symbol unsichtbar. Auf dunklem Grund ist
labelColor weiß, also genau das Gewünschte — und auf hellem Grund schwarz,
statt zu verschwinden.

Eigene Vorlagen lassen sich nicht wie SF-Symbole einfärben: dort greift die
Symbolkonfiguration nicht, und `color.set()` allein färbt ein Template-Bild
ohnehin nicht. Der Weg ist, das Bild zu zeichnen und die Farbe mit
`sourceAtop` darüberzulegen — das trifft die deckenden Bildpunkte und lässt
den Rest frei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:23:21 +02:00
Scarriffle
c0c5809c96 Rücksetzknopf im Mixer, mit reserviertem Platz
Ein kleiner Pfeil je Programm stellt die Lautstärke auf 100 % zurück. Er
erscheint nur, wenn der Wert davon abweicht — es gibt sonst nichts
zurückzusetzen.

Der Platz dafür ist trotzdem **immer** reserviert. Ein Knopf, der auftaucht
und dabei den Regler schmaler macht, verschiebt genau das, was man gerade
bedient: man zieht auf 90 %, der Knopf erscheint, der Regler schrumpft, und
der Griff sitzt woanders als die Hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:18:27 +02:00
Scarriffle
c44bb3af30 Menüleiste springt nicht mehr, Mixer fasst Hilfsprozesse zusammen
Die Breite folgte der Stellenzahl des aktuellen Werts. Aus „14 KB/s" wird
„431 KB/s", und mit der zusätzlichen Stelle wanderte die halbe Leiste. Jetzt
wieder feste Breiten, bemessen am breitesten Wert, der vorkommen kann.

Der frühere Fehler war nicht die feste Breite, sondern der falsche
Bezugswert: gezeichnet wird „63°", bemessen wurde „100 °C" — fünfzehn Punkte
für zwei Zeichen, die nie erscheinen. Nachgemessen: zwei Aufnahmen im
Abstand von sechs Sekunden mit geänderten Werten ergeben identische Breiten.

Der Mixer fasst Hilfsprozesse mit ihrem Programm zusammen. „Google Chrome"
und „Google Chrome Helper" standen als zwei Zeilen nebeneinander, mit
getrennten Reglern für dasselbe Programm — zieht man den einen, hört man
den anderen. Chrome hat je Tab einen Helfer; fünfzehn Regler für einen
Browser sind keine Auskunft.

Abgegriffen wird weiterhin je Prozess, geregelt aber je Programm. Der
Schlüssel der Taps muss deshalb den Prozess mitführen: mehrere Helfer teilen
sich eine Kennung und überschrieben sich sonst gegenseitig. Der Pegel einer
Zeile ist der lauteste ihrer Prozesse.

Die Zeilen sind einzeilig statt zweizeilig: Symbol, Stummschalter, Regler,
Wert. Der Name ist weg — bei einem erkennbaren Programmsymbol ist er
redundant, und die gewonnene Zeile macht die Liste halb so hoch. Dafür ist
das Symbol größer, und wer den Namen braucht, hält den Zeiger darauf.
Popover 420 statt 330 Punkte breit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:15:53 +02:00
Scarriffle
e79191d033 Pausenzeichen wieder ausgestanzt
„Weiß mit dünnem Rand" war für den Blitz bestellt, nicht für das
Pausenzeichen — ich hatte es auf beide angewendet. Bei fast vollem Akku
liegt das Zeichen auf weißer Füllung: das Weiß verschwindet darin, übrig
bleiben die dunklen Ränder als zwei Striche.

Das Pausenzeichen ist damit wieder ein Loch in der Füllung. Der Blitz
bleibt weiß mit Rand und fadend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:07:32 +02:00
Scarriffle
8545cc421c Blitz weiß mit Rand — und er fadet weiter
Beides, nicht eines von beiden: das Zeichen ist weiß mit dünnem dunklem
Rand, und beim Laden fadet es.

Der Rand ist dabei immer die Gegenfarbe zur Füllung. Damit bleibt das
Zeichen in jedem Moment des Fadens lesbar — über der hellen Füllung trägt
es der Rand, über dem leeren Teil die Füllung selbst.

Das Pausenzeichen ist niedriger als der Blitz. Es ist von Haus aus höher
gebaut und stieß oben und unten an den Rahmen des Akkus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:04:31 +02:00
Scarriffle
20963f53e2 Am Netz ohne Laden: Pausenzeichen statt Stecker
„Laden angehalten" ist die Aussage. Ein Stecker sagt nur „Kabel steckt",
und das sieht man am Kabel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:00:44 +02:00
Scarriffle
5d261ed3d8 Menüleiste: Breite für die Gestalt, nicht für den Höchstwert
Nachgemessen statt geraten. Das Netzwerkelement war 69 Punkte breit, meine
Rechnung ergab 47 — und die anderen Module lagen konstant 16 darüber. Sechs
Punkte hatte allein das Netzwerk zusätzlich.

Zwei Ursachen. Die Hysterese hielt die Breite auf dem Höchstwert der letzten
zwanzig Sekunden; bei einem Wert, der im Sekundentakt schwankt, ist der fast
immer größer als der aktuelle. Und der Text wurde linksbündig gezeichnet, die
Differenz stand also komplett rechts als Lücke.

Jetzt wird die Breite für die **Gestalt** reserviert: alle Ziffern auf 9
gesetzt, das ergibt die breiteste Zeichenfolge derselben Form. Von 14 auf
37 KB/s bewegt sich damit nichts, und breiter wird es nur, wenn eine Stelle
dazukommt. Gezeichnet wird mittig, damit ein Rest sich auf beide Seiten
verteilt statt rechts zu stehen. Ergebnis: 69 → 57 Punkte.

Die verbleibenden rund 16 Punkte je Element legt macOS selbst um jedes
Statuselement; die Lücken zwischen benachbarten Elementen sind gemessen 0.

Neue Menüleistenmodule starten jetzt mit „Symbol + Wert" statt „Wert" —
bestehende Einstellungen bleiben, wie sie sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:57:13 +02:00
Scarriffle
8f0831391e Blitz fadet, Einstellungsfenster mittig und veränderbar
Der Blitz fadet beim Laden zwischen Weiß und Schwarz — so gewünscht, und
ich hätte vorher fragen sollen statt eine eigene Lösung zu bauen und sie
hinterher zu begründen.

Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
Umkehrpunkten sichtbar. Der Zeitgeber läuft mit 30 Bildern je Sekunde und
nur, solange geladen wird — steht das Kabel nicht drin, läuft gar nichts.
Der Stecker für „am Netz, lädt nicht" bleibt ausgestanzt; dort bewegt sich
ohnehin nichts.

Das Einstellungsfenster lässt sich jetzt vergrößern und startet mit
620 × 620 statt 520 × 420. Der Bereich „Displays" passt mit der Vorschau
der Auslösefläche nicht mehr in die alte Höhe, und ein Fenster, in dem man
scrollt, obwohl der Bildschirm frei ist, ist eine Zumutung. Die feste Größe
in der View musste dafür weichen — sonst wächst das Fenster und der Inhalt
bleibt stehen.

Und es öffnet jetzt wirklich mittig. Mein erster Versuch mit 60 Punkten
Abstand von oben war zu zaghaft: der Titelbalken lag weiter so hoch, dass
man auf dem Weg zu seinen Knöpfen durch die Auslösefläche fuhr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:52:21 +02:00
Scarriffle
a84e5a0c8e Auslösefläche einstellbar, Blitz immer lesbar, Menüleiste schmaler
Der Blitz war ein Loch in der Füllung — über dem gefüllten Teil liest man
ihn, über dem leeren ist es Transparenz auf Transparenz. Bei wenig Ladung
sitzt er fast ganz im Leeren und verschwindet.

Statt zwischen Schwarz und Weiß zu faden geht er den Weg von macOS: geteilt
gezeichnet, über der Füllung ausgestanzt, daneben ausgemalt. Damit ist er
bei jedem Ladestand lesbar, ohne Bewegung in der Menüleiste. Ein Test
prüft, dass sich die beiden Teile an der Kante treffen statt zu überlappen —
sonst wäre der Blitz an der Nahtstelle dicker.

Die Auslösefläche der Notch lässt sich jetzt einstellen, mit Vorschau. Wie
genau jemand die Notch trifft, hängt an Hand und Zeigegerät; feste Werte im
Code waren dafür die falsche Antwort. Die Vorschau zeigt einen
nachgebildeten Bildschirm mit Notch und eingefärbter Fläche — „24 Punkte
unter der Notch" ist keine Vorstellung, die jemand im Kopf hat.

Die Menüleistenbreiten folgen jetzt dem, was dasteht. „Temp 53°" stand in
einer Box für „Temp 100 °C", und in der Menüleiste ist Platz das knappste
Gut. Dieselbe Hysterese wie beim Netzwerk: sofort wachsen, zögernd
schrumpfen.

Das Mixer-Symbol war ausgeschaltet auf 55 % abgeblendet. Das war meine Idee
und ein Fehlgriff: über einem hellen Schreibtischhintergrund ist es damit
praktisch weg. Ob der Mixer läuft, erfährt man beim Klick — dafür muss man
das Symbol aber erst finden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 12:46:02 +02:00
Scarriffle
bf799d57d9 Systemlautstärke und Gerätewahl im Mixer
Ausgabe und Eingabe stehen jetzt über den Programmen — mit Lautstärke,
Stummschalter und der Gerätewahl direkt am Namen. Ein eigenes Auswahlfeld
daneben wäre eine zweite Stelle für dieselbe Sache.

Sie stehen **außerhalb** des Mixer-Schalters, und das ist der Punkt: hier
wird kein Ton abgegriffen, sondern es werden die Regler des Systems bedient.
Das braucht keinen Tap, kein Aggregate-Gerät und keine Berechtigung — wer
nur die Lautstärke sucht, soll dafür nichts einschalten müssen.

Zwei Dinge, die beim Bauen nicht offensichtlich waren:

Viele Geräte führen keinen Regler für Element 0, sondern nur je Kanal.
Fragt man bloß den Hauptregler, hält man ein regelbares Gerät für
unregelbar. Also erst der Hauptregler, dann die Kanäle — und beim Setzen
beide Kanäle, sonst wandert das Stereobild.

Manche Geräte bringen gar keinen Regler mit, HDMI etwa. Dort steht „hier
nicht regelbar" statt eines Reglers, der nichts tut.

Warntöne folgen dem Ausgabegerät mit. Zwei getrennte Wahlen sind die
Quelle für „warum kommt der Systemton aus dem Notebook".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 19:28:39 +02:00
Scarriffle
68e53d3f6f Panel deckt die Menüleiste nicht mehr zu
Die Fläche reichte über die ganze Breite bis an die Bildschirmoberkante,
damit sie mit der schwarzen Notch zu einem Körper verschmilzt. Bei einer
Kachel ging das auf; bei sechs deckt sie die komplette Menüleiste zu, samt
aller Statuselemente.

Die Silhouette ist jetzt ein T: oben ein Steg in Notch-Breite, ab Unterkante
der Menüleiste die volle Fläche. Die Notch bleibt verschmolzen, die
Menüleiste bleibt frei.

Das allein hätte nur so ausgesehen, als wäre es gelöst. Das Fenster liegt
weiterhin über der Leiste, dort nur durchsichtig — und ein durchsichtiges
Fenster schluckt Klicks trotzdem. Die Trefferprüfung reicht sie jetzt
durch: links und rechts der Notch gehört der oberste Streifen der
Menüleiste.

Aus demselben Grund fragt der Koordinator nicht mehr den Fensterrahmen,
sondern die sichtbare Fläche. Wer den Rahmen befragt, hält einen Klick auf
ein fremdes Statuselement für einen Klick ins Panel — und einen Zeiger über
der Menüleiste für einen im Panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 17:44:07 +02:00
Scarriffle
eff3c30a71 Mixer im Zeilenlayout, Akku zeigt „am Netz, lädt nicht"
Am Netz und trotzdem kein Blitz sah aus wie Akkubetrieb — dabei ist es der
Normalfall, sobald das Ladelimit greift. Jetzt steht in diesem Zustand ein
Stecker im Akku, ausgestanzt wie der Blitz. Drei Zustände, drei Zeichen:
lädt, hängt am Netz, läuft auf Akku.

Der Mixer folgt jetzt der Anordnung von SoundSource, weil sie dort gut
gelöst ist: eine Zeile je Programm, links das Symbol als Kachel, dann der
Stummschalter, dann der Regler, rechts der Wert. Vorher stand der
Stummschalter rechts zwischen den Zusatzelementen, obwohl er zur Lautstärke
gehört, und die Prozentzahl sprang zwischen zwei Zeilen.

Die Farben folgen SoundSource **nicht**. Dort ist jeder Regler grün; in Onyx
ist Farbe ein Signal — die Verstärkung über 100 % ist eines, eine
gewöhnliche Lautstärke nicht.

Nebenbei zwei Fehler im Regler: der Knopf stand an den Enden über die Bahn
hinaus, und die Ziehposition rechnete ohne seine Breite. Am rechten Anschlag
ließen sich die letzten Prozent deshalb nicht einstellen.

Das Popover ist die Hauptfläche und jetzt 330 statt 280 Punkte breit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:05:37 +02:00
Scarriffle
9968236a37 Akku wird gezeichnet wie der von macOS
Der Blitz stand neben dem Akku statt darin, und das sah nach zwei Dingen
aus statt nach einem. Mit SF Symbols ging es nicht anders: fünf feste
Füllstufen, und Blitz-Varianten gibt es nur bei hundert Prozent.

Also selbst gezeichnet — ein Rahmen, ein Knubbel, eine Füllung, ein Blitz.
Das ist am Ende die kleinere Lösung als die Symbolakrobatik und gibt
obendrein, was vorher fehlte: die Füllung läuft stufenlos, 60 % sieht aus
wie 60 % und nicht wie 50 %.

Der Blitz wird ausgestanzt, nicht daraufgelegt. `destinationOut` nimmt weg,
was vorher gezeichnet wurde; übrig bleibt ein Loch in Blitzform, durch das
die Menüleiste scheint. Das funktioniert auf hellem wie dunklem Grund, ohne
die Hintergrundfarbe zu kennen — und genau so sieht der von macOS aus.

Unter zwanzig Prozent wird die Füllung rot, beim Laden nie: eine Warnung
vor etwas, das sich schon erledigt, ist keine.

Dieselbe Form auch in der Akku-Kachel. Zwei verschiedene Akkus in einer App
wären schlechter als gar keiner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 15:49:04 +02:00
Scarriffle
4b5e86b3f4 Akkusymbol folgt dem Ladestand
Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
keine Anzeige. Wer in der Menüleiste nur das Glyph stehen hat, will es ohne
die Zahl daneben lesen können.

Fünf Füllstufen, gerundet zur nächsten: 60 % soll nicht halbleer aussehen
und 40 % nicht dreiviertel voll.

Beim Bauen fiel auf, dass die naheliegende Lösung nicht geht. Die erste
Fassung hängte beim Laden ein „.bolt" an den Füllstand — von diesen
kombinierten Symbolen existiert aber allein `battery.100percent.bolt`, und
`battery.slash` gibt es auch nicht. Die übrigen wären leere Felder in der
Menüleiste gewesen. Nachgemessen statt angenommen, und ein Test prüft jetzt
für jeden erzeugten Namen, dass es ihn wirklich gibt.

Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die falsche
Wahl gewesen: dass geladen wird, sieht man am Netzstecker; wie weit es ist,
sieht man nirgends sonst. Also bleibt der Füllstand, und der Blitz kommt als
kleines Zeichen darüber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 13:30:13 +02:00
Scarriffle
667fb61c96 Absturz beim XPC-Rückruf, Menü zurück, Fenster weg von der Notch
Der Absturzbericht war eindeutig:

  Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
    swift_task_isCurrentExecutorWithFlags
    closure #3 in FanControl.proxy()
    EXC_BREAKPOINT

Die Rückrufe der XPC-Verbindung erben die MainActor-Isolation der Methode,
in der sie stehen. XPC ruft sie aber auf seiner eigenen Warteschlange auf,
Swift 6 prüft das zur Laufzeit und beendet den Prozess. Das
`Task { @MainActor in … }` im Rumpf half nicht: die Prüfung geschieht beim
Betreten des Abschlusses, nicht beim Zugriff.

Alle sieben Rückrufe sind jetzt `@Sendable`. Und weil das eine Fehlerklasse
ist und kein Einzelfall, dieselbe Behandlung für die übrigen Stellen, an
denen ein MainActor-Typ einen Abschluss an eine Systemschnittstelle gibt:
Papierkorb, Vorschaubilder, Adapter-Ende, Darwin-Nachricht.

Dazu drei Dinge aus dem Bericht von eben:

Der Linksklick aufs Menüleistensymbol fuhr das Panel aus — und ging dabei
als Fixieren durch. Danach stand das Panel offen und reagierte auf nichts
mehr. Das war schlechter als das Problem, das es lösen sollte. Ein
Statuselement zeigt bei einem Klick sein Menü; alles andere überrascht.
„Panel öffnen" bleibt draußen.

Ein Klick daneben schließt jetzt auch ein fixiertes Panel. „Klick fixiert"
ist eine gute Regel, aber wer sie nicht kennt, sitzt sonst vor etwas, das
offen steht und nicht reagiert — und sucht den Fehler in der App.

Einstellungs- und Einrichtungsfenster gehen nicht mehr direkt unter der
Notch auf. `center()` setzt oberhalb der Mitte; der Schließknopf landete
damit so weit oben, dass man auf dem Weg dorthin die Notch auslöste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:54:32 +02:00
Scarriffle
70a665f532 Menüleiste: Breite mitwachsend, Symbole unverzerrt, Sensor wählbar
Die Netzwerkkachel war auf „↓ 999,9 MB/s" dimensioniert und stand damit
neben „↓ 13 KB/s" zur Hälfte leer. Auf den schlimmsten Fall auszulegen ist
für einen Wert, der sich um den Faktor tausend ändert, die falsche Antwort.
Gemessen wird jetzt, was dasteht — mit unsymmetrischer Hysterese: sofort
wachsen, denn abgeschnittene Messwerte sind falsche Messwerte, und erst nach
zehn ruhigeren Messungen schrumpfen, damit ein Lastausschlag die Leiste nicht
zum Pumpen bringt.

Das Akkusymbol war zerquetscht. Ein Batteriesymbol ist doppelt so breit wie
hoch und wurde in ein Quadrat gezeichnet. Symbole behalten jetzt ihr
Seitenverhältnis, und die Breite des Elements richtet sich nach dem, was das
Symbol tatsächlich braucht — sonst hätte ein breites Glyph dieselbe Spalte
wie ein schmales.

Bei den Sensoren stand stumm das Maximum über alle Punkte: eine Zahl, von
der niemand weiß, woher sie kommt. Jetzt wählbar, und der gewählte Sensor
gibt sein eigenes Kürzel — „Temp 46°" sagt weniger als „Heatpipe 46°".
Voreinstellung bleibt der wärmste Punkt, weil das die häufigste Frage ist.

Und die Lüfterkachel zeigte „2500" ohne Einheit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:46:06 +02:00
Scarriffle
386eb6ae70 Ladelimit ist eine Anzeige, Akku-Kachel sagt etwas, Menüleiste bekommt Kürzel
Der Regler war eine Pseudobedienung. Der SMC lehnt Schreibzugriffe auf CHLT
ab — der Helfer hat es mit korrekter Größe versucht und „geschrieben (false),
zurückgelesen 85" gemeldet. Das Sicherheitsnetz hat also die Wahrheit gesagt;
die Oberfläche hat sie nur nicht gezeigt.

Weiter zu raten hieße, im Lade-Subsystem Keys durchzuprobieren, und das ist
die eine Operation in diesem Projekt, die Hardware dauerhaft beschädigen
kann. Also: das Limit wird gelesen und angezeigt, mit einem Weg in die
Systemeinstellungen, wo es tatsächlich geht. Der Schreibpfad ist aus dem
privilegierten Helfer entfernt — ein Root-Dienst trägt nur, was benutzt wird.

Die Akku-Kachel zeigte „40 %" und einen Verlauf über die letzten Sekunden.
Der Ladestand ändert sich in Minuten, nicht in Sekunden; der Graph war eine
waagerechte Linie. Jetzt: verbleibende Zeit bzw. Zeit bis voll, Leistung in
Watt mit Richtung, Ladelimit, Zustand, Zyklen — und der Energiesparmodus.
Der erklärt, warum die Maschine langsamer wirkt, und ohne diesen Hinweis
sucht man den Grund woanders.

In der Menüleiste standen fünf nackte Prozentzahlen nebeneinander. Man sah
Zahlen und wusste nicht, welche wozu gehört. Zwei neue Darstellungen: Kürzel
und Wert („CPU 34 %") sowie Symbol und Wert. Beim Akku stehen sie zuoberst in
der Auswahl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:37:04 +02:00
Scarriffle
5ba9e551a4 Ladelimit gehört zum Akku, nicht zu den Lüftern
Der Regler stand in der Lüfterkachel, weil dort der Helfer schon angebunden
war. Das ist die Sicht des Programmierers, nicht die des Nutzers: gesucht
wird er beim Akku, und zwar genau dann, wenn man auf den Ladestand schaut.

Er steht jetzt in der Akku-Kachel des Panels und im Akku-Popover der
Menüleiste. Angebunden über ein Protokoll statt über einen direkten Zugriff:
geschrieben wird vom privilegierten Helfer, und der gehört zur App, nicht zu
MetricsProvider — die Akku-Ansicht soll ihn bedienen können, ohne ihn zu
kennen. Ohne laufenden Helfer ist der Regler sichtbar, aber gesperrt, mit dem
Hinweis, wo er herkommt. Ein Regler, der still nichts tut, ist schlimmer als
einer, der sagt warum.

Dazu Protokollierung, warum das Panel schließt: Zeigerposition,
Auslösefläche, Fensterrahmen. „Es ging zu, obwohl ich noch drin war" ist
ohne diese drei Zahlen eine Behauptung gegen eine andere — und meine
automatisierten Proben halten den Zeiger perfekt still, treffen den Fall
also nie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:20:00 +02:00
Scarriffle
a54b332723 Notch: Toleranz für zitternde Hände, „Panel öffnen" fliegt raus
Warum meine Proben grün waren und deine Hand nicht: jedes einzelne
Abtastbild außerhalb der Auslösefläche hat das Aufziehen sofort
abgebrochen. Ein Zeiger, den eine Hand an den oberen Rand führt, steht
dort aber nicht still — er wandert um ein paar Punkte. Damit fing die
Entprellzeit von 220 ms dauernd von vorn an und kam nie ans Ziel.
`CGWarpMouseCursorPosition` setzt den Zeiger auf einen Punkt und hält ihn
absolut still; deshalb war jede automatisierte Probe 19 von 19 grün.

Kurzes Herausrutschen bricht jetzt nicht mehr ab: die Entprellzeit läuft
weiter, parallel läuft die Abbruchfrist von 300 ms. Kommt der Zeiger
zurück, wird die Frist gestoppt — die Entprellzeit setzt bewusst nicht neu
an, sonst dauerte es bei jedem Grenzübertritt wieder von vorn.

Das allein hätte einen neuen Fehler gebracht, und ein vorhandener Test hat
ihn gefunden: beim beiläufigen Streifen der Notch wäre die Entprellzeit
abgelaufen, während der Zeiger längst weg war — das Panel wäre für ein
Zehntel aufgeblitzt. Die Maschine merkt sich deshalb, wo der Zeiger steht,
und öffnet nur, wenn er da ist. Ist die Entprellzeit abgelaufen, während er
draußen war, geht es beim Zurückkommen ohne weiteres Warten auf.

Die Auslösefläche reicht außerdem 14 statt 4 Punkte unter die Notch. Vier
Punkte sind keine Fläche, die eine Hand trifft.

„Panel öffnen" ist aus dem Menü verschwunden. Ein Menüeintrag für etwas,
das beim Berühren der Notch von selbst passiert, beschreibt einen Umweg um
die eigentliche Bedienung herum. Als Notausgang bleibt es erreichbar, nur
ohne eigene Zeile: Linksklick auf das Symbol fährt das Panel aus,
Rechtsklick zeigt Einstellungen und Beenden.

Dazu Protokollierung jedes Zustandswechsels und jeder Unterdrückung. Ohne
diese Spur ist ein Panel, das sich „manchmal nicht öffnet", nicht zu
untersuchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:12:31 +02:00
Scarriffle
b2f69f34ae Ladelimit: Key gemessen, Schreibpfad gebaut
Der Key heißt CHLT, drei Bytes. Identifiziert per Differenzmessung gegen
macOS' eigene Ladebegrenzung, ohne einen einzigen Schreibzugriff:

  80 %  →  50 01 00
  95 %  →  5F 01 00
  85 %  →  55 01 00

Erstes Byte ist die Prozentzahl, zweites offenbar „Limit aktiv", drittes
unbenutzt. Drei Messpunkte, weil einer Zufall sein kann: BACC hatte beim
ersten Vergleich ebenfalls gepasst — ein Zähler, in dem gerade 50 stand.

ChargerConfiguration aus ioreg sah zunächst wie eine zweite Quelle aus
(niedriges Byte 0x50 = 80), blieb aber unverändert, als CHLT längst auf
95 stand. Also Zufall, und gut, dass darauf nichts gebaut wurde.

Geschrieben wird nur das erste Byte, und nur zwischen 80 und 100 — dem
Bereich, den macOS selbst anbietet und in dem gemessen wurde. Darunter ist
ungemessenes Gebiet. Bei den Lüftern liefert die Firmware mit F0Mn/F0Mx
eigene Grenzen mit, an denen sich ein Sicherheitsnetz festhalten kann; hier
gibt es das nicht, hier gibt es nur die Messung. Ein falscher Wert im
Lade-Subsystem ist die eine Operation in diesem Projekt, die den Akku
dauerhaft beschädigen kann.

Nach jedem Schreiben wird zurückgelesen und gemeldet, was tatsächlich
steht — nicht, was gewünscht war. „Kein Limit" heißt 100 Prozent und nicht
das Aktiv-Byte auf null: diese Kodierung hat macOS nie geschrieben, sie
wäre also ungemessen.

Bedienung im Lüfter-Bereich der Einstellungen und in der Lüfterkachel,
also auch im Menüleisten-Popover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 12:01:50 +02:00
Scarriffle
c9366302fc Wetter: Zwischenspeicher auf Platte, Zeitlimits auf alle Netzaufrufe
Das Protokoll zeigte das Wetter nach 185 ms erfolgreich geladen — und
danach nichts mehr. Dazwischen liegt genau ein Aufruf, der noch ins Netz
geht: die Abfrage der Namensnennung. Sie hatte kein Zeitlimit, und ohne
sie wurde der Schnappschuss nie gebaut. Ein Netzaufruf ohne Zeitlimit ist
ein Aufruf, der hängen darf.

Jetzt hat jeder ein Limit: Wetter zwölf Sekunden, Ortung acht, Umkehrsuche
fünf, Namensnennung drei. Die Namensnennung hält den Messwert ohnehin nicht
mehr auf — sie ist Pflicht für die Anzeige, nicht für das Laden — und wird
behalten, weil sie sich nicht ändert.

Der Zwischenspeicher lag nur im Arbeitsspeicher. Beim Start stand deshalb
ein Rädchen da, bis der erste Abruf durch war; hing der, für immer. Er
liegt jetzt in weather.json und wird beim Anlegen des Modells gelesen: das
Widget zeigt sofort, was zuletzt bekannt war, mit Zeitstempel, und lädt im
Hintergrund nach. Ein Wert von vor einer Stunde ist eine Auskunft, ein
Rädchen ist keine.

Nachgeprüft: Datei wird geschrieben, Hamburg mit zwölf Stundenwerten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:28:06 +02:00
Scarriffle
453353de58 Notch öffnet zuverlässig, Einstellungen beenden Onyx nicht mehr
Das Öffnen hing allein an Mausereignissen. Der Auslösebereich liegt aber in
der Menüleiste, und dorthin stellt macOS Bewegungen nicht verlässlich an
fremde Programme zu — mal kommen sie an, mal nicht, je nachdem, was gerade
den Zeiger führt. Ein Verfahren, das eine Zustellung voraussetzt, die es
nicht gibt, kann nicht zuverlässig sein.

Die Zeigerposition wird deshalb jetzt immer abgefragt, im Ruhezustand
zehnmal je Sekunde, bei offenem Panel sechzigmal. Die Monitore sind nur noch
Beschleunigung. Kosten: 0,1 % CPU im Leerlauf, gemessen. Nachgeprüft mit
fünf Anläufen aus verschiedenen Richtungen — fünfmal offen.

Die Unterdrückung war zu grob: jedes Vollbildfenster galt als Grund, das
Panel wegzuhalten. Damit war die Notch tot, sobald Xcode im Vollbild lief.
Gemeint war immer nur der Film über den ganzen Bildschirm, und den erkennt
man daran, dass etwas den Bildschirm wachhält — ein Textfenster tut das
nicht. Beide Bedingungen zusammen, nicht eine davon.

Dazu ein Loch, das dabei auffiel: fiel die Unterdrückung weg, während der
Zeiger schon in der Notch stand, wartete die Maschine auf ein Ereignis, das
erst bei der nächsten Bewegung kam.

Der rote Knopf blendet Fenster jetzt aus, statt sie zu schließen. Onyx hat
kein Hauptfenster; „Fenster zu" heißt hier nie „fertig". Manche
Hilfsprogramme erzwingen aber genau diese Gleichung und beenden eine App,
sobald ihr letztes Fenster verschwindet — dann war Onyx nach einem Blick in
die Einstellungen weg, samt Menüleiste und Notch. Ein Fenster, das nie
wirklich schließt, gibt dafür keinen Anlass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:21:00 +02:00
Scarriffle
b22a44ba70 Mediensteuerung hochkant, Symbole wieder sichtbar, „Auto" als Zustand
Die Mediensteuerung stand nebeneinander: Cover links, alles andere in den
verbleibenden gut hundert Punkten. Daher „Bernhard H…" statt eines Titels.
Jetzt übereinander, wie es zu einer hochkanten Kachel passt.

Das Cover ragte zusätzlich über die Kachel hinaus, weil `clipShape` vor dem
Rahmen stand — beschnitten wurde damit auf die Eigengröße des Bildes, und
die ist bei einem Podcast-Cover ein Vielfaches. Erst Rahmen, dann
beschneiden.

Menüleistensymbole waren kaum zu sehen. `color.set()` vor `image.draw(in:)`
färbt ein Template-Bild nicht: es wird schwarz gezeichnet, auf einer dunklen
Menüleiste also unsichtbar. AppKit färbt Template-Bilder nur ein, wenn sie
als `image` eines Buttons gesetzt sind — in einer selbst gezeichneten Ansicht
muss die Farbe in die Symbolkonfiguration. Betraf alle fünf Zeichenstellen,
nicht nur die Lüfter.

„fans.auto" stand als roher Schlüssel da: Xcode hatte ihn extrahiert, gefüllt
wurde er nie. Beim Nachsehen waren es 35 solche Einträge über alle Kataloge
hinweg — die meisten reine Formatschlüssel, die als Durchreicher trotzdem
einen Wert brauchen.

Der Automatik-Knopf ist jetzt eine gefüllte Pille, wenn sie greift. Blauer
Text sieht aus wie ein Link und nicht wie ein Zustand.

Und ein Absturz, der noch niemandem passiert ist: meldet der Helfer für einen
stehenden Lüfter keine Grenzen, hätte `Slider(in: 0...0)` die App beendet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:12:33 +02:00
Scarriffle
93fe95827f Panel wächst in die Breite, Menüleiste wird schmaler
Das Raster ist weg. Widgets stehen nebeneinander, alle gleich groß, in
der Reihenfolge aus den Einstellungen. Das Panel sitzt am oberen Rand —
nach unten zu wachsen bedeckt den Bildschirm, nach rechts und links legt
es sich in den ohnehin leeren Streifen neben der Notch. Eine zweite Reihe
entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt.

Die Größenauswahl fällt damit weg, und das ist kein Verlust: ein 2×1
neben einem 2×2 lässt oben rechts eine Lücke, die niemand füllen kann und
die aussieht wie ein Fehler. Genau daher kam auch die Überlappung. Ein
Test prüft jetzt für ein bis zwölf Kacheln, dass sich keine zwei
schneiden und alle vollständig im Panel liegen — außerhalb heißt oben:
hinter der Notch. Kacheln werden zusätzlich beschnitten, damit
überquellender Inhalt nicht über den Nachbarn zeichnet.

Der Kalender wählt jetzt zwischen Monatsraster und Terminliste. Das war
vorher an die Kachelgröße gekoppelt und ist in Wahrheit eine Frage
dessen, was man sehen will.

In der Menüleiste wird die Breite gemessen statt geschätzt. Vorher stand
je Darstellungsart eine feste Zahl im Code, großzügig gewählt — mit dem
Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb, in
dem nichts stand. Gemessen wird die breiteste vorkommende Zeichenfolge,
nicht die gerade angezeigte, sonst springt die Leiste bei jedem Messwert.
Die Balkendarstellung bekommt ihre Breite aus der Kernzahl, die erst nach
der ersten Messung feststeht.

Popover schließen jetzt beim Klick daneben. `behavior = .transient`
genügt nicht: es greift, solange die eigene App aktiv ist, aber Onyx läuft
als .accessory und wird durch einen Klick auf ein Statuselement nicht
aktiviert. Ein Klick in ein fremdes Fenster erreichte das Popover gar
nicht. Ein globaler Beobachter sieht solche Klicks, ein lokaler die in der
eigenen App, Escape schließt ebenfalls — und es ist immer nur eines offen.

Mixer und Lüfter haben eigene Menüleistenelemente. Beides regelt man
mitten in etwas anderem; der Weg über ein Einstellungsfenster war zu weit.
Die Lüfter sind damit auch als Panel-Kachel echt statt Platzhalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:07:20 +02:00
Scarriffle
a0342205fd Phase 9: Einrichtung, Autostart, Notarisierung — und die Mixer-Optik
Die Einrichtung beim ersten Start erklärt jede der vier Sachen einzeln und
fragt jede einzeln an. Vier Systemdialoge hintereinander wären der übliche
Weg und der falsche: wer nicht weiß wofür, klickt viermal „Nicht erlauben",
und danach fragt macOS nie wieder. Sie kommt beim ersten Start immer, auch
wenn zufällig schon alles erlaubt ist — sie erklärt auch den Helfer und den
Autostart.

Beim Autostart ist `requiresApproval` der Zustand, der weh tut. Wer das
Anmeldeobjekt in den Systemeinstellungen abgeschaltet hat, kann in Onyx auf
den Schalter drücken, so oft er will. Als „aus" angezeigt führt das in eine
Schleife, also steht dort jetzt, wo es weitergeht.

Der Mixer verliert den Schalter je Programm. Zehn Mini-Schalter untereinander
sind das, was den Eindruck macht; am Regler zu ziehen ist ohnehin die
Entscheidung, dieses Programm zu regeln, und der große Ausschalter stellt
alles zurück. Der Regler ist selbst gezeichnet — der Systemregler bringt sein
eigenes Erscheinungsbild mit und kann nicht zeigen, worauf es hier ankommt:
den Pegel und die Grenze bei 100 %.

Der Pegel wird im selben Durchlauf gemessen, in dem verstärkt wird, und über
`Atomic` weitergereicht. Eine gewöhnliche Eigenschaft wäre ein Wettlauf, eine
Sperre im Audiothread der klassische Weg zu Aussetzern. Dabei fällt das
`Date()` aus dem Echtzeitpfad, das dort nie hingehört hat.

Zwei Funde beim Bauen des Release-Wegs, beide hätten erst nach dem Hochladen
zur Ablehnung geführt: der Helfer wurde ohne sicheren Zeitstempel signiert,
und das Adapter-Framework ad-hoc. Beides gilt jetzt nur noch im Debug.
Scripts/release.sh prüft Zertifikat und Notarisierungsprofil vorweg, statt
nach zehn Minuten Bauen zu scheitern.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:36:26 +02:00
Scarriffle
8308536227 Phase 8: Ablage, AirDrop und Calendarr
Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:19 +02:00
Scarriffle
28b1b4270e Audiomixer zeigt Programme statt Prozesse
Core Audio meldet Prozesse, nicht Programme. Angezeigt wurden deshalb
Kennungen wie „com.apple.WebKit.GPU" und Dutzende Systemdienste, die
theoretisch Ton ausgeben könnten, es aber nie tun.

ProcessNaming fragt erst das laufende Programm, kürzt sonst bekannte
Hilfsprozess-Endungen weg und schlägt das übergeordnete Programm nach —
Chromes Ton kommt aus einem Helfer, angezeigt gehört Chrome. Symbole
gehen denselben Weg, sonst steht neben dem Namen ein Platzhalter.

ProcessVisibility zeigt, was klingt, kürzlich klang oder geregelt wird.
Die Nachlaufzeit von zwei Minuten ist der eigentliche Punkt: ohne sie
verschwindet Spotify beim Pausieren aus der Liste, und man kann die
Lautstärke genau dann nicht mehr einstellen, wenn man es will. Geregelte
Programme bleiben immer — wer etwas leiser gedreht hat, muss es auch
zurückdrehen können.

Die Prozentzahl wandert vom Namen zum Regler. Oben war sie genau die
Breite, die den Namen abgeschnitten hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 09:54:20 +02:00
Guido Schmit
c71ddb7ce3 Debugger kann sich wieder anhängen: Neusignieren war überflüssig
Xcode meldete beim Start "Could not attach to pid — ensure that scarriffle has
permission to debug Onyx". Ursache war mein eigener Build-Schritt: er signierte
die App mit Onyx.entitlements neu, und darin fehlt get-task-allow — die
Entitlement, die das Anhängen eines Debuggers überhaupt erlaubt. Xcode setzt sie
im Debug-Build selbst; mein Neusignieren entfernte sie stillschweigend wieder.

Die Reihenfolge im Bauprotokoll zeigt, dass der Schritt gar nicht nötig war:

  CodeSign OnyxHelper           (Zeile  943)
  PhaseScriptExecution          (Zeile 1092)
  CodeSign Onyx.app             (Zeile 1743)

Xcode signiert die App als Letztes, nach dem Skript. Damit repariert es das
Siegel von selbst, das durch das Einbetten des Helfers bricht — mein
Neusignieren war ein Nachbau von etwas, das ohnehin passiert, nur ohne die
Entitlements, die Xcode beilegt.

Der Schritt kopiert jetzt nur noch und signiert ausschließlich den Helfer, der
sein eigenes Siegel braucht. Nachgeprüft: App-Siegel gültig, get-task-allow im
Debug vorhanden, Helfer trägt den richtigen Bezeichner und weiterhin keine
Entitlements.

Dass get-task-allow auch im Release-Build steht, ist kein Fehler: Xcode legt sie
bei jedem mit einem Development-Zertifikat signierten Build bei. Mit einem
Developer-ID-Zertifikat entfällt sie — das kommt in Phase 9 mit der
Notarisierung.
2026-08-11 09:42:01 +02:00
Guido Schmit
bfa8c9dab1 Phase 7: Per-Programm-Audiomixer über Core-Audio-Taps
Ein Tap mit mutedWhenTapped schaltet die ursprüngliche Ausgabe eines Programms
stumm und liefert das Signal stattdessen bei Onyx ab; ein privates
Aggregate-Device aus Tap und echtem Ausgabegerät gibt es nach der Verstärkung
wieder aus. Das ist der einzige Weg, den macOS ohne eigenen Treiber anbietet.

Die Kennlinie ist hergeleitet, nicht geraten: halbe empfundene Lautstärke sind
rund −10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt k ≈ 1,66. Die Skala ist
damit symmetrisch — 50 % sind −10 dB, 200 % sind +10 dB. Mein erster Ansatz mit
der vierten Potenz ergab bei halbem Regler 6 % statt 32 %; der Test hat das
gefangen, bevor es an einen Lautsprecher kam.

Über 100 % greift ein weicher Begrenzer. Ohne ihn schneidet die Hardware die
Werte hart ab, und das knackt hörbar. Er arbeitet erst ab 0,7, weil eine
Begrenzung, die schon bei Zimmerlautstärke einsetzt, matt klingt.

Der Mixer ist standardmäßig aus, und auch eingeschaltet greift er nur bei
ausdrücklich gewählten Programmen ein. Alles andere läuft unverändert vorbei.
Der Notausschalter steht sichtbar in der Ansicht, nicht in einem Menü: wenn der
Ton weg ist, ist das der Knopf, den man sucht.

Drei Fehlerfälle sind eingeplant, weil jeder von ihnen ein stummes Programm
bedeutet. Der Wachhund baut Taps neu auf, die nur noch Stille liefern — der
bekannte Fall aus Spike C. Ein Wechsel des Ausgabegeräts baut alle Taps neu
auf, weil sie am alten Gerät hängen. Und ein fehlgeschlagener Aufbau räumt sich
selbst weg, statt in einem halben Zustand zu verharren.

Beim Beenden von Onyx wird der Mixer abgebaut: sonst bleiben die abgegriffenen
Programme stumm zurück.

178 Tests grün, App-Signatur gültig.
2026-08-10 23:09:48 +02:00
Guido Schmit
f48b29e53e Helfer startet nicht: --deep hat ihm die App-Entitlements verpasst
launchd lehnte den Start ab, ohne Absturzbericht und ohne sichtbaren Fehler:

  launchd: exited with exit reason (namespace: 9 code: 0x8) - OS_REASON_EXEC
  amfid: OnyxHelper not valid: Code=-413 "No matching profile found"
  taskgated-helper: Disallowing com.scarriffleservices.onyx.helper
                    because no eligible provisioning profiles found

Ursache war OTHER_CODE_SIGN_FLAGS = --deep, das ich anfangs für das ad-hoc
signierte MediaRemoteAdapter.framework gesetzt hatte. --deep überträgt die
Entitlements der App auf jede eingebettete Binary. Der Helfer bekam dadurch
application-identifier, weatherkit und application-groups verpasst — alles
eingeschränkte Entitlements, die ein Provisioning-Profil verlangen. Ein nacktes
Kommandozeilenprogramm kann aber keines einbetten, denn das ist eine Datei im
Bundle. Also verweigerte AMFI die Ausführung.

Für das Framework wird --deep gar nicht gebraucht: es wird von /usr/bin/perl
geladen, nicht von Onyx, und dafür ist ohnehin disable-library-validation da.

Der Helfer trägt jetzt keine Entitlements — er braucht auch keine. Ein
root-Dienst mit möglichst wenig Rechten ist genau das, was man will.

Gefunden über das Protokoll von amfid und taskgated. Der Weg dorthin führte
über zwei Ausschlüsse: der Helfer läuft direkt gestartet einwandfrei, und seine
Signatur erfüllt die Anforderung der App. Damit war klar, dass es weder an der
Binary noch an der Signatur liegt, sondern an der Umgebung.
2026-08-10 23:01:51 +02:00
Guido Schmit
d73b789fb7 Phase 6: Lüftersteuerung über privilegierten Helfer
Das Sicherheitsnetz kommt zuerst und liegt als reine Funktion vor — 17 Tests,
kein Hardwarezugriff nötig. Es sitzt im Helfer, nicht in der Oberfläche: die
kann abstürzen, hängen oder beendet sein, während die Lüfter auf einem festen
Wert stehen. Genau dafür ist es da.

Vier Regeln, nicht abschaltbar: nie unter das Firmware-Minimum, Rückfall auf
Automatik ab 95 °C, Rückfall wenn der Herzschlag fünf Sekunden ausbleibt,
Rückfall beim Beenden. Ohne lesbare Temperatur wird gar nicht gesteuert —
blind eine feste Drehzahl zu halten wäre die falsche Antwort auf fehlende
Information.

Die App sagt dem Helfer NICHT, wie warm es ist. Er misst selbst. Sonst hinge
das Netz an der Ehrlichkeit eines Prozesses, der abstürzen, hängen oder — bei
einer manipulierten Kopie — schlicht lügen kann. Der Herzschlag sagt nur "ich
lebe noch".

Der Helfer entscheidet jede Sekunde neu, nicht nur beim Setzen: nur so greifen
Wachhund und Temperaturwächter auch dann, wenn von der App nie wieder etwas
kommt. Geschrieben wird nur bei Änderung — jeder SMC-Schreibvorgang ist ein
Eingriff.

Der Schreibpfad existiert ausschließlich im Helfer, als eigene Kopie statt
geteilter Bibliothek. Was im App-Prozess nicht vorhanden ist, kann dort auch
nicht versehentlich aufgerufen werden.

XPC prüft die Signatur in BEIDE Richtungen. Ohne das könnte jedes Programm auf
dem Rechner die Lüfter eines root-Dienstes steuern — der Mach-Dienst ist
systemweit sichtbar.

Drei Fallstricke beim Einbetten, alle nachgemessen:

Xcode signiert Kommandozeilenprogramme unter dem Dateinamen. Der Helfer hieß
damit "OnyxHelper" statt com.scarriffleservices.onyx.helper, und die App hätte
ihren eigenen Helfer abgelehnt. Behoben über eine in die Binary eingebettete
Info.plist (CREATE_INFOPLIST_SECTION_IN_BINARY).

Der Build-Schritt läuft nach Xcodes Signatur; das Kopieren bricht das Siegel
des App-Bundles. Also wird zum Schluss neu signiert — mit denselben
Entitlements, sonst gingen App-Group, WeatherKit und die TCC-Berechtigungen
verloren.

Mit deklarierten outputFiles übersprang Xcode den Schritt, obwohl sich das
Programm geändert hatte.

Geprüft: App-Siegel gültig (deep), beide Signaturanforderungen erfüllt,
alle sieben Entitlements erhalten. 162 Tests grün.
2026-08-10 22:49:41 +02:00
Guido Schmit
aa8e903037 Frequenzanzeige: Datenzugang gelöst, Umrechnung nicht — bewusst abgebrochen
IOReport liefert je Kern die Verweilzeit in jeder Leistungsstufe, genau wie
gebraucht: MCPU00 bis MCPU14, 17 Zustände, saubere Deltas zwischen zwei
Messungen.

Die Frequenz zu einer Stufe steht dort aber nicht. Sie muss aus den
voltage-states-Tabellen im IODeviceTree kommen, und die passen nicht: der Kanal
hat 16 Leistungsstufen, keine Tabelle hat 16 Einträge. Dazu sind die Einheiten
uneinheitlich — voltage-states8 enthält echte Hertz (732–2472 MHz),
voltage-states5 dagegen Paare wie (50103, 635), die in keiner Einheit sinnvolle
Taktraten ergeben.

Ab hier wäre es Reverse Engineering undokumentierter Firmware-Tabellen, deren
Format sich zwischen Chip-Generationen ändert — genau die Sorte Code, die beim
nächsten Mac still falsche Zahlen anzeigt statt zu scheitern. Dafür ist eine
Zusatzangabe im Popover zu wenig, solange Phase 6 bis 9 offen sind.

Die halbfertige Implementierung ist entfernt statt auskommentiert: nicht
verdrahteter Code, der nil liefert, verrottet und täuscht später
Vollständigkeit vor. Der Stand samt Fortsetzungsweg steht in
docs/spikes/F-frequenz.md.
2026-08-10 22:36:39 +02:00
Guido Schmit
7fcca61d70 Durchsatz je Programm und öffentliche IP
Beides läuft nur, solange jemand hinsieht. Die Prozessliste startet je Messung
einen Fremdprozess — das im Sekundentakt zu tun kostete mehr als der gesamte
übrige Monitor. Sie wird deshalb an das offene Popover gekoppelt, nicht an die
Messschleife.

Für den Durchsatz je Programm gibt es keine Bibliotheksschnittstelle: die
zugrundeliegende NetworkStatistics-API ist privat und verlangt eine
Entitlement, die Apple nicht vergibt. nettop ist Apples eigenes Werkzeug dafür,
liegt im System und läuft — nachgemessen — ohne root für die Prozesse des
eigenen Nutzers. Die Rate entsteht wie bei den Interface-Zählern aus der
Differenz zweier Messungen.

Die öffentliche IP bleibt standardmäßig aus, und der Schalter dafür steht im
Popover statt versteckt in den Einstellungen: wer einen Systemmonitor benutzt,
rechnet nicht damit, dass der nach draußen telefoniert. Ist sie an, steht der
befragte Dienst sichtbar darunter — man soll nachlesen können, wem die Adresse
bekannt wird. Höchstens alle 15 Minuten, weil sie sich selten ändert.

Frequenzen sind der dritte Wunsch und der aufwendigste. Der Weg ist verifiziert:
IOReport ist vollständig verfügbar, die Gruppe "CPU Stats" liefert unter
"CPU Core Performance States" die Kanäle MCPU00 bis MCPU14 mit den
P-Zustands-Residenzen, und die Frequenztabellen stehen als voltage-states* im
IODeviceTree. Das kommt als Nächstes.

145 Tests grün.
2026-08-10 22:27:16 +02:00
Guido Schmit
d85425671d Aussagekräftige Popovers für CPU, Speicher, Akku und Netzwerk
Ein Popover, das nur den Wert größer wiederholt, den man gerade angeklickt hat,
ist verschenkter Platz. Hier ist Raum — anders als in der Menüleiste, wo jede
Zahl um Breite kämpft.

Prozessor: Ringe für Auslastung, Temperatur und Lüfter, alle Kerne als Raster
statt als Balkenreihe (bei fünfzehn Kernen wird eine Reihe unleserlich schmal),
Leistungsaufschlüsselung und beide Lüfter mit Namen.

Die Leistungsschlüssel sind gegen iStat Menus abgeglichen: PHPC liefert 14,02 W
und deckt sich mit deren CPU-Wert, PSTR mit der Gesamtleistung. Frequenzen
fehlen bewusst — die stehen nicht im SMC, dafür bräuchte es IOReport.

Speicher: Ring mit Aufschlüsselung nach Programmen, Reserviertem, Komprimiertem
und Freiem, Auslagerung mit Gesamtgröße und die fünf größten Verbraucher über
phys_footprint — derselbe Wert, den der Aktivitätsmonitor zeigt, nicht der
virtuelle Adressraum.

Akku: Restzeit, Kapazität, Zyklen, Temperatur, Lade- oder Entladeleistung,
Netzteilleistung und Spannung.

Netzwerk: Verlauf in beide Richtungen auf gemeinsamem Maßstab — getrennte
Maßstäbe ließen einen Upload von 20 KB/s so hoch aussehen wie einen Download
von 20 MB/s. Dazu Spitzenwerte, VPN-Hinweis, alle Adressen und die Summen seit
dem Start.

Was fehlt, wird benannt statt weggelassen: Durchsatz je Programm gibt es ohne
root nicht, das Ladelimit braucht den privilegierten Helfer und einen Key, der
auf diesem Modell noch nicht identifiziert ist.

Sensorliste erweitert und benannt: CPU-Kerne und Grafik stehen vorn, dazu
Luftstrom, Thunderbolt links und rechts. Lüfter heißen links und rechts statt
0 und 1.

Nebenbei ein Übersetzungsfehler behoben: die Modus-Auswahl in den Einstellungen
zeigte die Rohschlüssel, weil die Texte im MetricsProvider-Bündel liegen und
die Einstellungen im App-Bündel suchen.

145 Tests grün.
2026-08-10 22:17:13 +02:00
Guido Schmit
a4cc75ab6e CPU und GPU zwischen Auslastung und Temperatur umschaltbar
Die M-Serie-Konvention gilt auch auf dem M5: Tp* sind die CPU-Kerne, Tg* die
GPU-Cluster. Gezeigt wird der wärmste Punkt, nicht der Durchschnitt —
gedrosselt wird nach dem heißesten Kern. Gemessen: CPU 76 °C, GPU 70 °C.

Die Fühlerliste wird zur Laufzeit gesucht statt fest verdrahtet: welche es
gibt, hängt am Modell. Auf diesem Mac sind es 285 Temperaturfühler.

Zwei Leistungsprobleme, gefunden bevor sie jemandem auffallen konnten:

Die Suche geht alle 3486 Keys durch und braucht knapp eine Sekunde. Auf dem
Hauptthread wäre das eine spürbare Startverzögerung — sie läuft jetzt im
Hintergrund, bis dahin zeigt die Oberfläche "—" statt einer erfundenen Zahl.

Fünfzig Fühler zu lesen kostete 34 ms je Abtastung, bei 1 Hz also 3,4 % CPU.
Zwei Maßnahmen: Typ und Größe je Key werden zwischengespeichert (sie stehen in
der Firmware fest, sie bei jedem Lesen zu erfragen verdoppelte die
IOKit-Aufrufe), und Temperaturen werden höchstens alle zwei Sekunden neu
gelesen — sie bewegen sich in einer Sekunde ohnehin kaum. Ergebnis: 6 ms
Grundlast, 18 ms alle zwei Sekunden, im Mittel gut 1 %.

Dabei noch eine Eigenheit gefunden: die Byte-Reihenfolge im SMC ist nicht
durchgängig. Messwerte wie B0AV kommen little-endian aus dem Akku-Baustein,
die Metadaten des SMC selbst dagegen big-endian. #KEY little-endian gelesen
ergibt 2 651 652 096 statt 3486 — die Fühlersuche brach damit still ab und
alle Temperaturen blieben leer.

Lüfterdrehzahlen stehen jetzt im Sensor-Popover, mit Balken zwischen Minimum
und Maximum der Firmware: "2321 U/min" allein sagt niemandem, ob das viel ist.
Die Steuerung selbst kommt mit Phase 6 — Schreibzugriff auf den SMC verlangt
root — und der Popover sagt das auch.

145 Tests grün.
2026-08-10 22:05:38 +02:00
Guido Schmit
594d7c422a Phase 5: Hardware- und Netzwerkmonitor, Panel und Menüleiste
Sechs neue Widgets (CPU, GPU, Speicher, Akku, Sensoren, Netzwerk) und dieselben
sechs als einzeln aktivierbare Menüleisten-Module mit fünf Darstellungsarten.

Panel-Widget und Menüleisten-Modul derselben Größe teilen sich die Messschleife
über Referenzzählung. Ohne das liefe für jede Anzeige ein eigener Timer mit
denselben IOKit- und SMC-Abfragen — bei sechs Modulen und ebenso vielen Widgets
ein Vielfaches der nötigen Arbeit. Meldet sich der letzte Konsument ab, hört die
Schleife auf; im Ruhezustand misst Onyx nichts.

Die Differenzrechnung ist testgetrieben, und die Tests haben zwei echte Fehler
gefunden. Erstens: mein Überlaufschutz beim Durchsatz hielt einen
zurückgesetzten Zähler (Interface-Wechsel) für einen Überlauf und zeigte
4,3 GB/s. Da if_data64 64-Bit-Zähler liefert, die bei 10 Gbit/s erst nach
470 Jahren umlaufen, ist ein kleinerer Wert immer ein Zurücksetzen — die
Unterscheidung produzierte nur den Ausreißer, den sie verhindern sollte.
Zweitens: ByteCountFormatter schrieb bei null „Zero KB/s" statt „0 KB/s".

Bei den CPU-Zählern bleibt der Überlaufschutz nötig: die sind 32 Bit breit und
laufen nach gut 400 Tagen wirklich um, deshalb `&-` statt `-`.

Sensoren kommen über den SMC-Leser aus Spike A statt über die private
IOHID-Schnittstelle — auf dieser Maschine nachweislich verifiziert, und die
dort gefundene Little-Endian-Eigenheit ist berücksichtigt. Nur eine kurze Liste
benannter Fühler statt aller 3486 Keys: eine Wand aus Kürzeln ist keine
Information.

Gegengemessen statt vermutet: 15 Kerne, 39,8 W, GPU 50 %, 16,6/24 GB mit
kritischem Druck, Akku 68 Zyklen bei 100 % Gesundheit, fünf Sensoren, zwei
Lüfter, en0 mit 661 KB/s.

Menüleisten-Module zeichnen in labelColor statt in den Onyx-Farben — nur die
Systemfarbe passt sich heller wie dunkler Leiste an. Feste Breite je
Darstellungsart, sonst schiebt jeder Messwert die halbe Leiste hin und her.

Standardmäßig ist kein Modul aktiv: sechs neue Symbole beim ersten Start wären
eine Zumutung.

145 Tests grün.
2026-08-10 21:48:47 +02:00
Guido Schmit
a198d88104 Dock-Eintrag nur solange ein Fenster offen ist
Im Hintergrund gehört Onyx nicht in den Dock — es hat kein Fenster, das ein
Klick öffnen könnte. Mit sichtbarem Einstellungsfenster ist das Gegenteil
richtig: als reine .accessory-App lässt sich das Fenster weder über den Dock
noch über ⌘-Tab zurückholen, wenn es hinter etwas anderes rutscht.

Deshalb wird beim Öffnen auf .regular geschaltet und beim Schließen zurück —
außer der Dock-Eintrag ist in den Einstellungen ausdrücklich gewünscht.

Das generische weiße Symbol im Dock kam nicht aus dem Bundle: CFBundleIconName,
CFBundleIconFile, AppIcon.icns und der Assets-Katalog waren alle in Ordnung.
Es war der Icon-Cache von Launch Services, der die App von den vielen Starts
her kannte, als es noch gar kein Icon gab. Neu registriert und Cache verworfen.
2026-08-10 21:29:09 +02:00
Guido Schmit
c0874c83b7 Berechtigungen zeigen ihren Zustand live, Dock-Symbol wird wählbar
Die Einstellungen zeigten weiter "Anfragen", obwohl die Ortung längst lief:
authorizationStatus ist eine Abfrage, keine Beobachtung, und ich hatte sie
einmalig beim Öffnen gelesen. Jetzt meldet locationManagerDidChangeAuthorization
die Änderung, WeatherModel führt daraus einen beobachtbaren Wert, und die
Einstellungen aktualisieren sich, während das Fenster offen steht. Nach der
Zustimmung wird sofort geladen statt beim nächsten Anlauf.

Der Knopf "Systemeinstellungen öffnen" ganz unten zeigte fest auf den
Kalenderbereich — wer ihn beim Standort drückte, landete am falschen Ort. Es
gibt ihn nicht mehr; stattdessen führt jede Berechtigung in ihren eigenen
Bereich, und der Knopf erscheint nur im Zustand "abgelehnt". Bei "noch nie
gefragt" steht dort Anfragen, bei "erteilt" gar nichts: drei Zustände, drei
Antworten. Genau daran ist die erste Fassung gescheitert.

Dock-Symbol ist jetzt eine Einstellung, standardmäßig aus. Onyx läuft als
LSUIElement — das war Absicht und keine Auslassung: es hat kein Hauptfenster
und wäre im Dock ein Symbol, das beim Anklicken nichts öffnet, und es stünde im
Programmumschalter zwischen den Programmen, mit denen man arbeitet. Wer es
trotzdem will, schaltet es ein; die Wahl wird beim Start wieder angewendet.
2026-08-10 21:24:49 +02:00
Guido Schmit
7f6ded7de0 Menüleistensymbol: Konturfassung statt Silhouette
Das Glyph des App-Icons besteht aus deckenden Flächen. Als Template-Bild — der
einzige Weg, macOS zum Einfärben zu bewegen — verschmolzen die Facetten zu
einer flachen Silhouette, weil dabei nur der Alphakanal zählt.

MenuBarIcon.svg ist als Kontur gezeichnet: zwischen den Linien ist nichts, also
bleibt beim Einfärben die Struktur erhalten. Das Symbol wird damit automatisch
weiß auf dunkler und schwarz auf heller Menüleiste — ohne zweite Datei für den
zweiten Fall.

Die ebenfalls angebotene helle Flächenfassung wird damit überflüssig und ist
nicht eingecheckt: sie wäre auf einer hellen Menüleiste unsichtbar, und eine
ungenutzte Alternative im Repo lädt später zum Verwechseln ein.

Der Kopierschritt für quartz.svg entfällt — die Datei liegt jetzt direkt im
App-Target und wird ohne Umweg als Ressource eingebunden.
2026-08-10 21:17:13 +02:00
148 changed files with 22560 additions and 1341 deletions

View File

@@ -8,34 +8,83 @@
/* Begin PBXBuildFile section */
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; };
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F12041E330231599C6540C21 /* FanSettingsView.swift */; };
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */; };
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */; };
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */; };
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */ = {isa = PBXBuildFile; fileRef = 085D860E654E09E4CD6E535A /* chip.svg */; };
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */ = {isa = PBXBuildFile; fileRef = B8214C5599D1857E832447EC /* bolt-light.svg */; };
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; };
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */ = {isa = PBXBuildFile; productRef = D4578EB64F7A3372B49BC50C /* WeatherProvider */; };
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; };
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */ = {isa = PBXBuildFile; fileRef = 7A866E72010907DF4BEF025C /* bolt-dark.svg */; };
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = 91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */; };
6A972372414D6EFDC81E2661 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = B442E8A39C37C8FD92B4CA83 /* main.swift */; };
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89309D9F5722FA1B91CF2905 /* FanWidget.swift */; };
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 5D8203CC3A09D838A4AC385D /* AudioProvider */; };
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */ = {isa = PBXBuildFile; fileRef = 24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */; };
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6115888EE7738877C9304138 /* MixerMenuBarModule.swift */; };
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FF4E99C8BF0D8944C314A8AF /* NetworkProvider */; };
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 508DECB5C05264E6130C373E /* Localizable.xcstrings */; };
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */ = {isa = PBXBuildFile; productRef = FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */; };
B0FCCD92AF22F1636482EACD /* sound-waves.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */; };
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */; };
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */ = {isa = PBXBuildFile; productRef = FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */; };
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 349226D70F51196CCD1C94F8 /* MediaProvider */; };
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */ = {isa = PBXBuildFile; productRef = 6CACA1C2A58DEFDE46F71499 /* MetricsProvider */; };
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; };
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; };
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; };
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */; };
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; };
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 32F5E88F3CF35AE8C0E00613 /* Project object */;
proxyType = 1;
remoteGlobalIDString = BABC85A062EFB668A81EBAA7;
remoteInfo = OnyxHelper;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
055263008CB3306A8D09E6D4 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
085D860E654E09E4CD6E535A /* chip.svg */ = {isa = PBXFileReference; path = chip.svg; sourceTree = "<group>"; };
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetOptionsView.swift; sourceTree = "<group>"; };
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */ = {isa = PBXFileReference; path = "graphic-card.svg"; sourceTree = "<group>"; };
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */ = {isa = PBXFileReference; path = MenuBarIcon.svg; sourceTree = "<group>"; };
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLogin.swift; sourceTree = "<group>"; };
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCAccess.swift; sourceTree = "<group>"; };
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
50A018673EE6BD9C50F1EFA0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingWindowController.swift; sourceTree = "<group>"; };
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixerMenuBarModule.swift; sourceTree = "<group>"; };
63A5510538846C8427AEB831 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
7A866E72010907DF4BEF025C /* bolt-dark.svg */ = {isa = PBXFileReference; path = "bolt-dark.svg"; sourceTree = "<group>"; };
821B742C211265914B6CC44B /* OnyxKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = OnyxKit; path = Packages/OnyxKit; sourceTree = SOURCE_ROOT; };
87597F09E971C14CF1E475DB /* Onyx.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Onyx.entitlements; sourceTree = "<group>"; };
89309D9F5722FA1B91CF2905 /* FanWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanWidget.swift; sourceTree = "<group>"; };
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */ = {isa = PBXFileReference; includeInIndex = 0; path = OnyxHelper; sourceTree = BUILT_PRODUCTS_DIR; };
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */ = {isa = PBXFileReference; path = "sound-waves.svg"; sourceTree = "<group>"; };
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = "<group>"; };
B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = "<group>"; };
CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderWidgets.swift; sourceTree = "<group>"; };
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = "<group>"; };
F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = "<group>"; };
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = "<group>"; };
FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -44,13 +93,26 @@
buildActionMask = 2147483647;
files = (
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */,
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */,
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */,
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
68EE5495435A82E20248EBC2 /* OnyxHelperProtocol in Frameworks */,
EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */,
378A5A417112CCB31EF3F774 /* OnyxNotch in Frameworks */,
B6CD48B41866A468AE7A1BC7 /* OnyxWidgetKit in Frameworks */,
CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */,
5BE38CAFCDA4B9CBE787CABA /* CalendarProvider in Frameworks */,
1E2D654EFDDF55AEA75DBB7C /* WeatherProvider in Frameworks */,
C1F651D2BA69358344927F26 /* MediaProvider in Frameworks */,
C390C5B65769A007A49C3A80 /* MetricsProvider in Frameworks */,
9EC9D57A96489F5578000822 /* NetworkProvider in Frameworks */,
75FF046C0E0C7241B24A7186 /* AudioProvider in Frameworks */,
A9617230F602C5D52F63A7E2 /* ShelfProvider in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D4D0E94BCED22A73F694B2CB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C15B441081E9CCF74B52CADA /* OnyxHelperProtocol in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -68,6 +130,7 @@
isa = PBXGroup;
children = (
0804288DC4A8146DD9F3FC3E /* Onyx.app */,
8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */,
);
name = Products;
sourceTree = "<group>";
@@ -76,6 +139,7 @@
isa = PBXGroup;
children = (
D6653B90FCB65F334F91F2F1 /* Onyx */,
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */,
573275C59A65A7C11CCEC90A /* Packages */,
339A2C4AF25B5612E90B795D /* Products */,
);
@@ -89,19 +153,42 @@
path = Packages;
sourceTree = "<group>";
};
6D8938E9EC5D53ACF74A4E5C /* OnyxHelper */ = {
isa = PBXGroup;
children = (
B442E8A39C37C8FD92B4CA83 /* main.swift */,
4D6DE2FA3022423D80A867C9 /* SMCAccess.swift */,
);
path = OnyxHelper;
sourceTree = "<group>";
};
D6653B90FCB65F334F91F2F1 /* Onyx */ = {
isa = PBXGroup;
children = (
0FBA13B97C53F1D0D740A633 /* Resources */,
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
B83C4E3CB821F903F3977E88 /* AppModel.swift */,
7A866E72010907DF4BEF025C /* bolt-dark.svg */,
B8214C5599D1857E832447EC /* bolt-light.svg */,
085D860E654E09E4CD6E535A /* chip.svg */,
DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */,
FC3FDFD387188D36AF8287DE /* FanControl.swift */,
F12041E330231599C6540C21 /* FanSettingsView.swift */,
89309D9F5722FA1B91CF2905 /* FanWidget.swift */,
1E6A1C208C7EA56971A055E1 /* graphic-card.svg */,
50A018673EE6BD9C50F1EFA0 /* Info.plist */,
41C778876BEF3D8B04F7C3C9 /* LaunchAtLogin.swift */,
508DECB5C05264E6130C373E /* Localizable.xcstrings */,
24ADD22F5CE30B27020B479B /* MenuBarIcon.svg */,
6115888EE7738877C9304138 /* MixerMenuBarModule.swift */,
63A5510538846C8427AEB831 /* OnboardingView.swift */,
5FCD3B382484AE4E9EF2F4B6 /* OnboardingWindowController.swift */,
87597F09E971C14CF1E475DB /* Onyx.entitlements */,
F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */,
CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */,
45BD2DF1B775C733C8F75635 /* SettingsView.swift */,
8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */,
8BCDFD427ACD83B8AA7BA39E /* sound-waves.svg */,
16F2B1B9A74594526F34F5C9 /* WidgetOptionsView.swift */,
);
path = Onyx;
sourceTree = "<group>";
@@ -116,16 +203,18 @@
6B11A5AC704B6429B9484789 /* Sources */,
B211CE3C0EF94A8D680E4AE0 /* Resources */,
7FF994FE5530D53BEFBE13FE /* Frameworks */,
E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */,
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */,
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */,
);
buildRules = (
);
dependencies = (
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */,
);
name = Onyx;
packageProductDependencies = (
C64A111F449D0F9D36ED46FB /* OnyxCore */,
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */,
3D680DD645941493A7D3559E /* OnyxDesign */,
3A32CAE6CC4D01D660F0016F /* OnyxNotch */,
E66577A40689DF225BCED184 /* OnyxWidgetKit */,
@@ -133,11 +222,34 @@
A3E3949D664131D593CBEEDC /* CalendarProvider */,
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
349226D70F51196CCD1C94F8 /* MediaProvider */,
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */,
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */,
5D8203CC3A09D838A4AC385D /* AudioProvider */,
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */,
);
productName = Onyx;
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
productType = "com.apple.product-type.application";
};
BABC85A062EFB668A81EBAA7 /* OnyxHelper */ = {
isa = PBXNativeTarget;
buildConfigurationList = 517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */;
buildPhases = (
A9C106AA3316FA03B999A567 /* Sources */,
D4D0E94BCED22A73F694B2CB /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = OnyxHelper;
packageProductDependencies = (
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */,
);
productName = OnyxHelper;
productReference = 8B7ECCFCBA1BAAC6583D4A39 /* OnyxHelper */;
productType = "com.apple.product-type.tool";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -151,6 +263,10 @@
DevelopmentTeam = PP34X97WS3;
ProvisioningStyle = Automatic;
};
BABC85A062EFB668A81EBAA7 = {
DevelopmentTeam = PP34X97WS3;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 0A2ACEAA7E2F36634202B241 /* Build configuration list for PBXProject "Onyx" */;
@@ -171,6 +287,7 @@
projectDirPath = "";
projectRoot = "";
targets = (
BABC85A062EFB668A81EBAA7 /* OnyxHelper */,
2EB428ADC48D89BCA28D3EE1 /* Onyx */,
);
};
@@ -183,6 +300,12 @@
files = (
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */,
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
7CE2EDC6BC58678269BF27AD /* MenuBarIcon.svg in Resources */,
574FDD46FEBAC96E38CDFC7A /* bolt-dark.svg in Resources */,
3C7D32A39B906BD7385B1231 /* bolt-light.svg in Resources */,
3991CB2BF41C6C014EF4528F /* chip.svg in Resources */,
F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */,
B0FCCD92AF22F1636482EACD /* sound-waves.svg in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -203,13 +326,13 @@
outputFileListPaths = (
);
outputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl",
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "OUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n";
shellScript = "# Im Release mit der echten Identität signieren: ad-hoc signierte\n# Bibliotheken lehnt die Notarisierung ab.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n export SIGN_IDENTITY=\"$EXPANDED_CODE_SIGN_IDENTITY\"\nfi\nOUT=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor\" \\\n \"$SRCROOT/Scripts/build-mediaremote-adapter.sh\"\n";
};
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */ = {
E62FE369A03A1B8A44162F0E /* Helfer und Launchd-Plist einbetten */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -217,17 +340,15 @@
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg",
);
name = "Glyph für die Menüleiste ins Bundle kopieren";
name = "Helfer und Launchd-Plist einbetten";
outputFileListPaths = (
);
outputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "SRC=\"$SRCROOT/Onyx/AppIcon.icon/Assets/quartz.svg\"\nDST=\"$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/quartz.svg\"\nif [ -f \"$SRC\" ]; then\n mkdir -p \"$(dirname \"$DST\")\"\n cp \"$SRC\" \"$DST\"\nelse\n echo \"warning: $SRC fehlt — Menüleiste fällt auf ein SF-Symbol zurück\"\nfi\n";
shellScript = "APP=\"$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH\"\nmkdir -p \"$APP/MacOS\" \"$APP/Library/LaunchDaemons\"\ncp \"$BUILT_PRODUCTS_DIR/OnyxHelper\" \"$APP/MacOS/OnyxHelper\"\ncp \"$SRCROOT/OnyxHelper/com.scarriffleservices.onyx.helper.plist\" \\\n \"$APP/Library/LaunchDaemons/\"\n# Der Helfer wird eigenständig signiert — die Signatur der App deckt\n# ihn nicht ab, und ohne gültige eigene Signatur lehnt launchd ihn ab.\n#\n# `-i` ist Pflicht: Xcode signiert Kommandozeilenprogramme sonst mit\n# dem Produktnamen (\"OnyxHelper\") als Bezeichner. Die App prüft aber\n# auf com.scarriffleservices.onyx.helper und würde ihren eigenen\n# Helfer ablehnen.\n# Zeitstempel im Release, keiner im Debug.\n#\n# Die Notarisierung verlangt einen sicheren Zeitstempel; ohne ihn\n# lehnt Apple das Paket ab, und zwar erst nach dem Hochladen. Im\n# Debug wäre er nur eine Netzanfrage bei jedem Bauen.\nif [ \"$CONFIGURATION\" = \"Release\" ]; then\n TIMESTAMP=\"--timestamp\"\nelse\n TIMESTAMP=\"--timestamp=none\"\nfi\ncodesign --force --options runtime $TIMESTAMP \\\n -i com.scarriffleservices.onyx.helper \\\n --sign \"$EXPANDED_CODE_SIGN_IDENTITY\" \"$APP/MacOS/OnyxHelper\"\n\n# Kein Neusignieren der App hier: Xcode signiert sie als letzten\n# Schritt, nach diesem Skript. Das repariert das Siegel von selbst\n# und legt im Debug-Build auch get-task-allow bei, ohne die im\n# Skript nachzubauen wäre — nachgemessen an der Reihenfolge im\n# Bauprotokoll (PhaseScriptExecution vor CodeSign Onyx.app).\n";
};
/* End PBXShellScriptBuildPhase section */
@@ -237,16 +358,59 @@
buildActionMask = 2147483647;
files = (
C041F171FB69F4381309D978 /* AppModel.swift in Sources */,
E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */,
CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */,
0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */,
6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */,
B309DD8966B5348250CEC6BD /* LaunchAtLogin.swift in Sources */,
8256BA7B13AFAE4DD80C18B5 /* MixerMenuBarModule.swift in Sources */,
C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */,
1C17FA5C3C6B3938F95B9440 /* OnboardingWindowController.swift in Sources */,
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */,
5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */,
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */,
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */,
3636D37F49B08DF6221A2C39 /* WidgetOptionsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
A9C106AA3316FA03B999A567 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2BB6AFD10DFB13FC4467BE6B /* SMCAccess.swift in Sources */,
6A972372414D6EFDC81E2661 /* main.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
CA0FBD971F9234D78EC4DCB8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = BABC85A062EFB668A81EBAA7 /* OnyxHelper */;
targetProxy = 38EC624350BE3AFC906F5004 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
2EBE219F7BAFEEBF99ACB8DE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
INFOPLIST_FILE = OnyxHelper/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper;
PRODUCT_NAME = OnyxHelper;
SDKROOT = macosx;
SKIP_INSTALL = YES;
};
name = Debug;
};
42021FACB216529C4EA156E9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -319,7 +483,6 @@
MARKETING_VERSION = 0.1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -329,6 +492,24 @@
};
name = Release;
};
B649EAAA14C09F6A9E4A558D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CREATE_INFOPLIST_SECTION_IN_BINARY = YES;
INFOPLIST_FILE = OnyxHelper/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.onyx.helper;
PRODUCT_NAME = OnyxHelper;
SDKROOT = macosx;
SKIP_INSTALL = YES;
};
name = Release;
};
DEB51EF5615CABC756F24ADD /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -389,7 +570,6 @@
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
@@ -430,6 +610,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
517F123F9F5479E913E7B1AA /* Build configuration list for PBXNativeTarget "OnyxHelper" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2EBE219F7BAFEEBF99ACB8DE /* Debug */,
B649EAAA14C09F6A9E4A558D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
C126F7CB2B14EC196CE8743D /* Build configuration list for PBXNativeTarget "Onyx" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -461,6 +650,18 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxDesign;
};
5D8203CC3A09D838A4AC385D /* AudioProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = AudioProvider;
};
6CACA1C2A58DEFDE46F71499 /* MetricsProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = MetricsProvider;
};
91C048471C023EC8A82B1BD3 /* OnyxHelperProtocol */ = {
isa = XCSwiftPackageProductDependency;
productName = OnyxHelperProtocol;
};
A3E3949D664131D593CBEEDC /* CalendarProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = CalendarProvider;
@@ -481,6 +682,18 @@
isa = XCSwiftPackageProductDependency;
productName = OnyxWidgetKit;
};
FA6C9F90DF47DC0DFB9ED55D /* ShelfProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = ShelfProvider;
};
FBF369E2DC7BB5B1DF582630 /* OnyxHelperProtocol */ = {
isa = XCSwiftPackageProductDependency;
productName = OnyxHelperProtocol;
};
FF4E99C8BF0D8944C314A8AF /* NetworkProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = NetworkProvider;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 32F5E88F3CF35AE8C0E00613 /* Project object */;

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#1c1f1f" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#273a3d" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#273a3d" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#000000" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#0f1b1a" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#1c1f1f" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#273a3d" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#000000" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#273a3d" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#141414" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#d4d7d8" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#e9e9e9" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e5e5e5" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#d4d7d8" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#e9e9e9" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d4d4d4" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#e9e9e9" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#f4f4f4" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#c3c3c3" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#f3f5f6" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#f3f5f6" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d6d6d6" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#d6d6d6" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#c3c3c3" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#f3f5f6" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#d6d6d6" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#f3f5f6" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#a5a5a5" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path fill="#505050" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0 108.707 158.934a16.2 16.2 0 0 0-1.416 3.636L50.52 384.169a10.8 10.8 0 0 0 .081 5.665l126.344 121.391c1.267.507 2.631.775 4.021.775h150.07c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.569-1.866.635-3.883.134-5.84" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="m461.481 384.169-56.77-221.599a16.3 16.3 0 0 0-1.355-3.512L256.001 0a10.78 10.78 0 0 0-8.035 3.584l-3.811 4.233 133.203 147.924a16.2 16.2 0 0 1 3.659 6.829l56.77 221.599a10.81 10.81 0 0 1-3.015 10.511L314.801 509.015a10.8 10.8 0 0 1-7.46 2.985h23.694c1.389 0 2.753-.268 4.019-.774l126.292-121.217c.57-1.866.636-3.883.135-5.84" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#7a7a7a" d="M401.051 155.741 264.035 3.584A10.77 10.77 0 0 0 256.001 0v188.536l62.219 54.012 85.136-83.491a15.8 15.8 0 0 0-2.305-3.316" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="M377.357 155.741a16.2 16.2 0 0 1 3.659 6.829l3.766 14.701 18.573-18.213a15.8 15.8 0 0 0-2.305-3.317L264.035 3.584A10.77 10.77 0 0 0 256.001 0v20.972z" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#5b5b5b" d="M247.966 3.584 110.95 155.741a15.9 15.9 0 0 0-2.302 3.319l85.1 83.456L256 188.537V0a10.77 10.77 0 0 0-8.034 3.584" opacity="1" data-original="#d4f2f6" class=""></path><path fill="#505050" d="M256.001 0a10.77 10.77 0 0 0-8.035 3.584l-3.811 4.233 11.846 13.155z" opacity="1" data-original="#a6e7f0" class=""></path><path fill="#7a7a7a" d="M357.937 371.972 299.07 419.94l35.985 91.286.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#5d5d5d" d="m461.346 390.009-23.276-4.06a10.8 10.8 0 0 1-3.298 8.731L328.891 495.587l6.163 15.639.012-.001a10.7 10.7 0 0 0 3.428-2.209L458.467 394.68a10.74 10.74 0 0 0 2.879-4.671" opacity="1" data-original="#2ed1e2" class=""></path><path fill="#7a7a7a" d="m149.944 371.907-99.298 18.091a10.8 10.8 0 0 0 2.888 4.683l119.971 114.336a10.8 10.8 0 0 0 3.439 2.209l35.965-91.22z" opacity="1" data-original="#6bd9e7" class=""></path><path fill="#434242" d="m209.819 418.393-57.237-42.83a7.57 7.57 0 0 1-2.612-8.553l42.606-122.127a10.8 10.8 0 0 1 2.969-4.468l55.387-49.932a7.57 7.57 0 0 1 10.138 0l55.257 49.814a10.8 10.8 0 0 1 3.071 4.777l38.574 122.228a7.57 7.57 0 0 1-2.482 8.183l-53.224 42.684a10.82 10.82 0 0 1-6.767 2.379h-79.203a10.8 10.8 0 0 1-6.477-2.155" opacity="1" data-original="#eaf9fa" class=""></path></g></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,13 +1,44 @@
{
"fill" : {
"automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000"
},
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.23039,0.38246,0.66250,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.26369,0.36702,0.79660,1.00000"
}
}
],
"groups" : [
{
"layers" : [
{
"image-name" : "quartz.svg",
"name" : "quartz",
"hidden" : false,
"image-name" : "quartz (2).svg",
"name" : "quartz (2)",
"opacity-specializations" : [
{
"value" : 1
},
{
"appearance" : "dark",
"value" : 0
}
],
"position" : {
"scale" : 1.39,
"translation-in-points" : [
0,
0
]
}
},
{
"image-name" : "quartz (4).svg",
"name" : "quartz (4)",
"position" : {
"scale" : 1.39,
"translation-in-points" : [

View File

@@ -1,6 +1,8 @@
import SwiftUI
import AppKit
import OnyxNotch
import OnyxWidgetKit
import OnyxMenuBar
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
///
@@ -39,9 +41,54 @@ final class AppModel {
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
}
/// Ob Onyx im Dock auftaucht.
///
/// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der
/// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das
/// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter
/// auf und steht dort zwischen den Programmen, mit denen man arbeitet.
var showsDockIcon: Bool {
didSet {
guard showsDockIcon != oldValue else { return }
defaults.set(showsDockIcon, forKey: Keys.showsDockIcon)
NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory)
}
}
/// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
/// Änderungen aus den Einstellungen sofort durchschlagen.
weak var menuBar: MenuBarController?
/// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
///
/// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
/// wären eine Zumutung wer sie will, schaltet sie einzeln ein.
func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
else { return MenuBarModuleSettings() }
return decoded
}
func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: Keys.menuBarModule(moduleID))
}
menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
menuBarRevision += 1
}
/// Zählt hoch, wenn sich ein Modul ändert die Einstellungen zeichnen sich
/// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
private(set) var menuBarRevision = 0
private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea"
}
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
@@ -57,11 +104,40 @@ final class AppModel {
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
// zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false
self.triggerArea = (defaults.data(forKey: Keys.triggerArea)
.flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard
}
// MARK: - Widgets
var panelContentSize: CGSize { WidgetGrid.panelSize(for: layout) }
/// Die Auslösefläche der Notch. Wirkt sofort.
var triggerArea: TriggerArea {
didSet {
guard triggerArea != oldValue else { return }
if let data = try? JSONEncoder().encode(triggerArea) {
defaults.set(data, forKey: Keys.triggerArea)
}
onTriggerAreaChanged?(triggerArea)
}
}
var onTriggerAreaChanged: ((TriggerArea) -> Void)?
var panelContentSize: CGSize {
WidgetGrid.panelSize(for: layout, availableWidth: Self.availableWidth)
}
/// Wie breit das Panel höchstens werden darf.
///
/// Der **schmalste** aktive Bildschirm gibt den Ausschlag: dasselbe Panel
/// erscheint auf allen Displays, und eines, das auf dem Notebook über den
/// Rand ragt, wäre auf dem großen Monitor kein Trost. Der Abzug lässt Luft
/// zum Rand ein Panel, das exakt bis zur Kante geht, wirkt eingeklemmt.
static var availableWidth: CGFloat {
let narrowest = NSScreen.screens.map(\.frame.width).min() ?? 1440
return narrowest - 120
}
func isInPanel(_ widgetID: String) -> Bool {
layout.contains { $0.widgetID == widgetID }
@@ -69,19 +145,13 @@ final class AppModel {
func add(_ widget: any OnyxWidget) {
guard !isInPanel(widget.id) else { return }
let size = widget.supportedSizes.first ?? .small
layout.append(WidgetPlacement(widgetID: widget.id, size: size))
layout.append(WidgetPlacement(widgetID: widget.id))
}
func remove(_ widgetID: String) {
layout.removeAll { $0.widgetID == widgetID }
}
func setSize(_ size: WidgetSize, for widgetID: String) {
guard let index = layout.firstIndex(where: { $0.widgetID == widgetID }) else { return }
layout[index].size = size
}
func move(fromOffsets source: IndexSet, toOffset destination: Int) {
layout.move(fromOffsets: source, toOffset: destination)
}

View File

@@ -0,0 +1,142 @@
import SwiftUI
import OnyxDesign
/// Die Farbwahl.
///
/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet,
/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst
/// nicht hier Gelb heißt Warnung, Rot heißt kritisch, und wer das umstellen
/// kann, kann die Bedeutung zerstören.
struct ColorSettingsView: View {
@Bindable var theme: OnyxTheme
/// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss.
///
/// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung
/// darauf sticht, statt zu leiten.
private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [
("color.suggestion.onyx", "#7FA8FF"),
("color.suggestion.mint", "#6FCF97"),
("color.suggestion.amber", "#E8B44A"),
("color.suggestion.coral", "#E5675C"),
("color.suggestion.lilac", "#B79CFF"),
("color.suggestion.slate", "#9AA6B2"),
]
var body: some View {
Form {
Section("color.section.accent") {
ColorPicker(selection: $theme.accent, supportsOpacity: false) {
Text("color.accent")
}
HStack(spacing: 8) {
ForEach(Self.suggestions, id: \.hex) { suggestion in
Swatch(hex: suggestion.hex,
name: suggestion.name,
isSelected: theme.accent.onyxHex == suggestion.hex) {
theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent
}
}
Spacer(minLength: 0)
}
// Mit dem Farbfleck daneben: welche Farbe das System gerade
// führt, weiß man nicht auswendig und dann wirkt das
// Ergebnis wie ein Fehler statt wie eine Übernahme.
Button { theme.adoptSystemAccent() } label: {
HStack(spacing: 6) {
Text("color.adoptSystem")
Circle()
.fill(Color(nsColor: .controlAccentColor))
.frame(width: 11, height: 11)
}
}
Text("color.accent.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.network") {
ColorPicker(selection: $theme.download, supportsOpacity: false) {
Text("color.download")
}
ColorPicker(selection: $theme.upload, supportsOpacity: false) {
Text("color.upload")
}
Button {
let previous = theme.download
theme.download = theme.upload
theme.upload = previous
} label: {
Text("color.swap")
}
Text("color.network.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section("color.section.fixed") {
// Sichtbar, aber nicht bedienbar: sonst sucht man die
// Warnfarbe hier und findet sie nirgends.
FixedRow(color: Onyx.Color.positive, label: "color.fixed.positive")
FixedRow(color: Onyx.Color.warning, label: "color.fixed.warning")
FixedRow(color: Onyx.Color.critical, label: "color.fixed.critical")
Text("color.fixed.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
Button { theme.reset() } label: {
Text("color.reset")
}
.disabled(theme.isDefault)
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
}
}
/// Ein Farbfeld zum Anklicken.
private struct Swatch: View {
let hex: String
let name: LocalizedStringKey
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Circle()
.fill(Color(onyxHex: hex) ?? .gray)
.frame(width: 20, height: 20)
.overlay {
// Der Ring statt eines Hakens: ein Haken auf einer dunklen
// Farbe ist nicht zu sehen, auf einer hellen nicht auf
// einer dunklen Fläche.
Circle()
.strokeBorder(.primary, lineWidth: isSelected ? 2 : 0)
.padding(-3)
}
}
.buttonStyle(.plain)
.help(Text(name))
}
}
private struct FixedRow: View {
let color: Color
let label: LocalizedStringKey
var body: some View {
HStack {
Text(label)
Spacer(minLength: 12)
Circle().fill(color).frame(width: 14, height: 14)
}
}
}

232
Onyx/FanControl.swift Normal file
View File

@@ -0,0 +1,232 @@
import Foundation
import ServiceManagement
import OSLog
import OnyxHelperProtocol
import MetricsProvider
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "FanControl")
/// Die App-Seite der Lüftersteuerung.
///
/// Sie darf **wünschen**, nicht bestimmen. Ob eine Drehzahl gesetzt wird,
/// entscheidet allein das Sicherheitsnetz im Helfer mit Temperaturen, die er
/// selbst misst. Diese Klasse hält nur die Verbindung und den Herzschlag.
@MainActor
@Observable
public final class FanControl: ChargeLimitReading {
public enum InstallState: Equatable {
case notInstalled
case requiresApproval
case installed
case failed(String)
}
public private(set) var installState: InstallState = .notInstalled
public private(set) var report: FanStatusReport?
public private(set) var lastError: String?
private var connection: NSXPCConnection?
private var heartbeatTimer: Timer?
private var statusTimer: Timer?
/// Ob die Registrierung in diesem Programmlauf schon einmal erneuert wurde.
/// Einmal ist Selbstheilung, immer wieder wäre eine Schleife.
private var hasRepairedRegistration = false
private var service: SMAppService {
SMAppService.daemon(plistName: "com.scarriffleservices.onyx.helper.plist")
}
public init() { refreshInstallState() }
// MARK: - Einrichtung
public func refreshInstallState() {
switch service.status {
case .enabled: installState = .installed
case .requiresApproval: installState = .requiresApproval
case .notRegistered, .notFound: installState = .notInstalled
@unknown default: installState = .notInstalled
}
}
/// Registriert den Helfer. macOS fragt dabei nach Zustimmung.
public func install() {
do {
try service.register()
refreshInstallState()
log.notice("Helfer registriert: \(String(describing: self.installState))")
} catch {
// `requiresApproval` kommt hier als Fehler an und ist keiner: der
// Nutzer muss den Dienst nur noch in den Systemeinstellungen
// freigeben.
refreshInstallState()
if installState != .requiresApproval {
installState = .failed(error.localizedDescription)
log.error("Helfer nicht registrierbar: \(error.localizedDescription, privacy: .public)")
}
}
}
/// Entfernt den Helfer wieder. Die Lüfter fallen dabei auf Automatik.
public func uninstall() async {
for fan in report?.fans ?? [] { setAutomatic(index: fan.index) }
disconnect()
try? await service.unregister()
refreshInstallState()
}
/// Öffnet die Stelle, an der macOS den Dienst freigeben lässt.
public func openApprovalSettings() {
SMAppService.openSystemSettingsLoginItems()
}
// MARK: - Verbindung
/// Die XPC-Verbindung.
///
/// **Jeder Rückruf hier ist `@Sendable`, und das ist kein Feinschliff.**
/// Ohne die Auszeichnung erbt der Abschluss die MainActor-Isolation dieser
/// Methode. XPC ruft ihn aber auf seiner eigenen Warteschlange, Swift 6
/// prüft das zur Laufzeit und beendet den Prozess:
///
/// Thread 8: com.apple.NSXPCConnection.m-useronyx.helper
/// swift_task_isCurrentExecutorWithFlags
/// closure #3 in FanControl.proxy()
/// EXC_BREAKPOINT
///
/// Das `Task { @MainActor in }` im Rumpf half nicht: die Prüfung
/// geschieht beim **Betreten** des Abschlusses, nicht beim Zugriff.
private func proxy() -> OnyxHelperProtocol? {
if connection == nil {
let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName,
options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: OnyxHelperProtocol.self)
// In beide Richtungen prüfen: die App spricht nur mit einem Helfer,
// der ebenso signiert ist wie sie. Sonst könnte ein untergeschobener
// Dienst unter demselben Namen die Antworten liefern.
try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement)
connection.invalidationHandler = { @Sendable [weak self] in
Task { @MainActor in self?.connection = nil }
}
connection.interruptionHandler = { @Sendable [weak self] in
Task { @MainActor in self?.connection = nil }
}
connection.resume()
self.connection = connection
}
return connection?.remoteObjectProxyWithErrorHandler { @Sendable [weak self] error in
Task { @MainActor in
self?.lastError = error.localizedDescription
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
await self?.repairRegistration()
}
} as? OnyxHelperProtocol
}
/// Meldet den Helfer ab und wieder an.
///
/// **Der Fall, für den das da ist: die App ist umgezogen.** Aus dem
/// Ordner Downloads" nach Programme", oder vom Bauverzeichnis in ein
/// installiertes Bundle. macOS bindet den privilegierten Dienst beim
/// Anmelden an das Programm, das ihn angemeldet hat nach dem Umzug ist
/// das ein anderes Bundle. Der alte Dienst läuft weiter, redet aber nicht
/// mit dem neuen Programm.
///
/// Von außen ist das die verwirrendste Kombination, die es gibt: der
/// Zustand meldet eingerichtet", und trotzdem kommt bei jedem Zugriff
/// Kommunikation nicht möglich". Ohne diese Reparatur bleibt nur, den
/// Helfer von Hand zu entfernen und neu einzurichten und darauf muss
/// man erst einmal kommen.
///
/// Danach steht der Dienst auf wartet auf Freigabe": das Verschieben
/// eines Programms mit Root-Dienst darf niemand still hinnehmen, auch
/// diese App nicht.
private func repairRegistration() async {
guard !hasRepairedRegistration, installState == .installed else { return }
hasRepairedRegistration = true
log.notice("Helfer nicht erreichbar — Registrierung wird erneuert")
disconnect()
try? await service.unregister()
// launchd räumt den alten Dienst nicht im selben Atemzug ab.
try? await Task.sleep(for: .seconds(1))
install()
}
private func disconnect() {
heartbeatTimer?.invalidate(); heartbeatTimer = nil
statusTimer?.invalidate(); statusTimer = nil
connection?.invalidate(); connection = nil
report = nil
}
/// Beginnt zu beobachten und den Herzschlag zu senden.
///
/// Der Herzschlag ist die Lebensversicherung: bleibt er aus weil Onyx
/// abstürzt, beendet wird oder hängt fallen die Lüfter im Helfer nach
/// fünf Sekunden auf Automatik zurück.
public func start() {
refreshChargeLimit()
guard installState == .installed, heartbeatTimer == nil else { return }
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in
self?.proxy()?.heartbeat { _ in }
}
}
statusTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.refreshStatus() }
}
refreshStatus()
}
public func stop() { disconnect() }
private func refreshStatus() {
proxy()?.fanStatus { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
private func apply(_ data: Data?) {
guard let data, let decoded = try? JSONDecoder().decode(FanStatusReport.self, from: data)
else { return }
report = decoded
lastError = nil
}
// MARK: - Steuern
public func setTarget(index: Int, rpm: Double) {
proxy()?.setFan(index: index, targetRPM: rpm) { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
public func setAutomatic(index: Int) {
proxy()?.setFanAutomatic(index: index) { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
// MARK: - Ladelimit
/// Das eingestellte Ladelimit in Prozent. `nil` heißt: keines, oder noch
/// nicht gelesen.
public private(set) var chargeLimit: Int?
public func refreshChargeLimit() {
proxy()?.chargeLimit { @Sendable [weak self] percent in
Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil }
}
}
/// Setzt das Limit und übernimmt, was der Helfer **zurückgelesen** hat
/// nicht das, was gewünscht war. Ein Schreibvorgang, den der SMC verwirft,
/// darf sich in der Oberfläche nicht als Erfolg zeigen.
/// Ohne laufenden Helfer kommt kein Wert dann steht ein Strich da statt
/// einer Zahl, die es nicht gibt.
public var canReadCharge: Bool { installState == .installed }
}

193
Onyx/FanSettingsView.swift Normal file
View File

@@ -0,0 +1,193 @@
import SwiftUI
import OnyxDesign
import MetricsProvider
import OnyxHelperProtocol
/// Die Lüftersteuerung in den Einstellungen.
///
/// Bewusst hier und nicht im Notch-Panel: ein Regler, der die Kühlung des
/// Rechners verstellt, gehört nicht dorthin, wo man mit dem Mauszeiger
/// versehentlich hinkommt.
struct FanSettingsView: View {
@Bindable var control: FanControl
var body: some View {
Form {
switch control.installState {
case .notInstalled, .failed:
installSection
case .requiresApproval:
approvalSection
case .installed:
controlSection
// Das Ladelimit hängt am selben Helfer wie die Lüfter beides
// ist Hardwaresteuerung über den SMC, und beides ist erst
// erreichbar, wenn der Helfer läuft.
Section("charge.section") {
ChargeLimitControl(control: control, roomy: true)
}
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
.onAppear {
control.refreshInstallState()
control.start()
}
.onDisappear { control.stop() }
}
// MARK: - Einrichtung
private var installSection: some View {
Section {
Text("fans.intro")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
// Das Risiko benennen, bevor jemand zustimmt nicht danach.
Label("fans.warning", systemImage: "exclamationmark.triangle.fill")
.font(.callout)
.foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
Text("fans.safety")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
if case .failed(let message) = control.installState {
Text(message).font(.callout).foregroundStyle(Onyx.Color.critical)
}
Button("fans.install") { control.install() }
}
}
private var approvalSection: some View {
Section {
Label("fans.approval.needed", systemImage: "hand.raised")
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
Button("fans.approval.open") { control.openApprovalSettings() }
Button("fans.approval.recheck") { control.refreshInstallState() }
}
}
// MARK: - Steuerung
@ViewBuilder
private var controlSection: some View {
if let report = control.report {
Section {
if let hottest = report.hottestCelsius {
HStack {
Label("fans.hottest", systemImage: "thermometer")
Spacer()
Text("\(Int(hottest))°").monospacedDigit()
.foregroundStyle(hottest >= FanSafety.criticalCelsius
? Onyx.Color.critical : .secondary)
}
}
ForEach(report.fans, id: \.index) { fan in
FanRow(fan: fan, control: control)
}
}
Section {
Text("fans.safety.active")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("fans.uninstall", role: .destructive) {
Task { await control.uninstall() }
}
}
} else {
Section {
HStack {
ProgressView().controlSize(.small)
Text("fans.connecting").foregroundStyle(.secondary)
}
if let error = control.lastError {
Text(error).font(.callout).foregroundStyle(Onyx.Color.critical)
}
}
}
}
}
private struct FanRow: View {
let fan: FanStatus
let control: FanControl
@State private var target: Double = 0
@State private var editing = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Label(name, systemImage: "fan")
Spacer()
Text("\(Int(fan.currentRPM)) U/min").monospacedDigit().foregroundStyle(.secondary)
}
Picker("", selection: Binding(
get: { fan.isManual },
set: { manual in
if manual {
control.setTarget(index: fan.index, rpm: max(target, fan.limits.minimum))
} else {
control.setAutomatic(index: fan.index)
}
})) {
Text("fans.mode.auto").tag(false)
Text("fans.mode.manual").tag(true)
}
.pickerStyle(.segmented)
.labelsHidden()
if fan.isManual, fan.limits.isUsable {
HStack {
Text("\(Int(fan.limits.minimum))").font(.caption2).foregroundStyle(.secondary)
Slider(value: $target,
in: fan.limits.minimum...fan.limits.maximum,
onEditingChanged: { active in
editing = active
// Erst beim Loslassen setzen: jede Bewegung des
// Reglers wäre sonst ein SMC-Schreibvorgang.
if !active { control.setTarget(index: fan.index, rpm: target) }
})
Text("\(Int(fan.limits.maximum))").font(.caption2).foregroundStyle(.secondary)
}
Text("\(Int(target)) U/min").font(.caption).monospacedDigit()
}
// Ein erzwungener Rückfall wird erklärt, nicht verschwiegen.
if let reason = fan.automaticReason, reason != "noRequest" {
Label(explanation(for: reason), systemImage: "shield.lefthalf.filled")
.font(.caption)
.foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
}
}
.onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } }
.onChange(of: fan.targetRPM) { _, new in if !editing { target = new } }
}
private var name: String {
fan.index == 0 ? String(localized: "fans.left") : String(localized: "fans.right")
}
private func explanation(for reason: String) -> LocalizedStringKey {
switch reason {
case "watchdog": "fans.reason.watchdog"
case "temperature": "fans.reason.temperature"
case "noTemperature": "fans.reason.noTemperature"
case "unknownLimits": "fans.reason.unknownLimits"
default: "fans.reason.other"
}
}
}

265
Onyx/FanWidget.swift Normal file
View File

@@ -0,0 +1,265 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
import OnyxMenuBar
import OnyxHelperProtocol
/// Die Lüfter als Panel-Kachel und als Menüleistenmodul.
///
/// Beide zeigen dieselbe Ansicht auf derselben Datenquelle. Die Steuerung nur
/// in den Einstellungen zu verstecken war falsch: Lüfter regelt man, wenn es
/// gerade laut ist, und dann will man nicht erst ein Fenster suchen.
struct FanView: View {
let control: FanControl
/// Im Popover ist Platz für die Erklärung, in der Kachel nicht.
let roomy: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
switch control.installState {
case .installed:
if let report = control.report, !report.fans.isEmpty {
ForEach(Array(report.fans.enumerated()), id: \.offset) { index, fan in
FanRow(control: control, index: index, fan: fan, roomy: roomy)
}
if let hottest = report.hottestCelsius {
Divider().overlay(Onyx.Color.hairline)
HStack(spacing: 5) {
Image(systemName: "thermometer.medium")
.font(.system(size: 10))
Text("\(Int(hottest.rounded())) °C")
.font(Onyx.Font.metricSmall).monospacedDigit()
Spacer(minLength: 0)
}
.foregroundStyle(Onyx.Color.textTertiary)
}
} else {
notice("fans.noData", symbol: "fan")
}
case .requiresApproval:
notice("fans.approval.needed", symbol: "hand.raised") {
control.openApprovalSettings()
}
case .notInstalled:
notice("fans.notInstalled", symbol: "fan") { control.install() }
case .failed(let reason):
Text(reason)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.critical)
.fixedSize(horizontal: false, vertical: true)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { control.refreshInstallState() }
}
@ViewBuilder
private func notice(_ key: LocalizedStringKey, symbol: String,
action: (() -> Void)? = nil) -> some View {
VStack(alignment: .leading, spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text(key)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if let action {
Button(action: action) {
Text("fans.install")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10).padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
}
}
}
}
private struct FanRow: View {
let control: FanControl
let index: Int
let fan: FanStatus
let roomy: Bool
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("fans.fan \(fan.index + 1)")
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 4)
Text("fans.rpm \(Int(fan.currentRPM))")
.font(Onyx.Font.metricSmall).monospacedDigit()
.foregroundStyle(Onyx.Color.textPrimary)
}
HStack(spacing: 8) {
// Automatik ist der Ruhezustand und bleibt einen Klick entfernt:
// wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen.
// Als gefüllte Pille, wenn sie greift ein blauer Text sieht
// aus wie ein Link und nicht wie ein Zustand.
Button { control.setAutomatic(index: index) } label: {
Text("fans.auto")
.font(Onyx.Font.metricSmall)
.foregroundStyle(fan.isManual ? Onyx.Color.textTertiary
: Onyx.Color.surface)
.padding(.horizontal, 7)
.padding(.vertical, 2)
.background {
Capsule()
.fill(fan.isManual ? Color.clear : Onyx.Color.accent)
.overlay {
Capsule().strokeBorder(
fan.isManual ? Onyx.Color.hairline : .clear,
lineWidth: 1)
}
}
}
.buttonStyle(.plain)
.disabled(!fan.isManual)
// Ein Regler über einen leeren Bereich stürzt ab. Meldet der
// Helfer keine brauchbaren Grenzen etwa weil der Lüfter steht
// und der SMC nichts hergibt , gibt es nichts zu ziehen.
if fan.limits.maximum > fan.limits.minimum {
Slider(value: Binding(
get: { min(max(fan.targetRPM, fan.limits.minimum), fan.limits.maximum) },
set: { control.setTarget(index: index, rpm: $0) }),
in: fan.limits.minimum...fan.limits.maximum)
.controlSize(.mini)
} else {
Text("fans.noRange")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
}
}
}
// MARK: - Panel-Kachel
struct FanWidget: OnyxWidget {
let id = "fans"
var displayName: String { String(localized: "widget.fans.name") }
let symbolName = "fan"
private let control: FanControl
init(control: FanControl) { self.control = control }
func makeView() -> AnyView {
AnyView(ScrollView { FanView(control: control, roomy: false) }
.scrollIndicators(.never))
}
}
// MARK: - Menüleistenmodul
@MainActor
final class FanMenuBarModule: MenuBarModule {
let id = "fans"
var displayName: String { String(localized: "widget.fans.name") }
var shortLabel: String { String(localized: "fans.menubar.short") }
private let control: FanControl
private var view: FanStatusView?
private var isActive = false
init(control: FanControl) { self.control = control }
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = FanStatusView(presentation: presentation)
view.update(rpm: control.report?.fans.map(\.currentRPM).max())
self.view = view
return view
}
func makePopoverView() -> AnyView {
AnyView(FanView(control: control, roomy: true)
.padding(14)
.frame(width: 260))
}
func activate() {
isActive = true
control.start()
startObserving()
}
func deactivate() {
isActive = false
view = nil
}
/// Keine eigene Messschleife: `FanControl` fragt den Helfer ohnehin ab, hier
/// wird nur zugehört. `withObservationTracking` meldet sich **einmal** und
/// muss danach neu eingerichtet werden.
private func startObserving() {
withObservationTracking {
_ = control.report
} onChange: {
Task { @MainActor [weak self] in
guard let self, isActive else { return }
view?.update(rpm: control.report?.fans.map(\.currentRPM).max())
startObserving()
}
}
}
}
/// Die Drehzahl in der Menüleiste.
private final class FanStatusView: NSView, WidthReporting {
private let presentation: MenuBarPresentation
private var rpm: Double?
init(presentation: MenuBarPresentation) {
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: MenuBarText.width(for: reference, includesSymbol: true), height: 22)
}
/// Die breiteste Zahl, die vorkommen kann nicht die aktuelle. Sonst
/// änderte das Element bei jeder Messung seine Breite.
private var reference: String { presentation == .symbol ? "" : "9999" }
/// Der Lüfter zeigt ohnehin Symbol und Zahl die neuen Darstellungen
/// ändern daran nichts, sie stehen nur in der Auswahl nicht zur Verfügung.
var onWidthChange: ((CGFloat) -> Void)?
func update(rpm: Double?) {
self.rpm = rpm
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
let color = NSColor.labelColor
if presentation == .symbol {
MenuBarText.drawSymbol("fan", color: color,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
return
}
MenuBarText.drawSymbol("fan", color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5, width: 13, height: 13))
MenuBarText.draw(rpm.map { String(Int($0.rounded())) } ?? "",
color: color,
in: NSRect(x: 15, y: 0, width: bounds.width - 15, height: bounds.height))
}
}

View File

@@ -40,6 +40,14 @@
<string>Onyx zeigt das Wetter an deinem aktuellen Standort. Du kannst stattdessen auch einen festen Ort wählen.</string>
<key>NSAppleEventsUsageDescription</key>
<string>Onyx steuert Spotify und Musik, wenn die universelle Medienschnittstelle nicht verfügbar ist.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche, damit man den nachgebildeten Bildschirm wiedererkennt. Liegt das Bild auf dem Schreibtisch, braucht Onyx dafür einmal deine Erlaubnis.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Dokumente", braucht Onyx dafür einmal deine Erlaubnis.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>Onyx zeigt dein Hintergrundbild in der Vorschau der Auslösefläche. Liegt das Bild in „Downloads", braucht Onyx dafür einmal deine Erlaubnis.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Onyx zeigt im Mixer an, wie stark dein Mikrofon gerade ausschlägt. Gemessen wird nur, solange die Ansicht offen ist — aufgezeichnet wird nichts.</string>
<key>NSAudioCaptureUsageDescription</key>
<string>Onyx braucht Zugriff auf die Audioausgabe, um die Lautstärke einzelner Programme zu regeln. Es wird nichts aufgezeichnet.</string>

55
Onyx/LaunchAtLogin.swift Normal file
View File

@@ -0,0 +1,55 @@
import Foundation
import ServiceManagement
import Observation
import OnyxCore
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Login")
/// Ob Onyx beim Anmelden mitstartet.
///
/// Über `SMAppService.mainApp` und nicht über den alten Weg mit einem
/// versteckten Helferprogramm: macOS trägt die App dann selbst in die
/// Systemeinstellungen ein, und der Nutzer sieht dort einen Eintrag, den er
/// versteht. Der Preis ist, dass er ihn dort auch abschalten kann und dann
/// hilft kein `register()` mehr, sondern nur der Weg zurück dorthin.
@MainActor
@Observable
final class LaunchAtLogin {
private(set) var state: LoginItemState = .notRegistered
init() {
refresh()
}
func refresh() {
state = LoginItemState(statusRawValue: SMAppService.mainApp.status.rawValue)
}
var isEnabled: Bool { state.isEnabled }
var needsSystemSettings: Bool { state.needsSystemSettings }
/// - Returns: ob es geklappt hat. Bei `false` steht der Grund in `state`.
@discardableResult
func set(_ enabled: Bool) -> Bool {
do {
if enabled {
try SMAppService.mainApp.register()
} else {
try SMAppService.mainApp.unregister()
}
refresh()
log.notice("Anmeldeobjekt \(enabled ? "eingetragen" : "entfernt", privacy: .public), Zustand \(String(describing: self.state), privacy: .public)")
return isEnabled == enabled
} catch {
log.error("Anmeldeobjekt: \(error.localizedDescription, privacy: .public)")
refresh()
return false
}
}
func openSystemSettings() {
SMAppService.openSystemSettingsLoginItems()
}
}

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,139 @@
import SwiftUI
import AppKit
import AudioProvider
import OnyxMenuBar
import OnyxDesign
/// Der Lautstärkemixer als eigenes Element in der Menüleiste.
///
/// Bewusst getrennt vom Onyx-Element: den Ton einer einzelnen App leiser zu
/// drehen ist etwas, das man mitten in etwas anderem tut. Ein Umweg über ein
/// Menü und dann in ein Panel ist dafür zwei Schritte zu weit.
@MainActor
final class MixerMenuBarModule: MenuBarModule {
let id = "audio"
var displayName: String { String(localized: "mixer.menubar.name") }
var shortLabel: String { String(localized: "mixer.menubar.short") }
private let mixer: AudioMixer
private let system: SystemAudio
private var view: MixerStatusView?
private var isActive = false
init(mixer: AudioMixer, system: SystemAudio) {
self.mixer = mixer
self.system = system
}
func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MixerStatusView()
view.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
self.view = view
return view
}
/// Pegel nur, solange das Popover offen ist.
///
/// Der Mikrofonpegel schaltet den orangen Punkt an das darf keine
/// Sekunde länger dauern als das Hinsehen. An der View ließ es sich nicht
/// festmachen: `onDisappear` bleibt beim Popover aus, und der Punkt blieb
/// dauerhaft stehen.
func popoverDidOpen() { system.levels.addConsumer() }
func popoverDidClose() { system.levels.removeConsumer() }
func makePopoverView() -> AnyView {
AnyView(
ScrollView {
MixerView(mixer: mixer, system: system)
.padding(16)
}
.scrollIndicators(.automatic)
// Deutlich breiter als das Panel: hier ist der Mixer zu Hause.
// Eine Zeile aus Symbol, Stummschalter, Regler und Wert braucht
// Platz, und ein Regler von hundert Punkten Länge lässt sich nicht
// fein einstellen.
.frame(width: 420)
.frame(maxHeight: 620)
)
}
func activate() {
isActive = true
startObserving()
}
func deactivate() {
isActive = false
view = nil
}
/// Der Mixer misst ohnehin; hier wird nur zugehört. `withObservationTracking`
/// meldet sich einmal und muss danach neu eingerichtet werden.
private func startObserving() {
withObservationTracking {
_ = mixer.isEnabled
_ = mixer.managed
} onChange: {
Task { @MainActor [weak self] in
guard let self, isActive else { return }
view?.update(isEnabled: mixer.isEnabled, managed: mixer.managed.count)
startObserving()
}
}
}
}
/// Ein Glyph, mehr nicht.
///
/// Eine Zahl wäre hier sinnlos: welche Lautstärke" hat bei einem Mischpult
/// keine einzelne Antwort. Der Zustand, der zählt, ist ob überhaupt etwas
/// geregelt wird und den zeigt die Deckkraft.
private final class MixerStatusView: NSView {
private var isEnabled = false
private var managed = 0
init() {
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var intrinsicContentSize: NSSize {
NSSize(width: MenuBarText.minimumWidth, height: 22)
}
func update(isEnabled: Bool, managed: Int) {
self.isEnabled = isEnabled
self.managed = managed
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
// Volle Deckkraft, auch ausgeschaltet.
//
// Die Abdunklung war meine Idee und ein Fehlgriff: in der Menüleiste
// liegt oft ein heller Schreibtischhintergrund, und ein zu 55 % blasses
// Glyph ist dort praktisch weg. Ob der Mixer läuft, erfährt man beim
// Klick dafür muss man das Symbol aber erst finden.
let rect = NSRect(x: bounds.midX - 8, y: bounds.midY - 8, width: 16, height: 16)
if let glyph = Self.glyph {
// `labelColor` und nicht Weiß: unter einer hellen Menüleiste wäre
// ein weißes Symbol unsichtbar. Auf dunklem Grund ist labelColor
// weiß, also genau das Gewünschte.
MenuBarText.drawTemplate(glyph, color: .labelColor, in: rect)
return
}
MenuBarText.drawSymbol("slider.horizontal.3", color: .labelColor, in: rect)
}
/// Die eigene Vorlage aus dem Bundle. Einmal geladen und behalten das
/// Zeichnen läuft bei jeder Änderung, das Einlesen einer SVG nicht.
private static let glyph: NSImage? = {
guard let url = Bundle.main.url(forResource: "sound-waves", withExtension: "svg")
else { return nil }
return NSImage(contentsOf: url)
}()
}

210
Onyx/OnboardingView.swift Normal file
View File

@@ -0,0 +1,210 @@
import SwiftUI
import OnyxDesign
import AppKit
import CalendarProvider
import WeatherProvider
import AudioProvider
/// Der erste Start.
///
/// Onyx braucht vier Dinge vom System, und jedes für etwas anderes. Sie alle
/// beim Start hintereinander abzufragen wäre der übliche Weg und der falsche:
/// wer vier Dialoge sieht, ohne zu wissen wofür, klickt viermal Nicht
/// erlauben" und macOS fragt danach nie wieder. Deshalb steht hier zu jedem
/// Punkt, wofür er gut ist, und jeder wird einzeln und auf Klick angefragt.
///
/// Nichts davon ist Pflicht. Ohne Kalenderzugriff fehlt ein Widget, nicht die
/// App.
struct OnboardingView: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let mixer: AudioMixer
let fanControl: FanControl
let launchAtLogin: LaunchAtLogin
let onFinish: () -> Void
@State private var calendarGranted = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
OnboardingRow(
symbol: "calendar",
title: "onboarding.calendar",
detail: "onboarding.calendar.why",
state: calendarState) {
calendarGranted = await calendarModel.requestAccessNow()
} openSettings: {
open("Privacy_Calendars")
}
OnboardingRow(
symbol: "location",
title: "onboarding.location",
detail: "onboarding.location.why",
state: locationState) {
weatherModel.requestLocationAuthorization()
} openSettings: {
open("Privacy_LocationServices")
}
OnboardingRow(
symbol: "slider.horizontal.3",
title: "onboarding.audio",
detail: "onboarding.audio.why",
state: mixer.isEnabled ? .granted : .undetermined) {
mixer.enable()
} openSettings: {
open("Privacy_Microphone")
}
OnboardingRow(
symbol: "fan",
title: "onboarding.helper",
detail: "onboarding.helper.why",
state: helperState) {
fanControl.install()
} openSettings: {
fanControl.openApprovalSettings()
}
}
.padding(.horizontal, 22)
.padding(.vertical, 6)
}
Divider()
footer
}
.frame(width: 520, height: 520)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
.task {
calendarGranted = calendarModel.hasAccess
fanControl.refreshInstallState()
launchAtLogin.refresh()
}
}
// MARK: - Kopf und Fuß
private var header: some View {
HStack(spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable().frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 3) {
Text("onboarding.title").font(.title2).fontWeight(.semibold)
Text("onboarding.subtitle")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
}
.padding(22)
}
private var footer: some View {
HStack {
Toggle(isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) })) {
Text("onboarding.launchAtLogin")
}
.toggleStyle(.checkbox)
.disabled(launchAtLogin.needsSystemSettings)
if launchAtLogin.needsSystemSettings {
Button("onboarding.openSettings") { launchAtLogin.openSystemSettings() }
.buttonStyle(.link)
}
Spacer()
Button("onboarding.done", action: onFinish)
.keyboardShortcut(.defaultAction)
}
.padding(22)
}
// MARK: - Zustände
private var calendarState: OnboardingRow.State {
if calendarGranted { return .granted }
return calendarModel.authorization == .denied ? .denied : .undetermined
}
private var locationState: OnboardingRow.State {
switch weatherModel.locationAuthorization {
case .granted: .granted
case .denied: .denied
case .undetermined: .undetermined
}
}
private var helperState: OnboardingRow.State {
switch fanControl.installState {
case .installed: .granted
// Nicht `.denied`: hier hilft tatsächlich der Gang in die
// Systemeinstellungen, und der Knopf soll das auch sagen.
case .requiresApproval: .denied
case .notInstalled, .failed: .undetermined
}
}
private func open(_ pane: String) {
guard let url = URL(
string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")
else { return }
NSWorkspace.shared.open(url)
}
}
/// Ein Punkt im Onboarding: wofür, wie es steht, und was von hier weiterführt.
private struct OnboardingRow: View {
enum State { case granted, denied, undetermined }
let symbol: String
let title: LocalizedStringKey
let detail: LocalizedStringKey
let state: State
let request: () async -> Void
let openSettings: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 14) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary)
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 2) {
Text(title).fontWeight(.medium)
Text(detail)
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
switch state {
case .granted:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
.font(.system(size: 16))
case .undetermined:
Button("onboarding.allow") { Task { await request() } }
case .denied:
// Nach einer Ablehnung fragt macOS nie wieder ein
// Erlauben-Knopf wäre hier eine Lüge.
Button("onboarding.openSettings", action: openSettings)
}
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) { Divider() }
}
}

View File

@@ -0,0 +1,95 @@
import AppKit
import SwiftUI
import OnyxDesign
import CalendarProvider
import WeatherProvider
import AudioProvider
/// Zeigt das Onboarding einmal, beim ersten Start.
///
/// Als eigenes Fenster und nicht im Panel: TCC blendet Berechtigungsdialoge
/// stillschweigend aus, wenn keine App im Vordergrund steht, und eine
/// `.accessory`-App mit ausgefahrenem Panel steht nicht im Vordergrund. Genau
/// daran ist die Kalenderberechtigung schon einmal gescheitert.
@MainActor
final class OnboardingWindowController: NSObject, NSWindowDelegate {
private static let doneKey = "onboarding.completed"
private var window: NSWindow?
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
var isDone: Bool { defaults.bool(forKey: Self.doneKey) }
func showIfNeeded(calendarModel: CalendarModel, weatherModel: WeatherModel,
mixer: AudioMixer, fanControl: FanControl,
launchAtLogin: LaunchAtLogin) {
guard !isDone else { return }
show(calendarModel: calendarModel, weatherModel: weatherModel, mixer: mixer,
fanControl: fanControl, launchAtLogin: launchAtLogin)
}
func show(calendarModel: CalendarModel, weatherModel: WeatherModel,
mixer: AudioMixer, fanControl: FanControl, launchAtLogin: LaunchAtLogin) {
// Solange dieses Fenster steht, gehört Onyx in den Dock und in den
// Vordergrund sonst erscheinen die Systemdialoge gar nicht.
NSApp.setActivationPolicy(.regular)
if window == nil {
let hosting = NSHostingController(
rootView: OnboardingView(
calendarModel: calendarModel, weatherModel: weatherModel,
mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin,
onFinish: { [weak self] in self?.finish() }))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx"
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
window.delegate = self
// Nicht direkt unter die Notch: wer den Schließknopf trifft,
// fährt sonst durch die Auslösefläche.
if let screen = NSScreen.main {
let visible = screen.visibleFrame
window.setFrameOrigin(NSPoint(x: visible.midX - window.frame.width / 2,
y: visible.maxY - 60 - window.frame.height))
} else {
window.center()
}
self.window = window
}
NSApp.activate(ignoringOtherApps: true)
window?.makeKeyAndOrderFront(nil)
}
/// Auch das Schließen über den roten Knopf gilt als erledigt. Ein Fenster,
/// das beim nächsten Start wiederkommt, weil man es weggeklickt hat, ist
/// eine Zumutung.
private func finish() {
defaults.set(true, forKey: Self.doneKey)
hide()
}
/// Wie bei den Einstellungen: ausblenden statt schließen, damit ein
/// Hilfsprogramm mit letztes Fenster zu = App beenden" Onyx nicht mit
/// wegräumt.
func windowShouldClose(_ sender: NSWindow) -> Bool {
defaults.set(true, forKey: Self.doneKey)
hide()
return false
}
private func hide() {
window?.orderOut(nil)
// Zurück in den Hintergrund es sei denn, ein anderes Fenster steht
// noch offen.
if !NSApp.windows.contains(where: { $0 !== window && $0.isVisible }) {
NSApp.setActivationPolicy(.accessory)
}
}
}

View File

@@ -8,6 +8,13 @@ import OnyxWidgetKit
import CalendarProvider
import WeatherProvider
import MediaProvider
import MetricsProvider
import NetworkProvider
import AudioProvider
import ShelfProvider
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
@main
struct OnyxApp: App {
@@ -34,7 +41,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var calendarModel: CalendarModel?
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
private var metricsModel: MetricsModel?
private var networkModel: NetworkModel?
private var shelfStore: ShelfStore?
private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl()
private let audioMixer = AudioMixer()
private let systemAudio = SystemAudio()
private let launchAtLogin = LaunchAtLogin()
private let onboarding = OnboardingWindowController()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
@@ -52,8 +67,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let model = AppModel()
self.model = model
// Die gespeicherte Wahl gleich anwenden sonst steht Onyx nach einem
// Neustart wieder ohne Dock-Symbol da, obwohl es eingeschaltet ist.
NSApp.setActivationPolicy(model.showsDockIcon ? .regular : .accessory)
let coordinator = NotchCoordinator(policy: model.displayPolicy) { _, presentation in
let coordinator = NotchCoordinator(policy: model.displayPolicy,
trigger: model.triggerArea) { _, presentation in
NotchHostingView(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model)
@@ -67,14 +86,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Einstellungen wirken sofort. Eine Änderung, die erst nach einem
// Neustart greift, ist in einer App ohne Fenster praktisch unauffindbar.
model.onLayoutChanged = { [weak self] _ in self?.applyContentSize() }
model.onTriggerAreaChanged = { [weak self] area in
self?.coordinator?.trigger = area
}
model.onDisplayPolicyChanged = { [weak self] policy in
self?.coordinator?.policy = policy
self?.applyContentSize()
}
let menuBar = MenuBarController()
if let metricsModel {
for metric in MetricKind.allCases {
menuBar.register(MetricMenuBarModule(metric: metric, model: metricsModel),
settings: model.menuBarSettings(for: "metric.\(metric.rawValue)"))
}
}
if let networkModel {
menuBar.register(NetworkMenuBarModule(model: networkModel),
settings: model.menuBarSettings(for: "network"))
}
// Der Ausgangspegel muss die Programme auslassen, die der Mixer bereits
// abgreift nur der Mixer weiß, welche das sind.
systemAudio.levels.mutedProcesses = { [weak audioMixer] in
audioMixer?.tappedProcessIDs ?? []
}
// Mixer und Lüfter gehören genauso in die Leiste wie die Messwerte:
// beides regelt man mitten in etwas anderem, und dafür ist der Weg
// über ein Fenster zu weit.
menuBar.register(MixerMenuBarModule(mixer: audioMixer, system: systemAudio),
settings: model.menuBarSettings(for: "audio"))
menuBar.register(FanMenuBarModule(control: fanControl),
settings: model.menuBarSettings(for: "fans"))
menuBar.start()
self.menuBar = menuBar
model.menuBar = menuBar
// Der Mixer läuft nur an, wenn das Häkchen gesetzt ist er greift in
// den Tonweg ein, und das soll niemandem ungefragt passieren.
audioMixer.enableIfWanted()
installOnyxStatusItem()
observeMenuBarPreference()
@@ -90,12 +140,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// dort freischalten könnte. Genau dieser Zustand ist beim Testen
/// aufgetreten.
private func requestPermissions() {
guard let calendarModel else { return }
guard let calendarModel, let weatherModel else { return }
log.notice("Start: Einrichtung erledigt = \(self.onboarding.isDone, privacy: .public)")
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
// das Widget den Berechtigungshinweis statt einer leeren Liste.
calendarModel.refresh()
// Fenster öffnen, solange irgendeine Berechtigung noch aussteht.
let needsLocation = weatherModel?.locationIsUndetermined ?? false
// Die Einrichtung kommt beim ersten Start immer auch wenn zufällig
// schon alles erlaubt ist. Sie erklärt nicht nur Berechtigungen,
// sondern auch den Helfer und den Autostart, und sie ist der einzige
// Moment, in dem jemand erfährt, was Onyx eigentlich alles kann.
guard onboarding.isDone else {
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
mixer: audioMixer, fanControl: fanControl,
launchAtLogin: launchAtLogin)
return
}
// Danach nur noch, wenn wirklich etwas fehlt.
let needsLocation = weatherModel.locationIsUndetermined
guard !calendarModel.hasAccess || needsLocation else { return }
// Nicht im Hintergrund anfragen.
@@ -108,20 +171,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `NSApp.activate` davor hilft nicht: Aktivierung wirkt asynchron und
// ist beim Aufruf noch nicht durch.
//
// Deshalb der ehrliche Weg: beim ersten Start das Einstellungsfenster
// öffnen. Dort ist ein echtes Fenster im Vordergrund, der Dialog
// erscheint zuverlässig und der Nutzer weiß, warum gefragt wird.
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
// Einrichtung lief schon einmal, hier fehlt nur noch etwas dann
// genügt der Berechtigungsbereich der Einstellungen.
showSettings(tab: .permissions)
}
/// Die Einrichtung noch einmal zeigen aus den Einstellungen heraus.
private func showOnboarding() {
guard let calendarModel, let weatherModel else { return }
onboarding.show(calendarModel: calendarModel, weatherModel: weatherModel,
mixer: audioMixer, fanControl: fanControl,
launchAtLogin: launchAtLogin)
}
/// Ausdrücklich `false`, auch wenn es der Standard ist.
///
/// Onyx lebt in der Menüleiste und in der Notch. Dass das letzte Fenster
/// zugeht, sagt über das Beenden nichts aus und die Voreinstellung darf
/// sich nicht unbemerkt ändern.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
false
}
func applicationWillTerminate(_ notification: Notification) {
coordinator?.stop()
menuBar?.stop()
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
// Sekunden auf Automatik zurück auch wenn Onyx abstürzt statt sauber
// zu beenden.
fanControl.stop()
// Ohne das bleiben die abgegriffenen Programme stumm zurück: der Tap
// schaltet das Original stumm, und ohne laufenden Mixer gibt niemand
// mehr etwas aus.
audioMixer.disable()
}
/// Echte Widgets zuerst, danach die Platzhalter für alles, was noch fehlt.
private func registerWidgets() {
let calendarModel = CalendarModel(source: EventKitSource())
let calendarModel = CalendarModel()
self.calendarModel = calendarModel
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
@@ -133,7 +220,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.mediaModel = mediaModel
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
PlaceholderWidgets.registerAll()
let metricsModel = MetricsModel()
// Der Akku bekommt seinen Ladelimit-Regler über den Helfer, den sonst
// die Lüfter benutzen es ist derselbe SMC-Zugang.
metricsModel.chargeControl = fanControl
self.metricsModel = metricsModel
for metric in MetricKind.allCases {
WidgetRegistry.shared.register(MetricWidget(metric: metric, model: metricsModel))
}
let networkModel = NetworkModel()
self.networkModel = networkModel
WidgetRegistry.shared.register(NetworkWidget(model: networkModel))
WidgetRegistry.shared.register(AudioMixerWidget(mixer: audioMixer, system: systemAudio))
WidgetRegistry.shared.register(FanWidget(control: fanControl))
let shelfStore = ShelfStore()
self.shelfStore = shelfStore
// AirDrop steckt in der Ablage-Kachel zwei eigene Kacheln für je
// eine leere Ablegefläche waren ein Drittel des Panels.
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
}
private func applyContentSize() {
@@ -144,19 +252,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Menüleiste
/// Das feste Onyx-Element: Panel öffnen, Einstellungen, Beenden. Es ist auch
/// das Sicherheitsnetz, falls die Notch einmal nicht reagiert.
/// Das feste Onyx-Element.
///
/// **Kein Panel öffnen" im Menü**, aber ganz normal ein Menü.
///
/// Der Eintrag beschrieb einen Umweg um die eigentliche Bedienung herum
/// das Panel öffnet sich, wenn man die Notch berührt. Ihn durch Linksklick
/// fährt das Panel aus" zu ersetzen war aber schlechter als das Problem:
/// dieser Klick ging als Fixieren durch, und danach stand das Panel offen
/// und reagierte auf nichts. Ein Statuselement zeigt bei einem Klick sein
/// Menü. Alles andere überrascht.
private func installOnyxStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "onyx.menubar.main"
item.button?.image = Self.menuBarIcon()
let menu = NSMenu()
menu.addItem(withTitle: String(localized: "menu.openPanel"),
action: #selector(togglePanel), keyEquivalent: "").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.settings"),
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
action: #selector(openOnboarding), keyEquivalent: "").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
@@ -165,6 +280,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
onyxItem = item
}
@objc private func openOnboarding() { showOnboarding() }
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
///
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
@@ -172,28 +289,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Menüleiste. Dort steht sie als farbiger Klotz zwischen lauter
/// Strichsymbolen.
///
/// Geladen wird dieselbe SVG-Datei, aus der auch das App-Icon gebaut wird
/// NSImage liest sie als Vektor (`_NSSVGImageRep`), sie skaliert also
/// verlustfrei.
/// **Eine eigene Konturfassung, nicht das App-Icon.**
///
/// **Als Template-Bild.** Das Glyph besteht aus zehn deckenden Pfaden in
/// sehr dunklen Tönen (#1c1f1f bis #000000). Im App-Icon liegt es auf
/// blauem Verlauf und liest sich als Kristall; allein auf einer dunklen
/// Menüleiste ist es schwarz auf schwarz. macOS färbt ausschließlich
/// Template-Bilder ein, und die benutzen nur den Alphakanal die Facetten
/// verschmelzen dabei zu einer Silhouette.
/// Das Glyph des App-Icons besteht aus deckenden Flächen in sehr dunklen
/// Tönen. Auf dem blauen Verlauf des Icons liest es sich als Kristall,
/// allein auf einer dunklen Menüleiste ist es schwarz auf schwarz. Und als
/// Template-Bild der einzige Weg, macOS zum Einfärben zu bewegen
/// verschmelzen die Facetten zu einer flachen Silhouette, weil dabei nur
/// der Alphakanal zählt.
///
/// Das ist der bewusste Tausch: erkennbare Form in hellen wie dunklen
/// Menüleisten statt Facetten, die man in keiner von beiden sieht. Eine
/// Fassung mit durchsichtigen Facettenfugen behielte beides dafür müsste
/// das SVG entsprechend gezeichnet sein.
/// `MenuBarIcon.svg` ist deshalb als Kontur gezeichnet: zwischen den Linien
/// ist nichts, also bleibt beim Einfärben die Struktur erhalten. Das Symbol
/// wird damit automatisch weiß auf dunkler und schwarz auf heller
/// Menüleiste, ohne zweite Datei für den zweiten Fall.
///
/// 18 pt ist die übliche Höhe; die Leiste ist 22 pt hoch und braucht oben
/// und unten Luft.
private static func menuBarIcon() -> NSImage {
let size = NSSize(width: 18, height: 18)
if let url = Bundle.main.url(forResource: "quartz", withExtension: "svg"),
if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "svg"),
let glyph = NSImage(contentsOf: url) {
let image = NSImage(size: size, flipped: false) { rect in
glyph.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
@@ -232,7 +347,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func showSettings(tab: SettingsTab) {
guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, tab: tab)
weatherModel: weatherModel, metricsModel: metricsModel,
shelfStore: shelfStore, fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: { [weak self] in self?.showOnboarding() },
tab: tab)
}
@objc private func quit() { NSApp.terminate(nil) }
@@ -244,6 +363,9 @@ private struct NotchPanelContent: View {
let model: AppModel
var body: some View {
WidgetGrid(placements: model.layout)
WidgetGrid(placements: model.layout, availableWidth: AppModel.availableWidth)
// Im Rumpf gelesen, damit ein Farbwechsel ankommt.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
}
}

View File

@@ -1,70 +0,0 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Vorläufige Widgets für Phase 2.
///
/// Sie tragen keine echten Daten ihr Zweck ist zu belegen, dass Registry,
/// Layout-Engine, Persistenz und Panelgröße zusammenspielen, bevor die Provider
/// dazukommen. Jedes wird in seiner Phase durch das echte Widget ersetzt; die
/// Kennungen bleiben dabei gleich, damit gespeicherte Layouts weitergelten.
struct PlaceholderWidget: OnyxWidget {
let id: String
let displayName: String
let symbolName: String
let supportedSizes: [WidgetSize]
/// In welcher Phase dieses Widget echt wird.
let phase: String
func makeView(size: WidgetSize) -> AnyView {
AnyView(
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Image(systemName: symbolName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Onyx.Color.accent)
Text(displayName)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
Spacer(minLength: 0)
Text(phase)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
)
}
}
@MainActor
enum PlaceholderWidgets {
/// Registriert nur noch die Karten, deren echte Fassung fehlt. Sobald ein
/// Widget umgesetzt ist, verschwindet es hier die Kennung bleibt gleich,
/// damit gespeicherte Layouts weitergelten.
static func registerAll() {
let widgets: [PlaceholderWidget] = [
.init(id: "cpu", displayName: "CPU", symbolName: "cpu",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "memory", displayName: "Speicher", symbolName: "memorychip",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "battery", displayName: "Akku", symbolName: "battery.100",
supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "sensors", displayName: "Sensoren", symbolName: "thermometer",
supportedSizes: [.medium, .large], phase: "Phase 5"),
.init(id: "network", displayName: "Netzwerk", symbolName: "network",
supportedSizes: [.small, .medium, .large], phase: "Phase 5"),
.init(id: "fans", displayName: "Lüfter", symbolName: "fan",
supportedSizes: [.small, .medium], phase: "Phase 6"),
.init(id: "audio", displayName: "Audio-Mixer", symbolName: "slider.horizontal.3",
supportedSizes: [.medium, .large], phase: "Phase 7"),
.init(id: "shelf", displayName: "Ablage", symbolName: "tray.full",
supportedSizes: [.small, .medium], phase: "Phase 8"),
.init(id: "airdrop", displayName: "AirDrop", symbolName: "shareplay",
supportedSizes: [.small], phase: "Phase 8"),
]
widgets.forEach { WidgetRegistry.shared.register($0) }
}
}

View File

@@ -3,31 +3,107 @@ import OnyxDesign
import OnyxNotch
import OnyxWidgetKit
import CalendarProvider
import ShelfProvider
import OnyxMenuBar
import MetricsProvider
import OnyxHelperProtocol
import WeatherProvider
enum SettingsTab: Hashable {
case widgets, display, permissions
case widgets, display, colors, sources, menubar, fans, permissions
}
struct SettingsView: View {
@Bindable var model: AppModel
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let metricsModel: MetricsModel?
let shelfStore: ShelfStore?
let fanControl: FanControl
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
/// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes
/// Öffnen ihn setzen kann etwa auf Berechtigungen", wenn eine fehlt.
@Binding var selectedTab: SettingsTab
/// Die Reiter in der Reihenfolge, in der sie oben stehen.
private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [
(.widgets, "settings.tab.widgets"),
(.display, "settings.tab.display"),
(.colors, "settings.tab.colors"),
(.sources, "settings.tab.options"),
(.menubar, "settings.tab.menubar"),
(.fans, "settings.tab.fans"),
(.permissions, "settings.tab.permissions"),
]
var body: some View {
TabView(selection: $selectedTab) {
WidgetSettings(model: model)
.tabItem { Label("settings.tab.widgets", systemImage: "square.grid.2x2") }
.tag(SettingsTab.widgets)
DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display)
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions)
// Eigene Reiterleiste statt `TabView`.
//
// `TabView` zieht unter seiner Leiste einen Trenner quer durchs
// Fenster nicht abschaltbar, und die Leiste sitzt mittig darauf.
// Abstand nach oben half nicht: der Trenner wandert mit. Also die
// Leiste selbst gebaut, dann gibt es die Linie gar nicht erst.
VStack(spacing: 0) {
Picker("", selection: $selectedTab) {
ForEach(Self.tabs, id: \.tab) { entry in
Text(entry.label).tag(entry.tab)
}
}
.pickerStyle(.segmented)
.labelsHidden()
// Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund
// 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch
// dann, wenn das Fenster schmaler war sie ragte dann links und
// rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt
// dafür, dass hier trotzdem nichts gestaucht wird.
.frame(maxWidth: 1000)
.padding(.horizontal, 20)
.padding(.top, 10)
.padding(.bottom, 14)
content
}
// Eine Fläche für alles.
//
// Vorher hatte jeder Bereich seinen eigenen Untergrund: die
// Formulare bringen einen mit, der Rest des Fensters hat den des
// Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent
// auseinander. Sichtbar wurde das als Kante quer durchs Fenster.
.background(Color(nsColor: .windowBackgroundColor))
// Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters.
//
// Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder
// View SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter
// und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen
// galt, während der Farbwähler daneben schon die neue zeigte.
.tint(Onyx.Color.accent)
.labelStyle(.onyx)
// Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster
// wachsen, der Inhalt aber nicht mit.
.frame(minWidth: 740, minHeight: 460)
}
@ViewBuilder
private var content: some View {
switch selectedTab {
case .widgets:
WidgetSettings(model: model, shelfStore: shelfStore)
case .display:
DisplaySettings(model: model)
case .colors:
ColorSettingsView(theme: OnyxTheme.shared)
case .sources:
WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel,
shelfStore: shelfStore)
case .menubar:
MenuBarSettings(model: model, metricsModel: metricsModel)
case .fans:
FanSettingsView(control: fanControl)
case .permissions:
PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel,
launchAtLogin: launchAtLogin, showOnboarding: showOnboarding)
}
.frame(width: 520, height: 420)
}
}
@@ -42,96 +118,311 @@ struct SettingsView: View {
private struct PermissionSettings: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
@State private var granted = false
@State private var locationGranted = false
let launchAtLogin: LaunchAtLogin
let showOnboarding: () -> Void
@State private var calendarGranted = false
private var locationState: PermissionRow.State {
switch weatherModel.locationAuthorization {
case .granted: .granted
case .denied: .denied
case .undetermined: .undetermined
}
}
var body: some View {
Form {
Section {
HStack {
Label("settings.permissions.calendar", systemImage: "calendar")
Spacer()
if granted {
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.labelStyle(.titleAndIcon)
} else {
Button("settings.permissions.request") {
Task {
let ok = await calendarModel.requestAccessNow()
granted = ok
// macOS fragt nach einer Ablehnung nicht erneut.
// Dann hilft nur noch der Weg über die
// Systemeinstellungen also dorthin führen.
if !ok { openPrivacySettings() }
}
Section("settings.general") {
Toggle(isOn: Binding(
get: { launchAtLogin.isEnabled },
set: { _ = launchAtLogin.set($0) })) {
Text("settings.launchAtLogin")
}
.disabled(launchAtLogin.needsSystemSettings)
// Der Zustand, der sonst in eine Schleife führt: der Schalter
// steht auf aus, Drücken bewirkt nichts, weil das System den
// Eintrag blockiert. Also steht hier, wo es weitergeht.
if launchAtLogin.needsSystemSettings {
HStack {
Text("settings.launchAtLogin.blocked")
.font(.callout).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
Button("settings.permissions.openSystem") {
launchAtLogin.openSystemSettings()
}
}
}
Text("settings.permissions.calendar.why")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
HStack {
Label("settings.permissions.location", systemImage: "location")
Spacer()
if locationGranted {
Label("settings.permissions.granted", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.labelStyle(.titleAndIcon)
} else if weatherModel.locationIsDenied {
Button("settings.permissions.openSystem") { openLocationSettings() }
} else {
Button("settings.permissions.request") {
// Muss aus diesem Fenster kommen: macOS zeigt den
// Dialog nur, wenn eine App im Vordergrund ist.
weatherModel.requestLocationAuthorization()
}
}
Button("settings.onboarding.show", action: showOnboarding)
.buttonStyle(.borderless)
}
Text("settings.permissions.location.why")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section {
Button("settings.permissions.openSystem") { openPrivacySettings() }
PermissionRow(
title: "settings.permissions.calendar",
symbol: "calendar",
state: calendarGranted ? .granted
: (calendarModel.authorization == .denied ? .denied : .undetermined),
explanation: "settings.permissions.calendar.why",
pane: .calendar,
request: { calendarGranted = await calendarModel.requestAccessNow() })
}
Section {
PermissionRow(
title: "settings.permissions.location",
symbol: "location",
// Direkt aus dem beobachtbaren Wert: der Zustand ändert
// sich, während dieses Fenster offen steht.
state: locationState,
explanation: "settings.permissions.location.why",
pane: .location,
request: { weatherModel.requestLocationAuthorization() })
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
.task {
granted = calendarModel.hasAccess
guard !granted, !didAutoRequest,
calendarModel.authorization == .undetermined else { return }
didAutoRequest = true
// Kurz warten, bis das Fenster wirklich vorne steht. TCC zeigt den
// Dialog nur, wenn eine App im Vordergrund ist, an die er gehören
// kann und Aktivierung wirkt asynchron. Ohne diese Pause fragt
// Onyx, während es formal noch im Hintergrund liegt, und die
// Anfrage verpufft kommentarlos.
launchAtLogin.refresh()
calendarGranted = calendarModel.hasAccess
guard !calendarGranted, calendarModel.authorization == .undetermined else { return }
// Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den
// Dialog nur, wenn eine App im Vordergrund ist, und Aktivierung
// wirkt asynchron.
try? await Task.sleep(for: .milliseconds(400))
granted = await calendarModel.requestAccessNow()
calendarGranted = await calendarModel.requestAccessNow()
}
}
}
/// Eine Berechtigung mit ihrem Zustand und dem Weg, der von dort weiterführt.
///
/// Die drei Zustände brauchen drei verschiedene Antworten, und genau daran ist
/// die erste Fassung gescheitert: noch nie gefragt" lässt sich mit einer
/// Anfrage lösen, abgelehnt" nur über die Systemeinstellungen, und erteilt"
/// braucht gar nichts.
private struct PermissionRow: View {
enum State { case granted, denied, undetermined }
enum Pane {
case calendar, location
/// Jede Berechtigung führt in ihren eigenen Bereich. Ein gemeinsamer
/// Knopf, der immer im Kalender landet, ist schlimmer als keiner.
var url: URL? {
switch self {
case .calendar:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars")
case .location:
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices")
}
}
}
@State private var didAutoRequest = false
let title: LocalizedStringKey
let symbol: String
let state: State
let explanation: LocalizedStringKey
let pane: Pane
let request: () async -> Void
private func openLocationSettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return }
NSWorkspace.shared.open(url)
var body: some View {
HStack {
Label(title, systemImage: symbol)
Spacer()
switch state {
case .granted:
// Die Farbe sitzt am Symbol, nicht am ganzen Label: der
// Akzentstil färbt sonst darüber. Grün heißt hier erteilt"
// und ist keine Geschmacksfrage.
Label {
Text("settings.permissions.granted")
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
}
.foregroundStyle(Onyx.Color.positive)
.labelStyle(.titleAndIcon)
case .undetermined:
Button("settings.permissions.request") { Task { await request() } }
case .denied:
// macOS fragt nach einer Ablehnung nicht mehr ein
// Anfragen-Knopf wäre hier eine Lüge.
Button("settings.permissions.openSystem") {
if let url = pane.url { NSWorkspace.shared.open(url) }
}
}
}
Text(explanation)
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - Menüleiste
/// Jedes Modul einzeln an- und abschaltbar, mit eigener Darstellungsart.
///
/// Standardmäßig ist keines an: sechs neue Symbole beim ersten Start wären eine
/// Zumutung, und die Menüleiste ist ohnehin knapp auf einem Notch-Display
/// blockiert die Kamera zusätzlich die Mitte.
private struct MenuBarSettings: View {
@Bindable var model: AppModel
let metricsModel: MetricsModel?
private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [
("metric.cpu", "metric.cpu", "cpu"),
("metric.gpu", "metric.gpu", "cpu.fill"),
("metric.memory", "metric.memory", "memorychip"),
("metric.battery", "metric.battery", "battery.100"),
("metric.temperature", "metric.sensors", "thermometer"),
("network", "network.name", "network"),
("audio", "mixer.menubar.name", "slider.horizontal.3"),
("fans", "widget.fans.name", "fan"),
]
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text("settings.menubar.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
List {
ForEach(Self.modules, id: \.id) { module in
ModuleRow(model: model, id: module.id,
label: module.label, symbol: module.symbol,
metricsModel: metricsModel)
}
}
.listStyle(.inset)
}
.padding()
// Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul
// ändert sonst müsste jede Zeile ihren eigenen Zustand beobachten.
.id(model.menuBarRevision)
}
}
private struct ModuleRow: View {
@Bindable var model: AppModel
let id: String
let label: LocalizedStringKey
let symbol: String
let metricsModel: MetricsModel?
/// Welche Darstellungen dieses Modul beherrscht.
static func presentations(for id: String) -> [MenuBarPresentation] {
switch id {
// Ein Mischpult hat keinen einzelnen Messwert.
case "audio": []
// Drehzahl als Zahl oder nur das Symbol ein Verlauf der Drehzahl
// sagt in 28 Punkten Breite nichts.
case "fans": [.value, .symbol]
// Der Akku kann alles, und gerade dort will man Symbol oder Kürzel:
// eine nackte Prozentzahl neben vier anderen sagt nichts.
case "metric.battery": [.symbolAndValue, .labelAndValue, .value, .symbol,
.graph, .valueAndGraph]
default: MenuBarPresentation.allCases
}
}
private func openPrivacySettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") else { return }
NSWorkspace.shared.open(url)
/// Zu welcher Hardwaregröße dieses Modul gehört `nil` beim Netzwerk.
private var metricKind: MetricKind? {
guard id.hasPrefix("metric.") else { return nil }
return MetricKind(rawValue: String(id.dropFirst("metric.".count)))
}
var body: some View {
let settings = model.menuBarSettings(for: id)
HStack {
Toggle(isOn: Binding(
get: { settings.isEnabled },
set: { enabled in
var updated = settings
updated.isEnabled = enabled
model.setMenuBarSettings(updated, for: id)
})) {
Label(label, systemImage: symbol)
}
Spacer()
// Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das
// stille Maximum über alle Sensoren eine Zahl, von der niemand
// weiß, woher sie kommt.
if id == "metric.temperature", let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.selectedSensorKey ?? "" },
set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) {
Text("settings.menubar.sensor.hottest").tag("")
ForEach(metricsModel.snapshot.sensors) { sensor in
Text(sensor.name).tag(sensor.key)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
// Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte
// stammen aus derselben Messung ein zweites Element in der
// ohnehin knappen Menüleiste wäre Verschwendung.
if let metric = metricKind, metric.supportsDisplayModes, let metricsModel {
Picker("", selection: Binding(
get: { metricsModel.displayMode(for: metric) },
set: { metricsModel.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage").tag(MetricDisplayMode.usage)
Text("metric.mode.temperature").tag(MetricDisplayMode.temperature)
}
.labelsHidden()
.frame(width: 110)
.disabled(!settings.isEnabled)
}
// Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat
// keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen
// könnte eine Auswahl ohne Wirkung wäre eine Falle.
if !Self.presentations(for: id).isEmpty {
Picker("", selection: Binding(
get: { settings.presentation },
set: { presentation in
var updated = settings
updated.presentation = presentation
model.setMenuBarSettings(updated, for: id)
})) {
ForEach(Self.presentations(for: id)) { presentation in
Text(presentation.localizedName).tag(presentation)
}
}
.labelsHidden()
.frame(width: 150)
.disabled(!settings.isEnabled)
}
}
}
}
extension MenuBarPresentation {
var localizedName: LocalizedStringKey {
switch self {
case .value: "settings.menubar.value"
case .graph: "settings.menubar.graph"
case .bars: "settings.menubar.bars"
case .valueAndGraph: "settings.menubar.valueAndGraph"
case .symbol: "settings.menubar.symbol"
case .labelAndValue: "settings.menubar.labelAndValue"
case .symbolAndValue: "settings.menubar.symbolAndValue"
}
}
}
@@ -139,6 +430,7 @@ private struct PermissionSettings: View {
private struct WidgetSettings: View {
@Bindable var model: AppModel
let shelfStore: ShelfStore?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -177,6 +469,7 @@ private struct WidgetSettings: View {
}
}
}
}
.listStyle(.inset)
}
@@ -184,6 +477,39 @@ private struct WidgetSettings: View {
}
}
/// Einstellungen der Ablage.
///
/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus:
/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein
/// Papierkorb mit Zeitschaltuhr.
private struct ShelfSettingsRows: View {
@Bindable var store: ShelfStore
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
var body: some View {
Picker(selection: $store.autoCleanupDays) {
ForEach(Self.choices, id: \.self) { days in
if let days {
Text("settings.shelf.days \(days)").tag(Optional(days))
} else {
Text("settings.shelf.never").tag(Int?.none)
}
}
} label: {
Text("settings.shelf.cleanup")
}
HStack {
Text("settings.shelf.count \(store.items.count)")
.foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
}
}
}
private struct PlacementRow: View {
@Bindable var model: AppModel
let placement: WidgetPlacement
@@ -195,18 +521,6 @@ private struct PlacementRow: View {
Spacer()
// Nur die Größen anbieten, die das Widget auch sinnvoll ausfüllt.
// Ein Mini-Monat in 1×1 wäre unleserlich.
Picker("", selection: Binding(
get: { placement.size },
set: { model.setSize($0, for: placement.widgetID) })) {
ForEach(widget.supportedSizes) { size in
Text(size.localizedName).tag(size)
}
}
.labelsHidden()
.frame(width: 130)
Button {
model.remove(placement.widgetID)
} label: {
@@ -238,24 +552,30 @@ private struct DisplaySettings: View {
Divider()
// Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen
// niemandem etwas, ein nachgebildeter Bildschirm schon.
TriggerAreaSettings(trigger: $model.triggerArea)
Divider()
Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon)
Text("settings.display.menuBarIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Divider()
Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon)
Text("settings.display.dockIcon.hint")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
}
}
extension WidgetSize {
var localizedName: LocalizedStringKey {
switch self {
case .small: "settings.size.small"
case .medium: "settings.size.medium"
case .large: "settings.size.large"
case .wide: "settings.size.wide"
}
}
}

View File

@@ -1,7 +1,10 @@
import AppKit
import SwiftUI
import OnyxDesign
import CalendarProvider
import WeatherProvider
import MetricsProvider
import ShelfProvider
/// Führt das Einstellungsfenster selbst.
///
@@ -13,32 +16,82 @@ import WeatherProvider
///
/// Ein eigenes `NSWindow` hat diese Abhängigkeit nicht. Es kostet ein paar
/// Zeilen mehr und funktioniert dafür.
///
/// `@Observable`, und das ist keine Kür: der gewählte Reiter liegt hier, weil
/// ein erneutes Öffnen ihn setzen können muss. Ohne Beobachtung bemerkt die
/// View die Änderung nicht weder die von außen noch die eigene. Genau daran
/// hing das Fenster, in dem der Reiter oben umsprang und der Inhalt stehen
/// blieb.
@MainActor
@Observable
final class SettingsWindowController: NSObject, NSWindowDelegate {
private var window: NSWindow?
@ObservationIgnored private var window: NSWindow?
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
private var selectedTab: SettingsTab = .widgets
/// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen
/// zurückgeschaltet wird.
var keepsDockIcon = false
func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl,
launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void,
tab: SettingsTab = .widgets) {
selectedTab = tab
keepsDockIcon = model.showsDockIcon
// Solange ein Fenster offen ist, gehört Onyx in den Dock.
//
// Als reine `.accessory`-App hat sie dort keinen Eintrag das ist im
// Hintergrund richtig, aber mit sichtbarem Fenster verwirrend: das
// Fenster lässt sich dann weder über den Dock noch über -Tab
// zurückholen, wenn es hinter etwas anderes rutscht.
NSApp.setActivationPolicy(.regular)
if window == nil {
let hosting = NSHostingController(
rootView: SettingsView(model: model,
calendarModel: calendarModel,
weatherModel: weatherModel,
metricsModel: metricsModel,
shelfStore: shelfStore,
fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: showOnboarding,
selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 })))
let window = NSWindow(contentViewController: hosting)
window.title = "Onyx"
window.styleMask = [.titled, .closable, .miniaturizable]
// Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie
// verschwindet.
//
// `titlebarAppearsTransparent` allein reichte nicht: macOS zieht
// unter dem Titelbalken zusätzlich einen Trenner quer durchs
// Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen
// Titel auf eine Trennlinie.
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
// Veränderbar, und das ist keine Kür: der Bereich Displays" mit
// der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht
// mehr, und ein Fenster, in dem man scrollen muss, obwohl der
// Bildschirm frei ist, ist eine Zumutung.
window.styleMask = [.titled, .closable, .miniaturizable, .resizable]
window.setContentSize(NSSize(width: 920, height: 680))
// Breit genug für die Reiterleiste.
//
// Sieben Reiter mit deutschen Beschriftungen brauchen rund 880
// Punkte mehr, als man beim Nachrechnen der reinen Textbreiten
// herausbekommt: macOS legt je Reiter Innenabstand und Trenner
// dazu. Ist das Fenster schmaler, ragt die Leiste über beide
// Kanten hinaus, und der erste wie der letzte Reiter sind nicht
// mehr erreichbar.
window.contentMinSize = NSSize(width: 900, height: 460)
window.isReleasedWhenClosed = false
window.delegate = self
window.center()
Self.place(window)
// Über die Position freuen sich Nutzer mit mehreren Displays:
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
window.setFrameAutosaveName("onyx.settings")
@@ -51,9 +104,46 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil)
}
func windowWillClose(_ notification: Notification) {
// Zurück in den Hintergrund: Onyx hat sonst weiter den Fokus, obwohl
// kein Fenster mehr offen ist.
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
/// klebt.
///
/// `center()` setzt etwas oberhalb der Mitte bei einem 452 Punkt hohen
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
/// plötzlich das Panel vor sich.
private static func place(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
// Wirklich mittig, nicht etwas unterhalb der Notch".
//
// `center()` setzt oberhalb der Bildschirmmitte, und mein erster
// Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag
// weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die
// Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete.
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: visible.midY - size.height / 2))
}
/// Der rote Knopf blendet aus, statt zu schließen.
///
/// Onyx ist eine App ohne Hauptfenster; Fenster zu" heißt hier nie
/// fertig". Manche Hilfsprogramme erzwingen aber genau diese Gleichung und
/// beenden eine App, sobald ihr letztes Fenster verschwindet dann wäre
/// Onyx nach einem Blick in die Einstellungen weg, samt Menüleiste und
/// Notch. Ein Fenster, das nie wirklich schließt, gibt dafür keinen Anlass.
func windowShouldClose(_ sender: NSWindow) -> Bool {
hide()
return false
}
private func hide() {
window?.orderOut(nil)
// Zurück in den Hintergrund es sei denn, der Dock-Eintrag ist
// ausdrücklich gewünscht.
if !keepsDockIcon {
NSApp.setActivationPolicy(.accessory)
}
NSApp.hide(nil)
}
}

View File

@@ -0,0 +1,381 @@
import SwiftUI
import OnyxDesign
import CalendarProvider
import WeatherProvider
import ShelfProvider
/// Die Stellschrauben der einzelnen Widgets, an einem Ort.
///
/// Vorher hieß der Bereich Quellen" und enthielt nur die Kalenderquelle. Das
/// war die Sicht des Programmierers: für den Nutzer ist die Quelle eine
/// Einstellung **des Kalenderwidgets** unter mehreren welche Kalender
/// mitzählen, gehört genauso dazu, und beim Wetter der Ort. Ein Abschnitt je
/// Widget ist die Ordnung, die man sucht.
struct WidgetOptionsView: View {
let calendarModel: CalendarModel
let weatherModel: WeatherModel
let shelfStore: ShelfStore?
var body: some View {
Form {
Section("options.calendar") {
CalendarOptions(model: calendarModel)
}
Section("options.weather") {
WeatherOptions(model: weatherModel)
}
if let shelfStore {
Section("options.shelf") {
ShelfOptions(store: shelfStore)
}
}
}
.formStyle(.grouped)
// Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem
// des Fensters ab. In Dunkelgrau sieht man genau das als Kante.
.scrollContentBackground(.hidden)
.padding()
}
}
// MARK: - Kalender
private struct CalendarOptions: View {
@Bindable var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
var body: some View {
Picker(selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
} label: {
Text("options.calendar.source")
}
.pickerStyle(.menu)
Picker(selection: $model.showsMonthGrid) {
Text("options.calendar.agenda").tag(false)
Text("options.calendar.month").tag(true)
} label: {
Text("options.calendar.display")
}
.pickerStyle(.menu)
// Welche Kalender mitzählen. Gespeichert werden die **abgewählten**:
// kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn
// suchen muss, ohne zu wissen, dass es ihn gibt.
if model.availableCalendars.isEmpty {
Text("options.calendar.noneFound")
.font(.callout).foregroundStyle(.secondary)
} else {
ForEach(model.availableCalendars) { calendar in
Toggle(isOn: Binding(
get: { !model.hiddenCalendarIDs.contains(calendar.id) },
set: { shown in
if shown { model.hiddenCalendarIDs.remove(calendar.id) }
else { model.hiddenCalendarIDs.insert(calendar.id) }
})) {
Label {
Text(calendar.title)
} icon: {
Circle()
.fill(Color(hex: calendar.colorHex) ?? .secondary)
.frame(width: 9, height: 9)
}
}
}
}
if model.kind != .appleCalendar {
CalendarrServerSettings(model: model)
CalendarrStatus(state: calendarrState,
isLoading: model.isLoading,
onRefresh: { model.refresh() })
.task(id: model.kind) {
guard model.kind != .appleCalendar else { return }
let now = Date()
calendarrState = await CalendarrSource().load(
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
end: now.addingTimeInterval(60 * 86_400)))
}
}
}
}
/// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum.
///
/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts es liest den
/// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine
/// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat,
/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen.
private struct CalendarrStatus: View {
let state: CalendarSourceState?
let isLoading: Bool
let onRefresh: () -> Void
var body: some View {
HStack(spacing: 8) {
message
Spacer(minLength: 8)
if case .ready(let window) = state {
Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "")
.foregroundStyle(.secondary)
}
reloadButton
}
}
@ViewBuilder
private var message: some View {
switch state {
case .ready(let window):
Text("options.calendar.calendarrEvents \(window.events.count)")
case .neverWritten:
Text("calendar.source.status.neverWritten").font(.callout).foregroundStyle(.secondary)
case .loggedOut:
Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary)
case .incompatible:
Text("calendar.source.status.incompatible").font(.callout)
.foregroundStyle(Onyx.Color.warning)
case .unreadable(let reason):
Text(reason).font(.callout).foregroundStyle(Onyx.Color.warning)
case .denied:
Text("calendar.source.status.denied").font(.callout)
.foregroundStyle(Onyx.Color.warning)
case .none:
Text("calendar.source.status.loading").font(.callout).foregroundStyle(.secondary)
}
}
/// Feste Breite, damit die Zeile beim Laden nicht zuckt: der Kreisel ist
/// nicht so breit wie der Pfeil.
private var reloadButton: some View {
Button(action: onRefresh) {
Group {
if isLoading {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.clockwise")
}
}
.frame(width: 16, height: 16)
}
.buttonStyle(.borderless)
.disabled(isLoading)
.help(Text("options.calendar.reload"))
}
}
// MARK: - Wetter
private struct WeatherOptions: View {
let model: WeatherModel
@State private var query = ""
@State private var searching = false
@State private var notFound = false
private var usesCurrentLocation: Bool { model.place.isCurrent }
var body: some View {
Toggle(isOn: Binding(
get: { usesCurrentLocation },
set: { model.place = $0 ? .current : model.place })) {
Text("options.weather.automatic")
}
.disabled(usesCurrentLocation)
if usesCurrentLocation {
// Ohne Ortungsberechtigung nützt die Automatik nichts und das
// steht hier, statt dass das Widget stumm leer bleibt.
if model.locationIsDenied {
Text("options.weather.needsLocation")
.font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
}
} else if case .fixed(let name, _, _) = model.place {
LabeledContent {
Text(name).foregroundStyle(.secondary)
} label: {
Text("options.weather.place")
}
}
HStack {
TextField("options.weather.search", text: $query)
.textFieldStyle(.roundedBorder)
.onSubmit { Task { await search() } }
if searching {
ProgressView().controlSize(.small)
} else {
Button("options.weather.set") { Task { await search() } }
.disabled(query.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
if notFound {
Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning)
}
}
private func search() async {
searching = true
notFound = false
defer { searching = false }
if let found = await model.selectPlace(named: query) {
query = ""
_ = found
} else {
notFound = true
}
}
}
// MARK: - Ablage
private struct ShelfOptions: View {
@Bindable var store: ShelfStore
private static let choices: [Int?] = [nil, 1, 7, 30, 90]
var body: some View {
Picker(selection: $store.autoCleanupDays) {
ForEach(Self.choices, id: \.self) { days in
if let days {
Text("settings.shelf.days \(days)").tag(Optional(days))
} else {
Text("settings.shelf.never").tag(Int?.none)
}
}
} label: {
Text("settings.shelf.cleanup")
}
HStack {
Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary)
Spacer()
Button("settings.shelf.reveal") { store.revealInFinder() }
.buttonStyle(.borderless)
}
}
}
private extension Color {
/// Die Farbe eines Kalenders, wie die Quelle sie liefert.
init?(hex: String?) {
guard let hex, hex.hasPrefix("#"), hex.count == 7,
let value = Int(hex.dropFirst(), radix: 16) else { return nil }
self.init(red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255)
}
}
/// Die Anmeldung am Calendarr-Server.
///
/// Der Schnappschuss im gemeinsamen Ordner ist nur so aktuell wie der letzte
/// Start der Calendarr-App. Wer die Zugangsdaten hier einträgt, holt Onyx die
/// Termine selbst und dann ist es egal, ob Calendarr überhaupt läuft.
///
/// Adresse und Benutzername stehen in den Einstellungen, das Passwort im
/// Schlüsselbund.
private struct CalendarrServerSettings: View {
let model: CalendarModel
@State private var credentials = CalendarrCredentials()
@State private var server = ""
@State private var username = ""
@State private var password = ""
@State private var totpCode = ""
@State private var isChecking = false
@State private var failure: String?
private var account: CalendarrAccount? { credentials.account }
var body: some View {
if let account {
LabeledContent {
HStack(spacing: 10) {
Text(account.username).foregroundStyle(.secondary)
Button {
credentials.clear()
model.calendarrAccountChanged()
} label: {
Text("options.calendar.server.disconnect")
}
}
} label: {
Label {
Text(account.server.host() ?? account.server.absoluteString)
} icon: {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Onyx.Color.positive)
}
}
} else {
// Drei Felder und ein Knopf. Geprüft wird beim Verbinden, nicht
// erst beim nächsten Laden: ein Tippfehler, den man am Folgetag
// bemerkt, ist ein vermeidbarer.
TextField(text: $server) { Text("options.calendar.server.address") }
.textContentType(.URL)
TextField(text: $username) { Text("options.calendar.server.username") }
.textContentType(.username)
SecureField(text: $password) { Text("options.calendar.server.password") }
.textContentType(.password)
// Nur ausfüllen, wer einen zweiten Faktor hat. Ein Pflichtfeld
// wäre es für alle anderen eine Frage, die sie nicht beantworten
// können.
TextField(text: $totpCode) { Text("options.calendar.server.totp") }
.textContentType(.oneTimeCode)
HStack {
if let failure {
Text(failure).font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("options.calendar.server.hint")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 12)
Button(action: connect) {
if isChecking {
ProgressView().controlSize(.small)
} else {
Text("options.calendar.server.connect")
}
}
.disabled(isChecking || server.isEmpty || username.isEmpty || password.isEmpty)
}
}
}
private func connect() {
guard let url = CalendarrLiveDecoder.server(from: server) else {
failure = CalendarrLiveError.badServer.errorDescription
return
}
isChecking = true
failure = nil
Task {
defer { isChecking = false }
switch await CalendarrLiveSource.check(server: url, username: username,
password: password,
totpCode: totpCode) {
case .success(let token):
credentials.save(server: url, username: username,
password: password, token: token)
// Passwort und Code nicht im Formular stehen lassen beide
// liegen jetzt dort, wo sie hingehören.
password = ""
totpCode = ""
model.calendarrAccountChanged()
case .failure(let error):
failure = error.localizedDescription
}
}
}
}

40
Onyx/bolt-dark.svg Normal file
View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<polygon points="426.667,213.333 284.444,213.333 284.444,0 85.333,298.667 227.556,298.667 227.556,512 "/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 666 B

1
Onyx/bolt-light.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="512" height="512" x="0" y="0" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve" class=""><g><path d="M426.667 213.333H284.444V0L85.333 298.667h142.223V512z" fill="#ffffff" opacity="1" data-original="#000000" class=""></path></g></svg>

After

Width:  |  Height:  |  Size: 373 B

1
Onyx/chip.svg Normal file
View File

@@ -0,0 +1 @@
<svg height="512pt" viewBox="0 0 512 512" width="512pt" xmlns="http://www.w3.org/2000/svg"><path d="m150 150h212v212h-212zm0 0"/><path d="m497 211c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-26c0-19.300781-15.699219-35-35-35h-26v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-30v-45c0-8.285156-6.714844-15-15-15s-15 6.714844-15 15v45h-26c-19.300781 0-35 15.699219-35 35v26h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v30h-45c-8.285156 0-15 6.714844-15 15s6.714844 15 15 15h45v26c0 19.300781 15.699219 35 35 35h26v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h30v45c0 8.285156 6.714844 15 15 15s15-6.714844 15-15v-45h26c19.300781 0 35-15.699219 35-35v-26h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30h45c8.285156 0 15-6.714844 15-15s-6.714844-15-15-15h-45v-30zm-105 166c0 8.285156-6.714844 15-15 15h-242c-8.285156 0-15-6.714844-15-15v-242c0-8.285156 6.714844-15 15-15h242c8.285156 0 15 6.714844 15 15zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

1
Onyx/graphic-card.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m482.807 132.033h-416.747v-31.976c0-4.418-3.582-8-8-8h-50.06c-4.418 0-8 3.582-8 8s3.582 8 8 8h42.06v56.593h-18.713c-4.418 0-8 3.582-8 8v63.691c0 4.418 3.582 8 8 8h18.713v19.21h-18.713c-4.418 0-8 3.582-8 8v63.691c0 4.418 3.582 8 8 8h18.713v68.699c0 4.418 3.582 8 8 8s8-3.582 8-8v-36.081h30.802v32.508c0 4.418 3.582 8 8 8h111.592c4.418 0 8-3.582 8-8v-32.508h26.387v32.508c0 4.418 3.582 8 8 8h198.358c4.418 0 8-3.582 8-8v-32.508h17.608c16.097 0 29.193-13.096 29.193-29.193v-185.44c0-16.098-13.096-29.194-29.193-29.194zm-443.46 96.309v-47.692h10.713v47.691h-10.713zm0 98.901v-47.691h10.713v47.691zm169.107 73.125h-21.198v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-21.197v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-21.197v-24.508h95.592zm240.745 0h-17.062v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.06v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.059v-10.103c0-4.418-3.582-8-8-8s-8 3.582-8 8v10.103h-17.06v-24.508h182.358v24.508zm46.801-53.701c0 7.274-5.918 13.193-13.193 13.193h-416.747v-24.607c0-.003 0-.007 0-.01s0-.006 0-.01v-63.672c0-.003 0-.007 0-.01s0-.006 0-.01v-35.19c0-.003 0-.007 0-.01s0-.006 0-.01v-63.671c0-.003 0-.007 0-.01s0-.006 0-.01v-24.607h416.747c7.275 0 13.193 5.919 13.193 13.193zm-110.99-185.502c-51.16 0-92.782 41.621-92.782 92.781s41.622 92.781 92.782 92.781 92.781-41.621 92.781-92.781-41.621-92.781-92.781-92.781zm-41.818 157.126c17.407-5.223 32.416-17.168 41.284-33.275.178.003.355.014.534.014 2.446 0 4.823-.293 7.107-.83 3.553 16.244-.643 33.591-11.516 46.392-13.744-.782-26.524-5.203-37.409-12.301zm-33.3-48.457c10.281 5.547 21.821 8.447 33.451 8.447 6.489 0 13.003-.909 19.337-2.746 1.781 1.841 3.783 3.464 5.969 4.823-8.966 13.989-24.18 23.285-40.903 24.663-8.757-9.807-15.039-21.862-17.854-35.187zm116.937-80.231c-17.408 5.223-32.417 17.168-41.285 33.275-.178-.003-.355-.014-.534-.014-2.446 0-4.823.293-7.107.829-3.553-16.245.644-33.592 11.517-46.393 13.745.783 26.524 5.204 37.409 12.303zm-41.819 79.426c-8.317 0-15.083-6.767-15.083-15.083s6.766-15.082 15.083-15.082c8.316 0 15.083 6.766 15.083 15.082s-6.766 15.083-15.083 15.083zm-21.588-37.414c-1.841 1.781-3.465 3.784-4.824 5.969-13.989-8.966-23.285-24.18-24.663-40.904 9.803-8.752 21.853-15.032 35.171-17.849-8.638 16.017-10.813 35.102-5.684 52.784zm-9.481 21.799c-.003.178-.014.354-.014.533 0 2.446.293 4.823.83 7.108-16.245 3.552-33.592-.645-46.393-11.517.782-13.745 5.203-26.524 12.302-37.41 5.223 17.407 17.168 32.417 33.275 41.286zm52.657 22.864c1.841-1.781 3.465-3.783 4.824-5.969 13.989 8.966 23.285 24.18 24.662 40.904-9.803 8.752-21.853 15.032-35.17 17.849 8.638-16.017 10.813-35.103 5.684-52.784zm9.481-21.798c.003-.178.014-.355.014-.534 0-2.445-.293-4.822-.829-7.106 16.245-3.552 33.592.645 46.392 11.517-.782 13.744-5.203 26.524-12.302 37.409-5.223-17.408-17.168-32.417-33.275-41.286zm-8.737-22.12c-1.781-1.842-3.784-3.465-5.969-4.824 8.966-13.989 24.18-23.286 40.904-24.663 8.752 9.803 15.032 21.853 17.849 35.171-16.017-8.64-35.103-10.814-52.784-5.684zm-230.291-71.195c-51.16 0-92.781 41.621-92.781 92.781s41.622 92.781 92.781 92.781 92.781-41.621 92.781-92.781-41.622-92.781-92.781-92.781zm-41.818 157.125c17.407-5.223 32.416-17.168 41.284-33.275.178.003.355.014.534.014 2.446 0 4.823-.293 7.107-.829 3.553 16.244-.643 33.591-11.516 46.392-13.745-.782-26.524-5.203-37.409-12.302zm-33.3-48.455c10.281 5.547 21.821 8.446 33.45 8.446 6.489 0 13.003-.909 19.337-2.746 1.78 1.841 3.783 3.464 5.968 4.823-8.966 13.989-24.18 23.285-40.903 24.663-8.755-9.807-15.037-21.862-17.852-35.186zm116.937-80.232c-17.408 5.223-32.417 17.168-41.286 33.275-.178-.003-.355-.014-.534-.014-2.445 0-4.823.293-7.106.829-3.553-16.245.644-33.592 11.517-46.393 13.744.783 26.523 5.204 37.409 12.303zm-41.82 79.426c-8.316 0-15.083-6.767-15.083-15.083s6.766-15.082 15.083-15.082 15.083 6.766 15.083 15.082-6.766 15.083-15.083 15.083zm-21.587-37.414c-1.842 1.781-3.465 3.784-4.824 5.97-13.988-8.966-23.285-24.18-24.662-40.904 9.803-8.752 21.853-15.032 35.171-17.849-8.639 16.016-10.814 35.101-5.685 52.783zm-9.482 21.799c-.003.178-.013.354-.013.533 0 2.446.293 4.823.83 7.108-16.245 3.552-33.591-.645-46.392-11.516.782-13.745 5.203-26.525 12.302-37.41 5.222 17.407 17.166 32.416 33.273 41.285zm52.657 22.864c1.841-1.781 3.465-3.784 4.824-5.969 13.989 8.966 23.285 24.18 24.663 40.904-9.803 8.752-21.853 15.032-35.171 17.849 8.639-16.017 10.813-35.103 5.684-52.784zm9.481-21.798c.003-.178.014-.354.014-.533 0-2.445-.293-4.822-.829-7.106 16.245-3.552 33.592.645 46.393 11.517-.783 13.744-5.203 26.524-12.302 37.409-5.224-17.409-17.169-32.419-33.276-41.287zm-8.736-22.12c-1.781-1.842-3.784-3.465-5.97-4.824 8.966-13.989 24.181-23.286 40.905-24.663 8.752 9.803 15.032 21.853 17.848 35.172-16.016-8.64-35.101-10.814-52.783-5.685z"/></svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

1
Onyx/sound-waves.svg Normal file
View File

@@ -0,0 +1 @@
<svg id="Bahan_copy_2" height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Bahan copy 2"><g fill="rgb(0,0,0)"><path d="m7.72 24.83a3.72574 3.72574 0 0 0 -3.72 3.72v6.9a3.715 3.715 0 1 0 7.43 0v-6.9a3.7172 3.7172 0 0 0 -3.71-3.72z"/><path d="m19.86 19a3.72575 3.72575 0 0 0 -3.72 3.72v18.56a3.715 3.715 0 1 0 7.43 0v-18.56a3.71725 3.71725 0 0 0 -3.71-3.72z"/><path d="m32 5.04a3.72579 3.72579 0 0 0 -3.72 3.72v46.48a3.72 3.72 0 1 0 7.43994 0v-46.48a3.72579 3.72579 0 0 0 -3.71994-3.72z"/><path d="m44.14 11.92a3.7173 3.7173 0 0 0 -3.71 3.72v32.72a3.715 3.715 0 1 0 7.43 0v-32.72a3.7258 3.7258 0 0 0 -3.72-3.72z"/><path d="m56.28 17.99a3.71721 3.71721 0 0 0 -3.71 3.72v20.58a3.715 3.715 0 1 0 7.43 0v-20.58a3.72575 3.72575 0 0 0 -3.72-3.72z"/></g></svg>

After

Width:  |  Height:  |  Size: 790 B

18
OnyxHelper/Info.plist Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Wird als __TEXT,__info_plist in die Binary eingebettet.
Ohne sie leitet codesign den Bezeichner aus dem Dateinamen ab
("OnyxHelper"), und die Signaturprüfung zwischen App und Helfer
schlägt fehl — die App würde ihren eigenen Helfer ablehnen. -->
<key>CFBundleIdentifier</key>
<string>com.scarriffleservices.onyx.helper</string>
<key>CFBundleName</key>
<string>OnyxHelper</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
</dict>
</plist>

176
OnyxHelper/SMCAccess.swift Normal file
View File

@@ -0,0 +1,176 @@
import Foundation
import IOKit
/// SMC-Zugriff **mit** Schreibpfad nur im privilegierten Helfer.
///
/// Bewusst eine eigene Kopie und keine geteilte Bibliothek mit der App: der
/// Schreibpfad soll gar nicht erst in einem Prozess vorhanden sein, der ohne
/// Sonderrechte läuft und in dem jedes Widget Code ausführt. Was nicht da ist,
/// kann auch nicht versehentlich aufgerufen werden.
///
/// Byte-Reihenfolge und Struktur stammen aus `docs/spikes/A-smc.md`.
final class SMCAccess {
private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0
var build: UInt8 = 0; var reserved: UInt8 = 0
var release: UInt16 = 0 }
private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0
var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0
var memPLimit: UInt32 = 0 }
private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0
var dataAttributes: UInt8 = 0 }
private struct Param {
var key: UInt32 = 0
var vers = Version()
var pLimitData = PLimitData()
var keyInfo = KeyInfo()
var padding: UInt16 = 0
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}
private static let handleYPCEvent: UInt32 = 2
private static let readKey: UInt8 = 5
private static let writeKey: UInt8 = 6
private static let getKeyFromIndex: UInt8 = 8
private static let getKeyInfo: UInt8 = 9
private var connection: io_connect_t = 0
private var keyInfoCache: [String: KeyInfo] = [:]
init?() {
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC"))
guard service != 0 else { return nil }
defer { IOObjectRelease(service) }
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess
else { return nil }
}
deinit { if connection != 0 { IOServiceClose(connection) } }
// MARK: - Lesen
func float(_ key: String) -> Double? {
guard let value = read(key), value.bytes.count >= 4 else { return nil }
let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16
| UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0])
return Double(Float(bitPattern: bits))
}
func uint8(_ key: String) -> UInt8? { read(key)?.bytes.first }
func keys(withPrefix prefix: String) -> [String] {
guard let value = read("#KEY"), value.bytes.count >= 4 else { return [] }
// Big-endian, anders als die Messwerte siehe Spike A.
let total = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
guard total > 0, total < 100_000 else { return [] }
var found: [String] = []
for index in 0..<total {
var command = Param()
command.data8 = Self.getKeyFromIndex
command.data32 = index
guard let output = call(command) else { continue }
let name = Self.fourCCString(output.key)
if name.hasPrefix(prefix) { found.append(name) }
}
return found
}
// MARK: - Schreiben
/// Schreibt einen `flt`-Wert (Zieldrehzahl).
@discardableResult
func writeFloat(_ key: String, _ value: Double) -> Bool {
let bits = Float(value).bitPattern
return write(key, bytes: [UInt8(bits & 0xFF), UInt8((bits >> 8) & 0xFF),
UInt8((bits >> 16) & 0xFF), UInt8((bits >> 24) & 0xFF)])
}
/// Schreibt ein einzelnes Byte (Betriebsart).
@discardableResult
func writeUInt8(_ key: String, _ value: UInt8) -> Bool {
write(key, bytes: [value])
}
private func write(_ key: String, bytes: [UInt8]) -> Bool {
guard let info = keyInfo(for: key) else { return false }
// Nur schreiben, wenn die Größe zur Firmware passt. Ein zu kurzer oder
// zu langer Schreibvorgang landet sonst in benachbarten Feldern.
guard Int(info.dataSize) == bytes.count else { return false }
var command = Param()
command.key = Self.fourCC(key)
command.keyInfo = info
command.data8 = Self.writeKey
withUnsafeMutableBytes(of: &command.bytes) { raw in
for (index, byte) in bytes.enumerated() { raw[index] = byte }
}
return call(command) != nil
}
// MARK: - Innereien
private struct Value { let bytes: [UInt8] }
private func keyInfo(for key: String) -> KeyInfo? {
if let cached = keyInfoCache[key] { return cached }
var command = Param()
command.key = Self.fourCC(key)
command.data8 = Self.getKeyInfo
guard let output = call(command) else { return nil }
keyInfoCache[key] = output.keyInfo
return output.keyInfo
}
/// Liest die Rohbytes eines Keys für `CHLT`, dessen Typ `hex_` ist und
/// das sich damit keiner der Zahlenfassungen zuordnen lässt.
func readBytes(_ key: String) -> [UInt8]? {
read(key)?.bytes
}
private func read(_ key: String) -> Value? {
guard let info = keyInfo(for: key) else { return nil }
var command = Param()
command.key = Self.fourCC(key)
command.keyInfo = info
command.data8 = Self.readKey
guard let output = call(command) else { return nil }
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(bytes: Array(all.prefix(Int(min(info.dataSize, 32)))))
}
private func call(_ input: Param) -> Param? {
var input = input
var output = Param()
var size = MemoryLayout<Param>.stride
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
&input, MemoryLayout<Param>.stride,
&output, &size)
guard result == kIOReturnSuccess, output.result == 0 else { return nil }
return output
}
private static func fourCC(_ string: String) -> UInt32 {
var value: UInt32 = 0
for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) }
return value
}
private static func fourCCString(_ value: UInt32) -> String {
let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.scarriffleservices.onyx.helper</string>
<!-- Relativ zum App-Bundle. SMAppService löst den Pfad selbst auf; ein
absoluter Pfad würde brechen, sobald die App verschoben wird. -->
<key>BundleProgram</key>
<string>Contents/MacOS/OnyxHelper</string>
<key>MachServices</key>
<dict>
<key>com.scarriffleservices.onyx.helper</key>
<true/>
</dict>
<!-- Auf Abruf starten, nicht dauerhaft laufen: der Helfer wird erst
gebraucht, wenn jemand die Lüfter steuern will. -->
<key>KeepAlive</key>
<false/>
<key>RunAtLoad</key>
<false/>
<key>AssociatedBundleIdentifiers</key>
<array>
<string>com.scarriffleservices.onyx</string>
</array>
</dict>
</plist>

212
OnyxHelper/main.swift Normal file
View File

@@ -0,0 +1,212 @@
import Foundation
import OSLog
import OnyxHelperProtocol
private let log = Logger(subsystem: "com.scarriffleservices.onyx.helper", category: "Helper")
/// Der privilegierte Helfer.
///
/// Läuft als root und tut genau eine Sache: Lüfterdrehzahlen setzen. Kein
/// Netzwerk, keine Oberfläche, kein Dateizugriff. Je weniger hier steht, desto
/// kleiner die Angriffsfläche eines Dienstes mit Systemrechten.
///
/// **Er entscheidet selbst, was zulässig ist.** Die App darf einen Wunsch
/// äußern; ob er ausgeführt wird, prüft ausschließlich `FanSafety` hier mit
/// Temperaturen, die der Helfer selbst misst.
final class FanService: NSObject, OnyxHelperProtocol {
private let smc: SMCAccess?
/// Wunschdrehzahl je Lüfter. Leer heißt Automatik.
private var requests: [Int: FanRequest] = [:]
private var lastHeartbeat = Date.distantPast
private var lastDecision: [Int: FanDecision] = [:]
private let queue = DispatchQueue(label: "com.scarriffleservices.onyx.helper.fans")
private lazy var temperatureKeys: [String] = smc?.keys(withPrefix: "Tp") ?? []
private lazy var fanCount: Int = {
guard let value = smc?.float("FNum") else { return Int(smc?.uint8("FNum") ?? 0) }
return Int(value)
}()
override init() {
smc = SMCAccess()
super.init()
startControlLoop()
}
// MARK: - Regelschleife
/// Läuft ununterbrochen, solange der Helfer lebt.
///
/// Die Entscheidung wird **jede Sekunde neu** getroffen, nicht nur beim
/// Setzen. Nur so greifen Wachhund und Temperaturwächter auch dann, wenn
/// von der App nie wieder etwas kommt.
private func startControlLoop() {
queue.async { [weak self] in
let timer = Timer(timeInterval: 1, repeats: true) { _ in self?.apply() }
RunLoop.current.add(timer, forMode: .common)
RunLoop.current.run()
}
}
private func apply() {
guard let smc, fanCount > 0 else { return }
let hottest = hottestTemperature()
for index in 0..<fanCount {
let limits = FanLimits(minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0)
let decision = FanSafety.decide(request: requests[index],
lastHeartbeat: lastHeartbeat,
now: Date(),
hottestCelsius: hottest,
limits: limits)
// Nur schreiben, wenn sich etwas ändert: jeder SMC-Schreibvorgang
// ist ein Eingriff, und eine Sekunde später denselben Wert erneut
// zu setzen bringt nichts.
guard decision != lastDecision[index] else { continue }
lastDecision[index] = decision
switch decision {
case .manual(let rpm):
smc.writeUInt8("F\(index)md", 1)
smc.writeFloat("F\(index)Tg", rpm)
log.notice("Lüfter \(index): manuell \(Int(rpm)) U/min")
case .automatic(let reason):
smc.writeUInt8("F\(index)md", 0)
if reason != .noRequest {
// Ein erzwungener Rückfall ist ein Ereignis, das man später
// nachvollziehen können muss.
log.error("Lüfter \(index): Rückfall auf Automatik — \(reason.rawValue, privacy: .public)")
requests[index] = nil
} else {
log.notice("Lüfter \(index): Automatik")
}
}
}
}
private func hottestTemperature() -> Double? {
guard let smc, !temperatureKeys.isEmpty else { return nil }
return temperatureKeys.compactMap { key -> Double? in
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
return value
}.max()
}
/// Beim Beenden alles zurückstellen.
///
/// Ohne das blieben die Lüfter nach einem Abmelden oder Neustart des
/// Dienstes auf ihrem letzten Wert stehen bis zum nächsten Neustart des
/// Rechners.
func restoreAutomatic() {
guard let smc else { return }
for index in 0..<fanCount { smc.writeUInt8("F\(index)md", 0) }
log.notice("Alle Lüfter auf Automatik zurückgestellt")
}
// MARK: - Schnittstelle
func version(reply: @escaping (String) -> Void) { reply(OnyxHelper.version) }
func heartbeat(reply: @escaping (Bool) -> Void) {
lastHeartbeat = Date()
reply(true)
}
func fanStatus(reply: @escaping (Data?) -> Void) { reply(report()) }
func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void) {
lastHeartbeat = Date()
requests[index] = FanRequest(targetRPM: targetRPM)
apply()
reply(report())
}
// MARK: - Ladelimit
/// Der Key trägt drei Bytes; nur das erste wird verändert. Siehe
/// `ChargeSafety` für die Messung, auf der das beruht.
func chargeLimit(reply: @escaping (Int) -> Void) {
lastHeartbeat = Date()
guard let smc, let bytes = smc.readBytes(ChargeSafety.key),
let percent = ChargeSafety.decode(bytes) else {
reply(-1)
return
}
reply(percent)
}
func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void) {
lastHeartbeat = Date()
requests[index] = nil
apply()
reply(report())
}
private func report() -> Data? {
guard let smc else { return nil }
let fans = (0..<fanCount).map { index -> FanStatus in
let decision = lastDecision[index]
var reason: String?
var manual = false
if case .manual = decision { manual = true }
if case .automatic(let value) = decision { reason = value.rawValue }
return FanStatus(index: index,
currentRPM: smc.float("F\(index)Ac") ?? 0,
targetRPM: smc.float("F\(index)Tg") ?? 0,
limits: FanLimits(minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0),
isManual: manual,
automaticReason: reason)
}
return try? JSONEncoder().encode(
FanStatusReport(fans: fans, hottestCelsius: hottestTemperature(),
helperVersion: OnyxHelper.version))
}
}
// MARK: - XPC
final class Listener: NSObject, NSXPCListenerDelegate {
let service = FanService()
func listener(_ listener: NSXPCListener,
shouldAcceptNewConnection connection: NSXPCConnection) -> Bool {
// Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die
// Lüfter steuern: der Mach-Dienst eines root-Daemons ist systemweit
// sichtbar, und wer ihn anspricht, spricht mit root.
do {
try connection.setCodeSigningRequirement(OnyxHelper.clientRequirement)
} catch {
log.error("Verbindung abgelehnt: Signaturanforderung nicht setzbar")
return false
}
connection.exportedInterface = NSXPCInterface(with: OnyxHelperProtocol.self)
connection.exportedObject = service
connection.resume()
return true
}
}
let listener = NSXPCListener(machServiceName: OnyxHelper.machServiceName)
let delegate = Listener()
listener.delegate = delegate
listener.resume()
// Beim Beenden auch durch launchd die Lüfter freigeben.
for signalNumber in [SIGTERM, SIGINT] {
signal(signalNumber, SIG_IGN)
let source = DispatchSource.makeSignalSource(signal: signalNumber, queue: .main)
source.setEventHandler {
delegate.service.restoreAutomatic()
exit(0)
}
source.resume()
}
log.notice("Onyx-Helfer bereit")
RunLoop.main.run()

View File

@@ -9,6 +9,7 @@ let package = Package(
platforms: [.macOS(.v26)],
products: [
.library(name: "OnyxCore", targets: ["OnyxCore"]),
.library(name: "OnyxHelperProtocol", targets: ["OnyxHelperProtocol"]),
.library(name: "OnyxDesign", targets: ["OnyxDesign"]),
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
@@ -16,6 +17,10 @@ let package = Package(
.library(name: "CalendarProvider", targets: ["CalendarProvider"]),
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
.library(name: "MediaProvider", targets: ["MediaProvider"]),
.library(name: "MetricsProvider", targets: ["MetricsProvider"]),
.library(name: "NetworkProvider", targets: ["NetworkProvider"]),
.library(name: "AudioProvider", targets: ["AudioProvider"]),
.library(name: "ShelfProvider", targets: ["ShelfProvider"]),
],
targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt
@@ -24,11 +29,20 @@ let package = Package(
.target(name: "OnyxCore", resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "OnyxCoreTests", dependencies: ["OnyxCore"]),
// Geteilt zwischen App und privilegiertem Helfer deshalb ohne jede
// Abhängigkeit: der Helfer läuft als root und soll so wenig Code wie
// möglich enthalten.
.target(name: "OnyxHelperProtocol"),
.testTarget(name: "OnyxHelperProtocolTests", dependencies: ["OnyxHelperProtocol"]),
.target(name: "OnyxDesign"),
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"]),
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
.testTarget(name: "OnyxDesignTests", dependencies: ["OnyxDesign"]),
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
.testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]),
@@ -46,5 +60,21 @@ let package = Package(
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
.target(name: "MetricsProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "OnyxHelperProtocol"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MetricsProviderTests", dependencies: ["MetricsProvider"]),
.target(name: "NetworkProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit", "OnyxMenuBar", "MetricsProvider"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "NetworkProviderTests", dependencies: ["NetworkProvider"]),
.target(name: "AudioProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "AudioProviderTests", dependencies: ["AudioProvider"]),
.target(name: "ShelfProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "ShelfProviderTests", dependencies: ["ShelfProvider"]),
]
)

View File

@@ -0,0 +1,436 @@
import Foundation
import AppKit
import CoreAudio
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Der Per-Programm-Lautstärkemixer.
///
/// **Er ist standardmäßig aus.** Eingeschaltet greift er in den Tonweg jedes
/// gewählten Programms ein geht dabei etwas schief, ist das Programm still.
/// Ein Werkzeug, das ungefragt zwischen Musik und Lautsprecher tritt, hat man
/// sich nicht ausgesucht.
@MainActor
@Observable
public final class AudioMixer {
public private(set) var processes: [AudioProcess] = []
/// Die Programme, wie sie in der Oberfläche stehen Hilfsprozesse mit
/// ihrem Programm zusammengefasst.
public private(set) var groups: [AudioProcessGroup] = []
public private(set) var isEnabled = false
public private(set) var lastError: String?
/// Programme, die vom Mixer bearbeitet werden. Alle anderen laufen
/// unverändert am Mixer vorbei.
public private(set) var managed: Set<String> = []
private var store = VolumeStore()
private var taps: [String: ProcessTap] = [:]
private var refreshTimer: Timer?
private var watchdogTimer: Timer?
private var levelTimer: Timer?
/// Der letzte Ausschlag je Programm, 01.
public private(set) var levels: [String: Double] = [:]
private let defaults: UserDefaults
/// Wann ein Prozess zuletzt Ton ausgegeben hat. Ohne dieses Gedächtnis
/// verschwindet ein pausiertes Programm sofort aus der Liste.
private var lastHeard: [String: Date] = [:]
/// Ob der Mixer beim Programmstart von selbst anläuft.
///
/// Standardmäßig aus: der Mixer greift in den Tonweg ein, und das soll
/// niemandem ungefragt passieren. Wer ihn täglich benutzt, spart sich mit
/// dem Häkchen den Klick.
public var startsAutomatically: Bool {
didSet {
guard startsAutomatically != oldValue else { return }
defaults.set(startsAutomatically, forKey: Keys.autostart)
}
}
/// Die eigene Bundle-Kennung der Mixer darf sich nicht selbst abgreifen.
private static let ownBundleID = Bundle.main.bundleIdentifier
private enum Keys {
static let volumes = "onyx.audio.volumes"
static let muted = "onyx.audio.muted"
static let overdrive = "onyx.audio.overdrive"
static let managed = "onyx.audio.managed"
static let autostart = "onyx.audio.autostart"
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
let volumes = defaults.dictionary(forKey: Keys.volumes) as? [String: Double] ?? [:]
let muted = defaults.stringArray(forKey: Keys.muted) ?? []
let overdrive = defaults.stringArray(forKey: Keys.overdrive) ?? []
store = VolumeStore(volumes: volumes, muted: muted, overdrive: overdrive)
// Beim Laden aussieben statt nur beim Anzeigen: hat sich der Mixer
// einmal selbst in die Auswahl geschrieben, säße die Rückkopplung sonst
// dauerhaft in den Einstellungen und käme bei jedem Start zurück.
let stored = defaults.stringArray(forKey: Keys.managed) ?? []
managed = Set(stored.filter {
!ProcessVisibility.isExcluded(bundleID: $0, ownBundleID: Self.ownBundleID)
})
startsAutomatically = defaults.bool(forKey: Keys.autostart)
// Gleich zurückschreiben, wenn etwas herausgefallen ist: sonst steht der
// alte Eintrag weiter in den Einstellungen und wirkt bei der nächsten
// Fehlersuche wie ein Zustand, den es noch gibt.
if managed.count != stored.count { defaults.set(Array(managed), forKey: Keys.managed) }
}
/// Beim Programmstart aufrufen. Läuft nur an, wenn das Häkchen gesetzt ist.
public func enableIfWanted() {
guard startsAutomatically else { return }
enable()
}
// MARK: - Ein und aus
public func enable() {
guard !isEnabled else { return }
isEnabled = true
refresh()
refreshTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.refresh() }
}
// Der bekannte Fehlerfall aus Spike C: Taps liefern nach einiger Zeit
// nur noch Nullen. Von außen sieht das aus, als wäre das Programm
// stumm und genau das ist es dann auch, weil der Tap das Original
// stummschaltet.
watchdogTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.checkForStalledTaps() }
}
observeDeviceChanges()
log.notice("Mixer eingeschaltet")
}
/// Notausschalter. Stellt den unveränderten Systemzustand wieder her.
///
/// Muss immer erreichbar sein und darf nie fehlschlagen: wenn der Ton weg
/// ist, ist das der Knopf, den man sucht.
public func disable() {
isEnabled = false
refreshTimer?.invalidate(); refreshTimer = nil
watchdogTimer?.invalidate(); watchdogTimer = nil
levelTimer?.invalidate(); levelTimer = nil
for tap in taps.values { tap.stop() }
taps.removeAll()
levels = [:]
lastError = nil
log.notice("Mixer ausgeschaltet — Tonweg unverändert")
}
// MARK: - Programme
private func refresh() {
let all = Self.currentProcesses()
let now = Date()
// Core Audio führt Dutzende Systemdienste, die theoretisch Ton ausgeben
// könnten. In einem Mischpult sind sie nur Rauschen gezeigt wird, was
// klingt, kürzlich klang oder geregelt wird.
// Erst zusammenfassen, dann filtern: sonst verschwindet der
// Hauptprozess von Chrome, weil nur sein Helfer Ton ausgibt.
let candidates = AudioProcessGroup.group(all)
for group in candidates where group.isPlaying { lastHeard[group.bundleID] = now }
groups = candidates.filter { group in
ProcessVisibility.shouldShow(
isPlaying: group.isPlaying,
secondsSinceHeard: lastHeard[group.bundleID]
.map { now.timeIntervalSince($0) },
isManaged: managed.contains(group.bundleID))
}
processes = groups.flatMap(\.processes)
syncTaps()
startLevelTimerIfNeeded()
}
/// Legt Taps an und ab, sodass sie zu `managed` und den laufenden
/// Programmen passen.
private func syncTaps() {
guard isEnabled else { return }
guard let outputUID = Self.defaultOutputUID() else {
lastError = AudioMixerError.noOutputDevice.errorDescription
return
}
// Abgegriffen wird je **Prozess**, geregelt je **Programm**: Chromes
// Ton kommt aus mehreren Helfern, die alle denselben Regler bekommen.
// Der Schlüssel muss deshalb den Prozess mitführen mehrere Helfer
// teilen sich eine Kennung und überschrieben sonst einander.
var wanted: [String: (process: AudioProcess, group: AudioProcessGroup)] = [:]
for group in groups where managed.contains(group.bundleID) {
for process in group.processes {
wanted[Self.tapKey(process)] = (process, group)
}
}
// Beendete oder abgewählte Programme freigeben sonst bliebe der Tap
// stehen und schaltete ein Programm stumm, das gar nicht mehr gemeint ist.
for (key, tap) in taps where wanted[key] == nil {
tap.stop()
taps[key] = nil
}
for (key, entry) in wanted {
let gain = store.effectiveGain(for: entry.group.bundleID)
if let existing = taps[key] {
existing.setGain(gain)
continue
}
let tap = ProcessTap(process: entry.process)
tap.setGain(gain)
do {
try tap.start(outputDeviceUID: outputUID)
taps[key] = tap
lastError = nil
} catch {
// Bei einem Fehlschlag nicht in einem halben Zustand
// verharren: das Programm bleibt sonst stumm zurück.
tap.stop()
managed.remove(entry.group.bundleID)
persist()
lastError = error.localizedDescription
log.error("Tap für \(entry.process.name, privacy: .public) fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
}
}
}
/// Ein Tap je Prozess, nicht je Programm mehrere Helfer eines Programms
/// tragen dieselbe Kennung und überschrieben sich sonst gegenseitig.
private static func tapKey(_ process: AudioProcess) -> String {
"\(process.bundleID)#\(process.pid)"
}
/// Baut Taps neu auf, die nur noch Stille liefern.
private func checkForStalledTaps() {
for (key, tap) in taps {
guard tap.isRunning,
tap.secondsSinceAudio > 30,
tap.process.isPlaying
else { continue }
log.error("Tap für \(key, privacy: .public) liefert nur Stille — Neuaufbau")
tap.stop()
taps[key] = nil
}
syncTaps()
}
/// Wechselt das Ausgabegerät, müssen alle Taps neu aufgebaut werden: sie
/// hängen am alten Gerät und gäben ins Leere aus.
private func observeDeviceChanges() {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
AudioObjectAddPropertyListenerBlock(
AudioObjectID(kAudioObjectSystemObject), &address, .main) { [weak self] _, _ in
MainActor.assumeIsolated {
guard let self, self.isEnabled else { return }
log.notice("Ausgabegerät gewechselt — Taps werden neu aufgebaut")
for tap in self.taps.values { tap.stop() }
self.taps.removeAll()
self.syncTaps()
}
}
}
// MARK: - Steuerung
public func isManaged(_ bundleID: String) -> Bool { managed.contains(bundleID) }
/// Die Prozesse, deren Originalton gerade stummgeschaltet ist.
///
/// Der Ausgangspegel muss sie auslassen: ein Tap sieht das Signal **vor**
/// dem Stummschalten, sie zählten sonst doppelt einmal als Original, das
/// niemand hört, und einmal als das, was Onyx daraus macht.
public var tappedProcessIDs: [AudioObjectID] {
taps.values.map(\.process.objectID)
}
/// Der Ausschlag eines Programms, 01 für die Pegelanzeige.
public func level(for bundleID: String) -> Double { levels[bundleID] ?? 0 }
/// Holt die Ausschläge ab.
///
/// Läuft nur, solange überhaupt Taps bestehen: ohne abgegriffenes Programm
/// gibt es nichts zu messen, und ein Zeitgeber, der 15-mal pro Sekunde
/// nichts tut, gehört nicht in eine App, die im Hintergrund ruhig sein soll.
private func sampleLevels() {
guard !taps.isEmpty else {
if !levels.isEmpty { levels = [:] }
levelTimer?.invalidate()
levelTimer = nil
return
}
// Der Pegel gilt je **Programm**: Chromes Ton verteilt sich auf mehrere
// Helfer, und die Zeile zeigt den lautesten davon.
var peaks: [String: Double] = [:]
for (_, tap) in taps {
let app = ProcessNaming.parentBundleID(for: tap.process.bundleID)
peaks[app] = max(peaks[app] ?? 0, Double(tap.takePeak()))
}
var next: [String: Double] = [:]
for (app, peak) in peaks {
let previous = levels[app] ?? 0
// Schnell hoch, langsam runter. Ein Pegel, der jedem Puffer folgt,
// flackert; einer, der nur fällt, verpasst die Spitzen.
next[app] = peak > previous ? peak : previous * 0.72
}
levels = next
}
private func startLevelTimerIfNeeded() {
guard levelTimer == nil, !taps.isEmpty else { return }
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
Task { @MainActor in self?.sampleLevels() }
}
RunLoop.main.add(timer, forMode: .common)
levelTimer = timer
}
public func setManaged(_ isManaged: Bool, for bundleID: String) {
guard !ProcessVisibility.isExcluded(bundleID: bundleID,
ownBundleID: Self.ownBundleID) else { return }
if isManaged { managed.insert(bundleID) } else { managed.remove(bundleID) }
persist()
syncTaps()
}
public func volume(for bundleID: String) -> Double { store.volume(for: bundleID) }
public func isMuted(_ bundleID: String) -> Bool { store.isMuted(for: bundleID) }
public func isOverdriven(_ bundleID: String) -> Bool { store.isOverdriven(bundleID) }
/// Gibt den Bereich über 100 % frei je Programm, nicht für alle.
///
/// Ein Browser braucht ihn für eine leise Konferenz, die Musik daneben
/// nicht. Und ein Regler, der überall bis 400 % geht, führt genau dorthin,
/// wo man ihn nicht haben will.
public func setOverdrive(_ isOn: Bool, for bundleID: String) {
store.setOverdrive(isOn, for: bundleID)
applyGain(for: bundleID)
persist()
}
public func setVolume(_ volume: Double, for bundleID: String) {
store.set(volume, for: bundleID)
applyGain(for: bundleID)
persist()
}
public func setMuted(_ isMuted: Bool, for bundleID: String) {
store.setMuted(isMuted, for: bundleID)
applyGain(for: bundleID)
persist()
}
/// Gibt die neue Verstärkung an **alle** Taps des Programms weiter.
///
/// Vorher stand hier `taps[bundleID]` die Taps liegen aber unter
/// `Kennung#Prozessnummer`, weil ein Programm mehrere Tonquellen haben kann.
/// Der Zugriff ging deshalb immer ins Leere, und der Regler wirkte erst,
/// wenn die Auffrischung drei Sekunden später von selbst nachzog.
private func applyGain(for bundleID: String) {
let gain = store.effectiveGain(for: bundleID)
for tap in taps.values
where ProcessNaming.parentBundleID(for: tap.process.bundleID) == bundleID {
tap.setGain(gain)
}
}
private func persist() {
defaults.set(store.snapshot, forKey: Keys.volumes)
defaults.set(store.mutedIdentifiers, forKey: Keys.muted)
defaults.set(store.overdrivenIdentifiers, forKey: Keys.overdrive)
defaults.set(Array(managed), forKey: Keys.managed)
}
// MARK: - Core Audio
@MainActor
static func currentProcesses() -> [AudioProcess] {
let ownBundleID = Self.ownBundleID
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyProcessObjectList,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size) == noErr, size > 0
else { return [] }
let count = Int(size) / MemoryLayout<AudioObjectID>.stride
let raw = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<AudioObjectID>.alignment)
defer { raw.deallocate() }
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, raw) == noErr else { return [] }
let objects = Array(UnsafeBufferPointer(
start: raw.assumingMemoryBound(to: AudioObjectID.self), count: count))
return objects.compactMap { object -> AudioProcess? in
guard let bundleID = string(object, kAudioProcessPropertyBundleID),
!bundleID.isEmpty else { return nil }
let pid = value(object, kAudioProcessPropertyPID, pid_t(0)) ?? -1
guard !ProcessVisibility.isExcluded(bundleID: bundleID,
ownBundleID: ownBundleID) else { return nil }
let name = ProcessNaming.displayName(bundleID: bundleID, pid: pid)
let playing = (value(object, kAudioProcessPropertyIsRunningOutput, UInt32(0)) ?? 0) != 0
return AudioProcess(objectID: object, pid: pid, bundleID: bundleID,
name: name, isPlaying: playing)
}
// Was gerade klingt, gehört nach oben.
.sorted { ($0.isPlaying ? 0 : 1, $0.name.lowercased())
< ($1.isPlaying ? 0 : 1, $1.name.lowercased()) }
}
static func defaultOutputUID() -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDefaultOutputDevice,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
guard let device = value(AudioObjectID(kAudioObjectSystemObject), nil,
AudioObjectID(kAudioObjectUnknown), address: &address)
else { return nil }
return string(device, kAudioDevicePropertyDeviceUID)
}
private static func value<T>(_ object: AudioObjectID, _ selector: AudioObjectPropertySelector?,
_ initial: T,
address custom: UnsafeMutablePointer<AudioObjectPropertyAddress>? = nil)
-> T? {
var address = custom?.pointee ?? AudioObjectPropertyAddress(
mSelector: selector ?? 0, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result = initial
var size = UInt32(MemoryLayout<T>.size)
guard AudioObjectGetPropertyData(object, &address, 0, nil, &size, &result) == noErr
else { return nil }
return result
}
private static func string(_ object: AudioObjectID,
_ selector: AudioObjectPropertySelector) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &result) {
AudioObjectGetPropertyData(object, &address, 0, nil, &size, $0)
}
guard status == noErr, let result else { return nil }
return result as String
}
}

View File

@@ -0,0 +1,43 @@
import Foundation
/// Alle Tonquellen **eines** Programms in einer Zeile.
///
/// Chrome gibt seinen Ton über Hilfsprozesse aus einen je Tab. In der Liste
/// standen deshalb Google Chrome" und Google Chrome Helper" nebeneinander,
/// und zwar mit getrennten Reglern für dasselbe Programm: zieht man den einen,
/// hört man den anderen. Fünfzehn Regler für einen Browser sind keine Auskunft,
/// sondern eine Zumutung.
public struct AudioProcessGroup: Identifiable, Equatable, Sendable {
/// Die Kennung des **Programms**, nicht die des Prozesses. Sie ist der
/// Schlüssel für Lautstärke, Stummschaltung und Auswahl.
public let bundleID: String
public let name: String
public let processes: [AudioProcess]
public var id: String { bundleID }
/// Spielt einer, spielt die Gruppe.
///
/// Der Ton kommt aus dem Helfer, während der Hauptprozess schweigt die
/// Zeile muss trotzdem als spielend gelten, sonst verschwindet sie.
public var isPlaying: Bool { processes.contains { $0.isPlaying } }
public static func group(_ processes: [AudioProcess]) -> [AudioProcessGroup] {
let byApp = Dictionary(grouping: processes) {
ProcessNaming.parentBundleID(for: $0.bundleID)
}
return byApp.map { bundleID, members in
// Der Name kommt vom Programm selbst, wenn es dabei ist. Sonst vom
// Helfer dann ist er alles, was da ist.
let name = members.first { $0.bundleID == bundleID }?.name
?? ProcessNaming.fallbackName(for: bundleID)
return AudioProcessGroup(bundleID: bundleID, name: name,
processes: members.sorted { $0.pid < $1.pid })
}
// Nach Namen sortiert: Core Audio sagt keine Reihenfolge zu, und ohne
// feste Sortierung springen die Zeilen bei jeder Messung umher.
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
}

View File

@@ -0,0 +1,148 @@
import Foundation
/// Rechnet Reglerstellung in Verstärkung um.
public enum Gain {
/// Ohne Overdrive ist bei 100 % Schluss.
///
/// Das ist die Stellung, in der das Programm klingt, wie es gemeint war.
/// Alles darüber ist ein Eingriff und soll auch einer sein.
public static let maximumVolume: Double = 1.0
/// Mit Overdrive: das Vierfache.
///
/// Für leise Videokonferenzen und schlecht ausgesteuerte Videos. Darüber
/// hinaus bringt es nichts mehr der Begrenzer drückt dann alles zusammen,
/// und was lauter wird, ist vor allem das Grundrauschen der Quelle.
public static let overdriveMaximum: Double = 4.0
public static func maximum(overdrive: Bool) -> Double {
overdrive ? overdriveMaximum : maximumVolume
}
/// Verstärkungsfaktor zu einer Reglerstellung von 0 bis 2.
///
/// Bewusst nicht linear. Lautstärke wird ungefähr logarithmisch
/// wahrgenommen; ein linearer Regler wäre in der unteren Hälfte praktisch
/// wirkungslos und in der oberen viel zu empfindlich.
///
/// Der Exponent ist hergeleitet, nicht geraten: halbe empfundene Lautstärke
/// entspricht rund 10 dB, also Faktor 0,316. Aus 0,5^k = 0,316 folgt
/// k 1,66. Damit klingt der halbe Reglerweg auch halb so laut.
///
/// Eine Potenz statt einer Dezibel-Rechnung, weil hier kein Logarithmus je
/// Abtastwert anfallen soll der Faktor wird einmal je Puffer bestimmt,
/// aber die Formel steht in einem Echtzeitpfad.
public static let taper: Double = 1.66
/// Oberhalb von 100 % gilt die Kennlinie **nicht**.
///
/// Dort steht die Zahl für den Faktor selbst: 400 % sind das Vierfache der
/// Amplitude. Mit der Kennlinie wären daraus 4^1,66 10,4 geworden eine
/// Beschriftung, die etwas anderes verspricht, als der Regler tut. Bei
/// genau 100 % stimmen beide Hälften überein, die Kurve hat also keinen
/// Sprung, nur einen Knick.
public static func factor(forVolume volume: Double) -> Double {
let clamped = min(max(volume, 0), overdriveMaximum)
guard clamped > 0 else { return 0 }
return clamped <= 1 ? pow(clamped, taper) : clamped
}
}
/// Weiche Begrenzung.
///
/// Bei zweifacher Verstärkung entstehen Abtastwerte weit über dem gültigen
/// Bereich. Ungebremst schneidet die Hardware sie hart ab, und das knackt
/// hörbar. Der Tangens hyperbolicus biegt sie stattdessen sanft um: leise
/// Signale bleiben unangetastet, laute werden zunehmend gestaucht.
public enum SoftClip {
/// Ab hier greift die Kennlinie. Darunter bleibt das Signal unverändert
/// eine Begrenzung, die schon bei Zimmerlautstärke arbeitet, klingt matt.
public static let threshold: Float = 0.7
public static func apply(_ sample: Float) -> Float {
let magnitude = abs(sample)
guard magnitude > threshold else { return sample }
let excess = magnitude - threshold
let headroom = 1 - threshold
let compressed = threshold + headroom * tanh(excess / headroom)
return sample < 0 ? -compressed : compressed
}
}
/// Merkt sich Lautstärke und Stummschaltung je Programm.
///
/// Nach der Bundle-Kennung, nicht nach Prozessnummer: die ändert sich bei
/// jedem Start, und eine Einstellung, die einen Neustart des Programms nicht
/// überlebt, ist keine.
public struct VolumeStore: Sendable {
private var volumes: [String: Double] = [:]
private var muted: Set<String> = []
private var overdrive: Set<String> = []
public init() {}
/// Unbekannte Programme starten auf voller Lautstärke nicht auf null.
/// Ein Programm, das nach dem Start stumm ist, wirkt kaputt, und niemand
/// sucht die Ursache im Mixer.
public func volume(for bundleID: String) -> Double {
volumes[bundleID] ?? 1.0
}
public func isMuted(for bundleID: String) -> Bool { muted.contains(bundleID) }
public mutating func set(_ volume: Double, for bundleID: String) {
volumes[bundleID] = min(max(volume, 0), Gain.maximum(overdrive: isOverdriven(bundleID)))
}
public func isOverdriven(_ bundleID: String) -> Bool { overdrive.contains(bundleID) }
/// Schaltet den erweiterten Bereich frei.
///
/// Beim Abschalten wird die Lautstärke zurückgeholt: ein Wert von 340 %
/// bliebe sonst wirksam, obwohl der Regler ihn gar nicht mehr darstellen
/// kann der Knopf säße am rechten Anschlag und das Programm wäre trotzdem
/// dreifach verstärkt.
public mutating func setOverdrive(_ isOn: Bool, for bundleID: String) {
if isOn {
overdrive.insert(bundleID)
} else {
overdrive.remove(bundleID)
if let volume = volumes[bundleID], volume > Gain.maximumVolume {
volumes[bundleID] = Gain.maximumVolume
}
}
}
public mutating func setMuted(_ isMuted: Bool, for bundleID: String) {
// Getrennt von der Lautstärke: sonst verliert man beim Stummschalten
// den eingestellten Wert und muss ihn danach neu suchen.
if isMuted { muted.insert(bundleID) } else { muted.remove(bundleID) }
}
/// Was der Audiopfad tatsächlich anwenden soll.
public func effectiveGain(for bundleID: String) -> Double {
isMuted(for: bundleID) ? 0 : Gain.factor(forVolume: volume(for: bundleID))
}
// MARK: - Sicherung
public var snapshot: [String: Double] { volumes }
public var mutedIdentifiers: [String] { Array(muted) }
public var overdrivenIdentifiers: [String] { Array(overdrive) }
public init(volumes: [String: Double], muted: [String], overdrive: [String] = []) {
// Der Overdrive zuerst: sonst schneidet das Einsetzen der Lautstärken
// alles über 100 % ab, was gerade wiederhergestellt werden soll.
self.overdrive = Set(overdrive)
let allowed = Set(overdrive)
self.volumes = volumes.reduce(into: [:]) { result, entry in
let maximum = Gain.maximum(overdrive: allowed.contains(entry.key))
result[entry.key] = min(max(entry.value, 0), maximum)
}
self.muted = Set(muted)
}
}

View File

@@ -0,0 +1,285 @@
{
"sourceLanguage": "en",
"strings": {
"": {},
"%lld %%": {
"comment": "A row of controls for adjusting the volume and muting a process.",
"isCommentAutoGenerated": true,
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld %%"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld %%"
}
}
}
},
"mixer.autostart": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Beim Start von Onyx einschalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn on when Onyx starts"
}
}
}
},
"mixer.input": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Eingabe"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Input"
}
}
}
},
"mixer.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Audio-Mixer"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Audio mixer"
}
}
}
},
"mixer.noProcesses": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Programm gibt gerade Ton aus"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No app is playing audio"
}
}
}
},
"mixer.notAdjustable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Hier nicht regelbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Not adjustable here"
}
}
}
},
"mixer.off": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mixer ausschalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn mixer off"
}
}
}
},
"mixer.off.explain": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Lautstärke einzelner Programme regeln, auch über 100 %."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Set the volume of individual apps, including above 100 %."
}
}
}
},
"mixer.off.warning": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx leitet den Ton der geregelten Programme um. Beim Ausschalten ist der ursprüngliche Weg sofort wiederhergestellt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx routes the audio of the apps you control. Turning the mixer off restores the original path immediately."
}
}
}
},
"mixer.on": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Mixer einschalten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Turn mixer on"
}
}
}
},
"mixer.output": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ausgabe"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Output"
}
}
}
},
"mixer.overdrive": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Overdrive — bis 400 %, kann hörbar verzerren"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Overdrive — up to 400 %, may distort audibly"
}
}
}
},
"mixer.recordingHint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Solange der Mixer läuft, zeigt macOS das orange Aufnahmesymbol: Ton abgreifen heißt für das System aufnehmen, auch wenn nur die Lautstärke geregelt wird."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "While the mixer runs, macOS shows the orange recording dot: tapping audio counts as recording to the system, even when only the volume is adjusted."
}
}
}
},
"mixer.release": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nicht mehr regeln"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Stop controlling"
}
}
}
},
"mixer.reset": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zurück auf 100 %"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Back to 100 %"
}
}
}
},
"mixer.section.apps": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Programme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Programs"
}
}
}
},
"mixer.section.system": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "System"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "System"
}
}
}
},
"": {
"comment": "A placeholder for the volume of a process.",
"isCommentAutoGenerated": true
}
},
"version": "1.1"
}

View File

@@ -0,0 +1,664 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
import CoreAudio
/// Die Mischpult-Ansicht. Dieselbe im Panel und im Menüleisten-Popover.
///
/// Die Anordnung folgt SoundSource, weil sie dort gut gelöst ist: eine Zeile je
/// Programm, links das Symbol als Kachel, dann ein Stummschalter, dann der
/// Regler, rechts der Wert. Nichts steht übereinander, nichts muss man suchen.
///
/// Die Farben folgen ihr **nicht**. SoundSource färbt jeden Regler grün; in
/// Onyx ist Farbe ein Signal die Verstärkung über 100 % ist eines, eine
/// gewöhnliche Lautstärke nicht.
public struct MixerView: View {
let mixer: AudioMixer
let system: SystemAudio
/// Im Panel ist es eng, im Popover nicht.
let compact: Bool
public init(mixer: AudioMixer, system: SystemAudio, compact: Bool = false) {
self.mixer = mixer
self.system = system
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: compact ? 10 : 12) {
// Die Systemregler stehen **vor** den Programmen und außerhalb des
// Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und
// keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür
// nichts einschalten müssen.
SectionTitle("mixer.section.system", compact: compact)
SystemSection(system: system, compact: compact)
SectionTitle("mixer.section.apps", compact: compact)
if mixer.isEnabled {
if mixer.groups.isEmpty {
Text("mixer.noProcesses", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 8)
} else {
// Eine Karte um die Zeilen: sie fasst zusammen, was
// zusammengehört, und trennt es vom Ausschalter darunter.
VStack(spacing: 0) {
ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()),
id: \.element.id) { index, group in
if index > 0 { Divider().overlay(Onyx.Color.hairline) }
ProcessRow(mixer: mixer, group: group, compact: compact)
.padding(.horizontal, 10)
.padding(.vertical, compact ? 6 : 8)
}
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
if let error = mixer.lastError {
Text(error).font(Onyx.Font.caption).foregroundStyle(Onyx.Color.critical)
.fixedSize(horizontal: false, vertical: true)
}
autostartToggle
// Der Notausschalter gehört sichtbar dazu, nicht in ein Menü:
// wenn der Ton weg ist, ist das der Knopf, den man sucht.
Button { mixer.disable() } label: {
Text("mixer.off", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
.buttonStyle(.plain)
.padding(.top, 2)
} else {
OffState(mixer: mixer, compact: compact)
autostartToggle
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.animation(Onyx.Motion.value, value: mixer.groups.count)
// Kein `onAppear` für die Pegel: die Messung hängt am Popover, nicht
// an dieser View. `onDisappear` bleibt bei einem NSPopover aus, und
// ein Mikrofon, das nur beim Öffnen an- und nie wieder ausgeht, ist
// schlimmer als eines, das gar nicht misst.
}
}
extension MixerView {
/// Das Häkchen steht in **beiden** Zuständen: eingeschaltet, um es wieder
/// abzuwählen, ausgeschaltet, um es gleich mit zu setzen.
@ViewBuilder
var autostartToggle: some View {
Toggle(isOn: Binding(get: { mixer.startsAutomatically },
set: { mixer.startsAutomatically = $0 })) {
Text("mixer.autostart", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
.toggleStyle(.checkbox)
.controlSize(.small)
// Der Hinweis gehört hierher, weil hier die Entscheidung fällt.
//
// Ein Tap ist für macOS eine Tonaufnahme auch wenn nur die
// Lautstärke geregelt wird. Solange der Mixer läuft, steht deshalb das
// orange Aufnahmesymbol in der Leiste. Wer das ohne Erklärung sieht,
// sucht den Fehler bei sich oder hält die App für unsauber.
Text("mixer.recordingHint", bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Der ausgeschaltete Zustand erklärt, worauf man sich einlässt in zwei
/// Sätzen, nicht in einem Absatz. Wer vier Zeilen lesen muss, bevor er einen
/// Regler sieht, liest sie nicht.
private struct OffState: View {
let mixer: AudioMixer
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 15, weight: .light))
.foregroundStyle(Onyx.Color.textTertiary)
Text("mixer.off.explain", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
if !compact {
Text("mixer.off.warning", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
.fixedSize(horizontal: false, vertical: true)
}
Button { mixer.enable() } label: {
Text("mixer.on", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.surface)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(Onyx.Color.accent, in: .capsule)
}
.buttonStyle(.plain)
}
}
}
private struct ProcessRow: View {
let mixer: AudioMixer
let group: AudioProcessGroup
let compact: Bool
private var managed: Bool { mixer.isManaged(group.bundleID) }
private var muted: Bool { mixer.isMuted(group.bundleID) }
private var volume: Double { mixer.volume(for: group.bundleID) }
private var overdriven: Bool { mixer.isOverdriven(group.bundleID) }
var body: some View {
// Eine Zeile statt zwei: Symbol, Stummschalter, Regler, Wert. Der Name
// ist weg bei einem erkennbaren Programmsymbol ist er redundant, und
// die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht,
// hält den Zeiger auf das Symbol.
HStack(spacing: 10) {
icon
Button {
if !managed { mixer.setManaged(true, for: group.bundleID) }
mixer.setMuted(!muted, for: group.bundleID)
} label: {
Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol)
.font(.system(size: 11))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical
: (managed ? Onyx.Color.textSecondary
: Onyx.Color.textTertiary))
VolumeBar(
volume: volume,
maximum: Gain.maximum(overdrive: overdriven),
level: mixer.level(for: group.bundleID),
isActive: managed && !muted,
onChange: { newValue in
// Am Regler zu ziehen **ist** die Entscheidung, dieses
// Programm zu regeln. Ein zusätzlicher Schalter davor wäre
// eine Hürde, die nichts schützt der große Ausschalter
// unten stellt ohnehin alles zurück.
if !managed { mixer.setManaged(true, for: group.bundleID) }
mixer.setVolume(newValue, for: group.bundleID)
})
Text(managed ? "\(Int(volume * 100)) %" : "")
.font(Onyx.Font.metricSmall)
.monospacedDigit()
.foregroundStyle(volume > 1 && managed ? Onyx.Color.warning
: Onyx.Color.textTertiary)
// Feste Breite, sonst wandert der Regler beim Ziehen.
.frame(width: 36, alignment: .trailing)
overdriveButton
resetButton
}
.contentShape(.rect)
.contextMenu {
if managed {
Button { mixer.setManaged(false, for: group.bundleID) } label: {
Text("mixer.release", bundle: .module)
}
}
}
.animation(Onyx.Motion.value, value: managed)
}
/// Der Overdrive-Schalter.
///
/// Er steht bei jedem Programm, auch bei einem ungeregelten sonst müsste
/// man erst den Regler anfassen, um an den Schalter zu kommen, und hätte
/// damit schon geregelt. Eingeschaltet reicht der Regler bis 400 %.
@ViewBuilder
private var overdriveButton: some View {
Button {
if !managed { mixer.setManaged(true, for: group.bundleID) }
mixer.setOverdrive(!overdriven, for: group.bundleID)
} label: {
Image(systemName: "bolt.fill")
.font(.system(size: 9, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55))
.help(Text("mixer.overdrive", bundle: .module))
.frame(width: 14, height: 14)
}
/// Zurück auf 100 %.
///
/// Der Platz dafür ist **immer** reserviert, auch wenn der Knopf nicht zu
/// sehen ist. Ein Knopf, der auftaucht und dabei den Regler schmaler macht,
/// verschiebt genau das, was man gerade bedient und beim Loslassen springt
/// es zurück.
@ViewBuilder
private var resetButton: some View {
Group {
if managed, abs(volume - 1) > 0.001 || overdriven {
Button {
// Der Overdrive geht mit zurück: er ist keine eigene
// Einstellung, sondern die Freigabe für einen Wert, den es
// danach nicht mehr gibt. Bliebe er stehen, zeigte die Zeile
// 100 % und der Regler stünde trotzdem im Boost-Bereich.
mixer.setOverdrive(false, for: group.bundleID)
mixer.setVolume(1, for: group.bundleID)
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.system(size: 9, weight: .medium))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
.help(Text("mixer.reset", bundle: .module))
} else {
Color.clear
}
}
.frame(width: Self.resetSlotWidth, height: 14)
}
/// Die reservierte Breite des Rücksetzknopfes.
private static let resetSlotWidth: CGFloat = 14
/// Wie viele Wellen am Lautsprecher folgt der Lautstärke, wie überall
/// sonst in macOS auch.
private var speakerSymbol: String {
switch volume {
case ..<0.01: "speaker.fill"
case ..<0.5: "speaker.wave.1.fill"
case ..<1.2: "speaker.wave.2.fill"
default: "speaker.wave.3.fill"
}
}
/// Ohne Namen daneben trägt das Symbol die Erkennung allein also größer
/// und ohne Kachel, damit es tatsächlich zu erkennen ist.
@ViewBuilder
private var icon: some View {
// Auch Hilfsprozesse bekommen das Symbol ihres Programms.
Group {
if let image = ProcessNaming.icon(bundleID: group.bundleID,
pid: group.processes.first?.pid ?? 0) {
Image(nsImage: image)
.resizable()
.opacity(managed ? 1 : 0.65)
} else {
Image(systemName: "app.dashed")
.font(.system(size: 15))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.frame(width: 26, height: 26)
// Der Name hängt am Symbol, nicht an der Zeile: die Zeile besteht zum
// größten Teil aus Regler und Knöpfen, und die verschlucken den
// Hinweis. Am Symbol kommt er an und dort sucht man ihn auch, wenn
// man das Programm nicht erkennt.
.help(tooltip)
}
/// Name des Programms, bei mehreren Tonquellen mit ihrer Anzahl.
///
/// Chrome erscheint als eine Zeile, gibt seinen Ton aber über mehrere
/// Hilfsprozesse aus. Die Zahl erklärt, warum ein Regler mehr als einen
/// Prozess bewegt.
private var tooltip: String {
guard group.processes.count > 1 else { return group.name }
return "\(group.name) (\(group.processes.count))"
}
}
/// Der Lautstärkeregler.
///
/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes
/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt.
/// Und er kann nicht zeigen, was hier den Unterschied macht den Pegel und
/// die Grenze bei 100 %.
private struct VolumeBar: View {
let volume: Double
/// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive.
let maximum: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
private let trackHeight: CGFloat = 6
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let fraction = min(max(volume / maximum, 0), 1)
let knob: CGFloat = isDragging ? 13 : 11
// Der Knopf soll an den Enden nicht überstehen.
let travel = max(width - knob, 1)
let fillWidth = travel * fraction + knob / 2
ZStack(alignment: .leading) {
Capsule()
.fill(Onyx.Color.surface)
.frame(height: trackHeight)
// Markierung bei 100 %: darüber wird verstärkt, und man soll
// sehen, wann man diese Grenze überschreitet. Ohne Overdrive
// liegt sie am rechten Anschlag und sagt dort nichts mehr.
if maximum > 1 {
Rectangle()
.fill(Onyx.Color.hairline)
.frame(width: 1, height: trackHeight + 4)
.offset(x: knob / 2 + travel / maximum)
}
Capsule()
.fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent)
.frame(width: fillWidth, height: trackHeight)
.opacity(isActive ? 1 : 0.3)
// Der Pegel läuft **im** Regler mit, nicht daneben: eine
// zweite Leiste pro Zeile wäre doppelt so viel Grafik für
// dieselbe Auskunft.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: trackHeight)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
let position = min(max((value.location.x - knob / 2) / travel, 0), 1)
onChange(position * maximum)
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}
// MARK: - Widget
public struct AudioMixerWidget: OnyxWidget {
public let id = "audio"
public var displayName: String { String(localized: "mixer.name", bundle: .module) }
public let symbolName = "slider.horizontal.3"
private let mixer: AudioMixer
private let system: SystemAudio
public init(mixer: AudioMixer, system: SystemAudio) {
self.mixer = mixer
self.system = system
}
public func makeView() -> AnyView {
AnyView(
ScrollView {
MixerView(mixer: mixer, system: system, compact: true)
}
.scrollIndicators(.never)
)
}
}
// MARK: - Systemzeilen
private struct SectionTitle: View {
let key: LocalizedStringKey
let compact: Bool
init(_ key: LocalizedStringKey, compact: Bool) {
self.key = key
self.compact = compact
}
var body: some View {
Text(key, bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
.textCase(.uppercase)
.padding(.top, compact ? 0 : 2)
}
}
/// Ausgabe, Eingabe und Gerätewahl.
///
/// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter,
/// Regler, Wert. Zwei verschiedene Regler in einem Fenster wären eine
/// Behauptung, dass es zwei verschiedene Dinge sind es ist aber beides
/// Lautstärke.
private struct SystemSection: View {
let system: SystemAudio
let compact: Bool
var body: some View {
VStack(spacing: 0) {
SystemRow(
symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
title: outputName,
devices: system.outputDevices,
selected: system.outputDeviceID,
volume: system.outputVolume,
level: system.levels.output,
muted: system.outputMuted,
adjustable: system.outputIsAdjustable,
compact: compact,
onVolume: { system.setOutputVolume($0) },
onMute: { system.setOutputMuted(!system.outputMuted) },
onSelect: { system.selectOutput($0) })
Divider().overlay(Onyx.Color.hairline)
SystemRow(
symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill",
title: inputName,
devices: system.inputDevices,
selected: system.inputDeviceID,
volume: system.inputVolume,
level: system.levels.input,
muted: system.inputMuted,
adjustable: system.inputIsAdjustable,
compact: compact,
onVolume: { system.setInputVolume($0) },
onMute: { system.setInputMuted(!system.inputMuted) },
onSelect: { system.selectInput($0) })
}
.background(Onyx.Color.elevated.opacity(0.55),
in: .rect(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.hairline, lineWidth: 1)
}
}
private var outputName: String {
system.outputDevices.first { $0.id == system.outputDeviceID }?.name
?? String(localized: "mixer.output", bundle: .module)
}
private var inputName: String {
system.inputDevices.first { $0.id == system.inputDeviceID }?.name
?? String(localized: "mixer.input", bundle: .module)
}
}
private struct SystemRow: View {
let symbol: String
let title: String
let devices: [AudioDevice]
let selected: AudioObjectID
let volume: Double
let level: Double
let muted: Bool
let adjustable: Bool
let compact: Bool
let onVolume: (Double) -> Void
let onMute: () -> Void
let onSelect: (AudioDevice) -> Void
var body: some View {
VStack(alignment: .leading, spacing: compact ? 5 : 6) {
// Der Gerätename **ist** die Auswahl: ein eigenes Auswahlfeld
// daneben wäre eine zweite Stelle für dieselbe Sache.
Menu {
ForEach(devices) { device in
Button { onSelect(device) } label: {
if device.id == selected {
Label(device.name, systemImage: "checkmark")
} else {
Text(device.name)
}
}
}
} label: {
HStack(spacing: 4) {
Text(title)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 7))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
HStack(spacing: 9) {
Button(action: onMute) {
Image(systemName: symbol)
.font(.system(size: 11))
.frame(width: 15, alignment: .leading)
}
.buttonStyle(.plain)
.foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary)
if adjustable {
SystemVolumeBar(volume: volume, level: level,
isActive: !muted, onChange: onVolume)
Text("\(Int(volume * 100)) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 36, alignment: .trailing)
} else {
// Manche Geräte bringen keinen eigenen Regler mit HDMI,
// viele USB-Interfaces. Ein Regler, der nichts tut, wäre
// eine Pseudobedienung.
Text("mixer.notAdjustable", bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
}
.padding(.horizontal, 10)
.padding(.vertical, compact ? 7 : 9)
}
}
/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die
/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her.
private struct SystemVolumeBar: View {
let volume: Double
let level: Double
let isActive: Bool
let onChange: (Double) -> Void
@State private var isDragging = false
var body: some View {
GeometryReader { geometry in
let knob: CGFloat = isDragging ? 13 : 11
let travel = max(geometry.size.width - knob, 1)
let fraction = min(max(volume, 0), 1)
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.surface).frame(height: 6)
let fillWidth = travel * fraction + knob / 2
Capsule()
.fill(Onyx.Color.accent)
.frame(width: fillWidth, height: 6)
.opacity(isActive ? 1 : 0.3)
// Derselbe mitlaufende Pegel wie in den Programmzeilen. Er
// sitzt **im** Regler, weil eine zweite Leiste daneben doppelt
// so viel Grafik für dieselbe Auskunft wäre.
if isActive, level > 0.001 {
Capsule()
.fill(.white.opacity(0.4))
.frame(width: fillWidth * meterFraction, height: 6)
.blendMode(.plusLighter)
.allowsHitTesting(false)
}
Circle()
.fill(.white)
.frame(width: knob, height: knob)
.shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5)
.offset(x: travel * fraction)
.opacity(isActive ? 1 : 0.55)
}
.frame(height: 16)
.contentShape(.rect)
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
isDragging = true
onChange(min(max((value.location.x - knob / 2) / travel, 0), 1))
}
.onEnded { _ in isDragging = false })
}
.frame(height: 16)
.animation(Onyx.Motion.value, value: isDragging)
}
/// Ausschläge sind linear in der Amplitude und damit optisch nutzlos:
/// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren
/// Bereich so weit, dass man überhaupt etwas sieht.
private var meterFraction: Double {
min(1, sqrt(min(max(level, 0), 1)))
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import AppKit
/// Macht aus Core-Audio-Prozessen etwas, das man wiedererkennt.
///
/// Core Audio meldet **Prozesse**, nicht Programme. Was dort ankommt, heißt
/// `com.google.Chrome.helper` oder `com.apple.WebKit.GPU`. Technisch richtig,
/// im Mischpult unbrauchbar: wer Spotify leiser" sucht, sucht nach Spotify".
public enum ProcessNaming {
/// Endungen, mit denen Programme ihre Hilfsprozesse benennen.
///
/// Chromes Ton kommt aus einem solchen Prozess abgegriffen wird also
/// zurecht der Helfer, angezeigt aber das Programm dahinter.
private static let helperSuffixes = [
".helper", ".Helper", ".ServiceExtension", ".Renderer", ".GPU",
".plugin", ".xpc", ".Service",
]
/// Die Kennung des Programms, zu dem ein Prozess gehört.
public static func parentBundleID(for bundleID: String) -> String {
var result = bundleID
// Mehrfach kürzen: `com.google.Chrome.helper.Renderer` hat zwei.
var changed = true
while changed {
changed = false
for suffix in helperSuffixes where result.hasSuffix(suffix) {
result.removeLast(suffix.count)
changed = true
}
}
return result.isEmpty ? bundleID : result
}
/// Der anzuzeigende Name, wenn das Programm selbst nicht gefragt werden kann.
public static func fallbackName(for bundleID: String) -> String {
guard !bundleID.isEmpty else { return "Unbekannt" }
let parent = parentBundleID(for: bundleID)
// Der letzte Bestandteil ist bei umgekehrter Domainschreibweise der
// Programmname com.spotify.client" ist die Ausnahme, die auf einen
// generischen Begriff endet.
let components = parent.split(separator: ".")
// Ein einzelnes Wort ist keine umgekehrte Domain, sondern ein
// Prozessname wie `systemsoundserverd`. Den groß zu schreiben macht ihn
// nicht lesbarer, nur falsch.
guard components.count > 1 else { return parent }
guard let last = components.last else { return bundleID }
let generic = ["client", "app", "mac", "macos", "desktop"]
let chosen = generic.contains(last.lowercased()) && components.count > 1
? components[components.count - 2]
: last
return chosen.prefix(1).uppercased() + chosen.dropFirst()
}
/// Der beste verfügbare Name: erst das laufende Programm fragen, dann den
/// übergeordneten Prozess, dann die Kennung zerlegen.
@MainActor
public static func displayName(bundleID: String, pid: pid_t) -> String {
if let name = NSRunningApplication(processIdentifier: pid)?.localizedName {
return name
}
let parent = parentBundleID(for: bundleID)
if parent != bundleID,
let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
return FileManager.default.displayName(atPath: url.path)
.replacingOccurrences(of: ".app", with: "")
}
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
return FileManager.default.displayName(atPath: url.path)
.replacingOccurrences(of: ".app", with: "")
}
return fallbackName(for: bundleID)
}
/// Das Symbol des Programms auch für Hilfsprozesse.
@MainActor
public static func icon(bundleID: String, pid: pid_t) -> NSImage? {
if let icon = NSRunningApplication(processIdentifier: pid)?.icon { return icon }
let parent = parentBundleID(for: bundleID)
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: parent) {
return NSWorkspace.shared.icon(forFile: url.path)
}
return nil
}
}
/// Wer im Mischpult auftaucht.
public enum ProcessVisibility {
/// Wie lange ein verstummtes Programm noch sichtbar bleibt.
///
/// Ohne diese Nachlaufzeit verschwindet Spotify beim Pausieren aus der
/// Liste und man kann die Lautstärke genau dann nicht mehr einstellen,
/// wenn man es will.
public static let gracePeriod: TimeInterval = 120
/// - Parameters:
/// - isPlaying: gibt der Prozess gerade Ton aus?
/// - secondsSinceHeard: wie lange ist das letzte Mal her? `nil` = noch nie.
/// - isManaged: wird seine Lautstärke von Onyx geregelt?
public static func shouldShow(isPlaying: Bool,
secondsSinceHeard: TimeInterval?,
isManaged: Bool) -> Bool {
// Geregelte Programme immer: wer eine Lautstärke eingestellt hat, muss
// sie auch zurücknehmen können.
if isManaged || isPlaying { return true }
guard let seconds = secondsSinceHeard else { return false }
return seconds <= gracePeriod
}
}
public extension ProcessVisibility {
/// Programme, die gar nicht erst in der Liste auftauchen.
///
/// **Onyx selbst gehört dazu**, und nicht nur der Ordnung halber: sobald der
/// Mixer ein Programm bearbeitet, gibt *er* dessen Ton aus und steht damit
/// als Tonquelle in seiner eigenen Liste. Greift er sich dort ab, hört er
/// seine eigene Ausgabe wieder mit und gibt sie erneut aus. Gemessen klettert
/// der Pegel dabei binnen Sekunden von 0,37 auf 2,83: eine Rückkopplung, die
/// man nicht bauen, sondern ausschließen muss.
///
/// Dazu die Systemdienste: `coreaudiod` und die Sprachausgabe sind in einer
/// Liste, in der man Spotify leiser" sucht, nur Rauschen.
static func isExcluded(bundleID: String, ownBundleID: String?) -> Bool {
if let ownBundleID, !ownBundleID.isEmpty, bundleID == ownBundleID { return true }
return bundleID.hasPrefix("com.apple.audio") || bundleID.hasPrefix("com.apple.core")
}
}

View File

@@ -0,0 +1,251 @@
import Foundation
import Synchronization
import AudioToolbox
import CoreAudio
import AVFoundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Ein Programm, das Ton ausgibt.
public struct AudioProcess: Identifiable, Equatable, Sendable {
public let objectID: AudioObjectID
public let pid: pid_t
public let bundleID: String
public let name: String
public let isPlaying: Bool
public var id: AudioObjectID { objectID }
}
/// Fängt den Ton **eines** Programms ab, verstärkt ihn und gibt ihn wieder aus.
///
/// Der Weg ist der einzige, den macOS ohne Treiber anbietet: ein Process Tap
/// mit `mutedWhenTapped` schaltet die ursprüngliche Ausgabe stumm und liefert
/// das Signal stattdessen hier ab. Ein privates Aggregate-Device aus Tap und
/// echtem Ausgabegerät gibt es nach der Bearbeitung wieder aus.
///
/// **Fällt dieser Aufbau auseinander, ist das Programm still.** Deshalb wird
/// jeder Fehler zum Abbau des Taps führen, nicht zu einem halben Zustand und
/// deshalb gibt es einen Wachhund gegen die bekannten Null-Puffer.
final class ProcessTap {
/// Vorsatz der Gerätekennungen, die Onyx selbst anlegt.
static let aggregateUIDPrefix = "com.scarriffleservices.onyx.tap."
let process: AudioProcess
/// Wird im Echtzeitpfad gelesen. `atomic` genügt hier nicht als Sprachmittel,
/// aber ein einzelner `Float` wird auf arm64 atomar geschrieben.
private(set) var gain: Float = 1
/// Der höchste Ausschlag seit dem letzten Abholen als Bitmuster, weil
/// `Atomic` keine Fließkommazahlen führt.
///
/// Sperrenfrei und damit im Echtzeitpfad erlaubt. Eine gewöhnliche
/// Eigenschaft zu beschreiben wäre ein Wettlauf mit dem Leser, und eine
/// Sperre im Audiothread ist der klassische Weg zu Aussetzern.
private let peakBits = Atomic<UInt32>(0)
/// Wann zuletzt Ton kam in Sekunden seit dem Programmstart, damit der
/// Echtzeitpfad keine `Date` anfassen muss.
private let lastAudioUptime = Atomic<UInt64>(0)
private var tapID = AudioObjectID(kAudioObjectUnknown)
private var aggregateID = AudioObjectID(kAudioObjectUnknown)
private var procID: AudioDeviceIOProcID?
private let tapUUID = UUID()
/// Wann zuletzt ein Puffer mit Inhalt kam für den Wachhund.
private(set) var isRunning = false
/// Wie lange dieser Tap schon nur Stille liefert.
var secondsSinceAudio: TimeInterval {
let stored = lastAudioUptime.load(ordering: .relaxed)
guard stored > 0 else { return 0 }
return max(0, Self.uptime() - Double(stored) / 1000)
}
/// Der Ausschlag seit dem letzten Abruf, 01. Holt ihn ab und setzt zurück,
/// damit die Anzeige abfällt, statt auf dem Höchstwert stehen zu bleiben.
func takePeak() -> Float {
Float(bitPattern: peakBits.exchange(0, ordering: .relaxed))
}
private static func uptime() -> Double {
Double(DispatchTime.now().uptimeNanoseconds) / 1_000_000_000
}
init(process: AudioProcess) {
self.process = process
}
deinit { stop() }
func setGain(_ value: Double) {
gain = Float(value)
}
// MARK: - Aufbau
func start(outputDeviceUID: String) throws {
guard !isRunning else { return }
let description = CATapDescription(stereoMixdownOfProcesses: [process.objectID])
description.uuid = tapUUID
description.name = "Onyx \(process.name)"
description.isPrivate = true
// Der Kern des Ganzen: das Original wird stumm geschaltet, das Signal
// kommt nur noch hier an. Ohne das hörte man beides gleichzeitig.
description.muteBehavior = .mutedWhenTapped
var tap = AudioObjectID(kAudioObjectUnknown)
let tapStatus = AudioHardwareCreateProcessTap(description, &tap)
guard tapStatus == noErr else { throw AudioMixerError.tapFailed(tapStatus) }
tapID = tap
let aggregate: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Mixer \(process.name)",
// Kennung mit erkennbarem Vorsatz: die Geräte sind zwar privat,
// das verbirgt sie aber nur vor fremden Prozessen. Onyx selbst
// sieht sie in der Geräteliste und muss sie dort aussortieren
// können über den Namen ginge das nur unzuverlässig.
kAudioAggregateDeviceUIDKey: Self.aggregateUIDPrefix + UUID().uuidString,
// Privat: das Gerät soll nicht in der Tonauswahl des Systems
// auftauchen und auch nicht von anderen Programmen gewählt werden.
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUUID.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
var device = AudioObjectID(kAudioObjectUnknown)
let aggregateStatus = AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &device)
guard aggregateStatus == noErr else {
cleanUp()
throw AudioMixerError.aggregateFailed(aggregateStatus)
}
aggregateID = device
var proc: AudioDeviceIOProcID?
let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
[weak self] _, inputData, _, outputData, _ in
self?.render(input: inputData, output: outputData)
}
guard ioStatus == noErr, let proc else {
cleanUp()
throw AudioMixerError.ioProcFailed(ioStatus)
}
procID = proc
guard AudioDeviceStart(aggregateID, proc) == noErr else {
cleanUp()
throw AudioMixerError.startFailed
}
isRunning = true
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
log.notice("Tap aktiv für \(self.process.name, privacy: .public)")
}
// MARK: - Echtzeitpfad
/// Läuft auf dem Audiothread. Hier darf nichts geschehen, was blockieren
/// kann: keine Speicheranforderung, keine Sperre, kein Protokollieren.
private func render(input: UnsafePointer<AudioBufferList>,
output: UnsafeMutablePointer<AudioBufferList>) {
let inputList = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: input))
let outputList = UnsafeMutableAudioBufferListPointer(output)
let factor = gain
var peak: Float = 0
for (index, outputBuffer) in outputList.enumerated() {
guard index < inputList.count,
let source = inputList[index].mData,
let destination = outputBuffer.mData else { continue }
let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize))
/ MemoryLayout<Float>.size
let inputSamples = source.assumingMemoryBound(to: Float.self)
let outputSamples = destination.assumingMemoryBound(to: Float.self)
for sample in 0..<count {
let value = inputSamples[sample] * factor
// Nur begrenzen, wenn wirklich verstärkt wird unterhalb der
// Schwelle ist die Kennlinie ohnehin die Identität, und im
// Echtzeitpfad zählt jede eingesparte Rechnung.
let limited = factor > 1 ? SoftClip.apply(value) : value
outputSamples[sample] = limited
// Der Ausschlag wird im selben Durchlauf mitgenommen: ein
// zweiter Durchgang nur zum Messen wäre im Echtzeitpfad die
// teuerste Art, an dieselbe Zahl zu kommen.
let magnitude = abs(limited)
if magnitude > peak { peak = magnitude }
}
}
if peak > 0 {
// Höchstwert behalten, bis die Anzeige ihn abholt sonst
// verschluckt ein Puffer mit Stille die Spitze davor.
let previous = Float(bitPattern: peakBits.load(ordering: .relaxed))
if peak > previous { peakBits.store(peak.bitPattern, ordering: .relaxed) }
lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed)
}
}
// MARK: - Abbau
func stop() {
guard tapID != kAudioObjectUnknown || aggregateID != kAudioObjectUnknown else { return }
cleanUp()
isRunning = false
}
/// Immer in dieser Reihenfolge: erst anhalten, dann IOProc, dann Gerät,
/// dann Tap. Umgekehrt bleibt das Programm stumm zurück, weil der Tap noch
/// stummschaltet, aber nichts mehr ausgibt.
private func cleanUp() {
if let procID {
AudioDeviceStop(aggregateID, procID)
AudioDeviceDestroyIOProcID(aggregateID, procID)
self.procID = nil
}
if aggregateID != kAudioObjectUnknown {
AudioHardwareDestroyAggregateDevice(aggregateID)
aggregateID = AudioObjectID(kAudioObjectUnknown)
}
if tapID != kAudioObjectUnknown {
AudioHardwareDestroyProcessTap(tapID)
tapID = AudioObjectID(kAudioObjectUnknown)
}
}
}
public enum AudioMixerError: Error, LocalizedError {
case tapFailed(OSStatus)
case aggregateFailed(OSStatus)
case ioProcFailed(OSStatus)
case startFailed
case noOutputDevice
public var errorDescription: String? {
switch self {
case .tapFailed(let status):
"Der Ton dieses Programms lässt sich nicht abgreifen (\(status))."
case .aggregateFailed(let status):
"Das Mischgerät ließ sich nicht anlegen (\(status))."
case .ioProcFailed(let status):
"Die Tonverarbeitung ließ sich nicht starten (\(status))."
case .startFailed:
"Das Mischgerät startet nicht."
case .noOutputDevice:
"Kein Ausgabegerät gefunden."
}
}
}

View File

@@ -0,0 +1,345 @@
import Foundation
import CoreAudio
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Ein Ein- oder Ausgabegerät.
public struct AudioDevice: Identifiable, Equatable, Sendable {
public let id: AudioObjectID
public let name: String
public let isOutput: Bool
public let isInput: Bool
}
/// Die Systemlautstärken und die Gerätewahl.
///
/// Getrennt vom Mischpult der einzelnen Programme, weil es technisch etwas
/// ganz anderes ist: hier wird kein Ton abgegriffen, hier werden die Regler
/// des Systems bedient. Das geht ohne Tap, ohne Aggregate-Gerät und ohne
/// Berechtigung und funktioniert deshalb auch, wenn der Mixer aus ist.
@MainActor
@Observable
public final class SystemAudio {
public private(set) var outputDevices: [AudioDevice] = []
public private(set) var inputDevices: [AudioDevice] = []
public private(set) var outputDeviceID = AudioObjectID(kAudioObjectUnknown)
public private(set) var inputDeviceID = AudioObjectID(kAudioObjectUnknown)
public private(set) var outputVolume: Double = 0
public private(set) var outputMuted = false
public private(set) var inputVolume: Double = 0
public private(set) var inputMuted = false
/// Warnton-Lautstärke. `nil`, wenn das Gerät sie nicht getrennt führt.
public private(set) var alertVolume: Double?
/// Ob sich die Lautstärke überhaupt setzen lässt.
///
/// Manche Geräte HDMI, viele USB-Interfaces bringen keinen eigenen
/// Regler mit; dort regelt man am Gerät. Ein Regler, der nichts tut, wäre
/// eine Pseudobedienung.
public private(set) var outputIsAdjustable = false
public private(set) var inputIsAdjustable = false
private var listeners: [PropertyListener] = []
/// Die Pegel von Ausgabe und Eingabe.
///
/// Hier und nicht im Mixer: sie gehören zu den Systemreglern, und die
/// stehen bewusst außerhalb des Mixer-Schalters.
public let levels = SystemLevels()
public init() {
refresh()
observeSystemChanges()
}
// MARK: - Lesen
public func refresh() {
outputDevices = Self.devices().filter(\.isOutput)
inputDevices = Self.devices().filter(\.isInput)
outputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultOutputDevice)
inputDeviceID = Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice)
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
outputIsAdjustable = Self.volume(outputDeviceID,
scope: kAudioDevicePropertyScopeOutput) != nil
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
inputIsAdjustable = Self.volume(inputDeviceID,
scope: kAudioDevicePropertyScopeInput) != nil
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
let alertDevice = Self.defaultDevice(kAudioHardwarePropertyDefaultSystemOutputDevice)
alertVolume = alertDevice == outputDeviceID
? nil // Dasselbe Gerät, derselbe Regler keine zweite Zeile dafür.
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
}
// MARK: - Setzen
public func setOutputVolume(_ value: Double) {
Self.setVolume(value, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
outputVolume = min(max(value, 0), 1)
// Lautstärke ziehen hebt die Stummschaltung auf sonst zieht man am
// Regler und hört weiter nichts.
if outputMuted, value > 0 { setOutputMuted(false) }
}
public func setOutputMuted(_ muted: Bool) {
Self.setMuted(muted, on: outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
outputMuted = muted
}
public func setInputVolume(_ value: Double) {
Self.setVolume(value, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
inputVolume = min(max(value, 0), 1)
if inputMuted, value > 0 { setInputMuted(false) }
}
public func setInputMuted(_ muted: Bool) {
Self.setMuted(muted, on: inputDeviceID, scope: kAudioDevicePropertyScopeInput)
inputMuted = muted
}
public func selectOutput(_ device: AudioDevice) {
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultOutputDevice)
// Warntöne folgen dem Ausgabegerät: zwei getrennte Wahlen sind eine
// Quelle für warum kommt der Systemton aus dem Notebook".
Self.setDefaultDevice(device.id,
selector: kAudioHardwarePropertyDefaultSystemOutputDevice)
refresh()
}
public func selectInput(_ device: AudioDevice) {
Self.setDefaultDevice(device.id, selector: kAudioHardwarePropertyDefaultInputDevice)
refresh()
}
// MARK: - Core Audio
/// Gerätewechsel kommen von außen: Kopfhörer einstecken, Bluetooth
/// verbinden, ein anderes Programm stellt das Standardgerät um.
private func observeSystemChanges() {
listeners = [kAudioHardwarePropertyDefaultOutputDevice,
kAudioHardwarePropertyDefaultInputDevice,
kAudioHardwarePropertyDevices].compactMap { selector in
PropertyListener(selector: selector) { [weak self] in
Task { @MainActor in self?.refresh() }
}
}
}
private static func devices() -> [AudioDevice] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size) == noErr else { return [] }
var ids = [AudioObjectID](repeating: 0,
count: Int(size) / MemoryLayout<AudioObjectID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, &ids) == noErr else { return [] }
return ids.compactMap { id in
// Die eigenen Aggregate-Geräte gehören nicht in die Auswahl: sie
// sind Werkzeug, kein Ziel. Wer eines davon als Ausgabe wählt,
// hängt seinen Ton in eine Schleife.
guard !isOwnDevice(uid: uid(of: id)) else { return nil }
guard let name = name(of: id) else { return nil }
let output = channels(id, scope: kAudioDevicePropertyScopeOutput) > 0
let input = channels(id, scope: kAudioDevicePropertyScopeInput) > 0
// Geräte ohne Kanäle sind Aggregate-Hüllen und Ähnliches sie in
// die Auswahl zu stellen hilft niemandem.
guard output || input else { return nil }
return AudioDevice(id: id, name: name, isOutput: output, isInput: input)
}
}
/// Ob Onyx dieses Gerät selbst angelegt hat.
///
/// `nonisolated`, weil es eine reine Zeichenkettenprüfung ist und keinen
/// Zustand anfasst so lässt es sich auch prüfen, ohne den MainActor zu
/// bemühen.
nonisolated static func isOwnDevice(uid: String?) -> Bool {
guard let uid, !uid.isEmpty else { return false }
return uid.hasPrefix(ProcessTap.aggregateUIDPrefix)
}
private static func uid(of device: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(MemoryLayout<CFString?>.size)
var uid: CFString?
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &uid) == noErr
else { return nil }
return uid as String?
}
private static func name(of device: AudioObjectID) -> String? {
var address = AudioObjectPropertyAddress(
mSelector: kAudioObjectPropertyName,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var size = UInt32(MemoryLayout<CFString?>.size)
var name: CFString?
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &name) == noErr
else { return nil }
return name as String?
}
private static func channels(_ device: AudioObjectID,
scope: AudioObjectPropertyScope) -> Int {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyStreamConfiguration,
mScope: scope, mElement: kAudioObjectPropertyElementMain)
var size: UInt32 = 0
guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr,
size > 0 else { return 0 }
let buffer = UnsafeMutableRawPointer.allocate(byteCount: Int(size),
alignment: MemoryLayout<AudioBufferList>.alignment)
defer { buffer.deallocate() }
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, buffer) == noErr
else { return 0 }
let list = UnsafeMutableAudioBufferListPointer(
buffer.assumingMemoryBound(to: AudioBufferList.self))
return list.reduce(0) { $0 + Int($1.mNumberChannels) }
}
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, &device)
return device
}
private static func setDefaultDevice(_ device: AudioObjectID,
selector: AudioObjectPropertySelector) {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var value = device
let status = AudioObjectSetPropertyData(
AudioObjectID(kAudioObjectSystemObject), &address, 0, nil,
UInt32(MemoryLayout<AudioObjectID>.size), &value)
if status != noErr {
log.error("Ausgabegerät nicht gesetzt: \(status, privacy: .public)")
}
}
/// Die Lautstärke eines Geräts.
///
/// **Zuerst der Hauptregler, dann die Kanäle.** Viele Geräte führen keinen
/// Regler für Element 0, sondern nur je Kanal fragt man nur den
/// Hauptregler, hält man ein regelbares Gerät für unregelbar.
private static func volume(_ device: AudioObjectID,
scope: AudioObjectPropertyScope) -> Double? {
guard device != AudioObjectID(kAudioObjectUnknown) else { return nil }
for element in [kAudioObjectPropertyElementMain, 1, 2] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: scope, mElement: AudioObjectPropertyElement(element))
guard AudioObjectHasProperty(device, &address) else { continue }
var value: Float32 = 0
var size = UInt32(MemoryLayout<Float32>.size)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
else { continue }
return Double(value)
}
return nil
}
private static func setVolume(_ value: Double, on device: AudioObjectID,
scope: AudioObjectPropertyScope) {
guard device != AudioObjectID(kAudioObjectUnknown) else { return }
var scalar = Float32(min(max(value, 0), 1))
for element in [kAudioObjectPropertyElementMain, 1, 2] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyVolumeScalar,
mScope: scope, mElement: AudioObjectPropertyElement(element))
guard AudioObjectHasProperty(device, &address) else { continue }
var settable: DarwinBoolean = false
guard AudioObjectIsPropertySettable(device, &address, &settable) == noErr,
settable.boolValue else { continue }
AudioObjectSetPropertyData(device, &address, 0, nil,
UInt32(MemoryLayout<Float32>.size), &scalar)
// Beim Hauptregler ist man fertig; bei Kanälen müssen beide gesetzt
// werden, sonst wandert das Stereobild.
if element == kAudioObjectPropertyElementMain { return }
}
}
private static func muted(_ device: AudioObjectID,
scope: AudioObjectPropertyScope) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: scope, mElement: kAudioObjectPropertyElementMain)
guard AudioObjectHasProperty(device, &address) else { return false }
var value: UInt32 = 0
var size = UInt32(MemoryLayout<UInt32>.size)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &value) == noErr
else { return false }
return value != 0
}
private static func setMuted(_ muted: Bool, on device: AudioObjectID,
scope: AudioObjectPropertyScope) {
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyMute,
mScope: scope, mElement: kAudioObjectPropertyElementMain)
guard AudioObjectHasProperty(device, &address) else { return }
var value: UInt32 = muted ? 1 : 0
AudioObjectSetPropertyData(device, &address, 0, nil,
UInt32(MemoryLayout<UInt32>.size), &value)
}
}
/// Hält eine Core-Audio-Anmeldung und meldet sie beim Abräumen wieder ab.
///
/// Wie beim Kalender ein eigenes Objekt: `SystemAudio` liegt auf dem
/// MainActor, sein `deinit` aber nicht es dürfte die Anmeldung dort gar
/// nicht mehr anfassen. Und `AudioObjectRemovePropertyListenerBlock` will
/// **denselben** Block zurück, den man angemeldet hat; der muss also
/// irgendwo aufgehoben werden.
///
/// `@unchecked Sendable` ist belegbar: nach `init` wird hier nichts mehr
/// geschrieben.
private final class PropertyListener: @unchecked Sendable {
private let object = AudioObjectID(kAudioObjectSystemObject)
private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
block = { _, _ in handler() }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr
else { return nil }
}
deinit {
AudioObjectRemovePropertyListenerBlock(object, &address, .main, block)
}
}

View File

@@ -0,0 +1,329 @@
import Foundation
import Synchronization
import AudioToolbox
import CoreAudio
import AVFoundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Audio")
/// Der höchste Ausschlag seit dem letzten Abholen als Bitmuster, weil
/// `Atomic` keine Fließkommazahlen führt.
///
/// Sperrenfrei, weil geschrieben wird, während der Audiothread läuft. Eine
/// gewöhnliche Eigenschaft wäre dort ein Wettlauf, eine Sperre ein Aussetzer.
final class PeakMeter: Sendable {
private let bits = Atomic<UInt32>(0)
func record(_ magnitude: Float) {
let previous = Float(bitPattern: bits.load(ordering: .relaxed))
if magnitude > previous { bits.store(magnitude.bitPattern, ordering: .relaxed) }
}
/// Holt den Höchstwert ab und setzt zurück, damit die Anzeige abfällt,
/// statt auf der Spitze stehen zu bleiben.
func take() -> Float { Float(bitPattern: bits.exchange(0, ordering: .relaxed)) }
}
/// Misst, was gerade aus dem Lautsprecher kommt.
///
/// Über einen globalen Tap, also denselben Weg wie der Mixer nur ohne
/// Stummschalten und ohne Ausgabe. Ausgenommen werden die Programme, die der
/// Mixer bereits abgreift: deren Originalton ist stummgeschaltet, aber ein Tap
/// sieht ihn trotzdem. Sie zählten sonst doppelt einmal als Original, das
/// niemand hört, und einmal als das, was Onyx daraus macht.
final class OutputMeter {
let peak = PeakMeter()
private(set) var excluded: [AudioObjectID] = []
private var tapID = AudioObjectID(kAudioObjectUnknown)
private var aggregateID = AudioObjectID(kAudioObjectUnknown)
private var procID: AudioDeviceIOProcID?
var isRunning: Bool { procID != nil }
deinit { stop() }
func start(excluding processes: [AudioObjectID], outputDeviceUID: String) {
stop()
excluded = processes
let tapUUID = UUID()
let description = CATapDescription(stereoGlobalTapButExcludeProcesses: processes)
description.uuid = tapUUID
description.name = "Onyx Ausgangspegel"
description.isPrivate = true
// Nur zuhören. Ein Pegelmesser, der die Wiedergabe verändert, wäre
// kein Messgerät.
description.muteBehavior = .unmuted
guard AudioHardwareCreateProcessTap(description, &tapID) == noErr else {
tapID = AudioObjectID(kAudioObjectUnknown)
return
}
let aggregate: [String: Any] = [
kAudioAggregateDeviceNameKey: "Onyx Pegel",
kAudioAggregateDeviceUIDKey: ProcessTap.aggregateUIDPrefix + UUID().uuidString,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
kAudioAggregateDeviceMainSubDeviceKey: outputDeviceUID,
kAudioAggregateDeviceSubDeviceListKey: [[kAudioSubDeviceUIDKey: outputDeviceUID]],
kAudioAggregateDeviceTapListKey: [[
kAudioSubTapUIDKey: tapUUID.uuidString,
kAudioSubTapDriftCompensationKey: true,
]],
]
guard AudioHardwareCreateAggregateDevice(aggregate as CFDictionary, &aggregateID) == noErr
else {
aggregateID = AudioObjectID(kAudioObjectUnknown)
stop()
return
}
var proc: AudioDeviceIOProcID?
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) {
[peak] _, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData))
for buffer in input {
guard let data = buffer.mData else { continue }
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
let samples = data.assumingMemoryBound(to: Float.self)
var high: Float = 0
for index in 0..<count {
let magnitude = abs(samples[index])
if magnitude > high { high = magnitude }
}
peak.record(high)
}
// Die eigene Ausgabe bleibt still: das Gerät gibt auf denselben
// Lautsprecher aus, und ungeschriebene Puffer enthalten, was zuletzt
// darin stand.
AudioBuffers.silence(outputData)
}
guard status == noErr, let proc else { stop(); return }
procID = proc
guard AudioDeviceStart(aggregateID, proc) == noErr else { stop(); return }
}
func stop() {
if let procID {
AudioDeviceStop(aggregateID, procID)
AudioDeviceDestroyIOProcID(aggregateID, procID)
self.procID = nil
}
if aggregateID != kAudioObjectUnknown {
AudioHardwareDestroyAggregateDevice(aggregateID)
aggregateID = AudioObjectID(kAudioObjectUnknown)
}
if tapID != kAudioObjectUnknown {
AudioHardwareDestroyProcessTap(tapID)
tapID = AudioObjectID(kAudioObjectUnknown)
}
}
}
/// Misst, was das Mikrofon hört.
///
/// **Dafür wird tatsächlich aufgenommen** anders geht es nicht. Es gibt keine
/// Eigenschaft am Gerät, die den Ausschlag verrät; einen Pegel bekommt nur, wer
/// das Signal liest. Genau das meldet macOS mit dem orangen Punkt, und das ist
/// richtig so. Deshalb läuft diese Messung nur, solange jemand hinschaut, und
/// hört mit dem Schließen des Popovers sofort wieder auf.
final class InputMeter {
let peak = PeakMeter()
private var deviceID = AudioObjectID(kAudioObjectUnknown)
private var procID: AudioDeviceIOProcID?
var isRunning: Bool { procID != nil }
deinit { stop() }
func start(device: AudioObjectID) {
stop()
guard device != kAudioObjectUnknown, Self.deliversFloat(device) else { return }
deviceID = device
var proc: AudioDeviceIOProcID?
let status = AudioDeviceCreateIOProcIDWithBlock(&proc, device, nil) {
[peak] _, inputData, _, outputData, _ in
let input = UnsafeMutableAudioBufferListPointer(
UnsafeMutablePointer(mutating: inputData))
for buffer in input {
guard let data = buffer.mData else { continue }
let count = Int(buffer.mDataByteSize) / MemoryLayout<Float>.size
let samples = data.assumingMemoryBound(to: Float.self)
var high: Float = 0
for index in 0..<count {
let magnitude = abs(samples[index])
if magnitude > high { high = magnitude }
}
peak.record(high)
}
// Manche Geräte haben Ein- und Ausgang. Der eigene Ausgabepuffer
// bleibt still er trägt zum Gerät nur bei, was hier hineingeht.
AudioBuffers.silence(outputData)
}
guard status == noErr, let proc else { stop(); return }
procID = proc
if AudioDeviceStart(device, proc) != noErr {
log.error("Mikrofonpegel: Gerät startet nicht")
stop()
}
}
func stop() {
if let procID {
AudioDeviceStop(deviceID, procID)
AudioDeviceDestroyIOProcID(deviceID, procID)
self.procID = nil
}
deviceID = AudioObjectID(kAudioObjectUnknown)
}
/// Liefert das Gerät Fließkommawerte?
///
/// Ohne diese Prüfung würden Ganzzahlwerte als Fließkomma gelesen das
/// ergibt keinen zu hohen oder zu niedrigen Pegel, sondern Unsinn.
private static func deliversFloat(_ device: AudioObjectID) -> Bool {
var address = AudioObjectPropertyAddress(
mSelector: kAudioStreamPropertyVirtualFormat,
mScope: kAudioObjectPropertyScopeInput,
mElement: kAudioObjectPropertyElementMain)
var format = AudioStreamBasicDescription()
var size = UInt32(MemoryLayout<AudioStreamBasicDescription>.size)
guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, &format) == noErr
else { return false }
return format.mFormatFlags & kAudioFormatFlagIsFloat != 0
}
}
enum AudioBuffers {
/// Füllt alle Puffer mit Nullen.
static func silence(_ list: UnsafeMutablePointer<AudioBufferList>) {
for buffer in UnsafeMutableAudioBufferListPointer(list) {
guard let data = buffer.mData else { continue }
memset(data, 0, Int(buffer.mDataByteSize))
}
}
}
/// Die Pegel von Ausgabe und Eingabe.
///
/// Sie laufen nur, solange jemand hinschaut: beide brauchen einen offenen
/// Audioweg, der Mikrofonpegel obendrein den orangen Punkt. Ein Messgerät, das
/// im Hintergrund weiterläuft, wäre bei beidem falsch.
@MainActor
@Observable
public final class SystemLevels {
public private(set) var output: Double = 0
public private(set) var input: Double = 0
/// Ob gerade tatsächlich das Mikrofon gelesen wird für den Hinweis in der
/// Oberfläche. Wer den orangen Punkt sieht, soll in Onyx nachlesen können,
/// warum er da ist.
public private(set) var isListeningToInput = false
/// Prozesse, deren Originalton der Mixer bereits stummgeschaltet hat.
///
/// Wird von außen gesetzt, weil nur der Mixer sie kennt. Ohne die Angabe
/// zählte jedes geregelte Programm doppelt.
public var mutedProcesses: @MainActor () -> [AudioObjectID] = { [] }
@ObservationIgnored private let outputMeter = OutputMeter()
@ObservationIgnored private let inputMeter = InputMeter()
@ObservationIgnored private var consumers = 0
@ObservationIgnored private var timer: Timer?
public init() {}
/// Ein Zähler statt eines Schalters: Popover und Panel können gleichzeitig
/// offen sein, und das zweite Schließen darf nicht abschalten, was das erste
/// noch braucht.
public func addConsumer() {
consumers += 1
guard consumers == 1 else { return }
rebuild()
let timer = Timer(timeInterval: 1.0 / 15, repeats: true) { [weak self] _ in
Task { @MainActor in self?.sample() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
public func removeConsumer() {
consumers = max(0, consumers - 1)
guard consumers == 0 else { return }
timer?.invalidate()
timer = nil
outputMeter.stop()
inputMeter.stop()
isListeningToInput = false
output = 0
input = 0
}
private func rebuild() {
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
outputMeter.start(excluding: mutedProcesses(), outputDeviceUID: uid)
}
inputMeter.start(device: Self.defaultDevice(kAudioHardwarePropertyDefaultInputDevice))
isListeningToInput = inputMeter.isRunning
}
private func sample() {
// Ändert sich, welche Programme der Mixer abgreift, muss der Tap neu
// aufgebaut werden sonst zählte ein gerade erst geregeltes Programm
// ab dem nächsten Puffer doppelt.
let muted = mutedProcesses()
if outputMeter.isRunning, muted.sorted() != outputMeter.excluded.sorted() {
if let uid = Self.defaultDeviceUID(kAudioHardwarePropertyDefaultOutputDevice) {
outputMeter.start(excluding: muted, outputDeviceUID: uid)
}
}
// Schnell hoch, langsam runter wie bei den Programmzeilen, damit
// beide Balken dieselbe Sprache sprechen.
output = decay(output, toward: Double(outputMeter.peak.take()))
input = decay(input, toward: Double(inputMeter.peak.take()))
}
private func decay(_ current: Double, toward peak: Double) -> Double {
peak > current ? peak : current * 0.72
}
// MARK: - Core Audio
private static func defaultDevice(_ selector: AudioObjectPropertySelector) -> AudioObjectID {
var address = AudioObjectPropertyAddress(
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var device = AudioObjectID(kAudioObjectUnknown)
var size = UInt32(MemoryLayout<AudioObjectID>.size)
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject),
&address, 0, nil, &size, &device) == noErr
else { return AudioObjectID(kAudioObjectUnknown) }
return device
}
private static func defaultDeviceUID(_ selector: AudioObjectPropertySelector) -> String? {
let device = defaultDevice(selector)
guard device != kAudioObjectUnknown else { return nil }
var address = AudioObjectPropertyAddress(
mSelector: kAudioDevicePropertyDeviceUID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var result: CFString?
var size = UInt32(MemoryLayout<CFString?>.size)
let status = withUnsafeMutablePointer(to: &result) {
AudioObjectGetPropertyData(device, &address, 0, nil, &size, $0)
}
guard status == noErr, let result else { return nil }
return result as String
}
}

View File

@@ -0,0 +1,75 @@
import Foundation
/// Teilt die Terminliste in Tage.
///
/// **Ohne Tagesüberschrift ist die Liste unlesbar.** Zwei Zeilen Nina
/// arbeiten, 8:00" untereinander sind zwei verschiedene Tage, und nichts
/// verrät welche. Die Uhrzeit allein beantwortet nur die halbe Frage.
public enum AgendaGrouping {
public struct Day: Identifiable, Equatable, Sendable {
/// Der Tagesanfang er ist zugleich die Kennung.
public let date: Date
public let events: [OnyxEvent]
public var id: Date { date }
}
/// Gruppiert nach Anfangstag, in der Reihenfolge, in der die Tage kommen.
public static func group(_ events: [OnyxEvent],
calendar: Calendar = .autoupdatingCurrent) -> [Day] {
var order: [Date] = []
var byDay: [Date: [OnyxEvent]] = [:]
for event in events.sorted(by: Self.isEarlier) {
let day = calendar.startOfDay(for: event.start)
if byDay[day] == nil { order.append(day) }
byDay[day, default: []].append(event)
}
return order.map { Day(date: $0, events: byDay[$0] ?? []) }
}
/// Ganztägige zuerst, dann nach Uhrzeit.
///
/// Ein ganztägiger Termin beginnt um Mitternacht und stünde sonst zwischen
/// den frühen Terminen dabei gilt er für den ganzen Tag und gehört über
/// sie.
private static func isEarlier(_ left: OnyxEvent, _ right: OnyxEvent) -> Bool {
if left.isAllDay != right.isAllDay { return left.isAllDay }
return left.start < right.start
}
/// Welche Art von Tag die Entscheidung getrennt von ihrer Beschriftung,
/// damit sie ohne Sprachkatalog prüfbar bleibt.
public enum DayKind: Equatable, Sendable {
case today
case tomorrow
case later
}
public static func kind(for day: Date, today: Date = Date(),
calendar: Calendar = .autoupdatingCurrent) -> DayKind {
let start = calendar.startOfDay(for: day)
let reference = calendar.startOfDay(for: today)
switch calendar.dateComponents([.day], from: reference, to: start).day ?? 0 {
case 0: return .today
case 1: return .tomorrow
default: return .later
}
}
/// Heute", Morgen" oder das Datum.
///
/// Für die nächsten beiden Tage die Wörter: sie sind kürzer als jedes
/// Datum und beantworten die Frage sofort. Danach Wochentag und Datum
/// der Wochentag allein wäre in drei Wochen mehrdeutig.
public static func label(for day: Date, today: Date = Date(),
calendar: Calendar = .autoupdatingCurrent) -> String {
switch kind(for: day, today: today, calendar: calendar) {
case .today: return String(localized: "widget.calendar.today", bundle: .module)
case .tomorrow: return String(localized: "widget.calendar.tomorrow", bundle: .module)
case .later:
return calendar.startOfDay(for: day)
.formatted(.dateTime.weekday(.abbreviated).day().month(.defaultDigits))
}
}
}

View File

@@ -0,0 +1,84 @@
import Foundation
/// Woher die Termine kommen sollen.
public enum CalendarSourceKind: String, CaseIterable, Sendable {
case appleCalendar
case calendarr
case both
public var localizedName: String {
switch self {
case .appleCalendar: String(localized: "calendar.source.apple", bundle: .module)
case .calendarr: String(localized: "calendar.source.calendarr", bundle: .module)
case .both: String(localized: "calendar.source.both", bundle: .module)
}
}
/// Fasst die Zustände mehrerer Quellen zu einem zusammen.
///
/// Solange **eine** Quelle liefert, wird geliefert. Calendarr nie geöffnet
/// zu haben darf den Apple-Kalender nicht mit ausknipsen sonst wäre
/// beide" schlechter als jede einzelne Quelle für sich.
public static func merge(_ states: [CalendarSourceState]) -> CalendarSourceState {
guard !states.isEmpty else { return .ready(.empty) }
let windows = states.compactMap { state -> EventWindow? in
if case .ready(let window) = state { return window }
return nil
}
// Keine einzige Quelle liefert: dann ist der erste Grund die Antwort.
// Irgendeine Begründung ist besser als eine leere Liste, die aussieht,
// als wäre nichts los.
guard !windows.isEmpty else { return states[0] }
return .ready(.merged(windows))
}
}
public extension EventWindow {
/// Führt mehrere Fenster zu einem zusammen.
static func merged(_ windows: [EventWindow]) -> EventWindow {
guard !windows.isEmpty else { return .empty }
guard windows.count > 1 else { return windows[0] }
let events = windows.flatMap(\.events).sorted { $0.start < $1.start }
// Die Abdeckung ist der **Schnitt**, nicht die Vereinigung: ein Tag
// gilt nur als bekannt, wenn ihn jede beteiligte Quelle abdeckt. Deckt
// Calendarr den März nicht ab, ist der März unbekannt auch wenn der
// Apple-Kalender dafür Auskunft geben könnte. Sonst zeigte das Raster
// einen halben März als frei", der in Wahrheit nur halb bekannt ist.
var coverage: DateInterval?
for window in windows {
// `nil` heißt unbegrenzt und schränkt daher nichts ein.
guard let next = window.coverage else { continue }
coverage = coverage.map { current in
let start = max(current.start, next.start)
let end = min(current.end, next.end)
// Ohne Überschneidung bleibt nichts übrig und das ist die
// richtige Antwort, nicht ein beliebiger der beiden Zeiträume.
return DateInterval(start: start, end: max(start, end))
} ?? next
}
// Der älteste Stand begrenzt die Verlässlichkeit des Ganzen. Den
// neueren zu zeigen behauptete eine Aktualität, die nicht alle haben.
let asOf = windows.compactMap(\.asOf).min()
return EventWindow(events: events, coverage: coverage, asOf: asOf)
}
}
public extension CalendarSourceState {
/// Blendet die abgewählten Kalender aus.
///
/// Die Abdeckung bleibt unangetastet: welche Kalender man sehen will, sagt
/// nichts darüber aus, für welchen Zeitraum Daten vorliegen. Ein Tag ohne
/// sichtbare Termine bleibt ein bekannter Tag.
func filteringHidden(_ hidden: Set<String>) -> CalendarSourceState {
guard case .ready(let window) = self, !hidden.isEmpty else { return self }
return .ready(EventWindow(
events: window.events.filter { !hidden.contains($0.calendarID) },
coverage: window.coverage,
asOf: window.asOf))
}
}

View File

@@ -0,0 +1,107 @@
import SwiftUI
import OnyxDesign
/// Die Wahl der Terminquelle samt ehrlicher Auskunft darüber, was Calendarr
/// gerade liefert.
///
/// Der Zustandstext ist kein Beiwerk: bleibt das Widget leer, ist genau hier
/// die Antwort auf das Warum. Ohne ihn sieht Calendarr nie geöffnet" genauso
/// aus wie keine Termine".
public struct CalendarSourceSettings: View {
@Bindable private var model: CalendarModel
@State private var calendarrState: CalendarSourceState?
public init(model: CalendarModel) {
self.model = model
}
public var body: some View {
Form {
Section {
Picker(selection: $model.kind) {
ForEach(CalendarSourceKind.allCases, id: \.self) { kind in
Text(kind.localizedName).tag(kind)
}
} label: {
Text("calendar.source.title", bundle: .module)
}
.pickerStyle(.radioGroup)
}
if model.kind != .appleCalendar {
Section {
LabeledContent {
VStack(alignment: .leading, spacing: 4) {
statusText
Text("calendar.source.readOnly", bundle: .module)
.font(.caption)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
} label: {
Text(verbatim: "Calendarr")
}
}
}
}
.formStyle(.grouped)
.task(id: model.kind) { await loadCalendarrState() }
}
@ViewBuilder
private var statusText: some View {
switch calendarrState {
case .ready(let window):
VStack(alignment: .leading, spacing: 2) {
Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))",
bundle: .module)
if let coverage = window.coverage {
// Die Grenze der Abdeckung gehört sichtbar hin: dahinter
// zeigt Onyx bewusst unbekannt" statt frei", und das
// soll niemand für einen Fehler halten.
Text("calendar.source.status.coverage \(coverage.end.formatted(date: .abbreviated, time: .omitted))",
bundle: .module)
.foregroundStyle(.secondary)
}
}
.font(.callout)
case .neverWritten:
notice("calendar.source.status.neverWritten")
case .loggedOut:
notice("calendar.source.status.loggedOut")
case .incompatible:
notice("calendar.source.status.incompatible")
case .unreadable(let reason):
Text("calendar.source.status.unreadable \(reason)", bundle: .module)
.font(.callout).foregroundStyle(Onyx.Color.warning)
.fixedSize(horizontal: false, vertical: true)
case .denied, .none:
ProgressView().controlSize(.small)
}
}
private func notice(_ key: String.LocalizationValue) -> some View {
Text(String(localized: key, bundle: .module))
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
private func asOfText(_ date: Date?) -> String {
date?.formatted(date: .abbreviated, time: .shortened) ?? ""
}
private func loadCalendarrState() async {
guard model.kind != .appleCalendar else { return }
// Bewusst über die Quelle selbst und nicht über das Modell: hier soll
// der Zustand von Calendarr allein stehen, nicht die Mischung aus
// beiden Quellen. Sonst verdeckt ein funktionierender Apple-Kalender,
// dass Calendarr gar nichts liefert.
let source = CalendarrSource()
let now = Date()
calendarrState = await source.load(
DateInterval(start: now.addingTimeInterval(-14 * 86_400),
end: now.addingTimeInterval(60 * 86_400)))
}
}

View File

@@ -14,18 +14,139 @@ public final class CalendarModel {
public private(set) var state: CalendarSourceState = .ready(.empty)
public private(set) var isLoading = false
private let source: CalendarSource
private var calendar: Calendar
private var refreshTask: Task<Void, Never>?
public init(source: CalendarSource, calendar: Calendar = .autoupdatingCurrent) {
self.source = source
self.calendar = calendar
/// Welche Quellen gefragt werden. Umschalten lädt sofort neu.
public var kind: CalendarSourceKind {
didSet {
guard kind != oldValue else { return }
defaults.set(kind.rawValue, forKey: Self.kindKey)
observeCalendarrIfNeeded()
refresh()
}
}
/// Monatsraster statt Terminliste.
public var showsMonthGrid: Bool {
didSet { defaults.set(showsMonthGrid, forKey: Self.gridKey) }
}
/// Welche Kalender Daten liefern. `nil` heißt: alle.
///
/// Bewusst eine Liste der **abgewählten**, nicht der gewählten: kommt ein
/// neuer Kalender dazu, ist er dann automatisch dabei. Andersherum müsste
/// man ihn suchen und anhaken, ohne zu wissen, dass es ihn gibt.
public var hiddenCalendarIDs: Set<String> {
didSet {
guard hiddenCalendarIDs != oldValue else { return }
defaults.set(Array(hiddenCalendarIDs), forKey: Self.hiddenKey)
refresh()
}
}
/// Die Kalender, die zur Auswahl stehen.
public private(set) var availableCalendars: [OnyxCalendar] = []
private static let kindKey = "calendar.source"
private static let hiddenKey = "calendar.hiddenCalendars"
private static let gridKey = "calendar.showsMonthGrid"
private let appleSource: CalendarSource
private let calendarrSource: CalendarSource
/// Der Weg über den Server. Wird benutzt, sobald Zugangsdaten hinterlegt
/// sind dann hängt die Aktualität nicht mehr daran, ob jemand die
/// Calendarr-App geöffnet hat.
private let calendarrLive: CalendarrLiveSource
private let defaults: UserDefaults
private var calendar: Calendar
private var refreshTask: Task<Void, Never>?
/// `nil` = nicht auf Calendarr-Änderungen angemeldet.
private var calendarrObserver: DarwinObserver?
/// Das Sicherheitsnetz unter der Benachrichtigung.
private var calendarrTimer: Timer?
public init(appleSource: CalendarSource = EventKitSource(),
calendarrSource: CalendarSource = CalendarrSource(),
calendarrLive: CalendarrLiveSource = CalendarrLiveSource(),
defaults: UserDefaults = .standard,
calendar: Calendar = .autoupdatingCurrent) {
self.appleSource = appleSource
self.calendarrSource = calendarrSource
self.calendarrLive = calendarrLive
self.defaults = defaults
self.calendar = calendar
self.kind = defaults.string(forKey: Self.kindKey)
.flatMap(CalendarSourceKind.init(rawValue:)) ?? .appleCalendar
self.showsMonthGrid = defaults.bool(forKey: Self.gridKey)
self.hiddenCalendarIDs = Set(defaults.stringArray(forKey: Self.hiddenKey) ?? [])
observeCalendarrIfNeeded()
}
/// Die gerade befragten Quellen.
private var activeSources: [CalendarSource] {
switch kind {
case .appleCalendar: [appleSource]
case .calendarr: [calendarrChoice]
case .both: [appleSource, calendarrChoice]
}
}
/// Server oder Schnappschuss.
///
/// Sind Zugangsdaten hinterlegt, gilt der Server er ist die genauere
/// Auskunft. Der Schnappschuss bleibt das Netz darunter: er braucht kein
/// Netzwerk und hält, was Calendarr zuletzt geschrieben hat.
private var calendarrChoice: CalendarSource {
calendarrLive.isAuthorized ? calendarrLive : calendarrSource
}
/// Ob gerade direkt vom Server gelesen wird.
public var usesCalendarrServer: Bool { calendarrLive.isAuthorized }
/// Ob gelesen werden darf. Fragt nicht nach nur zum Anzeigen.
public var hasAccess: Bool { source.isAuthorized }
public var authorization: CalendarAuthorization { source.authorization }
///
/// **Eine** berechtigte Quelle genügt: bei beide" darf ein gesperrter
/// Apple-Kalender nicht dazu führen, dass auch Calendarr stumm bleibt.
public var hasAccess: Bool { activeSources.contains(where: \.isAuthorized) }
/// Der Berechtigungszustand, der die Oberfläche interessiert also der des
/// Apple-Kalenders, denn nur der kann überhaupt einen Dialog auslösen.
public var authorization: CalendarAuthorization {
kind == .calendarr ? .notRequired : appleSource.authorization
}
// MARK: - Calendarr-Benachrichtigung
/// Calendarr meldet neue Schnappschüsse per Darwin-Nachricht deshalb muss
/// Onyx die Datei nicht abfragen, sondern erfährt Änderungen.
///
/// Nur angemeldet, solange Calendarr überhaupt gefragt wird. Wer nur den
/// Apple-Kalender nutzt, soll von Calendarr auch nicht geweckt werden.
private func observeCalendarrIfNeeded() {
let wanted = kind != .appleCalendar
guard wanted != (calendarrObserver != nil) else { return }
calendarrObserver = wanted
? DarwinObserver(name: CalendarrSource.changeNotification) { @Sendable [weak self] in
Task { @MainActor in self?.refresh() }
}
: nil
// Dazu ein Blick alle zwei Minuten.
//
// Die Benachrichtigung ist der schnelle Weg, aber sie ist flüchtig:
// schreibt Calendarr, während Onyx nicht läuft oder gerade startet,
// kommt sie nie an und dann steht der Schnappschuss so lange still,
// bis Calendarr das nächste Mal schreibt. Das Nachsehen kostet das
// Einlesen einer Datei von wenigen Kilobyte; das ist billiger als eine
// Terminliste, die aus unerfindlichen Gründen von gestern ist.
// Am Server seltener als an der Datei: ein Dateizugriff kostet nichts,
// eine Anfrage über das Netz schon.
calendarrTimer?.invalidate()
calendarrTimer = wanted
? Timer.scheduledTimer(withTimeInterval: usesCalendarrServer ? 300 : 120,
repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.refresh() }
}
: nil
}
/// Fragt die Berechtigung an und lädt bei Erfolg gleich.
///
@@ -36,9 +157,11 @@ public final class CalendarModel {
@discardableResult
public func requestAccessNow() async -> Bool {
log.notice("Kalenderzugriff: Status vor der Anfrage = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
let granted = await source.requestAccess()
let granted = await appleSource.requestAccess()
log.notice("Kalenderzugriff: erteilt = \(granted, privacy: .public), Status danach = \(EKEventStore.authorizationStatus(for: .event).rawValue, privacy: .public)")
if granted { refresh() } else { state = .denied }
// Auch bei Ablehnung neu laden statt hart auf `denied` zu setzen: läuft
// Calendarr mit, hat Onyx weiterhin Termine zu zeigen.
refresh()
return granted
}
@@ -49,6 +172,15 @@ public final class CalendarModel {
/// Termine für den sichtbaren Monat plus Puffer nach beiden Seiten, damit
/// die Randtage der Nachbarmonate im Raster nicht fälschlich leer wirken.
/// Nach dem Verbinden oder Trennen: Quelle und Takt neu bestimmen.
public func calendarrAccountChanged() {
calendarrObserver = nil
calendarrTimer?.invalidate()
calendarrTimer = nil
observeCalendarrIfNeeded()
refresh()
}
public func refresh(around date: Date = Date()) {
refreshTask?.cancel()
refreshTask = Task { [weak self] in
@@ -56,20 +188,32 @@ public final class CalendarModel {
isLoading = true
defer { isLoading = false }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
// passiert die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
guard source.isAuthorized else {
state = .denied
return
}
let start = calendar.date(byAdding: .day, value: -14,
to: calendar.startOfDay(for: date))!
let end = calendar.date(byAdding: .day, value: 60, to: start)!
let interval = DateInterval(start: start, end: end)
var states: [CalendarSourceState] = []
for source in activeSources {
guard !Task.isCancelled else { return }
// Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer
// App ohne Fenster heraus wird von TCC verworfen, ohne dass etwas
// passiert die Anfrage muss aus dem Einstellungsfenster kommen.
// Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht
// eine leere Terminliste, die "nichts mehr heute" behaupten würde.
states.append(source.isAuthorized ? await source.load(interval) : .denied)
}
guard !Task.isCancelled else { return }
state = await source.load(DateInterval(start: start, end: end))
state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs)
// Die Liste der wählbaren Kalender nebenbei mitführen sie ändert
// sich, wenn jemand in Kalender einen anlegt oder die Quelle
// wechselt.
var found: [OnyxCalendar] = []
for source in activeSources where source.isAuthorized {
found.append(contentsOf: await source.calendars())
}
availableCalendars = found
}
}
@@ -94,7 +238,6 @@ public struct CalendarWidget: OnyxWidget {
public let id = "calendar"
public var displayName: String { String(localized: "widget.calendar.name", bundle: .module) }
public let symbolName = "calendar"
public let supportedSizes: [WidgetSize] = [.medium, .large]
private let model: CalendarModel
@@ -102,21 +245,23 @@ public struct CalendarWidget: OnyxWidget {
self.model = model
}
public func makeView(size: WidgetSize) -> AnyView {
AnyView(CalendarWidgetView(model: model, size: size))
public func makeView() -> AnyView {
AnyView(CalendarWidgetView(model: model))
}
}
private struct CalendarWidgetView: View {
let model: CalendarModel
let size: WidgetSize
var body: some View {
Group {
switch model.state {
case .ready(let window):
if size == .large {
LargeCalendar(window: window, upcoming: model.upcoming)
// Monatsraster oder Terminliste das ist keine Frage der
// Größe, sondern was man sehen will. Beides zugleich passt in
// eine Kachel nicht, ohne dass eines davon unlesbar wird.
if model.showsMonthGrid {
LargeCalendar(window: window, upcoming: [])
} else {
AgendaList(events: model.upcoming, coverage: window.coverage)
}
@@ -266,9 +411,19 @@ private struct AgendaList: View {
.foregroundStyle(Onyx.Color.textTertiary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading, spacing: 5) {
ForEach(events) { event in
EventRow(event: event)
// Nach Tagen getrennt: 8:00" allein sagt nicht, an welchem.
let days = AgendaGrouping.group(events)
VStack(alignment: .leading, spacing: 4) {
ForEach(Array(days.enumerated()), id: \.element.id) { index, day in
Text(AgendaGrouping.label(for: day.date))
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(Onyx.Color.textSecondary)
.textCase(.uppercase)
// Luft nur zwischen den Tagen, nicht über dem ersten.
.padding(.top, index == 0 ? 0 : 3)
ForEach(day.events) { event in
EventRow(event: event)
}
}
Spacer(minLength: 0)
}

View File

@@ -0,0 +1,481 @@
import Foundation
import Security
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
/// Liest die Termine **direkt vom Calendarr-Server**.
///
/// Der andere Weg der Schnappschuss im gemeinsamen Ordner hängt daran, dass
/// Calendarr läuft und schreibt. Wer die Mac-App wochenlang nicht öffnet, sieht
/// wochenalte Termine, und Onyx kann nichts dafür und nichts dagegen.
///
/// Hier holt Onyx selbst. Ein einziger Aufruf genügt: `/api/caldav/events` fasst
/// CalDAV-Konten, lokale Kalender, Geburtstage, iCal-Abos und Google bereits
/// zusammen nachgebaut wird nichts.
public final class CalendarrLiveSource: CalendarSource {
public let id = "calendarr.live"
/// Keine Systemberechtigung im Spiel: hier entscheidet allein, ob
/// Zugangsdaten hinterlegt sind.
public var authorization: CalendarAuthorization { .notRequired }
public var isAuthorized: Bool { store.account != nil }
private let store: CalendarrCredentials
private let session: URLSession
private let tokens: TokenCache
public init(store: CalendarrCredentials = CalendarrCredentials(),
session: URLSession = .shared) {
self.store = store
self.session = session
self.tokens = TokenCache()
}
public func requestAccess() async -> Bool { store.account != nil }
/// Die Kalender kommen aus den Terminen selbst.
///
/// Der Server hat keinen Endpunkt, der alle Quellen zusammen aufzählt die
/// gibt es je Art einzeln. Aus den Terminen abgeleitet stimmt die Liste
/// dagegen immer mit dem überein, was tatsächlich zu sehen ist. Der Preis:
/// ein Kalender ohne Termine im Fenster taucht nicht auf. Das ist der
/// bessere von zwei Fehlern man kann nichts ausblenden, was ohnehin
/// nichts zeigt.
public func calendars() async -> [OnyxCalendar] {
guard case .ready(let window) = await load(Self.defaultWindow()) else { return [] }
var seen: [String: OnyxCalendar] = [:]
for event in window.events where seen[event.calendarID] == nil {
seen[event.calendarID] = OnyxCalendar(id: event.calendarID,
title: event.calendarTitle,
colorHex: event.colorHex)
}
return seen.values.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
}
private static func defaultWindow() -> DateInterval {
let now = Date()
return DateInterval(start: now.addingTimeInterval(-14 * 86400),
end: now.addingTimeInterval(46 * 86400))
}
public func load(_ interval: DateInterval) async -> CalendarSourceState {
guard let account = store.account else { return .neverWritten }
let password = store.password()
await tokens.adopt(store.token())
do {
let token = try await tokens.token(for: account, password: password,
session: session)
let data: Data
do {
data = try await fetchEvents(interval, account: account, token: token)
} catch CalendarrLiveError.unauthorized {
// Abgelaufene Token sind der Normalfall, nicht die Ausnahme:
// sie halten sieben Tage. Einmal neu anmelden und noch einmal
// versuchen scheitert auch das, stimmt etwas anderes nicht.
await tokens.discard()
let fresh = try await tokens.token(for: account, password: password,
session: session)
data = try await fetchEvents(interval, account: account, token: fresh)
}
let events = try CalendarrLiveDecoder.events(from: data)
// Die Abdeckung ist genau das erfragte Fenster: mehr wurde nicht
// gefragt, weniger hat der Server nicht geliefert.
return .ready(EventWindow(events: events, coverage: interval, asOf: Date()))
} catch CalendarrLiveError.unauthorized {
return .loggedOut(asOf: nil)
} catch {
log.error("Calendarr-Server: \(error.localizedDescription, privacy: .public)")
return .unreadable(reason: error.localizedDescription)
}
}
private func fetchEvents(_ interval: DateInterval, account: CalendarrAccount,
token: String) async throws -> Data {
var request = URLRequest(url: try CalendarrLiveDecoder.eventsURL(server: account.server,
interval: interval))
request.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
request.timeoutInterval = 15
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
if http.statusCode == 401 || http.statusCode == 403 { throw CalendarrLiveError.unauthorized }
guard http.statusCode == 200 else { throw CalendarrLiveError.status(http.statusCode) }
return data
}
/// Prüft die Zugangsdaten, ohne etwas zu speichern für den Knopf in den
/// Einstellungen. Ein Feld, das man ausfüllt und bei dem man erst am
/// nächsten Tag merkt, dass ein Tippfehler drin war, ist keins.
public static func check(server: URL, username: String, password: String,
totpCode: String?,
session: URLSession = .shared) async -> Result<String, Error> {
do {
let token = try await TokenCache.fetchToken(server: server, username: username,
password: password, totpCode: totpCode,
session: session)
return .success(token)
} catch {
return .failure(error)
}
}
}
// MARK: - Token
/// Hält das Token, solange es gilt.
///
/// Ein Actor, weil mehrere Widgets gleichzeitig laden können und sich sonst
/// zwei Anmeldungen überholen.
private actor TokenCache {
private var token: String?
private var expiry: Date?
func discard() { token = nil; expiry = nil }
func adopt(_ stored: String?) {
guard token == nil, let stored else { return }
token = stored
expiry = CalendarrLiveDecoder.expiry(ofToken: stored)
}
func token(for account: CalendarrAccount, password: String?,
session: URLSession) async throws -> String {
// Fünf Minuten Sicherheitsabstand: ein Token, das während des Abrufs
// abläuft, kostet einen zweiten Durchgang.
if let token, let expiry, expiry.timeIntervalSinceNow > 300 { return token }
// Ohne Passwort oder mit zweitem Faktor kann sich niemand von
// selbst neu anmelden. Dann muss der Mensch ran, und das sagt der
// Fehler auch.
guard let password, !password.isEmpty else { throw CalendarrLiveError.twoFactorRequired }
let fresh = try await Self.fetchToken(server: account.server,
username: account.username,
password: password, totpCode: nil,
session: session)
token = fresh
expiry = CalendarrLiveDecoder.expiry(ofToken: fresh)
?? Date().addingTimeInterval(6 * 24 * 3600)
return fresh
}
/// Meldet an und gibt ein Langzeit-Token zurück.
///
/// Über `/api/auth/login` statt `/api/auth/token`: das Formular-Endstück
/// kennt keinen zweiten Faktor und antwortet Konten mit aktivem 2FA
/// **immer** mit falsches Passwort" auch wenn es stimmt. Der JSON-Weg
/// nimmt den Code entgegen und kann obendrein `remember_me`.
///
/// Und das ist hier keine Bequemlichkeit, sondern die Bedingung: mit 2FA
/// kann Onyx sich nicht selbst neu anmelden, wenn ein Token abläuft den
/// Code hat nur der Mensch. Ein gewöhnliches Token hielte sieben Tage,
/// `remember_me` hält hundertachtzig.
static func fetchToken(server: URL, username: String, password: String,
totpCode: String?, session: URLSession) async throws -> String {
var request = URLRequest(url: server.appendingPathComponent("api/auth/login"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
var body: [String: Any] = ["username": username, "password": password,
"remember_me": true]
if let totpCode, !totpCode.isEmpty { body["totp_code"] = totpCode }
request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.timeoutInterval = 15
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw CalendarrLiveError.badResponse }
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
if let failure = CalendarrLiveDecoder.loginFailure(status: http.statusCode,
detail: object?["detail"] as? String) {
throw failure
}
guard let token = object?["access_token"] as? String, !token.isEmpty
else { throw CalendarrLiveError.badResponse }
return token
}
}
public enum CalendarrLiveError: LocalizedError, Equatable {
case unauthorized
/// Das Konto verlangt einen zweiten Faktor ohne Code geht hier nichts.
case twoFactorRequired
case twoFactorWrong
case badResponse
case status(Int)
case badServer
public var errorDescription: String? {
switch self {
case .unauthorized: String(localized: "calendarr.live.error.credentials", bundle: .module)
case .twoFactorRequired: String(localized: "calendarr.live.error.twoFactor", bundle: .module)
case .twoFactorWrong: String(localized: "calendarr.live.error.twoFactorWrong", bundle: .module)
case .badResponse: String(localized: "calendarr.live.error.response", bundle: .module)
case .status(let code): String(localized: "calendarr.live.error.status \(code)", bundle: .module)
case .badServer: String(localized: "calendarr.live.error.server", bundle: .module)
}
}
}
// MARK: - Auswertung
/// Alles, was ohne Netz prüfbar ist und deshalb auch ohne Netz geprüft wird.
public enum CalendarrLiveDecoder {
public static func eventsURL(server: URL, interval: DateInterval) throws -> URL {
guard var components = URLComponents(url: server.appendingPathComponent("api/caldav/events"),
resolvingAgainstBaseURL: false)
else { throw CalendarrLiveError.badServer }
let formatter = ISO8601DateFormatter()
components.queryItems = [
URLQueryItem(name: "start", value: formatter.string(from: interval.start)),
URLQueryItem(name: "end", value: formatter.string(from: interval.end)),
]
guard let url = components.url else { throw CalendarrLiveError.badServer }
return url
}
/// Der Anmeldekörper. Prozentkodiert von Hand, weil `+` in einem Passwort
/// sonst als Leerzeichen ankommt ein Fehler, den man erst bei genau
/// diesem einen Passwort bemerkt.
public static func formBody(username: String, password: String) -> Data {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
func escape(_ text: String) -> String {
text.addingPercentEncoding(withAllowedCharacters: allowed) ?? text
}
return Data("username=\(escape(username))&password=\(escape(password))".utf8)
}
public static func events(from data: Data) throws -> [OnyxEvent] {
guard let object = try? JSONSerialization.jsonObject(with: data),
let list = (object as? [String: Any])?["events"] as? [[String: Any]]
?? object as? [[String: Any]]
else { throw CalendarrLiveError.badResponse }
return list.compactMap(event(from:))
.sorted { $0.start < $1.start }
}
static func event(from item: [String: Any]) -> OnyxEvent? {
guard let title = item["title"] as? String,
let startText = item["start"] as? String,
let start = date(from: startText)
else { return nil }
let isAllDay = item["allDay"] as? Bool ?? (item["all_day"] as? Bool ?? false)
let end = (item["end"] as? String).flatMap(date(from:))
// Ohne Ende ist ein Termin einen Tag lang, wenn er ganztägig ist,
// sonst eine Stunde. Beides ist geraten aber ein Termin ohne
// Länge wäre unsichtbar.
?? start.addingTimeInterval(isAllDay ? 86400 : 3600)
let identifier = item["id"] as? String
?? item["uid"] as? String
?? "\(startText)#\(title)"
return OnyxEvent(
id: identifier,
title: title,
start: start,
end: end,
isAllDay: isAllDay,
// Die Farbe des Termins schlägt die des Kalenders so macht es
// Calendarr selbst auch.
colorHex: item["color"] as? String ?? item["calendarColor"] as? String,
location: (item["location"] as? String).flatMap { $0.isEmpty ? nil : $0 },
calendarID: item["calendar_id"] as? String ?? "calendarr",
calendarTitle: item["calendar_name"] as? String ?? "Calendarr")
}
/// Ganztägige Termine kommen als `2026-08-14`, alle anderen mit Uhrzeit.
public static func date(from text: String) -> Date? {
if text.count == 10 {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .current
let parts = text.split(separator: "-").compactMap { Int($0) }
guard parts.count == 3 else { return nil }
return calendar.date(from: DateComponents(year: parts[0], month: parts[1],
day: parts[2]))
}
let withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = withFraction.date(from: text) { return date }
let plain = ISO8601DateFormatter()
if let date = plain.date(from: text) { return date }
// Ohne Zeitzone im Text: dann gilt die des Rechners. Calendarr liefert
// lokale Zeiten für Termine ohne feste Zone.
let local = DateFormatter()
local.locale = Locale(identifier: "en_US_POSIX")
local.timeZone = .current
for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd HH:mm:ss"] {
local.dateFormat = format
if let date = local.date(from: text) { return date }
}
return nil
}
/// Was eine abgelehnte Anmeldung bedeutet.
///
/// Der Server unterscheidet die Fälle nur im Begleittext, und der
/// Unterschied ist der zwischen Passwort neu tippen" und App aufmachen".
/// Ohne diese Unterscheidung stand bei einem Konto mit zweitem Faktor
/// Benutzername oder Passwort stimmen nicht" obwohl beides stimmte.
public static func loginFailure(status: Int, detail: String?) -> CalendarrLiveError? {
switch status {
case 200: return nil
case 401:
let detail = detail ?? ""
if detail == "2fa_required" { return .twoFactorRequired }
if detail.localizedCaseInsensitiveContains("2fa") { return .twoFactorWrong }
return .unauthorized
default: return .status(status)
}
}
/// Wann das Token abläuft steht in ihm selbst.
///
/// Ein JWT trägt seine Gültigkeit im Mittelteil, base64-kodiert. Die
/// Alternative wäre, sieben Tage anzunehmen; das steht heute so im Server
/// und morgen vielleicht nicht mehr.
public static func expiry(ofToken token: String) -> Date? {
let parts = token.split(separator: ".")
guard parts.count == 3 else { return nil }
var payload = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
// Base64 ohne Auffüllzeichen, wie in JWT üblich.
while payload.count % 4 != 0 { payload += "=" }
guard let data = Data(base64Encoded: payload),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let seconds = object["exp"] as? Double
else { return nil }
return Date(timeIntervalSince1970: seconds)
}
/// Nimmt entgegen, was Menschen eintippen: calendarr.example.com",
/// https://calendarr.example.com/" oder mit Pfad dahinter.
public static func server(from text: String) -> URL? {
var trimmed = text.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
if !trimmed.contains("://") { trimmed = "https://" + trimmed }
while trimmed.hasSuffix("/") { trimmed.removeLast() }
guard let url = URL(string: trimmed), url.host != nil else { return nil }
return url
}
}
// MARK: - Zugangsdaten
/// Adresse und Benutzername in den Einstellungen, das Passwort im Schlüsselbund.
///
/// Getrennt, weil es zwei verschiedene Dinge sind: das eine will man sehen und
/// nachlesen können, das andere darf nirgends stehen, wo man es versehentlich
/// mitliest und `defaults read` ist genau so eine Stelle.
public struct CalendarrCredentials: @unchecked Sendable {
/// `UserDefaults` gilt Swift nicht als übertragbar, ist aber seit jeher
/// threadsicher. Die Ausnahme steht hier und nicht als Sperre um jeden
/// Zugriff.
private let defaults: UserDefaults
private static let service = "com.scarriffleservices.onyx.calendarr"
private enum Keys {
static let server = "calendarr.live.server"
static let username = "calendarr.live.username"
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
public var account: CalendarrAccount? {
guard let server = defaults.string(forKey: Keys.server).flatMap(URL.init(string:)),
let username = defaults.string(forKey: Keys.username), !username.isEmpty
else { return nil }
return CalendarrAccount(server: server, username: username)
}
/// Das Token gehört mit in den Schlüsselbund, nicht nur ins Gedächtnis des
/// laufenden Programms.
///
/// Bei einem Konto mit zweitem Faktor ist es das einzige, womit Onyx
/// überhaupt noch hineinkommt: neu anmelden ginge nur mit einem Code, und
/// den hat nur der Mensch. Ginge es beim Beenden verloren, müsste man sich
/// bei jedem Start neu anmelden.
public func save(server: URL, username: String, password: String, token: String?) {
defaults.set(server.absoluteString, forKey: Keys.server)
defaults.set(username, forKey: Keys.username)
Self.setSecret(password, for: username, kind: .password)
Self.setSecret(token, for: username, kind: .token)
}
public func clear() {
if let username = defaults.string(forKey: Keys.username) {
Self.setSecret(nil, for: username, kind: .password)
Self.setSecret(nil, for: username, kind: .token)
}
defaults.removeObject(forKey: Keys.server)
defaults.removeObject(forKey: Keys.username)
}
public func password() -> String? { secret(.password) }
public func token() -> String? { secret(.token) }
private func secret(_ kind: Secret) -> String? {
guard let username = defaults.string(forKey: Keys.username) else { return nil }
var query = Self.query(for: username, kind: kind)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data
else { return nil }
return String(data: data, encoding: .utf8)
}
private enum Secret: String {
case password
case token
}
private static func query(for username: String, kind: Secret) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service + "." + kind.rawValue,
kSecAttrAccount as String: username]
}
private static func setSecret(_ value: String?, for username: String, kind: Secret) {
let query = query(for: username, kind: kind)
SecItemDelete(query as CFDictionary)
guard let value, !value.isEmpty else { return }
var item = query
item[kSecValueData as String] = Data(value.utf8)
// Nur auf diesem Gerät und nur nach dem ersten Entsperren: ein
// Kalenderwidget braucht kein Passwort, solange niemand angemeldet ist.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(item as CFDictionary, nil)
if status != errSecSuccess {
log.error("Passwort ließ sich nicht sichern (\(status, privacy: .public))")
}
}
}
public struct CalendarrAccount: Equatable, Sendable {
public let server: URL
public let username: String
public init(server: URL, username: String) {
self.server = server
self.username = username
}
}

View File

@@ -0,0 +1,222 @@
import Foundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Calendarr")
/// Liest den Schnappschuss, den Calendarr in den Gruppencontainer legt.
///
/// Streng lesend. Onyx meldet sich nie selbst an, holt nichts aus dem Netz und
/// schreibt nie in den Container zurück der Schnappschuss gehört Calendarr,
/// und zwei Schreiber auf einer Datei wären ein Fehler, den man erst bemerkt,
/// wenn Termine verschwinden.
public enum CalendarrSnapshotReader {
/// Wie weit nach vorne der Schnappschuss reicht.
///
/// Steht nicht in der Datei, sondern ist zugesagtes Verhalten des
/// Schreibers. Läge der Wert zu niedrig, würde Onyx zu viel ausgrauen
/// unschön, aber ehrlich. Läge er zu hoch, würde es leere Tage als frei"
/// behaupten, für die nie jemand Daten geschrieben hat. Von beiden Fehlern
/// ist nur der erste vertretbar, deshalb wird hier nicht großzügig gerundet.
public static let coverageDays = 49
/// Höchste Formatfassung, deren Bedeutung Onyx kennt.
public static let maxSupportedVersion = 1
/// Der Zeitraum, über den der Schnappschuss etwas aussagt.
///
/// Beginnt am Tag des Schreibens, nicht früher: Calendarr schreibt nach
/// vorne. Ein vergangener Tag ohne Termine kann war nichts" heißen oder
/// wurde nie geschrieben" das ist von außen nicht unterscheidbar, also
/// wird es auch nicht behauptet.
public static func coverage(writtenAt: Date, calendar: Calendar) -> DateInterval {
let start = calendar.startOfDay(for: writtenAt)
let end = calendar.date(byAdding: .day, value: coverageDays, to: start) ?? start
return DateInterval(start: start, end: end)
}
/// Wertet Schnappschuss und Kalenderliste aus. Rein ohne Dateisystem,
/// damit jeder Zustand ohne installierte Calendarr-App prüfbar bleibt.
public static func state(fromSnapshot snapshot: Data?,
calendars: Data?,
calendar: Calendar = .autoupdatingCurrent) -> CalendarSourceState {
// Keine Datei heißt nicht kaputt", sondern Calendarr war noch nie
// offen". Dafür gibt es einen eigenen, hilfreicheren Hinweis.
guard let snapshot else { return .neverWritten }
let raw: RawSnapshot
do {
raw = try JSONDecoder().decode(RawSnapshot.self, from: snapshot)
} catch {
log.error("Schnappschuss unlesbar: \(error.localizedDescription, privacy: .public)")
return .unreadable(reason: error.localizedDescription)
}
let version = raw.version ?? 1
guard version <= maxSupportedVersion else {
return .incompatible(found: version, maxSupported: maxSupportedVersion)
}
let parser = DateParser()
let writtenAt = raw.writtenAtDate(parser)
// Abgemeldet: die vorhandenen Termine sind alt und sehen trotzdem
// aktuell aus. Genau deshalb werden sie verworfen statt angezeigt.
if raw.loggedOut == true { return .loggedOut(asOf: writtenAt) }
let titles = calendarTitles(from: calendars)
let events = (raw.events ?? [])
.compactMap { $0.asOnyxEvent(titles: titles, parser: parser) }
.sorted { $0.start < $1.start }
return .ready(EventWindow(
events: events,
coverage: writtenAt.map { coverage(writtenAt: $0, calendar: calendar) },
asOf: writtenAt))
}
/// Die Kalenderliste als eigene Datei sie trägt die Namen, die im
/// Schnappschuss selbst nur als Kennung stehen.
public static func calendars(from data: Data?) -> [OnyxCalendar] {
guard let data,
let raw = try? JSONDecoder().decode([RawCalendar].self, from: data)
else { return [] }
return raw.map { OnyxCalendar(id: $0.id, title: $0.name, colorHex: $0.colorHex) }
}
private static func calendarTitles(from data: Data?) -> [String: String] {
Dictionary(calendars(from: data).map { ($0.id, $0.title) },
uniquingKeysWith: { first, _ in first })
}
// MARK: - Rohformat
private struct RawSnapshot: Decodable {
let version: Int?
let loggedOut: Bool?
let writtenAt: String?
let events: [RawEvent]?
func writtenAtDate(_ parser: DateParser) -> Date? {
writtenAt.flatMap(parser.date(from:))
}
}
private struct RawCalendar: Decodable {
let id: String
let name: String
let colorHex: String?
}
private struct RawEvent: Decodable {
let id: String
let title: String
let start: String
let end: String
let isAllDay: Bool?
let location: String?
let calendarKey: String?
let colorHex: String?
func asOnyxEvent(titles: [String: String], parser: DateParser) -> OnyxEvent? {
guard let start = parser.date(from: start),
let end = parser.date(from: end) else { return nil }
let key = calendarKey ?? ""
return OnyxEvent(
id: id, title: title, start: start, end: end,
isAllDay: isAllDay ?? false,
colorHex: colorHex,
// Ein leerer Ort ist kein Ort sonst steht unter jedem Termin
// eine leere Zeile mit Stecknadel.
location: (location?.isEmpty ?? true) ? nil : location,
calendarID: key,
calendarTitle: titles[key] ?? key)
}
}
/// Calendarr schreibt ISO 8601 in UTC, mal mit und mal ohne Sekundenbruch.
///
/// Bewusst kein gemeinsamer statischer Formatierer: `ISO8601DateFormatter`
/// ist nicht `Sendable`, und ein `nonisolated(unsafe)` wäre hier eine
/// Behauptung, die niemand prüft. Einmal je Durchlauf gebaut und
/// durchgereicht kostet nichts und ist nachweislich richtig.
struct DateParser {
private let plain: ISO8601DateFormatter
private let withFraction: ISO8601DateFormatter
init() {
plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
withFraction = ISO8601DateFormatter()
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
}
func date(from string: String) -> Date? {
withFraction.date(from: string) ?? plain.date(from: string)
}
}
}
// MARK: - Quelle
/// Calendarr als Terminquelle.
public final class CalendarrSource: CalendarSource, @unchecked Sendable {
public let id = "calendarr"
/// Der Gruppencontainer, den Calendarr und Onyx teilen.
///
/// Onyx läuft ohne Sandbox der Container ist trotzdem erreichbar, weil
/// die Gruppe im Entitlement steht und beide Apps im selben Team signiert
/// sind. Siehe docs/spikes/D-appgroup.md.
public static let appGroup = "PP34X97WS3.group.com.scarriffleservices.calendarr"
/// Die Nachricht, mit der Calendarr auf einen neuen Schnappschuss hinweist.
public static let changeNotification = "com.scarriffleservices.calendarr.snapshot-changed"
private let containerURL: URL?
private let calendar: Calendar
public init(calendar: Calendar = .autoupdatingCurrent) {
self.containerURL = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: Self.appGroup)
self.calendar = calendar
if containerURL == nil {
log.error("Gruppencontainer \(Self.appGroup, privacy: .public) nicht erreichbar")
}
}
/// Calendarr braucht keinen Systemdialog der Container ist entweder da
/// oder die App ist nicht installiert.
public var authorization: CalendarAuthorization { .notRequired }
public var isAuthorized: Bool { true }
public func requestAccess() async -> Bool { true }
public func calendars() async -> [OnyxCalendar] {
CalendarrSnapshotReader.calendars(from: read("widget-calendars.json"))
}
/// Das angefragte Intervall wird zum Filtern benutzt, nicht zum Abfragen:
/// der Schnappschuss liegt fertig da, seine Reichweite bestimmt Calendarr.
public func load(_ interval: DateInterval) async -> CalendarSourceState {
guard containerURL != nil else {
return .unreadable(reason: "Gruppencontainer nicht erreichbar")
}
let state = CalendarrSnapshotReader.state(fromSnapshot: read("widget-cache.json"),
calendars: read("widget-calendars.json"),
calendar: calendar)
guard case .ready(let window) = state else { return state }
// Die Abdeckung bleibt die des Schnappschusses und wird nicht auf das
// angefragte Fenster eingeengt sonst würde ein schmaler Ausschnitt
// wie eine schmale Datenlage aussehen.
return .ready(EventWindow(
events: window.events.filter { $0.end >= interval.start && $0.start < interval.end },
coverage: window.coverage,
asOf: window.asOf))
}
private func read(_ name: String) -> Data? {
guard let url = containerURL?.appendingPathComponent(name) else { return nil }
return try? Data(contentsOf: url)
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
/// Hört auf eine Darwin-Nachricht und meldet sich beim Abräumen wieder ab.
///
/// Warum ein eigenes Objekt und nicht einfach ein Feld im Modell: das Modell
/// liegt auf dem MainActor, sein `deinit` aber nicht es dürfte den Beobachter
/// dort gar nicht mehr anfassen. Ein Objekt, dessen einzige Aufgabe die
/// Anmeldung ist, bindet das Abmelden an genau die richtige Lebensdauer.
/// Auf `nil` setzen genügt zum Abmelden.
///
/// `@unchecked Sendable` ist hier belegbar und keine Behauptung ins Blaue:
/// `handler` wird nach `init` nie mehr geschrieben, und der Rückruf liest ihn
/// nur. Andere veränderliche Zustände gibt es nicht.
final class DarwinObserver: @unchecked Sendable {
private let name: CFString
private let handler: @Sendable () -> Void
/// - Parameter handler: läuft auf dem Thread, der die Nachricht zustellt
/// nicht zwingend dem Hauptthread. Wer UI anfasst, hüpft selbst hinüber.
init(name: String, handler: @escaping @Sendable () -> Void) {
self.name = name as CFString
self.handler = handler
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
{ _, observer, _, _, _ in
guard let observer else { return }
// Unretained: der Beobachter wird beim Abmelden entfernt, also
// kann hier kein toter Zeiger ankommen und ein starker
// Verweis hielte das Objekt ewig am Leben.
Unmanaged<DarwinObserver>.fromOpaque(observer)
.takeUnretainedValue().handler()
},
self.name, nil, .deliverImmediately)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
Unmanaged.passUnretained(self).toOpaque(),
CFNotificationName(name), nil)
}
}

View File

@@ -1,159 +1,532 @@
{
"sourceLanguage" : "en",
"strings" : {
"" : {
},
"%lld" : {
"comment" : "A day of the month. The argument is the day of the month.",
"isCommentAutoGenerated" : true
},
"widget.calendar.allDay" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ganztägig"
"sourceLanguage": "en",
"strings": {
"": {},
"%lld": {
"comment": "A day of the month. The argument is the day of the month.",
"isCommentAutoGenerated": true,
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%lld"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "All day"
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld"
}
}
}
},
"widget.calendar.day.unknown" : {
"comment" : "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Für diesen Tag liegen keine Daten vor"
"calendar.source.apple": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Apple Kalender"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No data available for this day"
"en": {
"stringUnit": {
"state": "translated",
"value": "Apple Calendar"
}
}
}
},
"widget.calendar.denied" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
"calendar.source.both": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Beide"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No calendar access.\nAllow it in System Settings."
"en": {
"stringUnit": {
"state": "translated",
"value": "Both"
}
}
}
},
"widget.calendar.incompatible" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
"calendar.source.calendarr": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx is too old for this\nCalendarr version. Please update."
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr"
}
}
}
},
"widget.calendar.loggedOut" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In Calendarr anmelden"
"calendar.source.readOnly": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx liest nur. Es meldet sich nie selbst an und schreibt nie nach Calendarr."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sign in to Calendarr"
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx only reads. It never signs in and never writes to Calendarr."
}
}
}
},
"widget.calendar.name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kalender"
"calendar.source.status.coverage": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bekannt bis %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Calendar"
"en": {
"stringUnit": {
"state": "translated",
"value": "Known through %@"
}
}
}
},
"widget.calendar.neverWritten" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
"calendar.source.status.coverage %@": {
"comment": "A footnote in the Calendarr settings, indicating the end of the coverage window.",
"isCommentAutoGenerated": true,
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Bekannt bis %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Calendarr once\nso events become available."
"en": {
"stringUnit": {
"state": "translated",
"value": "Known through %@"
}
}
}
},
"widget.calendar.noUpcoming" : {
"comment" : "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nichts mehr für heute"
"calendar.source.status.incompatible": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr schreibt ein neueres Format. Bitte Onyx aktualisieren."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nothing left today"
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendarr writes a newer format. Please update Onyx."
}
}
}
},
"widget.calendar.unreadable" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Termine nicht lesbar.\nKonfigurationsfehler."
"calendar.source.status.loggedOut": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In Calendarr nicht angemeldet. Alte Termine werden nicht gezeigt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Events unreadable.\nConfiguration error."
"en": {
"stringUnit": {
"state": "translated",
"value": "Not signed in to Calendarr. Old events are not shown."
}
}
}
},
"calendar.source.status.neverWritten": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr einmal öffnen, damit ein Schnappschuss geschrieben wird."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Calendarr once so it can write a snapshot."
}
}
}
},
"calendar.source.status.ready": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ Termine, Stand %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "%@ events, as of %@"
}
}
}
},
"calendar.source.status.ready %@ %@": {
"comment": "Die Anzahl der Termine in der The argument is the string “–”.",
"isCommentAutoGenerated": true,
"localizations": {
"en": {
"stringUnit": {
"state": "new",
"value": "calendar.source.status.ready %1$@ %2$@"
}
},
"de": {
"stringUnit": {
"state": "translated",
"value": "%@ Termine, Stand %@"
}
}
}
},
"calendar.source.status.unreadable": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schnappschuss unlesbar: %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Snapshot unreadable: %@"
}
}
}
},
"calendar.source.status.unreadable %@": {
"comment": "A warning message explaining that a Calendarr source is not readable. The argument is a human-readable description of the problem.",
"isCommentAutoGenerated": true,
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schnappschuss unlesbar: %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Snapshot unreadable: %@"
}
}
}
},
"calendar.source.title": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Terminquelle"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendar source"
}
}
}
},
"calendarr.live.error.credentials": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Benutzername oder Passwort stimmen nicht."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Wrong username or password."
}
}
}
},
"calendarr.live.error.response": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server hat geantwortet, aber nicht verständlich."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server replied, but not intelligibly."
}
}
}
},
"calendarr.live.error.server": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Diese Adresse ergibt keinen Server."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "That address doesnt resolve to a server."
}
}
}
},
"calendarr.live.error.status %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Server meldet Fehler %lld."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "The server reports error %lld."
}
}
}
},
"calendarr.live.error.twoFactor": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dieses Konto verlangt einen Zwei-Faktor-Code."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This account requires a two-factor code."
}
}
}
},
"calendarr.live.error.twoFactorWrong": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Zwei-Faktor-Code stimmt nicht — er gilt nur eine halbe Minute."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Wrong two-factor code — its only valid for half a minute."
}
}
}
},
"widget.calendar.allDay": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Ganztägig"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "All day"
}
}
}
},
"widget.calendar.day.unknown": {
"comment": "Erklärt einen ausgegrauten Tag: keine Information, nicht keine Termine",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Für diesen Tag liegen keine Daten vor"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No data available for this day"
}
}
}
},
"widget.calendar.denied": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Kalenderzugriff.\nIn den Systemeinstellungen erlauben."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No calendar access.\nAllow it in System Settings."
}
}
}
},
"widget.calendar.incompatible": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx ist zu alt für diese\nCalendarr-Version. Bitte aktualisieren."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx is too old for this\nCalendarr version. Please update."
}
}
}
},
"widget.calendar.loggedOut": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "In Calendarr anmelden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sign in to Calendarr"
}
}
}
},
"widget.calendar.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kalender"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Calendar"
}
}
}
},
"widget.calendar.neverWritten": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Calendarr einmal öffnen,\ndamit Termine bereitstehen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Open Calendarr once\nso events become available."
}
}
}
},
"widget.calendar.noUpcoming": {
"comment": "Nur zeigen, wenn der Zeitraum wirklich abgedeckt ist",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nichts mehr für heute"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing left today"
}
}
}
},
"widget.calendar.today": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Heute"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Today"
}
}
}
},
"widget.calendar.tomorrow": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Morgen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Tomorrow"
}
}
}
},
"widget.calendar.unreadable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Termine nicht lesbar.\nKonfigurationsfehler."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Events unreadable.\nConfiguration error."
}
}
}
}
},
"version" : "1.1"
}
"version": "1.1"
}

View File

@@ -64,7 +64,7 @@ public final class AdapterProcess {
Task { @MainActor in self?.consume(chunk) }
}
process.terminationHandler = { [weak self] _ in
process.terminationHandler = { @Sendable [weak self] _ in
Task { @MainActor in self?.handleTermination() }
}

View File

@@ -1,25 +1,55 @@
{
"sourceLanguage" : "en",
"strings" : {
"widget.media.name" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Medien" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Media" } }
"sourceLanguage": "en",
"strings": {
"widget.media.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Medien"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Media"
}
}
}
},
"widget.media.nothing" : {
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Nichts läuft gerade" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Nothing playing" } }
"widget.media.nothing": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nichts läuft gerade"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing playing"
}
}
}
},
"widget.media.viaAppleScript" : {
"comment" : "Hinweis, dass die schwächere Quelle greift",
"localizations" : {
"de" : { "stringUnit" : { "state" : "translated", "value" : "Über AppleScript — ohne Cover, nur Spotify und Musik" } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "Via AppleScript — no artwork, Spotify and Music only" } }
"widget.media.viaAppleScript": {
"comment": "Hinweis, dass die schwächere Quelle greift",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Über AppleScript — ohne Cover, nur Spotify und Musik"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Via AppleScript — no artwork, Spotify and Music only"
}
}
}
}
},
"version" : "1.0"
"version": "1.0"
}

View File

@@ -7,25 +7,23 @@ public struct MediaWidget: OnyxWidget {
public let id = "media"
public var displayName: String { String(localized: "widget.media.name", bundle: .module) }
public let symbolName = "play.circle"
public let supportedSizes: [WidgetSize] = [.medium, .wide]
private let model: MediaModel
public init(model: MediaModel) { self.model = model }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(MediaWidgetView(model: model, size: size))
public func makeView() -> AnyView {
AnyView(MediaWidgetView(model: model))
}
}
private struct MediaWidgetView: View {
let model: MediaModel
let size: WidgetSize
var body: some View {
Group {
if let state = model.nowPlaying, state.hasMetadata {
Playing(model: model, state: state, size: size)
Playing(model: model, state: state)
} else {
// Auch ohne Angaben bleibt das Widget bedienbar: die
// Medientasten erreichen jedes Programm. Ein Widget, das bei
@@ -56,48 +54,53 @@ private struct Idle: View {
private struct Playing: View {
let model: MediaModel
let state: NowPlaying
let size: WidgetSize
var body: some View {
HStack(spacing: 10) {
// Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben
// einem Cover blieben für den Titel noch gut hundert Punkte, und dann
// steht dort Bernhard H" statt eines Titels.
VStack(alignment: .leading, spacing: 7) {
Artwork(data: state.artwork)
.frame(width: artworkSize, height: artworkSize)
.frame(maxWidth: .infinity)
.frame(height: 88)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 1) {
Text(state.title)
.font(Onyx.Font.title)
.font(Onyx.Font.caption.weight(.medium))
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.lineLimit(2)
Text(state.artist)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
Spacer(minLength: 2)
Spacer(minLength: 0)
Scrubber(model: model, state: state)
Scrubber(model: model, state: state)
HStack {
TransportControls(model: model, size: 15)
Spacer(minLength: 0)
if state.origin == .appleScript {
// Sichtbar machen, dass gerade die schwächere Quelle
// greift: dann fehlen Cover und Browser-Wiedergaben,
// und das soll erklärbar sein statt rätselhaft.
Image(systemName: "applescript")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
.help(Text("widget.media.viaAppleScript", bundle: .module))
}
HStack(spacing: 0) {
Spacer(minLength: 0)
TransportControls(model: model, size: 15)
Spacer(minLength: 0)
}
.overlay(alignment: .trailing) {
if state.origin == .appleScript {
// Sichtbar machen, dass gerade die schwächere Quelle
// greift: dann fehlen Cover und Browser-Wiedergaben,
// und das soll erklärbar sein statt rätselhaft.
Image(systemName: "applescript")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
.help(Text("widget.media.viaAppleScript", bundle: .module))
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.activateSource() }
}
private var artworkSize: CGFloat { size == .wide ? 64 : 56 }
}
private struct Artwork: View {
@@ -106,6 +109,9 @@ private struct Artwork: View {
var body: some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
// Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum
// beschneidet man das Bild auf seine eigene, viel größere Größe und es
// ragt anschließend über die Kachel hinaus, mitten in den Nachbarn.
Group {
if let data, let image = NSImage(data: data) {
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
@@ -121,6 +127,7 @@ private struct Artwork: View {
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipShape(shape)
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
}

View File

@@ -0,0 +1,326 @@
import AppKit
import OnyxMenuBar
/// Der Akku, gezeichnet wie der von macOS.
///
/// Warum nicht als SF-Symbol: das kennt fünf Füllstufen und außer bei
/// hundert Prozent keine Blitz-Variante. Der Blitz musste deshalb **neben**
/// den Akku, und das sieht nach zwei Dingen aus statt nach einem. macOS
/// zeichnet ihn mitten hinein, und der Ladestand läuft stufenlos.
///
/// Selbst zu zeichnen ist hier die kleinere Lösung als die Symbolakrobatik:
/// ein Rahmen, ein Knubbel, eine Füllung und ein ausgestanzter Blitz.
public enum BatteryGlyph {
/// Maße wie in der Menüleiste von macOS.
public static let size = CGSize(width: 25, height: 12)
/// Damit zwei Prozent nicht wie null aussehen.
public static let minimumFill: CGFloat = 2
/// Ab hier wird die Farbe zum Signal dieselbe Schwelle wie bei macOS.
public static let lowThreshold = 0.2
public static func fillWidth(charge: Double, inner: CGFloat) -> CGFloat {
let clamped = min(max(charge, 0), 1)
guard clamped > 0 else { return 0 }
return min(max(inner * clamped, minimumFill), inner)
}
/// Was mitten im Akku steht.
public enum Indicator: CaseIterable, Sendable {
case none
/// Lädt.
case bolt
/// Am Netz, aber es fließt nichts meist, weil das Ladelimit erreicht
/// ist. Ohne eigenes Zeichen sieht das aus wie Akkubetrieb, und man
/// fragt sich, ob das Kabel wirklich steckt.
///
/// Als Pausenzeichen und nicht als Stecker: Laden angehalten" ist die
/// Aussage, und ein Stecker sagt nur Kabel steckt" das sieht man
/// ohnehin am Kabel.
case paused
public var symbolName: String? {
switch self {
case .none: nil
case .bolt: "bolt.fill"
case .paused: "pause.fill"
}
}
}
public static func indicator(isCharging: Bool, isPluggedIn: Bool) -> Indicator {
if isCharging { return .bolt }
return isPluggedIn ? .paused : .none
}
/// Wo der Blitz auf der Füllung liegt und wo im Leerraum.
///
/// Er wird über der Füllung **ausgestanzt** und daneben **ausgemalt**. Nur
/// auszustanzen reicht nicht: über dem leeren Teil ist das Transparenz auf
/// Transparenz, und bei wenig Ladung sitzt der Blitz fast ganz dort.
public static func boltSplit(charge: Double,
inner: CGRect) -> (filled: CGRect, empty: CGRect) {
let width = fillWidth(charge: charge, inner: inner.width)
let filled = CGRect(x: inner.minX, y: inner.minY,
width: width, height: inner.height)
let empty = CGRect(x: inner.minX + width, y: inner.minY,
width: inner.width - width, height: inner.height)
return (filled, empty)
}
/// Der Blitz in zwei Fassungen, hell und dunkel.
///
/// **Warum zwei Dateien statt einer eingefärbten:** eine helle Fläche mit
/// dunklem Rand ließ sich aus einem einzelnen Symbol nur nachbauen, indem
/// dasselbe Zeichen zweimal in verschiedener Größe übereinander gezeichnet
/// wurde und die Größenverhältnisse sahen nie stimmig aus. Zwei fertige
/// Fassungen ineinander zu blenden ist der geradere Weg: gezeichnet wird
/// genau das, was in der Datei steht.
public static let boltLight: NSImage? = load("bolt-light")
public static let boltDark: NSImage? = load("bolt-dark")
private static func load(_ name: String) -> NSImage? {
guard let url = Bundle.main.url(forResource: name, withExtension: "svg")
else { return nil }
return NSImage(contentsOf: url)
}
/// Wie dick der Rand um das Zeichen ist.
public static let outlineWidth: CGFloat = 1
/// Der Rahmen für den Rand dasselbe Zeichen, etwas größer.
public static func outlineRect(_ glyph: CGRect) -> CGRect {
glyph.insetBy(dx: -outlineWidth, dy: -outlineWidth)
}
/// Wie hoch das Zeichen im Akku ist.
///
/// Bezugsgröße ist der **Innenraum**, nicht die Außenhöhe: der Rahmen
/// nimmt oben und unten je einen Punkt, die Füllung sitzt zwei Punkte
/// eingerückt, und um das Zeichen kommt noch sein eigener Rand. Wer von
/// der Außenhöhe ausgeht, landet bei einem Blitz, der den ganzen Akku
/// ausfüllt und an den Rahmen stößt.
public static func glyphHeight(for indicator: Indicator, in height: CGFloat) -> CGFloat {
let inner = max(height - 2 * fillInset, 1)
switch indicator {
// Das Pausenzeichen ist von Haus aus höher gebaut als der Blitz.
case .paused: return inner * 0.8
default: return inner
}
}
/// Wie weit die Füllung vom Rahmen abrückt.
public static let fillInset: CGFloat = 2
/// Wie lange ein voller Hin- und Rückweg des Blitzes dauert.
public static let fadeDuration: TimeInterval = 2.2
/// Der Ping-Pong von 0 nach 1 und zurück.
///
/// Über den Kosinus statt linear: ein linearer Ping-Pong knickt an den
/// Umkehrpunkten sichtbar, der Kosinus wird dort flach. In der Menüleiste
/// fällt so ein Knick auf.
public static func fadeAmount(phase: Double) -> Double {
(1 - cos(phase.truncatingRemainder(dividingBy: 1) * 2 * .pi)) / 2
}
/// Wie weiß der Blitz gerade ist, 1 = weiß, 0 = schwarz.
///
/// Darum geht es: bei wenig Ladung sitzt der Blitz im leeren Teil des
/// Akkus und braucht Weiß, über der Füllung braucht er Schwarz. Ein Faden
/// dazwischen ist in beiden Fällen irgendwann gut lesbar und man sieht
/// am Wandern zusätzlich, dass geladen wird.
public static func boltWhiteness(phase: Double) -> Double {
1 - fadeAmount(phase: phase)
}
public static func isLow(charge: Double, isCharging: Bool = false) -> Bool {
// Ein roter Akku, der gerade lädt, wäre eine Warnung vor etwas, das
// sich schon erledigt.
!isCharging && charge < lowThreshold
}
/// Ein Symbol in der gewünschten Farbe. `color.set()` genügt nicht:
/// Template-Bilder tragen ihre Farbe nicht aus dem Grafikzustand.
private static func tint(_ image: NSImage, with color: NSColor) -> NSImage? {
let configuration = NSImage.SymbolConfiguration(paletteColors: [color])
return image.withSymbolConfiguration(configuration)
}
/// Zeichnet den Akku in `rect`.
///
/// - Parameter color: die Vordergrundfarbe der Menüleiste. Nur bei
/// kritischem Ladestand weicht die Füllung davon ab Farbe ist in der
/// Menüleiste ein Signal, keine Dekoration.
public static func draw(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
fadePhase: Double = 0,
color: NSColor, in rect: CGRect) {
let capWidth: CGFloat = 2
let body = CGRect(x: rect.minX, y: rect.minY,
width: rect.width - capWidth - 1, height: rect.height)
// Rahmen zurückgenommen, damit die Füllung die Aussage trägt.
let outline = NSBezierPath(roundedRect: body.insetBy(dx: 0.5, dy: 0.5),
xRadius: 3, yRadius: 3)
outline.lineWidth = 1
color.withAlphaComponent(0.45).setStroke()
outline.stroke()
// Der Knubbel am Pluspol.
let cap = CGRect(x: body.maxX + 1, y: rect.midY - 2.5, width: capWidth, height: 5)
color.withAlphaComponent(0.45).setFill()
NSBezierPath(roundedRect: cap, xRadius: 1, yRadius: 1).fill()
// Die Füllung.
let inner = body.insetBy(dx: 2, dy: 2)
let width = fillWidth(charge: charge, inner: inner.width)
if width > 0 {
let fill = CGRect(x: inner.minX, y: inner.minY,
width: width, height: inner.height)
(isLow(charge: charge, isCharging: isCharging)
? NSColor.systemRed : color).setFill()
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
}
// Das Zeichen wird **ausgestanzt**, nicht daraufgelegt.
//
// `destinationOut` nimmt weg, was vorher gezeichnet wurde Füllung und
// Rahmen. Übrig bleibt ein Loch in Blitz- bzw. Steckerform, durch das
// die Menüleiste scheint. Genau so sieht der von macOS aus, und es
// funktioniert auf hellem wie dunklem Grund, ohne die Hintergrundfarbe
// zu kennen.
guard let name = indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn).symbolName,
let glyph = NSImage(systemSymbolName: name, accessibilityDescription: nil)
else { return }
let indicator = self.indicator(isCharging: isCharging, isPluggedIn: isPluggedIn)
let glyphHeight = glyphHeight(for: indicator, in: rect.height)
let glyphWidth = (glyph.size.width / glyph.size.height) * glyphHeight
let glyphRect = CGRect(x: body.midX - glyphWidth / 2,
y: rect.midY - glyphHeight / 2,
width: glyphWidth, height: glyphHeight)
guard isCharging else {
// Das Pausenzeichen wird **ausgestanzt**: ein Loch in der Füllung.
//
// Weiß mit Rand funktioniert hier nicht bei fast vollem Akku
// liegt es auf weißer Füllung, das Weiß verschwindet darin und
// übrig bleiben die dunklen Ränder als zwei Striche.
NSGraphicsContext.saveGraphicsState()
glyph.draw(in: glyphRect, from: .zero, operation: .destinationOut, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
return
}
// Der Blitz blendet zwischen den beiden Fassungen über.
//
// Erst die dunkle voll deckend, dann die helle mit steigender
// Deckkraft darüber. So ist das Zeichen in jedem Moment vollständig
// gedeckt zeichnete man beide anteilig, schiene dazwischen der
// Hintergrund durch und der Blitz wirkte blass.
let whiteness = boltWhiteness(phase: fadePhase)
if let dark = boltDark, let light = boltLight {
dark.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
light.draw(in: glyphRect, from: .zero, operation: .sourceOver,
fraction: whiteness)
return
}
if let filled = tint(glyph, with: NSColor(white: whiteness, alpha: 1)) {
filled.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
}
}
}
// MARK: - SwiftUI
import SwiftUI
/// Derselbe Akku für die Kachel.
///
/// Dieselbe Form wie in der Menüleiste, damit nicht zwei verschiedene Akkus
/// in derselben App stehen. Der Blitz ist hier kein Ausstanzen, sondern eine
/// Maske in SwiftUI ist das der geradere Weg zum selben Bild.
public struct BatteryGlyphView: View {
private let charge: Double
private let isCharging: Bool
private let height: CGFloat
private let isPluggedIn: Bool
public init(charge: Double, isCharging: Bool, isPluggedIn: Bool = false,
height: CGFloat = 14) {
self.charge = charge
self.isCharging = isCharging
self.isPluggedIn = isPluggedIn
self.height = height
}
private var scale: CGFloat { height / BatteryGlyph.size.height }
private var width: CGFloat { BatteryGlyph.size.width * scale }
public var body: some View {
HStack(spacing: 1 * scale) {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3 * scale, style: .continuous)
.strokeBorder(.primary.opacity(0.45), lineWidth: 1)
GeometryReader { geometry in
let inner = geometry.size.width - 4 * scale
RoundedRectangle(cornerRadius: 1.5 * scale, style: .continuous)
.fill(fillColor)
.frame(width: BatteryGlyph.fillWidth(charge: charge, inner: inner))
.padding(2 * scale)
}
}
.frame(width: (BatteryGlyph.size.width - 3) * scale, height: height)
.overlay {
let indicator = BatteryGlyph.indicator(isCharging: isCharging,
isPluggedIn: isPluggedIn)
if let name = indicator.symbolName {
// `TimelineView` gibt die Zeit her, ohne dass hier ein
// Zeitgeber laufen muss.
let size = BatteryGlyph.glyphHeight(for: indicator, in: height)
if isCharging {
TimelineView(.animation) { context in
let phase = context.date.timeIntervalSinceReferenceDate
/ BatteryGlyph.fadeDuration
let whiteness = BatteryGlyph.boltWhiteness(phase: phase)
bolt(side: size, whiteness: whiteness)
}
} else {
Image(systemName: name)
.font(.system(size: size))
.blendMode(.destinationOut)
}
}
}
.compositingGroup()
RoundedRectangle(cornerRadius: 1 * scale, style: .continuous)
.fill(.primary.opacity(0.45))
.frame(width: 2 * scale, height: 5 * scale)
}
.frame(width: width, height: height)
}
/// Dieselben zwei Fassungen wie in der Menüleiste, ineinander geblendet.
@ViewBuilder
private func bolt(side: CGFloat, whiteness: Double) -> some View {
if let dark = BatteryGlyph.boltDark, let light = BatteryGlyph.boltLight {
ZStack {
Image(nsImage: dark).resizable().aspectRatio(contentMode: .fit)
Image(nsImage: light).resizable().aspectRatio(contentMode: .fit)
.opacity(whiteness)
}
.frame(width: side, height: side)
} else {
Image(systemName: "bolt.fill")
.font(.system(size: side))
.foregroundStyle(Color(white: whiteness))
}
}
private var fillColor: Color {
BatteryGlyph.isLow(charge: charge, isCharging: isCharging) ? .red : .primary
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// Das Akkusymbol zum jeweiligen Zustand.
///
/// Ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist Dekoration und
/// keine Anzeige es beantwortet die eine Frage nicht, für die es da ist. Wer
/// in der Menüleiste nur das Symbol stehen hat, will es auf einen Blick lesen
/// können, ohne die Zahl daneben.
public enum BatterySymbol {
/// Kein Akku da oder keiner zu lesen.
public static let unavailable = "batteryblock.slash"
/// Der Blitz beim Laden **getrennt** vom Füllstand.
///
/// Nachgemessen, nicht angenommen: von den kombinierten Symbolen existiert
/// allein `battery.100percent.bolt`. Für 0, 25, 50 und 75 Prozent gibt es
/// keine Blitz-Variante, und `battery.slash` gibt es auch nicht.
///
/// Den Füllstand beim Laden gegen einen Blitz einzutauschen wäre die
/// falsche Wahl: dass geladen wird, sieht man am Netzstecker; **wie weit**
/// es ist, sieht man nirgends sonst. Also bleibt der Füllstand, und der
/// Blitz kommt als kleines Zeichen darüber.
public static let chargingBolt = "bolt.fill"
/// SF Symbols kennt fünf Füllstufen. Gerundet wird zur nächsten: 60 % soll
/// nicht halbleer aussehen und 40 % nicht dreiviertel voll.
private static let steps = [0, 25, 50, 75, 100]
public static func name(charge: Double) -> String {
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
let step = steps.min { abs($0 - percent) < abs($1 - percent) } ?? 0
return "battery.\(step)percent"
}
}

View File

@@ -0,0 +1,125 @@
import Foundation
/// Die CPU-Zeitzähler eines Kerns, wie `host_processor_info` sie liefert.
///
/// Es sind **Summen seit dem Start**, keine Momentanwerte. Die Auslastung
/// ergibt sich erst aus der Differenz zweier Abtastungen.
public struct CPUTicks: Equatable, Sendable {
public let user: UInt32
public let system: UInt32
public let idle: UInt32
public let nice: UInt32
public init(user: UInt32, system: UInt32, idle: UInt32, nice: UInt32) {
self.user = user
self.system = system
self.idle = idle
self.nice = nice
}
/// Auslastung zwischen zwei Abtastungen, 0 bis 1.
public static func usage(from previous: CPUTicks, to current: CPUTicks) -> Double {
// `&-` statt `-`: die Zähler sind 32 Bit breit und laufen nach gut
// 400 Tagen über. Ein gewöhnlicher Abzug stürzt dabei ab oder liefert
// einen absurden Sprung; die überlaufende Subtraktion ergibt genau die
// richtige Differenz.
let user = UInt64(current.user &- previous.user)
let system = UInt64(current.system &- previous.system)
let nice = UInt64(current.nice &- previous.nice)
let idle = UInt64(current.idle &- previous.idle)
let busy = user + system + nice
let total = busy + idle
guard total > 0 else { return 0 }
return min(max(Double(busy) / Double(total), 0), 1)
}
}
/// Wie angespannt die Speicherlage ist.
public enum MemoryPressure: Equatable, Sendable {
case normal
case warning
case critical
/// - Parameters:
/// - free: Anteil wirklich freien Speichers, 0 bis 1.
/// - compressed: Anteil komprimierten Speichers, 0 bis 1.
/// - swapUsed: belegter Auslagerungsspeicher in Byte.
public static func classify(free: Double, compressed: Double, swapUsed: UInt64) -> MemoryPressure {
// Auslagerung schlägt alles andere: sobald sie läuft, bremst der
// Rechner spürbar auch wenn die Freispeicheranzeige harmlos aussieht.
// Ein bisschen Swap hat macOS fast immer angelegt, deshalb erst ab
// einem halben Gigabyte.
if swapUsed > 512 * 1024 * 1024 { return .critical }
if free < 0.10 || compressed > 0.20 { return .warning }
return .normal
}
}
/// Netzwerkdurchsatz aus zwei Zählerständen.
public enum Throughput {
/// Byte pro Sekunde zwischen zwei Abtastungen.
///
/// Die Normierung auf die tatsächlich verstrichene Zeit ist der Kern: die
/// Menüleisten-Module tasten je nach Einstellung alle 1 bis 10 Sekunden ab.
/// Ohne sie zeigte ein Modul mit 5 Sekunden Intervall das Fünffache an.
public static func rate(previous: UInt64, current: UInt64, elapsed: TimeInterval) -> Double {
guard elapsed > 0 else { return 0 }
// Zähler kleiner als vorher heißt: er wurde zurückgesetzt. Das passiert
// bei jedem Interface-Wechsel WLAN aus, VPN an und beim Neustart
// des Dienstes.
//
// Ein Überlauf wäre die andere denkbare Erklärung, kommt hier aber
// nicht vor: `if_data64` liefert 64-Bit-Zähler, die bei 10 Gbit/s
// erst nach rund 470 Jahren umlaufen. Der Versuch, beides zu
// unterscheiden, produziert nur den Ausreißer, den er verhindern soll
// deshalb null.
guard current >= previous else { return 0 }
return Double(current - previous) / elapsed
}
/// 2,4 MB/s" statt 2400000".
public static func formatted(_ bytesPerSecond: Double) -> String {
let formatter = ByteCountFormatter()
formatter.countStyle = .binary
formatter.allowedUnits = [.useKB, .useMB, .useGB]
formatter.zeroPadsFractionDigits = false
// Ohne das steht bei null Zero KB/s" statt 0 KB/s" in einer Spalte
// aus Zahlen fällt ein Wort unangenehm auf und ändert die Breite.
formatter.allowsNonnumericFormatting = false
return formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) + "/s"
}
}
/// Einheitliche Zahlendarstellung für alle Messwerte.
public enum MetricFormat {
/// Ganzzahlig. Nachkommastellen bei der Auslastung täuschen eine
/// Genauigkeit vor, die die Messung nicht hat und sie zappeln im
/// Sekundentakt, was die Anzeige unruhig macht.
public static func percent(_ fraction: Double) -> String {
"\(Int((min(max(fraction, 0), 1) * 100).rounded())) %"
}
/// Ohne Leerzeichen nur für die Menüleiste.
///
/// Dort ist die Breite fest für 100 %" reserviert, damit nichts springt,
/// wenn der Wert eine Stelle mehr bekommt. Das Leerzeichen kostet in dieser
/// Reservierung drei Punkte je Element, und in der Menüleiste ist Platz
/// das knappste Gut. Im Panel und in den Popovern bleibt es stehen.
public static func percentCompact(_ fraction: Double) -> String {
"\(Int((min(max(fraction, 0), 1) * 100).rounded()))%"
}
public static func temperature(_ celsius: Double) -> String {
"\(Int(celsius.rounded()))°"
}
public static func watts(_ value: Double) -> String {
String(format: "%.1f W", max(value, 0))
}
}

View File

@@ -0,0 +1,37 @@
import Foundation
/// Was mit dem Akku gerade geschieht in Worten statt in Symbolen.
///
/// Am Netz" allein beantwortet die Frage nicht, die man hat, wenn der
/// Ladestand stehen bleibt: lädt es gerade nicht, weil das Limit erreicht ist,
/// oder stimmt etwas nicht?
public enum ChargeActivity: Equatable, Sendable {
case charging
/// Angehalten, weil das eingestellte Ladelimit erreicht ist.
case pausedAtLimit(Int)
/// Am Netz, lädt aber nicht Grund unbekannt.
case pluggedNotCharging
case onBattery
/// Wie weit unter dem Limit noch als am Limit" gilt.
///
/// Der Ladestand pendelt um die Grenze. Auf das Prozent genau zu
/// vergleichen hieße, den Grund die halbe Zeit zu verschweigen.
public static let limitTolerance = 2
public static func resolve(isCharging: Bool, isPluggedIn: Bool,
charge: Double, limit: Int?) -> ChargeActivity {
if isCharging { return .charging }
guard isPluggedIn else { return .onBattery }
let percent = Int((min(max(charge, 0), 1) * 100).rounded())
// Ein Limit von hundert ist keines.
if let limit, limit < 100, percent >= limit - limitTolerance {
return .pausedAtLimit(limit)
}
// Steht das Laden aus einem anderen Grund etwa weil macOS den Akku
// schont , ist das der ehrliche Befund. Einen falschen Grund zu
// nennen wäre schlimmer als keinen.
return .pluggedNotCharging
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxHelperProtocol
/// Wer das Ladelimit **lesen** kann.
///
/// Nur lesen, und das ist eine gemessene Grenze, keine Bequemlichkeit: der
/// SMC lehnt Schreibzugriffe auf `CHLT` ab. Der Helfer hat es versucht und
/// `false` gemeldet, der Wert blieb unverändert. macOS setzt das Limit
/// offenbar auf einem anderen Weg als über den SMC-Userclient.
///
/// Ein Regler, der sich ziehen lässt und dann zurückspringt, ist schlimmer als
/// gar keiner er behauptet eine Fähigkeit, die nicht existiert.
@MainActor
public protocol ChargeLimitReading: AnyObject {
/// Das eingestellte Limit in Prozent, `nil` wenn keines gilt.
var chargeLimit: Int? { get }
/// Ob der Wert überhaupt gelesen werden kann ohne Helfer nicht.
var canReadCharge: Bool { get }
func refreshChargeLimit()
}
/// Zeigt das Ladelimit und führt dorthin, wo man es ändern kann.
public struct ChargeLimitControl: View {
private let control: any ChargeLimitReading
/// Im Popover ist Platz für den Hinweis, in der Kachel nicht.
private let roomy: Bool
public init(control: any ChargeLimitReading, roomy: Bool) {
self.control = control
self.roomy = roomy
}
public var body: some View {
HStack(spacing: 6) {
Text("charge.limit", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
Spacer(minLength: 4)
if let limit = control.chargeLimit, limit < ChargeSafety.noLimit {
Text(verbatim: "\(limit) %")
.font(Onyx.Font.metricSmall).monospacedDigit()
.foregroundStyle(Onyx.Color.accent)
} else if control.canReadCharge {
Text("charge.limit.off", bundle: .module)
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
} else {
Text(verbatim: "")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
if roomy {
// Kein Regler, sondern der Weg dorthin, wo es wirklich geht.
Button {
NSWorkspace.shared.open(
URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!)
} label: {
Image(systemName: "arrow.up.forward.app")
.font(.system(size: 10))
.foregroundStyle(Onyx.Color.textTertiary)
}
.buttonStyle(.plain)
.help(Text("charge.limit.inSystemSettings", bundle: .module))
}
}
.onAppear { control.refreshChargeLimit() }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,408 @@
import AppKit
import SwiftUI
import OnyxDesign
import OnyxMenuBar
/// Ein Menüleisten-Modul je Hardwaregröße.
///
/// Teilt sich das Modell und damit die Messschleife mit dem Panel-Widget
/// derselben Größe. Wer CPU im Panel **und** in der Menüleiste zeigt, bekommt
/// trotzdem nur eine Messung.
@MainActor
public final class MetricMenuBarModule: MenuBarModule {
public let id: String
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
private let metric: MetricKind
private let model: MetricsModel
private var token: UUID?
private var view: MetricStatusView?
private var observation: (any NSObjectProtocol)?
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = "metric.\(metric.rawValue)"
}
/// Kurz genug für die Menüleiste. Arbeitsspeicher" wäre dort ein Balken.
public var shortLabel: String {
switch metric {
case .cpu: "CPU"
case .gpu: "GPU"
case .memory: "RAM"
case .battery: String(localized: "menubar.short.battery", bundle: .module)
// Der gewählte Sensor gibt sein eigenes Kürzel Temp" neben einer
// Zahl sagt nicht, welcher Punkt gemeint ist.
case .temperature: model.selectedSensorName
?? String(localized: "menubar.short.temperature", bundle: .module)
}
}
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = MetricStatusView(metric: metric, presentation: presentation,
label: shortLabel)
view.sensorCelsius = model.selectedSensorCelsius
view.sensorCelsius = model.selectedSensorCelsius
view.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(MetricPopoverContent(model: model, metric: metric))
}
public func activate() {
guard token == nil else { return }
token = model.addConsumer(interval: 2)
startObserving()
}
public func deactivate() {
if let token { model.removeConsumer(token) }
token = nil
observation = nil
view = nil
}
/// `withObservationTracking` meldet sich **einmal** und muss danach neu
/// eingerichtet werden sonst friert die Anzeige nach der ersten Messung ein.
private func startObserving() {
withObservationTracking {
_ = model.snapshot
_ = model.displayModes // auch auf den Moduswechsel reagieren
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(snapshot: model.snapshot, history: model.series(metric),
mode: model.displayMode(for: metric))
startObserving()
}
}
}
}
/// Zeichnet den Messwert in die Menüleiste.
///
/// Eigene `NSView` statt SwiftUI: die Menüleiste verlangt eine feste Breite je
/// Darstellungsart, sonst springt bei jeder Messung die ganze Leiste. Mit
/// SwiftUI müsste man diese Breite doppelt führen einmal für das Layout und
/// einmal für `NSStatusItem.length`.
final class MetricStatusView: NSView, WidthReporting {
private let metric: MetricKind
private let presentation: MenuBarPresentation
private var snapshot = MetricsSnapshot()
private var history: [Double] = []
private var mode: MetricDisplayMode = .usage
private let label: String
init(metric: MetricKind, presentation: MenuBarPresentation, label: String) {
self.metric = metric
self.presentation = presentation
self.label = label
super.init(frame: NSRect(x: 0, y: 0, width: 22, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// Läuft nur, solange geladen wird nur dann bewegt sich der Blitz.
///
/// 30 Bilder je Sekunde reichen für ein weiches Faden und kosten deutlich
/// weniger als die 60, die man reflexhaft nähme. Steht das Ladekabel nicht
/// drin, läuft gar nichts.
private var fadeTimer: Timer?
private var fadePhase: Double = 0
private var shownWidth: CGFloat = MenuBarText.minimumWidth
/// Der breiteste Wert, der vorkommen kann.
///
/// **Fest**, nicht am aktuellen Wert bemessen: ein Element, das bei
/// 431" breiter wird als bei 14", schiebt alle Symbole rechts davon.
///
/// Der frühere Fehler war nicht die feste Breite, sondern der falsche
/// Bezugswert gezeichnet wird 63°", bemessen wurde 100 °C".
private var reference: String {
switch (metric, mode) {
case (.temperature, _), (_, .temperature): "100°"
default: "100%"
}
}
override var intrinsicContentSize: NSSize {
NSSize(width: shownWidth, height: 22)
}
/// Der Platz für das, was **gerade** dasteht.
///
/// Auf die breiteste denkbare Zeichenfolge zu dimensionieren war zu teuer:
/// Temp 53°" stand in einer Box für Temp 100 °C", und in der Menüleiste
/// ist Platz das knappste Gut. `AdaptiveWidth` nimmt dem die Unruhe
/// sofort wachsen, zögernd schrumpfen.
private var width: CGFloat {
switch presentation {
case .symbol: max(MenuBarText.minimumWidth, symbolSlot + 4)
case .graph: 28
case .bars: barsWidth
case .value: MenuBarText.width(for: reference)
case .valueAndGraph: MenuBarText.width(for: reference) + 2 + 28
case .labelAndValue: MenuBarText.width(for: "\(label) \(reference)")
case .symbolAndValue:
MenuBarText.width(for: reference) + symbolSlot + MenuBarText.innerGap
}
}
/// Ein Balken je Kern braucht Platz für jeden Kern. Bei fünfzehn ist eine
/// feste Breite entweder zu eng oder überall sonst zu weit.
private var barsWidth: CGFloat {
let count = (metric == .cpu && mode == .usage) ? max(snapshot.cpu.perCore.count, 1) : 1
guard count > 1 else { return MenuBarText.minimumWidth }
return min(CGFloat(count) * 2 + CGFloat(count - 1) * 1 + 4, 60)
}
/// Was tatsächlich dasteht. Bei den Sensoren der gewählte Punkt statt des
/// stillen Maximums über alle.
private var displayValue: String {
if metric == .temperature, let celsius = sensorCelsius {
return MetricFormat.temperature(celsius)
}
// In der Menüleiste ohne Leerzeichen vor dem Prozentzeichen siehe
// `MetricFormat.percentCompact`.
let value = MetricSummary.value(metric, snapshot, mode: mode)
return value.replacingOccurrences(of: " %", with: "%")
}
/// Meldet, wenn sich die nötige Breite geändert hat der Controller stellt
/// das Element danach neu ein.
var onWidthChange: ((CGFloat) -> Void)?
/// Der gewählte Sensor, falls einer gewählt ist.
var sensorCelsius: Double?
func update(snapshot: MetricsSnapshot, history: [Double], mode: MetricDisplayMode) {
self.snapshot = snapshot
self.history = history
self.mode = mode
needsDisplay = true
updateFadeTimer()
let next = max(width, MenuBarText.minimumWidth)
guard next != shownWidth else { return }
shownWidth = next
invalidateIntrinsicContentSize()
onWidthChange?(next)
}
override func draw(_ dirtyRect: NSRect) {
// `labelColor` statt der Onyx-Farben: die Menüleiste ist mal hell, mal
// dunkel, und nur die Systemfarbe passt sich beidem an.
let color = NSColor.labelColor
switch presentation {
case .value:
drawText(displayValue, color: color)
case .symbol:
drawSymbol(color: color)
case .graph:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .bars:
drawBars(color: color)
case .labelAndValue:
drawText("\(label) \(displayValue)", color: color)
case .symbolAndValue:
let glyph = symbolSlot
if metric == .battery {
drawBattery(color: color,
at: NSPoint(x: 0, y: bounds.midY - BatteryGlyph.size.height / 2))
} else if let image = Self.customGlyphs[metric] {
MenuBarText.drawTemplate(image, color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5,
width: glyph, height: 13))
} else {
MenuBarText.drawSymbol(currentSymbol, color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5,
width: glyph, height: 13))
}
drawText(displayValue, color: color,
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
width: bounds.width - glyph - MenuBarText.innerGap,
height: bounds.height))
case .valueAndGraph:
let split = bounds.width * 0.55
drawText(displayValue, color: color,
in: NSRect(x: 0, y: 0, width: split, height: bounds.height))
drawGraph(color: color,
in: NSRect(x: split + 2, y: 5,
width: bounds.width - split - 4, height: bounds.height - 10))
}
}
private func drawText(_ text: String, color: NSColor, in rect: NSRect? = nil) {
MenuBarText.draw(text, color: color, in: rect ?? bounds)
}
/// Eigene Vorlagen für CPU und GPU SF Symbols hat für beides nur den
/// gleichen Chip, und cpu" neben cpu.fill" ist kein Unterschied, den man
/// in der Menüleiste erkennt.
private static let customGlyphs: [MetricKind: NSImage] = {
var glyphs: [MetricKind: NSImage] = [:]
for (metric, name) in [(MetricKind.cpu, "chip"), (.gpu, "graphic-card")] {
guard let url = Bundle.main.url(forResource: name, withExtension: "svg"),
let image = NSImage(contentsOf: url) else { continue }
glyphs[metric] = image
}
return glyphs
}()
private func drawSymbol(color: NSColor) {
if metric == .battery {
drawBattery(color: color,
at: NSPoint(x: bounds.midX - BatteryGlyph.size.width / 2,
y: bounds.midY - BatteryGlyph.size.height / 2))
return
}
let side: CGFloat = 15
let rect = NSRect(x: bounds.midX - side / 2, y: bounds.midY - side / 2,
width: side, height: side)
if let glyph = Self.customGlyphs[metric] {
MenuBarText.drawTemplate(glyph, color: color, in: rect)
return
}
let width = MenuBarText.symbolWidth(currentSymbol, height: side)
MenuBarText.drawSymbol(currentSymbol, color: color,
in: NSRect(x: bounds.midX - width / 2,
y: bounds.midY - side / 2,
width: width, height: side))
}
/// Der Akku wird selbst gezeichnet mit stufenloser Füllung und dem Blitz
/// mitten drin, so wie macOS es zeigt.
private func drawBattery(color: NSColor, at origin: NSPoint) {
guard let battery = snapshot.battery else {
MenuBarText.drawSymbol(BatterySymbol.unavailable, color: color,
in: NSRect(x: origin.x, y: origin.y,
width: 15, height: 15))
return
}
BatteryGlyph.draw(charge: battery.charge, isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn, fadePhase: fadePhase,
color: color,
in: CGRect(origin: origin, size: BatteryGlyph.size))
}
/// Der Zeitgeber fürs Faden an und aus mit dem Ladevorgang.
private func updateFadeTimer() {
let shouldFade = metric == .battery && snapshot.battery?.isCharging == true
if shouldFade, fadeTimer == nil {
let timer = Timer(timeInterval: 1.0 / 30, repeats: true) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.fadePhase += (1.0 / 30) / BatteryGlyph.fadeDuration
self.needsDisplay = true
}
}
RunLoop.main.add(timer, forMode: .common)
fadeTimer = timer
} else if !shouldFade, fadeTimer != nil {
fadeTimer?.invalidate()
fadeTimer = nil
needsDisplay = true
}
}
/// Wie breit das Glyph ist. Der Akku wird selbst gezeichnet und hat eigene
/// Maße; alles andere ist ein SF-Symbol.
private var symbolSlot: CGFloat {
if metric == .battery { return BatteryGlyph.size.width }
// Eigene Vorlagen sind quadratisch; ihre Breite folgt der Höhe.
if Self.customGlyphs[metric] != nil { return 13 }
return MenuBarText.symbolWidth(currentSymbol, height: 13)
}
/// Das Symbol zum **Zustand**, nicht zur Metrik.
///
/// Beim Akku ändert es sich mit dem Ladestand und zeigt beim Laden einen
/// Blitz ein Symbol, das bei 5 % genauso aussieht wie bei 95 %, ist
/// Dekoration. Die übrigen Messgrößen haben nichts, was sich sinnvoll im
/// Glyph abbilden ließe.
private var currentSymbol: String {
guard metric == .battery else { return metric.symbolName }
guard let battery = snapshot.battery else { return BatterySymbol.unavailable }
return BatterySymbol.name(charge: battery.charge)
}
private func drawGraph(color: NSColor, in rect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
// Breite.
let xs = GraphLayout.positions(count: history.count,
capacity: MetricsModel.historyLength, in: rect)
guard !xs.isEmpty else { return }
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
context.setLineWidth(1)
context.setLineJoin(.round)
for (index, x) in xs.enumerated() {
let value = history[history.count - xs.count + index]
let point = CGPoint(x: x,
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
index == 0 ? context.move(to: point) : context.addLine(to: point)
}
context.strokePath()
}
/// Ein Balken je Kern. Bei fünfzehn Kernen ist das kein Diagramm mehr,
/// sondern ein Muster aber genau daran erkennt man auf einen Blick, ob
/// eine einzelne Last läuft oder alles ausgelastet ist.
private func drawBars(color: NSColor) {
// Bei der CPU-Auslastung ein Balken je Kern; sonst auch bei der
// CPU-Temperatur ein einzelner Balken.
let values = (metric == .cpu && mode == .usage)
? snapshot.cpu.perCore
: [MetricSummary.fraction(metric, snapshot, mode: mode)]
guard !values.isEmpty, let context = NSGraphicsContext.current?.cgContext else { return }
let area = bounds.insetBy(dx: 2, dy: 5)
let gap: CGFloat = values.count > 4 ? 1 : 2
let width = (area.width - gap * CGFloat(values.count - 1)) / CGFloat(values.count)
for (index, value) in values.enumerated() {
let height = max(area.height * CGFloat(min(max(value, 0), 1)), 1)
let rect = NSRect(x: area.minX + CGFloat(index) * (width + gap),
y: area.minY, width: width, height: height)
context.setFillColor(color.withAlphaComponent(0.85).cgColor)
context.fill(rect)
}
}
}
extension MenuBarPresentation {
}
public extension MetricSummary {
/// Der Messwert als Anteil von 0 bis 1 für Balken und Graphen.
static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Double {
switch metric {
case .cpu: snapshot.cpu.total
case .gpu: snapshot.gpu ?? 0
case .memory: snapshot.memory.usedFraction
case .battery: snapshot.battery?.charge ?? 0
case .temperature: (snapshot.sensors.map(\.celsius).max() ?? 0) / 100
}
}
}

View File

@@ -0,0 +1,443 @@
import SwiftUI
import OnyxDesign
/// Die Detailansichten hinter den Menüleisten-Symbolen.
///
/// Hier ist ausdrücklich Platz anders als in der Menüleiste, wo jede Zahl um
/// Breite kämpft. Ein Popover, das nur den Wert größer wiederholt, den man
/// gerade angeklickt hat, ist verschenkt.
@MainActor
struct MetricPopoverContent: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
VStack(alignment: .leading, spacing: 12) {
// Ein Titel oben: bei drei gleich aussehenden Ringen sieht man
// sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht.
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(.headline)
switch metric {
case .cpu, .gpu: ProcessorDetail(model: model, metric: metric)
case .memory: MemoryDetail(snapshot: model.snapshot)
case .battery: BatteryDetail(snapshot: model.snapshot, control: model.chargeControl)
case .temperature: SensorDetail(snapshot: model.snapshot)
}
}
.padding(14)
.frame(width: 280)
}
}
// MARK: - Bausteine
/// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine
/// lange Werteliste.
private struct SectionTitle: View {
let text: LocalizedStringKey
var body: some View {
Text(text, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
}
private struct Row: View {
let label: String
let value: String
var emphasis: Color?
var body: some View {
HStack {
Text(label).font(.callout)
Spacer(minLength: 12)
Text(value)
.font(.callout.monospacedDigit())
.foregroundStyle(emphasis ?? .secondary)
}
}
}
/// Die Mitte des **Rings** nicht die des Rings samt Beschriftung.
///
/// Ein `HStack` zentriert seine Inhalte über die volle Höhe. Die Beschriftung
/// unter dem Ring gehört aber dazu, und dadurch rutscht alles daneben um deren
/// halbe Höhe nach unten. Bei einer einzelnen Zeile fällt das auf: sie steht
/// dann sichtbar tiefer als die Mitte, auf die man schaut.
private extension VerticalAlignment {
enum DialCenterID: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[VerticalAlignment.center]
}
}
static let dialCenter = VerticalAlignment(DialCenterID.self)
}
/// Ein Ring mit Zahl in der Mitte.
private struct Dial: View {
let value: Double
let caption: String
let text: String
/// Ohne Angabe die Akzentfarbe. Als Standardwert ginge sie nicht: der
/// wird außerhalb des Hauptthreads erzeugt, die Farbe liegt aber dort.
var tint: Color?
var body: some View {
// Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und
// sah aus, als gehörte sie noch hinein.
VStack(spacing: 6) {
ZStack {
Circle().stroke(.quaternary, lineWidth: 5)
Circle()
.trim(from: 0, to: min(max(value, 0), 1))
.stroke(tint ?? Onyx.Color.accent,
style: .init(lineWidth: 5, lineCap: .round))
.rotationEffect(.degrees(-90))
Text(text).font(.system(size: 15, weight: .medium).monospacedDigit())
}
.frame(width: 62, height: 62)
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
Text(caption).font(.caption2).foregroundStyle(.secondary)
}
.padding(.bottom, 2)
}
}
// MARK: - Prozessor
private struct ProcessorDetail: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
let snapshot = model.snapshot
HStack(spacing: 14) {
Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0),
caption: metric == .cpu ? "CPU" : "GPU",
text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total
: (snapshot.gpu ?? 0)))
if let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature {
Dial(value: celsius / 100,
caption: String(localized: "popover.temperature", bundle: .module),
text: MetricFormat.temperature(celsius),
tint: MetricSummary.temperatureColor(celsius))
}
if let fan = snapshot.fans.first {
Dial(value: fan.fraction,
caption: String(localized: "popover.fans", bundle: .module),
text: MetricFormat.percent(fan.fraction))
}
}
.frame(maxWidth: .infinity)
if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty {
SectionTitle(text: "popover.perCore")
CoreGrid(values: model.snapshot.cpu.perCore)
}
SectionTitle(text: "popover.power")
if let cpu = snapshot.power.cpu {
Row(label: String(localized: "popover.power.cpu", bundle: .module),
value: MetricFormat.watts(cpu))
}
if let display = snapshot.power.display {
Row(label: String(localized: "popover.power.display", bundle: .module),
value: MetricFormat.watts(display))
}
if let total = snapshot.power.total {
Row(label: String(localized: "popover.power.total", bundle: .module),
value: MetricFormat.watts(total), emphasis: .primary)
}
if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans")
ForEach(snapshot.fans) { fan in
Row(label: fan.name, value: "\(Int(fan.rpm)) U/min")
}
}
Divider()
Picker("", selection: Binding(
get: { model.displayMode(for: metric) },
set: { model.setDisplayMode($0, for: metric) })) {
Text("metric.mode.usage", bundle: .module).tag(MetricDisplayMode.usage)
Text("metric.mode.temperature", bundle: .module).tag(MetricDisplayMode.temperature)
}
.pickerStyle(.segmented)
.labelsHidden()
}
}
/// Ein Kästchen je Kern statt einer Balkenreihe.
///
/// Bei fünfzehn Kernen wird eine Reihe unleserlich schmal. Als Raster bleibt
/// jeder Kern erkennbar, und man sieht auf einen Blick, ob eine einzelne Last
/// läuft oder alles arbeitet.
private struct CoreGrid: View {
let values: [Double]
var body: some View {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8),
spacing: 3) {
ForEach(Array(values.enumerated()), id: \.offset) { _, value in
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Onyx.Color.accent.opacity(0.18))
.overlay(alignment: .bottom) {
RoundedRectangle(cornerRadius: 2, style: .continuous)
.fill(Onyx.Color.accent)
.frame(height: max(14 * value, 1.5))
}
.frame(height: 14)
}
}
}
}
// MARK: - Speicher
private struct MemoryDetail: View {
let snapshot: MetricsSnapshot
var body: some View {
let memory = snapshot.memory
HStack(spacing: 14) {
Dial(value: memory.usedFraction,
caption: String(localized: "popover.memory.used", bundle: .module),
text: MetricFormat.percent(memory.usedFraction),
tint: pressureColor)
VStack(alignment: .leading, spacing: 3) {
legend(Onyx.Color.accent, "popover.memory.app", memory.app)
legend(.purple, "popover.memory.wired", memory.wired)
legend(.teal, "popover.memory.compressed", memory.compressed)
legend(.gray, "popover.memory.free", memory.free)
}
}
SectionTitle(text: "popover.memory.swap")
// Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird,
// ist ein voller Speicher kein Problem macOS füllt ihn absichtlich.
Row(label: String(localized: "popover.memory.swapUsed", bundle: .module),
value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))",
emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil)
if !snapshot.topMemoryProcesses.isEmpty {
SectionTitle(text: "popover.processes")
ForEach(snapshot.topMemoryProcesses) { process in
Row(label: process.name, value: bytes(process.bytes))
}
}
}
private var pressureColor: Color {
switch snapshot.memory.pressure {
case .normal: Onyx.Color.positive
case .warning: Onyx.Color.warning
case .critical: Onyx.Color.critical
}
}
private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View {
HStack(spacing: 5) {
Circle().fill(color).frame(width: 7, height: 7)
Text(key, bundle: .module).font(.caption)
Spacer(minLength: 8)
Text(bytes(value)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
}
private func bytes(_ value: UInt64) -> String {
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
}
}
// MARK: - Akku
private struct BatteryDetail: View {
let snapshot: MetricsSnapshot
let control: (any ChargeLimitReading)?
var body: some View {
if let battery = snapshot.battery {
HStack(alignment: .dialCenter, spacing: 14) {
Dial(value: battery.charge,
caption: String(localized: "metric.battery", bundle: .module),
text: MetricFormat.percent(battery.charge),
tint: battery.isCharging ? Onyx.Color.positive
: (battery.charge < 0.2 ? Onyx.Color.critical
: Onyx.Color.accent))
// Beides gehört neben den Ring: die Schätzung und der Grund
// dafür. Am Netzteil" über dem Ring und Am Netz, lädt nicht"
// darunter waren zweimal dieselbe Auskunft an zwei Stellen
// und die zweite stand allein unter dem Diagramm, wo sie
// aussah, als gehörte sie zu dessen Beschriftung.
VStack(alignment: .leading, spacing: 4) {
if let remaining = battery.timeRemaining {
Text(duration(remaining)).font(.title3.monospacedDigit())
Text(battery.isCharging ? "popover.battery.untilFull"
: "popover.battery.remaining",
bundle: .module)
.font(.caption).foregroundStyle(.secondary)
} else if !battery.isPluggedIn {
// Am Netz braucht es keine Entschuldigung für die
// fehlende Schätzung die Zeile darunter sagt ohnehin,
// was los ist.
Text("popover.battery.calculating", bundle: .module)
.font(.callout).foregroundStyle(.secondary)
}
// Was gerade geschieht, in Worten. Am Netz" allein
// beantwortet die Frage nicht, die man hat, wenn der
// Ladestand stehen bleibt.
activityLine(battery)
}
.alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] }
}
// Das Ladelimit steht hier und nicht in den Einstellungen: es ist
// eine Akkusache, und man greift danach, während man auf den
// Ladestand schaut.
if let control {
Divider().overlay(Onyx.Color.hairline)
ChargeLimitControl(control: control, roomy: true)
}
SectionTitle(text: "popover.battery.condition")
if let health = battery.health {
Row(label: String(localized: "popover.battery.health", bundle: .module),
value: MetricFormat.percent(health))
}
if let cycles = battery.cycleCount {
Row(label: String(localized: "popover.battery.cycles", bundle: .module),
value: "\(cycles)")
}
if let sensor = snapshot.sensors.first(where: { $0.key == "TB0T" }) {
Row(label: String(localized: "popover.temperature", bundle: .module),
value: MetricFormat.temperature(sensor.celsius))
}
SectionTitle(text: "popover.power")
if let watts = battery.watts {
Row(label: String(localized: battery.isCharging ? "popover.battery.charging"
: "popover.battery.draining",
bundle: .module),
value: MetricFormat.watts(abs(watts)), emphasis: .primary)
}
if let adapter = snapshot.power.adapter, adapter > 0 {
Row(label: String(localized: "popover.power.adapter", bundle: .module),
value: MetricFormat.watts(adapter))
}
if let voltage = snapshot.power.busVoltage {
Row(label: String(localized: "popover.battery.voltage", bundle: .module),
value: String(format: "%.2f V", voltage))
}
Divider()
// Ehrlich benennen, was fehlt: das Ladelimit braucht Schreibzugriff
// auf den SMC und damit den privilegierten Helfer und der Key ist
// auf diesem Modell noch nicht identifiziert.
Text("popover.battery.limitComing", bundle: .module)
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text("popover.battery.none", bundle: .module).foregroundStyle(.secondary)
}
}
@ViewBuilder
private func activityLine(_ battery: BatteryReading) -> some View {
let activity = ChargeActivity.resolve(isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: control?.chargeLimit)
HStack(spacing: 6) {
Image(systemName: symbol(for: activity))
.font(.caption)
.foregroundStyle(tint(for: activity))
switch activity {
case .charging:
Text("popover.battery.charging", bundle: .module)
case .pausedAtLimit(let limit):
Text("popover.battery.pausedAtLimit \(limit)", bundle: .module)
case .pluggedNotCharging:
Text("popover.battery.notCharging", bundle: .module)
case .onBattery:
Text("popover.battery.onBattery", bundle: .module)
}
Spacer(minLength: 0)
}
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
private func symbol(for activity: ChargeActivity) -> String {
switch activity {
case .charging: "bolt.fill"
case .pausedAtLimit: "pause.fill"
case .pluggedNotCharging: "powerplug.fill"
case .onBattery: "battery.50percent"
}
}
private func tint(for activity: ChargeActivity) -> Color {
// Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man
// sucht. Alles andere bleibt zurückhaltend.
activity == .charging ? Onyx.Color.positive : .secondary
}
private func duration(_ seconds: TimeInterval) -> String {
let total = Int(seconds / 60)
return total >= 60 ? "\(total / 60) h \(total % 60) min" : "\(total) min"
}
}
// MARK: - Sensoren
private struct SensorDetail: View {
let snapshot: MetricsSnapshot
var body: some View {
SectionTitle(text: "popover.temperature")
ForEach(snapshot.sensors) { sensor in
HStack {
Text(sensor.name).font(.callout)
Spacer(minLength: 12)
Text(MetricFormat.temperature(sensor.celsius))
.font(.callout.monospacedDigit())
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
// Der Ring macht aus einer Zahl einen Vergleich: 45° neben 79°
// sieht man schneller als man es liest.
Circle()
.trim(from: 0, to: min(sensor.celsius / 100, 1))
.stroke(MetricSummary.temperatureColor(sensor.celsius),
style: .init(lineWidth: 2, lineCap: .round))
.rotationEffect(.degrees(-90))
.frame(width: 12, height: 12)
}
}
if !snapshot.fans.isEmpty {
SectionTitle(text: "popover.fans")
ForEach(snapshot.fans) { fan in
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(fan.name).font(.callout)
Spacer(minLength: 12)
Text("\(Int(fan.rpm)) U/min").font(.callout.monospacedDigit())
.foregroundStyle(.secondary)
}
// Ohne Bezug sagt eine Drehzahl nichts: der Balken zeigt sie
// zwischen dem Minimum und Maximum der Firmware.
ProgressView(value: fan.fraction).controlSize(.small)
}
}
Text("metric.fan.controlComing", bundle: .module)
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}

View File

@@ -0,0 +1,440 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
import OnyxMenuBar
/// Ein Widget je Hardwaregröße. Alle teilen sich dasselbe Modell und damit
/// dieselbe Messschleife.
public struct MetricWidget: OnyxWidget {
public let id: String
public let metric: MetricKind
public var displayName: String {
String(localized: .init(metric.localizationKey), bundle: .module)
}
public var symbolName: String { metric.symbolName }
private let model: MetricsModel
public init(metric: MetricKind, model: MetricsModel) {
self.metric = metric
self.model = model
self.id = switch metric {
case .cpu: "cpu"
case .gpu: "gpu"
case .memory: "memory"
case .battery: "battery"
case .temperature: "sensors"
}
}
public func makeView() -> AnyView {
AnyView(MetricWidgetView(model: model, metric: metric))
}
}
extension MetricKind {
var localizationKey: String {
switch self {
case .cpu: "metric.cpu"
case .gpu: "metric.gpu"
case .memory: "metric.memory"
case .battery: "metric.battery"
case .temperature: "metric.sensors"
}
}
}
private struct MetricWidgetView: View {
let model: MetricsModel
let metric: MetricKind
@State private var token: UUID?
var body: some View {
Group {
// Die Kachel ist hochkant: die Sensorliste hat Platz für Details,
// alles andere zeigt Zahl und Verlauf.
if metric == .temperature {
SensorList(snapshot: model.snapshot, detailed: true)
} else if metric == .battery {
BatteryTile(model: model)
} else {
WideMetric(model: model, metric: metric)
}
}
.onAppear {
// Bei offenem Panel schneller messen: dort sieht man die Zahl,
// und ein Wert, der nur alle zwei Sekunden springt, wirkt träge.
token = model.addConsumer(interval: 1)
}
.onDisappear {
if let token { model.removeConsumer(token) }
token = nil
}
}
}
// MARK: - Bausteine
private struct CompactMetric: View {
let model: MetricsModel
let metric: MetricKind
private var mode: MetricDisplayMode { model.displayMode(for: metric) }
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Label {
Text(String(localized: .init(metric.localizationKey), bundle: .module))
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
} icon: {
Image(systemName: metric.symbolName)
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.accent)
}
Spacer(minLength: 0)
Text(MetricSummary.value(metric, model.snapshot, mode: mode))
.font(Onyx.Font.metric)
.foregroundStyle(MetricSummary.color(metric, model.snapshot, mode: mode))
.animation(Onyx.Motion.value,
value: MetricSummary.value(metric, model.snapshot, mode: mode))
if let detail = MetricSummary.detail(metric, model.snapshot) {
Text(detail)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
private struct WideMetric: View {
let model: MetricsModel
let metric: MetricKind
var body: some View {
// Übereinander, nicht nebeneinander: die Kachel ist hochkant, und ein
// Verlauf über die volle Breite liest sich besser als einer, der sich
// die Zeile mit der Zahl teilt.
VStack(alignment: .leading, spacing: 8) {
CompactMetric(model: model, metric: metric)
Spacer(minLength: 0)
Sparkline(values: model.series(metric),
tint: MetricSummary.color(metric, model.snapshot,
mode: model.displayMode(for: metric)))
.frame(height: 46)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Verlaufskurve. Bewusst ohne Achsen und Beschriftung: auf 90 × 40 Punkten
/// ist die Form die Information, nicht der Zahlenwert.
public struct Sparkline: View {
let values: [Double]
let tint: Color
/// Wie viele Werte der Verlauf fasst die Schrittweite hängt daran, nicht
/// an der Anzahl der bisher vorhandenen.
let capacity: Int
public init(values: [Double], tint: Color, capacity: Int = MetricsModel.historyLength) {
self.values = values
self.tint = tint
self.capacity = capacity
}
public var body: some View {
GeometryReader { geometry in
let points = Array(values.suffix(capacity))
if points.count > 1 {
let size = geometry.size
// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs,
// nicht aus der Anzahl vorhandener Werte. Frisch gestartet sind
// es zwei statt sechzig; daraus gerechnet spannte sich der Weg
// zwischen ihnen über die ganze Breite ein riesiger Zickzack,
// der nach viel Bewegung aussah und nichts bedeutete.
let xs = GraphLayout.positions(count: points.count,
capacity: capacity,
in: CGRect(origin: .zero, size: size))
let coordinates = zip(xs, points).map { x, value in
CGPoint(x: x, y: size.height * (1 - CGFloat(min(max(value, 0), 1))))
}
let line = Path { path in
for (index, point) in coordinates.enumerated() {
index == 0 ? path.move(to: point) : path.addLine(to: point)
}
}
// Dieselbe Kurve, unten geschlossen die Fläche darunter gibt
// dem Verlauf Gewicht, ohne dass eine zweite Linie nötig wäre.
// Die Fläche beginnt am ersten Messwert, nicht am linken Rand:
// sonst zieht sich bei wenigen Werten ein Keil über die ganze
// Breite, obwohl dort noch nichts gemessen wurde.
let area = Path { path in
guard let first = coordinates.first, let last = coordinates.last
else { return }
path.move(to: CGPoint(x: first.x, y: size.height))
coordinates.forEach { path.addLine(to: $0) }
path.addLine(to: CGPoint(x: last.x, y: size.height))
path.closeSubpath()
}
ZStack {
area.fill(LinearGradient(colors: [tint.opacity(0.22), .clear],
startPoint: .top, endPoint: .bottom))
line.stroke(tint, style: .init(lineWidth: 1.5, lineJoin: .round))
}
}
}
}
}
private struct SensorList: View {
let snapshot: MetricsSnapshot
let detailed: Bool
var body: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(snapshot.sensors.prefix(detailed ? 8 : 3)) { sensor in
HStack(spacing: 6) {
Text(sensor.name)
.font(.system(size: 10))
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 0)
Text(MetricFormat.temperature(sensor.celsius))
.font(Onyx.Font.metricSmall)
.foregroundStyle(MetricSummary.temperatureColor(sensor.celsius))
}
}
if detailed, !snapshot.fans.isEmpty {
Divider().overlay(Onyx.Color.hairline).padding(.vertical, 2)
ForEach(snapshot.fans) { fan in
HStack(spacing: 6) {
Image(systemName: "fan")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Text("\(Int(fan.rpm))")
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textSecondary)
Text(verbatim: "U/min")
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
}
}
}
if snapshot.sensors.isEmpty {
Text("metric.sensors.none", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
/// Wie eine Größe zu Text und Farbe wird an einer Stelle, damit Panel und
/// Menüleiste nicht auseinanderlaufen.
public enum MetricSummary {
/// Der anzuzeigende Wert, abhängig vom gewählten Modus.
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> String {
guard mode == .temperature, metric.supportsDisplayModes else {
return value(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
// Solange die Fühlersuche im Hintergrund läuft, gibt es keinen Wert.
// " ist dann die richtige Auskunft, nicht eine 0.
return celsius.map(MetricFormat.temperature) ?? ""
}
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> Color {
guard mode == .temperature, metric.supportsDisplayModes else {
return color(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
return temperatureColor(celsius ?? 0)
}
public static func fraction(_ metric: MetricKind, _ snapshot: MetricsSnapshot,
mode: MetricDisplayMode) -> Double {
guard mode == .temperature, metric.supportsDisplayModes else {
return fraction(metric, snapshot)
}
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
// Auf 100 °C normiert: darüber wird ohnehin gedrosselt.
return min(max((celsius ?? 0) / 100, 0), 1)
}
public static func value(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String {
switch metric {
case .cpu: MetricFormat.percent(snapshot.cpu.total)
case .gpu: snapshot.gpu.map(MetricFormat.percent) ?? ""
case .memory: MetricFormat.percent(snapshot.memory.usedFraction)
case .battery: snapshot.battery.map { MetricFormat.percent($0.charge) } ?? ""
case .temperature: snapshot.sensors.map(\.celsius).max()
.map(MetricFormat.temperature) ?? ""
}
}
public static func detail(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> String? {
switch metric {
case .cpu:
return snapshot.cpu.watts.map(MetricFormat.watts)
case .memory:
return ByteCountFormatter.string(fromByteCount: Int64(snapshot.memory.used),
countStyle: .binary)
case .battery:
guard let battery = snapshot.battery else { return nil }
if battery.isCharging {
return String(localized: "metric.battery.charging", bundle: .module)
}
// Beim Entladen ist die Leistung negativ; der Betrag ist gemeint.
return battery.watts.map { MetricFormat.watts(abs($0)) }
case .gpu, .temperature:
return nil
}
}
public static func color(_ metric: MetricKind, _ snapshot: MetricsSnapshot) -> Color {
switch metric {
case .memory:
switch snapshot.memory.pressure {
case .normal: return Onyx.Color.textPrimary
case .warning: return Onyx.Color.warning
case .critical: return Onyx.Color.critical
}
case .battery:
guard let battery = snapshot.battery else { return Onyx.Color.textPrimary }
if battery.isCharging { return Onyx.Color.positive }
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
case .temperature:
return temperatureColor(snapshot.sensors.map(\.celsius).max() ?? 0)
case .cpu, .gpu:
return Onyx.Color.textPrimary
}
}
public static func temperatureColor(_ celsius: Double) -> Color {
// Grenzwerte für Apple Silicon: unter 70 unauffällig, ab 90 wird
// gedrosselt. Dazwischen ist es warm, aber unproblematisch.
switch celsius {
case ..<70: Onyx.Color.textPrimary
case ..<90: Onyx.Color.warning
default: Onyx.Color.critical
}
}
}
/// Die Akku-Kachel.
///
/// 40 %" und ein Verlauf über die letzten Sekunden ist keine Auskunft der
/// Ladestand ändert sich in Minuten, nicht in Sekunden, und ein Graph darüber
/// ist eine waagerechte Linie. Was man wissen will: wie lange noch, zieht oder
/// gibt der Akku gerade, läuft der Energiesparmodus, wo endet das Laden.
private struct BatteryTile: View {
let model: MetricsModel
var body: some View {
let battery = model.snapshot.battery
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 7) {
// Das Symbol folgt dem Ladestand sonst zeigt es bei 5 %
// dasselbe wie bei 95 % und trägt nichts bei.
if let battery {
BatteryGlyphView(charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn, height: 15)
.foregroundStyle(tint(battery))
} else {
Image(systemName: BatterySymbol.unavailable)
.font(.system(size: 13))
.foregroundStyle(Onyx.Color.textTertiary)
}
Text(MetricFormat.percent(battery?.charge ?? 0))
.font(Onyx.Font.metric).monospacedDigit()
.foregroundStyle(tint(battery))
Spacer(minLength: 0)
}
if let battery {
if let remaining = battery.timeRemaining {
Text(battery.isCharging ? "battery.untilFull \(duration(remaining))"
: "battery.remaining \(duration(remaining))",
bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
} else if battery.isPluggedIn {
Text("battery.pluggedIn", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
}
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 3) {
if let watts = battery.watts, abs(watts) > 0.1 {
row(watts > 0 ? "battery.charging" : "battery.draining",
value: String(format: "%.1f W", abs(watts)))
}
if let limit = model.chargeControl?.chargeLimit, limit < 100 {
row("charge.limit", value: "\(limit) %")
}
if let health = battery.health {
row("battery.health", value: MetricFormat.percent(health))
}
if let cycles = battery.cycleCount {
row("battery.cycles", value: "\(cycles)")
}
}
// Der Energiesparmodus erklärt, warum die Maschine langsamer
// wirkt. Ohne diesen Hinweis sucht man den Grund woanders.
if battery.isLowPower {
HStack(spacing: 4) {
Image(systemName: "leaf.fill").font(.system(size: 9))
Text("battery.lowPower", bundle: .module).font(Onyx.Font.metricSmall)
}
.foregroundStyle(Onyx.Color.warning)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ key: String.LocalizationValue, value: String) -> some View {
HStack(spacing: 4) {
Text(String(localized: key, bundle: .module))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 4)
Text(value).monospacedDigit().foregroundStyle(Onyx.Color.textSecondary)
}
.font(Onyx.Font.metricSmall)
}
private func tint(_ battery: BatteryReading?) -> Color {
guard let battery else { return Onyx.Color.textPrimary }
if battery.isCharging { return Onyx.Color.positive }
return battery.charge < 0.2 ? Onyx.Color.critical : Onyx.Color.textPrimary
}
private func duration(_ seconds: TimeInterval) -> String {
let total = Int(seconds)
return total >= 3600 ? "\(total / 3600) h \(total % 3600 / 60) min"
: "\(total / 60) min"
}
}

View File

@@ -0,0 +1,187 @@
import Foundation
import SwiftUI
/// Ein Messwertstrom mit Referenzzählung.
///
/// Panel-Widget und Menüleisten-Modul greifen auf **dieselbe** Messschleife zu.
/// Ohne das liefe für jede Anzeige ein eigener Timer, der dieselben IOKit- und
/// SMC-Abfragen doppelt macht bei sechs Modulen und ebenso vielen Widgets
/// wäre das ein Vielfaches der nötigen Arbeit.
///
/// Meldet sich der letzte Konsument ab, hört die Schleife auf. Im Ruhezustand
/// misst Onyx nichts.
@MainActor
@Observable
public final class MetricsModel {
/// Wer das Ladelimit setzen kann die App reicht den Helfer herein.
/// `nil` heißt: kein Regler, weil es nichts zu regeln gibt.
public weak var chargeControl: (any ChargeLimitReading)?
/// Welcher Sensor in der Menüleiste steht. `nil` = der wärmste.
///
/// Ohne diese Wahl zeigte das Modul stumm das Maximum über alle Sensoren
/// eine Zahl, von der niemand weiß, woher sie kommt. Der wärmste Punkt"
/// bleibt die Voreinstellung, weil das die Frage ist, die man meistens
/// hat; aber wer den Akku oder die GPU im Blick behalten will, soll das
/// sagen können.
public var selectedSensorKey: String? {
didSet {
guard selectedSensorKey != oldValue else { return }
defaults.set(selectedSensorKey, forKey: "onyx.metric.sensorKey")
}
}
/// Der Wert des gewählten Sensors oder der wärmste, wenn keiner gewählt
/// ist oder der gewählte gerade nicht meldet.
public var selectedSensorCelsius: Double? {
if let key = selectedSensorKey,
let sensor = snapshot.sensors.first(where: { $0.key == key }) {
return sensor.celsius
}
return snapshot.sensors.map(\.celsius).max()
}
/// Wie der gewählte Sensor heißt fürs Kürzel in der Menüleiste.
public var selectedSensorName: String? {
guard let key = selectedSensorKey else { return nil }
return snapshot.sensors.first(where: { $0.key == key })?.name
}
public private(set) var snapshot = MetricsSnapshot()
/// Verlauf für die Graphen, jüngster Wert zuletzt.
public private(set) var history: [MetricsSnapshot] = []
/// So viele Punkte zeigt ein Menüleisten-Graph. Mehr aufzuheben kostet
/// Speicher für etwas, das niemand sieht.
public static let historyLength = 60
/// Was CPU und GPU zeigen. Beobachtbar, damit Panel und Menüleiste sofort
/// umschalten.
public private(set) var displayModes: [MetricKind: MetricDisplayMode] = [:]
private let defaults: UserDefaults
private let source = SystemMetrics()
private var timer: Timer?
/// Je Konsument das gewünschte Intervall. Gemessen wird mit dem kürzesten.
private var demands: [UUID: TimeInterval] = [:]
public init(defaults: UserDefaults = .standard) {
self.selectedSensorKey = defaults.string(forKey: "onyx.metric.sensorKey")
self.defaults = defaults
for metric in MetricKind.allCases where metric.supportsDisplayModes {
let stored = defaults.string(forKey: Self.key(metric))
displayModes[metric] = stored.flatMap(MetricDisplayMode.init(rawValue:)) ?? .usage
}
}
public func displayMode(for metric: MetricKind) -> MetricDisplayMode {
displayModes[metric] ?? .usage
}
public func setDisplayMode(_ mode: MetricDisplayMode, for metric: MetricKind) {
guard metric.supportsDisplayModes else { return }
displayModes[metric] = mode
defaults.set(mode.rawValue, forKey: Self.key(metric))
}
private static func key(_ metric: MetricKind) -> String {
"onyx.metric.\(metric.rawValue).displayMode"
}
/// Meldet Bedarf an. Der Rückgabewert wird zum Abmelden gebraucht.
@discardableResult
public func addConsumer(interval: TimeInterval = 2) -> UUID {
let token = UUID()
demands[token] = min(max(interval, 1), 10)
restartTimer()
return token
}
public func removeConsumer(_ token: UUID) {
demands[token] = nil
restartTimer()
}
/// Ändert das Intervall eines bestehenden Konsumenten, ohne die Schleife
/// anzuhalten etwa wenn das Panel aufgeht und schneller messen will.
public func updateConsumer(_ token: UUID, interval: TimeInterval) {
guard demands[token] != nil else { return }
demands[token] = min(max(interval, 1), 10)
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = nil
guard let interval = demands.values.min() else {
history.removeAll()
return
}
sample()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.sample() }
}
// `.common`, sonst steht die Messung, während ein Menü offen ist.
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
history.append(value)
if history.count > Self.historyLength { history.removeFirst(history.count - Self.historyLength) }
}
}
public extension MetricsModel {
/// Verlauf einer einzelnen Größe, für die Mini-Graphen.
func series(_ metric: MetricKind) -> [Double] {
let mode = displayMode(for: metric)
return history.compactMap { snapshot in
if mode == .temperature, metric.supportsDisplayModes {
let celsius = metric == .cpu ? snapshot.cpu.temperature : snapshot.gpuTemperature
return celsius.map { $0 / 100 }
}
switch metric {
case .cpu: return snapshot.cpu.total
case .gpu: return snapshot.gpu
case .memory: return snapshot.memory.usedFraction
case .battery: return snapshot.battery?.charge
case .temperature: return snapshot.sensors.map(\.celsius).max().map { $0 / 100 }
}
}
}
}
/// Was ein CPU- oder GPU-Element zeigt.
///
/// Beide Werte kommen aus derselben Messung die Umschaltung kostet nichts und
/// erspart ein zweites Element in einer ohnehin knappen Menüleiste.
public enum MetricDisplayMode: String, Codable, Sendable, CaseIterable, Identifiable {
case usage
case temperature
public var id: String { rawValue }
}
public enum MetricKind: String, CaseIterable, Sendable, Identifiable {
case cpu, gpu, memory, battery, temperature
public var id: String { rawValue }
public var symbolName: String {
switch self {
case .cpu: "cpu"
case .gpu: "cpu.fill"
case .memory: "memorychip"
case .battery: "battery.100"
case .temperature: "thermometer"
}
}
/// Nur CPU und GPU haben beides. Speicher und Akku haben keine eigene
/// Temperatur, die Sensorkarte zeigt ohnehin nur Temperaturen.
public var supportsDisplayModes: Bool { self == .cpu || self == .gpu }
}

View File

@@ -0,0 +1,199 @@
import Foundation
import IOKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "SMC")
/// Liest Werte aus dem System Management Controller. **Ausschließlich lesend.**
///
/// Es gibt hier bewusst keinen Schreibpfad. Lüfterdrehzahl und Ladegrenze
/// verlangen root und gehören in den privilegierten Helfer aus Phase 6 nicht
/// in einen Typ, der im normalen App-Prozess läuft und von jedem Widget
/// erreichbar ist.
///
/// Die Struktur und die Erkenntnisse stammen aus `docs/spikes/A-smc.md`, gemessen
/// auf Mac17,9. Wichtigster Punkt von dort: **alle Mehrbyte-Werte sind
/// little-endian.** Der verbreitete Beispielcode nimmt big-endian an er
/// stammt aus der Intel-Ära und liefert auf Apple Silicon Unsinn.
public final class SMCReader: @unchecked Sendable {
// MARK: - Aufbau des AppleSMC-UserClients
private struct Version { var major: UInt8 = 0; var minor: UInt8 = 0
var build: UInt8 = 0; var reserved: UInt8 = 0
var release: UInt16 = 0 }
private struct PLimitData { var version: UInt16 = 0; var length: UInt16 = 0
var cpuPLimit: UInt32 = 0; var gpuPLimit: UInt32 = 0
var memPLimit: UInt32 = 0 }
private struct KeyInfo { var dataSize: UInt32 = 0; var dataType: UInt32 = 0
var dataAttributes: UInt8 = 0 }
private struct Param {
var key: UInt32 = 0
var vers = Version()
var pLimitData = PLimitData()
var keyInfo = KeyInfo()
var padding: UInt16 = 0
var result: UInt8 = 0
var status: UInt8 = 0
var data8: UInt8 = 0
var data32: UInt32 = 0
var bytes: (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8) =
(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
}
private static let handleYPCEvent: UInt32 = 2
private static let readKey: UInt8 = 5
private static let getKeyFromIndex: UInt8 = 8
private static let getKeyInfo: UInt8 = 9
private var connection: io_connect_t = 0
private let lock = NSLock()
/// Typ und Größe je Key. Beides steht in der Firmware fest und ändert sich
/// zur Laufzeit nicht es bei jedem Lesen erneut zu erfragen verdoppelt
/// die Zahl der IOKit-Aufrufe. Bei fünfzig Temperaturfühlern im Sekundentakt
/// ist das der Unterschied zwischen spürbar und unmerklich.
private var keyInfoCache: [String: KeyInfo] = [:]
public init?() {
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSMC"))
guard service != 0 else {
log.error("AppleSMC nicht gefunden — Sensoren bleiben leer")
return nil
}
defer { IOObjectRelease(service) }
guard IOServiceOpen(service, mach_task_self_, 0, &connection) == kIOReturnSuccess else {
log.error("AppleSMC nicht zu öffnen")
return nil
}
}
deinit { if connection != 0 { IOServiceClose(connection) } }
// MARK: - Lesen
/// Ein `flt`-Wert (Lüfterdrehzahl, Temperatur, Leistung).
public func float(_ key: String) -> Double? {
guard let value = read(key), value.type == "flt ", value.bytes.count >= 4 else { return nil }
// Little-endian, siehe Klassenkommentar.
let bits = UInt32(value.bytes[3]) << 24 | UInt32(value.bytes[2]) << 16
| UInt32(value.bytes[1]) << 8 | UInt32(value.bytes[0])
return Double(Float(bitPattern: bits))
}
/// Ein vorzeichenloser Ganzzahlwert beliebiger Breite.
public func integer(_ key: String) -> UInt64? {
guard let value = read(key) else { return nil }
var result: UInt64 = 0
for (index, byte) in value.bytes.prefix(8).enumerated() {
result |= UInt64(byte) << (8 * index) // little-endian
}
return result
}
public func exists(_ key: String) -> Bool { read(key) != nil }
/// Alle Keys, deren Name mit einem der Präfixe beginnt.
///
/// Wird einmal beim Start aufgerufen. Fest verdrahtete Listen wären hier
/// falsch: welche Fühler es gibt, hängt am Modell auf dem M5 sind es
/// 285 Temperaturfühler, auf einem anderen Mac ganz andere.
public func keys(matching prefixes: [String]) -> [String] {
guard let total = keyCount() else { return [] }
var found: [String] = []
for index in 0..<UInt32(total) {
guard let name = key(atIndex: index) else { continue }
if prefixes.contains(where: name.hasPrefix) { found.append(name) }
}
return found
}
/// Die Anzahl der Keys **big-endian**.
///
/// Die Byte-Reihenfolge ist im SMC nicht durchgängig, und das ist keine
/// Nachlässigkeit dieser Klasse, sondern der Hardware: Messwerte wie `B0AV`
/// kommen little-endian aus dem Akku-Baustein, die Metadaten des SMC selbst
/// dagegen big-endian. `#KEY` little-endian gelesen ergibt 2 651 652 096
/// statt 3486 die Suche brach damit still ab und alle Temperaturen
/// blieben leer.
private func keyCount() -> UInt32? {
guard let value = read("#KEY"), value.bytes.count >= 4 else { return nil }
let count = UInt32(value.bytes[0]) << 24 | UInt32(value.bytes[1]) << 16
| UInt32(value.bytes[2]) << 8 | UInt32(value.bytes[3])
return (0..<100_000).contains(count) ? count : nil
}
private func key(atIndex index: UInt32) -> String? {
lock.lock()
defer { lock.unlock() }
var command = Param()
command.data8 = Self.getKeyFromIndex
command.data32 = index
guard let output = call(command) else { return nil }
return Self.fourCCString(output.key)
}
private struct Value { let type: String; let bytes: [UInt8] }
private func read(_ key: String) -> Value? {
lock.lock()
defer { lock.unlock() }
let described: KeyInfo
if let cached = keyInfoCache[key] {
described = cached
} else {
var info = Param()
info.key = Self.fourCC(key)
info.data8 = Self.getKeyInfo
guard let result = call(info) else { return nil }
described = result.keyInfo
keyInfoCache[key] = described
}
var command = Param()
command.key = Self.fourCC(key)
command.keyInfo = described
command.data8 = Self.readKey
guard let output = call(command) else { return nil }
let size = Int(min(described.dataSize, 32))
let all = withUnsafeBytes(of: output.bytes) { Array($0) }
return Value(type: Self.fourCCString(described.dataType),
bytes: Array(all.prefix(size)))
}
private func call(_ input: Param) -> Param? {
var input = input
var output = Param()
var size = MemoryLayout<Param>.stride
let result = IOConnectCallStructMethod(connection, Self.handleYPCEvent,
&input, MemoryLayout<Param>.stride,
&output, &size)
// Ein nicht vorhandener Key meldet sich über `result` und ist kein
// Fehler: welche Keys es gibt, unterscheidet sich je nach Modell.
guard result == kIOReturnSuccess, output.result == 0 else { return nil }
return output
}
private static func fourCC(_ string: String) -> UInt32 {
var value: UInt32 = 0
for character in string.utf8.prefix(4) { value = value << 8 | UInt32(character) }
return value
}
private static func fourCCString(_ value: UInt32) -> String {
let bytes = [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF),
UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)]
return String(bytes: bytes, encoding: .ascii) ?? "????"
}
}

View File

@@ -0,0 +1,483 @@
import Foundation
import IOKit
import IOKit.ps
import Darwin
/// Ein vollständiger Messwertsatz.
public struct MetricsSnapshot: Equatable, Sendable {
public var cpu = CPUReading()
public var gpu: Double?
/// Wärmster Punkt der GPU in Grad Celsius.
public var gpuTemperature: Double?
public var memory = MemoryReading()
public var battery: BatteryReading?
public var sensors: [SensorReading] = []
public var fans: [FanReading] = []
public var power = PowerReading()
public var topMemoryProcesses: [ProcessMemory] = []
public var asOf = Date()
}
/// Wohin die Leistung geht.
///
/// Die Schlüssel sind gegen den Aktivitätsmonitor und gegen iStat Menus
/// abgeglichen: `PHPC` deckt sich mit deren CPU-Wert, `PSTR` mit der
/// Gesamtleistung.
public struct PowerReading: Equatable, Sendable {
public var total: Double?
public var cpu: Double?
public var display: Double?
public var adapter: Double?
public var busVoltage: Double?
}
public struct CPUReading: Equatable, Sendable {
/// Gesamtauslastung, 0 bis 1.
public var total: Double = 0
/// Auslastung je Kern, in der Reihenfolge des Systems.
public var perCore: [Double] = []
/// Leistungsaufnahme in Watt, falls messbar.
public var watts: Double?
/// Der wärmste Kern in Grad Celsius.
///
/// Apple Silicon hat pro Kern einen Fühler auf dem M5 Pro sind es
/// über zwanzig. Der Höchstwert ist der aussagekräftige: gedrosselt wird
/// nach dem heißesten Punkt, nicht nach dem Durchschnitt.
public var temperature: Double?
}
public struct MemoryReading: Equatable, Sendable {
public var used: UInt64 = 0
public var total: UInt64 = 0
public var compressed: UInt64 = 0
public var swapUsed: UInt64 = 0
public var swapTotal: UInt64 = 0
/// Von Programmen belegt (aktiv + inaktiv).
public var app: UInt64 = 0
/// Vom Kernel festgehalten, nicht auslagerbar.
public var wired: UInt64 = 0
public var free: UInt64 = 0
public var pressure: MemoryPressure = .normal
public var usedFraction: Double {
total > 0 ? Double(used) / Double(total) : 0
}
}
public struct BatteryReading: Equatable, Sendable {
public var charge: Double = 0 // 0 bis 1
public var isCharging = false
public var isPluggedIn = false
public var cycleCount: Int?
public var health: Double? // 0 bis 1
public var watts: Double? // negativ = Entladung
public var timeRemaining: TimeInterval?
/// Energiesparmodus. Erklärt, warum die Maschine langsamer wirkt und
/// gehört deshalb sichtbar dorthin, wo man nach dem Grund sucht.
public var isLowPower = false
/// Temperatur der Zelle, falls der SMC sie hergibt.
public var celsius: Double?
}
/// Ein Programm mit seinem Speicherverbrauch.
public struct ProcessMemory: Equatable, Sendable, Identifiable {
public let pid: pid_t
public let name: String
public let bytes: UInt64
public var id: pid_t { pid }
}
public struct SensorReading: Equatable, Sendable, Identifiable {
public let key: String
public let name: String
public let celsius: Double
public var id: String { key }
}
public struct FanReading: Equatable, Sendable, Identifiable {
public let index: Int
public let name: String
public let rpm: Double
public let minimum: Double
public let maximum: Double
public var id: Int { index }
/// Anteil zwischen Minimum und Maximum, 0 bis 1.
public var fraction: Double {
guard maximum > minimum else { return 0 }
return min(max((rpm - minimum) / (maximum - minimum), 0), 1)
}
}
/// Liest die Hardwarewerte des Systems.
///
/// Bewusst kein Singleton mit Timer: das Abtasten steuert `MetricsModel`, weil
/// nur dort bekannt ist, wie viele Konsumenten es gerade gibt und wie schnell
/// sie es brauchen.
public final class SystemMetrics: @unchecked Sendable {
private let smc = SMCReader()
private var previousCPUTicks: [CPUTicks] = []
/// Welche Kern- und GPU-Fühler dieses Modell hat. `Tp*` sind die
/// CPU-Kerne, `Tg*` die GPU-Cluster die Konvention gilt für die ganze
/// M-Serie.
///
/// Die Suche läuft im Hintergrund: sie geht alle 3486 Keys durch und
/// braucht dafür knapp eine Sekunde. Auf dem Hauptthread wäre das eine
/// spürbare Startverzögerung für etwas, das nach dem ersten Messwert
/// niemandem auffällt.
private var temperatureKeys: (cpu: [String], gpu: [String]) = ([], [])
private let keysLock = NSLock()
/// Zwischenspeicher für die Temperaturen.
///
/// Fünfzig Fühler zu lesen kostet gut 15 ms. Bei 1 Hz wären das anderthalb
/// Prozent CPU für Werte, die sich in einer Sekunde kaum bewegen deshalb
/// höchstens alle zwei Sekunden, unabhängig von der Abtastrate ringsum.
private var cachedTemperatures: (cpu: Double?, gpu: Double?) = (nil, nil)
private var temperaturesReadAt = Date.distantPast
private static let temperatureInterval: TimeInterval = 2
/// Seit dem Start unveränderlich einmal ermitteln reicht.
private static let pageSize: UInt64 = {
var size: UInt64 = 0
var length = MemoryLayout<UInt64>.size
guard sysctlbyname("hw.pagesize", &size, &length, nil, 0) == 0, size > 0 else {
return 16384 // Apple Silicon
}
return size
}()
public init() {
// Nicht im Initialisierer blockieren: der läuft beim App-Start.
// `DispatchQueue` statt `Task.detached`: die Suche ist blockierende
// IOKit-Arbeit ohne Suspendierungspunkt, sie gehört nicht in den
// Nebenläufigkeitspool von Swift und `NSLock` ist aus einem
// asynchronen Kontext ohnehin nicht erlaubt.
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self, let smc else { return }
let cpu = smc.keys(matching: ["Tp"])
let gpu = smc.keys(matching: ["Tg"])
keysLock.lock()
temperatureKeys = (cpu, gpu)
keysLock.unlock()
}
}
public func sample() -> MetricsSnapshot {
var snapshot = MetricsSnapshot()
snapshot.cpu = readCPU()
snapshot.gpu = readGPU()
snapshot.memory = readMemory()
snapshot.battery = readBattery()
snapshot.sensors = readSensors()
snapshot.fans = readFans()
snapshot.topMemoryProcesses = readTopMemoryProcesses()
snapshot.power = readPower()
snapshot.cpu.watts = snapshot.power.cpu ?? snapshot.power.total
let temperatures = readTemperatures()
snapshot.cpu.temperature = temperatures.cpu
snapshot.gpuTemperature = temperatures.gpu
return snapshot
}
// MARK: - CPU
private func readCPU() -> CPUReading {
var count: natural_t = 0
var info: processor_info_array_t?
var infoCount: mach_msg_type_number_t = 0
guard host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO,
&count, &info, &infoCount) == KERN_SUCCESS,
let info else { return CPUReading() }
defer {
vm_deallocate(mach_task_self_, vm_address_t(bitPattern: info),
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride))
}
var current: [CPUTicks] = []
current.reserveCapacity(Int(count))
for core in 0..<Int(count) {
let base = core * Int(CPU_STATE_MAX)
current.append(CPUTicks(
user: UInt32(bitPattern: info[base + Int(CPU_STATE_USER)]),
system: UInt32(bitPattern: info[base + Int(CPU_STATE_SYSTEM)]),
idle: UInt32(bitPattern: info[base + Int(CPU_STATE_IDLE)]),
nice: UInt32(bitPattern: info[base + Int(CPU_STATE_NICE)])))
}
// Die erste Abtastung hat keinen Vorgänger und kann deshalb keine
// Auslastung ergeben. Null zu zeigen ist ehrlicher als der Anteil seit
// dem Systemstart, den man sonst bekäme der liegt immer bei ein paar
// Prozent und sieht aus wie ein Messwert.
defer { previousCPUTicks = current }
guard previousCPUTicks.count == current.count else { return CPUReading() }
let perCore = zip(previousCPUTicks, current).map(CPUTicks.usage(from:to:))
return CPUReading(total: perCore.isEmpty ? 0 : perCore.reduce(0, +) / Double(perCore.count),
perCore: perCore,
watts: nil)
}
// MARK: - GPU
private func readGPU() -> Double? {
var iterator: io_iterator_t = 0
guard IOServiceGetMatchingServices(kIOMainPortDefault,
IOServiceMatching("IOAccelerator"),
&iterator) == kIOReturnSuccess else { return nil }
defer { IOObjectRelease(iterator) }
while case let entry = IOIteratorNext(iterator), entry != 0 {
defer { IOObjectRelease(entry) }
var unmanaged: Unmanaged<CFMutableDictionary>?
guard IORegistryEntryCreateCFProperties(entry, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any],
let statistics = properties["PerformanceStatistics"] as? [String: Any]
else { continue }
// Der Schlüssel heißt je nach Gerät unterschiedlich; auf Apple
// Silicon ist es Device Utilization %".
for name in ["Device Utilization %", "GPU Activity(%)", "Renderer Utilization %"] {
if let value = statistics[name] as? Int { return Double(value) / 100 }
}
}
return nil
}
// MARK: - Arbeitsspeicher
private func readMemory() -> MemoryReading {
var statistics = vm_statistics64_data_t()
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size
/ MemoryLayout<integer_t>.size)
let result = withUnsafeMutablePointer(to: &statistics) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics64(mach_host_self(), HOST_VM_INFO64, $0, &count)
}
}
guard result == KERN_SUCCESS else { return MemoryReading() }
// `vm_kernel_page_size` ist eine globale Variable und damit unter
// strenger Nebenläufigkeitsprüfung tabu. Der Wert steht seit dem Start
// fest, also einmal über sysctl holen.
let pageSize = Self.pageSize
let total = ProcessInfo.processInfo.physicalMemory
let compressed = UInt64(statistics.compressor_page_count) * pageSize
// Benutzt" wie im Aktivitätsmonitor: aktiv, verdrahtet und komprimiert.
// Der Dateicache (`external_page_count`) zählt nicht mit er wird bei
// Bedarf freigegeben, und ihn mitzuzählen ließe jeden Mac dauernd voll
// aussehen.
let used = UInt64(statistics.active_count) * pageSize
+ UInt64(statistics.wire_count) * pageSize
+ compressed
var swap = xsw_usage()
var swapSize = MemoryLayout<xsw_usage>.size
sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
let app = UInt64(statistics.active_count + statistics.inactive_count) * pageSize
let wired = UInt64(statistics.wire_count) * pageSize
let freeBytes = UInt64(statistics.free_count) * pageSize
let free = total > 0 ? 1 - Double(used) / Double(total) : 0
return MemoryReading(used: used, total: total, compressed: compressed,
swapUsed: swap.xsu_used, swapTotal: swap.xsu_total,
app: app, wired: wired, free: freeBytes,
pressure: MemoryPressure.classify(
free: free,
compressed: total > 0 ? Double(compressed) / Double(total) : 0,
swapUsed: swap.xsu_used))
}
// MARK: - Akku
private func readBattery() -> BatteryReading? {
guard let blob = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let sources = IOPSCopyPowerSourcesList(blob)?.takeRetainedValue() as? [CFTypeRef],
let source = sources.first,
let description = IOPSGetPowerSourceDescription(blob, source)?
.takeUnretainedValue() as? [String: Any] else { return nil }
var reading = BatteryReading()
if let current = description[kIOPSCurrentCapacityKey] as? Int,
let maximum = description[kIOPSMaxCapacityKey] as? Int, maximum > 0 {
reading.charge = Double(current) / Double(maximum)
}
reading.isCharging = description[kIOPSIsChargingKey] as? Bool ?? false
reading.isPluggedIn = (description[kIOPSPowerSourceStateKey] as? String)
== kIOPSACPowerValue
if let seconds = description[kIOPSTimeToEmptyKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
if let seconds = description[kIOPSTimeToFullChargeKey] as? Int, seconds > 0 {
reading.timeRemaining = TimeInterval(seconds * 60)
}
// Zyklen und Gesundheit stehen nicht in der Power-Source-Beschreibung,
// sondern in der Batterie selbst.
let service = IOServiceGetMatchingService(kIOMainPortDefault,
IOServiceMatching("AppleSmartBattery"))
if service != 0 {
defer { IOObjectRelease(service) }
var unmanaged: Unmanaged<CFMutableDictionary>?
if IORegistryEntryCreateCFProperties(service, &unmanaged, kCFAllocatorDefault, 0)
== kIOReturnSuccess,
let properties = unmanaged?.takeRetainedValue() as? [String: Any] {
reading.cycleCount = properties["CycleCount"] as? Int
if let maximum = properties["MaxCapacity"] as? Int, maximum > 0 {
reading.health = Double(maximum) / 100
}
}
}
// Leistung aus dem SMC: positiv beim Laden, negativ beim Entladen.
reading.isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled
// TB0T ist die Akkutemperatur; nicht jede Maschine führt sie.
reading.celsius = smc?.float("TB0T").map { Double($0) }
if let watts = smc?.float("PPBR") {
reading.watts = reading.isCharging ? watts : -watts
}
return reading
}
/// Bei zwei Lüftern sind es links und rechts so beschriftet Apple sie
/// selbst. Bei einer anderen Anzahl bleibt die Nummer, weil die Anordnung
/// dann nicht bekannt ist.
private static func fanName(index: Int, of count: Int) -> String {
guard count == 2 else { return "Lüfter \(index + 1)" }
return index == 0 ? "Lüfter links" : "Lüfter rechts"
}
// MARK: - Leistung
private func readPower() -> PowerReading {
guard let smc else { return PowerReading() }
return PowerReading(total: smc.float("PSTR"),
cpu: smc.float("PHPC"),
display: smc.float("PDBR"),
adapter: smc.float("PDTR"),
busVoltage: smc.float("VP0R"))
}
// MARK: - Speicher je Programm
/// Die größten Speicherverbraucher.
///
/// `phys_footprint` ist derselbe Wert, den der Aktivitätsmonitor unter
/// Speicher" zeigt nicht der virtuelle Adressraum, der bei modernen
/// Programmen sinnlos groß ist. Fremde Nutzer bleiben außen vor: dafür
/// bräuchte es root, und für einen Blick aufs eigene System reicht das hier.
private func readTopMemoryProcesses(limit: Int = 5) -> [ProcessMemory] {
var pids = [pid_t](repeating: 0, count: 4096)
let size = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids,
Int32(pids.count * MemoryLayout<pid_t>.size))
guard size > 0 else { return [] }
let count = Int(size) / MemoryLayout<pid_t>.size
var results: [ProcessMemory] = []
for pid in pids.prefix(count) where pid > 0 {
var usage = rusage_info_v4()
let result = withUnsafeMutablePointer(to: &usage) {
$0.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
proc_pid_rusage(pid, RUSAGE_INFO_V4, $0)
}
}
guard result == 0, usage.ri_phys_footprint > 64 * 1024 * 1024 else { continue }
var nameBuffer = [CChar](repeating: 0, count: Int(MAXPATHLEN))
proc_name(pid, &nameBuffer, UInt32(nameBuffer.count))
let name = String(cString: nameBuffer)
guard !name.isEmpty else { continue }
results.append(ProcessMemory(pid: pid, name: name, bytes: usage.ri_phys_footprint))
}
return Array(results.sorted { $0.bytes > $1.bytes }.prefix(limit))
}
// MARK: - Sensoren und Lüfter
/// Die Temperaturfühler, die auf `Mac17,9` tatsächlich existieren.
///
/// Bewusst eine kurze Liste statt aller 3486 SMC-Keys: die meisten sind
/// unbenannt, und eine Wand aus Kürzeln ist keine Information. Was fehlt,
/// fällt beim Lesen einfach weg.
private static let temperatureKeys: [(String, String)] = [
("TB0T", "Akku"),
("Ts0P", "Handballenauflage"),
("Ts1P", "Gehäuse hinten"),
("TW0P", "WLAN"),
("TH0x", "SSD"),
("TCMb", "Luftstrom"),
("TDeL", "Thunderbolt links"),
("TDeR", "Thunderbolt rechts"),
]
private func readTemperatures() -> (cpu: Double?, gpu: Double?) {
guard Date().timeIntervalSince(temperaturesReadAt) >= Self.temperatureInterval else {
return cachedTemperatures
}
keysLock.lock()
let keys = temperatureKeys
keysLock.unlock()
// Solange die Suche im Hintergrund läuft, bleiben die Werte leer die
// Oberfläche zeigt dann " statt einer erfundenen Zahl.
guard !keys.cpu.isEmpty || !keys.gpu.isEmpty else { return (nil, nil) }
cachedTemperatures = (hottest(of: keys.cpu), hottest(of: keys.gpu))
temperaturesReadAt = Date()
return cachedTemperatures
}
/// Der höchste plausible Messwert einer Fühlergruppe.
///
/// Die Plausibilitätsgrenze ist nötig: einzelne Fühler liefern gelegentlich
/// 0 oder Werte jenseits von 200 °C, und ein einziger Ausreißer würde die
/// Anzeige übernehmen, weil hier das Maximum gebildet wird.
private func hottest(of keys: [String]) -> Double? {
guard let smc, !keys.isEmpty else { return nil }
return keys.compactMap { key -> Double? in
guard let value = smc.float(key), value > 5, value < 150 else { return nil }
return value
}.max()
}
private func readSensors() -> [SensorReading] {
guard let smc else { return [] }
let named = Self.temperatureKeys.compactMap { key, name -> SensorReading? in
guard let celsius = smc.float(key), celsius > 0, celsius < 150 else { return nil }
return SensorReading(key: key, name: name, celsius: celsius)
}
// CPU und GPU nach vorn: das sind die Werte, wegen derer man hinschaut.
let temperatures = readTemperatures()
var result: [SensorReading] = []
if let cpu = temperatures.cpu {
result.append(SensorReading(key: "cpu", name: "CPU-Kerne", celsius: cpu))
}
if let gpu = temperatures.gpu {
result.append(SensorReading(key: "gpu", name: "Grafik", celsius: gpu))
}
return result + named
}
private func readFans() -> [FanReading] {
guard let smc, let count = smc.integer("FNum"), count > 0 else { return [] }
return (0..<Int(count)).compactMap { index in
guard let rpm = smc.float("F\(index)Ac") else { return nil }
return FanReading(index: index,
name: Self.fanName(index: index, of: Int(count)),
rpm: rpm,
minimum: smc.float("F\(index)Mn") ?? 0,
maximum: smc.float("F\(index)Mx") ?? 0)
}
}
}

View File

@@ -0,0 +1,376 @@
{
"sourceLanguage": "en",
"strings": {
"menubar.short.network": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Netz"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Net"
}
}
}
},
"network.addresses": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Adressen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Addresses"
}
}
}
},
"network.download": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Download"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Download"
}
}
}
},
"network.field.interface": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Schnittstelle"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Interface"
}
}
}
},
"network.field.ipv4": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "IP"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "IP"
}
}
}
},
"network.field.ipv6": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "IPv6"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "IPv6"
}
}
}
},
"network.field.router": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Router"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Router"
}
}
}
},
"network.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Netzwerk"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Network"
}
}
}
},
"network.offline": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nicht verbunden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Not connected"
}
}
}
},
"network.peak.download": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Spitze ↓"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Peak ↓"
}
}
}
},
"network.peak.upload": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Spitze ↑"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Peak ↑"
}
}
}
},
"network.perProcess.unavailable": {
"extractionState": "stale",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Durchsatz je Programm zeigt Onyx nicht: dafür gibt es ohne root keine Schnittstelle."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx doesn't show per-app throughput: there is no interface for it without root."
}
}
}
},
"network.processes": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Programme"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Per app"
}
}
}
},
"network.public": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffentliche Adresse"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Public address"
}
}
}
},
"network.public.enable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Öffentliche IP anzeigen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Show public IP"
}
}
}
},
"network.public.failed": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Nicht abrufbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Unavailable"
}
}
}
},
"network.public.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Dafür fragt Onyx einen fremden Dienst — standardmäßig aus, weil ein Systemmonitor nicht ungefragt nach draußen telefonieren sollte."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "This asks an external service — off by default, because a system monitor shouldn't phone home unasked."
}
}
}
},
"network.total.received": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Empfangen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Received"
}
}
}
},
"network.total.sent": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Gesendet"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Sent"
}
}
}
},
"network.transferred": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Übertragen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Transferred"
}
}
}
},
"network.upload": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Upload"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Upload"
}
}
}
},
"network.vpn.active": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Verkehr läuft durch einen Tunnel"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Traffic goes through a tunnel"
}
}
}
},
"network.wifi.connected": {
"comment": "Wenn der Netzwerkname mangels Ortungsberechtigung fehlt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "WLAN verbunden"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Wi-Fi connected"
}
}
}
}
},
"version": "1.0"
}

View File

@@ -0,0 +1,153 @@
import Foundation
import OSLog
import MetricsProvider
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Network")
/// Durchsatz je Programm.
public struct ProcessTraffic: Equatable, Sendable, Identifiable {
public let name: String
public let pid: pid_t
public let downloadRate: Double
public let uploadRate: Double
public var id: pid_t { pid }
}
/// Liest den Durchsatz je Programm über `nettop`.
///
/// Es gibt dafür keine Bibliotheksschnittstelle die zugrundeliegende
/// `NetworkStatistics`-API ist privat und verlangt eine Entitlement, die Apple
/// nicht vergibt. `nettop` ist Apples eigenes Werkzeug dafür, liegt im System
/// und läuft **ohne root** für die Prozesse des eigenen Nutzers.
///
/// Deshalb wird es nur abgefragt, solange jemand hinschaut: einen
/// Fremdprozess im Sekundentakt zu starten wäre teurer als der gesamte übrige
/// Monitor zusammen.
public final class ProcessTrafficSampler: @unchecked Sendable {
private var previous: [pid_t: (received: UInt64, sent: UInt64)] = [:]
private var previousTime: Date?
private let lock = NSLock()
public init() {}
public func sample(limit: Int = 6) -> [ProcessTraffic] {
guard let output = runNettop() else { return [] }
let now = Date()
lock.lock()
defer { lock.unlock() }
let elapsed = previousTime.map { now.timeIntervalSince($0) } ?? 0
var current: [pid_t: (received: UInt64, sent: UInt64)] = [:]
var results: [ProcessTraffic] = []
// Format: `name.pid,bytes_in,bytes_out,`
for line in output.split(separator: "\n").dropFirst() {
let fields = line.split(separator: ",", omittingEmptySubsequences: false)
guard fields.count >= 3 else { continue }
let identifier = String(fields[0])
guard let separator = identifier.lastIndex(of: "."),
let pid = pid_t(identifier[identifier.index(after: separator)...]),
let received = UInt64(fields[1]), let sent = UInt64(fields[2])
else { continue }
let name = String(identifier[..<separator])
current[pid] = (received, sent)
guard elapsed > 0, let before = previous[pid] else { continue }
let down = Throughput.rate(previous: before.received, current: received, elapsed: elapsed)
let up = Throughput.rate(previous: before.sent, current: sent, elapsed: elapsed)
guard down + up > 0 else { continue }
results.append(ProcessTraffic(name: name, pid: pid,
downloadRate: down, uploadRate: up))
}
previous = current
previousTime = now
return Array(results.sorted { $0.downloadRate + $0.uploadRate
> $1.downloadRate + $1.uploadRate }.prefix(limit))
}
private func runNettop() -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
// `-P` fasst je Prozess zusammen, `-x` unterdrückt Einheiten, `-L 1`
// nimmt genau eine Messung und beendet sich dann.
process.arguments = ["-P", "-x", "-L", "1", "-J", "bytes_in,bytes_out"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
do {
try process.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
return String(data: data, encoding: .utf8)
} catch {
log.error("nettop nicht ausführbar: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
/// Ermittelt die öffentliche IP-Adresse.
///
/// **Standardmäßig aus.** Dafür geht eine Anfrage an einen fremden Dienst, und
/// wer einen Systemmonitor benutzt, rechnet nicht damit, dass der nach draußen
/// telefoniert. Wird sie eingeschaltet, steht der benutzte Dienst in der
/// Oberfläche man soll nachlesen können, wem die Adresse bekannt wird.
@MainActor
@Observable
public final class PublicAddressLookup {
public private(set) var address: String?
public private(set) var isLoading = false
public private(set) var failed = false
/// Der Dienst, der gefragt wird. Bewusst als sichtbare Angabe und nicht
/// versteckt im Code.
public static let serviceName = "api.ipify.org"
private static let endpoint = URL(string: "https://api.ipify.org?format=json")!
private var lastFetch: Date?
/// Mindestabstand zwischen zwei Abfragen. Die öffentliche Adresse ändert
/// sich selten; sie bei jedem Öffnen des Popovers neu zu holen wäre
/// unnötiger Verkehr nach draußen.
private static let minimumInterval: TimeInterval = 15 * 60
public init() {}
public func refresh(force: Bool = false) {
if !force, let last = lastFetch,
Date().timeIntervalSince(last) < Self.minimumInterval { return }
guard !isLoading else { return }
isLoading = true
failed = false
Task { [weak self] in
defer { Task { @MainActor in self?.isLoading = false } }
do {
let (data, _) = try await URLSession.shared.data(from: Self.endpoint)
let decoded = try JSONDecoder().decode([String: String].self, from: data)
await MainActor.run {
self?.address = decoded["ip"]
self?.lastFetch = Date()
}
} catch {
log.error("Öffentliche IP nicht abrufbar: \(error.localizedDescription, privacy: .public)")
await MainActor.run { self?.failed = true }
}
}
}
/// Vergisst die Adresse beim Abschalten der Funktion.
public func clear() {
address = nil
lastFetch = nil
failed = false
}
}

View File

@@ -0,0 +1,206 @@
import AppKit
import SwiftUI
import OnyxMenuBar
import MetricsProvider
/// Das Netzwerk in der Menüleiste.
///
/// Anders als die Hardwaregrößen hat es **zwei** Werte, die beide interessieren.
/// Sie untereinander zu setzen ist der einzige Weg, beide in der Höhe einer
/// Menüleiste unterzubringen nebeneinander bräuchte es die doppelte Breite,
/// und die hat man dort nicht.
@MainActor
public final class NetworkMenuBarModule: MenuBarModule {
public let id = "network"
public var displayName: String { String(localized: "network.name", bundle: .module) }
private let model: NetworkModel
private var token: UUID?
private var view: NetworkStatusView?
public init(model: NetworkModel) { self.model = model }
public var shortLabel: String { String(localized: "menubar.short.network", bundle: .module) }
public func makeStatusView(presentation: MenuBarPresentation) -> NSView {
let view = NetworkStatusView(presentation: presentation)
view.update(model.snapshot, history: model.series(download: true))
self.view = view
return view
}
public func makePopoverView() -> AnyView {
AnyView(NetworkPopoverContent(model: model))
}
public func activate() {
guard token == nil else { return }
token = model.addConsumer(interval: 2)
startObserving()
}
public func deactivate() {
if let token { model.removeConsumer(token) }
token = nil
view = nil
}
private func startObserving() {
withObservationTracking {
_ = model.snapshot
} onChange: {
Task { @MainActor [weak self] in
guard let self, token != nil else { return }
view?.update(model.snapshot, history: model.series(download: true))
startObserving()
}
}
}
}
final class NetworkStatusView: NSView, WidthReporting {
private let presentation: MenuBarPresentation
private var snapshot = NetworkSnapshot()
private var history: [Double] = []
init(presentation: MenuBarPresentation) {
self.presentation = presentation
super.init(frame: NSRect(x: 0, y: 0, width: MenuBarText.minimumWidth, height: 22))
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
/// Breiter als die Hardwaremodule: 2,4 MB/s" braucht schlicht mehr Platz
/// als 46 %".
/// Der Platz für die **aktuellen** Raten.
///
/// Auf 999,9 MB/s" zu dimensionieren hieße, neben 13 KB/s" dauerhaft
/// die halbe Breite leer zu lassen und genau so sah es aus. Gemessen wird
/// deshalb, was gerade dasteht; `AdaptiveWidth` sorgt dafür, dass die
/// Leiste dabei nicht zappelt.
/// Feste Breite, bemessen am breitesten Wert, der vorkommen kann.
///
/// Nicht am aktuellen: aus 14 KB/s" wird 431 KB/s", und mit jeder
/// zusätzlichen Stelle wanderte die halbe Menüleiste. 999 MB/s" kostet
/// gegenüber einem dreistelligen KB-Wert nur wenige Punkte das ist der
/// Preis dafür, dass nie etwas springt.
private static var ratesWidth: CGFloat {
let font = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
let widest = NSAttributedString(string: "↓ 999 MB/s", attributes: [.font: font])
return ceil(widest.size().width) + 2 * MenuBarText.sidePadding
}
private func neededWidth() -> CGFloat {
switch presentation {
case .symbol:
max(MenuBarText.minimumWidth,
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 15) + 4)
case .graph, .bars: 28
case .value: Self.ratesWidth
case .valueAndGraph: Self.ratesWidth + 26
// Die Raten tragen mit und ihre Beschriftung schon in sich; ein
// Kürzel davor wäre doppelt gemoppelt, ein Symbol dagegen nützlich
// es zeigt WLAN, Ethernet oder VPN.
case .labelAndValue, .symbolAndValue:
Self.ratesWidth + symbolSlot + MenuBarText.innerGap
}
}
private var symbolSlot: CGFloat {
MenuBarText.symbolWidth(snapshot.interfaceKind.symbolName, height: 13)
}
private var width: CGFloat = MenuBarText.minimumWidth
var onWidthChange: ((CGFloat) -> Void)?
override var intrinsicContentSize: NSSize {
NSSize(width: width, height: 22)
}
func update(_ snapshot: NetworkSnapshot, history: [Double]) {
self.snapshot = snapshot
self.history = history
needsDisplay = true
let next = max(neededWidth(), MenuBarText.minimumWidth)
guard next != width else { return }
width = next
invalidateIntrinsicContentSize()
onWidthChange?(next)
}
override func draw(_ dirtyRect: NSRect) {
let color = NSColor.labelColor
switch presentation {
case .symbol:
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
in: NSRect(x: bounds.midX - 7.5, y: bounds.midY - 7.5,
width: 15, height: 15))
case .graph, .bars:
drawGraph(color: color, in: bounds.insetBy(dx: 2, dy: 5))
case .labelAndValue, .symbolAndValue:
let glyph = symbolSlot
MenuBarText.drawSymbol(snapshot.interfaceKind.symbolName, color: color,
in: NSRect(x: 0, y: bounds.midY - 6.5,
width: glyph, height: 13))
drawRates(color: color,
in: NSRect(x: glyph + MenuBarText.innerGap, y: 0,
width: bounds.width - glyph - MenuBarText.innerGap,
height: bounds.height))
case .value, .valueAndGraph:
drawRates(color: color,
in: presentation == .value ? bounds
: NSRect(x: 0, y: 0, width: bounds.width - 26, height: bounds.height))
if presentation == .valueAndGraph {
drawGraph(color: color,
in: NSRect(x: bounds.width - 24, y: 5, width: 22, height: 12))
}
}
}
/// Zwei Zeilen à 9 pt die einzige Art, Hoch und Runter in 22 pt Höhe
/// unterzubringen.
private func drawRates(color: NSColor, in rect: NSRect) {
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular),
.foregroundColor: color,
]
let down = NSAttributedString(string: "" + Throughput.formatted(snapshot.downloadRate),
attributes: attributes)
let up = NSAttributedString(string: "" + Throughput.formatted(snapshot.uploadRate),
attributes: attributes)
// Mittig statt links: bleibt doch einmal Platz übrig etwa weil die
// zweite Zeile schmaler ist , verteilt er sich auf beide Seiten statt
// als Lücke rechts zu stehen.
down.draw(at: NSPoint(x: rect.midX - down.size().width / 2, y: rect.midY - 0.5))
up.draw(at: NSPoint(x: rect.midX - up.size().width / 2, y: rect.midY - 10.5))
}
private func drawGraph(color: NSColor, in rect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
// Die Schrittweite kommt aus der vollen Länge des Verlaufs, nicht aus
// der Anzahl vorhandener Werte: frisch gestartet sind es zwei statt
// sechzig, und daraus gerechnet spannte sich der Weg über die ganze
// Breite.
let xs = GraphLayout.positions(count: history.count,
capacity: NetworkModel.historyLength, in: rect)
guard !xs.isEmpty else { return }
context.setStrokeColor(color.withAlphaComponent(0.85).cgColor)
context.setLineWidth(1)
for (index, x) in xs.enumerated() {
let value = history[history.count - xs.count + index]
let point = CGPoint(x: x,
y: rect.minY + rect.height * CGFloat(min(max(value, 0), 1)))
index == 0 ? context.move(to: point) : context.addLine(to: point)
}
context.strokePath()
}
}

View File

@@ -0,0 +1,214 @@
import Foundation
import Darwin
import SystemConfiguration
import CoreWLAN
import MetricsProvider
public struct NetworkSnapshot: Equatable, Sendable {
/// Byte pro Sekunde.
public var downloadRate: Double = 0
public var uploadRate: Double = 0
/// Summen seit dem Systemstart.
public var totalReceived: UInt64 = 0
public var totalSent: UInt64 = 0
public var interfaceName: String?
public var interfaceKind: InterfaceKind = .unknown
public var ipv4: String?
public var ipv6: String?
public var router: String?
/// `nil`, solange die Ortungsberechtigung fehlt macOS gibt den Namen
/// sonst nicht heraus. Siehe `wifiName`.
public var ssid: String?
public var isWiFiConnected = false
public var asOf = Date()
}
public enum InterfaceKind: String, Equatable, Sendable {
case wifi, ethernet, vpn, cellular, loopback, unknown
public var symbolName: String {
switch self {
case .wifi: "wifi"
case .ethernet: "cable.connector"
case .vpn: "lock.shield"
case .cellular: "antenna.radiowaves.left.and.right"
case .loopback, .unknown: "network"
}
}
}
/// Liest Durchsatz, Adressen und Verbindungsart.
///
/// Alles ohne root und ohne private Schnittstellen. Der Durchsatz kommt aus den
/// Byte-Zählern des Kernels; sie sind Summen seit dem Start, die Rate ergibt
/// sich erst aus der Differenz siehe `Throughput.rate`.
public final class NetworkMetrics: @unchecked Sendable {
private var previousReceived: UInt64 = 0
private var previousSent: UInt64 = 0
private var previousSampleTime: Date?
public init() {}
public func sample() -> NetworkSnapshot {
var snapshot = NetworkSnapshot()
let counters = readCounters()
snapshot.totalReceived = counters.received
snapshot.totalSent = counters.sent
let now = Date()
if let previous = previousSampleTime {
let elapsed = now.timeIntervalSince(previous)
snapshot.downloadRate = Throughput.rate(previous: previousReceived,
current: counters.received, elapsed: elapsed)
snapshot.uploadRate = Throughput.rate(previous: previousSent,
current: counters.sent, elapsed: elapsed)
}
previousReceived = counters.received
previousSent = counters.sent
previousSampleTime = now
let primary = primaryInterface()
snapshot.interfaceName = primary.name
snapshot.interfaceKind = primary.kind
snapshot.router = primary.router
let addresses = localAddresses(for: primary.name)
snapshot.ipv4 = addresses.ipv4
snapshot.ipv6 = addresses.ipv6
snapshot.isWiFiConnected = primary.kind == .wifi
snapshot.ssid = wifiName()
return snapshot
}
// MARK: - Durchsatz
/// Summiert alle Interfaces außer Loopback.
///
/// Einzeln zu zählen wäre genauer, aber praktisch nutzlos: bei einem
/// Wechsel von WLAN auf VPN wandert der Verkehr auf ein anderes Interface,
/// und eine Anzeige, die dabei auf null fällt, ist falsch.
private func readCounters() -> (received: UInt64, sent: UInt64) {
var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, 0, NET_RT_IFLIST2, 0]
var length = 0
guard sysctl(&mib, 6, nil, &length, nil, 0) == 0, length > 0 else { return (0, 0) }
var buffer = [UInt8](repeating: 0, count: length)
guard sysctl(&mib, 6, &buffer, &length, nil, 0) == 0 else { return (0, 0) }
var received: UInt64 = 0
var sent: UInt64 = 0
buffer.withUnsafeBytes { raw in
var offset = 0
while offset < length {
let header = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr.self).pointee
guard header.ifm_msglen > 0 else { break }
defer { offset += Int(header.ifm_msglen) }
guard header.ifm_type == RTM_IFINFO2 else { continue }
let message = raw.baseAddress!.advanced(by: offset)
.assumingMemoryBound(to: if_msghdr2.self).pointee
// Loopback ausschließen: der lokale Verkehr zwischen Programmen
// hat mit was geht über die Leitung" nichts zu tun und kann
// ein Vielfaches davon sein.
guard message.ifm_data.ifi_type != UInt8(IFT_LOOP) else { continue }
received += message.ifm_data.ifi_ibytes
sent += message.ifm_data.ifi_obytes
}
}
return (received, sent)
}
// MARK: - Interface und Adressen
private struct Primary {
var name: String?
var kind: InterfaceKind = .unknown
var router: String?
}
private func primaryInterface() -> Primary {
var primary = Primary()
guard let store = SCDynamicStoreCreate(nil, "Onyx" as CFString, nil, nil),
let global = SCDynamicStoreCopyValue(store, "State:/Network/Global/IPv4" as CFString)
as? [String: Any] else { return primary }
primary.name = global["PrimaryInterface"] as? String
primary.router = global["Router"] as? String
primary.kind = kind(of: primary.name)
return primary
}
private func kind(of name: String?) -> InterfaceKind {
guard let name else { return .unknown }
// Die Namensschemata sind stabil: en0/en1 sind Ethernet oder WLAN,
// utun/ipsec/ppp gehören zu VPNs, lo0 ist die Loopback-Schnittstelle.
if name.hasPrefix("utun") || name.hasPrefix("ipsec") || name.hasPrefix("ppp") { return .vpn }
if name.hasPrefix("lo") { return .loopback }
if name.hasPrefix("pdp_ip") { return .cellular }
if name.hasPrefix("en") {
// en0 ist auf Notebooks das WLAN, aber nicht zwingend CoreWLAN
// weiß es genau.
let wifiNames = CWWiFiClient.interfaceNames() ?? []
return wifiNames.contains(name) ? .wifi : .ethernet
}
return .unknown
}
private func localAddresses(for interface: String?) -> (ipv4: String?, ipv6: String?) {
guard let interface else { return (nil, nil) }
var pointer: UnsafeMutablePointer<ifaddrs>?
guard getifaddrs(&pointer) == 0, let first = pointer else { return (nil, nil) }
defer { freeifaddrs(pointer) }
var ipv4: String?
var ipv6: String?
for entry in sequence(first: first, next: { $0.pointee.ifa_next }) {
guard String(cString: entry.pointee.ifa_name) == interface,
let address = entry.pointee.ifa_addr else { continue }
var host = [CChar](repeating: 0, count: Int(NI_MAXHOST))
guard getnameinfo(address, socklen_t(address.pointee.sa_len),
&host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0
else { continue }
let text = String(cString: host)
switch Int32(address.pointee.sa_family) {
case AF_INET where ipv4 == nil:
ipv4 = text
case AF_INET6 where ipv6 == nil:
// Link-lokale Adressen (fe80::) tragen einen Zonen-Anhang und
// sagen dem Nutzer nichts.
if !text.hasPrefix("fe80") { ipv6 = text }
default:
break
}
}
return (ipv4, ipv6)
}
// MARK: - WLAN
/// Der Netzwerkname oder `nil`, wenn macOS ihn nicht herausgibt.
///
/// Seit macOS 14 liefert `ssid()` nur mit erteilter Ortungsberechtigung
/// einen Wert. Fehlt sie, kommt `nil` zurück, **ohne** dass ein Fehler
/// erscheint. Deshalb wird hier nicht geraten: die Oberfläche zeigt dann
/// WLAN verbunden" statt eines leeren Feldes, das nach einem Defekt
/// aussieht.
private func wifiName() -> String? {
CWWiFiClient.shared().interface()?.ssid()
}
}

View File

@@ -0,0 +1,203 @@
import SwiftUI
import MetricsProvider
import OnyxDesign
import OnyxMenuBar
@MainActor
struct NetworkPopoverContent: View {
let model: NetworkModel
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top) {
rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download)
Spacer()
rate(model.snapshot.uploadRate, "network.upload", Onyx.Color.upload)
}
TrafficChart(history: model.history)
.frame(height: 56)
HStack {
peak(model.history.map(\.downloadRate).max() ?? 0, "network.peak.download")
Spacer()
peak(model.history.map(\.uploadRate).max() ?? 0, "network.peak.upload")
}
Divider()
Label(connectionName, systemImage: model.snapshot.interfaceKind.symbolName)
.font(.callout)
if model.snapshot.interfaceKind == .vpn {
// Ein VPN verändert, was die Zahlen darüber bedeuten der
// Verkehr läuft dann durch den Tunnel, nicht direkt.
// Über Text statt Label(_:systemImage:): letzteres kennt kein
// Bundle und zeigt sonst den rohen Schlüssel an.
Label {
Text("network.vpn.active", bundle: .module)
} icon: {
Image(systemName: "lock.shield")
}
.font(.caption)
.foregroundStyle(.secondary)
}
section("network.addresses")
field("network.field.interface", model.snapshot.interfaceName)
field("network.field.ipv4", model.snapshot.ipv4)
field("network.field.ipv6", model.snapshot.ipv6)
field("network.field.router", model.snapshot.router)
section("network.transferred")
field("network.total.received", bytes(model.snapshot.totalReceived))
field("network.total.sent", bytes(model.snapshot.totalSent))
section("network.public")
if model.showsPublicAddress {
HStack {
if model.publicAddress.isLoading {
ProgressView().controlSize(.small)
} else if let address = model.publicAddress.address {
Text(address).font(.callout.monospacedDigit()).textSelection(.enabled)
} else if model.publicAddress.failed {
Text("network.public.failed", bundle: .module)
.font(.callout).foregroundStyle(.secondary)
}
Spacer()
Button { model.publicAddress.refresh(force: true) } label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
}
// Wer die Adresse erfährt, gehört sichtbar dazu.
Text(verbatim: PublicAddressLookup.serviceName)
.font(.caption2).foregroundStyle(.tertiary)
} else {
Toggle(isOn: Binding(get: { model.showsPublicAddress },
set: { model.showsPublicAddress = $0 })) {
Text("network.public.enable", bundle: .module).font(.callout)
}
.toggleStyle(.switch)
.controlSize(.small)
Text("network.public.hint", bundle: .module)
.font(.caption2).foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
if !model.processes.isEmpty {
section("network.processes")
ForEach(model.processes) { process in
HStack {
Text(process.name).font(.callout).lineLimit(1)
Spacer(minLength: 8)
Text(Throughput.formatted(process.downloadRate))
.font(.caption.monospacedDigit())
.foregroundStyle(Onyx.Color.download)
Text(Throughput.formatted(process.uploadRate))
.font(.caption.monospacedDigit())
.foregroundStyle(Onyx.Color.upload)
}
}
}
}
.padding(14)
.frame(width: 300)
.onAppear { model.addDetailConsumer() }
.onDisappear { model.removeDetailConsumer() }
}
private var connectionName: String {
if let ssid = model.snapshot.ssid { return ssid }
if model.snapshot.isWiFiConnected {
return String(localized: "network.wifi.connected", bundle: .module)
}
return model.snapshot.interfaceName
?? String(localized: "network.offline", bundle: .module)
}
private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(Throughput.formatted(value))
.font(.system(size: 18, weight: .medium).monospacedDigit())
HStack(spacing: 4) {
Circle().fill(tint).frame(width: 6, height: 6)
Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary)
}
}
}
private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View {
HStack(spacing: 4) {
Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary)
Text(Throughput.formatted(value)).font(.caption2.monospacedDigit())
}
}
private func section(_ key: LocalizedStringKey) -> some View {
Text(key, bundle: .module)
.font(.caption.weight(.semibold))
.foregroundStyle(Onyx.Color.accent)
.textCase(.uppercase)
}
@ViewBuilder
private func field(_ key: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack {
Text(key, bundle: .module).font(.callout).foregroundStyle(.secondary)
Spacer(minLength: 12)
Text(value).font(.callout.monospacedDigit()).textSelection(.enabled)
}
}
}
private func bytes(_ value: UInt64) -> String {
ByteCountFormatter.string(fromByteCount: Int64(value), countStyle: .binary)
}
}
/// Verlauf in beide Richtungen: Download nach unten, Upload nach oben.
///
/// Zwei getrennte Kurven übereinander wären platzsparender, aber schwerer zu
/// lesen hier sieht man sofort, ob gerade geladen oder gesendet wird, und in
/// welchem Verhältnis. Beide Richtungen teilen sich denselben Maßstab, sonst
/// sähe ein Upload von 20 KB/s genauso hoch aus wie ein Download von 20 MB/s.
private struct TrafficChart: View {
let history: [NetworkSnapshot]
var body: some View {
GeometryReader { geometry in
let points = history.suffix(NetworkModel.historyLength)
let peak = max(points.map { max($0.downloadRate, $0.uploadRate) }.max() ?? 1, 1)
// Die Balkenbreite kommt aus der **vollen** Länge des Verlaufs.
// Vorher war sie `Breite / Anzahl`: bei fünf Messwerten also ein
// Fünftel je Balken, und mit jedem weiteren wurden sie schmaler.
// Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag.
let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength,
totalWidth: geometry.size.width, spacing: 1)
let half = geometry.size.height / 2
HStack(alignment: .center, spacing: 1) {
// Fehlende Messwerte bleiben als freie Fläche links stehen; der
// Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm.
Spacer(minLength: 0)
ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in
VStack(spacing: 1) {
Rectangle()
.fill(Onyx.Color.upload)
.frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5))
.frame(maxHeight: .infinity, alignment: .bottom)
Rectangle()
.fill(Onyx.Color.download)
.frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5))
.frame(maxHeight: .infinity, alignment: .top)
}
.frame(width: width)
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
}
}

View File

@@ -0,0 +1,238 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
import MetricsProvider
/// Wie `MetricsModel`, aber fürs Netz: eine Messschleife für Panel und
/// Menüleiste, mit Referenzzählung.
@MainActor
@Observable
public final class NetworkModel {
public private(set) var snapshot = NetworkSnapshot()
public private(set) var history: [NetworkSnapshot] = []
public static let historyLength = 60
/// Durchsatz je Programm nur gefüllt, solange ein Popover offen ist.
public private(set) var processes: [ProcessTraffic] = []
/// Die öffentliche Adresse, wenn eingeschaltet.
public let publicAddress = PublicAddressLookup()
/// Ob die öffentliche Adresse überhaupt abgefragt werden darf.
public var showsPublicAddress: Bool {
didSet {
guard showsPublicAddress != oldValue else { return }
defaults.set(showsPublicAddress, forKey: Self.publicAddressKey)
showsPublicAddress ? publicAddress.refresh() : publicAddress.clear()
}
}
private static let publicAddressKey = "onyx.network.showsPublicAddress"
private let defaults: UserDefaults
private let source = NetworkMetrics()
private let processSampler = ProcessTrafficSampler()
private var timer: Timer?
private var demands: [UUID: TimeInterval] = [:]
/// Zählt die offenen Popovers. Die Prozessliste kostet einen Fremdprozess
/// je Messung sie läuft nur, während wirklich jemand hinsieht.
private var detailConsumers = 0
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.showsPublicAddress = defaults.bool(forKey: Self.publicAddressKey)
}
/// Meldet ein offenes Popover an. Erst dann werden Prozesse abgefragt.
public func addDetailConsumer() {
detailConsumers += 1
if showsPublicAddress { publicAddress.refresh() }
sampleProcesses()
}
public func removeDetailConsumer() {
detailConsumers = max(detailConsumers - 1, 0)
if detailConsumers == 0 { processes = [] }
}
private func sampleProcesses() {
guard detailConsumers > 0 else { return }
// `nettop` blockiert kurz nicht auf dem Hauptthread.
let sampler = processSampler
Task.detached(priority: .utility) {
let result = sampler.sample()
await MainActor.run { [weak self] in
guard let self, detailConsumers > 0 else { return }
processes = result
}
}
}
@discardableResult
public func addConsumer(interval: TimeInterval = 2) -> UUID {
let token = UUID()
demands[token] = min(max(interval, 1), 10)
restartTimer()
return token
}
public func removeConsumer(_ token: UUID) {
demands[token] = nil
restartTimer()
}
private func restartTimer() {
timer?.invalidate()
timer = nil
guard let interval = demands.values.min() else {
history.removeAll()
return
}
sample()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.sample() }
}
RunLoop.main.add(timer, forMode: .common)
self.timer = timer
}
private func sample() {
let value = source.sample()
snapshot = value
sampleProcesses()
history.append(value)
if history.count > Self.historyLength {
history.removeFirst(history.count - Self.historyLength)
}
}
/// Verläufe, auf den bisherigen Höchstwert normiert.
///
/// Eine feste Obergrenze wäre unbrauchbar: bei 100 Mbit/s wäre eine
/// Video-Wiedergabe ein flacher Strich, und bei 1 Mbit/s ginge jeder
/// Download über den Rand. Die Kurve zeigt deshalb den Verlauf relativ zum
/// bisherigen Maximum die absoluten Zahlen stehen daneben.
public func series(download: Bool) -> [Double] {
let values = history.map { download ? $0.downloadRate : $0.uploadRate }
guard let peak = values.max(), peak > 0 else { return values.map { _ in 0 } }
return values.map { $0 / peak }
}
}
// MARK: - Widget
public struct NetworkWidget: OnyxWidget {
public let id = "network"
public var displayName: String { String(localized: "network.name", bundle: .module) }
public let symbolName = "network"
private let model: NetworkModel
public init(model: NetworkModel) { self.model = model }
public func makeView() -> AnyView {
AnyView(NetworkWidgetView(model: model))
}
}
private struct NetworkWidgetView: View {
let model: NetworkModel
@State private var token: UUID?
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 6) {
Image(systemName: model.snapshot.interfaceKind.symbolName)
.font(.system(size: 11))
.foregroundStyle(Onyx.Color.download)
Text(connectionName)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
}
Rates(snapshot: model.snapshot, compact: false)
Sparkline(values: model.series(download: true), tint: Onyx.Color.download,
capacity: NetworkModel.historyLength)
.frame(height: 18)
Divider().overlay(Onyx.Color.hairline)
Details(snapshot: model.snapshot)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.onAppear { token = model.addConsumer(interval: 1) }
.onDisappear {
if let token { model.removeConsumer(token) }
token = nil
}
}
/// Der Netzwerkname, wenn macOS ihn herausgibt sonst die Verbindungsart.
///
/// Seit macOS 14 liefert die SSID nur mit Ortungsberechtigung einen Wert.
/// Fehlt sie, wäre ein leeres Feld eine Falschaussage: verbunden ist man ja.
private var connectionName: String {
if let ssid = model.snapshot.ssid { return ssid }
if model.snapshot.isWiFiConnected {
return String(localized: "network.wifi.connected", bundle: .module)
}
return model.snapshot.interfaceName ?? String(localized: "network.offline", bundle: .module)
}
}
private struct Rates: View {
let snapshot: NetworkSnapshot
let compact: Bool
var body: some View {
VStack(alignment: .leading, spacing: 1) {
row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download)
row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload)
}
}
private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View {
HStack(spacing: 3) {
Image(systemName: symbol)
.font(.system(size: 8, weight: .bold))
.foregroundStyle(tint)
Text(text)
.font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
}
}
}
private struct Details: View {
let snapshot: NetworkSnapshot
var body: some View {
VStack(alignment: .leading, spacing: 2) {
field("network.field.interface", snapshot.interfaceName)
field("network.field.ipv4", snapshot.ipv4)
field("network.field.router", snapshot.router)
}
}
@ViewBuilder
private func field(_ label: LocalizedStringKey, _ value: String?) -> some View {
if let value {
HStack(spacing: 4) {
Text(label, bundle: .module)
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
Text(value)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textSecondary)
.textSelection(.enabled)
.lineLimit(1)
}
}
}
}

View File

@@ -1,56 +1,56 @@
{
"sourceLanguage" : "en",
"strings" : {
"singleInstance.blocked.message" : {
"comment" : "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx läuft bereits"
"sourceLanguage": "en",
"strings": {
"common.ok": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Onyx is already running"
"en": {
"stringUnit": {
"state": "translated",
"value": "OK"
}
}
}
},
"singleInstance.blocked.informative" : {
"comment" : "Erklärung samt Hinweis auf Xcode. %@ ist der Pfad der laufenden Instanz.",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels gleichzeitig auf der Notch arbeiten.\n\nLäuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut.\n\n%@"
"singleInstance.blocked.informative": {
"comment": "Erklärung samt Hinweis auf Xcode. %@ ist der Pfad der laufenden Instanz.",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Eine andere Onyx-Instanz läuft und ließ sich nicht beenden. Diese Instanz wird deshalb nicht gestartet, damit nicht zwei Panels gleichzeitig auf der Notch arbeiten.\n\nLäuft sie aus Xcode, beende sie dort mit ⌘. und starte erneut.\n\n%@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Another Onyx instance is running and could not be quit. This instance will not start, so that two panels don't fight over the same notch.\n\nIf it was launched from Xcode, stop it there with ⌘. and try again.\n\n%@"
"en": {
"stringUnit": {
"state": "translated",
"value": "Another Onyx instance is running and could not be quit. This instance will not start, so that two panels don't fight over the same notch.\n\nIf it was launched from Xcode, stop it there with ⌘. and try again.\n\n%@"
}
}
}
},
"common.ok" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "OK"
"singleInstance.blocked.message": {
"comment": "Titel des Dialogs, wenn eine zweite Instanz zurücktritt",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Onyx läuft bereits"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "OK"
"en": {
"stringUnit": {
"state": "translated",
"value": "Onyx is already running"
}
}
}
}
},
"version" : "1.0"
"version": "1.0"
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// Der Zustand des Anmeldeobjekts die Werte von `SMAppService.Status`,
/// aber ohne ServiceManagement, damit sie prüfbar bleiben.
public enum LoginItemState: Equatable, Sendable {
/// Nicht eingetragen. Ein Aufruf von `register()` behebt es.
case notRegistered
case enabled
/// Eingetragen, aber vom Nutzer in den Systemeinstellungen abgeschaltet.
case requiresApproval
/// Die App findet sich selbst nicht ein Installationsproblem.
case notFound
case unknown
public init(statusRawValue: Int) {
switch statusRawValue {
case 0: self = .notRegistered
case 1: self = .enabled
case 2: self = .requiresApproval
case 3: self = .notFound
default: self = .unknown
}
}
/// Startet Onyx mit? Im Zweifel nein lieber einen überflüssigen Klick
/// verlangen als behaupten, es sei eingeschaltet, und es startet nicht.
public var isEnabled: Bool { self == .enabled }
/// Ob der Weg über die Systemeinstellungen führt.
///
/// Das ist der Zustand, den man nicht verschweigen darf: wer das
/// Anmeldeobjekt dort abgeschaltet hat, kann in Onyx auf den Schalter
/// drücken, so oft er will erneutes Registrieren ändert daran nichts.
public var needsSystemSettings: Bool { self == .requiresApproval }
}

View File

@@ -0,0 +1,30 @@
import SwiftUI
/// Färbt das Symbol eines `Label` in der eingestellten Akzentfarbe.
///
/// **Nötig, weil `.tint` hier nicht greift.** SwiftUI zeichnet Symbole in
/// Listen und Formularen auf macOS in der Akzentfarbe des *Systems* nicht in
/// der des Fensters. Das Ergebnis war eine Widget-Liste in Systemgrün neben
/// einer Reiterleiste in Onyx-Blau, obwohl beides derselben Einstellung folgen
/// soll.
///
/// Ein Stil statt einer Farbe an jeder Fundstelle: er vererbt sich über die
/// Umgebung, also genügt eine Zeile an der Wurzel. Ein Symbol, das seine Farbe
/// selbst mitbringt etwa der grüne Haken für erteilt" , behält sie: die
/// innere Angabe schlägt die äußere.
public struct OnyxAccentLabelStyle: LabelStyle {
public init() {}
public func makeBody(configuration: Configuration) -> some View {
Label {
configuration.title
} icon: {
configuration.icon.foregroundStyle(Onyx.Color.accent)
}
}
}
public extension LabelStyle where Self == OnyxAccentLabelStyle {
static var onyx: OnyxAccentLabelStyle { OnyxAccentLabelStyle() }
}

View File

@@ -0,0 +1,106 @@
import SwiftUI
import AppKit
/// Die einstellbaren Farben.
///
/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt
/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung bei Temperatur, Akku,
/// Lüfter und Fehlermeldungen. Wer sie umstellen kann, kann ihre Bedeutung
/// zerstören; ein rotes alles gut" liest niemand richtig. Einstellbar ist
/// deshalb die Akzentfarbe, mit der Onyx sich selbst zeichnet, und das Paar für
/// Herunter- und Hochladen dort ist die Zuordnung willkürlich und nur eine
/// Gewohnheit.
@MainActor
@Observable
public final class OnyxTheme {
/// Die Instanz, aus der `Onyx.Color` liest.
public static let shared = OnyxTheme()
/// Kühl und entsättigt Farbe ist in Onyx ein Signal, keine Dekoration.
public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0)
/// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit,
/// aber eine verbreitete. Wer sie anders kennt, dreht sie um.
public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592)
public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612)
public var accent: Color {
didSet { store(accent, as: Keys.accent) }
}
public var download: Color {
didSet { store(download, as: Keys.download) }
}
public var upload: Color {
didSet { store(upload, as: Keys.upload) }
}
private let defaults: UserDefaults
private enum Keys {
static let accent = "onyx.theme.accent"
static let download = "onyx.theme.download"
static let upload = "onyx.theme.upload"
}
public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent
download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload
upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload
}
/// Ob alles auf den Werkseinstellungen steht für den Zurücksetzen-Knopf,
/// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt.
public var isDefault: Bool {
accent.onyxHex == Self.defaultAccent.onyxHex
&& download.onyxHex == Self.defaultDownload.onyxHex
&& upload.onyxHex == Self.defaultUpload.onyxHex
}
public func reset() {
accent = Self.defaultAccent
download = Self.defaultDownload
upload = Self.defaultUpload
}
/// Übernimmt die Akzentfarbe des Systems.
///
/// Genau die stand hier vorher an vielen Stellen unfreiwillig, weil
/// `.tint` und `.accentColor` ohne eigene Angabe dorthin durchgereicht
/// werden. Wem das gefiel, soll es behalten können.
public func adoptSystemAccent() {
accent = Color(nsColor: .controlAccentColor)
}
private func store(_ color: Color, as key: String) {
defaults.set(color.onyxHex, forKey: key)
}
}
public extension Color {
/// `#RRGGBB`, immer in sRGB.
///
/// Der Umweg über `NSColor` ist nötig, weil eine `Color` aus dem Farbwähler
/// in einem beliebigen Farbraum liegen kann ohne Umrechnung ergäbe die
/// Zerlegung in Komponenten je nach Bildschirm etwas anderes.
var onyxHex: String {
guard let srgb = NSColor(self).usingColorSpace(.sRGB) else { return "#7FA8FF" }
let red = Int((srgb.redComponent * 255).rounded())
let green = Int((srgb.greenComponent * 255).rounded())
let blue = Int((srgb.blueComponent * 255).rounded())
return String(format: "#%02X%02X%02X", red, green, blue)
}
/// Liest `#RRGGBB` und `RRGGBB`.
init?(onyxHex: String?) {
guard let onyxHex else { return nil }
var text = onyxHex.trimmingCharacters(in: .whitespaces)
if text.hasPrefix("#") { text.removeFirst() }
guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil }
self.init(.sRGB,
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255)
}
}

View File

@@ -21,8 +21,22 @@ public enum Onyx {
public static let textSecondary = SwiftUI.Color.white.opacity(0.58)
public static let textTertiary = SwiftUI.Color.white.opacity(0.34)
/// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration.
public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0)
/// Die Farbe, mit der Onyx sich selbst zeichnet einstellbar.
///
/// Gelesen statt gespeichert: die Tokens holen den Wert bei jedem
/// Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und
/// zeichnet neu, sobald sich die Farbe ändert bei einer Konstanten
/// wären alle vierzig Fundstellen einzeln umzustellen gewesen.
@MainActor public static var accent: SwiftUI.Color { OnyxTheme.shared.accent }
/// Herunter- und Hochladen. Auch einstellbar: welche Richtung grün ist
/// und welche rosa, ist Gewohnheit, keine Bedeutung.
@MainActor public static var download: SwiftUI.Color { OnyxTheme.shared.download }
@MainActor public static var upload: SwiftUI.Color { OnyxTheme.shared.upload }
/// Die Signalfarben bleiben fest. Gelb heißt Warnung, Rot heißt
/// kritisch, Grün heißt in Ordnung wer das umstellen kann, kann die
/// Bedeutung zerstören.
public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592)
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)

View File

@@ -0,0 +1,68 @@
import Foundation
/// Das Ladelimit Kodierung und harte Grenzen.
///
/// Der SMC-Key heißt `CHLT`, drei Bytes. Gemessen am 11.08.2026 auf Mac17,9,
/// rein lesend, gegen macOS' eigene Ladebegrenzung:
///
/// | Einstellung | `CHLT` |
/// |---|---|
/// | 80 % | `50 01 00` |
/// | 85 % | `55 01 00` |
/// | 95 % | `5F 01 00` |
///
/// Erstes Byte ist die Prozentzahl, das zweite offenbar Limit aktiv", das
/// dritte unbenutzt. Mehr ist nicht belegt, und mehr wird hier auch nicht
/// angenommen.
///
/// **Warum das strenger gehandhabt wird als die Lüfter:** dort liefert die
/// Firmware mit `F0Mn`/`F0Mx` ihre eigenen Grenzen mit, an denen sich das
/// Sicherheitsnetz festhalten kann. Hier gibt es nichts dergleichen es gibt
/// nur die Werte, die wir gemessen haben. Ein falscher Wert im Lade-Subsystem
/// ist die eine Operation in diesem Projekt, die den Akku dauerhaft
/// beschädigen kann.
public enum ChargeSafety {
/// Der SMC-Key.
public static let key = "CHLT"
/// Untergrenze. macOS selbst bietet nichts darunter an; darunter ist
/// ungemessenes Gebiet.
public static let minimumPercent = 80
public static let maximumPercent = 100
/// Kein Limit" heißt hundert Prozent.
///
/// Und nicht: das Aktiv-Byte auf null setzen. Diese Kodierung hat macOS nie
/// geschrieben, sie ist also ungemessen hundert Prozent erreicht dasselbe
/// innerhalb dessen, was belegt ist.
public static let noLimit = 100
public static func clamp(_ percent: Int) -> Int {
// Anheben statt ablehnen: ein Regler, der bei einer Fehleingabe gar
// nichts tut, wirkt kaputt.
min(max(percent, minimumPercent), maximumPercent)
}
/// Die drei Bytes für einen Prozentwert.
///
/// Nur das erste Byte trägt den Wunsch. Die beiden anderen bekommen exakt
/// das, was macOS dort schreibt ungemessene Bytes im Ladepfad werden
/// nicht angefasst.
public static func encode(_ percent: Int) -> [UInt8] {
[UInt8(clamp(percent)), 0x01, 0x00]
}
/// Der Prozentwert aus den gelesenen Bytes. `nil`, wenn kein Limit gilt
/// oder die Daten nicht plausibel sind.
public static func decode(_ bytes: [UInt8]) -> Int? {
guard bytes.count >= 2 else { return nil }
// Zweites Byte null: kein Limit, egal was im ersten steht.
guard bytes[1] != 0 else { return nil }
let percent = Int(bytes[0])
// Ein Wert außerhalb des Möglichen wird nicht angezeigt. Unbekannt"
// ist eine ehrlichere Auskunft als eine Zahl, die es nicht geben kann.
guard (1...100).contains(percent) else { return nil }
return percent
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
/// Der von der Firmware erlaubte Drehzahlbereich eines Lüfters.
public struct FanLimits: Equatable, Sendable, Codable {
public let minimum: Double
public let maximum: Double
public init(minimum: Double, maximum: Double) {
self.minimum = minimum
self.maximum = maximum
}
/// Ob die Werte überhaupt brauchbar sind. Nicht lesbare Keys ergeben Nullen.
public var isUsable: Bool { minimum > 0 && maximum > minimum }
}
public struct FanRequest: Equatable, Sendable, Codable {
public let targetRPM: Double
public init(targetRPM: Double) { self.targetRPM = targetRPM }
}
public enum FanDecision: Equatable, Sendable {
case manual(rpm: Double)
case automatic(reason: AutomaticReason)
public enum AutomaticReason: String, Equatable, Sendable {
/// Niemand will etwas anderes.
case noRequest
/// Die App hat sich zu lange nicht gemeldet.
case watchdog
/// Es ist zu heiß für eine feste Drehzahl.
case temperature
/// Ohne Temperaturmesswert lässt sich Sicherheit nicht beurteilen.
case noTemperature
/// Der erlaubte Bereich ist unbekannt.
case unknownLimits
}
}
/// Entscheidet, ob eine angeforderte Drehzahl gesetzt werden darf.
///
/// Diese Regeln sind **nicht abschaltbar** und liegen im Helfer, nicht in der
/// Oberfläche. Die Oberfläche kann abstürzen, eingefroren oder beendet sein,
/// während die Lüfter auf einem festen Wert stehen genau dafür gibt es das
/// Netz. Als reine Funktion ist es vollständig prüfbar, ohne Hardware.
public enum FanSafety {
/// Meldet sich die App länger nicht, übernimmt die Automatik.
public static let watchdogTimeout: TimeInterval = 5
/// Ab hier zählt nur noch Kühlung. Apple Silicon drosselt in dieser Gegend
/// von selbst; eine festgenagelte Drehzahl verschlimmert die Lage, weil sie
/// nicht nachregelt.
public static let criticalCelsius: Double = 95
public static func decide(request: FanRequest?,
lastHeartbeat: Date,
now: Date,
hottestCelsius: Double?,
limits: FanLimits) -> FanDecision {
// Rangfolge: erst die Gründe, die Schaden verhindern, dann der Wunsch.
guard let request else { return .automatic(reason: .noRequest) }
guard let hottest = hottestCelsius else {
// Ohne Temperatur lässt sich nicht beurteilen, ob eine feste
// Drehzahl gerade gefährlich ist. Blind weiterdrehen wäre falsch.
return .automatic(reason: .noTemperature)
}
guard hottest < criticalCelsius else { return .automatic(reason: .temperature) }
guard now.timeIntervalSince(lastHeartbeat) <= watchdogTimeout else {
return .automatic(reason: .watchdog)
}
guard limits.isUsable else { return .automatic(reason: .unknownLimits) }
// Nie unter das Firmware-Minimum: ein Lüfter, der langsamer läuft als
// vorgesehen, kann den Rechner überhitzen, ohne dass die Automatik
// eingreift die ist ja gerade abgeschaltet.
return .manual(rpm: min(max(request.targetRPM, limits.minimum), limits.maximum))
}
}

View File

@@ -0,0 +1,92 @@
import Foundation
/// Die Schnittstelle zwischen App und privilegiertem Helfer.
///
/// **Die App sagt dem Helfer nicht, wie warm es ist.** Der Helfer misst selbst.
/// Andernfalls hinge das Sicherheitsnetz an der Ehrlichkeit eines Prozesses,
/// der abstürzen, hängen oder bei einer manipulierten Kopie schlicht lügen
/// kann. Der Herzschlag sagt deshalb nur ich lebe noch", nichts weiter.
@objc public protocol OnyxHelperProtocol {
/// Version des Helfers. Läuft nach einem App-Update noch die alte, muss sie
/// ersetzt werden sonst passen die Aufrufe nicht mehr zusammen.
func version(reply: @escaping (String) -> Void)
/// Aktueller Zustand aller Lüfter, als JSON-kodierter ``FanStatus``.
///
/// JSON statt eines eigenen Typs: `NSSecureCoding` über XPC verlangt
/// registrierte Klassen auf beiden Seiten, und dafür ist die Datenmenge
/// hier zu klein.
func fanStatus(reply: @escaping (Data?) -> Void)
/// Setzt eine Wunschdrehzahl. Der Helfer prüft sie gegen sein
/// Sicherheitsnetz und kann sie begrenzen oder ablehnen.
func setFan(index: Int, targetRPM: Double, reply: @escaping (Data?) -> Void)
/// Zurück zur Automatik.
func setFanAutomatic(index: Int, reply: @escaping (Data?) -> Void)
/// Ich lebe noch." Bleibt er aus, fallen alle Lüfter auf Automatik zurück.
func heartbeat(reply: @escaping (Bool) -> Void)
/// Das aktuelle Ladelimit in Prozent, `-1` wenn keines gilt.
///
/// Nur lesen. Der SMC lehnt Schreibzugriffe auf `CHLT` ab nachgemessen,
/// siehe docs/spikes/A-smc.md. macOS setzt den Wert offenbar auf einem
/// anderen Weg als über den SMC-Userclient.
///
/// `Int` statt `Int?`: Optionals sind über `@objc` nicht darstellbar.
func chargeLimit(reply: @escaping (Int) -> Void)
}
/// Was der Helfer über einen Lüfter berichtet.
public struct FanStatus: Codable, Equatable, Sendable {
public let index: Int
public let currentRPM: Double
public let targetRPM: Double
public let limits: FanLimits
public let isManual: Bool
/// Warum gerade die Automatik läuft `nil` im manuellen Betrieb.
public let automaticReason: String?
public init(index: Int, currentRPM: Double, targetRPM: Double, limits: FanLimits,
isManual: Bool, automaticReason: String?) {
self.index = index
self.currentRPM = currentRPM
self.targetRPM = targetRPM
self.limits = limits
self.isManual = isManual
self.automaticReason = automaticReason
}
}
public struct FanStatusReport: Codable, Equatable, Sendable {
public let fans: [FanStatus]
public let hottestCelsius: Double?
public let helperVersion: String
public init(fans: [FanStatus], hottestCelsius: Double?, helperVersion: String) {
self.fans = fans
self.hottestCelsius = hottestCelsius
self.helperVersion = helperVersion
}
}
public enum OnyxHelper {
/// Muss zum Namen der Plist unter `Contents/Library/LaunchDaemons` passen.
public static let machServiceName = "com.scarriffleservices.onyx.helper"
public static let version = "1"
/// Nur ein Prozess mit exakt dieser Signatur darf mit dem Helfer sprechen.
///
/// Ohne diese Prüfung könnte **jedes** Programm auf dem Rechner die Lüfter
/// eines root-Dienstes steuern der Mach-Dienst ist systemweit sichtbar.
public static let clientRequirement =
"anchor apple generic and identifier \"com.scarriffleservices.onyx\" "
+ "and certificate leaf[subject.OU] = \"PP34X97WS3\""
/// Und die App spricht nur mit einem Helfer, der ebenso signiert ist.
public static let helperRequirement =
"anchor apple generic and identifier \"com.scarriffleservices.onyx.helper\" "
+ "and certificate leaf[subject.OU] = \"PP34X97WS3\""
}

View File

@@ -0,0 +1,41 @@
import CoreGraphics
/// Wo die Punkte eines Verlaufsdiagramms liegen.
///
/// Die Schrittweite kommt aus der **vollen** Länge des Verlaufs, nicht aus der
/// Anzahl der bisher vorhandenen Werte. Frisch gestartet hat der Verlauf zwei
/// Messwerte statt sechzig wurde daraus gerechnet, spannte sich der Weg
/// zwischen ihnen über die ganze Breite. Das sah nach viel Bewegung aus und
/// bedeutete nichts.
public enum GraphLayout {
/// - Parameters:
/// - count: wie viele Werte vorliegen.
/// - capacity: wie viele der Verlauf fasst.
public static func positions(count: Int, capacity: Int, in rect: CGRect) -> [CGFloat] {
// Eine Linie braucht zwei Punkte. Einen zu zeichnen ergäbe einen Strich
// am Rand, der wie ein Messwert aussieht.
guard count >= 2, capacity >= 2 else { return [] }
let shown = min(count, capacity)
let step = rect.width / CGFloat(capacity - 1)
// Neue Werte rechts: der Verlauf wächst nach links weg.
let start = rect.maxX - CGFloat(shown - 1) * step
return (0..<shown).map { start + CGFloat($0) * step }
}
/// Die Breite eines Balkens.
///
/// Aus der **vollen** Länge gerechnet, nicht aus den vorhandenen Werten:
/// sonst ist bei fünf Messwerten jeder Balken ein Fünftel breit, und mit
/// jedem weiteren werden sie schmaler. Fehlende Werte bleiben stattdessen
/// als freie Fläche links stehen.
public static func barWidth(capacity: Int, totalWidth: CGFloat,
spacing: CGFloat) -> CGFloat {
guard capacity > 0 else { return totalWidth }
let gaps = spacing * CGFloat(capacity - 1)
// Sichtbar bleiben, auch wenn es eng wird sonst verschwindet der
// Verlauf bei schmalem Fenster ganz.
return max((totalWidth - gaps) / CGFloat(capacity), 0.5)
}
}

View File

@@ -1,4 +1,5 @@
import AppKit
import OnyxDesign
import SwiftUI
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
@@ -9,6 +10,9 @@ import SwiftUI
@MainActor
public final class MenuBarController {
/// Hält den Delegaten am Leben `NSPopover` merkt ihn sich nur schwach.
private var popoverWatcher: PopoverWatcher?
/// Ein Modul, dessen Element keine Breite bekommen hat.
///
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
@@ -42,6 +46,7 @@ public final class MenuBarController {
}
public func stop() {
closeOpenPopover()
visibilityTimer?.invalidate()
visibilityTimer = nil
for id in entries.keys { setEnabled(false, forModuleID: id) }
@@ -78,6 +83,12 @@ public final class MenuBarController {
apply(entry.settings, toModuleID: id)
}
/// Welches Popover gerade offen ist. Höchstens eines.
private var openPopoverID: String?
private var globalMonitor: Any?
private var localMonitor: Any?
private var keyMonitor: Any?
public func settings(forModuleID id: String) -> MenuBarModuleSettings? {
entries[id]?.settings
}
@@ -100,7 +111,14 @@ public final class MenuBarController {
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.identifier = NSUserInterfaceItemIdentifier(id)
}
entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength
applyWidth(view.intrinsicContentSize.width, to: entry)
// Die nötige Breite steht nicht von Anfang an fest: die Kernzahl
// kennt das Modul erst nach der ersten Messung, und ein Moduswechsel
// ändert 100 %" in 100 °C".
(view as? WidthReporting)?.onWidthChange = { [weak self] width in
guard let self, let entry = entries[id] else { return }
applyWidth(width, to: entry)
}
entry.module.activate()
} else {
entry.module.deactivate()
@@ -108,20 +126,98 @@ public final class MenuBarController {
checkVisibility()
}
private func applyWidth(_ width: CGFloat, to entry: Entry) {
entry.item.length = width > 0 ? width : NSStatusItem.variableLength
}
// MARK: - Interaktion
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
if entry.popover.isShown {
entry.popover.performClose(nil)
// Immer nur eines offen. Zwei Popover nebeneinander sehen aus wie ein
// Fehler, und das zweite verdeckt oft das erste.
if openPopoverID == id {
closeOpenPopover()
return
}
closeOpenPopover()
// Die Akzentfarbe hier und nicht in jedem Popover einzeln: `.tint`
// vererbt sich nach unten, und ohne sie fielen Haken, Regler und
// Auswahlfelder auf die Systemfarbe zurück das war der Grund, warum
// die Popovers grün waren und die Widgets blau.
entry.popover.contentViewController = NSHostingController(
rootView: entry.module.makePopoverView())
rootView: entry.module.makePopoverView()
.tint(Onyx.Color.accent)
.labelStyle(.onyx))
let watcher = PopoverWatcher { [weak self] in self?.popoverClosedByItself() }
popoverWatcher = watcher
entry.popover.delegate = watcher
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
entry.module.popoverDidOpen()
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
entry.popover.contentViewController?.view.window?.makeKey()
openPopoverID = id
startWatchingForOutsideClicks()
}
// MARK: - Schließen bei Klick daneben
/// `NSPopover.behavior = .transient` genügt hier **nicht**.
///
/// Es schließt zuverlässig, solange die eigene App aktiv ist. Onyx läuft
/// aber als `.accessory` und wird durch einen Klick auf ein Statuselement
/// nicht aktiviert ein Klick in ein fremdes Fenster erreicht das Popover
/// dann gar nicht, und es bleibt offen stehen. Ein globaler Beobachter sieht
/// solche Klicks trotzdem.
private func startWatchingForOutsideClicks() {
guard globalMonitor == nil else { return }
globalMonitor = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] _ in
Task { @MainActor in self?.closeOpenPopover() }
}
// Der globale Beobachter sieht keine Klicks in die eigene App dafür
// der lokale. Er reicht das Ereignis weiter, damit Knöpfe im Popover
// weiterhin funktionieren.
localMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
guard let self, let id = openPopoverID, let entry = entries[id] else { return event }
// Klicks **im** Popover schließen es nicht.
if event.window !== entry.popover.contentViewController?.view.window,
event.window !== entry.item.button?.window {
closeOpenPopover()
}
return event
}
// Auch die Escape-Taste und ein Bildschirmwechsel beenden es.
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
if event.keyCode == 53 { self?.closeOpenPopover(); return nil }
return event
}
}
/// Zugegangen, ohne dass wir es veranlasst haben.
private func popoverClosedByItself() {
guard let id = openPopoverID, let entry = entries[id] else { return }
entry.module.popoverDidClose()
openPopoverID = nil
}
private func closeOpenPopover() {
if let id = openPopoverID, let entry = entries[id] {
entry.popover.performClose(nil)
}
// Über denselben Weg wie das Zugehen von selbst: der räumt
// `openPopoverID` ab und ist damit genau einmal wirksam, egal ob der
// Delegat vorher, nachher oder gar nicht meldet.
popoverClosedByItself()
globalMonitor.map(NSEvent.removeMonitor)
localMonitor.map(NSEvent.removeMonitor)
keyMonitor.map(NSEvent.removeMonitor)
globalMonitor = nil
localMonitor = nil
keyMonitor = nil
}
private func checkVisibility() {
@@ -136,3 +232,21 @@ public final class MenuBarController {
onVisibilityChanged?(hidden)
}
}
/// Horcht, ob das Popover zugegangen ist.
///
/// Ein eigener Typ, weil ein Delegat von AppKit ein `NSObject` sein muss und
/// der Controller keines ist. Nötig ist er trotzdem: das Popover geht auch
/// ohne Zutun zu beim Wechsel in eine andere App etwa , und ohne diese
/// Meldung erfährt eine laufende Messung nie, dass niemand mehr hinsieht.
final class PopoverWatcher: NSObject, NSPopoverDelegate {
private let onClose: @MainActor () -> Void
init(onClose: @escaping @MainActor () -> Void) {
self.onClose = onClose
}
func popoverDidClose(_ notification: Notification) {
MainActor.assumeIsolated { onClose() }
}
}

View File

@@ -12,6 +12,13 @@ public enum MenuBarPresentation: String, Codable, Sendable, CaseIterable, Identi
case valueAndGraph
/// Nur ein Glyph, nach Grenzwert eingefärbt.
case symbol
/// Kürzel und Wert, z. B. `CPU 34 %`.
///
/// Fünf nackte Prozentzahlen nebeneinander sind unbrauchbar: man sieht
/// Zahlen und weiß nicht, welche wozu gehört.
case labelAndValue
/// Glyph und Wert, z. B. 🔋 `34 %`.
case symbolAndValue
public var id: String { rawValue }
}
@@ -25,6 +32,9 @@ public protocol MenuBarModule: AnyObject {
var id: String { get }
var displayName: String { get }
/// Das Kürzel für die Menüleiste kurz, sonst frisst es die Leiste auf.
var shortLabel: String { get }
/// Zeichnet den kompakten Zustand. Muss eine feste Breite je Darstellungsart
/// liefern, sonst springt die ganze Menüleiste bei jedem Messwert.
func makeStatusView(presentation: MenuBarPresentation) -> NSView
@@ -32,6 +42,17 @@ public protocol MenuBarModule: AnyObject {
/// Der Inhalt des Popovers beim Klick.
func makePopoverView() -> AnyView
/// Wird gerufen, wenn das Popover dieses Moduls auf- und wieder zugeht.
///
/// **Die einzige verlässliche Stelle für alles, was nur laufen darf,
/// solange jemand hinsieht.** SwiftUIs `onDisappear` bleibt bei einem
/// `NSPopover` aus: dessen Controller lebt nach dem Schließen weiter, die
/// View verschwindet nie aus der Hierarchie. Wer eine Messung daran hängt,
/// hängt sie an etwas, das nie eintritt im Fall des Mikrofons mit dem
/// orangen Punkt als Beweis.
func popoverDidOpen()
func popoverDidClose()
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet hier
/// meldet sich das Modul beim Provider an und wieder ab.
func activate()
@@ -47,7 +68,7 @@ public struct MenuBarModuleSettings: Codable, Sendable, Equatable {
public var usesThresholdColors: Bool
public init(isEnabled: Bool = false,
presentation: MenuBarPresentation = .value,
presentation: MenuBarPresentation = .symbolAndValue,
refreshInterval: TimeInterval = 2,
usesThresholdColors: Bool = true) {
self.isEnabled = isEnabled
@@ -62,3 +83,9 @@ extension TimeInterval {
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
}
}
public extension MenuBarModule {
// Die meisten Module haben nichts abzuräumen.
func popoverDidOpen() {}
func popoverDidClose() {}
}

View File

@@ -0,0 +1,181 @@
import AppKit
/// Beschriftung und Breite eines Menüleisten-Elements.
///
/// Die Breite wird **gemessen**, nicht geschätzt. Vorher stand je Darstellungsart
/// eine feste Zahl im Code, großzügig gewählt, damit nichts abgeschnitten wird
/// mit dem Ergebnis, dass neben jedem Wert Platz für ein weiteres Symbol blieb,
/// in dem nichts stand. In einer Menüleiste ist das der teuerste Platz des
/// ganzen Bildschirms.
///
/// Gemessen wird die **breiteste Zeichenfolge, die vorkommen kann**, nicht die
/// gerade angezeigte. Sonst ändert das Element bei jeder Messung seine Größe und
/// schiebt alle Symbole rechts davon hin und her.
public enum MenuBarText {
/// Feste Ziffernbreite: ohne sie wackelt der Text bei jeder Messung.
///
/// Als berechnete Eigenschaft, nicht als gespeicherte: `NSFont` ist nicht
/// `Sendable`, und die Schrift jedes Mal neu zu erfragen kostet nichts
/// AppKit gibt dieselbe zwischengespeicherte Instanz zurück.
public static var font: NSFont {
NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular)
}
/// Ein Punkt je Seite. macOS legt um jedes Statuselement ohnehin rund
/// acht Punkte eigene Polsterung was hier draufkommt, kommt oben drauf.
public static let sidePadding: CGFloat = 1
public static let symbolWidth: CGFloat = 13
/// Abstand zwischen Symbol und Zahl.
public static let innerGap: CGFloat = 2
/// Schmaler geht nicht, ohne dass ein Glyph anstößt.
public static let minimumWidth: CGFloat = 18
/// Die Breite eines Elements aus gemessener Textbreite. Rein, damit die
/// Rechnung prüfbar bleibt.
public static func itemWidth(textWidth: CGFloat, includesSymbol: Bool) -> CGFloat {
let symbol = includesSymbol ? symbolWidth + innerGap : 0
let content = textWidth > 0 ? ceil(textWidth) + 2 * sidePadding : 2 * sidePadding
return max(minimumWidth, content + symbol)
}
public static func width(of text: String) -> CGFloat {
guard !text.isEmpty else { return 0 }
return NSAttributedString(string: text, attributes: [.font: font]).size().width
}
/// Die Breite für die breiteste vorkommende Zeichenfolge.
public static func width(for reference: String, includesSymbol: Bool = false) -> CGFloat {
itemWidth(textWidth: width(of: reference), includesSymbol: includesSymbol)
}
/// Zeichnet ein SF-Symbol in der gewünschten Farbe.
///
/// `color.set()` vor `image.draw(in:)` reicht **nicht**. Ein Template-Bild
/// trägt seine Farbe nicht aus dem Grafikzustand, sondern wird schwarz
/// gezeichnet auf einer dunklen Menüleiste also unsichtbar. AppKit färbt
/// Template-Bilder nur ein, wenn sie als `image` eines Buttons gesetzt sind;
/// in einer selbst gezeichneten Ansicht muss die Farbe in die
/// Symbolkonfiguration.
public static func drawSymbol(_ name: String, color: NSColor, in rect: NSRect) {
guard let tinted = symbol(name, color: color, height: rect.height) else { return }
// Seitenverhältnis wahren. Ein Batteriesymbol ist doppelt so breit wie
// hoch; in ein Quadrat gezeichnet sieht es zerquetscht aus.
let size = fittedSize(of: tinted, height: rect.height)
tinted.draw(in: NSRect(x: rect.midX - size.width / 2,
y: rect.midY - size.height / 2,
width: size.width, height: size.height))
}
/// Wie breit ein Symbol bei dieser Höhe tatsächlich ist.
///
/// Ohne diese Messung bekommt ein breites Symbol denselben Platz wie ein
/// schmales entweder gequetscht oder mit einer Lücke daneben.
public static func symbolWidth(_ name: String, height: CGFloat) -> CGFloat {
guard let image = symbol(name, color: .labelColor, height: height) else {
return height
}
return fittedSize(of: image, height: height).width
}
/// Zeichnet ein beliebiges Bild in der gewünschten Farbe.
///
/// Für eigene Vorlagen statt SF-Symbole: dort greift die
/// Symbolkonfiguration nicht, und `color.set()` allein färbt ein
/// Template-Bild nicht. Der Weg ist, das Bild zu zeichnen und die Farbe
/// mit `sourceAtop` darüberzulegen das trifft genau die deckenden
/// Bildpunkte und lässt den Rest frei.
public static func drawTemplate(_ image: NSImage, color: NSColor, in rect: NSRect) {
let natural = image.size
let fitted: NSSize = natural.height > 0
? NSSize(width: (natural.width / natural.height) * rect.height, height: rect.height)
: rect.size
let target = NSRect(x: rect.midX - fitted.width / 2,
y: rect.midY - fitted.height / 2,
width: fitted.width, height: fitted.height)
let tinted = NSImage(size: target.size, flipped: false) { bounds in
image.draw(in: bounds, from: .zero, operation: .sourceOver, fraction: 1)
color.set()
bounds.fill(using: .sourceAtop)
return true
}
tinted.draw(in: target)
}
private static func symbol(_ name: String, color: NSColor, height: CGFloat) -> NSImage? {
guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
else { return nil }
let configuration = NSImage.SymbolConfiguration(pointSize: height, weight: .regular)
.applying(NSImage.SymbolConfiguration(paletteColors: [color]))
return image.withSymbolConfiguration(configuration) ?? image
}
private static func fittedSize(of image: NSImage, height: CGFloat) -> NSSize {
let natural = image.size
guard natural.height > 0 else { return NSSize(width: height, height: height) }
return NSSize(width: (natural.width / natural.height) * height, height: height)
}
public static func draw(_ text: String, color: NSColor, in rect: NSRect) {
let string = NSAttributedString(string: text,
attributes: [.font: font, .foregroundColor: color])
let size = string.size()
string.draw(at: NSPoint(x: rect.midX - size.width / 2,
y: rect.midY - size.height / 2))
}
}
/// Eine Statusansicht, deren nötige Breite sich ändern kann.
///
/// Der Controller stellt das Statuselement daraufhin neu ein. Ohne diesen Weg
/// bliebe ein Element in der Breite stehen, die beim allerersten Zeichnen
/// gebraucht wurde vor der ersten Messung also.
@MainActor
public protocol WidthReporting: AnyObject {
var onWidthChange: ((CGFloat) -> Void)? { get set }
}
/// Eine Breite, die mitwächst aber nur ungern schrumpft.
///
/// Eine feste Breite für den schlimmsten Fall lässt neben jedem gewöhnlichen
/// Wert Platz stehen: 13 KB/s" braucht gut halb so viel wie 999,9 MB/s".
/// Eine Breite, die jedem Messwert folgt, schiebt dagegen bei jeder Messung
/// alle Symbole rechts davon hin und her.
///
/// Der Kompromiss ist unsymmetrisch, und das mit Absicht: **sofort wachsen**,
/// denn abgeschnittene Messwerte sind falsche Messwerte. **Zögernd
/// schrumpfen**, damit ein kurzer Lastausschlag die Leiste nicht zum Pumpen
/// bringt.
public struct AdaptiveWidth: Sendable {
/// Wie viele ruhigere Messungen es braucht, bevor Platz abgegeben wird.
/// Bei zwei Sekunden Abtastung sind das gut zwanzig Sekunden Geduld.
public static let shrinkAfter = 10
public let minimum: CGFloat
private var current: CGFloat = 0
private var calmSamples = 0
public init(minimum: CGFloat) {
self.minimum = minimum
}
public mutating func update(needed: CGFloat) -> CGFloat {
let wanted = max(needed, minimum)
if wanted > current {
current = wanted
calmSamples = 0
} else if wanted < current {
calmSamples += 1
if calmSamples >= Self.shrinkAfter {
current = wanted
calmSamples = 0
}
} else {
calmSamples = 0
}
return current
}
}

View File

@@ -0,0 +1,70 @@
{
"sourceLanguage": "en",
"strings": {
"trigger.height %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Höhe, %lld pt nach unten"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Height, %lld pt below"
}
}
}
},
"trigger.hint": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wie weit über die Notch hinaus der Zeiger das Panel öffnet. Breiter trifft man leichter; schmaler hält das Panel aus dem Weg, während man die Menüleiste benutzt."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "How far past the notch the pointer opens the panel. Wider is easier to hit; narrower keeps the panel out of the way while you use the menu bar."
}
}
}
},
"trigger.reset": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Auf Standard zurücksetzen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Reset to default"
}
}
}
},
"trigger.width %lld": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Breite, %lld pt je Seite"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Width, %lld pt per side"
}
}
}
}
},
"version": "1.1"
}

View File

@@ -1,4 +1,7 @@
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt
/// alle mit der Zeigerposition.
@@ -17,6 +20,14 @@ import AppKit
@MainActor
public final class NotchCoordinator {
/// Die Auslösefläche für alle Bildschirme. Ändern wirkt sofort.
public var trigger: TriggerArea = .standard {
didSet {
guard trigger != oldValue else { return }
controllers.values.forEach { $0.trigger = trigger }
}
}
public var policy: DisplayPolicy {
didSet { guard policy != oldValue else { return }; rebuild() }
}
@@ -30,6 +41,7 @@ public final class NotchCoordinator {
private var suppressionTimer: Timer?
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
private var pointerPollTimer: Timer?
private var pollInterval: TimeInterval = 0
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
/// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen
@@ -37,9 +49,11 @@ public final class NotchCoordinator {
private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView
public init(policy: DisplayPolicy = .builtInOnly,
trigger: TriggerArea = .standard,
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) {
self.policy = policy
self.trigger = trigger
self.virtualNotchSize = virtualNotchSize
self.makeContent = makeContent
}
@@ -49,6 +63,9 @@ public final class NotchCoordinator {
public func start() {
rebuild()
installMonitors()
// Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die
// Abfrage ist es, die überhaupt etwas sichtbar macht.
updatePolling()
NotificationCenter.default.addObserver(
self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil)
@@ -80,6 +97,7 @@ public final class NotchCoordinator {
private func rebuild() {
let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics))
let wantedIDs = Set(wanted.map(\.displayID))
log.notice("Bildschirme neu aufgebaut: \(wantedIDs.count, privacy: .public) von \(NSScreen.screens.count, privacy: .public) verwendet")
for (id, controller) in controllers where !wantedIDs.contains(id) {
controller.tearDown()
@@ -96,6 +114,7 @@ public final class NotchCoordinator {
let controller = NotchScreenController(
metrics: metrics,
virtualSize: virtualNotchSize,
trigger: trigger,
makeContent: { presentation in makeContent(id, presentation) })
controller.onVisibilityChanged = { [weak self] _ in
self?.updatePolling()
@@ -122,18 +141,19 @@ public final class NotchCoordinator {
return event
}) { monitors.append(local) }
// Klicks in der eigenen App: in der Notch fixieren, sonst schließen.
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
MainActor.assumeIsolated { [weak self] in
guard let self else { return }
let point = NSEvent.mouseLocation
for controller in controllers.values
where controller.isVisible && controller.geometry.triggerRect.contains(point) {
controller.click()
}
}
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
return event
}) { monitors.append(clicks) }
// Klicks in fremden Programmen schließen ein fixiertes Panel. Ohne das
// bleibt es stehen, bis jemand Escape drückt oder die Regel kennt.
if let outside = NSEvent.addGlobalMonitorForEvents(
matching: [.leftMouseDown, .rightMouseDown], handler: { _ in
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
}) { monitors.append(outside) }
if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in
guard event.keyCode == 53 else { return event } // esc
MainActor.assumeIsolated { [weak self] in
@@ -143,6 +163,20 @@ public final class NotchCoordinator {
}) { monitors.append(keys) }
}
/// Ein Klick fixiert nur in der Notch selbst. Überall sonst schließt er
/// auch das eigene Panel, denn wer hineinklickt, bedient ein Widget und
/// nicht das Panel.
private func handleClick() {
let point = NSEvent.mouseLocation
for controller in controllers.values where controller.isVisible {
if controller.geometry.triggerRect.contains(point) {
controller.click()
} else if !controller.containsVisiblePoint(point) {
controller.clickedOutside()
}
}
}
private func pointerMoved() {
let point = NSEvent.mouseLocation
for controller in controllers.values {
@@ -150,39 +184,62 @@ public final class NotchCoordinator {
}
}
/// Fragt die Zeigerposition ab, solange ein Panel offen ist.
/// Fragt die Zeigerposition ab immer, nur unterschiedlich oft.
///
/// Über dem eigenen Panel bekommt Onyx sonst gar nichts mit: der globale
/// Monitor feuert nur für Ereignisse, die an **fremde** Programme gehen, und
/// der lokale nur, wenn Onyx das aktive Programm ist was es als
/// `.accessory`-App nie ist. Genau in dem Moment, in dem der Zeiger das
/// Panel wieder verlässt, käme das Ereignis also verspätet oder gar nicht,
/// und das Schließen wirkt hakelig.
/// **Warum überhaupt abgefragt wird und nicht nur zugehört:**
///
/// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft
/// nur, während wirklich etwas offen ist, und kostet dabei einen
/// Rechteckvergleich pro Bild.
/// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der
/// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der
/// lokale nur, wenn Onyx das aktive Programm ist was es als
/// `.accessory`-App nie ist.
///
/// Und der Auslösebereich liegt in der Menüleiste. Dorthin stellt macOS
/// Mausbewegungen nicht verlässlich an fremde Programme zu: mal kommen sie
/// an, mal nicht, je nachdem, was gerade den Zeiger führt. Genau daher kam
/// das manchmal geht die Notch nicht auf". Ein Verfahren, das eine
/// Zustellung voraussetzt, die es nicht gibt, kann nicht zuverlässig sein.
///
/// Deshalb sind die Monitore nur noch Beschleunigung, und die Abfrage ist
/// die Wahrheit. Sie kostet einen Rechteckvergleich; bei zehn Abfragen je
/// Sekunde im Ruhezustand ist das nicht messbar.
private func updatePolling() {
let anyVisible = controllers.values.contains { $0.isVisible }
let interval = anyVisible ? Self.visiblePollInterval : Self.idlePollInterval
guard interval != pollInterval || pointerPollTimer == nil else { return }
if anyVisible, pointerPollTimer == nil {
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
}
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
// während ein Menü offen ist oder gescrollt wird.
RunLoop.main.add(timer, forMode: .common)
pointerPollTimer = timer
} else if !anyVisible {
pointerPollTimer?.invalidate()
pointerPollTimer = nil
pointerPollTimer?.invalidate()
let timer = Timer(timeInterval: interval, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
}
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
// während ein Menü offen ist oder gescrollt wird.
RunLoop.main.add(timer, forMode: .common)
pointerPollTimer = timer
pollInterval = interval
log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)")
}
/// Im Ruhezustand genügt ein Zehntel: dazu kommt ohnehin die Entprellzeit
/// von 220 ms, bevor sich etwas rührt.
private static let idlePollInterval: TimeInterval = 1.0 / 10.0
/// Offen wird jedes Bild abgefragt, sonst wirkt das Schließen hakelig.
private static let visiblePollInterval: TimeInterval = 1.0 / 60.0
private func refreshSuppression() {
// Einmal je Durchgang abfragen statt einmal je Bildschirm: die Sperre
// gilt für das ganze System.
let playing = NotchSuppression.isPreventingDisplaySleep
for screen in NSScreen.screens {
controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow)
guard let controller = controllers[screen.displayID] else { continue }
let suppress = NotchSuppression.shouldSuppress(
hasFullScreenWindow: screen.isShowingFullScreenWindow,
preventsDisplaySleep: playing)
controller.setSuppressed(suppress)
}
// Fällt die Unterdrückung weg, während der Zeiger schon in der Notch
// steht, käme sonst nichts die Maschine wartet auf ein Ereignis, das
// erst bei der nächsten Bewegung kommt.
pointerMoved()
}
// MARK: - Zugriff für die App

View File

@@ -70,11 +70,22 @@ public struct NotchGeometry: Equatable, Sendable {
public let rect: CGRect
public let screenFrame: CGRect
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch.
/// Wie weit die Auslösefläche über die Notch hinausreicht einstellbar,
/// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``.
public var trigger: TriggerArea = .standard
/// Nur noch für Bestandscode: die frühere feste Zugabe.
public static let triggerInsetX: CGFloat = 16
public static let triggerInsetY: CGFloat = 4
/// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand,
/// dort ist der Bildschirmrand. Vier Punkte waren zu wenig eine Hand
/// hält den Zeiger nicht auf vier Punkte genau.
public static let triggerInsetY: CGFloat = 14
func with(trigger: TriggerArea) -> NotchGeometry {
var copy = self
copy.trigger = trigger
return copy
}
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
self.kind = kind
@@ -82,7 +93,8 @@ public struct NotchGeometry: Equatable, Sendable {
self.screenFrame = screenFrame
}
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> NotchGeometry {
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize,
trigger: TriggerArea = .standard) -> NotchGeometry {
// Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am
// Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt,
// obwohl es nichts gibt, um das man herumlegen könnte.
@@ -97,7 +109,7 @@ public struct NotchGeometry: Equatable, Sendable {
y: metrics.frame.maxY - metrics.safeAreaTop,
width: width,
height: metrics.safeAreaTop),
screenFrame: metrics.frame)
screenFrame: metrics.frame).with(trigger: trigger)
}
}
@@ -108,14 +120,12 @@ public struct NotchGeometry: Equatable, Sendable {
y: metrics.frame.maxY - virtualSize.height,
width: width,
height: virtualSize.height),
screenFrame: metrics.frame)
screenFrame: metrics.frame).with(trigger: trigger)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
public var triggerRect: CGRect {
rect
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
.clamped(to: screenFrame)
trigger.rect(notch: rect, screen: screenFrame)
}
/// Der Bereich, in dem Inhalt tatsächlich zu sehen ist.

View File

@@ -28,12 +28,75 @@ public final class NotchPresentation {
self.contentTopInset = contentTopInset
}
/// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste
/// zuzudecken also die Breite der Notch.
///
/// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr.
public var notchWidth: CGFloat { collapsedSize.width }
/// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf.
/// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel
/// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen.
public static let collapseDuration: TimeInterval = 0.30
}
/// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll.
///
/// **Warum nicht einfach ein Rechteck.** Das Panel reicht bis an die
/// Bildschirmoberkante, damit seine Fläche mit der schwarzen Notch zu einem
/// Körper verschmilzt. Sobald es breiter wird als die Notch, deckt es links
/// und rechts davon aber die Menüleiste zu bei sechs Kacheln die ganze,
/// samt aller Statuselemente.
///
/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die
/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei.
struct NotchShape: Shape, InsettableShape {
var cornerRadius: CGFloat
var topBandHeight: CGFloat
var notchWidth: CGFloat
var inset: CGFloat = 0
var animatableData: AnimatablePair<CGFloat, CGFloat> {
get { .init(cornerRadius, notchWidth) }
set { cornerRadius = newValue.first; notchWidth = newValue.second }
}
func inset(by amount: CGFloat) -> NotchShape {
var copy = self
copy.inset += amount
return copy
}
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: inset, dy: inset)
// Ist das Panel nicht breiter als die Notch, ist der Steg die ganze
// Form dann bleibt es die schlichte abgerundete Fläche.
let band = min(max(notchWidth - 2 * inset, 0), rect.width)
let radius = min(cornerRadius, rect.width / 2, rect.height / 2)
var path = Path()
guard topBandHeight > 0, band < rect.width - 0.5 else {
path.addRoundedRect(in: rect,
cornerSize: CGSize(width: radius, height: radius),
style: .continuous)
return path
}
let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY,
width: band, height: topBandHeight + radius)
let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight,
width: rect.width, height: rect.height - topBandHeight)
// Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und
// eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen.
path.addRect(bandRect)
path.addRoundedRect(in: body,
cornerSize: CGSize(width: radius, height: radius),
style: .continuous)
return path
}
}
/// Der Rahmen, der aus der Notch herauswächst.
///
/// Die Fläche wird **nicht** eingeblendet. Sie beginnt exakt in der Notch-Silhouette
@@ -66,7 +129,9 @@ public struct NotchContainer<Content: View>: View {
}
public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
let shape = NotchShape(cornerRadius: cornerRadius,
topBandHeight: presentation.contentTopInset,
notchWidth: presentation.notchWidth)
ZStack(alignment: .top) {
// Das Weichzeichner-Material behält **feste** Größe; animiert wird
@@ -94,7 +159,7 @@ public struct NotchContainer<Content: View>: View {
.init(color: .clear, location: 0.4)],
startPoint: .top, endPoint: .bottom))
}
.overlay { shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1) }
.overlay { shape.stroke(Onyx.Color.hairline, lineWidth: 1) }
.frame(width: size.width, height: size.height)
// Konstante Deckkraft: einen Schatten aufzublenden kostet in
// jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
@@ -159,6 +224,17 @@ public final class NotchHostingView<Content: View>: NSHostingView<Content> {
width: size.width,
height: size.height)
guard visible.contains(point) else { return nil }
// Links und rechts neben der Notch gehört der oberste Streifen der
// Menüleiste. Dort ist das Panel durchsichtig und muss auch
// durchlässig sein, sonst schluckt es Klicks auf fremde
// Statuselemente, ohne dass man sieht, warum.
if presentation.isExpanded, point.y < presentation.contentTopInset {
let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0,
width: presentation.notchWidth,
height: presentation.contentTopInset)
guard band.contains(point) else { return nil }
}
return super.hitTest(point)
}
}

View File

@@ -1,4 +1,7 @@
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch")
/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen.
///
@@ -35,12 +38,23 @@ public final class NotchScreenController {
CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height)
}
/// Die eingestellte Auslösefläche. Ändern wirkt sofort.
public var trigger: TriggerArea {
didSet {
guard trigger != oldValue else { return }
geometry.trigger = trigger
}
}
public init(metrics: ScreenMetrics,
virtualSize: CGSize,
trigger: TriggerArea = .standard,
contentSize: CGSize = CGSize(width: 640, height: 320),
makeContent: (NotchPresentation) -> NSView) {
self.displayID = metrics.displayID
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
self.trigger = trigger
let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
self.geometry = geometry
self.contentSize = contentSize
self.presentation = NotchPresentation(
@@ -63,23 +77,45 @@ public final class NotchScreenController {
// MARK: - Eingaben
/// Zeigerposition in globalen Bildschirmkoordinaten.
/// Wo der Zeiger beim letzten Verlassen stand nur fürs Protokoll.
private var lastPoint: CGPoint = .zero
public func pointerMoved(to point: CGPoint) {
lastPoint = point
// Nur die tatsächlich sichtbare Fläche zählt. Das Fenster ist immer so
// groß wie das ausgefahrene Panel, damit die Animation Platz hat
// würde man `panel.frame` nehmen, hielte ein Zeiger in der noch leeren
// Fläche das Panel offen.
let inside = geometry.triggerRect.contains(point)
|| (presentation.isExpanded && panel.frame.contains(point))
let inside = geometry.triggerRect.contains(point) || containsVisiblePoint(point)
send(inside ? .pointerEntered : .pointerExited)
}
public func click() { send(.click) }
public func clickedOutside() { send(.clickedOutside) }
/// Ob ein Punkt auf der **sichtbaren** Fläche des Panels liegt.
///
/// Nicht `panel.frame`: das Fenster reicht bis an die Bildschirmoberkante
/// und ist links und rechts der Notch dort durchsichtig dieser Streifen
/// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick
/// auf ein fremdes Statuselement für einen Klick ins Panel.
public func containsVisiblePoint(_ point: CGPoint) -> Bool {
guard presentation.isExpanded, panel.frame.contains(point) else { return false }
let menuBarBand = panel.frame.maxY - geometry.rect.height
guard point.y > menuBarBand else { return true }
return abs(point.x - panel.frame.midX) <= geometry.rect.width / 2
}
public func escape() { send(.escape) }
public func setSuppressed(_ suppressed: Bool) { send(.suppressed(suppressed)) }
public func setSuppressed(_ suppressed: Bool) {
guard suppressed != machine.isSuppressed else { return }
log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)")
send(.suppressed(suppressed))
}
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize,
trigger: trigger)
presentation.collapsedSize = geometry.rect.size
// Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem
// (virtueller Balken) ändert sich der Freiraum mit.
@@ -103,7 +139,22 @@ public final class NotchScreenController {
/// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück.
private func send(_ input: NotchInput) {
let wasVisible = machine.isVisible
let before = machine.phase
apply(machine.handle(input))
// Jeden Zustandswechsel protokollieren außer den beiden, die
// sekündlich hunderte Male kommen. Ohne diese Spur ist ein Panel, das
// sich manchmal nicht öffnet", nicht zu untersuchen: hinterher weiß
// niemand mehr, ob der Zeiger erkannt wurde, ob die Maschine
// umgeschaltet hat oder ob die Unterdrückung dazwischenkam.
if machine.phase != before {
log.notice("Notch \(self.displayID, privacy: .public): \(String(describing: before), privacy: .public)\(String(describing: self.machine.phase), privacy: .public) durch \(String(describing: input), privacy: .public)")
// Beim Schließen zusätzlich, **warum** der Zeiger als draußen galt.
// Ohne diese drei Zahlen bleibt es ging zu, obwohl ich noch drin
// war" eine Behauptung gegen eine andere.
if before == .open, self.machine.phase == .closing {
log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)")
}
}
if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) }
}

View File

@@ -20,6 +20,12 @@ public enum NotchInput: Equatable, Sendable {
case pointerEntered
case pointerExited
case click
/// Klick irgendwo außerhalb von Panel und Notch.
///
/// Der Ausweg aus einem fixierten Panel. Klick fixiert" ist eine gute
/// Regel, aber wer sie nicht kennt, sitzt sonst vor einem Panel, das offen
/// steht und auf nichts reagiert.
case clickedOutside
case escape
case armTimerFired
case closeTimerFired
@@ -59,6 +65,16 @@ public struct NotchStateMachine: Equatable, Sendable {
public private(set) var phase: NotchPhase = .idle
public private(set) var isSuppressed: Bool = false
/// Wo der Zeiger zuletzt war. Die Maschine braucht das, um beim Ablauf der
/// Entprellzeit zu entscheiden ohne diese Angabe würde ein beiläufiges
/// Streifen der Notch das Panel kurz aufblitzen lassen: betreten,
/// verlassen, Entprellzeit läuft trotzdem ab, Panel auf, Frist abgelaufen,
/// Panel zu.
private var isPointerInside = false
/// Ob die Entprellzeit schon abgelaufen ist, während der Zeiger draußen war.
/// Kommt er zurück, wird ohne weiteres Warten geöffnet.
private var debounceElapsed = false
public init() {}
/// Sichtbar, egal aus welchem Grund.
@@ -88,19 +104,54 @@ public struct NotchStateMachine: Equatable, Sendable {
guard !isSuppressed else { return [] }
switch input {
case .pointerEntered: isPointerInside = true
case .pointerExited: isPointerInside = false
default: break
}
switch (phase, input) {
// Öffnen
case (.idle, .pointerEntered):
phase = .arming
debounceElapsed = false
return [.startArmTimer(Self.armDelay)]
case (.arming, .armTimerFired):
// Nur öffnen, wenn der Zeiger auch da ist. Sonst weitermerken,
// dass die Entprellzeit vorbei ist kommt er zurück, geht es
// sofort auf; bleibt er weg, beendet die Abbruchfrist die Sache.
guard isPointerInside else {
debounceElapsed = true
return []
}
phase = .open
return [.show]
// Kurz herausrutschen bricht nicht ab.
//
// Ein Zeiger, den eine Hand an den oberen Rand führt, steht dort nicht
// still. Jedes Abtastbild außerhalb sofort als Abbruch zu werten hieß:
// bei zitternder Hand fing die Entprellzeit dauernd von vorn an und kam
// nie ans Ziel das Panel öffnete manchmal einfach nicht".
//
// Der Aufziehzeitgeber läuft weiter; parallel läuft die Frist, nach der
// wirklich abgebrochen wird.
case (.arming, .pointerExited):
return [.startCloseTimer(Self.closeDelay)]
case (.arming, .pointerEntered):
// Zurück in der Zone: nur die Abbruchfrist stoppen. Die Entprellzeit
// **nicht** neu ansetzen, sonst dauert es bei jedem Grenzübertritt
// wieder von vorn.
guard debounceElapsed else { return [.cancelCloseTimer] }
phase = .open
return [.cancelCloseTimer, .show]
case (.arming, .closeTimerFired):
phase = .idle
debounceElapsed = false
return [.cancelArmTimer]
// Schließen
@@ -128,9 +179,15 @@ public struct NotchStateMachine: Equatable, Sendable {
return []
// Beenden
case (.open, .escape), (.pinned, .escape), (.closing, .escape):
case (.open, .escape), (.pinned, .escape), (.closing, .escape),
(.open, .clickedOutside), (.pinned, .clickedOutside), (.closing, .clickedOutside):
phase = .idle
return [.hide]
return [.cancelCloseTimer, .hide]
case (.arming, .clickedOutside):
phase = .idle
debounceElapsed = false
return [.cancelArmTimer, .cancelCloseTimer]
case (.arming, .escape):
phase = .idle

View File

@@ -0,0 +1,36 @@
import Foundation
import IOKit.pwr_mgt
/// Wann das Panel wegbleibt.
public enum NotchSuppression {
/// Beide Bedingungen zusammen, nicht eine davon.
///
/// Die erste Fassung hat jedes Vollbildfenster als Grund gewertet. Damit war
/// die Notch tot, sobald jemand Xcode im Vollbild hatte und das ist kein
/// Zustand, in dem man auf seinen Kalender verzichten will. Gemeint war
/// immer nur der Film über den ganzen Bildschirm.
public static func shouldSuppress(hasFullScreenWindow: Bool,
preventsDisplaySleep: Bool) -> Bool {
hasFullScreenWindow && preventsDisplaySleep
}
/// Ob gerade etwas den Bildschirm wachhält.
///
/// Das ist der verlässlichste Hinweis auf laufende Wiedergabe, den macOS
/// ohne Sonderrechte hergibt: jedes Programm, das ein Video abspielt, setzt
/// eine solche Sperre, damit der Bildschirm nicht mitten im Film dunkel
/// wird. Ein Textfenster im Vollbild tut das nicht.
public static var isPreventingDisplaySleep: Bool {
var assertions: Unmanaged<CFDictionary>?
guard IOPMCopyAssertionsStatus(&assertions) == kIOReturnSuccess,
let status = assertions?.takeRetainedValue() as? [String: Int]
else { return false }
// Beide Namen kommen vor: der ältere von IOKit und der, den
// AVFoundation benutzt.
let names = [kIOPMAssertionTypeNoDisplaySleep as String,
kIOPMAssertionTypePreventUserIdleDisplaySleep as String]
return names.contains { (status[$0] ?? 0) > 0 }
}
}

View File

@@ -0,0 +1,39 @@
import Foundation
import CoreGraphics
/// Wie weit die Auslösefläche über die Notch hinausreicht.
///
/// Feste Werte im Code waren dafür die falsche Antwort: wie genau jemand die
/// Notch trifft, hängt an der Hand und am Zeigegerät. Wer mit dem Trackpad
/// grob nach oben fährt, will eine großzügige Fläche; wer die Menüleiste viel
/// benutzt, will sie knapp, damit das Panel nicht dauernd aufgeht.
public struct TriggerArea: Codable, Equatable, Sendable {
/// Zugabe je Seite.
public let extraWidth: CGFloat
/// Zugabe nach unten.
public let extraHeight: CGFloat
public static let maximumExtraWidth: CGFloat = 200
public static let maximumExtraHeight: CGFloat = 120
/// Was bisher fest im Code stand. Wer nichts einstellt, merkt nichts.
public static let standard = TriggerArea(extraWidth: 16, extraHeight: 14)
public init(extraWidth: CGFloat, extraHeight: CGFloat) {
self.extraWidth = min(max(extraWidth, 0), Self.maximumExtraWidth)
self.extraHeight = min(max(extraHeight, 0), Self.maximumExtraHeight)
}
/// Die Fläche, deren Betreten das Panel scharf schaltet.
///
/// Zur Seite wächst sie symmetrisch, nach unten einseitig nach oben ist
/// der Bildschirmrand, von dort kommt niemand.
public func rect(notch: CGRect, screen: CGRect) -> CGRect {
CGRect(x: notch.minX - extraWidth,
y: notch.minY - extraHeight,
width: notch.width + 2 * extraWidth,
height: notch.height + extraHeight)
.intersection(screen)
}
}

View File

@@ -0,0 +1,350 @@
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?
}

View File

@@ -2,58 +2,25 @@ import Foundation
import CoreGraphics
import OnyxDesign
/// Die Größen, in denen ein Widget im Panel auftreten darf.
public enum WidgetSize: String, Codable, Sendable, CaseIterable, Identifiable {
/// 1 × 1 ein Messwert, ein Ring.
case small
/// 2 × 1 Messwert mit Verlauf, kurze Liste.
case medium
/// 2 × 2 Mini-Monat, Sensorliste.
case large
/// 4 × 1 Medienzeile über die volle Breite.
case wide
public var id: String { rawValue }
public var columnSpan: Int {
switch self {
case .small: 1
case .medium, .large: 2
case .wide: 4
}
}
public var rowSpan: Int {
switch self {
case .small, .medium, .wide: 1
case .large: 2
}
}
}
/// Ein Widget an seinem Platz im Layout, wie der Nutzer es angeordnet hat.
/// Ein Widget an seinem Platz im Layout, in der Reihenfolge, die der Nutzer
/// festgelegt hat.
///
/// Ohne Größe: alle Kacheln sind gleich groß. Eine Größenauswahl klingt nach
/// Freiheit und bringt vor allem Löcher ein 2×1 neben einem 2×2 lässt oben
/// rechts eine Lücke, die niemand füllen kann und die aussieht wie ein Fehler.
public struct WidgetPlacement: Codable, Equatable, Identifiable, Sendable {
public let id: UUID
public var widgetID: String
public var size: WidgetSize
public init(id: UUID = UUID(), widgetID: String, size: WidgetSize) {
public init(id: UUID = UUID(), widgetID: String) {
self.id = id
self.widgetID = widgetID
self.size = size
}
}
public struct GridCell: Hashable, Sendable, CustomStringConvertible {
public let row: Int
public let column: Int
public init(row: Int, column: Int) {
self.row = row
self.column = column
}
public var description: String { "(\(row),\(column))" }
/// Ältere Layouts trugen noch eine Größe. Sie wird beim Lesen verworfen,
/// statt die Datei für ungültig zu erklären sonst stünde nach dem
/// Aktualisieren ein leeres Panel da.
private enum CodingKeys: String, CodingKey { case id, widgetID }
}
/// Ein Widget mit ausgerechneter Rasterposition.
@@ -63,92 +30,67 @@ public struct ResolvedPlacement: Equatable, Identifiable, Sendable {
public let column: Int
public var id: UUID { placement.id }
public var columnSpan: Int { placement.size.columnSpan }
public var rowSpan: Int { placement.size.rowSpan }
}
/// Setzt Widgets in ein Raster mit fester Spaltenzahl.
/// Setzt Widgets nebeneinander.
///
/// Das Panel wächst in die **Breite**. Es sitzt an der Notch, also am oberen
/// Rand nach unten zu wachsen bedeckt den Bildschirm, nach rechts und links
/// legt es sich in den ohnehin leeren Streifen neben der Notch. Eine zweite
/// Reihe entsteht erst, wenn der Bildschirm keine weitere Kachel mehr hergibt.
public enum LayoutEngine {
public static let columns = 4
/// Kantenlänge eines 1×1-Feldes.
public static let cellSize: CGFloat = 96
/// Ordnet die Widgets in der gespeicherten Reihenfolge ein, jedes an die
/// erste Stelle, an die es passt.
/// Die eine Kachelgröße.
///
/// Der Reihe nach von links oben zu suchen statt stur hintereinander zu
/// setzen ist der ganze Unterschied: neben einem 2×2-Widget bleiben rechts
/// zwei 1×1-Plätze frei, und ein Verfahren, das immer nur hinten anfügt,
/// lässt sie dauerhaft leer.
public static func resolve(_ placements: [WidgetPlacement]) -> [ResolvedPlacement] {
var occupied = Set<GridCell>()
var resolved: [ResolvedPlacement] = []
/// Hochkant, weil fast jedes Widget eine Liste zeigt: Termine, Sensoren,
/// Programme im Mischpult. Breiter als hoch wäre für ein einziges davon
/// besser und für alle anderen schlechter.
public static let tileSize = CGSize(width: 180, height: 200)
public static var tileSpacing: CGFloat { Onyx.Metric.tileSpacing }
private static var padding: CGFloat { Onyx.Metric.panelPadding }
for placement in placements {
let span = placement.size
// Zu breit für das Raster: einpassen statt verwerfen. Ein Widget
// verschwinden zu lassen wäre für den Nutzer nicht erklärbar.
let columnSpan = min(span.columnSpan, columns)
guard let cell = firstFreeCell(columnSpan: columnSpan,
rowSpan: span.rowSpan,
occupied: occupied) else { continue }
for row in cell.row..<(cell.row + span.rowSpan) {
for column in cell.column..<(cell.column + columnSpan) {
occupied.insert(GridCell(row: row, column: column))
}
}
resolved.append(ResolvedPlacement(placement: placement,
row: cell.row,
column: cell.column))
}
return resolved
/// Wie viele Kacheln nebeneinander passen.
public static func columns(availableWidth: CGFloat) -> Int {
let usable = availableWidth - 2 * padding + tileSpacing
let fitting = Int(usable / (tileSize.width + tileSpacing))
// Mindestens eine: lieber ein Panel, das über den Rand ragt, als eine
// Division durch null oder ein Panel ganz ohne Inhalt.
return max(1, fitting)
}
private static func firstFreeCell(columnSpan: Int,
rowSpan: Int,
occupied: Set<GridCell>) -> GridCell? {
let maxRow = (occupied.map(\.row).max() ?? -1) + rowSpan + 1
for row in 0...maxRow {
for column in 0...(columns - columnSpan) {
let fits = (row..<(row + rowSpan)).allSatisfy { r in
(column..<(column + columnSpan)).allSatisfy { c in
!occupied.contains(GridCell(row: r, column: c))
}
}
if fits { return GridCell(row: row, column: column) }
}
public static func resolve(_ placements: [WidgetPlacement],
availableWidth: CGFloat) -> [ResolvedPlacement] {
let columnCount = columns(availableWidth: availableWidth)
return placements.enumerated().map { index, placement in
ResolvedPlacement(placement: placement,
row: index / columnCount,
column: index % columnCount)
}
return nil
}
/// Die Größe, die das Panel für dieses Layout braucht.
public static func panelSize(for resolved: [ResolvedPlacement]) -> CGSize {
let spacing = Onyx.Metric.tileSpacing
let padding = Onyx.Metric.panelPadding
let rows = resolved.map { $0.row + $0.rowSpan }.max() ?? 1
let usedRows = max(rows, 1)
public static func panelSize(for placements: [WidgetPlacement],
availableWidth: CGFloat) -> CGSize {
let columnCount = columns(availableWidth: availableWidth)
// Ein leeres Layout bekommt trotzdem eine Kachelfläche: ein Panel ohne
// Ausdehnung wäre ein unsichtbares Panel, und der Nutzer hielte das
// für einen Absturz.
let count = max(placements.count, 1)
let used = min(count, columnCount)
let rows = Int(ceil(Double(count) / Double(columnCount)))
return CGSize(
width: CGFloat(columns) * cellSize + CGFloat(columns - 1) * spacing + 2 * padding,
height: CGFloat(usedRows) * cellSize + CGFloat(usedRows - 1) * spacing + 2 * padding)
width: CGFloat(used) * tileSize.width
+ CGFloat(used - 1) * tileSpacing + 2 * padding,
height: CGFloat(rows) * tileSize.height
+ CGFloat(rows - 1) * tileSpacing + 2 * padding)
}
/// Der Rahmen eines Widgets innerhalb des Panels, in SwiftUI-Koordinaten
/// (Ursprung oben links).
public static func frame(for item: ResolvedPlacement, in panelSize: CGSize) -> CGRect {
let spacing = Onyx.Metric.tileSpacing
let padding = Onyx.Metric.panelPadding
let x = padding + CGFloat(item.column) * (cellSize + spacing)
let y = padding + CGFloat(item.row) * (cellSize + spacing)
let width = CGFloat(item.columnSpan) * cellSize + CGFloat(item.columnSpan - 1) * spacing
let height = CGFloat(item.rowSpan) * cellSize + CGFloat(item.rowSpan - 1) * spacing
return CGRect(x: x, y: y, width: width, height: height)
/// Der Rahmen einer Kachel im Panel, in SwiftUI-Koordinaten (oben links).
public static func frame(for item: ResolvedPlacement) -> CGRect {
CGRect(x: padding + CGFloat(item.column) * (tileSize.width + tileSpacing),
y: padding + CGFloat(item.row) * (tileSize.height + tileSpacing),
width: tileSize.width,
height: tileSize.height)
}
}

View File

@@ -62,15 +62,9 @@ public struct LayoutStore: Sendable {
/// wenige Widgets, die sofort etwas zeigen, als ein volles Panel, das
/// erst konfiguriert werden will.
public static func defaultLayout(knownWidgetIDs: Set<String>) -> [WidgetPlacement] {
let preferred: [(String, WidgetSize)] = [
("calendar", .large),
("weather", .medium),
("cpu", .small),
("battery", .small),
("media", .wide),
]
let preferred = ["calendar", "weather", "media", "cpu", "battery"]
return preferred
.filter { knownWidgetIDs.contains($0.0) }
.map { WidgetPlacement(widgetID: $0.0, size: $0.1) }
.filter { knownWidgetIDs.contains($0) }
.map { WidgetPlacement(widgetID: $0) }
}
}

View File

@@ -13,10 +13,9 @@ public protocol OnyxWidget: Identifiable, Sendable {
var displayName: String { get }
var symbolName: String { get }
/// Welche Größen dieses Widget sinnvoll ausfüllen kann.
var supportedSizes: [WidgetSize] { get }
@ViewBuilder func makeView(size: WidgetSize) -> AnyView
/// Der Inhalt der Kachel. Alle Kacheln sind gleich groß was ein Widget
/// zeigt, entscheidet es selbst, nicht eine Größenauswahl.
@ViewBuilder func makeView() -> AnyView
/// Einstellungen dieses Widgets, falls es welche hat.
@ViewBuilder func makeSettingsView() -> AnyView?
}

View File

@@ -11,18 +11,22 @@ public struct WidgetGrid: View {
private let resolved: [ResolvedPlacement]
private let panelSize: CGSize
public init(placements: [WidgetPlacement]) {
self.resolved = LayoutEngine.resolve(placements)
self.panelSize = LayoutEngine.panelSize(for: resolved)
public init(placements: [WidgetPlacement], availableWidth: CGFloat) {
self.resolved = LayoutEngine.resolve(placements, availableWidth: availableWidth)
self.panelSize = LayoutEngine.panelSize(for: placements, availableWidth: availableWidth)
}
public var body: some View {
ZStack(alignment: .topLeading) {
ForEach(resolved) { item in
let frame = LayoutEngine.frame(for: item, in: panelSize)
let frame = LayoutEngine.frame(for: item)
widgetView(for: item)
.frame(width: frame.width, height: frame.height)
// Beschneiden ist Pflicht, kein Feinschliff: quillt der
// Inhalt über, zeichnet er über die Nachbarkachel und
// ganz oben über die Notch.
.clipped()
.offset(x: frame.minX, y: frame.minY)
}
}
@@ -32,7 +36,7 @@ public struct WidgetGrid: View {
@ViewBuilder
private func widgetView(for item: ResolvedPlacement) -> some View {
if let widget = WidgetRegistry.shared.widget(id: item.placement.widgetID) {
OnyxTile { widget.makeView(size: item.placement.size) }
OnyxTile { widget.makeView() }
} else {
// Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch,
// ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall.
@@ -50,7 +54,8 @@ public struct WidgetGrid: View {
/// Die Größe, die das Panel für dieses Layout braucht der Controller
/// stellt das Fenster darauf ein.
public static func panelSize(for placements: [WidgetPlacement]) -> CGSize {
LayoutEngine.panelSize(for: LayoutEngine.resolve(placements))
public static func panelSize(for placements: [WidgetPlacement],
availableWidth: CGFloat) -> CGSize {
LayoutEngine.panelSize(for: placements, availableWidth: availableWidth)
}
}

View File

@@ -0,0 +1,88 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "AirDrop")
/// Der Weg zur Empfängerauswahl.
public enum AirDrop {
/// Öffnet AirDrop mit den übergebenen Dateien.
///
/// - Returns: ob AirDrop überhaupt zur Verfügung stand.
@MainActor
@discardableResult
public static func send(_ urls: [URL]) -> Bool {
guard !urls.isEmpty else { return false }
guard let service = NSSharingService(named: .sendViaAirDrop) else {
log.error("AirDrop steht nicht zur Verfügung")
return false
}
guard service.canPerform(withItems: urls) else {
log.error("AirDrop kann diese Auswahl nicht senden")
return false
}
service.perform(withItems: urls)
return true
}
}
/// Eine Fläche, auf die man Dateien fallen lässt, um sie zu verschicken.
public struct AirDropView: View {
/// Schmal, wenn sie sich die Kachel mit der Ablage teilt.
private let compact: Bool
@State private var isTargeted = false
@State private var failed = false
public init(compact: Bool = false) {
self.compact = compact
}
public var body: some View {
Group {
if compact {
// Nebeneinander statt übereinander: in 52 Punkten Höhe ist für
// Symbol über Text kein Platz.
HStack(spacing: 8) {
Image(systemName: "shareplay")
.font(.system(size: 15, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(failed ? Onyx.Color.warning
: Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
} else {
VStack(spacing: 6) {
Image(systemName: "shareplay")
.font(.system(size: 20, weight: .light))
.foregroundStyle(isTargeted ? Onyx.Color.accent
: Onyx.Color.textTertiary)
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(failed ? Onyx.Color.warning
: Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
}
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
// Versprechen an, nicht als fertige Datei.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
.animation(Onyx.Motion.value, value: isTargeted)
}
}

View File

@@ -0,0 +1,86 @@
import SwiftUI
import UniformTypeIdentifiers
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Nimmt gezogene Dateien an auch die, die es noch gar nicht gibt.
///
/// Mail, WhatsApp und die meisten Chat-Programme ziehen Anhänge nicht als
/// fertige Datei heraus, sondern als **Versprechen**: die Datei entsteht erst,
/// wenn das Ziel sagt, wohin. Genau diese Anhänge sind der Hauptgrund für eine
/// Ablage, also darf die Fläche nicht nur `public.file-url` annehmen.
///
/// `loadFileRepresentation` ist der Weg, der beides abdeckt: bei einer echten
/// Datei reicht es sie durch, bei einem Versprechen löst es dieses ein. Deshalb
/// `UTType.item` als angenommener Typ die Wurzel, der alles entspricht.
/// Was sich nicht als Datei materialisieren lässt, fällt hinten heraus.
public struct FileDropModifier: ViewModifier {
@Binding private var isTargeted: Bool
private let onFiles: ([URL]) -> Void
public init(isTargeted: Binding<Bool>, onFiles: @escaping ([URL]) -> Void) {
self._isTargeted = isTargeted
self.onFiles = onFiles
}
public func body(content: Content) -> some View {
content.onDrop(of: [.item], isTargeted: $isTargeted) { providers in
for provider in providers { receive(provider) }
return !providers.isEmpty
}
}
private func receive(_ provider: NSItemProvider) {
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
if let error {
log.error("Ablage: Datei nicht erhalten: \(error.localizedDescription, privacy: .public)")
return
}
guard let url else { return }
// Die übergebene Datei existiert **nur innerhalb dieses Blocks**;
// danach räumt das System sie weg. Wer sie erst auf dem MainActor
// anfassen will, greift ins Leere also hier und sofort sichern.
guard let staged = Self.stage(url) else { return }
Task { @MainActor in onFiles([staged]) }
}
}
/// Rettet die Datei aus dem flüchtigen Verzeichnis in einen eigenen
/// Zwischenordner.
///
/// `nonisolated`, und das ist keine Formalie: der Block oben läuft auf
/// irgendeinem Thread, diese Methode gehört aber zu einem `ViewModifier`
/// und ist damit an den Hauptthread gebunden die Bindung kommt über die
/// Konformität, nicht über den Inhalt. Der Compiler mahnt das nur an, zur
/// Laufzeit hält die Prüfung das Programm an. Hier ist ohnehin nichts
/// dabei, was den Hauptthread bräuchte.
nonisolated private static func stage(_ url: URL) -> URL? {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("Onyx-Drop-\(UUID().uuidString)", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory,
withIntermediateDirectories: true)
let target = directory.appendingPathComponent(url.lastPathComponent)
// Verschieben, wenn möglich: bei einem Video von zwei Gigabyte ist
// der Unterschied zum Kopieren spürbar. Über Datenträgergrenzen
// hinweg scheitert es, dann wird kopiert.
do { try FileManager.default.moveItem(at: url, to: target) }
catch { try FileManager.default.copyItem(at: url, to: target) }
return target
} catch {
log.error("Ablage: Zwischenspeichern gescheitert: \(error.localizedDescription, privacy: .public)")
return nil
}
}
}
public extension View {
/// Macht die ganze Fläche zum Ziel für gezogene Dateien.
func onFileDrop(isTargeted: Binding<Bool>,
perform: @escaping ([URL]) -> Void) -> some View {
modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform))
}
}

View File

@@ -0,0 +1,167 @@
{
"sourceLanguage" : "en",
"strings" : {
"airdrop.hint" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zum Senden ablegen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Drop to send"
}
}
}
},
"airdrop.name" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop"
}
}
}
},
"airdrop.unavailable" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop kann das nicht senden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "AirDrop cannot send this"
}
}
}
},
"shelf.airdrop" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Per AirDrop senden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Send via AirDrop"
}
}
}
},
"shelf.clear" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Alles leeren"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clear all"
}
}
}
},
"shelf.delete" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "In den Papierkorb"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Move to Trash"
}
}
}
},
"shelf.empty" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Dateien hierhin ziehen, um sie zu parken."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Drop files here to park them."
}
}
}
},
"shelf.name" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ablage & AirDrop"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shelf & AirDrop"
}
}
}
},
"shelf.open" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Öffnen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open"
}
}
}
},
"shelf.reveal" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Finder zeigen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Show in Finder"
}
}
}
}
},
"version" : "1.1"
}

View File

@@ -0,0 +1,83 @@
import Foundation
/// Eine abgelegte Datei.
public struct ShelfItem: Identifiable, Equatable, Sendable {
public let url: URL
public let name: String
public let size: Int64
public let addedAt: Date
public var id: String { url.path }
public init(url: URL, name: String, size: Int64, addedAt: Date) {
self.url = url
self.name = name
self.size = size
self.addedAt = addedAt
}
/// Neuestes zuerst.
///
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
/// Dateien bei jeder Aktualisierung durcheinander.
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
items.sorted {
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
}
}
}
/// Findet freie Namen, ohne je etwas zu überschreiben.
public enum ShelfNaming {
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`,
///
/// Gleichnamige Dateien sind der Normalfall `IMG_0001.jpg` aus zwei
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
public static func uniqueName(for name: String, taken: Set<String>) -> String {
let name = name.isEmpty ? "Datei" : name
guard taken.contains(name) else { return name }
let (base, ext) = split(name)
var counter = 2
while true {
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
if !taken.contains(candidate) { return candidate }
counter += 1
}
}
/// Zerlegt in Namensteil und Endung.
///
/// Ein führender Punkt zählt nicht als Endung sonst würde aus
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
/// unbekannten Doppelendungen nichts kaputt macht.
private static func split(_ name: String) -> (base: String, ext: String) {
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
return (name, "")
}
return (String(name[name.startIndex..<dot]),
String(name[name.index(after: dot)...]))
}
}
/// Räumt alte Dateien weg wenn man es ausdrücklich möchte.
public enum ShelfCleanup {
/// Was über die Frist hinaus liegt.
///
/// `nil` und `0` heißen beide gar nicht". Eine Ablage, die ungefragt
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
/// nicht der Verlust aller Dateien sein.
public static func expired(_ items: [ShelfItem], now: Date,
afterDays: Int?) -> [ShelfItem] {
guard let days = afterDays, days > 0 else { return [] }
let limit = now.addingTimeInterval(-Double(days) * 86_400)
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
// unwiderruflich, einen Tag länger warten ist es nicht.
return items.filter { $0.addedAt < limit }
}
}

View File

@@ -0,0 +1,137 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
/// Die Ablage: ein Ordner, in dem Dateien zwischenlagern.
///
/// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in
/// temporären Verzeichnissen, die das System aufräumt ein Verweis darauf
/// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie
/// braucht.
@MainActor
@Observable
public final class ShelfStore {
public private(set) var items: [ShelfItem] = []
public private(set) var lastError: String?
/// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard.
public var autoCleanupDays: Int? {
didSet {
defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey)
cleanUp()
}
}
private static let cleanupKey = "shelf.cleanupDays"
public let directory: URL
private let defaults: UserDefaults
public init(defaults: UserDefaults = .standard, directory: URL? = nil) {
self.defaults = defaults
self.directory = directory ?? FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Shelf", isDirectory: true)
let stored = defaults.integer(forKey: Self.cleanupKey)
self.autoCleanupDays = stored > 0 ? stored : nil
try? FileManager.default.createDirectory(at: self.directory,
withIntermediateDirectories: true)
reload()
cleanUp()
}
// MARK: - Lesen
public func reload() {
let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey,
.contentModificationDateKey, .isDirectoryKey]
guard let urls = try? FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]) else {
items = []
return
}
items = ShelfItem.sorted(urls.compactMap { url in
let values = try? url.resourceValues(forKeys: Set(keys))
return ShelfItem(
url: url,
name: url.lastPathComponent,
size: Int64(values?.fileSize ?? 0),
// Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde.
// Ein zehn Jahre altes Foto von gestern soll nicht sofort
// ablaufen, nur weil die Kamera ein altes Datum trägt.
addedAt: values?.addedToDirectoryDate
?? values?.contentModificationDate ?? Date())
})
}
// MARK: - Hinzufügen
/// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam.
@discardableResult
public func add(_ urls: [URL]) -> [ShelfItem] {
var added: [ShelfItem] = []
var taken = Set(items.map(\.name))
for source in urls {
// Dateien aus fremden Apps kommen als sicherheitsbereichte URLs.
// Ohne das Paar aus Start und Stop scheitert das Kopieren mit
// Berechtigung verweigert" und zwar erst bei manchen Quellen,
// was den Fehler besonders unangenehm macht.
let scoped = source.startAccessingSecurityScopedResource()
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken)
let target = directory.appendingPathComponent(name)
do {
try FileManager.default.copyItem(at: source, to: target)
taken.insert(name)
let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
added.append(ShelfItem(url: target, name: name,
size: Int64(size), addedAt: Date()))
} catch {
log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)")
lastError = error.localizedDescription
}
}
if !added.isEmpty { reload() }
return added
}
// MARK: - Entfernen
public func remove(_ item: ShelfItem) {
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
// und ein Fehlklick darf keine Datei vernichten.
NSWorkspace.shared.recycle([item.url]) { @Sendable [weak self] _, error in
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
Task { @MainActor in self?.reload() }
}
}
public func removeAll() {
guard !items.isEmpty else { return }
NSWorkspace.shared.recycle(items.map(\.url)) { @Sendable [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Räumt ab, was über die eingestellte Frist hinaus liegt.
public func cleanUp(now: Date = Date()) {
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
guard !expired.isEmpty else { return }
log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen")
NSWorkspace.shared.recycle(expired.map(\.url)) { @Sendable [weak self] _, _ in
Task { @MainActor in self?.reload() }
}
}
/// Öffnet den Ablageordner im Finder der Notausgang, wenn die Oberfläche
/// nicht das kann, was man gerade braucht.
public func revealInFinder() {
NSWorkspace.shared.activateFileViewerSelecting([directory])
}
}

View File

@@ -0,0 +1,219 @@
import SwiftUI
import AppKit
import QuickLookThumbnailing
import OnyxDesign
import OnyxWidgetKit
/// Vorschaubilder, einmal erzeugt und behalten.
///
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
/// jedem Tastendruck ein neuer Auftrag laufen.
@MainActor
@Observable
final class ThumbnailCache {
private var images: [String: NSImage] = [:]
private var pending: Set<String> = []
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
if let cached = images[item.id] { return cached }
guard !pending.contains(item.id) else { return nil }
pending.insert(item.id)
let request = QLThumbnailGenerator.Request(
fileAt: item.url, size: CGSize(width: size, height: size),
scale: NSScreen.main?.backingScaleFactor ?? 2,
representationTypes: .all)
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { @Sendable rep, _ in
guard let rep else { return }
let image = NSImage(cgImage: rep.cgImage,
size: CGSize(width: size, height: size))
Task { @MainActor [weak self] in
self?.images[item.id] = image
self?.pending.remove(item.id)
}
}
return nil
}
}
/// Die Ablage.
public struct ShelfView: View {
private let store: ShelfStore
private let compact: Bool
@State private var isTargeted = false
@State private var thumbnails = ThumbnailCache()
public init(store: ShelfStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: 6) {
if store.items.isEmpty {
EmptyShelf()
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 4) {
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
ShelfRow(item: item, store: store, thumbnails: thumbnails)
}
}
}
.scrollIndicators(.never)
HStack(spacing: 10) {
Button { store.removeAll() } label: {
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
Button { store.revealInFinder() } label: {
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textSecondary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
.contentShape(.rect)
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
.overlay {
if isTargeted {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
.background(Onyx.Color.accent.opacity(0.08),
in: .rect(cornerRadius: 10, style: .continuous))
.allowsHitTesting(false)
}
}
.animation(Onyx.Motion.value, value: isTargeted)
}
}
private struct EmptyShelf: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: "tray")
.font(.system(size: 16))
.foregroundStyle(Onyx.Color.textTertiary)
Text("shelf.empty", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
private struct ShelfRow: View {
let item: ShelfItem
let store: ShelfStore
let thumbnails: ThumbnailCache
@State private var isHovered = false
var body: some View {
HStack(spacing: 8) {
thumbnail
VStack(alignment: .leading, spacing: 1) {
Text(item.name)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Text(item.size.formatted(.byteCount(style: .file)))
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
Spacer(minLength: 0)
if isHovered {
Button { AirDrop.send([item.url]) } label: {
Image(systemName: "shareplay").font(.system(size: 10))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.padding(.vertical, 2)
.contentShape(.rect)
.onHover { isHovered = $0 }
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
// reicht die URL als Text weiter, und im Finder landet eine
// Internetverknüpfung statt der Datei.
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
.contextMenu {
Button { NSWorkspace.shared.open(item.url) } label: {
Text("shelf.open", bundle: .module)
}
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
Text("shelf.reveal", bundle: .module)
}
Button { AirDrop.send([item.url]) } label: {
Text("shelf.airdrop", bundle: .module)
}
Divider()
Button(role: .destructive) { store.remove(item) } label: {
Text("shelf.delete", bundle: .module)
}
}
}
@ViewBuilder
private var thumbnail: some View {
if let image = thumbnails.image(for: item, size: 22) {
Image(nsImage: image)
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 22, height: 22)
.clipShape(.rect(cornerRadius: 3, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Onyx.Color.elevated)
.frame(width: 22, height: 22)
}
}
}
// MARK: - Widget
/// Ablage und AirDrop in **einer** Kachel.
///
/// Zwei eigene Kacheln waren zu viel des Guten: beide zeigen im Ruhezustand
/// nichts als eine leere Fläche mit einem Satz darin, und zusammen nahmen sie
/// ein Drittel des Panels. Übereinander in einer Kachel bleibt beides
/// erreichbar und kostet halb so viel Platz.
public struct ShelfWidget: OnyxWidget {
public let id = "shelf"
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
public let symbolName = "tray.full"
private let store: ShelfStore
public init(store: ShelfStore) { self.store = store }
public func makeView() -> AnyView {
AnyView(
VStack(spacing: 8) {
// AirDrop oben und schmal: es ist eine reine Ablegefläche ohne
// Zustand, die Ablage darunter braucht den Platz für ihre Liste.
AirDropView(compact: true)
.frame(height: 52)
Divider().overlay(Onyx.Color.hairline)
ShelfView(store: store)
}
)
}
}

Some files were not shown because too many files have changed in this diff Show More