From 667fb61c96ce1acb594cd19d663ed04636f90ba0 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Tue, 11 Aug 2026 12:54:32 +0200 Subject: [PATCH] =?UTF-8?q?Absturz=20beim=20XPC-R=C3=BCckruf,=20Men=C3=BC?= =?UTF-8?q?=20zur=C3=BCck,=20Fenster=20weg=20von=20der=20Notch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Onyx/FanControl.swift | 28 +++++++--- Onyx/OnboardingWindowController.swift | 10 +++- Onyx/OnyxApp.swift | 47 +++++++--------- Onyx/SettingsWindowController.swift | 20 ++++++- .../CalendarProvider/CalendarWidget.swift | 2 +- .../MediaProvider/AdapterProcess.swift | 2 +- .../Sources/OnyxNotch/NotchCoordinator.swift | 31 ++++++++--- .../OnyxNotch/NotchScreenController.swift | 3 ++ .../Sources/OnyxNotch/NotchStateMachine.swift | 16 +++++- .../Sources/ShelfProvider/ShelfStore.swift | 6 +-- .../Sources/ShelfProvider/ShelfWidget.swift | 2 +- .../NotchStateMachineTests.swift | 53 ++++++++++++++++++- 12 files changed, 165 insertions(+), 55 deletions(-) diff --git a/Onyx/FanControl.swift b/Onyx/FanControl.swift index 1532209..ae7ab42 100644 --- a/Onyx/FanControl.swift +++ b/Onyx/FanControl.swift @@ -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-user…onyx.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 } } } diff --git a/Onyx/OnboardingWindowController.swift b/Onyx/OnboardingWindowController.swift index c1d3a4e..fa49eff 100644 --- a/Onyx/OnboardingWindowController.swift +++ b/Onyx/OnboardingWindowController.swift @@ -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 } diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 7caf61f..ae11aee 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -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. /// diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 0892a8f..4dbd9d0 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -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 diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 4359f6a..44c40e7 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -86,7 +86,7 @@ public final class CalendarModel { let wanted = kind != .appleCalendar guard wanted != (calendarrObserver != nil) else { return } calendarrObserver = wanted - ? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in + ? DarwinObserver(name: CalendarrSource.changeNotification) { @Sendable [weak self] in Task { @MainActor in self?.refresh() } } : nil diff --git a/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift b/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift index dc6a30f..6cd3a55 100644 --- a/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift +++ b/Packages/OnyxKit/Sources/MediaProvider/AdapterProcess.swift @@ -64,7 +64,7 @@ public final class AdapterProcess { Task { @MainActor in self?.consume(chunk) } } - process.terminationHandler = { [weak self] _ in + process.terminationHandler = { @Sendable [weak self] _ in Task { @MainActor in self?.handleTermination() } } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift index 09e548e..8fb6b11 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -130,18 +130,19 @@ public final class NotchCoordinator { return event }) { monitors.append(local) } + // Klicks in der eigenen App: in der Notch fixieren, sonst schließen. if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in - MainActor.assumeIsolated { [weak self] in - guard let self else { return } - let point = NSEvent.mouseLocation - for controller in controllers.values - where controller.isVisible && controller.geometry.triggerRect.contains(point) { - controller.click() - } - } + MainActor.assumeIsolated { [weak self] in self?.handleClick() } return event }) { monitors.append(clicks) } + // Klicks in fremden Programmen schließen ein fixiertes Panel. Ohne das + // bleibt es stehen, bis jemand Escape drückt oder die Regel kennt. + if let outside = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown], handler: { _ in + MainActor.assumeIsolated { [weak self] in self?.handleClick() } + }) { monitors.append(outside) } + if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in guard event.keyCode == 53 else { return event } // esc MainActor.assumeIsolated { [weak self] in @@ -151,6 +152,20 @@ public final class NotchCoordinator { }) { monitors.append(keys) } } + /// Ein Klick fixiert nur in der Notch selbst. Überall sonst schließt er — + /// auch das eigene Panel, denn wer hineinklickt, bedient ein Widget und + /// nicht das Panel. + private func handleClick() { + let point = NSEvent.mouseLocation + for controller in controllers.values where controller.isVisible { + if controller.geometry.triggerRect.contains(point) { + controller.click() + } else if !controller.panelFrame.contains(point) { + controller.clickedOutside() + } + } + } + private func pointerMoved() { let point = NSEvent.mouseLocation for controller in controllers.values { diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index 19aee62..411e5a2 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -81,6 +81,9 @@ public final class NotchScreenController { } public func click() { send(.click) } + public func clickedOutside() { send(.clickedOutside) } + /// Für den Koordinator: wo das Panel gerade steht. + public var panelFrame: CGRect { panel.frame } public func escape() { send(.escape) } public func setSuppressed(_ suppressed: Bool) { guard suppressed != machine.isSuppressed else { return } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift index cdfed08..65ea670 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift @@ -20,6 +20,12 @@ public enum NotchInput: Equatable, Sendable { case pointerEntered case pointerExited case click + /// Klick irgendwo außerhalb von Panel und Notch. + /// + /// Der Ausweg aus einem fixierten Panel. „Klick fixiert" ist eine gute + /// Regel, aber wer sie nicht kennt, sitzt sonst vor einem Panel, das offen + /// steht und auf nichts reagiert. + case clickedOutside case escape case armTimerFired case closeTimerFired @@ -173,9 +179,15 @@ public struct NotchStateMachine: Equatable, Sendable { return [] // Beenden - case (.open, .escape), (.pinned, .escape), (.closing, .escape): + case (.open, .escape), (.pinned, .escape), (.closing, .escape), + (.open, .clickedOutside), (.pinned, .clickedOutside), (.closing, .clickedOutside): phase = .idle - return [.hide] + return [.cancelCloseTimer, .hide] + + case (.arming, .clickedOutside): + phase = .idle + debounceElapsed = false + return [.cancelArmTimer, .cancelCloseTimer] case (.arming, .escape): phase = .idle diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift index f0543ea..dc641f4 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift @@ -106,7 +106,7 @@ public final class ShelfStore { public func remove(_ item: ShelfItem) { // In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager, // und ein Fehlklick darf keine Datei vernichten. - NSWorkspace.shared.recycle([item.url]) { [weak self] _, error in + NSWorkspace.shared.recycle([item.url]) { @Sendable [weak self] _, error in if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") } Task { @MainActor in self?.reload() } } @@ -114,7 +114,7 @@ public final class ShelfStore { public func removeAll() { guard !items.isEmpty else { return } - NSWorkspace.shared.recycle(items.map(\.url)) { [weak self] _, _ in + NSWorkspace.shared.recycle(items.map(\.url)) { @Sendable [weak self] _, _ in Task { @MainActor in self?.reload() } } } @@ -124,7 +124,7 @@ public final class ShelfStore { let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays) guard !expired.isEmpty else { return } log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen") - NSWorkspace.shared.recycle(expired.map(\.url)) { [weak self] _, _ in + NSWorkspace.shared.recycle(expired.map(\.url)) { @Sendable [weak self] _, _ in Task { @MainActor in self?.reload() } } } diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift index b5b32de..4c489c0 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift @@ -24,7 +24,7 @@ final class ThumbnailCache { fileAt: item.url, size: CGSize(width: size, height: size), scale: NSScreen.main?.backingScaleFactor ?? 2, representationTypes: .all) - QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in + QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { @Sendable rep, _ in guard let rep else { return } let image = NSImage(cgImage: rep.cgImage, size: CGSize(width: size, height: size)) diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift index 4478d8a..742e4d4 100644 --- a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchStateMachineTests.swift @@ -117,7 +117,11 @@ struct NotchStateMachineTests { let effects = machine.handle(.escape) #expect(machine.phase == .idle) - #expect(effects == [.hide]) + // Worauf es ankommt, ist das Schließen. Dass dabei ein womöglich + // laufender Schließtimer abbestellt wird, gehört dazu — aus `closing` + // heraus läuft er, und ein verspäteter Zeitgeber hat nichts mehr zu + // melden. + #expect(effects.contains(.hide)) } @Test("esc schließt auch ein nur geöffnetes Panel") @@ -127,7 +131,7 @@ struct NotchStateMachineTests { let effects = machine.handle(.escape) #expect(machine.phase == .idle) - #expect(effects == [.hide]) + #expect(effects.contains(.hide)) } @Test("esc im Ruhezustand tut nichts") @@ -336,3 +340,48 @@ struct NotchArmingToleranceTests { #expect(effects.contains(.cancelCloseTimer)) } } + +// Ein fixiertes Panel ließ sich nur durch einen erneuten Klick in die Notch +// oder mit Escape lösen. Wer nicht weiß, dass ein Klick fixiert, sitzt vor +// einem Panel, das offen steht und auf nichts reagiert — und sucht den Fehler +// in der App statt in seiner eigenen Mausbewegung. + +@Suite("Klick daneben") +struct NotchOutsideClickTests { + + @Test("Ein Klick daneben schließt das fixierte Panel") + func outsideClickReleasesPinned() { + var machine = NotchStateMachine() + machine.forcePhase(.pinned) + let effects = machine.handle(.clickedOutside) + + #expect(machine.phase == .idle) + #expect(effects.contains(.hide)) + } + + @Test("Auch ein offenes Panel schließt damit") + func outsideClickClosesOpen() { + var machine = NotchStateMachine() + machine.forcePhase(.open) + #expect(machine.handle(.clickedOutside).contains(.hide)) + #expect(machine.phase == .idle) + } + + @Test("Im Ruhezustand passiert nichts") + func outsideClickWhileIdleIsNoop() { + var machine = NotchStateMachine() + #expect(machine.handle(.clickedOutside).isEmpty) + #expect(machine.phase == .idle) + } + + @Test("Beim Aufziehen bricht ein Klick daneben ab") + func outsideClickCancelsArming() { + // Sonst fährt das Panel aus, nachdem man längst woanders geklickt hat. + var machine = NotchStateMachine() + _ = machine.handle(.pointerEntered) + let effects = machine.handle(.clickedOutside) + + #expect(machine.phase == .idle) + #expect(effects.contains(.cancelArmTimer)) + } +}