- record analytics/usage only in the host app; the extension no longer opens the SQLite queues, so hiding the keyboard cannot hold WAL locks (0xdead10cc) - sync clipboard history writes across processes so deletes made on one side are not reverted by the other - guard memory-relief reentrancy on controller and telemetry sides to break the recursive crash loop - resolve credentials off the main thread and migrate legacy keys once per install, so Keychain reads cannot be caught in the suspend window - gate Volcengine ASR ping completion to once (SIGTRAP on double resume) - treat same-text captures as the same logical copy and dedupe auto actions by content fingerprint within 30s, stopping Universal Clipboard storms - realign version line to 2.1.0 (build 110)
274 lines
10 KiB
Swift
274 lines
10 KiB
Swift
// ClipboardHistoryStore.swift
|
||
// OSGKeyboard · Shared
|
||
//
|
||
// App Group–backed clipboard history (local only; not iCloud-synced).
|
||
|
||
import Combine
|
||
import Foundation
|
||
|
||
@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"
|
||
public static let lastAutoRepliedChangeCount =
|
||
"clipboard.history.lastAutoRepliedChangeCount"
|
||
public static let lastAutoRepliedTextHash =
|
||
"clipboard.history.lastAutoRepliedTextHash"
|
||
public static let lastAutoRepliedAt =
|
||
"clipboard.history.lastAutoRepliedAt"
|
||
/// Bumped on every `persist()`. Lets an instance tell the notification
|
||
/// its own write just posted from one a peer process posted.
|
||
public static let revision = "clipboard.history.revision"
|
||
}
|
||
|
||
@Published public private(set) var entries: [ClipboardHistoryEntry] = []
|
||
|
||
private let defaults: UserDefaults
|
||
/// Installed by `startObservingCrossProcessChanges()`. Stays nil in the
|
||
/// transient readers that construct a store just to read the newest entry.
|
||
private var crossProcessObserver: FlowSessionDarwinObserver?
|
||
/// Revision this instance last wrote or read, so the observer can ignore
|
||
/// the notification its own `persist()` posted.
|
||
private var lastSeenRevision: Int
|
||
|
||
public init(defaults: UserDefaults? = nil) {
|
||
let resolved: UserDefaults
|
||
if let defaults {
|
||
resolved = defaults
|
||
} else if let suite = AppGroup.defaultsIfAvailable {
|
||
resolved = suite
|
||
} else {
|
||
resolved = .standard
|
||
}
|
||
self.defaults = resolved
|
||
lastSeenRevision = resolved.integer(forKey: Keys.revision)
|
||
entries = Self.loadEntries(from: resolved)
|
||
}
|
||
|
||
/// Reloads whenever the peer process writes history.
|
||
///
|
||
/// Idempotent. Call it from the long-lived owners only — the keyboard's
|
||
/// capture coordinator and the host's foreground activation — never from a
|
||
/// transient reader, which would register and tear down an observer per read.
|
||
///
|
||
/// This does **not** replace reloading when the host returns to the
|
||
/// foreground: Darwin notifications are dropped for a suspended process, so
|
||
/// everything the keyboard wrote while the host was backgrounded arrives
|
||
/// only through that reload.
|
||
public func startObservingCrossProcessChanges() {
|
||
guard crossProcessObserver == nil else { return }
|
||
crossProcessObserver = FlowSessionDarwinObserver(
|
||
notificationName: ClipboardHistoryDarwin.notificationName
|
||
) { [weak self] in
|
||
self?.reloadIfPeerWrote()
|
||
}
|
||
}
|
||
|
||
private func reloadIfPeerWrote() {
|
||
guard defaults.integer(forKey: Keys.revision) != lastSeenRevision else { return }
|
||
reload()
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Pasteboard generation whose replyable text auto mode already routed into
|
||
/// the Reply flow. App Group–backed so a single copy triggers auto-reply at
|
||
/// most once, even across keyboard close/reopen.
|
||
public var lastAutoRepliedChangeCount: Int? {
|
||
get {
|
||
guard defaults.object(forKey: Keys.lastAutoRepliedChangeCount) != nil else {
|
||
return nil
|
||
}
|
||
return defaults.integer(forKey: Keys.lastAutoRepliedChangeCount)
|
||
}
|
||
set {
|
||
if let newValue {
|
||
defaults.set(newValue, forKey: Keys.lastAutoRepliedChangeCount)
|
||
} else {
|
||
defaults.removeObject(forKey: Keys.lastAutoRepliedChangeCount)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Fingerprint of the content auto mode last routed. Universal Clipboard
|
||
/// re-announces one copy under several changeCounts, so changeCount alone
|
||
/// cannot stop repeat triggers for identical content.
|
||
public var lastAutoRepliedTextHash: String? {
|
||
get { defaults.string(forKey: Keys.lastAutoRepliedTextHash) }
|
||
set {
|
||
if let newValue {
|
||
defaults.set(newValue, forKey: Keys.lastAutoRepliedTextHash)
|
||
} else {
|
||
defaults.removeObject(forKey: Keys.lastAutoRepliedTextHash)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// When the last auto action fired. Combined with the text fingerprint this
|
||
/// bounds repeat triggers: identical content re-fires only after the window.
|
||
public var lastAutoRepliedAt: Date? {
|
||
get { defaults.object(forKey: Keys.lastAutoRepliedAt) as? Date }
|
||
set {
|
||
if let newValue {
|
||
defaults.set(newValue, forKey: Keys.lastAutoRepliedAt)
|
||
} else {
|
||
defaults.removeObject(forKey: Keys.lastAutoRepliedAt)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Records that auto mode routed `text` (pasteboard generation
|
||
/// `changeCount`) into a flow at `at`.
|
||
public func markAutoReplied(text: String, changeCount: Int, at: Date = Date()) {
|
||
lastAutoRepliedChangeCount = changeCount
|
||
lastAutoRepliedTextHash = ClipboardHistoryPolicy.contentFingerprint(for: text)
|
||
lastAutoRepliedAt = at
|
||
}
|
||
|
||
/// Whether identical content already fired inside the suppression window.
|
||
public func recentlyAutoReplied(text: String, now: Date = Date()) -> Bool {
|
||
guard let firedAt = lastAutoRepliedAt,
|
||
ClipboardHistoryPolicy.isRepeatSuppressed(firedAt: firedAt, now: now)
|
||
else { return false }
|
||
return lastAutoRepliedTextHash == ClipboardHistoryPolicy.contentFingerprint(for: text)
|
||
}
|
||
|
||
/// Inserts accepted text (dedupe + pin). Returns the new head when stored.
|
||
@discardableResult
|
||
public func ingest(
|
||
rawText: String?,
|
||
changeCount: Int?
|
||
) -> ClipboardHistoryEntry? {
|
||
let sanitized = ClipboardHistoryPolicy.sanitizedEntries(entries)
|
||
if sanitized != entries {
|
||
entries = sanitized
|
||
persist()
|
||
}
|
||
guard let text = ClipboardHistoryPolicy.acceptedText(from: rawText) else {
|
||
return nil
|
||
}
|
||
let entry = ClipboardHistoryEntry(text: text, changeCount: changeCount)
|
||
let merged = ClipboardHistoryPolicy.merging(incoming: entry, into: entries)
|
||
let bounded = ClipboardHistoryPolicy.sanitizedEntries(merged)
|
||
guard bounded.first?.id == entry.id else {
|
||
return nil
|
||
}
|
||
entries = bounded
|
||
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() {
|
||
lastSeenRevision = defaults.integer(forKey: Keys.revision)
|
||
entries = Self.loadEntries(from: defaults)
|
||
}
|
||
|
||
public var newestEntry: ClipboardHistoryEntry? {
|
||
entries.first
|
||
}
|
||
|
||
/// Newest entry still inside the AI clipboard-hint window, if any.
|
||
public func newestAIHintEligibleEntry(now: Date = Date()) -> ClipboardHistoryEntry? {
|
||
guard let newest = newestEntry,
|
||
ClipboardHistoryPolicy.isEligibleForAIHint(newest, now: now)
|
||
else { return nil }
|
||
return newest
|
||
}
|
||
|
||
/// 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)
|
||
// Tell the peer before it can overwrite this blob from a stale copy.
|
||
let revision = defaults.integer(forKey: Keys.revision) &+ 1
|
||
defaults.set(revision, forKey: Keys.revision)
|
||
lastSeenRevision = revision
|
||
ClipboardHistoryDarwin.postHistoryChanged()
|
||
} 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)
|
||
let sanitized = ClipboardHistoryPolicy.sanitizedEntries(decoded)
|
||
if sanitized != decoded, let cleanedData = try? JSONEncoder().encode(sanitized) {
|
||
defaults.set(cleanedData, forKey: Keys.entries)
|
||
}
|
||
return sanitized
|
||
} catch {
|
||
OSGLog.config.warning(
|
||
"clipboard history decode failed: \(error.localizedDescription, privacy: .public)"
|
||
)
|
||
return []
|
||
}
|
||
}
|
||
}
|