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:
@@ -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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// A new generation replaces any previous transient suggestion,
|
||||
// including generations that contain no acceptable text.
|
||||
clearSuggestion()
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user