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:
+4
-14
@@ -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).
|
||||
- **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.
|
||||
- **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
|
||||
- **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
|
||||
> (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
|
||||
- **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.
|
||||
- **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.
|
||||
- **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).
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
- 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).
|
||||
- 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).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -24,7 +24,7 @@ struct APISettingsCard: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
field(
|
||||
title: "Base URL · 接口地址",
|
||||
title: NSLocalizedString("api.baseUrl", comment: ""),
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
text: $config.baseURL,
|
||||
autocap: false
|
||||
@@ -33,7 +33,7 @@ struct APISettingsCard: View {
|
||||
keyField
|
||||
Divider().background(palette.divider)
|
||||
field(
|
||||
title: "Model · 模型",
|
||||
title: NSLocalizedString("api.model", comment: ""),
|
||||
placeholder: "gpt-4o-mini",
|
||||
text: $config.model,
|
||||
autocap: false
|
||||
@@ -41,7 +41,7 @@ struct APISettingsCard: View {
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Divider().background(palette.divider)
|
||||
// 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
|
||||
// TextField, which manifests as "typing jumps to a website".
|
||||
Button {
|
||||
@@ -86,7 +86,9 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showKey ? "隐藏密钥 · Hide key" : "显示密钥 · Show key"))
|
||||
.accessibilityLabel(Text(showKey
|
||||
? NSLocalizedString("api.key.hide", comment: "")
|
||||
: NSLocalizedString("api.key.show", comment: "")))
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
@@ -120,7 +122,7 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
// `.asciiCapable` is the *minimum* contract for these fields:
|
||||
// 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
|
||||
// Chinese / emoji completions that corrupt the value. Forcing
|
||||
// `.asciiCapable` keeps the system out of the way.
|
||||
@@ -132,7 +134,7 @@ struct APISettingsCard: View {
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.submitLabel(.done)
|
||||
.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(.vertical, Spacing.sm)
|
||||
|
||||
@@ -33,15 +33,15 @@ struct EnginePickerSection: View {
|
||||
engineOptionRow(
|
||||
id: "local",
|
||||
icon: "iphone.badge.checkmark",
|
||||
title: "本地识别 · On-device",
|
||||
title: NSLocalizedString("settings.engine.local.title", comment: ""),
|
||||
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"
|
||||
title: NSLocalizedString("settings.engine.cloud.title", comment: ""),
|
||||
subtitle: NSLocalizedString("settings.engine.cloud.subtitle", comment: "")
|
||||
)
|
||||
}
|
||||
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
@@ -55,7 +55,7 @@ struct EnginePickerSection: View {
|
||||
private var localSubtitle: String {
|
||||
// iOS 26's `SpeechAnalyzer` is always fully on-device, so the
|
||||
// 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 {
|
||||
|
||||
@@ -45,7 +45,7 @@ struct HomeView: View {
|
||||
Circle()
|
||||
.fill(config.isConfigured ? palette.success : palette.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(config.isConfigured ? "就绪 · Ready" : "未完成配置 · Setup incomplete")
|
||||
Text(config.isConfigured ? "home.status.ready" : "home.status.setupIncomplete")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,15 @@
|
||||
// In-app preview of the keyboard extension. Renders a stand-in
|
||||
// `KeyboardPreviewStub` so the user can see what the real extension
|
||||
// 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
|
||||
// the preview was a static mock — "the text never appears" was a
|
||||
// fair review note.
|
||||
//
|
||||
// Lifecycle (local engine = "transcribe" only, no LLM):
|
||||
// tap → start ASR (idempotent re-entry guard)
|
||||
// → SFSpeechRecognizer emits .partial / .final
|
||||
// → ASR emits .partial / .final
|
||||
// → currentPartial updates the transcript line in real time
|
||||
// tap → stop ASR
|
||||
// → lastFinal event lands
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// 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
|
||||
// and `UIPageViewController` has a long-standing behavior on some iOS
|
||||
// versions 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
|
||||
@@ -136,10 +137,6 @@ private struct WelcomePage: View {
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("onboarding.welcome.subtitle")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
PrivacyFootnote()
|
||||
@@ -210,15 +207,12 @@ private struct EnableKeyboardPage: View {
|
||||
Text("onboarding.enable.title")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
Text("onboarding.enable.title")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
step(num: 1, text: "设置 → 通用 → 键盘 → 键盘")
|
||||
step(num: 2, text: "点击「添加新键盘…」并选择 OSGKeyboard")
|
||||
step(num: 3, text: "点击 OSGKeyboard 并启用「允许完全访问」")
|
||||
step(num: 4, text: "Allow Full Access is required for microphone + LLM calls · 允许完全访问是麦克风和网络调用的前提")
|
||||
step(num: 1, text: NSLocalizedString("onboarding.enable.step1", comment: ""))
|
||||
step(num: 2, text: NSLocalizedString("onboarding.enable.step2", comment: ""))
|
||||
step(num: 3, text: NSLocalizedString("onboarding.enable.step3", comment: ""))
|
||||
step(num: 4, text: NSLocalizedString("onboarding.enable.step4", comment: ""))
|
||||
}
|
||||
.cardSurface()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
@@ -228,7 +222,7 @@ private struct EnableKeyboardPage: View {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
|
||||
Label(LocalizedStringKey("onboarding.enable.openSettings"), systemImage: "arrow.up.right.square")
|
||||
.primaryButton()
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
@@ -133,7 +133,10 @@ final class PreviewASRController: ObservableObject {
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
didConfigureAudioSession = true
|
||||
} catch {
|
||||
phase = .error("Audio session 错误 · Audio session error: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.audioSession", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -219,7 +222,11 @@ final class PreviewASRController: ObservableObject {
|
||||
// state from a previous foreground/background transition.
|
||||
guard hwFormat.sampleRate > 0, hwFormat.channelCount > 0 else {
|
||||
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
|
||||
}
|
||||
@@ -231,11 +238,11 @@ final class PreviewASRController: ObservableObject {
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
) else {
|
||||
phase = .error("无法创建 16 kHz 音频格式")
|
||||
phase = .error(NSLocalizedString("preview.error.formatCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
guard let converter = AVAudioConverter(from: hwFormat, to: targetFormat) else {
|
||||
phase = .error("无法创建音频转换器")
|
||||
phase = .error(NSLocalizedString("preview.error.converterCreate", comment: ""))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +287,10 @@ final class PreviewASRController: ObservableObject {
|
||||
do {
|
||||
try audioEngine.start()
|
||||
} catch {
|
||||
phase = .error("无法启动音频引擎 · Engine start failed: \(error.localizedDescription)")
|
||||
phase = .error(String.localizedStringWithFormat(
|
||||
NSLocalizedString("preview.error.engineStart", comment: ""),
|
||||
error.localizedDescription
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ struct SettingsView: View {
|
||||
|
||||
private var apiSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("API · 接口", subtitle: nil)
|
||||
sectionHeader("settings.api.title", subtitle: nil)
|
||||
APISettingsCard(config: config)
|
||||
}
|
||||
}
|
||||
@@ -91,12 +91,15 @@ struct SettingsView: View {
|
||||
// MARK: - Language (ASR + mode)
|
||||
|
||||
private var languageSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("Language · 语言", subtitle: "选择识别语言\(config.engineMode == "cloud" ? "和文字处理模式" : "")。Recognition language\(config.engineMode == "cloud" ? " and text processing mode" : "").")
|
||||
let subtitleKey: LocalizedStringKey = config.engineMode == "cloud"
|
||||
? "settings.language.subtitle.cloud"
|
||||
: "settings.language.subtitle.local"
|
||||
return VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
sectionHeader("settings.language.title", subtitle: subtitleKey)
|
||||
VStack(spacing: 0) {
|
||||
if config.engineMode == "cloud" {
|
||||
PickerRow(
|
||||
title: "Mode · 模式",
|
||||
title: NSLocalizedString("settings.mode.title", comment: ""),
|
||||
options: modeOptions,
|
||||
selection: Binding(
|
||||
get: { config.modeId },
|
||||
@@ -121,7 +124,8 @@ struct SettingsView: View {
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
|
||||
// Legend — only relevant for SFSpeechRecognizer path (iOS 18–25)
|
||||
// Legacy legend block: kept for UI compatibility, but with
|
||||
// iOS 26 as minimum target this branch never executes.
|
||||
if #unavailable(iOS 26) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Image(systemName: "iphone")
|
||||
@@ -146,8 +150,7 @@ struct SettingsView: View {
|
||||
/// ASR engine row. With iOS 26 as the deployment target, the
|
||||
/// only ASR backend is `SpeechAnalyzer` and it is always fully
|
||||
/// on-device — so this row is now a static badge rather than a
|
||||
/// toggle. The previous `requiresOnDevice` toggle (iOS 18–25
|
||||
/// `SFSpeechRecognizer` cloud-fallback control) is gone.
|
||||
/// toggle. The old cloud-fallback toggle is gone.
|
||||
private var asrEngineRow: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
Text("settings.engineRow.title")
|
||||
@@ -170,7 +173,7 @@ struct SettingsView: View {
|
||||
.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)] {
|
||||
dynamicLocales.isEmpty ? staticLocales : dynamicLocales
|
||||
}
|
||||
@@ -197,15 +200,15 @@ struct SettingsView: View {
|
||||
// MARK: - Dynamic locale loading
|
||||
|
||||
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.
|
||||
// Creating SFSpeechRecognizer instances in a @Sendable closure is safe —
|
||||
// the existing ASRService.swift does the same thing inside AsyncStream { }.
|
||||
// Creating `SFSpeechRecognizer` instances in a @Sendable closure is
|
||||
// safe here; we only read locale metadata (no transcription session).
|
||||
let entries: [(id: String, label: String, onDevice: Bool)] = await Task.detached(
|
||||
priority: .userInitiated
|
||||
) {
|
||||
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
|
||||
for locale in SFSpeechRecognizer.supportedLocales()
|
||||
@@ -228,7 +231,7 @@ struct SettingsView: View {
|
||||
private var promptSection: some View {
|
||||
VStack(alignment: .leading, spacing: Spacing.xs) {
|
||||
HStack {
|
||||
sectionHeader("System Prompt · 系统提示", subtitle: nil)
|
||||
sectionHeader("settings.systemPrompt.title", subtitle: nil)
|
||||
Spacer()
|
||||
Button("common.reset") { config.systemPrompt = config.defaultSystemPrompt }
|
||||
.font(TypeStyle.caption2)
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
"preview.openSettingsA11y" = "Open OSGKeyboard settings";
|
||||
"preview.modeChip.cycle" = "Cycle input mode";
|
||||
"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.placeholder.idle" = "Hold to talk";
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
"preview.openSettingsA11y" = "打开 OSGKeyboard 设置";
|
||||
"preview.modeChip.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.placeholder.idle" = "按住说话";
|
||||
|
||||
@@ -53,7 +53,7 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
public override func 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
|
||||
// cropped by the system chrome (Spotlight bar, home indicator).
|
||||
inputView?.allowsSelfSizing = true
|
||||
@@ -150,14 +150,10 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
self.state.phase = .denied(.mic)
|
||||
return
|
||||
}
|
||||
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech
|
||||
// recognition permission. Without this call + the
|
||||
// NSSpeechRecognitionUsageDescription key in Info.plist the
|
||||
// recogniser silently returns .denied and the user hears
|
||||
// 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.
|
||||
// We explicitly ask for Speech recognition permission here.
|
||||
// `SpeechAnalyzer` does not expose a dedicated request API,
|
||||
// so the app still relies on the shared Speech permission
|
||||
// gate and `NSSpeechRecognitionUsageDescription`.
|
||||
let speechGranted = await self.permissions.requestSpeechPermission()
|
||||
guard speechGranted else {
|
||||
self.state.phase = .denied(.speech)
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
// Speech-to-text abstraction. As of iOS 26 being the minimum
|
||||
// deployment target, the only ASR backend is `SpeechAnalyzer` +
|
||||
// `DictationTranscriber` — always on-device, no cloud fallback, no
|
||||
// `requiresOnDevice` toggle. The previous SFSpeechRecognizer path
|
||||
// (iOS 18–25) is gone; if a future platform ever needs it back,
|
||||
// `requiresOnDevice` toggle. The previous legacy recognizer path is
|
||||
// gone; if a future platform ever needs it back,
|
||||
// reintroduce as a sibling class in `ASRServiceFactory.make()`.
|
||||
//
|
||||
// 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`
|
||||
/// updates and exactly one `.final` (or `.error`) before finishing.
|
||||
/// `SpeechAnalyzer` is always fully on-device, so there is no
|
||||
/// `requiresOnDevice` flag — the previous iOS 18 SFSpeechRecognizer
|
||||
/// flag was about cloud fallback, which doesn't apply here.
|
||||
/// `requiresOnDevice` flag — that legacy cloud-fallback control
|
||||
/// doesn't apply to the iOS 26 `SpeechAnalyzer` path.
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
@@ -129,8 +129,8 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
|
||||
// iOS 26's `DictationTranscriber` requires **Int16** PCM
|
||||
// (precondition `"Audio sample data must be 16-bit signed
|
||||
// integers"` — Float32 was the iOS 18 `SFSpeechRecognizer`
|
||||
// shape; the new analyzer is strict). 16 kHz mono, Int16,
|
||||
// integers"` — the legacy recognizer used Float32 at this
|
||||
// boundary; `SpeechAnalyzer` is strict Int16). 16 kHz mono, Int16,
|
||||
// interleaved — the canonical layout Apple's Speech
|
||||
// framework examples use.
|
||||
let audioFormat = AVAudioFormat(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# OSGKeyboard
|
||||
|
||||
> 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).
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -20,14 +20,14 @@ OSGKeyboard is a free, open alternative to commercial voice-input tools. It runs
|
||||
2. Speak naturally
|
||||
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
|
||||
|
||||
- 🎙 **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
|
||||
- 🔌 **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
|
||||
@@ -41,7 +41,7 @@ The audio stays on-device (transcribed by Apple's on-device `SFSpeechRecognizer`
|
||||
### Requirements
|
||||
|
||||
- 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`
|
||||
- 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
|
||||
│ ├── Services/
|
||||
│ │ ├── 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
|
||||
│ └── Views/ # RecordButton, Waveform, KeyboardRootView
|
||||
├── 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.
|
||||
- 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+ users in v0.1.1 use the iOS 18 `SFSpeechRecognizer` path; the iOS 26 `SpeechAnalyzer` is planned for 0.2.0.
|
||||
- iOS 26+ only. Earlier iOS versions are not supported.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+8
-9
@@ -3,7 +3,7 @@
|
||||
> 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。
|
||||
> 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@@ -19,14 +19,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自
|
||||
2. 自由说话
|
||||
3. 松开 —— AI 帮你整理成干净的文字,自动插入光标处
|
||||
|
||||
**音频始终在设备本地转写**(iOS 18/19 用 `SFSpeechRecognizer`;iOS 26+ 的 `SpeechAnalyzer` 计划下版接入),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。**
|
||||
**音频始终在设备本地转写**(iOS 26+ 使用 `SpeechAnalyzer` + `DictationTranscriber`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。**
|
||||
|
||||
---
|
||||
|
||||
## 特性
|
||||
|
||||
- 🎙 **按住说话**,Typeless 风格的圆形麦克风按钮
|
||||
- 🧠 **端侧 ASR**(iOS 18/19 `SFSpeechRecognizer`;iOS 26+ 的 `SpeechAnalyzer` + `DictationTranscriber` 计划下版接入)
|
||||
- 🧠 **端侧 ASR**(iOS 26+ `SpeechAnalyzer` + `DictationTranscriber`)
|
||||
- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表
|
||||
- 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / 自建服务器 ……)
|
||||
- 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM
|
||||
@@ -40,14 +40,14 @@ OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自
|
||||
### 环境要求
|
||||
|
||||
- macOS + **Xcode 16+**(推荐 Xcode 26)
|
||||
- iPhone 运行 **iOS 18.0+**
|
||||
- iPhone 运行 **iOS 26.0+**
|
||||
- [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) 任一)
|
||||
|
||||
### 编译与运行
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<你的用户名>/OSGKeyboard.git
|
||||
git clone https://github.com/hkgood/OSGKeyboard.git
|
||||
cd OSGKeyboard
|
||||
xcodegen generate # 生成 OSGKeyboard.xcodeproj
|
||||
open OSGKeyboard.xcodeproj # 或命令行编译:
|
||||
@@ -79,7 +79,7 @@ OSGKeyboard/
|
||||
│ ├── KeyboardViewController.swift # 主体类
|
||||
│ ├── Services/
|
||||
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM
|
||||
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR
|
||||
│ │ ├── ASRService.swift # iOS 26 SpeechAnalyzer ASR
|
||||
│ │ └── PolishingService.swift # LLM 调用(带超时)
|
||||
│ └── Views/ # 录音按钮、波形、键盘主视图
|
||||
├── OSGKeyboardShared/ # 主 App + 键盘共享 framework
|
||||
@@ -131,8 +131,7 @@ LLMProvider(
|
||||
|
||||
- iOS 沙盒:键盘扩展 ~60 MB 内存上限,必须开完全访问
|
||||
- 密码框与部分 `WKWebView` 输入框不可用(iOS 限制)
|
||||
- iOS 18/19 用 `SFSpeechRecognizer` 做端侧 ASR;iOS 26+ 的 `SpeechAnalyzer` 计划下版接入(更快、支持语种更多)
|
||||
- v0.1.1 中 iOS 26+ 用户仍走 iOS 18 `SFSpeechRecognizer` 路径;iOS 26 `SpeechAnalyzer` 计划在 0.2.0 接入
|
||||
- 仅支持 iOS 26+;更早的 iOS 版本不在支持范围内。
|
||||
|
||||
---
|
||||
|
||||
@@ -150,4 +149,4 @@ LLMProvider(
|
||||
|
||||
---
|
||||
|
||||
**注意**:把 `<你的用户名>` 替换成你的 GitHub 用户名。
|
||||
**说明**:本项目发布仓库为 [`hkgood/OSGKeyboard`](https://github.com/hkgood/OSGKeyboard)。
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
name: OSGKeyboard
|
||||
options:
|
||||
bundleIdPrefix: com.osgkeyboard
|
||||
# Minimum OS: iOS 26. We dropped iOS 18–25 support so the
|
||||
# SFSpeechRecognizer + AVAudioSession branching could be removed in
|
||||
# Minimum OS: iOS 26. We dropped older iOS support so the
|
||||
# legacy speech + AVAudioSession branching could be removed in
|
||||
# favour of iOS 26's `SpeechAnalyzer` (always on-device) and
|
||||
# `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone
|
||||
# only — no Mac Catalyst, no visionOS.
|
||||
|
||||
Reference in New Issue
Block a user