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:
co-authored by
Claude Opus 5
parent
fd29df51cf
commit
a3399d3a96
@@ -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.
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user