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>
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>
Drei Ursachen, davon zwei echte Fehler.
Die NSVisualEffectView steckte in einem Rahmen, dessen Größe animiert wurde.
Ein Weichzeichner muss dann in jedem Einzelbild neu in anderer Größe gerechnet
werden — am teuersten genau beim Schließen, wo die Fläche am schnellsten
schrumpft. Das Material behält jetzt feste Größe, animiert wird nur die Maske
davor. Tint, Bänderung und Kante sind reine SwiftUI-Formen und dürfen weiter in
der Größe animieren. Auch die Schattendeckkraft ist jetzt konstant statt
animiert; sie aufzublenden kostete pro Bild eine neue Weichzeichnung.
Onyx bekam Mausbewegungen über dem eigenen Panel praktisch nicht mit. Der
globale Monitor feuert nur für Ereignisse an fremde Programme, der lokale nur
wenn Onyx aktiv ist — was es als .accessory-App nie ist. Ausgerechnet beim
Verlassen des Panels kam das Ereignis also verspätet oder gar nicht. Solange
ein Panel offen ist, wird die Zeigerposition jetzt mit 60 Hz abgefragt; der
Timer läuft ausschließlich in dieser Zeit.
Öffnen und Schließen benutzten dieselbe Kurve. Schließen ist jetzt kürzer und
ohne Nachschwingen: beim Öffnen sieht man zu, beim Schließen ist man schon
woanders.
Nebenbefund beim Prüfen: es liefen zwei Onyx-Instanzen aus verschiedenen Builds
gleichzeitig, jede mit eigener Zustandsmaschine auf derselben Notch. Das dürfte
einen guten Teil der Hakeligkeit erklärt haben.
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.