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:
Scarriffle
2026-09-05 21:06:06 +02:00
co-authored by Claude Opus 5
parent f289d02c37
commit 3da673662f
17 changed files with 1536 additions and 4 deletions
@@ -0,0 +1,143 @@
import Testing
import Foundation
@testable import ClipboardProvider
@Suite("Zwischenablage: die Regeln")
struct ClipboardRulesTests {
private func item(_ fingerprint: String, at seconds: TimeInterval = 0,
preview: String = "x") -> ClipboardItem {
ClipboardItem(kind: .text, preview: preview,
addedAt: Date(timeIntervalSince1970: seconds),
sourceBundleID: nil, fingerprint: fingerprint)
}
// MARK: - Was nie gemerkt werden darf
@Test("Ein als verborgen markierter Inhalt wird nicht gemerkt")
func concealedIsIgnored() {
// Passwortmanager setzen diese Marke. Sie zu missachten hieße, fremde
// Passwörter einzusammeln.
#expect(!ClipboardRules.shouldRecord(
types: ["public.utf8-plain-text", "org.nspasteboard.ConcealedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Durchreichen und Selbsterzeugtes ebenso wenig")
func transientAndGeneratedAreIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.TransientType"],
sourceBundleID: nil, excludedApps: []))
#expect(!ClipboardRules.shouldRecord(types: ["org.nspasteboard.AutoGeneratedType"],
sourceBundleID: nil, excludedApps: []))
}
@Test("Gewöhnlicher Text schon")
func plainTextIsRecorded() {
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.apple.TextEdit",
excludedApps: []))
}
@Test("Aus einem ausgeschlossenen Programm nicht")
func excludedAppIsIgnored() {
#expect(!ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: "com.agilebits.onepassword7",
excludedApps: ["com.agilebits.onepassword7"]))
}
@Test("Ohne bekanntes Herkunftsprogramm wird gemerkt")
func unknownSourceIsRecorded() {
// Sonst ginge alles verloren, was aus einem Hintergrunddienst kommt.
#expect(ClipboardRules.shouldRecord(types: ["public.utf8-plain-text"],
sourceBundleID: nil,
excludedApps: ["com.agilebits.onepassword7"]))
}
// MARK: - Vorschau
@Test("Zeilenumbrüche werden zu Leerzeichen")
func previewIsOneLine() {
#expect(ClipboardRules.preview(for: "erste\nzweite") == "erste zweite")
#expect(ClipboardRules.preview(for: "a\t\tb") == "a b")
}
@Test("Zu langer Text wird gekürzt und bekommt Auslassungspunkte")
func previewIsTruncated() {
let long = String(repeating: "a", count: 500)
let preview = ClipboardRules.preview(for: long)
#expect(preview.count == ClipboardRules.previewLength + 1)
#expect(preview.hasSuffix("…"))
}
@Test("Kurzer Text bleibt unangetastet")
func shortPreviewIsUnchanged() {
#expect(ClipboardRules.preview(for: "kurz") == "kurz")
}
// MARK: - Aufnehmen
@Test("Dasselbe noch einmal kopiert rutscht nach oben, statt sich anzureihen")
func duplicateMovesToFront() {
let existing = [item("a", at: 10), item("b", at: 20)]
let again = item("a", at: 30)
let result = ClipboardRules.merge(existing, adding: again)
#expect(result.items.map(\.fingerprint) == ["a", "b"])
#expect(result.items.first?.addedAt == Date(timeIntervalSince1970: 30))
// Der alte Eintrag ist verdrängt — seine Datei muss weg.
#expect(result.dropped.count == 1)
}
@Test("Über der Grenze fällt das Älteste heraus")
func limitDropsOldest() {
let existing = (0..<5).map { item("f\($0)", at: Double(5 - $0)) }
let result = ClipboardRules.merge(existing, adding: item("neu", at: 99), limit: 3)
#expect(result.items.count == 3)
#expect(result.items.first?.fingerprint == "neu")
// Verdrängtes wird gemeldet, damit die Dateien nicht liegen bleiben.
#expect(result.dropped.count == 3)
}
@Test("Verdrängtes wird vollständig gemeldet, auch die Wiederholung")
func droppedIncludesDuplicate() {
let existing = [item("a", at: 1), item("b", at: 2), item("c", at: 3)]
let result = ClipboardRules.merge(existing, adding: item("a", at: 4), limit: 2)
// „a" einmal als Wiederholung, „c" als das Älteste über der Grenze.
#expect(Set(result.dropped.map(\.fingerprint)) == ["a", "c"])
}
// MARK: - Verfall
@Test("Ohne Frist verfällt nichts")
func noExpiryKeepsEverything() {
let old = [item("a", at: 0)]
#expect(ClipboardRules.expired(old, olderThanDays: nil,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
#expect(ClipboardRules.expired(old, olderThanDays: 0,
now: Date(timeIntervalSince1970: 9_000_000)).isEmpty)
}
@Test("Was älter ist als die Frist, verfällt")
func oldItemsExpire() {
let now = Date(timeIntervalSince1970: 10 * 86_400)
let items = [item("alt", at: 0), item("neu", at: 9 * 86_400)]
let stale = ClipboardRules.expired(items, olderThanDays: 7, now: now)
#expect(stale.map(\.fingerprint) == ["alt"])
}
// MARK: - Reihenfolge
@Test("Neuestes zuerst, und gleich alte in fester Reihenfolge")
func sortingIsStable() {
let a = item("a", at: 5)
let b = item("b", at: 5)
let c = item("c", at: 9)
let once = ClipboardRules.sorted([a, b, c])
let twice = ClipboardRules.sorted([c, b, a])
#expect(once.first?.fingerprint == "c")
// Ohne festen Zweitschlüssel tauschten gleich alte Einträge bei jeder
// Anzeige die Plätze.
#expect(once.map(\.id) == twice.map(\.id))
}
}
@@ -0,0 +1,126 @@
import Testing
import Foundation
@testable import FaceProvider
@Suite("Gesichtsvergleich")
struct FaceMatcherTests {
/// Ein Vektor, der sich um `offset` von der Grundform unterscheidet.
private func vector(_ offset: Float, count: Int = 768) -> [Float] {
(0..<count).map { Float($0 % 7) * 0.01 + offset }
}
private func matcher(offsets: [Float] = [0],
confirmations: Int = 3,
attempts: Int = 10) -> FaceMatcher {
FaceMatcher(templates: offsets.enumerated().map {
FaceTemplate(pose: "p\($0.offset)", vector: vector($0.element))
},
requiredConfirmations: confirmations,
maximumAttempts: attempts)
}
// MARK: - Abstand
@Test("Derselbe Vektor hat Abstand null")
func identicalIsZero() {
#expect(FaceMatcher.distance(vector(0), vector(0)) == 0)
}
@Test("Verschieden lange Vektoren ergeben keinen Abstand")
func lengthMismatchIsNil() {
// Zwei verschieden lange Vektoren zu vergleichen ergäbe eine Zahl ohne
// Bedeutung — und die sähe aus wie ein sehr guter Treffer.
#expect(FaceMatcher.distance([1, 2, 3], [1, 2]) == nil)
#expect(FaceMatcher.distance([], []) == nil)
}
@Test("Der kleinste Abstand über alle Posen zählt")
func closestPoseWins() {
// Die Posen sind absichtlich verschieden; ein Mittelwert bestrafte
// genau die Vielfalt, für die sie aufgenommen wurden.
let m = matcher(offsets: [0, 0.5])
let d = m.distance(to: vector(0.5))
#expect(d == 0)
}
// MARK: - Entscheidung
@Test("Erst nach genug Bestätigungen wird geöffnet")
func needsSeveralConfirmations() {
// Die Kamera liefert dreißig Bilder je Sekunde — unter dreißig ist
// irgendwann eines dabei, das zufällig passt.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 1))
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 2))
#expect(m.evaluate(vector(0), in: &session) == .accepted)
}
@Test("Ein fremdes Gesicht wird abgelehnt")
func strangerIsRejected() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate(vector(5), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Fehlversuch setzt die Bestätigungen zurück")
func failureResetsConfirmations() {
// Sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
let m = matcher(confirmations: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(0), in: &session)
_ = m.evaluate(vector(0), in: &session)
#expect(session.confirmations == 2)
_ = m.evaluate(vector(5), in: &session)
#expect(session.confirmations == 0)
}
@Test("Nach genug Fehlversuchen ist gesperrt")
func locksOutAfterAttempts() {
let m = matcher(attempts: 3)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(5), in: &session) == .lockedOut)
}
@Test("Nach der Sperre hilft auch das richtige Gesicht nicht mehr")
func lockoutIsFinal() {
// Ab hier bleibt nur das Passwort — sonst wäre die Sperre eine
// Verzögerung und keine Grenze.
let m = matcher(attempts: 2)
var session = FaceMatcher.Session()
_ = m.evaluate(vector(5), in: &session)
_ = m.evaluate(vector(5), in: &session)
#expect(m.evaluate(vector(0), in: &session) == .lockedOut)
}
@Test("Ohne hinterlegtes Gesicht öffnet nichts")
func noEnrollmentNeverOpens() {
// Ein leerer Vergleich darf kein Freifahrtschein sein.
let m = FaceMatcher(templates: [])
var session = FaceMatcher.Session()
#expect(!m.isEnrolled)
#expect(m.evaluate(vector(0), in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Ein Vektor falscher Länge gilt als Fehlversuch, nicht als Treffer")
func wrongLengthIsAFailure() {
let m = matcher()
var session = FaceMatcher.Session()
#expect(m.evaluate([1, 2, 3], in: &session) == .undecided(confirmations: 0))
#expect(session.attempts == 1)
}
@Test("Die Schwelle liegt näher am Ähnlichen als am Verschiedenen")
func thresholdLeansStrict() {
// Aus dem Spike: ähnlich 0,13 — verschieden 0,47. Ein falsches Ja ist
// ein Fremder am entsperrten Rechner, ein falsches Nein nur ein
// Passwort, das man ohnehin kennt.
#expect(FaceMatcher.defaultThreshold < (0.13 + 0.47) / 2)
#expect(FaceMatcher.defaultThreshold > 0.13)
}
}
@@ -0,0 +1,83 @@
import Testing
import Foundation
@testable import NotesProvider
@Suite("Notizen: die Regeln")
struct NoteRulesTests {
@Test("Die Überschrift ist die erste nichtleere Zeile")
func titleIsFirstLine() {
let note = Note(text: "\n\n Einkaufen \nMilch\nBrot")
#expect(NoteRules.title(of: note) == "Einkaufen")
}
@Test("Eine zu lange erste Zeile wird gekürzt")
func longTitleIsTruncated() {
let note = Note(text: String(repeating: "a", count: 200))
let title = NoteRules.title(of: note)
#expect(title.count == NoteRules.titleLength + 1)
#expect(title.hasSuffix("…"))
}
@Test("Eine leere Notiz hat keine Überschrift")
func emptyHasNoTitle() {
#expect(NoteRules.title(of: Note(text: " \n\n ")) == "")
}
@Test("Eine Notiz aus lauter Leerzeichen zählt als leer")
func whitespaceIsEmpty() {
// Sie entsteht, wenn man auf „Neu" drückt und es sich anders überlegt.
#expect(NoteRules.isEmpty(Note(text: " \n\t ")))
#expect(!NoteRules.isEmpty(Note(text: "x")))
}
@Test("Eine leer gewordene Notiz verschwindet beim Speichern")
func emptyNoteIsDropped() {
let note = Note(text: "war mal etwas")
let kept = NoteRules.merge([], updating: note)
#expect(kept.count == 1)
var emptied = note
emptied.text = " "
#expect(NoteRules.merge(kept, updating: emptied).isEmpty)
}
@Test("Zuletzt geändert steht oben")
func newestFirst() {
let old = Note(text: "alt", updatedAt: Date(timeIntervalSince1970: 1))
let new = Note(text: "neu", updatedAt: Date(timeIntervalSince1970: 2))
#expect(NoteRules.sorted([old, new]).first?.text == "neu")
}
@Test("Gleich alte Notizen behalten ihre Reihenfolge")
func sortingIsStable() {
let when = Date(timeIntervalSince1970: 5)
let a = Note(text: "a", updatedAt: when)
let b = Note(text: "b", updatedAt: when)
#expect(NoteRules.sorted([a, b]).map(\.id) == NoteRules.sorted([b, a]).map(\.id))
}
@Test("Eine bestehende Notiz wird ersetzt, nicht verdoppelt")
func updateReplaces() {
let note = Note(text: "erst")
var changed = note
changed.text = "dann"
let result = NoteRules.merge([note], updating: changed)
#expect(result.count == 1)
#expect(result.first?.text == "dann")
}
@Test("Über der Grenze fällt die älteste heraus")
func limitDropsOldest() {
let existing = (0..<3).map {
Note(text: "n\($0)", updatedAt: Date(timeIntervalSince1970: Double($0)))
}
let result = NoteRules.merge(existing,
updating: Note(text: "neu",
updatedAt: Date(timeIntervalSince1970: 99)),
limit: 3)
#expect(result.count == 3)
#expect(result.first?.text == "neu")
#expect(!result.contains { $0.text == "n0" })
}
}