From ef8115508c7cf25238da4cedeb0eeec8bd30eff1 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:35:33 +0800 Subject: [PATCH] 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. --- OSGKeyboard/OSGKeyboardApp.swift | 3 + OSGKeyboard/Views/OnboardingView.swift | 70 +++++++++++++------ OSGKeyboardExt/Info.plist | 7 +- OSGKeyboardExt/KeyboardViewController.swift | 35 +++++++--- OSGKeyboardExt/Utilities/ExtL10n.swift | 13 +++- OSGKeyboardExt/Views/KeyboardRootView.swift | 44 ++++++------ OSGKeyboardExt/Views/RecordButton.swift | 2 +- OSGKeyboardShared/Models/ProviderConfig.swift | 14 +++- project.yml | 19 ++++- 9 files changed, 146 insertions(+), 61 deletions(-) diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift index df8c0a3..97b78f6 100644 --- a/OSGKeyboard/OSGKeyboardApp.swift +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -44,6 +44,9 @@ struct OSGKeyboardApp: App { coordinator: dictationCoordinator ) } + .onChange(of: config.hasCompletedOnboarding) { _, done in + if done { flowManager.autoStartIfNeeded() } + } .onChange(of: scenePhase) { _, phase in guard phase == .active, AppGroup.isAvailable, config.hasCompletedOnboarding else { return } if flowManager.isActive { diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 1f94790..97aad18 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -10,6 +10,7 @@ import SwiftUI import OSGKeyboardShared +import UIKit private enum OnboardingPage: Int, CaseIterable { case welcome = 0 @@ -23,19 +24,23 @@ private enum OnboardingPage: Int, CaseIterable { struct OnboardingView: View { @Environment(\.themePalette) private var palette: ThemePalette + @Environment(\.scenePhase) private var scenePhase @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 { ZStack { palette.background.ignoresSafeArea() VStack(spacing: 0) { Group { - switch OnboardingPage(rawValue: page) ?? .welcome { + switch OnboardingPage(rawValue: config.onboardingPage) ?? .welcome { case .welcome: WelcomePage() - case .microphone: MicPermissionPage() - case .speech: SpeechPermissionPage() + case .microphone: + MicPermissionPage(status: $micStatus) + case .speech: + SpeechPermissionPage(status: $speechStatus) case .keyboard: EnableKeyboardPage() case .api: APISetupPage(config: config) } @@ -51,37 +56,51 @@ struct OnboardingView: View { .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 { HStack(spacing: 6) { ForEach(0.. 0 { - Button { withAnimation(Motion.soft) { page -= 1 } } label: { + if config.onboardingPage > 0 { + Button { withAnimation(Motion.soft) { config.onboardingPage -= 1 } } label: { Text("common.back") .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) @@ -100,7 +119,7 @@ struct OnboardingView: View { if isLastPage { config.hasCompletedOnboarding = true } else { - page += 1 + config.onboardingPage += 1 } } } label: { @@ -204,7 +223,7 @@ private struct PrivacyFootnote: View { private struct MicPermissionPage: View { @Environment(\.themePalette) private var palette: ThemePalette - @State private var status = AppPermissions.micStatus + @Binding var status: AppPermissions.MicStatus @State private var isRequesting = false var body: some View { @@ -222,6 +241,9 @@ private struct MicPermissionPage: View { deniedHint: status == .denied ? "onboarding.permission.mic.deniedHint" : nil ) .onAppear { status = AppPermissions.micStatus } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in + status = AppPermissions.micStatus + } } private var statusLabel: LocalizedStringKey { @@ -254,6 +276,7 @@ private struct MicPermissionPage: View { private struct SpeechPermissionPage: View { @Environment(\.themePalette) private var palette: ThemePalette + @Binding var status: AppPermissions.SpeechStatus @State private var isRequesting = false var body: some View { @@ -264,23 +287,27 @@ private struct SpeechPermissionPage: View { status: statusLabel, statusColor: statusColor, primaryTitle: primaryButtonTitle, - primaryDisabled: isRequesting || AppPermissions.speechStatus == .granted, + primaryDisabled: isRequesting || status == .granted, onPrimary: { Task { await request() } }, secondaryTitle: speechDenied ? "onboarding.permission.openSettings" : nil, onSecondary: speechDenied ? { AppPermissions.openSystemSettings() } : 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 { - switch AppPermissions.speechStatus { + switch status { case .denied, .restricted: return true default: return false } } private var statusLabel: LocalizedStringKey { - switch AppPermissions.speechStatus { + switch status { case .undetermined: return "onboarding.permission.status.undetermined" case .granted: return "onboarding.permission.status.granted" case .denied, .restricted: return "onboarding.permission.status.denied" @@ -288,7 +315,7 @@ private struct SpeechPermissionPage: View { } private var statusColor: Color { - switch AppPermissions.speechStatus { + switch status { case .granted: return palette.success case .denied, .restricted: return palette.warning case .undetermined: return palette.textTertiary @@ -296,7 +323,7 @@ private struct SpeechPermissionPage: View { } private var primaryButtonTitle: LocalizedStringKey { - AppPermissions.speechStatus == .granted + status == .granted ? "onboarding.permission.status.granted" : "onboarding.permission.speech.allow" } @@ -304,6 +331,7 @@ private struct SpeechPermissionPage: View { private func request() async { isRequesting = true _ = await AppPermissions.requestSpeechRecognition() + status = AppPermissions.speechStatus isRequesting = false } } diff --git a/OSGKeyboardExt/Info.plist b/OSGKeyboardExt/Info.plist index 11f5b29..8494b88 100644 --- a/OSGKeyboardExt/Info.plist +++ b/OSGKeyboardExt/Info.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleDisplayName OSGKeyboard CFBundleExecutable @@ -12,6 +12,11 @@ $(PRODUCT_BUNDLE_IDENTIFIER) CFBundleInfoDictionaryVersion 6.0 + CFBundleLocalizations + + en + zh-Hans + CFBundleName $(PRODUCT_NAME) CFBundlePackageType diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index e0d90bd..802107d 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -65,6 +65,7 @@ public final class KeyboardViewController: UIInputViewController { private var wasFlowSessionActive = false private var flowSessionMonitorTask: Task? 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) { diff --git a/OSGKeyboardExt/Utilities/ExtL10n.swift b/OSGKeyboardExt/Utilities/ExtL10n.swift index fca204d..0bb5f14 100644 --- a/OSGKeyboardExt/Utilities/ExtL10n.swift +++ b/OSGKeyboardExt/Utilities/ExtL10n.swift @@ -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 { diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 142adce..c5dacb6 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -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") } } diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index cf453cb..f07726b 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -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 { diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index b6c99f0..b763290 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -28,6 +28,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { static let localeId = "config.localeId" static let engineMode = "config.engineMode" static let hasCompletedOnboarding = "config.hasCompletedOnboarding" + static let onboardingPage = "config.onboardingPage" } @Published public var providerId: String { @@ -68,7 +69,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { didSet { defaults.set(engineMode, forKey: Key.engineMode) } } @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 { @@ -113,6 +123,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.localeId = resolvedDefaults.string(forKey: Key.localeId) ?? "auto" self.engineMode = resolvedDefaults.string(forKey: Key.engineMode) ?? "cloud" 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 diff --git a/project.yml b/project.yml index c34423f..678486d 100644 --- a/project.yml +++ b/project.yml @@ -5,6 +5,10 @@ name: OSGKeyboard options: 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 # legacy speech + AVAudioSession branching could be removed in # favour of iOS 26's `SpeechAnalyzer` (always on-device) and @@ -44,6 +48,10 @@ targets: platform: iOS sources: - path: OSGKeyboard + excludes: + - "AppIcon.icon" + - path: OSGKeyboard/AppIcon.icon + buildPhase: resources entitlements: path: OSGKeyboard/OSGKeyboard.entitlements properties: @@ -95,6 +103,9 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios TARGETED_DEVICE_FAMILY: "1" 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: - target: OSGKeyboardShared embed: true @@ -114,8 +125,8 @@ targets: - "en.lproj" - "zh-Hans.lproj" resources: - - path: OSGKeyboardExt/en.lproj/Localizable.strings - - path: OSGKeyboardExt/zh-Hans.lproj/Localizable.strings + - path: OSGKeyboardExt/en.lproj + - path: OSGKeyboardExt/zh-Hans.lproj settings: base: IPHONEOS_DEPLOYMENT_TARGET: "26.0" @@ -135,6 +146,10 @@ targets: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_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." NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your voice. Audio is processed on your device and is not uploaded for transcription." NSExtension: