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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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.
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.
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.
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.
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.
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 war 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 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 muss das
SVG entsprechend gezeichnet sein — als Outline statt als Flächen.
Adapter für alles inklusive Browser, AppleScript für Spotify und Musik,
Medientasten als letzte Reserve. Die Reihenfolge ist keine Vorliebe, sondern
Absicherung: Apple hat die Now-Playing-Schnittstelle in macOS 15.4 schon
einmal zugemacht. Fällt der Adapter aus, verliert das Widget seine Anzeige —
aber nicht seine Funktion. Deshalb bleiben die Transporttasten auch dann
bedienbar, wenn gar keine Metadaten da sind.
Der Fortschritt zählt zwischen den Ereignissen selbst hoch. Spike B hatte
gezeigt, dass elapsedTime nicht laufend nachgeliefert wird: ein Scrubber, der
stur den letzten Wert anzeigt, steht still, während der Titel läuft. Gerechnet
wird aus asOf und playbackRate, begrenzt auf die Titellänge — sonst zeigt die
Anzeige nach dem Ende weiter steigende Zahlen.
Teilmeldungen (diff = true) ergänzen den Stand, statt ihn zu ersetzen. Eine
Meldung mit nur der Position würde sonst Titel, Interpret und Cover löschen.
In AnyDecodable wird Bool vor Double geprüft. Andernfalls wird "playing": true
zu 1 und die Wiedergabe gilt für immer als angehalten.
Play/Pause schaltet sofort um, statt auf das nächste Ereignis zu warten. Ein
Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt an; das echte
Ereignis korrigiert den Stand ohnehin.
Der Adapter liegt als Quellcode unter Vendor/ (BSD-3, Stand in
ONYX-VERSION.txt) und wird beim Bauen kompiliert, statt als fertige Binary
eingecheckt zu werden. Er wird bewusst nicht gelinkt: geladen wird er von
/usr/bin/perl, im eigenen Prozess greift Apples Entitlement-Prüfung.
124 Tests grün.
Das vollständige App-Icon bringt die abgerundete Kachel samt Farbverlauf mit.
Die gehört in Dock und Finder; in der Menüleiste steht sie als farbiger Klotz
zwischen lauter Strichsymbolen.
Geladen wird jetzt dieselbe SVG-Datei, aus der auch das App-Icon gebaut wird.
NSImage liest sie als Vektor (_NSSVGImageRep), sie skaliert also verlustfrei
auf die 18 pt der Menüleiste.
Der Weg dorthin ist ein Kopierschritt und keine zweite Datei: actool kompiliert
das .icon-Bündel zu .icns und Assets.car und legt nichts Lesbares ins Bundle,
und eine einzelne Datei aus dem Bündel lässt sich nicht als Ressource
referenzieren — XcodeGen bricht dabei ab. Ein Build-Schritt kopiert sie
stattdessen, damit es bei einer Quelldatei bleibt.
Weiterhin kein Template-Bild: als Schablone bliebe vom Kristall nur eine
schwarze Silhouette.
Das Icon-Composer-Bündel liegt jetzt im App-Target, actool erzeugt daraus alle
Größen samt der Transluzenz-Darstellung von macOS 26.
Das Menüleistensymbol wird zur Laufzeit aus applicationIconImage auf 18 pt
gebracht — die Leiste ist 22 pt hoch und braucht oben und unten Luft. Bewusst
kein Template-Bild: als Schablone bliebe vom Kristall nur eine schwarze
Silhouette und der Verlauf wäre weg, also genau das Erkennungsmerkmal. Der
Preis ist, dass es sich nicht automatisch an helle und dunkle Menüleisten
anpasst.
Rückfall auf ein SF-Symbol, falls das App-Icon fehlt: ein Menüleisteneintrag
ohne Bild wäre unsichtbar und die App darüber unerreichbar.
"Wetter nicht abrufbar" war eine falsche Auskunft: abrufbar war es sehr wohl,
es fehlte nur die Erlaubnis. Derselbe Fehlertyp wie zuvor beim Kalender —
ein Zustand, den die App nicht kennt, landet im nächstbesten Sammelfall und
schickt den Nutzer damit in die Irre.
locationUndetermined ist jetzt ein eigener Zustand neben locationDenied. Der
Unterschied ist nicht kosmetisch: bei undetermined lohnt eine Anfrage, bei
denied fragt macOS nie wieder und es hilft nur der Weg über die
Systemeinstellungen oder ein fest gewählter Ort. Beide Fälle führen jetzt
dorthin, wo es weitergeht.
Die Anfrage kommt aus dem Berechtigungen-Reiter, nicht aus dem Hintergrund —
dieselbe Lehre wie beim Kalender: ohne Vordergrundfenster zeigt macOS keinen
Dialog, und die App wirkt kaputt, ohne dass irgendwo ein Fehler steht.
Aufgefallen war es daran, dass im Protokoll überhaupt kein Wetter-Eintrag
stand. Der einzige Pfad zu "nicht abrufbar" ohne Protokolleintrag war der
Standort — jetzt protokolliert auch der.
Eigene WeatherCondition statt WeatherKits Typ: die Widget-Ebene soll nicht an
ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft und eine
Entitlement voraussetzt. Fällt WeatherKit weg, wird nur die Abbildung getauscht.
Der Zwischenspeicher ist Teil der Funktion, kein Feinschliff: WeatherKit rechnet
nach Abrufen ab, und ein Widget, das bei jedem Öffnen des Panels neu lädt,
verbrennt das Kontingent für Daten, die sich stündlich ändern. 15 Minuten
Gültigkeit, und der Schlüssel führt den Ort mit — sonst zeigt das Widget nach
einem Ortswechsel weiter das alte Wetter, überzeugend, weil Zahlen dastehen.
Ein abgelaufener Stand bleibt als "letzter bekannter" erhalten. Bei einem
Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel ehrlicher und
nützlicher als ein leeres Feld; er wird abgeblendet und mit Warnzeichen gezeigt.
Apples Namensnennung steht im Datenmodell, nicht in einer Notiz: sie ist
Bedingung der Nutzung von WeatherKit und wird sonst beim Bauen der Oberfläche
übersehen. In der 2x1-Kachel als klickbarer Link, in der 1x1 im Tooltip.
Temperaturen ohne Nachkommastellen — die täuschen eine Genauigkeit vor, die
die Vorhersage nicht hat.
NICHT aktiviert: com.apple.developer.weatherkit. Die Entitlement erzwingt ein
echtes Provisioning-Profil, und die App-ID lässt sich nicht registrieren
("cannot be registered to your development team because it is not available").
Bis das im Portal geklärt ist, bleibt sie auskommentiert — die App baut und
läuft, das Widget meldet ehrlich "Wetter nicht abrufbar".
106 Tests grün.
Zwei Fehler, beide durch Messen gefunden statt durch Raten — und einer davon
war meine eigene fehlerhafte Diagnose: `log show` wurde von zsh abgefangen, ich
hatte stderr umgeleitet und die Fehlermeldung nie gesehen. Meine Aussage "keine
TCC-Einträge, also wurde nie gefragt" stützte sich damit auf einen Befehl, der
gar nicht lief.
Einstellungsfenster: SwiftUIs Settings-Szene wird über den privaten Selektor
showSettingsWindow: geöffnet, dessen Name sich zwischen macOS-Versionen schon
geändert hat und der bei .accessory-Apps ohne Menüleiste unzuverlässig ankommt.
Der Menüeintrag tat schlicht nichts. Jetzt ein eigenes NSWindow.
Kalenderberechtigung, zwei Ursachen übereinander:
Xcode lagert im Debug-Build den Programmcode in eine eigene dylib aus (für
SwiftUI-Vorschauen). Das Hauptprogramm ist dann nur ein Rumpf, und TCC ordnete
die Anfrage dem Rumpf zu — kTCCServiceCalendar erreichte tccd überhaupt nicht.
ENABLE_DEBUG_DYLIB = NO behebt das; danach erscheint die Anfrage im Protokoll.
Danach immer noch kein Dialog: promptType 1, aber promptPolicy 0. TCC bereitet
den Dialog vor und entscheidet sich dagegen, weil keine App im Vordergrund ist,
an die er gehören könnte. NSApp.activate davor hilft nicht — Aktivierung wirkt
asynchron und ist beim Aufruf noch nicht durch. Deshalb wird beim ersten Start
das Einstellungsfenster auf dem Reiter Berechtigungen geöffnet: dort steht ein
echtes Fenster im Vordergrund, und der Nutzer weiß, warum gefragt wird.
Folgefehler dabei: ohne Berechtigung zeigte das Widget "Nichts mehr für heute"
— eine Aussage, die es ohne Zugriff gar nicht treffen kann. refresh() fragt
jetzt nicht mehr selbst nach und meldet ohne Berechtigung ehrlich `denied`.
Fehler aus requestFullAccessToEvents werden nicht mehr mit try? verschluckt.
Die Unterscheidung zwischen "keine Termine" und "keine Information" steckt von
Anfang an in den Grundlagen, nicht als Nachtrag für Calendarr. Ein Kalender
ohne Termine und ein Kalender, über den nichts bekannt ist, sehen in einer
Liste identisch aus — nämlich leer. Ein leerer März ist überzeugend und
schlicht falsch, wenn die Quelle nur 49 Tage abdeckt.
EventWindow trägt deshalb eine coverage mit. Bei EventKit ist sie der
angefragte Zeitraum, weil direkt abgefragt wird; bei Calendarr wird sie später
aus dem Snapshot kommen. Tage außerhalb werden im Mini-Monat ausgegraut und
tragen einen erklärenden Hinweis, statt als frei durchzugehen.
Die Abdeckung gewinnt auch gegen widersprüchliche Eingaben: liefert eine Quelle
einen Termin für einen Tag, den sie nach eigener Angabe nicht abdeckt, bleibt
der Tag unbekannt. Sonst behauptet die Anzeige mehr zu wissen, als belegt ist.
Als Test festgehalten.
CalendarSourceState statt eines Optionals: denied, neverWritten, loggedOut,
incompatible und unreadable bekommen jeweils eine eigene Antwort in der
Oberfläche. "Nichts anzuzeigen" ist keine.
Serientermine bekommen eine aus Kennung und Beginn zusammengesetzte ID —
EKEvent.eventIdentifier ist für alle Vorkommen gleich, eine Terminliste mit
einer täglichen Serie fiele sonst auf einen Eintrag zusammen.
Monatsraster testgetrieben, inklusive Schaltjahr, Wochenstart Montag wie
Sonntag und der Regel, dass das Ende der Abdeckung nicht mehr dazugehört.
93 Tests grün.
Einstellungen wirken sofort statt erst nach einem Neustart. In einer App ohne
Fenster ist eine verzögerte Änderung praktisch unauffindbar — man sieht ja
nicht, dass etwas passiert ist. AppModel meldet Layout- und Anzeigeänderungen
an den Koordinator, das Panel zeichnet sich neu und die Panelgröße zieht mit.
Widgets lassen sich hinzufügen, entfernen, in der Größe ändern und per
Drag-and-Drop umsortieren. Angeboten werden nur die Größen, die ein Widget
sinnvoll ausfüllt — ein Mini-Monat in 1x1 wäre unleserlich.
Lokalisierung als String Catalog in DE und EN, Quellsprache Englisch damit die
Schlüssel im Code lesbar bleiben. Der Katalog von OnyxCore musste ausdrücklich
als Ressource deklariert werden: ohne das gibt es kein Bundle.module und die
Übersetzungen wären zur Laufzeit unauffindbar. Im gebauten Bundle liegen jetzt
de.lproj und en.lproj, auch für das Paket.
Kein Menüleisten-Tab: solange es keine Module gibt, wäre das eine Oberfläche
für ein Feature, das noch nicht existiert. Er kommt mit Phase 5.
80 Tests grün.
Zwei Onyx-Instanzen sind kein Schönheitsfehler. Jede bringt eine eigene
Zustandsmaschine, eigene Timer und eine eigene Animation auf dieselbe Notch —
das Ergebnis sieht aus wie ein Ruckler und ist keiner. Genau das ist beim
Testen passiert, mit zwei Builds aus verschiedenen Ordnern.
Die zuletzt gestartete Instanz gewinnt, nicht die erste. Beim Entwickeln ist
das die frisch gebaute; die andere Reihenfolge hieße, nach jedem Build weiter
die alte Fassung zu testen, ohne es zu merken. Im normalen Gebrauch ist der
Unterschied unsichtbar, weil Onyx kein Fenster hat, das man stattdessen in den
Vordergrund holen könnte.
Erkannt wird über die Bundle-Kennung, nicht über den Pfad: die Instanzen, die
sich real in die Quere kommen, stammen aus verschiedenen Build-Ordnern und
teilen sich nur die Kennung.
Erst terminate(), nach 1,5 s forceTerminate(). Überlebt eine trotzdem — der
reale Fall ist eine aus Xcode gestartete Instanz, die im Debugger hängt und
selbst SIGKILL übersteht — tritt die neue Instanz zurück und sagt per Dialog,
warum und was zu tun ist. Ein stiller Rücktritt sähe aus wie ein Startfehler.
Die Entscheidungsregel liegt als reine Funktion in OnyxCore und ist getestet,
inklusive des Falls, dass die eigene PID mehrfach in der Liste steht —
sich selbst zu beenden wäre der denkbar schlechteste Ausgang.
In der Praxis geprüft: `open -n` erzwingt einen Zweitstart, danach läuft genau
eine Instanz. 80 Tests grün.
Das Panel reichte oben bündig an die Bildschirmkante, damit seine Fläche mit
der schwarzen Notch-Hardware zu einem Körper verschmilzt. Die Notch ist aber
undurchsichtig — die obersten 38 pt Inhalt waren schlicht nicht zu sehen. Links
und rechts daneben liegt in diesem Streifen zusätzlich die Menüleiste, der ganze
Streifen ist also unbrauchbar.
Die Fläche bleibt deshalb, wo sie war; nur der Inhalt rückt nach unten. Das
Fenster wächst entsprechend mit (446x340 → 446x378), damit unten nichts
abgeschnitten wird.
Der Controller nimmt jetzt die Inhaltsgröße entgegen und schlägt den Freiraum
selbst auf. Keine Aufrufstelle muss daran denken, und beim Wechsel zwischen
eingebautem Display (38 pt) und externem (virtueller Balken) wandert der
Freiraum automatisch mit.
Gegengemessen statt geschätzt: die Zentrierung war korrekt (Panel-midX 900 =
Notch-midX 900), der Fehler lag allein in der Höhe. Vier Tests halten den
Inhaltsbereich jetzt außerhalb der Notch fest, auch für den virtuellen Balken.
70 Tests grün.
Layout-Engine mit vier Spalten, testgetrieben. Widgets werden nicht stur
hintereinander gesetzt, sondern jeweils an die erste Stelle, an die sie passen.
Der Unterschied zeigt sich neben einem 2x2-Widget: dort bleiben rechts zwei
1x1-Plätze frei, die ein reines Anhängen dauerhaft leer ließe.
Persistenz unterscheidet zwei Fälle, die gleich aussehen und es nicht sind. Eine
leere Layout-Datei ist eine Aussage — der Nutzer hat alle Widgets entfernt. Eine
Datei, aus der nach dem Filtern unbekannter Kennungen nichts übrig bleibt, ist
dagegen ein Zeichen, dass sich die Kennungen geändert haben; dort wäre ein leeres
Panel eine stille Fehlfunktion, also greift das Standardlayout.
Beschädigte und fehlende Dateien führen beide zum Standardlayout statt zu einem
Absturz oder einem leeren Panel.
Die Panelgröße folgt dem Layout statt umgekehrt. Positioniert wird über
LayoutEngine.frame, nicht über LazyVGrid — das kann keine Kacheln über zwei
Zeilen führen, und genau das braucht der Mini-Monat.
Platzhalter-Widgets für alle 13 geplanten Karten. Sie tragen die endgültigen
Kennungen, damit gespeicherte Layouts weitergelten, wenn die echten Widgets sie
Phase für Phase ersetzen.
66 Tests grün.
Die Fläche wird nicht eingeblendet — sie beginnt exakt in der Notch-Silhouette
und wächst von dort nach unten und zur Seite, deckungsgleich mit der schwarzen
Hardware darüber. Eingeblendetes Panel wirkt wie Glas; Onyx soll sich wie ein
fester Körper anfühlen, der herausfährt.
Der Inhalt hat eine eigene Kurve: beim Öffnen um 90 ms verzögert, damit die
Fläche vorweg läuft und kein Text sich mitdehnt; beim Schließen sofort weg,
damit beim Einfahren nichts gestaucht wird. Die Eckenrundung wandert mit
(10 → 26), sonst sieht die kleine Form aufgeblasen aus.
Drei Fallstricke, die im Code stehen:
Der Fensterschatten folgt dem Fensterrahmen, nicht der gezeichneten Form. Das
Fenster hat immer volle Panelgröße, damit die Animation Platz hat — der
Systemschatten läge also die ganze Zeit um eine unsichtbare Fläche. Deshalb
hasShadow = false und Schatten aus SwiftUI.
Ausgefahren wird erst im nächsten Runloop-Durchlauf. Im selben Durchlauf wie
orderFrontRegardless sieht SwiftUI keinen Zustandswechsel, sondern nur den
Endzustand, und das Panel wäre schlagartig da.
NotchHostingView nimmt Klicks nur dort an, wo etwas zu sehen ist. Sonst
verschluckt das volle Fenster während des Aus- und Einfahrens Klicks in seiner
leeren Fläche.
Zustandsmaschine und Geometrie sind testgetrieben entstanden und tragen 40
Tests — sie sind frei von AppKit, damit jeder Übergang ohne Fenster und ohne
Warten prüfbar ist. Zeit kommt nur als Ereignis herein.
Zwei Entscheidungen, die im Code begründet sind:
Der Zeiger wird über einen globalen Ereignismonitor verfolgt, nicht über ein
unsichtbares Fenster auf der Notch. Ein solches Fenster müsste Mausereignisse
annehmen, um sie zu bemerken, und würde damit Menüleiste und Fensterknöpfe
darunter unbenutzbar machen.
"Nur internes Display" fällt auf ein externes zurück, wenn kein eingebautes da
ist. Am Dock mit geschlossenem Deckel hieße die Einstellung wörtlich genommen,
dass Onyx unerreichbar wird.
Design: NSVisualEffectView mit eigenem Tint statt Liquid Glass — Onyx ist Stein,
kein Glas. Alle Farben und Maße liegen als Tokens in OnyxDesign.
Menüleiste: jedes Modul bekommt ein eigenes NSStatusItem. Elemente, denen macOS
mangels Platz keine Breite gibt, werden erkannt und gemeldet, statt still zu
verschwinden.
App-Target über XcodeGen, damit die Projektdefinition im Diff lesbar bleibt.
Nicht sandboxed, mit App-Group-Entitlement — baut, startet, signiert mit
PP34X97WS3.