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:
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 toolbar’s 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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "润色场景";
|
||||
|
||||
Reference in New Issue
Block a user