diff --git a/BGMApp/BGMApp/BGMAppDelegate.mm b/BGMApp/BGMApp/BGMAppDelegate.mm index c2bccba6..369d9921 100644 --- a/BGMApp/BGMApp/BGMAppDelegate.mm +++ b/BGMApp/BGMApp/BGMAppDelegate.mm @@ -369,12 +369,18 @@ - (void) initVolumesMenuSection { - (void) applicationWillTerminate:(NSNotification*)aNotification { #pragma unused (aNotification) - + DebugMsg("BGMAppDelegate::applicationWillTerminate"); + // Deactivate control sync and playthrough before changing the default device back. This + // prevents the default device change from triggering volume sync listeners that could leave + // the output device at the wrong volume. See + // https://github.com/kyleneideck/BackgroundMusic/issues/841 + [audioDevices prepareForTermination]; + // Change the user's default output device back. NSError* error = [audioDevices unsetBGMDeviceAsOSDefault]; - + if (error) { [self showSetDeviceAsDefaultError:error message:@"Failed to reset your system's audio output device." diff --git a/BGMApp/BGMApp/BGMAppVolumesController.mm b/BGMApp/BGMApp/BGMAppVolumesController.mm index 82821170..0a0a48b9 100644 --- a/BGMApp/BGMApp/BGMAppVolumesController.mm +++ b/BGMApp/BGMApp/BGMAppVolumesController.mm @@ -38,7 +38,6 @@ // System Includes #include - #pragma clang assume_nonnull begin @implementation BGMAppVolumesController { @@ -256,4 +255,3 @@ - (void) observeValueForKeyPath:(NSString* __nullable)keyPath @end #pragma clang assume_nonnull end - diff --git a/BGMApp/BGMApp/BGMAudioDevice.cpp b/BGMApp/BGMApp/BGMAudioDevice.cpp index d61507f7..5099d095 100644 --- a/BGMApp/BGMApp/BGMAudioDevice.cpp +++ b/BGMApp/BGMApp/BGMAudioDevice.cpp @@ -72,6 +72,20 @@ bool BGMAudioDevice::CanBeOutputDeviceInBGMApp() const canBeDefault; } +#pragma mark Device Type + +bool BGMAudioDevice::IsAggregate() const +{ + try + { + return GetTransportType() == kAudioDeviceTransportTypeAggregate; + } + catch(CAException) + { + return false; + } +} + #pragma mark Available Controls bool BGMAudioDevice::HasSettableMasterVolume(AudioObjectPropertyScope inScope) const @@ -115,6 +129,12 @@ bool BGMAudioDevice::HasSettableMasterMute(AudioObjectPropertyScope inScope) void BGMAudioDevice::CopyMuteFrom(const BGMAudioDevice inDevice, AudioObjectPropertyScope inScope) { + // Skip aggregate devices for the same reason as in CopyVolumeFrom. + if(IsAggregate() || inDevice.IsAggregate()) + { + return; + } + // TODO: Support for devices that have per-channel mute controls but no master mute control if(HasSettableMasterMute(inScope) && inDevice.HasMuteControl(inScope, kMasterChannel)) { @@ -127,6 +147,17 @@ void BGMAudioDevice::CopyMuteFrom(const BGMAudioDevice inDevice, void BGMAudioDevice::CopyVolumeFrom(const BGMAudioDevice inDevice, AudioObjectPropertyScope inScope) { + // Don't try to copy volume to/from aggregate devices. Aggregate devices rely on macOS to + // manage their virtual volume control through the sub-devices. Using the deprecated + // AudioHardwareService APIs (which we use for virtual master volume) to write to an aggregate + // device can corrupt its volume control state, causing the volume slider to become permanently + // disabled in System Settings. See https://github.com/kyleneideck/BackgroundMusic/issues/848 + if(IsAggregate() || inDevice.IsAggregate()) + { + DebugMsg("BGMAudioDevice::CopyVolumeFrom: Skipping volume copy for aggregate device"); + return; + } + // Get the volume of the other device. bool didGetVolume = false; Float32 volume = FLT_MIN; @@ -155,7 +186,7 @@ void BGMAudioDevice::CopyVolumeFrom(const BGMAudioDevice inDevice, if(numChannels > 0) // Avoid divide by zero. { - volume /= numChannels; + volume /= static_cast(numChannels); } } diff --git a/BGMApp/BGMApp/BGMAudioDevice.h b/BGMApp/BGMApp/BGMAudioDevice.h index 051dcdc3..71e3adb2 100644 --- a/BGMApp/BGMApp/BGMAudioDevice.h +++ b/BGMApp/BGMApp/BGMAudioDevice.h @@ -78,6 +78,14 @@ class BGMAudioDevice */ bool CanBeOutputDeviceInBGMApp() const; +#pragma mark Device Type + + /*! + @return True if this device is an aggregate device (i.e. a device created in Audio MIDI Setup + that combines multiple audio devices). + */ + bool IsAggregate() const; + #pragma mark Available Controls bool HasSettableMasterVolume(AudioObjectPropertyScope inScope) const; diff --git a/BGMApp/BGMApp/BGMAudioDeviceManager.h b/BGMApp/BGMApp/BGMAudioDeviceManager.h index 382bddd0..c01ce8cb 100644 --- a/BGMApp/BGMApp/BGMAudioDeviceManager.h +++ b/BGMApp/BGMApp/BGMAudioDeviceManager.h @@ -63,6 +63,11 @@ static const int kBGMErrorCode_ReturningEarly = 2; // Replace BGMDevice as the default device with the output device - (NSError* __nullable) unsetBGMDeviceAsOSDefault; +// Prepare for app termination by deactivating control sync and playthrough before the default +// device is changed back. This prevents stale volume state from being left on the output device. +// Must be called before unsetBGMDeviceAsOSDefault. +- (void) prepareForTermination; + #ifdef __cplusplus // The virtual device published by BGMDriver. - (BGMBackgroundMusicDevice) bgmDevice; diff --git a/BGMApp/BGMApp/BGMAudioDeviceManager.mm b/BGMApp/BGMApp/BGMAudioDeviceManager.mm index 7eb62879..9ccc5581 100644 --- a/BGMApp/BGMApp/BGMAudioDeviceManager.mm +++ b/BGMApp/BGMApp/BGMAudioDeviceManager.mm @@ -128,6 +128,33 @@ - (NSError* __nullable) setBGMDeviceAsOSDefault { return nil; } +- (void) prepareForTermination { + DebugMsg("BGMAudioDeviceManager::prepareForTermination: Deactivating control sync and " + "playthrough before termination"); + + @try { + [stateLock lock]; + + // Deactivate control sync BEFORE changing the default device. This removes the volume/mute + // listeners so that the default device change doesn't trigger any stale volume sync that + // could leave the output device at the wrong volume. + // See https://github.com/kyleneideck/BackgroundMusic/issues/841 + BGMLogAndSwallowExceptions("BGMAudioDeviceManager::prepareForTermination", [&] { + deviceControlSync.Deactivate(); + }); + + // Stop playthrough so we don't try to pass audio during shutdown. + BGMLogAndSwallowExceptions("BGMAudioDeviceManager::prepareForTermination", [&] { + playThrough.Deactivate(); + }); + BGMLogAndSwallowExceptions("BGMAudioDeviceManager::prepareForTermination", [&] { + playThrough_UISounds.Deactivate(); + }); + } @finally { + [stateLock unlock]; + } +} + - (NSError* __nullable) unsetBGMDeviceAsOSDefault { // Copy the devices so we can call the HAL without holding stateLock. See startPlayThroughSync. BGMBackgroundMusicDevice* bgmDeviceCopy; diff --git a/BGMApp/BGMApp/BGMAutoPauseMusic.mm b/BGMApp/BGMApp/BGMAutoPauseMusic.mm index c4514255..1154bd8e 100644 --- a/BGMApp/BGMApp/BGMAutoPauseMusic.mm +++ b/BGMApp/BGMApp/BGMAutoPauseMusic.mm @@ -206,7 +206,7 @@ - (void) queueUnpauseBlock { // TODO: Fading in and out would make short pauses a lot less jarring because, if they were short enough, we wouldn't // actually pause the music player. So you'd hear a dip in the music's volume rather than a gap. UInt64 unpauseDelayNsec = - static_cast((wentSilent - wentAudible) * kUnpauseDelayWeightingFactor); + static_cast(static_cast(wentSilent - wentAudible) * kUnpauseDelayWeightingFactor); // Convert from absolute time to nanos. mach_timebase_info_data_t info; diff --git a/BGMApp/BGMApp/BGMBackgroundMusicDevice.cpp b/BGMApp/BGMApp/BGMBackgroundMusicDevice.cpp index e0daa18b..2af5a83f 100644 --- a/BGMApp/BGMApp/BGMBackgroundMusicDevice.cpp +++ b/BGMApp/BGMApp/BGMBackgroundMusicDevice.cpp @@ -223,8 +223,14 @@ BGMBackgroundMusicDevice::ResponsibleBundleIDsOf(CACFString inParentBundleID) { "com.apple.finder", { "com.apple.quicklook.ui.helper", "com.apple.quicklook.QuickLookUIService" } }, - // Safari - { "com.apple.Safari", { "com.apple.WebKit.WebContent" } }, + // Safari. Recent macOS versions can run normal tabs in either the classic WebContent + // service or the EnhancedSecurity variant, and Tahoe can also hand media playback off to + // the GPU helper (surfaced as “Safari Graphics and Media”). Safari's own app process + // doesn't emit the tab audio directly. + { "com.apple.Safari", + { "com.apple.WebKit.WebContent", + "com.apple.WebKit.WebContent.EnhancedSecurity", + "com.apple.WebKit.GPU" } }, // Firefox { "org.mozilla.firefox", { "org.mozilla.plugincontainer" } }, // Firefox Nightly @@ -240,19 +246,36 @@ BGMBackgroundMusicDevice::ResponsibleBundleIDsOf(CACFString inParentBundleID) { "com.hnc.Discord", { "com.hnc.Discord.helper", "com.hnc.Discord.helper.Renderer", + "com.hnc.Discord.helper.renderer", + "com.hnc.Discord.helper.plugin", "com.hnc.Discord.helper.Plugin" } }, // Brave { "com.brave.Browser", { "com.brave.Browser.helper", + "com.brave.Browser.helper.renderer", "com.brave.Browser.helper.plugin" } }, + // NAVER Whale + { "com.naver.Whale", + { "com.naver.Whale.helper", + "com.naver.Whale.helper.renderer", + "com.naver.Whale.helper.plugin" } }, // Skype { "com.skype.skype", { "com.skype.skype.Helper" } }, // Google Chrome - { "com.google.Chrome", { "com.google.Chrome.helper" } }, + { "com.google.Chrome", + { "com.google.Chrome.helper", + "com.google.Chrome.helper.renderer", + "com.google.Chrome.helper.plugin" } }, // Microsoft Edge - { "com.microsoft.edgemac", { "com.microsoft.edgemac.helper" } }, + { "com.microsoft.edgemac", + { "com.microsoft.edgemac.helper", + "com.microsoft.edgemac.helper.renderer", + "com.microsoft.edgemac.helper.plugin" } }, // Arc - { "company.thebrowser.Browser", { "company.thebrowser.browser.helper" } } + { "company.thebrowser.Browser", + { "company.thebrowser.browser.helper", + "company.thebrowser.browser.helper.renderer", + "company.thebrowser.browser.helper.plugin" } } }; // Parallels' VM "dock helper" apps have bundle IDs like @@ -331,4 +354,3 @@ CFStringRef BGMBackgroundMusicDevice::GetMusicPlayerBundleID() const } #pragma clang assume_nonnull end - diff --git a/BGMApp/BGMApp/BGMDeviceControlsList.cpp b/BGMApp/BGMApp/BGMDeviceControlsList.cpp index 7a0a186d..397dc7f0 100644 --- a/BGMApp/BGMApp/BGMDeviceControlsList.cpp +++ b/BGMApp/BGMApp/BGMDeviceControlsList.cpp @@ -186,9 +186,21 @@ bool BGMDeviceControlsList::MatchControlsListOf(AudioObjectID inDeviceID) // Check which controls the other device has. BGMAudioDevice device(inDeviceID); - bool hasMute = device.HasSettableMasterMute(inScope); - bool hasVolume = + // Aggregate devices (created in Audio MIDI Setup) rely on macOS to provide virtual volume + // control through the sub-devices. The deprecated AudioHardwareService APIs we use to detect + // volume support can return incorrect results for aggregate devices on newer macOS versions, + // which causes us to incorrectly disable BGMDevice's volume control. This then triggers + // PropagateControlListChange (which toggles the default device through a null device), and + // that can permanently break the aggregate device's volume slider in System Settings. + // + // To avoid this, always report that aggregate devices have volume and mute controls, since + // macOS provides virtual volume/mute for them regardless of what the HAL reports. + bool isAggregate = device.IsAggregate(); + + bool hasMute = isAggregate || device.HasSettableMasterMute(inScope); + + bool hasVolume = isAggregate || device.HasSettableMasterVolume(inScope) || device.HasSettableVirtualMasterVolume(inScope); if(!hasVolume) diff --git a/BGMApp/BGMApp/BGMPlayThrough.cpp b/BGMApp/BGMApp/BGMPlayThrough.cpp index 1d614a6d..ffbfacfd 100644 --- a/BGMApp/BGMApp/BGMPlayThrough.cpp +++ b/BGMApp/BGMApp/BGMPlayThrough.cpp @@ -606,8 +606,8 @@ OSStatus BGMPlayThrough::WaitForOutputDeviceToStart() noexcept DebugMsg("BGMPlayThrough::WaitForOutputDeviceToStart: Started %f ms after notification, %f " "ms after entering WaitForOutputDeviceToStart.", - static_cast(startedBy - mToldOutputDeviceToStartAt) * base / NSEC_PER_MSEC, - static_cast(startedBy - startedAt) * base / NSEC_PER_MSEC); + static_cast(startedBy - mToldOutputDeviceToStartAt) * static_cast(base) / NSEC_PER_MSEC, + static_cast(startedBy - startedAt) * static_cast(base) / NSEC_PER_MSEC); } // Figure out which error code to return. @@ -1121,7 +1121,7 @@ OSStatus BGMPlayThrough::OutputDeviceIOProc(AudioObjectID inDevice, refCon->mInToOutSampleOffset); // Recalculate the in-to-out offset and read head. - refCon->mInToOutSampleOffset = inOutputTime->mSampleTime - lastInputSampleTime; + refCon->mInToOutSampleOffset = inOutputTime->mSampleTime - static_cast(lastInputSampleTime); readHeadSampleTime = static_cast( inOutputTime->mSampleTime - refCon->mInToOutSampleOffset); } diff --git a/BGMApp/BGMApp/BGMTermination.mm b/BGMApp/BGMApp/BGMTermination.mm index 75afa37a..9b1ef759 100644 --- a/BGMApp/BGMApp/BGMTermination.mm +++ b/BGMApp/BGMApp/BGMTermination.mm @@ -122,6 +122,10 @@ // it's better for things to work even if BGMXPCHelper isn't installed. if(sAudioDevices) { + // Deactivate control sync and playthrough before changing the default device back. + // This prevents the default device change from triggering volume sync that could leave + // the output device at the wrong volume. + [sAudioDevices prepareForTermination]; [sAudioDevices unsetBGMDeviceAsOSDefault]; } } diff --git a/BGMApp/BGMApp/_uninstall-non-interactive.sh b/BGMApp/BGMApp/_uninstall-non-interactive.sh index 46dad07c..c905488d 100755 --- a/BGMApp/BGMApp/_uninstall-non-interactive.sh +++ b/BGMApp/BGMApp/_uninstall-non-interactive.sh @@ -133,6 +133,29 @@ osascript -e 'tell application id "com.apple.finder" || rm -rf "${trash_dir}" \ || true +# Clean up BGMDevice entries from macOS audio preferences to prevent persistent low volume +# after uninstall. See https://github.com/kyleneideck/BackgroundMusic/issues/841 +echo "Cleaning up audio device preferences." +for plist_dir in "$HOME/Library/Preferences" "/Library/Preferences/Audio"; do + for plist in "com.apple.audio.DeviceSettings.plist" "com.apple.audio.SystemSettings.plist"; do + plist_path="${plist_dir}/${plist}" + if [ -f "${plist_path}" ]; then + # Remove entries keyed by BGMDevice UIDs + for uid in "BGMDevice" "BGMDevice_UISounds"; do + defaults delete "${plist_path}" "${uid}" &>/dev/null || true + done + fi + done +done +# Also clean up ByHost audio preferences +for byhost_plist in "$HOME/Library/Preferences/ByHost"/com.apple.audio.*.plist; do + if [ -f "${byhost_plist}" ]; then + for uid in "BGMDevice" "BGMDevice_UISounds"; do + defaults delete "${byhost_plist}" "${uid}" &>/dev/null || true + done + fi +done + echo "Restarting Core Audio." # Wait a little because moving files to the trash plays a short sound. sleep 2 diff --git a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm index 321cb291..6875c106 100644 --- a/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm +++ b/BGMApp/BGMAppTests/UnitTests/BGMMusicPlayersUnitTests.mm @@ -20,7 +20,10 @@ // Copyright © 2016-2020 Kyle Neideck // -// Unit include +// Unit includes +#define private public +#import "BGMBackgroundMusicDevice.h" +#undef private #import "BGMMusicPlayers.h" // BGM includes @@ -45,6 +48,23 @@ // CAHALAudioSystemObject, are also mocked. The unit tests are compiled with mock implementations: // Mock_CAHALAudioObject.cpp and Mock_CAHALAudioSystemObject.cpp. +static NSArray* BGMResponsibleBundleIDs(NSString* bundleID) { + std::vector responsibleBundleIDs = + BGMBackgroundMusicDevice::ResponsibleBundleIDsOf(CACFString((__bridge CFStringRef)bundleID)); + + NSMutableArray* result = + [NSMutableArray arrayWithCapacity:(NSUInteger)responsibleBundleIDs.size()]; + + for (const CACFString& responsibleBundleID : responsibleBundleIDs) { + NSString* nsBundleID = (__bridge NSString*)responsibleBundleID.GetCFString(); + if (nsBundleID) { + [result addObject:nsBundleID]; + } + } + + return result; +} + @interface BGMMockUserDefaults : BGMUserDefaults @property NSUUID* selectedPlayerID; @@ -211,7 +231,38 @@ - (void) testSelectedMusicPlayerInBGMDeviceProperties { XCTAssertEqualObjects(players.selectedMusicPlayer.name, @"VLC"); } +- (void) testResponsibleBundleIDsOfSafariIncludeMediaHelperBundleIDs { + NSArray* responsibleBundleIDs = BGMResponsibleBundleIDs(@"com.apple.Safari"); + + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.apple.WebKit.WebContent"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.apple.WebKit.WebContent.EnhancedSecurity"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.apple.WebKit.GPU"]); +} + +- (void) testResponsibleBundleIDsOfChromeIncludeModernHelperBundleIDs { + NSArray* responsibleBundleIDs = BGMResponsibleBundleIDs(@"com.google.Chrome"); + + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.google.Chrome.helper"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.google.Chrome.helper.renderer"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.google.Chrome.helper.plugin"]); +} + +- (void) testResponsibleBundleIDsOfBraveIncludeModernHelperBundleIDs { + NSArray* responsibleBundleIDs = BGMResponsibleBundleIDs(@"com.brave.Browser"); + + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.brave.Browser.helper"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.brave.Browser.helper.renderer"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.brave.Browser.helper.plugin"]); +} + +- (void) testResponsibleBundleIDsOfWhaleIncludeModernHelperBundleIDs { + NSArray* responsibleBundleIDs = BGMResponsibleBundleIDs(@"com.naver.Whale"); + + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.naver.Whale.helper"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.naver.Whale.helper.renderer"]); + XCTAssertTrue([responsibleBundleIDs containsObject:@"com.naver.Whale.helper.plugin"]); +} + // TODO: Test setting the selectedMusicPlayer property @end - diff --git a/BGMDriver/BGMDriver/BGM_Device.cpp b/BGMDriver/BGMDriver/BGM_Device.cpp index 8cecf36e..fe420d82 100644 --- a/BGMDriver/BGMDriver/BGM_Device.cpp +++ b/BGMDriver/BGMDriver/BGM_Device.cpp @@ -1361,8 +1361,8 @@ void BGM_Device::GetZeroTimeStamp(Float64& outSampleTime, UInt64& outHostTime, U } // set the return values - outSampleTime = mLoopbackTime.numberTimeStamps * kLoopbackRingBufferFrameSize; - outHostTime = static_cast(mLoopbackTime.anchorHostTime + (static_cast(mLoopbackTime.numberTimeStamps) * theHostTicksPerRingBuffer)); + outSampleTime = static_cast(mLoopbackTime.numberTimeStamps) * kLoopbackRingBufferFrameSize; + outHostTime = static_cast(static_cast(mLoopbackTime.anchorHostTime) + (static_cast(mLoopbackTime.numberTimeStamps) * theHostTicksPerRingBuffer)); // TODO: I think we should increment outSeed whenever this device switches to/from having a wrapped engine outSeed = 1; } @@ -1523,16 +1523,13 @@ void BGM_Device::EndIOOperation(UInt32 inOperationID, UInt32 inIOBufferFrameSize void BGM_Device::ReadInputData(UInt32 inIOBufferFrameSize, Float64 inSampleTime, void* outBuffer) { // Wrap the provided buffer in an AudioBufferList. - AudioBufferList abl = { - .mNumberBuffers = 1, - .mBuffers[0] = { - .mNumberChannels = 2, - // Each frame is 2 Float32 samples (one per channel). The number of frames * the number - // of bytes per frame = the size of outBuffer in bytes. - .mDataByteSize = static_cast(inIOBufferFrameSize * sizeof(Float32) * 2), - .mData = outBuffer - } - }; + AudioBufferList abl; + abl.mNumberBuffers = 1; + abl.mBuffers[0].mNumberChannels = 2; + // Each frame is 2 Float32 samples (one per channel). The number of frames * the number + // of bytes per frame = the size of outBuffer in bytes. + abl.mBuffers[0].mDataByteSize = static_cast(inIOBufferFrameSize * sizeof(Float32) * 2); + abl.mBuffers[0].mData = outBuffer; // Copy the audio data from our ring buffer into the provided buffer. CARingBufferError err = @@ -1562,16 +1559,13 @@ void BGM_Device::ReadInputData(UInt32 inIOBufferFrameSize, Float64 inSampleTime, void BGM_Device::WriteOutputData(UInt32 inIOBufferFrameSize, Float64 inSampleTime, const void* inBuffer) { // Wrap the provided buffer in an AudioBufferList. - AudioBufferList abl = { - .mNumberBuffers = 1, - .mBuffers[0] = { - .mNumberChannels = 2, - // Each frame is 2 Float32 samples (one per channel). The number of frames * the number - // of bytes per frame = the size of inBuffer in bytes. - .mDataByteSize = static_cast(inIOBufferFrameSize * sizeof(Float32) * 2), - .mData = const_cast(inBuffer) - } - }; + AudioBufferList abl; + abl.mNumberBuffers = 1; + abl.mBuffers[0].mNumberChannels = 2; + // Each frame is 2 Float32 samples (one per channel). The number of frames * the number + // of bytes per frame = the size of inBuffer in bytes. + abl.mBuffers[0].mDataByteSize = static_cast(inIOBufferFrameSize * sizeof(Float32) * 2); + abl.mBuffers[0].mData = const_cast(inBuffer); // Copy the audio data from the provided buffer into our ring buffer. CARingBufferError err = @@ -1908,7 +1902,7 @@ Float64 BGM_Device::_HW_GetSampleRate() const CAException(kAudioHardwareUnspecifiedError), "BGM_Device::_HW_GetSampleRate: No wrapped audio device"); - return mWrappedAudioEngine->GetSampleRate(); + return static_cast(mWrappedAudioEngine->GetSampleRate()); } kern_return_t BGM_Device::_HW_SetSampleRate(Float64 inNewSampleRate) diff --git a/BGMDriver/BGMDriver/BGM_NullDevice.cpp b/BGMDriver/BGMDriver/BGM_NullDevice.cpp index cc80b0cd..aac0eef8 100644 --- a/BGMDriver/BGMDriver/BGM_NullDevice.cpp +++ b/BGMDriver/BGMDriver/BGM_NullDevice.cpp @@ -471,8 +471,8 @@ void BGM_NullDevice::GetZeroTimeStamp(Float64& outSampleTime, (static_cast(mNumberTimeStamps) * theHostTicksPerPeriod); // Set the return values. - outSampleTime = mNumberTimeStamps * kZeroTimeStampPeriod; - outHostTime = static_cast(mAnchorHostTime + theHostTicksSinceAnchor); + outSampleTime = static_cast(mNumberTimeStamps) * kZeroTimeStampPeriod; + outHostTime = static_cast(static_cast(mAnchorHostTime) + theHostTicksSinceAnchor); outSeed = 1; } diff --git a/BGMDriver/BGMDriver/BGM_VolumeControl.cpp b/BGMDriver/BGMDriver/BGM_VolumeControl.cpp index 9e5f4c19..23ec028f 100644 --- a/BGMDriver/BGMDriver/BGM_VolumeControl.cpp +++ b/BGMDriver/BGMDriver/BGM_VolumeControl.cpp @@ -440,7 +440,7 @@ void BGM_VolumeControl::SetVolumeRaw(SInt32 inNewVolumeRaw) // TODO: This assumes the control should never boost the signal. (So, technically, it never // actually applies gain, only loss.) SInt32 theRawRange = mMaxVolumeRaw - mMinVolumeRaw; - SInt32 theSliderPositionInRawSteps = static_cast(theSliderPosition * theRawRange); + SInt32 theSliderPositionInRawSteps = static_cast(theSliderPosition * static_cast(theRawRange)); theSliderPositionInRawSteps += mMinVolumeRaw; mAmplitudeGain = mVolumeCurve.ConvertRawToScalar(theSliderPositionInRawSteps); diff --git a/BGMDriver/BGMDriver/quick_install.sh b/BGMDriver/BGMDriver/quick_install.sh index 6473f0a9..f5400118 100755 --- a/BGMDriver/BGMDriver/quick_install.sh +++ b/BGMDriver/BGMDriver/quick_install.sh @@ -52,6 +52,129 @@ bold_face() { echo $(tput bold)$*$(tput sgr0) } +find_browser_audio_session_pids() { + local current_uid="$(id -u)" + + ps -axo pid=,uid=,args= | \ + awk -v current_uid="${current_uid}" ' + $2 == current_uid { + cmd = "" + for (i = 3; i <= NF; ++i) { + cmd = cmd $i " " + } + + if (cmd ~ /audio\.mojom\.AudioService/ || + cmd ~ /com\.apple\.WebKit\.(GPU|WebContent)/) { + print $1 + } + } + ' | sort -u +} + +declare -a SUSPENDED_BROWSER_AUDIO_PIDS=() + +suspend_browser_audio_clients_before_hal_restart() { + SUSPENDED_BROWSER_AUDIO_PIDS=() + + while IFS= read -r pid; do + [[ -n "${pid}" ]] && SUSPENDED_BROWSER_AUDIO_PIDS+=("${pid}") + done < <(find_browser_audio_session_pids) + + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + echo "Temporarily suspending browser audio clients before restarting coreaudiod: ${SUSPENDED_BROWSER_AUDIO_PIDS[*]}" + kill -STOP "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" >/dev/null 2>&1 || true + sleep 1 +} + +resume_suspended_browser_audio_clients() { + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + echo "Resuming browser audio clients after the guarded coreaudiod restart." + kill -CONT "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" >/dev/null 2>&1 || true + SUSPENDED_BROWSER_AUDIO_PIDS=() +} + +wait_for_coreaudiod_restart() { + local old_pid="$1" + local old_start_time="$2" + local timeout_seconds="${3:-20}" + local waited=0 + + while [[ ${waited} -lt ${timeout_seconds} ]]; do + local new_pid + new_pid="$(pgrep -x coreaudiod | head -n1 || true)" + + if [[ -n "${new_pid}" ]]; then + local new_start_time + new_start_time="$(ps -o lstart= -p "${new_pid}" 2>/dev/null | sed 's/^ *//')" + + if [[ -z "${old_pid}" ]] || [[ "${new_pid}" != "${old_pid}" ]] || \ + [[ -n "${old_start_time}" && "${new_start_time}" != "${old_start_time}" ]]; then + sleep 2 + return 0 + fi + fi + + sleep 1 + waited=$((waited + 1)) + done + + return 1 +} + +validate_driver_signature_for_hal_restart() { + if [[ ${UNINSTALL_ONLY} == true ]] || [[ "${BGM_ALLOW_UNVERIFIED_HAL_RESTART:-0}" == "1" ]]; then + return 0 + fi + + local verify_output="" + local details_output="" + + if ! verify_output="$(/usr/bin/codesign --verify --strict --verbose=2 "${INSTALLED_DRIVER_PATH}" 2>&1)"; then + echo "$(bold_face Aborting). Installed driver failed codesign verification: ${verify_output}" >&2 + return 1 + fi + + if ! details_output="$(/usr/bin/codesign -dv --verbose=2 "${INSTALLED_DRIVER_PATH}" 2>&1)"; then + echo "$(bold_face Aborting). Couldn't inspect installed driver signature details: ${details_output}" >&2 + return 1 + fi + + if echo "${details_output}" | grep -q 'Signature=adhoc'; then + echo "$(bold_face Aborting). Installed driver is still ad hoc signed. Set BGM_ALLOW_UNVERIFIED_HAL_RESTART=1 to override at your own risk." >&2 + return 1 + fi + + if ! echo "${details_output}" | grep -q 'TeamIdentifier=' || \ + echo "${details_output}" | grep -q 'TeamIdentifier=not set'; then + echo "$(bold_face Aborting). Installed driver has no TeamIdentifier. Set BGM_ALLOW_UNVERIFIED_HAL_RESTART=1 to override at your own risk." >&2 + return 1 + fi +} + +restart_coreaudiod_guarded() { + local old_pid + old_pid="$(pgrep -x coreaudiod | head -n1 || true)" + local old_start_time="" + if [[ -n "${old_pid}" ]]; then + old_start_time="$(ps -o lstart= -p "${old_pid}" 2>/dev/null | sed 's/^ *//')" + fi + + suspend_browser_audio_clients_before_hal_restart + trap 'resume_suspended_browser_audio_clients' RETURN + + sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod >/dev/null 2>&1 || sudo killall coreaudiod >/dev/null 2>&1 || return 1 + wait_for_coreaudiod_restart "${old_pid}" "${old_start_time}" 20 || return 1 + + resume_suspended_browser_audio_clients + trap - RETURN +} + usage() { echo "Usage: $0 [options]" >&2 echo -e "\t-u\tUninstall only" >&2 @@ -203,10 +326,9 @@ fi # Restart coreaudiod to load the installed build if [[ ${DONT_RESTART_COREAUDIOD} == false ]]; then - echo "$(bold_face Restarting coreaudiod). If a running app stops playing audio, change your default audio device (and change it back if you want) or open BGMApp." + validate_driver_signature_for_hal_restart + echo "$(bold_face Restarting coreaudiod). Browser audio utility processes will be paused briefly first to reduce Tahoe HAL deadlock risk." # ("set -x" so the command is echoed) - (set -x; sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod || sudo killall coreaudiod) + (set -x; restart_coreaudiod_guarded) fi - - diff --git a/BGMDriver/PublicUtility/CAVolumeCurve.cpp b/BGMDriver/PublicUtility/CAVolumeCurve.cpp index a68eb699..ec225dac 100644 --- a/BGMDriver/PublicUtility/CAVolumeCurve.cpp +++ b/BGMDriver/PublicUtility/CAVolumeCurve.cpp @@ -400,7 +400,7 @@ Float32 CAVolumeCurve::ConvertRawToDB(SInt32 inRaw) const SInt32 theRawStepsToAdd = std::min(theRawRange, theNumberRawSteps); // add this many steps worth of db to the answer; - theAnswer += theRawStepsToAdd * theDBPerRaw; + theAnswer += static_cast(theRawStepsToAdd) * theDBPerRaw; // figure out how many steps are left theNumberRawSteps -= theRawStepsToAdd; diff --git a/build_and_install.sh b/build_and_install.sh index 11b94766..2fa31aa3 100755 --- a/build_and_install.sh +++ b/build_and_install.sh @@ -173,6 +173,346 @@ bold_face() { echo $(tput bold)$*$(tput sgr0) } +log_info() { + echo "$*" | tee -a ${LOG_FILE} +} + +capture_command_with_timeout() { + local timeout_seconds="$1" + local output_var_name="$2" + shift 2 + + local had_errexit=0 + [[ $- == *e* ]] && had_errexit=1 + set +e + + local tmpfile + tmpfile="$(mktemp -t bgm-timeout.XXXXXX)" + "$@" >"${tmpfile}" 2>&1 & + local command_pid=$! + local exit_status=0 + local timed_out=0 + local waited=0 + + while kill -0 "${command_pid}" > /dev/null 2>&1; do + if [[ ${waited} -ge ${timeout_seconds} ]]; then + timed_out=1 + kill "${command_pid}" > /dev/null 2>&1 || true + sleep 1 + kill -9 "${command_pid}" > /dev/null 2>&1 || true + break + fi + + sleep 1 + waited=$((waited + 1)) + done + + if [[ ${timed_out} -eq 0 ]]; then + wait "${command_pid}" + exit_status=$? + else + wait "${command_pid}" > /dev/null 2>&1 || true + exit_status=124 + fi + + local captured_output + captured_output="$(cat "${tmpfile}")" + rm -f "${tmpfile}" + + printf -v "${output_var_name}" '%s' "${captured_output}" + + if [[ ${had_errexit} -eq 1 ]]; then + set -e + fi + + return "${exit_status}" +} + +codesign_details() { + /usr/bin/codesign -dv --verbose=2 "$1" 2>&1 +} + +verify_installed_bundle_for_hal_restart() { + local bundle_path="$1" + local bundle_label="$2" + local verify_output="" + local details_output="" + + if ! verify_output="$(/usr/bin/codesign --verify --strict --verbose=2 "${bundle_path}" 2>&1)"; then + log_info "codesign verification failed for ${bundle_label}: ${verify_output}" + return 1 + fi + + if ! details_output="$(codesign_details "${bundle_path}")"; then + log_info "Couldn't inspect ${bundle_label} signature details: ${details_output}" + return 1 + fi + + if printf '%s\n' "${details_output}" | grep -q 'Signature=adhoc'; then + log_info "${bundle_label} is still ad hoc signed, which Tahoe may refuse during HAL reload." + return 1 + fi + + if ! printf '%s\n' "${details_output}" | grep -q 'TeamIdentifier=' || \ + printf '%s\n' "${details_output}" | grep -q 'TeamIdentifier=not set'; then + log_info "${bundle_label} does not have a TeamIdentifier. Refusing unsafe HAL reload." + return 1 + fi + + return 0 +} + +validate_installed_products_before_hal_restart() { + if [[ "${BGM_ALLOW_UNVERIFIED_HAL_RESTART:-0}" == "1" ]]; then + log_info "WARNING: Skipping HAL restart signature validation because BGM_ALLOW_UNVERIFIED_HAL_RESTART=1." + return 0 + fi + + verify_installed_bundle_for_hal_restart "${DRIVER_PATH}/${DRIVER_DIR}" "Background Music Device.driver" + verify_installed_bundle_for_hal_restart "${XPC_HELPER_OUTPUT_PATH}/${XPC_HELPER_DIR}" "BGMXPCHelper.xpc" + verify_installed_bundle_for_hal_restart "${APP_PATH}/${APP_DIR}" "Background Music.app" +} + +find_browser_audio_session_pids() { + local current_uid="$(id -u)" + + ps -axo pid=,uid=,args= | \ + awk -v current_uid="${current_uid}" ' + $2 == current_uid { + cmd = "" + for (i = 3; i <= NF; ++i) { + cmd = cmd $i " " + } + + if (cmd ~ /audio\.mojom\.AudioService/ || + cmd ~ /com\.apple\.WebKit\.(GPU|WebContent)/) { + print $1 + } + } + ' | sort -u +} + +declare -a SUSPENDED_BROWSER_AUDIO_PIDS=() + +suspend_browser_audio_clients_before_hal_restart() { + SUSPENDED_BROWSER_AUDIO_PIDS=() + + while IFS= read -r pid; do + [[ -n "${pid}" ]] && SUSPENDED_BROWSER_AUDIO_PIDS+=("${pid}") + done < <(find_browser_audio_session_pids) + + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + log_info "Temporarily suspending browser audio clients before restarting coreaudiod: ${SUSPENDED_BROWSER_AUDIO_PIDS[*]}" + kill -STOP "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" >> ${LOG_FILE} 2>&1 || true + sleep 1 +} + +resume_suspended_browser_audio_clients() { + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + log_info "Resuming browser audio clients after the guarded coreaudiod restart." + kill -CONT "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" >> ${LOG_FILE} 2>&1 || true + SUSPENDED_BROWSER_AUDIO_PIDS=() +} + +wait_for_coreaudiod_restart() { + local old_pid="$1" + local old_start_time="$2" + local timeout_seconds="${3:-20}" + local waited=0 + + while [[ ${waited} -lt ${timeout_seconds} ]]; do + local new_pid + new_pid="$(pgrep -x coreaudiod | head -n1 || true)" + + if [[ -n "${new_pid}" ]]; then + local new_start_time + new_start_time="$(ps -o lstart= -p "${new_pid}" 2>/dev/null | sed 's/^ *//')" + + if [[ -z "${old_pid}" ]] || [[ "${new_pid}" != "${old_pid}" ]] || \ + [[ -n "${old_start_time}" && "${new_start_time}" != "${old_start_time}" ]]; then + sleep 2 + return 0 + fi + fi + + sleep 1 + waited=$((waited + 1)) + done + + return 1 +} + +restart_coreaudiod_guarded() { + local old_pid + old_pid="$(pgrep -x coreaudiod | head -n1 || true)" + local old_start_time="" + if [[ -n "${old_pid}" ]]; then + old_start_time="$(ps -o lstart= -p "${old_pid}" 2>/dev/null | sed 's/^ *//')" + fi + + suspend_browser_audio_clients_before_hal_restart + trap 'resume_suspended_browser_audio_clients' RETURN + + if ! (sudo launchctl kickstart -k system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill TERM system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill 15 system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill -15 system/com.apple.audio.coreaudiod &>/dev/null || \ + (sudo launchctl unload "${COREAUDIOD_PLIST}" &>/dev/null && \ + sudo launchctl load "${COREAUDIOD_PLIST}" &>/dev/null) || \ + sudo killall coreaudiod &>/dev/null); then + return 1 + fi + + if ! wait_for_coreaudiod_restart "${old_pid}" "${old_start_time}" 20; then + return 1 + fi + + resume_suspended_browser_audio_clients + trap - RETURN +} + +probe_bgm_device_once() { + local profiler_output="" + if capture_command_with_timeout 8 profiler_output system_profiler SPAudioDataType; then + if [[ "${profiler_output}" =~ "Background Music" ]]; then + return 0 + fi + else + log_info "system_profiler timed out while checking the HAL device after restart." + fi + + local input_device_output="" + if capture_command_with_timeout 8 input_device_output /usr/bin/xcrun swift pkg/ListInputDevices.swift; then + [[ "${input_device_output}" =~ "BGMDevice" ]] && return 0 + else + log_info "AVFoundation device probe timed out while checking the HAL device after restart." + fi + + return 1 +} + +wait_for_bgm_device_after_restart() { + local retries="${1:-5}" + + while [[ ${retries} -gt 0 ]]; do + if probe_bgm_device_once; then + return 0 + fi + + retries=$((retries - 1)) + [[ ${retries} -gt 0 ]] && sleep 2 + done + + return 1 +} + +find_signing_identity() { + SIGNING_IDENTITY="${SIGNING_IDENTITY:-${BGM_CODESIGN_IDENTITY:-}}" + + if [[ -n ${SIGNING_IDENTITY:-} ]]; then + return 0 + fi + + local available_identities + available_identities="$(security find-identity -v -p codesigning 2>/dev/null || true)" + + SIGNING_IDENTITY="$(printf '%s\n' "${available_identities}" | \ + sed -n 's/.*"\(Apple Development:[^"]*\)".*/\1/p' | head -n1)" + + if [[ -z ${SIGNING_IDENTITY} ]]; then + SIGNING_IDENTITY="$(printf '%s\n' "${available_identities}" | \ + sed -n 's/.*"\(Mac Development:[^"]*\)".*/\1/p' | head -n1)" + fi + + if [[ -z ${SIGNING_IDENTITY} ]]; then + SIGNING_IDENTITY="$(printf '%s\n' "${available_identities}" | \ + sed -n 's/.*"\(Developer ID Application:[^"]*\)".*/\1/p' | head -n1)" + fi + + return 0 +} + +bgmapp_entitlements_path() { + if [[ "${CONFIGURATION}" == "Release" ]]; then + echo "BGMApp/BGMApp/BGMApp.entitlements" + else + echo "BGMApp/BGMApp/BGMApp-Debug.entitlements" + fi +} + +resign_installed_products_if_possible() { + local launchd_plist="/Library/LaunchDaemons/com.bearisdriving.BGM.XPCHelper.plist" + local current_user="$(id -un)" + local current_group="$(id -gn)" + local -a temporarily_user_owned_paths=( + "${DRIVER_PATH}/${DRIVER_DIR}" + "${XPC_HELPER_OUTPUT_PATH}/${XPC_HELPER_DIR}" + ) + local restored_root_ownership=0 + + restore_root_owned_products() { + if [[ ${restored_root_ownership} -ne 0 ]]; then + return 0 + fi + + echo "Restoring root ownership on the driver and helper." >> ${LOG_FILE} + sudo chown -RH root:wheel "${temporarily_user_owned_paths[@]}" >> ${LOG_FILE} 2>&1 + restored_root_ownership=1 + } + + find_signing_identity + + if [[ -z ${SIGNING_IDENTITY} ]]; then + echo "$(tput setaf 11)WARNING$(tput sgr0): Couldn't find an Apple/macOS code signing " \ + "certificate. Tahoe may reject the ad hoc signed app, helper and driver." \ + | tee -a ${LOG_FILE} + return 0 + fi + + echo "Re-signing installed products with ${SIGNING_IDENTITY}." | tee -a ${LOG_FILE} + + # codesign needs access to the developer certificate's private key, which lives in the current + # user's login keychain rather than root's keychain. Temporarily hand the installed root-owned + # bundles to the invoking user, sign them as that user, then restore root ownership before + # launchd/coreaudiod use them. + trap restore_root_owned_products RETURN + sudo chown -R "${current_user}:${current_group}" "${temporarily_user_owned_paths[@]}" \ + >> ${LOG_FILE} 2>&1 + + /usr/bin/codesign --force --sign "${SIGNING_IDENTITY}" \ + --options runtime \ + "${DRIVER_PATH}/${DRIVER_DIR}" >> ${LOG_FILE} 2>&1 + + /usr/bin/codesign --force --sign "${SIGNING_IDENTITY}" \ + --options runtime \ + "${XPC_HELPER_OUTPUT_PATH}/${XPC_HELPER_DIR}" \ + >> ${LOG_FILE} 2>&1 + + /usr/bin/codesign --force --sign "${SIGNING_IDENTITY}" \ + --options runtime \ + --entitlements "$(bgmapp_entitlements_path)" \ + "${APP_PATH}/${APP_DIR}" >> ${LOG_FILE} 2>&1 + + restore_root_owned_products + trap - RETURN + + # post_install.sh bootstraps the launchd job before Xcode's final signing step runs. Reload the + # helper after re-signing so launchd sees the final executable with a Team ID. + echo "Reloading BGMXPCHelper after re-signing." | tee -a ${LOG_FILE} + sudo launchctl bootout system "${launchd_plist}" >> ${LOG_FILE} 2>&1 || \ + sudo launchctl unload "${launchd_plist}" >> ${LOG_FILE} 2>&1 || \ + true + sudo launchctl bootstrap system "${launchd_plist}" >> ${LOG_FILE} 2>&1 || \ + sudo launchctl load "${launchd_plist}" >> ${LOG_FILE} 2>&1 +} + # Takes a PID and returns 0 if the process is running. is_alive() { kill -0 $1 > /dev/null 2>&1 && return 0 || return 1 @@ -819,23 +1159,19 @@ if [[ "${XCODEBUILD_ACTION}" == "install" ]]; then # deleted easily after installing. sudo chown -R "$(whoami):admin" "BGMApp/build" "BGMDriver/build" + resign_installed_products_if_possible + + ERROR_MSG="Refusing to restart coreaudiod because the installed HAL artifacts failed signature validation. Re-sign them with a Team ID, or set BGM_ALLOW_UNVERIFIED_HAL_RESTART=1 to override at your own risk." + validate_installed_products_before_hal_restart + # Restart coreaudiod. - echo "Restarting coreaudiod to load the virtual audio device." \ - | tee -a ${LOG_FILE} - - # The extra or-clauses are fallback versions of the command that restarts coreaudiod. Apparently - # some of these commands don't work with older versions of launchctl, so I figure there's no - # harm in trying a bunch of different ways (which should all work). - (sudo launchctl kickstart -k system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill TERM system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill 15 system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill -15 system/com.apple.audio.coreaudiod &>/dev/null || \ - (sudo launchctl unload "${COREAUDIOD_PLIST}" &>/dev/null && \ - sudo launchctl load "${COREAUDIOD_PLIST}" &>/dev/null) || \ - sudo killall coreaudiod &>/dev/null) && \ - sleep 5 + log_info "Restarting coreaudiod with browser audio clients quiesced to load the virtual audio device." + ERROR_MSG="Guarded coreaudiod restart failed. Suspended browser audio clients were resumed, but the HAL may still be mid-reload. Close active browser audio playback (especially Chromium audio.mojom.AudioService) and retry." + restart_coreaudiod_guarded + + ERROR_MSG="Background Music Device didn't appear after the guarded coreaudiod restart. Close active audio playback or reboot before retrying." + wait_for_bgm_device_after_restart # Invalidate sudo ticket sudo -k @@ -866,4 +1202,3 @@ elif [[ "${XCODEBUILD_ACTION}" == "archive" ]]; then mv "$APP_PATH/Products/Applications/Background Music.app/Contents/MacOS/Background Music.dSYM" \ "$APP_PATH/dSYMs" fi - diff --git a/pkg/postinstall b/pkg/postinstall index f82d6a19..6c92f36f 100644 --- a/pkg/postinstall +++ b/pkg/postinstall @@ -39,8 +39,202 @@ function log_output { done < /dev/stdin } +capture_command_with_timeout() { + local timeout_seconds="$1" + local output_var_name="$2" + shift 2 + + local had_errexit=0 + [[ $- == *e* ]] && had_errexit=1 + set +e + + local tmpfile + tmpfile="$(mktemp -t bgm-timeout.XXXXXX)" + "$@" >"${tmpfile}" 2>&1 & + local command_pid=$! + local exit_status=0 + local timed_out=0 + local waited=0 + + while kill -0 "${command_pid}" > /dev/null 2>&1; do + if [[ ${waited} -ge ${timeout_seconds} ]]; then + timed_out=1 + kill "${command_pid}" > /dev/null 2>&1 || true + sleep 1 + kill -9 "${command_pid}" > /dev/null 2>&1 || true + break + fi + + sleep 1 + waited=$((waited + 1)) + done + + if [[ ${timed_out} -eq 0 ]]; then + wait "${command_pid}" + exit_status=$? + else + wait "${command_pid}" > /dev/null 2>&1 || true + exit_status=124 + fi + + local captured_output + captured_output="$(cat "${tmpfile}")" + rm -f "${tmpfile}" + printf -v "${output_var_name}" '%s' "${captured_output}" + + if [[ ${had_errexit} -eq 1 ]]; then + set -e + fi + + return "${exit_status}" +} + +verify_installed_bundle_for_hal_restart() { + local bundle_path="$1" + local bundle_label="$2" + local verify_output="" + local details_output="" + + if ! verify_output="$(/usr/bin/codesign --verify --strict --verbose=2 "${bundle_path}" 2>&1)"; then + log "codesign verification failed for ${bundle_label}: ${verify_output}" + return 1 + fi + + if ! details_output="$(/usr/bin/codesign -dv --verbose=2 "${bundle_path}" 2>&1)"; then + log "Couldn't inspect ${bundle_label} signature details: ${details_output}" + return 1 + fi + + if echo "${details_output}" | grep -q 'Signature=adhoc'; then + log "${bundle_label} is still ad hoc signed, which Tahoe may refuse during HAL reload." + return 1 + fi + + if ! echo "${details_output}" | grep -q 'TeamIdentifier=' || \ + echo "${details_output}" | grep -q 'TeamIdentifier=not set'; then + log "${bundle_label} does not have a TeamIdentifier. Refusing unsafe HAL reload." + return 1 + fi + + return 0 +} + +validate_installed_products_before_hal_restart() { + if [[ "${BGM_ALLOW_UNVERIFIED_HAL_RESTART:-0}" == "1" ]]; then + log "WARNING: Skipping HAL restart signature validation because BGM_ALLOW_UNVERIFIED_HAL_RESTART=1." + return 0 + fi + + verify_installed_bundle_for_hal_restart "/Library/Audio/Plug-Ins/HAL/Background Music Device.driver" "Background Music Device.driver" || return 1 + verify_installed_bundle_for_hal_restart "${xpc_helper_install_path}/BGMXPCHelper.xpc" "BGMXPCHelper.xpc" || return 1 + verify_installed_bundle_for_hal_restart "${dest_volume_no_trailing_slash}/Applications/Background Music.app" "Background Music.app" || return 1 +} + +find_browser_audio_session_pids() { + local owner_uid + owner_uid="$(stat -f %u /dev/console 2>/dev/null || echo 0)" + + ps -axo pid=,uid=,args= | \ + awk -v owner_uid="${owner_uid}" ' + $2 == owner_uid { + cmd = "" + for (i = 3; i <= NF; ++i) { + cmd = cmd $i " " + } + + if (cmd ~ /audio\.mojom\.AudioService/ || + cmd ~ /com\.apple\.WebKit\.(GPU|WebContent)/) { + print $1 + } + } + ' | sort -u +} + +declare -a SUSPENDED_BROWSER_AUDIO_PIDS=() + +suspend_browser_audio_clients_before_hal_restart() { + SUSPENDED_BROWSER_AUDIO_PIDS=() + + while IFS= read -r pid; do + [[ -n "${pid}" ]] && SUSPENDED_BROWSER_AUDIO_PIDS+=("${pid}") + done < <(find_browser_audio_session_pids) + + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + log "Temporarily suspending browser audio clients before restarting coreaudiod: ${SUSPENDED_BROWSER_AUDIO_PIDS[*]}" + kill -STOP "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" 2>/dev/null || true + sleep 1 +} + +resume_suspended_browser_audio_clients() { + if [[ ${#SUSPENDED_BROWSER_AUDIO_PIDS[@]} -eq 0 ]]; then + return 0 + fi + + log "Resuming browser audio clients after the guarded coreaudiod restart." + kill -CONT "${SUSPENDED_BROWSER_AUDIO_PIDS[@]}" 2>/dev/null || true + SUSPENDED_BROWSER_AUDIO_PIDS=() +} + +wait_for_coreaudiod_restart() { + local old_pid="$1" + local old_start_time="$2" + local timeout_seconds="${3:-20}" + local waited=0 + + while [[ ${waited} -lt ${timeout_seconds} ]]; do + local new_pid + new_pid="$(pgrep -x coreaudiod | head -n1 || true)" + + if [[ -n "${new_pid}" ]]; then + local new_start_time + new_start_time="$(ps -o lstart= -p "${new_pid}" 2>/dev/null | sed 's/^ *//')" + + if [[ -z "${old_pid}" ]] || [[ "${new_pid}" != "${old_pid}" ]] || \ + [[ -n "${old_start_time}" && "${new_start_time}" != "${old_start_time}" ]]; then + sleep 2 + return 0 + fi + fi + + sleep 1 + waited=$((waited + 1)) + done + + return 1 +} + +restart_coreaudiod_guarded() { + local old_pid + old_pid="$(pgrep -x coreaudiod | head -n1 || true)" + local old_start_time="" + if [[ -n "${old_pid}" ]]; then + old_start_time="$(ps -o lstart= -p "${old_pid}" 2>/dev/null | sed 's/^ *//')" + fi + + suspend_browser_audio_clients_before_hal_restart + trap 'resume_suspended_browser_audio_clients' RETURN + + (sudo launchctl kickstart -k system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill TERM system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill 15 system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo launchctl kill -15 system/com.apple.audio.coreaudiod &>/dev/null || \ + sudo killall coreaudiod &>/dev/null || \ + (sudo launchctl unload "$coreaudiod_plist" &>/dev/null && \ + sudo launchctl load "$coreaudiod_plist" &>/dev/null)) || return 1 + + wait_for_coreaudiod_restart "${old_pid}" "${old_start_time}" 20 || return 1 + + resume_suspended_browser_audio_clients + trap - RETURN +} + coreaudiod_plist="/System/Library/LaunchDaemons/com.apple.audio.coreaudiod.plist" dest_volume="$3" +dest_volume_no_trailing_slash="${dest_volume/%\//}" xpc_helper_install_path="$(bash safe_install_dir.sh -y)" log "Installing BGMXPCHelper to $xpc_helper_install_path" @@ -68,36 +262,30 @@ bash "post_install.sh" "$xpc_helper_install_path" "BGMXPCHelper.xpc/Contents/Mac # TODO: Verify the installed files, their permissions, the _BGMXPCHelper user/group, etc. -# The extra or-clauses are fallback versions of the command that restarts coreaudiod. Apparently -# some of these commands don't work with older versions of launchctl, so I figure there's no -# harm in trying a bunch of different ways (which should all work). -(sudo launchctl kickstart -k system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill SIGTERM system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill TERM system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill 15 system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo launchctl kill -15 system/com.apple.audio.coreaudiod &>/dev/null || \ - sudo killall coreaudiod &>/dev/null || \ - (sudo launchctl unload "$coreaudiod_plist" &>/dev/null && \ - sudo launchctl load "$coreaudiod_plist" &>/dev/null) - ) && \ - sleep 2 +if ! validate_installed_products_before_hal_restart; then + log "Refusing to restart coreaudiod because the installed HAL artifacts failed signature validation." + exit 1 +fi + +log "Restarting coreaudiod with browser audio clients quiesced to avoid Tahoe HAL deadlocks." +if ! restart_coreaudiod_guarded; then + log "Guarded coreaudiod restart failed. Suspended browser audio clients were resumed." + exit 1 +fi # Wait until coreaudiod has restarted and BGMDevice is ready to use. retries=5 while [[ $retries -gt 0 ]]; do - device_list="$(system_profiler SPAudioDataType)" + device_list="" + input_device_list="" - # The system_profiler command above doesn't work on GitHub Actions, so also try this - # alternative, which does. I haven't looked into the root cause, so this may be necessary for - # other systems as well. - input_device_list="$(./ListInputDevices)" - - # "BGMDevice" is the UID, which is a stable identifier. device_list doesn't include the UIDs, so - # we have to check for BGMDevice's name. - if [[ "$device_list" =~ "Background Music" ]] || \ + if capture_command_with_timeout 8 device_list system_profiler SPAudioDataType && \ + [[ "$device_list" =~ "Background Music" ]]; then + log "Background Music device is installed and available." + retries=0 + elif capture_command_with_timeout 8 input_device_list ./ListInputDevices && \ [[ "$input_device_list" =~ "BGMDevice" ]]; then log "Background Music device is installed and available." - # Break out of the loop. retries=0 else retries=$((retries - 1)) @@ -175,6 +363,3 @@ fi sleep 1 exit 0 - - -