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:
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
|
||||
|
||||
### Version format
|
||||
|
||||
The current source-of-truth version is **1.8.0 (build 74)**. Releases use stable SemVer:
|
||||
The current source-of-truth version is **1.8.0 (build 79)**. Releases use stable SemVer:
|
||||
|
||||
| Field | File | Rule |
|
||||
|-------|------|------|
|
||||
|
||||
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [1.8.0] - 2026-08-14
|
||||
|
||||
> **Release highlights**: See the concise [1.8.0 release notes](docs/RELEASE_NOTES_1.8.0.md) for the key changes since 1.6.6. / **版本亮点**:请参阅精简的 [1.8.0 更新说明](docs/RELEASE_NOTES_1.8.0.md),了解自 1.6.6 以来的关键变化。
|
||||
|
||||
### Added
|
||||
- **English QuickType bar**: while typing a word, three equal slots show the verbatim text (quoted when unknown), the unique Space correction, and a completion. The bar stays empty before typing and between committed words. / **英文 QuickType 栏**:输入单词时,三个等宽格显示原文(生词带引号)、空格会采用的唯一纠错和补全;尚未输入及单词提交后保持空白。
|
||||
- **System English lexicon**: typing uses `UITextChecker` completions/guesses and `requestSupplementaryLexicon` contact names / text replacements. / **系统英文词库**:打字使用 `UITextChecker` 补全/猜测,以及 `requestSupplementaryLexicon` 的通讯录名与文本替换。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -7,9 +7,30 @@
|
||||
// dyld (usually host coexistence or an oversized Shared+Rime mapping).
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#include <mach/mach.h>
|
||||
#include <mach/task_info.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static double OSGKeyboardExtPhysFootprintMB(void) {
|
||||
task_vm_info_data_t info = {0};
|
||||
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
|
||||
kern_return_t result = task_info(
|
||||
mach_task_self(),
|
||||
TASK_VM_INFO,
|
||||
(task_info_t)&info,
|
||||
&count
|
||||
);
|
||||
if (result != KERN_SUCCESS) {
|
||||
return -1;
|
||||
}
|
||||
return (double)info.phys_footprint / 1048576.0;
|
||||
}
|
||||
|
||||
__attribute__((constructor))
|
||||
static void OSGKeyboardExtBootProbe(void) {
|
||||
NSLog(@"[OSGDiag/boot] dyld.constructor pid=%d", getpid());
|
||||
NSLog(
|
||||
@"[OSGDiag/boot] dyld.constructor pid=%d foot=%.1fMB",
|
||||
getpid(),
|
||||
OSGKeyboardExtPhysFootprintMB()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,18 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
/// Coalesces host-document refreshes after mutations issued by this keyboard.
|
||||
private var assistantFieldActionRefreshTask: Task<Void, Never>?
|
||||
|
||||
private var memoryTelemetryContext: String {
|
||||
let language = typingSessionStorage?.language.rawValue ?? "-"
|
||||
return "surface=\(state.surface.rawValue) language=\(language) "
|
||||
+ "fullAccess=\(hasFullAccess ? 1 : 0) "
|
||||
+ "clipboard=\(state.clipboardHistoryEnabled ? 1 : 0)"
|
||||
}
|
||||
|
||||
private func recordMemory(_ stage: String, details: String? = nil) {
|
||||
KeyboardExtensionMemoryTelemetry.updateContext(memoryTelemetryContext)
|
||||
KeyboardExtensionMemoryTelemetry.record(stage, details: details)
|
||||
}
|
||||
|
||||
private var editHintScheduler: EditHintScheduler!
|
||||
private var textInserter: KeyboardTextInserter!
|
||||
private var flowCoordinator: KeyboardFlowCoordinator!
|
||||
@@ -104,14 +116,22 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// MARK: - Init
|
||||
|
||||
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
KeyboardExtensionMemoryTelemetry.begin(
|
||||
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||||
)
|
||||
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
|
||||
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
|
||||
recordMemory("KVC.init.done")
|
||||
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
|
||||
public required init?(coder: NSCoder) {
|
||||
KeyboardExtensionMemoryTelemetry.begin(
|
||||
context: "surface=uninitialized language=- fullAccess=- clipboard=-"
|
||||
)
|
||||
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
|
||||
super.init(coder: coder)
|
||||
recordMemory("KVC.init.done")
|
||||
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
|
||||
@@ -128,6 +148,11 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// Voice-first keyboard — hide the misleading "English" subtitle in Settings.
|
||||
primaryLanguage = "mis"
|
||||
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
|
||||
recordMemory(
|
||||
"KVC.viewDidLoad.begin",
|
||||
details: "preferredSurface=\(preferred.rawValue)"
|
||||
)
|
||||
KeyboardExtensionMemoryTelemetry.startBootSampling()
|
||||
OSGDiag.log(
|
||||
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
|
||||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||||
@@ -145,19 +170,31 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
installKeyboardHeight()
|
||||
configureDictationBehavior()
|
||||
installServices()
|
||||
recordMemory("KVC.viewDidLoad.afterInstallServices")
|
||||
OSGDiag.log("KVC.viewDidLoad after installServices \(OSGDiag.memoryTag())", category: "boot")
|
||||
// Apply open preference before mounting SwiftUI so the first frame is
|
||||
// already voice or typing — avoids a visible surface flash.
|
||||
applyPreferredSurfaceOnOpen()
|
||||
recordMemory("KVC.viewDidLoad.afterPreferredSurface")
|
||||
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
|
||||
installTypingContextProviders()
|
||||
installStateActions()
|
||||
installSurfaceObservers()
|
||||
installSwiftUI()
|
||||
recordMemory("KVC.viewDidLoad.afterInstallSwiftUI")
|
||||
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
|
||||
_ = configSync.loadPersistedConfig()
|
||||
let configLoadResult = configSync.loadPersistedConfig()
|
||||
recordMemory(
|
||||
"KVC.viewDidLoad.afterConfigLoad",
|
||||
details: "result=\(configLoadResult)"
|
||||
)
|
||||
configSync.installDarwinObservers()
|
||||
flowCoordinator.refreshSessionState()
|
||||
recordMemory(
|
||||
"KVC.viewDidLoad.done",
|
||||
details: "sessionActive=\(FlowSessionBridge.isSessionActive() ? 1 : 0) "
|
||||
+ "hostReady=\(FlowSessionBridge.isHostReady() ? 1 : 0)"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
|
||||
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
|
||||
@@ -179,6 +216,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
recordMemory(
|
||||
"KVC.viewWillDisappear",
|
||||
details: "preserve=\(flowCoordinator.preservesLifecycleOnDisappear ? 1 : 0)"
|
||||
)
|
||||
heightPhase = .idle
|
||||
flowCoordinator.stopSessionMonitor()
|
||||
// Remember what the user left on, then pre-position a reused
|
||||
@@ -215,6 +256,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
recordMemory("KVC.viewWillAppear.begin")
|
||||
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
|
||||
configureDictationBehavior()
|
||||
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
|
||||
@@ -229,12 +271,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// Re-warm Taptic after host app switches: SwiftUI `onAppear` often
|
||||
// skips when the extension process is reused, leaving generators cold.
|
||||
KeyboardHapticFeedback.prepare()
|
||||
recordMemory("KVC.viewWillAppear.afterHaptics")
|
||||
if state.surface == .typing {
|
||||
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
recordMemory("KVC.viewWillAppear.afterTypingEnter")
|
||||
}
|
||||
clipboardCapture.keyboardDidAppear()
|
||||
recordMemory("KVC.viewWillAppear.afterClipboard")
|
||||
recordMemory("KVC.viewWillAppear.done")
|
||||
OSGDiag.log(
|
||||
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -263,6 +309,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
recordMemory("KVC.viewDidAppear.begin")
|
||||
disableSystemGestureDelays()
|
||||
heightPhase = .presented
|
||||
lockPresentedKeyboardHeight()
|
||||
@@ -296,6 +343,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
recordMemory("KVC.viewDidAppear.done")
|
||||
}
|
||||
|
||||
public override func textDidChange(_ textInput: UITextInput?) {
|
||||
@@ -332,6 +380,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
recordMemory("KVC.didReceiveMemoryWarning")
|
||||
OSGDiag.log(
|
||||
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -621,6 +670,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
category: "boot"
|
||||
)
|
||||
state.surface = surface
|
||||
recordMemory("KVC.applySurface", details: "requested=\(surface.rawValue)")
|
||||
if surface == .typing {
|
||||
typingSession.enterTypingMode()
|
||||
refreshEnglishSupplementaryLexicon()
|
||||
|
||||
@@ -65,7 +65,17 @@ final class ClipboardCaptureCoordinator {
|
||||
|
||||
func keyboardDidAppear() {
|
||||
isKeyboardVisible = true
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"clipboard.reload.begin",
|
||||
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
|
||||
+ "entries=\(history.entries.count)"
|
||||
)
|
||||
history.reload()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"clipboard.reload.done",
|
||||
details: "enabled=\(state.clipboardHistoryEnabled ? 1 : 0) "
|
||||
+ "entries=\(history.entries.count)"
|
||||
)
|
||||
// A suggestion belongs to one keyboard presentation. Clear any
|
||||
// presentation state left behind by a reused extension controller.
|
||||
endCurrentSuggestion()
|
||||
|
||||
@@ -131,9 +131,15 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func enterTypingMode() {
|
||||
let hostHeavy = FlowSessionBridge.isHostHeavy()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.enter.begin",
|
||||
details: "language=\(language.rawValue) schema=\(schema.rawValue) "
|
||||
+ "hostHeavy=\(hostHeavy ? 1 : 0)"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"typing.enter begin lang=\(language.rawValue) schema=\(schema.rawValue) "
|
||||
+ "hostHeavy=\(FlowSessionBridge.isHostHeavy() ? 1 : 0) \(OSGDiag.memoryTag())",
|
||||
+ "hostHeavy=\(hostHeavy ? 1 : 0) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
)
|
||||
TypingInputConfiguration.shared.reload()
|
||||
@@ -142,13 +148,25 @@ public final class TypingSessionController: ObservableObject {
|
||||
// already has Rime; loading both on appear is what jetsams the extension.
|
||||
if language == .english {
|
||||
englishEngine.prepare()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.englishPrepare.done",
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
OSGDiag.log("typing.enter after englishPrepare \(OSGDiag.memoryTag())", category: "boot")
|
||||
} else {
|
||||
EnglishLexicon.shared.unload()
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.englishPrepare.skipped",
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
OSGDiag.log("typing.enter skip englishPrepare lang=\(language.rawValue) \(OSGDiag.memoryTag())", category: "boot")
|
||||
}
|
||||
syncAutocapitalization()
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
if hostHeavy {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
details: "hostHeavy=1"
|
||||
)
|
||||
OSGDiag.log("typing.enter defer rime hostHeavy=1 — retry scheduled", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
prepareTask = Task { [weak self] in
|
||||
@@ -174,6 +192,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
}
|
||||
|
||||
public func leaveTypingMode() {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.leave.begin",
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
OSGDiag.log("typing.leave \(OSGDiag.memoryTag())", category: "boot")
|
||||
prepareTask?.cancel()
|
||||
prepareTask = nil
|
||||
@@ -189,6 +211,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
// Drop English lexicon pages when leaving typing (jetsam recovery).
|
||||
EnglishLexicon.shared.unload()
|
||||
englishStorage = nil
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.leave.done",
|
||||
details: "language=\(language.rawValue)"
|
||||
)
|
||||
}
|
||||
|
||||
public func toggleCandidatePanelExpanded() {
|
||||
@@ -907,9 +933,18 @@ public final class TypingSessionController: ObservableObject {
|
||||
private func prepareIfNeeded() async {
|
||||
guard !prepared else { return }
|
||||
if FlowSessionBridge.isHostHeavy() {
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.deferred",
|
||||
details: "hostHeavy=1"
|
||||
)
|
||||
OSGDiag.log("rime.prepare deferred hostHeavy=1 \(OSGDiag.memoryTag())", category: "boot")
|
||||
return
|
||||
}
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.begin",
|
||||
details: "language=\(language.rawValue) "
|
||||
+ "resourcesReady=\(RimeResourceInstaller.isReady ? 1 : 0)"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"rime.prepare begin ready=\(RimeResourceInstaller.isReady) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -925,6 +960,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
if language == .english {
|
||||
refreshEnglishSuggestions()
|
||||
}
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.done",
|
||||
details: "language=\(language.rawValue) ready=\(engineReady ? 1 : 0)"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"rime.prepare done ready=\(engineReady) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
@@ -934,6 +973,10 @@ public final class TypingSessionController: ObservableObject {
|
||||
lastErrorNeedsHostDeployment =
|
||||
(error as? RimeResourceError)?.isResolvedByHostDeployment ?? false
|
||||
engineReady = false
|
||||
KeyboardExtensionMemoryTelemetry.record(
|
||||
"typing.rimePrepare.failed",
|
||||
details: "language=\(language.rawValue) errorType=\(String(describing: type(of: error)))"
|
||||
)
|
||||
OSGDiag.log(
|
||||
"rime.prepare failed error=\(error.localizedDescription) \(OSGDiag.memoryTag())",
|
||||
category: "boot"
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// KeyboardExtensionMemoryTelemetry.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Observes keyboard-extension memory without changing runtime behavior.
|
||||
// The host process never starts this monitor, so shared typing code can emit
|
||||
// extension-only milestones without duplicating host telemetry.
|
||||
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
public enum KeyboardExtensionMemoryBudget {
|
||||
/// Start preserving evidence before the extension reaches its safe ceiling.
|
||||
public static let warningMB: Double = 36
|
||||
/// Internal release target. Apple's keyboard-extension limit is not public.
|
||||
public static let safePeakMB: Double = 40
|
||||
/// Leave headroom below the observed ~60 MiB device jetsam boundary.
|
||||
public static let criticalMB: Double = 48
|
||||
|
||||
public enum Level: String, Sendable, Equatable {
|
||||
case normal
|
||||
case warning
|
||||
case high
|
||||
case critical
|
||||
case unavailable
|
||||
}
|
||||
|
||||
public static func level(forPhysFootprintMB footprintMB: Double) -> Level {
|
||||
guard footprintMB >= 0 else { return .unavailable }
|
||||
if footprintMB >= criticalMB { return .critical }
|
||||
if footprintMB >= safePeakMB { return .high }
|
||||
if footprintMB >= warningMB { return .warning }
|
||||
return .normal
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
public enum KeyboardExtensionMemoryTelemetry {
|
||||
private static let peakLogStepMB: Double = 4
|
||||
private static let bootSampleInterval = Duration.milliseconds(50)
|
||||
private static let bootSampleDuration: TimeInterval = 4
|
||||
|
||||
private static var isActive = false
|
||||
private static var processID: Int32 = 0
|
||||
private static var context = "surface=- language=-"
|
||||
private static var baselineFootprintMB: Double = -1
|
||||
private static var peakFootprintMB: Double = -1
|
||||
private static var lastLoggedPeakMB: Double = -1
|
||||
private static var highestLevel = KeyboardExtensionMemoryBudget.Level.normal
|
||||
private static var startedAt: TimeInterval = 0
|
||||
private static var samplingTask: Task<Void, Never>?
|
||||
|
||||
public static func begin(context initialContext: String) {
|
||||
samplingTask?.cancel()
|
||||
samplingTask = nil
|
||||
isActive = true
|
||||
processID = getpid()
|
||||
context = initialContext
|
||||
startedAt = ProcessInfo.processInfo.systemUptime
|
||||
|
||||
let snapshot = OSGDiag.memorySnapshot()
|
||||
baselineFootprintMB = snapshot.physFootprintMB
|
||||
peakFootprintMB = snapshot.physFootprintMB
|
||||
lastLoggedPeakMB = snapshot.physFootprintMB
|
||||
highestLevel = .normal
|
||||
emit(stage: "process.begin", snapshot: snapshot, alwaysLog: true)
|
||||
}
|
||||
|
||||
public static func updateContext(_ newContext: String) {
|
||||
guard isActive else { return }
|
||||
context = newContext
|
||||
}
|
||||
|
||||
public static func record(_ stage: String, details: String? = nil) {
|
||||
guard isActive else { return }
|
||||
let eventContext = details.map { "\(context) \($0)" } ?? context
|
||||
emit(
|
||||
stage: stage,
|
||||
snapshot: OSGDiag.memorySnapshot(),
|
||||
eventContext: eventContext,
|
||||
alwaysLog: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Samples short-lived startup spikes that milestone-only logging can miss.
|
||||
/// Poll samples log only on a new budget band or each additional 4 MiB peak.
|
||||
public static func startBootSampling() {
|
||||
guard isActive else { return }
|
||||
samplingTask?.cancel()
|
||||
let deadline = ProcessInfo.processInfo.systemUptime + bootSampleDuration
|
||||
samplingTask = Task { @MainActor in
|
||||
while !Task.isCancelled, ProcessInfo.processInfo.systemUptime < deadline {
|
||||
try? await Task.sleep(for: bootSampleInterval)
|
||||
guard !Task.isCancelled else { return }
|
||||
emit(
|
||||
stage: "boot.sample",
|
||||
snapshot: OSGDiag.memorySnapshot(),
|
||||
eventContext: context,
|
||||
alwaysLog: false
|
||||
)
|
||||
}
|
||||
samplingTask = nil
|
||||
record("boot.sample.complete")
|
||||
}
|
||||
}
|
||||
|
||||
private static func emit(
|
||||
stage: String,
|
||||
snapshot: OSGDiag.MemorySnapshot,
|
||||
eventContext: String? = nil,
|
||||
alwaysLog: Bool
|
||||
) {
|
||||
let footprint = snapshot.physFootprintMB
|
||||
if footprint >= 0 {
|
||||
peakFootprintMB = max(peakFootprintMB, footprint)
|
||||
}
|
||||
let level = KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: footprint)
|
||||
let crossedLevel = levelRank(level) > levelRank(highestLevel)
|
||||
if crossedLevel {
|
||||
highestLevel = level
|
||||
}
|
||||
let peakAdvanced = peakFootprintMB >= 0
|
||||
&& (lastLoggedPeakMB < 0 || peakFootprintMB - lastLoggedPeakMB >= peakLogStepMB)
|
||||
guard alwaysLog || crossedLevel || peakAdvanced else { return }
|
||||
if peakFootprintMB >= 0 {
|
||||
lastLoggedPeakMB = peakFootprintMB
|
||||
}
|
||||
|
||||
let elapsedMS = max(
|
||||
0,
|
||||
Int((ProcessInfo.processInfo.systemUptime - startedAt) * 1_000)
|
||||
)
|
||||
let delta = baselineFootprintMB >= 0 && peakFootprintMB >= 0
|
||||
? peakFootprintMB - baselineFootprintMB
|
||||
: -1
|
||||
OSGDiag.log(
|
||||
String(
|
||||
format: "extMemory pid=%d stage=%@ elapsed=%dms level=%@ crossed=%d "
|
||||
+ "rss=%.1fMB foot=%.1fMB peak=%.1fMB delta=%.1fMB "
|
||||
+ "safe=%dMB critical=%dMB context={%@}",
|
||||
processID,
|
||||
stage,
|
||||
elapsedMS,
|
||||
level.rawValue,
|
||||
crossedLevel ? 1 : 0,
|
||||
snapshot.rssMB,
|
||||
footprint,
|
||||
peakFootprintMB,
|
||||
delta,
|
||||
Int(KeyboardExtensionMemoryBudget.safePeakMB),
|
||||
Int(KeyboardExtensionMemoryBudget.criticalMB),
|
||||
eventContext ?? context
|
||||
),
|
||||
category: "memory"
|
||||
)
|
||||
}
|
||||
|
||||
private static func levelRank(_ level: KeyboardExtensionMemoryBudget.Level) -> Int {
|
||||
switch level {
|
||||
case .unavailable:
|
||||
return -1
|
||||
case .normal:
|
||||
return 0
|
||||
case .warning:
|
||||
return 1
|
||||
case .high:
|
||||
return 2
|
||||
case .critical:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,22 @@ import Foundation
|
||||
import Darwin
|
||||
|
||||
public enum OSGDiag {
|
||||
public struct MemorySnapshot: Sendable, Equatable {
|
||||
public let rssMB: Double
|
||||
public let physFootprintMB: Double
|
||||
|
||||
public init(rssMB: Double, physFootprintMB: Double) {
|
||||
self.rssMB = rssMB
|
||||
self.physFootprintMB = physFootprintMB
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefix every line for easy Console search: `OSGDiag`
|
||||
public static func log(_ message: String, category: String = "diag") {
|
||||
let line = "[OSGDiag/\(category)] \(message)"
|
||||
NSLog("%@", line)
|
||||
switch category {
|
||||
case "keyboardExt", "boot":
|
||||
case "keyboardExt", "boot", "memory":
|
||||
OSGLog.keyboardExt.info("\(line, privacy: .public)")
|
||||
case "flow", "asr":
|
||||
OSGLog.flow.info("\(line, privacy: .public)")
|
||||
@@ -40,7 +50,31 @@ public enum OSGDiag {
|
||||
return Double(info.resident_size) / 1_048_576.0
|
||||
}
|
||||
|
||||
/// Physical footprint in MiB. iOS jetsam tracks this more closely than RSS.
|
||||
public static func physFootprintMB() -> Double {
|
||||
var info = task_vm_info_data_t()
|
||||
var count = mach_msg_type_number_t(
|
||||
MemoryLayout<task_vm_info_data_t>.size / MemoryLayout<natural_t>.size
|
||||
)
|
||||
let kr = withUnsafeMutablePointer(to: &info) { ptr in
|
||||
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { rebound in
|
||||
task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), rebound, &count)
|
||||
}
|
||||
}
|
||||
guard kr == KERN_SUCCESS else { return -1 }
|
||||
return Double(info.phys_footprint) / 1_048_576.0
|
||||
}
|
||||
|
||||
public static func memorySnapshot() -> MemorySnapshot {
|
||||
MemorySnapshot(rssMB: memoryMB(), physFootprintMB: physFootprintMB())
|
||||
}
|
||||
|
||||
public static func memoryTag() -> String {
|
||||
String(format: "rss=%.1fMB", memoryMB())
|
||||
let snapshot = memorySnapshot()
|
||||
return String(
|
||||
format: "rss=%.1fMB foot=%.1fMB",
|
||||
snapshot.rssMB,
|
||||
snapshot.physFootprintMB
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// KeyboardExtensionMemoryBudgetTests.swift
|
||||
// OSGKeyboardTests
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class KeyboardExtensionMemoryBudgetTests: XCTestCase {
|
||||
func testMemoryLevelsUseDocumentedBoundaries() {
|
||||
XCTAssertEqual(
|
||||
KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: 35.9),
|
||||
.normal
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: 36),
|
||||
.warning
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: 40),
|
||||
.high
|
||||
)
|
||||
XCTAssertEqual(
|
||||
KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: 48),
|
||||
.critical
|
||||
)
|
||||
}
|
||||
|
||||
func testUnavailableFootprintIsNotMisclassifiedAsSafe() {
|
||||
XCTAssertEqual(
|
||||
KeyboardExtensionMemoryBudget.level(forPhysFootprintMB: -1),
|
||||
.unavailable
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,39 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class EnglishKeyboardDeviceUITests: XCTestCase {
|
||||
func testPiPColdStartsFromUserTap() throws {
|
||||
#if targetEnvironment(simulator)
|
||||
throw XCTSkip("PiP is unavailable in the iOS Simulator.")
|
||||
#else
|
||||
continueAfterFailure = false
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = ["--pip-device-ui-test"]
|
||||
let requestedCycles = ProcessInfo.processInfo.environment["PIP_STRESS_COUNT"]
|
||||
.flatMap(Int.init) ?? 30
|
||||
let cycles = min(max(requestedCycles, 1), 100)
|
||||
|
||||
for cycle in 1...cycles {
|
||||
app.terminate()
|
||||
app.launch()
|
||||
|
||||
let startButton = app.buttons["pip.start"]
|
||||
XCTAssertTrue(
|
||||
startButton.waitForExistence(timeout: 10),
|
||||
"Cycle \(cycle): PiP start button did not appear after cold launch."
|
||||
)
|
||||
startButton.tap()
|
||||
|
||||
let ready = app.descendants(matching: .any)["pip.status.ready"]
|
||||
XCTAssertTrue(
|
||||
ready.waitForExistence(timeout: 10),
|
||||
"Cycle \(cycle): PiP did not become active after a real user tap."
|
||||
)
|
||||
}
|
||||
|
||||
app.terminate()
|
||||
#endif
|
||||
}
|
||||
|
||||
func testOSGKeyboardAppearsOnNotesHost() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launchArguments = [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Physical-device PiP / host-wake stress using `devicectl --console` logs.
|
||||
#
|
||||
# Usage:
|
||||
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50]
|
||||
# ./Scripts/device-pip-stress.sh [UDID] [COUNT=50] [SUITES=cold,bgfg,hold]
|
||||
#
|
||||
# Suites:
|
||||
# cold — terminate-existing launch (force-quit / cold start)
|
||||
@@ -15,6 +15,7 @@ cd "$ROOT"
|
||||
|
||||
UDID="${1:-00008130-001C249C0E52001C}"
|
||||
COUNT="${2:-50}"
|
||||
SUITES="${3:-cold,bgfg,hold}"
|
||||
BUNDLE="com.osgkeyboard.ios"
|
||||
SAFARI="com.apple.mobilesafari"
|
||||
OUT_DIR="${ROOT}/.tmp/device-pip-stress-$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -51,41 +52,41 @@ classify_file() {
|
||||
local f="$1"
|
||||
local host="unknown" pip="unknown" mic="unknown"
|
||||
|
||||
if grep -Eq "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
||||
if rg -q "OSGKeyboardApp\.init|MainAppRoot\.onAppear|activateOnForeground" "$f"; then
|
||||
host="success"
|
||||
elif grep -Eq "Launched application with com\.osgkeyboard\.ios" "$f"; then
|
||||
elif rg -q "Launched application with com\.osgkeyboard\.ios" "$f"; then
|
||||
host="launch_only"
|
||||
fi
|
||||
|
||||
if grep -Eq "onboarding incomplete" "$f"; then
|
||||
if rg -q "onboarding incomplete" "$f"; then
|
||||
pip="onboarding"
|
||||
elif grep -Eq "aborted reason=permissions|blocked.*permissions" "$f"; then
|
||||
elif rg -q "aborted reason=permissions|blocked.*permissions" "$f"; then
|
||||
pip="permissions"
|
||||
elif grep -Eq "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" "$f"; then
|
||||
elif rg -q "Flow session started \(PiP keep-alive\)|low-profile PiP active|startSessionAsync\.ready" "$f"; then
|
||||
pip="success"
|
||||
elif grep -Eq "failure=unsupported|failed to start: unsupported" "$f"; then
|
||||
elif rg -q "failure=unsupported|failed to start: unsupported" "$f"; then
|
||||
pip="unsupported"
|
||||
elif grep -Eq "PiP keep-alive failed to start|startSessionAsync\.failed.*pipUnavailable|startAndWait failed" "$f"; then
|
||||
elif rg -q "PiP keep-alive failed to start|startSessionAsync\.failed.*pipUnavailable|startAndWait failed" "$f"; then
|
||||
pip="fail"
|
||||
elif grep -Eq "PiP start attempt failed" "$f"; then
|
||||
elif rg -q "PiP start attempt failed" "$f"; then
|
||||
# Retry path — only fail if we never saw success above
|
||||
pip="retry_then_unknown"
|
||||
elif grep -Eq "activateOnForeground|autoPiP|startSessionAsync\.begin" "$f"; then
|
||||
elif rg -q "activateOnForeground|autoPiP|startSessionAsync\.begin" "$f"; then
|
||||
pip="seen_no_result"
|
||||
fi
|
||||
|
||||
# Mic keep-alive contract: idle releases mic after PiP proves
|
||||
if grep -Eq "mic released between utterances|released audio session and frame pump" "$f"; then
|
||||
if rg -q "mic released between utterances|released audio session and frame pump" "$f"; then
|
||||
mic="released_ok"
|
||||
elif grep -Eq "PiP audio session ready" "$f"; then
|
||||
elif rg -q "PiP audio session ready" "$f"; then
|
||||
mic="armed"
|
||||
else
|
||||
mic="unknown"
|
||||
fi
|
||||
|
||||
# Promote retry_then_unknown if success markers appeared (grep order already handled)
|
||||
# Promote retry_then_unknown if success markers appeared (classification order already handled)
|
||||
if [[ "$pip" == "retry_then_unknown" ]]; then
|
||||
if grep -Eq "low-profile PiP active|Flow session started \(PiP keep-alive\)" "$f"; then
|
||||
if rg -q "low-profile PiP active|Flow session started \(PiP keep-alive\)" "$f"; then
|
||||
pip="success"
|
||||
else
|
||||
pip="fail"
|
||||
@@ -144,9 +145,16 @@ run_suite() {
|
||||
}
|
||||
|
||||
: >"$REPORT"
|
||||
run_suite cold
|
||||
run_suite bgfg
|
||||
run_suite hold
|
||||
IFS=',' read -r -a selected_suites <<<"$SUITES"
|
||||
for selected_suite in "${selected_suites[@]}"; do
|
||||
case "$selected_suite" in
|
||||
cold|bgfg|hold) run_suite "$selected_suite" ;;
|
||||
*)
|
||||
echo "error: unknown suite '$selected_suite'" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
python3 - "$REPORT" "$SUMMARY" "$UDID" "$COUNT" "$OUT_DIR" <<'PY'
|
||||
import json, collections, sys
|
||||
@@ -157,13 +165,14 @@ by = collections.defaultdict(list)
|
||||
for r in rows:
|
||||
by[r["suite"]].append(r)
|
||||
|
||||
selected = [name for name in ("cold", "bgfg", "hold") if by.get(name)]
|
||||
lines = [
|
||||
"Device PiP / mic keep-alive stress summary",
|
||||
f"device=Rocky 15 PM udid={udid} count_per_suite={count}",
|
||||
f"total_rows={len(rows)}",
|
||||
"",
|
||||
]
|
||||
for suite in ("cold", "bgfg", "hold"):
|
||||
for suite in selected:
|
||||
rs = by.get(suite, [])
|
||||
n = max(len(rs), 1)
|
||||
host_ok = sum(1 for r in rs if r["host"] in ("success", "launch_only"))
|
||||
|
||||
@@ -142,7 +142,8 @@
|
||||
"OSGKeyboardTests/AIAddressExtractionTests",
|
||||
"OSGKeyboardTests/AINoteExportTests",
|
||||
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
|
||||
"OSGKeyboardTests/ClipboardHistoryStoreTests"
|
||||
"OSGKeyboardTests/ClipboardHistoryStoreTests",
|
||||
"OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests"
|
||||
]
|
||||
},
|
||||
"host_misc": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# App Store Connect — OSGKeyboard 1.8.0 (build 74)
|
||||
# App Store Connect — OSGKeyboard 1.8.0 (build 79)
|
||||
|
||||
> Current metadata baseline for the iOS/iPadOS App Store build. Version and build
|
||||
> numbers come from `project.yml`. The repository also contains a separate
|
||||
@@ -11,7 +11,7 @@
|
||||
| App name | `OSGKeyboard` | ≤ 30 characters |
|
||||
| Subtitle | `Voice input, everywhere` | ≤ 30 characters |
|
||||
| Bundle ID | `com.osgkeyboard.ios` | iOS host target |
|
||||
| Version / build | `1.8.0` / `74` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
||||
| Version / build | `1.8.0` / `79` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` |
|
||||
| Minimum system | iOS/iPadOS 26 | iPhone and iPad |
|
||||
| Primary locale | `en-US` | Simplified Chinese is also bundled |
|
||||
| Primary category | Utilities | |
|
||||
@@ -211,10 +211,10 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added.
|
||||
|
||||
## Submission checklist
|
||||
|
||||
- [ ] Confirm `project.yml` still reads version 1.8.0 / build 74
|
||||
- [ ] Confirm `project.yml` still reads version 1.8.0 / build 79
|
||||
- [ ] Open the existing Xcode project (do not regenerate unless needed)
|
||||
- [ ] Run the release build and test suites on macOS with Xcode 26
|
||||
- [ ] Replace screenshots with captures from the submitted build
|
||||
- [ ] Verify the privacy answers against the submitted provider features
|
||||
- [ ] Confirm the tip product remains optional and unlocks no feature
|
||||
- [ ] Upload, select build 74, add review notes, and submit
|
||||
- [ ] Upload, select build 79, add review notes, and submit
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# OSGKeyboard 1.8.0 更新说明
|
||||
|
||||
> 以下为 OSGKeyboard 从 1.6.6 升级至 1.8.0 的关键变化。
|
||||
|
||||
## 新功能
|
||||
|
||||
### AI Agent
|
||||
|
||||
- 语音听写与 AI 问答合并为全新的助手模式:轻点麦克风开始听写,长按麦克风可直接语音提问。
|
||||
- 新增热点与情境热词,麦克风区域会展示天气、节日、新闻、热搜等建议;点按即可向 AI 获取相关信息。
|
||||
- AI 回答会实时显示在键盘中,确认后可插入当前输入框;支持的服务商可联网搜索,并在失败时自动回退。
|
||||
- 仅当原输入框与光标上下文仍然匹配时自动插入 AI 回答;上下文变化时会保留回答,等待用户明确插入或丢弃。
|
||||
- 设置 → AI Agent 可调节回复篇幅:简短、中等或详细。
|
||||
|
||||
### 剪贴板与 AI 技能
|
||||
|
||||
- 新增剪贴板历史。在设置 → 剪贴板开启“历史记录”后,可在本机保存最近 15 条纯文本;该功能默认关闭。
|
||||
- 新增剪贴板建议条。可从键盘顶栏打开历史并点按插入;开启建议条后,最新复制内容会显示在键盘上方。
|
||||
- 复制文字后约 30 秒内,助手会显示回复、总结、翻译等快捷技能;技能支持分页浏览,并可在技能页调整顺序。
|
||||
- 新增技能页,最多可将 8 个技能放入键盘。内置技能可提取待办、添加日历日程、存入备忘录,或识别地址后打开高德、百度与 Apple 地图导航。
|
||||
- 新增自定义 AI 技能,可设置名称、图标和提示词,也可选择连接自己的 iCloud 快捷指令。
|
||||
|
||||
### 编辑上次输入
|
||||
|
||||
- 编辑入口移至键盘右下角。点按后可口述修改要求、对比原文与编辑结果,并选择替换原文或追加内容。
|
||||
- 编辑只使用最近一次由 OSGKeyboard 插入且仍可验证的文字,不会读取输入框中的无关内容。
|
||||
|
||||
## 全新 App 界面
|
||||
|
||||
- iPhone 底部导航重做为“首页 / 技能 / 风格 / 设置”四栏 Liquid Glass Dock,并以绿色胶囊标示当前页面。
|
||||
- 首页改为信息总览,将使用统计、听写历史和个性词库集中显示为资料卡。
|
||||
- 技能与润色风格采用统一的卡片式管理:点按选择、右上角编辑、右下角显示启用状态;技能支持长按拖动排序。
|
||||
- 设置入口重新整理,AI Agent、剪贴板、语音与文字输入等页面更容易找到,并直接显示当前设置摘要。
|
||||
- iPad 改为侧栏 + 内容区布局;键盘横竖屏均充分利用可用宽度,并加入系统地球键、撤销、重做、复制和剪切等操作。
|
||||
|
||||
## 中英文输入全面升级
|
||||
|
||||
- 中英文输入均可在本机学习用户的输入习惯、候选频率和选择偏好;设置中可单独清除打字习惯,不会删除个性词库。
|
||||
- 拼音优化混合简拼排序,并支持 `zh`、`ch`、`sh` 两键简拼。
|
||||
- 英文键盘新增类 QuickType 候选栏,同时显示原词、纠错和补全;约 4 万词离线词表会结合系统词库、通讯录名称与文本替换提供建议。
|
||||
- 英文邻键纠错更贴合真实键位,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词。
|
||||
- 支持叠指连打、英文双空格句号,以及根据输入框显示前往、搜索、发送、完成、下一项等操作。
|
||||
|
||||
## 稳定性、隐私与重要变化
|
||||
|
||||
- 听写、AI 回答、编辑结果和剪贴板粘贴可通过统一的撤销操作回滚。
|
||||
- 修复切换键盘崩溃、中文输入初始化后无法恢复、通用剪贴板导致键盘卡住,以及 AI 会话结束后麦克风不可用等问题。
|
||||
- 全部内置与自定义润色风格都会保留问句原意,不再把用户的问题改写成回答。
|
||||
- 设备端语音识别仍为默认选项;AI 使用用户自己配置的服务商与 API Key,不再提供内置 DeepSeek 回退。
|
||||
- 剪贴板历史默认关闭,内容保存在本机,并仅在用户主动调用技能后发送给所配置的 AI 服务商。
|
||||
- 长按麦克风现用于向 AI 提问;编辑上次输入改用右下角编辑按钮。旧的长按剪贴板语音指令已由剪贴板历史和快捷技能取代。
|
||||
- 已安装旧中文名称“待办”或“日程”快捷指令的用户,需要从技能页重新安装 `OSGExtractTodos` 和 `OSGExtractEvents`。
|
||||
- 已移除空白区域滑动光标及其设置开关。
|
||||
|
||||
---
|
||||
|
||||
# OSGKeyboard 1.8.0 Release Notes
|
||||
|
||||
> Key changes when upgrading OSGKeyboard from 1.6.6 to 1.8.0.
|
||||
|
||||
## New Features
|
||||
|
||||
### AI Agent
|
||||
|
||||
- Voice dictation and AI questions now share one Assistant mode: tap the microphone to dictate, or hold it to ask a question by voice.
|
||||
- New trending and contextual hotwords surface suggestions for weather, holidays, news, trending topics, and more; tap one to ask AI about it.
|
||||
- AI answers stream directly into the keyboard for review and insertion. Supported providers can search the web and fall back automatically when needed.
|
||||
- AI answers insert automatically only while the original field and cursor context still match. If the context changes, the answer is retained for explicit insertion or discard.
|
||||
- Settings → AI Agent now offers Short, Medium, and Detailed response lengths.
|
||||
|
||||
### Clipboard and AI Skills
|
||||
|
||||
- New Clipboard History keeps the latest 15 plain-text copies on this device when enabled in Settings → Clipboard. It is off by default.
|
||||
- A new Clipboard suggestion strip lets you open history from the keyboard, insert an item, or keep the latest copy above the keyboard.
|
||||
- For about 30 seconds after copying text, Assistant shows quick Reply, Summarize, Translate, and other skills. Skills support paging and can be reordered from the Skills page.
|
||||
- The new Skills page lets you place up to eight skills on the keyboard. Built-in skills extract reminders, add calendar events, save to Notes, or open navigation in Amap, Baidu Maps, or Apple Maps.
|
||||
- Custom AI Skills support your own name, icon, and prompt, with an optional iCloud Shortcut connection.
|
||||
|
||||
### Edit Last Input
|
||||
|
||||
- Edit moves to the bottom-right keyboard button. Describe the change by voice, compare the original and edited text, then replace or append the result.
|
||||
- Editing uses only the latest verifiable text inserted by OSGKeyboard and does not read unrelated field content.
|
||||
|
||||
## A New App Interface
|
||||
|
||||
- The iPhone dock is rebuilt around four Liquid Glass destinations: Home, Skills, Styles, and Settings, with a green capsule marking the current page.
|
||||
- Home becomes an overview of usage statistics, dictation history, and Personal Dictionary cards.
|
||||
- Skills and polish styles share a card-based interaction: tap to select, edit from the top-right, see enabled status at the bottom-right, and long-press skills to reorder.
|
||||
- Settings navigation is reorganized so AI Agent, Clipboard, Speech, and Text Input are easier to find, with current summaries shown beside each row.
|
||||
- iPad adopts a sidebar + detail layout. The keyboard fills the available portrait and landscape width and adds the system globe key, undo, redo, copy, and cut.
|
||||
|
||||
## Chinese and English Typing, Rebuilt
|
||||
|
||||
- Chinese and English typing learn candidate frequency and selection preferences locally. Typing habits can be cleared without deleting Personal Dictionary entries.
|
||||
- Pinyin improves mixed abbreviations and supports two-key `zh`, `ch`, and `sh` abbreviations.
|
||||
- The English keyboard adds a QuickType-style bar for the typed word, correction, and completion. An offline list of about 40,000 words works with the system lexicon, contact names, and text replacements.
|
||||
- Neighbor-key correction follows the physical keyboard while avoiding unwanted changes to names, short words, and all-caps text. Rejecting a correction teaches the original.
|
||||
- Overlapping key presses, the English double-space period, and contextual Go, Search, Send, Done, and Next actions make typing feel closer to the system keyboard.
|
||||
|
||||
## Stability, Privacy, and Important Changes
|
||||
|
||||
- One Undo action now rolls back dictation, AI answers, edits, and clipboard pastes.
|
||||
- Fixes include keyboard-switch crashes, Chinese input recovery after setup, Universal Clipboard freezes, and a microphone that could remain unavailable after AI sessions.
|
||||
- Every built-in and custom polish style preserves questions instead of rewriting them as answers.
|
||||
- On-device speech recognition remains the default. AI uses your configured provider and API key; the built-in DeepSeek fallback has been removed.
|
||||
- Clipboard History stays off by default, keeps its contents on device, and sends text to the configured AI provider only after you invoke a skill.
|
||||
- Holding the microphone now asks AI, while Edit Last Input moves to the bottom-right Edit button. Clipboard History and quick skills replace the old hold-to-command clipboard flow.
|
||||
- If you installed the older Chinese-named Tasks or Events Shortcuts, reinstall `OSGExtractTodos` and `OSGExtractEvents` from the Skills page.
|
||||
- Blank-area cursor sliding and its Settings toggle have been removed.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -45,6 +45,34 @@ Record `OSGDiag` `rss=` tags for:
|
||||
|
||||
Target: typing peak below the old “voice + eager Librime construct” baseline.
|
||||
|
||||
## Extension physical-footprint budget
|
||||
|
||||
`phys_footprint` is the primary extension metric because device jetsam follows
|
||||
it more closely than RSS. `KeyboardExtensionMemoryTelemetry` records structured
|
||||
`[OSGDiag/memory] extMemory` lines at lifecycle and heavy-resource milestones,
|
||||
plus 50 ms samples during the first four seconds:
|
||||
|
||||
- **Normal:** below 36 MiB
|
||||
- **Warning:** 36–40 MiB
|
||||
- **High:** 40–48 MiB; 40 MiB is the internal safe peak
|
||||
- **Critical:** 48 MiB or above
|
||||
|
||||
The approximate 60 MiB device boundary is not a public Apple contract. The
|
||||
40 MiB target deliberately reserves room for transient SwiftUI, Rime, and
|
||||
system-framework pages. Telemetry is observation-only: crossing a band logs
|
||||
`crossed=1` but does not change the selected surface or unload resources.
|
||||
|
||||
Each record includes the current and peak footprint, delta from process start,
|
||||
elapsed startup time, surface/language, Full Access, clipboard state, and
|
||||
operation-specific context. Filter Console by `OSGDiag/memory` and compare:
|
||||
|
||||
1. `KVC.viewDidLoad.afterInstallServices`
|
||||
2. `KVC.viewDidLoad.afterInstallSwiftUI`
|
||||
3. `typing.englishPrepare.done`
|
||||
4. `typing.rimePrepare.done`
|
||||
5. `clipboard.reload.done`
|
||||
6. `KVC.viewDidAppear.done`
|
||||
|
||||
## Structural split
|
||||
|
||||
- Extension links **OSGKeyboardShared** only (no Charts / StoreKit / Speech / HostSupport).
|
||||
|
||||
@@ -185,32 +185,38 @@
|
||||
<h2>新功能</h2>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">AI 与语音助手</p>
|
||||
<p class="group-title">AI Agent</p>
|
||||
<div class="wn-media wide">
|
||||
<video src="assets/whats-new/ai-voice-assistant-zh.mp4" autoplay muted loop playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<ul>
|
||||
<li>键盘可切换到 <strong>AI</strong>:对着麦克风提问,回答会在键盘中实时出现,方便查看后再写入</li>
|
||||
<li>在即时通信等输入框中,先点白色<strong>「插入」</strong>,再点绿色<strong>「发送」</strong>;只有真正插入的内容会进入历史与字数统计</li>
|
||||
<li>AI 空闲时会轮播常用提问和热点建议,点一下即可生成回答;需要天气、新闻等最新资料时会联网查找,失败会自动换方式重试</li>
|
||||
<li>语音听写与 AI 问答合并为全新的<strong>助手模式</strong>:轻点麦克风开始听写,长按麦克风可直接语音提问</li>
|
||||
<li>新增<strong>热点与情境热词</strong>,麦克风区域会展示天气、节日、新闻、热搜等建议;点按即可向 AI 获取相关信息</li>
|
||||
<li>AI 回答会实时显示在键盘中,确认后可插入当前输入框;支持的服务商可联网搜索,并在失败时自动回退</li>
|
||||
<li>仅当原输入框与光标上下文仍然匹配时自动插入 AI 回答;上下文变化时会保留回答,等待用户明确插入或丢弃</li>
|
||||
<li>设置 → <strong>AI Agent</strong> 可调节<strong>「回复篇幅」</strong>:简短 / 中等 / 详细</li>
|
||||
<li>刚输入过内容时,<strong>长按麦克风</strong>说出修改要求,可预览后替换原文或追加新内容</li>
|
||||
<li>润色会保留原本的提问语气,不再把「你能听到吗」等问句改写成回答</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">剪贴板与技能</p>
|
||||
<p class="group-title">剪贴板与 AI 技能</p>
|
||||
<div class="wn-media wide">
|
||||
<video src="assets/whats-new/clipboard-skills-zh.mp4" autoplay muted loop playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<ul>
|
||||
<li>在设置 → <strong>剪贴板</strong>开启<strong>「历史记录」</strong>(默认关闭),可在本机保存最近 15 条纯文本;页面也可直接前往系统设置,将「从其他 App 粘贴」设为允许</li>
|
||||
<li>键盘顶栏的<strong>剪贴板</strong>按钮可打开历史,点一条即可插入;开启<strong>「建议条」</strong>后,最新复制内容会显示在键盘上方</li>
|
||||
<li>复制文字后约 30 秒内切到 AI,可直接选择<strong>回复、总结或翻译</strong>;也可以说出「回复剪贴板」或「翻译剪贴板」</li>
|
||||
<li>App 新增<strong>技能</strong>页,可管理最多 8 个键盘技能,并通过长按拖动调整顺序</li>
|
||||
<li>右上角 <strong>+</strong> 可自定义技能名称、图标和提示词,也可连接自己的 iCloud 快捷指令</li>
|
||||
<li>内置技能可从复制内容<strong>提取待办、写入日历、存入备忘录</strong>,或识别地址后打开高德、百度与 Apple 地图导航</li>
|
||||
<li>新增<strong>剪贴板历史</strong>。在设置 → 剪贴板开启“历史记录”后,可在本机保存最近 15 条纯文本;该功能默认关闭</li>
|
||||
<li>新增<strong>剪贴板建议条</strong>。可从键盘顶栏打开历史并点按插入;开启建议条后,最新复制内容会显示在键盘上方</li>
|
||||
<li>复制文字后约 30 秒内,助手会显示<strong>回复、总结、翻译</strong>等快捷技能;技能支持分页浏览,并可在技能页调整顺序</li>
|
||||
<li>新增<strong>技能</strong>页,最多可将 8 个技能放入键盘。内置技能可提取待办、添加日历日程、存入备忘录,或打开地图导航</li>
|
||||
<li>新增<strong>自定义 AI 技能</strong>,可设置名称、图标和提示词,也可选择连接自己的 iCloud 快捷指令</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">编辑上次输入</p>
|
||||
<ul>
|
||||
<li>编辑入口移至<strong>键盘右下角</strong>。点按后可口述修改要求、对比原文与编辑结果,并选择替换原文或追加内容</li>
|
||||
<li>编辑只使用最近一次由 OSGKeyboard 插入且仍可验证的文字,不会读取输入框中的无关内容</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -222,29 +228,31 @@
|
||||
<li>iPhone 底部导航重做为 <strong>首页 / 技能 / 风格 / 设置</strong>四栏 Liquid Glass Dock,绿色胶囊清楚标示当前页面</li>
|
||||
<li>技能与润色风格统一为卡片式管理:点按选择、右上角编辑、右下角显示启用状态;技能支持像主屏幕图标一样长按拖动排序</li>
|
||||
<li>设置入口重新整理,AI Agent、剪贴板、语音与文字输入等页面更容易找到,当前设置摘要直接显示在导航行右侧</li>
|
||||
<li>iPad 改为侧栏 + 内容区布局,增大导航触控区域并统一图标对齐;翻译移到麦克风旁与撤销对称,顶栏原位置改为剪贴板入口</li>
|
||||
<li>iPad 改为侧栏 + 内容区布局;键盘横竖屏均充分利用可用宽度,并加入系统地球键、撤销、重做、复制和剪切等操作</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>中英文输入全面升级</h2>
|
||||
<ul>
|
||||
<li>中英文输入均可在本机学习用户的输入习惯、候选频率和选择偏好;设置中可单独清除打字习惯,不会删除个性词库</li>
|
||||
<li>拼音优化混合简拼排序,并支持 <code>zh</code>、<code>ch</code>、<code>sh</code> 两键简拼</li>
|
||||
<li>英文键盘新增类 QuickType 候选栏,同时显示原词、纠错和补全;约 4 万词离线词表会结合系统词库、通讯录名称与文本替换提供建议</li>
|
||||
<li>英文邻键纠错更贴合真实键盘位置,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词</li>
|
||||
<li>拼音优化 <code>wom</code> 等混合简拼排序,并支持 <code>zh</code>、<code>ch</code>、<code>sh</code> 两键简拼;中文候选频率和英文选择偏好会保存在本机</li>
|
||||
<li>英文邻键纠错更贴合真实键位,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词</li>
|
||||
<li>支持叠指连打、英文双空格句号,以及根据输入框显示前往、搜索、发送、完成、下一项等回车键文案</li>
|
||||
<li>iPad 键盘横竖屏均铺满可用宽度,底行加入逗号、句号和系统地球键,并提供撤销、重做、复制和剪切</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>更稳定、更可控</h2>
|
||||
<ul>
|
||||
<li>听写、AI 回答、编辑结果和剪贴板粘贴现在都可通过同一个撤销键一次回滚</li>
|
||||
<li>长按麦克风现在用于<strong>编辑上次输入</strong>;旧的长按剪贴板语音流程已由剪贴板历史和快捷技能替代</li>
|
||||
<li>中文输入资源会在 App 中自动完成初始化,部署完成后已打开的键盘也会自行恢复,无需反复重启</li>
|
||||
<li>修复跨设备剪贴板导致键盘卡住、切换键盘崩溃、录音取消不及时,以及 AI 会话结束后麦克风不可用等问题</li>
|
||||
<li>默认继续使用设备端语音识别;AI 使用你自己的 API Key,未配置时首页和麦克风上方会提示;剪贴板默认关闭,内容只在你主动操作后交给所配置的服务商</li>
|
||||
<li>听写、AI 回答、编辑结果和剪贴板粘贴可通过统一的撤销操作回滚</li>
|
||||
<li>修复切换键盘崩溃、中文输入初始化后无法恢复、通用剪贴板导致键盘卡住,以及 AI 会话结束后麦克风不可用等问题</li>
|
||||
<li>全部内置与自定义润色风格都会保留问句原意,不再把用户的问题改写成回答</li>
|
||||
<li>设备端语音识别仍为默认选项;AI 使用用户自己配置的服务商与 API Key,不再提供内置 DeepSeek 回退</li>
|
||||
<li>长按麦克风现用于向 AI 提问;编辑上次输入改用右下角编辑按钮。旧的长按剪贴板语音指令已由剪贴板历史和快捷技能取代</li>
|
||||
<li>已安装旧中文名称“待办”或“日程”快捷指令的用户,需要从技能页重新安装 <code>OSGExtractTodos</code> 和 <code>OSGExtractEvents</code></li>
|
||||
<li>已移除空白区域滑动光标及其设置开关</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
@@ -254,32 +262,38 @@
|
||||
<h2>New Features</h2>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">AI and voice assistant</p>
|
||||
<p class="group-title">AI Agent</p>
|
||||
<div class="wn-media wide">
|
||||
<video src="assets/whats-new/ai-voice-assistant-en.mp4" autoplay muted loop playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Switch the keyboard to <strong>AI</strong>, ask with the microphone, and watch the answer appear on the keyboard before writing it into the current app</li>
|
||||
<li>In messaging fields, tap white <strong>Insert</strong>, then green <strong>Send</strong>; only inserted answers count toward history and character statistics</li>
|
||||
<li>Idle AI rotates useful prompts and current topics that can be sent with one tap; questions that need weather, news, or other current information use web search with automatic fallback</li>
|
||||
<li>Voice dictation and AI questions now share one <strong>Assistant</strong> mode: tap the microphone to dictate, or hold it to ask a question by voice</li>
|
||||
<li>New <strong>trending and contextual hotwords</strong> surface suggestions for weather, holidays, news, trending topics, and more; tap one to ask AI about it</li>
|
||||
<li>AI answers stream directly into the keyboard for review and insertion. Supported providers can search the web and fall back automatically when needed</li>
|
||||
<li>AI answers insert automatically only while the original field and cursor context still match. If the context changes, the answer is retained for explicit insertion or discard</li>
|
||||
<li>In Settings → <strong>AI Agent</strong>, choose <strong>Response length</strong>: Short / Medium / Detailed</li>
|
||||
<li>Right after entering text, <strong>hold the microphone</strong>, describe the change, then preview and replace the original or append the result</li>
|
||||
<li>Polish preserves your original question instead of rewriting prompts such as “Can you hear me?” into answers</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Clipboard and Skills</p>
|
||||
<p class="group-title">Clipboard and AI Skills</p>
|
||||
<div class="wn-media wide">
|
||||
<video src="assets/whats-new/clipboard-skills-en.mp4" autoplay muted loop playsinline preload="metadata"></video>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Turn on <strong>History</strong> in Settings → <strong>Clipboard</strong> (off by default) to keep the latest 15 plain-text copies on this device; the same page opens system Settings for Paste from Other Apps permission</li>
|
||||
<li>Open history from the keyboard's top-bar <strong>Clipboard</strong> button and tap an item to insert it; the optional <strong>Suggestion strip</strong> keeps the newest copy above the keyboard</li>
|
||||
<li>Within about 30 seconds of copying text, switch to AI and choose <strong>Reply, Summarize, or Translate</strong>, or say “reply to the clipboard” or “translate my clipboard”</li>
|
||||
<li>The new <strong>Skills</strong> page manages up to eight keyboard skills and reorders them with a long-press drag</li>
|
||||
<li>Use the top-right <strong>+</strong> to create a skill with your own name, icon, and prompt, with an optional iCloud Shortcut connection</li>
|
||||
<li>Built-in skills can <strong>extract to-dos, add calendar events, save to Notes</strong>, or recognize an address and open Amap, Baidu Maps, or Apple Maps</li>
|
||||
<li>New <strong>Clipboard History</strong> keeps the latest 15 plain-text copies on this device when enabled in Settings → Clipboard. It is off by default</li>
|
||||
<li>A new <strong>Clipboard suggestion strip</strong> lets you open history from the keyboard, insert an item, or keep the latest copy above the keyboard</li>
|
||||
<li>For about 30 seconds after copying text, Assistant shows quick <strong>Reply, Summarize, Translate</strong>, and other skills. Skills support paging and can be reordered from the Skills page</li>
|
||||
<li>The new <strong>Skills</strong> page lets you place up to eight skills on the keyboard. Built-in skills extract reminders, add calendar events, save to Notes, or open map navigation</li>
|
||||
<li><strong>Custom AI Skills</strong> support your own name, icon, and prompt, with an optional iCloud Shortcut connection</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-title">Edit Last Input</p>
|
||||
<ul>
|
||||
<li>Edit moves to the <strong>bottom-right keyboard button</strong>. Describe the change by voice, compare the original and edited text, then replace or append the result</li>
|
||||
<li>Editing uses only the latest verifiable text inserted by OSGKeyboard and does not read unrelated field content</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
@@ -291,29 +305,31 @@
|
||||
<li>The iPhone dock is rebuilt around four Liquid Glass destinations — <strong>Home / Skills / Styles / Settings</strong> — with a green capsule marking the current page</li>
|
||||
<li>Skills and polish styles share one card-based interaction: tap to select, edit from the top-right, and see enabled status at the bottom-right; skills reorder with a Home Screen-style long-press drag</li>
|
||||
<li>Settings navigation is reorganized so AI Agent, Clipboard, Speech, and Text Input are easier to find, with current summaries shown directly beside each row</li>
|
||||
<li>iPad uses a sidebar + detail layout with larger, aligned navigation rows; Translation moves beside the microphone opposite Undo, and its former top-bar slot becomes Clipboard</li>
|
||||
<li>iPad adopts a sidebar + detail layout. The keyboard fills the available portrait and landscape width and adds the system globe key, undo, redo, copy, and cut</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Chinese and English Typing, Rebuilt</h2>
|
||||
<ul>
|
||||
<li>Chinese and English typing learn candidate frequency and selection preferences locally. Typing habits can be cleared without deleting Personal Dictionary entries</li>
|
||||
<li>Pinyin improves mixed abbreviations and supports two-key <code>zh</code>, <code>ch</code>, and <code>sh</code> abbreviations</li>
|
||||
<li>The English keyboard adds a QuickType-style bar for the typed word, correction, and completion; an offline list of about 40,000 words works with the system lexicon, contact names, and text replacements</li>
|
||||
<li>Neighbor-key correction follows the physical keyboard while autocorrect makes fewer unwanted changes to names, short words, and all-caps text; rejecting a correction teaches the original</li>
|
||||
<li>Pinyin improves mixed abbreviations such as <code>wom</code> and supports two-key <code>zh</code>, <code>ch</code>, and <code>sh</code>; Chinese candidate frequency and English choices learn locally</li>
|
||||
<li>Neighbor-key correction follows the physical keyboard while avoiding unwanted changes to names, short words, and all-caps text; rejecting a correction teaches the original</li>
|
||||
<li>Overlapping key presses, the English double-space period, and contextual Go / Search / Send / Done / Next Return labels make typing feel closer to the system keyboard</li>
|
||||
<li>On iPad, the keyboard fills portrait and landscape width, adds comma, period, and the system globe key, plus undo, redo, copy, and cut</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>More Stable, More Controllable</h2>
|
||||
<ul>
|
||||
<li>One Undo key now rolls back dictation, AI answers, edit results, and clipboard pastes</li>
|
||||
<li>Holding the microphone now <strong>edits the last input</strong>; Clipboard History and quick skills replace the previous hold-to-command clipboard flow</li>
|
||||
<li>Chinese input resources initialize automatically in the app, and an already-open keyboard recovers after deployment without repeated restarts</li>
|
||||
<li>Fixes Universal Clipboard freezes, keyboard-switch crashes, delayed recording cancellation, and a microphone that could remain unavailable after AI sessions</li>
|
||||
<li>On-device speech recognition remains the default; AI uses your own API key, with setup tips on Home and above the microphone when missing; Clipboard stays off until enabled and sends text only after your action</li>
|
||||
<li>One Undo action now rolls back dictation, AI answers, edits, and clipboard pastes</li>
|
||||
<li>Fixes include keyboard-switch crashes, Chinese input recovery after setup, Universal Clipboard freezes, and a microphone that could remain unavailable after AI sessions</li>
|
||||
<li>Every built-in and custom polish style preserves questions instead of rewriting them as answers</li>
|
||||
<li>On-device speech recognition remains the default. AI uses your configured provider and API key; the built-in DeepSeek fallback has been removed</li>
|
||||
<li>Holding the microphone now asks AI, while Edit Last Input moves to the bottom-right Edit button. Clipboard History and quick skills replace the old hold-to-command clipboard flow</li>
|
||||
<li>If you installed the older Chinese-named Tasks or Events Shortcuts, reinstall <code>OSGExtractTodos</code> and <code>OSGExtractEvents</code> from the Skills page</li>
|
||||
<li>Blank-area cursor sliding and its Settings toggle have been removed</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ settings:
|
||||
STRING_CATALOG_GENERATE_SYMBOLS: YES
|
||||
CLANG_CXX_LANGUAGE_STANDARD: c++17
|
||||
MARKETING_VERSION: "1.8.0"
|
||||
CURRENT_PROJECT_VERSION: "75"
|
||||
CURRENT_PROJECT_VERSION: "79"
|
||||
# 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 xcconfig,适用于所有 target
|
||||
|
||||
Reference in New Issue
Block a user