From 91b83928ca410392dc624f53c8a7ebf55207e771 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 5 Sep 2026 23:02:35 +0200 Subject: [PATCH] Der Einblendung zuvorkommen, statt sie abschalten zu wollen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Onyx/Localizable.xcstrings | 8 +- Onyx/OnyxApp.swift | 53 ++++-- .../Sources/OnyxCore/MediaKeyTap.swift | 151 ++++++++++++++++++ .../OnyxKit/Sources/OnyxCore/SystemOSD.swift | 78 --------- docs/spikes/J-osd.md | 50 +++--- 5 files changed, 228 insertions(+), 112 deletions(-) create mode 100644 Packages/OnyxKit/Sources/OnyxCore/MediaKeyTap.swift delete mode 100644 Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index e89e14d..f054dd1 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -3690,13 +3690,13 @@ "de": { "stringUnit": { "state": "translated", - "value": "Apples Einblendung unterdrücken" + "value": "Lautstärketasten übernehmen" } }, "en": { "stringUnit": { "state": "translated", - "value": "Suppress Apple's own overlay" + "value": "Take over the volume keys" } } } @@ -3706,13 +3706,13 @@ "de": { "stringUnit": { "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": { "stringUnit": { "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." } } } diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 743796d..cc4c384 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -61,6 +61,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare /// Helligkeit bleibt sie leer und meldet nie etwas. 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. private let lockController = LockController() /// 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 // Behauptung: gespeichert, aber ohne Wirkung. - if model.suppressSystemOSD { SystemOSD.suppress() } - model.onSuppressOSDChanged = { suppress in - suppress ? SystemOSD.suppress() : SystemOSD.restore() - } + applyMediaKeys(model.suppressSystemOSD) + model.onSuppressOSDChanged = { [weak self] on in self?.applyMediaKeys(on) } lockController.usesFaceUnlock = model.faceUnlockEnabled model.onFaceUnlockChanged = { [weak self] uses in @@ -314,11 +315,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { - // Apples Einblendung wieder einhängen. Ohne das säße jemand bis zum - // 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() } + mediaKeys.stop() coordinator?.stop() menuBar?.stop() // 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() } + /// 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. /// /// Über `withObservationTracking` und nicht über einen eigenen Listener: diff --git a/Packages/OnyxKit/Sources/OnyxCore/MediaKeyTap.swift b/Packages/OnyxKit/Sources/OnyxCore/MediaKeyTap.swift new file mode 100644 index 0000000..dcbce21 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/MediaKeyTap.swift @@ -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? { + // 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) +} diff --git a/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift b/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift deleted file mode 100644 index d18e77d..0000000 --- a/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift +++ /dev/null @@ -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//…` 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 - } - } -} diff --git a/docs/spikes/J-osd.md b/docs/spikes/J-osd.md index ee9e3b9..8f4925e 100644 --- a/docs/spikes/J-osd.md +++ b/docs/spikes/J-osd.md @@ -17,31 +17,39 @@ selbst zurück? Was passiert beim Ab- und Anmelden? Steht SIP im Weg? | Domäne | **`gui/`** — die des angemeldeten Benutzers, nicht `system` | | SIP | aktiviert, `OSDUIHelper.app/Contents` ist `restricted` | -**Der entscheidende Punkt:** der Dienst ist in der **Benutzer**-Domäne geladen. -`launchctl bootout gui//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. +## Der Schluss daraus war falsch -## Was das kostet +Aus „der Dienst liegt in der Benutzer-Domäne" habe ich geschlossen, dass +`launchctl bootout gui//…` 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 -— die Feststelltaste hätte dann gar keine Rückmeldung mehr. +``` +$ launchctl bootout gui/501/com.apple.OSDUIHelper +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 - treffen soll, nicht eine, die man vorfindet. -- Beim Beenden von Onyx wird wieder eingehängt. Ohne das säße jemand bis zum - nächsten Abmelden ganz ohne Rückmeldung da und wüsste nicht, warum. Eine - Einstellung darf nicht überleben, was sie eingestellt hat. +**Die Lehre:** Ein Spike, der die entscheidende Handlung nicht ausführt, hat die +Frage nicht beantwortet. Er hat nur die Umstände beschrieben, unter denen sie +gestellt wird. Wo die Handlung sich zurücknehmen lässt, gehört sie in den +Spike — sonst steht am Ende eine Vermutung im Dokument, die wie ein Befund +aussieht. -## Was der Spike **nicht** getan hat +## Was stattdessen geht -Nichts ausgehängt. An einem Systemdienst zu drehen, um zu sehen was passiert, -ist nichts, was ungefragt geschieht — auch dann nicht, wenn es sich zurücknehmen -lässt. Geprüft wurde nur, wo der Dienst liegt und wem er gehört. +Nicht die Einblendung abschalten, sondern **ihr zuvorkommen**: die +Lautstärketasten mit einem `CGEventTap` abfangen, die Lautstärke selbst setzen +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 -anfordert. Der Verdacht besteht: er ist bedarfsgesteuert. Falls ja, muss das -Aushängen wiederholt werden — das zeigt sich beim ersten Einschalten. +Preis: das braucht die Berechtigung „Bedienungshilfen". Ohne sie tut der Tap +nichts — dann erscheint Apples Einblendung wie gehabt, und das muss die +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.