diff --git a/AGENTS.md b/AGENTS.md index e4141cc..aa2ffce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | |-------|------|------| diff --git a/CHANGELOG.md b/CHANGELOG.md index 221b216..31bcc00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 的通讯录名与文本替换。 diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift index 2a629de..f4c027a 100644 --- a/OSGKeyboard/OSGKeyboardApp.swift +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -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") { diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 349cafc..425000a 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -126,7 +126,8 @@ final class FlowSessionManager: ObservableObject { private var isColdStartHandoff = false private var coldStartRecoveryTask: Task? 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 } diff --git a/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift index 40f0e05..0d57d29 100644 --- a/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift +++ b/OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift @@ -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 { diff --git a/OSGKeyboard/Views/AIKeyboardDemoView.swift b/OSGKeyboard/Views/AIKeyboardDemoView.swift index 9f6e056..716c356 100644 --- a/OSGKeyboard/Views/AIKeyboardDemoView.swift +++ b/OSGKeyboard/Views/AIKeyboardDemoView.swift @@ -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)) diff --git a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift index 45cfbe1..1628d5e 100644 --- a/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift +++ b/OSGKeyboard/Views/AssistantKeyboardUITestHarness.swift @@ -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 diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index 45f46cf..e8a97da 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -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 } diff --git a/OSGKeyboardExt/BootProbe.m b/OSGKeyboardExt/BootProbe.m index 100757a..46043a9 100644 --- a/OSGKeyboardExt/BootProbe.m +++ b/OSGKeyboardExt/BootProbe.m @@ -7,9 +7,30 @@ // dyld (usually host coexistence or an oversized Shared+Rime mapping). #import +#include +#include #include +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() + ); } diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 4d6a9d2..7e1a309 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -66,6 +66,18 @@ public final class KeyboardViewController: UIInputViewController { /// Coalesces host-document refreshes after mutations issued by this keyboard. private var assistantFieldActionRefreshTask: Task? + 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() diff --git a/OSGKeyboardExt/Services/ClipboardCaptureCoordinator.swift b/OSGKeyboardExt/Services/ClipboardCaptureCoordinator.swift index 5cbae46..55bce83 100644 --- a/OSGKeyboardExt/Services/ClipboardCaptureCoordinator.swift +++ b/OSGKeyboardExt/Services/ClipboardCaptureCoordinator.swift @@ -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() diff --git a/OSGKeyboardShared/Typing/TypingSessionController.swift b/OSGKeyboardShared/Typing/TypingSessionController.swift index 24920ca..c845829 100644 --- a/OSGKeyboardShared/Typing/TypingSessionController.swift +++ b/OSGKeyboardShared/Typing/TypingSessionController.swift @@ -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" diff --git a/OSGKeyboardShared/Utilities/KeyboardExtensionMemoryTelemetry.swift b/OSGKeyboardShared/Utilities/KeyboardExtensionMemoryTelemetry.swift new file mode 100644 index 0000000..a56808c --- /dev/null +++ b/OSGKeyboardShared/Utilities/KeyboardExtensionMemoryTelemetry.swift @@ -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? + + 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 + } + } +} diff --git a/OSGKeyboardShared/Utilities/OSGDiag.swift b/OSGKeyboardShared/Utilities/OSGDiag.swift index b0eef3c..51a0f29 100644 --- a/OSGKeyboardShared/Utilities/OSGDiag.swift +++ b/OSGKeyboardShared/Utilities/OSGDiag.swift @@ -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.size / MemoryLayout.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 + ) } } diff --git a/OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests.swift b/OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests.swift new file mode 100644 index 0000000..35adafc --- /dev/null +++ b/OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests.swift @@ -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 + ) + } +} diff --git a/OSGKeyboardUITests/EnglishKeyboardDeviceUITests.swift b/OSGKeyboardUITests/EnglishKeyboardDeviceUITests.swift index 758acbd..2221217 100644 --- a/OSGKeyboardUITests/EnglishKeyboardDeviceUITests.swift +++ b/OSGKeyboardUITests/EnglishKeyboardDeviceUITests.swift @@ -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 = [ diff --git a/Scripts/device-pip-stress.sh b/Scripts/device-pip-stress.sh index 7746faf..26559f3 100755 --- a/Scripts/device-pip-stress.sh +++ b/Scripts/device-pip-stress.sh @@ -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")) diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index 7ba1477..98222db 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -142,7 +142,8 @@ "OSGKeyboardTests/AIAddressExtractionTests", "OSGKeyboardTests/AINoteExportTests", "OSGKeyboardTests/ClipboardHistoryPolicyTests", - "OSGKeyboardTests/ClipboardHistoryStoreTests" + "OSGKeyboardTests/ClipboardHistoryStoreTests", + "OSGKeyboardTests/KeyboardExtensionMemoryBudgetTests" ] }, "host_misc": { diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index bbc8703..4035043 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -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 diff --git a/docs/RELEASE_NOTES_1.8.0.md b/docs/RELEASE_NOTES_1.8.0.md new file mode 100644 index 0000000..d4f688b --- /dev/null +++ b/docs/RELEASE_NOTES_1.8.0.md @@ -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. diff --git a/docs/assets/whats-new/ai-voice-assistant-en.mp4 b/docs/assets/whats-new/ai-voice-assistant-en.mp4 index a635d0d..8400596 100644 Binary files a/docs/assets/whats-new/ai-voice-assistant-en.mp4 and b/docs/assets/whats-new/ai-voice-assistant-en.mp4 differ diff --git a/docs/assets/whats-new/ai-voice-assistant-zh.mp4 b/docs/assets/whats-new/ai-voice-assistant-zh.mp4 index 418398d..47eb335 100644 Binary files a/docs/assets/whats-new/ai-voice-assistant-zh.mp4 and b/docs/assets/whats-new/ai-voice-assistant-zh.mp4 differ diff --git a/docs/assets/whats-new/clipboard-skills-en.mp4 b/docs/assets/whats-new/clipboard-skills-en.mp4 index 02c414f..5127453 100644 Binary files a/docs/assets/whats-new/clipboard-skills-en.mp4 and b/docs/assets/whats-new/clipboard-skills-en.mp4 differ diff --git a/docs/assets/whats-new/clipboard-skills-zh.mp4 b/docs/assets/whats-new/clipboard-skills-zh.mp4 index 7e9094d..8270aac 100644 Binary files a/docs/assets/whats-new/clipboard-skills-zh.mp4 and b/docs/assets/whats-new/clipboard-skills-zh.mp4 differ diff --git a/docs/keyboard-memory-budget.md b/docs/keyboard-memory-budget.md index a943ed4..3207e73 100644 --- a/docs/keyboard-memory-budget.md +++ b/docs/keyboard-memory-budget.md @@ -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). diff --git a/docs/osgkeyboardversion.html b/docs/osgkeyboardversion.html index ef7b54f..72d5250 100644 --- a/docs/osgkeyboardversion.html +++ b/docs/osgkeyboardversion.html @@ -185,32 +185,38 @@

新功能

-

AI 与语音助手

+

AI Agent

    -
  • 键盘可切换到 AI:对着麦克风提问,回答会在键盘中实时出现,方便查看后再写入
  • -
  • 在即时通信等输入框中,先点白色「插入」,再点绿色「发送」;只有真正插入的内容会进入历史与字数统计
  • -
  • AI 空闲时会轮播常用提问和热点建议,点一下即可生成回答;需要天气、新闻等最新资料时会联网查找,失败会自动换方式重试
  • +
  • 语音听写与 AI 问答合并为全新的助手模式:轻点麦克风开始听写,长按麦克风可直接语音提问
  • +
  • 新增热点与情境热词,麦克风区域会展示天气、节日、新闻、热搜等建议;点按即可向 AI 获取相关信息
  • +
  • AI 回答会实时显示在键盘中,确认后可插入当前输入框;支持的服务商可联网搜索,并在失败时自动回退
  • +
  • 仅当原输入框与光标上下文仍然匹配时自动插入 AI 回答;上下文变化时会保留回答,等待用户明确插入或丢弃
  • 设置 → AI Agent 可调节「回复篇幅」:简短 / 中等 / 详细
  • -
  • 刚输入过内容时,长按麦克风说出修改要求,可预览后替换原文或追加新内容
  • -
  • 润色会保留原本的提问语气,不再把「你能听到吗」等问句改写成回答
-

剪贴板与技能

+

剪贴板与 AI 技能

    -
  • 在设置 → 剪贴板开启「历史记录」(默认关闭),可在本机保存最近 15 条纯文本;页面也可直接前往系统设置,将「从其他 App 粘贴」设为允许
  • -
  • 键盘顶栏的剪贴板按钮可打开历史,点一条即可插入;开启「建议条」后,最新复制内容会显示在键盘上方
  • -
  • 复制文字后约 30 秒内切到 AI,可直接选择回复、总结或翻译;也可以说出「回复剪贴板」或「翻译剪贴板」
  • -
  • App 新增技能页,可管理最多 8 个键盘技能,并通过长按拖动调整顺序
  • -
  • 右上角 + 可自定义技能名称、图标和提示词,也可连接自己的 iCloud 快捷指令
  • -
  • 内置技能可从复制内容提取待办、写入日历、存入备忘录,或识别地址后打开高德、百度与 Apple 地图导航
  • +
  • 新增剪贴板历史。在设置 → 剪贴板开启“历史记录”后,可在本机保存最近 15 条纯文本;该功能默认关闭
  • +
  • 新增剪贴板建议条。可从键盘顶栏打开历史并点按插入;开启建议条后,最新复制内容会显示在键盘上方
  • +
  • 复制文字后约 30 秒内,助手会显示回复、总结、翻译等快捷技能;技能支持分页浏览,并可在技能页调整顺序
  • +
  • 新增技能页,最多可将 8 个技能放入键盘。内置技能可提取待办、添加日历日程、存入备忘录,或打开地图导航
  • +
  • 新增自定义 AI 技能,可设置名称、图标和提示词,也可选择连接自己的 iCloud 快捷指令
  • +
+
+ +
+

编辑上次输入

+
    +
  • 编辑入口移至键盘右下角。点按后可口述修改要求、对比原文与编辑结果,并选择替换原文或追加内容
  • +
  • 编辑只使用最近一次由 OSGKeyboard 插入且仍可验证的文字,不会读取输入框中的无关内容
@@ -222,29 +228,31 @@
  • iPhone 底部导航重做为 首页 / 技能 / 风格 / 设置四栏 Liquid Glass Dock,绿色胶囊清楚标示当前页面
  • 技能与润色风格统一为卡片式管理:点按选择、右上角编辑、右下角显示启用状态;技能支持像主屏幕图标一样长按拖动排序
  • 设置入口重新整理,AI Agent、剪贴板、语音与文字输入等页面更容易找到,当前设置摘要直接显示在导航行右侧
  • -
  • iPad 改为侧栏 + 内容区布局,增大导航触控区域并统一图标对齐;翻译移到麦克风旁与撤销对称,顶栏原位置改为剪贴板入口
  • +
  • iPad 改为侧栏 + 内容区布局;键盘横竖屏均充分利用可用宽度,并加入系统地球键、撤销、重做、复制和剪切等操作
  • 中英文输入全面升级

      +
    • 中英文输入均可在本机学习用户的输入习惯、候选频率和选择偏好;设置中可单独清除打字习惯,不会删除个性词库
    • +
    • 拼音优化混合简拼排序,并支持 zhchsh 两键简拼
    • 英文键盘新增类 QuickType 候选栏,同时显示原词、纠错和补全;约 4 万词离线词表会结合系统词库、通讯录名称与文本替换提供建议
    • -
    • 英文邻键纠错更贴合真实键盘位置,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词
    • -
    • 拼音优化 wom 等混合简拼排序,并支持 zhchsh 两键简拼;中文候选频率和英文选择偏好会保存在本机
    • +
    • 英文邻键纠错更贴合真实键位,并减少对专名、短词和全大写词的误改;拒绝纠错后会学习保留原词
    • 支持叠指连打、英文双空格句号,以及根据输入框显示前往、搜索、发送、完成、下一项等回车键文案
    • -
    • iPad 键盘横竖屏均铺满可用宽度,底行加入逗号、句号和系统地球键,并提供撤销、重做、复制和剪切

    更稳定、更可控

      -
    • 听写、AI 回答、编辑结果和剪贴板粘贴现在都可通过同一个撤销键一次回滚
    • -
    • 长按麦克风现在用于编辑上次输入;旧的长按剪贴板语音流程已由剪贴板历史和快捷技能替代
    • -
    • 中文输入资源会在 App 中自动完成初始化,部署完成后已打开的键盘也会自行恢复,无需反复重启
    • -
    • 修复跨设备剪贴板导致键盘卡住、切换键盘崩溃、录音取消不及时,以及 AI 会话结束后麦克风不可用等问题
    • -
    • 默认继续使用设备端语音识别;AI 使用你自己的 API Key,未配置时首页和麦克风上方会提示;剪贴板默认关闭,内容只在你主动操作后交给所配置的服务商
    • +
    • 听写、AI 回答、编辑结果和剪贴板粘贴可通过统一的撤销操作回滚
    • +
    • 修复切换键盘崩溃、中文输入初始化后无法恢复、通用剪贴板导致键盘卡住,以及 AI 会话结束后麦克风不可用等问题
    • +
    • 全部内置与自定义润色风格都会保留问句原意,不再把用户的问题改写成回答
    • +
    • 设备端语音识别仍为默认选项;AI 使用用户自己配置的服务商与 API Key,不再提供内置 DeepSeek 回退
    • +
    • 长按麦克风现用于向 AI 提问;编辑上次输入改用右下角编辑按钮。旧的长按剪贴板语音指令已由剪贴板历史和快捷技能取代
    • +
    • 已安装旧中文名称“待办”或“日程”快捷指令的用户,需要从技能页重新安装 OSGExtractTodosOSGExtractEvents
    • +
    • 已移除空白区域滑动光标及其设置开关
    @@ -254,32 +262,38 @@

    New Features

    -

    AI and voice assistant

    +

    AI Agent

      -
    • Switch the keyboard to AI, ask with the microphone, and watch the answer appear on the keyboard before writing it into the current app
    • -
    • In messaging fields, tap white Insert, then green Send; only inserted answers count toward history and character statistics
    • -
    • 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
    • +
    • 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
    • In Settings → AI Agent, choose Response length: Short / Medium / Detailed
    • -
    • Right after entering text, hold the microphone, describe the change, then preview and replace the original or append the result
    • -
    • Polish preserves your original question instead of rewriting prompts such as “Can you hear me?” into answers
    -

    Clipboard and Skills

    +

    Clipboard and AI Skills

      -
    • Turn on History in Settings → Clipboard (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
    • -
    • Open history from the keyboard's top-bar Clipboard button and tap an item to insert it; the optional Suggestion strip keeps the newest copy above the keyboard
    • -
    • Within about 30 seconds of copying text, switch to AI and choose Reply, Summarize, or Translate, or say “reply to the clipboard” or “translate my clipboard”
    • -
    • The new Skills page manages up to eight keyboard skills and reorders them with a long-press drag
    • -
    • Use the top-right + to create a skill with your own name, icon, and prompt, with an optional iCloud Shortcut connection
    • -
    • Built-in skills can extract to-dos, add calendar events, save to Notes, or recognize an address and open Amap, Baidu Maps, or Apple Maps
    • +
    • 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 map navigation
    • +
    • 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
    @@ -291,29 +305,31 @@
  • The iPhone dock is rebuilt around four Liquid Glass destinations — Home / Skills / Styles / Settings — with a green capsule marking the current page
  • 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
  • Settings navigation is reorganized so AI Agent, Clipboard, Speech, and Text Input are easier to find, with current summaries shown directly beside each row
  • -
  • 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
  • +
  • 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 autocorrect makes fewer unwanted changes to names, short words, and all-caps text; rejecting a correction teaches the original
    • -
    • Pinyin improves mixed abbreviations such as wom and supports two-key zh, ch, and sh; Chinese candidate frequency and English choices learn locally
    • +
    • 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 / Next Return labels make typing feel closer to the system keyboard
    • -
    • On iPad, the keyboard fills portrait and landscape width, adds comma, period, and the system globe key, plus undo, redo, copy, and cut

    More Stable, More Controllable

      -
    • One Undo key now rolls back dictation, AI answers, edit results, and clipboard pastes
    • -
    • Holding the microphone now edits the last input; Clipboard History and quick skills replace the previous hold-to-command clipboard flow
    • -
    • Chinese input resources initialize automatically in the app, and an already-open keyboard recovers after deployment without repeated restarts
    • -
    • Fixes Universal Clipboard freezes, keyboard-switch crashes, delayed recording cancellation, and a microphone that could remain unavailable after AI sessions
    • -
    • 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
    • +
    • 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
    • +
    • 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
    diff --git a/project.yml b/project.yml index ba689a3..dadb8f0 100644 --- a/project.yml +++ b/project.yml @@ -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