Compare commits

...

10 Commits

Author SHA1 Message Date
Guido Schmit
d0692d0f86 README: Aufbau, Bauanleitung und die Entscheidungen, die man sonst für Fehler hält 2026-08-10 21:14:25 +02:00
Guido Schmit
95bc3b1931 Menüleistensymbol 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 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.
2026-08-10 21:13:18 +02:00
Guido Schmit
433cdcc3dd Phase 4: Medien-Widget mit dreistufiger Kaskade
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.
2026-08-10 21:02:13 +02:00
Guido Schmit
03dd7b945e Menüleiste zeigt das Glyph, nicht das ganze App-Icon
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.
2026-08-10 20:55:40 +02:00
Guido Schmit
4b559ba840 App-Icon eingehängt, Menüleistensymbol nutzt dasselbe
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.
2026-08-10 20:47:41 +02:00
Guido Schmit
3d21cac3b7 Wetter: fehlende Ortungserlaubnis wird als solche benannt
"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.
2026-08-10 20:45:49 +02:00
Guido Schmit
a557c81baf WeatherKit aktiviert: App-ID im Portal war bereits vorhanden
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.
2026-08-10 20:42:22 +02:00
Guido Schmit
fc92d226b4 Phase 3d: Wetter-Widget (WeatherKit-Entitlement noch offen)
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.
2026-08-10 20:33:32 +02:00
Guido Schmit
d345a4fcab Hardened Runtime verlangt eigene TCC-Entitlements
Die Ursache stand wörtlich im Protokoll von tccd:

  Prompting policy for hardened runtime; service: kTCCServiceCalendar requires
  entitlement com.apple.security.personal-information.calendars but it is missing

Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements auch ohne
App-Sandbox. Die verbreitete Annahme, sie gälten nur für sandboxed Apps, ist
falsch — und ich bin ihr aufgesessen. Ohne sie zeigt macOS den Dialog gar nicht
erst an: die Anfrage kommt nach Millisekunden mit false zurück, der Status
bleibt notDetermined, und die App taucht in den Systemeinstellungen nie auf.
Dort lässt sich auch nichts nachtragen, der Kalenderbereich hat kein
Pluszeichen — eine Sackgasse ohne jede Fehlermeldung.

Gesetzt sind jetzt calendars, location, apple-events und audio-input, also
alles, was Phase 3, 4 und 7 brauchen. Sie fehlen sonst genau dann, wenn das
Feature fertig ist und niemand mehr an TCC denkt.

Gefunden durch Ausschluss, nicht durch Nachdenken: eine minimale Test-App
(normales Fenster, im Vordergrund, gleiches Zertifikat, nichts von Onyx darin)
scheiterte identisch. Damit war belegt, dass es nicht an Onyx lag.

Der Weg dorthin führte über mehrere falsche Fährten, die in
docs/spikes/E-tcc-hardened-runtime.md stehen, damit sie niemand zweimal geht —
darunter eine eigene: `log show` wurde von zsh abgefangen, und weil stderr
umgeleitet war, sah die leere Ausgabe wie ein Befund aus.

In der Praxis geprüft: Dialog erscheint, Zugriff erteilt, Status 3.
2026-08-10 18:36:15 +02:00
Guido Schmit
873c763d50 Kalenderberechtigung: Anfrage aus dem Vordergrundfenster
TCC zeigt den Dialog nur, wenn eine App im Vordergrund ist, an die er gehören
kann. Bei einer App ohne Fenster verpufft die Anfrage kommentarlos: sie kommt
mit false zurück, der Status bleibt notDetermined, und in den
Systemeinstellungen taucht die App nie auf — dort lässt sich auch nichts von
Hand hinzufügen, Einträge entstehen ausschließlich durch eine echte Anfrage.

Deshalb fragt der Reiter Berechtigungen jetzt selbst, sobald er sichtbar ist,
mit 400 ms Vorlauf: Aktivierung wirkt asynchron, ohne die Pause fragt Onyx
während es formal noch im Hintergrund liegt.

CalendarAuthorization trennt undetermined von denied. Im ersten Fall lohnt eine
Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft nur der
Weg über die Systemeinstellungen — dorthin führt der Knopf dann auch.

Korrektur einer eigenen Fehldiagnose: promptPolicy = 0 im tccd-Protokoll gehört
zu kTCCServiceReminders, nicht zum Kalender. Der bekommt durchgehend
promptPolicy = 4, Nachfragen war also die ganze Zeit erlaubt. Die tatsächlichen
Ursachen waren die ausgelagerte Debug-dylib und ein verklemmter TCC-Eintrag.
2026-08-10 18:26:10 +02:00
72 changed files with 6619 additions and 306 deletions

View File

@@ -8,9 +8,11 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0B27E91E0F9BF29005B0ED64 /* SettingsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E43384BA603C866D148D9D8 /* SettingsWindowController.swift */; }; 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 */; }; 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */ = {isa = PBXBuildFile; productRef = E66577A40689DF225BCED184 /* OnyxWidgetKit */; };
39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; }; 39E29ADC71D4860543AD25EB /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45BD2DF1B775C733C8F75635 /* SettingsView.swift */; };
4E729C7A6498B4679C8D2C42 /* OnyxCore in Frameworks */ = {isa = PBXBuildFile; productRef = C64A111F449D0F9D36ED46FB /* OnyxCore */; }; 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 */; }; 5EA873EC28C7EE21AC85A263 /* PlaceholderWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB5D52C0D22AE1F5E0601AF9 /* PlaceholderWidgets.swift */; };
68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; 68EE5495435A82E20248EBC2 /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; };
9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; }; 9C2E419014B7E04630FDF151 /* OnyxApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */; };
@@ -19,9 +21,11 @@
C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; }; C041F171FB69F4381309D978 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B83C4E3CB821F903F3977E88 /* AppModel.swift */; };
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; }; CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */ = {isa = PBXBuildFile; productRef = A3E3949D664131D593CBEEDC /* CalendarProvider */; };
EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; }; EA4D6312E8A96E177F10797F /* OnyxNotch in Frameworks */ = {isa = PBXBuildFile; productRef = 3A32CAE6CC4D01D660F0016F /* OnyxNotch */; };
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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; }; 0804288DC4A8146DD9F3FC3E /* Onyx.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Onyx.app; sourceTree = BUILT_PRODUCTS_DIR; };
45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 45BD2DF1B775C733C8F75635 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; }; 508DECB5C05264E6130C373E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = "<group>"; };
@@ -45,6 +49,8 @@
378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */, 378A5A417112CCB31EF3F774 /* OnyxWidgetKit in Frameworks */,
B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */, B6CD48B41866A468AE7A1BC7 /* OnyxMenuBar in Frameworks */,
CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */, CD501EBAE39807F5D354C5B2 /* CalendarProvider in Frameworks */,
5BE38CAFCDA4B9CBE787CABA /* WeatherProvider in Frameworks */,
1E2D654EFDDF55AEA75DBB7C /* MediaProvider in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -87,6 +93,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0FBA13B97C53F1D0D740A633 /* Resources */, 0FBA13B97C53F1D0D740A633 /* Resources */,
055263008CB3306A8D09E6D4 /* AppIcon.icon */,
B83C4E3CB821F903F3977E88 /* AppModel.swift */, B83C4E3CB821F903F3977E88 /* AppModel.swift */,
50A018673EE6BD9C50F1EFA0 /* Info.plist */, 50A018673EE6BD9C50F1EFA0 /* Info.plist */,
508DECB5C05264E6130C373E /* Localizable.xcstrings */, 508DECB5C05264E6130C373E /* Localizable.xcstrings */,
@@ -109,6 +116,8 @@
6B11A5AC704B6429B9484789 /* Sources */, 6B11A5AC704B6429B9484789 /* Sources */,
B211CE3C0EF94A8D680E4AE0 /* Resources */, B211CE3C0EF94A8D680E4AE0 /* Resources */,
7FF994FE5530D53BEFBE13FE /* Frameworks */, 7FF994FE5530D53BEFBE13FE /* Frameworks */,
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */,
F10EA44D021D8D6C50BEDCAA /* Glyph für die Menüleiste ins Bundle kopieren */,
); );
buildRules = ( buildRules = (
); );
@@ -122,6 +131,8 @@
E66577A40689DF225BCED184 /* OnyxWidgetKit */, E66577A40689DF225BCED184 /* OnyxWidgetKit */,
A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */, A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */,
A3E3949D664131D593CBEEDC /* CalendarProvider */, A3E3949D664131D593CBEEDC /* CalendarProvider */,
D4578EB64F7A3372B49BC50C /* WeatherProvider */,
349226D70F51196CCD1C94F8 /* MediaProvider */,
); );
productName = Onyx; productName = Onyx;
productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */; productReference = 0804288DC4A8146DD9F3FC3E /* Onyx.app */;
@@ -170,12 +181,56 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */,
A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */, A529046C33E6B00C0E9508FF /* Localizable.xcstrings in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
9A7A5929B893076DEC10139E /* MediaRemote-Adapter bauen und beilegen */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/Scripts/build-mediaremote-adapter.sh",
);
name = "MediaRemote-Adapter bauen und beilegen";
outputFileListPaths = (
);
outputPaths = (
"$(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "OUT=\"$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 */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"$(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg",
);
name = "Glyph für die Menüleiste ins Bundle kopieren";
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";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
6B11A5AC704B6429B9484789 /* Sources */ = { 6B11A5AC704B6429B9484789 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
@@ -394,6 +449,10 @@
/* End XCLocalSwiftPackageReference section */ /* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
349226D70F51196CCD1C94F8 /* MediaProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = MediaProvider;
};
3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = { 3A32CAE6CC4D01D660F0016F /* OnyxNotch */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = OnyxNotch; productName = OnyxNotch;
@@ -414,6 +473,10 @@
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = OnyxCore; productName = OnyxCore;
}; };
D4578EB64F7A3372B49BC50C /* WeatherProvider */ = {
isa = XCSwiftPackageProductDependency;
productName = WeatherProvider;
};
E66577A40689DF225BCED184 /* OnyxWidgetKit */ = { E66577A40689DF225BCED184 /* OnyxWidgetKit */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
productName = OnyxWidgetKit; productName = OnyxWidgetKit;

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 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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,36 @@
{
"fill" : {
"automatic-gradient" : "display-p3:0.21900,0.30045,0.61471,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "quartz.svg",
"name" : "quartz",
"position" : {
"scale" : 1.39,
"translation-in-points" : [
0,
0
]
}
}
],
"shadow" : {
"kind" : "neutral",
"opacity" : 0.5
},
"translucency" : {
"enabled" : true,
"value" : 0.5
}
}
],
"supported-platforms" : {
"circles" : [
"watchOS"
],
"squares" : "shared"
}
}

View File

@@ -1,453 +1,491 @@
{ {
"sourceLanguage": "en", "sourceLanguage" : "en",
"strings": { "strings" : {
"menu.openPanel": { "menu.openPanel" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Panel öffnen" "value" : "Panel öffnen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Open Panel" "value" : "Open Panel"
} }
} }
} }
}, },
"menu.quit": { "menu.quit" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Onyx beenden" "value" : "Onyx beenden"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Quit Onyx" "value" : "Quit Onyx"
} }
} }
} }
}, },
"menu.settings": { "menu.settings" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Einstellungen …" "value" : "Einstellungen …"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Settings…" "value" : "Settings…"
} }
} }
} }
}, },
"settings.display.all": { "settings.display.all" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Allen Displays" "value" : "Allen Displays"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "All displays" "value" : "All displays"
} }
} }
} }
}, },
"settings.display.builtInOnly": { "settings.display.builtInOnly" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Nur dem internen Display" "value" : "Nur dem internen Display"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Built-in display only" "value" : "Built-in display only"
} }
} }
} }
}, },
"settings.display.hint": { "settings.display.hint" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar." "value" : "Auf Displays ohne echte Notch erscheint oben mittig ein Balken, der sich genauso verhält. Ist der Deckel geschlossen und kein internes Display vorhanden, erscheint das Panel trotzdem auf einem externen — sonst wäre Onyx nicht erreichbar."
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable." "value" : "On displays without a real notch, a bar appears at the top centre and behaves the same. With the lid closed and no built-in display present, the panel still appears on an external one — otherwise Onyx would be unreachable."
} }
} }
} }
}, },
"settings.display.menuBarIcon": { "settings.display.menuBarIcon" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Symbol in der Menüleiste anzeigen" "value" : "Symbol in der Menüleiste anzeigen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Show icon in the menu bar" "value" : "Show icon in the menu bar"
} }
} }
} }
}, },
"settings.display.menuBarIcon.hint": { "settings.display.menuBarIcon.hint" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran." "value" : "Über das Symbol erreichst du Panel, Einstellungen und Beenden. Ohne es kommst du nur über die Notch an Onyx heran."
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx." "value" : "The icon gives you the panel, settings and quit. Without it, the notch is the only way to reach Onyx."
} }
} }
} }
}, },
"settings.display.policy": { "settings.display.policy" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Panel anzeigen auf" "value" : "Panel anzeigen auf"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Show panel on" "value" : "Show panel on"
} }
} }
} }
}, },
"settings.size.large": { "settings.permissions.calendar" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Groß (2×2)" "value" : "Kalender"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Large (2×2)" "value" : "Calendar"
} }
} }
} }
}, },
"settings.size.medium": { "settings.permissions.calendar.why" : {
"extractionState": "stale", "localizations" : {
"localizations": { "de" : {
"de": { "stringUnit" : {
"stringUnit": { "state" : "translated",
"state": "translated", "value" : "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben."
"value": "Mittel (2×1)"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Medium (2×1)" "value" : "Onyx shows your events in the calendar widget. It only ever reads, never writes."
} }
} }
} }
}, },
"settings.size.small": { "settings.permissions.granted" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Klein (1×1)" "value" : "Erteilt"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Small (1×1)" "value" : "Granted"
} }
} }
} }
}, },
"settings.size.wide": { "settings.permissions.location" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Breit (4×1)" "value" : "Standort"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Wide (4×1)" "value" : "Location"
} }
} }
} }
}, },
"settings.tab.display": { "settings.permissions.location.why" : {
"extractionState": "stale", "localizations" : {
"localizations": { "de" : {
"de": { "stringUnit" : {
"stringUnit": { "state" : "translated",
"state": "translated", "value" : "Onyx zeigt das Wetter an deinem aktuellen Standort. Ohne Zugriff kannst du stattdessen einen festen Ort wählen."
"value": "Anzeige"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Display" "value" : "Onyx shows the weather for your current location. Without access you can pick a fixed place instead."
} }
} }
} }
}, },
"settings.tab.widgets": { "settings.permissions.openSystem" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Widgets" "value" : "Systemeinstellungen öffnen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Widgets" "value" : "Open System Settings"
} }
} }
} }
}, },
"settings.widgets.add": { "settings.permissions.request" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Hinzufügen" "value" : "Anfragen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Add" "value" : "Request"
} }
} }
} }
}, },
"settings.widgets.available": { "settings.size.large" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Verfügbar" "value" : "Groß (2×2)"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Available" "value" : "Large (2×2)"
} }
} }
} }
}, },
"settings.widgets.empty": { "settings.size.medium" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst." "state" : "translated",
"value" : "Mittel (2×1)"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "No widgets in the panel. It stays empty until you add one." "value" : "Medium (2×1)"
} }
} }
} }
}, },
"settings.widgets.hint": { "settings.size.small" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt." "state" : "translated",
"value" : "Klein (1×1)"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "The order determines the arrangement in the panel. Each widget takes the first spot it fits into." "value" : "Small (1×1)"
} }
} }
} }
}, },
"settings.widgets.inPanel": { "settings.size.wide" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Im Panel" "value" : "Breit (4×1)"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "In the panel" "value" : "Wide (4×1)"
} }
} }
} }
}, },
"settings.widgets.remove": { "settings.tab.display" : {
"extractionState": "stale", "extractionState" : "stale",
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Aus dem Panel entfernen" "value" : "Anzeige"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Remove from panel" "value" : "Display"
} }
} }
} }
}, },
"settings.tab.permissions": { "settings.tab.permissions" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Berechtigungen" "state" : "translated",
"value" : "Berechtigungen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Permissions" "value" : "Permissions"
} }
} }
} }
}, },
"settings.permissions.calendar": { "settings.tab.widgets" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Kalender" "state" : "translated",
"value" : "Widgets"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Calendar" "value" : "Widgets"
} }
} }
} }
}, },
"settings.permissions.granted": { "settings.widgets.add" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Erteilt" "state" : "translated",
"value" : "Hinzufügen"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Granted" "value" : "Add"
} }
} }
} }
}, },
"settings.permissions.request": { "settings.widgets.available" : {
"localizations": { "extractionState" : "stale",
"de": { "localizations" : {
"stringUnit": { "de" : {
"state": "translated", "stringUnit" : {
"value": "Anfragen" "state" : "translated",
"value" : "Verfügbar"
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Request" "value" : "Available"
} }
} }
} }
}, },
"settings.permissions.calendar.why": { "settings.widgets.empty" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Onyx zeigt deine Termine im Kalender-Widget an. Es wird ausschließlich gelesen, nie geschrieben." "value" : "Keine Widgets im Panel. Das Panel bleibt leer, bis du eins hinzufügst."
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Onyx shows your events in the calendar widget. It only ever reads, never writes." "value" : "No widgets in the panel. It stays empty until you add one."
} }
} }
} }
}, },
"settings.permissions.openSystem": { "settings.widgets.hint" : {
"localizations": { "localizations" : {
"de": { "de" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Systemeinstellungen öffnen" "value" : "Die Reihenfolge bestimmt die Anordnung im Panel. Jedes Widget rückt an die erste Stelle, an die es passt."
} }
}, },
"en": { "en" : {
"stringUnit": { "stringUnit" : {
"state": "translated", "state" : "translated",
"value": "Open System Settings" "value" : "The order determines the arrangement in the panel. Each widget takes the first spot it fits into."
}
}
}
},
"settings.widgets.inPanel" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Im Panel"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "In the panel"
}
}
}
},
"settings.widgets.remove" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aus dem Panel entfernen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove from panel"
} }
} }
} }
} }
}, },
"version": "1.0" "version" : "1.0"
} }

View File

