From 15cb43a0dd13f0a5ac53ee1ca70d4ed6ce694d0b Mon Sep 17 00:00:00 2001 From: valentino Date: Sun, 12 Apr 2026 22:54:37 +0800 Subject: [PATCH 1/3] Add Netease Music auto-pause support --- BGMApp/BGMApp/BGMAppDelegate.mm | 19 +- .../BGMApp/Music Players/BGMMusicPlayers.mm | 319 +++++++++++++++++- .../UnitTests/BGMMusicPlayersUnitTests.mm | 18 +- DEVELOPING.md | 12 +- README.md | 19 +- scripts/test-netease-autopause.sh | 156 +++++++++ 6 files changed, 536 insertions(+), 7 deletions(-) create mode 100755 scripts/test-netease-autopause.sh diff --git a/BGMApp/BGMApp/BGMAppDelegate.mm b/BGMApp/BGMApp/BGMAppDelegate.mm index c2bccba6..60c41ee0 100644 --- a/BGMApp/BGMApp/BGMAppDelegate.mm +++ b/BGMApp/BGMApp/BGMAppDelegate.mm @@ -51,6 +51,7 @@ static NSString* const kOptNoPersistentData = @"--no-persistent-data"; static NSString* const kOptShowDockIcon = @"--show-dock-icon"; +static NSString* const kOptSafeAudioMode = @"--safe-audio-mode"; @implementation BGMAppDelegate { // The button in the system status bar that shows the main menu. @@ -121,8 +122,13 @@ - (void) applicationDidFinishLaunching:(NSNotification*)aNotification { // Skip this if we're compiling on a version of macOS before 10.14 as won't compile and it // isn't needed. + if ([NSProcessInfo.processInfo.arguments indexOfObject:kOptSafeAudioMode] != NSNotFound) { + DebugMsg("BGMAppDelegate::applicationDidFinishLaunching: Safe audio mode enabled " + "from launch argument %s", kOptSafeAudioMode.UTF8String); + [self continueLaunchAfterInputDevicePermissionGranted]; + } #if MAC_OS_X_VERSION_MAX_ALLOWED >= 101400 // MAC_OS_X_VERSION_10_14 - if (@available(macOS 10.14, *)) { + else if (@available(macOS 10.14, *)) { // On macOS 10.14+ we need to get the user's permission to use input devices before we can // use BGMDevice for playthrough (see BGMPlayThrough), so we wait until they've given it // before making BGMDevice the default device. This way, if the user is playing audio when @@ -146,7 +152,7 @@ - (void) applicationDidFinishLaunching:(NSNotification*)aNotification { "audio.\n\nYou can grant the permission by going to " "System Preferences > Security and Privacy > " "Microphone and checking the box for Background Music." - exitAfterMessageDismissed:YES]; + exitAfterMessageDismissed:YES]; } }); }]; @@ -161,11 +167,19 @@ - (void) applicationDidFinishLaunching:(NSNotification*)aNotification { } - (void) continueLaunchAfterInputDevicePermissionGranted { + BOOL safeAudioModeEnabled = [NSProcessInfo.processInfo.arguments + indexOfObject:kOptSafeAudioMode] != NSNotFound; + // Choose an output device for BGMApp to use to play audio. if (![self setInitialOutputDevice]) { return; } + if (safeAudioModeEnabled) { + DebugMsg("BGMAppDelegate::continueLaunchAfterInputDevicePermissionGranted: " + "Safe audio mode enabled; using normal default-output routing."); + } + // Make BGMDevice the default device. [self setBGMDeviceAsDefault]; @@ -541,4 +555,3 @@ - (void) menu:(NSMenu*)menu willHighlightItem:(NSMenuItem* __nullable)item { @end #pragma clang assume_nonnull end - diff --git a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm index 39a5c9c6..57efb254 100644 --- a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm +++ b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm @@ -36,10 +36,327 @@ #import "BGMSwinsian.h" #import "BGMMusic.h" #import "BGMGooglePlayMusicDesktopPlayer.h" +#import #pragma clang assume_nonnull begin +// Netease Cloud Music support uses System Events because this app does not expose a normal scripting +// dictionary in this project. +static NSString* const kNeteaseMusicBundleID = @"com.netease.163music"; +static NSString* const kSystemEventsBundleID = @"com.apple.systemevents"; +static NSString* const kNeteaseMusicProcessName = @"NeteaseMusic"; +static NSString* const kNeteaseMusicControlMenuEnglish = @"Controls"; +static NSString* const kNeteaseMusicControlMenuEnglishAlt = @"Control"; +static NSString* const kNeteaseMusicControlMenuChinese = @"控制"; +static NSString* const kNeteaseMusicControlMenuChineseAlt = @"播放控制"; +static NSString* const kNeteaseMusicPauseEnglish = @"Pause"; +static NSString* const kNeteaseMusicPauseEnglish2 = @"Pause Playback"; +static NSString* const kNeteaseMusicPauseChinese = @"暂停"; +static NSString* const kNeteaseMusicPauseChinese2 = @"暂停播放"; +static NSString* const kNeteaseMusicPlayEnglish = @"Play"; +static NSString* const kNeteaseMusicPlayEnglish2 = @"Resume"; +static NSString* const kNeteaseMusicPlayChinese = @"播放"; +static NSString* const kNeteaseMusicPlayChinese2 = @"继续播放"; + +static NSArray* const kNeteaseMusicControlMenuLabels = @[ + kNeteaseMusicControlMenuEnglish, + kNeteaseMusicControlMenuEnglishAlt, + kNeteaseMusicControlMenuChinese, + kNeteaseMusicControlMenuChineseAlt +]; +static NSArray* const kNeteaseMusicPauseLabels = @[ + kNeteaseMusicPauseEnglish, + kNeteaseMusicPauseEnglish2, + kNeteaseMusicPauseChinese, + kNeteaseMusicPauseChinese2 +]; +static NSArray* const kNeteaseMusicPlayLabels = @[ + kNeteaseMusicPlayEnglish, + kNeteaseMusicPlayEnglish2, + kNeteaseMusicPlayChinese, + kNeteaseMusicPlayChinese2 +]; + +static void BGMRequestAccessibilityPermission(void) { + NSDictionary* options = @{ (__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES }; + AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options); +} + +static void BGMRequestAutomationPermissionForBundle(NSString* bundleID, NSString* playerName) { +#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101400 + if (@available(macOS 10.14, *)) { + NSAppleEventDescriptor* targetDescriptor = + [NSAppleEventDescriptor descriptorWithBundleIdentifier:bundleID]; + if (!targetDescriptor) { + DebugMsg("BGMRequestAutomationPermissionForBundle: failed to create descriptor for %s", + playerName.UTF8String); + return; + } + + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + OSStatus status = + AEDeterminePermissionToAutomateTarget(targetDescriptor.aeDesc, + typeWildCard, + typeWildCard, + true); + if (status == noErr) { + DebugMsg("BGMRequestAutomationPermissionForBundle: permission granted for %s (%s)", + playerName.UTF8String, + bundleID.UTF8String); + } else { + DebugMsg("BGMRequestAutomationPermissionForBundle: permission denied for %s (%s), status=%d", + playerName.UTF8String, + bundleID.UTF8String, + status); + } + }); + } else { + #pragma unused(bundleID, playerName) + } +#else + #pragma unused(bundleID, playerName) +#endif +} + +static inline NSString* NeteaseMusicEscapeAppleScriptString(NSString* input) { + return [[input stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] + stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""]; +} + +@interface BGMNeteaseMusic : BGMMusicPlayerBase + ++ (NSUUID*) sharedMusicPlayerID; + +@end + +@implementation BGMNeteaseMusic + ++ (NSUUID*) sharedMusicPlayerID { + NSUUID* musicPlayerID = [BGMMusicPlayerBase makeID:@"F4E4ABEF-C775-4284-981B-A2B14D19A342"]; + return (NSUUID*)musicPlayerID; +} + +- (instancetype) init { + if ((self = [super initWithMusicPlayerID:[BGMNeteaseMusic sharedMusicPlayerID] + name:@"网易云音乐" + bundleID:kNeteaseMusicBundleID])) { + // no additional init + } + + return self; +} + +- (void) wasSelected { + [super wasSelected]; + + // Netease control goes through System Events UI scripting, so request both permissions when the + // user actually chooses this player instead of prompting on every app launch. + BGMRequestAutomationPermissionForBundle(kSystemEventsBundleID, @"System Events"); + BGMRequestAccessibilityPermission(); +} + +- (NSString* __nullable) currentControlState { + NSString* controlMenuCandidates = + kNeteaseMusicControlMenuLabels.count + ? [NSString stringWithFormat:@"\"%@\"", NeteaseMusicEscapeAppleScriptString(kNeteaseMusicControlMenuLabels[0])] + : @"\"Controls\""; + for (NSUInteger i = 1; i < kNeteaseMusicControlMenuLabels.count; i++) { + controlMenuCandidates = [controlMenuCandidates stringByAppendingFormat: + @", \"%@\"", + NeteaseMusicEscapeAppleScriptString(kNeteaseMusicControlMenuLabels[i])]; + } + + NSString* controlStateCandidates = + kNeteaseMusicPauseLabels.count + ? [NSString stringWithFormat:@"\"%@\"", NeteaseMusicEscapeAppleScriptString(kNeteaseMusicPauseLabels[0])] + : @"\"Pause\""; + for (NSUInteger i = 1; i < kNeteaseMusicPauseLabels.count; i++) { + controlStateCandidates = [controlStateCandidates stringByAppendingFormat: + @", \"%@\"", + NeteaseMusicEscapeAppleScriptString(kNeteaseMusicPauseLabels[i])]; + } + NSString* playStateCandidates = + kNeteaseMusicPlayLabels.count + ? [NSString stringWithFormat:@"\"%@\"", NeteaseMusicEscapeAppleScriptString(kNeteaseMusicPlayLabels[0])] + : @"\"Play\""; + for (NSUInteger i = 1; i < kNeteaseMusicPlayLabels.count; i++) { + playStateCandidates = [playStateCandidates stringByAppendingFormat: + @", \"%@\"", + NeteaseMusicEscapeAppleScriptString(kNeteaseMusicPlayLabels[i])]; + } + + NSString* source = + [NSString stringWithFormat: + @"tell application \"System Events\"\n" + @"if not (exists process \"%@\") then return \"\"\n" + @"tell process \"%@\"\n" + @" set controlMenuName to missing value\n" + @" repeat with candidateMenu in {%@}\n" + @" if exists menu bar item (contents of candidateMenu) of menu bar 1 then\n" + @" set controlMenuName to contents of candidateMenu\n" + @" exit repeat\n" + @" end if\n" + @" end repeat\n" + @" if controlMenuName is missing value then return \"\"\n" + @" set controlMenu to menu 1 of menu bar item controlMenuName of menu bar 1\n" + @" repeat with candidateItem in {%@}\n" + @" if exists menu item (contents of candidateItem) of controlMenu then\n" + @" return contents of candidateItem\n" + @" end if\n" + @" end repeat\n" + @" repeat with candidateItem in {%@}\n" + @" if exists menu item (contents of candidateItem) of controlMenu then\n" + @" return contents of candidateItem\n" + @" end if\n" + @" end repeat\n" + @" return \"\"\n" + @"end tell\n" + @"end tell\n", + kNeteaseMusicProcessName, + kNeteaseMusicProcessName, + controlMenuCandidates, + controlStateCandidates, + playStateCandidates]; + + NSDictionary* __nullable error = nil; + NSAppleScript* script = [[NSAppleScript alloc] initWithSource:source]; + NSAppleEventDescriptor* result = [script executeAndReturnError:&error]; + + if (error) { + NSString* errString = error ? [error description] : @""; + DebugMsg("BGMNeteaseMusic::currentControlState: System Events returned error=%s", + errString.UTF8String); + return nil; + } + + return result.stringValue; +} + +- (BOOL) clickControlMenuItemForCandidates:(NSArray*)itemNames { + if (!itemNames.count) { + return NO; + } + + NSString* controlMenuCandidates = + kNeteaseMusicControlMenuLabels.count + ? [NSString stringWithFormat:@"\"%@\"", NeteaseMusicEscapeAppleScriptString(kNeteaseMusicControlMenuLabels[0])] + : @"\"Controls\""; + for (NSUInteger i = 1; i < kNeteaseMusicControlMenuLabels.count; i++) { + controlMenuCandidates = [controlMenuCandidates stringByAppendingFormat: + @", \"%@\"", + NeteaseMusicEscapeAppleScriptString(kNeteaseMusicControlMenuLabels[i])]; + } + + NSString* targetCandidates = + [NSString stringWithFormat:@"\"%@\"", NeteaseMusicEscapeAppleScriptString(itemNames[0])]; + for (NSUInteger i = 1; i < itemNames.count; i++) { + targetCandidates = [targetCandidates stringByAppendingFormat: + @", \"%@\"", + NeteaseMusicEscapeAppleScriptString(itemNames[i])]; + } + + NSString* source = + [NSString stringWithFormat: + @"tell application \"System Events\"\n" + @"if not (exists process \"%@\") then return \"\"\n" + @"tell process \"%@\"\n" + @" set controlMenuName to missing value\n" + @" repeat with candidateMenu in {%@}\n" + @" if exists menu bar item (contents of candidateMenu) of menu bar 1 then\n" + @" set controlMenuName to contents of candidateMenu\n" + @" exit repeat\n" + @" end if\n" + @" end repeat\n" + @" if controlMenuName is not missing value then\n" + @" click menu bar item controlMenuName of menu bar 1\n" + @" delay 0.1\n" + @" set controlMenu to menu 1 of menu bar item controlMenuName of menu bar 1\n" + @" repeat with candidateItem in {%@}\n" + @" if exists menu item (contents of candidateItem) of controlMenu then\n" + @" click menu item (contents of candidateItem) of controlMenu\n" + @" delay 0.05\n" + @" return \"ok\"\n" + @" end if\n" + @" end repeat\n" + @" key code 53\n" + @" end if\n" + @" return \"\"\n" + @"end tell\n" + @"end tell\n", + kNeteaseMusicProcessName, + kNeteaseMusicProcessName, + controlMenuCandidates, + targetCandidates]; + + NSDictionary* __nullable error = nil; + NSAppleScript* script = [[NSAppleScript alloc] initWithSource:source]; + NSAppleEventDescriptor* result = [script executeAndReturnError:&error]; + + if (error) { + NSString* errString = error ? [error description] : @""; + DebugMsg("BGMNeteaseMusic::clickControlMenuItem: System Events returned error=%s", + errString.UTF8String); + return NO; + } + + return (result != nil && result.stringValue && [result.stringValue length] > 0); +} + +- (BOOL) isRunning { + return [[NSRunningApplication runningApplicationsWithBundleIdentifier:kNeteaseMusicBundleID] count] > 0; +} + +- (BOOL) isPlaying { + if (!self.running) { + return NO; + } + + NSString* state = [self currentControlState]; + return ([state isEqualToString:kNeteaseMusicPauseEnglish] || + [state isEqualToString:kNeteaseMusicPauseEnglish2] || + [state isEqualToString:kNeteaseMusicPauseChinese] || + [state isEqualToString:kNeteaseMusicPauseChinese2]); +} + +- (BOOL) isPaused { + if (!self.running) { + return NO; + } + + NSString* state = [self currentControlState]; + return ([state isEqualToString:kNeteaseMusicPlayEnglish] || + [state isEqualToString:kNeteaseMusicPlayEnglish2] || + [state isEqualToString:kNeteaseMusicPlayChinese] || + [state isEqualToString:kNeteaseMusicPlayChinese2]); +} + +- (BOOL) pause { + // isPlaying checks isRunning, so we don't need to check it here. + BOOL wasPlaying = self.playing; + + if (wasPlaying) { + DebugMsg("BGMNeteaseMusic::pause: Pausing Netease Cloud Music"); + return [self clickControlMenuItemForCandidates:kNeteaseMusicPauseLabels]; + } + + return NO; +} + +- (BOOL) unpause { + // isPaused checks isRunning, so we don't need to check it here. + BOOL wasPaused = self.paused; + + if (wasPaused) { + DebugMsg("BGMNeteaseMusic::unpause: Unpausing Netease Cloud Music"); + return [self clickControlMenuItemForCandidates:kNeteaseMusicPlayLabels]; + } + + return NO; +} + +@end + @implementation BGMMusicPlayers { BGMAudioDeviceManager* audioDevices; BGMUserDefaults* userDefaults; @@ -53,6 +370,7 @@ - (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices // it to this array. NSArray>* mpClasses = @[ [BGMVOX class], [BGMVLC class], + [BGMNeteaseMusic class], [BGMSpotify class], [BGMiTunes class], [BGMDecibel class], @@ -264,4 +582,3 @@ - (void) updateBGMDeviceMusicPlayerProperties { @end #pragma clang assume_nonnull end - diff --git a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm index 321cb291..f2c69944 100644 --- a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm +++ b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm @@ -182,6 +182,23 @@ - (void) testSelectedMusicPlayerInUserDefaults { XCTAssertEqualObjects(players.selectedMusicPlayer.name, @"Spotify"); } +- (void) testDefaultPlayerListIncludesNeteaseCloudMusic { + BGMMusicPlayers* players = [[BGMMusicPlayers alloc] initWithAudioDevices:devices + userDefaults:defaults]; + + XCTAssertGreaterThanOrEqual(players.musicPlayers.count, 9); + + BOOL hasNetease = NO; + for (id player in players.musicPlayers) { + if ([player.name isEqualToString:@"网易云音乐"]) { + hasNetease = YES; + break; + } + } + + XCTAssertTrue(hasNetease); +} + - (void) testUnrecognizedSelectedMusicPlayerInUserDefaults { // If there's an unrecognized ID in user defaults, the default music player should be selected. defaults.selectedPlayerID = [[NSUUID alloc] initWithUUIDString:@"11111111-1111-1111-0000-000000000000"]; @@ -214,4 +231,3 @@ - (void) testSelectedMusicPlayerInBGMDeviceProperties { // TODO: Test setting the selectedMusicPlayer property @end - diff --git a/DEVELOPING.md b/DEVELOPING.md index ecae5cce..a2c66d3c 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -214,6 +214,17 @@ To test with Address Sanitizer, you might have to set the environment var `ASAN_ around [Issue #647](https://github.com/google/sanitizers/issues/647). (In Xcode, go `Product` > `Scheme` > `Edit Scheme...`, select the Background Music scheme, and add the environment var in Run > Arguments.) +For end-to-end verification of the Netease Music auto-pause integration, run: + +```shell +./scripts/test-netease-autopause.sh +``` + +The script rebuilds the Debug app, refreshes `~/Applications/Background Music Debug.app`, resets the +relevant TCC entries, opens the Accessibility settings page, waits for manual authorization, then +launches Background Music, triggers competing audio with IINA and checks whether Netease Music +changes from `Pause` to `Play`. + ---- [1] It actually publishes two devices -- the main one and one for UI-related sounds, but you probably @@ -222,4 +233,3 @@ only need to know about the main one. [↩](#a1) [2] All, unless you're playing audio through a program that's set to always use a specific device or, for some reason, doesn't switch to the new default device right away. [↩](#a2) - diff --git a/README.md b/README.md index f4c3c4e6..a78b0639 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The auto-pause feature currently supports following music players: + [Hermes](http://hermesapp.org/) + [Swinsian](https://swinsian.com/) + [GPMDP](https://www.googleplaymusicdesktopplayer.com/) ++ [Netease Music](https://music.163.com/) Adding support for a new music player is usually straightforward.[1](#f1) If you don't know how to program, or just don't feel like it, feel free to [create an issue](https://github.com/kyleneideck/BackgroundMusic/issues/new). Otherwise, see @@ -157,6 +158,11 @@ and check the box next to it. Background Music doesn't actually listen to your m the permission because it gets your system audio from its virtual input device, which macOS counts as a microphone. (We're working on it in [#177](/../../issues/177).) +If you use auto-pause with **Netease Music**, you also need to allow **Background Music** under +`System Settings > Privacy & Security > Accessibility`. Background Music uses macOS UI scripting to +control Netease Music's **Controls** menu because Netease Music does not expose a native AppleScript +play/pause command. + If the volume slider for an app isn't working, try looking in `More Apps` for entries like `Some App (Helper)`. For some meeting or video chat apps, you may need to do this to change the current meeting volume. @@ -189,6 +195,18 @@ meeting volume. Many other issues are listed in [TODO.md](/TODO.md) and in [GitHub Issues](https://github.com/kyleneideck/BackgroundMusic/issues). +### Source-build auto-pause check for Netease Music + +If you're debugging a source build, you can run: + +```bash +./scripts/test-netease-autopause.sh +``` + +The script rebuilds the Debug app, refreshes `~/Applications/Background Music Debug.app`, opens the +Accessibility settings page, waits for you to authorize the app, then verifies that another audio +source causes Netease Music to pause. + # Related projects - [Core Audio User-Space Driver @@ -238,4 +256,3 @@ Licensed under [GPLv2](https://www.gnu.org/licenses/gpl-2.0.html), or any later Music needs (`isPlaying`, `isPaused`, `play` and `pause`), it can take significantly more effort to add. (And in some cases would require changes to the music player itself.) [↩](#a1) - diff --git a/scripts/test-netease-autopause.sh b/scripts/test-netease-autopause.sh new file mode 100755 index 00000000..67be536a --- /dev/null +++ b/scripts/test-netease-autopause.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_PATH="$ROOT_DIR/BGMApp/BGMApp.xcodeproj" +SCHEME_NAME="Background Music" +DERIVED_DATA_DIR="$HOME/Library/Developer/Xcode/DerivedData" +STABLE_APP_PATH="$HOME/Applications/Background Music Debug.app" +BUNDLE_ID="com.bearisdriving.BGM.App" +SAFE_AUDIO_ARG="--safe-audio-mode" +IINA_CLI="/Applications/IINA.app/Contents/MacOS/iina-cli" +IINA_SOUND="/System/Library/Sounds/Glass.aiff" +LOG_PREDICATE='process == "Background Music" AND (eventMessage CONTAINS[c] "BGMNeteaseMusic" OR eventMessage CONTAINS[c] "BGMAutoPauseMusic" OR eventMessage CONTAINS[c] "setBGMDeviceAsDefault")' + +find_built_app() { + find "$DERIVED_DATA_DIR" \ + -path '*/Build/Products/Debug/Background Music.app' \ + -type d \ + -print \ + | head -n 1 +} + +netease_menu_state() { + osascript <<'APPLESCRIPT' +tell application "System Events" + tell process "NeteaseMusic" + tell menu 1 of menu bar item "Controls" of menu bar 1 + repeat with candidateItem in {"Pause", "Play"} + if exists menu item (contents of candidateItem) then + return name of menu item (contents of candidateItem) + end if + end repeat + end tell + end tell +end tell +APPLESCRIPT +} + +current_audio_defaults() { + python3 <<'PY' +import json +import subprocess +import sys + +result = subprocess.run( + ["system_profiler", "-json", "SPAudioDataType"], + check=True, + capture_output=True, + text=True, +) +data = json.loads(result.stdout) +items = data.get("SPAudioDataType", []) +devices = items[0].get("_items", []) if items else [] + +default_output = None +default_system = None +for device in devices: + name = device.get("_name") + if device.get("coreaudio_default_audio_output_device") == "spaudio_yes": + default_output = name + if device.get("coreaudio_default_audio_system_device") == "spaudio_yes": + default_system = name + +print(f"default_output={default_output or ''}") +print(f"default_system={default_system or ''}") +PY +} + +print_recent_logs() { + /usr/bin/log show --style syslog --last 2m --predicate "$LOG_PREDICATE" +} + +cleanup() { + killall IINA 2>/dev/null || true +} + +trap cleanup EXIT + +echo "==> Building Debug app" +xcodebuild -project "$PROJECT_PATH" -scheme "$SCHEME_NAME" -configuration Debug + +BUILT_APP="$(find_built_app || true)" +if [[ -z "${BUILT_APP:-}" ]]; then + echo "Failed to locate built Debug app in DerivedData." >&2 + exit 1 +fi + +echo "==> Refreshing stable Debug app at: $STABLE_APP_PATH" +mkdir -p "$HOME/Applications" +rm -rf "$STABLE_APP_PATH" +ditto "$BUILT_APP" "$STABLE_APP_PATH" + +echo "==> Resetting TCC entries for $BUNDLE_ID" +tccutil reset Accessibility "$BUNDLE_ID" || true +tccutil reset AppleEvents "$BUNDLE_ID" || true + +echo "==> Opening Accessibility settings" +open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" +cat < Restarting Background Music Debug" +killall "Background Music" 2>/dev/null || true +open -na "$STABLE_APP_PATH" --args "$SAFE_AUDIO_ARG" +sleep 3 + +echo "==> Checking current audio defaults" +audio_defaults="$(current_audio_defaults)" +echo "$audio_defaults" +default_output="$(printf '%s\n' "$audio_defaults" | sed -n 's/^default_output=//p')" +default_system="$(printf '%s\n' "$audio_defaults" | sed -n 's/^default_system=//p')" + +if [[ "$default_output" != "Background Music" && "$default_system" != "Background Music" ]]; then + echo "Expected Background Music to be selected as an audio default device after launch." >&2 + exit 1 +fi + +echo "==> Checking 网易云音乐 menu state before trigger" +before_state="$(netease_menu_state)" +echo "Netease state before trigger: $before_state" +if [[ "$before_state" != "Pause" ]]; then + echo "Expected 网易云音乐 to be playing before test, but menu state is: $before_state" >&2 + exit 1 +fi + +if [[ ! -x "$IINA_CLI" ]]; then + echo "IINA CLI not found at $IINA_CLI" >&2 + exit 1 +fi + +echo "==> Triggering competing audio with IINA" +"$IINA_CLI" --no-stdin --keep-running --mpv-loop-file=inf "$IINA_SOUND" >/tmp/codex-iina.log 2>&1 & +sleep 4 + +after_state="$(netease_menu_state)" +echo "Netease state after trigger: $after_state" + +if [[ "$after_state" == "Play" ]]; then + echo "PASS: Background Music paused 网易云音乐." + exit 0 +fi + +echo "FAIL: 网易云音乐 was not paused." +echo +print_recent_logs +exit 1 From c80c47a99789f981526860cdd17d29b7ec3efb88 Mon Sep 17 00:00:00 2001 From: valentino Date: Sun, 12 Apr 2026 23:21:02 +0800 Subject: [PATCH 2/3] Document local Netease Music testing workflow --- DEVELOPING.md | 21 ++++++++++++++++++++- README.md | 33 ++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/DEVELOPING.md b/DEVELOPING.md index a2c66d3c..59708852 100644 --- a/DEVELOPING.md +++ b/DEVELOPING.md @@ -208,6 +208,23 @@ xcodebuild -project BGMApp/BGMApp.xcodeproj \ open "BGMApp/build/Debug/Background Music.app" ``` +For day-to-day local verification, it is usually easier to launch the DerivedData build product directly: + +```shell +xcodebuild -project BGMApp/BGMApp.xcodeproj -scheme "Background Music" -configuration Debug +open "$HOME/Library/Developer/Xcode/DerivedData/.../Build/Products/Debug/Background Music.app" +``` + +If you need stable TCC permissions for Accessibility testing, copy the built app to a fixed user-local +path and run that copy instead: + +```shell +mkdir -p "$HOME/Applications" +ditto "$HOME/Library/Developer/Xcode/DerivedData/.../Build/Products/Debug/Background Music.app" \ + "$HOME/Applications/Background Music.app" +open -a "$HOME/Applications/Background Music.app" +``` + You might have to delete `BGMApp/build` first if you're using `xcodebuild` and run into permissions problems. To test with Address Sanitizer, you might have to set the environment var `ASAN_OPTIONS=detect_odr_violation=0` to work @@ -225,6 +242,9 @@ relevant TCC entries, opens the Accessibility settings page, waits for manual au launches Background Music, triggers competing audio with IINA and checks whether Netease Music changes from `Pause` to `Play`. +This script is meant for local integration testing only. It does not grant Accessibility +automatically; macOS requires the user to approve that step in System Settings. + ---- [1] It actually publishes two devices -- the main one and one for UI-related sounds, but you probably @@ -232,4 +252,3 @@ only need to know about the main one. [↩](#a1) [2] All, unless you're playing audio through a program that's set to always use a specific device or, for some reason, doesn't switch to the new default device right away. [↩](#a2) - diff --git a/README.md b/README.md index a78b0639..53e55373 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,23 @@ brew install --cask background-music Just run `Applications > Background Music.app`! **Background Music** sets itself as your default output device under `System Settings > Sound` when it starts up (and sets it back on Quit). +If you're running a source build for local testing instead of the installed app, build `Background Music.app` with +Xcode or `xcodebuild` and then either: + +```bash +open "~/Library/Developer/Xcode/DerivedData/.../Build/Products/Debug/Background Music.app" +``` + +or copy it to a stable user-local path first: + +```bash +ditto "~/Library/Developer/Xcode/DerivedData/.../Build/Products/Debug/Background Music.app" \ + "$HOME/Applications/Background Music.app" +open -a "$HOME/Applications/Background Music.app" +``` + +Using `~/Applications/Background Music.app` makes macOS Accessibility permissions easier to keep stable across rebuilds. + ### Launch at Startup (Optional) Add **Background Music** to `System Settings > General > Login Items`. @@ -192,6 +209,9 @@ meeting volume. - **Some applications play notification sounds that are only just long enough to trigger an auto-pause.** - Increase the `kPauseDelayNSec` constant in [BGMAutoPauseMusic.mm](/BGMApp/BGMApp/BGMAutoPauseMusic.mm). It will increase your music's overlap time over other audio, so don't increase it too much. See [#5](https://github.com/kyleneideck/BackgroundMusic/issues/5) for details. +- **Auto-pausing Netease Music may briefly show its macOS menu bar items.** + - The current Netease Music integration uses macOS UI scripting to control the app because it does not expose a native AppleScript play/pause command. The integration works, but it can momentarily reveal Netease Music's menu while pausing or resuming playback. + Many other issues are listed in [TODO.md](/TODO.md) and in [GitHub Issues](https://github.com/kyleneideck/BackgroundMusic/issues). @@ -207,6 +227,18 @@ The script rebuilds the Debug app, refreshes `~/Applications/Background Music De Accessibility settings page, waits for you to authorize the app, then verifies that another audio source causes Netease Music to pause. +The test flow is: + +1. Rebuild the Debug app +2. Refresh a stable test copy in `~/Applications` +3. Reset `Accessibility` and `AppleEvents` for Background Music +4. Open `Privacy & Security > Accessibility` +5. Wait for you to authorize the test app and confirm Netease Music is playing +6. Launch Background Music +7. Verify the system audio defaults include `Background Music` +8. Trigger competing audio with IINA +9. Check whether Netease Music changes from `Pause` to `Play` + # Related projects - [Core Audio User-Space Driver @@ -255,4 +287,3 @@ Licensed under [GPLv2](https://www.gnu.org/licenses/gpl-2.0.html), or any later [1] However, if the music player doesn't support AppleScript, or doesn't support the events Background Music needs (`isPlaying`, `isPaused`, `play` and `pause`), it can take significantly more effort to add. (And in some cases would require changes to the music player itself.) [↩](#a1) - From 944d4ef9c24a4540bb29ace6cc068521022e8162 Mon Sep 17 00:00:00 2001 From: valentino Date: Sun, 12 Apr 2026 23:48:40 +0800 Subject: [PATCH 3/3] Use direct AXPress for Netease menu control --- .../BGMApp/Music Players/BGMMusicPlayers.mm | 87 ++++++++++++++++++- README.md | 4 +- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm index 57efb254..063fba0d 100644 --- a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm +++ b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm @@ -124,6 +124,73 @@ static void BGMRequestAutomationPermissionForBundle(NSString* bundleID, NSString stringByReplacingOccurrencesOfString:@"\"" withString:@"\\\""]; } +static id __nullable BGMNeteaseCopyAXAttributeValue(AXUIElementRef element, CFStringRef attribute) { + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); + if (error != kAXErrorSuccess || value == NULL) { + return nil; + } + + return CFBridgingRelease(value); +} + +static AXUIElementRef __nullable BGMNeteaseCopyMatchingMenuItem(NSArray* menuNames, + NSArray* itemNames) { + NSArray* runningApps = + [NSRunningApplication runningApplicationsWithBundleIdentifier:kNeteaseMusicBundleID]; + NSRunningApplication* app = runningApps.firstObject; + if (!app) { + return nil; + } + + AXUIElementRef appElement = AXUIElementCreateApplication(app.processIdentifier); + if (!appElement) { + return nil; + } + + AXUIElementRef result = NULL; + AXUIElementRef menuBar = (__bridge AXUIElementRef)BGMNeteaseCopyAXAttributeValue(appElement, + kAXMenuBarAttribute); + if (!menuBar) { + CFRelease(appElement); + return nil; + } + + NSArray* menuBarItems = BGMNeteaseCopyAXAttributeValue(menuBar, kAXChildrenAttribute); + for (id menuBarItemObj in menuBarItems) { + AXUIElementRef menuBarItem = (__bridge AXUIElementRef)menuBarItemObj; + NSString* title = BGMNeteaseCopyAXAttributeValue(menuBarItem, kAXTitleAttribute); + if (![menuNames containsObject:title]) { + continue; + } + + NSArray* menus = BGMNeteaseCopyAXAttributeValue(menuBarItem, kAXChildrenAttribute); + for (id menuObj in menus) { + AXUIElementRef menu = (__bridge AXUIElementRef)menuObj; + NSArray* menuItems = BGMNeteaseCopyAXAttributeValue(menu, kAXChildrenAttribute); + for (id menuItemObj in menuItems) { + AXUIElementRef menuItem = (__bridge AXUIElementRef)menuItemObj; + NSString* itemTitle = BGMNeteaseCopyAXAttributeValue(menuItem, kAXTitleAttribute); + if ([itemNames containsObject:itemTitle]) { + result = (AXUIElementRef)CFRetain(menuItem); + break; + } + } + + if (result) { + break; + } + } + + if (result) { + break; + } + } + + CFRelease(appElement); + return result; +} + @interface BGMNeteaseMusic : BGMMusicPlayerBase + (NSUUID*) sharedMusicPlayerID; @@ -150,8 +217,9 @@ - (instancetype) init { - (void) wasSelected { [super wasSelected]; - // Netease control goes through System Events UI scripting, so request both permissions when the - // user actually chooses this player instead of prompting on every app launch. + // Netease control prefers direct accessibility actions and falls back to System Events UI + // scripting, so request both permissions when the user actually chooses this player instead of + // prompting on every app launch. BGMRequestAutomationPermissionForBundle(kSystemEventsBundleID, @"System Events"); BGMRequestAccessibilityPermission(); } @@ -237,6 +305,21 @@ - (BOOL) clickControlMenuItemForCandidates:(NSArray*)itemNames { if (!itemNames.count) { return NO; } + + AXUIElementRef targetMenuItem = BGMNeteaseCopyMatchingMenuItem(kNeteaseMusicControlMenuLabels, + itemNames); + if (targetMenuItem) { + AXError axError = AXUIElementPerformAction(targetMenuItem, kAXPressAction); + CFRelease(targetMenuItem); + + if (axError == kAXErrorSuccess) { + DebugMsg("BGMNeteaseMusic::clickControlMenuItem: pressed AX menu item directly"); + return YES; + } + + DebugMsg("BGMNeteaseMusic::clickControlMenuItem: direct AXPress failed with error=%d, falling back", + axError); + } NSString* controlMenuCandidates = kNeteaseMusicControlMenuLabels.count diff --git a/README.md b/README.md index 53e55373..13b8565b 100644 --- a/README.md +++ b/README.md @@ -209,8 +209,8 @@ meeting volume. - **Some applications play notification sounds that are only just long enough to trigger an auto-pause.** - Increase the `kPauseDelayNSec` constant in [BGMAutoPauseMusic.mm](/BGMApp/BGMApp/BGMAutoPauseMusic.mm). It will increase your music's overlap time over other audio, so don't increase it too much. See [#5](https://github.com/kyleneideck/BackgroundMusic/issues/5) for details. -- **Auto-pausing Netease Music may briefly show its macOS menu bar items.** - - The current Netease Music integration uses macOS UI scripting to control the app because it does not expose a native AppleScript play/pause command. The integration works, but it can momentarily reveal Netease Music's menu while pausing or resuming playback. +- **Auto-pausing Netease Music can still fall back to macOS UI scripting if direct accessibility control fails.** + - Background Music now tries to press Netease Music's playback menu items directly through macOS accessibility APIs. If that fails because of an app update or system accessibility quirk, it falls back to UI scripting, which may still briefly reveal the menu while pausing or resuming playback. Many other issues are listed in [TODO.md](/TODO.md) and in [GitHub Issues](https://github.com/kyleneideck/BackgroundMusic/issues).