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)) + } +}