feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site

Migrate keyboard dictation to continuous Flow sessions with auto-start,
tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group
IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
This commit is contained in:
Rocky
2026-06-19 18:05:50 +08:00
parent 6148d05093
commit 7f059dbd45
48 changed files with 3815 additions and 1065 deletions
+2 -2
View File
@@ -39,8 +39,8 @@
<string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
<string>OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>OSGKeyboard uses on-device speech recognition to transcribe your dictation. Audio never leaves your device.</string>
<string>OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription.</string>
</dict>
</plist>
+343 -121
View File
@@ -4,7 +4,7 @@
// Principal class for the Custom Keyboard Extension. Hosts a single
// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline:
//
// AudioCaptureService ASRService PolishingService insertText
// host app dictation handoff App Group transcript insertText
//
// Design notes:
// The class is `@MainActor` every UI mutation and `textDocumentProxy`
@@ -19,12 +19,22 @@
import UIKit
import SwiftUI
import AVFoundation
import OSGKeyboardShared
@objc(KeyboardViewController)
@MainActor
public final class KeyboardViewController: UIInputViewController {
private enum FlowWatchdog {
static let pollIntervalNs: UInt64 = 200_000_000
/// Give the user time to manually open the host app when auto-jump fails.
static let startTimeout: TimeInterval = 30
static let resultTimeout: TimeInterval = 45
}
private enum DictationWatchdog {
static let pollIntervalNs: UInt64 = 400_000_000
static let timeout: TimeInterval = 45
}
// MARK: - View model
@@ -37,17 +47,21 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - State
private let state = State()
private let audio = AudioCaptureService()
private let asr: ASRService = ASRServiceFactory.make()
private let polisher = PolishingService()
private let permissions = PermissionManager()
private let persistor = AppGroupPersistor()
private var session: AudioCaptureService.Session?
private var asrTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var hosting: UIHostingController<KeyboardRootView>!
/// Legacy one-shot handoff (`osgkeyboard://dictate`).
private var awaitingDictationResult = false
private var dictationRequestStartedAt: TimeInterval = 0
private var dictationWatchdogTask: Task<Void, Never>?
/// Flow session: waiting for host app to come alive after `startflow`.
private var isPendingFlowStart = false
private var flowStartDeadline: TimeInterval = 0
private var isFlowRecording = false
private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>?
private var utteranceStartedAt: TimeInterval = 0
// MARK: - Lifecycle
@@ -60,6 +74,8 @@ public final class KeyboardViewController: UIInputViewController {
installStateActions()
installSwiftUI()
loadPersistedConfig()
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
}
public override func viewWillDisappear(_ animated: Bool) {
@@ -67,6 +83,12 @@ public final class KeyboardViewController: UIInputViewController {
cancelPipeline()
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
@@ -74,7 +96,8 @@ public final class KeyboardViewController: UIInputViewController {
public override func textDidChange(_ textInput: (any UITextInput)?) {
super.textDidChange(textInput)
// Hook for future per-app mode switching (e.g. password field .off).
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
}
// MARK: - Wiring
@@ -82,7 +105,7 @@ public final class KeyboardViewController: UIInputViewController {
private func installStateActions() {
state.beginRecording = { [weak self] in self?.pressBegan() }
state.endRecording = { [weak self] in self?.pressEnded() }
state.tapMic = { [weak self] in self?.advanceToNextInputMode() }
state.tapMic = { [weak self] in self?.toggleRecording() }
state.openSettings = { [weak self] in self?.openHostApp() }
state.setMode = { [weak self] m in self?.persistMode(m) }
state.setLocale = { [weak self] l in self?.persistLocale(l) }
@@ -125,11 +148,18 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Press handlers
private func toggleRecording() {
switch state.phase {
case .recording:
pressEnded()
case .idle, .denied, .error:
pressBegan()
case .requestingPermissions, .processing:
break
}
}
private func pressBegan() {
// Allow re-entry from `.denied` and from a finished/cleared
// `.error` so the user can simply press the mic again after
// returning from Settings with permission granted they
// shouldn't have to wait for an auto-clear timer.
switch state.phase {
case .idle, .denied, .error:
break
@@ -137,106 +167,213 @@ public final class KeyboardViewController: UIInputViewController {
return
}
guard state.mode != .off else { return }
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
// press (before the first Task has had a chance to flip phase to
// .recording) is rejected by the guard above. This fixes the race
// where the user double-tapped the mic and we started two
// pipelines at once.
state.phase = .requestingPermissions
Task { @MainActor [weak self] in
guard let self else { return }
let micGranted = await self.permissions.requestMicPermission()
guard micGranted else {
self.state.phase = .denied(.mic)
return
}
// We explicitly ask for Speech recognition permission here.
// `SpeechAnalyzer` does not expose a dedicated request API,
// so the app still relies on the shared Speech permission
// gate and `NSSpeechRecognitionUsageDescription`.
let speechGranted = await self.permissions.requestSpeechPermission()
guard speechGranted else {
self.state.phase = .denied(.speech)
return
}
self.startPipeline()
guard hasFullAccess else {
let msg = "请在系统设置中为 OSGKeyboard 开启“允许完全访问”,否则无法使用语音输入"
state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError()
return
}
guard AppGroup.isAvailable else {
let msg = "App Group 未配置,键盘无法与主 App 通信。请重新安装并检查签名配置。"
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
return
}
if FlowSessionBridge.isSessionActive() {
startFlowRecording()
} else {
beginFlowStart()
}
}
private func pressEnded() {
guard state.phase == .recording else { return }
stopPipeline()
if isPendingFlowStart {
cancelPendingFlowStart()
return
}
guard isFlowRecording else { return }
isFlowRecording = false
stopUtteranceCountdown()
FlowSessionBridge.setRecordingState(.stopped)
state.phase = .processing
state.lastTranscript = "识别中..."
startFlowResultWatchdog()
}
// MARK: - Pipeline
private func startFlowRecording() {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
private func startPipeline() {
let session = audio.start()
self.session = session
state.phase = .recording
state.level = 0
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
FlowSessionBridge.setRecordingState(.recording)
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
startUtteranceCountdown()
startFlowLevelWatchdog()
debug("startFlowRecording")
}
let locale = resolveLocale(state.localeId)
let events = asr.transcribe(
stream: session.audio,
locale: locale
)
asrTask = Task { @MainActor [weak self] in
guard let self else { return }
var lastPartial: String = ""
for await event in events {
switch event {
case .capability(let onDevice):
self.state.onDeviceSupported = onDevice
case .partial(let s):
lastPartial = s
self.state.lastTranscript = s
case .final(let s):
let transcript = s.isEmpty ? lastPartial : s
self.handleFinalTranscript(transcript)
case .error(let m):
self.state.phase = .error(.asr(m))
self.scheduleAutoClearError()
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
utteranceTimerTask?.cancel()
utteranceTimerTask = Task { @MainActor [weak self] in
while let self, self.isFlowRecording, !Task.isCancelled {
let elapsed = Date().timeIntervalSince1970 - self.utteranceStartedAt
let remaining = max(0, Int(ceil(FlowSessionKeys.maxUtteranceDuration - elapsed)))
self.state.utteranceRemainingSeconds = remaining
if remaining <= 0 {
self.pressEnded()
return
}
}
}
levelTask = Task { @MainActor [weak self] in
for await level in session.levels {
guard let self else { return }
// Smooth a little extra to feel natural.
self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4
try? await Task.sleep(nanoseconds: 200_000_000)
}
}
}
private func stopPipeline() {
session?.stop()
session = nil
asrTask?.cancel(); asrTask = nil
levelTask?.cancel(); levelTask = nil
private func stopUtteranceCountdown() {
utteranceTimerTask?.cancel()
utteranceTimerTask = nil
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
}
private func beginFlowStart() {
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = "正在启动语音会话..."
state.phase = .processing
openHostApp(path: "startflow")
startFlowStartWatchdog()
debug("beginFlowStart")
}
private func cancelPendingFlowStart() {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
state.phase = .idle
state.lastTranscript = ""
}
private func startFlowStartWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isPendingFlowStart {
if FlowSessionBridge.isSessionActive() {
self.startFlowRecording()
return
}
let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.flowStartDeadline = 0
self.showManualSettingsHint(path: "startflow")
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func startFlowLevelWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isFlowRecording {
let levels = FlowSessionBridge.audioLevels()
if let peak = levels.max(), peak > 0 {
self.state.level = Double(peak)
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func startFlowResultWatchdog() {
stopFlowWatchdog()
let startedAt = Date().timeIntervalSince1970
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
if let result = FlowSessionBridge.consumeTranscriptionResult() {
self.stopFlowWatchdog()
self.handleFlowTranscript(result)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
self.stopFlowWatchdog()
self.state.phase = .error(.unknown(error), message: error)
self.scheduleAutoClearError()
return
}
let now = Date().timeIntervalSince1970
if now - startedAt > FlowWatchdog.resultTimeout {
self.stopFlowWatchdog()
let msg = "等待识别结果超时,请重试"
self.state.phase = .error(.unknown(msg), message: msg)
self.scheduleAutoClearError()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func handleFlowTranscript(_ transcript: String) {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
state.phase = .idle
state.level = 0
return
}
// Host app already polished when configured; keyboard only inserts.
textDocumentProxy.insertText(trimmed)
state.lastTranscript = ""
state.level = 0
state.phase = .idle
debug("flow insert length=\(trimmed.count)")
}
private func stopFlowWatchdog() {
flowWatchdogTask?.cancel()
flowWatchdogTask = nil
}
private func cancelPipeline() {
stopPipeline()
asr.cancel()
if state.phase == .recording || state.phase == .processing {
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
}
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
}
if awaitingDictationResult {
debug("cancelPipeline ignored while awaiting legacy handoff result")
return
}
if state.phase == .processing {
state.phase = .idle
}
state.level = 0
// Reset the on-device flag so the StatusBadge stops showing the
// cloud-fallback indicator between recordings.
state.onDeviceSupported = false
}
private func handleFinalTranscript(_ transcript: String) {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
debug("received empty transcript")
awaitingDictationResult = false
stopDictationWatchdog()
state.phase = .idle
return
}
debug("received transcript length=\(trimmed.count)")
awaitingDictationResult = false
stopDictationWatchdog()
// Local engine or transcribe mode: insert directly, no LLM call.
if state.isLocalEngine || state.mode == .transcribe {
textDocumentProxy.insertText(trimmed)
@@ -307,18 +444,8 @@ public final class KeyboardViewController: UIInputViewController {
persistor.persist(mode: m)
if isRecording {
if m == .off {
// Switching to .off while recording: drop the partial
// (no insertion, no LLM). User has explicitly disabled
// the keyboard, so we honour that immediately.
stopPipeline()
state.phase = .idle
state.lastTranscript = ""
} else if m == .transcribe {
// Switching to .transcribe while in .polish: end the
// recording, the partial will flow through
// handleFinalTranscript which inserts the raw text in
// .transcribe mode (no LLM call).
pressEnded()
}
}
}
@@ -335,34 +462,123 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
private func openHostApp() {
let urlString = "osgkeyboard://settings"
if let url = URL(string: urlString) {
var responder: UIResponder? = self
while let r = responder {
if let app = r as? UIApplication {
app.open(url)
return
}
responder = r.next
}
private func openHostApp(path: String = "settings") {
guard hasFullAccess else {
let msg = "未开启“允许完全访问”,请先在键盘设置中打开"
state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError()
return
}
if let url = URL(string: UIApplication.openSettingsURLString) {
var responder: UIResponder? = self
while let r = responder {
if let app = r as? UIApplication {
app.open(url); return
}
responder = r.next
guard let url = URL(string: "osgkeyboard://\(path)") else {
handleHostAppOpenResult(path: path, success: false)
return
}
HostAppLauncher.open(url: url, from: self) { [weak self] success in
self?.handleHostAppOpenResult(path: path, success: success)
}
}
private func handleHostAppOpenResult(path: String, success: Bool) {
debug("openHostApp path=\(path) success=\(success)")
guard !success else { return }
// Flow start: auto-jump often fails in WeChat/Safari keep polling
// so a manually opened host app can still satisfy the session check.
if path == "startflow", isPendingFlowStart {
state.lastTranscript = "无法自动跳转,请从主屏幕打开 OSGKeyboard,然后返回继续"
return
}
if path == "dictate" {
awaitingDictationResult = false
stopDictationWatchdog()
}
showManualSettingsHint(path: path)
}
private func consumePendingDictationResultIfNeeded() {
guard let transcript = DictationBridge.consumePendingTranscript() else { return }
debug("consumePendingDictationResultIfNeeded success")
handleFinalTranscript(transcript)
}
private func refreshDictationProgressStateIfNeeded() {
guard awaitingDictationResult, case .processing = state.phase else { return }
let progress = DictationBridge.currentStatus()
switch progress.status {
case .requested:
state.lastTranscript = state.isLocalEngine
? "正在打开 OSGKeyboard(本地转写)..."
: "正在打开 OSGKeyboard..."
case .recording:
state.lastTranscript = state.isLocalEngine
? "正在本地录音,请完成后返回当前输入页"
: "正在录音,请完成后返回当前输入页"
case .transcribing:
state.lastTranscript = state.isLocalEngine
? "本地识别中,请稍候并返回输入页"
: "识别中,请稍候并返回输入页"
case .error:
let msg = progress.message ?? "录音失败,请重试"
debug("host returned error: \(msg)")
awaitingDictationResult = false
stopDictationWatchdog()
state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError()
case .cancelled:
debug("host cancelled")
awaitingDictationResult = false
stopDictationWatchdog()
state.phase = .idle
case .done, .idle:
break
}
// Host app can be killed or leave without callback. If status does not
// advance for too long, fail fast with an actionable retry message.
let now = Date().timeIntervalSince1970
let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt
if now - lastProgressAt > DictationWatchdog.timeout {
let timeoutMessage = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试"
debug("dictation timeout after \(Int(now - lastProgressAt))s")
awaitingDictationResult = false
stopDictationWatchdog()
DictationBridge.clear()
state.phase = .error(.unknown(timeoutMessage), message: timeoutMessage)
scheduleAutoClearError()
}
}
private func showManualSettingsHint(path: String = "settings") {
let msg: String
if !hasFullAccess {
msg = "请先开启 OSGKeyboard 的“允许完全访问”,否则键盘无法跳转到 App"
} else if path == "settings" {
msg = "系统拒绝了键盘跳转。请手动打开 OSGKeyboard App 进入设置页"
} else if path == "startflow" {
msg = "语音会话未启动。请从主屏幕打开 OSGKeyboard App,返回后再按麦克风"
} else if state.isLocalEngine {
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 完成本地转写,再返回输入页"
} else {
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 录音,再返回输入页"
}
state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError()
}
private func startDictationWatchdog() {
stopDictationWatchdog()
dictationWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.awaitingDictationResult {
self.consumePendingDictationResultIfNeeded()
self.refreshDictationProgressStateIfNeeded()
try? await Task.sleep(nanoseconds: DictationWatchdog.pollIntervalNs)
}
}
}
// MARK: - Helpers
private func resolveLocale(_ id: String) -> Locale {
if id == "auto" { return .current }
return Locale(identifier: id)
private func stopDictationWatchdog() {
dictationWatchdogTask?.cancel()
dictationWatchdogTask = nil
}
private func scheduleAutoClearError() {
@@ -381,4 +597,10 @@ public final class KeyboardViewController: UIInputViewController {
}
}
}
private func debug(_ message: String) {
#if DEBUG
print("🎙️[KeyboardVC] \(message)")
#endif
}
}
+2 -2
View File
@@ -6,9 +6,9 @@
<array>
<string>group.com.osgkeyboard.shared</string>
</array>
<key>com.apple.security.keychain-access-groups</key>
<key>keychain-access-groups</key>
<array>
<string>com.osgkeyboard.shared</string>
<string>$(AppIdentifierPrefix)com.osgkeyboard.shared</string>
</array>
</dict>
</plist>
@@ -62,16 +62,19 @@ public struct AppGroupPersistor {
/// Persist `mode` to the App Group store.
public func persist(mode: KeyboardViewController.State.InputMode) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setModeId(mode.rawValue)
}
/// Persist `localeId` to the App Group store.
public func persist(localeId: String) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setLocaleId(localeId)
}
/// Persist `engineMode` to the App Group store.
public func persist(engineMode: String) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setEngineMode(engineMode)
}
}
@@ -0,0 +1,70 @@
// HostAppLauncher.swift
// OSGKeyboard · Keyboard Extension
//
// Opens the host app via URL using every extension-safe strategy:
// 1. `extensionContext.open` (official)
// 2. Responder-chain `UIApplication.open` (TypeWhisper pattern)
// 3. `sharedApplication` KVC fallback (common in full-access keyboards)
import UIKit
enum HostAppLauncher {
@MainActor
static func open(
url: URL,
from controller: KeyboardViewController,
completion: @escaping @MainActor (Bool) -> Void
) {
if let context = controller.extensionContext {
context.open(url) { success in
Task { @MainActor in
if success {
completion(true)
return
}
completion(openViaFallback(url, from: controller))
}
}
return
}
completion(openViaFallback(url, from: controller))
}
@MainActor
private static func openViaFallback(
_ url: URL,
from controller: KeyboardViewController
) -> Bool {
if openViaResponderChain(url, from: controller) {
return true
}
return openViaSharedApplication(url)
}
@MainActor
private static func openViaResponderChain(
_ url: URL,
from controller: KeyboardViewController
) -> Bool {
var responder: UIResponder? = controller
while let current = responder {
if let application = current as? UIApplication {
application.open(url, options: [:]) { _ in }
return true
}
responder = current.next
}
return false
}
@MainActor
private static func openViaSharedApplication(_ url: URL) -> Bool {
guard
let application = UIApplication.value(forKeyPath: "sharedApplication") as? UIApplication
else {
return false
}
application.open(url, options: [:]) { _ in }
return true
}
}
@@ -47,15 +47,34 @@ public final class PermissionManager: @unchecked Sendable {
/// 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 { status in
switch status {
case .authorized: cont.resume(returning: true)
case .denied, .restricted, .notDetermined:
cont.resume(returning: false)
@unknown default:
cont.resume(returning: false)
}
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)
}
}
}
+51 -32
View File
@@ -22,7 +22,7 @@ import SwiftUI
import OSGKeyboardShared
public struct KeyboardRootView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@ObservedObject var state: State
@@ -35,6 +35,10 @@ public struct KeyboardRootView: View {
/// it up.
static let totalHeight: CGFloat = 280
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
public var body: some View {
VStack(spacing: 0) {
topBar
@@ -48,26 +52,12 @@ public struct KeyboardRootView: View {
}
.padding(.top, 4)
.padding(.bottom, 6)
// iOS keyboard extensions always render dark (Apple's default
// for custom keyboards), and we let the system UI chrome show
// through by drawing no background of our own.
// Let the system UI chrome show through by drawing no background
// of our own.
.background(Color.clear)
.frame(height: Self.totalHeight)
// Top edge: subtle highlight gradient + 0.5pt divider line.
// These give the keyboard a "physical surface" feel and visually
// separate it from the host text field above. We overlay (not
// background) so the underlying color stays clear.
.overlay(alignment: .top) {
VStack(spacing: 0) {
Rectangle()
.fill(LinearGradient(
colors: [Color.white.opacity(0.05), .clear],
startPoint: .top, endPoint: .bottom
))
.frame(height: 1)
Spacer(minLength: 0)
}
}
// Feed the resolved palette to all nested chips/buttons.
.environment(\.themePalette, palette)
}
// MARK: - Top bar
@@ -96,7 +86,7 @@ public struct KeyboardRootView: View {
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("home.action.openSettingsA11y"))
.accessibilityLabel(Text(KeyboardL10n.openSettingsA11y))
}
.padding(.horizontal, Spacing.md)
}
@@ -115,9 +105,8 @@ public struct KeyboardRootView: View {
RecordButton(
phase: buttonPhase,
level: state.level,
onPressBegan: state.beginRecording,
onPressEnded: state.endRecording,
onTap: state.tapMic
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: 140, height: 140)
}
@@ -139,7 +128,7 @@ public struct KeyboardRootView: View {
state.deleteBackward()
}
Button(action: state.insertSpace) {
Text("common.space")
Text(KeyboardL10n.space)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
@@ -150,7 +139,7 @@ public struct KeyboardRootView: View {
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("common.space"))
.accessibilityLabel(Text(KeyboardL10n.space))
ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline()
}
@@ -211,13 +200,13 @@ private struct TranscriptLine: View {
ZStack {
switch phase {
case .idle:
Text("keyboard.placeholder.idle")
Text(KeyboardL10n.placeholderIdle)
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
case .requestingPermissions:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary)
Text("keyboard.placeholder.preparing")
Text(KeyboardL10n.placeholderPreparing)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
@@ -231,9 +220,11 @@ private struct TranscriptLine: View {
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent)
Text("keyboard.placeholder.processing")
Text(transcript.isEmpty ? KeyboardL10n.placeholderProcessing : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
case .error(_, let msg):
Text(msg ?? "")
@@ -256,7 +247,7 @@ private struct TranscriptLine: View {
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(Text("keyboard.deniedHint"))
.accessibilityHint(Text(KeyboardL10n.deniedHint))
}
}
.frame(maxWidth: .infinity)
@@ -265,8 +256,8 @@ private struct TranscriptLine: View {
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
switch reason {
case .mic: return "麦克风被拒绝 · Mic denied"
case .speech: return "语音识别被拒绝 · Speech denied"
case .mic: return KeyboardL10n.micDenied
case .speech: return KeyboardL10n.speechDenied
}
}
}
@@ -361,7 +352,7 @@ private struct LocalEngineChip: View {
var body: some View {
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
Text("keyboard.placeholder.localBadge")
Text(KeyboardL10n.localBadge)
}
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
@@ -372,6 +363,34 @@ private struct LocalEngineChip: View {
}
}
// MARK: - Extension text fallback
//
// Custom keyboard extensions can end up without the expected localized
// resource table when signing/project generation drifts. Keep a tiny
// in-code fallback map so UI never shows raw key names like
// "common.space" in production.
private enum KeyboardL10n {
private static var isChinese: Bool {
Locale.preferredLanguages.first?.hasPrefix("zh") == true
}
static var space: String { isChinese ? "空格" : "Space" }
static var placeholderIdle: String { isChinese ? "点按说话" : "Tap to talk" }
static var placeholderPreparing: String { isChinese ? "准备中…" : "Preparing" }
static var placeholderProcessing: String { isChinese ? "处理中…" : "Processing" }
static var localBadge: String { isChinese ? "本地" : "On-device" }
static var micDenied: String { isChinese ? "麦克风被拒绝" : "Mic denied" }
static var speechDenied: String { isChinese ? "语音识别被拒绝" : "Speech denied" }
static var deniedHint: String {
isChinese
? "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"
: "Open OSGKeyboard settings to grant microphone / speech access."
}
static var openSettingsA11y: String {
isChinese ? "打开 OSGKeyboard 设置" : "Open OSGKeyboard settings"
}
}
// MARK: - Mode chip
private struct ModeChip: View {
+42 -78
View File
@@ -1,10 +1,8 @@
// RecordButton.swift
// OSGKeyboard · Keyboard Extension
//
// The hero control. 120 pt primary disc with a soft inner gradient, a
// breathing outer ring while recording, and a centred waveform that maps
// directly to the real audio RMS. Idle / recording / processing are three
// distinct visual states no flicker, no surprise transitions.
// Tap-to-toggle mic: tap once to start, tap again to stop. Shows a
// remaining-time countdown while recording; last 10 seconds turn red.
import SwiftUI
import OSGKeyboardShared
@@ -21,38 +19,38 @@ struct RecordButton: View {
let phase: Phase
let level: Double // 0...1
let onPressBegan: () -> Void
let onPressEnded: () -> Void
let onTap: () -> Void
/// Seconds left in the current utterance; shown only while recording.
let remainingSeconds: Int?
let onToggle: () -> Void
@GestureState private var isPressed: Bool = false
@State private var breath: Bool = false
init(
phase: Phase,
level: Double,
onPressBegan: @escaping () -> Void,
onPressEnded: @escaping () -> Void,
onTap: @escaping () -> Void
remainingSeconds: Int? = nil,
onToggle: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.onPressBegan = onPressBegan
self.onPressEnded = onPressEnded
self.onTap = onTap
self.remainingSeconds = remainingSeconds
self.onToggle = onToggle
}
private var isUrgent: Bool {
guard phase == .recording, let remainingSeconds else { return false }
return remainingSeconds <= 10
}
var body: some View {
ZStack {
// Outer breathing ring (recording only)
Circle()
.stroke(palette.recordRed.opacity(0.35), lineWidth: 2)
.stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2)
.frame(width: 150, height: 150)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
// Halo: soft red glow that intensifies with input level
Circle()
.fill(
RadialGradient(
@@ -68,7 +66,6 @@ struct RecordButton: View {
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
// Secondary outer ring (always present, dimmer when idle)
Circle()
.stroke(
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
@@ -76,7 +73,6 @@ struct RecordButton: View {
)
.frame(width: 140, height: 140)
// Main disc with gradient + soft inner highlight
ZStack {
Circle()
.fill(discGradient)
@@ -84,7 +80,6 @@ struct RecordButton: View {
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.blendMode(.overlay)
// Centre content switches by phase
Group {
switch phase {
case .idle:
@@ -92,17 +87,19 @@ struct RecordButton: View {
.font(.system(size: 38, weight: .medium))
.foregroundStyle(palette.textPrimary)
case .recording:
WaveformView(level: level, active: true)
.frame(width: 80, height: 44)
.transition(.opacity)
VStack(spacing: 4) {
if let remainingSeconds {
Text(formatRemaining(remainingSeconds))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.foregroundStyle(isUrgent ? .white : palette.textPrimary)
.monospacedDigit()
.contentTransition(.numericText())
}
WaveformView(level: level, active: true)
.frame(width: 72, height: 32)
}
.transition(.opacity)
case .processing:
// Scaled to ~50pt inside a 120pt disc (~42%)
// same absolute size as the preview stub's
// spinner (2.5x of the default ProgressView),
// just a smaller fraction because the real
// disc is bigger. The user gets the same
// visual weight whether they're looking at
// the in-app preview or the live keyboard.
ProgressView()
.progressViewStyle(.circular)
.tint(palette.textPrimary)
@@ -115,63 +112,34 @@ struct RecordButton: View {
}
}
.frame(width: 120, height: 120)
.scaleEffect(isPressed ? 0.94 : 1.0)
.animation(Motion.quick, value: isPressed)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
// Press-to-talk: act on the FIRST touch-down, not after a 150 ms
// minimum duration. That's what Typeless feels like, and it's what
// makes the keyboard feel responsive. A tap (very short press) is
// interpreted as "toggle" for the secondary action (onTap), not
// "record" the recording only fires if the press lasts long
// enough to read as intentional. This avoids the previous bug
// where every single tap fired both onPressBegan AND onTap.
.gesture(
LongPressGesture(minimumDuration: 0.18)
.sequenced(before: DragGesture(minimumDistance: 0))
.updating($isPressed) { value, state, _ in
switch value {
case .second(true, _): state = true
default: state = false
}
}
.onChanged { value in
if case .second(true, _) = value, !pressArmed {
pressArmed = true
onPressBegan()
}
}
.onEnded { _ in
if pressArmed { pressArmed = false; onPressEnded() }
}
)
.simultaneousGesture(
// Pure tap: only fires when the user lifts before the long-press
// threshold. This becomes the "secondary action" (e.g. cycle
// mode). It is paired with, not conflicting with, the long-press.
TapGesture(count: 1)
.onEnded {
if !pressArmed { onTap() }
}
)
.onTapGesture {
guard phase != .processing else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
.onChange(of: phase) { _, new in
breath = (new == .recording)
}
.accessibilityLabel(Text("keyboard.pressToTalkA11y"))
.accessibilityLabel(Text("keyboard.tapToTalkA11y"))
}
@State private var pressArmed: Bool = false
private func formatRemaining(_ seconds: Int) -> String {
let m = seconds / 60
let s = seconds % 60
return String(format: "%d:%02d", m, s)
}
private var discGradient: LinearGradient {
switch phase {
case .recording:
return LinearGradient(
colors: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)],
startPoint: .top,
endPoint: .bottom
)
let colors: [Color] = isUrgent
? [palette.recordRed, palette.recordRed.opacity(0.85)]
: [palette.recordRed.opacity(0.95), palette.recordRed.opacity(0.75)]
return LinearGradient(colors: colors, startPoint: .top, endPoint: .bottom)
case .processing:
return LinearGradient(
colors: [palette.surfaceElevated, palette.surface],
@@ -185,10 +153,6 @@ struct RecordButton: View {
endPoint: .bottom
)
case .idle:
// Brand green same hue as `Palette.{dark,light}.accent`
// and the AccentColor asset. The disc is the keyboard's
// primary CTA, and a dark-gray disc looked like an inert
// surface, not an actionable button.
return LinearGradient(
colors: [
palette.accent.opacity(0.95),
+4 -4
View File
@@ -105,15 +105,15 @@
/* Keyboard preview */
"preview.title" = "Keyboard Preview";
"preview.subtitle" = "Tap the disc to start/stop recording. The real keyboard uses the same layout.";
"preview.placeholder" = "Type or tap to record";
"preview.subtitle" = "Tap Start/Stop Recording to test here. On the real keyboard, press and hold the mic disc.";
"preview.placeholder" = "Type here or tap the record button";
"preview.clear" = "Clear text";
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
"preview.modeChip.cycle" = "Cycle input mode";
"preview.localeChip.cycle" = "Cycle recognition language";
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "Hold to talk";
"keyboard.placeholder.idle" = "Tap to talk";
"keyboard.placeholder.preparing" = "Preparing";
"keyboard.placeholder.processing" = "Processing";
"keyboard.placeholder.error" = "Polishing failed";
@@ -124,7 +124,7 @@
"keyboard.denied.speech" = "Speech denied";
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
"keyboard.pressToTalkA11y" = "Push to talk";
"keyboard.tapToTalkA11y" = "Tap to talk";
/* Mode chip labels (used in both ext + preview stub) */
"mode.off" = "Off";
@@ -105,15 +105,15 @@
/* Keyboard preview */
"preview.title" = "键盘预览";
"preview.subtitle" = "点按 disc 开始/结束录音;真实键盘使用同样布局。";
"preview.placeholder" = "试着输入或按 disc 录音";
"preview.subtitle" = "点击「开始录音」/「停止录音」按钮测试;真实键盘为长按麦克风圆盘。";
"preview.placeholder" = "试着输入或点击按钮录音";
"preview.clear" = "清空";
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
"preview.modeChip.cycle" = "切换输入模式";
"preview.localeChip.cycle" = "切换识别语言";
/* Keyboard (ext) */
"keyboard.placeholder.idle" = "按说话";
"keyboard.placeholder.idle" = "按说话";
"keyboard.placeholder.preparing" = "准备中…";
"keyboard.placeholder.processing" = "处理中…";
"keyboard.placeholder.error" = "润色失败";
@@ -124,7 +124,7 @@
"keyboard.denied.speech" = "语音识别被拒绝";
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.pressToTalkA11y" = "按说话";
"keyboard.tapToTalkA11y" = "按说话";
/* Mode chip labels */
"mode.off" = "关闭";