81581f0e5f
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
119 lines
4.0 KiB
Swift
119 lines
4.0 KiB
Swift
// KeyboardState.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// View-model for the keyboard extension. Lives in Shared (not the
|
|
// extension target) so unit tests can import it directly without the
|
|
// `app-extension` linking headaches. The keyboard view controller
|
|
// (`KeyboardViewController`) re-exports the same type as a typealias so
|
|
// existing call sites (`KeyboardViewController.State`) keep compiling.
|
|
|
|
import Foundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
public final class KeyboardState: ObservableObject {
|
|
public init() {}
|
|
|
|
/// Pipeline phase. Errors are structured so the UI layer can choose
|
|
/// the right icon / copy for each failure mode without
|
|
/// reverse-parsing a free-form string.
|
|
public enum Phase: Equatable {
|
|
case idle
|
|
case requestingPermissions
|
|
case recording
|
|
case processing
|
|
case error(ErrorKind, message: String? = nil)
|
|
case denied(Reason)
|
|
|
|
/// Why the pipeline failed. `message` is a short, user-facing
|
|
/// hint (e.g. "请检查主 App 设置"); the structured kind is what
|
|
/// drives icon / colour.
|
|
public enum ErrorKind: Equatable {
|
|
case micDenied
|
|
case speechDenied
|
|
case asr(String)
|
|
case llm(LLMError)
|
|
case appGroupUnavailable
|
|
case unknown(String)
|
|
}
|
|
|
|
public enum Reason: Equatable { case mic, speech }
|
|
}
|
|
|
|
public enum InputMode: String, CaseIterable, Identifiable {
|
|
case off
|
|
case transcribe
|
|
case polish
|
|
|
|
public var id: String { rawValue }
|
|
|
|
public var labelKey: String {
|
|
switch self {
|
|
case .off: return "mode.off"
|
|
case .transcribe: return "mode.transcribe"
|
|
case .polish: return "mode.polish"
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published public var phase: Phase = .idle
|
|
@Published public var level: Double = 0
|
|
@Published public var mode: InputMode = .polish
|
|
@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. 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
|
|
/// "local" → ASR only, no LLM. "cloud" → ASR + optional LLM polish.
|
|
@Published public var engineMode: String = "cloud"
|
|
|
|
/// Convenience shorthand used by the pipeline and views.
|
|
public var isLocalEngine: Bool { engineMode == "local" }
|
|
|
|
// Action hooks — injected by the view controller at install time.
|
|
public var beginRecording: () -> Void = {}
|
|
public var endRecording: () -> Void = {}
|
|
public var tapMic: () -> Void = {}
|
|
public var openSettings: () -> Void = {}
|
|
public var setMode: (InputMode) -> Void = { _ in }
|
|
public var setLocale: (String) -> Void = { _ in }
|
|
public var setEngineMode: (String) -> Void = { _ in }
|
|
public var insertNewline: () -> Void = {}
|
|
public var insertSpace: () -> Void = {}
|
|
public var deleteBackward: () -> Void = {}
|
|
|
|
// MARK: - Preview helpers (DEBUG only)
|
|
|
|
#if DEBUG
|
|
public static var previewIdle: KeyboardState {
|
|
let s = KeyboardState()
|
|
s.phase = .idle
|
|
s.level = 0
|
|
s.mode = .polish
|
|
s.localeId = "zh-Hans"
|
|
s.lastTranscript = ""
|
|
return s
|
|
}
|
|
public static var previewRecording: KeyboardState {
|
|
let s = KeyboardState()
|
|
s.phase = .recording
|
|
s.level = 0.65
|
|
s.mode = .polish
|
|
s.localeId = "zh-Hans"
|
|
s.lastTranscript = "你好,我想说一段测试"
|
|
return s
|
|
}
|
|
public static var previewProcessing: KeyboardState {
|
|
let s = KeyboardState()
|
|
s.phase = .processing
|
|
s.level = 0
|
|
s.mode = .polish
|
|
s.localeId = "zh-Hans"
|
|
s.lastTranscript = ""
|
|
return s
|
|
}
|
|
#endif
|
|
} |