fix(keyboard): stabilize Flow startup under memory pressure
Delay competing Rime work, add extension memory telemetry and stress coverage, and refresh the 1.8 release assets and metadata for build 79.
This commit is contained in:
@@ -41,6 +41,8 @@ struct OSGKeyboardApp: App {
|
||||
AIClipboardSkillLayoutDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--assistant-ui-test") {
|
||||
AssistantKeyboardUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--pip-device-ui-test") {
|
||||
FlowPiPDeviceUITestHarness()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
|
||||
ClipboardHistoryDemoView()
|
||||
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
|
||||
|
||||
@@ -126,7 +126,8 @@ final class FlowSessionManager: ObservableObject {
|
||||
private var isColdStartHandoff = false
|
||||
private var coldStartRecoveryTask: Task<Void, Never>?
|
||||
var shouldDeferHostHeavyWork: Bool {
|
||||
startingUtteranceId != nil
|
||||
isStarting
|
||||
|| startingUtteranceId != nil
|
||||
|| isUtteranceRecording
|
||||
|| isUtteranceProcessing
|
||||
|| hasUnacknowledgedTerminalResult()
|
||||
@@ -922,6 +923,11 @@ final class FlowSessionManager: ObservableObject {
|
||||
coldStartRecoveryTask?.cancel()
|
||||
coldStartRecoveryTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// The failed controller has been fully torn down. Give the app
|
||||
// scene and Pegasus service a short settling interval before the
|
||||
// one bounded cold-start retry creates a new controller generation.
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||
let outcome = await self.pipController.startAndWait()
|
||||
self.traceState("coldStartRecovery.pip", extra: "outcome=\(outcome)")
|
||||
guard !Task.isCancelled, self.isColdStartHandoff else { return }
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
// AIClipboardSkillLayoutDemoView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// Interactive layout preview of AI Agent clipboard skills on the real
|
||||
// `AIKeyboardView`. Launch with `--ai-skills-demo` and optional
|
||||
// `--skills-count=N` (1...8).
|
||||
// Interactive layout preview and What's New recording host for AI Agent
|
||||
// clipboard skills on the real unified `AIKeyboardView`. Launch with
|
||||
// `--ai-skills-demo`, optional `--skills-count=N` (1...8), and optional
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct AIClipboardSkillLayoutDemoView: View {
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@State private var count: Int = Self.initialCount
|
||||
|
||||
init() {
|
||||
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||
AIKeyboardView.debugKeepsSkillTip = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
controls
|
||||
@@ -26,10 +33,15 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
.background(Palette.light.background)
|
||||
}
|
||||
.background(Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea())
|
||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
||||
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
|
||||
.preferredColorScheme(.light)
|
||||
.onAppear { apply(count) }
|
||||
.onChange(of: count) { _, newValue in apply(newValue) }
|
||||
.task { await runTimeline() }
|
||||
.onChange(of: count) { _, newValue in showSkills(newValue) }
|
||||
.onDisappear {
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||
AIKeyboardView.debugKeepsSkillTip = false
|
||||
}
|
||||
}
|
||||
|
||||
private var controls: some View {
|
||||
@@ -70,19 +82,53 @@ struct AIClipboardSkillLayoutDemoView: View {
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
private func apply(_ count: Int) {
|
||||
let clamped = min(max(count, 1), 8)
|
||||
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
||||
state.surface = .ai
|
||||
private func prepareState() {
|
||||
config.uiLanguage = language == .en ? .english : .chinese
|
||||
AIKeyboardView.debugPreviewSkills = nil
|
||||
state.surface = .voice
|
||||
state.aiServiceAvailable = true
|
||||
state.micDisabled = false
|
||||
state.layoutWidth = 390
|
||||
state.usesIPadLayoutMetrics = false
|
||||
state.clipboardHistoryEnabled = true
|
||||
state.undoAvailable = true
|
||||
state.editAvailable = true
|
||||
state.clipboardSuggestionText = language == .en
|
||||
? "Meeting at 3pm tomorrow"
|
||||
: "明天下午三点开会"
|
||||
state.skillTipText = language == .en
|
||||
? "Copied text is ready"
|
||||
: "复制内容已就绪"
|
||||
state.aiSession.enter()
|
||||
}
|
||||
|
||||
private func showSkills(_ count: Int) {
|
||||
let clamped = min(max(count, 1), 8)
|
||||
AIKeyboardView.debugPreviewSkills = Self.previewSkills(count: clamped)
|
||||
state.enabledClipboardSkillIDs = Array(
|
||||
AIClipboardSkillCatalog.catalog.map(\.id).prefix(clamped)
|
||||
)
|
||||
state.aiSession.enter()
|
||||
}
|
||||
|
||||
private func runTimeline() async {
|
||||
prepareState()
|
||||
try? await Task.sleep(for: .seconds(3))
|
||||
state.clipboardSuggestionText = nil
|
||||
state.skillTipText = nil
|
||||
showSkills(count)
|
||||
try? await Task.sleep(for: .seconds(8))
|
||||
}
|
||||
|
||||
private var language: WhatsNewDemoScenario.Language {
|
||||
let prefix = "--whats-new-lang="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return .zh
|
||||
}
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: String(argument.dropFirst(prefix.count))
|
||||
) ?? .zh
|
||||
}
|
||||
|
||||
private static var initialCount: Int {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// AIKeyboardDemoView.swift
|
||||
// OSGKeyboard · Main App (DEBUG-only)
|
||||
//
|
||||
// What's New 1.7.0 recording host. Uses the **real** AI Agent settings page
|
||||
// and the **real** `AIKeyboardView` (compiled into the app target) driven by a
|
||||
// scripted `KeyboardState` — no ASR / LLM. Launch with `--ai-demo`.
|
||||
// What's New recording host. Uses the **real** AI Agent settings page and the
|
||||
// **real** unified `AIKeyboardView` (compiled into the app target), driven by a
|
||||
// scripted `KeyboardState` — no ASR / LLM. Launch with `--ai-demo` and optional
|
||||
// `--whats-new-lang=zh|en`.
|
||||
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
@@ -15,10 +16,6 @@ struct AIKeyboardDemoView: View {
|
||||
case keyboard
|
||||
}
|
||||
|
||||
private static let question = "周末去哪儿玩比较合适?"
|
||||
private static let answer =
|
||||
"可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。若想轻松一点,选人少的湖边步道也很合适。"
|
||||
|
||||
@StateObject private var config = ProviderConfig.shared
|
||||
@StateObject private var state = KeyboardState()
|
||||
@StateObject private var typing = TypingSessionController()
|
||||
@@ -26,6 +23,10 @@ struct AIKeyboardDemoView: View {
|
||||
@State private var scene: Scene = .settings
|
||||
@State private var levelTick = 0.35
|
||||
|
||||
init() {
|
||||
AIKeyboardView.debugSkipsLongPressCoach = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(red: 0.06, green: 0.06, blue: 0.07).ignoresSafeArea()
|
||||
@@ -58,15 +59,18 @@ struct AIKeyboardDemoView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.environment(\.locale, Locale(identifier: "zh-Hans"))
|
||||
.environment(\.locale, language == .en ? Locale(identifier: "en") : Locale(identifier: "zh-Hans"))
|
||||
.task { await runTimeline() }
|
||||
.onDisappear {
|
||||
AIKeyboardView.debugSkipsLongPressCoach = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Scripted timeline (real view models)
|
||||
|
||||
private func runTimeline() async {
|
||||
prepareKeyboardState()
|
||||
config.uiLanguage = .chinese
|
||||
config.uiLanguage = language == .en ? .english : .chinese
|
||||
config.aiResponseLength = .medium
|
||||
|
||||
try? await sleep(2.4)
|
||||
@@ -86,16 +90,16 @@ struct AIKeyboardDemoView: View {
|
||||
try? await sleep(0.2)
|
||||
levelTick = Double.random(in: 0.25...0.9)
|
||||
state.level = levelTick
|
||||
state.aiSession.updateTranscript(Self.question, utteranceID: utteranceID)
|
||||
state.aiSession.updateTranscript(question, utteranceID: utteranceID)
|
||||
}
|
||||
|
||||
state.aiSession.beginRecognizing(utteranceID: utteranceID)
|
||||
try? await sleep(0.55)
|
||||
state.aiSession.beginGenerating(question: Self.question, utteranceID: utteranceID)
|
||||
state.aiSession.beginGenerating(question: question, utteranceID: utteranceID)
|
||||
try? await sleep(0.7)
|
||||
|
||||
// Progressive draft so the real answer area updates like production.
|
||||
let chars = Array(Self.answer)
|
||||
let chars = Array(answer)
|
||||
var index = 0
|
||||
let step = 4
|
||||
while index < chars.count {
|
||||
@@ -106,8 +110,10 @@ struct AIKeyboardDemoView: View {
|
||||
)
|
||||
try? await sleep(0.05)
|
||||
}
|
||||
state.aiSession.receiveAnswer(Self.answer, utteranceID: utteranceID)
|
||||
try? await sleep(1.0)
|
||||
state.aiSession.receiveAnswer(answer, utteranceID: utteranceID)
|
||||
// Hold the explicit-insert review state long enough for screen capture
|
||||
// and for viewers to read the answer before the demo advances.
|
||||
try? await sleep(3.0)
|
||||
|
||||
state.aiSession.markAnswerInserted(offersSend: true)
|
||||
try? await sleep(1.1)
|
||||
@@ -116,7 +122,7 @@ struct AIKeyboardDemoView: View {
|
||||
}
|
||||
|
||||
private func prepareKeyboardState() {
|
||||
state.surface = .ai
|
||||
state.surface = .voice
|
||||
state.aiServiceAvailable = true
|
||||
state.micDisabled = false
|
||||
state.layoutWidth = 390
|
||||
@@ -125,6 +131,30 @@ struct AIKeyboardDemoView: View {
|
||||
state.aiSession.enter()
|
||||
}
|
||||
|
||||
private var language: WhatsNewDemoScenario.Language {
|
||||
let prefix = "--whats-new-lang="
|
||||
guard let argument = ProcessInfo.processInfo.arguments.first(
|
||||
where: { $0.hasPrefix(prefix) }
|
||||
) else {
|
||||
return .zh
|
||||
}
|
||||
return WhatsNewDemoScenario.Language(
|
||||
rawValue: String(argument.dropFirst(prefix.count))
|
||||
) ?? .zh
|
||||
}
|
||||
|
||||
private var question: String {
|
||||
language == .en
|
||||
? "Where should I go this weekend?"
|
||||
: "周末去哪儿玩比较合适?"
|
||||
}
|
||||
|
||||
private var answer: String {
|
||||
language == .en
|
||||
? "Try a nearby town day trip: walk through a park or old street, stop at a café, then have a local dinner."
|
||||
: "可以去近郊走走:上午逛古镇或公园,下午找一家口碑好的咖啡馆休息,傍晚再吃顿当地特色菜。"
|
||||
}
|
||||
|
||||
/// Slow-mo for screenshot-sequence recording.
|
||||
private func sleep(_ seconds: Double) async throws {
|
||||
try await Task.sleep(nanoseconds: UInt64(seconds * 2.4 * 1_000_000_000))
|
||||
|
||||
@@ -191,4 +191,54 @@ struct AssistantKeyboardUITestHarness: View {
|
||||
state.showsSystemGlobeKey = isIPad
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical-device harness that gives XCUITest a real user tap for PiP.
|
||||
///
|
||||
/// `devicectl process launch` is not a user interaction, so iOS may silently
|
||||
/// ignore a programmatic foreground PiP request even when AVKit reports it as
|
||||
/// possible. This harness isolates the production controller behind one tap.
|
||||
struct FlowPiPDeviceUITestHarness: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@StateObject private var flowManager = FlowSessionManager()
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Button("Start PiP") {
|
||||
flowManager.startSession(reason: "uiTest.userTap")
|
||||
}
|
||||
.accessibilityIdentifier("pip.start")
|
||||
.disabled(flowManager.isStarting || flowManager.isActive)
|
||||
|
||||
Text(statusIdentifier)
|
||||
.accessibilityIdentifier(statusIdentifier)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background {
|
||||
FlowPiPHostView { view in
|
||||
flowManager.attachPiPHostView(view)
|
||||
}
|
||||
.frame(width: 64, height: 36)
|
||||
.opacity(0.02)
|
||||
}
|
||||
.onAppear {
|
||||
flowManager.setAppForeground(scenePhase == .active)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, phase in
|
||||
flowManager.handleScenePhase(phase)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIdentifier: String {
|
||||
if flowManager.isActive, FlowSessionBridge.isHostReady() {
|
||||
return "pip.status.ready"
|
||||
}
|
||||
if flowManager.sessionWarning != nil {
|
||||
return "pip.status.failed"
|
||||
}
|
||||
if flowManager.isStarting {
|
||||
return "pip.status.starting"
|
||||
}
|
||||
return "pip.status.idle"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -137,8 +137,8 @@ struct MainAppRoot: View {
|
||||
releaseNotes.presentIfNeeded(onboardingCompleted: true)
|
||||
}
|
||||
|
||||
/// Rime remains startup-owned, but a short delay keeps its CPU and file I/O
|
||||
/// away from SwiftUI's first-frame layout on installs and version updates.
|
||||
/// Rime remains startup-owned, but PiP gets exclusive use of the launch
|
||||
/// critical path before deployment claims CPU, file I/O and memory.
|
||||
private func scheduleRimeDeployment(reason: String) {
|
||||
rimeStartupTask?.cancel()
|
||||
guard !RimeResourceInstaller.isReady else {
|
||||
@@ -148,12 +148,22 @@ struct MainAppRoot: View {
|
||||
}
|
||||
|
||||
OSGDiag.log(
|
||||
"rime startup scheduled reason=\(reason) delay=500ms \(OSGDiag.memoryTag())",
|
||||
"rime startup scheduled reason=\(reason) afterFlowStart \(OSGDiag.memoryTag())",
|
||||
category: "flow"
|
||||
)
|
||||
rimeStartupTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||
|
||||
// A normal cold PiP start settles in under one second. Keep a
|
||||
// bounded ceiling so an unavailable PiP never blocks typing
|
||||
// resource installation for the rest of the foreground session.
|
||||
let flowDeadline = Date().addingTimeInterval(12)
|
||||
while flowManager.shouldDeferHostHeavyWork, Date() < flowDeadline {
|
||||
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
}
|
||||
guard !Task.isCancelled, scenePhase == .active else { return }
|
||||
RimeDeploymentController.shared.deployNow(reason: reason)
|
||||
rimeStartupTask = nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user