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
@@ -155,6 +155,22 @@ private struct URLSessionManagedASRWebSocketFactory: ManagedASRWebSocketFactory
}
}
/// Once-only gate for callback APIs that may invoke their handler more than
/// once (see `ping()`). The first `claim()` returns true; later calls return
/// false so the wrapped continuation is resumed exactly once.
private final class ResumeOnceGate: @unchecked Sendable {
private let lock = NSLock()
private var claimed = false
func claim() -> Bool {
lock.lock()
defer { lock.unlock() }
if claimed { return false }
claimed = true
return true
}
}
private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @unchecked Sendable {
private let task: URLSessionWebSocketTask
@@ -167,8 +183,14 @@ private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @uncheck
}
func ping() async throws {
// `URLSessionWebSocketTask.sendPing` can invoke its handler twice when
// the task is cancelled with a ping in flight (cancel path + pong
// timeout path). Resuming a CheckedContinuation twice traps (SIGTRAP),
// so the handler is funneled through a once-only gate.
let gate = ResumeOnceGate()
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
task.sendPing { error in
guard gate.claim() else { return }
if let error {
continuation.resume(throwing: error)
} else {