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
@@ -1,172 +0,0 @@
// AnalyticsExtensionPrivacyTests.swift
// OSGKeyboardExtTests
//
// The keyboard analytics runtime is anonymous and emits only typed dimensions.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsExtensionPrivacyTests: XCTestCase {
func testKeyboardRuntimeRecordsWithoutAutomaticallyUploading() async throws {
let network = ExtensionAnalyticsNetwork()
let runtime = AnalyticsRuntime.keyboardExtension(
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"),
repositoryConfiguration: AnalyticsRepositoryConfiguration(
databaseURL: try temporaryDatabaseURL()
),
uploadConfiguration: AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/v1/events")!
),
network: network,
wallClock: ExtensionAnalyticsClock(),
monotonicClock: ExtensionAnalyticsMonotonicClock(),
uuidGenerator: ExtensionAnalyticsUUIDGenerator(),
random: ExtensionAnalyticsRandomGenerator()
)
runtime.client.recordKeyboardActivated()
for _ in 0..<200 {
if await runtime.repository.debugSnapshot().pendingEvents.count == 1 {
break
}
try await Task.sleep(for: .milliseconds(10))
}
try await Task.sleep(for: .milliseconds(50))
let pendingSnapshot = await runtime.repository.debugSnapshot()
let requests = await network.requests()
XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1)
XCTAssertTrue(requests.isEmpty)
}
func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws {
let databaseURL = try temporaryDatabaseURL()
let network = ExtensionAnalyticsNetwork()
let runtime = AnalyticsRuntime.keyboardExtension(
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"),
repositoryConfiguration: AnalyticsRepositoryConfiguration(
databaseURL: databaseURL
),
uploadConfiguration: AnalyticsUploadConfiguration(
endpoint: URL(string: "https://analytics.test/v1/events")!
),
network: network,
wallClock: ExtensionAnalyticsClock(),
monotonicClock: ExtensionAnalyticsMonotonicClock(),
uuidGenerator: ExtensionAnalyticsUUIDGenerator(),
random: ExtensionAnalyticsRandomGenerator()
)
runtime.client.recordKeyboardActivated()
for _ in 0..<200 {
if await runtime.repository.debugSnapshot().pendingEvents.count == 1 {
break
}
try await Task.sleep(for: .milliseconds(10))
}
let pendingSnapshot = await runtime.repository.debugSnapshot()
XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1)
await runtime.uploadCoordinator.uploadAvailableEvents()
let requests = await network.requests()
let request = try XCTUnwrap(requests.first)
XCTAssertEqual(requests.count, 1)
XCTAssertNil(
request.headers.first {
$0.key.caseInsensitiveCompare("Authorization") == .orderedSame
}
)
let upload = try JSONDecoder().decode(
AnalyticsUploadRequest.self,
from: request.body
)
XCTAssertEqual(upload.events.count, 1)
XCTAssertEqual(upload.events.first?.surface, .keyboard)
XCTAssertEqual(upload.events.first?.eventType, .keyboardActivated)
let root = try XCTUnwrap(
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
)
XCTAssertEqual(Set(root.keys), ["installationId", "events"])
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
let event = try XCTUnwrap(eventObjects.first)
XCTAssertFalse(event.keys.contains("installationId"))
XCTAssertFalse(event.keys.contains("properties"))
XCTAssertFalse(event.keys.contains("text"))
XCTAssertFalse(event.keys.contains("prompt"))
XCTAssertFalse(event.keys.contains("transcript"))
}
private func temporaryDatabaseURL() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("OSGKeyboardExtensionAnalyticsTests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent("analytics.sqlite3")
}
}
private struct ExtensionAnalyticsClock: AnalyticsWallClock {
func now() -> Date {
Date(timeIntervalSince1970: 1_700_000_000)
}
}
private struct ExtensionAnalyticsMonotonicClock: AnalyticsMonotonicClock {
func nowNanoseconds() -> UInt64 {
0
}
}
private final class ExtensionAnalyticsUUIDGenerator:
AnalyticsUUIDGenerating,
@unchecked Sendable {
private let lock = NSLock()
private var value = 1
func makeUUID() -> UUID {
lock.withLock {
defer { value += 1 }
return UUID(
uuidString: String(
format: "00000000-0000-0000-0000-%012x",
value
)
)!
}
}
}
private struct ExtensionAnalyticsRandomGenerator: AnalyticsRandomGenerating {
func next(upperBound: UInt64) -> UInt64 {
0
}
}
private actor ExtensionAnalyticsNetwork: AnalyticsNetworking {
private var captured: [AnalyticsHTTPRequest] = []
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
captured.append(request)
let upload = try JSONDecoder().decode(
AnalyticsUploadRequest.self,
from: request.body
)
return AnalyticsHTTPResponse(
statusCode: 200,
headers: [:],
body: Data(
#"{"accepted":\#(upload.events.count),"replayed":0}"#.utf8
)
)
}
func requests() -> [AnalyticsHTTPRequest] {
captured
}
}
@@ -188,6 +188,47 @@ final class ClipboardSuggestionLifecycleTests: XCTestCase {
XCTAssertNil(state.clipboardSuggestionText)
}
func testSameTextUnderNewChangeCountIsSwallowedAsSameLogicalCopy() {
copy("meeting at 3pm")
coordinator.captureIfNeeded()
let firstEntry = history.newestEntry
// Universal Clipboard re-announce: same text, new changeCount.
pasteboard.setText("meeting at 3pm", changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertEqual(history.entries.count, 1)
XCTAssertEqual(history.newestEntry?.id, firstEntry?.id)
XCTAssertEqual(history.newestEntry?.createdAt, firstEntry?.createdAt)
XCTAssertEqual(history.lastObservedChangeCount, 12)
XCTAssertEqual(state.clipboardSuggestionText, "meeting at 3pm")
}
func testRecopyAfterDifferentContentCreatesNewGeneration() {
copy("first message")
coordinator.captureIfNeeded()
copy("second message")
coordinator.captureIfNeeded()
copy("first message")
coordinator.captureIfNeeded()
XCTAssertEqual(history.entries.map(\.text), ["first message", "second message"])
XCTAssertEqual(state.clipboardSuggestionText, "first message")
}
func testDismissedSuggestionStaysHiddenAcrossUniversalClipboardResync() {
copy("dismiss me")
coordinator.captureIfNeeded()
coordinator.dismissSuggestion()
pasteboard.setText("dismiss me", changeCount: 12)
coordinator.captureIfNeeded()
XCTAssertNil(state.clipboardSuggestionText)
XCTAssertEqual(history.entries.count, 1)
}
private func copy(_ text: String) {
pasteboard.setText(text, changeCount: pasteboard.changeCount + 1)
}