diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bf6e7d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# macOS AppleDouble metadata +._* +**/._* + +# Xcode user/local state +*.xcuserstate +xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ + +# Build products +DerivedData/ +build/ diff --git a/AppIcon.icns b/AppIcon.icns index 9daa0ca..985a146 100644 Binary files a/AppIcon.icns and b/AppIcon.icns differ diff --git a/PodsMute.xcodeproj/project.pbxproj b/PodsMute.xcodeproj/project.pbxproj index db90512..3d00ef5 100644 --- a/PodsMute.xcodeproj/project.pbxproj +++ b/PodsMute.xcodeproj/project.pbxproj @@ -3,35 +3,45 @@ archiveVersion = 1; classes = { }; - objectVersion = 63; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 31D968C52EE89FF100CE703C /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 31D968C42EE89FF100CE703C /* AppIcon.icns */; }; 4858453AC7AEF3575566CC45 /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FCF4C6BD82713D6B817092EF /* CoreAudio.framework */; }; 7EB1763721962FA2554DB2DA /* PodsMuteApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DEFCE9EF839109AAB5DB5A /* PodsMuteApp.swift */; }; 9973D694A7FC6BD1D4891ABF /* AudioAccessoryMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73538844FEA894F7FBC2E66D /* AudioAccessoryMonitor.swift */; }; 9FC807F9FCF708EEC67C6C90 /* StatusBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56E07DA6711D12BFB1F0182 /* StatusBarController.swift */; }; + A211620296EA884D7F8246A3 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = EAAACD1DC939335E47965686 /* AppIcon.icns */; }; + B4392F4FD546456FB9D11E82 /* MediaKeyMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4392F4ED546456FB9D11E82 /* MediaKeyMonitor.swift */; }; BB0BD99D5E7F4B2D819C69E3 /* BluetoothManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D77BC34E631E2BCDE420B9 /* BluetoothManager.swift */; }; + C7A711E6129D41AFA6268D71 /* MusicLaunchMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A711E5129D41AFA6268D71 /* MusicLaunchMonitor.swift */; }; + D41441EED5F94CE8B8C86C21 /* InterceptDiagnosticsMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D41441EDD5F94CE8B8C86C21 /* InterceptDiagnosticsMonitor.swift */; }; + DCC234C51DE24939B9878C40 /* RemoteCommandCenterMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC234C41DE24939B9878C40 /* RemoteCommandCenterMonitor.swift */; }; E54EF3A9ECC33C27E887C227 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D25F062F51F8D6C97166F2F7 /* Cocoa.framework */; }; E5653B2A9DB346A57F338EE7 /* AudioMuteController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D26B96934BB0614761F88913 /* AudioMuteController.swift */; }; ED7EAEB0DA64857E8C360556 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DD850274536DA42968B9FB2 /* AppDelegate.swift */; }; + F10F03D607534CE3AC0C8F2C /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F10F03D507534CE3AC0C8F2C /* MediaPlayer.framework */; }; F992AE49381FE48D748AD93A /* IOBluetooth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F854C8347EC19C6BFD62FA3C /* IOBluetooth.framework */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 0DD850274536DA42968B9FB2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 26D77BC34E631E2BCDE420B9 /* BluetoothManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothManager.swift; sourceTree = ""; }; - 31D968C42EE89FF100CE703C /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; 501C9A01B95DE856250B7E35 /* PodsMute.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = PodsMute.entitlements; sourceTree = ""; }; 73538844FEA894F7FBC2E66D /* AudioAccessoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAccessoryMonitor.swift; sourceTree = ""; }; 90BE3A89C12CA9404F743738 /* PodsMute.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PodsMute.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9F6A6C09AC4F893074FC7996 /* PodsMute-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "PodsMute-Bridging-Header.h"; sourceTree = ""; }; + B4392F4ED546456FB9D11E82 /* MediaKeyMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyMonitor.swift; sourceTree = ""; }; B56E07DA6711D12BFB1F0182 /* StatusBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarController.swift; sourceTree = ""; }; BDD459EE935302520878AAA1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + C7A711E5129D41AFA6268D71 /* MusicLaunchMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicLaunchMonitor.swift; sourceTree = ""; }; D25F062F51F8D6C97166F2F7 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = Cocoa.framework; sourceTree = ""; }; D26B96934BB0614761F88913 /* AudioMuteController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMuteController.swift; sourceTree = ""; }; + D41441EDD5F94CE8B8C86C21 /* InterceptDiagnosticsMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterceptDiagnosticsMonitor.swift; sourceTree = ""; }; D5DEFCE9EF839109AAB5DB5A /* PodsMuteApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodsMuteApp.swift; sourceTree = ""; }; + DCC234C41DE24939B9878C40 /* RemoteCommandCenterMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandCenterMonitor.swift; sourceTree = ""; }; + EAAACD1DC939335E47965686 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; + F10F03D507534CE3AC0C8F2C /* MediaPlayer.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = MediaPlayer.framework; sourceTree = SDKROOT; }; F854C8347EC19C6BFD62FA3C /* IOBluetooth.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = IOBluetooth.framework; sourceTree = ""; }; FCF4C6BD82713D6B817092EF /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = CoreAudio.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -43,6 +53,7 @@ files = ( F992AE49381FE48D748AD93A /* IOBluetooth.framework in Frameworks */, 4858453AC7AEF3575566CC45 /* CoreAudio.framework in Frameworks */, + F10F03D607534CE3AC0C8F2C /* MediaPlayer.framework in Frameworks */, E54EF3A9ECC33C27E887C227 /* Cocoa.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -69,6 +80,7 @@ D25F062F51F8D6C97166F2F7 /* Cocoa.framework */, FCF4C6BD82713D6B817092EF /* CoreAudio.framework */, F854C8347EC19C6BFD62FA3C /* IOBluetooth.framework */, + F10F03D507534CE3AC0C8F2C /* MediaPlayer.framework */, ); name = Frameworks; sourceTree = ""; @@ -86,7 +98,7 @@ 5672D0B19F5E37276BA21575 /* Resources */ = { isa = PBXGroup; children = ( - 31D968C42EE89FF100CE703C /* AppIcon.icns */, + EAAACD1DC939335E47965686 /* AppIcon.icns */, ); path = Resources; sourceTree = ""; @@ -114,6 +126,10 @@ 73538844FEA894F7FBC2E66D /* AudioAccessoryMonitor.swift */, D26B96934BB0614761F88913 /* AudioMuteController.swift */, 26D77BC34E631E2BCDE420B9 /* BluetoothManager.swift */, + B4392F4ED546456FB9D11E82 /* MediaKeyMonitor.swift */, + C7A711E5129D41AFA6268D71 /* MusicLaunchMonitor.swift */, + D41441EDD5F94CE8B8C86C21 /* InterceptDiagnosticsMonitor.swift */, + DCC234C41DE24939B9878C40 /* RemoteCommandCenterMonitor.swift */, ); path = Services; sourceTree = ""; @@ -172,7 +188,6 @@ }; }; buildConfigurationList = 96AE2A6F51C966928290EE97 /* Build configuration list for PBXProject "PodsMute" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -181,6 +196,8 @@ ); mainGroup = 5DBF3392A66BD7CB057FB62F; minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = DF523DF552AF2DDA400DA576 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -194,7 +211,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 31D968C52EE89FF100CE703C /* AppIcon.icns in Resources */, + A211620296EA884D7F8246A3 /* AppIcon.icns in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -209,6 +226,10 @@ 9973D694A7FC6BD1D4891ABF /* AudioAccessoryMonitor.swift in Sources */, E5653B2A9DB346A57F338EE7 /* AudioMuteController.swift in Sources */, BB0BD99D5E7F4B2D819C69E3 /* BluetoothManager.swift in Sources */, + B4392F4FD546456FB9D11E82 /* MediaKeyMonitor.swift in Sources */, + C7A711E6129D41AFA6268D71 /* MusicLaunchMonitor.swift in Sources */, + D41441EED5F94CE8B8C86C21 /* InterceptDiagnosticsMonitor.swift in Sources */, + DCC234C51DE24939B9878C40 /* RemoteCommandCenterMonitor.swift in Sources */, 7EB1763721962FA2554DB2DA /* PodsMuteApp.swift in Sources */, 9FC807F9FCF708EEC67C6C90 /* StatusBarController.swift in Sources */, ); @@ -294,7 +315,9 @@ "$(inherited)", "@executable_path/../Frameworks", ); - OTHER_LDFLAGS = "-ObjC"; + OTHER_LDFLAGS = ( + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.podsmute.app; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "PodsMute/Bridge/PodsMute-Bridging-Header.h"; @@ -385,7 +408,9 @@ "$(inherited)", "@executable_path/../Frameworks", ); - OTHER_LDFLAGS = "-ObjC"; + OTHER_LDFLAGS = ( + "-ObjC", + ); PRODUCT_BUNDLE_IDENTIFIER = com.podsmute.app; SDKROOT = macosx; SWIFT_OBJC_BRIDGING_HEADER = "PodsMute/Bridge/PodsMute-Bridging-Header.h"; diff --git a/PodsMute/App/AppDelegate.swift b/PodsMute/App/AppDelegate.swift index 9cd21be..4b64199 100644 --- a/PodsMute/App/AppDelegate.swift +++ b/PodsMute/App/AppDelegate.swift @@ -24,6 +24,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var audioController: AudioMuteController! private var audioAccessoryMonitor: AudioAccessoryMonitor! + private var mediaKeyMonitor: MediaKeyMonitor! + private var musicLaunchMonitor: MusicLaunchMonitor! + private var interceptDiagnosticsMonitor: InterceptDiagnosticsMonitor! + private var remoteCommandCenterMonitor: RemoteCommandCenterMonitor! private var statusBarController: StatusBarController! // Keep reference to BluetoothManager for device detection (status display) @@ -40,6 +44,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup audioaccessoryd notification monitoring setupAudioAccessoryMonitoring() + // Also catch AirPods crown presses that arrive as media Play/Pause events. + setupMediaKeyMonitoring() + + // Fallback for AirPods remote commands that bypass keyboard event taps. + setupMusicLaunchMonitoring() + + // Try to consume media-remote commands before Music is launched. + setupRemoteCommandCenterMonitoring() + + // Diagnostic logging for candidate event sources. + interceptDiagnosticsMonitor.startMonitoring() + print("[AppDelegate] Application ready") print("[AppDelegate] Press your AirPods button to toggle mute") print("[AppDelegate] Listening for audioaccessoryd mute state notifications...") @@ -55,6 +71,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { } audioAccessoryMonitor?.stopMonitoring() + mediaKeyMonitor?.stopMonitoring() + musicLaunchMonitor?.stopMonitoring() + interceptDiagnosticsMonitor?.stopMonitoring() + remoteCommandCenterMonitor?.stopMonitoring() } func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { @@ -73,6 +93,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Create audio accessory monitor (for AirPods crown button detection) audioAccessoryMonitor = AudioAccessoryMonitor() + // Create media key monitor (for crown presses mapped to Play/Pause) + mediaKeyMonitor = MediaKeyMonitor() + + // Create Music launch monitor (fallback for Bluetooth remote commands) + musicLaunchMonitor = MusicLaunchMonitor() + + // Create diagnostics monitor + interceptDiagnosticsMonitor = InterceptDiagnosticsMonitor() + + // Create media remote command monitor + remoteCommandCenterMonitor = RemoteCommandCenterMonitor() + // Create status bar controller statusBarController = StatusBarController( audioController: audioController, @@ -113,6 +145,54 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + private func setupMediaKeyMonitoring() { + mediaKeyMonitor.onPlayPausePressed = { [weak self] in + guard let self = self else { return } + + print("[AppDelegate] AirPods media key press received") + + self.audioController.toggleMute() + self.statusBarController.updateIcon() + self.statusBarController.showMutePopover(isMuted: self.audioController.isMuted) + } + + let success = mediaKeyMonitor.startMonitoring() + + if success { + print("[AppDelegate] Media key monitoring started successfully") + } else { + print("[AppDelegate] WARNING: Failed to start media key monitoring") + } + } + + private func setupMusicLaunchMonitoring() { + musicLaunchMonitor.onMusicLaunch = { [weak self] in + guard let self = self else { return } + + print("[AppDelegate] Music launch fallback received") + + self.audioController.toggleMute() + self.statusBarController.updateIcon() + self.statusBarController.showMutePopover(isMuted: self.audioController.isMuted) + } + + musicLaunchMonitor.startMonitoring() + } + + private func setupRemoteCommandCenterMonitoring() { + remoteCommandCenterMonitor.onRemoteCommand = { [weak self] in + guard let self = self else { return } + + print("[AppDelegate] Media remote command received") + + self.audioController.toggleMute() + self.statusBarController.updateIcon() + self.statusBarController.showMutePopover(isMuted: self.audioController.isMuted) + } + + remoteCommandCenterMonitor.startMonitoring() + } + private func checkForAirPods() { let devices = bluetoothManager.pairedDevices() diff --git a/PodsMute/App/Info.plist b/PodsMute/App/Info.plist index 1fe1d21..2fd5508 100644 --- a/PodsMute/App/Info.plist +++ b/PodsMute/App/Info.plist @@ -23,6 +23,8 @@ CFBundleIconFile AppIcon + CFBundleIconName + AppIcon LSUIElement diff --git a/PodsMute/Resources/AppIcon.icns b/PodsMute/Resources/AppIcon.icns index 4b968d1..985a146 100644 Binary files a/PodsMute/Resources/AppIcon.icns and b/PodsMute/Resources/AppIcon.icns differ diff --git a/PodsMute/Services/InterceptDiagnosticsMonitor.swift b/PodsMute/Services/InterceptDiagnosticsMonitor.swift new file mode 100644 index 0000000..fe9e890 --- /dev/null +++ b/PodsMute/Services/InterceptDiagnosticsMonitor.swift @@ -0,0 +1,182 @@ +// +// InterceptDiagnosticsMonitor.swift +// PodsMute +// +// Logs candidate event sources for AirPods controls and media routing. +// + +import AppKit +import IOKit.hid + +/// Diagnostic logging for event streams that PodsMute may be able to intercept. +final class InterceptDiagnosticsMonitor { + + // MARK: - Properties + + private var localEventMonitor: Any? + private var globalEventMonitor: Any? + private var workspaceObservers: [NSObjectProtocol] = [] + private var distributedObserver: NSObjectProtocol? + private var hidManager: IOHIDManager? + + private let interestingNotificationTokens = [ + "airpod", + "audio", + "bluetooth", + "hid", + "media", + "music", + "mute", + "remote", + "sound", + ] + + // MARK: - Public Methods + + func startMonitoring() { + print("[InterceptDiagnostics] Starting candidate event logging") + startNSEventLogging() + startWorkspaceLogging() + startDistributedNotificationLogging() + startHIDLogging() + } + + func stopMonitoring() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + + if let globalEventMonitor { + NSEvent.removeMonitor(globalEventMonitor) + self.globalEventMonitor = nil + } + + for observer in workspaceObservers { + NSWorkspace.shared.notificationCenter.removeObserver(observer) + } + workspaceObservers.removeAll() + + if let distributedObserver { + DistributedNotificationCenter.default().removeObserver(distributedObserver) + self.distributedObserver = nil + } + + if let hidManager { + IOHIDManagerUnscheduleFromRunLoop(hidManager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + IOHIDManagerClose(hidManager, IOOptionBits(kIOHIDOptionsTypeNone)) + self.hidManager = nil + } + + print("[InterceptDiagnostics] Stopped candidate event logging") + } + + // MARK: - NSEvent + + private func startNSEventLogging() { + let mask: NSEvent.EventTypeMask = [.systemDefined, .keyDown, .flagsChanged] + + localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: mask) { event in + self.logNSEvent(event, source: "local") + return event + } + + globalEventMonitor = NSEvent.addGlobalMonitorForEvents(matching: mask) { [weak self] event in + self?.logNSEvent(event, source: "global") + } + } + + private func logNSEvent(_ event: NSEvent, source: String) { + switch event.type { + case .systemDefined: + let keyCode = (event.data1 & 0xFFFF0000) >> 16 + let keyFlags = event.data1 & 0x0000FFFF + let keyState = (keyFlags & 0xFF00) >> 8 + print("[InterceptDiagnostics] NSEvent \(source): systemDefined subtype=\(event.subtype.rawValue) keyCode=\(keyCode) keyState=\(keyState) data1=\(event.data1) data2=\(event.data2)") + case .keyDown: + print("[InterceptDiagnostics] NSEvent \(source): keyDown keyCode=\(event.keyCode) chars=\(event.charactersIgnoringModifiers ?? "") modifiers=\(event.modifierFlags.rawValue)") + case .flagsChanged: + print("[InterceptDiagnostics] NSEvent \(source): flagsChanged keyCode=\(event.keyCode) modifiers=\(event.modifierFlags.rawValue)") + default: + break + } + } + + // MARK: - Workspace + + private func startWorkspaceLogging() { + let center = NSWorkspace.shared.notificationCenter + let names: [Notification.Name] = [ + NSWorkspace.didLaunchApplicationNotification, + NSWorkspace.didActivateApplicationNotification, + NSWorkspace.didTerminateApplicationNotification, + ] + + for name in names { + let observer = center.addObserver(forName: name, object: nil, queue: .main) { notification in + let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication + print("[InterceptDiagnostics] Workspace: \(notification.name.rawValue) app=\(app?.localizedName ?? "unknown") bundle=\(app?.bundleIdentifier ?? "unknown") pid=\(app?.processIdentifier ?? -1)") + } + workspaceObservers.append(observer) + } + } + + // MARK: - Distributed Notifications + + private func startDistributedNotificationLogging() { + distributedObserver = DistributedNotificationCenter.default().addObserver( + forName: nil, + object: nil, + queue: .main + ) { [weak self] notification in + guard self?.shouldLogDistributedNotification(notification) == true else { return } + print("[InterceptDiagnostics] DistributedNotification: name=\(notification.name.rawValue) object=\(notification.object ?? "nil") userInfo=\(notification.userInfo ?? [:])") + } + } + + private func shouldLogDistributedNotification(_ notification: Notification) -> Bool { + let haystack = [ + notification.name.rawValue, + notification.object.map { String(describing: $0) } ?? "", + notification.userInfo.map { String(describing: $0) } ?? "", + ].joined(separator: " ").lowercased() + + return interestingNotificationTokens.contains { haystack.contains($0) } + } + + // MARK: - IOHID + + private func startHIDLogging() { + let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) + hidManager = manager + + let matching: [[String: Any]] = [ + [kIOHIDDeviceUsagePageKey as String: kHIDPage_Consumer], + [kIOHIDDeviceUsagePageKey as String: kHIDPage_Telephony], + [kIOHIDDeviceUsagePageKey as String: kHIDPage_GenericDesktop], + ] + + IOHIDManagerSetDeviceMatchingMultiple(manager, matching as CFArray) + + IOHIDManagerRegisterInputValueCallback(manager, { context, _, _, value in + guard let context else { return } + let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue() + monitor.logHIDValue(value) + }, Unmanaged.passUnretained(self).toOpaque()) + + IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.commonModes.rawValue) + + let result = IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) + print("[InterceptDiagnostics] IOHID monitor open result=\(result)") + } + + private func logHIDValue(_ value: IOHIDValue) { + let element = IOHIDValueGetElement(value) + + let usagePage = IOHIDElementGetUsagePage(element) + let usage = IOHIDElementGetUsage(element) + let intValue = IOHIDValueGetIntegerValue(value) + + print("[InterceptDiagnostics] IOHID: usagePage=0x\(String(usagePage, radix: 16)) usage=0x\(String(usage, radix: 16)) value=\(intValue)") + } +} diff --git a/PodsMute/Services/MediaKeyMonitor.swift b/PodsMute/Services/MediaKeyMonitor.swift new file mode 100644 index 0000000..a66eacb --- /dev/null +++ b/PodsMute/Services/MediaKeyMonitor.swift @@ -0,0 +1,212 @@ +// +// MediaKeyMonitor.swift +// PodsMute +// +// Captures headset/media play-pause button events before macOS routes them to Music. +// + +import ApplicationServices +import Cocoa + +/// Monitors system media key events and consumes Play/Pause presses. +/// +/// AirPods Max crown presses can arrive as a Play/Pause media key. If no media app +/// handles the key first, macOS may launch Music. This event tap catches the key, +/// toggles mute through the app callback, and suppresses the original event. +final class MediaKeyMonitor { + + // MARK: - Properties + + var onPlayPausePressed: (() -> Void)? + + private var eventTap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + private var isMonitoring = false + private var activeTapLocation: CGEventTapLocation? + + // NSEvent.Subtype.systemDefined raw value. + private let mediaKeyEventSubtype: Int16 = 8 + + // NSEvent.EventType.systemDefined raw value. + private let systemDefinedEventType = CGEventType(rawValue: 14)! + + // NX_KEYTYPE_PLAY from IOKit/hidsystem/ev_keymap.h. + private let playPauseKeyCode = 16 + + // Other transport controls that should not be allowed to wake Music when + // they come from the headset button sequence. + private let mediaTransportKeyCodes: Set = [16, 17, 18, 19, 20] + + // MARK: - Public Methods + + @discardableResult + func startMonitoring() -> Bool { + guard !isMonitoring else { + print("[MediaKeyMonitor] Already monitoring") + return true + } + + requestAccessibilityPermissionIfNeeded() + print("[MediaKeyMonitor] Running bundle: \(Bundle.main.bundlePath)") + print("[MediaKeyMonitor] Accessibility trusted: \(AXIsProcessTrusted())") + + let eventMask = CGEventMask(1 << systemDefinedEventType.rawValue) + let callback: CGEventTapCallBack = { _, type, event, userInfo in + guard let userInfo = userInfo else { + return Unmanaged.passUnretained(event) + } + + let monitor = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + monitor.enableEventTap() + return Unmanaged.passUnretained(event) + } + + guard type == monitor.systemDefinedEventType, + let mediaKey = monitor.mediaKeyDown(from: event) else { + return Unmanaged.passUnretained(event) + } + + print("[MediaKeyMonitor] Media key captured: code=\(mediaKey.keyCode), data1=\(mediaKey.data1), data2=\(mediaKey.data2)") + + guard monitor.mediaTransportKeyCodes.contains(mediaKey.keyCode) else { + return Unmanaged.passUnretained(event) + } + + if mediaKey.keyCode == monitor.playPauseKeyCode { + monitor.handlePlayPausePressed() + } + + // Suppress headset transport controls so Music does not receive them. + return nil + } + + guard let tap = createEventTap(eventMask: eventMask, callback: callback) else { + print("[MediaKeyMonitor] Failed to create media key event tap") + print("[MediaKeyMonitor] Grant Accessibility permission in System Settings > Privacy & Security > Accessibility") + return false + } + + guard let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) else { + print("[MediaKeyMonitor] Failed to create media key run loop source") + return false + } + + eventTap = tap + runLoopSource = source + + CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes) + enableEventTap() + + isMonitoring = true + print("[MediaKeyMonitor] Started monitoring media keys with \(tapLocationDescription)") + return true + } + + func stopMonitoring() { + guard isMonitoring else { return } + + if let tap = eventTap { + CGEvent.tapEnable(tap: tap, enable: false) + } + + if let source = runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes) + } + + eventTap = nil + runLoopSource = nil + activeTapLocation = nil + isMonitoring = false + print("[MediaKeyMonitor] Stopped monitoring") + } + + // MARK: - Private Methods + + private func requestAccessibilityPermissionIfNeeded() { + guard !AXIsProcessTrusted() else { return } + + let options = [ + kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true + ] as CFDictionary + + _ = AXIsProcessTrustedWithOptions(options) + print("[MediaKeyMonitor] Accessibility permission is required to intercept media keys") + } + + private func enableEventTap() { + guard let eventTap = eventTap else { return } + CGEvent.tapEnable(tap: eventTap, enable: true) + } + + private func createEventTap(eventMask: CGEventMask, callback: @escaping CGEventTapCallBack) -> CFMachPort? { + let tapLocations: [CGEventTapLocation] = [ + .cghidEventTap, + .cgSessionEventTap, + .cgAnnotatedSessionEventTap, + ] + + for location in tapLocations { + if let tap = CGEvent.tapCreate( + tap: location, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: callback, + userInfo: Unmanaged.passUnretained(self).toOpaque() + ) { + activeTapLocation = location + return tap + } + + print("[MediaKeyMonitor] Could not create \(description(for: location)); trying next location") + } + + return nil + } + + private func mediaKeyDown(from event: CGEvent) -> (keyCode: Int, data1: Int, data2: Int)? { + guard let nsEvent = NSEvent(cgEvent: event), + nsEvent.subtype.rawValue == mediaKeyEventSubtype else { + return nil + } + + let data1 = nsEvent.data1 + let data2 = nsEvent.data2 + let keyCode = (data1 & 0xFFFF0000) >> 16 + let keyFlags = data1 & 0x0000FFFF + let keyState = (keyFlags & 0xFF00) >> 8 + let isKeyDown = keyState == 0x0A + + guard isKeyDown else { + return nil + } + + return (keyCode: keyCode, data1: data1, data2: data2) + } + + private func handlePlayPausePressed() { + DispatchQueue.main.async { [weak self] in + self?.onPlayPausePressed?() + } + } + + private var tapLocationDescription: String { + guard let activeTapLocation = activeTapLocation else { return "unknown event tap" } + return description(for: activeTapLocation) + } + + private func description(for location: CGEventTapLocation) -> String { + switch location { + case .cghidEventTap: + return "HID event tap" + case .cgSessionEventTap: + return "session event tap" + case .cgAnnotatedSessionEventTap: + return "annotated session event tap" + @unknown default: + return "unknown event tap" + } + } +} diff --git a/PodsMute/Services/MusicLaunchMonitor.swift b/PodsMute/Services/MusicLaunchMonitor.swift new file mode 100644 index 0000000..82be294 --- /dev/null +++ b/PodsMute/Services/MusicLaunchMonitor.swift @@ -0,0 +1,85 @@ +// +// MusicLaunchMonitor.swift +// PodsMute +// +// Handles AirPods remote commands that macOS routes directly to Music. +// + +import AppKit + +/// Detects Music launches caused by Bluetooth remote commands. +/// +/// AirPods Max crown presses can bypass CGEvent taps and be handled by macOS as +/// media-remote commands. When no media client is active, macOS launches Music. +/// This monitor treats that launch as the button signal, toggles mute, and closes +/// Music so it does not stay open. +final class MusicLaunchMonitor { + + // MARK: - Properties + + var onMusicLaunch: (() -> Void)? + + private let musicBundleIdentifier = "com.apple.Music" + private var observer: NSObjectProtocol? + private var lastHandledAt: Date? + private let debounceInterval: TimeInterval = 1.0 + + // MARK: - Public Methods + + func startMonitoring() { + guard observer == nil else { + print("[MusicLaunchMonitor] Already monitoring") + return + } + + observer = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notification in + self?.handleApplicationLaunch(notification) + } + + print("[MusicLaunchMonitor] Started monitoring Music launches") + } + + func stopMonitoring() { + guard let observer = observer else { return } + + NSWorkspace.shared.notificationCenter.removeObserver(observer) + self.observer = nil + print("[MusicLaunchMonitor] Stopped monitoring") + } + + // MARK: - Private Methods + + private func handleApplicationLaunch(_ notification: Notification) { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, + app.bundleIdentifier == musicBundleIdentifier else { + return + } + + guard shouldHandleLaunch() else { return } + + print("[MusicLaunchMonitor] Music launched; treating as AirPods remote command") + onMusicLaunch?() + + if app.terminate() { + print("[MusicLaunchMonitor] Requested Music termination") + } else { + print("[MusicLaunchMonitor] Failed to request Music termination") + } + } + + private func shouldHandleLaunch() -> Bool { + let now = Date() + + if let lastHandledAt, + now.timeIntervalSince(lastHandledAt) < debounceInterval { + return false + } + + lastHandledAt = now + return true + } +} diff --git a/PodsMute/Services/RemoteCommandCenterMonitor.swift b/PodsMute/Services/RemoteCommandCenterMonitor.swift new file mode 100644 index 0000000..42af0de --- /dev/null +++ b/PodsMute/Services/RemoteCommandCenterMonitor.swift @@ -0,0 +1,100 @@ +// +// RemoteCommandCenterMonitor.swift +// PodsMute +// +// Registers PodsMute as a media remote-command target. +// + +import Foundation +import MediaPlayer + +/// Handles media remote commands before macOS falls back to launching Music. +/// +/// AirPods controls can be routed through the system media remote path instead +/// of keyboard/HID events. Registering a remote command target gives PodsMute a +/// chance to consume Play/Pause-style commands directly. +final class RemoteCommandCenterMonitor { + + // MARK: - Properties + + var onRemoteCommand: (() -> Void)? + + private var isMonitoring = false + private let commandCenter = MPRemoteCommandCenter.shared() + private let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() + + // MARK: - Public Methods + + func startMonitoring() { + guard !isMonitoring else { + print("[RemoteCommandCenterMonitor] Already monitoring") + return + } + + configureNowPlayingInfo() + configureCommands() + + isMonitoring = true + print("[RemoteCommandCenterMonitor] Started monitoring media remote commands") + } + + func stopMonitoring() { + guard isMonitoring else { return } + + commandCenter.playCommand.removeTarget(nil) + commandCenter.pauseCommand.removeTarget(nil) + commandCenter.togglePlayPauseCommand.removeTarget(nil) + nowPlayingInfoCenter.nowPlayingInfo = nil + + isMonitoring = false + print("[RemoteCommandCenterMonitor] Stopped monitoring") + } + + // MARK: - Private Methods + + private func configureNowPlayingInfo() { + nowPlayingInfoCenter.nowPlayingInfo = [ + MPMediaItemPropertyTitle: "Microphone Mute", + MPMediaItemPropertyArtist: "PodsMute", + MPNowPlayingInfoPropertyPlaybackRate: 1.0, + MPNowPlayingInfoPropertyElapsedPlaybackTime: 0.0, + ] + + if #available(macOS 10.12.2, *) { + nowPlayingInfoCenter.playbackState = .playing + } + } + + private func configureCommands() { + commandCenter.playCommand.isEnabled = true + commandCenter.pauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.isEnabled = true + + commandCenter.nextTrackCommand.isEnabled = false + commandCenter.previousTrackCommand.isEnabled = false + commandCenter.changePlaybackPositionCommand.isEnabled = false + + commandCenter.playCommand.addTarget { [weak self] _ in + self?.handleRemoteCommand(name: "play") + return .success + } + + commandCenter.pauseCommand.addTarget { [weak self] _ in + self?.handleRemoteCommand(name: "pause") + return .success + } + + commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in + self?.handleRemoteCommand(name: "togglePlayPause") + return .success + } + } + + private func handleRemoteCommand(name: String) { + print("[RemoteCommandCenterMonitor] Remote command received: \(name)") + + DispatchQueue.main.async { [weak self] in + self?.onRemoteCommand?() + } + } +} diff --git a/README.md b/README.md index fadac9b..c5b54f1 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ A macOS menu bar application that detects AirPods button presses and toggles sys ## Features - **Button Detection**: Press on AirPods toggles microphone mute +- **Media Key Capture**: Crown presses mapped to Play/Pause are consumed so they do not launch Music - **Menu Bar Integration**: Shows headphones icon with colored mic badge (green = unmuted, red = muted) - **Visual Feedback**: Popover appears briefly when mute state changes - **Connection Status**: View AirPods connection state in the menu @@ -35,7 +36,8 @@ PodsMute/ ├── Services/ │ ├── AudioMuteController.swift # Core Audio mute control │ ├── AudioAccessoryMonitor.swift # Darwin notification listener -│ └── BluetoothManager.swift # Bluetooth connection status +│ ├── BluetoothManager.swift # Bluetooth connection status +│ └── MediaKeyMonitor.swift # Play/Pause media key capture ├── Bridge/ │ └── PodsMute-Bridging-Header.h # Bridging header for IOBluetooth ├── Resources/ @@ -92,6 +94,8 @@ PodsMute/ The app listens for Darwin notifications from `audioaccessoryd`, the macOS daemon that handles audio accessory events. When AirPods trigger a mute action, the daemon emits a `com.apple.audioaccessoryd.MuteState` notification which this app intercepts to toggle the system microphone. +AirPods Max crown presses can also arrive as a system Play/Pause media key. PodsMute installs a media-key event tap, consumes that event, and toggles mute so macOS does not route the press to Music. + ### Audio Mute Uses Core Audio HAL APIs: @@ -114,7 +118,9 @@ Uses IOBluetooth to check connection status of paired AirPods devices for displa 1. Check System Settings > Privacy & Security > Microphone 2. Ensure the app has microphone access permission -3. Make sure AirPods are connected and set as input device +3. Check System Settings > Privacy & Security > Accessibility +4. Ensure the app has Accessibility permission so it can intercept Play/Pause media keys +5. Make sure AirPods are connected and set as input device ### Icon color wrong in light/dark mode diff --git a/project.yml b/project.yml index e3e2972..f31c288 100644 --- a/project.yml +++ b/project.yml @@ -47,5 +47,7 @@ targets: embed: false - framework: CoreAudio.framework embed: false + - framework: MediaPlayer.framework + embed: false - framework: Cocoa.framework embed: false