mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
265 lines
9.6 KiB
Swift
265 lines
9.6 KiB
Swift
/*
|
||
* 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
|