Atoll/DynamicIsland/managers/MediaKeyInterceptor.swift
2026-01-29 12:26:04 +05:30

265 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* Originally from boring.notch project
* Modified and adapted for Atoll (DynamicIsland)
* See NOTICE for details.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import AppKit
import CoreGraphics
#if canImport(ApplicationServices)
import ApplicationServices
#endif
private let NX_SYSDEFINED_EVENT_TYPE: UInt32 = 14
private let NX_KEYTYPE_SOUND_UP: Int32 = 0
private let NX_KEYTYPE_SOUND_DOWN: Int32 = 1
private let NX_KEYTYPE_BRIGHTNESS_UP: Int32 = 2
private let NX_KEYTYPE_BRIGHTNESS_DOWN: Int32 = 3
private let NX_KEYTYPE_MUTE: Int32 = 7
enum MediaKeyDirection {
case up
case down
}
enum MediaKeyStep {
case standard
case fine
}
struct MediaKeyConfiguration {
var interceptVolume: Bool
var interceptBrightness: Bool
var interceptCommandModifiedBrightness: Bool
static let disabled = MediaKeyConfiguration(
interceptVolume: false,
interceptBrightness: false,
interceptCommandModifiedBrightness: false
)
}
protocol MediaKeyInterceptorDelegate: AnyObject {
func mediaKeyInterceptor(
_ interceptor: MediaKeyInterceptor,
didReceiveVolumeCommand direction: MediaKeyDirection,
step: MediaKeyStep,
isRepeat: Bool,
modifiers: NSEvent.ModifierFlags
)
func mediaKeyInterceptor(
_ interceptor: MediaKeyInterceptor,
didReceiveBrightnessCommand direction: MediaKeyDirection,
step: MediaKeyStep,
isRepeat: Bool,
modifiers: NSEvent.ModifierFlags
)
func mediaKeyInterceptorDidToggleMute(_ interceptor: MediaKeyInterceptor)
}
final class MediaKeyInterceptor {
static let shared = MediaKeyInterceptor()
weak var delegate: MediaKeyInterceptorDelegate?
var configuration: MediaKeyConfiguration = .disabled {
didSet {
updateTapState()
}
}
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isTapEnabled = false
#if canImport(ApplicationServices)
private var didRequestAccessibilityPrompt = false
#endif
private let systemDefinedEventType = CGEventType(rawValue: NX_SYSDEFINED_EVENT_TYPE)
private let eventTapLocations: [CGEventTapLocation] = [.cghidEventTap, .cgSessionEventTap]
private init() {}
@discardableResult
func start() -> Bool {
guard eventTap == nil else {
updateTapState()
return true
}
#if canImport(ApplicationServices)
requestAccessibilityPermissionIfNeeded()
#endif
guard let systemDefinedType = systemDefinedEventType else {
NSLog("❌ Unable to resolve system-defined event type")
return false
}
let mask = CGEventMask(1) << systemDefinedType.rawValue
let callback: CGEventTapCallBack = { _, type, cgEvent, userInfo in
guard let userInfo else { return Unmanaged.passUnretained(cgEvent) }
let interceptor = Unmanaged<MediaKeyInterceptor>.fromOpaque(userInfo).takeUnretainedValue()
return interceptor.handleEvent(cgEvent: cgEvent, type: type)
}
var createdTap: CFMachPort?
for location in eventTapLocations {
if let tap = CGEvent.tapCreate(
tap: location,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: mask,
callback: callback,
userInfo: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
) {
createdTap = tap
break
}
}
guard let tap = createdTap else {
#if canImport(ApplicationServices)
if !AXIsProcessTrusted() {
NSLog("⚠️ Accessibility permission missing; grant access in System Settings › Privacy & Security › Accessibility")
}
#endif
NSLog("❌ Failed to create media key event tap")
return false
}
eventTap = tap
runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
if let source = runLoopSource {
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
}
CGEvent.tapEnable(tap: tap, enable: true)
isTapEnabled = true
NSLog("✅ Media key event tap installed (HID)")
return true
}
func stop() {
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: false)
CFMachPortInvalidate(tap)
}
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
eventTap = nil
runLoopSource = nil
isTapEnabled = false
}
private func updateTapState() {
guard let tap = eventTap else { return }
let shouldEnable = configuration.interceptVolume || configuration.interceptBrightness || configuration.interceptCommandModifiedBrightness
if shouldEnable != isTapEnabled {
CGEvent.tapEnable(tap: tap, enable: shouldEnable)
isTapEnabled = shouldEnable
}
}
private func handleEvent(cgEvent: CGEvent, type: CGEventType) -> Unmanaged<CGEvent>? {
guard let systemDefinedType = systemDefinedEventType,
type == systemDefinedType,
let nsEvent = NSEvent(cgEvent: cgEvent),
nsEvent.subtype.rawValue == 8 else {
return Unmanaged.passUnretained(cgEvent)
}
let data1 = nsEvent.data1
let keyCode = (data1 & 0xFFFF0000) >> 16
let keyFlags = data1 & 0x0000FFFF
let keyState = ((keyFlags & 0xFF00) >> 8) == 0xA // 0xA = keyDown, 0xB = keyUp
let isRepeat = (keyFlags & 0x0001) == 1
let step = step(for: nsEvent)
let modifiers = nsEvent.modifierFlags
guard keyState else {
// Swallow key-up events only when intercepting, otherwise let them pass through
if shouldHandle(keyCode: Int32(keyCode), modifiers: modifiers) {
return nil
}
return Unmanaged.passUnretained(cgEvent)
}
switch Int32(keyCode) {
case NX_KEYTYPE_SOUND_UP:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .up, step: step, isRepeat: isRepeat, modifiers: modifiers)
return nil
case NX_KEYTYPE_SOUND_DOWN:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveVolumeCommand: .down, step: step, isRepeat: isRepeat, modifiers: modifiers)
return nil
case NX_KEYTYPE_MUTE:
guard configuration.interceptVolume else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptorDidToggleMute(self)
return nil
case NX_KEYTYPE_BRIGHTNESS_UP:
guard shouldHandleBrightness(modifiers: modifiers) else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .up, step: step, isRepeat: isRepeat, modifiers: modifiers)
return nil
case NX_KEYTYPE_BRIGHTNESS_DOWN:
guard shouldHandleBrightness(modifiers: modifiers) else { return Unmanaged.passUnretained(cgEvent) }
delegate?.mediaKeyInterceptor(self, didReceiveBrightnessCommand: .down, step: step, isRepeat: isRepeat, modifiers: modifiers)
return nil
default:
return Unmanaged.passUnretained(cgEvent)
}
}
private func shouldHandle(keyCode: Int32, modifiers: NSEvent.ModifierFlags) -> Bool {
switch keyCode {
case NX_KEYTYPE_SOUND_UP, NX_KEYTYPE_SOUND_DOWN, NX_KEYTYPE_MUTE:
return configuration.interceptVolume
case NX_KEYTYPE_BRIGHTNESS_UP, NX_KEYTYPE_BRIGHTNESS_DOWN:
return configuration.interceptBrightness || (configuration.interceptCommandModifiedBrightness && modifiers.contains(.command))
default:
return false
}
}
private func shouldHandleBrightness(modifiers: NSEvent.ModifierFlags) -> Bool {
if configuration.interceptBrightness {
return true
}
return configuration.interceptCommandModifiedBrightness && modifiers.contains(.command)
}
private func step(for event: NSEvent) -> MediaKeyStep {
let modifiers = event.modifierFlags
if modifiers.contains(.option) && modifiers.contains(.shift) {
return .fine
}
return .standard
}
}
#if canImport(ApplicationServices)
extension MediaKeyInterceptor {
private func requestAccessibilityPermissionIfNeeded() {
guard !AXIsProcessTrusted(), !didRequestAccessibilityPrompt else { return }
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
let options: CFDictionary = [promptKey: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
didRequestAccessibilityPrompt = true
}
}
#endif