@@ -23,5 +23,47 @@
der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. --> der Host mit Team-Zertifikat. Ohne das wird es nicht geladen. -->
<key>com.apple.security.cs.disable-library-validation</key> <key>com.apple.security.cs.disable-library-validation</key>
<true/> <true/>
<!-- Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements —
auch ohne Sandbox. Das ist die verbreitete Fehlannahme: sie gelten
nicht nur für sandboxed Apps.
Fehlt eine davon, weigert sich macOS, den Berechtigungsdialog
überhaupt anzuzeigen. Die Anfrage kommt kommentarlos mit `false`
zurück, der Status bleibt `notDetermined`, und die App taucht in den
Systemeinstellungen nie auf — dort lässt sich auch nichts von Hand
nachtragen. Der Grund steht ausschließlich im Protokoll von tccd:
"Prompting policy for hardened runtime; service: kTCCServiceCalendar
requires entitlement com.apple.security.personal-information.calendars
but it is missing"
Nachgemessen mit einer minimalen Test-App: ohne die Entitlement kein
Dialog, mit ihr sofort. -->
<!-- Kalender-Widget (Phase 3) -->
<key>com.apple.security.personal-information.calendars</key>
<true/>
<!-- Wetter am aktuellen Standort (Phase 3) -->
<key>com.apple.security.personal-information.location</key>
<true/>
<!-- AppleScript-Rückfall für Spotify und Musik (Phase 4) -->
<key>com.apple.security.automation.apple-events</key>
<true/>
<!-- Per-App-Lautstärke über Core-Audio-Taps (Phase 7). Aufgezeichnet wird
nichts; die Berechtigung heißt nur so. -->
<key>com.apple.security.device.audio-input</key>
<true/>
<!-- Wetterdaten von Apple.
Anders als die Einträge darüber genügt hier die Entitlement allein
nicht: sie erzwingt ein echtes Provisioning-Profil, und die App-ID
com.scarriffleservices.onyx muss im Developer-Portal die
WeatherKit-Capability tragen. -->
<key>com.apple.developer.weatherkit</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -6,6 +6,8 @@ import OnyxNotch
import OnyxMenuBar import OnyxMenuBar
import OnyxWidgetKit import OnyxWidgetKit
import CalendarProvider import CalendarProvider
import WeatherProvider
import MediaProvider
@main @main
struct OnyxApp: App { struct OnyxApp: App {
@@ -30,6 +32,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var menuBar: MenuBarController? private var menuBar: MenuBarController?
private var onyxItem: NSStatusItem? private var onyxItem: NSStatusItem?
private var calendarModel: CalendarModel? private var calendarModel: CalendarModel?
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
private let settingsWindow = SettingsWindowController() private let settingsWindow = SettingsWindowController()
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
@@ -90,7 +94,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt // Immer einmal laden: mit Berechtigung kommen Termine, ohne sie zeigt
// das Widget den Berechtigungshinweis statt einer leeren Liste. // das Widget den Berechtigungshinweis statt einer leeren Liste.
calendarModel.refresh() calendarModel.refresh()
guard !calendarModel.hasAccess else { return } // Fenster öffnen, solange irgendeine Berechtigung noch aussteht.
let needsLocation = weatherModel?.locationIsUndetermined ?? false
guard !calendarModel.hasAccess || needsLocation else { return }
// Nicht im Hintergrund anfragen. // Nicht im Hintergrund anfragen.
// //
@@ -119,6 +125,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self.calendarModel = calendarModel self.calendarModel = calendarModel
WidgetRegistry.shared.register(CalendarWidget(model: calendarModel)) WidgetRegistry.shared.register(CalendarWidget(model: calendarModel))
let weatherModel = WeatherModel()
self.weatherModel = weatherModel
WidgetRegistry.shared.register(WeatherWidget(model: weatherModel))
let mediaModel = MediaModel()
self.mediaModel = mediaModel
WidgetRegistry.shared.register(MediaWidget(model: mediaModel))
PlaceholderWidgets.registerAll() PlaceholderWidgets.registerAll()
} }
@@ -135,9 +149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func installOnyxStatusItem() { private func installOnyxStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "onyx.menubar.main" item.autosaveName = "onyx.menubar.main"
item.button?.image = NSImage(systemSymbolName: "circle.hexagongrid.fill", item.button?.image = Self.menuBarIcon()
accessibilityDescription: "Onyx")
item.button?.image?.isTemplate = true
let menu = NSMenu() let menu = NSMenu()
menu.addItem(withTitle: String(localized: "menu.openPanel"), menu.addItem(withTitle: String(localized: "menu.openPanel"),
@@ -153,6 +165,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
onyxItem = item onyxItem = item
} }
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
///
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
/// Farbverlauf mit, und die gehört in Dock und Finder, nicht in die
/// 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.
///
/// **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 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.
///
/// 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"),
let glyph = NSImage(contentsOf: url) {
let image = NSImage(size: size, flipped: false) { rect in
glyph.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
return true
}
image.isTemplate = true
image.accessibilityDescription = "Onyx"
return image
}
// Ein Menüleisteneintrag ohne Bild wäre unsichtbar und die App darüber
// unerreichbar deshalb ein Rückfall statt eines leeren Knopfs.
let fallback = NSImage(systemSymbolName: "circle.hexagongrid.fill",
accessibilityDescription: "Onyx") ?? NSImage()
fallback.isTemplate = true
return fallback
}
private func observeMenuBarPreference() { private func observeMenuBarPreference() {
guard let model else { return } guard let model else { return }
withObservationTracking { withObservationTracking {
@@ -171,8 +230,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openSettings() { showSettings(tab: .widgets) } @objc private func openSettings() { showSettings(tab: .widgets) }
private func showSettings(tab: SettingsTab) { private func showSettings(tab: SettingsTab) {
guard let model, let calendarModel else { return } guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel, tab: tab) settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, tab: tab)
} }
@objc private func quit() { NSApp.terminate(nil) } @objc private func quit() { NSApp.terminate(nil) }

View File

@@ -44,10 +44,6 @@ enum PlaceholderWidgets {
/// damit gespeicherte Layouts weitergelten. /// damit gespeicherte Layouts weitergelten.
static func registerAll() { static func registerAll() {
let widgets: [PlaceholderWidget] = [ let widgets: [PlaceholderWidget] = [
.init(id: "weather", displayName: "Wetter", symbolName: "cloud.sun",
supportedSizes: [.small, .medium], phase: "Phase 3"),
.init(id: "media", displayName: "Medien", symbolName: "play.circle",
supportedSizes: [.medium, .wide], phase: "Phase 4"),
.init(id: "cpu", displayName: "CPU", symbolName: "cpu", .init(id: "cpu", displayName: "CPU", symbolName: "cpu",
supportedSizes: [.small, .medium], phase: "Phase 5"), supportedSizes: [.small, .medium], phase: "Phase 5"),
.init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill", .init(id: "gpu", displayName: "GPU", symbolName: "cpu.fill",

View File

@@ -3,6 +3,7 @@ import OnyxDesign
import OnyxNotch import OnyxNotch
import OnyxWidgetKit import OnyxWidgetKit
import CalendarProvider import CalendarProvider
import WeatherProvider
enum SettingsTab: Hashable { enum SettingsTab: Hashable {
case widgets, display, permissions case widgets, display, permissions
@@ -11,6 +12,7 @@ enum SettingsTab: Hashable {
struct SettingsView: View { struct SettingsView: View {
@Bindable var model: AppModel @Bindable var model: AppModel
let calendarModel: CalendarModel let calendarModel: CalendarModel
let weatherModel: WeatherModel
@Binding var selectedTab: SettingsTab @Binding var selectedTab: SettingsTab
var body: some View { var body: some View {
@@ -21,7 +23,7 @@ struct SettingsView: View {
DisplaySettings(model: model) DisplaySettings(model: model)
.tabItem { Label("settings.tab.display", systemImage: "macbook") } .tabItem { Label("settings.tab.display", systemImage: "macbook") }
.tag(SettingsTab.display) .tag(SettingsTab.display)
PermissionSettings(calendarModel: calendarModel) PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel)
.tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") } .tabItem { Label("settings.tab.permissions", systemImage: "hand.raised") }
.tag(SettingsTab.permissions) .tag(SettingsTab.permissions)
} }
@@ -39,7 +41,9 @@ struct SettingsView: View {
/// Systemeinstellungen. /// Systemeinstellungen.
private struct PermissionSettings: View { private struct PermissionSettings: View {
let calendarModel: CalendarModel let calendarModel: CalendarModel
let weatherModel: WeatherModel
@State private var granted = false @State private var granted = false
@State private var locationGranted = false
var body: some View { var body: some View {
Form { Form {
@@ -70,13 +74,58 @@ private struct PermissionSettings: View {
.fixedSize(horizontal: false, vertical: true) .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()
}
}
}
Text("settings.permissions.location.why")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Section { Section {
Button("settings.permissions.openSystem") { openPrivacySettings() } Button("settings.permissions.openSystem") { openPrivacySettings() }
} }
} }
.formStyle(.grouped) .formStyle(.grouped)
.padding() .padding()
.onAppear { granted = calendarModel.hasAccess } .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.
try? await Task.sleep(for: .milliseconds(400))
granted = await calendarModel.requestAccessNow()
}
}
@State private var didAutoRequest = false
private func openLocationSettings() {
guard let url = URL(string:
"x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") else { return }
NSWorkspace.shared.open(url)
} }
private func openPrivacySettings() { private func openPrivacySettings() {

View File

@@ -1,6 +1,7 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
import CalendarProvider import CalendarProvider
import WeatherProvider
/// Führt das Einstellungsfenster selbst. /// Führt das Einstellungsfenster selbst.
/// ///
@@ -19,13 +20,15 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
/// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann. /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann.
private var selectedTab: SettingsTab = .widgets private var selectedTab: SettingsTab = .widgets
func show(model: AppModel, calendarModel: CalendarModel, tab: SettingsTab = .widgets) { func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel,
tab: SettingsTab = .widgets) {
selectedTab = tab selectedTab = tab
if window == nil { if window == nil {
let hosting = NSHostingController( let hosting = NSHostingController(
rootView: SettingsView(model: model, rootView: SettingsView(model: model,
calendarModel: calendarModel, calendarModel: calendarModel,
weatherModel: weatherModel,
selectedTab: Binding( selectedTab: Binding(
get: { [weak self] in self?.selectedTab ?? .widgets }, get: { [weak self] in self?.selectedTab ?? .widgets },
set: { [weak self] in self?.selectedTab = $0 }))) set: { [weak self] in self?.selectedTab = $0 })))

View File

@@ -14,6 +14,8 @@ let package = Package(
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]), .library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]), .library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
.library(name: "CalendarProvider", targets: ["CalendarProvider"]), .library(name: "CalendarProvider", targets: ["CalendarProvider"]),
.library(name: "WeatherProvider", targets: ["WeatherProvider"]),
.library(name: "MediaProvider", targets: ["MediaProvider"]),
], ],
targets: [ targets: [
// Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt // Der String-Katalog muss ausdrücklich als Ressource stehen sonst gibt
@@ -36,5 +38,13 @@ let package = Package(
.target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"], .target(name: "CalendarProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]), resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]), .testTarget(name: "CalendarProviderTests", dependencies: ["CalendarProvider"]),
.target(name: "WeatherProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "WeatherProviderTests", dependencies: ["WeatherProvider"]),
.target(name: "MediaProvider", dependencies: ["OnyxDesign", "OnyxWidgetKit"],
resources: [.process("Localizable.xcstrings")]),
.testTarget(name: "MediaProviderTests", dependencies: ["MediaProvider"]),
] ]
) )

View File

@@ -80,12 +80,25 @@ public enum CalendarSourceState: Equatable, Sendable {
case unreadable(reason: String) case unreadable(reason: String)
} }
/// Der Berechtigungszustand einer Quelle.
///
/// `undetermined` und `denied` müssen getrennt bleiben: im ersten Fall lohnt
/// eine Anfrage, im zweiten zeigt macOS nie wieder einen Dialog und es hilft
/// nur der Weg über die Systemeinstellungen.
public enum CalendarAuthorization: Equatable, Sendable {
case granted
case denied
case undetermined
/// Die Quelle braucht gar keine Berechtigung (z. B. Calendarr).
case notRequired
}
/// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies. /// Eine Quelle für Termine. Apple-Kalender und Calendarr liefern beide dies.
public protocol CalendarSource: Sendable { public protocol CalendarSource: Sendable {
var id: String { get } var id: String { get }
/// Ob bereits gelesen werden darf **ohne** dabei nachzufragen. /// Der Zustand **ohne** dabei nachzufragen. Die Einstellungen brauchen
/// Die Einstellungen brauchen das, um den Zustand anzuzeigen, ohne /// das, um ihn anzuzeigen, ohne nebenbei einen Systemdialog auszulösen.
/// nebenbei einen Systemdialog auszulösen. var authorization: CalendarAuthorization { get }
var isAuthorized: Bool { get } var isAuthorized: Bool { get }
func requestAccess() async -> Bool func requestAccess() async -> Bool
func calendars() async -> [OnyxCalendar] func calendars() async -> [OnyxCalendar]

View File

@@ -25,6 +25,7 @@ public final class CalendarModel {
/// Ob gelesen werden darf. Fragt nicht nach nur zum Anzeigen. /// Ob gelesen werden darf. Fragt nicht nach nur zum Anzeigen.
public var hasAccess: Bool { source.isAuthorized } public var hasAccess: Bool { source.isAuthorized }
public var authorization: CalendarAuthorization { source.authorization }
/// Fragt die Berechtigung an und lädt bei Erfolg gleich. /// Fragt die Berechtigung an und lädt bei Erfolg gleich.
/// ///

View File

@@ -16,10 +16,18 @@ public final class EventKitSource: CalendarSource, @unchecked Sendable {
public init() {} public init() {}
public var isAuthorized: Bool { public var authorization: CalendarAuthorization {
EKEventStore.authorizationStatus(for: .event) == .fullAccess switch EKEventStore.authorizationStatus(for: .event) {
case .fullAccess: .granted
case .denied, .restricted: .denied
// `writeOnly` ist für uns so gut wie nichts Onyx zeigt nur an.
case .notDetermined, .writeOnly: .undetermined
@unknown default: .denied
}
} }
public var isAuthorized: Bool { authorization == .granted }
public func requestAccess() async -> Bool { public func requestAccess() async -> Bool {
switch EKEventStore.authorizationStatus(for: .event) { switch EKEventStore.authorizationStatus(for: .event) {
case .fullAccess: case .fullAccess:

View File

@@ -0,0 +1,80 @@
import Foundation
/// Übersetzt die JSON-Zeilen des mediaremote-adapter.
///
/// Der Adapter sendet im `stream`-Modus Zeilen der Form
/// `{"type":"data","diff":false,"payload":{}}`. Bei `diff = true` enthält
/// `payload` **nur die geänderten Felder** ein vollständiges Ersetzen würde
/// dann Titel und Cover löschen, sobald sich nur die Position ändert.
public enum AdapterPayload {
public struct Envelope: Decodable {
public let type: String
public let diff: Bool?
public let payload: [String: AnyDecodable]?
}
/// Wendet eine Zeile auf den bisherigen Stand an.
///
/// - Returns: der neue Stand, oder `nil` wenn die Zeile nichts aussagt.
public static func apply(line: String, to previous: NowPlaying?) -> NowPlaying? {
guard let data = line.data(using: .utf8),
let envelope = try? JSONDecoder().decode(Envelope.self, from: data),
envelope.type == "data" else { return nil }
guard let payload = envelope.payload, !payload.isEmpty else {
// Ein leerer Payload bei einer Vollmeldung heißt: es läuft nichts.
// Bei einer Teilmeldung heißt er gar nichts und wird verworfen.
return envelope.diff == true ? previous : nil
}
// Bei einer Teilmeldung auf dem alten Stand aufbauen, sonst neu beginnen.
var state = (envelope.diff == true ? previous : nil) ?? NowPlaying()
state.origin = .adapter
if let value = payload["title"]?.string { state.title = value }
if let value = payload["artist"]?.string { state.artist = value }
if let value = payload["album"]?.string { state.album = value }
if let value = payload["playing"]?.bool { state.isPlaying = value }
if let value = payload["duration"]?.double { state.duration = value > 0 ? value : nil }
if let value = payload["elapsedTime"]?.double { state.elapsed = value }
if let value = payload["playbackRate"]?.double { state.playbackRate = value }
if let value = payload["bundleIdentifier"]?.string { state.bundleIdentifier = value }
if let value = payload["artworkData"]?.string {
state.artwork = Data(base64Encoded: value, options: .ignoreUnknownCharacters)
}
// `timestamp` ist der Zeitpunkt, zu dem `elapsedTime` galt nicht der
// Empfangszeitpunkt. Ohne ihn springt der Scrubber bei jedem Ereignis.
if let raw = payload["timestamp"]?.string,
let parsed = ISO8601DateFormatter().date(from: raw) {
state.asOf = parsed
} else {
state.asOf = Date()
}
return state
}
}
/// Minimaler dynamischer Wert der Payload hat kein festes Schema, und ein
/// vollständiger `Decodable`-Typ müsste bei jedem neuen Feld nachgezogen werden.
public struct AnyDecodable: Decodable, Sendable {
private enum Storage: Sendable {
case string(String), double(Double), bool(Bool), other
}
private let storage: Storage
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
// Reihenfolge zählt: `Bool` vor `Double`, sonst wird `true` zu 1.
if let value = try? container.decode(Bool.self) { storage = .bool(value) }
else if let value = try? container.decode(Double.self) { storage = .double(value) }
else if let value = try? container.decode(String.self) { storage = .string(value) }
else { storage = .other }
}
public var string: String? { if case .string(let v) = storage { v } else { nil } }
public var double: Double? { if case .double(let v) = storage { v } else { nil } }
public var bool: Bool? { if case .bool(let v) = storage { v } else { nil } }
}

View File

@@ -0,0 +1,166 @@
import Foundation
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Hält den mediaremote-adapter am Leben und liest seinen Datenstrom.
///
/// Der Umweg über `/usr/bin/perl` ist kein Trick um seiner selbst willen:
/// seit macOS 15.4 prüft der `mediaremoted`-Daemon Entitlements, und
/// Fremdprogramme bekommen keine Now-Playing-Daten mehr. Perl ist ein
/// mitgeliefertes, berechtigtes Systemprogramm; es lädt das beigelegte
/// Framework und gibt die Daten zeilenweise aus.
@MainActor
public final class AdapterProcess {
/// Befehlskennungen aus `MediaRemote.h` des Adapters.
private enum Command: Int {
case play = 0, pause = 1, togglePlayPause = 2, next = 4, previous = 5
}
public private(set) var isRunning = false
/// Wird bei jeder Änderung gerufen. `nil` heißt: es läuft nichts.
public var onUpdate: ((NowPlaying?) -> Void)?
private var process: Process?
private var buffer = Data()
private var current: NowPlaying?
private var restartAttempts = 0
private var restartTimer: Timer?
private let perl = URL(fileURLWithPath: "/usr/bin/perl")
public init() {}
/// Ob die beigelegten Dateien vorhanden sind. Fehlen sie, greift die
/// nächste Stufe der Kaskade ohne dass irgendwo ein Prozess scheitert.
public static var isAvailable: Bool { resources != nil }
private static var resources: (script: URL, framework: URL)? {
guard let vendor = Bundle.main.resourceURL?.appendingPathComponent("Vendor") else { return nil }
let script = vendor.appendingPathComponent("mediaremote-adapter.pl")
let framework = vendor.appendingPathComponent("MediaRemoteAdapter.framework")
guard FileManager.default.fileExists(atPath: script.path),
FileManager.default.fileExists(atPath: framework.path) else { return nil }
return (script, framework)
}
// MARK: - Lebenszyklus
public func start() {
guard !isRunning, let resources = Self.resources else { return }
let process = Process()
process.executableURL = perl
process.arguments = [resources.script.path, resources.framework.path, "stream"]
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let chunk = handle.availableData
guard !chunk.isEmpty else { return }
Task { @MainActor in self?.consume(chunk) }
}
process.terminationHandler = { [weak self] _ in
Task { @MainActor in self?.handleTermination() }
}
do {
try process.run()
self.process = process
isRunning = true
log.notice("MediaRemote-Adapter gestartet")
} catch {
log.error("Adapter konnte nicht starten: \(error.localizedDescription, privacy: .public)")
scheduleRestart()
}
}
public func stop() {
restartTimer?.invalidate()
restartTimer = nil
(process?.standardOutput as? Pipe)?.fileHandleForReading.readabilityHandler = nil
process?.terminationHandler = nil
process?.terminate()
process = nil
isRunning = false
}
private func handleTermination() {
isRunning = false
process = nil
// Der Adapter beendet sich auch im Normalbetrieb gelegentlich. Ein
// Neustart mit wachsendem Abstand hält ihn am Leben, ohne bei einem
// echten Defekt in einer Schleife zu landen.
scheduleRestart()
}
private func scheduleRestart() {
guard restartAttempts < 5 else {
log.error("Adapter startet nicht mehr — Kaskade fällt auf AppleScript zurück")
onUpdate?(nil)
return
}
let delay = pow(2.0, Double(restartAttempts))
restartAttempts += 1
restartTimer?.invalidate()
restartTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { _ in
MainActor.assumeIsolated { [weak self] in self?.start() }
}
}
// MARK: - Datenstrom
private func consume(_ chunk: Data) {
buffer.append(chunk)
// Zeilenweise: ein Lesevorgang kann mehrere Zeilen oder eine halbe
// enthalten. Cover kommen base64-kodiert und sprengen einen Puffer
// schnell, deshalb wird der Rest aufgehoben statt verworfen.
while let newline = buffer.firstIndex(of: 0x0A) {
let lineData = buffer[buffer.startIndex..<newline]
buffer.removeSubrange(buffer.startIndex...newline)
guard let line = String(data: lineData, encoding: .utf8), !line.isEmpty else { continue }
restartAttempts = 0
if let updated = AdapterPayload.apply(line: line, to: current) {
current = updated
onUpdate?(updated)
} else if line.contains("\"payload\":{}") {
current = nil
onUpdate?(nil)
}
}
}
// MARK: - Steuerung
public func send(_ command: MediaCommand) {
switch command {
case .playPause: run(["send", String(Command.togglePlayPause.rawValue)])
case .next: run(["send", String(Command.next.rawValue)])
case .previous: run(["send", String(Command.previous.rawValue)])
case .seek(let position):
// Der Adapter erwartet Mikrosekunden.
run(["seek", String(Int(position * 1_000_000))])
}
}
/// Befehle laufen als eigener, kurzlebiger Prozess.
///
/// Den Datenstrom dafür zu unterbrechen wäre der falsche Weg: er würde bei
/// jedem Tastendruck neu aufgebaut, und in der Lücke fehlen Ereignisse.
private func run(_ arguments: [String]) {
guard let resources = Self.resources else { return }
let process = Process()
process.executableURL = perl
process.arguments = [resources.script.path, resources.framework.path] + arguments
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
try? process.run()
}
}

View File

@@ -0,0 +1,25 @@
{
"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.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"
}

View File

@@ -0,0 +1,138 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Zweite Stufe: AppleScript für Spotify und Musik.
///
/// Deckt weit weniger ab als der Adapter Browser und alle anderen Programme
/// fallen heraus. Dafür ist es offiziell unterstützt und überlebt
/// macOS-Aktualisierungen, die den Adapter brechen könnten.
public enum AppleScriptSource {
private struct Player {
let bundleID: String
let name: String
}
private static let players = [
Player(bundleID: "com.spotify.client", name: "Spotify"),
Player(bundleID: "com.apple.Music", name: "Music"),
]
/// Fragt die laufenden Programme ab. Nicht laufende werden übersprungen
/// ein AppleScript an ein beendetes Programm würde es sonst **starten**.
public static func poll() -> NowPlaying? {
for player in players {
guard NSRunningApplication
.runningApplications(withBundleIdentifier: player.bundleID)
.isEmpty == false else { continue }
if let state = query(player) { return state }
}
return nil
}
private static func query(_ player: Player) -> NowPlaying? {
let script = """
tell application id "\(player.bundleID)"
if player state is playing or player state is paused then
set t to name of current track
set a to artist of current track
set al to album of current track
set dur to duration of current track
set pos to player position
set st to (player state as text)
return t & "\\n" & a & "\\n" & al & "\\n" & dur & "\\n" & pos & "\\n" & st
end if
end tell
"""
var error: NSDictionary?
guard let result = NSAppleScript(source: script)?
.executeAndReturnError(&error).stringValue else {
if let error { log.debug("AppleScript \(player.name): \(String(describing: error))") }
return nil
}
let parts = result.components(separatedBy: "\n")
guard parts.count >= 6 else { return nil }
// Spotify gibt die Länge in Millisekunden, Musik in Sekunden.
let rawDuration = Double(parts[3]) ?? 0
let duration = player.bundleID == "com.spotify.client" ? rawDuration / 1000 : rawDuration
return NowPlaying(title: parts[0], artist: parts[1], album: parts[2],
isPlaying: parts[5].hasPrefix("playing"),
duration: duration > 0 ? duration : nil,
elapsed: Double(parts[4]) ?? 0,
playbackRate: 1, asOf: Date(), artwork: nil,
bundleIdentifier: player.bundleID, origin: .appleScript)
}
public static func send(_ command: MediaCommand) -> Bool {
for player in players {
guard !NSRunningApplication
.runningApplications(withBundleIdentifier: player.bundleID).isEmpty else { continue }
let action = switch command {
case .playPause: "playpause"
case .next: "next track"
case .previous: "previous track"
case .seek(let position): "set player position to \(position)"
}
var error: NSDictionary?
NSAppleScript(source: "tell application id \"\(player.bundleID)\" to \(action)")?
.executeAndReturnError(&error)
if error == nil { return true }
}
return false
}
}
/// Letzte Stufe: die Medientasten der Tastatur.
///
/// Sie kennt keine Titel und keine Position aber sie steuert **jedes**
/// Programm, auch solche, die weder der Adapter noch AppleScript erreichen.
/// Damit bleibt das Widget selbst dann bedienbar, wenn Apple die
/// Now-Playing-Schnittstelle weiter zumacht.
public enum MediaKeys {
private enum Key: Int32 {
case playPause = 16 // NX_KEYTYPE_PLAY
case next = 17 // NX_KEYTYPE_NEXT
case previous = 18 // NX_KEYTYPE_PREVIOUS
}
public static func send(_ command: MediaCommand) {
let key: Key? = switch command {
case .playPause: .playPause
case .next: .next
case .previous: .previous
case .seek: nil // Über Tasten nicht möglich.
}
guard let key else { return }
post(key, down: true)
post(key, down: false)
}
private static func post(_ key: Key, down: Bool) {
// Medientasten sind keine gewöhnlichen Tastendrücke, sondern
// NSSystemDefined-Ereignisse mit Subtyp 8. Ein normaler CGEvent käme
// nirgends an.
let flags = NSEvent.ModifierFlags(rawValue: UInt(down ? 0xA00 : 0xB00))
let data1 = Int((key.rawValue << 16) | ((down ? 0xA : 0xB) << 8))
guard let event = NSEvent.otherEvent(with: .systemDefined,
location: .zero,
modifierFlags: flags,
timestamp: 0,
windowNumber: 0,
context: nil,
subtype: 8,
data1: data1,
data2: -1)?.cgEvent else { return }
event.post(tap: .cghidEventTap)
}
}

View File

@@ -0,0 +1,129 @@
import Foundation
import AppKit
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Media")
/// Führt die drei Stufen zusammen.
///
/// **Adapter** liefert alles inklusive Browser. **AppleScript** springt für
/// Spotify und Musik ein, wenn der Adapter schweigt. **Medientasten** steuern
/// immer, auch ohne jede Angabe.
///
/// Die Reihenfolge ist keine Vorliebe, sondern Absicherung: Apple hat die
/// Now-Playing-Schnittstelle in macOS 15.4 bereits einmal zugemacht. Fällt der
/// Adapter aus, verliert das Widget seine Anzeige aber nicht seine Funktion.
@MainActor
@Observable
public final class MediaModel {
public private(set) var nowPlaying: NowPlaying?
/// Für die laufende Fortschrittsanzeige. Wird sekündlich gesetzt, aber nur
/// solange etwas abspielt und das Panel offen ist.
public private(set) var tick = Date()
private let adapter = AdapterProcess()
private var fallbackTimer: Timer?
private var tickTimer: Timer?
private var consumers = 0
public init() {
adapter.onUpdate = { [weak self] state in
guard let self else { return }
// Der Adapter hat Vorrang. Nur wenn er nichts weiß, wird gefragt.
if let state {
nowPlaying = state
} else {
nowPlaying = AppleScriptSource.poll()
}
updateTicking()
}
}
/// An- und Abmelden statt Start und Stopp: das Widget im Panel und ein
/// künftiges Menüleisten-Modul teilen sich dieselbe Quelle, und der
/// Adapter soll genau einmal laufen.
public func addConsumer() {
consumers += 1
guard consumers == 1 else { return }
if AdapterProcess.isAvailable {
adapter.start()
} else {
log.notice("Adapter nicht im Bundle — nur AppleScript und Medientasten")
}
// AppleScript wird abgefragt, nicht gemeldet. Zwei Sekunden reichen für
// eine Anzeige, die ohnehin nur beim geöffneten Panel sichtbar ist.
fallbackTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.pollFallbackIfNeeded() }
}
pollFallbackIfNeeded()
}
public func removeConsumer() {
consumers = max(consumers - 1, 0)
guard consumers == 0 else { return }
adapter.stop()
fallbackTimer?.invalidate()
fallbackTimer = nil
tickTimer?.invalidate()
tickTimer = nil
}
private func pollFallbackIfNeeded() {
// Nur einspringen, wenn der Adapter nichts liefert sonst würde die
// schwächere Quelle die bessere überschreiben.
guard nowPlaying == nil || nowPlaying?.origin == .appleScript else { return }
nowPlaying = AppleScriptSource.poll()
updateTicking()
}
/// Die Uhr läuft nur, wenn wirklich etwas abspielt. Ein Timer, der bei
/// pausierter Wiedergabe weiterläuft, kostet ohne jeden Nutzen.
private func updateTicking() {
let shouldTick = nowPlaying?.isPlaying == true
if shouldTick, tickTimer == nil {
tickTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.tick = Date() }
}
} else if !shouldTick {
tickTimer?.invalidate()
tickTimer = nil
}
}
// MARK: - Steuerung
public func send(_ command: MediaCommand) {
switch nowPlaying?.origin {
case .adapter:
adapter.send(command)
case .appleScript:
if !AppleScriptSource.send(command) { MediaKeys.send(command) }
case .mediaKeys, .none:
// Ohne bekannte Quelle bleibt die Taste sie erreicht auch
// Programme, die keine der beiden anderen Stufen kennt.
MediaKeys.send(command)
}
// Sofort umschalten statt auf das nächste Ereignis zu warten. Ein
// Play-Knopf, der eine halbe Sekunde später reagiert, fühlt sich kaputt
// an; kommt das echte Ereignis, korrigiert es den Stand ohnehin.
if case .playPause = command, var state = nowPlaying {
state.elapsed = state.elapsed(at: Date())
state.asOf = Date()
state.isPlaying.toggle()
nowPlaying = state
updateTicking()
}
}
/// Öffnet das Programm, das gerade abspielt.
public func activateSource() {
guard let bundleID = nowPlaying?.bundleIdentifier,
let app = NSRunningApplication
.runningApplications(withBundleIdentifier: bundleID).first else { return }
app.activate()
}
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
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))
}
}
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)
} else {
// Auch ohne Angaben bleibt das Widget bedienbar: die
// Medientasten erreichen jedes Programm. Ein Widget, das bei
// fehlenden Metadaten gar nichts mehr kann, wäre die schlechtere
// Antwort auf ein Problem, das nur die Anzeige betrifft.
Idle(model: model)
}
}
.onAppear { model.addConsumer() }
.onDisappear { model.removeConsumer() }
}
}
private struct Idle: View {
let model: MediaModel
var body: some View {
VStack(spacing: 8) {
Text("widget.media.nothing", bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textTertiary)
TransportControls(model: model, size: 13)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct Playing: View {
let model: MediaModel
let state: NowPlaying
let size: WidgetSize
var body: some View {
HStack(spacing: 10) {
Artwork(data: state.artwork)
.frame(width: artworkSize, height: artworkSize)
VStack(alignment: .leading, spacing: 2) {
Text(state.title)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Text(state.artist)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
Spacer(minLength: 2)
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))
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.activateSource() }
}
private var artworkSize: CGFloat { size == .wide ? 64 : 56 }
}
private struct Artwork: View {
let data: Data?
var body: some View {
let shape = RoundedRectangle(cornerRadius: 8, style: .continuous)
Group {
if let data, let image = NSImage(data: data) {
Image(nsImage: image).resizable().aspectRatio(contentMode: .fill)
} else {
// Kein Cover ist der Normalfall bei Browser-Wiedergaben und
// beim AppleScript-Rückfall deshalb ein ruhiger Platzhalter
// statt eines Fehlerzeichens.
shape.fill(Onyx.Color.elevated)
.overlay {
Image(systemName: "music.note")
.font(.system(size: 18))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
.clipShape(shape)
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: 1))
}
}
private struct Scrubber: View {
let model: MediaModel
let state: NowPlaying
var body: some View {
// `model.tick` wird sekündlich gesetzt; das Lesen hier sorgt dafür,
// dass die Ansicht zwischen den Adapter-Ereignissen mitläuft.
let now = model.tick
let progress = state.progress(at: now)
VStack(spacing: 2) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
Capsule().fill(Onyx.Color.hairline)
Capsule()
.fill(Onyx.Color.accent)
.frame(width: geometry.size.width * (progress ?? 0))
}
.contentShape(Rectangle())
.onTapGesture { location in
guard let duration = state.duration, duration > 0 else { return }
let ratio = min(max(location.x / geometry.size.width, 0), 1)
model.send(.seek(to: duration * ratio))
}
}
.frame(height: 3)
if state.duration != nil {
HStack {
Text(format(state.elapsed(at: now)))
Spacer(minLength: 0)
Text(format(state.duration ?? 0))
}
.font(.system(size: 8))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textTertiary)
}
}
.animation(.linear(duration: 0.9), value: progress)
}
private func format(_ seconds: TimeInterval) -> String {
let total = Int(seconds.rounded())
return String(format: "%d:%02d", total / 60, total % 60)
}
}
private struct TransportControls: View {
let model: MediaModel
let size: CGFloat
var body: some View {
HStack(spacing: 12) {
button("backward.fill") { model.send(.previous) }
button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") {
model.send(.playPause)
}
button("forward.fill") { model.send(.next) }
}
}
private func button(_ symbol: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: size))
.foregroundStyle(Onyx.Color.textPrimary)
}
.buttonStyle(.plain)
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
/// Woher die Angaben stammen. Bestimmt, was steuerbar ist.
public enum MediaOrigin: String, Equatable, Sendable {
/// mediaremote-adapter kennt alle Quellen inkl. Browser.
case adapter
/// AppleScript für Spotify oder Musik.
case appleScript
/// Nur Medientasten: Steuerung ohne Angaben.
case mediaKeys
}
/// Was gerade läuft.
public struct NowPlaying: Equatable, Sendable {
public var title: String
public var artist: String
public var album: String
public var isPlaying: Bool
/// Gesamtlänge in Sekunden. `nil` bei Livestreams.
public var duration: TimeInterval?
/// Position **zum Zeitpunkt `asOf`** nicht jetzt. Siehe `elapsed(at:)`.
public var elapsed: TimeInterval
public var playbackRate: Double
/// Wann die Angaben gültig waren.
public var asOf: Date
public var artwork: Data?
public var bundleIdentifier: String?
public var origin: MediaOrigin
public init(title: String = "", artist: String = "", album: String = "",
isPlaying: Bool = false, duration: TimeInterval? = nil,
elapsed: TimeInterval = 0, playbackRate: Double = 1,
asOf: Date = Date(), artwork: Data? = nil,
bundleIdentifier: String? = nil, origin: MediaOrigin = .adapter) {
self.title = title
self.artist = artist
self.album = album
self.isPlaying = isPlaying
self.duration = duration
self.elapsed = elapsed
self.playbackRate = playbackRate
self.asOf = asOf
self.artwork = artwork
self.bundleIdentifier = bundleIdentifier
self.origin = origin
}
/// Die Position zum angegebenen Zeitpunkt.
///
/// Der Adapter liefert `elapsedTime` **nicht** laufend nach bei
/// unveränderter Wiedergabe kommt minutenlang kein Ereignis. Ein Scrubber,
/// der stur `elapsed` anzeigt, steht deshalb still, obwohl der Titel läuft.
/// Er muss zwischen den Ereignissen selbst hochzählen, ausgehend von
/// `asOf` und `playbackRate`.
public func elapsed(at date: Date) -> TimeInterval {
guard isPlaying else { return clamp(elapsed) }
let advanced = elapsed + date.timeIntervalSince(asOf) * playbackRate
return clamp(advanced)
}
/// Fortschritt von 0 bis 1, oder `nil` ohne bekannte Länge.
public func progress(at date: Date) -> Double? {
guard let duration, duration > 0 else { return nil }
return min(max(elapsed(at: date) / duration, 0), 1)
}
public var hasMetadata: Bool { !title.isEmpty || !artist.isEmpty }
private func clamp(_ value: TimeInterval) -> TimeInterval {
// Nie über die Länge hinaus: sonst zeigt die Anzeige nach dem Ende
// eines Titels weiter steigende Zahlen, solange kein Ereignis kommt.
guard let duration, duration > 0 else { return max(value, 0) }
return min(max(value, 0), duration)
}
}
/// Was das Widget auslösen kann.
public enum MediaCommand: Equatable, Sendable {
case playPause
case next
case previous
case seek(to: TimeInterval)
}

View File

