import AppKit import Observation import CryptoKit import os private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Clipboard") /// Der Verlauf der Zwischenablage. /// /// **Abgetastet, nicht gemeldet.** macOS kennt keine Benachrichtigung über eine /// geänderte Zwischenablage — es gibt nur `changeCount`, einen Zähler, der /// hochgeht. Zweimal je Sekunde nachzusehen ist der übliche Preis; dasselbe tut /// die Lüftersteuerung mit dem SMC. /// /// **Inhalte gehen in Dateien, nie in `UserDefaults`.** Was dort steht, liest /// jedes Programm des Benutzers mit `defaults read` mit — für Kopiertes, das /// auch mal eine Adresse oder eine Kontonummer ist, ist das der falsche Ort. @MainActor @Observable public final class ClipboardStore { public private(set) var items: [ClipboardItem] = [] /// Nach wie vielen Tagen Einträge verfallen. `nil` heißt: nie. public var expiryDays: Int? { didSet { guard expiryDays != oldValue else { return } defaults.set(expiryDays ?? 0, forKey: Keys.expiryDays) purgeExpired() } } /// Programme, aus denen nie gemerkt wird. /// /// Über die `TransientType`-Marken hinaus: nicht jeder Passwortmanager /// setzt sie, und manches Programm kopiert Dinge, die einen nichts angehen. public var excludedApps: Set { didSet { guard excludedApps != oldValue else { return } defaults.set(Array(excludedApps).sorted(), forKey: Keys.excludedApps) } } /// Ob überhaupt gemerkt wird. Aus heißt: der Verlauf bleibt stehen, es /// kommt nur nichts Neues dazu. public var isRecording: Bool { didSet { guard isRecording != oldValue else { return } defaults.set(isRecording, forKey: Keys.isRecording) } } @ObservationIgnored private let directory: URL @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private let pasteboard: NSPasteboard @ObservationIgnored private var lastChangeCount: Int @ObservationIgnored private var timer: Timer? /// Was Onyx selbst in die Ablage gelegt hat — das soll nicht als neuer /// Eintrag zurückkommen. @ObservationIgnored private var ownChangeCount: Int? private enum Keys { static let expiryDays = "onyx.clipboard.expiryDays" static let excludedApps = "onyx.clipboard.excludedApps" static let isRecording = "onyx.clipboard.isRecording" } /// Zweimal je Sekunde. Öfter merkt niemand, seltener verpasst man einen /// schnellen Doppelgriff. private static let interval: TimeInterval = 0.5 public init(directory: URL? = nil, defaults: UserDefaults = .standard, pasteboard: NSPasteboard = .general) { self.directory = directory ?? Self.defaultDirectory self.defaults = defaults self.pasteboard = pasteboard self.lastChangeCount = pasteboard.changeCount let days = defaults.object(forKey: Keys.expiryDays) as? Int // Null steht für „nie" — ein eigener Wert wäre ein zweiter Weg, dasselbe // zu sagen. self.expiryDays = (days == nil || days == 0) ? 7 : days self.excludedApps = Set(defaults.stringArray(forKey: Keys.excludedApps) ?? []) self.isRecording = defaults.object(forKey: Keys.isRecording) as? Bool ?? true try? FileManager.default.createDirectory(at: self.directory, withIntermediateDirectories: true) load() purgeExpired() start() } public static var defaultDirectory: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Onyx/Clipboard", isDirectory: true) } // MARK: - Abtasten private func start() { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: Self.interval, repeats: true) { _ in MainActor.assumeIsolated { [weak self] in self?.poll() } } } public func stop() { timer?.invalidate() timer = nil } private func poll() { let count = pasteboard.changeCount guard count != lastChangeCount else { return } lastChangeCount = count // Was Onyx selbst eingesetzt hat, ist kein neuer Fund. if ownChangeCount == count { ownChangeCount = nil; return } guard isRecording else { return } capture() } private func capture() { let types = pasteboard.types?.map(\.rawValue) ?? [] let source = NSWorkspace.shared.frontmostApplication?.bundleIdentifier guard ClipboardRules.shouldRecord(types: types, sourceBundleID: source, excludedApps: excludedApps) else { return } if let text = pasteboard.string(forType: .string), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { add(kind: .text, preview: ClipboardRules.preview(for: text), payload: Data(text.utf8), source: source) } else if let image = pasteboard.data(forType: .tiff) ?? pasteboard.data(forType: .png) { let size = NSImage(data: image)?.size ?? .zero add(kind: .image, preview: "\(Int(size.width)) × \(Int(size.height))", payload: image, source: source) } } private func add(kind: ClipboardItem.Kind, preview: String, payload: Data, source: String?) { let fingerprint = SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined() let item = ClipboardItem(kind: kind, preview: preview, addedAt: Date(), sourceBundleID: source, fingerprint: fingerprint) let result = ClipboardRules.merge(items, adding: item) items = result.items write(payload, for: item) result.dropped.forEach(removeFile) save() } // MARK: - Zurücklegen /// Legt einen Eintrag wieder in die Zwischenablage. public func copyBack(_ item: ClipboardItem) { guard let data = try? Data(contentsOf: fileURL(for: item)) else { log.error("Inhalt von \(item.id, privacy: .public) nicht lesbar") return } pasteboard.clearContents() switch item.kind { case .text: pasteboard.setString(String(decoding: data, as: UTF8.self), forType: .string) case .image: pasteboard.setData(data, forType: .tiff) } // Der eigene Griff soll nicht als neuer Eintrag zurückkommen. ownChangeCount = pasteboard.changeCount lastChangeCount = pasteboard.changeCount } public func remove(_ item: ClipboardItem) { items.removeAll { $0.id == item.id } removeFile(item) save() } public func removeAll() { items.forEach(removeFile) items = [] save() } public func purgeExpired() { let stale = ClipboardRules.expired(items, olderThanDays: expiryDays, now: Date()) guard !stale.isEmpty else { return } let ids = Set(stale.map(\.id)) items.removeAll { ids.contains($0.id) } stale.forEach(removeFile) save() } // MARK: - Ablage private var indexURL: URL { directory.appendingPathComponent("index.json") } private func fileURL(for item: ClipboardItem) -> URL { directory.appendingPathComponent("\(item.id.uuidString).\(item.kind == .text ? "txt" : "tiff")") } /// Die Indexdatei trägt ihre Fassung mit. /// /// Ohne Nummer ist eine spätere Änderung am Format nicht von einer kaputten /// Datei zu unterscheiden — dasselbe Vorgehen wie in `LayoutStore`. private struct Index: Codable { var version = 1 var items: [ClipboardItem] } private func load() { guard let data = try? Data(contentsOf: indexURL) else { return } guard let index = try? JSONDecoder().decode(Index.self, from: data), index.version == 1 else { log.notice("Unbekannte Fassung der Indexdatei — Verlauf beginnt neu") return } // Nur, was auch wirklich noch daliegt: eine Zeile ohne Datei wäre ein // Eintrag, der beim Anklicken nichts tut. items = ClipboardRules.sorted(index.items.filter { FileManager.default.fileExists(atPath: fileURL(for: $0).path) }) } private func save() { do { let data = try JSONEncoder().encode(Index(items: items)) try data.write(to: indexURL, options: .atomic) } catch { log.error("Index nicht schreibbar: \(error.localizedDescription, privacy: .public)") } } private func write(_ payload: Data, for item: ClipboardItem) { do { try payload.write(to: fileURL(for: item), options: .atomic) } catch { log.error("Inhalt nicht schreibbar: \(error.localizedDescription, privacy: .public)") } } private func removeFile(_ item: ClipboardItem) { try? FileManager.default.removeItem(at: fileURL(for: item)) } }