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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f289d02c37
commit
3da673662f
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user