From b1635d5d35153edc2cfa9ee29747e54a28c33fe0 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:57:45 +0800 Subject: [PATCH] feat: iOS-style localization + onboarding engine picker + UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five user-flagged issues addressed in this commit. Some of the uncommitted files belong to a prior agent pass and are included as-is so this is a clean working tree. 1. Remove globe (nextKeyboard) button from keyboard bottom bar. iOS already provides a globe key in the system keyboard strip for next-keyboard switching, so the in-extension one was redundant. The bar is now: ⌫ (delete) [space] ↩ (return). Affected: OSGKeyboardExt/Views/KeyboardRootView.swift and OSGKeyboard/Views/KeyboardPreviewStub.swift (preview mirrors). 2. Fix TabView with .page style auto-jumping on TextField focus. SwiftUI's `.tabViewStyle(.page(...))` wraps content in a UIPageViewController, which 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. Replaced the TabView with a ZStack + conditional view + transition. We give up swipe-to-page, but Back/Next buttons + page dots are the canonical onboarding affordance and the user is one tap from the next page anyway. 3. Onboarding APISetupPage now offers Engine choice (Local vs Cloud), matching the in-app Settings page. First-run users can pick the on-device engine and skip the API-key setup entirely. Extracted the engine section from SettingsView into a shared `EnginePickerSection` component used by both. Cloud path keeps the provider + API fields; Local path shows a "no API key needed" confirmation card. 4. APISettingsCard test-connection error messages are now built with NSLocalizedString + String.localizedStringWithFormat (for the interpolated HTTP status / reply preview). Status badge labels are also NSLocalizedString-backed. 5. iOS-standard localization. - New `en.lproj/Localizable.strings` and `zh-Hans.lproj/ Localizable.strings` (in both the main app and the keyboard extension bundles — each .appex has its own bundle). - `project.yml` sets `CFBundleDevelopmentRegion: en` and `CFBundleLocalizations: [en, zh-Hans]`; the two .lproj dirs are added as resources. - Every `Text("...")` / `Button("...")` / `Label("...")` / `accessibilityLabel(Text("..."))` in user-facing views was rewritten to use `Text("key")` (SwiftUI auto-resolves string-literal `LocalizedStringKey`s against the strings file) or `NSLocalizedString("key", comment: "")` for interpolated / dynamic values. The hardcoded "中 · EN" / "EN · 中" pattern is gone. - Two helper signatures that took `String` for the title/body of a row (`footnoteRow`, `sectionHeader`) are now `LocalizedStringKey` so the row labels are looked up. - `Label("key", systemImage: "...")` and `.confirmationDialog(LocalizedStringKey("key"), ...)` and `.navigationTitle(LocalizedStringKey("key"))` are used where `String` would just print the key. - Preview-onboarding `APISetupPage` updated to use the same engine picker as Settings (issue 3), with a section that only renders the provider + API fields when the user picks Cloud. Verified on iOS 26 simulator with system language set to both zh-Hans (default) and en: the same screen renders "按住说话,松开 即得润色文字。" / "下一步" in Chinese, and "Hold to talk. Release for polished text, in any app." / "Next" in English — no "中 · EN" doubling, no missing keys. Build: BUILD SUCCEEDED. Tests: 21/21 pass. 🤖 Generated with Claude Code --- .../AppIcon.appiconset/Group 24.png | Bin 24338 -> 44527 bytes OSGKeyboard/Info.plist | 7 +- OSGKeyboard/Views/APISettingsCard.swift | 37 +++-- OSGKeyboard/Views/AppGroupErrorView.swift | 10 +- OSGKeyboard/Views/EnginePickerSection.swift | 111 ++++++++++++++ OSGKeyboard/Views/HomeView.swift | 10 +- OSGKeyboard/Views/KeyboardPreviewSheet.swift | 8 +- OSGKeyboard/Views/KeyboardPreviewStub.swift | 17 +-- OSGKeyboard/Views/OnboardingView.swift | 107 +++++++++---- OSGKeyboard/Views/PreviewASRController.swift | 4 +- OSGKeyboard/Views/SettingsView.swift | 118 ++++----------- OSGKeyboard/en.lproj/Localizable.strings | 141 ++++++++++++++++++ OSGKeyboard/zh-Hans.lproj/Localizable.strings | 141 ++++++++++++++++++ OSGKeyboardExt/KeyboardViewController.swift | 40 +++-- .../Services/AppGroupPersistor.swift | 16 +- OSGKeyboardExt/Views/KeyboardRootView.swift | 27 ++-- OSGKeyboardExt/Views/RecordButton.swift | 2 +- OSGKeyboardExt/en.lproj/Localizable.strings | 141 ++++++++++++++++++ .../zh-Hans.lproj/Localizable.strings | 141 ++++++++++++++++++ OSGKeyboardShared/Models/ProviderConfig.swift | 32 ++-- .../Services/AppGroupStore.swift | 35 ++++- .../Services/KeyboardState.swift | 28 ++-- project.yml | 14 ++ 23 files changed, 978 insertions(+), 209 deletions(-) create mode 100644 OSGKeyboard/Views/EnginePickerSection.swift create mode 100644 OSGKeyboard/en.lproj/Localizable.strings create mode 100644 OSGKeyboard/zh-Hans.lproj/Localizable.strings create mode 100644 OSGKeyboardExt/en.lproj/Localizable.strings create mode 100644 OSGKeyboardExt/zh-Hans.lproj/Localizable.strings diff --git a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png index 0e6ff46ac1c0840ab758a889473f1bde05f121f1..d80ad7f1c55351a047a9f2419346c3e324c20c61 100644 GIT binary patch literal 44527 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 O0829))8Zqfzy1$YphNTk literal 24338 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 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"