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:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### 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 |
|
||||
|-------|------|------|
|
||||
|
||||
@@ -7,7 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [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
|
||||
- **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 或跟随系统语言。
|
||||
|
||||
## [2.2.0] - 2026-09-18
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//
|
||||
// Host-only analytics lifecycle. The shared module owns the wire contract,
|
||||
// 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 Foundation
|
||||
|
||||
@@ -65,6 +65,9 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
}
|
||||
.onAppear {
|
||||
configure(width: proxy.size.width)
|
||||
#if DEBUG
|
||||
startVoiceMemorySampling()
|
||||
#endif
|
||||
}
|
||||
.onChange(of: proxy.size.width) { _, width in
|
||||
configureLayout(width: width)
|
||||
@@ -83,6 +86,22 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
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) {
|
||||
configureLayout(width: width)
|
||||
guard !configured else { return }
|
||||
|
||||
@@ -391,6 +391,13 @@ struct MainAppRoot: View {
|
||||
/// Starts foreground-only services once per active transition. Rime yields
|
||||
/// the first frame; Flow and CLM keep their existing lazy-heavy-work rules.
|
||||
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)
|
||||
// Automatically arm the low-profile PiP on every host open.
|
||||
// Capture/ASR remain lazy and start only on an actual mic press.
|
||||
|
||||
@@ -51,6 +51,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
/// it to tell "the user is mid-sentence" from "the typing surface is merely
|
||||
/// on screen" — see `isMidTypingBurst`.
|
||||
private var lastTypingActivityUptime: TimeInterval = -1
|
||||
/// Reentrancy guard for `shedHeavyResources` — see the comment there.
|
||||
private var isSheddingHeavyResources = false
|
||||
|
||||
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
|
||||
private var keyboardHeightConstraint: NSLayoutConstraint?
|
||||
@@ -69,9 +71,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||
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
|
||||
/// candidate state separate from the heavy typing-engine lifetime.
|
||||
private var isKeyboardPresentationActive = false
|
||||
@@ -205,9 +204,13 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowCoordinator.refreshSessionState()
|
||||
// Installed last: the handler tears down surfaces, so it must not be
|
||||
// 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(
|
||||
"KVC.viewDidLoad.done",
|
||||
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
|
||||
@@ -226,7 +229,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
isKeyboardPresentationActive = false
|
||||
typingDocumentPresentationID = nil
|
||||
typingSessionStorage?.endDocumentPresentation()
|
||||
AnalyticsExtensionService.shared.keyboardWillDisappear()
|
||||
assistantFieldActionRefreshTask?.cancel()
|
||||
assistantFieldActionRefreshTask = nil
|
||||
clipboardCapture?.keyboardWillDisappear()
|
||||
@@ -335,10 +337,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
keyboardUsageSessionID = UUID()
|
||||
AnalyticsExtensionService.shared.recordPresentation(
|
||||
hasFullAccess: hasFullAccess
|
||||
)
|
||||
OSGDiag.log(
|
||||
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -460,6 +458,17 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// Relief can be requested from the boot sampler before `installServices`
|
||||
// has run; nothing below is safe to touch until the view is loaded.
|
||||
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(
|
||||
"KVC.shed level=\(level.rawValue) reason=\(reason) "
|
||||
+ "surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
@@ -816,6 +825,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
category: "boot"
|
||||
)
|
||||
state.surface = surface
|
||||
#if DEBUG
|
||||
MemoryDeviceProbe.log("applySurface.\(surface.rawValue)")
|
||||
#endif
|
||||
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
|
||||
if surface == .typing {
|
||||
activateTypingPresentationIfNeeded()
|
||||
@@ -969,19 +981,14 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
/// echo this keyboard's own document mutation through `textDidChange`.
|
||||
private func insertTextIntoDocument(
|
||||
_ text: String,
|
||||
source: KeyboardTextInsertionSource
|
||||
source _: KeyboardTextInsertionSource
|
||||
) {
|
||||
guard !text.isEmpty else { return }
|
||||
lastTypingActivityUptime = ProcessInfo.processInfo.systemUptime
|
||||
textDocumentProxy.insertText(text)
|
||||
if source.contributesToKeyboardUsage {
|
||||
let counts = KeyboardUsageCharacterClassifier.classify(text)
|
||||
AnalyticsExtensionService.shared.keyboardUsageRecorder
|
||||
.recordManualKeyboardCounts(
|
||||
counts,
|
||||
sessionID: keyboardUsageSessionID
|
||||
)
|
||||
}
|
||||
// Usage analytics stay in the host app. Recording SQLite from this
|
||||
// process held WAL locks across keyboard hide and triggered
|
||||
// RunningBoard `0xdead10cc` kills.
|
||||
// A non-empty insertion makes content actions available immediately.
|
||||
// The next host callback remains the authoritative correction.
|
||||
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
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
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
|
||||
// Log only credential availability and the base URL origin. Never put
|
||||
@@ -106,7 +110,7 @@ public struct AppGroupPersistor {
|
||||
state.clipboardAutoEmailReplyEnabled = store.clipboardAutoEmailReplyEnabled
|
||||
applySkillSnapshot(store: store, into: state)
|
||||
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
|
||||
@@ -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
|
||||
/// the polish key is missing — show a soft tip above the mic instead.
|
||||
private func applyAPIKeyAvailability(
|
||||
store: AppGroupStore,
|
||||
public func applyCredentialAvailability(
|
||||
isPolishKeyMissing: Bool,
|
||||
isCloudAPIKeyMissingForVoiceInput: Bool,
|
||||
into state: KeyboardViewController.State
|
||||
) {
|
||||
state.aiServiceAvailable = !store.isPolishKeyMissing
|
||||
if store.isCloudAPIKeyMissingForVoiceInput {
|
||||
state.aiServiceAvailable = !isPolishKeyMissing
|
||||
if isCloudAPIKeyMissingForVoiceInput {
|
||||
state.micDisabled = true
|
||||
state.micDisabledHint = ExtL10n.string("keyboard.mic.disabled.missingApiKey")
|
||||
} else if store.isPolishKeyMissing {
|
||||
} else if isPolishKeyMissing {
|
||||
state.micDisabled = false
|
||||
state.micDisabledHint = ExtL10n.string("keyboard.mic.hint.missingPolishApiKey")
|
||||
} else {
|
||||
|
||||
@@ -65,6 +65,9 @@ final class ClipboardCaptureCoordinator {
|
||||
self.history = history
|
||||
self.semanticRanking = semanticRanking
|
||||
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
|
||||
.sink { [weak self] snapshot in
|
||||
// `@Published` fires in willSet, so the store's property is not
|
||||
@@ -267,12 +270,11 @@ final class ClipboardCaptureCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
// A new generation replaces any previous transient suggestion,
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
|
||||
// Prefer hasStrings peek before reading body (reduces empty reads).
|
||||
guard pasteboard.hasStrings else {
|
||||
semanticRanking.clear()
|
||||
history.lastObservedChangeCount = changeCount
|
||||
return
|
||||
@@ -284,6 +286,17 @@ final class ClipboardCaptureCoordinator {
|
||||
if isCurrentGeneration {
|
||||
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()
|
||||
if let entry = history.ingest(rawText: raw, changeCount: changeCount) {
|
||||
semanticRanking.analyze(entry)
|
||||
@@ -345,6 +358,20 @@ final class ClipboardCaptureCoordinator {
|
||||
secureFieldSuppressedChangeCount = nil
|
||||
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,
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
@@ -428,6 +455,10 @@ final class ClipboardCaptureCoordinator {
|
||||
}
|
||||
// One auto action per copy, even across keyboard close/reopen.
|
||||
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(
|
||||
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
|
||||
newestClipboard: newest
|
||||
@@ -450,6 +481,7 @@ final class ClipboardCaptureCoordinator {
|
||||
AIClipboardSkillCatalog.emailReplySkill,
|
||||
scene: nil,
|
||||
changeCount: changeCount,
|
||||
sourceText: newest.text,
|
||||
readOnlyResult: true,
|
||||
insertLabelKey: "keyboard.assistant.insertReply"
|
||||
)
|
||||
@@ -459,7 +491,13 @@ final class ClipboardCaptureCoordinator {
|
||||
if state.clipboardAutoTranslateEnabled,
|
||||
ClipboardSkillSemanticRanker.isForeignLanguage(snapshot.analysis),
|
||||
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
|
||||
}
|
||||
// 3. Auto-reply for any interpersonal message worth answering — a task,
|
||||
@@ -480,6 +518,7 @@ final class ClipboardCaptureCoordinator {
|
||||
sourceText: newest.text
|
||||
),
|
||||
changeCount: changeCount,
|
||||
sourceText: newest.text,
|
||||
readOnlyResult: false
|
||||
)
|
||||
}
|
||||
@@ -491,11 +530,16 @@ final class ClipboardCaptureCoordinator {
|
||||
_ skill: AIClipboardSkill,
|
||||
scene: AIClipboardReplyScene?,
|
||||
changeCount: Int,
|
||||
sourceText: String,
|
||||
readOnlyResult: Bool,
|
||||
insertLabelKey: String = "keyboard.assistant.insertTranslation"
|
||||
) {
|
||||
// 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:
|
||||
// the surface the user is actively on, or — when the keyboard opened
|
||||
// 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
|
||||
/// does not immediately resurface it for the same copy.
|
||||
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
|
||||
/// reads as replyable, raise the clean full-keyboard guide instead of firing
|
||||
@@ -548,6 +596,12 @@ final class ClipboardCaptureCoordinator {
|
||||
lastGuidedChangeCount != changeCount else {
|
||||
return
|
||||
}
|
||||
let textFingerprint = ClipboardHistoryPolicy.contentFingerprint(for: newest.text)
|
||||
if lastGuidedTextFingerprint == textFingerprint,
|
||||
let lastGuidedAt,
|
||||
ClipboardHistoryPolicy.isRepeatSuppressed(firedAt: lastGuidedAt) {
|
||||
return
|
||||
}
|
||||
guard AIHintPool.isClipboardSkillWindowActive(
|
||||
clipboardHistoryEnabled: state.clipboardHistoryEnabled,
|
||||
newestClipboard: newest
|
||||
@@ -567,6 +621,8 @@ final class ClipboardCaptureCoordinator {
|
||||
)
|
||||
guard recommended.first?.id == AIClipboardSkillCatalog.replyID else { return }
|
||||
lastGuidedChangeCount = changeCount
|
||||
lastGuidedTextFingerprint = textFingerprint
|
||||
lastGuidedAt = Date()
|
||||
state.clipboardOverlay = .autoReplyGuide
|
||||
}
|
||||
|
||||
@@ -594,6 +650,7 @@ final class ClipboardCaptureCoordinator {
|
||||
reply,
|
||||
scene: scene,
|
||||
changeCount: newest.changeCount ?? history.lastObservedChangeCount,
|
||||
sourceText: newest.text,
|
||||
readOnlyResult: false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ final class KeyboardConfigSync {
|
||||
case .loaded:
|
||||
OSGLog.keyboardExt.info("config loaded")
|
||||
syncOnboardingStateFromAppGroup()
|
||||
refreshCredentialsFromKeychain()
|
||||
return .loaded
|
||||
case .unavailable:
|
||||
state.phase = .error(
|
||||
@@ -83,15 +84,52 @@ final class KeyboardConfigSync {
|
||||
)
|
||||
// Host may complete (or reset) onboarding while the extension stays alive.
|
||||
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.
|
||||
/// Does not sync `onboardingPage` — page flow is host-app exclusive.
|
||||
func syncOnboardingStateFromAppGroup() {
|
||||
let store = AppGroupStore()
|
||||
// Keychain fallback: a reboot must not resurrect the mic gate when
|
||||
// App Group transiently reads empty.
|
||||
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
|
||||
// Synchronous reads stay defaults-only on the main thread (0xdead10cc
|
||||
// window). The Keychain fallback — a reboot must not resurrect the mic
|
||||
// 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
|
||||
state.oobePracticeSession = practice
|
||||
state.isOnboardingPracticeActive = practice != nil
|
||||
|
||||
@@ -170,6 +170,9 @@ final class KeyboardFlowCoordinator {
|
||||
/// the user returns from startflow and taps the Voice tab — height/appear
|
||||
/// noise used to call this again while `ready` briefly lagged).
|
||||
func ensurePiPReadyOnKeyboardOpen() {
|
||||
#if DEBUG
|
||||
MemoryDeviceProbe.log("pip.ensure.entry")
|
||||
#endif
|
||||
guard FlowHandoffPolicy.allowsProactiveHostAutoLaunch,
|
||||
voiceSetupReady,
|
||||
hasFullAccess(),
|
||||
@@ -198,6 +201,9 @@ final class KeyboardFlowCoordinator {
|
||||
|
||||
FlowSessionBridge.markPiPArmAttempt()
|
||||
detectAndStoreAppContext()
|
||||
#if DEBUG
|
||||
MemoryDeviceProbe.log("pip.arm.beginFlowStart")
|
||||
#endif
|
||||
beginFlowStart(recordAfterHandoff: false)
|
||||
traceState("keyboardOpen.autoArmPiP")
|
||||
}
|
||||
|
||||
@@ -360,6 +360,9 @@ struct TypingRootView: View {
|
||||
.padding(.trailing, Spacing.xs)
|
||||
}
|
||||
.scrollBounceBehavior(.basedOnSize, axes: .horizontal)
|
||||
// iOS 26+ 默认给 ScrollView 加边缘效果(soft = 渐进模糊+压暗),在键盘
|
||||
// 顶栏会把候选文字整段糊掉(顶部更糊、底部较清)。候选条不需要它。
|
||||
.scrollEdgeEffectHidden()
|
||||
}
|
||||
|
||||
/// Opaque chip like the translation control so ▼ never shares pixels with text.
|
||||
|
||||
@@ -505,6 +505,9 @@ struct AIKeyboardView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.scrollClipDisabled()
|
||||
// iOS 26+ 默认的 scroll edge effect(渐进模糊)会把顶栏的
|
||||
// 粘贴/技能胶囊糊掉——复制后这行一出现模糊就出现。
|
||||
.scrollEdgeEffectHidden()
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
|
||||
func testSameTextUnderNewChangeCountIsSwallowedAsSameLogicalCopy() {
|
||||
copy("meeting at 3pm")
|
||||
coordinator.captureIfNeeded()
|
||||
let firstEntry = history.newestEntry
|
||||
|
||||
// Universal Clipboard re-announce: same text, new changeCount.
|
||||
pasteboard.setText("meeting at 3pm", changeCount: 12)
|
||||
coordinator.captureIfNeeded()
|
||||
|
||||
XCTAssertEqual(history.entries.count, 1)
|
||||
XCTAssertEqual(history.newestEntry?.id, firstEntry?.id)
|
||||
XCTAssertEqual(history.newestEntry?.createdAt, firstEntry?.createdAt)
|
||||
XCTAssertEqual(history.lastObservedChangeCount, 12)
|
||||
XCTAssertEqual(state.clipboardSuggestionText, "meeting at 3pm")
|
||||
}
|
||||
|
||||
func testRecopyAfterDifferentContentCreatesNewGeneration() {
|
||||
copy("first message")
|
||||
coordinator.captureIfNeeded()
|
||||
copy("second message")
|
||||
coordinator.captureIfNeeded()
|
||||
|
||||
copy("first message")
|
||||
coordinator.captureIfNeeded()
|
||||
|
||||
XCTAssertEqual(history.entries.map(\.text), ["first message", "second message"])
|
||||
XCTAssertEqual(state.clipboardSuggestionText, "first message")
|
||||
}
|
||||
|
||||
func testDismissedSuggestionStaysHiddenAcrossUniversalClipboardResync() {
|
||||
copy("dismiss me")
|
||||
coordinator.captureIfNeeded()
|
||||
coordinator.dismissSuggestion()
|
||||
|
||||
pasteboard.setText("dismiss me", changeCount: 12)
|
||||
coordinator.captureIfNeeded()
|
||||
|
||||
XCTAssertNil(state.clipboardSuggestionText)
|
||||
XCTAssertEqual(history.entries.count, 1)
|
||||
}
|
||||
|
||||
private func copy(_ text: String) {
|
||||
pasteboard.setText(text, changeCount: pasteboard.changeCount + 1)
|
||||
}
|
||||
|
||||
@@ -155,6 +155,22 @@ private struct URLSessionManagedASRWebSocketFactory: ManagedASRWebSocketFactory
|
||||
}
|
||||
}
|
||||
|
||||
/// Once-only gate for callback APIs that may invoke their handler more than
|
||||
/// once (see `ping()`). The first `claim()` returns true; later calls return
|
||||
/// false so the wrapped continuation is resumed exactly once.
|
||||
private final class ResumeOnceGate: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var claimed = false
|
||||
|
||||
func claim() -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if claimed { return false }
|
||||
claimed = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @unchecked Sendable {
|
||||
private let task: URLSessionWebSocketTask
|
||||
|
||||
@@ -167,8 +183,14 @@ private final class URLSessionManagedASRWebSocket: ManagedASRWebSocket, @uncheck
|
||||
}
|
||||
|
||||
func ping() async throws {
|
||||
// `URLSessionWebSocketTask.sendPing` can invoke its handler twice when
|
||||
// the task is cancelled with a ping in flight (cancel path + pong
|
||||
// timeout path). Resuming a CheckedContinuation twice traps (SIGTRAP),
|
||||
// so the handler is funneled through a once-only gate.
|
||||
let gate = ResumeOnceGate()
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
task.sendPing { error in
|
||||
guard gate.claim() else { return }
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
|
||||
@@ -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.
|
||||
// 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
|
||||
|
||||
@@ -126,4 +126,33 @@ final class ClipboardHistoryPolicyTests: XCTestCase {
|
||||
)
|
||||
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
|
||||
|
||||
@testable import OSGKeyboardShared
|
||||
import Combine
|
||||
import XCTest
|
||||
|
||||
@MainActor
|
||||
@@ -92,4 +93,103 @@ final class ClipboardHistoryStoreTests: XCTestCase {
|
||||
XCTAssertTrue(ClipboardHistoryPolicy.encodedPayloadFitsLimit(store.entries))
|
||||
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])
|
||||
}
|
||||
|
||||
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() {
|
||||
let (levels, _) = armedLevels()
|
||||
KeyboardExtensionMemoryTelemetry.simulateFootprintForTesting(41)
|
||||
|
||||
@@ -81,4 +81,75 @@ final class EnglishKeyboardDeviceUITests: XCTestCase {
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
]
|
||||
},
|
||||
"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",
|
||||
"tests": [
|
||||
"OSGKeyboardTests/AnalyticsModelTests",
|
||||
@@ -54,8 +54,7 @@
|
||||
"OSGKeyboardTests/AnalyticsAIOperationTests",
|
||||
"OSGKeyboardTests/KeyboardUsageModelTests",
|
||||
"OSGKeyboardTests/KeyboardUsageRepositoryTests",
|
||||
"OSGKeyboardTests/KeyboardUsageUploadCoordinatorTests",
|
||||
"OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests"
|
||||
"OSGKeyboardTests/KeyboardUsageUploadCoordinatorTests"
|
||||
]
|
||||
},
|
||||
"sync": {
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ settings:
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS: YES
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "2.2.0"
|
||||
CURRENT_PROJECT_VERSION: "96"
|
||||
MARKETING_VERSION: "2.1.0"
|
||||
CURRENT_PROJECT_VERSION: "110"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user