Files
onyx/Packages/OnyxKit/Sources/ClipboardProvider/ClipboardItem.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

133 lines
5.4 KiB
Swift

import Foundation
/// Ein gemerkter Eintrag der Zwischenablage.
///
/// Der **Inhalt steht nicht hier drin**, sondern in einer Datei daneben. Was
/// hier steht, ist genug für die Liste — und wenig genug, dass die Indexdatei
/// klein bleibt, auch bei fünfhundert Einträgen.
public struct ClipboardItem: Identifiable, Equatable, Sendable, Codable {
public enum Kind: String, Codable, Sendable {
case text
case image
}
public let id: UUID
public let kind: Kind
/// Was in der Kachel zu lesen ist. Bei Bildern der Dateiname oder das Maß.
public let preview: String
public let addedAt: Date
/// Aus welchem Programm es kam — für die Ausschlussliste und die Anzeige.
public let sourceBundleID: String?
/// Erkennt Wiederholungen, ohne den ganzen Inhalt im Speicher zu halten.
///
/// Dieselbe Zeile zweimal zu kopieren ist der Normalfall, nicht die
/// Ausnahme — sie soll nach oben rutschen und nicht ein zweites Mal
/// dastehen.
public let fingerprint: String
public init(id: UUID = UUID(), kind: Kind, preview: String, addedAt: Date,
sourceBundleID: String?, fingerprint: String) {
self.id = id
self.kind = kind
self.preview = preview
self.addedAt = addedAt
self.sourceBundleID = sourceBundleID
self.fingerprint = fingerprint
}
}
/// Die Regeln der Zwischenablage — ohne Pasteboard, ohne Dateisystem, ohne Uhr.
///
/// Alles hier ist reine Rechnung und damit prüfbar. Was mit dem System zu tun
/// hat, steht in ``ClipboardStore``.
public enum ClipboardRules {
/// Wie viele Einträge höchstens gemerkt werden.
///
/// Eine Grenze muss es geben: ohne sie wächst der Verlauf, bis das Laden
/// spürbar wird — und niemand scrollt durch tausend Einträge.
public static let limit = 200
/// Wie lang eine Vorschauzeile höchstens ist.
public static let previewLength = 120
/// Typen, bei denen ausdrücklich **nicht** gemerkt werden soll.
///
/// Passwortmanager setzen `ConcealedType`, wenn sie ein Passwort ablegen;
/// `TransientType` heißt „nur zum Durchreichen"; `AutoGeneratedType` setzen
/// Werkzeuge, die etwas ohne Zutun des Menschen kopieren. Diese drei Marken
/// sind eine Verabredung zwischen Programmen, kein Systemdienst — wer sie
/// missachtet, sammelt fremde Passwörter ein.
public static let ignoredTypes: Set<String> = [
"org.nspasteboard.TransientType",
"org.nspasteboard.ConcealedType",
"org.nspasteboard.AutoGeneratedType",
]
/// Ob eine Änderung überhaupt gemerkt werden darf.
public static func shouldRecord(types: [String],
sourceBundleID: String?,
excludedApps: Set<String>) -> Bool {
guard types.allSatisfy({ !ignoredTypes.contains($0) }) else { return false }
guard let sourceBundleID else { return true }
return !excludedApps.contains(sourceBundleID)
}
/// Die eine Zeile, die in der Liste steht.
///
/// Zeilenumbrüche werden zu Leerzeichen: ein mehrzeiliger Ausschnitt würde
/// die Kachel sprengen, und die zweite Zeile sagt selten mehr als die
/// erste.
public static func preview(for text: String) -> String {
let flat = text
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\t", with: " ")
let collapsed = flat.split(separator: " ", omittingEmptySubsequences: true).joined(separator: " ")
guard collapsed.count > previewLength else { return collapsed }
return String(collapsed.prefix(previewLength)) + "…"
}
/// Nimmt einen Eintrag auf: Wiederholungen rutschen nach oben, statt sich
/// anzureihen, und über der Grenze fällt das Älteste heraus.
///
/// Gibt zusätzlich zurück, welche Einträge dabei verdrängt wurden — deren
/// Dateien muss der Aufrufer wegräumen.
public static func merge(_ items: [ClipboardItem],
adding item: ClipboardItem,
limit: Int = limit) -> (items: [ClipboardItem],
dropped: [ClipboardItem]) {
var dropped = items.filter { $0.fingerprint == item.fingerprint }
var kept = items.filter { $0.fingerprint != item.fingerprint }
kept.insert(item, at: 0)
if kept.count > limit {
dropped.append(contentsOf: kept[limit...])
kept = Array(kept.prefix(limit))
}
return (kept, dropped)
}
/// Was zu alt geworden ist. `nil` als Frist heißt: nichts verfällt.
public static func expired(_ items: [ClipboardItem],
olderThanDays days: Int?,
now: Date) -> [ClipboardItem] {
guard let days, days > 0 else { return [] }
let cutoff = now.addingTimeInterval(-Double(days) * 86_400)
return items.filter { $0.addedAt < cutoff }
}
/// Neuestes zuerst.
///
/// Die Kennung als Zweitschlüssel, damit zwei im selben Augenblick
/// gemerkte Einträge nicht bei jeder Anzeige die Plätze tauschen.
public static func sorted(_ items: [ClipboardItem]) -> [ClipboardItem] {
items.sorted {
$0.addedAt == $1.addedAt
? $0.id.uuidString > $1.id.uuidString
: $0.addedAt > $1.addedAt
}
}
}