@@ -0,0 +1,95 @@
{
"sourceLanguage": "en",
"strings": {
"↑ %@": {
"comment": "A label that shows the high temperature. The argument is the high temperature.",
"isCommentAutoGenerated": true
},
"↓ %@": {
"comment": "A label that reads \"low\" in the weather app.",
"isCommentAutoGenerated": true
},
"widget.weather.asOf": {
"comment": "%@ ist die Uhrzeit des Messwerts",
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Stand %@"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "As of %@"
}
}
}
},
"widget.weather.locationDenied": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Kein Standortzugriff.\nOrt in den Einstellungen wählen."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "No location access.\nPick a place in settings."
}
}
}
},
"widget.weather.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wetter"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Weather"
}
}
}
},
"widget.weather.unavailable": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Wetter nicht abrufbar"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Weather unavailable"
}
}
}
},
"widget.weather.locationUndetermined": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Standort noch nicht erlaubt.\nIn den Einstellungen freigeben."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Location not allowed yet.\nGrant it in settings."
}
}
}
}
},
"version": "1.1"
}

View File

@@ -0,0 +1,79 @@
import Foundation
/// Hält den zuletzt geladenen Wetterstand je Ort.
///
/// WeatherKit rechnet nach Abrufen ab. Ein Widget, das bei jedem Öffnen des
/// Panels neu lädt, verbrennt das Kontingent für Daten, die sich stündlich
/// ändern der Zwischenspeicher ist deshalb Teil der Funktion und kein
/// Feinschliff.
public struct WeatherCache: Sendable {
/// Wie lange ein Stand als aktuell gilt.
public static let maxAge: TimeInterval = 15 * 60
private var entries: [String: WeatherSnapshot] = [:]
public init() {}
/// Der Stand, wenn er noch frisch genug ist.
public func valid(for place: WeatherPlace, now: Date = Date()) -> WeatherSnapshot? {
guard let entry = entries[Self.key(place)] else { return nil }
return now.timeIntervalSince(entry.asOf) <= Self.maxAge ? entry : nil
}
/// Der letzte bekannte Stand, unabhängig vom Alter.
///
/// Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel
/// deutlich besser als ein leeres Feld man sieht, dass etwas da ist und
/// wie alt es ist.
public func lastKnown(for place: WeatherPlace) -> WeatherSnapshot? {
entries[Self.key(place)]
}
public mutating func store(_ snapshot: WeatherSnapshot, for place: WeatherPlace) {
entries[Self.key(place)] = snapshot
}
/// Der Schlüssel muss den Ort mitführen, sonst zeigt das Widget nach einem
/// Ortswechsel weiter das alte Wetter und zwar überzeugend, weil Zahlen
/// dastehen.
private static func key(_ place: WeatherPlace) -> String {
switch place {
case .current:
"current"
case .fixed(_, let latitude, let longitude):
// Auf vier Nachkommastellen gerundet: das sind gut zehn Meter,
// feiner unterscheidet sich das Wetter ohnehin nicht.
String(format: "%.4f,%.4f", latitude, longitude)
}
}
}
public extension WeatherCondition {
/// Das SF-Symbol zur Lage.
///
/// Nur dort zwischen Tag und Nacht unterscheiden, wo es ein etabliertes
/// Symbolpaar gibt. Eine erzwungene Unterscheidung erzeugt sonst Symbole,
/// die niemand wiedererkennt.
func symbolName(isDaylight: Bool) -> String {
switch self {
case .clear: isDaylight ? "sun.max.fill" : "moon.stars.fill"
case .mostlyClear: isDaylight ? "sun.min.fill" : "moon.fill"
case .partlyCloudy: isDaylight ? "cloud.sun.fill" : "cloud.moon.fill"
case .cloudy: "cloud.fill"
case .fog: "cloud.fog.fill"
case .drizzle: "cloud.drizzle.fill"
case .rain: "cloud.rain.fill"
case .heavyRain: "cloud.heavyrain.fill"
case .sleet: "cloud.sleet.fill"
case .snow: "cloud.snow.fill"
case .heavySnow: "snowflake"
case .hail: "cloud.hail.fill"
case .thunderstorm: "cloud.bolt.rain.fill"
case .windy: "wind"
case .hot: "thermometer.sun.fill"
case .frigid: "thermometer.snowflake"
case .unknown: "questionmark.circle"
}
}
}

View File

@@ -0,0 +1,198 @@
import Foundation
import OSLog
import CoreLocation
import WeatherKit
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Weather")
/// Liefert den aktuellen Standort, oder sagt warum nicht.
///
/// Eigene Klasse statt eines Aufrufs mittendrin: `CLLocationManager` meldet
/// asynchron über einen Delegaten, und die Berechtigung kann sich jederzeit
/// ändern. Beides gehört an eine Stelle.
@MainActor
final class LocationProvider: NSObject, CLLocationManagerDelegate {
private let manager = CLLocationManager()
private var pending: [CheckedContinuation<CLLocation?, Never>] = []
override init() {
super.init()
manager.delegate = self
manager.desiredAccuracy = kCLLocationAccuracyKilometer
}
var authorization: CLAuthorizationStatus { manager.authorizationStatus }
var isDenied: Bool {
authorization == .denied || authorization == .restricted
}
var isUndetermined: Bool { authorization == .notDetermined }
func requestAuthorization() {
guard authorization == .notDetermined else { return }
manager.requestWhenInUseAuthorization()
}
/// Eine einzelne Ortung. Liefert `nil`, wenn keine Berechtigung vorliegt
/// oder nichts hereinkommt.
func currentLocation() async -> CLLocation? {
guard !isDenied, !isUndetermined else { return nil }
if let cached = manager.location, cached.timestamp.timeIntervalSinceNow > -900 {
return cached
}
return await withCheckedContinuation { continuation in
pending.append(continuation)
manager.requestLocation()
}
}
private func resume(with location: CLLocation?) {
let waiting = pending
pending.removeAll()
waiting.forEach { $0.resume(returning: location) }
}
nonisolated func locationManager(_ manager: CLLocationManager,
didUpdateLocations locations: [CLLocation]) {
Task { @MainActor in self.resume(with: locations.last) }
}
nonisolated func locationManager(_ manager: CLLocationManager,
didFailWithError error: Error) {
Task { @MainActor in
log.error("Ortung fehlgeschlagen: \(error.localizedDescription, privacy: .public)")
self.resume(with: nil)
}
}
}
/// Wetter aus Apples WeatherKit.
@MainActor
public final class WeatherKitSource {
private let service = WeatherService.shared
private let location = LocationProvider()
private let geocoder = CLGeocoder()
public init() {}
public var locationIsDenied: Bool { location.isDenied }
public var locationIsUndetermined: Bool { location.isUndetermined }
public var locationIsAuthorized: Bool { !location.isDenied && !location.isUndetermined }
/// Fragt die Ortungsberechtigung an.
///
/// Muss aus einem Vordergrundfenster gerufen werden wie beim Kalender
/// zeigt macOS den Dialog sonst nicht an, und die App wirkt kaputt.
public func requestLocationAuthorization() { location.requestAuthorization() }
public func load(_ place: WeatherPlace) async -> WeatherState {
let coordinate: CLLocation
let placeName: String
switch place {
case .current:
if location.isUndetermined {
log.notice("Wetter: Ortungsberechtigung noch nicht erteilt")
return .locationUndetermined
}
guard let here = await location.currentLocation() else {
let denied = location.isDenied
log.error("Wetter: keine Ortung erhalten (verweigert: \(denied, privacy: .public))")
return denied
? .locationDenied
: .unavailable(lastKnown: nil, reason: "Standort nicht verfügbar")
}
coordinate = here
placeName = await name(for: here)
case .fixed(let name, let latitude, let longitude):
coordinate = CLLocation(latitude: latitude, longitude: longitude)
placeName = name
}
do {
let weather = try await service.weather(for: coordinate)
return .ready(Self.snapshot(from: weather,
placeName: placeName,
attribution: try? await attribution()))
} catch {
log.error("WeatherKit: \(error.localizedDescription, privacy: .public)")
return .unavailable(lastKnown: nil, reason: error.localizedDescription)
}
}
/// Apples Namensnennung. Keine Kür sie ist Bedingung der Nutzung.
private func attribution() async throws -> WeatherAttribution {
let legal = try await service.attribution
return WeatherAttribution(name: "Apple Weather",
legalPageURL: legal.legalPageURL,
logoURL: legal.combinedMarkDarkURL)
}
private func name(for location: CLLocation) async -> String {
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
return "Aktueller Ort"
}
return placemark.locality ?? placemark.subAdministrativeArea
?? placemark.administrativeArea ?? "Aktueller Ort"
}
// MARK: - Umwandlung
private static func snapshot(from weather: Weather,
placeName: String,
attribution: WeatherAttribution?) -> WeatherSnapshot {
let current = weather.currentWeather
let today = weather.dailyForecast.first
return WeatherSnapshot(
placeName: placeName,
temperature: current.temperature,
apparentTemperature: current.apparentTemperature,
condition: map(current.condition),
isDaylight: current.isDaylight,
humidity: current.humidity,
windSpeed: current.wind.speed,
high: today?.highTemperature,
low: today?.lowTemperature,
hourly: weather.hourlyForecast.forecast
.filter { $0.date >= Date() }
.prefix(12)
.map {
HourlyPoint(date: $0.date,
temperature: $0.temperature,
condition: map($0.condition),
precipitationChance: $0.precipitationChance)
},
asOf: current.date,
attribution: attribution)
}
/// WeatherKit kennt deutlich mehr Lagen als hier unterschieden werden.
/// Feiner aufzulösen bringt nichts: das Widget hat ein Symbol und ein Wort
/// Platz, und mäßiger Sprühregen" liest dort niemand.
private static func map(_ condition: WeatherKit.WeatherCondition) -> WeatherCondition {
switch condition {
case .clear: .clear
case .mostlyClear: .mostlyClear
case .partlyCloudy, .mostlyCloudy: .partlyCloudy
case .cloudy: .cloudy
case .foggy, .haze, .smoky: .fog
case .drizzle, .freezingDrizzle: .drizzle
case .rain, .sunShowers, .freezingRain: .rain
case .heavyRain: .heavyRain
case .sleet, .wintryMix, .hail: .sleet
case .snow, .flurries, .sunFlurries, .blowingSnow: .snow
case .heavySnow, .blizzard: .heavySnow
case .thunderstorms, .isolatedThunderstorms, .scatteredThunderstorms,
.strongStorms, .tropicalStorm, .hurricane: .thunderstorm
case .windy, .breezy, .blowingDust: .windy
case .hot: .hot
case .frigid: .frigid
@unknown default: .unknown
}
}
}

View File

@@ -0,0 +1,133 @@
import Foundation
/// Wetterlage, unabhängig von der Quelle.
///
/// Eine eigene Aufzählung statt WeatherKits Typ: die Widget-Ebene soll nicht
/// gegen ein Framework gebunden sein, das eine kostenpflichtige Mitgliedschaft
/// und eine Entitlement voraussetzt. Fällt WeatherKit einmal weg, wird nur die
/// Abbildung ausgetauscht.
public enum WeatherCondition: String, Equatable, Sendable, CaseIterable {
case clear
case mostlyClear
case partlyCloudy
case cloudy
case fog
case drizzle
case rain
case heavyRain
case sleet
case snow
case heavySnow
case hail
case thunderstorm
case windy
case hot
case frigid
case unknown
}
/// Der Ort, für den Wetter angezeigt wird.
public enum WeatherPlace: Equatable, Sendable, Codable {
/// Aktueller Standort über CoreLocation.
case current
/// Fester Ort, in den Widget-Einstellungen gewählt.
case fixed(name: String, latitude: Double, longitude: Double)
public var isCurrent: Bool { self == .current }
}
public struct HourlyPoint: Equatable, Sendable, Identifiable {
public let date: Date
public let temperature: Measurement<UnitTemperature>
public let condition: WeatherCondition
public let precipitationChance: Double
public var id: Date { date }
public init(date: Date, temperature: Measurement<UnitTemperature>,
condition: WeatherCondition, precipitationChance: Double) {
self.date = date
self.temperature = temperature
self.condition = condition
self.precipitationChance = precipitationChance
}
}
/// Was Apple bei Nutzung von WeatherKit sichtbar verlangt.
///
/// Das ist keine Kür: die Nennung samt Link auf die Rechtshinweise ist
/// Bedingung der Nutzung. Deshalb steht sie im Datenmodell und nicht in einer
/// Notiz, die man beim Bauen der Oberfläche übersieht.
public struct WeatherAttribution: Equatable, Sendable {
public let name: String
public let legalPageURL: URL
public let logoURL: URL?
public init(name: String, legalPageURL: URL, logoURL: URL?) {
self.name = name
self.legalPageURL = legalPageURL
self.logoURL = logoURL
}
}
public struct WeatherSnapshot: Equatable, Sendable {
public let placeName: String
public let temperature: Measurement<UnitTemperature>
public let apparentTemperature: Measurement<UnitTemperature>
public let condition: WeatherCondition
public let isDaylight: Bool
public let humidity: Double
public let windSpeed: Measurement<UnitSpeed>
public let high: Measurement<UnitTemperature>?
public let low: Measurement<UnitTemperature>?
public let hourly: [HourlyPoint]
public let asOf: Date
public let attribution: WeatherAttribution?
public init(placeName: String,
temperature: Measurement<UnitTemperature>,
apparentTemperature: Measurement<UnitTemperature>,
condition: WeatherCondition,
isDaylight: Bool,
humidity: Double,
windSpeed: Measurement<UnitSpeed>,
high: Measurement<UnitTemperature>?,
low: Measurement<UnitTemperature>?,
hourly: [HourlyPoint],
asOf: Date,
attribution: WeatherAttribution?) {
self.placeName = placeName
self.temperature = temperature
self.apparentTemperature = apparentTemperature
self.condition = condition
self.isDaylight = isDaylight
self.humidity = humidity
self.windSpeed = windSpeed
self.high = high
self.low = low
self.hourly = hourly
self.asOf = asOf
self.attribution = attribution
}
}
/// Wie beim Kalender: jeder Fehlerfall bekommt eine eigene Antwort in der
/// Oberfläche. Keine Daten" ist keine.
public enum WeatherState: Equatable, Sendable {
case ready(WeatherSnapshot)
/// Noch nie geladen.
case idle
case loading
/// Ortungsberechtigung wurde verweigert betrifft nur `.current`.
/// macOS fragt dann nicht mehr; es hilft nur der Weg über die
/// Systemeinstellungen oder ein fest gewählter Ort.
case locationDenied
/// Es wurde noch nie gefragt. Anders als `denied` lohnt hier eine Anfrage
/// und sie muss aus einem Vordergrundfenster kommen, sonst zeigt macOS
/// keinen Dialog. Ein eigener Zustand, weil "nicht abrufbar" hier eine
/// falsche Auskunft wäre: abrufbar ist es sehr wohl, es fehlt nur die
/// Erlaubnis.
case locationUndetermined
/// Kein Netz oder Dienst nicht erreichbar. Der letzte Stand bleibt sichtbar.
case unavailable(lastKnown: WeatherSnapshot?, reason: String)
}

View File

@@ -0,0 +1,279 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
/// Hält den Wetterstand für das Widget.
@MainActor
@Observable
public final class WeatherModel {
public private(set) var state: WeatherState = .idle
public var place: WeatherPlace {
didSet { guard place != oldValue else { return }; load(force: true) }
}
private let source: WeatherKitSource
private var cache = WeatherCache()
private var task: Task<Void, Never>?
public init(source: WeatherKitSource = WeatherKitSource(), place: WeatherPlace = .current) {
self.source = source
self.place = place
}
public var locationIsDenied: Bool { source.locationIsDenied }
public var locationIsUndetermined: Bool { source.locationIsUndetermined }
public var locationIsAuthorized: Bool { source.locationIsAuthorized }
/// Fragt die Ortungsberechtigung an und lädt danach.
public func requestLocationAuthorization() {
source.requestLocationAuthorization()
// Die Entscheidung fällt asynchron. Kurz warten und dann laden die
// Alternative wäre ein Delegat quer durch drei Schichten für einen
// Vorgang, der einmal im Leben der App passiert.
Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
self?.load(force: true)
}
}
/// Lädt, wenn der Zwischenspeicher abgelaufen ist.
///
/// `force` gilt nur für einen Ortswechsel nicht für jedes Öffnen des
/// Panels. WeatherKit rechnet nach Abrufen ab, und das Wetter ändert sich
/// nicht im Sekundentakt.
public func load(force: Bool = false) {
if !force, let fresh = cache.valid(for: place) {
state = .ready(fresh)
return
}
task?.cancel()
let place = place
task = Task { [weak self] in
guard let self else { return }
if case .ready = state {} else { state = .loading }
let result = await source.load(place)
guard !Task.isCancelled else { return }
switch result {
case .ready(let snapshot):
cache.store(snapshot, for: place)
state = .ready(snapshot)
case .unavailable(_, let reason):
// Der letzte bekannte Stand bleibt sichtbar. Ein alter Messwert
// mit Zeitstempel ist ehrlicher und nützlicher als ein leeres Feld.
state = .unavailable(lastKnown: cache.lastKnown(for: place), reason: reason)
default:
state = result
}
}
}
public func stop() {
task?.cancel()
task = nil
}
}
// MARK: - Widget
public struct WeatherWidget: OnyxWidget {
public let id = "weather"
public var displayName: String { String(localized: "widget.weather.name", bundle: .module) }
public let symbolName = "cloud.sun"
public let supportedSizes: [WidgetSize] = [.small, .medium]
private let model: WeatherModel
public init(model: WeatherModel) { self.model = model }
public func makeView(size: WidgetSize) -> AnyView {
AnyView(WeatherWidgetView(model: model, size: size))
}
}
private struct WeatherWidgetView: View {
let model: WeatherModel
let size: WidgetSize
var body: some View {
Group {
switch model.state {
case .ready(let snapshot):
content(snapshot, isStale: false)
case .unavailable(let lastKnown, _):
if let lastKnown {
content(lastKnown, isStale: true)
} else {
Notice(symbol: "wifi.slash", text: "widget.weather.unavailable")
}
case .locationDenied:
Notice(symbol: "location.slash", text: "widget.weather.locationDenied")
case .locationUndetermined:
Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined")
case .loading, .idle:
ProgressView()
.controlSize(.small)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task { model.load() }
.onDisappear { model.stop() }
}
@ViewBuilder
private func content(_ snapshot: WeatherSnapshot, isStale: Bool) -> some View {
if size == .small {
CompactWeather(snapshot: snapshot, isStale: isStale)
} else {
WideWeather(snapshot: snapshot, isStale: isStale)
}
}
}
private struct Notice: View {
let symbol: String
let text: LocalizedStringKey
var body: some View {
VStack(spacing: 6) {
Image(systemName: symbol)
.font(.system(size: 15))
.foregroundStyle(Onyx.Color.textTertiary)
Text(text, bundle: .module)
.font(Onyx.Font.caption)
.foregroundStyle(Onyx.Color.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct CompactWeather: View {
let snapshot: WeatherSnapshot
let isStale: Bool
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(.system(size: 18))
.foregroundStyle(Onyx.Color.textPrimary)
.symbolRenderingMode(.hierarchical)
Spacer(minLength: 0)
Text(snapshot.temperature.onyxFormatted)
.font(Onyx.Font.metric)
.foregroundStyle(Onyx.Color.textPrimary)
Text(snapshot.placeName)
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textTertiary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.opacity(isStale ? 0.55 : 1)
.help(helpText)
}
private var helpText: Text {
// Apples Namensnennung ist Bedingung der Nutzung. In der 1×1-Kachel ist
// dafür kein Platz sie steht hier und zusätzlich in den Einstellungen.
Text(verbatim: [snapshot.placeName,
snapshot.attribution.map { "Wetterdaten: \($0.name)" }]
.compactMap { $0 }.joined(separator: " · "))
}
}
private struct WideWeather: View {
let snapshot: WeatherSnapshot
let isStale: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight))
.font(.system(size: 22))
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Onyx.Color.textPrimary)
VStack(alignment: .leading, spacing: 1) {
Text(snapshot.temperature.onyxFormatted)
.font(Onyx.Font.metric)
.foregroundStyle(Onyx.Color.textPrimary)
Text(snapshot.placeName)
.font(.system(size: 10))
.foregroundStyle(Onyx.Color.textSecondary)
.lineLimit(1)
}
Spacer(minLength: 0)
if let high = snapshot.high, let low = snapshot.low {
VStack(alignment: .trailing, spacing: 1) {
Text("\(high.onyxFormatted)")
Text("\(low.onyxFormatted)")
}
.font(Onyx.Font.metricSmall)
.foregroundStyle(Onyx.Color.textTertiary)
}
}
if !snapshot.hourly.isEmpty {
HStack(spacing: 0) {
ForEach(snapshot.hourly.prefix(5)) { point in
VStack(spacing: 2) {
Text(point.date.formatted(.dateTime.hour()))
.font(.system(size: 8))
.foregroundStyle(Onyx.Color.textTertiary)
Image(systemName: point.condition.symbolName(isDaylight: true))
.font(.system(size: 9))
.foregroundStyle(Onyx.Color.textSecondary)
Text(point.temperature.onyxFormatted)
.font(.system(size: 9))
.monospacedDigit()
.foregroundStyle(Onyx.Color.textSecondary)
}
.frame(maxWidth: .infinity)
}
}
}
Spacer(minLength: 0)
HStack(spacing: 4) {
if isStale {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 8))
.foregroundStyle(Onyx.Color.warning)
}
Text(stampText)
.font(.system(size: 8))
.foregroundStyle(Onyx.Color.textTertiary)
Spacer(minLength: 0)
if let attribution = snapshot.attribution {
// Pflichtangabe, klickbar auf Apples Rechtshinweise.
Link(attribution.name, destination: attribution.legalPageURL)
.font(.system(size: 8))
.foregroundStyle(Onyx.Color.textTertiary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var stampText: String {
let time = snapshot.asOf.formatted(date: .omitted, time: .shortened)
return String(format: String(localized: "widget.weather.asOf", bundle: .module), time)
}
}
extension Measurement where UnitType == UnitTemperature {
/// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt.
/// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor,
/// die die Vorhersage nicht hat.
var onyxFormatted: String {
formatted(.measurement(width: .narrow,
usage: .weather,
numberFormatStyle: .number.precision(.fractionLength(0))))
}
}

View File

@@ -0,0 +1,181 @@
import Testing
import Foundation
@testable import MediaProvider
@Suite("Wiedergabe-Fortschritt")
struct NowPlayingProgressTests {
private let t0 = Date(timeIntervalSince1970: 1_800_000_000)
private func playing(elapsed: TimeInterval = 30, duration: TimeInterval? = 200,
rate: Double = 1) -> NowPlaying {
NowPlaying(title: "Titel", isPlaying: true, duration: duration,
elapsed: elapsed, playbackRate: rate, asOf: t0)
}
@Test("Während der Wiedergabe zählt die Position zwischen den Ereignissen weiter")
func elapsedAdvancesWhilePlaying() {
// Der Adapter liefert `elapsedTime` nicht laufend nach. Ohne dieses
// Weiterzählen steht der Scrubber still, obwohl der Titel läuft.
let state = playing(elapsed: 30)
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 40)
}
@Test("Im pausierten Zustand bleibt die Position stehen")
func elapsedFreezesWhenPaused() {
var state = playing(elapsed: 30)
state.isPlaying = false
#expect(state.elapsed(at: t0.addingTimeInterval(60)) == 30)
}
@Test("Doppelte Geschwindigkeit zählt doppelt so schnell")
func playbackRateIsHonoured() {
let state = playing(elapsed: 0, rate: 2)
#expect(state.elapsed(at: t0.addingTimeInterval(10)) == 20)
}
@Test("Die Position läuft nie über die Länge hinaus")
func elapsedNeverExceedsDuration() {
// Sonst zeigt die Anzeige nach dem Ende eines Titels weiter steigende
// Zahlen, solange kein neues Ereignis kommt.
let state = playing(elapsed: 190, duration: 200)
#expect(state.elapsed(at: t0.addingTimeInterval(3600)) == 200)
}
@Test("Ohne bekannte Länge zählt sie einfach weiter — Livestreams haben kein Ende")
func liveStreamKeepsCounting() {
let state = playing(elapsed: 10, duration: nil)
#expect(state.elapsed(at: t0.addingTimeInterval(100)) == 110)
}
@Test("Eine negative Position wird auf null gezogen")
func elapsedNeverGoesNegative() {
var state = playing(elapsed: 5)
state.isPlaying = false
state.elapsed = -3
#expect(state.elapsed(at: t0) == 0)
}
@Test("Fortschritt liegt zwischen 0 und 1")
func progressIsNormalised() {
let state = playing(elapsed: 50, duration: 200)
#expect(state.progress(at: t0) == 0.25)
}
@Test("Ohne Länge gibt es keinen Fortschritt statt eines falschen Wertes")
func progressIsNilWithoutDuration() {
#expect(playing(duration: nil).progress(at: t0) == nil)
}
@Test("Länge null ergibt keinen Fortschritt statt einer Division durch null")
func progressIsNilForZeroDuration() {
#expect(playing(duration: 0).progress(at: t0) == nil)
}
}
@Suite("Adapter-Payload")
struct AdapterPayloadTests {
private func line(_ json: String) -> String { json }
@Test("Vollmeldung setzt alle Felder")
func fullPayloadPopulates() {
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","album":"LP",\
"playing":true,"duration":210.5,"elapsedTime":42.0,"playbackRate":1,\
"bundleIdentifier":"com.google.Chrome"}}
""", to: nil)
#expect(state?.title == "Song")
#expect(state?.artist == "Band")
#expect(state?.isPlaying == true)
#expect(state?.duration == 210.5)
#expect(state?.elapsed == 42)
#expect(state?.bundleIdentifier == "com.google.Chrome")
#expect(state?.origin == .adapter)
}
@Test("Teilmeldung ergänzt den bisherigen Stand, statt ihn zu ersetzen")
func diffPayloadMerges() {
// Der eigentliche Grund für diese Unterscheidung: eine Teilmeldung mit
// nur der Position würde sonst Titel, Interpret und Cover löschen.
let full = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Song","artist":"Band","playing":true}}
""", to: nil)
let updated = AdapterPayload.apply(line: """
{"type":"data","diff":true,"payload":{"elapsedTime":99.0}}
""", to: full)
#expect(updated?.title == "Song")
#expect(updated?.artist == "Band")
#expect(updated?.elapsed == 99)
}
@Test("Leere Vollmeldung heißt: es läuft nichts")
func emptyFullPayloadMeansNothingPlaying() {
#expect(AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{}}
""", to: nil) == nil)
}
@Test("Leere Teilmeldung lässt den Stand unangetastet")
func emptyDiffKeepsState() {
let previous = NowPlaying(title: "Song")
let result = AdapterPayload.apply(line: """
{"type":"data","diff":true,"payload":{}}
""", to: previous)
#expect(result?.title == "Song")
}
@Test("Der Zeitstempel der Quelle wird übernommen, nicht der Empfangszeitpunkt")
func timestampComesFromPayload() {
// Sonst springt der Scrubber bei jedem Ereignis, weil die Position auf
// einen falschen Bezugszeitpunkt gerechnet wird.
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","elapsedTime":10.0,\
"timestamp":"2026-08-10T14:22:16Z"}}
""", to: nil)
let expected = ISO8601DateFormatter().date(from: "2026-08-10T14:22:16Z")
#expect(state?.asOf == expected)
}
@Test("Cover kommt als base64 an")
func artworkIsDecoded() {
let raw = Data([0xFF, 0xD8, 0xFF]).base64EncodedString()
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","artworkData":"\(raw)"}}
""", to: nil)
#expect(state?.artwork == Data([0xFF, 0xD8, 0xFF]))
}
@Test("Länge null wird zu 'unbekannt' statt zu einer Null-Länge")
func zeroDurationBecomesNil() {
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"Stream","duration":0}}
""", to: nil)
#expect(state?.duration == nil)
}
@Test("Kaputte Zeilen werden verworfen, nicht interpretiert")
func garbageIsIgnored() {
for junk in ["", "kein json", "{}", "{\"type\":\"log\"}", "null"] {
#expect(AdapterPayload.apply(line: junk, to: nil) == nil)
}
}
@Test("`playing: true` wird nicht als Zahl gelesen")
func boolIsNotMistakenForNumber() {
// Reihenfolge in AnyDecodable: Bool vor Double. Andernfalls wird `true`
// zu 1 und `playing` bliebe für immer falsch.
let state = AdapterPayload.apply(line: """
{"type":"data","diff":false,"payload":{"title":"S","playing":true}}
""", to: nil)
#expect(state?.isPlaying == true)
}
}

View File

@@ -0,0 +1,138 @@
import Testing
import Foundation
@testable import WeatherProvider
// WeatherKit rechnet nach Abrufen ab (500.000 im Monat inklusive). Ein Widget,
// das bei jedem Öffnen des Panels neu lädt, verbrennt das Kontingent für Daten,
// die sich stündlich ändern. Der Zwischenspeicher ist deshalb kein Feinschliff,
// sondern Teil der Funktion.
@Suite("Wetter-Zwischenspeicher")
struct WeatherCacheTests {
private let place = WeatherPlace.fixed(name: "Lemgo", latitude: 52.02, longitude: 8.9)
private let other = WeatherPlace.fixed(name: "Berlin", latitude: 52.52, longitude: 13.40)
private func snapshot(at date: Date) -> WeatherSnapshot {
WeatherSnapshot(placeName: "Lemgo",
temperature: .init(value: 18, unit: .celsius),
apparentTemperature: .init(value: 17, unit: .celsius),
condition: .partlyCloudy, isDaylight: true, humidity: 0.6,
windSpeed: .init(value: 10, unit: .kilometersPerHour),
high: nil, low: nil, hourly: [], asOf: date, attribution: nil)
}
@Test("Frischer Eintrag wird wiederverwendet")
func freshEntryIsReused() {
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: place)
#expect(cache.valid(for: place, now: now.addingTimeInterval(60)) != nil)
}
@Test("Nach Ablauf der Frist wird neu geladen")
func staleEntryIsDiscarded() {
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: place)
let later = now.addingTimeInterval(WeatherCache.maxAge + 1)
#expect(cache.valid(for: place, now: later) == nil)
}
@Test("Genau auf der Frist gilt der Eintrag noch")
func exactlyAtMaxAgeIsStillValid() {
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: place)
#expect(cache.valid(for: place, now: now.addingTimeInterval(WeatherCache.maxAge)) != nil)
}
@Test("Ein anderer Ort benutzt nicht den Eintrag des ersten")
func differentPlaceMisses() {
// Sonst zeigt das Widget nach dem Umstellen des Ortes weiter das alte
// Wetter und zwar überzeugend, weil Zahlen dastehen.
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: place)
#expect(cache.valid(for: other, now: now) == nil)
}
@Test("Der aktuelle Standort ist ein eigener Eintrag")
func currentLocationIsItsOwnEntry() {
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: .current)
#expect(cache.valid(for: .current, now: now) != nil)
#expect(cache.valid(for: place, now: now) == nil)
}
@Test("Leerer Zwischenspeicher liefert nichts")
func emptyCacheMisses() {
#expect(WeatherCache().valid(for: place, now: Date()) == nil)
}
@Test("Ein abgelaufener Eintrag bleibt als letzter bekannter Stand erhalten")
func staleEntryRemainsAsLastKnown() {
// Bei einem Netzfehler ist ein alter Messwert mit sichtbarem Zeitstempel
// deutlich besser als ein leeres Feld.
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now), for: place)
let later = now.addingTimeInterval(WeatherCache.maxAge * 10)
#expect(cache.valid(for: place, now: later) == nil)
#expect(cache.lastKnown(for: place) != nil)
}
@Test("Ein neuer Eintrag ersetzt den alten desselben Ortes")
func storingReplaces() {
let now = Date()
var cache = WeatherCache()
cache.store(snapshot(at: now.addingTimeInterval(-1000)), for: place)
cache.store(snapshot(at: now), for: place)
#expect(cache.lastKnown(for: place)?.asOf == now)
}
@Test("Die Frist entspricht dem Plan: 15 Minuten")
func maxAgeMatchesPlan() {
#expect(WeatherCache.maxAge == 15 * 60)
}
}
@Suite("Wettersymbole")
struct WeatherSymbolTests {
@Test("Klarer Himmel zeigt tagsüber die Sonne, nachts den Mond")
func clearDependsOnDaylight() {
#expect(WeatherCondition.clear.symbolName(isDaylight: true) == "sun.max.fill")
#expect(WeatherCondition.clear.symbolName(isDaylight: false) == "moon.stars.fill")
}
@Test("Teilweise bewölkt unterscheidet ebenfalls Tag und Nacht")
func partlyCloudyDependsOnDaylight() {
#expect(WeatherCondition.partlyCloudy.symbolName(isDaylight: true) !=
WeatherCondition.partlyCloudy.symbolName(isDaylight: false))
}
@Test("Regen sieht nachts aus wie tagsüber")
func rainIgnoresDaylight() {
// Nicht jede Lage hat eine sinnvolle Nachtvariante. Eine erzwungene
// Unterscheidung erzeugt nur Symbole, die niemand wiedererkennt.
#expect(WeatherCondition.rain.symbolName(isDaylight: true) ==
WeatherCondition.rain.symbolName(isDaylight: false))
}
@Test("Jede Lage liefert ein Symbol — auch die unbekannte")
func everyConditionHasASymbol() {
for condition in WeatherCondition.allCases {
#expect(!condition.symbolName(isDaylight: true).isEmpty)
#expect(!condition.symbolName(isDaylight: false).isEmpty)
}
}
}

87
README.md Normal file
View File

