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