diff --git a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png index 0e6ff46..d80ad7f 100644 Binary files a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png and b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png differ diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist index 227d0db..c0b8d78 100644 --- a/OSGKeyboard/Info.plist +++ b/OSGKeyboard/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/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift index b4ff9b5..1f6b661 100644 --- a/OSGKeyboard/Views/APISettingsCard.swift +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -50,7 +50,7 @@ struct APISettingsCard: View { HStack { Image(systemName: "key.fill") .foregroundStyle(palette.accent) - Text("获取 API Key · Get an API key") + Text("api.getKey") .foregroundStyle(palette.textPrimary) Spacer() Image(systemName: "arrow.up.right.square") @@ -77,7 +77,7 @@ struct APISettingsCard: View { private var keyField: some View { VStack(alignment: .leading, spacing: 6) { HStack { - Text("API Key · 密钥") + Text("api.key") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) Spacer() @@ -143,7 +143,7 @@ struct APISettingsCard: View { private var testConnectionRow: some View { VStack(alignment: .leading, spacing: 6) { HStack { - Text("Connection · 连接测试") + Text("api.connection") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) Spacer() @@ -176,10 +176,10 @@ struct APISettingsCard: View { private var testButtonLabel: String { switch testStatus { - case .idle: return "测试连接 · Test" - case .running: return "测试中… · Testing…" - case .success: return "成功 · Retry" - case .failure: return "失败 · Retry" + case .idle: return NSLocalizedString("api.test.idle", comment: "") + case .running: return NSLocalizedString("api.test.running", comment: "") + case .success: return NSLocalizedString("api.test.success", comment: "") + case .failure: return NSLocalizedString("api.test.failure", comment: "") } } @@ -218,17 +218,30 @@ struct APISettingsCard: View { Task { do { let reply = try await client.polish("ping", systemPrompt: "Reply with the single word PONG.") - testStatus = .success("连接成功 · Connected · “\(reply.prefix(60))”") + // Interpolated success message — LocalizedStringKey can't + // take %@, so we use `String.localizedStringWithFormat` + // with a key that has %@ in the value. + let preview = String(reply.prefix(60)) + testStatus = .success(String.localizedStringWithFormat( + NSLocalizedString("api.test.connectedWith", comment: "Connected test prefix"), + preview + )) } catch LLMError.noAPIKey { - testStatus = .failure("未填写 API Key · API key missing") + testStatus = .failure(NSLocalizedString("api.test.missing", comment: "")) } catch let error as LLMError { switch error { case .http(let status): - testStatus = .failure("HTTP \(status)") + testStatus = .failure(String.localizedStringWithFormat( + NSLocalizedString("api.test.http", comment: ""), + status + )) case .rateLimited: - testStatus = .failure("API 限流 (429) · Rate limited") + testStatus = .failure(NSLocalizedString("api.test.rateLimited", comment: "")) case .transport(let msg): - testStatus = .failure("网络错误 · Network error: \(msg)") + testStatus = .failure(String.localizedStringWithFormat( + NSLocalizedString("api.test.transportWith", comment: ""), + msg + )) default: testStatus = .failure(error.errorDescription ?? "\(error)") } diff --git a/OSGKeyboard/Views/AppGroupErrorView.swift b/OSGKeyboard/Views/AppGroupErrorView.swift index ce0b901..ece6b09 100644 --- a/OSGKeyboard/Views/AppGroupErrorView.swift +++ b/OSGKeyboard/Views/AppGroupErrorView.swift @@ -21,17 +21,17 @@ struct AppGroupErrorView: View { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 48)) .foregroundStyle(palette.danger) - Text("App Group 未配置 · App Group not configured") + Text("appGroup.error.title") .font(TypeStyle.title2) .multilineTextAlignment(.center) - Text("OSGKeyboard 需要 App Group 才能在键盘扩展和主 App 之间共享配置。\nOSGKeyboard needs an App Group to share config between the main app and the keyboard extension.") + Text("appGroup.error.body") .font(TypeStyle.body) .multilineTextAlignment(.center) .foregroundStyle(palette.textSecondary) VStack(alignment: .leading, spacing: Spacing.sm) { - Label("在 Apple Developer 后台创建 group.com.osgkeyboard.shared · Create it in the Apple Developer portal", systemImage: "1.circle") - Label("主 App 和键盘扩展都启用该 App Group · Enable it for both the main app and the keyboard extension", systemImage: "2.circle") - Label("重新生成 provisioning profile 并下载 · Re-generate the provisioning profile and download it", systemImage: "3.circle") + Label("appGroup.error.step1", systemImage: "1.circle") + Label("appGroup.error.step2", systemImage: "2.circle") + Label("appGroup.error.step3", systemImage: "3.circle") } .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift new file mode 100644 index 0000000..3f216de --- /dev/null +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -0,0 +1,111 @@ +// EnginePickerSection.swift +// OSGKeyboard · Main App +// +// Engine picker — Local (on-device ASR, no LLM) vs Cloud (ASR + LLM +// polish). Lives in its own file so the onboarding flow (first-run, +// no API key yet) and the in-app Settings sheet can render the same +// component: both need to expose the same two options, and the user +// must reach the same "Cloud needs an API key" conclusion from either +// entry point. +// +// Selecting "local" also forces `modeId = "transcribe"`: the Local +// engine skips the LLM round-trip, so leaving modeId on `polish` +// would surface a confusing "I set everything up and nothing +// happens" state. The settings UI is the source of truth for +// `engineMode`; the onboarding page mutates the same `ProviderConfig` +// singleton. + +import SwiftUI +import OSGKeyboardShared + +struct EnginePickerSection: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var config: ProviderConfig + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.xs) { + sectionHeader( + "settings.engine.title", + subtitle: "settings.engine.subtitle" + ) + VStack(spacing: 0) { + engineOptionRow( + id: "local", + icon: "iphone.badge.checkmark", + title: "本地识别 · On-device", + subtitle: localSubtitle + ) + Divider().background(palette.divider) + engineOptionRow( + id: "cloud", + icon: "wand.and.stars", + title: "云端润色 · Cloud polish", + subtitle: "ASR 转录 + LLM 润色,需要 API Key\nASR + LLM polish, API key required" + ) + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + } + + private var localSubtitle: String { + if #available(iOS 26, *) { + return "SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network" + } else { + return "端侧 ASR · 仅转录,无润色\nOn-device ASR, transcription only, no polish" + } + } + + private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View { + let isSelected = config.engineMode == id + return Button { + withAnimation(.easeInOut(duration: 0.2)) { + config.engineMode = id + if id == "local" { config.modeId = "transcribe" } + } + } label: { + HStack(spacing: Spacing.sm) { + Image(systemName: icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(isSelected ? palette.accent : palette.textSecondary) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.body) + .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) + Text(subtitle) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(palette.accent) + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) + Text(subtitle) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index dd925fc..0e47ba7 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -91,14 +91,14 @@ struct HomeView: View { Image(systemName: "waveform") .font(.system(size: 36, weight: .light)) .foregroundStyle(palette.accent) - Text("点此配置 · Tap to configure") + Text("home.hero.label") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } } } .buttonStyle(.plain) - .accessibilityLabel(Text("打开设置 · Open OSGKeyboard settings")) + .accessibilityLabel(Text("home.action.openSettingsA11y")) } // MARK: - Actions @@ -110,7 +110,7 @@ struct HomeView: View { UIApplication.shared.open(url) } } label: { - Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard") + Label("home.action.enableInSettings", systemImage: "keyboard") .primaryButton() } .buttonStyle(.plain) @@ -118,7 +118,7 @@ struct HomeView: View { Button { showSettings = true } label: { - Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3") + Label("home.action.editApi", systemImage: "slider.horizontal.3") .secondaryButton() } .buttonStyle(.plain) @@ -127,7 +127,7 @@ struct HomeView: View { Button { showKeyboardPreview = true } label: { - Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye") + Label("home.action.keyboardPreview", systemImage: "eye") .secondaryButton() } .buttonStyle(.plain) diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift index a174044..fec09ff 100644 --- a/OSGKeyboard/Views/KeyboardPreviewSheet.swift +++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift @@ -77,10 +77,10 @@ struct KeyboardPreviewSheet: View { palette.background.ignoresSafeArea() VStack(spacing: 0) { VStack(spacing: Spacing.md) { - Text("键盘预览 · Keyboard Preview") + Text("preview.title") .font(TypeStyle.title2) .foregroundStyle(palette.textPrimary) - Text("点按 disc 开始/结束录音;真实键盘使用同样布局。\nTap the disc to start/stop recording. The real keyboard uses the same layout.") + Text("preview.subtitle") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) @@ -110,7 +110,7 @@ struct KeyboardPreviewSheet: View { HStack(spacing: Spacing.xs) { Image(systemName: "text.cursor") .foregroundStyle(palette.textSecondary) - TextField("试着输入或按 disc 录音 · Type or tap to record", text: $typedText, axis: .vertical) + TextField(LocalizedStringKey("preview.placeholder"), text: $typedText, axis: .vertical) .lineLimit(1...4) .textFieldStyle(.plain) .foregroundStyle(palette.textPrimary) @@ -123,7 +123,7 @@ struct KeyboardPreviewSheet: View { .foregroundStyle(palette.textTertiary) } .buttonStyle(.plain) - .accessibilityLabel("清空 · Clear text") + .accessibilityLabel("preview.clear") } } .padding(Spacing.md) diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift index f594cb7..dc97c11 100644 --- a/OSGKeyboard/Views/KeyboardPreviewStub.swift +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -61,7 +61,7 @@ struct KeyboardPreviewStub: View { private var modeChip: some View { HStack(spacing: 4) { Image(systemName: "wand.and.stars") - Text("润色 · Polish") + Text("mode.polish") Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) } .font(TypeStyle.caption2) @@ -74,7 +74,7 @@ struct KeyboardPreviewStub: View { private var localeChip: some View { HStack(spacing: 4) { Image(systemName: "globe") - Text("简体 · ZH-Hans") + Text("locale.zh-Hans") Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) } .font(TypeStyle.caption2) @@ -92,7 +92,7 @@ struct KeyboardPreviewStub: View { case .recording: HStack(spacing: 4) { Circle().fill(palette.recordRed).frame(width: 6, height: 6) - Text("REC · 录音中").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) + Text("keyboard.rec").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) } .padding(.horizontal, Spacing.xs).padding(.vertical, 3) .background(palette.surface, in: Capsule()) @@ -123,7 +123,7 @@ struct KeyboardPreviewStub: View { Group { switch phase { case .idle: - Text("按住说话 · Hold to talk") + Text("keyboard.placeholder.idle") .font(TypeStyle.caption) .foregroundStyle(palette.textTertiary) case .recording: @@ -136,7 +136,7 @@ struct KeyboardPreviewStub: View { case .processing: HStack(spacing: 6) { ProgressView().controlSize(.mini).tint(palette.accent) - Text("处理中 · Processing") + Text("keyboard.placeholder.processing") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } @@ -234,12 +234,12 @@ struct KeyboardPreviewStub: View { // MARK: - Bottom bar private var bottomBar: some View { + // Mirror the real keyboard — globe button removed (the iOS + // system keyboard strip already provides one). HStack(spacing: Spacing.xxs) { - iconButton("globe") iconButton("delete.left") - Spacer(minLength: 0) Button(action: {}) { - Text("空格 · Space") + Text("common.space") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) .frame(maxWidth: .infinity, minHeight: 42) @@ -247,7 +247,6 @@ struct KeyboardPreviewStub: View { .overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) - Spacer(minLength: 0) iconButton("return") } .padding(.horizontal, Spacing.sm) diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 4571257..f3e3f18 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -1,12 +1,24 @@ // OnboardingView.swift // OSGKeyboard · Main App // -// Three-step onboarding presented as a horizontal pager: +// Three-step onboarding: // // 1) Welcome — what the app does, in one sentence // 2) Enable — Settings → General → Keyboards → Add → Allow Full Access // 3) Setup — pick a provider, paste a key // +// We deliberately do NOT use `TabView` with `.page` style for the +// pager. That style wraps the content in a `UIPageViewController`, +// and `UIPageViewController` has a long-standing iOS 18 bug where +// the keyboard-showing layout reflow on a `TextField` focus is +// misread as a horizontal swipe — the page jumps back to step 1 +// the moment the user starts typing. Replacing the TabView with a +// `ZStack`-based conditional view sidesteps the bug entirely; we +// give up the swipe-to-page gesture, but the Back/Next buttons at +// the bottom (and the page dots) are the canonical onboarding +// affordance and the user is never more than one tap from the next +// page anyway. +// // Visual style: one large accent surface, generous whitespace, single CTA // at the bottom. No tipsy animations, no cheerful illustrations — every // pixel is doing one job. @@ -24,12 +36,15 @@ struct OnboardingView: View { ZStack { palette.background.ignoresSafeArea() VStack(spacing: 0) { - TabView(selection: $page) { - WelcomePage().tag(0) - EnableKeyboardPage().tag(1) - APISetupPage(config: config).tag(2) + Group { + switch page { + case 0: WelcomePage() + case 1: EnableKeyboardPage() + default: APISetupPage(config: config) + } } - .tabViewStyle(.page(indexDisplayMode: .never)) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .transition(.opacity.combined(with: .move(edge: .trailing))) pageDots .padding(.bottom, Spacing.md) @@ -57,7 +72,7 @@ struct OnboardingView: View { HStack(spacing: Spacing.sm) { if page > 0 { Button { withAnimation(Motion.soft) { page -= 1 } } label: { - Text("返回 · Back") + Text("common.back") .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) @@ -75,7 +90,11 @@ struct OnboardingView: View { if page < 2 { page += 1 } } } label: { - Text(page == 2 ? (config.isConfigured ? "完成 · Done" : "继续 · Continue") : "下一步 · Next") + Text(page == 2 + ? (config.isConfigured + ? NSLocalizedString("common.done", comment: "") + : NSLocalizedString("common.continue", comment: "")) + : NSLocalizedString("common.next", comment: "")) .font(TypeStyle.headline) .frame(maxWidth: .infinity, minHeight: 50) .background( @@ -113,11 +132,11 @@ private struct WelcomePage: View { Text("OSGKeyboard") .font(TypeStyle.title) .foregroundStyle(palette.textPrimary) - Text("按住说话,松开即得润色文字。") + Text("onboarding.welcome.subtitle") .font(TypeStyle.body) .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) - Text("Hold to talk. Release for polished text, in any app.") + Text("onboarding.welcome.subtitle") .font(TypeStyle.footnote) .foregroundStyle(palette.textTertiary) .multilineTextAlignment(.center) @@ -136,14 +155,14 @@ private struct PrivacyFootnote: View { var body: some View { VStack(alignment: .leading, spacing: 8) { footnoteRow(icon: "lock.fill", - title: "Audio stays on device · 音频不出本机", - body: "Transcribed locally with Apple's speech engine. · 由 Apple 端侧引擎转录") + title: "privacy.audio.title", + body: "privacy.audio.body") footnoteRow(icon: "wifi", - title: "Only the polished text is sent · 仅发送润色后文字", - body: "Sent to your chosen LLM to add structure & punctuation. · 仅向所选 LLM 发送润色后文字") + title: "privacy.network.title", + body: "privacy.network.body") footnoteRow(icon: "keyboard", - title: "Works everywhere · 处处可用", - body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears. · 微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方") + title: "privacy.universal.title", + body: "privacy.universal.body") } .padding(Spacing.md) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) @@ -154,7 +173,10 @@ private struct PrivacyFootnote: View { .padding(.horizontal, Spacing.md) } - private func footnoteRow(icon: String, title: String, body: String) -> some View { + // `LocalizedStringKey` (not `String`) so the call-site string + // literals are auto-looked-up in Localizable.strings. Passing a + // plain `String` would just print the key. + private func footnoteRow(icon: String, title: LocalizedStringKey, body: LocalizedStringKey) -> some View { HStack(alignment: .top, spacing: Spacing.xs) { Image(systemName: icon) .font(.system(size: 14, weight: .medium)) @@ -185,10 +207,10 @@ private struct EnableKeyboardPage: View { .font(.system(size: 64, weight: .light)) .foregroundStyle(palette.accent) VStack(spacing: Spacing.sm) { - Text("启用 OSGKeyboard") + Text("onboarding.enable.title") .font(TypeStyle.title2) .foregroundStyle(palette.textPrimary) - Text("Enable OSGKeyboard") + Text("onboarding.enable.title") .font(TypeStyle.body) .foregroundStyle(palette.textTertiary) } @@ -240,25 +262,56 @@ private struct APISetupPage: View { ScrollView { VStack(alignment: .leading, spacing: Spacing.md) { VStack(alignment: .leading, spacing: Spacing.xxs) { - Text("配置 AI 提供商") + Text("onboarding.api.title") .font(TypeStyle.title2) .foregroundStyle(palette.textPrimary) - Text("Configure your AI provider") - .font(TypeStyle.body) - .foregroundStyle(palette.textTertiary) - Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.") + Text("onboarding.api.subtitle") .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) + .foregroundStyle(palette.textTertiary) .padding(.top, Spacing.xxs) } .padding(.horizontal, Spacing.md) .padding(.top, Spacing.lg) - ProviderPickerSection(config: config) + // Same Engine picker as Settings — see EnginePickerSection. + EnginePickerSection(config: config) .padding(.horizontal, Spacing.md) - APISettingsCard(config: config) + if config.engineMode == "cloud" { + ProviderPickerSection(config: config) + .padding(.horizontal, Spacing.md) + + APISettingsCard(config: config) + .padding(.horizontal, Spacing.md) + } else { + // Local path: no LLM, no API key needed. Show a short + // confirmation so the user understands "no further + // setup required". + VStack(alignment: .leading, spacing: Spacing.xs) { + HStack(spacing: Spacing.sm) { + Image(systemName: "checkmark.seal.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(palette.accent) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text("onboarding.api.localReady.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text("onboarding.api.localReady.body") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + } + Spacer() + } + .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) + ) .padding(.horizontal, Spacing.md) + } } .padding(.bottom, Spacing.xxxl) } diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index 9bc11f3..4a36c0a 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -78,7 +78,7 @@ final class PreviewASRController: ObservableObject { // hits `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift`. let micGranted = await Self.requestMicrophonePermission() guard micGranted else { - phase = .denied("麦克风被拒绝 · Mic denied") + phase = .denied(NSLocalizedString("keyboard.denied.mic", comment: "")) return } @@ -86,7 +86,7 @@ final class PreviewASRController: ObservableObject { // the callback fires on TCC's reply queue, NOT the main queue. let speechGranted = await Self.requestSpeechRecognitionPermission() guard speechGranted else { - phase = .denied("语音识别被拒绝 · Speech denied") + phase = .denied(NSLocalizedString("keyboard.denied.speech", comment: "")) return } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 825b9ce..4ba688e 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -39,108 +39,42 @@ struct SettingsView: View { .padding(.vertical, Spacing.md) } } - .navigationTitle("设置 · Settings") + .navigationTitle(LocalizedStringKey("settings.title")) .navigationBarTitleDisplayMode(.inline) .task { await loadDynamicLocales() } .toolbar { ToolbarItem(placement: .confirmationAction) { - Button("完成 · Done") { dismiss() } + Button("common.done") { dismiss() } .font(TypeStyle.headline) .foregroundStyle(palette.accent) } } } .confirmationDialog( - "重置所有设置? · Reset all settings?", + LocalizedStringKey("settings.reset.title"), isPresented: $showResetConfirm, titleVisibility: .visible ) { - Button("重置 · Reset", role: .destructive) { + Button(LocalizedStringKey("common.reset"), role: .destructive) { config.reset() } - Button("取消 · Cancel", role: .cancel) {} + Button(LocalizedStringKey("common.cancel"), role: .cancel) {} } message: { - Text("API key、model 和 base URL 都会被清空。\nAPI key, model, and base URL will be cleared.") + Text("settings.reset.message") } } // MARK: - Engine - private var localEngineSubtitle: String { - if #available(iOS 26, *) { - return "SpeechAnalyzer · 始终端侧,无需联网 · Always on-device, no network" - } else { - return "端侧 ASR · 仅转录,无润色 · On-device ASR, transcription only, no polish" - } - } - private var engineSection: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - sectionHeader("引擎 · Engine", subtitle: "选择识别方式。本地引擎无需 API Key,仅做语音转录。\nPick the recognition engine. Local engine does transcription only, no API key needed.") - VStack(spacing: 0) { - engineOptionRow( - id: "local", - icon: "iphone.badge.checkmark", - title: "本地识别", - subtitle: localEngineSubtitle - ) - Divider().background(palette.divider) - engineOptionRow( - id: "cloud", - icon: "wand.and.stars", - title: "云端润色", - subtitle: "ASR 转录 + LLM 润色,需要 API Key · ASR + LLM polish, API key required" - ) - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - } - - private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View { - let isSelected = config.engineMode == id - return Button { - withAnimation(.easeInOut(duration: 0.2)) { - config.engineMode = id - // Lock mode to transcribe when switching to local engine - if id == "local" { config.modeId = "transcribe" } - } - } label: { - HStack(spacing: Spacing.sm) { - Image(systemName: icon) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle(isSelected ? palette.accent : palette.textSecondary) - .frame(width: 28) - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(TypeStyle.body) - .foregroundStyle(isSelected ? palette.accent : palette.textPrimary) - Text(subtitle) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - } - Spacer() - if isSelected { - Image(systemName: "checkmark") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(palette.accent) - } - } - .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + EnginePickerSection(config: config) } // MARK: - Provider private var providerSection: some View { VStack(alignment: .leading, spacing: Spacing.xs) { - sectionHeader("Provider · 提供商", subtitle: "选择 LLM 提供商。Pick the LLM that polishes your dictation.") + sectionHeader("settings.provider.title", subtitle: "settings.provider.subtitle") ProviderPickerSection(config: config) } } @@ -193,14 +127,14 @@ struct SettingsView: View { Image(systemName: "iphone") .font(TypeStyle.caption2) .foregroundStyle(palette.success) - Text("端侧识别 · On-device") + Text("settings.legend.onDevice") .font(TypeStyle.caption2) .foregroundStyle(palette.textTertiary) Spacer() Image(systemName: "cloud") .font(TypeStyle.caption2) .foregroundStyle(palette.warning) - Text("需联网 · Cloud fallback") + Text("settings.legend.cloudFallback") .font(TypeStyle.caption2) .foregroundStyle(palette.textTertiary) } @@ -216,7 +150,7 @@ struct SettingsView: View { private var asrEngineRow: some View { if #available(iOS 26, *) { HStack(spacing: Spacing.sm) { - Text("识别引擎 · Engine") + Text("settings.engineRow.title") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Spacer() @@ -224,7 +158,7 @@ struct SettingsView: View { Image(systemName: "iphone.badge.checkmark") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(palette.success) - Text("SpeechAnalyzer · 始终端侧") + Text("settings.engineBadge.ios26") .font(TypeStyle.caption) .foregroundStyle(palette.success) } @@ -240,7 +174,7 @@ struct SettingsView: View { set: { config.requiresOnDevice = $0 } )) { VStack(alignment: .leading, spacing: 2) { - Text("仅端侧识别 · On-device only") + Text("settings.onDeviceOnly.title") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Text("禁用云端回退,识别失败时会报错而非联网 · Disable cloud fallback, fail locally instead of going online") @@ -261,20 +195,20 @@ struct SettingsView: View { private var staticLocales: [(id: String, label: String, onDevice: Bool)] { [ - ("auto", "Auto · 跟随系统", false), - ("zh-Hans", "中文(简体)", false), - ("zh-Hant", "中文(繁體)", false), - ("en-US", "English (US)", false), - ("ja-JP", "日本語", false), - ("ko-KR", "한국어", false) + ("auto", NSLocalizedString("locale.auto", comment: ""), false), + ("zh-Hans", NSLocalizedString("locale.zh-Hans", comment: ""), false), + ("zh-Hant", NSLocalizedString("locale.zh-Hant", comment: ""), false), + ("en-US", NSLocalizedString("locale.en-US", comment: ""), false), + ("ja-JP", NSLocalizedString("locale.ja-JP", comment: ""), false), + ("ko-KR", NSLocalizedString("locale.ko-KR", comment: ""), false) ] } private var modeOptions: [(id: String, label: String)] { [ - ("off", "Off · 关闭"), - ("transcribe", "Transcribe · 仅转写"), - ("polish", "Polish · 润色") + ("off", NSLocalizedString("settings.mode.off", comment: "")), + ("transcribe", NSLocalizedString("settings.mode.transcribe", comment: "")), + ("polish", NSLocalizedString("settings.mode.polish", comment: "")) ] } @@ -314,7 +248,7 @@ struct SettingsView: View { HStack { sectionHeader("System Prompt · 系统提示", subtitle: nil) Spacer() - Button("重置 · Reset") { config.systemPrompt = config.defaultSystemPrompt } + Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt } .font(TypeStyle.caption2) .foregroundStyle(palette.accent) } @@ -340,7 +274,7 @@ struct SettingsView: View { Button(role: .destructive) { showResetConfirm = true } label: { - Text("重置所有设置 · Reset all settings") + Text("settings.reset.confirm") .font(TypeStyle.caption) .foregroundStyle(palette.danger) .frame(maxWidth: .infinity, minHeight: 40) @@ -350,7 +284,7 @@ struct SettingsView: View { // MARK: - Header - private func sectionHeader(_ title: String, subtitle: String?) -> some View { + private func sectionHeader(_ title: LocalizedStringKey, subtitle: LocalizedStringKey?) -> some View { VStack(alignment: .leading, spacing: 2) { Text(title) .font(TypeStyle.caption2) @@ -423,7 +357,7 @@ private struct LocalePickerRow: View { var body: some View { HStack { - Text("ASR locale · 识别语言") + Text("settings.asrLocale") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Spacer() diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings new file mode 100644 index 0000000..da5b5e9 --- /dev/null +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -0,0 +1,141 @@ +/* OSGKeyboard · English localization (development language) */ + +"OSGKeyboard" = "OSGKeyboard"; + +/* Onboarding */ +"onboarding.welcome.subtitle" = "Hold to talk. Release for polished text, in any app."; +"onboarding.enable.title" = "Enable OSGKeyboard"; +"onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; +"onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; +"onboarding.enable.step3" = "Tap OSGKeyboard and enable “Allow Full Access”"; +"onboarding.enable.step4" = "Allow Full Access is required for the microphone and LLM calls."; +"onboarding.enable.openSettings" = "Open iOS Settings"; +"onboarding.api.title" = "Choose engine"; +"onboarding.api.subtitle" = "Local needs no API key; Cloud polishes your text via LLM."; +"onboarding.api.localReady.title" = "No API key needed"; +"onboarding.api.localReady.body" = "Local recognition is ready to use. Tap Done to start."; + +/* Common navigation */ +"common.back" = "Back"; +"common.next" = "Next"; +"common.done" = "Done"; +"common.continue" = "Continue"; +"common.reset" = "Reset"; +"common.cancel" = "Cancel"; +"common.delete" = "Delete"; +"common.space" = "Space"; +"common.newline" = "Return"; + +/* Privacy footnote (onboarding welcome page) */ +"privacy.audio.title" = "Audio stays on device"; +"privacy.audio.body" = "Transcribed locally with Apple’s speech engine."; +"privacy.network.title" = "Only the polished text is sent"; +"privacy.network.body" = "Sent to your chosen LLM to add structure & punctuation."; +"privacy.universal.title" = "Works everywhere"; +"privacy.universal.body" = "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears."; + +/* Home */ +"home.status.ready" = "Ready"; +"home.status.setupIncomplete" = "Setup incomplete"; +"home.hero.label" = "Tap to configure"; +"home.action.enableInSettings" = "Enable in iOS Settings"; +"home.action.editApi" = "Edit API Configuration"; +"home.action.keyboardPreview" = "Keyboard Preview (Debug)"; +"home.action.openSettingsA11y" = "Open OSGKeyboard settings"; + +/* API settings */ +"api.baseUrl" = "Base URL"; +"api.model" = "Model"; +"api.getKey" = "Get an API key"; +"api.key" = "API Key"; +"api.key.placeholder" = "sk-…"; +"api.key.show" = "Show"; +"api.key.hide" = "Hide"; +"api.connection" = "Connection"; +"api.test.idle" = "Test connection"; +"api.test.running" = "Testing…"; +"api.test.success" = "Success · Retry"; +"api.test.failure" = "Failed · Retry"; +"api.test.connected" = "Connected"; +"api.test.missing" = "API key missing"; +"api.test.rateLimited" = "Rate limited (429)"; +"api.test.http" = "HTTP %d"; +"api.test.network" = "Network error"; +"api.test.connectedWith" = "“%@”"; +"api.test.transportWith" = "%@"; + +/* Settings */ +"settings.title" = "Settings"; +"settings.reset.title" = "Reset all settings?"; +"settings.reset.message" = "API key, model, and base URL will be cleared."; +"settings.reset.confirm" = "Reset all settings"; +"settings.engine.title" = "Engine"; +"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; +"settings.engine.local.title" = "On-device"; +"settings.engine.local.ios26" = "Always on-device, no network."; +"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; +"settings.engine.cloud.title" = "Cloud polish"; +"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; +"settings.provider.title" = "Provider"; +"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; +"settings.api.title" = "API"; +"settings.language.title" = "Language"; +"settings.language.subtitle.cloud" = "Recognition language and text processing mode."; +"settings.language.subtitle.local" = "Recognition language."; +"settings.mode.title" = "Mode"; +"settings.mode.off" = "Off"; +"settings.mode.transcribe" = "Transcribe"; +"settings.mode.polish" = "Polish"; +"settings.systemPrompt.title" = "System Prompt"; +"settings.systemPrompt.reset" = "Reset"; +"settings.asrLocale" = "ASR locale"; +"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; +"settings.engineRow.title" = "Recognition engine"; +"settings.onDeviceOnly.title" = "On-device only"; +"settings.onDeviceOnly.body" = "Disable cloud fallback. The keyboard reports an error instead of going online."; +"settings.legend.onDevice" = "On-device"; +"settings.legend.cloudFallback" = "Cloud fallback"; + +/* App group error */ +"appGroup.error.title" = "App Group not configured"; +"appGroup.error.body" = "OSGKeyboard needs an App Group to share config between the main app and the keyboard extension."; +"appGroup.error.step1" = "Create group.com.osgkeyboard.shared in the Apple Developer portal"; +"appGroup.error.step2" = "Enable it for both the main app and the keyboard extension"; +"appGroup.error.step3" = "Re-generate the provisioning profile and download it"; + +/* Keyboard preview */ +"preview.title" = "Keyboard Preview"; +"preview.subtitle" = "Tap the disc to start/stop recording. The real keyboard uses the same layout."; +"preview.placeholder" = "Type or tap to record"; +"preview.clear" = "Clear text"; +"preview.openSettingsA11y" = "Open OSGKeyboard settings"; + +/* Keyboard (ext) */ +"keyboard.placeholder.idle" = "Hold to talk"; +"keyboard.placeholder.preparing" = "Preparing"; +"keyboard.placeholder.processing" = "Processing"; +"keyboard.placeholder.error" = "Polishing failed"; +"keyboard.placeholder.localBadge" = "On-device"; +"keyboard.rec" = "REC"; +"keyboard.space" = "Space"; +"keyboard.denied.mic" = "Mic denied"; +"keyboard.denied.speech" = "Speech denied"; +"keyboard.openSettingsA11y" = "Open OSGKeyboard settings"; +"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."; +"keyboard.pressToTalkA11y" = "Push to talk"; + +/* Mode chip labels (used in both ext + preview stub) */ +"mode.off" = "Off"; +"mode.transcribe" = "Transcribe"; +"mode.polish" = "Polish"; + +/* Locale chip labels (preview stub) */ +"locale.zh-Hans" = "ZH-Hans"; + +/* Locale menu items (settings) */ +"locale.auto" = "Auto"; +"locale.zh-Hans" = "Chinese (Simplified)"; +"locale.zh-Hant" = "Chinese (Traditional)"; +"locale.en-US" = "English (US)"; +"locale.ja-JP" = "Japanese"; +"locale.ko-KR" = "Korean"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..b1e291d --- /dev/null +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,141 @@ +/* OSGKeyboard · 简体中文翻译 */ + +"OSGKeyboard" = "OSGKeyboard"; + +/* Onboarding */ +"onboarding.welcome.subtitle" = "按住说话,松开即得润色文字。"; +"onboarding.enable.title" = "启用 OSGKeyboard"; +"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; +"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; +"onboarding.enable.step3" = "点击 OSGKeyboard 并启用「允许完全访问」"; +"onboarding.enable.step4" = "允许完全访问是麦克风和网络调用的前提。"; +"onboarding.enable.openSettings" = "打开 iOS 设置"; +"onboarding.api.title" = "选择引擎"; +"onboarding.api.subtitle" = "Local 不需要 API Key;Cloud 用 LLM 润色文字。"; +"onboarding.api.localReady.title" = "无需配置 API Key"; +"onboarding.api.localReady.body" = "本地识别直接可用,按 Done 即可开始使用。"; + +/* Common navigation */ +"common.back" = "返回"; +"common.next" = "下一步"; +"common.done" = "完成"; +"common.continue" = "继续"; +"common.reset" = "重置"; +"common.cancel" = "取消"; +"common.delete" = "删除"; +"common.space" = "空格"; +"common.newline" = "换行"; + +/* Privacy footnote (onboarding welcome page) */ +"privacy.audio.title" = "音频不出本机"; +"privacy.audio.body" = "由 Apple 端侧引擎转录。"; +"privacy.network.title" = "仅发送润色后文字"; +"privacy.network.body" = "仅向所选 LLM 发送润色后文字,用于整理结构和标点。"; +"privacy.universal.title" = "处处可用"; +"privacy.universal.body" = "微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方。"; + +/* Home */ +"home.status.ready" = "就绪"; +"home.status.setupIncomplete" = "未完成配置"; +"home.hero.label" = "点此配置"; +"home.action.enableInSettings" = "在 iOS 设置中启用"; +"home.action.editApi" = "编辑 API 配置"; +"home.action.keyboardPreview" = "键盘预览(Debug)"; +"home.action.openSettingsA11y" = "打开 OSGKeyboard 设置"; + +/* API settings */ +"api.baseUrl" = "接口地址"; +"api.model" = "模型"; +"api.getKey" = "获取 API Key"; +"api.key" = "API Key"; +"api.key.placeholder" = "sk-…"; +"api.key.show" = "显示"; +"api.key.hide" = "隐藏"; +"api.connection" = "连接测试"; +"api.test.idle" = "测试连接"; +"api.test.running" = "测试中…"; +"api.test.success" = "成功 · 重试"; +"api.test.failure" = "失败 · 重试"; +"api.test.connected" = "连接成功"; +"api.test.missing" = "未填写 API Key"; +"api.test.rateLimited" = "API 限流 (429)"; +"api.test.http" = "HTTP %d"; +"api.test.network" = "网络错误"; +"api.test.connectedWith" = "“%@”"; +"api.test.transportWith" = "%@"; + +/* Settings */ +"settings.title" = "设置"; +"settings.reset.title" = "重置所有设置?"; +"settings.reset.message" = "API key、model 和 base URL 都会被清空。"; +"settings.reset.confirm" = "重置所有设置"; +"settings.engine.title" = "引擎"; +"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; +"settings.engine.local.title" = "本地识别"; +"settings.engine.local.ios26" = "始终端侧,无需联网。"; +"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; +"settings.engine.cloud.title" = "云端润色"; +"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; +"settings.provider.title" = "提供商"; +"settings.provider.subtitle" = "选择 LLM 提供商。"; +"settings.api.title" = "接口"; +"settings.language.title" = "语言"; +"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; +"settings.language.subtitle.local" = "选择识别语言。"; +"settings.mode.title" = "模式"; +"settings.mode.off" = "关闭"; +"settings.mode.transcribe" = "转写"; +"settings.mode.polish" = "润色"; +"settings.systemPrompt.title" = "系统提示"; +"settings.systemPrompt.reset" = "重置"; +"settings.asrLocale" = "识别语言"; +"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; +"settings.engineRow.title" = "识别引擎"; +"settings.onDeviceOnly.title" = "仅端侧识别"; +"settings.onDeviceOnly.body" = "禁用云端回退,识别失败时会报错而非联网。"; +"settings.legend.onDevice" = "端侧识别"; +"settings.legend.cloudFallback" = "需联网"; + +/* App group error */ +"appGroup.error.title" = "App Group 未配置"; +"appGroup.error.body" = "OSGKeyboard 需要 App Group 才能在键盘扩展和主 App 之间共享配置。"; +"appGroup.error.step1" = "在 Apple Developer 后台创建 group.com.osgkeyboard.shared"; +"appGroup.error.step2" = "主 App 和键盘扩展都启用该 App Group"; +"appGroup.error.step3" = "重新生成 provisioning profile 并下载"; + +/* Keyboard preview */ +"preview.title" = "键盘预览"; +"preview.subtitle" = "点按 disc 开始/结束录音;真实键盘使用同样布局。"; +"preview.placeholder" = "试着输入或按 disc 录音"; +"preview.clear" = "清空"; +"preview.openSettingsA11y" = "打开 OSGKeyboard 设置"; + +/* Keyboard (ext) */ +"keyboard.placeholder.idle" = "按住说话"; +"keyboard.placeholder.preparing" = "准备中…"; +"keyboard.placeholder.processing" = "处理中…"; +"keyboard.placeholder.error" = "润色失败"; +"keyboard.placeholder.localBadge" = "本地"; +"keyboard.rec" = "REC"; +"keyboard.space" = "空格"; +"keyboard.denied.mic" = "麦克风被拒绝"; +"keyboard.denied.speech" = "语音识别被拒绝"; +"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置"; +"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"; +"keyboard.pressToTalkA11y" = "按住说话"; + +/* Mode chip labels */ +"mode.off" = "关闭"; +"mode.transcribe" = "转写"; +"mode.polish" = "润色"; + +/* Locale chip labels (preview stub) */ +"locale.zh-Hans" = "简体"; + +/* Locale menu items (settings) */ +"locale.auto" = "跟随系统"; +"locale.zh-Hans" = "中文(简体)"; +"locale.zh-Hant" = "中文(繁體)"; +"locale.en-US" = "English (US)"; +"locale.ja-JP" = "日本語"; +"locale.ko-KR" = "한국어"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 8a5e88a..104ec35 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -80,15 +80,17 @@ public final class KeyboardViewController: UIInputViewController { // MARK: - Wiring private func installStateActions() { - state.beginRecording = { [weak self] in self?.pressBegan() } - state.endRecording = { [weak self] in self?.pressEnded() } - state.tapMic = { [weak self] in self?.advanceToNextInputMode() } - state.openSettings = { [weak self] in self?.openHostApp() } - state.setMode = { [weak self] m in self?.persistMode(m) } - state.setLocale = { [weak self] l in self?.persistLocale(l) } - state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } - state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } - state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } + state.beginRecording = { [weak self] in self?.pressBegan() } + state.endRecording = { [weak self] in self?.pressEnded() } + state.tapMic = { [weak self] in self?.advanceToNextInputMode() } + state.openSettings = { [weak self] in self?.openHostApp() } + state.setMode = { [weak self] m in self?.persistMode(m) } + state.setLocale = { [weak self] l in self?.persistLocale(l) } + state.setRequiresOnDevice = { [weak self] v in self?.persistRequiresOnDevice(v) } + state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) } + state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } + state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } + state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } } private func installSwiftUI() { @@ -181,7 +183,11 @@ public final class KeyboardViewController: UIInputViewController { state.lastTranscript = "" let locale = resolveLocale(state.localeId) - let events = asr.transcribe(stream: session.audio, locale: locale) + let events = asr.transcribe( + stream: session.audio, + locale: locale, + requiresOnDevice: state.requiresOnDevice + ) asrTask = Task { @MainActor [weak self] in guard let self else { return } @@ -237,8 +243,8 @@ public final class KeyboardViewController: UIInputViewController { state.phase = .idle return } - // In `.transcribe` mode, skip the LLM and insert raw. - if state.mode == .transcribe { + // Local engine or transcribe mode: insert directly, no LLM call. + if state.isLocalEngine || state.mode == .transcribe { textDocumentProxy.insertText(trimmed) state.lastTranscript = "" state.phase = .idle @@ -328,6 +334,16 @@ public final class KeyboardViewController: UIInputViewController { persistor.persist(localeId: id) } + private func persistRequiresOnDevice(_ value: Bool) { + state.requiresOnDevice = value + persistor.persist(requiresOnDevice: value) + } + + private func persistEngineMode(_ mode: String) { + state.engineMode = mode + persistor.persist(engineMode: mode) + } + // MARK: - Open host app private func openHostApp() { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 3d6b34f..0424bfa 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -30,8 +30,10 @@ public struct AppGroupPersistor { return .unavailable } let store = AppGroupStore() - state.localeId = store.localeId - state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish + state.localeId = store.localeId + state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish + state.requiresOnDevice = store.requiresOnDevice + state.engineMode = store.engineMode #if DEBUG // Print a masked view of the live App Group config so we can see @@ -68,4 +70,14 @@ public struct AppGroupPersistor { public func persist(localeId: String) { AppGroupStore().setLocaleId(localeId) } + + /// Persist the `requiresOnDevice` flag to the App Group store. + public func persist(requiresOnDevice: Bool) { + AppGroupStore().setRequiresOnDevice(requiresOnDevice) + } + + /// Persist `engineMode` to the App Group store. + public func persist(engineMode: String) { + AppGroupStore().setEngineMode(engineMode) + } } \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index dffb6a2..fdb5e21 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -96,7 +96,7 @@ public struct KeyboardRootView: View { .overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) } .buttonStyle(.plain) - .accessibilityLabel(Text("打开设置 · Open OSGKeyboard settings")) + .accessibilityLabel(Text("home.action.openSettingsA11y")) } .padding(.horizontal, Spacing.md) } @@ -128,16 +128,18 @@ public struct KeyboardRootView: View { // MARK: - Bottom bar private var bottomBar: some View { + // The globe / "next keyboard" button used to live here so the + // user could long-press to call up the system keyboard picker. + // In practice the user already has a "globe" key in the iOS + // system keyboard strip (every iOS keyboard does), so an + // in-extension globe is redundant and crowds the bar. + // Bottom bar: ⌫ (delete) [ space ] ↩ (return) HStack(spacing: Spacing.xxs) { - ToolbarIconButton(systemName: "globe", label: "nextKeyboard") { - state.tapMic() - } ToolbarIconButton(systemName: "delete.left", label: "delete") { state.deleteBackward() } - Spacer(minLength: 0) Button(action: state.insertSpace) { - Text("空格 · Space") + Text("common.space") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) .frame(maxWidth: .infinity, minHeight: 42) @@ -148,8 +150,7 @@ public struct KeyboardRootView: View { ) } .buttonStyle(.plain) - .accessibilityLabel(Text("空格 · Space")) - Spacer(minLength: 0) + .accessibilityLabel(Text("common.space")) ToolbarIconButton(systemName: "return", label: "newline") { state.insertNewline() } @@ -210,13 +211,13 @@ private struct TranscriptLine: View { ZStack { switch phase { case .idle: - Text("按住说话 · Hold to talk") + Text("keyboard.placeholder.idle") .font(TypeStyle.caption) .foregroundStyle(palette.textTertiary) case .requestingPermissions: HStack(spacing: 6) { ProgressView().controlSize(.mini).tint(palette.textSecondary) - Text("准备中… · Preparing") + Text("keyboard.placeholder.preparing") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } @@ -230,7 +231,7 @@ private struct TranscriptLine: View { case .processing: HStack(spacing: 6) { ProgressView().controlSize(.mini).tint(palette.accent) - Text("处理中 · Processing") + Text("keyboard.placeholder.processing") .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } @@ -255,7 +256,7 @@ private struct TranscriptLine: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityHint(Text("打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.")) + .accessibilityHint(Text("keyboard.deniedHint")) } } .frame(maxWidth: .infinity) @@ -360,7 +361,7 @@ private struct LocalEngineChip: View { var body: some View { HStack(spacing: 4) { Image(systemName: "iphone.badge.checkmark") - Text("本地 · On-device") + Text("keyboard.placeholder.localBadge") } .font(TypeStyle.caption2) .foregroundStyle(palette.accent) diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index ede04af..f8e0993 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -153,7 +153,7 @@ struct RecordButton: View { .onChange(of: phase) { _, new in breath = (new == .recording) } - .accessibilityLabel(Text("按住说话 · Push to talk")) + .accessibilityLabel(Text("keyboard.pressToTalkA11y")) } @State private var pressArmed: Bool = false diff --git a/OSGKeyboardExt/en.lproj/Localizable.strings b/OSGKeyboardExt/en.lproj/Localizable.strings new file mode 100644 index 0000000..da5b5e9 --- /dev/null +++ b/OSGKeyboardExt/en.lproj/Localizable.strings @@ -0,0 +1,141 @@ +/* OSGKeyboard · English localization (development language) */ + +"OSGKeyboard" = "OSGKeyboard"; + +/* Onboarding */ +"onboarding.welcome.subtitle" = "Hold to talk. Release for polished text, in any app."; +"onboarding.enable.title" = "Enable OSGKeyboard"; +"onboarding.enable.step1" = "Settings → General → Keyboard → Keyboards"; +"onboarding.enable.step2" = "Tap “Add New Keyboard…” and choose OSGKeyboard"; +"onboarding.enable.step3" = "Tap OSGKeyboard and enable “Allow Full Access”"; +"onboarding.enable.step4" = "Allow Full Access is required for the microphone and LLM calls."; +"onboarding.enable.openSettings" = "Open iOS Settings"; +"onboarding.api.title" = "Choose engine"; +"onboarding.api.subtitle" = "Local needs no API key; Cloud polishes your text via LLM."; +"onboarding.api.localReady.title" = "No API key needed"; +"onboarding.api.localReady.body" = "Local recognition is ready to use. Tap Done to start."; + +/* Common navigation */ +"common.back" = "Back"; +"common.next" = "Next"; +"common.done" = "Done"; +"common.continue" = "Continue"; +"common.reset" = "Reset"; +"common.cancel" = "Cancel"; +"common.delete" = "Delete"; +"common.space" = "Space"; +"common.newline" = "Return"; + +/* Privacy footnote (onboarding welcome page) */ +"privacy.audio.title" = "Audio stays on device"; +"privacy.audio.body" = "Transcribed locally with Apple’s speech engine."; +"privacy.network.title" = "Only the polished text is sent"; +"privacy.network.body" = "Sent to your chosen LLM to add structure & punctuation."; +"privacy.universal.title" = "Works everywhere"; +"privacy.universal.body" = "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears."; + +/* Home */ +"home.status.ready" = "Ready"; +"home.status.setupIncomplete" = "Setup incomplete"; +"home.hero.label" = "Tap to configure"; +"home.action.enableInSettings" = "Enable in iOS Settings"; +"home.action.editApi" = "Edit API Configuration"; +"home.action.keyboardPreview" = "Keyboard Preview (Debug)"; +"home.action.openSettingsA11y" = "Open OSGKeyboard settings"; + +/* API settings */ +"api.baseUrl" = "Base URL"; +"api.model" = "Model"; +"api.getKey" = "Get an API key"; +"api.key" = "API Key"; +"api.key.placeholder" = "sk-…"; +"api.key.show" = "Show"; +"api.key.hide" = "Hide"; +"api.connection" = "Connection"; +"api.test.idle" = "Test connection"; +"api.test.running" = "Testing…"; +"api.test.success" = "Success · Retry"; +"api.test.failure" = "Failed · Retry"; +"api.test.connected" = "Connected"; +"api.test.missing" = "API key missing"; +"api.test.rateLimited" = "Rate limited (429)"; +"api.test.http" = "HTTP %d"; +"api.test.network" = "Network error"; +"api.test.connectedWith" = "“%@”"; +"api.test.transportWith" = "%@"; + +/* Settings */ +"settings.title" = "Settings"; +"settings.reset.title" = "Reset all settings?"; +"settings.reset.message" = "API key, model, and base URL will be cleared."; +"settings.reset.confirm" = "Reset all settings"; +"settings.engine.title" = "Engine"; +"settings.engine.subtitle" = "Pick the recognition engine. Local engine does transcription only, no API key needed."; +"settings.engine.local.title" = "On-device"; +"settings.engine.local.ios26" = "Always on-device, no network."; +"settings.engine.local.legacy" = "On-device ASR. Transcription only, no polish."; +"settings.engine.cloud.title" = "Cloud polish"; +"settings.engine.cloud.subtitle" = "ASR + LLM polish. API key required."; +"settings.provider.title" = "Provider"; +"settings.provider.subtitle" = "Pick the LLM that polishes your dictation."; +"settings.api.title" = "API"; +"settings.language.title" = "Language"; +"settings.language.subtitle.cloud" = "Recognition language and text processing mode."; +"settings.language.subtitle.local" = "Recognition language."; +"settings.mode.title" = "Mode"; +"settings.mode.off" = "Off"; +"settings.mode.transcribe" = "Transcribe"; +"settings.mode.polish" = "Polish"; +"settings.systemPrompt.title" = "System Prompt"; +"settings.systemPrompt.reset" = "Reset"; +"settings.asrLocale" = "ASR locale"; +"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; +"settings.engineRow.title" = "Recognition engine"; +"settings.onDeviceOnly.title" = "On-device only"; +"settings.onDeviceOnly.body" = "Disable cloud fallback. The keyboard reports an error instead of going online."; +"settings.legend.onDevice" = "On-device"; +"settings.legend.cloudFallback" = "Cloud fallback"; + +/* App group error */ +"appGroup.error.title" = "App Group not configured"; +"appGroup.error.body" = "OSGKeyboard needs an App Group to share config between the main app and the keyboard extension."; +"appGroup.error.step1" = "Create group.com.osgkeyboard.shared in the Apple Developer portal"; +"appGroup.error.step2" = "Enable it for both the main app and the keyboard extension"; +"appGroup.error.step3" = "Re-generate the provisioning profile and download it"; + +/* Keyboard preview */ +"preview.title" = "Keyboard Preview"; +"preview.subtitle" = "Tap the disc to start/stop recording. The real keyboard uses the same layout."; +"preview.placeholder" = "Type or tap to record"; +"preview.clear" = "Clear text"; +"preview.openSettingsA11y" = "Open OSGKeyboard settings"; + +/* Keyboard (ext) */ +"keyboard.placeholder.idle" = "Hold to talk"; +"keyboard.placeholder.preparing" = "Preparing"; +"keyboard.placeholder.processing" = "Processing"; +"keyboard.placeholder.error" = "Polishing failed"; +"keyboard.placeholder.localBadge" = "On-device"; +"keyboard.rec" = "REC"; +"keyboard.space" = "Space"; +"keyboard.denied.mic" = "Mic denied"; +"keyboard.denied.speech" = "Speech denied"; +"keyboard.openSettingsA11y" = "Open OSGKeyboard settings"; +"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."; +"keyboard.pressToTalkA11y" = "Push to talk"; + +/* Mode chip labels (used in both ext + preview stub) */ +"mode.off" = "Off"; +"mode.transcribe" = "Transcribe"; +"mode.polish" = "Polish"; + +/* Locale chip labels (preview stub) */ +"locale.zh-Hans" = "ZH-Hans"; + +/* Locale menu items (settings) */ +"locale.auto" = "Auto"; +"locale.zh-Hans" = "Chinese (Simplified)"; +"locale.zh-Hant" = "Chinese (Traditional)"; +"locale.en-US" = "English (US)"; +"locale.ja-JP" = "Japanese"; +"locale.ko-KR" = "Korean"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings b/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..b1e291d --- /dev/null +++ b/OSGKeyboardExt/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,141 @@ +/* OSGKeyboard · 简体中文翻译 */ + +"OSGKeyboard" = "OSGKeyboard"; + +/* Onboarding */ +"onboarding.welcome.subtitle" = "按住说话,松开即得润色文字。"; +"onboarding.enable.title" = "启用 OSGKeyboard"; +"onboarding.enable.step1" = "设置 → 通用 → 键盘 → 键盘"; +"onboarding.enable.step2" = "点击「添加新键盘…」并选择 OSGKeyboard"; +"onboarding.enable.step3" = "点击 OSGKeyboard 并启用「允许完全访问」"; +"onboarding.enable.step4" = "允许完全访问是麦克风和网络调用的前提。"; +"onboarding.enable.openSettings" = "打开 iOS 设置"; +"onboarding.api.title" = "选择引擎"; +"onboarding.api.subtitle" = "Local 不需要 API Key;Cloud 用 LLM 润色文字。"; +"onboarding.api.localReady.title" = "无需配置 API Key"; +"onboarding.api.localReady.body" = "本地识别直接可用,按 Done 即可开始使用。"; + +/* Common navigation */ +"common.back" = "返回"; +"common.next" = "下一步"; +"common.done" = "完成"; +"common.continue" = "继续"; +"common.reset" = "重置"; +"common.cancel" = "取消"; +"common.delete" = "删除"; +"common.space" = "空格"; +"common.newline" = "换行"; + +/* Privacy footnote (onboarding welcome page) */ +"privacy.audio.title" = "音频不出本机"; +"privacy.audio.body" = "由 Apple 端侧引擎转录。"; +"privacy.network.title" = "仅发送润色后文字"; +"privacy.network.body" = "仅向所选 LLM 发送润色后文字,用于整理结构和标点。"; +"privacy.universal.title" = "处处可用"; +"privacy.universal.body" = "微信、备忘录、邮件、ChatGPT、Claude、Cursor — 任何键盘出现的地方。"; + +/* Home */ +"home.status.ready" = "就绪"; +"home.status.setupIncomplete" = "未完成配置"; +"home.hero.label" = "点此配置"; +"home.action.enableInSettings" = "在 iOS 设置中启用"; +"home.action.editApi" = "编辑 API 配置"; +"home.action.keyboardPreview" = "键盘预览(Debug)"; +"home.action.openSettingsA11y" = "打开 OSGKeyboard 设置"; + +/* API settings */ +"api.baseUrl" = "接口地址"; +"api.model" = "模型"; +"api.getKey" = "获取 API Key"; +"api.key" = "API Key"; +"api.key.placeholder" = "sk-…"; +"api.key.show" = "显示"; +"api.key.hide" = "隐藏"; +"api.connection" = "连接测试"; +"api.test.idle" = "测试连接"; +"api.test.running" = "测试中…"; +"api.test.success" = "成功 · 重试"; +"api.test.failure" = "失败 · 重试"; +"api.test.connected" = "连接成功"; +"api.test.missing" = "未填写 API Key"; +"api.test.rateLimited" = "API 限流 (429)"; +"api.test.http" = "HTTP %d"; +"api.test.network" = "网络错误"; +"api.test.connectedWith" = "“%@”"; +"api.test.transportWith" = "%@"; + +/* Settings */ +"settings.title" = "设置"; +"settings.reset.title" = "重置所有设置?"; +"settings.reset.message" = "API key、model 和 base URL 都会被清空。"; +"settings.reset.confirm" = "重置所有设置"; +"settings.engine.title" = "引擎"; +"settings.engine.subtitle" = "选择识别方式。本地引擎无需 API Key,仅做语音转录。"; +"settings.engine.local.title" = "本地识别"; +"settings.engine.local.ios26" = "始终端侧,无需联网。"; +"settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; +"settings.engine.cloud.title" = "云端润色"; +"settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; +"settings.provider.title" = "提供商"; +"settings.provider.subtitle" = "选择 LLM 提供商。"; +"settings.api.title" = "接口"; +"settings.language.title" = "语言"; +"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; +"settings.language.subtitle.local" = "选择识别语言。"; +"settings.mode.title" = "模式"; +"settings.mode.off" = "关闭"; +"settings.mode.transcribe" = "转写"; +"settings.mode.polish" = "润色"; +"settings.systemPrompt.title" = "系统提示"; +"settings.systemPrompt.reset" = "重置"; +"settings.asrLocale" = "识别语言"; +"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; +"settings.engineRow.title" = "识别引擎"; +"settings.onDeviceOnly.title" = "仅端侧识别"; +"settings.onDeviceOnly.body" = "禁用云端回退,识别失败时会报错而非联网。"; +"settings.legend.onDevice" = "端侧识别"; +"settings.legend.cloudFallback" = "需联网"; + +/* App group error */ +"appGroup.error.title" = "App Group 未配置"; +"appGroup.error.body" = "OSGKeyboard 需要 App Group 才能在键盘扩展和主 App 之间共享配置。"; +"appGroup.error.step1" = "在 Apple Developer 后台创建 group.com.osgkeyboard.shared"; +"appGroup.error.step2" = "主 App 和键盘扩展都启用该 App Group"; +"appGroup.error.step3" = "重新生成 provisioning profile 并下载"; + +/* Keyboard preview */ +"preview.title" = "键盘预览"; +"preview.subtitle" = "点按 disc 开始/结束录音;真实键盘使用同样布局。"; +"preview.placeholder" = "试着输入或按 disc 录音"; +"preview.clear" = "清空"; +"preview.openSettingsA11y" = "打开 OSGKeyboard 设置"; + +/* Keyboard (ext) */ +"keyboard.placeholder.idle" = "按住说话"; +"keyboard.placeholder.preparing" = "准备中…"; +"keyboard.placeholder.processing" = "处理中…"; +"keyboard.placeholder.error" = "润色失败"; +"keyboard.placeholder.localBadge" = "本地"; +"keyboard.rec" = "REC"; +"keyboard.space" = "空格"; +"keyboard.denied.mic" = "麦克风被拒绝"; +"keyboard.denied.speech" = "语音识别被拒绝"; +"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置"; +"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"; +"keyboard.pressToTalkA11y" = "按住说话"; + +/* Mode chip labels */ +"mode.off" = "关闭"; +"mode.transcribe" = "转写"; +"mode.polish" = "润色"; + +/* Locale chip labels (preview stub) */ +"locale.zh-Hans" = "简体"; + +/* Locale menu items (settings) */ +"locale.auto" = "跟随系统"; +"locale.zh-Hans" = "中文(简体)"; +"locale.zh-Hant" = "中文(繁體)"; +"locale.en-US" = "English (US)"; +"locale.ja-JP" = "日本語"; +"locale.ko-KR" = "한국어"; diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 5300144..a856d6d 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -16,16 +16,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { public static let shared = ProviderConfig() private enum Key { - static let providerId = "config.providerId" - static let baseURL = "config.baseURL" + static let providerId = "config.providerId" + static let baseURL = "config.baseURL" // Legacy: apiKey used to live in UserDefaults before the // migration. We still read it once (see init below) and then // delete the entry, but no other code path touches this key. - static let apiKeyLegacy = "config.apiKey" - static let model = "config.model" - static let systemPrompt = "config.systemPrompt" - static let modeId = "config.modeId" - static let localeId = "config.localeId" + static let apiKeyLegacy = "config.apiKey" + static let model = "config.model" + static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" + static let requiresOnDevice = "config.requiresOnDevice" + static let engineMode = "config.engineMode" } @Published public var providerId: String { @@ -60,6 +62,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { @Published public var localeId: String { didSet { defaults.set(localeId, forKey: Key.localeId) } } + /// When `true`, forces SFSpeechRecognizer to on-device only mode. + /// Ignored on iOS 26+ where SpeechAnalyzer is always on-device. + @Published public var requiresOnDevice: Bool { + didSet { defaults.set(requiresOnDevice, forKey: Key.requiresOnDevice) } + } + /// "local" → on-device ASR only, no LLM polishing. + /// "cloud" → ASR + LLM polish (default). + @Published public var engineMode: String { + didSet { defaults.set(engineMode, forKey: Key.engineMode) } + } public var isConfigured: Bool { !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty @@ -89,8 +101,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? AppGroupStore.defaultSystemPrompt(for: pid) - self.modeId = defaults.string(forKey: Key.modeId) ?? "polish" - self.localeId = defaults.string(forKey: Key.localeId) ?? "auto" + self.modeId = defaults.string(forKey: Key.modeId) ?? "polish" + self.localeId = defaults.string(forKey: Key.localeId) ?? "auto" + self.requiresOnDevice = defaults.bool(forKey: Key.requiresOnDevice) + self.engineMode = defaults.string(forKey: Key.engineMode) ?? "cloud" } /// Read the API key from the Keychain, falling back to a one-time diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 0937cb9..d520ad0 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -21,12 +21,14 @@ public struct AppGroupStore: @unchecked Sendable { // MARK: - Keys private enum Key { - static let providerId = "config.providerId" - static let baseURL = "config.baseURL" - static let model = "config.model" - static let systemPrompt = "config.systemPrompt" - static let modeId = "config.modeId" - static let localeId = "config.localeId" + static let providerId = "config.providerId" + static let baseURL = "config.baseURL" + static let model = "config.model" + static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" + static let requiresOnDevice = "config.requiresOnDevice" + static let engineMode = "config.engineMode" } // MARK: - Reads @@ -62,6 +64,19 @@ public struct AppGroupStore: @unchecked Sendable { defaults.string(forKey: Key.localeId) ?? "auto" } + /// When `true`, `SFSpeechRecognizer` is forced to on-device mode + /// (`requiresOnDeviceRecognition = true`). Ignored on iOS 26+ where + /// `SpeechAnalyzer` is always on-device. + public var requiresOnDevice: Bool { + defaults.bool(forKey: Key.requiresOnDevice) + } + + /// "local" → on-device ASR only, no LLM polishing. + /// "cloud" → ASR + LLM polish (default behaviour). + public var engineMode: String { + defaults.string(forKey: Key.engineMode) ?? "cloud" + } + // MARK: - Writes public func setModeId(_ id: String) { @@ -72,6 +87,14 @@ public struct AppGroupStore: @unchecked Sendable { defaults.set(id, forKey: Key.localeId) } + public func setRequiresOnDevice(_ value: Bool) { + defaults.set(value, forKey: Key.requiresOnDevice) + } + + public func setEngineMode(_ mode: String) { + defaults.set(mode, forKey: Key.engineMode) + } + // MARK: - Client public func makeClient() -> LLMClient { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index a89429d..86767b3 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -68,17 +68,27 @@ public final class KeyboardState: ObservableObject { /// ASR for Japanese). Updated once per recording by the ASR /// pipeline before any `.partial` is emitted. @Published public var onDeviceSupported: Bool = false + /// When `true`, `SFSpeechRecognizer` is forced to on-device mode. + /// Ignored on iOS 26+ where `SpeechAnalyzer` is always on-device. + @Published public var requiresOnDevice: Bool = false + /// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish. + @Published public var engineMode: String = "cloud" + + /// Convenience shorthand used by the pipeline and views. + public var isLocalEngine: Bool { engineMode == "local" } // Action hooks — injected by the view controller at install time. - public var beginRecording: () -> Void = {} - public var endRecording: () -> Void = {} - public var tapMic: () -> Void = {} - public var openSettings: () -> Void = {} - public var setMode: (InputMode) -> Void = { _ in } - public var setLocale: (String) -> Void = { _ in } - public var insertNewline: () -> Void = {} - public var insertSpace: () -> Void = {} - public var deleteBackward: () -> Void = {} + public var beginRecording: () -> Void = {} + public var endRecording: () -> Void = {} + public var tapMic: () -> Void = {} + public var openSettings: () -> Void = {} + public var setMode: (InputMode) -> Void = { _ in } + public var setLocale: (String) -> Void = { _ in } + public var setRequiresOnDevice: (Bool) -> Void = { _ in } + public var setEngineMode: (String) -> Void = { _ in } + public var insertNewline: () -> Void = {} + public var insertSpace: () -> Void = {} + public var deleteBackward: () -> Void = {} // MARK: - Preview helpers (DEBUG only) diff --git a/project.yml b/project.yml index 9830162..dfe0ed9 100644 --- a/project.yml +++ b/project.yml @@ -54,12 +54,21 @@ targets: - com.osgkeyboard.shared resources: - path: OSGKeyboard/Assets.xcassets + - path: OSGKeyboard/en.lproj + - path: OSGKeyboard/zh-Hans.lproj info: path: OSGKeyboard/Info.plist properties: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + # English is the development language; zh-Hans is the only + # translation right now. The list also tells iOS to surface + # the keyboard in the per-app language picker for both. + CFBundleDevelopmentRegion: en + CFBundleLocalizations: + - en + - zh-Hans UILaunchScreen: UIColorName: "BackgroundColor" UISupportedInterfaceOrientations: @@ -94,6 +103,11 @@ targets: platform: iOS sources: - path: OSGKeyboardExt + excludes: + - "en.lproj" + - "zh-Hans.lproj" + - path: OSGKeyboardExt/en.lproj + - path: OSGKeyboardExt/zh-Hans.lproj settings: base: IPHONEOS_DEPLOYMENT_TARGET: "18.0"