Skip to content

Commit

Permalink
Reimplement audio mute for App Store (#2593)
Browse files Browse the repository at this point in the history
  • Loading branch information
mallexxx authored Apr 15, 2024
1 parent ef05acd commit f9630cf
Show file tree
Hide file tree
Showing 10 changed files with 100 additions and 96 deletions.
2 changes: 0 additions & 2 deletions DuckDuckGo/Common/Extensions/WKWebView+Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
126 changes: 66 additions & 60 deletions DuckDuckGo/Common/Extensions/WKWebViewExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}

Expand All @@ -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
Expand Down Expand Up @@ -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:")
}

}
2 changes: 1 addition & 1 deletion DuckDuckGo/PinnedTabs/Model/PinnedTabsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ final class PinnedTabsViewModel: ObservableObject {
audioStateView = .muted
case .unmuted:
audioStateView = .unmuted
case .notSupported:
case .none:
audioStateView = .notSupported
}
}
Expand Down
2 changes: 1 addition & 1 deletion DuckDuckGo/PinnedTabs/View/PinnedTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down
9 changes: 4 additions & 5 deletions DuckDuckGo/Tab/Model/Tab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -539,7 +539,7 @@ protocol NewWindowPolicyDecisionMaker {
self?.onDuckDuckGoEmailSignOut(notification)
}

self.audioState = webView.audioState()
self.audioState = webView.audioState
addDeallocationChecks(for: webView)
}

Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 2 additions & 5 deletions DuckDuckGo/TabBar/View/TabBarViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 9 additions & 11 deletions DuckDuckGo/TabBar/View/TabBarViewItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -446,7 +446,7 @@ final class TabBarViewItem: NSCollectionViewItem {
switch delegate?.tabBarViewItemAudioState(self) {
case .muted:
mutedTabIcon.isHidden = false
default:
case .unmuted, .none:
mutedTabIcon.isHidden = true
}
}
Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 9 additions & 8 deletions UnitTests/Permissions/WebViewMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down
5 changes: 5 additions & 0 deletions UnitTests/Tab/WKWebViewPrivateMethodsAvailabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 3 additions & 3 deletions UnitTests/TabBar/View/MockTabViewItemDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Expand Down Expand Up @@ -86,7 +86,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate {

}

func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState {
func tabBarViewItemAudioState(_ tabBarViewItem: TabBarViewItem) -> WKWebView.AudioState? {
return audioState
}

Expand All @@ -99,7 +99,7 @@ class MockTabViewItemDelegate: TabBarViewItemDelegate {
}

func clear() {
self.audioState = .notSupported
self.audioState = nil
}

}

0 comments on commit f9630cf

Please sign in to comment.