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
@@ -65,6 +65,9 @@ final class ClipboardCaptureCoordinator {
self.history = history
self.semanticRanking = semanticRanking
self.pasteboard = pasteboard
// The host can delete or clear history while this extension is alive;
// without this the next capture writes our stale array back and undoes it.
history.startObservingCrossProcessChanges()
snapshotObserver = semanticRanking.$snapshot
.sink { [weak self] snapshot in
// `@Published` fires in willSet, so the store's property is not
@@ -267,12 +270,11 @@ final class ClipboardCaptureCoordinator {
return
}
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
semanticRanking.clear()
history.lastObservedChangeCount = changeCount
return
@@ -284,6 +286,17 @@ final class ClipboardCaptureCoordinator {
if isCurrentGeneration {
return
}
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: raw),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
return
}
// A genuinely new generation replaces any previous transient suggestion.
clearSuggestion()
semanticRanking.clear()
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
semanticRanking.analyze(entry)
@@ -345,6 +358,20 @@ final class ClipboardCaptureCoordinator {
secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: sample.text),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
KeyboardExtensionMemoryTelemetry.record(
"clipboard.swallowDuplicate",
details: "changeCount=\(changeCount)"
)
return
}
// A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text.
clearSuggestion()
@@ -428,6 +455,10 @@ final class ClipboardCaptureCoordinator {
}
// One auto action per copy, even across keyboard close/reopen.
guard history.lastAutoRepliedChangeCount != changeCount else { return }
// Universal Clipboard re-announces one copy under several changeCounts,
// each a fresh "generation" here. Suppress identical content inside the
// window; a deliberate re-copy after it may trigger again.
guard !history.recentlyAutoReplied(text: newest.text) else { return }
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
@@ -450,6 +481,7 @@ final class ClipboardCaptureCoordinator {
AIClipboardSkillCatalog.emailReplySkill,
scene: nil,
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true,
insertLabelKey: "keyboard.assistant.insertReply"
)
@@ -459,7 +491,13 @@ final class ClipboardCaptureCoordinator {
if state.clipboardAutoTranslateEnabled,
ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis),
let translate = catalog.first(where: { $0.id == AIClipboardSkillCatalog.translateID }) {
fireAutoAction(translate, scene: nil, changeCount: changeCount, readOnlyResult: true)
fireAutoAction(
translate,
scene: nil,
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true
)
return
}
// 3. Auto-reply for any interpersonal message worth answering — a task,
@@ -480,6 +518,7 @@ final class ClipboardCaptureCoordinator {
sourceText: newest.text
),
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: false
)
}
@@ -491,11 +530,16 @@ final class ClipboardCaptureCoordinator {
_ skill: AIClipboardSkill,
scene: AIClipboardReplyScene?,
changeCount: Int,
sourceText: String,
readOnlyResult: Bool,
insertLabelKey: String = "keyboard.assistant.insertTranslation"
) {
// Mark handled before submitting so re-entrant publishes cannot double-fire.
history.lastAutoRepliedChangeCount = changeCount
history.markAutoReplied(text: sourceText, changeCount: changeCount)
KeyboardExtensionMemoryTelemetry.record(
"clipboard.autoFire",
details: "skill=\(skill.id) changeCount=\(changeCount)"
)
// Remember the keyboard to return to once the result is used or closed:
// the surface the user is actively on, or — when the keyboard opened
// straight onto voice — the one they last left, so finishing never
@@ -527,6 +571,10 @@ final class ClipboardCaptureCoordinator {
/// Only auto-present once per pasteboard generation, so dismissing the layer
/// does not immediately resurface it for the same copy.
private var lastGuidedChangeCount: Int?
/// Universal Clipboard re-announces one copy under several changeCounts, so
/// the content fingerprint + present time suppress those repeats too.
private var lastGuidedTextFingerprint: String?
private var lastGuidedAt: Date?
/// For users who never turned auto mode on: when a freshly copied message
/// reads as replyable, raise the clean full-keyboard guide instead of firing
@@ -548,6 +596,12 @@ final class ClipboardCaptureCoordinator {
lastGuidedChangeCount != changeCount else {
return
}
let textFingerprint = ClipboardHistoryPolicy.contentFingerprint(for: newest.text)
if lastGuidedTextFingerprint == textFingerprint,
let lastGuidedAt,
ClipboardHistoryPolicy.isRepeatSuppressed(firedAt: lastGuidedAt) {
return
}
guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest
@@ -567,6 +621,8 @@ final class ClipboardCaptureCoordinator {
)
guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return }
lastGuidedChangeCount = changeCount
lastGuidedTextFingerprint = textFingerprint
lastGuidedAt = Date()
state.clipboardOverlay = .autoReplyGuide
}
@@ -594,6 +650,7 @@ final class ClipboardCaptureCoordinator {
reply,
scene: scene,
changeCount: newest.changeCount ?? history.lastObservedChangeCount,
sourceText: newest.text,
readOnlyResult: false
)
}