chore: drop iOS 18–25 / non-iPhone support, require iOS 26

Two related cleanups the user asked for in one shot:

1. iPhone-only is now enforced at every target — Mac Catalyst and
   visionOS were never configured in `project.yml`, but the
   `OSGKeyboardShared` framework and the two test bundles were
   still defaulting to `TARGETED_DEVICE_FAMILY = "1,2"` (iPhone +
   iPad). All four targets now explicitly set `"1"`. SDK is
   `iphoneos` for everyone, no `xros` / `macosx`.

2. Deployment target bumped from iOS 18.0 to iOS 26.0 across the
   board (`project.yml` + the ext's per-target setting). With
   iOS 26 as the floor, the iOS 18–25 SFSpeechRecognizer path
   became dead code and several `#available` checks became
   always-true. Removed:

   - `AppleSpeechASR` (the entire SFSpeechRecognizer-based ASR
     backend) and the `#available(iOS 26.0, *)` factory branch.
     `ASRServiceFactory.make()` now returns `SpeechAnalyzerASR()`
     directly. SpeechAnalyzer is always fully on-device, which
     also made the `requiresOnDevice` flag meaningless.
   - `requiresOnDevice` from the `ASRService.transcribe` protocol
     signature, from `ProviderConfig`, `AppGroupStore`,
     `KeyboardState`, `AppGroupPersistor`, and the ext's
     `KeyboardViewController` (`state.requiresOnDevice`,
     `state.setRequiresOnDevice`, `persistRequiresOnDevice`).
   - `#available(iOS 17.0, *)` branch in
     `PreviewASRController.requestMicrophonePermission` and the
     ext's `PermissionManager.requestMicPermission` — both now
     just call the iOS 17+ `AVAudioApplication` API directly.
   - The `else` (iOS 18–25) branch in `SettingsView.asrEngineRow`
     — the on-device-only toggle is gone, the row is a static
     "SpeechAnalyzer active" badge. Same for the `else` branch
     in `EnginePickerSection.localSubtitle`.
   - `makeMicAuthHandler` (the iOS < 17 mic permission callback
     wrapper) from `PreviewASRController`.

   No `#available` / `@available` checks remain in the codebase
   except for the SpeechAnalyzer class itself (now unnecessary
   too, but kept for clarity — `AVAudioApplication` and
   `SpeechAnalyzer` are both iOS 17+ / iOS 26+ respectively,
   and the deployment target of 26 makes the explicit
   `@available` redundant; I left the SpeechAnalyzer class
   un-`@available` and removed the `@available(iOS 26.0, *)`
   decoration since it's no longer needed).

   The Keyboard ext's existing ASRService usage
   (`asr.transcribe(stream:locale:)`) is unchanged at the
   call-site level — just the third argument is gone.

3. Updated `info.plist` UISupportedInterfaceOrientations is
   already `[UIInterfaceOrientationPortrait]` only, which is
   correct for an iPhone-only app; no change needed.

Build: BUILD SUCCEEDED.
Tests: 22/22 pass.
Verified: `TARGETED_DEVICE_FAMILY = 1` on all four targets,
`SDKROOT = iphoneos` on all four.

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 20:24:26 +08:00
parent a803a27a88
commit 81581f0e5f
11 changed files with 85 additions and 291 deletions
+3 -5
View File
@@ -53,11 +53,9 @@ struct EnginePickerSection: View {
}
private var localSubtitle: String {
if #available(iOS 26, *) {
return "SpeechAnalyzer · 始终端侧,无需联网\nAlways on-device, no network"
} else {
return "端侧 ASR · 仅转录,无润色\nOn-device ASR, transcription only, no polish"
}
// 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"
}
private func engineOptionRow(id: String, icon: String, title: String, subtitle: String) -> some View {
+12 -27
View File
@@ -208,8 +208,7 @@ final class PreviewASRController: ObservableObject {
// 5. Wire up ASR.
let events = asr.transcribe(
stream: stream,
locale: locale,
requiresOnDevice: false
locale: locale
)
asrTask = Task { @MainActor [weak self] in
guard let self else { return }
@@ -242,10 +241,9 @@ final class PreviewASRController: ObservableObject {
// MARK: - Permission helpers (nonisolated)
//
// `SFSpeechRecognizer.requestAuthorization` and (iOS < 17)
// `AVAudioSession.requestRecordPermission` deliver their callbacks
// on a TCC reply queue, NOT the main queue. If we wrap those
// callbacks inline in `start(locale:)` which is `@MainActor`
// `SFSpeechRecognizer.requestAuthorization` delivers its callback
// on a TCC reply queue, NOT the main queue. If we wrap that
// callback inline in `start(locale:)` which is `@MainActor`
// Swift 6 strict concurrency infers the closure body as
// `@MainActor`, and the runtime crashes on
// `dispatch_assert_queue` in `_swift_task_checkIsolatedSwift` as
@@ -275,19 +273,14 @@ final class PreviewASRController: ObservableObject {
// main actor before resuming.
private nonisolated static func requestMicrophonePermission() async -> Bool {
if #available(iOS 17.0, *) {
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined: return await AVAudioApplication.requestRecordPermission()
@unknown default: return false
}
} else {
return await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
AVAudioSession.sharedInstance().requestRecordPermission(
Self.makeMicAuthHandler(continuation: cont)
)
}
// iOS 17+ API; the iOS < 17 fallback (`AVAudioSession.recordPermission`
// + `requestRecordPermission` callback) is gone now that the
// deployment target is iOS 26.
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined: return await AVAudioApplication.requestRecordPermission()
@unknown default: return false
}
}
@@ -307,14 +300,6 @@ final class PreviewASRController: ObservableObject {
}
}
private nonisolated static func makeMicAuthHandler(
continuation: CheckedContinuation<Bool, Never>
) -> @Sendable (Bool) -> Void {
return { granted in
continuation.resume(returning: granted)
}
}
// MARK: - Audio tap (nonisolated, runs on AVAudioEngine render thread)
//
// `AVAudioNode.installTap`'s callback fires on the audio engine's
+22 -40
View File
@@ -143,49 +143,31 @@ struct SettingsView: View {
}
}
/// ASR engine row adapts to OS version:
/// iOS 26+: shows a badge indicating SpeechAnalyzer is active (always on-device).
/// iOS 1825: shows a toggle to force on-device recognition only.
@ViewBuilder
/// 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 1825
/// `SFSpeechRecognizer` cloud-fallback control) is gone.
private var asrEngineRow: some View {
if #available(iOS 26, *) {
HStack(spacing: Spacing.sm) {
Text("settings.engineRow.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.success)
Text("settings.engineBadge.ios26")
.font(TypeStyle.caption)
.foregroundStyle(palette.success)
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(palette.success.opacity(0.12), in: Capsule())
HStack(spacing: Spacing.sm) {
Text("settings.engineRow.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer()
HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(palette.success)
Text("settings.engineBadge.ios26")
.font(TypeStyle.caption)
.foregroundStyle(palette.success)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
} else {
Toggle(isOn: Binding(
get: { config.requiresOnDevice },
set: { config.requiresOnDevice = $0 }
)) {
VStack(alignment: .leading, spacing: 2) {
Text("settings.onDeviceOnly.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Text("禁用云端回退,识别失败时会报错而非联网 · Disable cloud fallback, fail locally instead of going online")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(palette.success.opacity(0.12), in: Capsule())
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
/// Falls back to a static list while SFSpeechRecognizer locales are loading.