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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user