Files
onyx/Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Scarriffle 8308536227 Phase 8: Ablage, AirDrop und Calendarr
Calendarr liefert Termine über den Gruppencontainer. Streng lesend: keine
eigene Anmeldung, kein Netz, kein Schreiben zurück.

Die Abdeckung ist der Kern und kein Detail. Der Schnappschuss reicht 49
Tage nach vorne, und das steht nirgends in der Datei — es ist zugesagtes
Verhalten des Schreibers. Außerhalb liegt keine Information vor, nicht
keine Termine; das Monatsraster graut solche Tage aus, statt „frei" zu
behaupten. Verlockend wäre, die Abdeckung aus dem frühesten Termin
abzuleiten. Das ist falsch, und ein Test hält es fest: mehrtägige Termine
ragen ins Fenster hinein und werden mitgeschrieben, obwohl ihr Anfang
nicht abgedeckt ist.

Bei „beide Quellen" ist die Abdeckung der Schnitt, nicht die Vereinigung.
Ein Tag gilt nur als bekannt, wenn ihn jede Quelle abdeckt. Umgekehrt
darf ein Ausfall einer Quelle die andere nicht mitreißen: Calendarr nie
geöffnet zu haben knipst den Apple-Kalender nicht aus.

Die Ablage kopiert, statt zu verweisen. Anhänge aus Mail oder WhatsApp
liegen in temporären Verzeichnissen — ein Verweis wäre in Stunden tot,
und die Ablage wäre leer, wenn man sie braucht. Aus demselben Grund nimmt
die Fläche UTType.item statt public.file-url an: solche Anhänge kommen
als Dateiversprechen, und loadFileRepresentation löst beides ein. Die
übergebene Datei existiert nur innerhalb des Rückrufs, also wird sie dort
sofort gesichert statt später auf dem MainActor.

Gelöscht wird in den Papierkorb. Automatisches Aufräumen ist standardmäßig
aus — eine Ablage, die ungefragt löscht, ist ein Papierkorb mit
Zeitschaltuhr.

Nebenbei: „mixer.on" und „network.vpn.active" standen als rohe Schlüssel
in der Oberfläche. Label(_:systemImage:) kennt kein Bundle und sucht immer
im Hauptbundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 10:16:19 +02:00

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]) { [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)) { [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)) { [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])
}
}