Files
OSGKeyboard/OSGKeyboard/Views/SettingsView.swift
T
rocky c07cf4db9f refactor: drop Qwen3 CoreML ASR, add local-engine cloud polish toggle
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.
2026-06-24 01:51:34 +08:00

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)
}
}