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
+4 -4
View File
@@ -3690,13 +3690,13 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Apples Einblendung unterdrücken" "value": "Lautstärketasten übernehmen"
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Suppress Apple's own overlay" "value": "Take over the volume keys"
} }
} }
} }
@@ -3706,13 +3706,13 @@
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Sonst zeigt der Mac bei jedem Lautstärkeschritt zwei Anzeigen. Betrifft alle Einblendungen — auch Helligkeit und Feststelltaste. Beim Beenden von Onyx wird sie wieder eingeschaltet." "value": "Onyx fängt die Tasten ab und regelt selbst — dann erscheint Apples eigene Einblendung nicht mehr, weil der Tastendruck das System nie erreicht. Abschalten lässt sie sich nicht: das verhindert SIP. Braucht die Berechtigung „Bedienungshilfen“."
} }
}, },
"en": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "state": "translated",
"value": "Otherwise the Mac shows two indicators for every volume step. Affects every overlay — brightness and caps lock too. It is restored when Onyx quits." "value": "Onyx intercepts the keys and sets the volume itself — Apple's own overlay then stays away, because the keypress never reaches the system. Switching it off is not possible: SIP prevents that. Requires the Accessibility permission."
} }
} }
} }
+44 -9
View File
@@ -61,6 +61,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare /// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
/// Helligkeit bleibt sie leer und meldet nie etwas. /// Helligkeit bleibt sie leer und meldet nie etwas.
private let brightness = DisplayBrightness() private let brightness = DisplayBrightness()
/// Fängt die Lautstärketasten ab, damit Apples Einblendung nicht erscheint.
private let mediaKeys = MediaKeyTap()
/// Der Sperrbildschirm. Steht bereit, sperrt aber nur auf Verlangen. /// Der Sperrbildschirm. Steht bereit, sperrt aber nur auf Verlangen.
private let lockController = LockController() private let lockController = LockController()
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte /// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
@@ -189,10 +192,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
// Ohne diese Zeile wäre der Schalter in den Einstellungen eine // Ohne diese Zeile wäre der Schalter in den Einstellungen eine
// Behauptung: gespeichert, aber ohne Wirkung. // Behauptung: gespeichert, aber ohne Wirkung.
if model.suppressSystemOSD { SystemOSD.suppress() } applyMediaKeys(model.suppressSystemOSD)
model.onSuppressOSDChanged = { suppress in model.onSuppressOSDChanged = { [weak self] on in self?.applyMediaKeys(on) }
suppress ? SystemOSD.suppress() : SystemOSD.restore()
}
lockController.usesFaceUnlock = model.faceUnlockEnabled lockController.usesFaceUnlock = model.faceUnlockEnabled
model.onFaceUnlockChanged = { [weak self] uses in model.onFaceUnlockChanged = { [weak self] uses in
@@ -314,11 +315,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
func applicationWillTerminate(_ notification: Notification) { func applicationWillTerminate(_ notification: Notification) {
// Apples Einblendung wieder einhängen. Ohne das säße jemand bis zum mediaKeys.stop()
// nächsten Abmelden ganz ohne Rückmeldung da — auch bei der Helligkeit,
// mit der Onyx nichts zu tun hat — und wüsste nicht, warum. Eine
// Einstellung darf nicht überleben, was sie eingestellt hat.
if model?.suppressSystemOSD == true { SystemOSD.restore() }
coordinator?.stop() coordinator?.stop()
menuBar?.stop() menuBar?.stop()
// Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf // Der Herzschlag endet damit. Der Helfer stellt die Lüfter binnen fünf
@@ -444,6 +441,44 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func lockNow() { lockController.lock() } @objc private func lockNow() { lockController.lock() }
/// Schaltet die Übernahme der Lautstärketasten an oder aus.
///
/// **Apples Einblendung lässt sich nicht abschalten.** `launchctl bootout`
/// scheitert an SIP, egal in welcher Domäne — nachgemessen in
/// `docs/spikes/J-osd.md`. Was geht, ist ihr zuvorzukommen: das System
/// blendet nichts ein, wenn der Tastendruck es nie erreicht.
private func applyMediaKeys(_ on: Bool) {
guard on else { mediaKeys.stop(); return }
guard MediaKeyTap.isTrusted else {
// Ohne Bedienungshilfen tut der Abgriff nichts. Den Dialog anfragen
// statt stumm zu scheitern.
MediaKeyTap.requestTrust()
return
}
mediaKeys.onKey = { [weak self] key in
MainActor.assumeIsolated { self?.handleMediaKey(key) ?? false }
}
mediaKeys.start()
}
/// Wie macOS selbst: ein Sechzehntel je Anschlag.
private static let volumeStep = 1.0 / 16.0
private func handleMediaKey(_ key: MediaKeyTap.Key) -> Bool {
let current = systemAudio.outputVolume
switch key {
case .volumeUp:
systemAudio.setOutputVolume(min(1, current + Self.volumeStep))
case .volumeDown:
systemAudio.setOutputVolume(max(0, current - Self.volumeStep))
case .mute:
systemAudio.setOutputMuted(!systemAudio.outputMuted)
}
// Die Activity kommt von selbst: `observeVolume()` hört auf genau die
// Änderung, die wir hier gerade ausgelöst haben.
return true
}
/// Meldet jede Änderung der Systemlautstärke als Live Activity an. /// Meldet jede Änderung der Systemlautstärke als Live Activity an.
/// ///
/// Über `withObservationTracking` und nicht über einen eigenen Listener: /// Über `withObservationTracking` und nicht über einen eigenen Listener:
@@ -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
}
}
}
+29 -21
View File
@@ -17,31 +17,39 @@ selbst zurück? Was passiert beim Ab- und Anmelden? Steht SIP im Weg?
| Domäne | **`gui/<uid>`** — die des angemeldeten Benutzers, nicht `system` | | Domäne | **`gui/<uid>`** — die des angemeldeten Benutzers, nicht `system` |
| SIP | aktiviert, `OSDUIHelper.app/Contents` ist `restricted` | | SIP | aktiviert, `OSDUIHelper.app/Contents` ist `restricted` |
**Der entscheidende Punkt:** der Dienst ist in der **Benutzer**-Domäne geladen. ## Der Schluss daraus war falsch
`launchctl bootout gui/<uid>/com.apple.OSDUIHelper` hängt ihn dort aus — ohne
Root und ohne die SIP-geschützte Datei anzufassen. Geändert wird nur, was in der
eigenen Sitzung geladen ist.
## Was das kostet Aus „der Dienst liegt in der Benutzer-Domäne" habe ich geschlossen, dass
`launchctl bootout gui/<uid>/…` ihn dort aushängt, ohne Root und ohne SIP zu
berühren. **Das stimmt nicht.** Nachgemessen, diesmal ausführend:
Es verschwindet **jede** Einblendung, nicht nur die für die Lautstärke: auch ```
Helligkeit und Feststelltaste. Onyx zeigt nur die Lautstärke und die Helligkeit $ launchctl bootout gui/501/com.apple.OSDUIHelper
— die Feststelltaste hätte dann gar keine Rückmeldung mehr. Boot-out failed: 150: Operation not permitted while System Integrity Protection is engaged
```
Deshalb: 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.
- Der Schalter steht **standardmäßig aus**. Das ist eine Entscheidung, die man **Die Lehre:** Ein Spike, der die entscheidende Handlung nicht ausführt, hat die
treffen soll, nicht eine, die man vorfindet. Frage nicht beantwortet. Er hat nur die Umstände beschrieben, unter denen sie
- Beim Beenden von Onyx wird wieder eingehängt. Ohne das säße jemand bis zum gestellt wird. Wo die Handlung sich zurücknehmen lässt, gehört sie in den
nächsten Abmelden ganz ohne Rückmeldung da und wüsste nicht, warum. Eine Spike — sonst steht am Ende eine Vermutung im Dokument, die wie ein Befund
Einstellung darf nicht überleben, was sie eingestellt hat. aussieht.
## Was der Spike **nicht** getan hat ## Was stattdessen geht
Nichts ausgehängt. An einem Systemdienst zu drehen, um zu sehen was passiert, Nicht die Einblendung abschalten, sondern **ihr zuvorkommen**: die
ist nichts, was ungefragt geschieht — auch dann nicht, wenn es sich zurücknehmen Lautstärketasten mit einem `CGEventTap` abfangen, die Lautstärke selbst setzen
lässt. Geprüft wurde nur, wo der Dienst liegt und wem er gehört. und das Ereignis verbrauchen. Was das System nie erreicht, blendet es auch nicht
ein.
**Damit ist auch nicht gemessen**, ob er von selbst zurückkommt, wenn etwas ihn Preis: das braucht die Berechtigung „Bedienungshilfen". Ohne sie tut der Tap
anfordert. Der Verdacht besteht: er ist bedarfsgesteuert. Falls ja, muss das nichts — dann erscheint Apples Einblendung wie gehabt, und das muss die
Aushängen wiederholt werden — das zeigt sich beim ersten Einschalten. Oberfläche sagen, statt es stumm hinzunehmen.
## Der Vorteil des Abfangens
Es verschwindet nur die Einblendung für die Tasten, die Onyx selbst bedient.
Feststelltaste und alles andere behalten ihre — beim Aushängen des Dienstes
wären sie mit verschwunden, und für die Feststelltaste hat Onyx keinen Ersatz.