Der Einblendung zuvorkommen, statt sie abschalten zu wollen

Der Schluss im letzten Commit war falsch. Aus „der Dienst liegt in der
Benutzer-Domäne" hatte ich geschlossen, dass `launchctl bootout` ihn dort
aushängt. Nachgemessen, diesmal ausführend:

    Boot-out failed: 150: Operation not permitted while
    System Integrity Protection is engaged

SIP schützt den Dienst, nicht nur den Dateipfad; die Domäne spielt keine Rolle.
Ein rein lesender Spike konnte das nicht zeigen — nur der Versuch. `SystemOSD`
fliegt deshalb wieder raus: Code, der behauptet zu wirken und es nicht tut, ist
schlimmer als keiner.

Stattdessen ein `CGEventTap` auf die Lautstärketasten. Onyx regelt selbst und
verbraucht das Ereignis — was das System nie erreicht, blendet es auch nicht
ein. Preis ist die Berechtigung „Bedienungshilfen"; ohne sie bleibt alles wie
vorher, und der Dialog wird angefragt statt stumm zu scheitern.

Vorteil gegenüber dem Aushängen: es verschwindet nur die Einblendung für die
Tasten, die Onyx auch bedient. Die Feststelltaste behält ihre — beim Aushängen
wäre sie mit verschwunden, und dafür hat Onyx keinen Ersatz.

Der Rückruf steht als eigene Funktion und nicht als Verschluss in `start()`: als
Verschluss brachte er Swift 6.3.3 im Durchgang `SendNonSendable` zum Absturz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 23:02:35 +02:00
co-authored by Claude Opus 5
parent a3399d3a96
commit 91b83928ca
5 changed files with 228 additions and 112 deletions
@@ -0,0 +1,151 @@
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)
guard let tap = CGEvent.tapCreate(tap: .cgSessionEventTap,
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)
}
@@ -1,78 +0,0 @@
import Foundation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "OSD")
/// Apples eigene Einblendung für Lautstärke, Helligkeit und Feststelltaste.
///
/// Solange sie erscheint, zeigt der Mac bei jedem Lautstärkeschritt **zwei**
/// Anzeigen: Apples Kasten in der Bildschirmmitte und die Live Activity unter
/// der Notch. Eine davon ist eine zu viel.
///
/// **Wie es geht — und warum es ohne Root geht.** `com.apple.OSDUIHelper` ist
/// ein LaunchAgent in der GUI-Domäne des angemeldeten Benutzers, nicht im
/// System. `launchctl bootout gui/<uid>/…` hängt ihn dort aus. Die Datei selbst
/// liegt unter SIP-Schutz und wird **nicht** angefasst; geändert wird nur, was
/// in der eigenen Sitzung geladen ist. Siehe `docs/spikes/J-osd.md`.
///
/// **Und was das kostet.** Es verschwindet nicht nur die Lautstärkeanzeige,
/// sondern jede: auch Helligkeit und Feststelltaste. Deshalb steht der Schalter
/// standardmäßig aus, und deshalb wird beim Beenden wieder eingehängt — sonst
/// säße jemand bis zum nächsten Abmelden ganz ohne Rückmeldung da, ohne zu
/// wissen, warum.
public enum SystemOSD {
private static let service = "com.apple.OSDUIHelper"
private static var domain: String { "gui/\(getuid())/\(service)" }
/// Ob der Dienst gerade ausgehängt ist.
public static var isSuppressed: Bool {
// `launchctl print` gibt für einen ausgehängten Dienst einen Fehler
// zurück. Kein Fehler heißt: er ist da.
run(["print", domain]) != 0
}
/// Hängt die Einblendung aus. Wirkt bis zum nächsten Abmelden.
@discardableResult
public static func suppress() -> Bool {
guard !isSuppressed else { return true }
let status = run(["bootout", domain])
if status != 0 {
log.notice("OSD-Dienst nicht aushängbar: \(status, privacy: .public)")
}
return status == 0
}
/// Hängt sie wieder ein.
///
/// Beim Beenden zu rufen. Ohne das bliebe der Mac bis zum nächsten Abmelden
/// ohne jede Einblendung — auch für Helligkeit, mit der Onyx nichts zu tun
/// hat.
@discardableResult
public static func restore() -> Bool {
guard isSuppressed else { return true }
let status = run(["bootstrap", "gui/\(getuid())",
"/System/Library/LaunchAgents/\(service).plist"])
if status != 0 {
log.notice("OSD-Dienst nicht einhängbar: \(status, privacy: .public)")
}
return status == 0
}
private static func run(_ arguments: [String]) -> Int32 {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/bin/launchctl")
task.arguments = arguments
task.standardOutput = FileHandle.nullDevice
task.standardError = FileHandle.nullDevice
do {
try task.run()
task.waitUntilExit()
return task.terminationStatus
} catch {
log.error("launchctl nicht startbar: \(error.localizedDescription, privacy: .public)")
return -1
}
}
}