import AppKit import OSLog private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notch") /// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt /// alle mit der Zeigerposition. /// /// Die Zeigerposition kommt aus zwei Quellen, und beide werden gebraucht: /// /// **Ereignismonitor**, solange das Panel zu ist. Ein unsichtbares Fenster über /// der Notch wäre die naheliegende Alternative, müsste dafür aber Mausereignisse /// annehmen — und würde die Menüleiste und die Fensterknöpfe darunter /// unbenutzbar machen. Mausbewegungen global zu beobachten verlangt anders als /// bei Tastatureingaben keine Bedienungshilfen-Berechtigung. /// /// **Abfrage**, solange es offen ist. Über dem eigenen Panel schweigen beide /// Monitore: der globale feuert nur für fremde Programme, der lokale nur wenn /// Onyx aktiv ist — was es als `.accessory`-App nie ist. Siehe `updatePolling()`. @MainActor public final class NotchCoordinator { public var policy: DisplayPolicy { didSet { guard policy != oldValue else { return }; rebuild() } } public var virtualNotchSize: CGSize { didSet { guard virtualNotchSize != oldValue else { return }; rebuild() } } private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] private var monitors: [Any] = [] private var suppressionTimer: Timer? /// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`. private var pointerPollTimer: Timer? private var pollInterval: TimeInterval = 0 /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. /// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen /// gehört — sonst müsste jede Aufrufstelle den Animationszustand selbst führen. private let makeContent: (CGDirectDisplayID, NotchPresentation) -> NSView public init(policy: DisplayPolicy = .builtInOnly, virtualNotchSize: CGSize = CGSize(width: 220, height: 32), makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { self.policy = policy self.virtualNotchSize = virtualNotchSize self.makeContent = makeContent } // MARK: - Lebenszyklus public func start() { rebuild() installMonitors() // Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die // Abfrage ist es, die überhaupt etwas sichtbar macht. updatePolling() NotificationCenter.default.addObserver( self, selector: #selector(screensChanged), name: NSApplication.didChangeScreenParametersNotification, object: nil) // Vollbild lässt sich nicht über eine Benachrichtigung erfragen; es // bleibt nur, gelegentlich nachzusehen. Zwei Sekunden reichen — das // Panel ist ohnehin zu, solange niemand die Notch berührt. suppressionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.refreshSuppression() } } } public func stop() { monitors.forEach(NSEvent.removeMonitor) monitors.removeAll() suppressionTimer?.invalidate() suppressionTimer = nil pointerPollTimer?.invalidate() pointerPollTimer = nil NotificationCenter.default.removeObserver(self) controllers.values.forEach { $0.tearDown() } controllers.removeAll() } // MARK: - Bildschirme @objc private func screensChanged() { rebuild() } private func rebuild() { let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics)) let wantedIDs = Set(wanted.map(\.displayID)) log.notice("Bildschirme neu aufgebaut: \(wantedIDs.count, privacy: .public) von \(NSScreen.screens.count, privacy: .public) verwendet") for (id, controller) in controllers where !wantedIDs.contains(id) { controller.tearDown() controllers[id] = nil } for metrics in wanted { if let existing = controllers[metrics.displayID] { // Bestehenden Controller aktualisieren statt neu zu bauen: // ein Auflösungswechsel darf ein offenes Panel nicht wegreißen. existing.update(metrics: metrics, virtualSize: virtualNotchSize) } else { let id = metrics.displayID let controller = NotchScreenController( metrics: metrics, virtualSize: virtualNotchSize, makeContent: { presentation in makeContent(id, presentation) }) controller.onVisibilityChanged = { [weak self] _ in self?.updatePolling() } controllers[id] = controller } } refreshSuppression() } // MARK: - Ereignisse private func installMonitors() { let moved: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged] if let global = NSEvent.addGlobalMonitorForEvents(matching: moved, handler: { _ in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } }) { monitors.append(global) } // Der globale Monitor schweigt, solange Onyx selbst aktiv ist — dann // greift dieser hier, sonst schließt das Panel unter dem eigenen Zeiger. if let local = NSEvent.addLocalMonitorForEvents(matching: moved, handler: { event in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } 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 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 self?.controllers.values.forEach { $0.escape() } } return nil }) { 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 { controller.pointerMoved(to: point) } } /// Fragt die Zeigerposition ab — immer, nur unterschiedlich oft. /// /// **Warum überhaupt abgefragt wird und nicht nur zugehört:** /// /// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der /// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der /// lokale nur, wenn Onyx das aktive Programm ist — was es als /// `.accessory`-App nie ist. /// /// Und der Auslösebereich liegt in der Menüleiste. Dorthin stellt macOS /// Mausbewegungen nicht verlässlich an fremde Programme zu: mal kommen sie /// an, mal nicht, je nachdem, was gerade den Zeiger führt. Genau daher kam /// das „manchmal geht die Notch nicht auf". Ein Verfahren, das eine /// Zustellung voraussetzt, die es nicht gibt, kann nicht zuverlässig sein. /// /// Deshalb sind die Monitore nur noch Beschleunigung, und die Abfrage ist /// die Wahrheit. Sie kostet einen Rechteckvergleich; bei zehn Abfragen je /// Sekunde im Ruhezustand ist das nicht messbar. private func updatePolling() { let anyVisible = controllers.values.contains { $0.isVisible } let interval = anyVisible ? Self.visiblePollInterval : Self.idlePollInterval guard interval != pollInterval || pointerPollTimer == nil else { return } pointerPollTimer?.invalidate() let timer = Timer(timeInterval: interval, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.pointerMoved() } } // `.common` statt der Standard-Schleife: sonst steht die Abfrage, // während ein Menü offen ist oder gescrollt wird. RunLoop.main.add(timer, forMode: .common) pointerPollTimer = timer pollInterval = interval log.notice("Zeigerabfrage: \(Int(1 / interval), privacy: .public) Hz, \(self.controllers.count, privacy: .public) Bildschirm(e)") } /// Im Ruhezustand genügt ein Zehntel: dazu kommt ohnehin die Entprellzeit /// von 220 ms, bevor sich etwas rührt. private static let idlePollInterval: TimeInterval = 1.0 / 10.0 /// Offen wird jedes Bild abgefragt, sonst wirkt das Schließen hakelig. private static let visiblePollInterval: TimeInterval = 1.0 / 60.0 private func refreshSuppression() { // Einmal je Durchgang abfragen statt einmal je Bildschirm: die Sperre // gilt für das ganze System. let playing = NotchSuppression.isPreventingDisplaySleep for screen in NSScreen.screens { guard let controller = controllers[screen.displayID] else { continue } let suppress = NotchSuppression.shouldSuppress( hasFullScreenWindow: screen.isShowingFullScreenWindow, preventsDisplaySleep: playing) controller.setSuppressed(suppress) } // Fällt die Unterdrückung weg, während der Zeiger schon in der Notch // steht, käme sonst nichts — die Maschine wartet auf ein Ereignis, das // erst bei der nächsten Bewegung kommt. pointerMoved() } // MARK: - Zugriff für die App public var activeControllers: [NotchScreenController] { Array(controllers.values) } public func controller(for displayID: CGDirectDisplayID) -> NotchScreenController? { controllers[displayID] } /// Öffnet das Panel auf dem Bildschirm, auf dem gerade der Zeiger steht — /// der Weg, über den das Menüleisten-Icon das Panel aufruft. public func togglePanelUnderPointer() { let point = NSEvent.mouseLocation guard let screen = NSScreen.screens.first(where: { $0.frame.contains(point) }) ?? NSScreen.main, let controller = controllers[screen.displayID] else { return } controller.click() } }