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,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
}
}
}
@@ -0,0 +1,247 @@
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))
}
}
@@ -0,0 +1,106 @@
import SwiftUI
import AppKit
import OnyxDesign
import OnyxWidgetKit
/// Der Verlauf der Zwischenablage, zum Zurückholen.
public struct ClipboardView: View {
private let store: ClipboardStore
private let compact: Bool
@State private var hovered: UUID?
@State private var justCopied: UUID?
public init(store: ClipboardStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if store.items.isEmpty {
OnyxNotice(symbol: "doc.on.clipboard",
text: store.isRecording
? String(localized: "clipboard.empty", bundle: .module)
: String(localized: "clipboard.paused", bundle: .module))
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(store.items.prefix(compact ? 5 : 40).enumerated()),
id: \.element.id) { index, item in
if index > 0 { OnyxHairline() }
row(item)
}
}
}
.scrollIndicators(.never)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private func row(_ item: ClipboardItem) -> some View {
// Die ganze Zeile ist der Knopf. Ein kleines Ziel neben dem Text wäre
// genauer zu treffen zu verlangen, als die Aufgabe es hergibt.
Button { copy(item) } label: {
HStack(spacing: Onyx.Space.row) {
Image(systemName: item.kind == .image ? "photo" : "text.alignleft")
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary)
.frame(width: 16)
Text(item.preview)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
.truncationMode(.tail)
Spacer(minLength: Onyx.Space.xs)
if justCopied == item.id {
// Eine Rückmeldung, die nichts verschiebt: ohne sie sieht
// ein Klick, der die Ablage füllt, wie ein Klick aus, der
// nichts getan hat.
Image(systemName: "checkmark")
.font(.system(size: Onyx.Symbol.caption))
.foregroundStyle(Onyx.Color.positive)
} else if hovered == item.id {
Button { store.remove(item) } label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: Onyx.Symbol.small))
.foregroundStyle(Onyx.Color.textTertiary)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Onyx.Space.row)
.padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md)
.contentShape(.rect)
}
.buttonStyle(.plain)
.background(hovered == item.id ? Onyx.Color.elevated : .clear)
.onHover { hovered = $0 ? item.id : (hovered == item.id ? nil : hovered) }
}
private func copy(_ item: ClipboardItem) {
store.copyBack(item)
justCopied = item.id
Task {
try? await Task.sleep(for: .seconds(1.2))
if justCopied == item.id { justCopied = nil }
}
}
}
public struct ClipboardWidget: OnyxWidget {
public let id = "clipboard"
public var displayName: String { String(localized: "clipboard.name", bundle: .module) }
public let symbolName = "doc.on.clipboard"
private let store: ClipboardStore
public init(store: ClipboardStore) { self.store = store }
public func makeView() -> AnyView {
AnyView(ClipboardView(store: store))
}
}
@@ -0,0 +1,54 @@
{
"sourceLanguage": "en",
"strings": {
"clipboard.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Zwischenablage"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Clipboard"
}
}
}
},
"clipboard.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Was du kopierst, erscheint hier."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "What you copy shows up here."
}
}
}
},
"clipboard.paused": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Der Verlauf ist angehalten."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "History is paused."
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,138 @@
import Foundation
/// Ein hinterlegtes Gesicht — ein Merkmalsvektor aus einer Aufnahme.
///
/// Mehrere davon je Person, aus verschiedenen Posen. Ein einzelner Vektor
/// beschreibt eine Kopfhaltung bei einer Beleuchtung, nicht ein Gesicht.
public struct FaceTemplate: Equatable, Sendable, Codable {
public let id: UUID
/// Wofür die Aufnahme gedacht war — nur zur Anzeige beim Anlernen.
public let pose: String
/// Der Merkmalsvektor. 768 Elemente aus
/// `VNGenerateImageFeaturePrintRequest`; siehe `docs/spikes/G-gesichtsmerkmal.md`.
public let vector: [Float]
public init(id: UUID = UUID(), pose: String, vector: [Float]) {
self.id = id
self.pose = pose
self.vector = vector
}
}
/// Wie ein Vergleich ausgegangen ist.
public enum FaceDecision: Equatable, Sendable {
/// Erkannt — genug übereinstimmende Einzelbilder hintereinander.
case accepted
/// Noch nicht genug. Der Aufrufer soll weiter Bilder liefern.
case undecided(confirmations: Int)
/// Abgelehnt und gesperrt: ab hier hilft nur noch das Passwort.
case lockedOut
}
/// Der Vergleich — reine Rechnung, ohne Kamera und ohne Vision.
///
/// **Warum das ein eigener Typ ist und keine Methode am Kameracode.** Genau hier
/// entscheidet sich, ob ein Fremder hereinkommt. Das muss man mit abgelegten
/// Vektoren durchspielen können, ohne sich vor eine Kamera zu setzen — mit
/// Grenzfällen, die man vor einer Kamera gar nicht herstellen könnte.
///
/// **Und warum die Schwelle streng ist.** Ein falsches Ja ist ein Fremder am
/// entsperrten Rechner. Ein falsches Nein ist ein Passwort, das man ohnehin
/// kennt. Die beiden Fehler sind nicht gleich teuer, also ist die Schwelle nicht
/// mittig.
public struct FaceMatcher: Sendable {
/// Ab welchem Abstand zwei Vektoren als dieselbe Person gelten.
///
/// Aus dem Spike: ähnliche Bilder lagen bei 0,13, deutlich verschiedene bei
/// 0,47. Die Schwelle liegt bewusst näher am Ähnlichen — siehe oben.
public static let defaultThreshold: Float = 0.22
/// Wie viele Einzelbilder hintereinander passen müssen.
///
/// Eines genügt nicht: die Kamera liefert dreißig Bilder je Sekunde, und
/// unter dreißig ist irgendwann eines dabei, das zufällig passt.
public static let defaultConfirmations = 3
/// Nach wie vielen Fehlversuchen Schluss ist.
public static let defaultAttempts = 10
public let threshold: Float
public let requiredConfirmations: Int
public let maximumAttempts: Int
private let templates: [FaceTemplate]
public init(templates: [FaceTemplate],
threshold: Float = defaultThreshold,
requiredConfirmations: Int = defaultConfirmations,
maximumAttempts: Int = defaultAttempts) {
self.templates = templates
self.threshold = threshold
self.requiredConfirmations = requiredConfirmations
self.maximumAttempts = maximumAttempts
}
/// Ob überhaupt ein Gesicht hinterlegt ist.
public var isEnrolled: Bool { !templates.isEmpty }
/// Der kleinste Abstand zu allen hinterlegten Vektoren.
///
/// Der kleinste und nicht der mittlere: die Posen sind absichtlich
/// verschieden, und ein Mittelwert über verschiedene Posen bestraft genau
/// die Vielfalt, für die sie aufgenommen wurden.
public func distance(to vector: [Float]) -> Float? {
templates.compactMap { Self.distance($0.vector, vector) }.min()
}
/// Euklidischer Abstand. `nil` bei ungleicher Länge — zwei verschieden
/// lange Vektoren zu vergleichen ergäbe eine Zahl ohne Bedeutung.
public static func distance(_ a: [Float], _ b: [Float]) -> Float? {
guard a.count == b.count, !a.isEmpty else { return nil }
var sum: Float = 0
for i in a.indices {
let d = a[i] - b[i]
sum += d * d
}
return sum.squareRoot()
}
/// Der laufende Versuch: zählt Bestätigungen und Fehlversuche mit.
public struct Session: Sendable {
public private(set) var confirmations = 0
public private(set) var attempts = 0
public private(set) var isLockedOut = false
public init() {}
fileprivate mutating func confirm() { confirmations += 1 }
fileprivate mutating func fail(limit: Int) {
attempts += 1
// Bestätigungen laufen **nicht** über Fehlversuche hinweg weiter:
// sonst summierten sich zufällige Treffer über Minuten zu einem Ja.
confirmations = 0
if attempts >= limit { isLockedOut = true }
}
}
/// Nimmt ein Einzelbild entgegen und sagt, wie es weitergeht.
public func evaluate(_ vector: [Float], in session: inout Session) -> FaceDecision {
guard !session.isLockedOut else { return .lockedOut }
// Ohne hinterlegtes Gesicht gibt es nichts zu vergleichen — dann ist
// jeder Versuch ein Fehlversuch und nicht etwa ein Freifahrtschein.
guard isEnrolled, let distance = distance(to: vector) else {
session.fail(limit: maximumAttempts)
return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0)
}
if distance <= threshold {
session.confirm()
return session.confirmations >= requiredConfirmations
? .accepted
: .undecided(confirmations: session.confirmations)
}
session.fail(limit: maximumAttempts)
return session.isLockedOut ? .lockedOut : .undecided(confirmations: 0)
}
}
@@ -0,0 +1,38 @@
{
"sourceLanguage": "en",
"strings": {
"notes.name": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Notizen"
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Notes"
}
}
}
},
"notes.empty": {
"localizations": {
"de": {
"stringUnit": {
"state": "translated",
"value": "Noch nichts notiert."
}
},
"en": {
"stringUnit": {
"state": "translated",
"value": "Nothing noted yet."
}
}
}
}
},
"version": "1.0"
}
@@ -0,0 +1,71 @@
import Foundation
/// Eine kurze Notiz.
///
/// Kein Titel als eigenes Feld: wer eine Notiz schreibt, will schreiben und
/// nicht erst benennen. Die Überschrift ist die erste Zeile — dieselbe
/// Verabredung, die Apples Notizen seit jeher treffen.
public struct Note: Identifiable, Equatable, Sendable, Codable {
public let id: UUID
public var text: String
public var updatedAt: Date
public init(id: UUID = UUID(), text: String = "", updatedAt: Date = Date()) {
self.id = id
self.text = text
self.updatedAt = updatedAt
}
}
/// Die Regeln der Notizen — ohne Dateisystem und ohne Uhr.
public enum NoteRules {
/// Wie viele Notizen höchstens.
///
/// Die Kachel ist klein und die Liste soll überschaubar bleiben. Wer mehr
/// braucht, braucht ein Notizprogramm und keine Notch.
public static let limit = 50
/// Wie lang eine Überschrift höchstens ist.
public static let titleLength = 60
/// Die erste nichtleere Zeile, gekürzt. Leer bleibt leer — daraus macht die
/// Anzeige „Neue Notiz", und das ist ihre Sache, nicht unsere.
public static func title(of note: Note) -> String {
let line = note.text
.split(whereSeparator: \.isNewline)
.first { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
guard let line else { return "" }
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.count > titleLength else { return trimmed }
return String(trimmed.prefix(titleLength)) + "…"
}
/// Ob eine Notiz überhaupt etwas enthält.
///
/// Eine Notiz aus lauter Leerzeichen ist keine — sie entsteht, wenn man auf
/// „Neu" drückt und es sich anders überlegt, und soll dann von selbst
/// verschwinden statt als leere Zeile stehen zu bleiben.
public static func isEmpty(_ note: Note) -> Bool {
note.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Zuletzt geändert zuerst, mit fester Reihenfolge bei Gleichstand.
public static func sorted(_ notes: [Note]) -> [Note] {
notes.sorted {
$0.updatedAt == $1.updatedAt
? $0.id.uuidString > $1.id.uuidString
: $0.updatedAt > $1.updatedAt
}
}
/// Nimmt eine geänderte Notiz auf: leere fallen weg, über der Grenze fällt
/// die älteste heraus.
public static func merge(_ notes: [Note], updating note: Note,
limit: Int = limit) -> [Note] {
var kept = notes.filter { $0.id != note.id }
if !isEmpty(note) { kept.append(note) }
let sorted = sorted(kept)
return Array(sorted.prefix(limit))
}
}
@@ -0,0 +1,77 @@
import Foundation
import Observation
import os
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "Notes")
/// Die Notizen und ihre Ablage.
///
/// Eine Datei für alles: Notizen sind kurz, und fünfzig davon sind ein paar
/// Kilobyte. Eine Datei je Notiz wäre der Ordnung wegen — nicht der Sache.
///
/// **Nicht in `UserDefaults`.** Was dort steht, liest jedes Programm des
/// Benutzers mit `defaults read` mit, und eine Notiz ist oft genau das, was man
/// sich merkt, weil man es nicht im Kopf behalten will.
@MainActor
@Observable
public final class NotesStore {
public private(set) var notes: [Note] = []
@ObservationIgnored private let fileURL: URL
private struct Archive: Codable {
var version = 1
var notes: [Note]
}
public init(directory: URL? = nil) {
let folder = directory ?? Self.defaultDirectory
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
fileURL = folder.appendingPathComponent("notes.json")
load()
}
public static var defaultDirectory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Onyx/Notes", isDirectory: true)
}
/// Legt eine leere Notiz an und gibt sie zurück, damit die Anzeige den
/// Schreibcursor hineinsetzen kann.
///
/// Sie steht noch **nicht** in der Liste: eine leere Notiz ist keine, und
/// wer auf „Neu" drückt und es sich anders überlegt, soll keine Leerzeile
/// hinterlassen. Erst der erste Buchstabe macht sie zur Notiz.
public func makeDraft() -> Note { Note() }
public func update(_ note: Note) {
var updated = note
updated.updatedAt = Date()
notes = NoteRules.merge(notes, updating: updated)
save()
}
public func remove(_ note: Note) {
notes.removeAll { $0.id == note.id }
save()
}
private func load() {
guard let data = try? Data(contentsOf: fileURL) else { return }
guard let archive = try? JSONDecoder().decode(Archive.self, from: data),
archive.version == 1 else {
log.notice("Unbekannte Fassung der Notizdatei — sie bleibt unangetastet")
return
}
notes = NoteRules.sorted(archive.notes)
}
private func save() {
do {
try JSONEncoder().encode(Archive(notes: notes)).write(to: fileURL, options: .atomic)
} catch {
log.error("Notizen nicht schreibbar: \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -0,0 +1,132 @@
import SwiftUI
import OnyxDesign
import OnyxWidgetKit
/// Kurze Notizen, direkt in der Kachel zu schreiben.
public struct NotesView: View {
private let store: NotesStore
private let compact: Bool
@State private var editing: Note?
@State private var draft = ""
@FocusState private var isWriting: Bool
public init(store: NotesStore, compact: Bool = false) {
self.store = store
self.compact = compact
}
public var body: some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
if let editing {
editor(for: editing)
} else {
list
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
private var list: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("notes.name", bundle: .module)
.font(Onyx.Font.title)
.foregroundStyle(Onyx.Color.textPrimary)
Spacer()
Button { begin(store.makeDraft()) } label: {
Image(systemName: "square.and.pencil")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)
}
.padding(.bottom, Onyx.Space.sm)
if store.notes.isEmpty {
OnyxNotice(symbol: "note.text",
text: String(localized: "notes.empty", bundle: .module))
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(Array(store.notes.prefix(compact ? 4 : 20).enumerated()),
id: \.element.id) { index, note in
if index > 0 { OnyxHairline() }
Button { begin(note) } label: {
HStack {
Text(NoteRules.title(of: note))
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.lineLimit(1)
Spacer(minLength: Onyx.Space.xs)
}
.padding(.vertical, Onyx.Space.sm)
.contentShape(.rect)
}
.buttonStyle(.plain)
}
}
}
.scrollIndicators(.never)
}
}
}
private func editor(for note: Note) -> some View {
VStack(alignment: .leading, spacing: Onyx.Space.sm) {
HStack {
Button { finish() } label: {
Image(systemName: "chevron.left")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.accent)
Spacer()
Button { store.remove(note); editing = nil } label: {
Image(systemName: "trash")
.font(.system(size: Onyx.Symbol.small))
}
.buttonStyle(.plain)
.foregroundStyle(Onyx.Color.textTertiary)
}
// `TextEditor` ohne eigenen Hintergrund: der Standardhintergrund
// ist hell und stünde als weißer Block in der schwarzen Kachel.
TextEditor(text: $draft)
.font(Onyx.Font.footnote)
.foregroundStyle(Onyx.Color.textPrimary)
.scrollContentBackground(.hidden)
.focused($isWriting)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.onAppear { isWriting = true }
}
private func begin(_ note: Note) {
editing = note
draft = note.text
}
private func finish() {
if var note = editing {
note.text = draft
store.update(note)
}
editing = nil
draft = ""
}
}
public struct NotesWidget: OnyxWidget {
public let id = "notes"
public var displayName: String { String(localized: "notes.name", bundle: .module) }
public let symbolName = "note.text"
private let store: NotesStore
public init(store: NotesStore) { self.store = store }
public func makeView() -> AnyView { AnyView(NotesView(store: store)) }
}