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.
This commit is contained in:
Rocky
2026-08-11 02:49:54 +08:00
parent 3de665d254
commit fd6e0d3e7e
30 changed files with 1478 additions and 118 deletions
@@ -84,6 +84,8 @@ public struct AppGroupStore: @unchecked Sendable {
PolishStylePackCatalog.resolve(id: activePolishStyleId, userCatalog: polishStyleCatalog)
}
public var llmThinkingEnabled: Bool { configuration.llmThinkingEnabled }
public var clipboardHistoryEnabled: Bool { configuration.clipboardHistoryEnabled }
public var clipboardCandidateBarEnabled: Bool { configuration.clipboardCandidateBarEnabled }
public var isPolishKeyMissing: Bool { configuration.isPolishKeyMissing }
public var isTranslationEffective: Bool { configuration.isTranslationEffective }
public var isLocalEngine: Bool { configuration.isLocalEngine }
@@ -182,6 +184,16 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardHistoryEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardHistoryEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setClipboardCandidateBarEnabled(_ enabled: Bool) {
mutateConfiguration { $0.clipboardCandidateBarEnabled = enabled }
AppGroupConfigDarwin.postConfigChanged()
}
public func setLocalASRCustomLanguageModelEnabled(_ enabled: Bool) {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
@@ -0,0 +1,58 @@
// ClipboardHistoryPolicy.swift
// OSGKeyboard · Shared
//
// Pure rules for accepting clipboard text and simple English/whitespace tokens.
import Foundation
public enum ClipboardHistoryPolicy: Sendable {
public static let maxEntries = 15
/// Reject short all-digit strings (OTP / verification-code shaped).
public static let otpDigitMaxLength = 8
/// Returns trimmed text when it should be stored; otherwise `nil`.
public static func acceptedText(from raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if looksLikeOTP(trimmed) { return nil }
return trimmed
}
/// Pure digits (optionally with spaces/dashes) of length 48 treat as OTP.
public static func looksLikeOTP(_ text: String) -> Bool {
let digits = text.filter(\.isNumber)
guard digits.count == text.filter({ !$0.isWhitespace && $0 != "-" }).count else {
return false
}
return (4...otpDigitMaxLength).contains(digits.count)
}
/// Simple whitespace / ASCII-punctuation split for English-ish snippets.
public static func whitespaceTokens(from text: String) -> [String] {
let separators = CharacterSet.whitespacesAndNewlines
.union(.punctuationCharacters)
return text
.components(separatedBy: separators)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
// Skip pure CJK-only single chars that aren't useful as chips.
.filter { token in
token.count > 1 || token.unicodeScalars.contains { $0.isASCII }
}
}
/// Merge `incoming` onto `existing` (newest first), dedupe by exact text.
public static func merging(
incoming: ClipboardHistoryEntry,
into existing: [ClipboardHistoryEntry],
limit: Int = maxEntries
) -> [ClipboardHistoryEntry] {
var next = existing.filter { $0.text != incoming.text }
next.insert(incoming, at: 0)
if next.count > limit {
next = Array(next.prefix(limit))
}
return next
}
}
@@ -0,0 +1,141 @@
// 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 []
}
}
}
@@ -16,6 +16,13 @@ import UIKit
public final class KeyboardState: ObservableObject {
public init() {}
/// Full-height clipboard UI layered over the active keyboard surface.
public enum ClipboardKeyboardOverlay: Equatable {
case none
case enableGuide
case historyPanel
}
/// Pipeline phase. Errors are structured so the UI layer can choose
/// the right icon / copy for each failure mode without
/// reverse-parsing a free-form string.
@@ -129,6 +136,18 @@ public final class KeyboardState: ObservableObject {
@Published public var returnKeyRole: ReturnKeyRole = .newline
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true
/// Opt-in clipboard history capture (mirrored from App Group).
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
/// Host field is a password / secure entry never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Full-keyboard clipboard overlay (enable guide or history list).
@Published public var clipboardOverlay: ClipboardKeyboardOverlay = .none
/// Suggestion strip above keys (newest clipboard item).
@Published public var clipboardSuggestionText: String?
/// Pasteboard changeCount associated with the current suggestion (for dismiss).
@Published public var clipboardSuggestionChangeCount: Int?
/// Typing-grid haptic strength (off / light / strong).
@Published public var keyboardHapticIntensity: KeyboardHapticIntensity = .default
/// Single source of truth for selecting iPad-scale keyboard metrics.
@@ -253,6 +272,17 @@ public final class KeyboardState: ObservableObject {
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
public var openInputMethodSetup: () -> Void = {}
/// Opens the host app Settings Clipboard page (enable history toggle).
public var openClipboardSettings: () -> Void = {}
/// Top-bar clipboard button: guide when history off, else history panel.
public var openClipboardPanel: () -> Void = {}
public var dismissClipboardOverlay: () -> Void = {}
public var insertClipboardText: (String) -> Void = { _ in }
public var dismissClipboardSuggestion: () -> Void = {}
public var clearClipboardHistory: () -> Void = {}
public var deleteClipboardHistoryEntry: (UUID) -> Void = { _ in }
/// Notify that the user inserted text (hides suggestion strip).
public var noteUserDidInputText: () -> Void = {}
/// System globe (🌐) key target. Kept weak to avoid a state controller
/// ownership cycle; UIKit's standard all-touch-events action provides both
/// tap-to-advance and long-press input-mode selection.
@@ -0,0 +1,34 @@
// SettingsDeepLink.swift
// OSGKeyboard · Shared
//
// One-shot deep-link target for the host Settings stack (keyboard app).
import Foundation
public enum SettingsDeepLink: String, Sendable {
case clipboard
private static let pendingKey = "settings.pendingDeepLink"
public static func setPending(_ link: SettingsDeepLink?) {
guard let defaults = AppGroup.defaultsIfAvailable else { return }
if let link {
defaults.set(link.rawValue, forKey: pendingKey)
} else {
defaults.removeObject(forKey: pendingKey)
}
defaults.synchronize()
}
public static func consumePending() -> SettingsDeepLink? {
guard let defaults = AppGroup.defaultsIfAvailable else { return nil }
guard let raw = defaults.string(forKey: pendingKey) else { return nil }
defaults.removeObject(forKey: pendingKey)
defaults.synchronize()
return SettingsDeepLink(rawValue: raw)
}
}
public extension Notification.Name {
static let osgOpenSettingsDeepLink = Notification.Name("osg.OpenSettingsDeepLink")
}