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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user