diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift index 5249926..e52e462 100644 --- a/Onyx/AppModel.swift +++ b/Onyx/AppModel.swift @@ -24,6 +24,7 @@ final class AppModel { var onNotchStyleChanged: ((NotchStyle) -> Void)? var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)? var onFaceUnlockChanged: ((Bool) -> Void)? + var onSuppressOSDChanged: ((Bool) -> Void)? var layout: [WidgetPlacement] { didSet { @@ -64,6 +65,19 @@ final class AppModel { 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. var lockScreenEnabled: Bool { didSet { @@ -186,6 +200,7 @@ final class AppModel { static let showsIdleIsland = "onyx.showsIdleIsland" static let notchStyle = "onyx.notchStyle" static let notchWidths = "onyx.notchWidths" + static let suppressSystemOSD = "onyx.suppressSystemOSD" static let lockScreenEnabled = "onyx.lock.enabled" static let faceUnlockEnabled = "onyx.lock.face" static let bandLeading = "onyx.band.leading" @@ -208,6 +223,8 @@ final class AppModel { self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true self.notchStyle = defaults.string(forKey: Keys.notchStyle) .flatMap(NotchStyle.init(rawValue:)) ?? .automatic + self.suppressSystemOSD = defaults.object(forKey: Keys.suppressSystemOSD) as? Bool ?? false + // Beides standardmäßig **aus**: ein Sperrbildschirm, den man nicht // bestellt hat, ist ein Schreck, und ein Gesichtsvergleich ohne // angelerntes Gesicht kann ohnehin nichts. diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index ddafb33..e89e14d 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -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": { "extractionState": "stale", "localizations": { diff --git a/Onyx/LockSettingsView.swift b/Onyx/LockSettingsView.swift index c2d0571..a1e71a3 100644 --- a/Onyx/LockSettingsView.swift +++ b/Onyx/LockSettingsView.swift @@ -55,6 +55,7 @@ struct LockSettingsView: View { Button(String(localized: "lock.face.forget"), role: .destructive) { enrollment.reset() model.faceUnlockEnabled = false + controller.refreshEnrollment() } } } @@ -71,7 +72,12 @@ struct LockSettingsView: View { } } .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, style: .continuous)) - Text(enrollment.pose.localizedName, bundle: .main) + Text(enrollment.pose.localizedName) .font(Onyx.Font.headline) Text(enrollment.seesFace ? "lock.enroll.ready" : "lock.enroll.noFace") diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 0b67978..743796d 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -189,6 +189,11 @@ 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() + } + lockController.usesFaceUnlock = model.faceUnlockEnabled model.onFaceUnlockChanged = { [weak self] uses in self?.lockController.usesFaceUnlock = uses @@ -309,6 +314,11 @@ 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() } coordinator?.stop() menuBar?.stop() // 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) } + } /// Der Inhalt des Panels. Liest `model.layout` und zeichnet sich damit neu, diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 0ac47e8..a221fc4 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -183,6 +183,11 @@ private struct NotchSettings: View { .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"), hint: String(localized: "settings.notch.style.hint")) { Picker("", selection: $model.notchStyle) { diff --git a/Packages/OnyxKit/Sources/FaceProvider/FaceEnrollment.swift b/Packages/OnyxKit/Sources/FaceProvider/FaceEnrollment.swift index cb4a328..93b9bef 100644 --- a/Packages/OnyxKit/Sources/FaceProvider/FaceEnrollment.swift +++ b/Packages/OnyxKit/Sources/FaceProvider/FaceEnrollment.swift @@ -16,11 +16,14 @@ public final class FaceEnrollment { public enum Pose: String, CaseIterable, Sendable { 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 { - case .straight: "face.pose.straight" - case .left: "face.pose.left" - case .right: "face.pose.right" + case .straight: String(localized: "face.pose.straight", bundle: .module) + case .left: String(localized: "face.pose.left", bundle: .module) + case .right: String(localized: "face.pose.right", bundle: .module) } } } diff --git a/Packages/OnyxKit/Sources/LockProvider/LockController.swift b/Packages/OnyxKit/Sources/LockProvider/LockController.swift index 42c4fbe..b15c1a9 100644 --- a/Packages/OnyxKit/Sources/LockProvider/LockController.swift +++ b/Packages/OnyxKit/Sources/LockProvider/LockController.swift @@ -41,10 +41,24 @@ public final class LockController { /// Ob Gesichtserkennung überhaupt benutzt werden soll. public var usesFaceUnlock = true - public init() {} + public init() { + refreshEnrollment() + } /// 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 diff --git a/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift b/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift new file mode 100644 index 0000000..d18e77d --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxCore/SystemOSD.swift @@ -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//…` 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 new file mode 100644 index 0000000..ee9e3b9 --- /dev/null +++ b/docs/spikes/J-osd.md @@ -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/`** — 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. + +## 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.