Files
OSGKeyboard/OSGKeyboardShared/Services/ClipboardHistoryStore.swift
T
Rocky fd6e0d3e7e feat(keyboard): add clipboard history and undo pastes
Ship optional keyboard clipboard history with settings, suggestion strip, and paste-permission guidance; let undo roll back clipboard inserts; bump build to 64.
2026-08-11 02:49:54 +08:00

142 lines
4.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ClipboardHistoryStore.swift
// OSGKeyboard · Shared
//
// App Groupbacked clipboard history (local only; not iCloud-synced).
import Foundation
import Combine
@MainActor
public final class ClipboardHistoryStore: ObservableObject {
public static let shared = ClipboardHistoryStore()
public enum Keys {
public static let entries = "clipboard.history.v1"
public static let lastChangeCount = "clipboard.history.lastChangeCount"
public static let suggestionDismissedChangeCount =
"clipboard.history.suggestionDismissedChangeCount"
}
@Published public private(set) var entries: [ClipboardHistoryEntry] = []
private let defaults: UserDefaults
public init(defaults: UserDefaults? = nil) {
if let defaults {
self.defaults = defaults
} else if let suite = AppGroup.defaultsIfAvailable {
self.defaults = suite
} else {
self.defaults = .standard
}
entries = Self.loadEntries(from: self.defaults)
}
public var lastObservedChangeCount: Int {
get { defaults.integer(forKey: Keys.lastChangeCount) }
set { defaults.set(newValue, forKey: Keys.lastChangeCount) }
}
public var suggestionDismissedChangeCount: Int? {
get {
guard defaults.object(forKey: Keys.suggestionDismissedChangeCount) != nil else {
return nil
}
return defaults.integer(forKey: Keys.suggestionDismissedChangeCount)
}
set {
if let newValue {
defaults.set(newValue, forKey: Keys.suggestionDismissedChangeCount)
} else {
defaults.removeObject(forKey: Keys.suggestionDismissedChangeCount)
}
}
}
/// Inserts accepted text (dedupe + pin). Returns the new head when stored.
@discardableResult
public func ingest(
rawText: String?,
changeCount: Int?
) -> ClipboardHistoryEntry? {
guard let text = ClipboardHistoryPolicy.acceptedText(from: rawText) else {
return nil
}
let entry = ClipboardHistoryEntry(text: text, changeCount: changeCount)
entries = ClipboardHistoryPolicy.merging(incoming: entry, into: entries)
persist()
if let changeCount {
lastObservedChangeCount = changeCount
// New content clears a previous suggestion dismiss for that older change.
if suggestionDismissedChangeCount != changeCount {
suggestionDismissedChangeCount = nil
}
}
return entry
}
public func remove(id: UUID) {
entries.removeAll { $0.id == id }
persist()
}
public func clearAll() {
entries = []
persist()
}
public func reload() {
entries = Self.loadEntries(from: defaults)
}
public var newestEntry: ClipboardHistoryEntry? {
entries.first
}
/// Whether the suggestion strip should offer `newestEntry` for this changeCount.
public func shouldShowSuggestion(
forChangeCount changeCount: Int?,
candidateBarEnabled: Bool,
historyEnabled: Bool
) -> Bool {
guard historyEnabled, candidateBarEnabled else { return false }
guard newestEntry != nil else { return false }
if let changeCount,
let dismissed = suggestionDismissedChangeCount,
dismissed == changeCount {
return false
}
return true
}
public func dismissSuggestion(forChangeCount changeCount: Int?) {
if let changeCount {
suggestionDismissedChangeCount = changeCount
}
}
private func persist() {
do {
let data = try JSONEncoder().encode(entries)
defaults.set(data, forKey: Keys.entries)
} catch {
OSGLog.config.warning(
"clipboard history encode failed: \(error.localizedDescription, privacy: .public)"
)
}
}
private static func loadEntries(from defaults: UserDefaults) -> [ClipboardHistoryEntry] {
guard let data = defaults.data(forKey: Keys.entries) else { return [] }
do {
let decoded = try JSONDecoder().decode([ClipboardHistoryEntry].self, from: data)
return Array(decoded.prefix(ClipboardHistoryPolicy.maxEntries))
} catch {
OSGLog.config.warning(
"clipboard history decode failed: \(error.localizedDescription, privacy: .public)"
)
return []
}
}
}