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 -1
View File
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format ### Version format
The current source-of-truth version is **2.2.0 (build 96)**. Releases use stable SemVer: The current source-of-truth version is **2.1.0 (build 110)**. Releases use stable SemVer:
| Field | File | Rule | | Field | File | Rule |
|-------|------|------| |-------|------|------|
+8
View File
@@ -7,7 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Changed
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
### Fixed ### Fixed
- **Clipboard history reverting deletes**: history is one shared blob, and the main App kept an in-memory copy that only refreshed when a screen appeared — so a delete made there could write back entries the keyboard had just captured or removed, and a clear done in the keyboard could be undone entirely. Both processes now announce their writes to each other, and the main App re-reads history whenever it returns to the foreground. / **剪贴板历史撤销删除**:历史记录是整块共享数据,而主 App 仅在页面出现时刷新内存副本——因此在主 App 中删除一条,可能把键盘刚采集或刚删除的条目重新写回,在键盘里执行的清空也可能被整体撤销。现在两端写入时会互相通知,主 App 每次回到前台也会重新读取历史。
- **Keyboard crash loop on memory relief**: recording a memory milestone could request relief while relief was already running, and the two sides called each other until the keyboard extension exhausted its stack (SIGSEGV grouped under `KeyboardViewController.viewDidLoad`). Reentrancy guards on both the controller and the telemetry side break the cycle. / **键盘内存降级递归崩溃**:记录内存里程碑时可能再次请求降级,两侧互相回调直至扩展栈溢出(崩溃聚合在 `KeyboardViewController.viewDidLoad`)。控制器与遥测两侧均加重入保护,打断循环。
- **Keyboard killed while reading credentials**: the extension resolved API keys synchronously on the main thread at every config read — including boot and Flow session setup — and a Keychain read in flight while the system suspends the keyboard is killed as `0xdead10cc`. Legacy credential migration is now attempted once per install instead of on every load, credential availability refreshes asynchronously after appear, and the onboarding gate reads the Keychain off the main thread. / **键盘读取凭据时被系统终止**:扩展此前每次读取配置都会在主线程同步解析 API 密钥(含启动与 Flow 会话建立),而挂起窗口内进行中的钥匙串读取会被系统以 `0xdead10cc` 查杀。旧凭据迁移改为每次安装仅尝试一次,凭据可用性改为键盘出现后异步刷新,入门引导门控的钥匙串读取移出主线程。
- **Managed ASR connect crash**: `URLSessionWebSocketTask.sendPing` can invoke its completion twice when the task is cancelled mid-ping, so opening a managed Volcengine session could trap on the second continuation resume (SIGTRAP). The handler now passes through a once-only gate. / **托管语音识别建连崩溃**:`URLSessionWebSocketTask.sendPing` 在 ping 在途时被取消会回调两次,打开托管火山引擎会话时第二次恢复 continuation 会触发 SIGTRAP 断言。回调现经一次性闸门过滤。
- **Auto-reply storms from Universal Clipboard**: copying on a nearby Mac re-announces one copy under several pasteboard changeCounts, and each re-announcement re-armed auto reply/translate and the auto-reply guide — including on every later keyboard open, because re-ingesting the same text kept refreshing its 30-second hint window. A capture whose text is identical to the newest history entry is now treated as the same logical copy (bookkeeping only, no re-analysis), and auto actions additionally dedupe on a content fingerprint within a 30-second window. The same text fires once; copying something else and then re-copying the text still triggers. / **跨端剪贴板引发自动回复风暴**:在附近 Mac 上复制一次会被系统以多个 changeCount 重复宣告,且每次宣告都会重新武装自动回复/翻译与自动回复引导——由于同文本被重复入库并不断刷新 30 秒提示窗口,之后每次打开键盘也会再触发。现在,与最新历史条目文本相同的采集会被视为同一次复制(仅更新记账,不重新分析),自动动作再叠加内容指纹 + 30 秒窗口去重。同一文本只触发一次;复制其他内容后再重新复制该文本仍可触发。
- **Style card captions**: resolve each built-in style’s short description through the in-app language override, so switching App Language updates the caption instead of showing a localization key or the system language. / **风格卡片说明**:内置风格的说明小字改为走应用内语言设置;切换 App 语言后说明会跟着变,而不再显示本地化 key 或跟随系统语言。 - **Style card captions**: resolve each built-in style’s short description through the in-app language override, so switching App Language updates the caption instead of showing a localization key or the system language. / **风格卡片说明**:内置风格的说明小字改为走应用内语言设置;切换 App 语言后说明会跟着变,而不再显示本地化 key 或跟随系统语言。
## [2.2.0] - 2026-09-18 ## [2.2.0] - 2026-09-18
@@ -3,6 +3,8 @@
// //
// Host-only analytics lifecycle. The shared module owns the wire contract, // Host-only analytics lifecycle. The shared module owns the wire contract,
// SQLite queue and uploader; this type only connects iOS lifecycle signals. // SQLite queue and uploader; this type only connects iOS lifecycle signals.
// The keyboard extension must never open these databases — an open WAL
// connection survives hide and is killed as RunningBoard `0xdead10cc`.
import BackgroundTasks import BackgroundTasks
import Foundation import Foundation
@@ -65,6 +65,9 @@ struct AssistantKeyboardUITestHarness: View {
} }
.onAppear { .onAppear {
configure(width: proxy.size.width) configure(width: proxy.size.width)
#if DEBUG
startVoiceMemorySampling()
#endif
} }
.onChange(of: proxy.size.width) { _, width in .onChange(of: proxy.size.width) { _, width in
configureLayout(width: width) configureLayout(width: width)
@@ -83,6 +86,22 @@ struct AssistantKeyboardUITestHarness: View {
colorScheme == .dark ? Palette.dark.background : Palette.light.background colorScheme == .dark ? Palette.dark.background : Palette.light.background
} }
#if DEBUG
/// DEBUG-only: samples the host process footprint while the voice surface
/// renders, so the Liquid Glass cost can be read off the simulator's App
/// Group container directly (no device log capture needed).
private func startVoiceMemorySampling() {
MemoryDeviceProbe.log("harness.voice.beforeRender")
Task { @MainActor in
for i in 0..<25 {
try? await Task.sleep(for: .milliseconds(200))
MemoryDeviceProbe.log("harness.voice.t\(i)")
}
MemoryDeviceProbe.log("harness.voice.done")
}
}
#endif
private func configure(width: CGFloat) { private func configure(width: CGFloat) {
configureLayout(width: width) configureLayout(width: width)
guard !configured else { return } guard !configured else { return }
+7
View File
@@ -391,6 +391,13 @@ struct MainAppRoot: View {
/// Starts foreground-only services once per active transition. Rime yields /// Starts foreground-only services once per active transition. Rime yields
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules. /// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
private func activateForegroundServices(reason: String) { private func activateForegroundServices(reason: String) {
// Clipboard history is a whole-array blob shared with the keyboard, and
// a suspended app receives no Darwin notifications — so everything the
// keyboard captured or deleted while we were backgrounded is visible
// only after this reload. Skipping it leaves every host view holding a
// stale array that silently reverts those writes on its next delete.
ClipboardHistoryStore.shared.startObservingCrossProcessChanges()
ClipboardHistoryStore.shared.reload()
scheduleRimeDeployment(reason: reason) scheduleRimeDeployment(reason: reason)
// Automatically arm the low-profile PiP on every host open. // Automatically arm the low-profile PiP on every host open.
// Capture/ASR remain lazy and start only on an actual mic press. // Capture/ASR remain lazy and start only on an actual mic press.
+27 -20
View File
@@ -51,6 +51,8 @@ public final class KeyboardViewController: UIInputViewController {
/// it to tell "the user is mid-sentence" from "the typing surface is merely /// it to tell "the user is mid-sentence" from "the typing surface is merely
/// on screen" — see `isMidTypingBurst`. /// on screen" — see `isMidTypingBurst`.
private var lastTypingActivityUptime: TimeInterval = -1 private var lastTypingActivityUptime: TimeInterval = -1
/// Reentrancy guard for `shedHeavyResources` — see the comment there.
private var isSheddingHeavyResources = false
private var hosting: UIHostingController<KeyboardSurfaceRoot>? private var hosting: UIHostingController<KeyboardSurfaceRoot>?
private var keyboardHeightConstraint: NSLayoutConstraint? private var keyboardHeightConstraint: NSLayoutConstraint?
@@ -69,9 +71,6 @@ public final class KeyboardViewController: UIInputViewController {
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// Coalesces host-document refreshes after mutations issued by this keyboard. /// Coalesces host-document refreshes after mutations issued by this keyboard.
private var assistantFieldActionRefreshTask: Task<Void, Never>? private var assistantFieldActionRefreshTask: Task<Void, Never>?
/// One random ID per keyboard presentation. The repository splits this ID
/// into independent UTC-day fragments when a presentation crosses midnight.
private var keyboardUsageSessionID = UUID()
/// UIKit can reuse this controller across host apps. Keep document-scoped /// UIKit can reuse this controller across host apps. Keep document-scoped
/// candidate state separate from the heavy typing-engine lifetime. /// candidate state separate from the heavy typing-engine lifetime.
private var isKeyboardPresentationActive = false private var isKeyboardPresentationActive = false
@@ -205,9 +204,13 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.refreshSessionState() flowCoordinator.refreshSessionState()
// Installed last: the handler tears down surfaces, so it must not be // Installed last: the handler tears down surfaces, so it must not be
// reachable until the whole controller is wired up. // reachable until the whole controller is wired up.
KeyboardExtensionMemoryTelemetry.reliefHandler = { [weak self] level in //
self?.shedHeavyResources(level: level, reason: "budget") ?? false // 临时停用主动内存降级("自杀"逻辑):真机实测语音界面正常工作就要
} // 68-88 MB,远超 48 MiB critical,且诊断里没有任何纯内存 jetsam 记录——
// 这套阈值一直在防一个不会发生的杀,代价是跳语音和 Rime 反复拆建。
// 内存遥测的采样/记录保留(供后续压力测试校准阈值);系统真正的内存警告
// didReceiveMemoryWarning 仍保留作兜底。量出真实 jetsam 上限后再恢复。
KeyboardExtensionMemoryTelemetry.reliefHandler = nil
recordMemory( recordMemory(
"KVC.viewDidLoad.done", "KVC.viewDidLoad.done",
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) " details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
@@ -226,7 +229,6 @@ public final class KeyboardViewController: UIInputViewController {
isKeyboardPresentationActive = false isKeyboardPresentationActive = false
typingDocumentPresentationID = nil typingDocumentPresentationID = nil
typingSessionStorage?.endDocumentPresentation() typingSessionStorage?.endDocumentPresentation()
AnalyticsExtensionService.shared.keyboardWillDisappear()
assistantFieldActionRefreshTask?.cancel() assistantFieldActionRefreshTask?.cancel()
assistantFieldActionRefreshTask = nil assistantFieldActionRefreshTask = nil
clipboardCapture?.keyboardWillDisappear() clipboardCapture?.keyboardWillDisappear()
@@ -335,10 +337,6 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) { public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated) super.viewDidAppear(animated)
keyboardUsageSessionID = UUID()
AnalyticsExtensionService.shared.recordPresentation(
hasFullAccess: hasFullAccess
)
OSGDiag.log( OSGDiag.log(
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())", "KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot" category: "boot"
@@ -460,6 +458,17 @@ public final class KeyboardViewController: UIInputViewController {
// Relief can be requested from the boot sampler before `installServices` // Relief can be requested from the boot sampler before `installServices`
// has run; nothing below is safe to touch until the view is loaded. // has run; nothing below is safe to touch until the view is loaded.
guard isViewLoaded, flowCoordinator != nil else { return false } guard isViewLoaded, flowCoordinator != nil else { return false }
// Every step below records a memory milestone, and a milestone can ask
// for relief. Telemetry gates that loop on its side; this guard also
// covers `didReceiveMemoryWarning`, which calls in directly without
// going through the sampler. Reporting `false` keeps the level armed so
// the outer shed's outcome decides the episode.
guard !isSheddingHeavyResources else { return false }
isSheddingHeavyResources = true
defer { isSheddingHeavyResources = false }
#if DEBUG
MemoryDeviceProbe.log("shed.\(level.rawValue).reason=\(reason).surface=\(state.surface.rawValue).midBurst=\(isMidTypingBurst ? 1 : 0)")
#endif
OSGDiag.log( OSGDiag.log(
"KVC.shed level=\(level.rawValue) reason=\(reason) " "KVC.shed level=\(level.rawValue) reason=\(reason) "
+ "surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())", + "surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
@@ -816,6 +825,9 @@ public final class KeyboardViewController: UIInputViewController {
category: "boot" category: "boot"
) )
state.surface = surface state.surface = surface
#if DEBUG
MemoryDeviceProbe.log("applySurface.\(surface.rawValue)")
#endif
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)") recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
if surface == .typing { if surface == .typing {
activateTypingPresentationIfNeeded() activateTypingPresentationIfNeeded()
@@ -969,19 +981,14 @@ public final class KeyboardViewController: UIInputViewController {
/// echo this keyboard's own document mutation through `textDidChange`. /// echo this keyboard's own document mutation through `textDidChange`.
private func insertTextIntoDocument( private func insertTextIntoDocument(
_ text: String, _ text: String,
source: KeyboardTextInsertionSource source _: KeyboardTextInsertionSource
) { ) {
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
textDocumentProxy.insertText(text) textDocumentProxy.insertText(text)
if source.contributesToKeyboardUsage { // Usage analytics stay in the host app. Recording SQLite from this
let counts = KeyboardUsageCharacterClassifier.classify(text) // process held WAL locks across keyboard hide and triggered
AnalyticsExtensionService.shared.keyboardUsageRecorder // RunningBoard `0xdead10cc` kills.
.recordManualKeyboardCounts(
counts,
sessionID: keyboardUsageSessionID
)
}
// A non-empty insertion makes content actions available immediately. // A non-empty insertion makes content actions available immediately.
// The next host callback remains the authoritative correction. // The next host callback remains the authoritative correction.
refreshAssistantFieldAction(hasTextOverride: true) refreshAssistantFieldAction(hasTextOverride: true)
@@ -1,58 +0,0 @@
// AnalyticsExtensionService.swift
// OSGKeyboard · Keyboard Extension
//
// The extension only records into durable queues. The host app is the sole
// network uploader, avoiding extension-lifecycle and cross-process lock races.
import Foundation
import OSGKeyboardShared
final class AnalyticsExtensionService: Sendable {
static let shared = AnalyticsExtensionService()
let client: any AnalyticsClient
let keyboardUsageRecorder: any KeyboardUsageRecording
private init() {
let environment = Self.environment
let runtime = AnalyticsRuntime.keyboardExtension(
environment: environment,
uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint)
)
let keyboardUsageRuntime = KeyboardUsageRuntime(
environment: environment,
analyticsRepository: runtime.repository,
uploadConfiguration: KeyboardUsageUploadConfiguration(
endpoint: Self.keyboardUsageEndpoint
)
)
client = runtime.client
keyboardUsageRecorder = keyboardUsageRuntime.recorder
}
func recordPresentation(hasFullAccess _: Bool) {
client.recordSessionActivity()
client.recordKeyboardActivated()
}
func keyboardWillDisappear() {}
private static let endpoint = URL(
string: "https://account.osglab.com/v1/analytics/events"
)!
private static let keyboardUsageEndpoint = URL(
string: "https://account.osglab.com/v1/analytics/keyboard-usage"
)!
private static var environment: AnalyticsEnvironment {
let appVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String ?? "unknown"
let version = ProcessInfo.processInfo.operatingSystemVersion
return AnalyticsEnvironment(
appVersion: appVersion,
osVersion: "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
)
}
}
@@ -47,7 +47,11 @@ public struct AppGroupPersistor {
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
applySkillSnapshot(store: store, into: state) applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state) // Credential availability is intentionally NOT resolved here: it needs
// synchronous Keychain reads, which are a RunningBoard 0xdead10cc kill
// vector on the extension's main thread inside the appear/suspend
// window. KeyboardState's optimistic defaults stand until
// `KeyboardConfigSync.refreshCredentialsFromKeychain()` lands.
#if DEBUG #if DEBUG
// Log only credential availability and the base URL origin. Never put // Log only credential availability and the base URL origin. Never put
@@ -106,7 +110,7 @@ public struct AppGroupPersistor {
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
applySkillSnapshot(store: store, into: state) applySkillSnapshot(store: store, into: state)
state.keyboardHapticIntensity = store.keyboardHapticIntensity state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state) // Credential availability is refreshed asynchronously — see load(into:).
} }
/// Resolve once outside SwiftUI body evaluation. Assigning the complete /// Resolve once outside SwiftUI body evaluation. Assigning the complete
@@ -154,17 +158,22 @@ public struct AppGroupPersistor {
) )
} }
/// Applies a credential snapshot that was resolved OFF the main actor
/// (see `KeyboardConfigSync.refreshCredentialsFromKeychain`). This method
/// itself performs no Keychain access.
///
/// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when /// Cloud without ASR/LLM keys blocks the mic. Local ASR still works when
/// the polish key is missing — show a soft tip above the mic instead. /// the polish key is missing — show a soft tip above the mic instead.
private func applyAPIKeyAvailability( public func applyCredentialAvailability(
store: AppGroupStore, isPolishKeyMissing: Bool,
isCloudAPIKeyMissingForVoiceInput: Bool,
into state: KeyboardViewController.State into state: KeyboardViewController.State
) { ) {
state.aiServiceAvailable = !store.isPolishKeyMissing state.aiServiceAvailable = !isPolishKeyMissing
if store.isCloudAPIKeyMissingForVoiceInput { if isCloudAPIKeyMissingForVoiceInput {
state.micDisabled = true state.micDisabled = true
state.micDisabledHint = ExtL10n.string("keyboard.mic.disabled.missingApiKey") state.micDisabledHint = ExtL10n.string("keyboard.mic.disabled.missingApiKey")
} else if store.isPolishKeyMissing { } else if isPolishKeyMissing {
state.micDisabled = false state.micDisabled = false
state.micDisabledHint = ExtL10n.string("keyboard.mic.hint.missingPolishApiKey") state.micDisabledHint = ExtL10n.string("keyboard.mic.hint.missingPolishApiKey")
} else { } else {
@@ -65,6 +65,9 @@ final class ClipboardCaptureCoordinator {
self.history = history self.history = history
self.semanticRanking = semanticRanking self.semanticRanking = semanticRanking
self.pasteboard = pasteboard self.pasteboard = pasteboard
// The host can delete or clear history while this extension is alive;
// without this the next capture writes our stale array back and undoes it.
history.startObservingCrossProcessChanges()
snapshotObserver = semanticRanking.$snapshot snapshotObserver = semanticRanking.$snapshot
.sink { [weak self] snapshot in .sink { [weak self] snapshot in
// `@Published` fires in willSet, so the store's property is not // `@Published` fires in willSet, so the store's property is not
@@ -267,12 +270,11 @@ final class ClipboardCaptureCoordinator {
return return
} }
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
// A new generation replaces any previous transient suggestion, // A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text. // including generations that contain no acceptable text.
clearSuggestion() clearSuggestion()
// Prefer hasStrings peek before reading body (reduces empty reads).
guard pasteboard.hasStrings else {
semanticRanking.clear() semanticRanking.clear()
history.lastObservedChangeCount = changeCount history.lastObservedChangeCount = changeCount
return return
@@ -284,6 +286,17 @@ final class ClipboardCaptureCoordinator {
if isCurrentGeneration { if isCurrentGeneration {
return return
} }
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: raw),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
return
}
// A genuinely new generation replaces any previous transient suggestion.
clearSuggestion()
semanticRanking.clear() semanticRanking.clear()
if let entry = history.ingest(rawText: raw, changeCount: changeCount) { if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
semanticRanking.analyze(entry) semanticRanking.analyze(entry)
@@ -345,6 +358,20 @@ final class ClipboardCaptureCoordinator {
secureFieldSuppressedChangeCount = nil secureFieldSuppressedChangeCount = nil
let isCurrentGeneration = changeCount == history.lastObservedChangeCount let isCurrentGeneration = changeCount == history.lastObservedChangeCount
// Universal Clipboard re-announces one copy under several changeCounts.
// Text identical to the newest entry is the same logical copy: update
// bookkeeping only — re-ingesting would refresh the entry's createdAt
// and keep the AI-hint window (and auto triggers) alive indefinitely.
if let text = ClipboardHistoryPolicy.acceptedText(from: sample.text),
text == history.newestEntry?.text {
history.lastObservedChangeCount = changeCount
KeyboardExtensionMemoryTelemetry.record(
"clipboard.swallowDuplicate",
details: "changeCount=\(changeCount)"
)
return
}
// A new generation replaces any previous transient suggestion, // A new generation replaces any previous transient suggestion,
// including generations that contain no acceptable text. // including generations that contain no acceptable text.
clearSuggestion() clearSuggestion()
@@ -428,6 +455,10 @@ final class ClipboardCaptureCoordinator {
} }
// One auto action per copy, even across keyboard close/reopen. // One auto action per copy, even across keyboard close/reopen.
guard history.lastAutoRepliedChangeCount != changeCount else { return } guard history.lastAutoRepliedChangeCount != changeCount else { return }
// Universal Clipboard re-announces one copy under several changeCounts,
// each a fresh "generation" here. Suppress identical content inside the
// window; a deliberate re-copy after it may trigger again.
guard !history.recentlyAutoReplied(text: newest.text) else { return }
guard AIHintPool.isClipboardSkillWindowActive( guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled, clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest newestClipboard: newest
@@ -450,6 +481,7 @@ final class ClipboardCaptureCoordinator {
AIClipboardSkillCatalog.emailReplySkill, AIClipboardSkillCatalog.emailReplySkill,
scene: nil, scene: nil,
changeCount: changeCount, changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true, readOnlyResult: true,
insertLabelKey: "keyboard.assistant.insertReply" insertLabelKey: "keyboard.assistant.insertReply"
) )
@@ -459,7 +491,13 @@ final class ClipboardCaptureCoordinator {
if state.clipboardAutoTranslateEnabled, if state.clipboardAutoTranslateEnabled,
ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis), ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis),
let translate = catalog.first(where: { $0.id == AIClipboardSkillCatalog.translateID }) { let translate = catalog.first(where: { $0.id == AIClipboardSkillCatalog.translateID }) {
fireAutoAction(translate, scene: nil, changeCount: changeCount, readOnlyResult: true) fireAutoAction(
translate,
scene: nil,
changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: true
)
return return
} }
// 3. Auto-reply for any interpersonal message worth answering — a task, // 3. Auto-reply for any interpersonal message worth answering — a task,
@@ -480,6 +518,7 @@ final class ClipboardCaptureCoordinator {
sourceText: newest.text sourceText: newest.text
), ),
changeCount: changeCount, changeCount: changeCount,
sourceText: newest.text,
readOnlyResult: false readOnlyResult: false
) )
} }
@@ -491,11 +530,16 @@ final class ClipboardCaptureCoordinator {
_ skill: AIClipboardSkill, _ skill: AIClipboardSkill,
scene: AIClipboardReplyScene?, scene: AIClipboardReplyScene?,
changeCount: Int, changeCount: Int,
sourceText: String,
readOnlyResult: Bool, readOnlyResult: Bool,
insertLabelKey: String = "keyboard.assistant.insertTranslation" insertLabelKey: String = "keyboard.assistant.insertTranslation"
) { ) {
// Mark handled before submitting so re-entrant publishes cannot double-fire. // Mark handled before submitting so re-entrant publishes cannot double-fire.
history.lastAutoRepliedChangeCount = changeCount history.markAutoReplied(text: sourceText, changeCount: changeCount)
KeyboardExtensionMemoryTelemetry.record(
"clipboard.autoFire",
details: "skill=\(skill.id) changeCount=\(changeCount)"
)
// Remember the keyboard to return to once the result is used or closed: // Remember the keyboard to return to once the result is used or closed:
// the surface the user is actively on, or — when the keyboard opened // the surface the user is actively on, or — when the keyboard opened
// straight onto voice — the one they last left, so finishing never // straight onto voice — the one they last left, so finishing never
@@ -527,6 +571,10 @@ final class ClipboardCaptureCoordinator {
/// Only auto-present once per pasteboard generation, so dismissing the layer /// Only auto-present once per pasteboard generation, so dismissing the layer
/// does not immediately resurface it for the same copy. /// does not immediately resurface it for the same copy.
private var lastGuidedChangeCount: Int? private var lastGuidedChangeCount: Int?
/// Universal Clipboard re-announces one copy under several changeCounts, so
/// the content fingerprint + present time suppress those repeats too.
private var lastGuidedTextFingerprint: String?
private var lastGuidedAt: Date?
/// For users who never turned auto mode on: when a freshly copied message /// For users who never turned auto mode on: when a freshly copied message
/// reads as replyable, raise the clean full-keyboard guide instead of firing /// reads as replyable, raise the clean full-keyboard guide instead of firing
@@ -548,6 +596,12 @@ final class ClipboardCaptureCoordinator {
lastGuidedChangeCount != changeCount else { lastGuidedChangeCount != changeCount else {
return return
} }
let textFingerprint = ClipboardHistoryPolicy.contentFingerprint(for: newest.text)
if lastGuidedTextFingerprint == textFingerprint,
let lastGuidedAt,
ClipboardHistoryPolicy.isRepeatSuppressed(firedAt: lastGuidedAt) {
return
}
guard AIHintPool.isClipboardSkillWindowActive( guard AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: state.clipboardHistoryEnabled, clipboardHistoryEnabled: state.clipboardHistoryEnabled,
newestClipboard: newest newestClipboard: newest
@@ -567,6 +621,8 @@ final class ClipboardCaptureCoordinator {
) )
guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return } guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return }
lastGuidedChangeCount = changeCount lastGuidedChangeCount = changeCount
lastGuidedTextFingerprint = textFingerprint
lastGuidedAt = Date()
state.clipboardOverlay = .autoReplyGuide state.clipboardOverlay = .autoReplyGuide
} }
@@ -594,6 +650,7 @@ final class ClipboardCaptureCoordinator {
reply, reply,
scene: scene, scene: scene,
changeCount: newest.changeCount ?? history.lastObservedChangeCount, changeCount: newest.changeCount ?? history.lastObservedChangeCount,
sourceText: newest.text,
readOnlyResult: false readOnlyResult: false
) )
} }
@@ -66,6 +66,7 @@ final class KeyboardConfigSync {
case .loaded: case .loaded:
OSGLog.keyboardExt.info("config loaded") OSGLog.keyboardExt.info("config loaded")
syncOnboardingStateFromAppGroup() syncOnboardingStateFromAppGroup()
refreshCredentialsFromKeychain()
return .loaded return .loaded
case .unavailable: case .unavailable:
state.phase = .error( state.phase = .error(
@@ -83,15 +84,52 @@ final class KeyboardConfigSync {
) )
// Host may complete (or reset) onboarding while the extension stays alive. // Host may complete (or reset) onboarding while the extension stays alive.
syncOnboardingStateFromAppGroup() syncOnboardingStateFromAppGroup()
refreshCredentialsFromKeychain()
}
/// Resolves credential availability from the Keychain OFF the main actor,
/// then applies it to `state`. The extension's main thread never performs
/// a synchronous SecItemCopyMatching: one in flight while the system
/// suspends the keyboard is a RunningBoard 0xdead10cc kill vector.
/// Until the first refresh lands, `KeyboardState`'s optimistic defaults
/// (mic enabled, AI available) stand; a mis-gated mic corrects itself
/// within a beat of keyboard appear.
func refreshCredentialsFromKeychain() {
Task.detached { [weak self] in
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
let polishKeyMissing = store.isPolishKeyMissing
let cloudVoiceKeyMissing = store.isCloudAPIKeyMissingForVoiceInput
await MainActor.run {
guard let self else { return }
self.persistor.applyCredentialAvailability(
isPolishKeyMissing: polishKeyMissing,
isCloudAPIKeyMissingForVoiceInput: cloudVoiceKeyMissing,
into: self.state
)
}
}
} }
/// Mirrors host-app onboarding completion for the mic gate only. /// Mirrors host-app onboarding completion for the mic gate only.
/// Does not sync `onboardingPage` — page flow is host-app exclusive. /// Does not sync `onboardingPage` — page flow is host-app exclusive.
func syncOnboardingStateFromAppGroup() { func syncOnboardingStateFromAppGroup() {
let store = AppGroupStore() let store = AppGroupStore()
// Keychain fallback: a reboot must not resurrect the mic gate when // Synchronous reads stay defaults-only on the main thread (0xdead10cc
// App Group transiently reads empty. // window). The Keychain fallback — a reboot must not resurrect the mic
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding() // gate when App Group transiently reads empty — resolves asynchronously
// and only when the App Group value looks incomplete.
let appGroupCompleted = store.hasCompletedOnboarding
state.hasCompletedOnboarding = appGroupCompleted
if !appGroupCompleted {
Task.detached { [weak self] in
let keychainCompleted = Keychain.hasCompletedOnboarding()
await MainActor.run {
guard let self, keychainCompleted else { return }
self.state.hasCompletedOnboarding = true
}
}
}
let practice = KeyboardSetupBridge.activeOOBEPracticeSession let practice = KeyboardSetupBridge.activeOOBEPracticeSession
state.oobePracticeSession = practice state.oobePracticeSession = practice
state.isOnboardingPracticeActive = practice != nil state.isOnboardingPracticeActive = practice != nil
@@ -170,6 +170,9 @@ final class KeyboardFlowCoordinator {
/// the user returns from startflow and taps the Voice tab — height/appear /// the user returns from startflow and taps the Voice tab — height/appear
/// noise used to call this again while `ready` briefly lagged). /// noise used to call this again while `ready` briefly lagged).
func ensurePiPReadyOnKeyboardOpen() { func ensurePiPReadyOnKeyboardOpen() {
#if DEBUG
MemoryDeviceProbe.log("pip.ensure.entry")
#endif
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch, guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
voiceSetupReady, voiceSetupReady,
hasFullAccess(), hasFullAccess(),
@@ -198,6 +201,9 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.markPiPArmAttempt() FlowSessionBridge.markPiPArmAttempt()
detectAndStoreAppContext() detectAndStoreAppContext()
#if DEBUG
MemoryDeviceProbe.log("pip.arm.beginFlowStart")
#endif
beginFlowStart(recordAfterHandoff: false) beginFlowStart(recordAfterHandoff: false)
traceState("keyboardOpen.autoArmPiP") traceState("keyboardOpen.autoArmPiP")
} }
@@ -360,6 +360,9 @@ struct TypingRootView: View {
.padding(.trailing, Spacing.xs) .padding(.trailing, Spacing.xs)
} }
.scrollBounceBehavior(.basedOnSize, axes: .horizontal) .scrollBounceBehavior(.basedOnSize, axes: .horizontal)
// iOS 26+ 默认给 ScrollView 加边缘效果(soft = 渐进模糊+压暗),在键盘
// 顶栏会把候选文字整段糊掉(顶部更糊、底部较清)。候选条不需要它。
.scrollEdgeEffectHidden()
} }
/// Opaque chip like the translation control so ▼ never shares pixels with text. /// Opaque chip like the translation control so ▼ never shares pixels with text.
@@ -505,6 +505,9 @@ struct AIKeyboardView: View {
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.scrollClipDisabled() .scrollClipDisabled()
// iOS 26+ 默认的 scroll edge effect(渐进模糊)会把顶栏的
// 粘贴/技能胶囊糊掉——复制后这行一出现模糊就出现。
.scrollEdgeEffectHidden()
} else { } else {
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@@ -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) 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) { private func copy(_ text: String) {
pasteboard.setText(text, changeCount: pasteboard.changeCount + 1) pasteboard.setText(text, changeCount: pasteboard.changeCount + 1)
} }
@@ -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 final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @unchecked Sendable {
private let task: URLSessionWebSocketTask private let task: URLSessionWebSocketTask
@@ -167,8 +183,14 @@ private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @uncheck
} }
func ping() async throws { 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 try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
task.sendPing { error in task.sendPing { error in
guard gate.claim() else { return }
if let error { if let error {
continuation.resume(throwing: error) continuation.resume(throwing: error)
} else { } else {
@@ -312,6 +312,9 @@ public struct AnalyticsRuntime: Sendable {
/// Main-app runtime. A bearer provider may be supplied by host-only account /// Main-app runtime. A bearer provider may be supplied by host-only account
/// code without making the shared framework depend on that code. /// 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( public static func mainApp(
environment: AnalyticsEnvironment, environment: AnalyticsEnvironment,
repositoryConfiguration: AnalyticsRepositoryConfiguration = .appGroupDefault(), 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 { public func setEnabled(_ enabled: Bool) async {
await repository.setEnabled(enabled) await repository.setEnabled(enabled)
} }
@@ -20,6 +20,10 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public static let baseURL = "config.baseURL" public static let baseURL = "config.baseURL"
/// Legacy plaintext slot — migrated to Keychain on first read. /// Legacy plaintext slot — migrated to Keychain on first read.
public static let apiKeyLegacy = "config.apiKey" 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" public static let model = "config.model"
/// Cloud ASR provider — independent from polish `providerId`. /// Cloud ASR provider — independent from polish `providerId`.
public static let asrProviderId = "config.asrProviderId" public static let asrProviderId = "config.asrProviderId"
@@ -435,11 +439,21 @@ public struct AppGroupConfiguration: Sendable, Equatable {
} }
// One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain. // One-shot legacy migration: plaintext apiKey in UserDefaults → Keychain.
// 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( _ = resolveAPIKey(
defaults: defaults, defaults: defaults,
providerId: config.providerId, providerId: config.providerId,
preferICloudSync: config.settingsICloudSyncEnabled preferICloudSync: config.settingsICloudSyncEnabled
) )
}
// One-shot defaults for installs that predate explicit settings. // One-shot defaults for installs that predate explicit settings.
// Preserve the legacy engine choice, but use the current privacy-safe // 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. // Pure rules for accepting clipboard text and simple English/whitespace tokens.
import CryptoKit
import Foundation import Foundation
public enum ClipboardHistoryPolicy: Sendable { public enum ClipboardHistoryPolicy: Sendable {
@@ -298,4 +299,20 @@ public enum ClipboardHistoryPolicy: Sendable {
) -> Bool { ) -> Bool {
now.timeIntervalSince(entry.createdAt) <= aiHintEligibilitySeconds 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" "clipboard.history.suggestionDismissedChangeCount"
public static let lastAutoRepliedChangeCount = public static let lastAutoRepliedChangeCount =
"clipboard.history.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] = [] @Published public private(set) var entries: [ClipboardHistoryEntry] = []
private let defaults: UserDefaults 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) { public init(defaults: UserDefaults? = nil) {
let resolved: UserDefaults
if let defaults { if let defaults {
self.defaults = defaults resolved = defaults
} else if let suite = AppGroup.defaultsIfAvailable { } else if let suite = AppGroup.defaultsIfAvailable {
self.defaults = suite resolved = suite
} else { } 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 { 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. /// Inserts accepted text (dedupe + pin). Returns the new head when stored.
@discardableResult @discardableResult
public func ingest( public func ingest(
@@ -117,6 +200,7 @@ public final class ClipboardHistoryStore: ObservableObject {
} }
public func reload() { public func reload() {
lastSeenRevision = defaults.integer(forKey: Keys.revision)
entries = Self.loadEntries(from: defaults) entries = Self.loadEntries(from: defaults)
} }
@@ -158,6 +242,11 @@ public final class ClipboardHistoryStore: ObservableObject {
do { do {
let data = try JSONEncoder().encode(entries) let data = try JSONEncoder().encode(entries)
defaults.set(data, forKey: Keys.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 { } catch {
OSGLog.config.warning( OSGLog.config.warning(
"clipboard history encode failed: \(error.localizedDescription, privacy: .public)" "clipboard history encode failed: \(error.localizedDescription, privacy: .public)"
@@ -328,6 +328,10 @@ public actor ClipboardSemanticAnalyzer {
} }
public func analyze(_ sourceText: String) -> ClipboardSemanticAnalysis { 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) let text = sourceText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { guard !text.isEmpty else {
return emptyAnalysis() return emptyAnalysis()
@@ -1058,6 +1062,9 @@ public actor ClipboardSemanticAnalyzer {
} }
let entry = ModelEntry(configuration: configuration, model: model) let entry = ModelEntry(configuration: configuration, model: model)
models[id] = entry models[id] = entry
#if DEBUG
MemoryDeviceProbe.log("nlmodel.loaded.\(id)")
#endif
return entry return entry
} }
@@ -1129,6 +1136,9 @@ public actor ClipboardSemanticAnalyzer {
} }
let entry = VerifierEntry(configuration: configuration, model: model) let entry = VerifierEntry(configuration: configuration, model: model)
verifierModels[id] = entry verifierModels[id] = entry
#if DEBUG
MemoryDeviceProbe.log("nlmodel.verifier.loaded.\(id)")
#endif
return entry return entry
} }
@@ -68,13 +68,16 @@ extension FlowSessionBridge {
store.removeObject(forKey: FlowSessionKeys.flowStartTransactionPayload) store.removeObject(forKey: FlowSessionKeys.flowStartTransactionPayload)
store.removeObject(forKey: FlowSessionKeys.pendingKeyboardUtteranceId) store.removeObject(forKey: FlowSessionKeys.pendingKeyboardUtteranceId)
if let sessionId { 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( let snapshot = FlowReadySnapshot(
sessionId: sessionId, sessionId: sessionId,
ready: false, ready: false,
reason: .starting, reason: .starting,
heartbeatAt: now, heartbeatAt: now,
engineMode: AppGroupConfiguration.load(fromAvailable: store).engineMode, engineMode: config.engineMode,
localeId: AppGroupConfiguration.load(fromAvailable: store).localeId, localeId: config.localeId,
sessionExpiresAt: nil, sessionExpiresAt: nil,
hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration) hostGeneration: store.string(forKey: FlowSessionKeys.hostGeneration)
) )
@@ -288,6 +288,9 @@ public final class TypingSessionController: ObservableObject {
/// in the document so we only clear suggestion state. /// in the document so we only clear suggestion state.
public func setLanguage(_ newLanguage: TypingInputLanguage) -> TypingOutput { public func setLanguage(_ newLanguage: TypingInputLanguage) -> TypingOutput {
guard language != newLanguage else { return .none } guard language != newLanguage else { return .none }
#if DEBUG
MemoryDeviceProbe.log("setLanguage.\(language.rawValue)->\(newLanguage.rawValue)")
#endif
var output = TypingOutput.none var output = TypingOutput.none
if language == .chinese, !composition.preedit.isEmpty { if language == .chinese, !composition.preedit.isEmpty {
let raw = engine.flushPreedit() let raw = engine.flushPreedit()
@@ -57,6 +57,9 @@ public enum KeyboardExtensionMemoryTelemetry {
/// Highest level already relieved in the current pressure episode. Reset only /// Highest level already relieved in the current pressure episode. Reset only
/// when the footprint falls back below `warningMB` — see `requestReliefIfNeeded`. /// when the footprint falls back below `warningMB` — see `requestReliefIfNeeded`.
private static var lastReliefLevel = KeyboardExtensionMemoryBudget.Level.normal 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 /// Poll interval once the startup burst is over. Sustained sampling is what
/// catches growth *between* milestones (a long clipboard session, a big /// catches growth *between* milestones (a long clipboard session, a big
@@ -177,6 +180,9 @@ public enum KeyboardExtensionMemoryTelemetry {
if crossedLevel { if crossedLevel {
highestLevel = level highestLevel = level
} }
#if DEBUG
MemoryDeviceProbe.log("emit.\(stage).\(level.rawValue)")
#endif
// Shed BEFORE logging: at `.critical` we are ~12 MiB from the observed // Shed BEFORE logging: at `.critical` we are ~12 MiB from the observed
// jetsam boundary and the log line is the less important half. // jetsam boundary and the log line is the less important half.
requestReliefIfNeeded(level: level, stage: stage) requestReliefIfNeeded(level: level, stage: stage)
@@ -234,11 +240,26 @@ public enum KeyboardExtensionMemoryTelemetry {
} }
guard level == .high || level == .critical, let handler = reliefHandler else { return } guard level == .high || level == .critical, let handler = reliefHandler else { return }
guard levelRank(level) > levelRank(lastReliefLevel) 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( OSGDiag.log(
"extMemory relief level=\(level.rawValue) stage=\(stage) \(OSGDiag.memoryTag())", "extMemory relief level=\(level.rawValue) stage=\(stage) \(OSGDiag.memoryTag())",
category: "memory" 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( OSGDiag.log(
"extMemory relief declined level=\(level.rawValue) stage=\(stage)", "extMemory relief declined level=\(level.rawValue) stage=\(stage)",
category: "memory" 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
@@ -126,4 +126,33 @@ final class ClipboardHistoryPolicyTests: XCTestCase {
) )
XCTAssertEqual(tokens, ["great", "experience", "he", "writes"]) 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 // OSGKeyboardTests
@testable import OSGKeyboardShared @testable import OSGKeyboardShared
import Combine
import XCTest import XCTest
@MainActor @MainActor
@@ -92,4 +93,103 @@ final class ClipboardHistoryStoreTests: XCTestCase {
XCTAssertTrue(ClipboardHistoryPolicy.encodedPayloadFitsLimit(store.entries)) XCTAssertTrue(ClipboardHistoryPolicy.encodedPayloadFitsLimit(store.entries))
XCTAssertLessThan(store.entries.count, ClipboardHistoryPolicy.maxEntries) 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]) 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() { func testRepeatedHighFootprintShedsOnce() {
let (levels, _) = armedLevels() let (levels, _) = armedLevels()
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41) KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
@@ -81,4 +81,75 @@ final class EnglishKeyboardDeviceUITests: XCTestCase {
"OSGKeyboard extension window should be on screen" "OSGKeyboard extension window should be on screen"
) )
} }
/// Chinese → English used to take the whole extension down. Memory relief
/// fired while the typing surface was being torn down, and every teardown
/// step recorded a milestone that asked for relief again — the nesting
/// overflowed the main thread's stack.
func testSwitchingToEnglishAfterChineseKeepsTheKeyboardAlive() throws {
let app = XCUIApplication()
app.launchArguments = [
"--whats-new-host",
"--whats-new-lang=en",
"--whats-new-scenario=edit"
]
app.launch()
let textView = app.textViews["notes.host.textView"]
XCTAssertTrue(
textView.waitForExistence(timeout: 12),
"Notes host text view should appear"
)
if !textView.exists {
return
}
textView.tap()
let keyboard = XCUIApplication(bundleIdentifier: "com.osgkeyboard.ios.keyboard")
guard let chineseTab = inputTab("chinese", host: app, keyboard: keyboard) else {
throw XCTSkip(
"OSGKeyboard extension is not the active keyboard on this device. Enable it in Settings ▸ Keyboard, then re-run."
)
}
chineseTab.tap()
// Loading Rime pushes the footprint past our relief thresholds, which is
// what arms the shed that runs on the way back out to English.
XCTAssertTrue(
keyboard.buttons["n"].waitForExistence(timeout: 12),
"Chinese typing surface should come up"
)
guard let englishTab = inputTab("english", host: app, keyboard: keyboard) else {
XCTFail("English tab should be reachable from the Chinese surface")
return
}
englishTab.tap()
XCTAssertTrue(
keyboard.buttons["n"].waitForExistence(timeout: 12),
"English surface should be usable instead of the extension being killed"
)
XCTAssertTrue(
keyboard.windows.firstMatch.exists,
"OSGKeyboard extension should survive the switch to English"
)
}
/// The top-bar tabs show up in the host's accessibility hierarchy on some OS
/// versions and in the extension's on others; try both before concluding
/// that OSGKeyboard is not the active keyboard.
private func inputTab(
_ name: String,
host: XCUIApplication,
keyboard: XCUIApplication,
timeout: TimeInterval = 10
) -> XCUIElement? {
let identifier = "assistant.tab.\(name)"
let inHost = host.descendants(matching: .any)[identifier]
if inHost.waitForExistence(timeout: timeout) { return inHost }
let inExtension = keyboard.descendants(matching: .any)[identifier]
if inExtension.waitForExistence(timeout: timeout) { return inExtension }
return nil
}
} }
+2 -3
View File
@@ -43,7 +43,7 @@
] ]
}, },
"analytics": { "analytics": {
"description": "Privacy-safe analytics schema, durable queue, uploader, identity lifecycle, and anonymous extension runtime", "description": "Privacy-safe analytics schema, durable queue, uploader, and identity lifecycle",
"platform": "ios", "platform": "ios",
"tests": [ "tests": [
"OSGKeyboardTests/AnalyticsModelTests", "OSGKeyboardTests/AnalyticsModelTests",
@@ -54,8 +54,7 @@
"OSGKeyboardTests/AnalyticsAIOperationTests", "OSGKeyboardTests/AnalyticsAIOperationTests",
"OSGKeyboardTests/KeyboardUsageModelTests", "OSGKeyboardTests/KeyboardUsageModelTests",
"OSGKeyboardTests/KeyboardUsageRepositoryTests", "OSGKeyboardTests/KeyboardUsageRepositoryTests",
"OSGKeyboardTests/KeyboardUsageUploadCoordinatorTests", "OSGKeyboardTests/KeyboardUsageUploadCoordinatorTests"
"OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests"
] ]
}, },
"sync": { "sync": {
+2 -2
View File
@@ -48,8 +48,8 @@ settings:
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
STRING_CATALOG_GENERATE_SYMBOLS: YES STRING_CATALOG_GENERATE_SYMBOLS: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17 CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "2.2.0" MARKETING_VERSION: "2.1.0"
CURRENT_PROJECT_VERSION: "96" CURRENT_PROJECT_VERSION: "110"
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target # 项目级签名 xcconfig,适用于所有 target