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:
Rocky
2026-06-19 18:35:33 +08:00
parent 275fc81104
commit ef8115508c
9 changed files with 146 additions and 61 deletions
+3
View File
@@ -44,6 +44,9 @@ struct OSGKeyboardApp: App {
coordinator: dictationCoordinator coordinator: dictationCoordinator
) )
} }
.onChange(of: config.hasCompletedOnboarding) { _, done in
if done { flowManager.autoStartIfNeeded() }
}
.onChange(of: scenePhase) { _, phase in .onChange(of: scenePhase) { _, phase in
guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return } guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return }
if flowManager.isActive { if flowManager.isActive {
+49 -21
View File
@@ -10,6 +10,7 @@
import SwiftUI import SwiftUI
import OSGKeyboardShared import OSGKeyboardShared
import UIKit
private enum OnboardingPage: Int, CaseIterable { private enum OnboardingPage: Int, CaseIterable {
case welcome = 0 case welcome = 0
@@ -23,19 +24,23 @@ private enum OnboardingPage: Int, CaseIterable {
struct OnboardingView: View { struct OnboardingView: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.scenePhase) private var scenePhase
@ObservedObject var config: ProviderConfig @ObservedObject var config: ProviderConfig
@State private var page: Int = 0 @State private var micStatus = AppPermissions.micStatus
@State private var speechStatus = AppPermissions.speechStatus
var body: some View { var body: some View {
ZStack { ZStack {
palette.background.ignoresSafeArea() palette.background.ignoresSafeArea()
VStack(spacing: 0) { VStack(spacing: 0) {
Group { Group {
switch OnboardingPage(rawValue: page) ?? .welcome { switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
case .welcome: WelcomePage() case .welcome: WelcomePage()
case .microphone: MicPermissionPage() case .microphone:
case .speech: SpeechPermissionPage() MicPermissionPage(status: $micStatus)
case .speech:
SpeechPermissionPage(status: $speechStatus)
case .keyboard: EnableKeyboardPage() case .keyboard: EnableKeyboardPage()
case .api: APISetupPage(config: config) case .api: APISetupPage(config: config)
} }
@@ -51,37 +56,51 @@ struct OnboardingView: View {
.padding(.bottom, Spacing.lg) .padding(.bottom, Spacing.lg)
} }
} }
.onAppear {
if config.onboardingPage < 0 || config.onboardingPage >= OnboardingPage.count {
config.onboardingPage = 0
}
refreshPermissionStatuses()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshPermissionStatuses() }
}
}
private func refreshPermissionStatuses() {
micStatus = AppPermissions.micStatus
speechStatus = AppPermissions.speechStatus
} }
private var pageDots: some View { private var pageDots: some View {
HStack(spacing: 6) { HStack(spacing: 6) {
ForEach(0..<OnboardingPage.count, id: \.self) { i in ForEach(0..<OnboardingPage.count, id: \.self) { i in
Capsule() Capsule()
.fill(i == page ? palette.accent : Color.white.opacity(0.18)) .fill(i == config.onboardingPage ? palette.accent : Color.white.opacity(0.18))
.frame(width: i == page ? 18 : 6, height: 6) .frame(width: i == config.onboardingPage ? 18 : 6, height: 6)
.animation(Motion.quick, value: page) .animation(Motion.quick, value: config.onboardingPage)
} }
} }
} }
private var isLastPage: Bool { page == OnboardingPage.api.rawValue } private var isLastPage: Bool { config.onboardingPage == OnboardingPage.api.rawValue }
private var canAdvance: Bool { private var canAdvance: Bool {
switch OnboardingPage(rawValue: page) ?? .welcome { switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome {
case .welcome, .keyboard, .api: case .welcome, .keyboard, .api:
return page != OnboardingPage.api.rawValue || config.isConfigured return !isLastPage || config.isConfigured
case .microphone: case .microphone:
return AppPermissions.micStatus != .undetermined return micStatus != .undetermined
case .speech: case .speech:
return AppPermissions.speechStatus != .undetermined return speechStatus != .undetermined
} }
} }
@ViewBuilder @ViewBuilder
private var bottomBar: some View { private var bottomBar: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
if page > 0 { if config.onboardingPage > 0 {
Button { withAnimation(Motion.soft) { page -= 1 } } label: { Button { withAnimation(Motion.soft) { config.onboardingPage -= 1 } } label: {
Text("common.back") Text("common.back")
.font(TypeStyle.headline) .font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50) .frame(maxWidth: .infinity, minHeight: 50)
@@ -100,7 +119,7 @@ struct OnboardingView: View {
if isLastPage { if isLastPage {
config.hasCompletedOnboarding = true config.hasCompletedOnboarding = true
} else { } else {
page += 1 config.onboardingPage += 1
} }
} }
} label: { } label: {
@@ -204,7 +223,7 @@ private struct PrivacyFootnote: View {
private struct MicPermissionPage: View { private struct MicPermissionPage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@State private var status = AppPermissions.micStatus @Binding var status: AppPermissions.MicStatus
@State private var isRequesting = false @State private var isRequesting = false
var body: some View { var body: some View {
@@ -222,6 +241,9 @@ private struct MicPermissionPage: View {
deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil
) )
.onAppear { status = AppPermissions.micStatus } .onAppear { status = AppPermissions.micStatus }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
status = AppPermissions.micStatus
}
} }
private var statusLabel: LocalizedStringKey { private var statusLabel: LocalizedStringKey {
@@ -254,6 +276,7 @@ private struct MicPermissionPage: View {
private struct SpeechPermissionPage: View { private struct SpeechPermissionPage: View {
@Environment(\.themePalette) private var palette: ThemePalette @Environment(\.themePalette) private var palette: ThemePalette
@Binding var status: AppPermissions.SpeechStatus
@State private var isRequesting = false @State private var isRequesting = false
var body: some View { var body: some View {
@@ -264,23 +287,27 @@ private struct SpeechPermissionPage: View {
status: statusLabel, status: statusLabel,
statusColor: statusColor, statusColor: statusColor,
primaryTitle: primaryButtonTitle, primaryTitle: primaryButtonTitle,
primaryDisabled: isRequesting || AppPermissions.speechStatus == .granted, primaryDisabled: isRequesting || status == .granted,
onPrimary: { Task { await request() } }, onPrimary: { Task { await request() } },
secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil, secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil,
onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil, onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : nil,
deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil deniedHint: speechDenied ? "onboarding.permission.speech.deniedHint" : nil
) )
.onAppear { status = AppPermissions.speechStatus }
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
status = AppPermissions.speechStatus
}
} }
private var speechDenied: Bool { private var speechDenied: Bool {
switch AppPermissions.speechStatus { switch status {
case .denied, .restricted: return true case .denied, .restricted: return true
default: return false default: return false
} }
} }
private var statusLabel: LocalizedStringKey { private var statusLabel: LocalizedStringKey {
switch AppPermissions.speechStatus { switch status {
case .undetermined: return "onboarding.permission.status.undetermined" case .undetermined: return "onboarding.permission.status.undetermined"
case .granted: return "onboarding.permission.status.granted" case .granted: return "onboarding.permission.status.granted"
case .denied, .restricted: return "onboarding.permission.status.denied" case .denied, .restricted: return "onboarding.permission.status.denied"
@@ -288,7 +315,7 @@ private struct SpeechPermissionPage: View {
} }
private var statusColor: Color { private var statusColor: Color {
switch AppPermissions.speechStatus { switch status {
case .granted: return palette.success case .granted: return palette.success
case .denied, .restricted: return palette.warning case .denied, .restricted: return palette.warning
case .undetermined: return palette.textTertiary case .undetermined: return palette.textTertiary
@@ -296,7 +323,7 @@ private struct SpeechPermissionPage: View {
} }
private var primaryButtonTitle: LocalizedStringKey { private var primaryButtonTitle: LocalizedStringKey {
AppPermissions.speechStatus == .granted status == .granted
? "onboarding.permission.status.granted" ? "onboarding.permission.status.granted"
: "onboarding.permission.speech.allow" : "onboarding.permission.speech.allow"
} }
@@ -304,6 +331,7 @@ private struct SpeechPermissionPage: View {
private func request() async { private func request() async {
isRequesting = true isRequesting = true
_ = await AppPermissions.requestSpeechRecognition() _ = await AppPermissions.requestSpeechRecognition()
status = AppPermissions.speechStatus
isRequesting = false isRequesting = false
} }
} }
+6 -1
View File
@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>en</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>OSGKeyboard</string> <string>OSGKeyboard</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
@@ -12,6 +12,11 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string> <string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
+26 -9
View File
@@ -65,6 +65,7 @@ public final class KeyboardViewController: UIInputViewController {
private var wasFlowSessionActive = false private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>? private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver? private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var isAwaitingFlowResult = false
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -85,8 +86,13 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
cancelPipeline()
stopFlowSessionMonitor() 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) { public override func viewWillAppear(_ animated: Bool) {
@@ -320,7 +326,7 @@ public final class KeyboardViewController: UIInputViewController {
flowWatchdogTask = Task { @MainActor [weak self] in flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isPendingFlowStart { while let self, !Task.isCancelled, self.isPendingFlowStart {
if FlowSessionBridge.isSessionActive() { if FlowSessionBridge.isSessionActive() {
self.startFlowRecording() self.completeFlowStartHandoff()
return return
} }
let now = Date().timeIntervalSince1970 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() { private func startFlowLevelWatchdog() {
stopFlowWatchdog() stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in flowWatchdogTask = Task { @MainActor [weak self] in
@@ -350,15 +367,18 @@ public final class KeyboardViewController: UIInputViewController {
private func startFlowResultWatchdog() { private func startFlowResultWatchdog() {
stopFlowWatchdog() stopFlowWatchdog()
isAwaitingFlowResult = true
let startedAt = Date().timeIntervalSince1970 let startedAt = Date().timeIntervalSince1970
flowWatchdogTask = Task { @MainActor [weak self] in flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled { while let self, !Task.isCancelled {
if let result = FlowSessionBridge.consumeTranscriptionResult() { if let result = FlowSessionBridge.consumeTranscriptionResult() {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
self.handleFlowTranscript(result) self.handleFlowTranscript(result)
return return
} }
if let error = FlowSessionBridge.consumeTranscriptionError() { if let error = FlowSessionBridge.consumeTranscriptionError() {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
self.state.phase = .error(.unknown(error), message: error) self.state.phase = .error(.unknown(error), message: error)
self.scheduleAutoClearError() self.scheduleAutoClearError()
@@ -366,6 +386,7 @@ public final class KeyboardViewController: UIInputViewController {
} }
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if now - startedAt > FlowWatchdog.resultTimeout { if now - startedAt > FlowWatchdog.resultTimeout {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog() self.stopFlowWatchdog()
let msg = ExtL10n.string("keyboard.flow.resultTimeout") let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.unknown(msg), message: msg) self.state.phase = .error(.unknown(msg), message: msg)
@@ -398,6 +419,9 @@ public final class KeyboardViewController: UIInputViewController {
} }
private func cancelPipeline() { private func cancelPipeline() {
if isAwaitingFlowResult || awaitingDictationResult {
return
}
if isFlowRecording || isPendingFlowStart { if isFlowRecording || isPendingFlowStart {
if isFlowRecording { if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted) FlowSessionBridge.setRecordingState(.aborted)
@@ -408,13 +432,6 @@ public final class KeyboardViewController: UIInputViewController {
stopFlowWatchdog() stopFlowWatchdog()
state.level = 0 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) { private func handleFinalTranscript(_ transcript: String) {
+10 -3
View File
@@ -1,14 +1,21 @@
// ExtL10n.swift // ExtL10n.swift
// OSGKeyboard · Keyboard Extension // OSGKeyboard · Keyboard Extension
// //
// Loads strings from the extension bundle. Replaces the old KeyboardL10n // Loads strings from the keyboard extension bundle (not SwiftUI's default
// hard-coded fallback map keys live in Localizable.strings. // bundle, which may not see our Localizable.strings).
import Foundation import Foundation
import SwiftUI
enum ExtL10n { enum ExtL10n {
private static let bundle = Bundle(for: KeyboardViewController.self)
static func string(_ key: String) -> String { 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 { static func format(_ key: String, _ args: CVarArg...) -> String {
+21 -23
View File
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) .overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("keyboard.openSettingsA11y")) .accessibilityLabel(ExtL10n.text("keyboard.openSettingsA11y"))
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
} }
@@ -129,7 +129,7 @@ public struct KeyboardRootView: View {
state.deleteBackward() state.deleteBackward()
} }
Button(action: state.insertSpace) { Button(action: state.insertSpace) {
Text("keyboard.space") ExtL10n.text("keyboard.space")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42) .frame(maxWidth: .infinity, minHeight: 42)
@@ -140,7 +140,7 @@ public struct KeyboardRootView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text("keyboard.space")) .accessibilityLabel(ExtL10n.text("keyboard.space"))
ToolbarIconButton(systemName: "return", label: "newline") { ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline() state.insertNewline()
} }
@@ -203,18 +203,18 @@ private struct TranscriptLine: View {
switch phase { switch phase {
case .idle: case .idle:
if flowSessionActive { if flowSessionActive {
Text("keyboard.placeholder.idle") ExtL10n.text("keyboard.placeholder.idle")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} else { } else {
Text("keyboard.flow.sessionInactive") ExtL10n.text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} }
case .requestingPermissions: case .requestingPermissions:
HStack(spacing: 6) { HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary) ProgressView().controlSize(.mini).tint(palette.textSecondary)
Text("keyboard.placeholder.preparing") ExtL10n.text("keyboard.placeholder.preparing")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
@@ -228,7 +228,7 @@ private struct TranscriptLine: View {
case .processing: case .processing:
HStack(spacing: 6) { HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent) 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) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
@@ -255,17 +255,17 @@ private struct TranscriptLine: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityHint(Text("keyboard.deniedHint")) .accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md) .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 { switch reason {
case .mic: return "keyboard.denied.mic" case .mic: return ExtL10n.string("keyboard.denied.mic")
case .speech: return "keyboard.denied.speech" 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) { HStack(spacing: 4) {
Circle() Circle()
.fill(color) .fill(color)
@@ -341,7 +341,7 @@ private struct StatusBadge: View {
.font(.system(size: 9, weight: .bold)) .font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
} }
Text(labelKey) Text(ExtL10n.string(labelKey))
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
@@ -360,7 +360,7 @@ private struct LocalEngineChip: View {
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark") Image(systemName: "iphone.badge.checkmark")
Text("keyboard.placeholder.localBadge") ExtL10n.text("keyboard.placeholder.localBadge")
} }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
@@ -409,8 +409,8 @@ private struct ModeChip: View {
.menuStyle(.button) .menuStyle(.button)
} }
private func label(for m: KeyboardViewController.State.InputMode) -> LocalizedStringKey { private func label(for m: KeyboardViewController.State.InputMode) -> String {
LocalizedStringKey(m.labelKey) ExtL10n.string(m.labelKey)
} }
private func icon(for m: KeyboardViewController.State.InputMode) -> String { private func icon(for m: KeyboardViewController.State.InputMode) -> String {
@@ -446,9 +446,9 @@ private struct LocaleChip: View {
onChange(o.id) onChange(o.id)
} label: { } label: {
if o.id == localeId { if o.id == localeId {
Label(LocalizedStringKey(o.labelKey), systemImage: "checkmark") Label(ExtL10n.string(o.labelKey), systemImage: "checkmark")
} else { } else {
Text(LocalizedStringKey(o.labelKey)) Text(ExtL10n.string(o.labelKey))
} }
} }
} }
@@ -469,10 +469,8 @@ private struct LocaleChip: View {
.menuStyle(.button) .menuStyle(.button)
} }
private var currentLabel: LocalizedStringKey { private var currentLabel: String {
if let key = options.first(where: { $0.id == localeId })?.labelKey { options.first(where: { $0.id == localeId }).map { ExtL10n.string($0.labelKey) }
return LocalizedStringKey(key) ?? ExtL10n.string("locale.chip.auto")
}
return "locale.chip.auto"
} }
} }
+1 -1
View File
@@ -124,7 +124,7 @@ struct RecordButton: View {
.onChange(of: phase) { _, new in .onChange(of: phase) { _, new in
breath = (new == .recording) breath = (new == .recording)
} }
.accessibilityLabel(Text("keyboard.tapToTalkA11y")) .accessibilityLabel(ExtL10n.text("keyboard.tapToTalkA11y"))
} }
private func formatRemaining(_ seconds: Int) -> String { private func formatRemaining(_ seconds: Int) -> String {
+13 -1
View File
@@ -28,6 +28,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
static let localeId = "config.localeId" static let localeId = "config.localeId"
static let engineMode = "config.engineMode" static let engineMode = "config.engineMode"
static let hasCompletedOnboarding = "config.hasCompletedOnboarding" static let hasCompletedOnboarding = "config.hasCompletedOnboarding"
static let onboardingPage = "config.onboardingPage"
} }
@Published public var providerId: String { @Published public var providerId: String {
@@ -68,7 +69,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet { defaults.set(engineMode, forKey: Key.engineMode) } didSet { defaults.set(engineMode, forKey: Key.engineMode) }
} }
@Published public var hasCompletedOnboarding: Bool { @Published public var hasCompletedOnboarding: Bool {
didSet { defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding) } didSet {
defaults.set(hasCompletedOnboarding, forKey: Key.hasCompletedOnboarding)
if hasCompletedOnboarding {
onboardingPage = 0
}
}
}
/// Persisted onboarding step so returning from Settings does not reset progress.
@Published public var onboardingPage: Int {
didSet { defaults.set(onboardingPage, forKey: Key.onboardingPage) }
} }
public var isConfigured: Bool { public var isConfigured: Bool {
@@ -113,6 +123,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto" self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto"
self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud" self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud"
self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding) self.hasCompletedOnboarding = resolvedDefaults.bool(forKey: Key.hasCompletedOnboarding)
let savedPage = resolvedDefaults.integer(forKey: Key.onboardingPage)
self.onboardingPage = savedPage > 0 ? savedPage : 0
} }
/// Read the API key from the Keychain, falling back to a one-time /// Read the API key from the Keychain, falling back to a one-time
+17 -2
View File
@@ -5,6 +5,10 @@
name: OSGKeyboard name: OSGKeyboard
options: options:
bundleIdPrefix: com.osgkeyboard bundleIdPrefix: com.osgkeyboard
# Icon Composer bundles must stay intact; do not explode into icon.json + SVG.
fileTypes:
icon:
file: true
# Minimum OS: iOS 26. We dropped older iOS support so the # Minimum OS: iOS 26. We dropped older iOS support so the
# legacy speech + AVAudioSession branching could be removed in # legacy speech + AVAudioSession branching could be removed in
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and # favour of iOS 26's `SpeechAnalyzer` (always on-device) and
@@ -44,6 +48,10 @@ targets:
platform: iOS platform: iOS
sources: sources:
- path: OSGKeyboard - path: OSGKeyboard
excludes:
- "AppIcon.icon"
- path: OSGKeyboard/AppIcon.icon
buildPhase: resources
entitlements: entitlements:
path: OSGKeyboard/OSGKeyboard.entitlements path: OSGKeyboard/OSGKeyboard.entitlements
properties: properties:
@@ -95,6 +103,9 @@ targets:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
TARGETED_DEVICE_FAMILY: "1" TARGETED_DEVICE_FAMILY: "1"
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
# AppIcon.icon (Liquid Glass) + AppIcon.appiconset (fallback) share the name "AppIcon".
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS: YES
dependencies: dependencies:
- target: OSGKeyboardShared - target: OSGKeyboardShared
embed: true embed: true
@@ -114,8 +125,8 @@ targets:
- "en.lproj" - "en.lproj"
- "zh-Hans.lproj" - "zh-Hans.lproj"
resources: resources:
- path: OSGKeyboardExt/en.lproj/Localizable.strings - path: OSGKeyboardExt/en.lproj
- path: OSGKeyboardExt/zh-Hans.lproj/Localizable.strings - path: OSGKeyboardExt/zh-Hans.lproj
settings: settings:
base: base:
IPHONEOS_DEPLOYMENT_TARGET: "26.0" IPHONEOS_DEPLOYMENT_TARGET: "26.0"
@@ -135,6 +146,10 @@ targets:
CFBundleDisplayName: OSGKeyboard CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
CFBundleDevelopmentRegion: en
CFBundleLocalizations:
- en
- zh-Hans
NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running." NSMicrophoneUsageDescription: "OSGKeyboard uses the microphone for voice dictation and keeps a background audio session active while a voice session is running."
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription." NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription."
NSExtension: NSExtension: