Files
onyx/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardStore.swift
T
ScarriffleandClaude Opus 5 3da673662f Zwischenablage, Notizen und der Gesichtsvergleich
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>
2026-09-05 21:06:06 +02:00

248 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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))
}
}