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,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)) }
}