Absturz beim XPC-Rückruf, Menü zurück, Fenster weg von der Notch

Der Absturzbericht war eindeutig:

  Thread 8: com.apple.NSXPCConnection.m-user…onyx.helper
    swift_task_isCurrentExecutorWithFlags
    closure #3 in FanControl.proxy()
    EXC_BREAKPOINT

Die Rückrufe der XPC-Verbindung erben die MainActor-Isolation der Methode,
in der sie stehen. XPC ruft sie aber auf seiner eigenen Warteschlange auf,
Swift 6 prüft das zur Laufzeit und beendet den Prozess. Das
`Task { @MainActor in … }` im Rumpf half nicht: die Prüfung geschieht beim
Betreten des Abschlusses, nicht beim Zugriff.

Alle sieben Rückrufe sind jetzt `@Sendable`. Und weil das eine Fehlerklasse
ist und kein Einzelfall, dieselbe Behandlung für die übrigen Stellen, an
denen ein MainActor-Typ einen Abschluss an eine Systemschnittstelle gibt:
Papierkorb, Vorschaubilder, Adapter-Ende, Darwin-Nachricht.

Dazu drei Dinge aus dem Bericht von eben:

Der Linksklick aufs Menüleistensymbol fuhr das Panel aus — und ging dabei
als Fixieren durch. Danach stand das Panel offen und reagierte auf nichts
mehr. Das war schlechter als das Problem, das es lösen sollte. Ein
Statuselement zeigt bei einem Klick sein Menü; alles andere überrascht.
„Panel öffnen" bleibt draußen.

Ein Klick daneben schließt jetzt auch ein fixiertes Panel. „Klick fixiert"
ist eine gute Regel, aber wer sie nicht kennt, sitzt sonst vor etwas, das
offen steht und nicht reagiert — und sucht den Fehler in der App.

Einstellungs- und Einrichtungsfenster gehen nicht mehr direkt unter der
Notch auf. `center()` setzt oberhalb der Mitte; der Schließknopf landete
damit so weit oben, dass man auf dem Weg dorthin die Notch auslöste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-11 12:54:32 +02:00
parent 70a665f532
commit 667fb61c96
12 changed files with 165 additions and 55 deletions

View File

