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.
This commit is contained in:
Guido Schmit
2026-08-10 21:02:13 +02:00
parent 03dd7b945e
commit 433cdcc3dd
53 changed files with 4888 additions and 2 deletions

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);
}
}