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:
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal file
25
Vendor/mediaremote-adapter/src/adapter/env.h
vendored
Normal 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
|
||||
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal file
105
Vendor/mediaremote-adapter/src/adapter/env.m
vendored
Normal 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));
|
||||
}
|
||||
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal file
11
Vendor/mediaremote-adapter/src/adapter/get.h
vendored
Normal 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
|
||||
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal file
146
Vendor/mediaremote-adapter/src/adapter/get.m
vendored
Normal 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(); }
|
||||
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal file
15
Vendor/mediaremote-adapter/src/adapter/globals.h
vendored
Normal 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
|
||||
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/globals.m
vendored
Normal 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);
|
||||
}
|
||||
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal file
19
Vendor/mediaremote-adapter/src/adapter/keys.h
vendored
Normal 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
|
||||
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal file
83
Vendor/mediaremote-adapter/src/adapter/keys.m
vendored
Normal 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
|
||||
];
|
||||
}
|
||||
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal file
20
Vendor/mediaremote-adapter/src/adapter/now_playing.h
vendored
Normal 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
|
||||
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal file
201
Vendor/mediaremote-adapter/src/adapter/now_playing.m
vendored
Normal 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;
|
||||
}
|
||||
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal file
44
Vendor/mediaremote-adapter/src/adapter/repeat.m
vendored
Normal 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()); }
|
||||
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/seek.m
vendored
Normal 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()); }
|
||||
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal file
69
Vendor/mediaremote-adapter/src/adapter/send.m
vendored
Normal 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()); }
|
||||
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal file
46
Vendor/mediaremote-adapter/src/adapter/shuffle.m
vendored
Normal 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());
|
||||
}
|
||||
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal file
30
Vendor/mediaremote-adapter/src/adapter/speed.m
vendored
Normal 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()); }
|
||||
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal file
523
Vendor/mediaremote-adapter/src/adapter/stream.m
vendored
Normal 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
|
||||
*/
|
||||
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal file
217
Vendor/mediaremote-adapter/src/adapter/test.m
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user