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
@@ -312,6 +312,9 @@ public struct AnalyticsRuntime: Sendable {
/// Main-app runtime. A bearer provider may be supplied by host-only account
/// code without making the shared framework depend on that code.
/// Production keyboard-extension processes must not construct this runtime
/// (or any other SQLite-backed analytics client): an open WAL connection is
/// killed as RunningBoard `0xdead10cc` when the keyboard hides.
public static func mainApp(
environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
@@ -341,36 +344,6 @@ public struct AnalyticsRuntime: Sendable {
)
}
/// Keyboard-extension runtime. This factory intentionally has no bearer
/// parameter, so extension uploads are anonymous by construction.
public static func keyboardExtension(
environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: AnalyticsUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
monotonicClock: any AnalyticsMonotonicClock = SystemAnalyticsMonotonicClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) -> Self {
Self(
surface: .keyboard,
environment: environment,
repositoryConfiguration: repositoryConfiguration,
uploadConfiguration: uploadConfiguration,
network: network,
bearerProvider: nil,
wallClock: wallClock,
monotonicClock: monotonicClock,
uuidGenerator: uuidGenerator,
random: random,
trigger: trigger,
logger: logger
)
}
public func setEnabled(_ enabled: Bool) async {
await repository.setEnabled(enabled)
}
@@ -20,6 +20,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let baseURL = "config.baseURL"
/// Legacy plaintext slot — migrated to Keychain on first read.
public static let apiKeyLegacy = "config.apiKey"
/// Marks that the one-shot legacy credential probe already ran for this
/// install, so steady-state `load()` never touches the Keychain.
public static let legacyCredentialMigrationAttempted =
"config.legacyCredentialMigrationAttempted"
public static let model = "config.model"
/// Cloud ASR provider — independent from polish `providerId`.
public static let asrProviderId = "config.asrProviderId"
@@ -435,11 +439,21 @@ public struct AppGroupConfiguration: Sendable, Equatable {
}
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
_ = resolveAPIKey(
defaults: defaults,
providerId: config.providerId,
preferICloudSync: config.settingsICloudSyncEnabled
)
// Gated to a single eager attempt per install: the keyboard extension
// runs load() on every config read, and a synchronous
// SecItemCopyMatching inside the appear/suspend window is a
// RunningBoard 0xdead10cc kill vector. A deferred attempt still happens
// lazily via `apiKey` / `asrApiKey` on first real use, so a migration
// skipped here (e.g. Keychain unavailable) is not lost.
let hasLegacyPlaintextKey = defaults.string(forKey: Keys.apiKeyLegacy)?.isEmpty == false
if hasLegacyPlaintextKey || !defaults.bool(forKey: Keys.legacyCredentialMigrationAttempted) {
defaults.set(true, forKey: Keys.legacyCredentialMigrationAttempted)
_ = resolveAPIKey(
defaults: defaults,
providerId: config.providerId,
preferICloudSync: config.settingsICloudSyncEnabled
)
}
// One-shot defaults for installs that predate explicit settings.
// Preserve the legacy engine choice, but use the current privacy-safe
@@ -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)
)
@@ -288,6 +288,9 @@ public final class TypingSessionController: ObservableObject {
/// in the document so we only clear suggestion state.
public func setLanguage(_ newLanguage: TypingInputLanguage) -> TypingOutput {
guard language != newLanguage else { return .none }
#if DEBUG
MemoryDeviceProbe.log("setLanguage.\(language.rawValue)->\(newLanguage.rawValue)")
#endif
var output = TypingOutput.none
if language == .chinese, !composition.preedit.isEmpty {
let raw = engine.flushPreedit()
@@ -57,6 +57,9 @@ public enum KeyboardExtensionMemoryTelemetry {
/// Highest level already relieved in the current pressure episode. Reset only
/// when the footprint falls back below `warningMB` — see `requestReliefIfNeeded`.
private static var lastReliefLevel = KeyboardExtensionMemoryBudget.Level.normal
/// True while `reliefHandler` is running, so relief can never be requested
/// from inside relief — see `requestReliefIfNeeded`.
private static var isRelieving = false
/// Poll interval once the startup burst is over. Sustained sampling is what
/// catches growth *between* milestones (a long clipboard session, a big
@@ -177,6 +180,9 @@ public enum KeyboardExtensionMemoryTelemetry {
if crossedLevel {
highestLevel = level
}
#if DEBUG
MemoryDeviceProbe.log("emit.\(stage).\(level.rawValue)")
#endif
// Shed BEFORE logging: at `.critical` we are ~12 MiB from the observed
// jetsam boundary and the log line is the less important half.
requestReliefIfNeeded(level: level, stage: stage)
@@ -234,11 +240,26 @@ public enum KeyboardExtensionMemoryTelemetry {
}
guard level == .high || level == .critical, let handler = reliefHandler else { return }
guard levelRank(level) > levelRank(lastReliefLevel) else { return }
// Shedding records a milestone at every teardown step, and recording
// samples the footprint again — which asks the same handler to shed
// from inside itself. `lastReliefLevel` cannot stop that, because it is
// only assigned once the handler returns. The nesting is unbounded and
// overflowed the main thread's stack instead of relieving anything.
guard !isRelieving else { return }
#if DEBUG
MemoryDeviceProbe.log("relief.fire.\(level.rawValue).\(stage)")
#endif
OSGDiag.log(
"extMemory relief level=\(level.rawValue) stage=\(stage) \(OSGDiag.memoryTag())",
category: "memory"
)
guard handler(level) else {
isRelieving = true
let relieved = handler(level)
isRelieving = false
#if DEBUG
MemoryDeviceProbe.log("relief.done.\(level.rawValue).relieved=\(relieved ? 1 : 0)")
#endif
guard relieved else {
OSGDiag.log(
"extMemory relief declined level=\(level.rawValue) stage=\(stage)",
category: "memory"
@@ -274,3 +295,55 @@ public enum KeyboardExtensionMemoryTelemetry {
}
}
}
#if DEBUG
/// DEBUG-only device probe. `log stream --device` is unavailable on this Mac and
/// tool-hosted tests can't run on a physical device, so the extension's real
/// jetsam footprint can't be read from the console. Instead we append every
/// memory sample to an App Group file and pull it back to the Mac:
///
/// ```
/// xcrun devicectl device copy from --device <udid> \
/// --domain-type appGroupDataContainer \
/// --domain-identifier group.com.osgkeyboard.shared \
/// --source memory-probe.log --destination /tmp/
/// ```
///
/// Each line: `<uptime>s <tag> foot=<MB> rss=<MB>`. Temporary — remove once the
/// footprint budget is re-baselined.
public enum MemoryDeviceProbe {
public static let fileName = "memory-probe.log"
/// devicectl can only pull from the container's Library/Documents/tmp, so
/// the probe lives under Library rather than the container root.
private static var fileURL: URL? {
guard let base = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: AppGroup.identifier)?
.appendingPathComponent("Library", isDirectory: true)
else { return nil }
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
return base.appendingPathComponent(fileName, isDirectory: false)
}
/// Appends one sample. Cheap enough for a short DEBUG run; not for shipping.
public static func log(_ tag: String) {
guard let url = fileURL else { return }
let snap = OSGDiag.memorySnapshot()
let line = String(
format: "%.2f %@ foot=%.1f rss=%.1f\n",
ProcessInfo.processInfo.systemUptime,
tag,
snap.physFootprintMB,
snap.rssMB
)
guard let data = line.data(using: .utf8) else { return }
if let handle = try? FileHandle(forWritingTo: url) {
handle.seekToEndOfFile()
handle.write(data)
try? handle.close()
} else {
try? data.write(to: url, options: .atomic)
}
}
}
#endif