Files
ScarriffleandClaude Opus 5 b3ad9c49d6 Den Tastenabgriff näher an die Hardware setzen
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>
2026-09-06 21:05:25 +02:00

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)
}