feat(keyboard): improve typing, voice flow, and polish reliability

Reduce extension memory pressure and delivery races while adding richer candidates, tactile feedback, and safer two-level creative polishing.
This commit is contained in:
Rocky
2026-08-05 21:39:31 +08:00
parent 38e5ad570d
commit 31f5937a7f
177 changed files with 8343 additions and 3904 deletions
+15
View File
@@ -0,0 +1,15 @@
// BootProbe.m
// OSGKeyboard · Keyboard Extension
//
// Runs at dyld load — before any Swift `KeyboardViewController` init.
// If Console shows this line but never `KVC.init`, Swift/Shared init is the
// killer. If even this line is missing, the extension was jetsammed during
// dyld (usually host coexistence or an oversized Shared+Rime mapping).
#import <Foundation/Foundation.h>
#include <unistd.h>
__attribute__((constructor))
static void OSGKeyboardExtBootProbe(void) {
NSLog(@"[OSGDiag/boot] dyld.constructor pid=%d", getpid());
}
+127 -42
View File
@@ -36,7 +36,15 @@ public final class KeyboardViewController: UIInputViewController {
public typealias State = KeyboardState
private let state = State()
private let typingSession = TypingSessionController()
/// Created on first typing use so the default voice surface never pays
/// for `TypingSessionController` / engine factories at KVC init.
private var typingSessionStorage: TypingSessionController?
private var typingSession: TypingSessionController {
if let typingSessionStorage { return typingSessionStorage }
let created = TypingSessionController()
typingSessionStorage = created
return created
}
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardSurfaceRoot>?
@@ -47,40 +55,89 @@ public final class KeyboardViewController: UIInputViewController {
private var textInserter: KeyboardTextInserter!
private var flowCoordinator: KeyboardFlowCoordinator!
private var configSync: KeyboardConfigSync!
private var cursorDrag: CursorDragController!
/// UIKit may synchronously lay out the view during `viewDidLoad`.
/// Keep this optional so an early layout pass is harmless.
private var cursorDrag: CursorDragController?
private var targetKeyboardHeight: CGFloat {
KeyboardSurfaceRoot.height(for: state.surface)
}
// MARK: - Init
public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
OSGDiag.log("KVC.init(nib) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
OSGDiag.log("KVC.init(nib) done \(OSGDiag.memoryTag())", category: "boot")
}
public required init?(coder: NSCoder) {
OSGDiag.log("KVC.init(coder) begin \(OSGDiag.memoryTag())", category: "boot")
super.init(coder: coder)
OSGDiag.log("KVC.init(coder) done \(OSGDiag.memoryTag())", category: "boot")
}
deinit {
// Intentionally NSLog-only: deinit is nonisolated.
NSLog("%@", "[OSGDiag/boot] KVC.deinit")
}
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// Voice-first keyboard hide the misleading "English" subtitle in Settings.
primaryLanguage = "mis"
OSGLog.keyboardExt.info("viewDidLoad — extension booted")
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"KVC.viewDidLoad begin preferredSurface=\(preferred.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
// Deliberately NO CustomLanguageModelManager prewarm here: the
// extension never runs ASR (the host app owns the microphone and
// the SpeechAnalyzer pipeline), and compiling/caching an LM inside
// the keyboard's ~60 MB jetsam budget risks the system killing the
// keyboard outright. The host app prewarms it on session start.
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
// Establish layout dependencies before applying the preferred surface.
// `applySurface` updates height and UIKit may lay out synchronously.
installKeyboardHeight()
configureDictationBehavior()
installServices()
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()
OSGDiag.log("KVC.viewDidLoad after preferredSurface surface=\(state.surface.rawValue)", category: "boot")
installTypingContextProviders()
installStateActions()
installSurfaceObservers()
installSwiftUI()
OSGDiag.log("KVC.viewDidLoad after installSwiftUI \(OSGDiag.memoryTag())", category: "boot")
_ = configSync.loadPersistedConfig()
configSync.installDarwinObservers()
flowCoordinator.refreshSessionState()
OSGDiag.log(
"KVC.viewDidLoad done surface=\(state.surface.rawValue) "
+ "sessionActive=\(FlowSessionBridge.isSessionActive()) "
+ "hostReady=\(FlowSessionBridge.isHostReady()) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
OSGDiag.log(
"KVC.viewWillDisappear surface=\(state.surface.rawValue) "
+ "preserve=\(flowCoordinator.preservesLifecycleOnDisappear) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.stopSessionMonitor()
// Remember what the user left on, then pre-position a reused
// extension instance for the next open policy (no first-frame jump).
TypingInputConfiguration.persistLastSurface(state.surface)
prepareSurfaceForNextPresentation()
if state.surface == .typing {
typingSession.leaveTypingMode()
}
@@ -93,6 +150,11 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
OSGDiag.log(
"KVC.viewWillAppear begin surface=\(state.surface.rawValue) "
+ "fullAccess=\(hasFullAccess) \(OSGDiag.memoryTag())",
category: "boot"
)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
@@ -101,23 +163,41 @@ public final class KeyboardViewController: UIInputViewController {
flowCoordinator.startSessionMonitor()
configSync.syncOnboardingStateFromAppGroup()
configSync.refreshConfigFromAppGroup()
// Settings may have changed while the extension stayed alive.
applyPreferredSurfaceOnOpen()
if state.surface == .typing {
OSGDiag.log("KVC.viewWillAppear enterTypingMode", category: "boot")
typingSession.enterTypingMode()
}
configSync.autoAdvancePastKeyboardSetupStepIfNeeded()
OSGDiag.log(
"KVC.viewWillAppear done surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewIsAppearing(_ animated: Bool) {
super.viewIsAppearing(animated)
applyPresentationHeightOffset()
OSGDiag.log(
"KVC.viewIsAppearing height=\(keyboardHeightConstraint?.constant ?? -1) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
OSGDiag.log(
"KVC.viewDidAppear begin surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
disableSystemGestureDelays()
keyboardHeightConstraint?.constant = targetKeyboardHeight
refreshReturnKeyRole()
OSGDiag.log(
"KVC.viewDidAppear done height=\(targetKeyboardHeight) \(OSGDiag.memoryTag())",
category: "boot"
)
}
public override func textDidChange(_ textInput: UITextInput?) {
@@ -135,6 +215,10 @@ public final class KeyboardViewController: UIInputViewController {
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
OSGDiag.log(
"KVC.didReceiveMemoryWarning surface=\(state.surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
flowCoordinator.cancelPipelineUnlessAwaitingResult()
if state.surface == .typing {
typingSession.leaveTypingMode()
@@ -146,7 +230,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cursorDrag.layoutChrome()
cursorDrag?.layoutChrome()
}
// MARK: - Services
@@ -201,22 +285,17 @@ public final class KeyboardViewController: UIInputViewController {
state.setTranslationTargetLocaleId = { [weak self] id in
self?.configSync.persistTranslationTargetLocaleId(id)
}
state.advanceOnboarding = { [weak self] in self?.configSync.advanceOnboarding() }
state.completeOnboarding = { [weak self] in self?.configSync.completeOnboarding() }
state.requestMicPermission = { [weak self] in self?.requestMicPermissionFromExtension() }
state.requestSpeechPermission = { [weak self] in self?.requestSpeechPermissionFromExtension() }
state.openSystemSettings = { [weak self] in self?.openSystemSettingsFromExtension() }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.moveCursorHorizontal = { [weak self] steps in
self?.cursorDrag.moveCursorHorizontally(by: steps)
self?.cursorDrag?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.cursorDrag.moveCursorVertically(by: steps)
self?.cursorDrag?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.cursorDrag.setCursorDragActive(active)
self?.cursorDrag?.setCursorDragActive(active)
}
state.setSurface = { [weak self] surface in
self?.applySurface(surface)
@@ -244,24 +323,48 @@ public final class KeyboardViewController: UIInputViewController {
private func applySurface(_ surface: State.Surface) {
if surface == .typing, state.locksTypingSurface {
OSGDiag.log("applySurface blocked typing (locksTypingSurface)", category: "boot")
return
}
if surface == .typing, FlowSessionBridge.isHostHeavy() {
OSGDiag.log("applySurface stay voice hostHeavy=1", category: "boot")
return
}
guard state.surface != surface else {
refreshKeyboardHeight()
return
}
OSGDiag.log(
"applySurface \(state.surface.rawValue)\(surface.rawValue) \(OSGDiag.memoryTag())",
category: "boot"
)
state.surface = surface
if surface == .voice {
typingSession.leaveTypingMode()
} else {
typingSession.enterTypingMode()
}
refreshKeyboardHeight()
}
private func applyPreferredSurfaceOnOpen() {
let preferredSurface: State.Surface = TypingInputConfiguration.prefersTypingOnOpen()
? .typing
: .voice
applySurface(preferredSurface)
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
OSGDiag.log(
"applyPreferredSurfaceOnOpen preferred=\(preferred.rawValue) "
+ "remember=\(TypingInputConfiguration.remembersLastSurface()) "
+ "defaultTyping=\(TypingInputConfiguration.prefersTypingOnOpen())",
category: "boot"
)
applySurface(preferred)
}
/// When not remembering, snap to the static open preference while hidden
/// so a reused keyboard instance does not animate voice typing on show.
private func prepareSurfaceForNextPresentation() {
guard !TypingInputConfiguration.remembersLastSurface() else { return }
let preferred = TypingInputConfiguration.preferredSurfaceOnOpen()
guard state.surface != preferred else { return }
state.surface = preferred
}
private func refreshKeyboardHeight() {
@@ -270,10 +373,11 @@ public final class KeyboardViewController: UIInputViewController {
// switch subtracts the system's ~228 pt encapsulated height from the
// requested typing height and collapses the keyboard to a thin strip.
// Once presented, update our height constraint directly, matching the
// final assignment in `viewDidAppear`.
// final assignment in `viewDidAppear`. Avoid synchronous layout here:
// this is also called during `viewDidLoad`, where re-entrant layout can
// observe partially initialized controller dependencies.
keyboardHeightConstraint?.constant = targetKeyboardHeight
view.setNeedsLayout()
view.layoutIfNeeded()
}
private func refreshReturnKeyRole() {
@@ -328,19 +432,11 @@ public final class KeyboardViewController: UIInputViewController {
hasDictationKey = true
}
/// Only walk our own input-view subtree. Recursing into the host window /
/// root VC previously risked "System gesture gate timed out" and the
/// system killing the keyboard plugin.
private func disableSystemGestureDelays() {
disableGestureDelays(in: view)
var parent = view.superview
while let current = parent {
disableGestureDelays(in: current)
parent = current.superview
}
if let window = view.window {
disableGestureDelays(in: window)
if let rootView = window.rootViewController?.view {
disableGestureDelays(in: rootView)
}
}
}
private func disableGestureDelays(in targetView: UIView) {
@@ -355,17 +451,6 @@ public final class KeyboardViewController: UIInputViewController {
targetView.subviews.forEach(disableGestureDelays)
}
// MARK: - Permission / settings stubs (v0.3.0)
private func requestMicPermissionFromExtension() {}
private func requestSpeechPermissionFromExtension() {}
private func openSystemSettingsFromExtension() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
HostAppLauncher.open(url: url, from: self) { _ in }
}
// MARK: - Layout
private func installKeyboardHeight() {
@@ -414,7 +499,7 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
hosting = host
cursorDrag.install(on: view)
cursorDrag?.install(on: view)
}
// MARK: - App context
@@ -41,6 +41,7 @@ public struct AppGroupPersistor {
state.translationTargetLocaleId = store.translationTargetLocaleId
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.keyboardHapticIntensity = store.keyboardHapticIntensity
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
@@ -85,12 +86,15 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
state.engineMode = store.engineMode
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
let shouldProtectTranslation = KeyboardTranslationConfigProtection.shouldProtect(
until: protectTranslationUntil
)
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.keyboardHapticIntensity = store.keyboardHapticIntensity
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
@@ -1,7 +1,8 @@
// KeyboardConfigSync.swift
// OSGKeyboard · Keyboard Extension
//
// App Group config hydration, Darwin observers, and onboarding mirroring.
// App Group config hydration, Darwin observers, and onboarding-complete
// mirroring (mic gate only full setup lives in the host app).
import Foundation
import OSGKeyboardShared
@@ -74,39 +75,17 @@ final class KeyboardConfigSync {
into: state,
protectTranslationUntil: translationConfigProtectedUntil
)
// Host may complete (or reset) onboarding while the extension stays alive.
syncOnboardingStateFromAppGroup()
}
/// Mirrors host-app onboarding completion for the mic gate only.
/// Does not sync `onboardingPage` page flow is host-app exclusive.
func syncOnboardingStateFromAppGroup() {
let store = AppGroupStore()
// Fall back to the reboot-durable Keychain marker so a device restart
// does not resurrect the in-keyboard onboarding overlay when the App
// Group value transiently reads empty.
// Keychain fallback: a reboot must not resurrect the mic gate when
// App Group transiently reads empty.
state.hasCompletedOnboarding = store.hasCompletedOnboarding || Keychain.hasCompletedOnboarding()
state.onboardingPage = store.onboardingPage
}
func autoAdvancePastKeyboardSetupStepIfNeeded() {
guard !state.hasCompletedOnboarding else { return }
guard state.onboardingPage == 3 else { return }
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
let store = AppGroupStore()
store.setOnboardingPage(4)
state.onboardingPage = 4
}
func advanceOnboarding() {
let store = AppGroupStore()
let nextPage = min(4, store.onboardingPage + 1)
store.setOnboardingPage(nextPage)
state.onboardingPage = nextPage
}
func completeOnboarding() {
let store = AppGroupStore()
store.setHasCompletedOnboarding(true)
store.setOnboardingPage(4)
state.hasCompletedOnboarding = true
state.onboardingPage = 4
}
func persistLocale(_ id: String) {
@@ -122,7 +101,7 @@ final class KeyboardConfigSync {
func persistTranslationTargetLocaleId(_ id: String) {
let resolved = TranslationLanguageCatalog.resolve(id).id
state.translationTargetLocaleId = resolved
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
translationConfigProtectedUntil = KeyboardTranslationConfigProtection.protectionDeadline()
persistor.persist(translationTargetLocaleId: resolved)
}
@@ -117,6 +117,7 @@ final class KeyboardFlowCoordinator {
FlowSessionBridge.reloadFromDisk()
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
adoptPendingResultIfNeeded()
consumePendingFlowDeliveryIfNeeded()
recoverFromDeadHostIfNeeded()
@@ -179,20 +180,19 @@ final class KeyboardFlowCoordinator {
// Host busy (recording/processing) is NOT "still starting". Treating
// it as preparingSession was the orange-stuck bug after cold start:
// host utt.rec=1 ready=false keyboard forever "".
let hostBusy = readySnapshot?.reason == .recording
|| readySnapshot?.reason == .processing
let hostBusy = FlowKeyboardHostWarming.isHostBusy(reason: readySnapshot?.reason)
// PiP sessions publish `reason=.starting` while the small window is
// coming up treat that as warming so the mic stays orange (wait)
// instead of jumping into another cold start.
let hostWarming = !hostReady
&& !hostBusy
&& FlowSessionBridge.isSessionActive()
&& (
FlowSessionBridge.isHostReachable()
|| isPendingFlowStart
|| withinReadyGrace
|| readySnapshot?.reason == .starting
)
let hostWarming = FlowKeyboardHostWarming.isHostWarming(
hostReady: hostReady,
hostBusy: hostBusy,
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
isPendingFlowStart: isPendingFlowStart,
withinReadyGrace: withinReadyGrace,
snapshotReason: readySnapshot?.reason
)
state.flowSessionActive = FlowSessionBridge.isSessionActive()
state.debugPendingFlowStart = isPendingFlowStart
state.debugFlowRecording = isFlowRecording
@@ -204,7 +204,8 @@ final class KeyboardFlowCoordinator {
hasFullAccess: hasFullAccess(),
appGroupAvailable: AppGroup.isAvailable,
hostReady: hostReady,
isPreparingSession: isPendingFlowStart || hostWarming
isPreparingSession: isPendingFlowStart || hostWarming,
hasCompletedOnboarding: state.hasCompletedOnboarding
)
let signature = [
"phase=\(String(describing: state.phase))",
@@ -224,30 +225,22 @@ final class KeyboardFlowCoordinator {
/// Re-attach to a host utterance this keyboard process no longer owns.
private func adoptHostBusyStateIfNeeded(snapshot: FlowReadySnapshot?) {
guard let snapshot, let sessionId = snapshot.sessionId else { return }
// Ignore snapshots from a dead host generation.
if let snapGen = snapshot.hostGeneration,
let liveGen = FlowSessionBridge.currentHostGeneration(),
snapGen != liveGen {
let action = FlowKeyboardAdoptBusyPolicy.decide(
snapshot: snapshot,
currentHostGeneration: FlowSessionBridge.currentHostGeneration(),
isFlowRecording: isFlowRecording,
isAwaitingFlowResult: isAwaitingFlowResult,
lastConsumedUtteranceId: lastConsumedUtteranceId,
lastStoppedUtteranceId: lastStoppedUtteranceId
)
switch action {
case .none:
return
}
// Host already finished never re-adopt a consumed utterance, and
// clear sticky local processing left behind by a stale busy snapshot.
if snapshot.reason != .recording, snapshot.reason != .processing {
clearStickyProcessingIfNeeded(hostReady: snapshot.ready)
return
}
switch snapshot.reason {
case .recording:
guard !isFlowRecording else { return }
guard !isAwaitingFlowResult else { return }
case .clearStickyProcessing:
clearStickyProcessingIfNeeded(hostReady: snapshot?.ready ?? false)
case .adoptRecording(let sessionId, let busyId):
// Require the host's utterance id inventing one makes matchingResult
// forever miss the real delivery and leaves the mic white forever.
guard let busyId = snapshot.busyUtteranceId else { return }
guard busyId != lastConsumedUtteranceId else { return }
guard busyId != lastStoppedUtteranceId else { return }
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
@@ -264,10 +257,7 @@ final class KeyboardFlowCoordinator {
startUtteranceCountdown()
startFlowLevelWatchdog()
traceState("adoptHostBusy.recording", extra: "session=\(sessionId)")
case .processing:
guard !isAwaitingFlowResult else { return }
guard let busyId = snapshot.busyUtteranceId else { return }
guard busyId != lastConsumedUtteranceId else { return }
case .adoptProcessing(let sessionId, let busyId):
activeSessionId = sessionId
currentUtteranceId = busyId
isPendingFlowStart = false
@@ -281,8 +271,6 @@ final class KeyboardFlowCoordinator {
}
startFlowResultWatchdog()
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
default:
break
}
}
@@ -304,6 +292,10 @@ final class KeyboardFlowCoordinator {
/// Session is live but the ready contract has not landed yet poll
/// quickly instead of sticking on "session inactive" orange.
///
/// Cold-start (`osgkeyboard://startflow`) is allowed only when the user
/// explicitly pressed the mic (`recordWhenHostReady`). An idle open must
/// never relaunch the host: Flow + ASR warmup then jetsams the keyboard.
private func startHostReadyWaitIfNeeded() {
guard !isPendingFlowStart else { return }
guard FlowSessionBridge.isSessionActive() else {
@@ -329,6 +321,13 @@ final class KeyboardFlowCoordinator {
return
}
// No mic intent + host already dead leave cleanup to clearIfHostStale.
// Starting a wait poll here previously ended in an unprompted startflow.
if !recordWhenHostReady, isHostTrulyDeadForColdStart() {
stopHostReadyWait()
return
}
guard hostReadyWaitTask == nil else { return }
hostReadyWaitTask = Task { @MainActor [weak self] in
defer { self?.hostReadyWaitTask = nil }
@@ -345,18 +344,22 @@ final class KeyboardFlowCoordinator {
self.recordWhenHostReady = false
return
}
// Host died mid-wait only cold-start after debounced dead samples.
let dead = FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: false
)
// Host died mid-wait cold-start only after debounced dead
// samples AND an explicit mic-driven record intent.
let dead = self.isHostTrulyDeadForColdStart()
if self.coldStartDebouncer.observe(hostTrulyDead: dead) {
let shouldRecord = self.recordWhenHostReady
self.recordWhenHostReady = false
self.coldStartDebouncer.reset()
self.beginFlowStart(recordAfterHandoff: shouldRecord)
if shouldRecord {
self.beginFlowStart(recordAfterHandoff: true)
} else {
self.traceState(
"hostReadyWait.deadWithoutIntent",
extra: "skipColdStart=1"
)
self.stopHostReadyWait()
}
return
}
try? await Task.sleep(nanoseconds: 150_000_000)
@@ -371,6 +374,15 @@ final class KeyboardFlowCoordinator {
}
}
private func isHostTrulyDeadForColdStart() -> Bool {
FlowHandoffPolicy.shouldOpenHostColdStart(
sessionActive: FlowSessionBridge.isSessionActive(),
hostReachable: FlowSessionBridge.isHostReachable(),
hostStale: FlowSessionBridge.isHostStale(),
withinReadyGrace: false
)
}
private func finishHostReadyWaitIfNeeded() {
coldStartDebouncer.reset()
guard recordWhenHostReady else { return }
@@ -408,6 +420,9 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
switch state.micVoiceAvailability {
case .unavailable(.onboardingIncomplete):
promptFinishSetupInApp()
return
case .unavailable(.missingAPIKey):
return
case .unavailable(.noFullAccess):
@@ -476,6 +491,10 @@ final class KeyboardFlowCoordinator {
}
func beginFlowStart(recordAfterHandoff: Bool = false) {
guard state.hasCompletedOnboarding else {
promptFinishSetupInApp()
return
}
guard !isPendingFlowStart else {
traceState("beginFlowStart.ignored", extra: "reason=pendingAlreadyTrue")
return
@@ -492,6 +511,11 @@ final class KeyboardFlowCoordinator {
: "keyboard.flow.startingSession"
)
recomputeMicVoiceAvailability()
OSGDiag.log(
"beginFlowStart → openHostApp(startflow) recordAfterHandoff=\(recordAfterHandoff) "
+ "\(OSGDiag.memoryTag())",
category: "boot"
)
openHostApp("startflow")
startFlowStartWatchdog()
traceState(
@@ -580,14 +604,19 @@ final class KeyboardFlowCoordinator {
if let result = matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
isAwaitingFlowResult = false
stopFlowWatchdog()
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.clearResult()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
@@ -597,9 +626,6 @@ final class KeyboardFlowCoordinator {
"utterance=\(result.utteranceId.uuidString.prefix(8)) "
+ "commandSeq=\(result.commandSeq) warning=\(result.warning == nil ? 0 : 1)"
)
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let result = matchingResult(), isTerminalFailure(result) {
@@ -612,7 +638,16 @@ final class KeyboardFlowCoordinator {
)
isAwaitingFlowResult = false
stopFlowWatchdog()
FlowSessionBridge.clearResult()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
@@ -635,18 +670,64 @@ final class KeyboardFlowCoordinator {
}
}
private func matchingResult() -> FlowResult? {
guard let result = FlowSessionBridge.latestResult() else { return nil }
guard let activeSessionId, let currentUtteranceId else { return nil }
guard result.sessionId == activeSessionId,
result.utteranceId == currentUtteranceId else {
return nil
private func adoptPendingResultIfNeeded() {
guard !isAwaitingFlowResult, currentUtteranceId == nil,
let pendingId = FlowSessionBridge.pendingKeyboardUtteranceId(),
let result = FlowSessionBridge.latestResult(),
result.utteranceId == pendingId,
result.status == .final || isTerminalFailure(result) else {
return
}
return result
let currentField = fieldContextProvider()
if let expected = result.fieldFingerprint,
let current = currentField?.deliveryFingerprint,
expected != current {
if let text = result.text,
currentField?.precedingText?.hasSuffix(text) == true {
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
traceState(
"pendingResult.acknowledged",
extra: "reason=textAlreadyPresent"
)
return
}
traceState(
"pendingResult.deferred",
extra: "reason=fieldFingerprintMismatch"
)
return
}
activeSessionId = result.sessionId
currentUtteranceId = result.utteranceId
isAwaitingFlowResult = true
state.phase = .processing
traceState(
"pendingResult.adopted",
extra: "utterance=\(pendingId.uuidString.prefix(8))"
)
}
private func matchingResult() -> FlowResult? {
FlowKeyboardResultMatcher.matchingResult(
latest: FlowSessionBridge.latestResult(),
activeSessionId: activeSessionId,
currentUtteranceId: currentUtteranceId,
currentHostGeneration: FlowSessionBridge.currentHostGeneration()
)
}
private func isTerminalFailure(_ result: FlowResult) -> Bool {
result.status == .error || result.status == .timeout || result.status == .aborted
FlowKeyboardResultMatcher.isTerminalFailure(result)
}
/// When the host process died mid-utterance, abort local recording / waiting
@@ -676,6 +757,9 @@ final class KeyboardFlowCoordinator {
}
private func failHostDisconnected() {
if deliverRawFallbackIfAvailable(reason: "hostDisconnected") {
return
}
traceState("hostDisconnected.fail")
isAwaitingFlowResult = false
isFlowRecording = false
@@ -695,6 +779,49 @@ final class KeyboardFlowCoordinator {
debug("host disconnected while awaiting Flow result")
}
@discardableResult
private func deliverRawFallbackIfAvailable(reason: String) -> Bool {
FlowSessionBridge.reloadFromDisk()
guard let result = matchingResult(),
result.status == .partial
|| result.status == .rawReady
|| (result.status == .final && result.rawText != nil),
let raw = (result.rawText ?? result.text)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty else {
return false
}
textInserter.handleFlowTranscript(
TranscriptionDelivery(text: raw, polishWarning: nil)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: nil
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
lastConsumedUtteranceId = result.utteranceId
lastStoppedUtteranceId = nil
currentUtteranceId = nil
isAwaitingFlowResult = false
isFlowRecording = false
stopFlowWatchdog()
state.level = 0
state.phase = .idle
state.lastTranscript = ""
recomputeMicVoiceAvailability()
FlowTrace.transcript(
"keyboard.insert",
raw,
"via=rawFallback reason=\(reason) utterance=\(result.utteranceId.uuidString.prefix(8))"
)
return true
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
@@ -718,6 +845,16 @@ final class KeyboardFlowCoordinator {
recomputeMicVoiceAvailability()
}
/// Scheme C: voice needs host-app setup; typing stays available.
private func promptFinishSetupInApp() {
let msg = ExtL10n.string("keyboard.hint.finishSetupInApp")
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
recomputeMicVoiceAvailability()
openHostApp("settings")
traceState("onboarding.incomplete", extra: "action=openHostApp(settings)")
}
private func startFlowRecording() {
recomputeMicVoiceAvailability()
let withinReadyGrace = lastHostReadyAt > 0
@@ -767,6 +904,7 @@ final class KeyboardFlowCoordinator {
}
activeSessionId = sessionId
currentUtteranceId = UUID()
FlowSessionBridge.setPendingKeyboardUtteranceId(currentUtteranceId)
lastStoppedUtteranceId = nil
writeCommand(.startRecording)
isFlowRecording = true
@@ -884,7 +1022,7 @@ final class KeyboardFlowCoordinator {
switch state.phase {
case .recording, .processing:
if let result = matchingResult(),
result.status == .partial,
result.status == .partial || result.status == .rawReady,
let partial = result.text,
!partial.isEmpty {
state.lastTranscript = partial
@@ -902,17 +1040,23 @@ final class KeyboardFlowCoordinator {
debug("resultWatchdog started timeout=\(Int(resultTimeout))s engine=\(state.engineMode)")
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
FlowSessionBridge.reloadFromDisk()
if let result = self.matchingResult(), result.status == .final, let text = result.text, !text.isEmpty {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.clearResult()
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
@@ -924,15 +1068,21 @@ final class KeyboardFlowCoordinator {
+ "commandSeq=\(result.commandSeq) "
+ "waitedSeconds=\(String(format: "%.2f", Date().timeIntervalSince1970 - startedAt))"
)
self.textInserter.handleFlowTranscript(
TranscriptionDelivery(text: text, polishWarning: result.warning)
)
return
}
if let result = self.matchingResult(), self.isTerminalFailure(result) {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
FlowSessionBridge.clearResult()
FlowSessionBridge.writeAck(
FlowAck(
sessionId: result.sessionId,
utteranceId: result.utteranceId,
commandSeq: result.commandSeq,
hostGeneration: result.hostGeneration,
revision: result.revision
)
)
FlowSessionBridge.setPendingKeyboardUtteranceId(nil)
self.lastConsumedUtteranceId = result.utteranceId
self.lastStoppedUtteranceId = nil
self.currentUtteranceId = nil
@@ -976,6 +1126,9 @@ final class KeyboardFlowCoordinator {
return
}
if now - startedAt > resultTimeout {
if self.deliverRawFallbackIfAvailable(reason: "resultTimeout") {
return
}
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.currentUtteranceId = nil
@@ -1,81 +0,0 @@
// PermissionManager.swift
// OSGKeyboard · Keyboard Extension
//
// Extracted from KeyboardViewController so the view controller doesn't
// need to know about AVAudioApplication vs AVAudioSession branching
// or SFSpeechRecognizer.requestAuthorization callback bridging.
//
// Contract:
// `requestMicPermission()` returns true if the user has authorised
// or *just* authorised; false otherwise. Idempotent within a
// process the second call will not prompt again if the user has
// already answered.
// `requestSpeechPermission()` mirrors the same shape but for
// SFSpeechRecognizer.
import Foundation
import AVFoundation
import Speech
@MainActor
public final class PermissionManager: @unchecked Sendable {
public init() {}
private var didRequestMicOnce: Bool = false
/// Request microphone access. Returns true if granted (already or
/// after this call). Uses the iOS 17+ `AVAudioApplication` API.
public func requestMicPermission() async -> Bool {
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
if !didRequestMicOnce {
didRequestMicOnce = true
return await AVAudioApplication.requestRecordPermission()
}
return false
@unknown default: return false
}
}
/// Request Speech Recognition permission. Returns true if granted
/// (already or after this call). The `SFSpeechRecognizer` plist
/// key + this call are still required even on iOS 26 the
/// `SpeechAnalyzer` API does not expose an explicit request
/// method of its own and the framework checks the same TCC
/// entry on first use.
public func requestSpeechPermission() async -> Bool {
await Self.requestSpeechPermissionNonisolated()
}
// MARK: - Nonisolated permission bridge
//
// `SFSpeechRecognizer.requestAuthorization` callback is not guaranteed
// to run on main queue. Building the callback inline inside a
// `@MainActor` method can trigger runtime actor/isolation assertions.
// Keep the continuation + callback creation in nonisolated helpers.
private nonisolated static func requestSpeechPermissionNonisolated() async -> Bool {
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
SFSpeechRecognizer.requestAuthorization(
makeSpeechAuthHandler(continuation: cont)
)
}
}
private nonisolated static func makeSpeechAuthHandler(
continuation: CheckedContinuation<Bool, Never>
) -> @Sendable (SFSpeechRecognizerAuthorizationStatus) -> Void {
return { status in
switch status {
case .authorized:
continuation.resume(returning: true)
case .denied, .restricted, .notDetermined:
continuation.resume(returning: false)
@unknown default:
continuation.resume(returning: false)
}
}
}
}
+171 -55
View File
@@ -18,8 +18,8 @@ enum TypingLayoutMetrics {
static let verticalKeySpacing: CGFloat = 8
static let secondRowInset: CGFloat = 18
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
/// Match the voice surface: compact side keys and a flexible center key.
static let bottomSideKeyWidth: CGFloat = KeyboardChromeLayout.sideActionKeyWidth
/// Match the voice surface's shared 20 / 60 / 20 bottom-row geometry.
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
/// Collapsed candidate strip: keep this small so ScrollView doesn't fight .
@@ -85,17 +85,22 @@ struct TypingRootView: View {
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
.frame(maxWidth: KeyboardChromeLayout.contentMaxWidth)
.frame(maxWidth: .infinity)
.frame(height: Self.totalHeight)
.background(Color.clear)
.environment(\.themePalette, palette)
.onAppear { typing.enterTypingMode() }
// enterTypingMode is owned by KeyboardViewController.viewWillAppear
// when surface == .typing avoid a duplicate prepare here.
.onChange(of: typing.isCandidatePanelExpanded) { _, isExpanded in
if isExpanded { candidatePanelMounted = true }
}
.onChange(of: hasCandidateContent) { _, hasContent in
if !hasContent { candidatePanelMounted = false }
}
.onAppear {
KeyboardHapticFeedback.prepare()
}
}
// MARK: - Shared top region
@@ -302,21 +307,79 @@ struct TypingRootView: View {
private func keyButton(_ label: String) -> some View {
if label == "" {
typingDeleteKey()
} else if label == "" {
typingShiftKey()
} else {
let isSpecial = ["", "123", "#+=", "ABC"].contains(label)
Button {
let isSpecial = ["123", "#+=", "ABC"].contains(label)
let role: KeyboardHapticKeyRole = isSpecial ? .modifier : .character
PressDownKeyButton {
TypingKeyFeedback.play(
role: role,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleKey(label))
} label: {
keyLabel(label, isSpecial: isSpecial)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
keyLabel(label, isSpecial: isSpecial)
}
}
.buttonStyle(nativeKeyStyle)
.accessibilityLabel(Text(label))
}
}
/// Hold = continuous uppercase while pressed; tap = one-shot / Caps Lock cycle.
private func typingShiftKey() -> some View {
let lit = typing.isShiftEnabled
return keyLabel("", isSpecial: true)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.fill(lit ? keyPressedFill : keyFill)
)
.overlay(
RoundedRectangle(
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
style: .continuous
)
.stroke(palette.divider, lineWidth: 0.5)
)
.shadow(
color: Color.black.opacity(lit ? 0.04 : 0.13),
radius: lit ? 0.5 : 1,
y: lit ? 0 : 1
)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !typing.shiftHeld {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
)
typing.beginShiftHold()
}
}
.onEnded { _ in
typing.endShiftHold()
}
)
.accessibilityLabel(Text("shift"))
.accessibilityAddTraits(.isButton)
}
/// Shares ``RepeatingPressButton`` with the voice toolbar delete key.
private func typingDeleteKey() -> some View {
RepeatingPressButton {
RepeatingPressButton(hapticIntensity: state.keyboardHapticIntensity) {
apply(typing.handleKey(""))
} label: { isPressed in
Image(systemName: "delete.left")
@@ -352,7 +415,7 @@ struct TypingRootView: View {
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
switch label {
case "":
Image(systemName: typing.shiftActive || typing.capsLock ? "shift.fill" : "shift")
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
.font(.system(size: 19, weight: .medium))
.foregroundStyle(keyTextColor)
default:
@@ -368,43 +431,88 @@ struct TypingRootView: View {
}
private var bottomRow: some View {
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
Button {
typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) {
PressDownKeyButton {
TypingKeyFeedback.play(
role: .modifier,
intensity: state.keyboardHapticIntensity
)
typing.setPage(typing.page == .letters ? .numbers : .letters)
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.page == .letters ? "123" : "ABC")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(keyTextColor)
}
.frame(
width: TypingLayoutMetrics.bottomSideKeyWidth,
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.buttonStyle(nativeKeyStyle)
}
.accessibilityLabel(Text(typing.page == .letters ? "123" : "ABC"))
Button {
apply(typing.handleSpace())
} label: {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
.frame(maxWidth: .infinity)
.frame(height: TypingLayoutMetrics.bottomRowHeight)
}
.buttonStyle(nativeKeyStyle)
Button {
apply(typing.handleReturn())
} label: {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleSpace())
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
Text(typing.language == .chinese ? "空格" : "space")
.font(.system(size: 17, weight: .regular))
.foregroundStyle(keyTextColor)
}
.frame(
width: TypingLayoutMetrics.bottomSideKeyWidth,
width: widths.center,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(typing.language == .chinese ? "空格" : "space"))
PressDownKeyButton {
TypingKeyFeedback.play(
role: .action,
intensity: state.keyboardHapticIntensity
)
apply(typing.handleReturn())
} label: { isPressed in
NativeKeyboardKeySurface(
isPressed: isPressed,
fill: returnKeyFill,
pressedFill: returnKeyPressedFill,
border: returnKeyBorder,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
) {
returnKeyLabel
.foregroundStyle(returnKeyTextColor)
}
.frame(
width: widths.side,
height: TypingLayoutMetrics.bottomRowHeight
)
}
.accessibilityLabel(Text(returnKeyAccessibilityLabel))
}
.buttonStyle(returnKeyStyle)
}
.frame(height: TypingLayoutMetrics.bottomRowHeight)
}
@ViewBuilder
@@ -437,7 +545,8 @@ struct TypingRootView: View {
}
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
if label == "" || label == "" || label == "#+=" {
// Match system: page switchers + delete are wider than character keys.
if label == "" || label == "" || label == "#+=" || label == "123" {
return 1.35
}
if rowIndex == 2 && index == 0 {
@@ -463,26 +572,33 @@ struct TypingRootView: View {
colorScheme == .dark ? Color(white: 0.36) : .white
}
private var nativeKeyStyle: NativeKeyboardKeyStyle {
NativeKeyboardKeyStyle(
fill: keyFill,
pressedFill: keyPressedFill,
border: palette.divider,
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
private var returnKeyFill: Color {
switch state.returnKeyRole {
case .newline: return keyFill
case .send: return sendKeyFill
}
}
private var returnKeyStyle: NativeKeyboardKeyStyle {
private var returnKeyPressedFill: Color {
switch state.returnKeyRole {
case .newline: return keyPressedFill
case .send: return sendKeyPressedFill
}
}
private var returnKeyBorder: Color {
switch state.returnKeyRole {
case .newline:
return nativeKeyStyle
return palette.divider
case .send:
return NativeKeyboardKeyStyle(
fill: sendKeyFill,
pressedFill: sendKeyPressedFill,
border: Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08),
cornerRadius: TypingLayoutMetrics.keyCornerRadius
)
return Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08)
}
}
private var returnKeyAccessibilityLabel: String {
switch state.returnKeyRole {
case .newline: return "return"
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
}
}
@@ -0,0 +1,105 @@
// KeyboardHapticFeedback.swift
// OSGKeyboard · Keyboard Extension
//
// Role-based typing haptics (no screen-position mapping). Intensity comes
// from Settings General Haptics (off / light / strong).
import UIKit
import OSGKeyboardShared
/// Key roles drive distinct Taptic styles closer to a real keyboard than
/// a single buzz for every tap.
enum KeyboardHapticKeyRole {
/// Letters, digits, punctuation.
case character
/// Shift, 123 / ABC / #+=.
case modifier
/// Space, return / send.
case action
/// Delete (including hold-to-repeat ticks).
case delete
}
@MainActor
enum KeyboardHapticFeedback {
private static let soft = UIImpactFeedbackGenerator(style: .soft)
private static let light = UIImpactFeedbackGenerator(style: .light)
private static let medium = UIImpactFeedbackGenerator(style: .medium)
private static let heavy = UIImpactFeedbackGenerator(style: .heavy)
private static let rigid = UIImpactFeedbackGenerator(style: .rigid)
/// Warm the generators so the first keypress is not soft/late.
static func prepare() {
soft.prepare()
light.prepare()
medium.prepare()
heavy.prepare()
rigid.prepare()
}
static func play(role: KeyboardHapticKeyRole, intensity: KeyboardHapticIntensity) {
guard intensity != .off else { return }
switch intensity {
case .off:
return
case .light:
playLight(role: role)
case .strong:
playStrong(role: role)
}
}
// Soft / light near stock keyboard feedback.
private static func playLight(role: KeyboardHapticKeyRole) {
switch role {
case .character:
soft.impactOccurred(intensity: 0.55)
soft.prepare()
case .modifier:
light.impactOccurred(intensity: 0.65)
light.prepare()
case .action:
light.impactOccurred(intensity: 0.8)
light.prepare()
case .delete:
medium.impactOccurred(intensity: 0.45)
medium.prepare()
}
}
// Heavier / sharper more mechanical-keyboard feel.
private static func playStrong(role: KeyboardHapticKeyRole) {
switch role {
case .character:
medium.impactOccurred(intensity: 0.75)
medium.prepare()
case .modifier:
medium.impactOccurred(intensity: 0.95)
medium.prepare()
case .action:
heavy.impactOccurred(intensity: 0.9)
heavy.prepare()
case .delete:
rigid.impactOccurred(intensity: 1.0)
rigid.prepare()
}
}
}
/// Sound + haptic for a typing-grid key press (pressed-down timing).
@MainActor
enum TypingKeyFeedback {
static func play(
role: KeyboardHapticKeyRole,
intensity: KeyboardHapticIntensity,
isDelete: Bool = false
) {
if isDelete {
KeyboardSoundFeedback.deleteClick()
} else {
KeyboardSoundFeedback.keyClick()
}
KeyboardHapticFeedback.play(role: role, intensity: intensity)
}
}
@@ -1,293 +0,0 @@
// KeyboardOnboardingOverlay.swift
// OSGKeyboard · Keyboard Extension
//
// v0.3.0 in-keyboard onboarding. Replaces the previous "jump out to
// the host app" flow with a five-step overlay that lives on top of
// the normal keyboard UI.
//
// Why in-keyboard instead of jumping to the host app?
//
// - iOS keyboard extensions **cannot programmatically switch back
// to the previous app** after a host-app jump. The user has to
// re-find their app, re-tap a text field, and re-select OSGKeyboard
// from the globe menu. That's a 5+ tap friction.
//
// - Steps 1, 2, 4 (welcome, mic permission, speech permission,
// API key) need nothing the host app owns. They can all live in
// the keyboard.
//
// - The only step that *must* leave the keyboard is step 3
// ("Enable Keyboard") iOS requires the user to flip a toggle
// in `Settings.app`, which is reachable from the extension via
// `UIApplication.openSettingsURLString`. After the user comes
// back, `viewWillAppear` reads `KeyboardSetupBridge.isReadyForOnboardingSkip`
// and the overlay auto-advances past step 3.
//
// The overlay mounts only when `state.hasCompletedOnboarding == false`.
// All inputs route through `KeyboardState` action hooks, so the
// controller can mirror them into the App Group without the view
// having to know about persistence.
import SwiftUI
import OSGKeyboardShared
struct KeyboardOnboardingOverlay: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject var state: KeyboardViewController.State
var body: some View {
ZStack {
// Dim the underlying keyboard so the overlay reads as a
// distinct surface. We can't completely hide it without
// losing keyboard-system visibility, so a 60% black wash
// is the sweet spot between focus and consistency.
palette.background.opacity(0.96).ignoresSafeArea()
VStack(spacing: 0) {
header
Spacer(minLength: Spacing.sm)
Group {
switch currentStep {
case .welcome: welcomeStep
case .microphone: microphoneStep
case .speech: speechStep
case .keyboard: keyboardStep
case .api: apiStep
}
}
.transition(.opacity.combined(with: .move(edge: .trailing)))
.frame(maxWidth: .infinity, maxHeight: .infinity)
Spacer(minLength: Spacing.sm)
footer
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
}
// MARK: - Steps
private enum Step: Int, CaseIterable {
case welcome = 0, microphone, speech, keyboard, api
static let count = 5
}
private var currentStep: Step {
Step(rawValue: state.onboardingPage) ?? .welcome
}
private var header: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
ForEach(0..<Step.count, id: \.self) { idx in
Capsule()
.fill(idx <= currentStep.rawValue
? palette.accent
: palette.divider)
.frame(height: 4)
}
}
.padding(.horizontal, Spacing.xs)
Text(ExtL10n.string("keyboard.onboarding.title"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
}
// MARK: - Step content
private var welcomeStep: some View {
stepBody(
iconSystemName: "waveform.badge.mic",
title: "keyboard.onboarding.welcome.title",
body: "keyboard.onboarding.welcome.body"
)
}
private var microphoneStep: some View {
stepBody(
iconSystemName: "mic.fill",
title: "keyboard.onboarding.mic.title",
body: "keyboard.onboarding.mic.body"
)
}
private var speechStep: some View {
stepBody(
iconSystemName: "ear",
title: "keyboard.onboarding.speech.title",
body: "keyboard.onboarding.speech.body"
)
}
private var keyboardStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "keyboard")
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string("keyboard.onboarding.keyboard.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string("keyboard.onboarding.keyboard.body"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Button {
state.openSystemSettings()
} label: {
HStack(spacing: 6) {
Image(systemName: "arrow.up.right.square")
Text(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
}
.font(TypeStyle.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent, in: Capsule())
}
.accessibilityLabel(ExtL10n.string("keyboard.onboarding.keyboard.openSettings"))
}
.frame(maxWidth: .infinity)
}
private var apiStep: some View {
VStack(spacing: Spacing.md) {
Image(systemName: "key.fill")
.font(.system(size: 32, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string("keyboard.onboarding.api.title"))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string("keyboard.onboarding.api.body"))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Text(ExtL10n.string("keyboard.onboarding.api.skipHint"))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
}
private func stepBody(
iconSystemName: String,
title: String,
body: String
) -> some View {
VStack(spacing: Spacing.md) {
Image(systemName: iconSystemName)
.font(.system(size: 36, weight: .light))
.foregroundStyle(palette.accent)
Text(ExtL10n.string(title))
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
.multilineTextAlignment(.center)
Text(ExtL10n.string(body))
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity)
}
// MARK: - Footer
private var footer: some View {
HStack(spacing: Spacing.xs) {
if currentStep != .welcome {
Button(ExtL10n.string("keyboard.onboarding.back")) {
state.onboardingPage = max(0, currentStep.rawValue - 1)
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: 36)
}
Spacer(minLength: 0)
primaryButton
}
}
@ViewBuilder
private var primaryButton: some View {
switch currentStep {
case .welcome:
Button(ExtL10n.string("keyboard.onboarding.getStarted")) {
state.onboardingPage = 1
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .microphone:
Button(ExtL10n.string("keyboard.onboarding.mic.grant")) {
state.requestMicPermission()
// Optimistically advance if permission is denied the
// status text on the next viewWillAppear will reflect it.
state.onboardingPage = 2
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .speech:
Button(ExtL10n.string("keyboard.onboarding.speech.grant")) {
state.requestSpeechPermission()
state.onboardingPage = 3
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .keyboard:
// Step 3 is auto-advanced by viewWillAppear once the user
// has enabled the keyboard in Settings.app. We don't show
// a "Continue" button here that would re-trigger the
// confusion we're solving.
Button(ExtL10n.string("keyboard.onboarding.keyboard.openSettings")) {
state.openSystemSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
case .api:
HStack(spacing: Spacing.xs) {
Button(ExtL10n.string("keyboard.onboarding.api.skip")) {
state.completeOnboarding()
}
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.frame(minHeight: 36)
Button(ExtL10n.string("keyboard.onboarding.api.openHostApp")) {
state.openSettings()
}
.buttonStyle(OverlayPrimaryButtonStyle(palette: palette))
}
}
}
}
private struct OverlayPrimaryButtonStyle: ButtonStyle {
let palette: ThemePalette
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(TypeStyle.caption.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.xs + 2)
.background(palette.accent.opacity(configuration.isPressed ? 0.7 : 1.0),
in: Capsule())
.frame(minHeight: 36)
.contentShape(Capsule())
}
}
+45 -37
View File
@@ -23,8 +23,7 @@ private enum KeyboardLayoutMetrics {
static let micSize: CGFloat = 121
static let micToButtonGap: CGFloat = 8
static let bottomActionRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
static let bottomActionFixedWidth: CGFloat = KeyboardChromeLayout.sideActionKeyWidth
static let bottomActionSpacing: CGFloat = Spacing.xs
static let bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing
/// Gap between the top control row and the transcript / hint line.
/// Four points keeps the "" line visually attached to the controls.
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
@@ -34,7 +33,7 @@ private enum KeyboardLayoutMetrics {
/// park delete/return at the far screen edges and turn each cursor-drag
/// pad into a ~450 pt runway capping keeps the reach ergonomics of the
/// phone layout. iPhone widths are all below this, so it is a no-op there.
static let contentMaxWidth: CGFloat = 700
static let contentMaxWidth: CGFloat = KeyboardChromeLayout.contentMaxWidth
// MARK: - Content-driven keyboard height (single source of truth)
static let outerPaddingTop: CGFloat = 4
@@ -45,6 +44,8 @@ private enum KeyboardLayoutMetrics {
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Moves the action cluster down so its keys share the typing row's baseline.
static let actionClusterTopGap: CGFloat = Spacing.xl
/// Centres the mic between the transcript hint and bottom action row.
static let micUpwardAdjustment: CGFloat = (actionClusterTopGap - micToButtonGap) / 2
/// The shared 4 pt outer padding is the complete bottom inset.
static let actionClusterBottomGap: CGFloat = 0
@@ -65,7 +66,7 @@ public struct KeyboardRootView: View {
public init(
state: KeyboardViewController.State,
typing: TypingSessionController = TypingSessionController(),
typing: TypingSessionController,
onInsert: @escaping (String) -> Void = { _ in }
) {
self.state = state
@@ -85,6 +86,7 @@ public struct KeyboardRootView: View {
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
+ KeyboardLayoutMetrics.headerBandHeight
+ KeyboardLayoutMetrics.actionClusterTopGap
- KeyboardLayoutMetrics.micUpwardAdjustment
/// Horizontal inset the side pads should respect.
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
@@ -115,20 +117,7 @@ public struct KeyboardRootView: View {
.frame(height: Self.totalHeight)
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
// v0.3.0: in-keyboard first-launch onboarding. Mounted as
// an overlay so the normal keyboard chrome stays
// responsive underneath (mic button still works, chip
// taps register). Only rendered until
// `state.hasCompletedOnboarding` flips to true; from
// then on the overlay is unmounted and never re-rendered.
if !state.hasCompletedOnboarding {
KeyboardOnboardingOverlay(state: state)
.environment(\.themePalette, palette)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
}
@@ -196,23 +185,37 @@ public struct KeyboardRootView: View {
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
.offset(y: -KeyboardLayoutMetrics.micUpwardAdjustment)
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
}
.frame(height: KeyboardLayoutMetrics.micSize)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomDeleteButton(disabled: editingBlocked)
} else {
bottomDeleteButton(disabled: editingBlocked)
bottomReturnButton(disabled: editingBlocked)
bottomSpaceButton(disabled: editingBlocked)
GeometryReader { proxy in
let widths = KeyboardChromeLayout.actionKeyWidths(
availableWidth: proxy.size.width
)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
} else {
bottomDeleteButton(disabled: editingBlocked)
.frame(width: widths.side)
bottomReturnButton(disabled: editingBlocked)
.frame(width: widths.center)
bottomSpaceButton(disabled: editingBlocked)
.frame(width: widths.side)
}
}
}
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
.opacity(dragging ? 0 : 1)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
@@ -234,20 +237,14 @@ public struct KeyboardRootView: View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomSpaceButton(disabled: Bool) -> some View {
RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) {
state.insertSpace()
}
.frame(
width: KeyboardLayoutMetrics.bottomActionFixedWidth,
height: KeyboardLayoutMetrics.bottomActionRowHeight
)
.frame(height: KeyboardLayoutMetrics.bottomActionRowHeight)
}
private func bottomReturnButton(disabled: Bool) -> some View {
@@ -299,19 +296,28 @@ extension KeyboardRootView {
#if DEBUG
#Preview("Keyboard · Idle") {
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
KeyboardRootView(
state: KeyboardViewController.State.previewIdle,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Recording") {
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
KeyboardRootView(
state: KeyboardViewController.State.previewRecording,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Processing") {
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
KeyboardRootView(
state: KeyboardViewController.State.previewProcessing,
typing: TypingSessionController()
)
.frame(width: 390, height: KeyboardRootView.totalHeight)
.preferredColorScheme(.dark)
}
@@ -417,6 +423,8 @@ private struct TranscriptLine: View {
ExtL10n.text("keyboard.error.fullAccessRequired")
case .unavailable(.appGroupUnavailable):
ExtL10n.text("keyboard.error.appGroupCommunication")
case .unavailable(.onboardingIncomplete):
ExtL10n.text("keyboard.hint.finishSetupInApp")
case .recording, .processing:
EmptyView()
}
@@ -36,7 +36,7 @@ struct KeyboardBrandLogo: View {
.accessibilityHidden(true)
}
.buttonStyle(BrandLogoPressStyle())
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
}
}
@@ -94,6 +94,8 @@ struct RepeatingPressButton<Label: View>: View {
var disabled: Bool = false
/// Plays the system delete click on each fire (matches stock keyboard).
var playsDeleteSound: Bool = true
/// Typing-grid haptic strength; `.off` skips haptics (voice toolbar default).
var hapticIntensity: KeyboardHapticIntensity = .off
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@@ -130,6 +132,7 @@ struct RepeatingPressButton<Label: View>: View {
if playsDeleteSound {
KeyboardSoundFeedback.deleteClick()
}
KeyboardHapticFeedback.play(role: .delete, intensity: hapticIntensity)
action()
}
@@ -180,6 +183,39 @@ struct RepeatingDeleteButton: View {
}
}
// MARK: - Press-down typing key
/// Fires on touch-down (not release) so click sound / haptic match the stock
/// keyboard and the voice toolbars RectangularToolbarButton.
struct PressDownKeyButton<Label: View>: View {
var disabled: Bool = false
let action: () -> Void
@ViewBuilder let label: (_ isPressed: Bool) -> Label
@State private var isPressing = false
var body: some View {
label(isPressing)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityAddTraits(.isButton)
}
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
action()
}
.onEnded { _ in
isPressing = false
}
}
}
// MARK: - Rectangular toolbar button
struct RectangularToolbarButton: View {
+2 -20
View File
@@ -211,26 +211,8 @@
"locale.ja-JP" = "Japanese";
"locale.ko-KR" = "Korean";
/* v0.3.0: in-keyboard first-launch onboarding */
"keyboard.onboarding.title" = "Set up OSGKeyboard";
"keyboard.onboarding.back" = "Back";
"keyboard.onboarding.getStarted" = "Get Started";
"keyboard.onboarding.welcome.title" = "Welcome to OSGKeyboard";
"keyboard.onboarding.welcome.body" = "Voice-to-text with AI polish. Set up takes 30 seconds — most of it happens right here in the keyboard.";
"keyboard.onboarding.mic.title" = "Microphone access";
"keyboard.onboarding.mic.body" = "OSGKeyboard needs microphone access to transcribe your speech. iOS will show a system prompt.";
"keyboard.onboarding.mic.grant" = "Allow Microphone";
"keyboard.onboarding.speech.title" = "Speech recognition";
"keyboard.onboarding.speech.body" = "Apple's on-device speech engine turns your voice into text. iOS will show a system prompt.";
"keyboard.onboarding.speech.grant" = "Allow Speech Recognition";
"keyboard.onboarding.keyboard.title" = "Enable OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "Open Settings → General → Keyboard → Keyboards → Add New Keyboard → OSGKeyboard. Then tap OSGKeyboard again and enable Allow Full Access. Come back here when you're done.";
"keyboard.onboarding.keyboard.openSettings" = "Open Settings";
"keyboard.onboarding.api.title" = "One last step";
"keyboard.onboarding.api.body" = "To polish your text with AI, OSGKeyboard needs an LLM API key. You can add it now in the app, or skip and add it later from Settings.";
"keyboard.onboarding.api.skipHint" = "Polishing won't work without an API key, but the keyboard will still type the raw transcript.";
"keyboard.onboarding.api.skip" = "Skip";
"keyboard.onboarding.api.openHostApp" = "Open OSGKeyboard";
/* Voice gated until host-app onboarding finishes */
"keyboard.hint.finishSetupInApp" = "Finish setup in OSGKeyboard to use voice input.";
/* v0.3.0: AppContext chip on the keyboard top bar */
"keyboard.appContext.a11y" = "Polish context";
+2 -20
View File
@@ -211,26 +211,8 @@
"locale.ja-JP" = "日本語";
"locale.ko-KR" = "한국어";
/* v0.3.0:键盘内首次启动引导 */
"keyboard.onboarding.title" = "设置 OSGKeyboard";
"keyboard.onboarding.back" = "返回";
"keyboard.onboarding.getStarted" = "开始设置";
"keyboard.onboarding.welcome.title" = "欢迎使用 OSGKeyboard";
"keyboard.onboarding.welcome.body" = "语音转文字 + AI 润色。30 秒搞定,绝大部分步骤直接在键盘里完成。";
"keyboard.onboarding.mic.title" = "麦克风权限";
"keyboard.onboarding.mic.body" = "OSGKeyboard 需要麦克风权限来转写你的语音。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.mic.grant" = "允许麦克风";
"keyboard.onboarding.speech.title" = "语音识别权限";
"keyboard.onboarding.speech.body" = "Apple 的本地语音引擎负责把声音变成文字。点击下方按钮后,iOS 会弹出系统提示。";
"keyboard.onboarding.speech.grant" = "允许语音识别";
"keyboard.onboarding.keyboard.title" = "启用 OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "打开 设置 → 通用 → 键盘 → 键盘 → 添加新键盘 → OSGKeyboard。然后再次点击 OSGKeyboard,打开\"允许完全访问\"。完成后回到这里。";
"keyboard.onboarding.keyboard.openSettings" = "打开设置";
"keyboard.onboarding.api.title" = "最后一步";
"keyboard.onboarding.api.body" = "要让 AI 润色你的文字,OSGKeyboard 需要一个 LLM API key。你可以现在在 App 里填写,也可以先跳过,之后在设置里补上。";
"keyboard.onboarding.api.skipHint" = "没有 API key 也能用——只是不会润色,只输出原始转写。";
"keyboard.onboarding.api.skip" = "跳过";
"keyboard.onboarding.api.openHostApp" = "打开 OSGKeyboard";
/* 未完成主 App 引导时,语音入口提示 */
"keyboard.hint.finishSetupInApp" = "请在 OSGKeyboard App 中完成设置后再使用语音输入。";
/* v0.3.0:键盘顶栏的输入场景芯片 */
"keyboard.appContext.a11y" = "润色场景";