An der Sitzung angesetzt fing er synthetische Ereignisse zuverlässig ab — die echten Lautstärketasten aber nicht: nachgemessen läuft der Abgriff, und ein selbst erzeugter Tastendruck wird erkannt und verbraucht, während der Druck auf die Taste weiterhin Apples Einblendung auslöste. Auf neueren macOS-Fassungen werden die Tasten offenbar weiter vorn behandelt, bevor die Sitzung sie sieht. `cghidEventTap` sitzt davor. Ob das reicht, zeigt erst die echte Taste — synthetisch ist beides nicht zu unterscheiden. Die Protokollzeilen bleiben vorerst drin: ohne sie ist „es geht immer noch nicht" nicht von „der Abgriff läuft gar nicht" zu trennen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
6.7 KiB
Swift
157 lines
6.7 KiB
Swift
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<CGEvent>? {
|
|
// 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<CGEvent>? {
|
|
guard let refcon else { return Unmanaged.passUnretained(event) }
|
|
let tap = Unmanaged<MediaKeyTap>.fromOpaque(refcon).takeUnretainedValue()
|
|
return tap.handle(type: type, event: event)
|
|
}
|