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:
co-authored by
Claude Opus 5
parent
a3399d3a96
commit
91b83928ca
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user