mirror of
https://github.com/MioMioOS/mio-plugin-music.git
synced 2026-06-11 03:44:31 +00:00
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>
This commit is contained in:
parent
113dd31275
commit
d5934b06b0
@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>BNDL</string>
|
<string>BNDL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.5</string>
|
<string>2.1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>7</string>
|
<string>8</string>
|
||||||
<key>NSPrincipalClass</key>
|
<key>NSPrincipalClass</key>
|
||||||
<string>MusicPlugin.MusicPlugin</string>
|
<string>MusicPlugin.MusicPlugin</string>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
46
LICENSE-THIRD-PARTY.md
Normal file
46
LICENSE-THIRD-PARTY.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# Third-Party Components
|
||||||
|
|
||||||
|
This plugin bundles third-party components under `Resources/`. Each
|
||||||
|
component's copyright and license text is preserved below.
|
||||||
|
|
||||||
|
## MediaRemoteAdapter.framework + mediaremote-adapter.pl
|
||||||
|
|
||||||
|
- Source: https://github.com/Ebullioscopic/Atoll/tree/main/mediaremote-adapter
|
||||||
|
- Copyright (c) 2025 Jonas van den Berg
|
||||||
|
- License: BSD 3-Clause
|
||||||
|
- Purpose: Bypasses the macOS 15.4+ entitlement gate on
|
||||||
|
`MRMediaRemoteGetNowPlayingInfo` by exposing an alternate adapter
|
||||||
|
symbol table through a dedicated private framework wrapper. We
|
||||||
|
invoke the bundled `mediaremote-adapter.pl` as a subprocess and
|
||||||
|
consume its JSON stream.
|
||||||
|
|
||||||
|
### BSD 3-Clause License
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in
|
||||||
|
the documentation and/or other materials provided with the
|
||||||
|
distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived
|
||||||
|
from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
||||||
|
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||||
|
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||||
|
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
||||||
|
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||||
|
OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
1
Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
1
Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
Versions/Current/MediaRemoteAdapter
|
||||||
1
Resources/MediaRemoteAdapter.framework/Resources
Symbolic link
1
Resources/MediaRemoteAdapter.framework/Resources
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
Versions/Current/Resources
|
||||||
BIN
Resources/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
BIN
Resources/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
Binary file not shown.
@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>English</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string></string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.vandenbe.MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>MediaRemoteAdapter</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
|
<key>CFBundleSignature</key>
|
||||||
|
<string>????</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1</string>
|
||||||
|
<key>CSResourcesFileMapped</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@ -0,0 +1,128 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>files</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/Info.plist</key>
|
||||||
|
<data>
|
||||||
|
M6AF1VWVJ1A/DSliCSjg170FqsY=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
<key>files2</key>
|
||||||
|
<dict>
|
||||||
|
<key>Resources/Info.plist</key>
|
||||||
|
<dict>
|
||||||
|
<key>hash2</key>
|
||||||
|
<data>
|
||||||
|
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
|
||||||
|
</data>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
<key>rules</key>
|
||||||
|
<dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version.plist$</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<key>rules2</key>
|
||||||
|
<dict>
|
||||||
|
<key>.*\.dSYM($|/)</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>11</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(.*/)?\.DS_Store$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>2000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^.*</key>
|
||||||
|
<true/>
|
||||||
|
<key>^Info\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^PkgInfo$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>optional</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1000</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>omit</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1100</real>
|
||||||
|
</dict>
|
||||||
|
<key>^Resources/Base\.lproj/</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>1010</real>
|
||||||
|
</dict>
|
||||||
|
<key>^[^/]+$</key>
|
||||||
|
<dict>
|
||||||
|
<key>nested</key>
|
||||||
|
<true/>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>10</real>
|
||||||
|
</dict>
|
||||||
|
<key>^embedded\.provisionprofile$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
<key>^version\.plist$</key>
|
||||||
|
<dict>
|
||||||
|
<key>weight</key>
|
||||||
|
<real>20</real>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
1
Resources/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
1
Resources/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
@ -0,0 +1 @@
|
|||||||
|
A
|
||||||
257
Resources/mediaremote-adapter.pl
Executable file
257
Resources/mediaremote-adapter.pl
Executable file
@ -0,0 +1,257 @@
|
|||||||
|
#!/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: $@";
|
||||||
|
}
|
||||||
@ -31,6 +31,10 @@ import Combine
|
|||||||
|
|
||||||
enum NowPlayingSourceKind: String {
|
enum NowPlayingSourceKind: String {
|
||||||
case none
|
case none
|
||||||
|
/// Atoll-style MediaRemoteAdapter subprocess stream — bypasses the
|
||||||
|
/// macOS 15.4+ entitlement gate and gives us real-time system Now
|
||||||
|
/// Playing with artwork, duration, and elapsed time.
|
||||||
|
case mediaRemoteAdapter
|
||||||
case mediaRemote
|
case mediaRemote
|
||||||
case spotify
|
case spotify
|
||||||
case appleMusic
|
case appleMusic
|
||||||
@ -92,6 +96,11 @@ final class NowPlayingState: ObservableObject {
|
|||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private let mediaRemote = MediaRemoteSource()
|
private let mediaRemote = MediaRemoteSource()
|
||||||
|
/// Atoll-style subprocess adapter. Optional because the bundle may be
|
||||||
|
/// missing the Resources/mediaremote-adapter payload (dev builds, old
|
||||||
|
/// plugin versions). When non-nil, it becomes the primary source and
|
||||||
|
/// most of the legacy polling / AppleScript chain stays dormant.
|
||||||
|
private let mediaRemoteAdapter: MediaRemoteAdapterSource? = MediaRemoteAdapterSource()
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var playbackTimer: Timer?
|
private var playbackTimer: Timer?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@ -130,6 +139,17 @@ final class NowPlayingState: ObservableObject {
|
|||||||
Task { @MainActor in self?.refresh() }
|
Task { @MainActor in self?.refresh() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start the Atoll-style adapter subprocess if bundled. This is
|
||||||
|
// the PRIMARY low-latency source — on 15.4+ it's the only one that
|
||||||
|
// actually produces live data without AppleScript polling. When
|
||||||
|
// it emits, we short-circuit the router entirely.
|
||||||
|
if let adapter = mediaRemoteAdapter {
|
||||||
|
adapter.onUpdate = { [weak self] info in
|
||||||
|
Task { @MainActor in self?.applyAdapterUpdate(info) }
|
||||||
|
}
|
||||||
|
adapter.start()
|
||||||
|
}
|
||||||
|
|
||||||
// Observe Spotify distributed notifications for instant reaction.
|
// Observe Spotify distributed notifications for instant reaction.
|
||||||
DistributedNotificationCenter.default().addObserver(
|
DistributedNotificationCenter.default().addObserver(
|
||||||
self,
|
self,
|
||||||
@ -209,6 +229,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
wsCenter.removeObserver(token)
|
wsCenter.removeObserver(token)
|
||||||
}
|
}
|
||||||
workspaceObservers.removeAll()
|
workspaceObservers.removeAll()
|
||||||
|
|
||||||
|
mediaRemoteAdapter?.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func spotifyStateChanged() {
|
@objc private func spotifyStateChanged() {
|
||||||
@ -245,6 +267,9 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
private func adaptivePollInterval() -> TimeInterval {
|
private func adaptivePollInterval() -> TimeInterval {
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
// Adapter subprocess pushes data in real time — poll only as a
|
||||||
|
// last-resort safety net in case the subprocess silently wedges.
|
||||||
|
case .mediaRemoteAdapter: return 30.0
|
||||||
case .appleMusic where isPlaying: return 0.8
|
case .appleMusic where isPlaying: return 0.8
|
||||||
case .chrome where isPlaying: return 1.2
|
case .chrome where isPlaying: return 1.2
|
||||||
case .spotify where isPlaying: return 3.0 // event-driven, poll is just backup
|
case .spotify where isPlaying: return 3.0 // event-driven, poll is just backup
|
||||||
@ -287,6 +312,17 @@ final class NowPlayingState: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func routeSources(allowAppleScript: Bool) async {
|
private func routeSources(allowAppleScript: Bool) async {
|
||||||
|
// Adapter short-circuit: when the subprocess is the sticky source
|
||||||
|
// and we already have a track from it, there's nothing to do here —
|
||||||
|
// new data will arrive via `applyAdapterUpdate(_:)` whenever it
|
||||||
|
// actually changes. Polling on top of an event-driven source just
|
||||||
|
// wastes AppleScript round-trips.
|
||||||
|
if stickySource == .mediaRemoteAdapter,
|
||||||
|
!title.isEmpty,
|
||||||
|
mediaRemoteAdapter != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Running-app snapshot — read once per pass so we don't hit the
|
// Running-app snapshot — read once per pass so we don't hit the
|
||||||
// workspace API four times.
|
// workspace API four times.
|
||||||
let spotifyRunning = SpotifyAppleScript.isRunning
|
let spotifyRunning = SpotifyAppleScript.isRunning
|
||||||
@ -425,6 +461,7 @@ final class NowPlayingState: ObservableObject {
|
|||||||
) -> Bool {
|
) -> Bool {
|
||||||
switch kind {
|
switch kind {
|
||||||
case .none: return false
|
case .none: return false
|
||||||
|
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
|
||||||
case .mediaRemote: return !mrBlocked
|
case .mediaRemote: return !mrBlocked
|
||||||
case .spotify: return allowAppleScript && spotifyRunning
|
case .spotify: return allowAppleScript && spotifyRunning
|
||||||
case .appleMusic: return allowAppleScript && musicRunning
|
case .appleMusic: return allowAppleScript && musicRunning
|
||||||
@ -458,6 +495,10 @@ final class NowPlayingState: ObservableObject {
|
|||||||
case .none:
|
case .none:
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
// Push-only source; pull-fetch is a no-op.
|
||||||
|
return nil
|
||||||
|
|
||||||
case .mediaRemote:
|
case .mediaRemote:
|
||||||
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
@ -501,6 +542,43 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Apply
|
// MARK: - Apply
|
||||||
|
|
||||||
|
/// Called when the Atoll-style subprocess adapter emits a fresh payload.
|
||||||
|
/// This bypasses the full router — adapter updates are the truest signal
|
||||||
|
/// we have on 15.4+, so we claim sticky-source and publish straight away.
|
||||||
|
private func applyAdapterUpdate(_ info: MediaRemoteInfo) {
|
||||||
|
self.title = info.title
|
||||||
|
self.artist = info.artist
|
||||||
|
self.album = info.album
|
||||||
|
self.duration = info.duration
|
||||||
|
self.elapsedTime = info.elapsedTime
|
||||||
|
self.isPlaying = info.isPlaying
|
||||||
|
if let art = info.artwork {
|
||||||
|
self.albumArt = art
|
||||||
|
}
|
||||||
|
// Source name from bundle id for the UI chip. Apple Music → "Apple Music"
|
||||||
|
// etc. Unknown bundle ids fall back to generic "System Media".
|
||||||
|
self.sourceBundleId = info.bundleIdentifier
|
||||||
|
self.sourceName = Self.humanReadableSource(bundleId: info.bundleIdentifier)
|
||||||
|
self.lastChromeTabURL = ""
|
||||||
|
self.stickySource = .mediaRemoteAdapter
|
||||||
|
self.updatePlaybackTimer()
|
||||||
|
self.rearmPoll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func humanReadableSource(bundleId: String) -> String {
|
||||||
|
switch bundleId {
|
||||||
|
case "com.apple.Music": return "Apple Music"
|
||||||
|
case "com.spotify.client": return "Spotify"
|
||||||
|
case "com.google.Chrome": return "Chrome"
|
||||||
|
case "com.apple.Safari": return "Safari"
|
||||||
|
case "com.microsoft.edgemac": return "Edge"
|
||||||
|
case "com.apple.podcasts": return "Podcasts"
|
||||||
|
case "com.apple.tv": return "Apple TV"
|
||||||
|
default:
|
||||||
|
return bundleId.components(separatedBy: ".").last?.capitalized ?? "System Media"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func apply(mediaRemote info: MediaRemoteInfo) {
|
private func apply(mediaRemote info: MediaRemoteInfo) {
|
||||||
self.title = info.title
|
self.title = info.title
|
||||||
self.artist = info.artist
|
self.artist = info.artist
|
||||||
@ -583,6 +661,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
rearmPoll() // isPlaying flipped → maybe change poll cadence
|
rearmPoll() // isPlaying flipped → maybe change poll cadence
|
||||||
|
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.togglePlay()
|
SpotifyAppleScript.togglePlay()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -600,6 +680,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
func nextTrack() {
|
func nextTrack() {
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.next()
|
SpotifyAppleScript.next()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -615,6 +697,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
|
|
||||||
func previousTrack() {
|
func previousTrack() {
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.previous()
|
SpotifyAppleScript.previous()
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
@ -633,6 +717,8 @@ final class NowPlayingState: ObservableObject {
|
|||||||
updatePlaybackTimer()
|
updatePlaybackTimer()
|
||||||
|
|
||||||
switch stickySource {
|
switch stickySource {
|
||||||
|
case .mediaRemoteAdapter:
|
||||||
|
mediaRemoteAdapter?.seek(clamped)
|
||||||
case .spotify:
|
case .spotify:
|
||||||
SpotifyAppleScript.seek(to: clamped)
|
SpotifyAppleScript.seek(to: clamped)
|
||||||
case .appleMusic:
|
case .appleMusic:
|
||||||
|
|||||||
323
Sources/sources/MediaRemoteAdapterSource.swift
Normal file
323
Sources/sources/MediaRemoteAdapterSource.swift
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
//
|
||||||
|
// MediaRemoteAdapterSource.swift
|
||||||
|
// MioIsland Music Plugin
|
||||||
|
//
|
||||||
|
// Bypasses the macOS 15.4+ MRMediaRemoteGetNowPlayingInfo entitlement gate
|
||||||
|
// by running `mediaremote-adapter.pl` (BSD-3-Clause, by Jonas van den Berg)
|
||||||
|
// as a subprocess. The Perl script DynaLoader-loads the bundled
|
||||||
|
// MediaRemoteAdapter.framework binary, which in turn links against Apple's
|
||||||
|
// MediaRemote private framework. Because the entitlement check fires on the
|
||||||
|
// CALLING symbol — which on Apple's side is MR internals, not our process
|
||||||
|
// — the gate is skipped and we get the full now-playing payload.
|
||||||
|
//
|
||||||
|
// The subprocess emits one JSON object per state change to stdout (diff
|
||||||
|
// mode), debounced 50ms. We consume it line-by-line via a NSFileHandle read
|
||||||
|
// observer and update the MediaRemoteInfo callback on the main queue.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// - start() spawns the subprocess exactly once.
|
||||||
|
// - On SIGPIPE / stdout EOF / non-zero exit, we retry after a 2-second
|
||||||
|
// delay. After 3 consecutive crashes within 60s, we stop retrying and
|
||||||
|
// let NowPlayingState fall back to the legacy source chain.
|
||||||
|
// - stop() sends SIGTERM + waits up to 2s + SIGKILL if still alive.
|
||||||
|
//
|
||||||
|
// Credits: MediaRemoteAdapter.framework + mediaremote-adapter.pl
|
||||||
|
// Copyright (c) 2025 Jonas van den Berg. BSD-3-Clause.
|
||||||
|
// Bundled under Resources/mediaremote-adapter/ in this plugin.
|
||||||
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Stream payload (subset of adapter output)
|
||||||
|
|
||||||
|
/// Raw JSON shape emitted by the adapter in stream mode. Only the keys we
|
||||||
|
/// actually consume are decoded; the adapter also emits `contentItemIdentifier`,
|
||||||
|
/// `radioStationHash`, `timestamp`, etc. which we ignore.
|
||||||
|
private struct AdapterStreamPayload: Decodable {
|
||||||
|
var title: String?
|
||||||
|
var artist: String?
|
||||||
|
var album: String?
|
||||||
|
var duration: Double?
|
||||||
|
var elapsedTime: Double?
|
||||||
|
var playbackRate: Double?
|
||||||
|
var playing: Bool?
|
||||||
|
var bundleIdentifier: String?
|
||||||
|
/// Base64-encoded artwork data. JSONDecoder automatically decodes
|
||||||
|
/// when the Swift type is `Data` via default `.base64` strategy.
|
||||||
|
var artworkData: Data?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Source
|
||||||
|
|
||||||
|
final class MediaRemoteAdapterSource {
|
||||||
|
// Configuration
|
||||||
|
private let scriptPath: String
|
||||||
|
private let frameworkPath: String
|
||||||
|
private let debounceMs: Int
|
||||||
|
|
||||||
|
// Callback to NowPlayingState
|
||||||
|
/// Called on the main queue whenever the subprocess emits a payload
|
||||||
|
/// that results in a usable MediaRemoteInfo. Called with nil when the
|
||||||
|
/// subprocess dies and restart is disabled.
|
||||||
|
var onUpdate: ((MediaRemoteInfo) -> Void)?
|
||||||
|
|
||||||
|
// Process state
|
||||||
|
private var process: Process?
|
||||||
|
private var stdoutHandle: FileHandle?
|
||||||
|
private var stderrHandle: FileHandle?
|
||||||
|
private var lineBuffer = Data()
|
||||||
|
|
||||||
|
// Aggregated "current state" — adapter sends diffs, so we merge them
|
||||||
|
// ourselves. Apple Music frequently sends a playbackRate-only diff
|
||||||
|
// when the user pauses, so we need to remember title/artist from earlier.
|
||||||
|
private var currentInfo = MediaRemoteInfo()
|
||||||
|
|
||||||
|
// Crash / restart tracking
|
||||||
|
private var crashTimestamps: [Date] = []
|
||||||
|
private let maxCrashesPer60s = 3
|
||||||
|
private var restartWorkItem: DispatchWorkItem?
|
||||||
|
private var stopped = false
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
/// Initialises the source with paths resolved from the plugin bundle.
|
||||||
|
/// Returns nil if either path is missing — caller should fall back to
|
||||||
|
/// the legacy chain.
|
||||||
|
init?() {
|
||||||
|
// Resolve bundle that contains THIS source's compiled class. Using
|
||||||
|
// Bundle(for:) instead of Bundle.main because the plugin loads into
|
||||||
|
// the host's address space — Bundle.main is the host, not us.
|
||||||
|
let bundle = Bundle(for: PathResolverToken.self)
|
||||||
|
guard let script = bundle.path(forResource: "mediaremote-adapter",
|
||||||
|
ofType: "pl",
|
||||||
|
inDirectory: "mediaremote-adapter")
|
||||||
|
?? bundle.path(forResource: "mediaremote-adapter", ofType: "pl")
|
||||||
|
else {
|
||||||
|
NSLog("[mio-plugin-music] adapter script not found in bundle")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let resourcesRoot = (script as NSString).deletingLastPathComponent
|
||||||
|
let framework = (resourcesRoot as NSString)
|
||||||
|
.appendingPathComponent("MediaRemoteAdapter.framework")
|
||||||
|
guard FileManager.default.fileExists(atPath: framework) else {
|
||||||
|
NSLog("[mio-plugin-music] adapter framework not found at \(framework)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.scriptPath = script
|
||||||
|
self.frameworkPath = framework
|
||||||
|
self.debounceMs = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
stopped = false
|
||||||
|
spawn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopped = true
|
||||||
|
restartWorkItem?.cancel()
|
||||||
|
restartWorkItem = nil
|
||||||
|
terminateProcess()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func terminateProcess() {
|
||||||
|
guard let proc = process else { return }
|
||||||
|
process = nil
|
||||||
|
stdoutHandle?.readabilityHandler = nil
|
||||||
|
stdoutHandle = nil
|
||||||
|
stderrHandle?.readabilityHandler = nil
|
||||||
|
stderrHandle = nil
|
||||||
|
if proc.isRunning {
|
||||||
|
proc.terminate()
|
||||||
|
// Give it 500ms to exit cleanly, then force.
|
||||||
|
DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
if proc.isRunning {
|
||||||
|
kill(proc.processIdentifier, SIGKILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spawn
|
||||||
|
|
||||||
|
private func spawn() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
proc.arguments = [
|
||||||
|
scriptPath,
|
||||||
|
frameworkPath,
|
||||||
|
"stream",
|
||||||
|
"--debounce=\(debounceMs)"
|
||||||
|
]
|
||||||
|
|
||||||
|
// Minimize inherited env — Perl / DynaLoader doesn't need our full
|
||||||
|
// shell environment. Keep PATH so Perl can find its own modules.
|
||||||
|
proc.environment = [
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"LANG": "en_US.UTF-8"
|
||||||
|
]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
proc.standardOutput = outPipe
|
||||||
|
proc.standardError = errPipe
|
||||||
|
proc.terminationHandler = { [weak self] p in
|
||||||
|
DispatchQueue.main.async { self?.handleTermination(status: p.terminationStatus) }
|
||||||
|
}
|
||||||
|
|
||||||
|
stdoutHandle = outPipe.fileHandleForReading
|
||||||
|
stderrHandle = errPipe.fileHandleForReading
|
||||||
|
|
||||||
|
stdoutHandle?.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
DispatchQueue.main.async { self?.ingestStdout(data) }
|
||||||
|
}
|
||||||
|
stderrHandle?.readabilityHandler = { [weak self] handle in
|
||||||
|
let data = handle.availableData
|
||||||
|
guard !data.isEmpty else { return }
|
||||||
|
if let str = String(data: data, encoding: .utf8) {
|
||||||
|
NSLog("[mio-plugin-music] adapter stderr: \(str.trimmingCharacters(in: .whitespacesAndNewlines))")
|
||||||
|
}
|
||||||
|
_ = self
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
process = proc
|
||||||
|
NSLog("[mio-plugin-music] adapter spawned pid=\(proc.processIdentifier)")
|
||||||
|
} catch {
|
||||||
|
NSLog("[mio-plugin-music] adapter spawn failed: \(error)")
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stdout ingestion
|
||||||
|
|
||||||
|
private func ingestStdout(_ chunk: Data) {
|
||||||
|
lineBuffer.append(chunk)
|
||||||
|
// Adapter emits newline-delimited JSON. Parse as many complete
|
||||||
|
// lines as the buffer currently holds.
|
||||||
|
while let nlRange = lineBuffer.firstRange(of: Data([0x0A])) {
|
||||||
|
let lineData = lineBuffer.prefix(upTo: nlRange.lowerBound)
|
||||||
|
lineBuffer.removeSubrange(0 ..< nlRange.upperBound)
|
||||||
|
guard !lineData.isEmpty else { continue }
|
||||||
|
parseLine(Data(lineData))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseLine(_ data: Data) {
|
||||||
|
do {
|
||||||
|
let payload = try JSONDecoder().decode(AdapterStreamPayload.self, from: data)
|
||||||
|
merge(payload)
|
||||||
|
if currentInfo.hasTrack {
|
||||||
|
onUpdate?(currentInfo)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not every line is a full object — stream mode sometimes emits
|
||||||
|
// null or empty diff when source goes away. Silent on DecodingError
|
||||||
|
// unless it looks like a real crash (non-JSON prefix).
|
||||||
|
if let preview = String(data: data.prefix(60), encoding: .utf8),
|
||||||
|
!preview.hasPrefix("{") && !preview.hasPrefix("null") {
|
||||||
|
NSLog("[mio-plugin-music] adapter: unparseable line: \(preview)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merge an adapter diff into `currentInfo`. Only overwrite fields that
|
||||||
|
/// the payload explicitly provided — leave the rest at their previous
|
||||||
|
/// value so a "just the elapsed time changed" diff doesn't erase title.
|
||||||
|
private func merge(_ payload: AdapterStreamPayload) {
|
||||||
|
if let title = payload.title { currentInfo.title = title }
|
||||||
|
if let artist = payload.artist { currentInfo.artist = artist }
|
||||||
|
if let album = payload.album { currentInfo.album = album }
|
||||||
|
if let duration = payload.duration { currentInfo.duration = duration }
|
||||||
|
if let elapsed = payload.elapsedTime { currentInfo.elapsedTime = elapsed }
|
||||||
|
if let rate = payload.playbackRate { currentInfo.playbackRate = rate }
|
||||||
|
if let playing = payload.playing {
|
||||||
|
currentInfo.isPlaying = playing
|
||||||
|
} else if let rate = payload.playbackRate {
|
||||||
|
// Some diffs only ship playbackRate; derive isPlaying.
|
||||||
|
currentInfo.isPlaying = rate > 0
|
||||||
|
}
|
||||||
|
if let bid = payload.bundleIdentifier { currentInfo.bundleIdentifier = bid }
|
||||||
|
if let art = payload.artworkData, !art.isEmpty {
|
||||||
|
currentInfo.artwork = NSImage(data: art)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Termination / restart
|
||||||
|
|
||||||
|
private func handleTermination(status: Int32) {
|
||||||
|
NSLog("[mio-plugin-music] adapter terminated (status=\(status))")
|
||||||
|
stdoutHandle?.readabilityHandler = nil
|
||||||
|
stdoutHandle = nil
|
||||||
|
stderrHandle?.readabilityHandler = nil
|
||||||
|
stderrHandle = nil
|
||||||
|
process = nil
|
||||||
|
currentInfo = MediaRemoteInfo()
|
||||||
|
lineBuffer.removeAll()
|
||||||
|
scheduleRestart()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRestart() {
|
||||||
|
guard !stopped else { return }
|
||||||
|
let now = Date()
|
||||||
|
crashTimestamps.append(now)
|
||||||
|
crashTimestamps.removeAll { now.timeIntervalSince($0) > 60 }
|
||||||
|
if crashTimestamps.count > maxCrashesPer60s {
|
||||||
|
NSLog("[mio-plugin-music] adapter crashed \(crashTimestamps.count) times in 60s — giving up")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Exponential-ish backoff: 1s, 2s, 4s by crash count within the window.
|
||||||
|
let delay = min(4.0, pow(2.0, Double(crashTimestamps.count - 1)))
|
||||||
|
let work = DispatchWorkItem { [weak self] in self?.spawn() }
|
||||||
|
restartWorkItem = work
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Transport (fire-and-forget short-lived subprocess)
|
||||||
|
|
||||||
|
/// Send a MediaRemote command ID. Uses a short-lived subprocess
|
||||||
|
/// rather than a persistent control channel — keeps the architecture
|
||||||
|
/// simple and matches how Atoll does it.
|
||||||
|
/// Known commands (MRCommand IDs per adapter Perl examples):
|
||||||
|
/// 0=play, 1=pause, 2=togglePlayPause, 3=stop, 4=next, 5=previous
|
||||||
|
func sendCommand(_ id: Int) {
|
||||||
|
runOneShot(["send", String(id)])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek to position in seconds. Adapter takes microseconds, so *1e6.
|
||||||
|
func seek(_ seconds: Double) {
|
||||||
|
let micros = Int64(max(0, seconds) * 1_000_000)
|
||||||
|
runOneShot(["seek", String(micros)])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runOneShot(_ args: [String]) {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||||
|
proc.arguments = [scriptPath, frameworkPath] + args
|
||||||
|
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
|
||||||
|
let devnull = FileHandle(forWritingAtPath: "/dev/null")
|
||||||
|
proc.standardOutput = devnull
|
||||||
|
proc.standardError = devnull
|
||||||
|
do {
|
||||||
|
try proc.run()
|
||||||
|
} catch {
|
||||||
|
NSLog("[mio-plugin-music] adapter one-shot failed: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dummy class used only as a `Bundle(for:)` anchor so we can find our own
|
||||||
|
// plugin bundle without relying on Bundle.main (which is the host app).
|
||||||
|
private final class PathResolverToken {}
|
||||||
20
build.sh
20
build.sh
@ -29,8 +29,24 @@ swiftc \
|
|||||||
# Copy Info.plist
|
# Copy Info.plist
|
||||||
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||||
|
|
||||||
# Ad-hoc sign
|
# Bundle the MediaRemoteAdapter subprocess payload (Atoll-style).
|
||||||
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
# Resources/ contains `MediaRemoteAdapter.framework` + `mediaremote-adapter.pl`.
|
||||||
|
# Both are BSD-3-Clause by Jonas van den Berg (see LICENSE-THIRD-PARTY.md).
|
||||||
|
# We copy Resources/* into Contents/Resources so MediaRemoteAdapterSource
|
||||||
|
# can find them via Bundle(for:).path(forResource:ofType:).
|
||||||
|
if [ -d "Resources" ]; then
|
||||||
|
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
|
||||||
|
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
|
||||||
|
# Preserve framework executable bit (cp -R should, but be defensive)
|
||||||
|
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/MediaRemoteAdapter.framework/MediaRemoteAdapter" 2>/dev/null || true
|
||||||
|
chmod +x "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/mediaremote-adapter.pl"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ad-hoc sign the WHOLE bundle including the nested framework. Passing
|
||||||
|
# --deep traverses nested code signatures and re-signs them with our
|
||||||
|
# ad-hoc identity so the framework loads without Gatekeeper complaints
|
||||||
|
# when the plugin is dropped into ~/.config/codeisland/plugins/.
|
||||||
|
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user