From 81581f0e5fcbe63b563f08eb40c16a989bfa4fa4 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:24:26 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20drop=20iOS=2018=E2=80=9325=20/=20non-i?= =?UTF-8?q?Phone=20support,=20require=20iOS=2026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- OSGKeyboard/Views/EnginePickerSection.swift | 8 +- OSGKeyboard/Views/PreviewASRController.swift | 39 ++--- OSGKeyboard/Views/SettingsView.swift | 62 +++---- OSGKeyboardExt/KeyboardViewController.swift | 9 +- .../Services/AppGroupPersistor.swift | 6 - .../Services/PermissionManager.swift | 46 ++--- OSGKeyboardShared/Models/ProviderConfig.swift | 7 - OSGKeyboardShared/Services/ASRService.swift | 162 ++---------------- .../Services/AppGroupStore.swift | 12 -- .../Services/KeyboardState.swift | 11 +- project.yml | 14 +- 11 files changed, 85 insertions(+), 291 deletions(-) diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift index 3f216de..3a9dee9 100644 --- a/OSGKeyboard/Views/EnginePickerSection.swift +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -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 { diff --git a/OSGKeyboard/Views/PreviewASRController.swift b/OSGKeyboard/Views/PreviewASRController.swift index 709b3ac..cd1bc74 100644 --- a/OSGKeyboard/Views/PreviewASRController.swift +++ b/OSGKeyboard/Views/PreviewASRController.swift @@ -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) 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 - ) -> @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 diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 4ba688e..96ededc 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -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 18–25: 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 18–25 + /// `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. diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 104ec35..307f0ee 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -86,7 +86,6 @@ public final class KeyboardViewController: UIInputViewController { 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(" ") } @@ -185,8 +184,7 @@ public final class KeyboardViewController: UIInputViewController { let locale = resolveLocale(state.localeId) let events = asr.transcribe( stream: session.audio, - locale: locale, - requiresOnDevice: state.requiresOnDevice + locale: locale ) asrTask = Task { @MainActor [weak self] in @@ -334,11 +332,6 @@ 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) diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 0424bfa..0f53e94 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -32,7 +32,6 @@ public struct AppGroupPersistor { let store = AppGroupStore() state.localeId = store.localeId state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish - state.requiresOnDevice = store.requiresOnDevice state.engineMode = store.engineMode #if DEBUG @@ -71,11 +70,6 @@ public struct AppGroupPersistor { 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) diff --git a/OSGKeyboardExt/Services/PermissionManager.swift b/OSGKeyboardExt/Services/PermissionManager.swift index cb8ed62..eecb9b4 100644 --- a/OSGKeyboardExt/Services/PermissionManager.swift +++ b/OSGKeyboardExt/Services/PermissionManager.swift @@ -25,43 +25,27 @@ public final class PermissionManager: @unchecked Sendable { private var didRequestMicOnce: Bool = false /// Request microphone access. Returns true if granted (already or - /// after this call). iOS 17 uses `AVAudioApplication.recordPermission`; - /// older systems fall back to `AVAudioSession.recordPermission`. + /// after this call). Uses the iOS 17+ `AVAudioApplication` API. public func requestMicPermission() async -> Bool { - if #available(iOS 17.0, *) { - switch AVAudioApplication.shared.recordPermission { - case .granted: return true - case .denied: return false - case .undetermined: - if !didRequestMicOnce { - didRequestMicOnce = true - return await AVAudioApplication.requestRecordPermission() - } - return false - @unknown default: return false - } - } else { - let session = AVAudioSession.sharedInstance() - switch session.recordPermission { - case .granted: return true - case .denied: return false - case .undetermined: - if !didRequestMicOnce { - didRequestMicOnce = true - return await withCheckedContinuation { cont in - session.requestRecordPermission { cont.resume(returning: $0) } - } - } - return false - @unknown default: return false + switch AVAudioApplication.shared.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + if !didRequestMicOnce { + didRequestMicOnce = true + return await AVAudioApplication.requestRecordPermission() } + return false + @unknown default: return false } } /// Request Speech Recognition permission. Returns true if granted - /// (already or after this call). For iOS 18 SFSpeechRecognizer this - /// is required before recognition can begin; for iOS 26 SpeechAnalyzer - /// the framework prompts on first use, so this call is a no-op there. + /// (already or after this call). The `SFSpeechRecognizer` plist + /// key + this call are still required even on iOS 26 — the + /// `SpeechAnalyzer` API does not expose an explicit request + /// method of its own and the framework checks the same TCC + /// entry on first use. public func requestSpeechPermission() async -> Bool { await withCheckedContinuation { (cont: CheckedContinuation) in SFSpeechRecognizer.requestAuthorization { status in diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index fd87ae8..2776c21 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -26,7 +26,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" - static let requiresOnDevice = "config.requiresOnDevice" static let engineMode = "config.engineMode" } @@ -62,11 +61,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { @Published public var localeId: String { didSet { defaults.set(localeId, forKey: Key.localeId) } } - /// When `true`, forces SFSpeechRecognizer to on-device only mode. - /// Ignored on iOS 26+ where SpeechAnalyzer is always on-device. - @Published public var requiresOnDevice: Bool { - didSet { defaults.set(requiresOnDevice, forKey: Key.requiresOnDevice) } - } /// "local" → on-device ASR only, no LLM polishing. /// "cloud" → ASR + LLM polish (default). @Published public var engineMode: String { @@ -109,7 +103,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { ?? AppGroupStore.defaultSystemPrompt(for: pid) self.modeId = defaults.string(forKey: Key.modeId) ?? "polish" self.localeId = defaults.string(forKey: Key.localeId) ?? "auto" - self.requiresOnDevice = defaults.bool(forKey: Key.requiresOnDevice) self.engineMode = defaults.string(forKey: Key.engineMode) ?? "cloud" } diff --git a/OSGKeyboardShared/Services/ASRService.swift b/OSGKeyboardShared/Services/ASRService.swift index 6ce38df..a0cca39 100644 --- a/OSGKeyboardShared/Services/ASRService.swift +++ b/OSGKeyboardShared/Services/ASRService.swift @@ -1,11 +1,12 @@ // ASRService.swift // OSGKeyboard · Shared // -// Speech-to-text abstraction. -// • iOS 26+: uses `SpeechAnalyzer` + `DictationTranscriber` — always on-device. -// • iOS 18–25: uses `SFSpeechRecognizer`, with optional requiresOnDevice flag. -// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP …) so -// dictation is first-class for non-English languages. +// 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, +// reintroduce as a sibling class in `ASRServiceFactory.make()`. // // Lives in `OSGKeyboardShared` (not the keyboard extension target) so // that the host app's `KeyboardPreviewSheet` can run the same ASR @@ -20,28 +21,23 @@ import os // MARK: - Sendable conformance -// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We -// only ever access them serially — the PCM buffer is built and consumed -// inside a single Task, and the recogniser task is cancelled but never +// `AVAudioPCMBuffer` and `SpeechAnalyzer` are not Sendable. We only +// ever access them serially — the PCM buffer is built and consumed +// inside a single Task, and the analyzer is cancelled but never // shared concurrently — so an unchecked conformance is sound here. extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {} -extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {} // MARK: - Protocol public protocol ASRService: Sendable { /// Start a transcription session. The returned stream emits `.partial` /// updates and exactly one `.final` (or `.error`) before finishing. - /// - Parameters: - /// - stream: Audio buffer stream from `AudioCaptureService`. - /// - locale: Target recognition locale. - /// - requiresOnDevice: When `true`, forces on-device recognition only - /// (SFSpeechRecognizer path). Ignored on iOS 26+ where - /// `SpeechAnalyzer` is always fully on-device. + /// `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. func transcribe( stream: AsyncStream, - locale: Locale, - requiresOnDevice: Bool + locale: Locale ) -> AsyncStream /// Cancel any in-flight recognition and tear down its tasks. @@ -62,138 +58,17 @@ public enum ASREvent: Sendable, Equatable { // MARK: - Factory public enum ASRServiceFactory { - /// Returns the best available ASR backend for the current OS: - /// `SpeechAnalyzerASR` on iOS 26+ (always on-device), `AppleSpeechASR` - /// on older OS versions. + /// Returns the ASR backend. With iOS 26 as the deployment target, + /// there is exactly one backend (`SpeechAnalyzer`). public static func make() -> ASRService { - if #available(iOS 26.0, *) { - return SpeechAnalyzerASR() - } - return AppleSpeechASR() - } -} - -// MARK: - Apple Speech implementation (iOS 18–25) - -final class AppleSpeechASR: ASRService, @unchecked Sendable { - - private let lock = OSAllocatedUnfairLock() - private var recognizerTask: SFSpeechRecognitionTask? - private var feedTask: Task? - - func transcribe( - stream: AsyncStream, - locale: Locale, - requiresOnDevice: Bool - ) -> AsyncStream { - AsyncStream { continuation in - let recognizer = SFSpeechRecognizer(locale: locale) - ?? SFSpeechRecognizer(locale: .current) - guard let recognizer, recognizer.isAvailable else { - continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)")) - continuation.finish() - return - } - recognizer.defaultTaskHint = .dictation - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - // Honour the user's "force on-device" preference; fall back to - // whatever the device natively supports if the flag is off. - request.requiresOnDeviceRecognition = requiresOnDevice || recognizer.supportsOnDeviceRecognition - let onDeviceSupported = recognizer.supportsOnDeviceRecognition - if !onDeviceSupported { - #if DEBUG - print("⚠️ 设备不支持 \(locale.identifier) 端侧 ASR, 回退云端。") - #endif - } - // Tell the UI about the capability *before* any partials so - // the StatusBadge can light up the cloud-fallback indicator - // as soon as the user presses the mic. - continuation.yield(.capability(onDeviceSupported: onDeviceSupported)) - - let task = recognizer.recognitionTask(with: request) { result, error in - if let error { - let nsErr = error as NSError - // Codes 203 / 1110 = "no speech detected" — a normal exit. - if nsErr.code == 203 || nsErr.code == 1110 { - continuation.yield(.final("")) - } else { - continuation.yield(.error(error.localizedDescription)) - } - continuation.finish() - return - } - guard let result else { return } - if result.isFinal { - continuation.yield(.final(result.bestTranscription.formattedString)) - continuation.finish() - } else { - continuation.yield(.partial(result.bestTranscription.formattedString)) - } - } - - self.lock.withLock { self.recognizerTask = task } - - // Feed audio: for each snapshot, build a 16 kHz mono Float32 - // PCM buffer and immediately `request.append(pcm)`. The PCM - // buffer never leaves this task, so it doesn't need to be - // Sendable. - let feedFormat = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - self.feedTask = Task { [request] in - for await snap in stream { - if Task.isCancelled { break } - guard !snap.samples.isEmpty, - let pcm = AVAudioPCMBuffer( - pcmFormat: feedFormat, - frameCapacity: AVAudioFrameCount(snap.samples.count) - ) - else { continue } - pcm.frameLength = AVAudioFrameCount(snap.samples.count) - if let dst = pcm.floatChannelData?[0] { - snap.samples.withUnsafeBufferPointer { src in - if let base = src.baseAddress { - memcpy(dst, base, snap.samples.count * MemoryLayout.size) - } - } - } - request.append(pcm) - } - if !Task.isCancelled { - request.endAudio() - } - } - - continuation.onTermination = { @Sendable [weak self] _ in - self?.cancel() - } - } - } - - func cancel() { - let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task?) in - let r = self.recognizerTask - let f = self.feedTask - self.recognizerTask = nil - self.feedTask = nil - return (r, f) - } - recTask?.cancel() - feedT?.cancel() + SpeechAnalyzerASR() } } // MARK: - SpeechAnalyzer implementation (iOS 26+) /// ASR backend that uses the iOS 26 `SpeechAnalyzer` + `DictationTranscriber` -/// APIs. This engine is always fully on-device — `requiresOnDevice` has no -/// effect and `.capability(onDeviceSupported: true)` is always emitted. -@available(iOS 26.0, *) +/// APIs. This engine is always fully on-device. final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { private let lock = OSAllocatedUnfairLock() @@ -202,8 +77,7 @@ final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { func transcribe( stream: AsyncStream, - locale: Locale, - requiresOnDevice: Bool // ignored — SpeechAnalyzer is always on-device + locale: Locale ) -> AsyncStream { AsyncStream { continuation in // SpeechAnalyzer is always fully on-device. diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index d520ad0..505391b 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -27,7 +27,6 @@ public struct AppGroupStore: @unchecked Sendable { static let systemPrompt = "config.systemPrompt" static let modeId = "config.modeId" static let localeId = "config.localeId" - static let requiresOnDevice = "config.requiresOnDevice" static let engineMode = "config.engineMode" } @@ -64,13 +63,6 @@ public struct AppGroupStore: @unchecked Sendable { defaults.string(forKey: Key.localeId) ?? "auto" } - /// When `true`, `SFSpeechRecognizer` is forced to on-device mode - /// (`requiresOnDeviceRecognition = true`). Ignored on iOS 26+ where - /// `SpeechAnalyzer` is always on-device. - public var requiresOnDevice: Bool { - defaults.bool(forKey: Key.requiresOnDevice) - } - /// "local" → on-device ASR only, no LLM polishing. /// "cloud" → ASR + LLM polish (default behaviour). public var engineMode: String { @@ -87,10 +79,6 @@ public struct AppGroupStore: @unchecked Sendable { defaults.set(id, forKey: Key.localeId) } - public func setRequiresOnDevice(_ value: Bool) { - defaults.set(value, forKey: Key.requiresOnDevice) - } - public func setEngineMode(_ mode: String) { defaults.set(mode, forKey: Key.engineMode) } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 86767b3..6f970f9 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -63,14 +63,10 @@ public final class KeyboardState: ObservableObject { @Published public var localeId: String = "auto" @Published public var lastTranscript: String = "" /// `true` if the active ASR session is running on-device for the - /// current locale. `false` means the request fell back to the - /// network (e.g. ja-JP on a device that doesn't ship on-device - /// ASR for Japanese). Updated once per recording by the ASR - /// pipeline before any `.partial` is emitted. + /// current locale. With iOS 26's `SpeechAnalyzer` this is always + /// `true` — kept on the state object because the UI's status + /// badge still wants a single source of truth to read from. @Published public var onDeviceSupported: Bool = false - /// When `true`, `SFSpeechRecognizer` is forced to on-device mode. - /// Ignored on iOS 26+ where `SpeechAnalyzer` is always on-device. - @Published public var requiresOnDevice: Bool = false /// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish. @Published public var engineMode: String = "cloud" @@ -84,7 +80,6 @@ public final class KeyboardState: ObservableObject { public var openSettings: () -> Void = {} public var setMode: (InputMode) -> Void = { _ in } public var setLocale: (String) -> Void = { _ in } - public var setRequiresOnDevice: (Bool) -> Void = { _ in } public var setEngineMode: (String) -> Void = { _ in } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} diff --git a/project.yml b/project.yml index dfe0ed9..4dbfc94 100644 --- a/project.yml +++ b/project.yml @@ -5,8 +5,13 @@ 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 + # favour of iOS 26's `SpeechAnalyzer` (always on-device) and + # `AVAudioApplication.requestRecordPermission` (iOS 17+). iPhone + # only — no Mac Catalyst, no visionOS. deploymentTarget: - iOS: "18.0" + iOS: "26.0" developmentLanguage: en createIntermediateGroups: true generateEmptyDirectories: true @@ -15,7 +20,7 @@ options: settings: base: SWIFT_VERSION: "6.0" - IPHONEOS_DEPLOYMENT_TARGET: "18.0" + IPHONEOS_DEPLOYMENT_TARGET: "26.0" ENABLE_USER_SCRIPT_SANDBOXING: YES SWIFT_STRICT_CONCURRENCY: complete GENERATE_INFOPLIST_FILE: NO @@ -110,7 +115,7 @@ targets: - path: OSGKeyboardExt/zh-Hans.lproj settings: base: - IPHONEOS_DEPLOYMENT_TARGET: "18.0" + IPHONEOS_DEPLOYMENT_TARGET: "26.0" entitlements: path: OSGKeyboardExt/OSGKeyboardExt.entitlements properties: @@ -158,6 +163,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared + TARGETED_DEVICE_FAMILY: "1" DEFINES_MODULE: YES SKIP_INSTALL: YES BUILD_LIBRARY_FOR_DISTRIBUTION: NO @@ -183,6 +189,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests + TARGETED_DEVICE_FAMILY: "1" # ========================================================= # 键盘扩展单元测试 (TEST-4) @@ -199,6 +206,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests + TARGETED_DEVICE_FAMILY: "1" schemes: OSGKeyboard: