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>
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.
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.
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.
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.
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.
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.
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.
Die Fehlermeldung führte in die Irre: "cannot be registered to your development
team because it is not available" klingt nach einem Namenskonflikt mit einem
fremden Team. Tatsächlich versuchte Xcode, eine App-ID anzulegen, die es schon
gab — mit bereits angehakter WeatherKit-Capability. Nach dem erneuten Lauf mit
-allowProvisioningUpdates wurde das passende Profil gefunden und eingebettet.
Im signierten Build stehen jetzt alle sechs Entitlements: weatherkit,
application-groups, personal-information.calendars, personal-information.location,
automation.apple-events und device.audio-input.
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.
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.
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.