904b9b3d-c0eb-42f3-acef-958.../Resources/mediaremote-adapter.pl
徐翔宇 d5934b06b0 v2.1.0: Atoll-style MediaRemoteAdapter — bypass 15.4+ entitlement gate
Ports the MediaRemoteAdapter pattern from Atoll
(github.com/Ebullioscopic/Atoll). On macOS 15.4+, Apple gated
MRMediaRemoteGetNowPlayingInfo behind a private entitlement, which made
our previous MediaRemoteSource return empty dicts and forced us onto
slow-path AppleScript polling. This commit bundles Jonas van den Berg's
MediaRemoteAdapter.framework (BSD-3-Clause) plus mediaremote-adapter.pl
and runs them as a subprocess — the framework links against Apple's MR
in a way that skips the caller-side entitlement check, so we get the
full now-playing payload (title, artist, album, duration, elapsed,
isPlaying, artwork, bundleIdentifier) pushed to us in real time.

Bundle additions (~500KB total):
- Resources/MediaRemoteAdapter.framework (universal x86_64 + arm64 + arm64e)
- Resources/mediaremote-adapter.pl
- LICENSE-THIRD-PARTY.md with full BSD-3-Clause attribution

New source: MediaRemoteAdapterSource.swift
- Spawns /usr/bin/perl with minimal env (PATH + LANG only).
- FileHandle.readabilityHandler ingests newline-delimited JSON stream
  from stdout, parses via Codable AdapterStreamPayload, merges diffs
  into persistent MediaRemoteInfo so playbackRate-only payloads don't
  erase title/artist.
- Artwork base64 decoded via Data default strategy.
- Crash handling: SIGTERM → 500ms → SIGKILL on stop. Auto-restart with
  exponential backoff (1s/2s/4s), circuit-breaker after 3 crashes
  within 60s → fall back to legacy chain.
- Transport controls (togglePlay/next/prev/seek) via short-lived one-shot
  `perl adapter.pl send N` subprocesses. send codes: 2=toggle, 4=next,
  5=prev. seek takes microseconds.

NowPlayingState wiring:
- New sticky kind `.mediaRemoteAdapter`, highest priority.
- `applyAdapterUpdate(_:)` publishes directly (no router pass).
- `routeSources` short-circuits when adapter is sticky + has data —
  subprocess pushes fresh data on every change, polling would be pure
  waste.
- `adaptivePollInterval()` returns 30s for adapter (safety net only).
- `isCandidateLive` + `tryFetch` treat adapter as push-only (returns nil
  from pull-fetch so the sticky fast-path falls through to parallel
  probing if subprocess is dead).
- `stop()` terminates the subprocess cleanly.
- Transport controls route to adapter.sendCommand() / adapter.seek()
  when it's the sticky source.

Build:
- build.sh copies Resources/ into Contents/Resources with preserved
  exec bits on the framework binary + Perl script.
- `codesign --force --deep --sign -` re-signs the whole tree ad-hoc
  so the nested framework inherits our identity and Gatekeeper loads
  it without complaint.
- Bundle grew from 48KB → 1.6MB (zipped 564KB). Acceptable for the
  latency win: Apple Music track switches now visible <100ms vs prior
  800ms adaptive-poll worst case.

Security audit (done before bundling):
- Perl script: strict + warnings, whitelisted function names, no
  shell-out, no network I/O, params passed to framework via ENV
  (no string concat). Safe.
- Framework: ad-hoc signed (Identifier com.vandenbe.MediaRemoteAdapter).
  --deep re-sign with our identity replaces the original ad-hoc cert so
  signature validation passes locally and in Gatekeeper.
- Subprocess runs with PATH=/usr/bin:/bin + LANG only. No inherited
  secrets.
- Explicit Process arguments array — no shell interpolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:19:40 +08:00

258 lines
7.3 KiB
Perl
Executable File

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