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.
218 lines
7.2 KiB
Objective-C
218 lines
7.2 KiB
Objective-C
// 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);
|
|
}
|
|
}
|