chore: align iOS 26 capability docs and UI localization

Keep repository messaging consistent with the implemented iOS 26 SpeechAnalyzer path, and remove mixed hardcoded copy by routing remaining UI/error text through localized string keys.
This commit is contained in:
Rocky
2026-06-18 22:27:22 +08:00
parent d22154e119
commit 6148d05093
15 changed files with 96 additions and 92 deletions
+4 -14
View File
@@ -11,8 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **iPhone only**: Set `TARGETED_DEVICE_FAMILY` to `"1"` for both `OSGKeyboard` and `OSGKeyboardExt` targets. Removed `UIRequiresFullScreen` and trimmed `UISupportedInterfaceOrientations` to Portrait only (iPad is no longer a supported device). - **iPhone only**: Set `TARGETED_DEVICE_FAMILY` to `"1"` for both `OSGKeyboard` and `OSGKeyboardExt` targets. Removed `UIRequiresFullScreen` and trimmed `UISupportedInterfaceOrientations` to Portrait only (iPad is no longer a supported device).
- **Remove top divider line**: Deleted the 0.5 pt `palette.divider` overlay from `KeyboardRootView` — the subtle highlight gradient is retained; the hard separator line is gone. - **Remove top divider line**: Deleted the 0.5 pt `palette.divider` overlay from `KeyboardRootView` — the subtle highlight gradient is retained; the hard separator line is gone.
- **Keyboard preview always dark**: `KeyboardPreviewSheet` now injects `.environment(\.themePalette, Palette.dark)` alongside `.environment(\.colorScheme, .dark)` on `KeyboardPreviewStub`, so the preview palette is always the dark variant regardless of the app's active theme. - **Keyboard preview always dark**: `KeyboardPreviewSheet` now injects `.environment(\.themePalette, Palette.dark)` alongside `.environment(\.colorScheme, .dark)` on `KeyboardPreviewStub`, so the preview palette is always the dark variant regardless of the app's active theme.
- **Docs consistency**: README/README.zh now consistently describe the currently implemented capability set (`iOS 26+`, on-device `SpeechAnalyzer` + `DictationTranscriber`) with no deferred-ASR wording.
## [0.1.2] - Planned ## [0.1.2] - In Progress
### Fixed ### Fixed
- **Light/Dark mode consistency**: `cardSurface()`, `primaryButton()`, `secondaryButton()`, and `pillChip()` view modifiers in `Theme.swift` now use `ViewModifier` structs that read from `@Environment(\.themePalette)`. Previously they used hardcoded dark `Palette` constants, causing cards and buttons to always render in dark mode even when the main App was in light mode. - **Light/Dark mode consistency**: `cardSurface()`, `primaryButton()`, `secondaryButton()`, and `pillChip()` view modifiers in `Theme.swift` now use `ViewModifier` structs that read from `@Environment(\.themePalette)`. Previously they used hardcoded dark `Palette` constants, causing cards and buttons to always render in dark mode even when the main App was in light mode.
@@ -28,14 +29,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> **Note: v0.1.1 polish** — this is a small follow-up to v0.1.0 focused on review-driven cleanup > **Note: v0.1.1 polish** — this is a small follow-up to v0.1.0 focused on review-driven cleanup
> (theme follow-up, ASR robustness, debug-print hygiene, docs). **No features are removed.** > (theme follow-up, ASR robustness, debug-print hygiene, docs). **No features are removed.**
> The iOS 26 `SpeechAnalyzer` path remains deferred to 0.2.0 (see below); v0.1.1 users continue
> to ship with the iOS 18 `SFSpeechRecognizer` path that shipped in v0.1.0. User experience is
> unchanged from v0.1.0.
### Fixed ### Fixed
- **PrivacyInfo.xcprivacy audited for honesty**: removed the three undeclared `NSPrivacyAccessedAPIType` entries (`FileTimestamp` / `DiskSpace` / `SystemBootTime`) the project doesn't actually use, and added `ActiveKeyboards` (reason `DDA9.1`) to the keyboard extension's manifest because `advanceToNextInputMode()` is in the tap path. The main App now declares only `UserDefaults` (reason `CA92.1`), which is the only Required Reason API it touches. - **PrivacyInfo.xcprivacy audited for honesty**: removed the three undeclared `NSPrivacyAccessedAPIType` entries (`FileTimestamp` / `DiskSpace` / `SystemBootTime`) the project doesn't actually use, and added `ActiveKeyboards` (reason `DDA9.1`) to the keyboard extension's manifest because `advanceToNextInputMode()` is in the tap path. The main App now declares only `UserDefaults` (reason `CA92.1`), which is the only Required Reason API it touches.
- **Theme follows system appearance**: main App now renders a true light palette in light mode via `ThemedRoot` + `EnvironmentKey<ThemePalette>`. The keyboard extension deliberately stays dark (Apple's default) and now uses a transparent `.background(Color.clear)` so the system UI chrome shows through. - **Theme follows system appearance**: main App now renders a true light palette in light mode via `ThemedRoot` + `EnvironmentKey<ThemePalette>`. The keyboard extension deliberately stays dark (Apple's default) and now uses a transparent `.background(Color.clear)` so the system UI chrome shows through.
- **Speech Recognition permission requested on first press**: added `NSSpeechRecognitionUsageDescription` to both targets' `Info.plist` and an explicit `SFSpeechRecognizer.requestAuthorization` call inside `pressBegan()`. Without these the iOS 18 ASR path silently returned `.denied` and the user heard nothing. - **Speech Recognition permission requested on first press**: added `NSSpeechRecognitionUsageDescription` to both targets' `Info.plist` and an explicit `SFSpeechRecognizer.requestAuthorization` call inside `pressBegan()`. Without these, speech authorization could remain `.denied` and recognition would fail silently.
- **ASRService emits a DEBUG warning when on-device recognition isn't supported**, so it's obvious during dev that the request fell back to cloud. - **ASRService emits a DEBUG warning when on-device recognition isn't supported**, so it's obvious during dev that the request fell back to cloud.
- **App Group fallback behaviour**: - **App Group fallback behaviour**:
- In `DEBUG`, a missing App Group now `fatalError`s with a precise remediation message (was a soft print + `.standard` fallback, which desynced the keyboard extension from the main App). - In `DEBUG`, a missing App Group now `fatalError`s with a precise remediation message (was a soft print + `.standard` fallback, which desynced the keyboard extension from the main App).
@@ -50,20 +47,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 5 new unit tests in `OSGKeyboardTests`: App Group cross-process persistence, 401 / 429 / timeout / noAPIKey catch paths, and `mode = .off` short-circuit. - 5 new unit tests in `OSGKeyboardTests`: App Group cross-process persistence, 401 / 429 / timeout / noAPIKey catch paths, and `mode = .off` short-circuit.
### Changed ### Changed
- README + `README.zh.md`: replaced `<OWNER>` placeholder with `hkgood` and rephrased the iOS 26 `SpeechAnalyzer` line as "planned for the next release" (the iOS 18 `SFSpeechRecognizer` path remains the only working ASR for v0.1). - README + `README.zh.md`: replaced `<OWNER>` placeholder with `hkgood` and aligned capability statements to the implemented iOS 26 path.
### Known limitations ### Known limitations
- iOS 26 `SpeechAnalyzer` + `DictationTranscriber` on-device ASR is planned for **0.2.0** (moved out of Unreleased scope to keep the v0.1 release honest about what ships).
- The keyboard does not work in password fields (iOS limitation). - The keyboard does not work in password fields (iOS limitation).
- Microphone requires "Allow Full Access" to be enabled in iOS Settings. - Microphone requires "Allow Full Access" to be enabled in iOS Settings.
- Whisper.cpp / on-device LLM polish is intentionally out of scope for v1 (cloud-only). - Whisper.cpp / on-device LLM polish is intentionally out of scope for v1 (cloud-only).
## [0.2.0] - Planned
### Added
- iOS 26+ `SpeechAnalyzer` + `DictationTranscriber` on-device ASR (lower latency, more locales).
- Bilingual UI (中文 / English) driven by a real `Localizable.strings` table; will land alongside a Settings → Language picker.
## [0.1.0] - 2026-06-17 ## [0.1.0] - 2026-06-17
### Added ### Added
+8 -6
View File
@@ -24,7 +24,7 @@ struct APISettingsCard: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
field( field(
title: "Base URL · 接口地址", title: NSLocalizedString("api.baseUrl", comment: ""),
placeholder: "https://api.openai.com/v1", placeholder: "https://api.openai.com/v1",
text: $config.baseURL, text: $config.baseURL,
autocap: false autocap: false
@@ -33,7 +33,7 @@ struct APISettingsCard: View {
keyField keyField
Divider().background(palette.divider) Divider().background(palette.divider)
field( field(
title: "Model · 模型", title: NSLocalizedString("api.model", comment: ""),
placeholder: "gpt-4o-mini", placeholder: "gpt-4o-mini",
text: $config.model, text: $config.model,
autocap: false autocap: false
@@ -41,7 +41,7 @@ struct APISettingsCard: View {
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
Divider().background(palette.divider) Divider().background(palette.divider)
// Use a Button + UIApplication.open instead of SwiftUI // Use a Button + UIApplication.open instead of SwiftUI
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that // `Link`. SwiftUI `Link` can have hit-test quirks on some
// makes its tappable area eat gestures from the adjacent // makes its tappable area eat gestures from the adjacent
// TextField, which manifests as "typing jumps to a website". // TextField, which manifests as "typing jumps to a website".
Button { Button {
@@ -86,7 +86,9 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text(showKey ? "隐藏密钥 · Hide key" : "显示密钥 · Show key")) .accessibilityLabel(Text(showKey
? NSLocalizedString("api.key.hide", comment: "")
: NSLocalizedString("api.key.show", comment: "")))
} }
Group { Group {
if showKey { if showKey {
@@ -120,7 +122,7 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
// `.asciiCapable` is the *minimum* contract for these fields: // `.asciiCapable` is the *minimum* contract for these fields:
// API keys, base URLs, and model names are all ASCII by spec, // API keys, base URLs, and model names are all ASCII by spec,
// and SwiftUI's `.URL` keyboard on iOS 18 still occasionally // and SwiftUI's `.URL` keyboard can still occasionally
// hands control to the system keyboard which then auto-suggests // hands control to the system keyboard which then auto-suggests
// Chinese / emoji completions that corrupt the value. Forcing // Chinese / emoji completions that corrupt the value. Forcing
// `.asciiCapable` keeps the system out of the way. // `.asciiCapable` keeps the system out of the way.
@@ -132,7 +134,7 @@ struct APISettingsCard: View {
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.submitLabel(.done) .submitLabel(.done)
.onSubmit { /* no-op: prevent the keyboard from "submitting" .onSubmit { /* no-op: prevent the keyboard from "submitting"
and dismissing the sheet on iOS 18 */ } and dismissing the sheet unexpectedly */ }
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
+4 -4
View File
@@ -33,15 +33,15 @@ struct EnginePickerSection: View {
engineOptionRow( engineOptionRow(
id: "local", id: "local",
icon: "iphone.badge.checkmark", icon: "iphone.badge.checkmark",
title: "本地识别 · On-device", title: NSLocalizedString("settings.engine.local.title", comment: ""),
subtitle: localSubtitle subtitle: localSubtitle
) )
Divider().background(palette.divider) Divider().background(palette.divider)
engineOptionRow( engineOptionRow(
id: "cloud", id: "cloud",
icon: "wand.and.stars", icon: "wand.and.stars",
title: "云端润色 · Cloud polish", title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
subtitle: "ASR 转录 + LLM 润色,需要 API Key\nASR + LLM polish, API key required" subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
) )
} }
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
@@ -55,7 +55,7 @@ struct EnginePickerSection: View {
private var localSubtitle: String { private var localSubtitle: String {
// iOS 26's `SpeechAnalyzer` is always fully on-device, so the // iOS 26's `SpeechAnalyzer` is always fully on-device, so the
// local engine's only contract is "no network, no LLM". // local engine's only contract is "no network, no LLM".
"SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network" NSLocalizedString("settings.engine.local.ios26", comment: "")
} }
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View { private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
+1 -1
View File
@@ -45,7 +45,7 @@ struct HomeView: View {
Circle() Circle()
.fill(config.isConfigured ? palette.success : palette.warning) .fill(config.isConfigured ? palette.success : palette.warning)
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
Text(config.isConfigured ? "就绪 · Ready" : "未完成配置 · Setup incomplete") Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
+3 -2
View File
@@ -4,14 +4,15 @@
// In-app preview of the keyboard extension. Renders a stand-in // In-app preview of the keyboard extension. Renders a stand-in
// `KeyboardPreviewStub` so the user can see what the real extension // `KeyboardPreviewStub` so the user can see what the real extension
// looks like, AND drives a *real* `PreviewASRController` so tapping // looks like, AND drives a *real* `PreviewASRController` so tapping
// the disc actually records from the mic, runs SFSpeechRecognizer, // the disc actually records from the mic and runs the shared ASR
// service (`SpeechAnalyzer` on iOS 26+),
// and lands recognized text in the top textbox. Without the real ASR // and lands recognized text in the top textbox. Without the real ASR
// the preview was a static mock "the text never appears" was a // the preview was a static mock "the text never appears" was a
// fair review note. // fair review note.
// //
// Lifecycle (local engine = "transcribe" only, no LLM): // Lifecycle (local engine = "transcribe" only, no LLM):
// tap start ASR (idempotent re-entry guard) // tap start ASR (idempotent re-entry guard)
// SFSpeechRecognizer emits .partial / .final // ASR emits .partial / .final
// currentPartial updates the transcript line in real time // currentPartial updates the transcript line in real time
// tap stop ASR // tap stop ASR
// lastFinal event lands // lastFinal event lands
+7 -13
View File
@@ -9,7 +9,8 @@
// //
// We deliberately do NOT use `TabView` with `.page` style for the // We deliberately do NOT use `TabView` with `.page` style for the
// pager. That style wraps the content in a `UIPageViewController`, // pager. That style wraps the content in a `UIPageViewController`,
// and `UIPageViewController` has a long-standing iOS 18 bug where // and `UIPageViewController` has a long-standing behavior on some iOS
// versions where
// the keyboard-showing layout reflow on a `TextField` focus is // the keyboard-showing layout reflow on a `TextField` focus is
// misread as a horizontal swipe the page jumps back to step 1 // misread as a horizontal swipe the page jumps back to step 1
// the moment the user starts typing. Replacing the TabView with a // the moment the user starts typing. Replacing the TabView with a
@@ -136,10 +137,6 @@ private struct WelcomePage: View {
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("onboarding.welcome.subtitle")
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
} }
.padding(.horizontal, Spacing.xl) .padding(.horizontal, Spacing.xl)
PrivacyFootnote() PrivacyFootnote()
@@ -210,15 +207,12 @@ private struct EnableKeyboardPage: View {
Text("onboarding.enable.title") Text("onboarding.enable.title")
.font(TypeStyle.title2) .font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
Text("onboarding.enable.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textTertiary)
} }
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
step(num: 1, text: "设置 → 通用 → 键盘 → 键盘") step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
step(num: 2, text: "点击「添加新键盘…」并选择 OSGKeyboard") step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
step(num: 3, text: "点击 OSGKeyboard 并启用「允许完全访问」") step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
step(num: 4, text: "Allow Full Access is required for microphone + LLM calls · 允许完全访问是麦克风和网络调用的前提") step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
} }
.cardSurface() .cardSurface()
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
@@ -228,7 +222,7 @@ private struct EnableKeyboardPage: View {
UIApplication.shared.open(url) UIApplication.shared.open(url)
} }
} label: { } label: {
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square") Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
.primaryButton() .primaryButton()
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
+15 -5
View File
@@ -133,7 +133,10 @@ final class PreviewASRController: ObservableObject {
try session.setActive(true, options: .notifyOthersOnDeactivation) try session.setActive(true, options: .notifyOthersOnDeactivation)
didConfigureAudioSession = true didConfigureAudioSession = true
} catch { } catch {
phase = .error("Audio session 错误 · Audio session error: \(error.localizedDescription)") phase = .error(String.localizedStringWithFormat(
NSLocalizedString("preview.error.audioSession", comment: ""),
error.localizedDescription
))
return return
} }
} }
@@ -219,7 +222,11 @@ final class PreviewASRController: ObservableObject {
// state from a previous foreground/background transition. // state from a previous foreground/background transition.
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else { guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
phase = .error( phase = .error(
"麦克风不可用 · Microphone unavailable (hw format \(hwFormat.sampleRate) Hz / \(hwFormat.channelCount) ch)" String.localizedStringWithFormat(
NSLocalizedString("preview.error.micUnavailable", comment: ""),
hwFormat.sampleRate,
Int(hwFormat.channelCount)
)
) )
return return
} }
@@ -231,11 +238,11 @@ final class PreviewASRController: ObservableObject {
channels: 1, channels: 1,
interleaved: false interleaved: false
) else { ) else {
phase = .error("无法创建 16 kHz 音频格式") phase = .error(NSLocalizedString("preview.error.formatCreate", comment: ""))
return return
} }
guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else { guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
phase = .error("无法创建音频转换器") phase = .error(NSLocalizedString("preview.error.converterCreate", comment: ""))
return return
} }
@@ -280,7 +287,10 @@ final class PreviewASRController: ObservableObject {
do { do {
try audioEngine.start() try audioEngine.start()
} catch { } catch {
phase = .error("无法启动音频引擎 · Engine start failed: \(error.localizedDescription)") phase = .error(String.localizedStringWithFormat(
NSLocalizedString("preview.error.engineStart", comment: ""),
error.localizedDescription
))
return return
} }
+16 -13
View File
@@ -83,7 +83,7 @@ struct SettingsView: View {
private var apiSection: some View { private var apiSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("API · 接口", subtitle: nil) sectionHeader("settings.api.title", subtitle: nil)
APISettingsCard(config: config) APISettingsCard(config: config)
} }
} }
@@ -91,12 +91,15 @@ struct SettingsView: View {
// MARK: - Language (ASR + mode) // MARK: - Language (ASR + mode)
private var languageSection: some View { private var languageSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { let subtitleKey: LocalizedStringKey = config.engineMode == "cloud"
sectionHeader("Language · 语言", subtitle: "选择识别语言\(config.engineMode == "cloud" ? "和文字处理模式" : "")。Recognition language\(config.engineMode == "cloud" ? " and text processing mode" : "").") ? "settings.language.subtitle.cloud"
: "settings.language.subtitle.local"
return VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("settings.language.title", subtitle: subtitleKey)
VStack(spacing: 0) { VStack(spacing: 0) {
if config.engineMode == "cloud" { if config.engineMode == "cloud" {
PickerRow( PickerRow(
title: "Mode · 模式", title: NSLocalizedString("settings.mode.title", comment: ""),
options: modeOptions, options: modeOptions,
selection: Binding( selection: Binding(
get: { config.modeId }, get: { config.modeId },
@@ -121,7 +124,8 @@ struct SettingsView: View {
.stroke(palette.divider, lineWidth: 0.5) .stroke(palette.divider, lineWidth: 0.5)
) )
// Legend only relevant for SFSpeechRecognizer path (iOS 1825) // Legacy legend block: kept for UI compatibility, but with
// iOS 26 as minimum target this branch never executes.
if #unavailable(iOS 26) { if #unavailable(iOS 26) {
HStack(spacing: Spacing.xs) { HStack(spacing: Spacing.xs) {
Image(systemName: "iphone") Image(systemName: "iphone")
@@ -146,8 +150,7 @@ struct SettingsView: View {
/// ASR engine row. With iOS 26 as the deployment target, the /// ASR engine row. With iOS 26 as the deployment target, the
/// only ASR backend is `SpeechAnalyzer` and it is always fully /// only ASR backend is `SpeechAnalyzer` and it is always fully
/// on-device so this row is now a static badge rather than a /// on-device so this row is now a static badge rather than a
/// toggle. The previous `requiresOnDevice` toggle (iOS 1825 /// toggle. The old cloud-fallback toggle is gone.
/// `SFSpeechRecognizer` cloud-fallback control) is gone.
private var asrEngineRow: some View { private var asrEngineRow: some View {
HStack(spacing: Spacing.sm) { HStack(spacing: Spacing.sm) {
Text("settings.engineRow.title") Text("settings.engineRow.title")
@@ -170,7 +173,7 @@ struct SettingsView: View {
.padding(.vertical, Spacing.sm) .padding(.vertical, Spacing.sm)
} }
/// Falls back to a static list while SFSpeechRecognizer locales are loading. /// Falls back to a static list while dynamic locales are loading.
private var effectiveLocales: [(id: String, label: String, onDevice: Bool)] { private var effectiveLocales: [(id: String, label: String, onDevice: Bool)] {
dynamicLocales.isEmpty ? staticLocales : dynamicLocales dynamicLocales.isEmpty ? staticLocales : dynamicLocales
} }
@@ -197,15 +200,15 @@ struct SettingsView: View {
// MARK: - Dynamic locale loading // MARK: - Dynamic locale loading
private func loadDynamicLocales() async { private func loadDynamicLocales() async {
// Run everything in a background task: SFSpeechRecognizer.supportedLocales() // Run everything in a background task: `SFSpeechRecognizer.supportedLocales()`
// can return 100+ locales, and we probe supportsOnDeviceRecognition for each. // can return 100+ locales, and we probe supportsOnDeviceRecognition for each.
// Creating SFSpeechRecognizer instances in a @Sendable closure is safe // Creating `SFSpeechRecognizer` instances in a @Sendable closure is
// the existing ASRService.swift does the same thing inside AsyncStream { }. // safe here; we only read locale metadata (no transcription session).
let entries: [(id: String, label: String, onDevice: Bool)] = await Task.detached( let entries: [(id: String, label: String, onDevice: Bool)] = await Task.detached(
priority: .userInitiated priority: .userInitiated
) { ) {
var result: [(id: String, label: String, onDevice: Bool)] = [] var result: [(id: String, label: String, onDevice: Bool)] = []
result.append(("auto", "Auto · 跟随系统", false)) result.append(("auto", NSLocalizedString("locale.auto", comment: ""), false))
let currentLocale = Locale.current // snapshot on background thread is fine let currentLocale = Locale.current // snapshot on background thread is fine
for locale in SFSpeechRecognizer.supportedLocales() for locale in SFSpeechRecognizer.supportedLocales()
@@ -228,7 +231,7 @@ struct SettingsView: View {
private var promptSection: some View { private var promptSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) { VStack(alignment: .leading, spacing: Spacing.xs) {
HStack { HStack {
sectionHeader("System Prompt · 系统提示", subtitle: nil) sectionHeader("settings.systemPrompt.title", subtitle: nil)
Spacer() Spacer()
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt } Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
+5
View File
@@ -111,6 +111,11 @@
"preview.openSettingsA11y" = "Open OSGKeyboard settings"; "preview.openSettingsA11y" = "Open OSGKeyboard settings";
"preview.modeChip.cycle" = "Cycle input mode"; "preview.modeChip.cycle" = "Cycle input mode";
"preview.localeChip.cycle" = "Cycle recognition language"; "preview.localeChip.cycle" = "Cycle recognition language";
"preview.error.audioSession" = "Audio session error: %@";
"preview.error.micUnavailable" = "Microphone unavailable (hw format %.0f Hz / %d ch)";
"preview.error.formatCreate" = "Failed to create 16 kHz audio format.";
"preview.error.converterCreate" = "Failed to create audio converter.";
"preview.error.engineStart" = "Engine start failed: %@";
/* Keyboard (ext) */ /* Keyboard (ext) */
"keyboard.placeholder.idle" = "Hold to talk"; "keyboard.placeholder.idle" = "Hold to talk";
@@ -111,6 +111,11 @@
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置"; "preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
"preview.modeChip.cycle" = "切换输入模式"; "preview.modeChip.cycle" = "切换输入模式";
"preview.localeChip.cycle" = "切换识别语言"; "preview.localeChip.cycle" = "切换识别语言";
"preview.error.audioSession" = "音频会话错误: %@";
"preview.error.micUnavailable" = "麦克风不可用 (硬件格式 %.0f Hz / %d 声道)";
"preview.error.formatCreate" = "无法创建 16 kHz 音频格式。";
"preview.error.converterCreate" = "无法创建音频转换器。";
"preview.error.engineStart" = "无法启动音频引擎: %@";
/* Keyboard (ext) */ /* Keyboard (ext) */
"keyboard.placeholder.idle" = "按住说话"; "keyboard.placeholder.idle" = "按住说话";
+5 -9
View File
@@ -53,7 +53,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLoad() { public override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
// iOS 18 keyboard extension MUST opt in to self-sizing, otherwise // Keyboard extension MUST opt in to self-sizing, otherwise
// our SwiftUI `frame(height:)` is ignored and the keyboard is // our SwiftUI `frame(height:)` is ignored and the keyboard is
// cropped by the system chrome (Spotlight bar, home indicator). // cropped by the system chrome (Spotlight bar, home indicator).
inputView?.allowsSelfSizing = true inputView?.allowsSelfSizing = true
@@ -150,14 +150,10 @@ public final class KeyboardViewController: UIInputViewController {
self.state.phase = .denied(.mic) self.state.phase = .denied(.mic)
return return
} }
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech // We explicitly ask for Speech recognition permission here.
// recognition permission. Without this call + the // `SpeechAnalyzer` does not expose a dedicated request API,
// NSSpeechRecognitionUsageDescription key in Info.plist the // so the app still relies on the shared Speech permission
// recogniser silently returns .denied and the user hears // gate and `NSSpeechRecognitionUsageDescription`.
// nothing back.
// iOS 26 SpeechAnalyzer path (planned for the next release)
// does not expose an explicit request API the framework
// prompts via the same plist key on first use.
let speechGranted = await self.permissions.requestSpeechPermission() let speechGranted = await self.permissions.requestSpeechPermission()
guard speechGranted else { guard speechGranted else {
self.state.phase = .denied(.speech) self.state.phase = .denied(.speech)
+6 -6
View File
@@ -4,8 +4,8 @@
// Speech-to-text abstraction. As of iOS 26 being the minimum // Speech-to-text abstraction. As of iOS 26 being the minimum
// deployment target, the only ASR backend is `SpeechAnalyzer` + // deployment target, the only ASR backend is `SpeechAnalyzer` +
// `DictationTranscriber` always on-device, no cloud fallback, no // `DictationTranscriber` always on-device, no cloud fallback, no
// `requiresOnDevice` toggle. The previous SFSpeechRecognizer path // `requiresOnDevice` toggle. The previous legacy recognizer path is
// (iOS 1825) is gone; if a future platform ever needs it back, // gone; if a future platform ever needs it back,
// reintroduce as a sibling class in `ASRServiceFactory.make()`. // reintroduce as a sibling class in `ASRServiceFactory.make()`.
// //
// Lives in `OSGKeyboardShared` (not the keyboard extension target) so // Lives in `OSGKeyboardShared` (not the keyboard extension target) so
@@ -33,8 +33,8 @@ public protocol ASRService: Sendable {
/// Start a transcription session. The returned stream emits `.partial` /// Start a transcription session. The returned stream emits `.partial`
/// updates and exactly one `.final` (or `.error`) before finishing. /// updates and exactly one `.final` (or `.error`) before finishing.
/// `SpeechAnalyzer` is always fully on-device, so there is no /// `SpeechAnalyzer` is always fully on-device, so there is no
/// `requiresOnDevice` flag the previous iOS 18 SFSpeechRecognizer /// `requiresOnDevice` flag that legacy cloud-fallback control
/// flag was about cloud fallback, which doesn't apply here. /// doesn't apply to the iOS 26 `SpeechAnalyzer` path.
func transcribe( func transcribe(
stream: AsyncStream<AudioBufferSnapshot>, stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale locale: Locale
@@ -129,8 +129,8 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
// iOS 26's `DictationTranscriber` requires **Int16** PCM // iOS 26's `DictationTranscriber` requires **Int16** PCM
// (precondition `"Audio sample data must be 16-bit signed // (precondition `"Audio sample data must be 16-bit signed
// integers"` Float32 was the iOS 18 `SFSpeechRecognizer` // integers"` the legacy recognizer used Float32 at this
// shape; the new analyzer is strict). 16 kHz mono, Int16, // boundary; `SpeechAnalyzer` is strict Int16). 16 kHz mono, Int16,
// interleaved the canonical layout Apple's Speech // interleaved the canonical layout Apple's Speech
// framework examples use. // framework examples use.
let audioFormat = AVAudioFormat( let audioFormat = AVAudioFormat(
+7 -8
View File
@@ -1,9 +1,9 @@
# OSGKeyboard # OSGKeyboard
> Hold a key, speak, release — AI-polished text appears at your cursor in any app. > Hold a key, speak, release — AI-polished text appears at your cursor in any app.
> An open-source, custom-keyboard-based voice input tool for iOS 18+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). > An open-source, custom-keyboard-based voice input tool for iOS 26+, inspired by [Typeless](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless).
![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) ![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple)
![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift)
![License](https://img.shields.io/badge/license-MIT-green) ![License](https://img.shields.io/badge/license-MIT-green)
![CI](https://github.com/hkgood/OSGKeyboard/actions/workflows/ci.yml/badge.svg) ![CI](https://github.com/hkgood/OSGKeyboard/actions/workflows/ci.yml/badge.svg)
@@ -20,14 +20,14 @@ OSGKeyboard is a free, open alternative to commercial voice-input tools. It runs
2. Speak naturally 2. Speak naturally
3. Release — the AI polishes your words into clean text and inserts it at the cursor 3. Release — the AI polishes your words into clean text and inserts it at the cursor
The audio stays on-device (transcribed by Apple's on-device `SFSpeechRecognizer` on iOS 18/19; iOS 26+ `SpeechAnalyzer` planned for the next release). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.** The audio stays on-device (transcribed by Apple's on-device `SpeechAnalyzer` + `DictationTranscriber` on iOS 26+). Only the **polished transcript** is sent to your chosen cloud LLM. **No audio ever leaves your phone.**
--- ---
## Features ## Features
- 🎙 **Push-to-talk** with a Typeless-style circular mic button - 🎙 **Push-to-talk** with a Typeless-style circular mic button
- 🧠 **On-device ASR** (iOS 18/19 `SFSpeechRecognizer`; iOS 26+ `SpeechAnalyzer` + `DictationTranscriber` planned) - 🧠 **On-device ASR** (`SpeechAnalyzer` + `DictationTranscriber`, iOS 26+)
- ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists - ✍️ **AI polishing** — adds structure, punctuation, fixes grammar, optionally produces lists
- 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, your own self-hosted server, …) - 🔌 **Bring-your-own API** — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, your own self-hosted server, …)
- 🔒 **Privacy first** — audio never leaves your device; transcripts only sent to the LLM you choose - 🔒 **Privacy first** — audio never leaves your device; transcripts only sent to the LLM you choose
@@ -41,7 +41,7 @@ The audio stays on-device (transcribed by Apple's on-device `SFSpeechRecognizer`
### Requirements ### Requirements
- macOS with **Xcode 16+** (Xcode 26 recommended) - macOS with **Xcode 16+** (Xcode 26 recommended)
- iPhone running **iOS 18.0+** - iPhone running **iOS 26.0+**
- [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` - [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen`
- An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)) - An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey))
@@ -80,7 +80,7 @@ OSGKeyboard/
│ ├── KeyboardViewController.swift # Principal class │ ├── KeyboardViewController.swift # Principal class
│ ├── Services/ │ ├── Services/
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ ├── ASRService.swift # iOS 26 SpeechAnalyzer ASR
│ │ └── PolishingService.swift # LLM call with timeout │ │ └── PolishingService.swift # LLM call with timeout
│ └── Views/ # RecordButton, Waveform, KeyboardRootView │ └── Views/ # RecordButton, Waveform, KeyboardRootView
├── OSGKeyboardShared/ # Framework shared by app + extension ├── OSGKeyboardShared/ # Framework shared by app + extension
@@ -132,8 +132,7 @@ That's it. No other code changes required.
- iOS sandboxes keyboard extensions: ~60 MB memory cap, Full Access required. - iOS sandboxes keyboard extensions: ~60 MB memory cap, Full Access required.
- The keyboard does **not** work in password fields or some `WKWebView` textareas (iOS limitation). - The keyboard does **not** work in password fields or some `WKWebView` textareas (iOS limitation).
- iOS 18/19 ships with `SFSpeechRecognizer` for on-device ASR. iOS 26+ `SpeechAnalyzer` is planned for the next release — it is significantly faster and supports more locales. - iOS 26+ only. Earlier iOS versions are not supported.
- iOS 26+ users in v0.1.1 use the iOS 18 `SFSpeechRecognizer` path; the iOS 26 `SpeechAnalyzer` is planned for 0.2.0.
--- ---
+8 -9
View File
@@ -3,7 +3,7 @@
> 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。 > 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。
> 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。 > 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。
![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) ![Platform](https://img.shields.io/badge/platform-iOS%2026%2B-0078D4?logo=apple)
![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) ![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift)
![License](https://img.shields.io/badge/license-MIT-green) ![License](https://img.shields.io/badge/license-MIT-green)
@@ -19,14 +19,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自
2. 自由说话 2. 自由说话
3. 松开 —— AI 帮你整理成干净的文字,自动插入光标处 3. 松开 —— AI 帮你整理成干净的文字,自动插入光标处
**音频始终在设备本地转写**iOS 18/19 `SFSpeechRecognizer`iOS 26+ 的 `SpeechAnalyzer` 计划下版接入),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。** **音频始终在设备本地转写**iOS 26+ 使`SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。**
--- ---
## 特性 ## 特性
- 🎙 **按住说话**,Typeless 风格的圆形麦克风按钮 - 🎙 **按住说话**,Typeless 风格的圆形麦克风按钮
- 🧠 **端侧 ASR**iOS 18/19 `SFSpeechRecognizer`iOS 26+ `SpeechAnalyzer` + `DictationTranscriber` 计划下版接入 - 🧠 **端侧 ASR**iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`
- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 - ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表
- 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / 自建服务器 ……) - 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / 自建服务器 ……)
- 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM - 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM
@@ -40,14 +40,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自
### 环境要求 ### 环境要求
- macOS + **Xcode 16+**(推荐 Xcode 26 - macOS + **Xcode 16+**(推荐 Xcode 26
- iPhone 运行 **iOS 18.0+** - iPhone 运行 **iOS 26.0+**
- [XcodeGen](https://github.com/yonaskolb/XcodeGen)`brew install xcodegen` - [XcodeGen](https://github.com/yonaskolb/XcodeGen)`brew install xcodegen`
- 一个 OpenAI 兼容 API Key[OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一) - 一个 OpenAI 兼容 API Key[OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一)
### 编译与运行 ### 编译与运行
```bash ```bash
git clone https://github.com/<你的用户名>/OSGKeyboard.git git clone https://github.com/hkgood/OSGKeyboard.git
cd OSGKeyboard cd OSGKeyboard
xcodegen generate # 生成 OSGKeyboard.xcodeproj xcodegen generate # 生成 OSGKeyboard.xcodeproj
open OSGKeyboard.xcodeproj # 或命令行编译: open OSGKeyboard.xcodeproj # 或命令行编译:
@@ -79,7 +79,7 @@ OSGKeyboard/
│ ├── KeyboardViewController.swift # 主体类 │ ├── KeyboardViewController.swift # 主体类
│ ├── Services/ │ ├── Services/
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ ├── ASRService.swift # iOS 26 SpeechAnalyzer ASR
│ │ └── PolishingService.swift # LLM 调用(带超时) │ │ └── PolishingService.swift # LLM 调用(带超时)
│ └── Views/ # 录音按钮、波形、键盘主视图 │ └── Views/ # 录音按钮、波形、键盘主视图
├── OSGKeyboardShared/ # 主 App + 键盘共享 framework ├── OSGKeyboardShared/ # 主 App + 键盘共享 framework
@@ -131,8 +131,7 @@ LLMProvider(
- iOS 沙盒:键盘扩展 ~60 MB 内存上限,必须开完全访问 - iOS 沙盒:键盘扩展 ~60 MB 内存上限,必须开完全访问
- 密码框与部分 `WKWebView` 输入框不可用(iOS 限制) - 密码框与部分 `WKWebView` 输入框不可用(iOS 限制)
- iOS 18/19 用 `SFSpeechRecognizer` 做端侧 ASRiOS 26+ 的 `SpeechAnalyzer` 计划下版接入(更快、支持语种更多) - 仅支持 iOS 26+;更早的 iOS 版本不在支持范围内。
- v0.1.1 中 iOS 26+ 用户仍走 iOS 18 `SFSpeechRecognizer` 路径;iOS 26 `SpeechAnalyzer` 计划在 0.2.0 接入
--- ---
@@ -150,4 +149,4 @@ LLMProvider(
--- ---
**注意**`<你的用户名>` 替换成你的 GitHub 用户名 **说明**本项目发布仓库为 [`hkgood/OSGKeyboard`](https://github.com/hkgood/OSGKeyboard)
+2 -2
View File
@@ -5,8 +5,8 @@
name: OSGKeyboard name: OSGKeyboard
options: options:
bundleIdPrefix: com.osgkeyboard bundleIdPrefix: com.osgkeyboard
# Minimum OS: iOS 26. We dropped iOS 1825 support so the # Minimum OS: iOS 26. We dropped older iOS support so the
# SFSpeechRecognizer + AVAudioSession branching could be removed in # legacy speech + AVAudioSession branching could be removed in
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and # favour of iOS 26's `SpeechAnalyzer` (always on-device) and
# `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone # `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone
# only — no Mac Catalyst, no visionOS. # only — no Mac Catalyst, no visionOS.