fix: onboarding permission refresh, keyboard l10n, and flow recording
Refresh mic/speech status when returning from TCC dialogs, persist onboarding page across Settings trips, load extension strings from .lproj bundles via ExtL10n, and keep flow result polling alive across host-app jumps.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
@@ -12,6 +12,11 @@
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
|
||||
@@ -65,6 +65,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
private var wasFlowSessionActive = false
|
||||
private var flowSessionMonitorTask: Task<Void, Never>?
|
||||
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
|
||||
private var isAwaitingFlowResult = false
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
@@ -85,8 +86,13 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cancelPipeline()
|
||||
stopFlowSessionMonitor()
|
||||
// Preserve flow handoff / recording / result polling across the
|
||||
// intentional jump to the host app (keyboard extension pauses here).
|
||||
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
|
||||
return
|
||||
}
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
@@ -320,7 +326,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled, self.isPendingFlowStart {
|
||||
if FlowSessionBridge.isSessionActive() {
|
||||
self.startFlowRecording()
|
||||
self.completeFlowStartHandoff()
|
||||
return
|
||||
}
|
||||
let now = Date().timeIntervalSince1970
|
||||
@@ -335,6 +341,17 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session is live — return to idle so the user can tap again to record.
|
||||
private func completeFlowStartHandoff() {
|
||||
isPendingFlowStart = false
|
||||
flowStartDeadline = 0
|
||||
stopFlowWatchdog()
|
||||
state.lastTranscript = ""
|
||||
state.phase = .idle
|
||||
refreshFlowSessionState()
|
||||
debug("completeFlowStartHandoff")
|
||||
}
|
||||
|
||||
private func startFlowLevelWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
@@ -350,15 +367,18 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
private func startFlowResultWatchdog() {
|
||||
stopFlowWatchdog()
|
||||
isAwaitingFlowResult = true
|
||||
let startedAt = Date().timeIntervalSince1970
|
||||
flowWatchdogTask = Task { @MainActor [weak self] in
|
||||
while let self, !Task.isCancelled {
|
||||
if let result = FlowSessionBridge.consumeTranscriptionResult() {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.handleFlowTranscript(result)
|
||||
return
|
||||
}
|
||||
if let error = FlowSessionBridge.consumeTranscriptionError() {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
self.state.phase = .error(.unknown(error), message: error)
|
||||
self.scheduleAutoClearError()
|
||||
@@ -366,6 +386,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
let now = Date().timeIntervalSince1970
|
||||
if now - startedAt > FlowWatchdog.resultTimeout {
|
||||
self.isAwaitingFlowResult = false
|
||||
self.stopFlowWatchdog()
|
||||
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
|
||||
self.state.phase = .error(.unknown(msg), message: msg)
|
||||
@@ -398,6 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
}
|
||||
|
||||
private func cancelPipeline() {
|
||||
if isAwaitingFlowResult || awaitingDictationResult {
|
||||
return
|
||||
}
|
||||
if isFlowRecording || isPendingFlowStart {
|
||||
if isFlowRecording {
|
||||
FlowSessionBridge.setRecordingState(.aborted)
|
||||
@@ -408,13 +432,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
stopFlowWatchdog()
|
||||
state.level = 0
|
||||
}
|
||||
if awaitingDictationResult {
|
||||
debug("cancelPipeline ignored while awaiting legacy handoff result")
|
||||
return
|
||||
}
|
||||
if state.phase == .processing {
|
||||
state.phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFinalTranscript(_ transcript: String) {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// ExtL10n.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Loads strings from the extension bundle. Replaces the old KeyboardL10n
|
||||
// hard-coded fallback map — keys live in Localizable.strings.
|
||||
// Loads strings from the keyboard extension bundle (not SwiftUI's default
|
||||
// bundle, which may not see our Localizable.strings).
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum ExtL10n {
|
||||
private static let bundle = Bundle(for: KeyboardViewController.self)
|
||||
|
||||
static func string(_ key: String) -> String {
|
||||
NSLocalizedString(key, bundle: .main, comment: "")
|
||||
NSLocalizedString(key, bundle: bundle, comment: "")
|
||||
}
|
||||
|
||||
static func text(_ key: String) -> Text {
|
||||
Text(string(key))
|
||||
}
|
||||
|
||||
static func format(_ key: String, _ args: CVarArg...) -> String {
|
||||
|
||||
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
|
||||
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("keyboard.openSettingsA11y"))
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
@@ -129,7 +129,7 @@ public struct KeyboardRootView: View {
|
||||
state.deleteBackward()
|
||||
}
|
||||
Button(action: state.insertSpace) {
|
||||
Text("keyboard.space")
|
||||
ExtL10n.text("keyboard.space")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.frame(maxWidth: .infinity, minHeight: 42)
|
||||
@@ -140,7 +140,7 @@ public struct KeyboardRootView: View {
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("keyboard.space"))
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.space"))
|
||||
ToolbarIconButton(systemName: "return", label: "newline") {
|
||||
state.insertNewline()
|
||||
}
|
||||
@@ -203,18 +203,18 @@ private struct TranscriptLine: View {
|
||||
switch phase {
|
||||
case .idle:
|
||||
if flowSessionActive {
|
||||
Text("keyboard.placeholder.idle")
|
||||
ExtL10n.text("keyboard.placeholder.idle")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
} else {
|
||||
Text("keyboard.flow.sessionInactive")
|
||||
ExtL10n.text("keyboard.flow.sessionInactive")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
case .requestingPermissions:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.textSecondary)
|
||||
Text("keyboard.placeholder.preparing")
|
||||
ExtL10n.text("keyboard.placeholder.preparing")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -228,7 +228,7 @@ private struct TranscriptLine: View {
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini).tint(palette.accent)
|
||||
Text(transcript.isEmpty ? String(localized: "keyboard.placeholder.processing") : transcript)
|
||||
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
@@ -255,17 +255,17 @@ private struct TranscriptLine: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("keyboard.deniedHint"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> LocalizedStringKey {
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return "keyboard.denied.mic"
|
||||
case .speech: return "keyboard.denied.speech"
|
||||
case .mic: return ExtL10n.string("keyboard.denied.mic")
|
||||
case .speech: return ExtL10n.string("keyboard.denied.speech")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,7 +331,7 @@ private struct StatusBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(color: Color, labelKey: LocalizedStringKey, showWarning: Bool = false) -> some View {
|
||||
private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View {
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
@@ -341,7 +341,7 @@ private struct StatusBadge: View {
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(palette.warning)
|
||||
}
|
||||
Text(labelKey)
|
||||
Text(ExtL10n.string(labelKey))
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ private struct LocalEngineChip: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "iphone.badge.checkmark")
|
||||
Text("keyboard.placeholder.localBadge")
|
||||
ExtL10n.text("keyboard.placeholder.localBadge")
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(palette.accent)
|
||||
@@ -409,8 +409,8 @@ private struct ModeChip: View {
|
||||
.menuStyle(.button)
|
||||
}
|
||||
|
||||
private func label(for m: KeyboardViewController.State.InputMode) -> LocalizedStringKey {
|
||||
LocalizedStringKey(m.labelKey)
|
||||
private func label(for m: KeyboardViewController.State.InputMode) -> String {
|
||||
ExtL10n.string(m.labelKey)
|
||||
}
|
||||
|
||||
private func icon(for m: KeyboardViewController.State.InputMode) -> String {
|
||||
@@ -446,9 +446,9 @@ private struct LocaleChip: View {
|
||||
onChange(o.id)
|
||||
} label: {
|
||||
if o.id == localeId {
|
||||
Label(LocalizedStringKey(o.labelKey), systemImage: "checkmark")
|
||||
Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(LocalizedStringKey(o.labelKey))
|
||||
Text(ExtL10n.string(o.labelKey))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,10 +469,8 @@ private struct LocaleChip: View {
|
||||
.menuStyle(.button)
|
||||
}
|
||||
|
||||
private var currentLabel: LocalizedStringKey {
|
||||
if let key = options.first(where: { $0.id == localeId })?.labelKey {
|
||||
return LocalizedStringKey(key)
|
||||
}
|
||||
return "locale.chip.auto"
|
||||
private var currentLabel: String {
|
||||
options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
|
||||
?? ExtL10n.string("locale.chip.auto")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ struct RecordButton: View {
|
||||
.onChange(of: phase) { _, new in
|
||||
breath = (new == .recording)
|
||||
}
|
||||
.accessibilityLabel(Text("keyboard.tapToTalkA11y"))
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
|
||||
}
|
||||
|
||||
private func formatRemaining(_ seconds: Int) -> String {
|
||||
|
||||
Reference in New Issue
Block a user