@@ -0,0 +1,87 @@
# Onyx
**Notch-Erweiterung für macOS.** Beim Überfahren der Notch fährt ein Panel mit
eigenen Widgets aus — Kalender, Wetter, Medien, Hardware, Netzwerk, Audio-Mixer,
Ablage und AirDrop.
Zielgerät: MacBook Pro mit Apple Silicon, macOS 26+.
---
## Bauen
```bash
xcodegen generate # erzeugt Onyx.xcodeproj aus project.yml
open Onyx.xcodeproj # dann ⌘R
```
Voraussetzungen: Xcode 26, [XcodeGen](https://github.com/yonaskolb/XcodeGen),
Signierung mit Team `PP34X97WS3`.
Tests laufen ohne Xcode:
```bash
cd Packages/OnyxKit && swift test
```
---
## Aufbau
Die Projektdefinition liegt als `project.yml` vor, nicht als handgepflegte
`.xcodeproj` — so ist im Diff lesbar, was sich an den Build-Einstellungen ändert.
```
Onyx/ App-Target (nicht sandboxed, Hardened Runtime)
Packages/OnyxKit/ Alle Module als lokales Swift-Package
OnyxCore Einzelinstanz-Sicherung
OnyxDesign Farben, Maße, Bewegung — einzige Quelle
OnyxNotch Panel-Fenster, Geometrie, Zustandsmaschine
OnyxWidgetKit Widget-Protokoll, Layout-Engine, Persistenz
OnyxMenuBar Modul-Protokoll für Menüleisten-Elemente
CalendarProvider EventKit (und später Calendarr)
WeatherProvider WeatherKit + CoreLocation
MediaProvider MediaRemote-Adapter, AppleScript, Medientasten
Vendor/ Fremdcode im Quellzustand (siehe unten)
Spikes/ Wegwerf-Werkzeuge zur API-Verifikation
docs/spikes/ Was dabei herauskam — auch die Sackgassen
```
---
## Entscheidungen, die man sonst für Fehler hält
**Keine App-Sandbox.** Sie ist mit dem privilegierten Helper, mit IOKit und mit
dem MediaRemote-Adapter unvereinbar. Der App-Group-Container für Calendarr
bleibt trotzdem erreichbar — auf macOS ist er von der Sandbox unabhängig.
Nachgemessen in [docs/spikes/D-appgroup.md](docs/spikes/D-appgroup.md).
**TCC-Entitlements trotz fehlender Sandbox.** Bei aktivierter Hardened Runtime
verlangt macOS `com.apple.security.personal-information.*` auch ohne Sandbox.
Fehlt eine davon, erscheint der Berechtigungsdialog gar nicht erst — ohne
Fehlermeldung. Das hat einen halben Abend gekostet:
[docs/spikes/E-tcc-hardened-runtime.md](docs/spikes/E-tcc-hardened-runtime.md).
**`ENABLE_DEBUG_DYLIB = NO`.** Xcode lagert im Debug-Build den Programmcode in
eine eigene dylib aus. TCC ordnet Berechtigungsanfragen dann dem verbleibenden
Rumpf zu, der weder Info.plist noch Entitlements trägt.
**Eigenes Einstellungsfenster.** SwiftUIs `Settings`-Szene öffnet über einen
privaten Selektor, der bei Apps ohne Menüleiste unzuverlässig ankommt.
**Der MediaRemote-Adapter wird nicht gelinkt.** Er wird von `/usr/bin/perl`
geladen, einem berechtigten Systemprogramm — im eigenen Prozess greift Apples
Entitlement-Prüfung und es käme nichts an.
---
## Fremdcode
`Vendor/mediaremote-adapter` — [ungive/mediaremote-adapter](https://github.com/ungive/mediaremote-adapter),
BSD-3-Clause. Als Quellcode eingebettet und beim Bauen kompiliert, nicht als
fertige Binary: so ist nachvollziehbar, was läuft. Stand siehe
`ONYX-VERSION.txt`.
---
© 2026 Scarriffleservices

View File

@@ -0,0 +1,81 @@
#!/bin/bash
# Baut den mediaremote-adapter aus dem mitgelieferten Quellcode.
#
# Wird als Build-Schritt des App-Targets aufgerufen. Repliziert das
# CMakeLists.txt des Projekts mit clang — cmake ist keine Voraussetzung, die
# man einem Mitentwickler zumuten will.
#
# Der Umweg über /usr/bin/perl ist kein Trick um seiner selbst willen: seit
# macOS 15.4 prüft mediaremoted Entitlements, und Fremdprogramme bekommen keine
# Now-Playing-Daten mehr. Perl ist ein berechtigtes Systemprogramm und lädt das
# hier gebaute Framework.
#
# Quelle: https://github.com/ungive/mediaremote-adapter (BSD-3-Clause),
# eingebettet unter Vendor/ — siehe ONYX-VERSION.txt für den Stand.
set -euo pipefail
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
SRC="${SRC:-$(cd "$(dirname "$0")/.." && pwd)/Vendor/mediaremote-adapter}"
OUT="${OUT:?Zielverzeichnis fehlt}"
NAME=MediaRemoteAdapter
FW="$OUT/$NAME.framework"
[[ -d "$SRC" ]] || { echo "Quellen fehlen unter $SRC" >&2; exit 1; }
rm -rf "$OUT"
mkdir -p "$FW/Versions/A/Headers" "$FW/Versions/A/Resources"
SOURCES=(
"$SRC"/src/adapter/{env,get,globals,keys,now_playing,repeat,seek,send,shuffle,speed,stream,test}.m
"$SRC"/src/private/MediaRemote.m
"$SRC"/src/utility/{Debounce,helpers}.m
)
echo "→ Framework-Binary"
xcrun clang -dynamiclib -fobjc-arc -fvisibility=default -O2 \
-arch arm64 -mmacosx-version-min=26.0 \
-I"$SRC/include" -I"$SRC/src" \
-framework Foundation -framework AppKit -framework UniformTypeIdentifiers \
-install_name "@rpath/$NAME.framework/Versions/A/$NAME" \
-o "$FW/Versions/A/$NAME" \
"${SOURCES[@]}"
cp "$SRC/include/$NAME.h" "$FW/Versions/A/Headers/"
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
<?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>CFBundleExecutable</key><string>$NAME</string>
<key>CFBundleIdentifier</key><string>com.vandenbe.$NAME</string>
<key>CFBundleName</key><string>$NAME</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>0.1</string>
<key>CFBundleVersion</key><string>0.1.0</string>
</dict>
</plist>
PLIST
ln -sf A "$FW/Versions/Current"
ln -sf "Versions/Current/$NAME" "$FW/$NAME"
ln -sf Versions/Current/Headers "$FW/Headers"
ln -sf Versions/Current/Resources "$FW/Resources"
echo "→ Test-Client"
xcrun clang -fobjc-arc -O2 -arch arm64 \
-I"$SRC/src/test" \
-framework Foundation -framework MediaPlayer \
-o "$OUT/${NAME}TestClient" \
"$SRC"/src/test/{main,NowPlayingTest}.m
echo "→ ad-hoc signieren"
codesign --force --deep --sign - "$FW"
codesign --force --sign - "$OUT/${NAME}TestClient"
cp "$SRC/bin/mediaremote-adapter.pl" "$OUT/"
echo
echo "Fertig: $OUT"
ls -la "$OUT"

View File

@@ -0,0 +1,49 @@
---
BasedOnStyle: LLVM
IndentWidth: 4
---
Language: Cpp
AccessModifierOffset: -4
PointerAlignment: Left
BreakBeforeBraces: Custom
BraceWrapping:
AfterCaseLabel: false
AfterClass: true
AfterControlStatement: Never
AfterEnum: true
AfterFunction: true
AfterNamespace: true
AfterStruct: true
AfterUnion: true
AfterExternBlock: false
BeforeCatch: true
AlignAfterOpenBracket: DontAlign
Cpp11BracedListStyle: false
IncludeBlocks: Regroup
IncludeCategories:
- Regex: "<httplib\\.h>"
Priority: 3
- Regex: "<[^Q][[:alnum:]_.]+>"
Priority: 1
- Regex: "<Q.+>"
Priority: 2
- Regex: "<[^Q].+>"
Priority: 3
- Regex: ".*"
Priority: 4
SeparateDefinitionBlocks: Always
AllowShortLambdasOnASingleLine: Empty
AlwaysBreakTemplateDeclarations: true
AlignOperands: DontAlign
AlignEscapedNewlines: Left
BinPackParameters: true
AlignTrailingComments:
Kind: Never
---
Language: JavaScript
SpacesInContainerLiterals: false
JavaScriptQuotes: Single
JavaScriptWrapImports: true
---
# Notes:
# Designated initializer list formatting: Write a ',' after the last item.

View File

@@ -0,0 +1,10 @@
line_width = 80
tab_size = 4
separate_ctrl_name_with_space = False
separate_fn_name_with_space = False
dangle_parens = False
fractional_tab_policy = 'use-space'
max_subgroups_hwrap = 3
command_case = 'canonical'
keyword_case = 'upper'
literal_comment_pattern = '.*'

8
Vendor/mediaremote-adapter/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
build
.DS_Store
notes
CMakePresets.json
.cache
todo*.txt
dist
perltidy.ERR

View File

View File

@@ -0,0 +1,4 @@
# https://perltidy.sourceforge.net/perltidy.html
-naws
--indent-columns=2

View File

@@ -0,0 +1,92 @@
cmake_minimum_required(VERSION 3.15)
project(MediaRemoteAdapter LANGUAGES OBJC)
set(ADAPTER_VERSION_MAJOR 0)
set(ADAPTER_VERSION_MINOR 1)
set(ADAPTER_VERSION_PATCH 0)
set(ADAPTER_VERSION_SHORT "${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}")
set(ADAPTER_VERSION
"${ADAPTER_VERSION_MAJOR}.${ADAPTER_VERSION_MINOR}.${ADAPTER_VERSION_PATCH}"
)
set(MEDIAREMOTEADAPTER_FRAMEWORK_NAME "MediaRemoteAdapter"
CACHE STRING "The output name of the adapter framework")
set(MEDIAREMOTEADAPTER_TEST_CLIENT_NAME "MediaRemoteAdapterTestClient"
CACHE STRING "The output name of the adapter test client")
set(ADAPTER_SOURCES
src/adapter/env.m
src/adapter/get.m
src/adapter/globals.m
src/adapter/keys.m
src/adapter/now_playing.m
src/adapter/repeat.m
src/adapter/seek.m
src/adapter/send.m
src/adapter/shuffle.m
src/adapter/speed.m
src/adapter/stream.m
src/adapter/test.m
src/private/MediaRemote.m
src/utility/Debounce.m
src/utility/helpers.m)
set(HEADERS include/MediaRemoteAdapter.h)
set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64")
add_library(MediaRemoteAdapter SHARED ${ADAPTER_SOURCES})
set_target_properties(
MediaRemoteAdapter
PROPERTIES FRAMEWORK TRUE
FRAMEWORK_VERSION A
MACOSX_FRAMEWORK_IDENTIFIER
"com.vandenbe.${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
MACOSX_FRAMEWORK_SHORT_VERSION_STRING "${ADAPTER_VERSION_SHORT}"
MACOSX_FRAMEWORK_BUNDLE_VERSION "${ADAPTER_VERSION}"
MACOSX_FRAMEWORK_BUNDLE_NAME
"${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}"
PUBLIC_HEADER "${HEADERS}"
OUTPUT_NAME "${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}")
target_link_libraries(MediaRemoteAdapter "-framework Foundation"
"-framework AppKit")
find_library(UT_FRAMEWORK UniformTypeIdentifiers)
if(UT_FRAMEWORK)
target_link_libraries(MediaRemoteAdapter ${UT_FRAMEWORK})
endif()
target_include_directories(MediaRemoteAdapter
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)
target_include_directories(MediaRemoteAdapter
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_compile_options(MediaRemoteAdapter PRIVATE "-fobjc-arc")
# Ensure that symbols are visible, otherwise the Perl script can't call into
# functions that are exported by the framework.
target_compile_options(MediaRemoteAdapter PRIVATE -fvisibility=default)
add_custom_command(
TARGET MediaRemoteAdapter
POST_BUILD
COMMAND codesign --force --deep --sign -
$<TARGET_BUNDLE_DIR:MediaRemoteAdapter>
COMMENT "Ad-hoc signing ${MEDIAREMOTEADAPTER_FRAMEWORK_NAME}.framework")
add_executable(MediaRemoteAdapterTestClient src/test/main.m
src/test/NowPlayingTest.m)
target_link_libraries(MediaRemoteAdapterTestClient
PRIVATE "-framework Foundation" "-framework MediaPlayer")
target_include_directories(MediaRemoteAdapterTestClient
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/test)
set_target_properties(MediaRemoteAdapterTestClient
PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
set_source_files_properties(src/test/main.m PROPERTIES COMPILE_FLAGS
"-fobjc-arc")
set_source_files_properties(src/test/NowPlayingTest.m PROPERTIES COMPILE_FLAGS
"-fobjc-arc")
set_target_properties(
MediaRemoteAdapterTestClient
PROPERTIES OUTPUT_NAME "${MEDIAREMOTEADAPTER_TEST_CLIENT_NAME}")

28
Vendor/mediaremote-adapter/LICENSE vendored Normal file
View File

@@ -0,0 +1,28 @@
BSD 3-Clause License
Copyright (c) 2025, Jonas van den Berg and contributors
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

10
Vendor/mediaremote-adapter/Makefile vendored Normal file
View File

@@ -0,0 +1,10 @@
.PHONY: all version
all:
version:
@system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'
@date
update-readme-badges:
python3 ./scripts/update-readme-badges.py

View File

@@ -0,0 +1 @@
3ac3d4bdf862c7b5399b4fba4df5689f5c38609a 2026-05-11 16:52:03 +0200

529
Vendor/mediaremote-adapter/README.md vendored Normal file
View File

@@ -0,0 +1,529 @@
> **@Apple**&ensp;*Before breaking this,
> please consider giving Mac users the option
> to share actively playing media with the apps they use
> and to control media playback.
> Perhaps by introducing a new entitlement
> that can be granted to apps by users in the system settings.
> There are
> [many](https://musicpresence.app)
> [use](https://folivora.ai)
> [cases](https://lyricfever.com)
> [for](https://theboring.name)
> [this](https://github.com/kirtan-shah/nowplaying-cli).*
> **@Developers**&ensp;*Please **star**
> this repository to show Apple that we care.*
---
<!-- BADGES BEGIN -->
![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600)
![](https://img.shields.io/static/v1?label=macOS&message=macOS%2026.0%20%2825A5316i%29&labelColor=444&color=blue)
![](https://img.shields.io/static/v1?label=last%20tested&message=Thu%20Jul%2024%2002%3A24%3A11%20CEST%202025&labelColor=444&color)
<!-- BADGES END -->
# MediaRemote Adapter
Get now playing information using the MediaRemote framework
on all macOS versions, including 15.4 and above.
This works by using a system binary &ndash; `/usr/bin/perl` in this case &ndash;
which is entitled to use the MediaRemote framework
and by dynamically loading a custom helper framework
that prints real-time updates to stdout.
## Example
Install the [media-control](https://github.com/ungive/media-control)
CLI tool to see this project in action. Works on all macOS versions:
```
$ brew tap ungive/media-control
$ brew install media-control
$ media-control stream
```
## Usage
This project provides a Perl script
with a well-defined CLI interface that you can invoke from your app
in order to read now playing information and control media players.
The [mediaremote-adapter.pl](./bin/mediaremote-adapter.pl) script
needs to be bundled with your app,
alongside the `MediaRemoteAdapter.framework`
and optionally the `MediaRemoteAdapterTestClient`
which are exposed as CMake targets in [CMakeLists.txt](./CMakeLists.txt).
You can find instructions to build the framework in the next section.
The script must then be invoked like this:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework COMMAND
```
`COMMAND` is a placeholder for one of the commands documented below.
For the `test` command the `NowPlayingTestCMediaRemoteAdapterTestClientlient` must be passed
as an additional argument:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
```
For ease of use you can also always pass the path to the test client:
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient COMMAND
```
For more help on available commands read below or omit the `COMMAND` argument.
> [!WARNING]
> This project is still in development
> and the API may experience breaking changes across minor revisions.
> [!NOTE]
> For a maintained Swift package look at this excellent fork:
> https://github.com/ejbills/mediaremote-adapter
## Build from source
```
$ git clone https://github.com/ungive/mediaremote-adapter.git
$ cd mediaremote-adapter
$ mkdir build && cd build
$ cmake ..
$ cmake --build .
$ cd ..
$ FRAMEWORK_PATH=$(realpath ./build/MediaRemoteAdapter.framework)
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" stream
```
This creates the `MediaRemoteAdapter.framework` in the build directory,
which must be *bundled* with your app, but *not linked against*.
The framework is only used by the script
and must merely be passed as a script argument.
If you want to be able to test whether the adapter still works,
which can be useful to e.g. automatically fall back to AppleScript,
you need to also bundle the `MediaRemoteAdapterTestClient` executable with your app
and pass it as an additional argument:
```
$ HELPER_PATH=$(realpath ./build/MediaRemoteAdapterTestClient)
$ /usr/bin/perl ./bin/mediaremote-adapter.pl "$FRAMEWORK_PATH" "$HELPER_PATH" test
```
An exit code of `0` then means the adapter is functional and safe to use.
The framework and test executable are built for the following architectures:
`x86_64` `arm64`
## Commands
- [get](#get)
- [stream](#stream)
- [send COMMAND](#send-command)
- [seek POSITION](#seek-position)
- [shuffle MODE](#shuffle-mode)
- [repeat MODE](#repeat-mode)
- [speed SPEED](#speed-speed)
- [test](#test)
### get
Prints now playing information once with all available metadata.
Output is encoded as JSON and characterized by either `null`
or a dictionary with any of the following keys:
> `bundleIdentifier`
`parentApplicationBundleIdentifier`
`playing`
`title`
`artist`
`album`
`duration`
`elapsedTime`
`timestamp`
`artworkMimeType`
`artworkData`
`chapterNumber`
`composer`
`genre`
`isAdvertisement`
`isBanned`
`isInWishList`
`isLiked`
`isMusicApp`
`playbackRate`
`prohibitsSkip`
`queueIndex`
`radioStationIdentifier`
`repeatMode`
`shuffleMode`
`startTime`
`supportsFastForward15Seconds`
`supportsIsBanned`
`supportsIsLiked`
`supportsRewind15Seconds`
`totalChapterCount`
`totalDiscCount`
`totalQueueCount`
`totalTrackCount`
`trackNumber`
`uniqueIdentifier`
`contentItemIdentifier`
`radioStationHash`
`mediaType`
The following mandatory keys never have a null value:
`bundleIdentifier`
`playing`
`title`.
If any of the mandatory keys cannot be determined,
the command prints `null`.
Media without a title is considered invalid.
The `mediaType` may contain one of the following values:
- `MRMediaRemoteMediaTypeMusic`
- `kMRMediaRemoteNowPlayingInfoTypeAudio`
- Possibly others, this key is not very well documented
**Caveats**
Metadata such as `artworkData` and `artworkMimeType`
often takes a bit of time to load
and may not appear in the output in all cases.
Do not rely on this key to be present reliably.
Either use the `stream` command or poll `get` regularly,
to ensure you get the artwork data *eventually*.
**Options**
`--now`&ensp;Adds an `elapsedTimeNow` key with an estimation of the current
elapsed playback time. This estimation may be off by up to a second.
To determine a more accurate time without polling `get` continuously,
calculate it using the `elapsedTime` and `timestamp` keys. `elapsedTime`
contains the elapsed time at the time that is stored in `timestamp`.
`--micros`&ensp;Replaces the following keys with microsecond equivalents:
| Original key | Converted key name | Comment |
|------------------|------------------------|---------------------------|
| `duration` | `durationMicros` | - |
| `elapsedTime` | `elapsedTimeMicros` | - |
| `elapsedTimeNow` | `elapsedTimeNowMicros` | Only present with `--now` |
| `timestamp` | `timestampEpochMicros` | Converted to epoch time |
`--no-artwork`&ensp;Omits the `artworkData` and `artworkMimeType` keys
from the payload. Useful for consumers that do not render artwork,
since this avoids emitting several hundred kilobytes of base64-encoded
image data per update.
---
### stream
Streams now playing information updates in real-time
until the script receives a SIGTERM signal.
Output is encoded as JSON and characterized by
a dictionary with the following keys:
> `type`
`diff`
`payload`
`type` is always a string with the value `"data"`.
`payload` contains the now playing information and is a dictionary
that is structurally identical to the output of the `get` command,
with the same keys. The dictionary itself is never `null`.
No keys are set at all,
when no media player is reporting now playing information.
Some keys may have a `null` value,
when the media player reports `null` for them (this happens rarely, if ever).
Any key may be `null`, when `diff` is set to true and the key vanishes.
`diff` is a boolean that indicates whether the `payload`
contains only fields whose values have been updated.
When set to `false`,
the payload is to be considered the current now playing state
with all available keys and their values,
regardless of any payloads that have been sent in the past.
When set to `true` on the other hand,
the last sent non-diff payload must be updated with these new values,
in order to have a representation of the the current now playing state.
When a key is not present anymore, it's set to `null` in the payload
and its previous value should be removed.
**Diffing is enabled by default, but can be disabled with a command line flag.**
**Options**
`--no-diff`&ensp;Disables diffing. `diff` is always `false`
and `payload` always contains all current information.
`--debounce=N`&ensp;Adds a debounce delay in milliseconds
between the point where changes are detected
and when they are printed.
If a new update comes in during delaying,
the delay is restarted and all updates are merged.
This is useful to prevent bursts of smaller updates.
The default is 0.
`--micros`&ensp;Identical to the `--micros` option of the `get` command.
`--no-artwork`&ensp;Identical to the `--no-artwork` option of the `get`
command. Particularly useful with `stream` with the `--no-diff` parameter,
since it otherwise re-emits the full artwork payload on every update.
**Experimental options**
`--experimental-peculiar-debounce:BUNDLE_ID=N`&ensp;
Adds a debounce delay in milliseconds for the case when the media player
with the given bundle identifier (`BUNDLE_ID`) reports metadata that
contains parts of the previous track and parts of the next track,
but doesn't contain the full metadata of the next track.
Whenever the track title changes,
the update for it is either delayed for the given debounce delay
or the update is printed when all other metadata updated as well,
whichever happens earlier.
Currently only `com.tidal.desktop` can be passed for `BUNDLE_ID`,
since it is the only media player that is known to have this issue.
A value of `1000` for `N` is recommended for TIDAL specifically.
---
### send COMMAND
Sends a MediaRemote command to the now playing application.
The value for `COMMAND` must be a valid ID from the table below.
| ID | MediaRemote key | Description |
|:--:|-------------------------|-------------------------------|
| 0 | kMRPlay | Start playback |
| 1 | kMRPause | Pause playback |
| 2 | kMRTogglePlayPause | Toggle between play and pause |
| 3 | kMRStop | Stop playback |
| 4 | kMRNextTrack | Skip to the next track |
| 5 | kMRPreviousTrack | Return to the previous track |
| 6 | kMRToggleShuffle | Toggle shuffle mode |
| 7 | kMRToggleRepeat | Toggle repeat mode |
| 8 | kMRStartForwardSeek | Start seeking forward |
| 9 | kMREndForwardSeek | Stop seeking forward |
| 10 | kMRStartBackwardSeek | Start seeking backward |
| 11 | kMREndBackwardSeek | Stop seeking backward |
| 12 | kMRGoBackFifteenSeconds | Go back 15 seconds |
| 13 | kMRSkipFifteenSeconds | Skip ahead 15 seconds |
---
### seek POSITION
Seeks to a specific timeline position with the now playing application.
The value for `POSITION` must a valid positive integer.
The unit is microseconds.
---
### shuffle MODE
Sets the shuffle mode.
The value for `MODE` must be a valid ID from the table below.
| ID | Description |
|:--:|----------------|
| 1 | Disable |
| 2 | Shuffle albums |
| 3 | Shuffle tracks |
---
### repeat MODE
Sets the repeat mode.
The value for `MODE` must be a valid ID from the table below.
| ID | Description |
|:--:|-----------------|
| 1 | Disable |
| 2 | Repeat track |
| 3 | Repeat playlist |
---
### speed SPEED
Sets the playback speed.
The value for `SPEED` must be a valid positive integer.
---
### test
Tests if the adapter is entitled to use the MediaRemote framework
and if it is able to execute any of the supported commands without failure.
An exit code of 0 means the adapter is functional and safe to use.
This can be integrated into your app
to help confirm that the adapter is still functional and if not,
fall back to other methods for media detection (e.g. AppleScript),
since future macOS updates may break MediaRemote access again.
#### Usage
```
/usr/bin/perl /path/to/mediaremote-adapter.pl /path/to/MediaRemoteAdapter.framework /path/to/MediaRemoteAdapterTestClient test
```
Note that the `test` command requires the absolute path
to the `MediaRemoteAdapterTestClient` executable after the framework path.
For ease of use you can always pass the path to the test client executable,
even when using other commands, like `get` or `stream`.
#### Output
An exit code of `0` indicates that the adapter is still functional
and can safely be used to detect media.
Any other exit indicates that the adapter is likely broken.
If you ever get an exit code other than `0`,
please [report this](https://github.com/ungive/mediaremote-adapter/issues). Thank you!
#### How this works
1. Now playing information is attempted to be read normally using `get`
2. If no media is detected, the `MediaRemoteAdapterTestClient` helper process is launched to simulate media playback
3. While the helper process is running, now playing information is attempted to be read again using `get`
4. Afterwards the helper process is terminated
5. If any of the `get` attempts yielded media information, the command exits with an exit code of `0`
6. Otherwise the command exits with an exit code of `1`
> [!WARNING]
> **May interfere with other apps using MediaRemote**
> The test can create a fake media entry that will briefly appear
as the now playing application.
This only happens when no other media is playing.
Since the helper process has no bundle identifier,
it is mostly ignored by the `stream` and `get` commands —
`stream` won't update, and `get` will print `null`.
---
## Built-in fixes
This library has some fixes built-in
to accomodate for inconsistencies within the MediaRemote framework:
- Artwork data sometimes unloads for a brief moment,
e.g. when changing the current timeline position of a track.
To combat this, artwork data is reused when the track has not changed,
the track had artwork data before and the artwork data has disappeared.
This fix is applied when using the `stream` command
If you need a way to disable any or all of these fixes,
please open an issue or create a pull request.
---
## Implementation notes
- Consider `NSJSONSerialization` for JSON deserialization.
This is what is used for encoding
- You can use `NSData`'s `initWithBase64EncodedString`
for decoding of base64 data
- Every line printed to stderr is an error message.
If the script did not exit with a non-zero exit code,
then any of these errors are non-fatal and can be safely ignored
- Other apps using MediaRemote Adapter may run `test` which should not interfere with the `stream` and `get` commands, but will generate a missing bundle identifier error message, which can be ignored. See the `test` command section for more information.
- You should not reinvoke the script when a fatal error occurs
(non-zero exit code)
- Make sure to pass the absolute path of the bundled framework and helper executable
as arguments and not a relative path
## Why this works
According to the findings by [@My-Iris](https://github.com/Mx-Iris) in
[this comment](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
processes with a bundle identifier starting with `com.apple.`
are granted permission to access the MediaRemote framework.
The Perl platform binary `/usr/bin/perl`
is reported as having the bundle identifier `com.apple.perl` (or a variation).
You can confirm this by streaming log messages using the Console.app
whilst running the script:
`default 14:44:55.871495+0200 mediaremoted Adding client <MRDMediaRemoteClient 0x15820b1a0, bundleIdentifier = com.apple.perl5, pid = 86889>`
## Motivation
This project was created due to the MediaRemote framework
being completely non-functional when being loaded directly from within an app,
starting with macOS 15.4 (see the numerous issues linked below).
The aim of this project is to provide a tool (and perhaps soon a full library)
that serves as a fully functional alternative to using MediaRemote directly
and perhaps to inspire Apple to give us a public API
to read now playing information and control media playback on the device
(see the note at the top of this file).
## Projects that use this library
- [Now Playing Display](https://www.gxlabs.co/now-playing/): A macOS "now playing" hardware display for the Seeed XIAO ESP32-C6 paired with the Seeed Round Display for XIAO. Shows album art, track info, progress, and playback controls on a 240x240 circular TFT — connected over USB. Source code: https://github.com/gxlabs/now-playing-device
- [Music Presence](https://musicpresence.app) is a cross-platform desktop application
for showing what you are listening to in your Discord status.
It uses this library since version [2.3.1](https://github.com/ungive/discord-music-presence/releases/tag/v2.3.1)
to detect media from all media players again. Website: https://musicpresence.app
- [media-control](https://github.com/ungive/media-control)
is a CLI tool to control and observe media playback on any macOS version.
You can install it directly via brew: `$ brew tap ungive/media-control && brew install media-control`. Source code: https://github.com/ungive/media-control
*If you use this library in your project, please
[let me know](https://github.com/ungive/mediaremote-adapter/issues) and I'll add it to the list!*
## Useful links
- Issues regarding MediaRemote breaking since macOS 15.4
- https://github.com/vincentneo/LosslessSwitcher/issues/161
- https://github.com/aviwad/LyricFever/issues/94
- https://github.com/TheBoredTeam/boring.notch/issues/417
- https://community.folivora.ai/t/now-playing-is-no-longer-working-on-macos-15-4/42802/11
- https://github.com/ungive/discord-music-presence/issues/165
- https://github.com/ungive/discord-music-presence/issues/245
- https://github.com/kirtan-shah/nowplaying-cli/issues/28
- https://github.com/FelixKratz/SketchyBar/issues/708
- Getting now playing information using `osascript` and `MRNowPlayingRequest`.
Note that this is unable to load the song artwork
and it is impossible to get real-time updates with this solution.
It is much simpler to implement though
- https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800
- https://github.com/fastfetch-cli/fastfetch/commit/1557f0c5564a8288604824e55db47508f65e82c9
- https://gist.github.com/SKaplanOfficial/f9f5bdd6455436203d0d318c078358de
## Acknowledgements
Thank you [@Alexander5015](https://github.com/Alexander5015) for implementing the `test` command,
so we're able to detect when the adapter stops working!
Thank you [@EinTim23](https://github.com/EinTim23) for bringing
a [similar workaround](https://github.com/EinTim23/PlayerLink/commit/9821b6a294873f975852f06419a0baf2fe404800) to my attention!
Without your hint I most likely would not have dug into this anytime soon
and my app [Music Presence](https://musicpresence.app)
would still only work with AppleScript automation.
Thank you [@My-Iris](https://github.com/Mx-Iris)
for providing insight into the changes made since macOS 15.4:
[aviwad/LyricFever#94](https://github.com/aviwad/LyricFever/issues/94#issuecomment-2746155419)
## License
This project is licensed under the BSD 3-Clause License.
See [LICENSE](./LICENSE) for details.
Copyright (c) 2025 Jonas van den Berg and contributors

View File

@@ -0,0 +1,280 @@
#!/usr/bin/perl
# Copyright (c) 2025 Jonas van den Berg
# This file is licensed under the BSD 3-Clause License.
# For usage information read below or run the script without arguments.
use strict;
use warnings;
use DynaLoader;
use File::Spec;
use File::Basename;
sub print_help() {
print <<'HELP';
Usage:
mediaremote-adapter.pl FRAMEWORK_PATH [TEST_CLIENT_PATH]
[FUNCTION [PARAMS|OPTIONS...]]
FRAMEWORK_PATH:
Absolute path to the MediaRemoteAdapter.framework directory
TEST_CLIENT_PATH: (optional)
Absolute path to the MediaRemoteAdapterTestClient executable. Only for "test"
FUNCTION:
stream Streams now playing information (as diff by default)
get Prints now playing information once with all available metadata
send Sends a command to the now playing application
seek Seeks to a specific timeline position
shuffle Sets the shuffle mode
repeat Sets the repeat mode
speed Sets the playback speed
test Tests if the adapter is entitled to use the MediaRemote framework.
An exit code other than 0 indicates the adapter is non-functional
PARAMS:
send(command)
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
seek(position)
position: The timeline position in microseconds
shuffle(mode)
mode: The shuffle mode
repeat(mode)
mode: The repeat mode
speed(speed)
speed: The playback speed
OPTIONS:
get
--now: Adds an "elapsedTimeNow" key with an estimation of the current
elapsed playback time. This estimation may be off by up to a second.
To determine a more accurate time without polling "get" continuously,
calculate it using the "elapsedTime" and "timestamp" keys. "elapsedTime"
contains the elapsed time at the time that is stored in "timestamp".
stream
--no-diff: Disable diffing and always dump all metadata
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
get, stream
--micros: Replaces the following time keys with microsecond equivalents:
"duration" -> "durationMicros"
"elapsedTime" -> "elapsedTimeMicros"
"elapsedTimeNow" -> "elapsedTimeNowMicros"
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
--no-artwork: Omits "artworkData" and "artworkMimeType" from the payload.
Useful for consumers that do not render artwork, since this avoids
emitting several hundred kilobytes of base64 data per update.
--human-readable, -h: Makes values human-readable. Use only for debugging.
The JSON output is pretty-printed and the following keys are adapted:
"artworkData" -> Binary data is truncated to a shorter representation
Examples (script name and framework path omitted):
stream --no-diff --debounce=100
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
HELP
exit 0;
}
if (!defined $ARGV[1]) {
print_help();
}
sub fail {
my ($error) = @_;
print STDERR "$error\n";
exit 1;
}
fail "Framework path not provided" unless @ARGV >= 1;
my $framework_path = shift @ARGV;
# Optionally accept MEDIAREMOTEADAPTER_TEST_CLIENT_PATH path as second argument
my $maybe_helper_path = $ARGV[0] // '';
if ($maybe_helper_path =~ m{/}){
my $helper_path = shift @ARGV;
$ENV{MEDIAREMOTEADAPTER_TEST_CLIENT_PATH} = $helper_path;
}
if (!defined $ARGV[0]) {
print_help();
}
my $framework_basename = File::Basename::basename($framework_path);
fail "Provided path is not a framework: $framework_path"
unless $framework_basename =~ s/\.framework$//;
my $framework = File::Spec->catfile($framework_path, $framework_basename);
fail "Framework not found at $framework" unless -e $framework;
my $handle = DynaLoader::dl_load_file($framework, 0)
or fail "Failed to load framework: $framework";
my $function_name = shift @ARGV or fail "Missing function name";
fail "Invalid function name: '$function_name'"
unless $function_name eq "stream"
|| $function_name eq "get"
|| $function_name eq "send"
|| $function_name eq "seek"
|| $function_name eq "shuffle"
|| $function_name eq "repeat"
|| $function_name eq "speed"
|| $function_name eq "test";
sub parse_options {
my ($start_index) = @_;
my %arg_map;
my $i = $start_index;
while ($i <= $#ARGV) {
my $arg = $ARGV[$i];
if ($arg =~ /^--([a-z:\.\\-]+)(?:=(.*))?$/) {
my $key = $1;
my $value = defined $2 ? $2 : undef;
$arg_map{$key} = $value;
splice @ARGV, $i, 1;
}
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
my @flags = split //, $1;
$arg_map{$_} = undef for @flags;
splice @ARGV, $i, 1;
}
else {
$i++;
}
}
return \%arg_map;
}
sub env_func {
my $symbol_name = shift;
return "${symbol_name}_env";
}
sub set_env_param {
my ($func, $index, $name, $value) = @_;
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
}
sub set_env_option_unsafe {
my ($name, $value) = @_;
$name =~ s/-/_/g;
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
}
sub set_env_option {
my ($options, $key) = @_;
my $value = $options->{$key};
if (defined $value) {
fail "Unexpected value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
sub set_env_option_value {
my ($options, $key) = @_;
my $value = $options->{$key};
if (!defined $value) {
fail "Missing value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
my $symbol_name = "adapter_$function_name";
if ($function_name eq "send") {
my $id = shift @ARGV;
fail "Missing ID for '$function_name' command" unless defined $id;
set_env_param($symbol_name, 0, "command", "$id");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "stream") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "no-diff") {
set_env_option($options, $key);
}
elsif ($key eq "debounce") {
set_env_option_value($options, $key);
}
elsif ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "no-artwork") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "experimental-peculiar-debounce:com.tidal.desktop") {
set_env_option_value($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "get") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "no-artwork") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "now") {
set_env_option($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "seek") {
my $position = shift @ARGV;
fail "Missing position for '$function_name' command" unless defined $position;
set_env_param($symbol_name, 0, "position", "$position");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "shuffle") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "repeat") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "speed") {
my $speed = shift @ARGV;
fail "Missing speed for '$function_name' command" unless defined $speed;
set_env_param($symbol_name, 0, "speed", "$speed");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "test") {
$symbol_name = "adapter_test";
}
if (defined shift @ARGV) {
fail "Too many arguments";
}
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
or fail "Symbol '$symbol_name' not found in $framework";
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
eval {
no strict "refs";
&{"main::$function_name"}();
};
if ($@) {
fail "Error executing $function_name: $@";
}

View File

@@ -0,0 +1,126 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_H
#define MEDIAREMOTEADAPTER_ADAPTER_H
#import <Foundation/Foundation.h>
// Methods suffixed with "_env" read its parameters from the environment.
// Parameters must have the format:
// MEDIAREMOTEADAPTER_<FUNC_NAME>_<PARAM_INDEX>_<PARAM_NAME>
// Example: MEDIAREMOTEADAPTER_adapter_send_0_command
extern NSString *kMRAProcessIdentifier;
extern NSString *kMRABundleIdentifier;
extern NSString *kMRAParentApplicationBundleIdentifier;
extern NSString *kMRAPlaying;
extern NSString *kMRADurationMicros;
extern NSString *kMRAElapsedTimeMicros;
extern NSString *kMRATimestampEpochMicros;
extern NSString *kMRAElapsedTimeNow;
extern NSString *kMRAElapsedTimeNowMicros;
extern NSString *kMRATitle;
extern NSString *kMRAArtist;
extern NSString *kMRAAlbum;
extern NSString *kMRADuration;
extern NSString *kMRAElapsedTime;
extern NSString *kMRATimestamp;
extern NSString *kMRAArtworkMimeType;
extern NSString *kMRAArtworkData;
extern NSString *kMRAChapterNumber;
extern NSString *kMRAComposer;
extern NSString *kMRAGenre;
extern NSString *kMRAIsAdvertisement;
extern NSString *kMRAIsBanned;
extern NSString *kMRAIsInWishList;
extern NSString *kMRAIsLiked;
extern NSString *kMRAIsMusicApp;
extern NSString *kMRAPlaybackRate;
extern NSString *kMRAProhibitsSkip;
extern NSString *kMRAQueueIndex;
extern NSString *kMRARadioStationIdentifier;
extern NSString *kMRARepeatMode;
extern NSString *kMRAShuffleMode;
extern NSString *kMRAStartTime;
extern NSString *kMRASupportsFastForward15Seconds;
extern NSString *kMRASupportsIsBanned;
extern NSString *kMRASupportsIsLiked;
extern NSString *kMRASupportsRewind15Seconds;
extern NSString *kMRATotalChapterCount;
extern NSString *kMRATotalDiscCount;
extern NSString *kMRATotalQueueCount;
extern NSString *kMRATotalTrackCount;
extern NSString *kMRATrackNumber;
extern NSString *kMRAUniqueIdentifier;
extern NSString *kMRAContentItemIdentifier;
extern NSString *kMRARadioStationHash;
extern NSString *kMRAMediaType;
// Prints the current MediaRemote now playing information to stdout.
// Data is encoded as a JSON dictionary or "null" when there is no information.
extern void adapter_get();
extern void adapter_get_env();
// Streams MediaRemote now playing updates to stdout.
// Each update is printed on a separate lined, encoded as a JSON dictionary.
// Exits when the process receives a SIGTERM signal.
extern void adapter_stream();
extern void adapter_stream_env();
typedef enum {
kMRAPlay = 0,
kMRAPause = 1,
kMRATogglePlayPause = 2,
kMRAStop = 3,
kMRANextTrack = 4,
kMRAPreviousTrack = 5,
kMRAToggleShuffle = 6,
kMRAToggleRepeat = 7,
kMRAStartForwardSeek = 8,
kMRAEndForwardSeek = 9,
kMRAStartBackwardSeek = 10,
kMRAEndBackwardSeek = 11,
kMRAGoBackFifteenSeconds = 12,
kMRASkipFifteenSeconds = 13,
} MRACommand;
// Sends the given MediaRemote command to the current now playing application.
extern void adapter_send(MRACommand command);
extern void adapter_send_env();
// Seeks the timeline of the nowplaying application to the given position.
// The position must be given in microseconds.
extern void adapter_seek(long position);
extern void adapter_seek_env();
typedef enum {
kMRAShuffleDisabled = 1,
kMRAShuffleAlbums = 2,
kMRAShuffleTracks = 3,
} MRAShuffleMode;
extern void adapter_shuffle(MRAShuffleMode mode);
extern void adapter_shuffle_env();
typedef enum {
kMRARepeatDisabled = 1,
kMRARepeatTrack = 2,
kMRARepeatPlaylist = 3,
} MRARepeatMode;
extern void adapter_repeat(MRARepeatMode mode);
extern void adapter_repeat_env();
extern void adapter_speed(int speed);
extern void adapter_speed_env();
// Tests whether the process is entitled to use the MediaRemote framework.
// Exits with exit code 0, if it is. Any other exit code means it is not.
extern void adapter_test();
#endif // MEDIAREMOTEADAPTER_ADAPTER_H

View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import subprocess
import urllib.parse
import os
import re
STATIC_BADGES = """
![](https://img.shields.io/github/stars/ungive/mediaremote-adapter?style=flat&label=stars&logo=github&labelColor=444&color=DAAA3F&cacheSeconds=3600)
"""
def get_output(command):
result = subprocess.run(
command,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
return result.stdout.strip()
def normalize_whitespace(text):
return re.sub(r'\s+', ' ', text.strip())
def generate_badges():
system_version = get_output(
"system_profiler SPSoftwareDataType | sed -En 's/.*System Version: *//p'"
)
date_output = normalize_whitespace(get_output("date"))
encoded_system_version = urllib.parse.quote(system_version)
encoded_date = urllib.parse.quote(date_output)
badge1 = f"![](https://img.shields.io/static/v1?label=macOS&message={encoded_system_version}&labelColor=444&color=blue)"
badge2 = f"![](https://img.shields.io/static/v1?label=last%20tested&message={encoded_date}&labelColor=444&color)"
return f"{STATIC_BADGES.strip()}\n{badge1}\n{badge2}"
def update_readme(script_dir, new_badges):
readme_path = os.path.join(script_dir, "..", "README.md")
with open(readme_path, "r") as file:
content = file.read()
new_content = re.sub(
r"<!-- BADGES BEGIN -->.*?<!-- BADGES END -->",
f"<!-- BADGES BEGIN -->\n{new_badges}\n<!-- BADGES END -->",
content,
flags=re.DOTALL,
)
with open(readme_path, "w") as file:
file.write(new_content)
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
badges = generate_badges()
update_readme(script_dir, badges)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,25 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_ENV_H
#define MEDIAREMOTEADAPTER_ADAPTER_ENV_H
#import <Foundation/Foundation.h>
NSString *getEnvValue(NSString *name);
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
NSString *param_name);
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
NSString *param_name);
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
NSString *param_name);
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
NSString *param_name);
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
NSString *param_name);
NSString *getEnvOption(NSString *option_name);
NSNumber *getEnvOptionInt(NSString *option_name);
#endif // MEDIAREMOTEADAPTER_ADAPTER_ENV_H

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "env.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "utility/helpers.h"
static NSNumber *parseIntegerOrNil(NSString *str) {
if (str == nil)
return nil;
NSScanner *scanner = [NSScanner scannerWithString:str];
NSInteger value;
if ([scanner scanInteger:&value] && [scanner isAtEnd]) {
return @(value);
} else {
return nil;
}
}
NSString *getEnvValue(NSString *name) {
NSDictionary *env = [[NSProcessInfo processInfo] environment];
return env[[name stringByReplacingOccurrencesOfString:@"-"
withString:@"_"]];
}
NSString *getEnvFuncParam(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *envVarName =
[NSString stringWithFormat:@"MEDIAREMOTEADAPTER_PARAM_%@_%d_%@",
func_name, param_pos, param_name];
return getEnvValue(envVarName);
}
NSString *getEnvFuncParamSafe(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *result = getEnvFuncParam(func_name, param_pos, param_name);
if (result == nil) {
failf(@"Missing parameter '%@' for "
@"function '%@' at position %d",
param_name, func_name, param_pos);
}
return result;
}
NSNumber *getEnvFuncParamInt(NSString *func_name, int param_pos,
NSString *param_name) {
return parseIntegerOrNil(getEnvFuncParam(func_name, param_pos, param_name));
}
long getEnvFuncParamLongSafe(NSString *func_name, int param_pos,
NSString *param_name) {
NSString *raw = getEnvFuncParam(func_name, param_pos, param_name);
if (raw == nil) {
failf(@"Missing parameter '%@' for "
@"function '%@' at position %d",
param_name, func_name, param_pos);
}
NSNumber *result = parseIntegerOrNil(raw);
if (result == nil) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is not an integer: '%@'",
param_name, func_name, param_pos, raw);
}
if ([result longLongValue] > [result longValue]) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too large to fit into a "
@"long integer: %@",
param_name, func_name, param_pos, raw);
}
return [result longValue];
}
int getEnvFuncParamIntSafe(NSString *func_name, int param_pos,
NSString *param_name) {
long value = getEnvFuncParamLongSafe(func_name, param_pos, param_name);
if (value > INT_MAX) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too large to fit into an "
@"integer: %ld",
param_name, func_name, param_pos, value);
}
if (value < INT_MIN) {
failf(@"Parameter '%@' for "
@"function '%@' at position %d is too small to fit into an "
@"integer: %ld",
param_name, func_name, param_pos, value);
}
return (int)value;
}
NSString *getEnvOption(NSString *option_name) {
NSString *envVarName = [NSString
stringWithFormat:@"MEDIAREMOTEADAPTER_OPTION_%@", option_name];
return getEnvValue(envVarName);
}
NSNumber *getEnvOptionInt(NSString *option_name) {
return parseIntegerOrNil(getEnvOption(option_name));
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GET_H
#define MEDIAREMOTEADAPTER_ADAPTER_GET_H
#import <Foundation/Foundation.h>
NSDictionary *internal_get(BOOL isTestMode);
#endif // MEDIAREMOTEADAPTER_ADAPTER_GET_H

View File

@@ -0,0 +1,146 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include <Foundation/Foundation.h>
#import <dispatch/dispatch.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/get.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
#define GET_TIMEOUT_MILLIS 2000
#define JSON_NULL @"null"
NSDictionary *internal_get(BOOL isTestMode) {
NSString *micros_option = getEnvOption(@"micros");
__block const bool convert_micros = micros_option != nil;
NSString *human_readable_option = getEnvOption(@"human-readable");
__block const bool human_readable = human_readable_option != nil;
NSString *now_option = getEnvOption(@"now");
__block const bool calculate_now = now_option != nil;
NSString *no_artwork_option = getEnvOption(@"no-artwork");
const bool no_artwork = no_artwork_option != nil;
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
__block BOOL isFromTestClient = NO;
dispatch_group_t group = dispatch_group_create();
// PID and Bundle Identifier
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
if (pid != 0) {
liveData[kMRAProcessIdentifier] = @(pid);
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
if (process.bundleIdentifier != nil) {
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
}
dispatch_group_leave(group);
});
if (!ok) {
dispatch_group_leave(group);
}
} else {
dispatch_group_leave(group);
}
});
// Now Playing Client
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
NSString *parentAppBundleID = nil;
if (client && [client respondsToSelector:@selector
(parentApplicationBundleIdentifier)]) {
parentAppBundleID = [client
performSelector:@selector(parentApplicationBundleIdentifier)];
}
if (parentAppBundleID) {
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
}
dispatch_group_leave(group);
});
// Is Playing
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingApplicationIsPlaying(
g_serialdispatchQueue, ^(bool isPlaying) {
liveData[kMRAPlaying] = @(isPlaying);
dispatch_group_leave(group);
});
dispatch_group_enter(group);
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
NSDictionary *information) {
NSString *serviceIdentifier =
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
if (!isTestMode &&
[serviceIdentifier
isEqualToString:@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
isFromTestClient = YES;
dispatch_group_leave(group);
return;
}
NSDictionary *converted = convertNowPlayingInformation(
information, convert_micros, calculate_now, no_artwork);
[liveData addEntriesFromDictionary:converted];
dispatch_group_leave(group);
});
// Wait for all async callbacks or timeout
dispatch_time_t timeout =
dispatch_time(DISPATCH_TIME_NOW, GET_TIMEOUT_MILLIS * NSEC_PER_MSEC);
long result = dispatch_group_wait(group, timeout);
if (result != 0) {
printErrf(
@"Reading now playing information timed out after %d milliseconds",
GET_TIMEOUT_MILLIS);
return nil;
}
if (isFromTestClient) {
return nil;
}
if (human_readable) {
makePayloadHumanReadable(liveData);
}
if (!allMandatoryPayloadKeysSet(liveData)) {
return nil;
}
return liveData;
}
void adapter_get() {
NSDictionary *liveData = internal_get(NO);
NSString *micros_option = getEnvOption(@"micros");
const bool convert_micros = micros_option != nil;
NSString *human_readable_option = getEnvOption(@"human-readable");
const bool human_readable = human_readable_option != nil;
NSString *resultStr = nil;
if (!liveData) {
resultStr = JSON_NULL;
} else {
resultStr = serializeJsonDictionarySafe(liveData, human_readable);
if (!resultStr) {
fail(@"Failed to serialize now playing information");
}
}
printOut(resultStr);
}
extern void adapter_get_env() { adapter_get(); }

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
#define MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H
#import <CoreFoundation/CoreFoundation.h>
#import <dispatch/dispatch.h>
#import "private/MediaRemote.h"
extern MediaRemote* g_mediaRemote;
extern dispatch_queue_t g_serialdispatchQueue;
#endif // MEDIAREMOTEADAPTER_ADAPTER_GLOBALS_H

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "globals.h"
#import "utility/helpers.h"
MediaRemote *g_mediaRemote = NULL;
dispatch_queue_t g_serialdispatchQueue;
__attribute__((constructor)) static void initGlobals() {
g_mediaRemote = [[MediaRemote alloc] init];
if (!g_mediaRemote) {
fail(@"Failed to initialize MediaRemote Framework");
return;
}
g_serialdispatchQueue = dispatch_queue_create(
"mediaremote-adapter.serial-dispatch-queue", DISPATCH_QUEUE_SERIAL);
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
#define MEDIAREMOTEADAPTER_ADAPTER_KEYS_H
#import <Foundation/Foundation.h>
// These keys are mandatory and must never be null, empty or missing.
NSArray<NSString *> *mandatoryPayloadKeys(void);
// Checks whether all mandatory payload keys returned by mandatoryPayloadKeys()
// are present in the given payload dictionary and have a non-null value.
bool allMandatoryPayloadKeysSet(NSDictionary *data);
// These keys identify a now playing item uniquely.
NSArray<NSString *> *identifyingPayloadKeys(void);
#endif // MEDIAREMOTEADAPTER_ADAPTER_KEYS_H

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "keys.h"
#import "MediaRemoteAdapter.h"
NSString *kMRAProcessIdentifier = @"processIdentifier";
NSString *kMRABundleIdentifier = @"bundleIdentifier";
NSString *kMRAParentApplicationBundleIdentifier =
@"parentApplicationBundleIdentifier";
NSString *kMRAPlaying = @"playing";
NSString *kMRADurationMicros = @"durationMicros";
NSString *kMRAElapsedTimeMicros = @"elapsedTimeMicros";
NSString *kMRATimestampEpochMicros = @"timestampEpochMicros";
NSString *kMRAElapsedTimeNow = @"elapsedTimeNow";
NSString *kMRAElapsedTimeNowMicros = @"elapsedTimeNowMicros";
NSString *kMRATitle = @"title";
NSString *kMRAArtist = @"artist";
NSString *kMRAAlbum = @"album";
NSString *kMRADuration = @"duration";
NSString *kMRAElapsedTime = @"elapsedTime";
NSString *kMRATimestamp = @"timestamp";
NSString *kMRAArtworkMimeType = @"artworkMimeType";
NSString *kMRAArtworkData = @"artworkData";
NSString *kMRAChapterNumber = @"chapterNumber";
NSString *kMRAComposer = @"composer";
NSString *kMRAGenre = @"genre";
NSString *kMRAIsAdvertisement = @"isAdvertisement";
NSString *kMRAIsBanned = @"isBanned";
NSString *kMRAIsInWishList = @"isInWishList";
NSString *kMRAIsLiked = @"isLiked";
NSString *kMRAIsMusicApp = @"isMusicApp";
NSString *kMRAPlaybackRate = @"playbackRate";
NSString *kMRAProhibitsSkip = @"prohibitsSkip";
NSString *kMRAQueueIndex = @"queueIndex";
NSString *kMRARadioStationIdentifier = @"radioStationIdentifier";
NSString *kMRARepeatMode = @"repeatMode";
NSString *kMRAShuffleMode = @"shuffleMode";
NSString *kMRAStartTime = @"startTime";
NSString *kMRASupportsFastForward15Seconds = @"supportsFastForward15Seconds";
NSString *kMRASupportsIsBanned = @"supportsIsBanned";
NSString *kMRASupportsIsLiked = @"supportsIsLiked";
NSString *kMRASupportsRewind15Seconds = @"supportsRewind15Seconds";
NSString *kMRATotalChapterCount = @"totalChapterCount";
NSString *kMRATotalDiscCount = @"totalDiscCount";
NSString *kMRATotalQueueCount = @"totalQueueCount";
NSString *kMRATotalTrackCount = @"totalTrackCount";
NSString *kMRATrackNumber = @"trackNumber";
NSString *kMRAUniqueIdentifier = @"uniqueIdentifier";
NSString *kMRAContentItemIdentifier = @"contentItemIdentifier";
NSString *kMRARadioStationHash = @"radioStationHash";
NSString *kMRAMediaType = @"mediaType";
NSArray<NSString *> *mandatoryPayloadKeys(void) {
return @[ kMRAProcessIdentifier, kMRATitle, kMRAPlaying ];
}
bool allMandatoryPayloadKeysSet(NSDictionary *data) {
NSArray<NSString *> *keys = mandatoryPayloadKeys();
for (NSString *key in keys) {
if (data[key] == nil || data[key] == [NSNull null]) {
return false;
}
id value = data[key];
if ([value isKindOfClass:[NSString class]] &&
[(NSString *)value length] == 0) {
return false;
}
}
return true;
}
NSArray<NSString *> *identifyingPayloadKeys(void) {
return @[
kMRAProcessIdentifier, kMRABundleIdentifier,
kMRAParentApplicationBundleIdentifier, kMRATitle, kMRAArtist, kMRAAlbum
];
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
#define MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H
#import <Foundation/Foundation.h>
// Requests information once so that the process runs long enough for the
// MediaRemote command to actually be sent to the now playing application.
void waitForCommandCompletion();
// Converts raw MediaRemote now playing information to adapter keys.
// Optionally replaces keys with time values with microseconds equivalents.
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
bool convertMicros,
bool calculateNow,
bool withoutArtwork);
#endif // MEDIAREMOTEADAPTER_UTILITY_NOW_PLAYING_H

View File

@@ -0,0 +1,201 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "now_playing.h"
#import "MediaRemoteAdapter.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "private/MediaRemote.h"
#define WAIT_TIMEOUT_MILLIS 2000
void waitForCommandCompletion() {
id semaphore = dispatch_semaphore_create(0);
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
dispatch_semaphore_signal(semaphore);
});
dispatch_time_t timeout =
dispatch_time(DISPATCH_TIME_NOW, WAIT_TIMEOUT_MILLIS * NSEC_PER_MSEC);
dispatch_semaphore_wait(semaphore, timeout);
}
NSNumber *getElapsedTimeNow(NSDictionary *information) {
id elapsed = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (![elapsed isKindOfClass:[NSNumber class]]) {
return nil;
}
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
if (![timestamp isKindOfClass:[NSDate class]]) {
return elapsed;
}
NSTimeInterval timestampEpoch = [(NSDate *)timestamp timeIntervalSince1970];
NSTimeInterval currentEpoch = [[NSDate date] timeIntervalSince1970];
NSTimeInterval timeDiff = currentEpoch - timestampEpoch;
double playbackRate = 0;
id playbackRateVal = information[kMRMediaRemoteNowPlayingInfoPlaybackRate];
if ([playbackRateVal isKindOfClass:[NSNumber class]]) {
playbackRate = [(NSNumber *)playbackRateVal doubleValue];
}
double realElapsed = [(NSNumber *)elapsed doubleValue];
if (playbackRate >= 0) {
realElapsed += timeDiff * playbackRate;
}
return @(realElapsed);
}
NSMutableDictionary *convertNowPlayingInformation(NSDictionary *information,
bool convertMicros,
bool calculateNow,
bool withoutArtwork) {
NSMutableDictionary *data = [NSMutableDictionary dictionary];
void (^setKey)(id, id) = ^(id key, id fromKey) {
id value = nil;
if (information != nil) {
id result = information[fromKey];
if (result != nil) {
value = result;
}
}
if (value != nil) {
[data setObject:value forKey:key];
}
};
void (^setValue)(id key, id (^)(void)) = ^(id key, id (^evaluate)(void)) {
id value = nil;
if (information != nil) {
value = evaluate();
}
if (value != nil) {
[data setObject:value forKey:key];
}
};
setKey(kMRATitle, kMRMediaRemoteNowPlayingInfoTitle);
setKey(kMRAArtist, kMRMediaRemoteNowPlayingInfoArtist);
setKey(kMRAAlbum, kMRMediaRemoteNowPlayingInfoAlbum);
if (!withoutArtwork) {
setKey(kMRAArtworkMimeType,
kMRMediaRemoteNowPlayingInfoArtworkMIMEType);
setKey(kMRAArtworkData, kMRMediaRemoteNowPlayingInfoArtworkData);
}
if (!convertMicros) {
setKey(kMRADuration, kMRMediaRemoteNowPlayingInfoDuration);
setKey(kMRAElapsedTime, kMRMediaRemoteNowPlayingInfoElapsedTime);
setKey(kMRATimestamp, kMRMediaRemoteNowPlayingInfoTimestamp);
if (calculateNow) {
// This key is added and does not replace the original because it
// is just a rough estimation, meant to be used for convenience.
setValue(kMRAElapsedTimeNow, ^id {
id elapsedTime =
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
id nowValue = getElapsedTimeNow(information);
if (nowValue != nil) {
elapsedTime = nowValue;
}
return elapsedTime;
});
}
} else {
// These keys replace their original counterparts because semantics
// don't change and no accuracy is lost, merely the time unit changes.
setValue(kMRADurationMicros, ^id {
id duration = information[kMRMediaRemoteNowPlayingInfoDuration];
if (duration != nil && [duration isKindOfClass:[NSNumber class]]) {
NSTimeInterval durationMicros =
[duration doubleValue] * 1000 * 1000;
return @(floor(durationMicros));
}
return nil;
});
setValue(kMRAElapsedTimeMicros, ^id {
id elapsedTime = information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (elapsedTime != nil &&
[elapsedTime isKindOfClass:[NSNumber class]]) {
NSTimeInterval elapsedTimeMicros =
[elapsedTime doubleValue] * 1000 * 1000;
return @(floor(elapsedTimeMicros));
}
return nil;
});
if (calculateNow) {
// This key is added and does not replace the original because it
// is just a rough estimation, meant to be used for convenience.
setValue(kMRAElapsedTimeNowMicros, ^id {
id elapsedTime =
information[kMRMediaRemoteNowPlayingInfoElapsedTime];
if (calculateNow) {
elapsedTime = getElapsedTimeNow(information);
}
if (elapsedTime != nil &&
[elapsedTime isKindOfClass:[NSNumber class]]) {
NSTimeInterval elapsedTimeMicros =
[elapsedTime doubleValue] * 1000 * 1000;
return @(floor(elapsedTimeMicros));
}
return nil;
});
}
setValue(kMRATimestampEpochMicros, ^id {
id timestamp = information[kMRMediaRemoteNowPlayingInfoTimestamp];
if (timestamp != nil && [timestamp isKindOfClass:[NSDate class]]) {
NSTimeInterval timestampEpoch = [timestamp timeIntervalSince1970];
NSTimeInterval timestampEpochMicro = timestampEpoch * 1000 * 1000;
return @(floor(timestampEpochMicro));
}
return nil;
});
}
// Some of the following keys might fail due to not being convertible
// to JSON automatically. This is difficult to test because most media
// players do not even set these keys and the data types are not documented
// anywhere. Still, JSON serialization of the resulting dictionary deletes
// any invalid keys, converts or deletes invalid values and prints error
// messages whenever any dictionary entry has been removed. Users should
// report whenever they encounter such an error with these keys.
// clang-format off
setKey(kMRAChapterNumber, kMRMediaRemoteNowPlayingInfoChapterNumber);
setKey(kMRAComposer, kMRMediaRemoteNowPlayingInfoComposer);
setKey(kMRAGenre, kMRMediaRemoteNowPlayingInfoGenre);
setKey(kMRAIsAdvertisement, kMRMediaRemoteNowPlayingInfoIsAdvertisement);
setKey(kMRAIsBanned, kMRMediaRemoteNowPlayingInfoIsBanned);
setKey(kMRAIsInWishList, kMRMediaRemoteNowPlayingInfoIsInWishList);
setKey(kMRAIsLiked, kMRMediaRemoteNowPlayingInfoIsLiked);
setKey(kMRAIsMusicApp, kMRMediaRemoteNowPlayingInfoIsMusicApp);
setKey(kMRAPlaybackRate, kMRMediaRemoteNowPlayingInfoPlaybackRate);
setKey(kMRAProhibitsSkip, kMRMediaRemoteNowPlayingInfoProhibitsSkip);
setKey(kMRAQueueIndex, kMRMediaRemoteNowPlayingInfoQueueIndex);
setKey(kMRARadioStationIdentifier, kMRMediaRemoteNowPlayingInfoRadioStationIdentifier);
setKey(kMRARepeatMode, kMRMediaRemoteNowPlayingInfoRepeatMode);
setKey(kMRAShuffleMode, kMRMediaRemoteNowPlayingInfoShuffleMode);
setKey(kMRAStartTime, kMRMediaRemoteNowPlayingInfoStartTime);
setKey(kMRASupportsFastForward15Seconds, kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds);
setKey(kMRASupportsIsBanned, kMRMediaRemoteNowPlayingInfoSupportsIsBanned);
setKey(kMRASupportsIsLiked, kMRMediaRemoteNowPlayingInfoSupportsIsLiked);
setKey(kMRASupportsRewind15Seconds, kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds);
setKey(kMRATotalChapterCount, kMRMediaRemoteNowPlayingInfoTotalChapterCount);
setKey(kMRATotalDiscCount, kMRMediaRemoteNowPlayingInfoTotalDiscCount);
setKey(kMRATotalQueueCount, kMRMediaRemoteNowPlayingInfoTotalQueueCount);
setKey(kMRATotalTrackCount, kMRMediaRemoteNowPlayingInfoTotalTrackCount);
setKey(kMRATrackNumber, kMRMediaRemoteNowPlayingInfoTrackNumber);
setKey(kMRAUniqueIdentifier, kMRMediaRemoteNowPlayingInfoUniqueIdentifier);
setKey(kMRAContentItemIdentifier, kMRMediaRemoteNowPlayingInfoContentItemIdentifier);
setKey(kMRARadioStationHash, kMRMediaRemoteNowPlayingInfoRadioStationHash);
setKey(kMRAMediaType, kMRMediaRemoteNowPlayingInfoMediaType);
// clang-format on
return data;
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedModes;
__attribute__((constructor)) static void init() {
acceptedModes = @[
@(kMRARepeatDisabled),
@(kMRARepeatTrack),
@(kMRARepeatPlaylist),
];
}
static bool isModeAccepted(int mode) {
return [acceptedModes containsObject:@(mode)];
}
void adapter_repeat(MRARepeatMode mode) {
if (!isModeAccepted((int)mode)) {
failf(@"Invalid repeat mode: %d", (int)mode);
}
g_mediaRemote.setRepeatMode((int)mode);
waitForCommandCompletion();
}
static inline int repeat_0_mode() {
return getEnvFuncParamIntSafe(@"adapter_repeat", 0, @"mode");
}
void adapter_repeat_env() { adapter_repeat((MRARepeatMode)repeat_0_mode()); }

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
void adapter_seek(long position) {
if (position < 0) {
failf(@"Negative values are not allowed: %d", position);
}
g_mediaRemote.setElapsedTime(position / 1000000.0);
waitForCommandCompletion();
}
static inline long seek_0_position() {
return getEnvFuncParamLongSafe(@"adapter_seek", 0, @"position");
}
void adapter_seek_env() { adapter_seek(seek_0_position()); }

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedCommands;
__attribute__((constructor)) static void init() {
acceptedCommands = @[
@(kMRAPlay),
@(kMRAPause),
@(kMRATogglePlayPause),
@(kMRAStop),
@(kMRANextTrack),
@(kMRAPreviousTrack),
@(kMRAToggleShuffle),
@(kMRAToggleRepeat),
@(kMRAStartForwardSeek),
@(kMRAEndForwardSeek),
@(kMRAStartBackwardSeek),
@(kMRAEndBackwardSeek),
@(kMRAGoBackFifteenSeconds),
@(kMRASkipFifteenSeconds),
];
// TODO like/unlike tracks by reading now playing information first,
// getting the track ID, station ID and station hash
// and then sending the respective MRCommand.
// does "ban" mean "remove like" here?
}
static MRCommand findCommand(int command, bool *found) {
if ([acceptedCommands containsObject:@(command)]) {
*found = true;
return (MRCommand)command;
}
*found = false;
return (MRCommand)0;
}
void adapter_send(MRACommand command) {
bool ok = false;
MRCommand commandValue = findCommand((int)command, &ok);
if (!ok) {
failf(@"Invalid command: %d", command);
}
bool result = g_mediaRemote.sendCommand(commandValue, nil);
if (!result) {
failf(@"Failed to send command %d", command);
}
waitForCommandCompletion();
}
static inline int send_0_command() {
return getEnvFuncParamIntSafe(@"adapter_send", 0, @"command");
}
void adapter_send_env() { adapter_send((MRACommand)send_0_command()); }

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
static NSArray<NSNumber *> *acceptedModes;
__attribute__((constructor)) static void init() {
acceptedModes = @[
@(kMRAShuffleDisabled),
@(kMRAShuffleAlbums),
@(kMRAShuffleTracks),
];
}
static bool isModeAccepted(int mode) {
return [acceptedModes containsObject:@(mode)];
}
void adapter_shuffle(MRAShuffleMode mode) {
if (!isModeAccepted((int)mode)) {
failf(@"Invalid shuffle mode: %d", (int)mode);
}
g_mediaRemote.setShuffleMode((int)mode);
waitForCommandCompletion();
}
static inline int shuffle_0_mode() {
return getEnvFuncParamIntSafe(@"adapter_shuffle", 0, @"mode");
}
void adapter_shuffle_env() {
adapter_shuffle((MRAShuffleMode)shuffle_0_mode());
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#include "private/MediaRemote.h"
#include <limits.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/now_playing.h"
#import "utility/helpers.h"
void adapter_speed(int speed) {
if (speed < 0) {
failf(@"Negative values are not allowed: %d", speed);
}
g_mediaRemote.setPlaybackSpeed(speed);
waitForCommandCompletion();
}
static inline int speed_0_speed() {
return getEnvFuncParamIntSafe(@"adapter_speed", 0, @"speed");
}
void adapter_speed_env() { adapter_speed(speed_0_speed()); }

View File

@@ -0,0 +1,523 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import <AppKit/AppKit.h>
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
#import "MediaRemoteAdapter.h"
#import "adapter/env.h"
#import "adapter/globals.h"
#import "adapter/keys.h"
#import "adapter/now_playing.h"
#import "private/MediaRemote.h"
#import "utility/Debounce.h"
#import "utility/helpers.h"
#ifndef DEBOUNCE_DELAY_MILLIS
#define DEBOUNCE_DELAY_MILLIS 0
#endif
static CFRunLoopRef g_runLoop = NULL;
static NSString *serializeData(NSDictionary *data, BOOL diff, BOOL pretty) {
return serializeJsonDictionarySafe(
@{
@"type" : @"data",
@"diff" : @(diff),
@"payload" : data ?: @{},
},
pretty);
}
static NSDictionary *createDiff(NSDictionary *a, NSDictionary *b) {
NSMutableDictionary *diff = [NSMutableDictionary dictionary];
NSMutableSet *allKeys = [NSMutableSet setWithArray:a.allKeys];
[allKeys addObjectsFromArray:b.allKeys];
for (id key in allKeys) {
id oldValue = a[key];
id newValue = b[key];
BOOL valuesDiffer = NO;
if (oldValue == nil && newValue != nil) {
valuesDiffer = YES;
} else if (oldValue != nil && newValue == nil) {
valuesDiffer = YES;
} else if (![oldValue isEqual:newValue]) {
valuesDiffer = YES;
}
if (valuesDiffer) {
diff[key] = newValue ?: [NSNull null];
}
}
return [diff copy];
}
static BOOL isSameItemIdentity(NSDictionary *a, NSDictionary *b) {
NSArray<NSString *> *keys = identifyingPayloadKeys();
for (NSString *key in keys) {
id aValue = a[key];
id bValue = b[key];
if (aValue == nil && bValue == nil) {
continue;
}
if (aValue == nil || bValue == nil) {
return NO;
}
if (![aValue isEqual:bValue]) {
return NO;
}
}
return YES;
}
static NSDictionary *previousData = nil;
static void printData(NSDictionary *data, BOOL diff, BOOL pretty) {
NSString *serialized = nil;
if (diff && previousData != nil && isSameItemIdentity(previousData, data)) {
NSDictionary *result = createDiff(previousData, data);
if ([result count] == 0) {
return;
}
serialized = serializeData(result, YES, pretty);
} else {
serialized = serializeData(data, NO, pretty);
}
if (serialized != nil) {
if (diff) {
previousData = [data copy];
}
// Print the serialized data without duplicates. Note that while this
// can fail when the key order in the serialized JSON output changes,
// it practically won't because if it did, there would also be a change
// in values that needs to be reported.
printOutUnique(serialized);
}
if (!diff) {
previousData = nil;
}
}
static void appForNotification(NSNotification *notification,
void (^block)(NSRunningApplication *)) {
NSDictionary *userInfo = notification.userInfo;
id pidValue = userInfo[kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey];
if (pidValue != nil) {
int pid = [pidValue intValue];
appForPID(pid, block);
} else {
block(nil);
}
};
typedef struct MetadataStats {
BOOL trackTitleChanged;
int identifyingTrackKeysIdentical;
int identifyingTrackKeysChanged;
} MetadataStats;
static MetadataStats createMetadataStats() {
MetadataStats stats = {
.trackTitleChanged = NO,
.identifyingTrackKeysIdentical = 0,
.identifyingTrackKeysChanged = 0,
};
return stats;
}
static MetadataStats compareIdentifyingTrackKeys(NSDictionary *prev,
NSDictionary *next) {
MetadataStats stats = createMetadataStats();
for (NSString *key in @[ kMRATitle, kMRAArtist, kMRAAlbum ]) {
id a = prev[key], b = next[key];
if (a == nil || b == nil)
continue;
if ([a isEqual:b]) {
stats.identifyingTrackKeysIdentical++;
} else {
stats.identifyingTrackKeysChanged++;
if ([key isEqualToString:kMRATitle]) {
stats.trackTitleChanged = YES;
}
}
}
return stats;
}
extern void adapter_stream() {
// Get ADAPTER_TEST_MODE as a boolean and set BOOL isTestMode
BOOL isTestMode = NO;
char *testModeEnv = getenv("ADAPTER_TEST_MODE");
if (testModeEnv && strcmp(testModeEnv, "0") != 0 &&
strlen(testModeEnv) > 0) {
isTestMode = YES;
}
int debounce_delay_millis = 0;
NSNumber *debounce_option = getEnvOptionInt(@"debounce");
if (debounce_option != nil) {
debounce_delay_millis = [debounce_option intValue];
}
NSString *no_diff_option = getEnvOption(@"no_diff");
NSString *no_artwork_option = getEnvOption(@"no-artwork");
NSString *micros_option = getEnvOption(@"micros");
NSString *human_readable_option = getEnvOption(@"human-readable");
// This option is needed for media players which, when changing tracks,
// update the artist and/or other fields later than e.g. the title, the
// invalid in-between metadata therefore representing "peculiar" media. The
// only known player that does this is the TIDAL desktop player with the
// bundle ID "com.tidal.desktop". This is easy to reproduce when playing
// media from a playlist with tracks from different artists.
// FIXME Implement this for any bundle ID, should other players need it.
// In that case parse any "experimental-peculiar-debounce:*" option.
NSNumber *peculiar_debounce_option =
getEnvOptionInt(@"experimental-peculiar-debounce:com.tidal.desktop");
__block NSString *peculiar_bundle_id = nil;
__block Debounce *peculiar_debounce = nil;
__block BOOL did_peculiar_debounce = NO;
if (peculiar_debounce_option != nil) {
peculiar_bundle_id = @"com.tidal.desktop";
int debounce_millis = [peculiar_debounce_option intValue];
peculiar_debounce =
[[Debounce alloc] initWithDelay:(debounce_millis / 1000.0)
queue:g_serialdispatchQueue];
}
__block NSMutableDictionary *liveData = [NSMutableDictionary dictionary];
__block MetadataStats liveDataStats = createMetadataStats();
__block const Debounce *const debounce =
[[Debounce alloc] initWithDelay:(debounce_delay_millis / 1000.0)
queue:g_serialdispatchQueue];
__block const BOOL no_diff = (no_diff_option != nil);
__block const BOOL no_artwork = (no_artwork_option != nil);
__block const BOOL convert_micros = (micros_option != nil);
__block const bool human_readable = (human_readable_option != nil);
void (^localPrintData)(NSDictionary *) = ^(NSDictionary *data) {
printData(data, !no_diff, human_readable);
};
void (^directHandle)() = ^() {
if (allMandatoryPayloadKeysSet(liveData)) {
if (human_readable) {
NSMutableDictionary *shallowClone =
[NSMutableDictionary dictionaryWithDictionary:liveData];
makePayloadHumanReadable(shallowClone);
localPrintData(shallowClone);
} else {
localPrintData(liveData);
}
} else {
localPrintData(nil);
}
};
void (^internalHandle)(bool) = ^(bool updatedStats) {
if (peculiar_debounce == nil ||
![peculiar_bundle_id isEqual:liveData[kMRABundleIdentifier]]) {
directHandle();
return;
}
if (updatedStats && liveDataStats.trackTitleChanged &&
liveDataStats.identifyingTrackKeysIdentical > 0) {
did_peculiar_debounce = true;
[peculiar_debounce call:^{
did_peculiar_debounce = false;
directHandle();
}];
} else if (did_peculiar_debounce &&
(!updatedStats ||
liveDataStats.identifyingTrackKeysChanged == 0)) {
// Ignore this handle call, since there is an active debounce call.
} else {
[peculiar_debounce cancel];
did_peculiar_debounce = false;
directHandle();
}
};
void (^handle)() = ^() {
internalHandle(false);
};
void (^handleWithUpdatedStats)() = ^() {
internalHandle(true);
};
void (^requestNowPlayingApplicationPID)() = ^{
g_mediaRemote.getNowPlayingApplicationPID(
g_serialdispatchQueue, ^(int pid) {
if (pid == 0) {
liveData[kMRAProcessIdentifier] = nil;
handle();
return;
}
liveData[kMRAProcessIdentifier] = @(pid);
bool ok = appForPID(pid, ^(NSRunningApplication *process) {
if (process.bundleIdentifier != nil) {
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
}
handle();
});
if (!ok) {
handle();
}
});
};
void (^requestNowPlayingParentApplicationBundleIdentifier)() = ^{
g_mediaRemote.getNowPlayingClient(g_serialdispatchQueue, ^(id client) {
NSString *parentAppBundleID = nil;
if (client && [client respondsToSelector:@selector
(parentApplicationBundleIdentifier)]) {
id result = [client
performSelector:@selector(parentApplicationBundleIdentifier)];
if ([result isKindOfClass:[NSString class]]) {
parentAppBundleID = result;
}
}
if (parentAppBundleID) {
liveData[kMRAParentApplicationBundleIdentifier] = parentAppBundleID;
} else {
[liveData removeObjectForKey:kMRAParentApplicationBundleIdentifier];
}
handle();
});
};
void (^requestNowPlayingApplicationIsPlaying)() = ^{
g_mediaRemote.getNowPlayingApplicationIsPlaying(
g_serialdispatchQueue, ^(bool isPlaying) {
liveData[kMRAPlaying] = @(isPlaying);
handle();
});
};
void (^requestNowPlayingInfo)() = ^{
g_mediaRemote.getNowPlayingInfo(g_serialdispatchQueue, ^(
NSDictionary *information) {
NSString *serviceIdentifier =
information[kMRMediaRemoteNowPlayingInfoServiceIdentifier];
if (!isTestMode &&
[serviceIdentifier
isEqualToString:
@"com.vandenbe.MediaRemoteAdapter.TestClient"]) {
return;
}
NSMutableDictionary *converted = convertNowPlayingInformation(
information, convert_micros, false, no_artwork);
// Transfer anything over from the existing live data.
if (liveData[kMRAProcessIdentifier] != nil) {
converted[kMRAProcessIdentifier] = liveData[kMRAProcessIdentifier];
}
if (liveData[kMRABundleIdentifier] != nil) {
converted[kMRABundleIdentifier] = liveData[kMRABundleIdentifier];
}
if (liveData[kMRAParentApplicationBundleIdentifier] != nil) {
converted[kMRAParentApplicationBundleIdentifier] =
liveData[kMRAParentApplicationBundleIdentifier];
}
if (liveData[kMRAPlaying] != nil) {
converted[kMRAPlaying] = liveData[kMRAPlaying];
}
// Use the old artwork data, since often the MediaRemote framework
// unloads the artwork and then loads it again shortly after.
// Only do this when the items have the same identity.
if (isSameItemIdentity(liveData, converted) &&
liveData[kMRAArtworkData] != nil &&
liveData[kMRAArtworkData] != [NSNull null] &&
converted[kMRAArtworkData] == nil) {
converted[kMRAArtworkData] = liveData[kMRAArtworkData];
if (liveData[kMRAArtworkMimeType] != nil &&
liveData[kMRAArtworkMimeType] != [NSNull null] &&
converted[kMRAArtworkMimeType] == nil) {
converted[kMRAArtworkMimeType] = liveData[kMRAArtworkMimeType];
}
}
liveDataStats = compareIdentifyingTrackKeys(liveData, converted);
[liveData setDictionary:converted];
handleWithUpdatedStats();
});
};
void (^requestAll)() = ^{
requestNowPlayingApplicationPID();
requestNowPlayingParentApplicationBundleIdentifier();
requestNowPlayingApplicationIsPlaying();
requestNowPlayingInfo();
};
void (^resetAll)() = ^{
[liveData removeAllObjects];
};
void (^refreshAll)() = ^{
resetAll();
requestAll();
};
// FIXME Is this foolproof? This continues and registers observers
// which might intervene with the initial requests.
requestAll();
NSNotificationCenter *default_center = [NSNotificationCenter defaultCenter];
NSNotificationCenter *shared_workscape_notification_center =
[[NSWorkspace sharedWorkspace] notificationCenter];
// TODO Refactor the below two callbacks. They share a lot of code.
id is_playing_change_observer = [default_center
addObserverForName:
kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
dispatch_async(g_serialdispatchQueue, ^() {
appForNotification(notification, ^(
NSRunningApplication *process) {
if (process == nil) {
// The process for this notification could not be
// determined. Assume that there is no now playing
// application anymore.
resetAll();
handle();
return;
}
id isPlayingValue =
notification.userInfo
[kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey];
if (isPlayingValue == nil) {
return;
}
if (liveData[kMRABundleIdentifier] != nil &&
process.bundleIdentifier != nil &&
![liveData[kMRABundleIdentifier]
isEqual:process.bundleIdentifier]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] != nil &&
![liveData[kMRAProcessIdentifier]
isEqual:@(process.processIdentifier)]) {
// This is a different process, reset all data.
resetAll();
}
liveData[kMRABundleIdentifier] = process.bundleIdentifier;
requestNowPlayingParentApplicationBundleIdentifier();
liveData[kMRAPlaying] = @([isPlayingValue boolValue]);
if (liveData[kMRATitle] == nil) {
requestNowPlayingInfo();
} else {
handle();
}
});
});
}];
id info_change_observer = [default_center
addObserverForName:kMRMediaRemoteNowPlayingInfoDidChangeNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
[debounce call:^{
appForNotification(notification, ^(
NSRunningApplication *process) {
if (process == nil) {
// The process for this notification could not be
// determined. Assume that there is no now playing
// application anymore.
resetAll();
handle();
return;
}
if (liveData[kMRABundleIdentifier] != nil &&
process.bundleIdentifier != nil &&
![liveData[kMRABundleIdentifier]
isEqual:process.bundleIdentifier]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] != nil &&
![liveData[kMRAProcessIdentifier]
isEqual:@(process.processIdentifier)]) {
// This is a different process, reset all data.
resetAll();
}
if (liveData[kMRAProcessIdentifier] == nil) {
requestNowPlayingApplicationPID();
}
if (liveData[kMRAParentApplicationBundleIdentifier] ==
nil) {
requestNowPlayingParentApplicationBundleIdentifier();
}
if (liveData[kMRAPlaying] == nil) {
requestNowPlayingApplicationIsPlaying();
}
requestNowPlayingInfo();
});
}];
}];
// Register notifications for when applications are closed.
id app_termination_observer = [shared_workscape_notification_center
addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification *notification) {
dispatch_async(g_serialdispatchQueue, ^() {
NSDictionary *userInfo = [notification userInfo];
id bundleIdentifier =
userInfo[@"NSApplicationBundleIdentifier"];
if (bundleIdentifier != nil &&
[bundleIdentifier
isEqual:liveData[kMRABundleIdentifier]]) {
// Refresh all data, since the application terminated.
refreshAll();
}
});
}];
g_mediaRemote.registerForNowPlayingNotifications(g_serialdispatchQueue);
CFRunLoopRun();
g_mediaRemote.unregisterForNowPlayingNotifications();
[default_center removeObserver:is_playing_change_observer];
[default_center removeObserver:info_change_observer];
[shared_workscape_notification_center
removeObserver:app_termination_observer];
}
extern void adapter_stream_env() { adapter_stream(); }
extern void _adapter_stream_cancel() {
if (g_runLoop) {
CFRunLoopStop(g_runLoop);
}
}
static void handleSignal(int signal) {
if (signal == SIGINT || signal == SIGTERM) {
_adapter_stream_cancel();
}
}
__attribute__((constructor)) static void init() {
g_runLoop = CFRunLoopGetCurrent();
signal(SIGINT, handleSignal);
signal(SIGTERM, handleSignal);
}
__attribute__((destructor)) static void teardown() { _adapter_stream_cancel(); }
// FIXME Fix "peculiar media" (artist is updated later than title). Example:
/*
35.558 Thirteen by Big Star on Camping Songs
36.091 Good Vibrations (Remastered 2001) by Big Star on Camping Songs
36.204 Good Vibrations (Remastered 2001) by Big Star on Camping Songs (+image)
36.624 Good Vibrations (Remastered 2001) by The Beach Boys on Camping Songs
*/

View File

@@ -0,0 +1,217 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#include <signal.h>
#import "MediaRemoteAdapter.h"
#import "adapter/get.h"
#import "test/NowPlayingTest.h"
#import "utility/helpers.h"
static NSTask *nowPlayingClientHelperTask = nil;
static NSFileHandle *helperInput = nil;
static NSFileHandle *helperOutput = nil;
void cleanup_helper() {
if (nowPlayingClientHelperTask && helperInput && helperOutput) {
@try {
[helperInput writeData:[@"cleanup\n"
dataUsingEncoding:NSUTF8StringEncoding]];
[helperInput closeFile];
} @catch (NSException *exception) {
}
// Graceful shutdown with timeout
NSTimeInterval timeout = 2.0;
NSDate *cleanupDeadline = [NSDate dateWithTimeIntervalSinceNow:timeout];
while (nowPlayingClientHelperTask.isRunning &&
[cleanupDeadline timeIntervalSinceNow] > 0) {
[NSThread sleepForTimeInterval:0.1];
}
if (nowPlayingClientHelperTask.isRunning) {
@try {
[nowPlayingClientHelperTask terminate];
} @catch (NSException *exception) {
}
NSDate *terminationDeadline =
[NSDate dateWithTimeIntervalSinceNow:1.0];
while (nowPlayingClientHelperTask.isRunning &&
[terminationDeadline timeIntervalSinceNow] > 0) {
[NSThread sleepForTimeInterval:0.1];
}
}
if (nowPlayingClientHelperTask.isRunning) {
// Force kill as last resort
kill(nowPlayingClientHelperTask.processIdentifier, SIGKILL);
}
@try {
if (helperOutput.readabilityHandler) {
helperOutput.readabilityHandler = nil;
}
[helperOutput closeFile];
} @catch (__unused NSException *exception) {
}
@
try {
[nowPlayingClientHelperTask waitUntilExit];
} @catch (__unused NSException *exception) {
}
} else if (nowPlayingClientHelperTask) {
@try {
[nowPlayingClientHelperTask terminate];
[nowPlayingClientHelperTask waitUntilExit];
} @catch (__unused NSException *exception) {
}
}
nowPlayingClientHelperTask = nil;
helperInput = nil;
helperOutput = nil;
}
void cleanup_and_exit() {
cleanup_helper();
exit(1);
}
void handleSignal(int signal) {
if (signal == SIGINT || signal == SIGTERM)
cleanup_and_exit();
}
extern void adapter_test(void) {
@autoreleasepool {
signal(SIGINT, handleSignal);
signal(SIGTERM, handleSignal);
signal(SIGPIPE, SIG_IGN);
// If adapterOutput is not null, we know the adapter is working
// correctly
NSDictionary *result = internal_get(YES);
if (result != nil) {
cleanup_helper();
exit(0);
}
// Instantiate helper to ensure MediaRemote has data
// We only do this if adapterOutput is null to minimize the impact on
// other apps using the adapter
NSString *helperPath =
NSProcessInfo.processInfo
.environment[@"MEDIAREMOTEADAPTER_TEST_CLIENT_PATH"];
if (helperPath.length == 0) {
printErrf(@"Test client path is missing");
cleanup_helper();
exit(1);
}
// Set up pipes for communication with the helper process
NSPipe *inputPipe = [NSPipe pipe];
NSPipe *outputPipe = [NSPipe pipe];
nowPlayingClientHelperTask = [[NSTask alloc] init];
nowPlayingClientHelperTask.launchPath = helperPath;
nowPlayingClientHelperTask.standardInput = inputPipe;
nowPlayingClientHelperTask.standardOutput = outputPipe;
@try {
[nowPlayingClientHelperTask launch];
} @catch (NSException *exception) {
printErrf(
@"Exeption while trying to launch test client task: %@: %@",
exception.name, exception.reason);
cleanup_helper();
exit(2);
}
helperInput = inputPipe.fileHandleForWriting;
helperOutput = outputPipe.fileHandleForReading;
dispatch_semaphore_t setupSem = dispatch_semaphore_create(0);
NSMutableString *lineBuffer = [[NSMutableString alloc] init];
helperOutput.readabilityHandler = ^(NSFileHandle *fh) {
@autoreleasepool {
NSData *chunk = [fh availableData];
if (chunk.length == 0) {
fh.readabilityHandler = nil;
return;
}
// Validate UTF-8 encoding with graceful degradation
NSString *chunkStr =
[[NSString alloc] initWithData:chunk
encoding:NSUTF8StringEncoding];
if (!chunkStr) {
return;
}
[lineBuffer appendString:chunkStr];
NSUInteger bufferLength = [lineBuffer length];
NSUInteger searchStart = 0;
while (searchStart < bufferLength) {
NSRange remainingRange =
NSMakeRange(searchStart, bufferLength - searchStart);
NSRange nlRange = [lineBuffer rangeOfString:@"\n"
options:0
range:remainingRange];
if (nlRange.location == NSNotFound) {
break;
}
NSUInteger lineLength = nlRange.location - searchStart;
NSString *line = [lineBuffer
substringWithRange:NSMakeRange(searchStart, lineLength)];
if ([line isEqualToString:@"setup_done"]) {
fh.readabilityHandler = nil;
dispatch_semaphore_signal(setupSem);
return;
}
searchStart = nlRange.location + nlRange.length;
}
if (searchStart > 0) {
[lineBuffer
deleteCharactersInRange:NSMakeRange(0, searchStart)];
}
}
};
// Wait for setup_done or timeout
NSTimeInterval setupTimeout = 3.0;
dispatch_time_t timeout = dispatch_time(
DISPATCH_TIME_NOW, (int64_t)(setupTimeout * NSEC_PER_SEC));
long result_wait = dispatch_semaphore_wait(setupSem, timeout);
if (helperOutput.readabilityHandler) {
helperOutput.readabilityHandler = nil;
}
if (result_wait != 0) {
printErrf(@"The test client did not signal setup_done within %.1fs",
setupTimeout);
cleanup_helper();
exit(3);
}
// Small delay to ensure new data is available, for some reason the
// first call to adapter_get slows down MediaRemote?
[NSThread sleepForTimeInterval:0.01];
result = internal_get(YES);
if (result != nil) {
cleanup_helper();
exit(0);
}
cleanup_helper();
exit(4);
}
}

View File

@@ -0,0 +1,163 @@
// clang-format off
#ifndef MEDIAREMOTE_PRIVATE_H_
#define MEDIAREMOTE_PRIVATE_H_
#include <Foundation/Foundation.h>
#pragma mark Notifications
extern NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification;
extern NSString *kMRMediaRemotePickableRoutesDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification;
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification;
extern NSString *kMRMediaRemoteRouteStatusDidChangeNotification;
#pragma mark Keys
extern NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey;
extern NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey;
extern NSString *kMRMediaRemoteNowPlayingInfoAlbum;
extern NSString *kMRMediaRemoteNowPlayingInfoArtist;
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkData;
extern NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType;
extern NSString *kMRMediaRemoteNowPlayingInfoChapterNumber;
extern NSString *kMRMediaRemoteNowPlayingInfoComposer;
extern NSString *kMRMediaRemoteNowPlayingInfoDuration;
extern NSString *kMRMediaRemoteNowPlayingInfoElapsedTime;
extern NSString *kMRMediaRemoteNowPlayingInfoGenre;
extern NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement;
extern NSString *kMRMediaRemoteNowPlayingInfoIsBanned;
extern NSString *kMRMediaRemoteNowPlayingInfoIsInWishList;
extern NSString *kMRMediaRemoteNowPlayingInfoIsLiked;
extern NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp;
extern NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate;
extern NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip;
extern NSString *kMRMediaRemoteNowPlayingInfoQueueIndex;
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoRepeatMode;
extern NSString *kMRMediaRemoteNowPlayingInfoShuffleMode;
extern NSString *kMRMediaRemoteNowPlayingInfoStartTime;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked;
extern NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds;
extern NSString *kMRMediaRemoteNowPlayingInfoTimestamp;
extern NSString *kMRMediaRemoteNowPlayingInfoTitle;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount;
extern NSString *kMRMediaRemoteNowPlayingInfoTrackNumber;
extern NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier;
extern NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash;
extern NSString *kMRMediaRemoteNowPlayingInfoMediaType;
extern NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier;
extern NSString *kMRMediaRemoteOptionMediaType;
extern NSString *kMRMediaRemoteOptionSourceID;
extern NSString *kMRMediaRemoteOptionTrackID;
extern NSString *kMRMediaRemoteOptionStationID;
extern NSString *kMRMediaRemoteOptionStationHash;
extern NSString *kMRMediaRemoteRouteDescriptionUserInfoKey;
extern NSString *kMRMediaRemoteRouteStatusUserInfoKey;
#pragma mark API
typedef enum {
/*
* Use nil for userInfo.
*/
kMRPlay = 0,
kMRPause = 1,
kMRTogglePlayPause = 2,
kMRStop = 3,
kMRNextTrack = 4,
kMRPreviousTrack = 5,
kMRToggleShuffle = 6,
kMRToggleRepeat = 7,
kMRStartForwardSeek = 8,
kMREndForwardSeek = 9,
kMRStartBackwardSeek = 10,
kMREndBackwardSeek = 11,
kMRGoBackFifteenSeconds = 12,
kMRSkipFifteenSeconds = 13,
/*
* Use a NSDictionary for userInfo, which contains three keys:
* kMRMediaRemoteOptionTrackID
* kMRMediaRemoteOptionStationID
* kMRMediaRemoteOptionStationHash
*/
kMRLikeTrack = 0x6A,
kMRBanTrack = 0x6B,
kMRAddTrackToWishList = 0x6C,
kMRRemoveTrackFromWishList = 0x6D
} MRCommand;
extern CFStringRef MRMediaRemoteSendCommand;
typedef bool (*MRMediaRemoteSendCommand_t)(MRCommand command, id userInfo);
extern CFStringRef MRMediaRemoteSetPlaybackSpeed;
extern CFStringRef MRMediaRemoteSetElapsedTime;
extern CFStringRef MRMediaRemoteSetShuffleMode;
extern CFStringRef MRMediaRemoteSetRepeatMode;
typedef void (*MRMediaRemoteSetPlaybackSpeed_t)(int speed);
typedef void (*MRMediaRemoteSetElapsedTime_t)(double elapsedTime);
typedef void (*MRMediaRemoteSetShuffleMode_t)(int mode);
typedef void (*MRMediaRemoteSetRepeatMode_t)(int mode);
extern CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications;
extern CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications;
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationPID;
extern CFStringRef MRMediaRemoteGetNowPlayingClient;
extern CFStringRef MRMediaRemoteGetNowPlayingInfo;
extern CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying;
typedef void (*MRMediaRemoteRegisterForNowPlayingNotifications_t)(dispatch_queue_t queue);
typedef void (*MRMediaRemoteUnregisterForNowPlayingNotifications_t)();
typedef void (^MRMediaRemoteGetNowPlayingInfoCompletion_t)(NSDictionary *information);
typedef void (^MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t)(int PID);
typedef void (^MRMediaRemoteGetNowPlayingClientCompletion_t)(id clientObj);
typedef void (^MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t)(bool isPlaying);
typedef void (*MRMediaRemoteGetNowPlayingApplicationPID_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationPIDCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingClient_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingClientCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingInfo_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingInfoCompletion_t completion);
typedef void (*MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)(dispatch_queue_t queue, MRMediaRemoteGetNowPlayingApplicationIsPlayingCompletion_t completion);
#pragma mark Miscellaneous
extern NSString *kMRNowPlayingClientUserInfoKey;
// Accessed with the kMRNowPlayingClientUserInfoKey
// on the userInfo dictionary of an NSNotification.
@interface MRClient : NSObject {}
-(NSString *)parentApplicationBundleIdentifier;
-(NSString *)bundleIdentifier;
-(NSString *)displayName;
@end
@interface MediaRemote : NSObject
// Commands
@property(readonly) MRMediaRemoteSendCommand_t sendCommand;
// Other controls
@property(readonly) MRMediaRemoteSetPlaybackSpeed_t setPlaybackSpeed;
@property(readonly) MRMediaRemoteSetElapsedTime_t setElapsedTime;
@property(readonly) MRMediaRemoteSetShuffleMode_t setShuffleMode;
@property(readonly) MRMediaRemoteSetRepeatMode_t setRepeatMode;
// Observers
@property(readonly) MRMediaRemoteRegisterForNowPlayingNotifications_t registerForNowPlayingNotifications;
@property(readonly) MRMediaRemoteUnregisterForNowPlayingNotifications_t unregisterForNowPlayingNotifications;
// Metadata
@property(readonly) MRMediaRemoteGetNowPlayingApplicationPID_t getNowPlayingApplicationPID;
@property(readonly) MRMediaRemoteGetNowPlayingClient_t getNowPlayingClient;
@property(readonly) MRMediaRemoteGetNowPlayingInfo_t getNowPlayingInfo;
@property(readonly) MRMediaRemoteGetNowPlayingApplicationIsPlaying_t getNowPlayingApplicationIsPlaying;
// Constructor
-(id)init;
@end
#endif /* MEDIAREMOTE_PRIVATE_H_ */

View File

@@ -0,0 +1,114 @@
// clang-format off
#include <Foundation/Foundation.h>
#include "MediaRemote.h"
NSString *kMRMediaRemoteNowPlayingInfoDidChangeNotification = @"kMRMediaRemoteNowPlayingInfoDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification = @"kMRMediaRemoteNowPlayingPlaybackQueueDidChangeNotification";
NSString *kMRMediaRemotePickableRoutesDidChangeNotification = @"kMRMediaRemotePickableRoutesDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification = @"kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification";
NSString *kMRMediaRemoteRouteStatusDidChangeNotification = @"kMRMediaRemoteRouteStatusDidChangeNotification";
NSString *kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationPIDUserInfoKey";
NSString *kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey = @"kMRMediaRemoteNowPlayingApplicationIsPlayingUserInfoKey";
NSString *kMRMediaRemoteNowPlayingInfoAlbum = @"kMRMediaRemoteNowPlayingInfoAlbum";
NSString *kMRMediaRemoteNowPlayingInfoArtist = @"kMRMediaRemoteNowPlayingInfoArtist";
NSString *kMRMediaRemoteNowPlayingInfoArtworkData = @"kMRMediaRemoteNowPlayingInfoArtworkData";
NSString *kMRMediaRemoteNowPlayingInfoArtworkMIMEType = @"kMRMediaRemoteNowPlayingInfoArtworkMIMEType";
NSString *kMRMediaRemoteNowPlayingInfoChapterNumber = @"kMRMediaRemoteNowPlayingInfoChapterNumber";
NSString *kMRMediaRemoteNowPlayingInfoComposer = @"kMRMediaRemoteNowPlayingInfoComposer";
NSString *kMRMediaRemoteNowPlayingInfoDuration = @"kMRMediaRemoteNowPlayingInfoDuration";
NSString *kMRMediaRemoteNowPlayingInfoElapsedTime = @"kMRMediaRemoteNowPlayingInfoElapsedTime";
NSString *kMRMediaRemoteNowPlayingInfoGenre = @"kMRMediaRemoteNowPlayingInfoGenre";
NSString *kMRMediaRemoteNowPlayingInfoIsAdvertisement = @"kMRMediaRemoteNowPlayingInfoIsAdvertisement";
NSString *kMRMediaRemoteNowPlayingInfoIsBanned = @"kMRMediaRemoteNowPlayingInfoIsBanned";
NSString *kMRMediaRemoteNowPlayingInfoIsInWishList = @"kMRMediaRemoteNowPlayingInfoIsInWishList";
NSString *kMRMediaRemoteNowPlayingInfoIsLiked = @"kMRMediaRemoteNowPlayingInfoIsLiked";
NSString *kMRMediaRemoteNowPlayingInfoIsMusicApp = @"kMRMediaRemoteNowPlayingInfoIsMusicApp";
NSString *kMRMediaRemoteNowPlayingInfoPlaybackRate = @"kMRMediaRemoteNowPlayingInfoPlaybackRate";
NSString *kMRMediaRemoteNowPlayingInfoProhibitsSkip = @"kMRMediaRemoteNowPlayingInfoProhibitsSkip";
NSString *kMRMediaRemoteNowPlayingInfoQueueIndex = @"kMRMediaRemoteNowPlayingInfoQueueIndex";
NSString *kMRMediaRemoteNowPlayingInfoRadioStationIdentifier = @"kMRMediaRemoteNowPlayingInfoRadioStationIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoRepeatMode = @"kMRMediaRemoteNowPlayingInfoRepeatMode";
NSString *kMRMediaRemoteNowPlayingInfoShuffleMode = @"kMRMediaRemoteNowPlayingInfoShuffleMode";
NSString *kMRMediaRemoteNowPlayingInfoStartTime = @"kMRMediaRemoteNowPlayingInfoStartTime";
NSString *kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsFastForward15Seconds";
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsBanned = @"kMRMediaRemoteNowPlayingInfoSupportsIsBanned";
NSString *kMRMediaRemoteNowPlayingInfoSupportsIsLiked = @"kMRMediaRemoteNowPlayingInfoSupportsIsLiked";
NSString *kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds = @"kMRMediaRemoteNowPlayingInfoSupportsRewind15Seconds";
NSString *kMRMediaRemoteNowPlayingInfoTimestamp = @"kMRMediaRemoteNowPlayingInfoTimestamp";
NSString *kMRMediaRemoteNowPlayingInfoTitle = @"kMRMediaRemoteNowPlayingInfoTitle";
NSString *kMRMediaRemoteNowPlayingInfoTotalChapterCount = @"kMRMediaRemoteNowPlayingInfoTotalChapterCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalDiscCount = @"kMRMediaRemoteNowPlayingInfoTotalDiscCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalQueueCount = @"kMRMediaRemoteNowPlayingInfoTotalQueueCount";
NSString *kMRMediaRemoteNowPlayingInfoTotalTrackCount = @"kMRMediaRemoteNowPlayingInfoTotalTrackCount";
NSString *kMRMediaRemoteNowPlayingInfoTrackNumber = @"kMRMediaRemoteNowPlayingInfoTrackNumber";
NSString *kMRMediaRemoteNowPlayingInfoUniqueIdentifier = @"kMRMediaRemoteNowPlayingInfoUniqueIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoContentItemIdentifier = @"kMRMediaRemoteNowPlayingInfoContentItemIdentifier";
NSString *kMRMediaRemoteNowPlayingInfoRadioStationHash = @"kMRMediaRemoteNowPlayingInfoRadioStationHash";
NSString *kMRMediaRemoteNowPlayingInfoMediaType = @"kMRMediaRemoteNowPlayingInfoMediaType";
NSString *kMRMediaRemoteNowPlayingInfoServiceIdentifier = @"kMRMediaRemoteNowPlayingInfoServiceIdentifier";
NSString *kMRMediaRemoteOptionMediaType = @"kMRMediaRemoteOptionMediaType";
NSString *kMRMediaRemoteOptionSourceID = @"kMRMediaRemoteOptionSourceID";
NSString *kMRMediaRemoteOptionTrackID = @"kMRMediaRemoteOptionTrackID";
NSString *kMRMediaRemoteOptionStationID = @"kMRMediaRemoteOptionStationID";
NSString *kMRMediaRemoteOptionStationHash = @"kMRMediaRemoteOptionStationHash";
NSString *kMRMediaRemoteRouteDescriptionUserInfoKey = @"kMRMediaRemoteRouteDescriptionUserInfoKey";
NSString *kMRMediaRemoteRouteStatusUserInfoKey = @"kMRMediaRemoteRouteStatusUserInfoKey";
CFStringRef MRMediaRemoteSendCommand = CFSTR("MRMediaRemoteSendCommand");
CFStringRef MRMediaRemoteSetPlaybackSpeed = CFSTR("MRMediaRemoteSetPlaybackSpeed");
CFStringRef MRMediaRemoteSetElapsedTime = CFSTR("MRMediaRemoteSetElapsedTime");
CFStringRef MRMediaRemoteSetShuffleMode = CFSTR("MRMediaRemoteSetShuffleMode");
CFStringRef MRMediaRemoteSetRepeatMode = CFSTR("MRMediaRemoteSetRepeatMode");
CFStringRef MRMediaRemoteRegisterForNowPlayingNotifications = CFSTR("MRMediaRemoteRegisterForNowPlayingNotifications");
CFStringRef MRMediaRemoteUnregisterForNowPlayingNotifications = CFSTR("MRMediaRemoteUnregisterForNowPlayingNotifications");
CFStringRef MRMediaRemoteGetNowPlayingApplicationPID = CFSTR("MRMediaRemoteGetNowPlayingApplicationPID");
CFStringRef MRMediaRemoteGetNowPlayingClient = CFSTR("MRMediaRemoteGetNowPlayingClient");
CFStringRef MRMediaRemoteGetNowPlayingInfo = CFSTR("MRMediaRemoteGetNowPlayingInfo");
CFStringRef MRMediaRemoteGetNowPlayingApplicationIsPlaying = CFSTR("MRMediaRemoteGetNowPlayingApplicationIsPlaying");
NSString *kMRNowPlayingClientUserInfoKey = @"kMRNowPlayingClientUserInfoKey";
static NSString *MediaRemoteFrameworkBundleURL = @"/System/Library/PrivateFrameworks/MediaRemote.framework";
@implementation MediaRemote
@synthesize sendCommand;
@synthesize setPlaybackSpeed;
@synthesize setElapsedTime;
@synthesize setShuffleMode;
@synthesize setRepeatMode;
@synthesize registerForNowPlayingNotifications;
@synthesize unregisterForNowPlayingNotifications;
@synthesize getNowPlayingApplicationPID;
@synthesize getNowPlayingClient;
@synthesize getNowPlayingInfo;
@synthesize getNowPlayingApplicationIsPlaying;
-(id)init
{
if (!(self = [super init])) {
return nil;
}
CFURLRef bundleURL = (__bridge CFURLRef)[NSURL fileURLWithPath:MediaRemoteFrameworkBundleURL];
CFBundleRef bundle = CFBundleCreate(kCFAllocatorDefault, bundleURL);
if (!bundle) {
return nil;
}
sendCommand = (MRMediaRemoteSendCommand_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSendCommand);
setPlaybackSpeed = (MRMediaRemoteSetPlaybackSpeed_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetPlaybackSpeed);
setElapsedTime = (MRMediaRemoteSetElapsedTime_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetElapsedTime);
setShuffleMode = (MRMediaRemoteSetShuffleMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetShuffleMode);
setRepeatMode = (MRMediaRemoteSetRepeatMode_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteSetRepeatMode);
registerForNowPlayingNotifications = (MRMediaRemoteRegisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteRegisterForNowPlayingNotifications);
unregisterForNowPlayingNotifications = (MRMediaRemoteUnregisterForNowPlayingNotifications_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteUnregisterForNowPlayingNotifications);
getNowPlayingApplicationPID = (MRMediaRemoteGetNowPlayingApplicationPID_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationPID);
getNowPlayingClient = (MRMediaRemoteGetNowPlayingClient_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingClient);
getNowPlayingInfo = (MRMediaRemoteGetNowPlayingInfo_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingInfo);
getNowPlayingApplicationIsPlaying = (MRMediaRemoteGetNowPlayingApplicationIsPlaying_t)CFBundleGetFunctionPointerForName(bundle, MRMediaRemoteGetNowPlayingApplicationIsPlaying);
return self;
}
@end

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#import <MediaPlayer/MediaPlayer.h>
NS_ASSUME_NONNULL_BEGIN
@protocol RemoteCommandCenterDelegateListener <NSObject>
- (void)didReceivePlayCommand;
- (void)didReceivePauseCommand;
@end
@interface NowPlayingInfoDelegate : NSObject
@property(nonatomic, readonly) MPNowPlayingInfoCenter *center;
- (void)updateMetadataWithTitle:(NSString *)title
artist:(NSString *)artist
duration:(NSTimeInterval)duration;
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time;
@end
@interface RemoteCommandCenterDelegate : NSObject
@property(nonatomic, weak) id<RemoteCommandCenterDelegateListener> listener;
- (instancetype)initWithListener:
(id<RemoteCommandCenterDelegateListener>)listener;
@end
@interface NowPlayingPublishTest
: NSObject <RemoteCommandCenterDelegateListener>
@property(nonatomic, strong, readonly)
NowPlayingInfoDelegate *nowPlayingDelegate;
@property(nonatomic, strong, readonly)
RemoteCommandCenterDelegate *commandDelegate;
@property(nonatomic, assign, readonly) BOOL isPlaying;
@property(nonatomic, assign, readonly) NSTimeInterval elapsedTime;
@property(nonatomic, strong, nullable, readonly) NSDate *playbackStartDate;
@property(nonatomic, assign, readonly) NSTimeInterval totalDuration;
- (void)updateNowPlayingInfo;
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,231 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#include <MediaPlayer/MediaPlayer.h>
#import "NowPlayingTest.h"
NS_ASSUME_NONNULL_BEGIN
// Constants
static const NSTimeInterval kDefaultTrackDuration = 10.0 * 60.0; // 10 minutes
static const float kPlayingRate = 1.0f;
static const float kPausedRate = 0.0f;
@implementation NowPlayingInfoDelegate {
MPNowPlayingInfoCenter *_center;
}
- (instancetype)init {
if (self = [super init]) {
_center = [MPNowPlayingInfoCenter defaultCenter];
}
return self;
}
- (MPNowPlayingInfoCenter *)center {
return _center;
}
- (void)updateMetadataWithTitle:(NSString *)title
artist:(NSString *)artist
duration:(NSTimeInterval)duration {
NSMutableDictionary *nowPlayingInfo = [@{
MPMediaItemPropertyTitle : title ?: @"Unknown Title",
MPMediaItemPropertyAlbumTitle : @"Unknown Album",
MPMediaItemPropertyArtist : artist ?: @"Unknown Artist",
MPMediaItemPropertyPlaybackDuration : @(duration),
MPNowPlayingInfoPropertyElapsedPlaybackTime : @0,
MPNowPlayingInfoPropertyCurrentPlaybackDate : [NSDate date],
MPNowPlayingInfoPropertyPlaybackRate : @(kPlayingRate),
MPNowPlayingInfoPropertyMediaType : @(MPNowPlayingInfoMediaTypeAudio),
MPNowPlayingInfoPropertyServiceIdentifier :
@"com.vandenbe.MediaRemoteAdapter.TestClient",
} mutableCopy];
#if defined(__MAC_15_0) && __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_15_0
if (@available(macOS 15, *)) {
nowPlayingInfo[MPNowPlayingInfoPropertyExcludeFromSuggestions] = @YES;
}
#endif
self.center.playbackState = MPNowPlayingPlaybackStatePlaying;
self.center.nowPlayingInfo = [nowPlayingInfo copy];
}
- (void)setPlaybackRate:(float)rate elapsedTime:(NSTimeInterval)time {
NSMutableDictionary *currentInfo = [self.center.nowPlayingInfo mutableCopy];
if (!currentInfo) {
return;
}
currentInfo[MPNowPlayingInfoPropertyPlaybackRate] = @(rate);
currentInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = @(time);
self.center.nowPlayingInfo = [currentInfo copy];
}
@end
@implementation RemoteCommandCenterDelegate
- (instancetype)initWithListener:
(id<RemoteCommandCenterDelegateListener>)listener {
if (self = [super init]) {
_listener = listener;
[self setupRemoteCommandHandlers];
}
return self;
}
- (void)setupRemoteCommandHandlers {
MPRemoteCommandCenter *commandCenter =
[MPRemoteCommandCenter sharedCommandCenter];
[commandCenter.playCommand addTarget:self
action:@selector(handlePlayCommand:)];
[commandCenter.pauseCommand addTarget:self
action:@selector(handlePauseCommand:)];
}
- (MPRemoteCommandHandlerStatus)handlePlayCommand:
(MPRemoteCommandEvent *)event {
[self.listener didReceivePlayCommand];
return MPRemoteCommandHandlerStatusSuccess;
}
- (MPRemoteCommandHandlerStatus)handlePauseCommand:
(MPRemoteCommandEvent *)event {
[self.listener didReceivePauseCommand];
return MPRemoteCommandHandlerStatusSuccess;
}
@end
@interface NowPlayingPublishTest ()
@property(nonatomic, strong, readwrite)
NowPlayingInfoDelegate *nowPlayingDelegate;
@property(nonatomic, strong, readwrite)
RemoteCommandCenterDelegate *commandDelegate;
@property(nonatomic, assign, readwrite) BOOL isPlaying;
@property(nonatomic, assign, readwrite) NSTimeInterval elapsedTime;
@property(nonatomic, strong, nullable, readwrite) NSDate *playbackStartDate;
@property(nonatomic, assign, readwrite) NSTimeInterval totalDuration;
@end
@implementation NowPlayingPublishTest
- (instancetype)init {
if (self = [super init]) {
[self setupDelegates];
[self initializePlaybackState];
[self setupInitialTrack];
}
return self;
}
- (void)setupDelegates {
self.nowPlayingDelegate = [[NowPlayingInfoDelegate alloc] init];
self.commandDelegate =
[[RemoteCommandCenterDelegate alloc] initWithListener:self];
}
- (void)initializePlaybackState {
self.totalDuration = kDefaultTrackDuration;
self.elapsedTime = 0.0;
self.playbackStartDate = [NSDate date];
self.isPlaying = YES;
}
- (void)setupInitialTrack {
[self.nowPlayingDelegate updateMetadataWithTitle:@"Is It Broken Yet?"
artist:@"Alexander5015, ungive"
duration:self.totalDuration];
[self updateNowPlayingInfo];
}
- (void)didReceivePlayCommand {
if (self.isPlaying) {
return; // Already playing
}
[self startPlayback];
}
- (void)didReceivePauseCommand {
if (!self.isPlaying) {
return; // Already paused
}
[self pausePlayback];
}
- (void)startPlayback {
self.isPlaying = YES;
self.playbackStartDate = [NSDate date];
[self updateNowPlayingInfo];
}
- (void)pausePlayback {
self.isPlaying = NO;
[self updateElapsedTimeFromPlaybackStart];
self.playbackStartDate = nil;
[self updateNowPlayingInfo];
}
- (void)updateElapsedTimeFromPlaybackStart {
if (!self.playbackStartDate) {
return;
}
NSTimeInterval playedInterval =
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
self.elapsedTime += playedInterval;
// Ensure elapsed time doesn't exceed total duration
if (self.elapsedTime > self.totalDuration) {
self.elapsedTime = self.totalDuration;
}
}
- (void)updateNowPlayingInfo {
NSTimeInterval currentElapsedTime = [self calculateCurrentElapsedTime];
float playbackRate = [self calculatePlaybackRate:currentElapsedTime];
[self.nowPlayingDelegate setPlaybackRate:playbackRate
elapsedTime:currentElapsedTime];
}
- (NSTimeInterval)calculateCurrentElapsedTime {
NSTimeInterval currentElapsed = self.elapsedTime;
if (self.isPlaying && self.playbackStartDate) {
NSTimeInterval intervalSinceStart =
[[NSDate date] timeIntervalSinceDate:self.playbackStartDate];
currentElapsed += intervalSinceStart;
// Cap at total duration
if (currentElapsed > self.totalDuration) {
currentElapsed = self.totalDuration;
}
}
return currentElapsed;
}
- (float)calculatePlaybackRate:(NSTimeInterval)currentElapsedTime {
if (!self.isPlaying) {
return kPausedRate;
}
// Check if track has ended
if (currentElapsedTime >= self.totalDuration) {
self.isPlaying = NO; // Auto-pause when track ends
return kPausedRate;
}
return kPlayingRate;
}
@end
NS_ASSUME_NONNULL_END

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2025 Alexander5015
// This file is licensed under the BSD 3-Clause License.
#import <Foundation/Foundation.h>
#import "NowPlayingTest.h"
static const NSTimeInterval kRunLoopInterval = 0.1;
static const size_t kInputBufferSize = 256;
int main(int argc, const char *argv[]) {
@autoreleasepool {
dup2(STDOUT_FILENO, STDERR_FILENO);
NowPlayingPublishTest *test = [[NowPlayingPublishTest alloc] init];
puts("setup_done");
fflush(stdout);
BOOL shouldExit = NO;
while (!shouldExit) {
NSDate *waitUntil =
[NSDate dateWithTimeIntervalSinceNow:kRunLoopInterval];
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:waitUntil];
fd_set fds;
struct timeval tv = {0, 0};
FD_ZERO(&fds);
FD_SET(STDIN_FILENO, &fds);
int ret = select(STDIN_FILENO + 1, &fds, NULL, NULL, &tv);
if (ret > 0 && FD_ISSET(STDIN_FILENO, &fds)) {
char buf[kInputBufferSize];
if (fgets(buf, sizeof(buf), stdin)) {
NSString *command =
[[NSString alloc] initWithUTF8String:buf];
command = [command
stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if ([command isEqualToString:@"cleanup"]) {
printf("cleanup_done\n");
fflush(stdout);
shouldExit = YES;
break;
} else {
puts("unknown_command");
fflush(stdout);
}
}
}
}
}
return 0;
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
#define MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H
#import <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface Debounce : NSObject
@property(nonatomic, assign, readonly) NSTimeInterval delay;
- (instancetype)initWithDelay:(NSTimeInterval)delay
queue:(nullable dispatch_queue_t)queue;
- (void)call:(dispatch_block_t)block;
- (void)cancel;
@end
NS_ASSUME_NONNULL_END
#endif // MEDIAREMOTEADAPTER_UTILITY_DEBOUNCE_H

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "Debounce.h"
@interface Debounce ()
@property(nonatomic, strong) dispatch_queue_t queue;
@property(nonatomic, strong, nullable) dispatch_block_t pendingBlock;
@property(nonatomic, assign, readwrite) NSTimeInterval delay;
@end
@implementation Debounce
- (instancetype)initWithDelay:(NSTimeInterval)delay
queue:(dispatch_queue_t)queue {
self = [super init];
if (self) {
_delay = MAX(0.0, delay);
_queue = queue ?: dispatch_get_main_queue();
}
return self;
}
- (void)call:(dispatch_block_t)block {
[self cancel];
self.pendingBlock = dispatch_block_create(DISPATCH_BLOCK_BARRIER, block);
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.delay * NSEC_PER_SEC)),
self.queue, self.pendingBlock);
}
- (void)cancel {
if (self.pendingBlock) {
dispatch_block_cancel(self.pendingBlock);
self.pendingBlock = nil;
}
}
@end

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#ifndef MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
#define MEDIAREMOTEADAPTER_UTILITY_HELPERS_H
#include <stdarg.h>
#import <AppKit/AppKit.h>
#import <Foundation/Foundation.h>
void printOut(NSString *message);
void printOutUnique(NSString *message);
void printErr(NSString *message);
void printErrf(NSString *format, ...);
void fail(NSString *message);
void failf(NSString *format, ...);
NSString *formatError(NSError *error);
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint);
bool appForPID(int pid, void (^block)(NSRunningApplication *));
void makePayloadHumanReadable(NSMutableDictionary *dict);
#endif // MEDIAREMOTEADAPTER_UTILITY_HELPERS_H

View File

@@ -0,0 +1,246 @@
// Copyright (c) 2025 Jonas van den Berg
// This file is licensed under the BSD 3-Clause License.
#import "helpers.h"
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#import <ImageIO/ImageIO.h>
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
#import <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
#else
#import <CoreServices/CoreServices.h>
#endif
#define JSON_NULL @"null";
void printOut(NSString *message) {
fprintf(stdout, "%s\n", [message UTF8String]);
fflush(stdout);
}
void printOutUnique(NSString *message) {
static NSString *previous = nil;
if (![previous isEqualToString:message]) {
printOut(message);
previous = [message copy];
}
}
void printErr(NSString *message) {
fprintf(stderr, "%s\n", [message UTF8String]);
fflush(stderr);
}
void printErrf(NSString *format, ...) {
va_list args;
va_start(args, format);
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
arguments:args];
va_end(args);
fprintf(stderr, "%s\n", [formattedMessage UTF8String]);
fflush(stderr);
}
void fail(NSString *message) {
printErr(message);
exit(1);
}
void failf(NSString *format, ...) {
va_list args;
va_start(args, format);
NSString *formattedMessage = [[NSString alloc] initWithFormat:format
arguments:args];
va_end(args);
fail(formattedMessage);
}
NSString *formatError(NSError *error) {
return
[NSString stringWithFormat:@"%@ (%@:%ld)", [error localizedDescription],
[error domain], (long)[error code]];
}
static id sanitizeValueForJsonEncoding(id value, NSString *parentKey) {
const id unsupported_type = nil; // remove silently at call site with log
if ([value isKindOfClass:[NSDictionary class]]) {
NSDictionary *dictionary = (NSDictionary *)value;
NSMutableDictionary *result = [NSMutableDictionary dictionary];
for (id key in dictionary) {
if (![key isKindOfClass:[NSString class]]) {
printErrf(@"Invalid JSON key in dictionary: %@ (%@)",
[key description], [key class]);
continue;
}
id raw = [dictionary objectForKey:key];
id clean = sanitizeValueForJsonEncoding(raw, key);
if (clean) {
result[key] = clean;
} else {
printErrf(@"Invalid JSON value type in dictionary for key "
@"'%@': %@ (%@)",
key, raw, [raw class]);
}
}
return result;
} else if ([value isKindOfClass:[NSArray class]]) {
NSArray *array = (NSArray *)value;
NSMutableArray *result = [NSMutableArray array];
for (NSUInteger i = 0; i < array.count; i++) {
id elem = array[i];
id clean = sanitizeValueForJsonEncoding(elem, parentKey);
if (clean) {
[result addObject:clean];
} else if (parentKey != nil) {
printErrf(@"Invalid JSON value type in array at index %d "
@"under key '%@': %@ (%@)",
i, parentKey, elem, [elem class]);
} else {
printErrf(
@"Invalid JSON value type in array at index %d: %@ (%@)", i,
elem, [elem class]);
}
}
return result;
} else if ([value isKindOfClass:[NSString class]] ||
[value isKindOfClass:[NSNull class]]) {
return value;
} else if ([value isKindOfClass:[NSNumber class]]) {
NSNumber *number = (NSNumber *)value;
double unwrapped = [number doubleValue];
if (isnan(unwrapped) || isinf(unwrapped)) {
return unsupported_type;
}
return value;
} else if ([value isKindOfClass:[NSDate class]]) {
static NSDateFormatter *formatter = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
formatter = [[NSDateFormatter alloc] init];
formatter.locale =
[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
formatter.timeZone = [NSTimeZone timeZoneWithAbbreviation:@"UTC"];
formatter.dateFormat = @"yyyy-MM-dd'T'HH:mm:ss'Z'";
});
return [formatter stringFromDate:(NSDate *)value];
} else if ([value isKindOfClass:[NSURL class]]) {
return [(NSURL *)value absoluteString];
} else if ([value isKindOfClass:[NSData class]]) {
return [(NSData *)value base64EncodedStringWithOptions:0];
} else {
return unsupported_type;
}
}
static NSDictionary *sanitizeDictionaryForJsonEncoding(NSDictionary *data) {
return sanitizeValueForJsonEncoding(data, nil);
}
NSString *serializeJsonDictionarySafe(NSDictionary *any, bool prettyPrint) {
if (any == nil) {
NSCAssert(false, @"Cannot serialize nil as JSON");
return JSON_NULL;
}
any = sanitizeDictionaryForJsonEncoding(any);
if (any == nil) {
NSCAssert(false, @"Sanitized JSON dictionary is nil");
return JSON_NULL;
}
NSCAssert([NSJSONSerialization isValidJSONObject:any],
@"Sanitized JSON dictionary is not a valid JSON object");
@try {
NSError *error;
NSJSONWritingOptions options =
prettyPrint ? NSJSONWritingPrettyPrinted : 0;
NSData *serialized = [NSJSONSerialization dataWithJSONObject:any
options:options
error:&error];
if (!serialized) {
printErrf(@"Failed to serialize JSON: %@", error);
return nil;
}
return [[NSString alloc] initWithData:serialized
encoding:NSUTF8StringEncoding];
} @catch (NSException *exception) {
if ([exception.name isEqualToString:NSInvalidArgumentException]) {
printErrf(@"Exception during JSON serialization: %@: %@", exception,
[any class]);
} else {
printErrf(@"Exception during JSON serialization: %@", exception);
}
return nil;
}
}
/*
// Dictionary with invalid values to test sanitization before serialization.
any = @{
@"validString" : @"Hello",
@"validNumber" : @123,
@"invalidDate" : [NSDate date],
@"invalidURL" : [NSURL URLWithString:@"https://apple.com"],
@"invalidSet" : [NSSet setWithObjects:@"a", @"b", nil],
@"nestedDict" : @{@42 : @"badKey", @"validNestedKey" :
@"nestedValue"},
@123 : @"badKeyAtRoot",
@"binaryData" :
[@"Hello, base64!" dataUsingEncoding:NSUTF8StringEncoding],
@"invalidArray" : @[
@"okay", [@"Hello, base64!"
dataUsingEncoding:NSUTF8StringEncoding]
],
};
*/
bool appForPID(int pid, void (^block)(NSRunningApplication *)) {
if (pid <= 0) {
return false;
}
NSRunningApplication *process =
[NSRunningApplication runningApplicationWithProcessIdentifier:pid];
if (process == nil) {
return false;
}
block(process);
return true;
}
static NSString *guessImageMimeTypeFromData(NSData *data) {
if (!data)
return nil;
CGImageSourceRef src =
CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
if (!src)
return nil;
CFStringRef uti = CGImageSourceGetType(src);
CFRelease(src);
if (!uti)
return nil;
#if __has_include(<UniformTypeIdentifiers/UniformTypeIdentifiers.h>)
UTType *type = [UTType typeWithIdentifier:(__bridge NSString *)uti];
return type.preferredMIMEType;
#else
CFStringRef mime =
UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType);
if (!mime)
return nil;
NSString *mimeType = (__bridge_transfer NSString *)mime;
return mimeType;
#endif
}
void makePayloadHumanReadable(NSMutableDictionary *dict) {
for (NSString *key in [dict allKeys]) {
id value = dict[key];
if ([value isKindOfClass:[NSData class]]) {
NSString *mimeType = guessImageMimeTypeFromData(value);
dict[key] = [NSString
stringWithFormat:@"<%@%@%lu bytes...>", mimeType ?: @"",
mimeType ? @" " : @"",
(unsigned long)[value length]];
}
}
}

1
bright.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 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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,73 @@
# Hardened Runtime verlangt eigene TCC-Entitlements
Aufgetreten am 10.08.2026 beim Kalender-Widget. Aufwendig zu finden, in einer
Zeile zu beheben — deshalb hier festgehalten, bevor es bei Standort, Apple
Events und Audio erneut zuschlägt.
## Erscheinungsbild
`EKEventStore.requestFullAccessToEvents()` kehrt nach wenigen Millisekunden mit
`false` zurück. Kein Fehler, kein Dialog. `authorizationStatus` bleibt auf
`notDetermined`. In den Systemeinstellungen taucht die App **gar nicht** auf —
und dort lässt sich auch nichts von Hand nachtragen, denn der Kalenderbereich
hat kein Pluszeichen. Einträge entstehen ausschließlich durch eine erfolgreiche
Anfrage. Man sitzt also in einer Sackgasse ohne jede Fehlermeldung.
## Ursache
```
tccd: Prompting policy for hardened runtime; service: kTCCServiceCalendar
requires entitlement com.apple.security.personal-information.calendars
but it is missing
```
Bei aktivierter Hardened Runtime verlangt TCC diese Entitlements — **auch ohne
App-Sandbox.** Die verbreitete Annahme, sie gälten nur für sandboxed Apps, ist
falsch. Ohne sie zeigt macOS den Dialog nicht einmal an.
## Behebung
In `Onyx.entitlements`, je nach genutztem Dienst:
| Dienst | Entitlement |
|---|---|
| Kalender | `com.apple.security.personal-information.calendars` |
| Standort | `com.apple.security.personal-information.location` |
| Apple Events (AppleScript) | `com.apple.security.automation.apple-events` |
| Audioaufnahme / Process Taps | `com.apple.security.device.audio-input` |
| Kontakte | `com.apple.security.personal-information.addressbook` |
## Wie es gefunden wurde
Nicht durch Nachdenken, sondern durch Ausschluss. Entscheidend war eine
**minimale Test-App**: normales Fenster, im Vordergrund, gleiches Zertifikat,
nichts von Onyx darin — und sie scheiterte identisch. Damit war belegt, dass es
nicht an Onyx lag, und die Suche konnte sich auf die Umgebung verlagern.
Der Weg dorthin führte über mehrere falsche Fährten, die hier stehen, damit sie
niemand zweimal geht:
- **`log show` wurde von zsh abgefangen** („too many arguments"). Weil stderr
umgeleitet war, sah die leere Ausgabe wie ein Befund aus. `/usr/bin/log`
benutzen.
- **`promptPolicy = 0`** im Protokoll gehörte zu `kTCCServiceReminders`, nicht
zum Kalender. Der bekam durchgehend `promptPolicy = 4`.
- **Bildschirmzeit** stand unter Verdacht (ein `digital_health_restrictions`-Profil
ist installiert), schränkt aber nur Game Center und die Zeitsynchronisation ein.
- **Ein verklemmter TCC-Eintrag** existierte tatsächlich und wurde mit
`tccutil reset Calendar <bundle-id>` entfernt — behob das Problem aber nicht.
Zwei Nebenbefunde, die für sich schon Fehler waren und mitbehoben wurden:
- **`ENABLE_DEBUG_DYLIB`** (Xcodes Voreinstellung im Debug-Build) lagert den
Programmcode in eine eigene dylib aus. Das Hauptprogramm ist dann nur ein
Rumpf, und die TCC-Anfrage erreichte `tccd` überhaupt nicht. Steht jetzt auf `NO`.
- **SwiftUIs `Settings`-Szene** wird über den privaten Selektor
`showSettingsWindow:` geöffnet und kommt bei `.accessory`-Apps unzuverlässig
an. Onyx führt sein Einstellungsfenster deshalb selbst.
## Merksatz
Wenn ein Berechtigungsdialog ausbleibt, ohne dass irgendwo ein Fehler steht:
zuerst `/usr/bin/log show --predicate 'process == "tccd"'` lesen. Der Grund
steht dort im Klartext — und nirgendwo sonst.

36
outline.svg Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve" width="512" height="512">
<g>
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="&#10;&#9;&#9;M197.235,67.578L115.2,158.68c-1.702,1.89-2.922,4.165-3.553,6.629L56.541,380.415c-0.943,3.68,0.176,7.582,2.926,10.203&#10;&#9;&#9;l116.456,110.985c1.952,1.86,4.545,2.897,7.241,2.897h145.672c2.696,0,5.289-1.036,7.241-2.896l116.456-110.986&#10;&#9;&#9;c2.75-2.621,3.869-6.523,2.926-10.203l-55.107-215.106c-0.631-2.464-1.851-4.739-3.553-6.629l-133-147.698&#10;&#9;&#9;c-4.169-4.63-11.43-4.63-15.599,0l-27.346,30.366"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="256" y1="190.51" x2="256" y2="7.5"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="399.03" y1="161.9" x2="316.4" y2="242.94"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="195.57" y1="242.91" x2="112.97" y2="161.9"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="455.33" y1="386.08" x2="354.95" y2="368.57"/>
<polyline style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" points="&#10;&#9;&#9;297.8,415.14 332.74,503.75 332.75,503.75 &#9;"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="214.18" y1="415.2" x2="179.26" y2="503.75"/>
<line style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" x1="153.05" y1="368.51" x2="56.66" y2="386.07"/>
<path style="fill:none;stroke:#000000;stroke-width:15;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;" d="&#10;&#9;&#9;M344.163,329.755l10.821,34.288c0.912,2.891-0.044,6.047-2.409,7.943l-51.664,41.433c-1.863,1.494-4.181,2.31-6.568,2.31h-76.882&#10;&#9;&#9;c-2.267,0-4.474-0.734-6.289-2.092l-55.56-41.575c-2.565-1.919-3.591-5.278-2.536-8.302l41.358-118.549&#10;&#9;&#9;c0.581-1.665,1.572-3.156,2.882-4.337l53.764-48.468c2.796-2.52,7.046-2.52,9.841,0l53.638,48.355&#10;&#9;&#9;c1.389,1.252,2.418,2.853,2.981,4.637l16.211,51.366"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -47,6 +47,45 @@ targets:
product: OnyxMenuBar product: OnyxMenuBar
- package: OnyxKit - package: OnyxKit
product: CalendarProvider product: CalendarProvider
- package: OnyxKit
product: WeatherProvider
- package: OnyxKit
product: MediaProvider
postBuildScripts:
# Der MediaRemote-Adapter wird aus dem mitgelieferten Quellcode gebaut,
# nicht als fertige Binary eingecheckt: so ist nachvollziehbar, was da
# läuft, und die Ad-hoc-Signatur passt zum restlichen Bundle.
#
# Er darf NICHT gelinkt werden. Geladen wird er von /usr/bin/perl, einem
# berechtigten Systemprogramm — im eigenen Prozess greift Apples
# Entitlement-Prüfung und es käme gar nichts an.
- name: MediaRemote-Adapter bauen und beilegen
script: |
OUT="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/Vendor" \
"$SRCROOT/Scripts/build-mediaremote-adapter.sh"
inputFiles:
- $(SRCROOT)/Scripts/build-mediaremote-adapter.sh
outputFiles:
- $(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/Vendor/mediaremote-adapter.pl
# Das Menüleistensymbol soll das Glyph aus dem App-Icon zeigen, nicht die
# abgerundete Kachel mit Verlauf. actool kompiliert das .icon-Bündel zu
# .icns und Assets.car und legt nichts Lesbares ins Bundle — und eine
# einzelne Datei daraus lässt sich nicht als Ressource referenzieren.
# Deshalb kopieren statt duplizieren: es bleibt bei einer Quelldatei.
- name: Glyph für die Menüleiste ins Bundle kopieren
script: |
SRC="$SRCROOT/Onyx/AppIcon.icon/Assets/quartz.svg"
DST="$BUILT_PRODUCTS_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/quartz.svg"
if [ -f "$SRC" ]; then
mkdir -p "$(dirname "$DST")"
cp "$SRC" "$DST"
else
echo "warning: $SRC fehlt — Menüleiste fällt auf ein SF-Symbol zurück"
fi
inputFiles:
- $(SRCROOT)/Onyx/AppIcon.icon/Assets/quartz.svg
outputFiles:
- $(BUILT_PRODUCTS_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/quartz.svg
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx PRODUCT_BUNDLE_IDENTIFIER: com.scarriffleservices.onyx
@@ -59,3 +98,6 @@ targets:
ENABLE_APP_SANDBOX: NO ENABLE_APP_SANDBOX: NO
ENABLE_USER_SCRIPT_SANDBOXING: NO ENABLE_USER_SCRIPT_SANDBOXING: NO
COMBINE_HIDPI_IMAGES: YES COMBINE_HIDPI_IMAGES: YES
# Icon-Composer-Bündel (Onyx/AppIcon.icon). actool übernimmt daraus
# alle Größen und die Transluzenz-Darstellung von macOS 26.
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon