c07cf4db9f
Rolls back the v0.2.0 Qwen3 CoreML on-device ASR stack and replaces the
'local engine' UX with iOS 26 SpeechAnalyzer + DictationTranscriber only.
The 'Cloud polish after ASR' toggle (ProviderConfig.localModeCloudPolishEnabled)
lets users opt into a post-ASR DeepSeek round-trip from the local engine.
Defaults to off so the local engine stays genuinely local. New PolishError.missingAPIError
surfaces an inline 'fill in your key' warning when the toggle is on but the
Keychain is empty. DeepSeek preset default model bumped to deepseek-v4-flash.
Deleted:
- OSGKeyboard/ThirdParty/Qwen3Speech/ (74 files, ~16k LoC)
- OSGKeyboard/Services/ModelManager.swift (492)
- OSGKeyboard/Services/OnDeviceModelWarmup.swift (197)
- OSGKeyboard/Services/Qwen3ASRService.swift (257)
- OSGKeyboard/Services/ModelDownloadSourcePicker.swift (126)
- OSGKeyboard/Views/OnDeviceModelsView.swift (184)
- OSGKeyboard/Views/DownloadConfirmSheet.swift (96)
- OSGKeyboardShared/Models/OnDeviceModel.swift (140)
- OSGKeyboardShared/Services/OnDeviceModelStatus.swift (104)
- Qwen3ASRServiceProvider registration in OSGKeyboardApp
- Qwen3Speech package declaration in project.yml
- 5 .qwen3ASR enum / branch reference sites in HomeView, OnboardingView,
LocalEngineSettingsRows, FlowSessionManager, ASRService, EngineServiceLabel
- Two pre-existing Swift 6 strict-concurrency errors in
LiveDictationController + FlowSessionManager (the weak [weak self] in
detached-task MainActor.run blocks) that were blocking clean builds
Added:
- LocalModelsGroup: 'Built-in iOS SpeechAnalyzer' badge + 'Cloud polish
after ASR' Switch toggle
- PolishingService: honour localModeCloudPolishEnabled; new .missingAPIKey
error case with localised warning
- AppGroupStore.localModeCloudPolishEnabled (mirrored into App Group
so the keyboard extension honours the toggle during live dictation)
- SettingsView: show provider/api sections when local-mode cloud polish
is on so the user can paste a DeepSeek key
- FlowSessionManager: route through PolishingService for local + polish-on
flow; translate missingAPIKey into a polished warning
- KeyboardViewController: handle PolishingService.PolishError.missingAPIKey
in the keyboard-side live polish path
- CHANGELOG v0.2.1: documents the rollback + new toggle
- README.md / README.zh.md: engine matrix section, data flow note
Verified: xcodebuild -scheme OSGKeyboard -destination 'generic/platform=iOS Simulator'
build succeeds under SWIFT_STRICT_CONCURRENCY=complete.
473 lines
18 KiB
Swift
473 lines
18 KiB
Swift
// SettingsView.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// Sheet that hosts the API configuration. Single scrollable column, every
|
|
// field earns its space.
|
|
|
|
import SwiftUI
|
|
import Speech
|
|
import OSGKeyboardShared
|
|
|
|
enum SettingsPresentation {
|
|
case tab
|
|
case sheet
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
|
|
@ObservedObject var config = ProviderConfig.shared
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
|
|
let presentation: SettingsPresentation
|
|
|
|
init(presentation: SettingsPresentation = .sheet) {
|
|
self.presentation = presentation
|
|
}
|
|
|
|
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
|
|
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
|
|
// v0.2.0: no on-device model manager / pending download state —
|
|
// iOS `SpeechAnalyzer` ships with iOS 26 and needs nothing
|
|
// downloaded.
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
PageHeaderRow(title: "settings.title") {
|
|
HStack(spacing: Spacing.xs) {
|
|
PageHeaderConfirmButton(
|
|
systemImage: "arrow.counterclockwise",
|
|
accessibilityLabel: "settings.reset.confirm",
|
|
confirmTitle: "settings.reset.title",
|
|
confirmMessage: "settings.reset.message",
|
|
confirmActionTitle: "common.reset"
|
|
) {
|
|
config.reset()
|
|
SpeechHistoryStore.shared.clearAll()
|
|
}
|
|
if presentation == .sheet {
|
|
Button("common.done") { dismiss() }
|
|
.font(TypeStyle.headline)
|
|
.foregroundStyle(palette.accent)
|
|
.frame(minHeight: 44)
|
|
}
|
|
}
|
|
}
|
|
|
|
ZStack {
|
|
palette.background.ignoresSafeArea()
|
|
ScrollView {
|
|
VStack(spacing: Spacing.md) {
|
|
appLanguageSection
|
|
engineSection
|
|
if config.engineMode == "cloud" {
|
|
providerSection
|
|
apiSection
|
|
} else if config.localModeCloudPolishEnabled {
|
|
// v0.2.0: local engine + cloud polish on.
|
|
// Surface the provider / API key fields so
|
|
// the user can fill in their DeepSeek key.
|
|
// We hide them when the toggle is off so the
|
|
// local engine stays genuinely local.
|
|
providerSection
|
|
apiSection
|
|
}
|
|
languageAndModelsSection
|
|
if config.engineMode == "cloud" {
|
|
systemPromptLinkSection
|
|
}
|
|
if presentation == .tab {
|
|
footerLinks
|
|
}
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.padding(.vertical, Spacing.md)
|
|
.padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
|
|
}
|
|
}
|
|
}
|
|
.background(palette.background)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
.task { await loadDynamicLocales() }
|
|
// v0.2.0: no on-device model manager to refresh — the
|
|
// iOS ASR backend is always ready.
|
|
}
|
|
}
|
|
|
|
// MARK: - App language
|
|
|
|
private var appLanguageSection: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.appLanguage.title")
|
|
Picker("", selection: $config.uiLanguage) {
|
|
ForEach(AppUILanguage.allCases) { language in
|
|
Text(LocalizedStringKey(language.labelKey)).tag(language)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.padding(Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Engine
|
|
|
|
private var engineSection: some View {
|
|
EnginePickerSection(config: config)
|
|
}
|
|
|
|
// MARK: - Language & on-device models
|
|
|
|
private var languageAndModelsSection: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.language.title")
|
|
VStack(spacing: 0) {
|
|
if config.engineMode == "local" {
|
|
LocalModelsGroup(config: config)
|
|
Divider().background(palette.divider)
|
|
}
|
|
LocalePickerRow(
|
|
locales: effectiveLocales,
|
|
selection: Binding(
|
|
get: { config.localeId },
|
|
set: { config.localeId = $0 }
|
|
)
|
|
)
|
|
}
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
|
|
// Legacy legend block: kept for UI compatibility, but with
|
|
// iOS 26 as minimum target this branch never executes.
|
|
if #unavailable(iOS 26) {
|
|
HStack(spacing: Spacing.xs) {
|
|
Image(systemName: "iphone")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.accent)
|
|
Text("settings.legend.onDevice")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
Spacer()
|
|
Image(systemName: "cloud")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.warning)
|
|
Text("settings.legend.cloudFallback")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
.padding(.horizontal, Spacing.xs)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var providerSection: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.provider.title")
|
|
ProviderPickerSection(config: config)
|
|
}
|
|
}
|
|
|
|
// MARK: - API
|
|
|
|
private var apiSection: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.api.title")
|
|
APISettingsCard(config: config)
|
|
}
|
|
}
|
|
|
|
// MARK: - Language helpers
|
|
|
|
/// Falls back to a static list while dynamic locales are loading.
|
|
private var effectiveLocales: [(id: String, onDevice: Bool)] {
|
|
dynamicLocales.isEmpty ? staticLocales : dynamicLocales
|
|
}
|
|
|
|
private var staticLocales: [(id: String, onDevice: Bool)] {
|
|
[
|
|
("auto", false),
|
|
("zh-Hans", false),
|
|
("zh-Hant", false),
|
|
("en-US", false),
|
|
("ja-JP", false),
|
|
("ko-KR", false),
|
|
]
|
|
}
|
|
|
|
// MARK: - Dynamic locale loading
|
|
|
|
private func loadDynamicLocales() async {
|
|
// Run everything in a background task: `SFSpeechRecognizer.supportedLocales()`
|
|
// can return 100+ locales, and we probe supportsOnDeviceRecognition for each.
|
|
// Creating `SFSpeechRecognizer` instances in a @Sendable closure is
|
|
// safe here; we only read locale metadata (no transcription session).
|
|
let entries: [(id: String, onDevice: Bool)] = await Task.detached(
|
|
priority: .userInitiated
|
|
) {
|
|
var result: [(id: String, onDevice: Bool)] = [("auto", false)]
|
|
|
|
for locale in SFSpeechRecognizer.supportedLocales()
|
|
.sorted(by: { $0.identifier < $1.identifier }) {
|
|
let id = locale.identifier
|
|
let onDevice = SFSpeechRecognizer(locale: locale)?.supportsOnDeviceRecognition ?? false
|
|
result.append((id: id, onDevice: onDevice))
|
|
}
|
|
return result
|
|
}.value
|
|
|
|
// .task {} calls us from the main actor, so this assignment is safe.
|
|
dynamicLocales = entries
|
|
}
|
|
|
|
// MARK: - System prompt (cloud only)
|
|
|
|
private var systemPromptLinkSection: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.systemPrompt.title")
|
|
VStack(spacing: 0) {
|
|
NavigationLink {
|
|
SystemPromptSettingsView(config: config)
|
|
} label: {
|
|
footerNavigationRow(title: "settings.systemPrompt.edit")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Footer links (tab settings only)
|
|
|
|
private var footerLinks: some View {
|
|
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
|
|
sectionHeader("settings.about.title")
|
|
VStack(spacing: 0) {
|
|
Button {
|
|
config.hasCompletedOnboarding = false
|
|
config.onboardingPage = 0
|
|
} label: {
|
|
HStack(spacing: Spacing.sm) {
|
|
Text("settings.onboarding.replay")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Divider().background(palette.divider)
|
|
|
|
NavigationLink {
|
|
PrivacyPolicyView()
|
|
} label: {
|
|
footerNavigationRow(title: "settings.privacy.policy")
|
|
}
|
|
.buttonStyle(.plain)
|
|
Divider().background(palette.divider)
|
|
|
|
NavigationLink {
|
|
HelpFeedbackView()
|
|
} label: {
|
|
footerNavigationRow(title: "settings.link.support")
|
|
}
|
|
.buttonStyle(.plain)
|
|
Divider().background(palette.divider)
|
|
|
|
footerExternalLinkRow(
|
|
title: "settings.link.github",
|
|
url: LegalLinks.repositoryURL
|
|
)
|
|
Divider().background(palette.divider)
|
|
|
|
NavigationLink {
|
|
OpenSourceLicensesView()
|
|
} label: {
|
|
footerNavigationRow(title: "settings.link.licenses")
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func footerExternalLinkRow(title: LocalizedStringKey, url: URL) -> some View {
|
|
Button {
|
|
openURL(url)
|
|
} label: {
|
|
HStack(spacing: Spacing.sm) {
|
|
Text(title)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
MaterialIcon(name: .openInNew, size: 18)
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
/// In-app disclosure row that pushes a child view onto the
|
|
/// `NavigationStack` rather than opening Safari. Used for the
|
|
/// Third-Party Licenses entry so the system "back" button
|
|
/// returns to Settings.
|
|
private func footerNavigationRow(title: LocalizedStringKey) -> some View {
|
|
HStack(spacing: Spacing.sm) {
|
|
Text(title)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
.contentShape(Rectangle())
|
|
}
|
|
|
|
// MARK: - Header
|
|
|
|
private func sectionHeader(_ title: LocalizedStringKey) -> some View {
|
|
Text(title)
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.textCase(.uppercase)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
// MARK: - Picker row (generic)
|
|
|
|
private struct PickerRow: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
|
|
let title: String
|
|
let options: [(id: String, label: String)]
|
|
@Binding var selection: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(title)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Menu {
|
|
ForEach(options, id: \.id) { o in
|
|
Button {
|
|
selection = o.id
|
|
} label: {
|
|
if o.id == selection {
|
|
Label(o.label, systemImage: "checkmark")
|
|
} else {
|
|
Text(o.label)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Text(currentLabel)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
}
|
|
|
|
private var currentLabel: String {
|
|
options.first(where: { $0.id == selection })?.label ?? "—"
|
|
}
|
|
}
|
|
|
|
// MARK: - Locale picker row (with on-device indicator)
|
|
|
|
private struct LocalePickerRow: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
@ObservedObject private var config = ProviderConfig.shared
|
|
|
|
let locales: [(id: String, onDevice: Bool)]
|
|
@Binding var selection: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text("settings.asrLocale")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Spacer()
|
|
Menu {
|
|
ForEach(locales, id: \.id) { locale in
|
|
Button {
|
|
selection = locale.id
|
|
} label: {
|
|
// iOS Menu converts SwiftUI Label to UIAction (title + image).
|
|
// Using Label keeps checkmark + on-device icon both visible.
|
|
let name = label(for: locale.id)
|
|
if locale.id == selection {
|
|
Label(name, systemImage: "checkmark")
|
|
} else if locale.onDevice {
|
|
Label(name, systemImage: "iphone")
|
|
} else {
|
|
Text(name)
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 6) {
|
|
// On-device badge for the currently selected locale.
|
|
if let current = locales.first(where: { $0.id == selection }), current.onDevice {
|
|
Image(systemName: "iphone")
|
|
.font(.system(size: 11, weight: .medium))
|
|
.foregroundStyle(palette.accent)
|
|
}
|
|
Text(currentLabel)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 11, weight: .bold))
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
}
|
|
|
|
private func label(for localeId: String) -> String {
|
|
ASRLocaleLabels.displayName(for: localeId, language: config.uiLanguage)
|
|
}
|
|
|
|
private var currentLabel: String {
|
|
label(for: selection)
|
|
}
|
|
}
|