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>
This commit is contained in:
77
Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift
Normal file
77
Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "AirDrop")
|
||||
|
||||
/// Der Weg zur Empfängerauswahl.
|
||||
public enum AirDrop {
|
||||
|
||||
/// Öffnet AirDrop mit den übergebenen Dateien.
|
||||
///
|
||||
/// - Returns: ob AirDrop überhaupt zur Verfügung stand.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
public static func send(_ urls: [URL]) -> Bool {
|
||||
guard !urls.isEmpty else { return false }
|
||||
guard let service = NSSharingService(named: .sendViaAirDrop) else {
|
||||
log.error("AirDrop steht nicht zur Verfügung")
|
||||
return false
|
||||
}
|
||||
guard service.canPerform(withItems: urls) else {
|
||||
log.error("AirDrop kann diese Auswahl nicht senden")
|
||||
return false
|
||||
}
|
||||
service.perform(withItems: urls)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Fläche, auf die man Dateien fallen lässt, um sie zu verschicken.
|
||||
public struct AirDropView: View {
|
||||
@State private var isTargeted = false
|
||||
@State private var failed = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "shareplay")
|
||||
.font(.system(size: 20, weight: .light))
|
||||
.foregroundStyle(isTargeted ? Onyx.Color.accent : Onyx.Color.textTertiary)
|
||||
Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(failed ? Onyx.Color.warning : Onyx.Color.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
||||
style: StrokeStyle(lineWidth: 1, dash: [4, 3]))
|
||||
}
|
||||
// Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als
|
||||
// Versprechen an, nicht als fertige Datei.
|
||||
.contentShape(.rect)
|
||||
.onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) }
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
public struct AirDropWidget: OnyxWidget {
|
||||
public let id = "airdrop"
|
||||
public var displayName: String { String(localized: "airdrop.name", bundle: .module) }
|
||||
public let symbolName = "shareplay"
|
||||
public let supportedSizes: [WidgetSize] = [.small, .medium]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func makeView(size: WidgetSize) -> AnyView {
|
||||
AnyView(AirDropView())
|
||||
}
|
||||
}
|
||||
79
Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift
Normal file
79
Packages/OnyxKit/Sources/ShelfProvider/FileDropView.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import OSLog
|
||||
|
||||
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Shelf")
|
||||
|
||||
/// Nimmt gezogene Dateien an — auch die, die es noch gar nicht gibt.
|
||||
///
|
||||
/// Mail, WhatsApp und die meisten Chat-Programme ziehen Anhänge nicht als
|
||||
/// fertige Datei heraus, sondern als **Versprechen**: die Datei entsteht erst,
|
||||
/// wenn das Ziel sagt, wohin. Genau diese Anhänge sind der Hauptgrund für eine
|
||||
/// Ablage, also darf die Fläche nicht nur `public.file-url` annehmen.
|
||||
///
|
||||
/// `loadFileRepresentation` ist der Weg, der beides abdeckt: bei einer echten
|
||||
/// Datei reicht es sie durch, bei einem Versprechen löst es dieses ein. Deshalb
|
||||
/// `UTType.item` als angenommener Typ — die Wurzel, der alles entspricht.
|
||||
/// Was sich nicht als Datei materialisieren lässt, fällt hinten heraus.
|
||||
public struct FileDropModifier: ViewModifier {
|
||||
|
||||
@Binding private var isTargeted: Bool
|
||||
private let onFiles: ([URL]) -> Void
|
||||
|
||||
public init(isTargeted: Binding<Bool>, onFiles: @escaping ([URL]) -> Void) {
|
||||
self._isTargeted = isTargeted
|
||||
self.onFiles = onFiles
|
||||
}
|
||||
|
||||
public func body(content: Content) -> some View {
|
||||
content.onDrop(of: [.item], isTargeted: $isTargeted) { providers in
|
||||
for provider in providers { receive(provider) }
|
||||
return !providers.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
private func receive(_ provider: NSItemProvider) {
|
||||
provider.loadFileRepresentation(forTypeIdentifier: UTType.item.identifier) { url, error in
|
||||
if let error {
|
||||
log.error("Ablage: Datei nicht erhalten: \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
guard let url else { return }
|
||||
|
||||
// Die übergebene Datei existiert **nur innerhalb dieses Blocks**;
|
||||
// danach räumt das System sie weg. Wer sie erst auf dem MainActor
|
||||
// anfassen will, greift ins Leere — also hier und sofort sichern.
|
||||
guard let staged = Self.stage(url) else { return }
|
||||
Task { @MainActor in onFiles([staged]) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rettet die Datei aus dem flüchtigen Verzeichnis in einen eigenen
|
||||
/// Zwischenordner.
|
||||
private static func stage(_ url: URL) -> URL? {
|
||||
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
|
||||
.appendingPathComponent("Onyx-Drop-\(UUID().uuidString)", isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: directory,
|
||||
withIntermediateDirectories: true)
|
||||
let target = directory.appendingPathComponent(url.lastPathComponent)
|
||||
// Verschieben, wenn möglich: bei einem Video von zwei Gigabyte ist
|
||||
// der Unterschied zum Kopieren spürbar. Über Datenträgergrenzen
|
||||
// hinweg scheitert es, dann wird kopiert.
|
||||
do { try FileManager.default.moveItem(at: url, to: target) }
|
||||
catch { try FileManager.default.copyItem(at: url, to: target) }
|
||||
return target
|
||||
} catch {
|
||||
log.error("Ablage: Zwischenspeichern gescheitert: \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Macht die ganze Fläche zum Ziel für gezogene Dateien.
|
||||
func onFileDrop(isTargeted: Binding<Bool>,
|
||||
perform: @escaping ([URL]) -> Void) -> some View {
|
||||
modifier(FileDropModifier(isTargeted: isTargeted, onFiles: perform))
|
||||
}
|
||||
}
|
||||
166
Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings
Normal file
166
Packages/OnyxKit/Sources/ShelfProvider/Localizable.xcstrings
Normal file
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"strings": {
|
||||
"airdrop.hint": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dateien zum Senden ablegen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Drop files to send"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"airdrop.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "AirDrop"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "AirDrop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"airdrop.unavailable": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "AirDrop kann das nicht senden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "AirDrop cannot send this"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.airdrop": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Per AirDrop senden"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Send via AirDrop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.clear": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Alles leeren"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Clear all"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.delete": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "In den Papierkorb"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Move to Trash"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.empty": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Dateien hierhin ziehen, um sie zu parken."
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Drop files here to park them."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.name": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Ablage"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Shelf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.open": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Öffnen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Open"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"shelf.reveal": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Im Finder zeigen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Show in Finder"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "1.1"
|
||||
}
|
||||
83
Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift
Normal file
83
Packages/OnyxKit/Sources/ShelfProvider/ShelfItem.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
/// Eine abgelegte Datei.
|
||||
public struct ShelfItem: Identifiable, Equatable, Sendable {
|
||||
public let url: URL
|
||||
public let name: String
|
||||
public let size: Int64
|
||||
public let addedAt: Date
|
||||
|
||||
public var id: String { url.path }
|
||||
|
||||
public init(url: URL, name: String, size: Int64, addedAt: Date) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.size = size
|
||||
self.addedAt = addedAt
|
||||
}
|
||||
|
||||
/// Neuestes zuerst.
|
||||
///
|
||||
/// Der Name als Zweitschlüssel ist kein Detail: das Dateisystem sagt keine
|
||||
/// Reihenfolge zu, und ohne festen zweiten Vergleich springen gleich alte
|
||||
/// Dateien bei jeder Aktualisierung durcheinander.
|
||||
public static func sorted(_ items: [ShelfItem]) -> [ShelfItem] {
|
||||
items.sorted {
|
||||
$0.addedAt == $1.addedAt ? $0.name < $1.name : $0.addedAt > $1.addedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Findet freie Namen, ohne je etwas zu überschreiben.
|
||||
public enum ShelfNaming {
|
||||
|
||||
/// Der erste freie Name in der Reihe `Vertrag.pdf`, `Vertrag 2.pdf`, …
|
||||
///
|
||||
/// Gleichnamige Dateien sind der Normalfall — `IMG_0001.jpg` aus zwei
|
||||
/// verschiedenen Chats. Überschreiben wäre stiller Datenverlust.
|
||||
public static func uniqueName(for name: String, taken: Set<String>) -> String {
|
||||
let name = name.isEmpty ? "Datei" : name
|
||||
guard taken.contains(name) else { return name }
|
||||
|
||||
let (base, ext) = split(name)
|
||||
var counter = 2
|
||||
while true {
|
||||
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
||||
if !taken.contains(candidate) { return candidate }
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Zerlegt in Namensteil und Endung.
|
||||
///
|
||||
/// Ein führender Punkt zählt nicht als Endung — sonst würde aus
|
||||
/// `.gitignore` ein ` 2.gitignore` mit führendem Leerzeichen. Und bei
|
||||
/// `archiv.tar.gz` zählt nur die letzte Endung: `archiv 2.tar.gz` wäre
|
||||
/// zwar hübscher, aber `archiv.tar 2.gz` ist die Regel, die auch bei
|
||||
/// unbekannten Doppelendungen nichts kaputt macht.
|
||||
private static func split(_ name: String) -> (base: String, ext: String) {
|
||||
guard let dot = name.lastIndex(of: "."), dot != name.startIndex else {
|
||||
return (name, "")
|
||||
}
|
||||
return (String(name[name.startIndex..<dot]),
|
||||
String(name[name.index(after: dot)...]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Räumt alte Dateien weg — wenn man es ausdrücklich möchte.
|
||||
public enum ShelfCleanup {
|
||||
|
||||
/// Was über die Frist hinaus liegt.
|
||||
///
|
||||
/// `nil` und `0` heißen beide „gar nicht". Eine Ablage, die ungefragt
|
||||
/// löscht, ist keine Ablage; und ein Fehlgriff im Einstellungsfeld darf
|
||||
/// nicht der Verlust aller Dateien sein.
|
||||
public static func expired(_ items: [ShelfItem], now: Date,
|
||||
afterDays: Int?) -> [ShelfItem] {
|
||||
guard let days = afterDays, days > 0 else { return [] }
|
||||
let limit = now.addingTimeInterval(-Double(days) * 86_400)
|
||||
// Streng älter: genau auf der Frist bleibt es liegen. Löschen ist
|
||||
// unwiderruflich, einen Tag länger warten ist es nicht.
|
||||
return items.filter { $0.addedAt < limit }
|
||||
}
|
||||
}
|
||||
137
Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Normal file
137
Packages/OnyxKit/Sources/ShelfProvider/ShelfStore.swift
Normal file
@@ -0,0 +1,137 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
203
Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Normal file
203
Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift
Normal file
@@ -0,0 +1,203 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import QuickLookThumbnailing
|
||||
import OnyxDesign
|
||||
import OnyxWidgetKit
|
||||
|
||||
/// Vorschaubilder, einmal erzeugt und behalten.
|
||||
///
|
||||
/// `QLThumbnailGenerator` ist nicht billig, und die Ablage zeichnet sich bei
|
||||
/// jeder Änderung neu. Ohne diesen Zwischenspeicher würde für jedes Bild bei
|
||||
/// jedem Tastendruck ein neuer Auftrag laufen.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThumbnailCache {
|
||||
private var images: [String: NSImage] = [:]
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func image(for item: ShelfItem, size: CGFloat) -> NSImage? {
|
||||
if let cached = images[item.id] { return cached }
|
||||
guard !pending.contains(item.id) else { return nil }
|
||||
pending.insert(item.id)
|
||||
|
||||
let request = QLThumbnailGenerator.Request(
|
||||
fileAt: item.url, size: CGSize(width: size, height: size),
|
||||
scale: NSScreen.main?.backingScaleFactor ?? 2,
|
||||
representationTypes: .all)
|
||||
QLThumbnailGenerator.shared.generateBestRepresentation(for: request) { rep, _ in
|
||||
guard let rep else { return }
|
||||
let image = NSImage(cgImage: rep.cgImage,
|
||||
size: CGSize(width: size, height: size))
|
||||
Task { @MainActor [weak self] in
|
||||
self?.images[item.id] = image
|
||||
self?.pending.remove(item.id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Ablage.
|
||||
public struct ShelfView: View {
|
||||
private let store: ShelfStore
|
||||
private let compact: Bool
|
||||
@State private var isTargeted = false
|
||||
@State private var thumbnails = ThumbnailCache()
|
||||
|
||||
public init(store: ShelfStore, compact: Bool = false) {
|
||||
self.store = store
|
||||
self.compact = compact
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
if store.items.isEmpty {
|
||||
EmptyShelf()
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(store.items.prefix(compact ? 4 : 20)) { item in
|
||||
ShelfRow(item: item, store: store, thumbnails: thumbnails)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollIndicators(.never)
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button { store.removeAll() } label: {
|
||||
Text("shelf.clear", bundle: .module).font(Onyx.Font.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
|
||||
Button { store.revealInFinder() } label: {
|
||||
Text("shelf.reveal", bundle: .module).font(Onyx.Font.caption)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
// Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin.
|
||||
.contentShape(.rect)
|
||||
.onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) }
|
||||
.overlay {
|
||||
if isTargeted {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
.background(Onyx.Color.accent.opacity(0.08),
|
||||
in: .rect(cornerRadius: 10, style: .continuous))
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmptyShelf: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Image(systemName: "tray")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text("shelf.empty", bundle: .module)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ShelfRow: View {
|
||||
let item: ShelfItem
|
||||
let store: ShelfStore
|
||||
let thumbnails: ThumbnailCache
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
thumbnail
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(item.name)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textPrimary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text(item.size.formatted(.byteCount(style: .file)))
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if isHovered {
|
||||
Button { AirDrop.send([item.url]) } label: {
|
||||
Image(systemName: "shareplay").font(.system(size: 10))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
|
||||
Button { store.remove(item) } label: {
|
||||
Image(systemName: "xmark.circle.fill").font(.system(size: 11))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
.contentShape(.rect)
|
||||
.onHover { isHovered = $0 }
|
||||
// `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres
|
||||
// reicht die URL als Text weiter, und im Finder landet eine
|
||||
// Internetverknüpfung statt der Datei.
|
||||
.onDrag { NSItemProvider(contentsOf: item.url) ?? NSItemProvider() }
|
||||
.onTapGesture(count: 2) { NSWorkspace.shared.open(item.url) }
|
||||
.contextMenu {
|
||||
Button { NSWorkspace.shared.open(item.url) } label: {
|
||||
Text("shelf.open", bundle: .module)
|
||||
}
|
||||
Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) } label: {
|
||||
Text("shelf.reveal", bundle: .module)
|
||||
}
|
||||
Button { AirDrop.send([item.url]) } label: {
|
||||
Text("shelf.airdrop", bundle: .module)
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { store.remove(item) } label: {
|
||||
Text("shelf.delete", bundle: .module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var thumbnail: some View {
|
||||
if let image = thumbnails.image(for: item, size: 22) {
|
||||
Image(nsImage: image)
|
||||
.resizable().aspectRatio(contentMode: .fit)
|
||||
.frame(width: 22, height: 22)
|
||||
.clipShape(.rect(cornerRadius: 3, style: .continuous))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 3, style: .continuous)
|
||||
.fill(Onyx.Color.elevated)
|
||||
.frame(width: 22, height: 22)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
public struct ShelfWidget: OnyxWidget {
|
||||
public let id = "shelf"
|
||||
public var displayName: String { String(localized: "shelf.name", bundle: .module) }
|
||||
public let symbolName = "tray.full"
|
||||
public let supportedSizes: [WidgetSize] = [.medium, .large]
|
||||
|
||||
private let store: ShelfStore
|
||||
|
||||
public init(store: ShelfStore) { self.store = store }
|
||||
|
||||
public func makeView(size: WidgetSize) -> AnyView {
|
||||
AnyView(ShelfView(store: store, compact: size == .medium))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user