Drei neue Module nach dem Bauplan von `ShelfProvider`: reine Regeln getrennt vom Zustand, versionierte Indexdatei, Inhalte in Dateien statt in `UserDefaults` — was dort steht, liest jedes Programm mit `defaults read` mit. Die Zwischenablage achtet `ConcealedType`, `TransientType` und `AutoGeneratedType`. Diese Marken sind eine Verabredung zwischen Programmen und kein Systemdienst; wer sie missachtet, sammelt fremde Passwörter ein. Der Gesichtsvergleich stand im Plan als der eine echte Unsicherheitsfaktor. Ein Spike hat ihn erledigt: `VNGenerateFaceprintRequest` gibt es nicht, aber `VNGenerateImageFeaturePrintRequest` ist öffentlich, liefert 768 Elemente und ein Abstandsmaß — Faktor 3,5 Trennschärfe. Kein eigenes CoreML-Modell nötig. `FaceMatcher` entscheidet über mehrere Posen, mehrere Einzelbilder und eine Sperre nach Fehlversuchen. Die Schwelle liegt bewusst nicht mittig: ein falsches Ja ist ein Fremder am entsperrten Rechner, ein falsches Nein nur ein Passwort, das man ohnehin kennt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
9.1 KiB
Swift
248 lines
9.1 KiB
Swift
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<String> {
|
||
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))
|
||
}
|
||
}
|