feat: iOS-style localization + onboarding engine picker + UX fixes

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
This commit is contained in:
Rocky
2026-06-18 19:57:45 +08:00
parent e8a031075b
commit b1635d5d35
23 changed files with 978 additions and 209 deletions
+28 -12
View File
@@ -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() {
@@ -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)
}
}
+14 -13
View File
@@ -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)
+1 -1
View File
@@ -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
+141
View File
@@ -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 Apples 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";
@@ -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" = "한국어";