@@ -80,6 +80,20 @@ public final class FanControl: ChargeLimitReading {
// MARK: - Verbindung
/// Die XPC-Verbindung.
///
/// **Jeder Rückruf hier ist `@Sendable`, und das ist kein Feinschliff.**
/// Ohne die Auszeichnung erbt der Abschluss die MainActor-Isolation dieser
/// Methode. XPC ruft ihn aber auf seiner eigenen Warteschlange, Swift 6
/// prüft das zur Laufzeit und beendet den Prozess:
///
/// Thread 8: com.apple.NSXPCConnection.m-useronyx.helper
/// swift_task_isCurrentExecutorWithFlags
/// closure #3 in FanControl.proxy()
/// EXC_BREAKPOINT
///
/// Das `Task { @MainActor in }` im Rumpf half nicht: die Prüfung
/// geschieht beim **Betreten** des Abschlusses, nicht beim Zugriff.
private func proxy() -> OnyxHelperProtocol? {
if connection == nil {
let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName,
@@ -90,16 +104,16 @@ public final class FanControl: ChargeLimitReading {
// Dienst unter demselben Namen die Antworten liefern.
try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement)
connection.invalidationHandler = { [weak self] in
connection.invalidationHandler = { @Sendable [weak self] in
Task { @MainActor in self?.connection = nil }
}
connection.interruptionHandler = { [weak self] in
connection.interruptionHandler = { @Sendable [weak self] in
Task { @MainActor in self?.connection = nil }
}
connection.resume()
self.connection = connection
}
return connection?.remoteObjectProxyWithErrorHandler { [weak self] error in
return connection?.remoteObjectProxyWithErrorHandler { @Sendable [weak self] error in
Task { @MainActor in
self?.lastError = error.localizedDescription
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
@@ -137,7 +151,7 @@ public final class FanControl: ChargeLimitReading {
public func stop() { disconnect() }
private func refreshStatus() {
proxy()?.fanStatus { [weak self] data in
proxy()?.fanStatus { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
@@ -152,13 +166,13 @@ public final class FanControl: ChargeLimitReading {
// MARK: - Steuern
public func setTarget(index: Int, rpm: Double) {
proxy()?.setFan(index: index, targetRPM: rpm) { [weak self] data in
proxy()?.setFan(index: index, targetRPM: rpm) { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
public func setAutomatic(index: Int) {
proxy()?.setFanAutomatic(index: index) { [weak self] data in
proxy()?.setFanAutomatic(index: index) { @Sendable [weak self] data in
Task { @MainActor in self?.apply(data) }
}
}
@@ -170,7 +184,7 @@ public final class FanControl: ChargeLimitReading {
public private(set) var chargeLimit: Int?
public func refreshChargeLimit() {
proxy()?.chargeLimit { [weak self] percent in
proxy()?.chargeLimit { @Sendable [weak self] percent in
Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil }
}
}

View File

@@ -50,7 +50,15 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
window.styleMask = [.titled, .closable]
window.isReleasedWhenClosed = false
window.delegate = self
window.center()
// Nicht direkt unter die Notch: wer den Schließknopf trifft,
// fährt sonst durch die Auslösefläche.
if let screen = NSScreen.main {
let visible = screen.visibleFrame
window.setFrameOrigin(NSPoint(x: visible.midX - window.frame.width / 2,
y: visible.maxY - 60 - window.frame.height))
} else {
window.center()
}
self.window = window
}

View File

@@ -238,42 +238,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// Das feste Onyx-Element.
///
/// **Kein Panel öffnen" im Menü.** Das Panel öffnet sich, wenn man die
/// Notch berührt ein Menüeintrag dafür beschreibt einen Umweg um die
/// eigentliche Bedienung herum und wirft die Frage auf, wozu es die Notch
/// dann gibt. Als Notausgang bleibt es trotzdem erreichbar, nur ohne
/// eigene Zeile: **Linksklick auf das Symbol fährt das Panel aus,
/// Rechtsklick zeigt das Menü.**
/// **Kein Panel öffnen" im Menü**, aber ganz normal ein Menü.
///
/// Der Eintrag beschrieb einen Umweg um die eigentliche Bedienung herum
/// das Panel öffnet sich, wenn man die Notch berührt. Ihn durch Linksklick
/// fährt das Panel aus" zu ersetzen war aber schlechter als das Problem:
/// dieser Klick ging als Fixieren durch, und danach stand das Panel offen
/// und reagierte auf nichts. Ein Statuselement zeigt bei einem Klick sein
/// Menü. Alles andere überrascht.
private func installOnyxStatusItem() {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
item.autosaveName = "onyx.menubar.main"
item.button?.image = Self.menuBarIcon()
item.button?.target = self
item.button?.action = #selector(onyxItemClicked)
item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
let menu = NSMenu()
menu.addItem(withTitle: String(localized: "menu.settings"),
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
action: #selector(openOnboarding), keyEquivalent: "").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
item.menu = menu
onyxItem = item
}
/// Links: Panel. Rechts: Menü.
@objc private func onyxItemClicked() {
guard let item = onyxItem, let button = item.button else { return }
if NSApp.currentEvent?.type == .rightMouseUp {
let menu = NSMenu()
menu.addItem(withTitle: String(localized: "menu.settings"),
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
// Das Menü nur für diesen Klick anhängen und danach wieder lösen
// bleibt es hängen, verschluckt es den Linksklick.
item.menu = menu
button.performClick(nil)
item.menu = nil
} else {
togglePanel()
}
}
@objc private func openOnboarding() { showOnboarding() }
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
///

View File

@@ -60,7 +60,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window.styleMask = [.titled, .closable, .miniaturizable]
window.isReleasedWhenClosed = false
window.delegate = self
window.center()
Self.place(window)
// Über die Position freuen sich Nutzer mit mehreren Displays:
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
window.setFrameAutosaveName("onyx.settings")
@@ -73,6 +73,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
window?.makeKeyAndOrderFront(nil)
}
/// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch
/// klebt.
///
/// `center()` setzt etwas oberhalb der Mitte bei einem 452 Punkt hohen
/// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer
/// ihn treffen will, fährt durch die Auslösefläche der Notch und hat
/// plötzlich das Panel vor sich.
private static func place(_ window: NSWindow) {
guard let screen = window.screen ?? NSScreen.main else { window.center(); return }
let visible = screen.visibleFrame
let size = window.frame.size
// Ein Fingerbreit Abstand nach oben genügt: die Auslösefläche reicht
// 14 Punkte unter die Notch, der Titelbalken ist 28 hoch.
let top = visible.maxY - 60
window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2,
y: top - size.height))
}
/// Der rote Knopf blendet aus, statt zu schließen.
///
/// Onyx ist eine App ohne Hauptfenster; Fenster zu" heißt hier nie