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

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