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>
138 lines
5.5 KiB
Swift
138 lines
5.5 KiB
Swift
import Foundation
|
|
import AppKit
|
|
import OSLog
|
|
|
|
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
|
|
|
|
/// Die Ablage: ein Ordner, in dem Dateien zwischenlagern.
|
|
///
|
|
/// Kopiert wird, nicht verwiesen. Anhänge aus Mail oder WhatsApp liegen in
|
|
/// temporären Verzeichnissen, die das System aufräumt — ein Verweis darauf
|
|
/// wäre in Stunden tot, und die Ablage wäre genau dann leer, wenn man sie
|
|
/// braucht.
|
|
@MainActor
|
|
@Observable
|
|
public final class ShelfStore {
|
|
|
|
public private(set) var items: [ShelfItem] = []
|
|
public private(set) var lastError: String?
|
|
|
|
/// Automatisches Aufräumen nach N Tagen. `nil` = aus, und das ist Standard.
|
|
public var autoCleanupDays: Int? {
|
|
didSet {
|
|
defaults.set(autoCleanupDays ?? 0, forKey: Self.cleanupKey)
|
|
cleanUp()
|
|
}
|
|
}
|
|
|
|
private static let cleanupKey = "shelf.cleanupDays"
|
|
|
|
public let directory: URL
|
|
private let defaults: UserDefaults
|
|
|
|
public init(defaults: UserDefaults = .standard, directory: URL? = nil) {
|
|
self.defaults = defaults
|
|
self.directory = directory ?? FileManager.default
|
|
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("Onyx/Shelf", isDirectory: true)
|
|
let stored = defaults.integer(forKey: Self.cleanupKey)
|
|
self.autoCleanupDays = stored > 0 ? stored : nil
|
|
try? FileManager.default.createDirectory(at: self.directory,
|
|
withIntermediateDirectories: true)
|
|
reload()
|
|
cleanUp()
|
|
}
|
|
|
|
// MARK: - Lesen
|
|
|
|
public func reload() {
|
|
let keys: [URLResourceKey] = [.fileSizeKey, .addedToDirectoryDateKey,
|
|
.contentModificationDateKey, .isDirectoryKey]
|
|
guard let urls = try? FileManager.default.contentsOfDirectory(
|
|
at: directory, includingPropertiesForKeys: keys,
|
|
options: [.skipsHiddenFiles]) else {
|
|
items = []
|
|
return
|
|
}
|
|
items = ShelfItem.sorted(urls.compactMap { url in
|
|
let values = try? url.resourceValues(forKeys: Set(keys))
|
|
return ShelfItem(
|
|
url: url,
|
|
name: url.lastPathComponent,
|
|
size: Int64(values?.fileSize ?? 0),
|
|
// Wann die Datei **hier** ankam, nicht wann sie erzeugt wurde.
|
|
// Ein zehn Jahre altes Foto von gestern soll nicht sofort
|
|
// ablaufen, nur weil die Kamera ein altes Datum trägt.
|
|
addedAt: values?.addedToDirectoryDate
|
|
?? values?.contentModificationDate ?? Date())
|
|
})
|
|
}
|
|
|
|
// MARK: - Hinzufügen
|
|
|
|
/// Legt Kopien in der Ablage an und meldet, was tatsächlich ankam.
|
|
@discardableResult
|
|
public func add(_ urls: [URL]) -> [ShelfItem] {
|
|
var added: [ShelfItem] = []
|
|
var taken = Set(items.map(\.name))
|
|
|
|
for source in urls {
|
|
// Dateien aus fremden Apps kommen als sicherheitsbereichte URLs.
|
|
// Ohne das Paar aus Start und Stop scheitert das Kopieren mit
|
|
// „Berechtigung verweigert" — und zwar erst bei manchen Quellen,
|
|
// was den Fehler besonders unangenehm macht.
|
|
let scoped = source.startAccessingSecurityScopedResource()
|
|
defer { if scoped { source.stopAccessingSecurityScopedResource() } }
|
|
|
|
let name = ShelfNaming.uniqueName(for: source.lastPathComponent, taken: taken)
|
|
let target = directory.appendingPathComponent(name)
|
|
do {
|
|
try FileManager.default.copyItem(at: source, to: target)
|
|
taken.insert(name)
|
|
let size = (try? target.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
|
|
added.append(ShelfItem(url: target, name: name,
|
|
size: Int64(size), addedAt: Date()))
|
|
} catch {
|
|
log.error("Ablage: \(source.lastPathComponent, privacy: .public) nicht kopiert: \(error.localizedDescription, privacy: .public)")
|
|
lastError = error.localizedDescription
|
|
}
|
|
}
|
|
if !added.isEmpty { reload() }
|
|
return added
|
|
}
|
|
|
|
// MARK: - Entfernen
|
|
|
|
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]) { @Sendable [weak self] _, error in
|
|
if let error { log.error("Ablage: nicht gelöscht: \(error.localizedDescription, privacy: .public)") }
|
|
Task { @MainActor in self?.reload() }
|
|
}
|
|
}
|
|
|
|
public func removeAll() {
|
|
guard !items.isEmpty else { return }
|
|
NSWorkspace.shared.recycle(items.map(\.url)) { @Sendable [weak self] _, _ in
|
|
Task { @MainActor in self?.reload() }
|
|
}
|
|
}
|
|
|
|
/// Räumt ab, was über die eingestellte Frist hinaus liegt.
|
|
public func cleanUp(now: Date = Date()) {
|
|
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)) { @Sendable [weak self] _, _ in
|
|
Task { @MainActor in self?.reload() }
|
|
}
|
|
}
|
|
|
|
/// Öffnet den Ablageordner im Finder — der Notausgang, wenn die Oberfläche
|
|
/// nicht das kann, was man gerade braucht.
|
|
public func revealInFinder() {
|
|
NSWorkspace.shared.activateFileViewerSelecting([directory])
|
|
}
|
|
}
|