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
@@ -126,4 +126,33 @@ final class ClipboardHistoryPolicyTests: XCTestCase {
)
XCTAssertEqual(tokens, ["great", "experience", "he", "writes"])
}
func testContentFingerprintIsStableAndTextSensitive() {
let first = ClipboardHistoryPolicy.contentFingerprint(for: "same message")
let second = ClipboardHistoryPolicy.contentFingerprint(for: "same message")
let other = ClipboardHistoryPolicy.contentFingerprint(for: "same message!")
XCTAssertEqual(first, second)
XCTAssertEqual(first.count, 64)
XCTAssertNotEqual(first, other)
}
func testRepeatSuppressionEndsAfterHintWindow() {
let firedAt = Date()
XCTAssertTrue(
ClipboardHistoryPolicy.isRepeatSuppressed(
firedAt: firedAt,
now: firedAt.addingTimeInterval(
ClipboardHistoryPolicy.aiHintEligibilitySeconds - 1
)
)
)
XCTAssertFalse(
ClipboardHistoryPolicy.isRepeatSuppressed(
firedAt: firedAt,
now: firedAt.addingTimeInterval(
ClipboardHistoryPolicy.aiHintEligibilitySeconds + 1
)
)
)
}
}
@@ -2,6 +2,7 @@
// OSGKeyboardTests
@testable import OSGKeyboardShared
import Combine
import XCTest
@MainActor
@@ -92,4 +93,103 @@ final class ClipboardHistoryStoreTests: XCTestCase {
XCTAssertTrue(ClipboardHistoryPolicy.encodedPayloadFitsLimit(store.entries))
XCTAssertLessThan(store.entries.count, ClipboardHistoryPolicy.maxEntries)
}
func testRecentlyAutoRepliedOnlyWithinWindowAndForSameText() {
let suiteName = "ClipboardHistoryStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
defer { suite.removePersistentDomain(forName: suiteName) }
let store = ClipboardHistoryStore(defaults: suite)
let firedAt = Date()
XCTAssertFalse(store.recentlyAutoReplied(text: "hello", now: firedAt))
store.markAutoReplied(text: "hello", changeCount: 7, at: firedAt)
// Universal Clipboard re-sync: same text seconds later stays suppressed.
XCTAssertTrue(
store.recentlyAutoReplied(text: "hello", now: firedAt.addingTimeInterval(5))
)
// A different copy inside the window still fires.
XCTAssertFalse(
store.recentlyAutoReplied(text: "different", now: firedAt.addingTimeInterval(5))
)
// A deliberate re-copy after the window fires again.
XCTAssertFalse(
store.recentlyAutoReplied(text: "hello", now: firedAt.addingTimeInterval(31))
)
XCTAssertEqual(store.lastAutoRepliedChangeCount, 7)
}
func testAutoReplyMarkersPersistAcrossStoreInstances() {
let suiteName = "ClipboardHistoryStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
defer { suite.removePersistentDomain(forName: suiteName) }
let firedAt = Date()
ClipboardHistoryStore(defaults: suite)
.markAutoReplied(text: "persisted", changeCount: 9, at: firedAt)
let reloaded = ClipboardHistoryStore(defaults: suite)
XCTAssertEqual(reloaded.lastAutoRepliedChangeCount, 9)
XCTAssertEqual(reloaded.lastAutoRepliedAt, firedAt)
XCTAssertTrue(
reloaded.recentlyAutoReplied(text: "persisted", now: firedAt.addingTimeInterval(1))
)
}
/// The lost-update this fixes: the host keeps a long-lived
/// `ClipboardHistoryStore` whose `entries` only refreshed on view appear.
/// History is one whole-array blob, so a host-side delete made against a
/// stale array wrote back entries the keyboard had just captured or removed.
func testPeerWriteReachesStaleStoreBeforeItOverwritesHistory() async throws {
let suiteName = "ClipboardHistoryStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
defer { suite.removePersistentDomain(forName: suiteName) }
let host = ClipboardHistoryStore(defaults: suite)
host.startObservingCrossProcessChanges()
host.ingest(rawText: "host-a", changeCount: 1)
host.ingest(rawText: "host-b", changeCount: 2)
// The keyboard extension captures a new copy while the host sits on a
// settings screen that loaded its list earlier.
let keyboard = ClipboardHistoryStore(defaults: suite)
keyboard.startObservingCrossProcessChanges()
let synced = expectation(description: "host store reloads after the peer write")
var cancellable: AnyCancellable?
cancellable = host.$entries.sink { entries in
if entries.contains(where: { $0.text == "keyboard-c" }) { synced.fulfill() }
}
keyboard.ingest(rawText: "keyboard-c", changeCount: 3)
await fulfillment(of: [synced], timeout: 2)
cancellable?.cancel()
// The host now deletes an old row. Before the fix this persisted its
// stale [host-b, host-a] and silently dropped keyboard-c.
let hostA = try XCTUnwrap(host.entries.first { $0.text == "host-a" })
host.remove(id: hostA.id)
XCTAssertEqual(
ClipboardHistoryStore(defaults: suite).entries.map(\.text),
["keyboard-c", "host-b"]
)
}
func testPersistBumpsRevisionAndReloadAdoptsIt() {
let suiteName = "ClipboardHistoryStoreTests.\(UUID().uuidString)"
let suite = UserDefaults(suiteName: suiteName)!
defer { suite.removePersistentDomain(forName: suiteName) }
let writer = ClipboardHistoryStore(defaults: suite)
XCTAssertEqual(suite.integer(forKey: ClipboardHistoryStore.Keys.revision), 0)
writer.ingest(rawText: "one", changeCount: 1)
let afterFirst = suite.integer(forKey: ClipboardHistoryStore.Keys.revision)
XCTAssertGreaterThan(afterFirst, 0)
writer.ingest(rawText: "two", changeCount: 2)
XCTAssertGreaterThan(
suite.integer(forKey: ClipboardHistoryStore.Keys.revision),
afterFirst
)
}
}
@@ -112,6 +112,22 @@ final class KeyboardExtensionMemoryReliefTests: XCTestCase {
XCTAssertEqual(levels(), [.critical, .critical, .critical])
}
func testReliefIsNeverRequestedFromInsideRelief() {
let box = LevelBox()
KeyboardExtensionMemoryTelemetry.begin(context: "test")
KeyboardExtensionMemoryTelemetry.stopSampling()
KeyboardExtensionMemoryTelemetry.reliefHandler = { level in
box.levels.append(level)
// Shedding records milestones, and recording samples memory again.
// Without the reentrancy gate this re-entered the handler until the
// main thread's stack ran out — the crash this test pins down.
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
return true
}
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(49)
XCTAssertEqual(box.levels, [.critical])
}
func testRepeatedHighFootprintShedsOnce() {
let (levels, _) = armedLevels()
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)