import AppKit import os private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "MediaKeys") /// Fängt die Lautstärketasten ab, bevor das System sie sieht. /// /// **Warum das der Weg ist.** Apples Einblendung lässt sich nicht abschalten: /// `launchctl bootout` scheitert an SIP, egal in welcher Domäne. Was aber geht, /// ist ihr zuvorzukommen — das System blendet nichts ein, wenn der Tastendruck /// es nie erreicht. Siehe `docs/spikes/J-osd.md`. /// /// **Der Preis ist die Berechtigung „Bedienungshilfen".** Ohne sie wird der Tap /// gar nicht erst erzeugt, und dann bleibt alles wie vorher — Apples Kasten /// erscheint, Onyx zeigt seine Activity daneben. Das ist ein hinnehmbarer /// Zustand, aber kein stiller: wer den Schalter umlegt, muss erfahren, dass noch /// etwas fehlt. /// **Nicht akteursgebunden.** Der Rückruf eines `CGEventTap` läuft auf dem /// Faden, dessen Ereignisschleife ihn angemeldet hat — hier der Hauptfaden. Den /// Übersetzer davon zu überzeugen, kostete eine `assumeIsolated`-Umschließung /// im Rückruf, und die brachte Swift 6.3.3 im Durchgang `SendNonSendable` zum /// Absturz. Die Grenze liegt deshalb beim Aufrufer: `onKey` wird auf dem /// Hauptfaden gerufen, und wer dort Akteurszustand anfasst, sagt das selbst. public final class MediaKeyTap { /// Welche Taste gedrückt wurde. public enum Key: Sendable { case volumeUp, volumeDown, mute } /// Wird beim Tastendruck gerufen. Gibt `true` zurück, wenn Onyx die Taste /// übernommen hat — dann wird das Ereignis verbraucht. /// /// Wird auf dem **Hauptfaden** gerufen. nonisolated(unsafe) public var onKey: ((Key) -> Bool)? nonisolated(unsafe) public private(set) var isRunning = false /// Wird auch aus dem Rückruf heraus gelesen, der zwar auf dem Hauptfaden /// läuft, dem Übersetzer gegenüber aber nicht als solcher gilt. nonisolated(unsafe) private var tap: CFMachPort? nonisolated(unsafe) private var source: CFRunLoopSource? /// Die Kennungen aus `IOKit/hidsystem/ev_keymap.h`. Sie einzubinden zöge /// den ganzen HID-Header nach; drei Zahlen sind billiger. private enum KeyCode { static let soundUp: Int32 = 0 static let soundDown: Int32 = 1 static let mute: Int32 = 7 } public init() {} /// Ob Onyx die Berechtigung hat. public static var isTrusted: Bool { AXIsProcessTrusted() } /// Fragt sie an — öffnet den Systemdialog, falls noch nicht entschieden. public static func requestTrust() { // Der Schlüssel als Zeichenkette statt über die globale Konstante: // letztere ist eine veränderliche Variable und damit für Swifts // Nebenläufigkeitsprüfung nicht sicher — der Wert ist seit jeher // derselbe. let options = ["AXTrustedCheckOptionPrompt": true] _ = AXIsProcessTrustedWithOptions(options as CFDictionary) } public func start() { guard !isRunning, Self.isTrusted else { return } // `NSSystemDefined` ist der Ereignistyp der Sondertasten. let mask = CGEventMask(1 << 14) // **`cghidEventTap` und nicht `cgSessionEventTap`.** An der Sitzung // angesetzt fing der Abgriff zwar synthetische Ereignisse ab, die // echten Tasten aber nicht: auf neueren macOS-Fassungen werden sie // weiter vorn behandelt, bevor die Sitzung sie zu sehen bekommt. Der // HID-Abgriff sitzt davor. guard let tap = CGEvent.tapCreate(tap: .cghidEventTap, place: .headInsertEventTap, options: .defaultTap, eventsOfInterest: mask, callback: onyxMediaKeyCallback, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { log.notice("Tastenabgriff nicht erzeugbar — Bedienungshilfen fehlen?") return } self.tap = tap source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) CGEvent.tapEnable(tap: tap, enable: true) isRunning = true } public func stop() { guard let tap else { return } CGEvent.tapEnable(tap: tap, enable: false) if let source { CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes) } self.tap = nil source = nil isRunning = false } /// Entscheidet über ein einzelnes Ereignis. fileprivate func handle(type: CGEventType, event: CGEvent) -> Unmanaged? { // Ein Tap, der zu lange braucht, wird vom System abgeschaltet. Dann // wieder einschalten, statt stumm auszufallen. if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { if let port = tap { CGEvent.tapEnable(tap: port, enable: true) } return Unmanaged.passUnretained(event) } guard let key = Self.key(from: event), onKey?(key) == true else { return Unmanaged.passUnretained(event) } // Verbraucht: das System sieht den Tastendruck nie und blendet deshalb // auch nichts ein. return nil } /// Welche Sondertaste in diesem Ereignis steckt — oder keine. private static func key(from event: CGEvent) -> Key? { guard let nsEvent = NSEvent(cgEvent: event), nsEvent.subtype.rawValue == 8 else { return nil } let data = nsEvent.data1 let keyCode = Int32((data & 0xFFFF_0000) >> 16) let keyState = (data & 0x0000_FF00) >> 8 // Nur das Drücken, nicht das Loslassen — sonst zählt jeder Anschlag // doppelt. guard keyState == 0x0A else { return nil } return switch keyCode { case KeyCode.soundUp: .volumeUp case KeyCode.soundDown: .volumeDown case KeyCode.mute: .mute default: nil } } } /// Der Rückruf des Tastenabgriffs. /// /// **Als eigene Funktion und nicht als Verschluss in `start()`.** Als Verschluss /// brachte er Swift 6.3.3 im Durchgang `SendNonSendable` zum Absturz — kein /// Fehler in der Logik, sondern einer im Übersetzer. Hier steht er außerhalb, /// und der Absturz bleibt aus. private func onyxMediaKeyCallback(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent, refcon: UnsafeMutableRawPointer?) -> Unmanaged? { guard let refcon else { return Unmanaged.passUnretained(event) } let tap = Unmanaged.fromOpaque(refcon).takeUnretainedValue() return tap.handle(type: type, event: event) }