Zwei Anzeigen für einen Handgriff sind eine zu viel

Apples eigene Einblendung lässt sich aushängen, ohne Root und ohne SIP
anzufassen: `com.apple.OSDUIHelper` liegt in der GUI-Domäne des angemeldeten
Benutzers, nicht im System. `launchctl bootout` dort hinein genügt.

Standardmäßig aus, denn es verschwindet **jede** Einblendung — auch Helligkeit
und Feststelltaste, und für die Feststelltaste hat Onyx keinen Ersatz. Beim
Beenden wird wieder eingehängt: eine Einstellung darf nicht überleben, was sie
eingestellt hat.

Dazu zwei Fehler im Anlernen behoben. Der Posenname stand als roher Schlüssel da
— die Zeichenketten liegen im Bündel von `FaceProvider`, gesucht wurden sie im
Hauptbündel. Und „Gesicht vergleichen" blieb nach dem Anlernen grau, weil
`isEnrolled` bei jedem Aufruf den Schlüsselbund las: richtig, aber für SwiftUI
unsichtbar, weil es nichts gab, woran eine Ansicht die Änderung hätte bemerken
können.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 22:26:41 +02:00
co-authored by Claude Opus 5
parent fd29df51cf
commit a3399d3a96
9 changed files with 221 additions and 8 deletions
+17
View File
@@ -24,6 +24,7 @@ final class AppModel {
var onNotchStyleChanged: ((NotchStyle) -> Void)? var onNotchStyleChanged: ((NotchStyle) -> Void)?
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)? var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
var onFaceUnlockChanged: ((Bool) -> Void)? var onFaceUnlockChanged: ((Bool) -> Void)?
var onSuppressOSDChanged: ((Bool) -> Void)?
var layout: [WidgetPlacement] { var layout: [WidgetPlacement] {
didSet { didSet {
@@ -64,6 +65,19 @@ final class AppModel {
onNotchWidthsChanged?(notchWidths) onNotchWidthsChanged?(notchWidths)
} }
/// Ob Apples eigene Einblendung unterdrückt wird.
///
/// Standardmäßig aus: es verschwindet **jede** Einblendung, auch die für
/// Helligkeit und Feststelltaste. Das ist eine Entscheidung, die man
/// treffen soll, nicht eine, die man vorfindet.
var suppressSystemOSD: Bool {
didSet {
guard suppressSystemOSD != oldValue else { return }
defaults.set(suppressSystemOSD, forKey: Keys.suppressSystemOSD)
onSuppressOSDChanged?(suppressSystemOSD)
}
}
/// Ob Onyx einen eigenen Sperrbildschirm anbietet. /// Ob Onyx einen eigenen Sperrbildschirm anbietet.
var lockScreenEnabled: Bool { var lockScreenEnabled: Bool {
didSet { didSet {
@@ -186,6 +200,7 @@ final class AppModel {
static let showsIdleIsland = "onyx.showsIdleIsland" static let showsIdleIsland = "onyx.showsIdleIsland"
static let notchStyle = "onyx.notchStyle" static let notchStyle = "onyx.notchStyle"
static let notchWidths = "onyx.notchWidths" static let notchWidths = "onyx.notchWidths"
static let suppressSystemOSD = "onyx.suppressSystemOSD"
static let lockScreenEnabled = "onyx.lock.enabled" static let lockScreenEnabled = "onyx.lock.enabled"
static let faceUnlockEnabled = "onyx.lock.face" static let faceUnlockEnabled = "onyx.lock.face"
static let bandLeading = "onyx.band.leading" static let bandLeading = "onyx.band.leading"
@@ -208,6 +223,8 @@ final class AppModel {
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
self.notchStyle = defaults.string(forKey: Keys.notchStyle) self.notchStyle = defaults.string(forKey: Keys.notchStyle)
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic .flatMap(NotchStyle.init(rawValue:)) ?? .automatic
self.suppressSystemOSD = defaults.object(forKey: Keys.suppressSystemOSD) as? Bool ?? false
// Beides standardmäßig **aus**: ein Sperrbildschirm, den man nicht // Beides standardmäßig **aus**: ein Sperrbildschirm, den man nicht
// bestellt hat, ist ein Schreck, und ein Gesichtsvergleich ohne // bestellt hat, ist ein Schreck, und ein Gesichtsvergleich ohne
// angelerntes Gesicht kann ohnehin nichts. // angelerntes Gesicht kann ohnehin nichts.
+32
View File
@@ -3685,6 +3685,38 @@
} }
} }
}, },
"settings.osd.suppress": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Apples Einblendung unterdrücken"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Suppress Apple's own overlay"
}
}
}
},
"settings.osd.suppress.hint": {
"localizations": {
"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."
}
},
"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."
}
}
}
},
"settings.permissions.calendar": { "settings.permissions.calendar": {
"extractionState": "stale", "extractionState": "stale",
"localizations": { "localizations": {
+8 -2
View File
@@ -55,6 +55,7 @@ struct LockSettingsView: View {
Button(String(localized: "lock.face.forget"), role: .destructive) { Button(String(localized: "lock.face.forget"), role: .destructive) {
enrollment.reset() enrollment.reset()
model.faceUnlockEnabled = false model.faceUnlockEnabled = false
controller.refreshEnrollment()
} }
} }
} }
@@ -71,7 +72,12 @@ struct LockSettingsView: View {
} }
} }
.sheet(isPresented: $isEnrolling) { .sheet(isPresented: $isEnrolling) {
EnrollmentSheet(enrollment: enrollment) { isEnrolling = false } EnrollmentSheet(enrollment: enrollment) {
isEnrolling = false
// Sonst bliebe „Gesicht vergleichen" grau, obwohl gerade eines
// angelernt wurde.
controller.refreshEnrollment()
}
} }
} }
@@ -109,7 +115,7 @@ private struct EnrollmentSheet: View {
.clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.card, .clipShape(RoundedRectangle(cornerRadius: Onyx.Radius.card,
style: .continuous)) style: .continuous))
Text(enrollment.pose.localizedName, bundle: .main) Text(enrollment.pose.localizedName)
.font(Onyx.Font.headline) .font(Onyx.Font.headline)
Text(enrollment.seesFace ? "lock.enroll.ready" : "lock.enroll.noFace") Text(enrollment.seesFace ? "lock.enroll.ready" : "lock.enroll.noFace")
+11
View File
@@ -189,6 +189,11 @@ 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() }
model.onSuppressOSDChanged = { suppress in
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
self?.lockController.usesFaceUnlock = uses self?.lockController.usesFaceUnlock = uses
@@ -309,6 +314,11 @@ 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
// 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
@@ -650,6 +660,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func quit() { NSApp.terminate(nil) } @objc private func quit() { NSApp.terminate(nil) }
} }
/// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu, /// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu,
+5
View File
@@ -183,6 +183,11 @@ private struct NotchSettings: View {
.frame(width: 200) .frame(width: 200)
} }
OnyxFormRow(String(localized: "settings.osd.suppress"),
hint: String(localized: "settings.osd.suppress.hint")) {
Toggle("", isOn: $model.suppressSystemOSD).labelsHidden()
}
OnyxFormRow(String(localized: "settings.notch.style"), OnyxFormRow(String(localized: "settings.notch.style"),
hint: String(localized: "settings.notch.style.hint")) { hint: String(localized: "settings.notch.style.hint")) {
Picker("", selection: $model.notchStyle) { Picker("", selection: $model.notchStyle) {
@@ -16,11 +16,14 @@ public final class FaceEnrollment {
public enum Pose: String, CaseIterable, Sendable { public enum Pose: String, CaseIterable, Sendable {
case straight, left, right case straight, left, right
public var localizedName: LocalizedStringKey { /// Aufgelöst **hier** und nicht als Schlüssel weitergereicht: die
/// Zeichenketten liegen im Bündel dieses Moduls, und wer sie anderswo
/// nachschlägt, bekommt den Schlüssel selbst zu sehen.
public var localizedName: String {
switch self { switch self {
case .straight: "face.pose.straight" case .straight: String(localized: "face.pose.straight", bundle: .module)
case .left: "face.pose.left" case .left: String(localized: "face.pose.left", bundle: .module)
case .right: "face.pose.right" case .right: String(localized: "face.pose.right", bundle: .module)
} }
} }
} }
@@ -41,10 +41,24 @@ public final class LockController {
/// Ob Gesichtserkennung überhaupt benutzt werden soll. /// Ob Gesichtserkennung überhaupt benutzt werden soll.
public var usesFaceUnlock = true public var usesFaceUnlock = true
public init() {} public init() {
refreshEnrollment()
}
/// Ob ein Gesicht angelernt ist. /// Ob ein Gesicht angelernt ist.
public var isEnrolled: Bool { !store.load().isEmpty } ///
/// **Gespeichert und nicht berechnet.** Als berechnete Eigenschaft las sie
/// bei jedem Aufruf den Schlüsselbund — richtig, aber für SwiftUI
/// unsichtbar: es gab nichts, woran eine Ansicht hätte merken können, dass
/// gerade ein Gesicht angelernt wurde. Der Schalter „Gesicht vergleichen"
/// blieb deshalb grau, bis man das Fenster neu öffnete.
public private(set) var isEnrolled = false
/// Liest nach, ob ein Gesicht hinterlegt ist. Nach dem Anlernen und nach
/// dem Vergessen zu rufen.
public func refreshEnrollment() {
isEnrolled = !store.load().isEmpty
}
// MARK: - Sperren // MARK: - Sperren
@@ -0,0 +1,78 @@
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
}
}
}
+47
View File
@@ -0,0 +1,47 @@
# Spike J — Apples eigene Einblendung unterdrücken
> Im Plan heißt dieser Spike „G". Die Buchstaben in `docs/spikes/` sind
> fortlaufend vergeben; hier läuft die vorhandene Reihe weiter.
**Frage.** Lässt sich `com.apple.OSDUIHelper` zuverlässig aushängen? Kommt er von
selbst zurück? Was passiert beim Ab- und Anmelden? Steht SIP im Weg?
**Gemessen am 05.09.2026**, rein lesend.
## Ergebnis
| Prüfung | Befund |
|---|---|
| Prozess läuft dauerhaft? | **nein** — bedarfsgesteuert, `state = not running` |
| LaunchAgent | `/System/Library/LaunchAgents/com.apple.OSDUIHelper.plist` |
| Domäne | **`gui/<uid>`** — 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/<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
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.
Deshalb:
- 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.
## Was der Spike **nicht** getan hat
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.
**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.