fix: keyboard stability, host-only analytics, and auto-reply dedupe

- 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)
This commit is contained in:
2026-09-25 12:14:29 +08:00
parent c0848a00de
commit a2abfc5e8c
31 changed files with 727 additions and 314 deletions
@@ -20,3 +20,27 @@ public enum AppGroupConfigDarwin {
)
}
}
/// Cross-process Darwin notification for clipboard-history writes.
///
/// History is one whole-array JSON blob under a single key, so a peer holding a
/// stale in-memory copy silently reverts the other side's deletes (and drops
/// its new entries) on its next write. Every other shared surface already has a
/// notification; this one did not.
///
/// A suspended process never receives Darwin notifications, so this covers only
/// the window where both processes are alive. The host must still reload when it
/// returns to the foreground.
public enum ClipboardHistoryDarwin {
public static let notificationName = "com.osgkeyboard.clipboard.history.changed"
public static func postHistoryChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(notificationName as CFString),
nil,
nil,
true
)
}
}
@@ -3,6 +3,7 @@
//
// Pure rules for accepting clipboard text and simple English/whitespace tokens.
import CryptoKit
import Foundation
public enum ClipboardHistoryPolicy: Sendable {
@@ -298,4 +299,20 @@ public enum ClipboardHistoryPolicy: Sendable {
) -> Bool {
now.timeIntervalSince(entry.createdAt) <= aiHintEligibilitySeconds
}
/// Stable content fingerprint for deduping auto triggers across pasteboard
/// generations. SHA-256 because `String.hashValue` is per-process randomized.
public static func contentFingerprint(for text: String) -> String {
SHA256.hash(data: Data(text.utf8))
.map { String(format: "%02x", $0) }
.joined()
}
/// Whether an action fired at `firedAt` still suppresses an identical repeat
/// at `now`. Universal Clipboard re-announces one copy under several
/// changeCounts within seconds; a deliberate re-copy after the window may
/// fire again.
public static func isRepeatSuppressed(firedAt: Date, now: Date = Date()) -> Bool {
now.timeIntervalSince(firedAt) <= aiHintEligibilitySeconds
}
}
@@ -17,21 +17,61 @@ public final class ClipboardHistoryStore: ObservableObject {
"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 {
self.defaults = defaults
resolved = defaults
} else if let suite = AppGroup.defaultsIfAvailable {
self.defaults = suite
resolved = suite
} else {
self.defaults = .standard
resolved = .standard
}
entries = Self.loadEntries(from: self.defaults)
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 {
@@ -74,6 +114,49 @@ public final class ClipboardHistoryStore: ObservableObject {
}
}
/// 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(
@@ -117,6 +200,7 @@ public final class ClipboardHistoryStore: ObservableObject {
}
public func reload() {
lastSeenRevision = defaults.integer(forKey: Keys.revision)
entries = Self.loadEntries(from: defaults)
}
@@ -158,6 +242,11 @@ public final class ClipboardHistoryStore: ObservableObject {
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)"
@@ -328,6 +328,10 @@ public actor ClipboardSemanticAnalyzer {
}
public func analyze(_ sourceText: String) -> ClipboardSemanticAnalysis {
#if DEBUG
MemoryDeviceProbe.log("semantic.analyze.begin")
defer { MemoryDeviceProbe.log("semantic.analyze.end") }
#endif
let text = sourceText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
return emptyAnalysis()
@@ -1058,6 +1062,9 @@ public actor ClipboardSemanticAnalyzer {
}
let entry = ModelEntry(configuration: configuration, model: model)
models[id] = entry
#if DEBUG
MemoryDeviceProbe.log("nlmodel.loaded.\(id)")
#endif
return entry
}
@@ -1129,6 +1136,9 @@ public actor ClipboardSemanticAnalyzer {
}
let entry = VerifierEntry(configuration: configuration, model: model)
verifierModels[id] = entry
#if DEBUG
MemoryDeviceProbe.log("nlmodel.verifier.loaded.\(id)")
#endif
return entry
}
@@ -68,13 +68,16 @@ extension FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowStartTransactionPayload)
store.removeObject(forKey: FlowSessionKeys.pendingKeyboardUtteranceId)
if let sessionId {
// One load per lifecycle event — each load runs defaults migrations
// and backfills, so loading twice here doubles that churn.
let config = AppGroupConfiguration.load(fromAvailable: store)
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: false,
reason: .starting,
heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId,
engineMode: config.engineMode,
localeId: config.localeId,
sessionExpiresAt: nil,
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
)