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:
@@ -80,6 +80,20 @@ public final class FanControl: ChargeLimitReading {
|
|||||||
|
|
||||||
// MARK: - Verbindung
|
// 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? {
|
private func proxy() -> OnyxHelperProtocol? {
|
||||||
if connection == nil {
|
if connection == nil {
|
||||||
let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName,
|
let connection = NSXPCConnection(machServiceName: OnyxHelper.machServiceName,
|
||||||
@@ -90,16 +104,16 @@ public final class FanControl: ChargeLimitReading {
|
|||||||
// Dienst unter demselben Namen die Antworten liefern.
|
// Dienst unter demselben Namen die Antworten liefern.
|
||||||
try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement)
|
try? connection.setCodeSigningRequirement(OnyxHelper.helperRequirement)
|
||||||
|
|
||||||
connection.invalidationHandler = { [weak self] in
|
connection.invalidationHandler = { @Sendable [weak self] in
|
||||||
Task { @MainActor in self?.connection = nil }
|
Task { @MainActor in self?.connection = nil }
|
||||||
}
|
}
|
||||||
connection.interruptionHandler = { [weak self] in
|
connection.interruptionHandler = { @Sendable [weak self] in
|
||||||
Task { @MainActor in self?.connection = nil }
|
Task { @MainActor in self?.connection = nil }
|
||||||
}
|
}
|
||||||
connection.resume()
|
connection.resume()
|
||||||
self.connection = connection
|
self.connection = connection
|
||||||
}
|
}
|
||||||
return connection?.remoteObjectProxyWithErrorHandler { [weak self] error in
|
return connection?.remoteObjectProxyWithErrorHandler { @Sendable [weak self] error in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.lastError = error.localizedDescription
|
self?.lastError = error.localizedDescription
|
||||||
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
|
log.error("Helfer nicht erreichbar: \(error.localizedDescription, privacy: .public)")
|
||||||
@@ -137,7 +151,7 @@ public final class FanControl: ChargeLimitReading {
|
|||||||
public func stop() { disconnect() }
|
public func stop() { disconnect() }
|
||||||
|
|
||||||
private func refreshStatus() {
|
private func refreshStatus() {
|
||||||
proxy()?.fanStatus { [weak self] data in
|
proxy()?.fanStatus { @Sendable [weak self] data in
|
||||||
Task { @MainActor in self?.apply(data) }
|
Task { @MainActor in self?.apply(data) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,13 +166,13 @@ public final class FanControl: ChargeLimitReading {
|
|||||||
// MARK: - Steuern
|
// MARK: - Steuern
|
||||||
|
|
||||||
public func setTarget(index: Int, rpm: Double) {
|
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) }
|
Task { @MainActor in self?.apply(data) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setAutomatic(index: Int) {
|
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) }
|
Task { @MainActor in self?.apply(data) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +184,7 @@ public final class FanControl: ChargeLimitReading {
|
|||||||
public private(set) var chargeLimit: Int?
|
public private(set) var chargeLimit: Int?
|
||||||
|
|
||||||
public func refreshChargeLimit() {
|
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 }
|
Task { @MainActor in self?.chargeLimit = percent > 0 ? percent : nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.styleMask = [.titled, .closable]
|
window.styleMask = [.titled, .closable]
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
|
// 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()
|
window.center()
|
||||||
|
}
|
||||||
self.window = window
|
self.window = window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -238,42 +238,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
/// Das feste Onyx-Element.
|
/// Das feste Onyx-Element.
|
||||||
///
|
///
|
||||||
/// **Kein „Panel öffnen" im Menü.** Das Panel öffnet sich, wenn man die
|
/// **Kein „Panel öffnen" im Menü**, aber ganz normal ein Menü.
|
||||||
/// 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
|
/// Der Eintrag beschrieb einen Umweg um die eigentliche Bedienung herum —
|
||||||
/// dann gibt. Als Notausgang bleibt es trotzdem erreichbar, nur ohne
|
/// das Panel öffnet sich, wenn man die Notch berührt. Ihn durch „Linksklick
|
||||||
/// eigene Zeile: **Linksklick auf das Symbol fährt das Panel aus,
|
/// fährt das Panel aus" zu ersetzen war aber schlechter als das Problem:
|
||||||
/// Rechtsklick zeigt das Menü.**
|
/// 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() {
|
private func installOnyxStatusItem() {
|
||||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||||
item.autosaveName = "onyx.menubar.main"
|
item.autosaveName = "onyx.menubar.main"
|
||||||
item.button?.image = Self.menuBarIcon()
|
item.button?.image = Self.menuBarIcon()
|
||||||
item.button?.target = self
|
|
||||||
item.button?.action = #selector(onyxItemClicked)
|
let menu = NSMenu()
|
||||||
item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
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
|
onyxItem = item
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Links: Panel. Rechts: Menü.
|
@objc private func openOnboarding() { showOnboarding() }
|
||||||
@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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
window.styleMask = [.titled, .closable, .miniaturizable]
|
window.styleMask = [.titled, .closable, .miniaturizable]
|
||||||
window.isReleasedWhenClosed = false
|
window.isReleasedWhenClosed = false
|
||||||
window.delegate = self
|
window.delegate = self
|
||||||
window.center()
|
Self.place(window)
|
||||||
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
// Über die Position freuen sich Nutzer mit mehreren Displays:
|
||||||
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
// beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben.
|
||||||
window.setFrameAutosaveName("onyx.settings")
|
window.setFrameAutosaveName("onyx.settings")
|
||||||
@@ -73,6 +73,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate {
|
|||||||
window?.makeKeyAndOrderFront(nil)
|
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.
|
/// Der rote Knopf blendet aus, statt zu schließen.
|
||||||
///
|
///
|
||||||
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
/// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public final class CalendarModel {
|
|||||||
let wanted = kind != .appleCalendar
|
let wanted = kind != .appleCalendar
|
||||||
guard wanted != (calendarrObserver != nil) else { return }
|
guard wanted != (calendarrObserver != nil) else { return }
|
||||||
calendarrObserver = wanted
|
calendarrObserver = wanted
|
||||||
? DarwinObserver(name: CalendarrSource.changeNotification) { [weak self] in
|
? DarwinObserver(name: CalendarrSource.changeNotification) { @Sendable [weak self] in
|
||||||
Task { @MainActor in self?.refresh() }
|
Task { @MainActor in self?.refresh() }
|
||||||
}
|
}
|
||||||
: nil
|
: nil
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ public final class AdapterProcess {
|
|||||||
Task { @MainActor in self?.consume(chunk) }
|
Task { @MainActor in self?.consume(chunk) }
|
||||||
}
|
}
|
||||||
|
|
||||||
process.terminationHandler = { [weak self] _ in
|
process.terminationHandler = { @Sendable [weak self] _ in
|
||||||
Task { @MainActor in self?.handleTermination() }
|
Task { @MainActor in self?.handleTermination() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,18 +130,19 @@ public final class NotchCoordinator {
|
|||||||
return event
|
return event
|
||||||
}) { monitors.append(local) }
|
}) { 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
|
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
|
||||||
MainActor.assumeIsolated { [weak self] in
|
MainActor.assumeIsolated { [weak self] in self?.handleClick() }
|
||||||
guard let self else { return }
|
|
||||||
let point = NSEvent.mouseLocation
|
|
||||||
for controller in controllers.values
|
|
||||||
where controller.isVisible && controller.geometry.triggerRect.contains(point) {
|
|
||||||
controller.click()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return event
|
return event
|
||||||
}) { monitors.append(clicks) }
|
}) { 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
|
if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in
|
||||||
guard event.keyCode == 53 else { return event } // esc
|
guard event.keyCode == 53 else { return event } // esc
|
||||||
MainActor.assumeIsolated { [weak self] in
|
MainActor.assumeIsolated { [weak self] in
|
||||||
@@ -151,6 +152,20 @@ public final class NotchCoordinator {
|
|||||||
}) { monitors.append(keys) }
|
}) { 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() {
|
private func pointerMoved() {
|
||||||
let point = NSEvent.mouseLocation
|
let point = NSEvent.mouseLocation
|
||||||
for controller in controllers.values {
|
for controller in controllers.values {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ public final class NotchScreenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func click() { send(.click) }
|
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 escape() { send(.escape) }
|
||||||
public func setSuppressed(_ suppressed: Bool) {
|
public func setSuppressed(_ suppressed: Bool) {
|
||||||
guard suppressed != machine.isSuppressed else { return }
|
guard suppressed != machine.isSuppressed else { return }
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ public enum NotchInput: Equatable, Sendable {
|
|||||||
case pointerEntered
|
case pointerEntered
|
||||||
case pointerExited
|
case pointerExited
|
||||||
case click
|
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 escape
|
||||||
case armTimerFired
|
case armTimerFired
|
||||||
case closeTimerFired
|
case closeTimerFired
|
||||||
@@ -173,9 +179,15 @@ public struct NotchStateMachine: Equatable, Sendable {
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
// Beenden
|
// Beenden
|
||||||
case (.open, .escape), (.pinned, .escape), (.closing, .escape):
|
case (.open, .escape), (.pinned, .escape), (.closing, .escape),
|
||||||
|
(.open, .clickedOutside), (.pinned, .clickedOutside), (.closing, .clickedOutside):
|
||||||
phase = .idle
|
phase = .idle
|
||||||
return [.hide]
|
return [.cancelCloseTimer, .hide]
|
||||||
|
|
||||||
|
case (.arming, .clickedOutside):
|
||||||
|
phase = .idle
|
||||||
|
debounceElapsed = false
|
||||||
|
return [.cancelArmTimer, .cancelCloseTimer]
|
||||||
|
|
||||||
case (.arming, .escape):
|
case (.arming, .escape):
|
||||||
phase = .idle
|
phase = .idle
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public final class ShelfStore {
|
|||||||
public func remove(_ item: ShelfItem) {
|
public func remove(_ item: ShelfItem) {
|
||||||
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
|
// In den Papierkorb statt endgültig: die Ablage ist ein Zwischenlager,
|
||||||
// und ein Fehlklick darf keine Datei vernichten.
|
// 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)") }
|
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
|
||||||
Task { @MainActor in self?.reload() }
|
Task { @MainActor in self?.reload() }
|
||||||
}
|
}
|
||||||
@@ -114,7 +114,7 @@ public final class ShelfStore {
|
|||||||
|
|
||||||
public func removeAll() {
|
public func removeAll() {
|
||||||
guard !items.isEmpty else { return }
|
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() }
|
Task { @MainActor in self?.reload() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ public final class ShelfStore {
|
|||||||
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
|
let expired = ShelfCleanup.expired(items, now: now, afterDays: autoCleanupDays)
|
||||||
guard !expired.isEmpty else { return }
|
guard !expired.isEmpty else { return }
|
||||||
log.notice("Ablage: \(expired.count, privacy: .public) Dateien abgelaufen")
|
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() }
|
Task { @MainActor in self?.reload() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ final class ThumbnailCache {
|
|||||||
fileAt: item.url, size: CGSize(width: size, height: size),
|
fileAt: item.url, size: CGSize(width: size, height: size),
|
||||||
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
||||||
representationTypes: .all)
|
representationTypes: .all)
|
||||||
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in
|
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { @Sendable rep, _ in
|
||||||
guard let rep else { return }
|
guard let rep else { return }
|
||||||
let image = NSImage(cgImage: rep.cgImage,
|
let image = NSImage(cgImage: rep.cgImage,
|
||||||
size: CGSize(width: size, height: size))
|
size: CGSize(width: size, height: size))
|
||||||
|
|||||||
@@ -117,7 +117,11 @@ struct NotchStateMachineTests {
|
|||||||
let effects = machine.handle(.escape)
|
let effects = machine.handle(.escape)
|
||||||
|
|
||||||
#expect(machine.phase == .idle)
|
#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")
|
@Test("esc schließt auch ein nur geöffnetes Panel")
|
||||||
@@ -127,7 +131,7 @@ struct NotchStateMachineTests {
|
|||||||
let effects = machine.handle(.escape)
|
let effects = machine.handle(.escape)
|
||||||
|
|
||||||
#expect(machine.phase == .idle)
|
#expect(machine.phase == .idle)
|
||||||
#expect(effects == [.hide])
|
#expect(effects.contains(.hide))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("esc im Ruhezustand tut nichts")
|
@Test("esc im Ruhezustand tut nichts")
|
||||||
@@ -336,3 +340,48 @@ struct NotchArmingToleranceTests {
|
|||||||
#expect(effects.contains(.cancelCloseTimer))
|
#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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user