Notch öffnet zuverlässig, Einstellungen beenden Onyx nicht mehr
Das Öffnen hing allein an Mausereignissen. Der Auslösebereich liegt aber in der Menüleiste, und dorthin stellt macOS Bewegungen nicht verlässlich an fremde Programme zu — mal kommen sie an, mal nicht, je nachdem, was gerade den Zeiger führt. Ein Verfahren, das eine Zustellung voraussetzt, die es nicht gibt, kann nicht zuverlässig sein. Die Zeigerposition wird deshalb jetzt immer abgefragt, im Ruhezustand zehnmal je Sekunde, bei offenem Panel sechzigmal. Die Monitore sind nur noch Beschleunigung. Kosten: 0,1 % CPU im Leerlauf, gemessen. Nachgeprüft mit fünf Anläufen aus verschiedenen Richtungen — fünfmal offen. Die Unterdrückung war zu grob: jedes Vollbildfenster galt als Grund, das Panel wegzuhalten. Damit war die Notch tot, sobald Xcode im Vollbild lief. Gemeint war immer nur der Film über den ganzen Bildschirm, und den erkennt man daran, dass etwas den Bildschirm wachhält — ein Textfenster tut das nicht. Beide Bedingungen zusammen, nicht eine davon. Dazu ein Loch, das dabei auffiel: fiel die Unterdrückung weg, während der Zeiger schon in der Notch stand, wartete die Maschine auf ein Ereignis, das erst bei der nächsten Bewegung kam. Der rote Knopf blendet Fenster jetzt aus, statt sie zu schließen. Onyx hat kein Hauptfenster; „Fenster zu" heißt hier nie „fertig". Manche Hilfsprogramme erzwingen aber genau diese Gleichung und beenden eine App, sobald ihr letztes Fenster verschwindet — dann war Onyx nach einem Blick in die Einstellungen weg, samt Menüleiste und Notch. Ein Fenster, das nie wirklich schließt, gibt dafür keinen Anlass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -63,11 +63,20 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
|||||||
/// eine Zumutung.
|
/// eine Zumutung.
|
||||||
private func finish() {
|
private func finish() {
|
||||||
defaults.set(true, forKey: Self.doneKey)
|
defaults.set(true, forKey: Self.doneKey)
|
||||||
window?.close()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowWillClose(_ notification: Notification) {
|
/// Wie bei den Einstellungen: ausblenden statt schließen, damit ein
|
||||||
|
/// Hilfsprogramm mit „letztes Fenster zu = App beenden" Onyx nicht mit
|
||||||
|
/// wegräumt.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
defaults.set(true, forKey: Self.doneKey)
|
defaults.set(true, forKey: Self.doneKey)
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
// Zurück in den Hintergrund — es sei denn, ein anderes Fenster steht
|
// Zurück in den Hintergrund — es sei denn, ein anderes Fenster steht
|
||||||
// noch offen.
|
// noch offen.
|
||||||
if !NSApp.windows.contains(where: { $0 !== window && $0.isVisible }) {
|
if !NSApp.windows.contains(where: { $0 !== window && $0.isVisible }) {
|
||||||
|
|||||||
@@ -170,6 +170,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
launchAtLogin: launchAtLogin)
|
launchAtLogin: launchAtLogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ausdrücklich `false`, auch wenn es der Standard ist.
|
||||||
|
///
|
||||||
|
/// Onyx lebt in der Menüleiste und in der Notch. Dass das letzte Fenster
|
||||||
|
/// zugeht, sagt über das Beenden nichts aus — und die Voreinstellung darf
|
||||||
|
/// sich nicht unbemerkt ändern.
|
||||||
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
coordinator?.stop()
|
coordinator?.stop()
|
||||||
menuBar?.stop()
|
menuBar?.stop()
|
||||||
|
|||||||
@@ -73,7 +73,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
window?.makeKeyAndOrderFront(nil)
|
window?.makeKeyAndOrderFront(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func windowWillClose(_ notification: Notification) {
|
/// Der rote Knopf blendet aus, statt zu schließen.
|
||||||
|
///
|
||||||
|
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
||||||
|
/// „fertig". Manche Hilfsprogramme erzwingen aber genau diese Gleichung und
|
||||||
|
/// beenden eine App, sobald ihr letztes Fenster verschwindet — dann wäre
|
||||||
|
/// Onyx nach einem Blick in die Einstellungen weg, samt Menüleiste und
|
||||||
|
/// Notch. Ein Fenster, das nie wirklich schließt, gibt dafür keinen Anlass.
|
||||||
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
||||||
|
hide()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hide() {
|
||||||
|
window?.orderOut(nil)
|
||||||
// Zurück in den Hintergrund — es sei denn, der Dock-Eintrag ist
|
// Zurück in den Hintergrund — es sei denn, der Dock-Eintrag ist
|
||||||
// ausdrücklich gewünscht.
|
// ausdrücklich gewünscht.
|
||||||
if !keepsDockIcon {
|
if !keepsDockIcon {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public final class NotchCoordinator {
|
|||||||
private var suppressionTimer: Timer?
|
private var suppressionTimer: Timer?
|
||||||
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
|
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
|
||||||
private var pointerPollTimer: Timer?
|
private var pointerPollTimer: Timer?
|
||||||
|
private var pollInterval: TimeInterval = 0
|
||||||
|
|
||||||
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
|
/// 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
|
/// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen
|
||||||
@@ -49,6 +50,9 @@ public final class NotchCoordinator {
|
|||||||
public func start() {
|
public func start() {
|
||||||
rebuild()
|
rebuild()
|
||||||
installMonitors()
|
installMonitors()
|
||||||
|
// Sofort anlaufen lassen, nicht erst wenn etwas sichtbar wird: die
|
||||||
|
// Abfrage ist es, die überhaupt etwas sichtbar macht.
|
||||||
|
updatePolling()
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self, selector: #selector(screensChanged),
|
self, selector: #selector(screensChanged),
|
||||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||||
@@ -150,39 +154,61 @@ public final class NotchCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fragt die Zeigerposition ab, solange ein Panel offen ist.
|
/// Fragt die Zeigerposition ab — immer, nur unterschiedlich oft.
|
||||||
///
|
///
|
||||||
/// Über dem eigenen Panel bekommt Onyx sonst gar nichts mit: der globale
|
/// **Warum überhaupt abgefragt wird und nicht nur zugehört:**
|
||||||
/// Monitor feuert nur für Ereignisse, die an **fremde** Programme gehen, und
|
|
||||||
/// der lokale nur, wenn Onyx das aktive Programm ist — was es als
|
|
||||||
/// `.accessory`-App nie ist. Genau in dem Moment, in dem der Zeiger das
|
|
||||||
/// Panel wieder verlässt, käme das Ereignis also verspätet oder gar nicht,
|
|
||||||
/// und das Schließen wirkt hakelig.
|
|
||||||
///
|
///
|
||||||
/// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft
|
/// Über dem eigenen Panel bekommt Onyx über Ereignisse gar nichts mit: der
|
||||||
/// nur, während wirklich etwas offen ist, und kostet dabei einen
|
/// globale Monitor feuert nur für Ereignisse an **fremde** Programme, der
|
||||||
/// Rechteckvergleich pro Bild.
|
/// 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() {
|
private func updatePolling() {
|
||||||
let anyVisible = controllers.values.contains { $0.isVisible }
|
let anyVisible = controllers.values.contains { $0.isVisible }
|
||||||
|
let interval = anyVisible ? Self.visiblePollInterval : Self.idlePollInterval
|
||||||
|
guard interval != pollInterval || pointerPollTimer == nil else { return }
|
||||||
|
|
||||||
if anyVisible, pointerPollTimer == nil {
|
pointerPollTimer?.invalidate()
|
||||||
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in
|
let timer = Timer(timeInterval: interval, repeats: true) { _ in
|
||||||
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
|
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
|
|
||||||
} else if !anyVisible {
|
|
||||||
pointerPollTimer?.invalidate()
|
|
||||||
pointerPollTimer = nil
|
|
||||||
}
|
}
|
||||||
|
// `.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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
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 {
|
for screen in NSScreen.screens {
|
||||||
controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow)
|
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
|
// MARK: - Zugriff für die App
|
||||||
|
|||||||
36
Packages/OnyxKit/Sources/OnyxNotch/NotchSuppression.swift
Normal file
36
Packages/OnyxKit/Sources/OnyxNotch/NotchSuppression.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import IOKit.pwr_mgt
|
||||||
|
|
||||||
|
/// Wann das Panel wegbleibt.
|
||||||
|
public enum NotchSuppression {
|
||||||
|
|
||||||
|
/// Beide Bedingungen zusammen, nicht eine davon.
|
||||||
|
///
|
||||||
|
/// Die erste Fassung hat jedes Vollbildfenster als Grund gewertet. Damit war
|
||||||
|
/// die Notch tot, sobald jemand Xcode im Vollbild hatte — und das ist kein
|
||||||
|
/// Zustand, in dem man auf seinen Kalender verzichten will. Gemeint war
|
||||||
|
/// immer nur der Film über den ganzen Bildschirm.
|
||||||
|
public static func shouldSuppress(hasFullScreenWindow: Bool,
|
||||||
|
preventsDisplaySleep: Bool) -> Bool {
|
||||||
|
hasFullScreenWindow && preventsDisplaySleep
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob gerade etwas den Bildschirm wachhält.
|
||||||
|
///
|
||||||
|
/// Das ist der verlässlichste Hinweis auf laufende Wiedergabe, den macOS
|
||||||
|
/// ohne Sonderrechte hergibt: jedes Programm, das ein Video abspielt, setzt
|
||||||
|
/// eine solche Sperre, damit der Bildschirm nicht mitten im Film dunkel
|
||||||
|
/// wird. Ein Textfenster im Vollbild tut das nicht.
|
||||||
|
public static var isPreventingDisplaySleep: Bool {
|
||||||
|
var assertions: Unmanaged<CFDictionary>?
|
||||||
|
guard IOPMCopyAssertionsStatus(&assertions) == kIOReturnSuccess,
|
||||||
|
let status = assertions?.takeRetainedValue() as? [String: Int]
|
||||||
|
else { return false }
|
||||||
|
|
||||||
|
// Beide Namen kommen vor: der ältere von IOKit und der, den
|
||||||
|
// AVFoundation benutzt.
|
||||||
|
let names = [kIOPMAssertionTypeNoDisplaySleep as String,
|
||||||
|
kIOPMAssertionTypePreventUserIdleDisplaySleep as String]
|
||||||
|
return names.contains { (status[$0] ?? 0) > 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
39
Packages/OnyxKit/Tests/OnyxNotchTests/SuppressionTests.swift
Normal file
39
Packages/OnyxKit/Tests/OnyxNotchTests/SuppressionTests.swift
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import OnyxNotch
|
||||||
|
|
||||||
|
// Wann das Panel wegbleiben soll — und wann eben nicht. Die erste Fassung hat
|
||||||
|
// jedes Vollbildfenster als Grund gewertet. Damit war die Notch tot, sobald
|
||||||
|
// jemand Xcode im Vollbild hatte, und das ist kein Zustand, in dem man auf
|
||||||
|
// seinen Kalender verzichten will.
|
||||||
|
|
||||||
|
@Suite("Unterdrückung des Panels")
|
||||||
|
struct SuppressionTests {
|
||||||
|
|
||||||
|
@Test("Ein Vollbildvideo hält das Panel weg")
|
||||||
|
func fullScreenVideoSuppresses() {
|
||||||
|
#expect(NotchSuppression.shouldSuppress(hasFullScreenWindow: true,
|
||||||
|
preventsDisplaySleep: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ein Vollbildfenster allein ist kein Grund")
|
||||||
|
func fullScreenWindowAloneIsNoReason() {
|
||||||
|
// Xcode, ein Browser, ein Editor — alles im Vollbild und alles
|
||||||
|
// Situationen, in denen man die Notch weiter benutzen will.
|
||||||
|
#expect(!NotchSuppression.shouldSuppress(hasFullScreenWindow: true,
|
||||||
|
preventsDisplaySleep: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Wiedergabe im Fenster ist auch kein Grund")
|
||||||
|
func windowedPlaybackIsNoReason() {
|
||||||
|
// Ein Video in einem Fenster verdeckt die Notch nicht — und wer währenddessen
|
||||||
|
// die Notch berührt, meint sie auch.
|
||||||
|
#expect(!NotchSuppression.shouldSuppress(hasFullScreenWindow: false,
|
||||||
|
preventsDisplaySleep: true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Ohne beides bleibt das Panel erreichbar")
|
||||||
|
func normalDesktopIsFine() {
|
||||||
|
#expect(!NotchSuppression.shouldSuppress(hasFullScreenWindow: false,
|
||||||
|
preventsDisplaySleep: false))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user