diff --git a/DuckDuckGo/Common/Extensions/WKWebView+Private.h b/DuckDuckGo/Common/Extensions/WKWebView+Private.h index b5e8c44d99..daa465149f 100644 --- a/DuckDuckGo/Common/Extensions/WKWebView+Private.h +++ b/DuckDuckGo/Common/Extensions/WKWebView+Private.h @@ -63,8 +63,6 @@ typedef NS_OPTIONS(NSUInteger, _WKFindOptions) { - (void)_stopMediaCapture API_AVAILABLE(macos(10.15.4), ios(13.4)); - (void)_stopAllMediaPlayback; -- (_WKMediaMutedState)_mediaMutedState API_AVAILABLE(macos(11.0), ios(14.0));; -- (void)_setPageMuted:(_WKMediaMutedState)mutedState API_AVAILABLE(macos(10.13), ios(11.0)); @end diff --git a/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift b/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift index fcd19de900..5f7a7b316c 100644 --- a/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift +++ b/DuckDuckGo/Common/Extensions/WKWebViewExtension.swift @@ -32,7 +32,17 @@ extension WKWebView { enum AudioState { case muted case unmuted - case notSupported + + init(wkMediaMutedState: _WKMediaMutedState) { + self = wkMediaMutedState.contains(.audioMuted) ? .muted : .unmuted + } + + mutating func toggle() { + self = switch self { + case .muted: .unmuted + case .unmuted: .muted + } + } } enum CaptureState { @@ -114,96 +124,84 @@ extension WKWebView { return .active } -#if !APPSTORE - private func setMediaCaptureMuted(_ muted: Bool) { - guard self.responds(to: #selector(WKWebView._setPageMuted(_:))) else { - assertionFailure("WKWebView does not respond to selector _stopMediaCapture") - return + @objc dynamic var mediaMutedState: _WKMediaMutedState { + get { + // swizzle the method to call `_mediaMutedState` without performSelector: usage + guard Self.swizzleMediaMutedStateOnce else { return [] } + return self.mediaMutedState // call the original } - let mutedState: _WKMediaMutedState = { - guard self.responds(to: #selector(WKWebView._mediaMutedState)) else { return [] } - return self._mediaMutedState() - }() - var newState = mutedState - if muted { - newState.insert(.captureDevicesMuted) - } else { - newState.remove(.captureDevicesMuted) + set { + // swizzle the method to call `_setPageMuted:` without performSelector: usage (as thereā€˜s a non-object argument to pass) + guard Self.swizzleSetPageMutedOnce else { return } + self.mediaMutedState = newValue // call the original } - guard newState != mutedState else { return } - self._setPageMuted(newState) } -#endif - func muteOrUnmute() { -#if !APPSTORE - guard self.responds(to: #selector(WKWebView._setPageMuted(_:))) else { - assertionFailure("WKWebView does not respond to selector _stopMediaCapture") - return + static private let swizzleMediaMutedStateOnce: Bool = { + guard let originalMethod = class_getInstanceMethod(WKWebView.self, Selector.mediaMutedState), + let swizzledMethod = class_getInstanceMethod(WKWebView.self, #selector(getter: mediaMutedState)) else { + assertionFailure("WKWebView does not respond to selector _mediaMutedState") + return false } - let mutedState: _WKMediaMutedState = { - guard self.responds(to: #selector(WKWebView._mediaMutedState)) else { return [] } - return self._mediaMutedState() - }() - var newState = mutedState - - if newState == .audioMuted { - newState.remove(.audioMuted) - } else { - newState.insert(.audioMuted) + method_exchangeImplementations(originalMethod, swizzledMethod) + return true + }() + + static private let swizzleSetPageMutedOnce: Bool = { + guard let originalMethod = class_getInstanceMethod(WKWebView.self, Selector.setPageMuted), + let swizzledMethod = class_getInstanceMethod(WKWebView.self, #selector(setter: mediaMutedState)) else { + assertionFailure("WKWebView does not respond to selector _setPageMuted:") + return false } - guard newState != mutedState else { return } - self._setPageMuted(newState) -#endif - } + method_exchangeImplementations(originalMethod, swizzledMethod) + return true + }() /// Returns the audio state of the WKWebView. /// /// - Returns: `muted` if the web view is muted /// `unmuted` if the web view is unmuted - /// `notSupported` if the web view does not support fetching the current audio state - func audioState() -> AudioState { -#if APPSTORE - return .notSupported -#else - guard self.responds(to: #selector(WKWebView._mediaMutedState)) else { - assertionFailure("WKWebView does not respond to selector _mediaMutedState") - return .notSupported + var audioState: AudioState { + get { + AudioState(wkMediaMutedState: mediaMutedState) + } + set { + switch newValue { + case .muted: + self.mediaMutedState.insert(.audioMuted) + case .unmuted: + self.mediaMutedState.remove(.audioMuted) + } } - - let mutedState = self._mediaMutedState() - - return mutedState.contains(.audioMuted) ? .muted : .unmuted -#endif } func stopMediaCapture() { - guard #available(macOS 12.0, *) else { #if !APPSTORE + guard #available(macOS 12.0, *) else { guard self.responds(to: #selector(_stopMediaCapture)) else { assertionFailure("WKWebView does not respond to _stopMediaCapture") return } self._stopMediaCapture() -#endif return } +#endif setCameraCaptureState(.none) setMicrophoneCaptureState(.none) } func stopAllMediaPlayback() { - guard #available(macOS 12.0, *) else { #if !APPSTORE + guard #available(macOS 12.0, *) else { guard self.responds(to: #selector(_stopAllMediaPlayback)) else { assertionFailure("WKWebView does not respond to _stopAllMediaPlayback") return } self._stopAllMediaPlayback() return -#endif } +#endif pauseAllMediaPlayback() } @@ -212,20 +210,26 @@ extension WKWebView { switch permission { case .camera: guard #available(macOS 12.0, *) else { -#if !APPSTORE - self.setMediaCaptureMuted(muted) -#endif + if muted { + self.mediaMutedState.insert(.captureDevicesMuted) + } else { + self.mediaMutedState.remove(.captureDevicesMuted) + } return } + self.setCameraCaptureState(muted ? .muted : .active, completionHandler: {}) case .microphone: guard #available(macOS 12.0, *) else { -#if !APPSTORE - self.setMediaCaptureMuted(muted) -#endif + if muted { + self.mediaMutedState.insert(.captureDevicesMuted) + } else { + self.mediaMutedState.remove(.captureDevicesMuted) + } return } + self.setMicrophoneCaptureState(muted ? .muted : .active, completionHandler: {}) case .geolocation: self.configuration.processPool.geolocationProvider?.isPaused = muted @@ -360,6 +364,8 @@ extension WKWebView { static let fullScreenPlaceholderView = NSSelectorFromString("_fullScreenPlaceholderView") static let printOperationWithPrintInfoForFrame = NSSelectorFromString("_printOperationWithPrintInfo:forFrame:") static let loadAlternateHTMLString = NSSelectorFromString("_loadAlternateHTMLString:baseURL:forUnreachableURL:") + static let mediaMutedState = NSSelectorFromString("_mediaMutedState") + static let setPageMuted = NSSelectorFromString("_setPageMuted:") } } diff --git a/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift b/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift index ee5c9f0c91..7324862828 100644 --- a/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift +++ b/DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift @@ -122,7 +122,7 @@ final class PinnedTabsViewModel: ObservableObject { audioStateView = .muted case .unmuted: audioStateView = .unmuted - case .notSupported: + case .none: audioStateView = .notSupported } } diff --git a/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift b/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift index 40768d20a3..3e067f1187 100644 --- a/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift +++ b/DuckDuckGo/PinnedTabs/View/PinnedTabView.swift @@ -212,7 +212,7 @@ struct PinnedTabInnerView: View { .renderingMode(.template) .frame(width: 12, height: 12) }.offset(x: 8, y: -8) - default: EmptyView() + case .unmuted, .none: EmptyView() } } diff --git a/DuckDuckGo/Tab/Model/Tab.swift b/DuckDuckGo/Tab/Model/Tab.swift index a1e4228051..39b66c9daa 100644 --- a/DuckDuckGo/Tab/Model/Tab.swift +++ b/DuckDuckGo/Tab/Model/Tab.swift @@ -539,7 +539,7 @@ protocol NewWindowPolicyDecisionMaker { self?.onDuckDuckGoEmailSignOut(notification) } - self.audioState = webView.audioState() + self.audioState = webView.audioState addDeallocationChecks(for: webView) } @@ -1035,12 +1035,11 @@ protocol NewWindowPolicyDecisionMaker { } } - @Published private(set) var audioState: WKWebView.AudioState = .notSupported + @Published private(set) var audioState: WKWebView.AudioState? func muteUnmuteTab() { - webView.muteOrUnmute() - - audioState = webView.audioState() + webView.audioState.toggle() + audioState = webView.audioState } private enum ReloadIfNeededSource { diff --git a/DuckDuckGo/TabBar/View/TabBarViewController.swift b/DuckDuckGo/TabBar/View/TabBarViewController.swift index 6ebc31e640..8f0253224b 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewController.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewController.swift @@ -1144,12 +1144,9 @@ extension TabBarViewController: TabBarViewItemDelegate { removeFireproofing(from: tab) } - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState { + func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? { guard let indexPath = collectionView.indexPath(for: tabBarViewItem), - let tab = tabCollectionViewModel.tabCollection.tabs[safe: indexPath.item] - else { - return .notSupported - } + let tab = tabCollectionViewModel.tabCollection.tabs[safe: indexPath.item] else { return nil } return tab.audioState } diff --git a/DuckDuckGo/TabBar/View/TabBarViewItem.swift b/DuckDuckGo/TabBar/View/TabBarViewItem.swift index b4a1bbca64..a92752aa56 100644 --- a/DuckDuckGo/TabBar/View/TabBarViewItem.swift +++ b/DuckDuckGo/TabBar/View/TabBarViewItem.swift @@ -46,7 +46,7 @@ protocol TabBarViewItemDelegate: AnyObject { func tabBarViewItemFireproofSite(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemMuteUnmuteSite(_ tabBarViewItem: TabBarViewItem) func tabBarViewItemRemoveFireproofing(_ tabBarViewItem: TabBarViewItem) - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState + func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? func otherTabBarViewItemsState(for tabBarViewItem: TabBarViewItem) -> OtherTabBarViewItemsState @@ -446,7 +446,7 @@ final class TabBarViewItem: NSCollectionViewItem { switch delegate?.tabBarViewItemAudioState(self) { case .muted: mutedTabIcon.isHidden = false - default: + case .unmuted, .none: mutedTabIcon.isHidden = true } } @@ -540,15 +540,13 @@ extension TabBarViewItem: NSMenuDelegate { } private func addMuteUnmuteMenuItem(to menu: NSMenu) { - let audioState = delegate?.tabBarViewItemAudioState(self) ?? .notSupported - - if audioState != .notSupported { - menu.addItem(NSMenuItem.separator()) - let menuItemTitle = audioState == .muted ? UserText.unmuteTab : UserText.muteTab - let muteUnmuteMenuItem = NSMenuItem(title: menuItemTitle, action: #selector(muteUnmuteSiteAction(_:)), keyEquivalent: "") - muteUnmuteMenuItem.target = self - menu.addItem(muteUnmuteMenuItem) - } + guard let audioState = delegate?.tabBarViewItemAudioState(self) else { return } + + menu.addItem(NSMenuItem.separator()) + let menuItemTitle = audioState == .muted ? UserText.unmuteTab : UserText.muteTab + let muteUnmuteMenuItem = NSMenuItem(title: menuItemTitle, action: #selector(muteUnmuteSiteAction(_:)), keyEquivalent: "") + muteUnmuteMenuItem.target = self + menu.addItem(muteUnmuteMenuItem) } private func addCloseMenuItem(to menu: NSMenu) { diff --git a/UnitTests/Permissions/WebViewMock.swift b/UnitTests/Permissions/WebViewMock.swift index d8b0dd4ae5..8899c7f88f 100644 --- a/UnitTests/Permissions/WebViewMock.swift +++ b/UnitTests/Permissions/WebViewMock.swift @@ -107,15 +107,16 @@ final class WebViewMock: WKWebView { stopMediaCaptureHandler?() } - var mediaMutedStateValue = _WKMediaMutedState() - override func _mediaMutedState() -> _WKMediaMutedState { - mediaMutedStateValue - } - + var mediaMutedStateValue: _WKMediaMutedState = [] var setPageMutedHandler: ((_WKMediaMutedState) -> Void)? - override func _setPageMuted(_ mutedState: _WKMediaMutedState) { - mediaMutedStateValue = mutedState - setPageMutedHandler?(mutedState) + override var mediaMutedState: _WKMediaMutedState { + get { + mediaMutedStateValue + } + set { + mediaMutedStateValue = newValue + setPageMutedHandler?(newValue) + } } var setCameraCaptureStateHandler: ((Bool?) -> Void)? diff --git a/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift b/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift index b569bdb58d..d62bf1f85a 100644 --- a/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift +++ b/UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift @@ -39,6 +39,11 @@ final class WKWebViewPrivateMethodsAvailabilityTests: XCTestCase { XCTAssertTrue(WKBackForwardList.instancesRespond(to: WKBackForwardList.removeAllItemsSelector)) } + func testWebViewRespondsTo_pageMutedState() { + XCTAssertTrue(WKWebView.instancesRespond(to: WKWebView.Selector.setPageMuted)) + XCTAssertTrue(WKWebView.instancesRespond(to: WKWebView.Selector.mediaMutedState)) + } + func testWKWebpagePreferencesCustomHeaderFieldsSupported() { XCTAssertTrue(NavigationPreferences.customHeadersSupported) let testHeaders = ["X-CUSTOM-HEADER": "TEST"] diff --git a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift index c09b956f23..e63dad9b0a 100644 --- a/UnitTests/TabBar/View/MockTabViewItemDelegate.swift +++ b/UnitTests/TabBar/View/MockTabViewItemDelegate.swift @@ -24,7 +24,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { var mockedCurrentTab: Tab? var hasItemsToTheRight = false - var audioState: WKWebView.AudioState = .notSupported + var audioState: WKWebView.AudioState? func tabBarViewItem(_ tabBarViewItem: DuckDuckGo_Privacy_Browser.TabBarViewItem, isMouseOver: Bool) { @@ -86,7 +86,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } - func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState { + func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? { return audioState } @@ -99,7 +99,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate { } func clear() { - self.audioState = .notSupported + self.audioState = nil } }