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>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.5</string>
|
||||
<string>2.1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>7</string>
|
||||
<string>8</string>
|
||||
<key>NSPrincipalClass</key>
|
||||
<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 {
|
||||
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 spotify
|
||||
case appleMusic
|
||||
@ -92,6 +96,11 @@ final class NowPlayingState: ObservableObject {
|
||||
// MARK: - Private
|
||||
|
||||
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 playbackTimer: Timer?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@ -130,6 +139,17 @@ final class NowPlayingState: ObservableObject {
|
||||
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.
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
@ -209,6 +229,8 @@ final class NowPlayingState: ObservableObject {
|
||||
wsCenter.removeObserver(token)
|
||||
}
|
||||
workspaceObservers.removeAll()
|
||||
|
||||
mediaRemoteAdapter?.stop()
|
||||
}
|
||||
|
||||
@objc private func spotifyStateChanged() {
|
||||
@ -245,6 +267,9 @@ final class NowPlayingState: ObservableObject {
|
||||
|
||||
private func adaptivePollInterval() -> TimeInterval {
|
||||
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 .chrome where isPlaying: return 1.2
|
||||
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 {
|
||||
// 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
|
||||
// workspace API four times.
|
||||
let spotifyRunning = SpotifyAppleScript.isRunning
|
||||
@ -425,6 +461,7 @@ final class NowPlayingState: ObservableObject {
|
||||
) -> Bool {
|
||||
switch kind {
|
||||
case .none: return false
|
||||
case .mediaRemoteAdapter: return false // push-only, not candidate for pull-fetch
|
||||
case .mediaRemote: return !mrBlocked
|
||||
case .spotify: return allowAppleScript && spotifyRunning
|
||||
case .appleMusic: return allowAppleScript && musicRunning
|
||||
@ -458,6 +495,10 @@ final class NowPlayingState: ObservableObject {
|
||||
case .none:
|
||||
return nil
|
||||
|
||||
case .mediaRemoteAdapter:
|
||||
// Push-only source; pull-fetch is a no-op.
|
||||
return nil
|
||||
|
||||
case .mediaRemote:
|
||||
let info: MediaRemoteInfo? = await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
@ -501,6 +542,43 @@ final class NowPlayingState: ObservableObject {
|
||||
|
||||
// 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) {
|
||||
self.title = info.title
|
||||
self.artist = info.artist
|
||||
@ -583,6 +661,8 @@ final class NowPlayingState: ObservableObject {
|
||||
rearmPoll() // isPlaying flipped → maybe change poll cadence
|
||||
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.sendCommand(2) // kMRATogglePlayPause
|
||||
case .spotify:
|
||||
SpotifyAppleScript.togglePlay()
|
||||
case .appleMusic:
|
||||
@ -600,6 +680,8 @@ final class NowPlayingState: ObservableObject {
|
||||
|
||||
func nextTrack() {
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.sendCommand(4) // kMRANextTrack
|
||||
case .spotify:
|
||||
SpotifyAppleScript.next()
|
||||
case .appleMusic:
|
||||
@ -615,6 +697,8 @@ final class NowPlayingState: ObservableObject {
|
||||
|
||||
func previousTrack() {
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.sendCommand(5) // kMRAPreviousTrack
|
||||
case .spotify:
|
||||
SpotifyAppleScript.previous()
|
||||
case .appleMusic:
|
||||
@ -633,6 +717,8 @@ final class NowPlayingState: ObservableObject {
|
||||
updatePlaybackTimer()
|
||||
|
||||
switch stickySource {
|
||||
case .mediaRemoteAdapter:
|
||||
mediaRemoteAdapter?.seek(clamped)
|
||||
case .spotify:
|
||||
SpotifyAppleScript.seek(to: clamped)
|
||||
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
|
||||
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
|
||||
|
||||
# Ad-hoc sign
|
||||
codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
|
||||
# Bundle the MediaRemoteAdapter subprocess payload (Atoll-style).
|
||||
# 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}"
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user