[JJC-20260618-005-D] P1/P2 cleanup: structured errors, privacy audit, timeout SSOT, view-model tests
13 items, 555-line diff, build + 15/15 tests green.
ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.
ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.
ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.
ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.
RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.
TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).
TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.
TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.
TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
This commit is contained in:
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
> unchanged from v0.1.0.
|
> 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.
|
||||||
- **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 the iOS 18 ASR path silently returned `.denied` and the user heard nothing.
|
||||||
- **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.
|
||||||
|
|||||||
@@ -16,24 +16,6 @@
|
|||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<string>C617.1</string>
|
|
||||||
</dict>
|
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<string>E174.1</string>
|
|
||||||
</dict>
|
|
||||||
<dict>
|
|
||||||
<key>NSPrivacyAccessedAPIType</key>
|
|
||||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
|
||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
||||||
<string>35F9.1</string>
|
|
||||||
</dict>
|
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -27,7 +27,6 @@ struct APISettingsCard: View {
|
|||||||
title: "Base URL",
|
title: "Base URL",
|
||||||
placeholder: "https://api.openai.com/v1",
|
placeholder: "https://api.openai.com/v1",
|
||||||
text: $config.baseURL,
|
text: $config.baseURL,
|
||||||
keyboard: .URL,
|
|
||||||
autocap: false
|
autocap: false
|
||||||
)
|
)
|
||||||
Divider().background(palette.divider)
|
Divider().background(palette.divider)
|
||||||
@@ -37,7 +36,6 @@ struct APISettingsCard: View {
|
|||||||
title: "Model",
|
title: "Model",
|
||||||
placeholder: "gpt-4o-mini",
|
placeholder: "gpt-4o-mini",
|
||||||
text: $config.model,
|
text: $config.model,
|
||||||
keyboard: .default,
|
|
||||||
autocap: false
|
autocap: false
|
||||||
)
|
)
|
||||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||||
@@ -97,6 +95,7 @@ struct APISettingsCard: View {
|
|||||||
SecureField("sk-…", text: $config.apiKey)
|
SecureField("sk-…", text: $config.apiKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.keyboardType(.asciiCapable)
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
@@ -113,15 +112,20 @@ struct APISettingsCard: View {
|
|||||||
title: String,
|
title: String,
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
text: Binding<String>,
|
text: Binding<String>,
|
||||||
keyboard: UIKeyboardType,
|
|
||||||
autocap: Bool
|
autocap: Bool
|
||||||
) -> some View {
|
) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.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
|
||||||
|
// 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.
|
||||||
TextField(placeholder, text: text)
|
TextField(placeholder, text: text)
|
||||||
.keyboardType(keyboard)
|
.keyboardType(.asciiCapable)
|
||||||
.autocorrectionDisabled(true)
|
.autocorrectionDisabled(true)
|
||||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||||
.font(TypeStyle.body)
|
.font(TypeStyle.body)
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Speech
|
|
||||||
import OSGKeyboardShared
|
import OSGKeyboardShared
|
||||||
|
|
||||||
@objc(KeyboardViewController)
|
@objc(KeyboardViewController)
|
||||||
@@ -29,84 +28,11 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
// MARK: - View model
|
// MARK: - View model
|
||||||
|
|
||||||
@MainActor
|
/// Typealias so existing call sites (`KeyboardViewController.State`)
|
||||||
public final class State: ObservableObject {
|
/// keep compiling unchanged. The actual class lives in
|
||||||
public init() {}
|
/// `OSGKeyboardShared` so unit tests can `@testable import` it
|
||||||
public enum Phase: Equatable {
|
/// without dragging in the `app-extension` linking surface.
|
||||||
case idle
|
public typealias State = KeyboardState
|
||||||
case requestingPermissions
|
|
||||||
case recording
|
|
||||||
case processing
|
|
||||||
case error(String)
|
|
||||||
case denied(Reason)
|
|
||||||
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 = ""
|
|
||||||
|
|
||||||
// Action hooks — injected by the view controller at install time.
|
|
||||||
var beginRecording: () -> Void = {}
|
|
||||||
var endRecording: () -> Void = {}
|
|
||||||
var tapMic: () -> Void = {} // tap on the mic area (advances keyboard)
|
|
||||||
var openSettings: () -> Void = {}
|
|
||||||
var setMode: (InputMode) -> Void = { _ in }
|
|
||||||
var setLocale: (String) -> Void = { _ in }
|
|
||||||
var insertNewline: () -> Void = {}
|
|
||||||
var insertSpace: () -> Void = {}
|
|
||||||
var deleteBackward: () -> Void = {}
|
|
||||||
|
|
||||||
// MARK: - Preview helpers
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
static var previewIdle: State {
|
|
||||||
let s = State()
|
|
||||||
s.phase = .idle
|
|
||||||
s.level = 0
|
|
||||||
s.mode = .polish
|
|
||||||
s.localeId = "zh-Hans"
|
|
||||||
s.lastTranscript = ""
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
static var previewRecording: State {
|
|
||||||
let s = State()
|
|
||||||
s.phase = .recording
|
|
||||||
s.level = 0.65
|
|
||||||
s.mode = .polish
|
|
||||||
s.localeId = "zh-Hans"
|
|
||||||
s.lastTranscript = "你好,我想说一段测试"
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
static var previewProcessing: State {
|
|
||||||
let s = State()
|
|
||||||
s.phase = .processing
|
|
||||||
s.level = 0
|
|
||||||
s.mode = .polish
|
|
||||||
s.localeId = "zh-Hans"
|
|
||||||
s.lastTranscript = ""
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
|
|
||||||
@@ -114,11 +40,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private let audio = AudioCaptureService()
|
private let audio = AudioCaptureService()
|
||||||
private let asr: ASRService = ASRServiceFactory.make()
|
private let asr: ASRService = ASRServiceFactory.make()
|
||||||
private let polisher = PolishingService()
|
private let polisher = PolishingService()
|
||||||
|
private let permissions = PermissionManager()
|
||||||
|
private let persistor = AppGroupPersistor()
|
||||||
|
|
||||||
private var session: AudioCaptureService.Session?
|
private var session: AudioCaptureService.Session?
|
||||||
private var asrTask: Task<Void, Never>?
|
private var asrTask: Task<Void, Never>?
|
||||||
private var levelTask: Task<Void, Never>?
|
private var levelTask: Task<Void, Never>?
|
||||||
private var didRequestMicOnce: Bool = false
|
|
||||||
|
|
||||||
private var hosting: UIHostingController<KeyboardRootView>!
|
private var hosting: UIHostingController<KeyboardRootView>!
|
||||||
|
|
||||||
@@ -132,7 +59,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
inputView?.allowsSelfSizing = true
|
inputView?.allowsSelfSizing = true
|
||||||
installStateActions()
|
installStateActions()
|
||||||
installSwiftUI()
|
installSwiftUI()
|
||||||
loadPersistedLocale()
|
loadPersistedConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
@@ -186,38 +113,13 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self.hosting = host
|
self.hosting = host
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadPersistedLocale() {
|
private func loadPersistedConfig() {
|
||||||
guard AppGroup.isAvailable else {
|
switch persistor.load(into: state) {
|
||||||
state.phase = .error("App Group 未配置")
|
case .loaded:
|
||||||
return
|
break
|
||||||
|
case .unavailable:
|
||||||
|
state.phase = .error(.appGroupUnavailable, message: "App Group 未配置")
|
||||||
}
|
}
|
||||||
let store = AppGroupStore()
|
|
||||||
let id = store.localeId
|
|
||||||
state.localeId = id
|
|
||||||
state.mode = State.InputMode(rawValue: store.modeId) ?? .polish
|
|
||||||
#if DEBUG
|
|
||||||
// Print a masked view of the live App Group config so we can see
|
|
||||||
// from the device console exactly what the keyboard extension
|
|
||||||
// actually sees (and whether it agrees with the main App).
|
|
||||||
let key = store.apiKey
|
|
||||||
let masked: String
|
|
||||||
if key.count > 8 {
|
|
||||||
masked = "\(key.prefix(4))…\(key.suffix(4)) (\(key.count) chars)"
|
|
||||||
} else if key.isEmpty {
|
|
||||||
masked = "<empty>"
|
|
||||||
} else {
|
|
||||||
masked = "<\(key.count) chars>"
|
|
||||||
}
|
|
||||||
print("""
|
|
||||||
🔍 [KeyboardViewController.loadPersistedLocale]
|
|
||||||
providerId = \(store.providerId)
|
|
||||||
baseURL = \(store.baseURL)
|
|
||||||
apiKey = \(masked)
|
|
||||||
model = \(store.model)
|
|
||||||
modeId = \(store.modeId)
|
|
||||||
localeId = \(store.localeId)
|
|
||||||
""")
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Press handlers
|
// MARK: - Press handlers
|
||||||
@@ -233,7 +135,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.phase = .requestingPermissions
|
state.phase = .requestingPermissions
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let micGranted = await self.requestMicPermission()
|
let micGranted = await self.permissions.requestMicPermission()
|
||||||
guard micGranted else {
|
guard micGranted else {
|
||||||
self.state.phase = .denied(.mic)
|
self.state.phase = .denied(.mic)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
@@ -247,7 +149,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// iOS 26 SpeechAnalyzer path (planned for the next release)
|
// iOS 26 SpeechAnalyzer path (planned for the next release)
|
||||||
// does not expose an explicit request API — the framework
|
// does not expose an explicit request API — the framework
|
||||||
// prompts via the same plist key on first use.
|
// prompts via the same plist key on first use.
|
||||||
let speechGranted = await self.requestSpeechPermission()
|
let speechGranted = await self.permissions.requestSpeechPermission()
|
||||||
guard speechGranted else {
|
guard speechGranted else {
|
||||||
self.state.phase = .denied(.speech)
|
self.state.phase = .denied(.speech)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
@@ -279,6 +181,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
var lastPartial: String = ""
|
var lastPartial: String = ""
|
||||||
for await event in events {
|
for await event in events {
|
||||||
switch event {
|
switch event {
|
||||||
|
case .capability(let onDevice):
|
||||||
|
self.state.onDeviceSupported = onDevice
|
||||||
case .partial(let s):
|
case .partial(let s):
|
||||||
lastPartial = s
|
lastPartial = s
|
||||||
self.state.lastTranscript = s
|
self.state.lastTranscript = s
|
||||||
@@ -286,7 +190,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
let transcript = s.isEmpty ? lastPartial : s
|
let transcript = s.isEmpty ? lastPartial : s
|
||||||
self.handleFinalTranscript(transcript)
|
self.handleFinalTranscript(transcript)
|
||||||
case .error(let m):
|
case .error(let m):
|
||||||
self.state.phase = .error("ASR: \(m)")
|
self.state.phase = .error(.asr(m))
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +219,9 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.phase = .idle
|
state.phase = .idle
|
||||||
}
|
}
|
||||||
state.level = 0
|
state.level = 0
|
||||||
|
// Reset the on-device flag so the StatusBadge stops showing the
|
||||||
|
// cloud-fallback indicator between recordings.
|
||||||
|
state.onDeviceSupported = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFinalTranscript(_ transcript: String) {
|
private func handleFinalTranscript(_ transcript: String) {
|
||||||
@@ -339,19 +246,19 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self.textDocumentProxy.insertText(polished)
|
self.textDocumentProxy.insertText(polished)
|
||||||
self.state.lastTranscript = ""
|
self.state.lastTranscript = ""
|
||||||
self.state.phase = .idle
|
self.state.phase = .idle
|
||||||
} catch LLMError.noAPIKey {
|
} catch let error as LLMError {
|
||||||
|
switch error {
|
||||||
|
case .noAPIKey:
|
||||||
// Don't silently insert the raw transcript — the user
|
// Don't silently insert the raw transcript — the user
|
||||||
// thinks they're getting polished text when really no
|
// thinks they're getting polished text when really no
|
||||||
// key is configured. Show a precise, actionable error.
|
// key is configured. Show a precise, actionable error.
|
||||||
self.state.phase = .error("未配置 API Key · 请在主 App 设置中填写")
|
self.state.phase = .error(.llm(error), message: "未配置 API Key · 请在主 App 设置中填写")
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
} catch let error as LLMError {
|
|
||||||
switch error {
|
|
||||||
case .http(401):
|
case .http(401):
|
||||||
self.state.phase = .error("API Key 无效 (401) · 请检查主 App 设置")
|
self.state.phase = .error(.llm(error), message: "API Key 无效 (401) · 请检查主 App 设置")
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
case .http(429), .rateLimited:
|
case .http(429), .rateLimited:
|
||||||
self.state.phase = .error("API 限流 (429) · 请稍后再试")
|
self.state.phase = .error(.llm(error), message: "API 限流 (429) · 请稍后再试")
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
case .cancelled:
|
case .cancelled:
|
||||||
// User-initiated cancellation (e.g. mode switch mid-
|
// User-initiated cancellation (e.g. mode switch mid-
|
||||||
@@ -363,12 +270,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
// Other LLMError variants (transport / decoding /
|
// Other LLMError variants (transport / decoding /
|
||||||
// invalidURL / cancelled) fall back to raw transcript
|
// invalidURL) fall back to raw transcript + generic
|
||||||
// + generic error badge, same as the catch-all below.
|
// error badge, same as the catch-all below.
|
||||||
self.textDocumentProxy.insertText(trimmed)
|
self.textDocumentProxy.insertText(trimmed)
|
||||||
self.state.lastTranscript = ""
|
self.state.lastTranscript = ""
|
||||||
let msg = error.errorDescription ?? "Polishing failed — inserted raw."
|
let msg = error.errorDescription ?? "Polishing failed — inserted raw."
|
||||||
self.state.phase = .error(msg)
|
self.state.phase = .error(.llm(error), message: msg)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -379,7 +286,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
self.state.lastTranscript = ""
|
self.state.lastTranscript = ""
|
||||||
let msg = (error as? LocalizedError)?.errorDescription
|
let msg = (error as? LocalizedError)?.errorDescription
|
||||||
?? "Polishing failed — inserted raw."
|
?? "Polishing failed — inserted raw."
|
||||||
self.state.phase = .error(msg)
|
self.state.phase = .error(.unknown(msg), message: msg)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,7 +297,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private func persistMode(_ m: State.InputMode) {
|
private func persistMode(_ m: State.InputMode) {
|
||||||
let isRecording = state.phase == .recording
|
let isRecording = state.phase == .recording
|
||||||
state.mode = m
|
state.mode = m
|
||||||
AppGroupStore().setModeId(m.rawValue)
|
persistor.persist(mode: m)
|
||||||
if isRecording {
|
if isRecording {
|
||||||
if m == .off {
|
if m == .off {
|
||||||
// Switching to .off while recording: drop the partial
|
// Switching to .off while recording: drop the partial
|
||||||
@@ -411,58 +318,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
|
|
||||||
private func persistLocale(_ id: String) {
|
private func persistLocale(_ id: String) {
|
||||||
state.localeId = id
|
state.localeId = id
|
||||||
AppGroupStore().setLocaleId(id)
|
persistor.persist(localeId: id)
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Permissions
|
|
||||||
|
|
||||||
private 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request Speech Recognition permission. Returns true if granted
|
|
||||||
/// (or already authorised). For the iOS 18 SFSpeechRecognizer path
|
|
||||||
/// this is required before recognition can begin; for the iOS 26
|
|
||||||
/// SpeechAnalyzer path the framework prompts on first use.
|
|
||||||
private func requestSpeechPermission() async -> Bool {
|
|
||||||
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
|
||||||
SFSpeechRecognizer.requestAuthorization { status in
|
|
||||||
switch status {
|
|
||||||
case .authorized: cont.resume(returning: true)
|
|
||||||
case .denied, .restricted, .notDetermined:
|
|
||||||
cont.resume(returning: false)
|
|
||||||
@unknown default:
|
|
||||||
cont.resume(returning: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Open host app
|
// MARK: - Open host app
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<string>DDA9.1</string>
|
||||||
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -35,6 +35,11 @@ public protocol ASRService: Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public enum ASREvent: Sendable, Equatable {
|
public enum ASREvent: Sendable, Equatable {
|
||||||
|
/// Emitted exactly once at the start of every `transcribe` call, so
|
||||||
|
/// the UI can flag non-on-device locales (e.g. ja-JP on devices that
|
||||||
|
/// only ship on-device ASR for en/zh). The ASR session continues
|
||||||
|
/// either way — we fall back to cloud automatically.
|
||||||
|
case capability(onDeviceSupported: Bool)
|
||||||
case partial(String)
|
case partial(String)
|
||||||
case final(String)
|
case final(String)
|
||||||
case error(String)
|
case error(String)
|
||||||
@@ -73,11 +78,16 @@ final class AppleSpeechASR: ASRService, @unchecked Sendable {
|
|||||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||||
request.shouldReportPartialResults = true
|
request.shouldReportPartialResults = true
|
||||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||||
if !recognizer.supportsOnDeviceRecognition {
|
let onDeviceSupported = recognizer.supportsOnDeviceRecognition
|
||||||
|
if !onDeviceSupported {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("⚠️ 设备不支持 \(locale.identifier) 端侧 ASR, 回退云端。")
|
print("⚠️ 设备不支持 \(locale.identifier) 端侧 ASR, 回退云端。")
|
||||||
#endif
|
#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
|
let task = recognizer.recognitionTask(with: request) { result, error in
|
||||||
if let error {
|
if let error {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// AppGroupPersistor.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// Extracted from KeyboardViewController so the view controller doesn't
|
||||||
|
// have to know about App Group availability checks, AppGroupStore
|
||||||
|
// reads/writes, or how to render the locale / mode into the State
|
||||||
|
// view model.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import OSGKeyboardShared
|
||||||
|
|
||||||
|
/// Outcome of `load()` — distinguishes "everything fine" from "the
|
||||||
|
/// App Group isn't configured so we can't read anything". The view
|
||||||
|
/// controller flips its `phase` accordingly.
|
||||||
|
public enum AppGroupLoadResult: Equatable {
|
||||||
|
case loaded
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public struct AppGroupPersistor {
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
/// Hydrate `state` from the App Group. Returns `loaded` on success
|
||||||
|
/// or `unavailable` if the App Group suite can't be opened (which
|
||||||
|
/// in DEBUG `fatalError`s inside `AppGroup.isAvailable`).
|
||||||
|
public func load(into state: KeyboardViewController.State) -> AppGroupLoadResult {
|
||||||
|
guard AppGroup.isAvailable else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
let store = AppGroupStore()
|
||||||
|
state.localeId = store.localeId
|
||||||
|
state.mode = KeyboardViewController.State.InputMode(rawValue: store.modeId) ?? .polish
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
// Print a masked view of the live App Group config so we can see
|
||||||
|
// from the device console exactly what the keyboard extension
|
||||||
|
// actually sees (and whether it agrees with the main App).
|
||||||
|
let key = store.apiKey
|
||||||
|
let masked: String
|
||||||
|
if key.count > 8 {
|
||||||
|
masked = "\(key.prefix(4))…\(key.suffix(4)) (\(key.count) chars)"
|
||||||
|
} else if key.isEmpty {
|
||||||
|
masked = "<empty>"
|
||||||
|
} else {
|
||||||
|
masked = "<\(key.count) chars>"
|
||||||
|
}
|
||||||
|
print("""
|
||||||
|
🔍 [AppGroupPersistor.load]
|
||||||
|
providerId = \(store.providerId)
|
||||||
|
baseURL = \(store.baseURL)
|
||||||
|
apiKey = \(masked)
|
||||||
|
model = \(store.model)
|
||||||
|
modeId = \(store.modeId)
|
||||||
|
localeId = \(store.localeId)
|
||||||
|
""")
|
||||||
|
#endif
|
||||||
|
return .loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `mode` to the App Group store.
|
||||||
|
public func persist(mode: KeyboardViewController.State.InputMode) {
|
||||||
|
AppGroupStore().setModeId(mode.rawValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `localeId` to the App Group store.
|
||||||
|
public func persist(localeId: String) {
|
||||||
|
AppGroupStore().setLocaleId(localeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// PermissionManager.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension
|
||||||
|
//
|
||||||
|
// Extracted from KeyboardViewController so the view controller doesn't
|
||||||
|
// need to know about AVAudioApplication vs AVAudioSession branching
|
||||||
|
// or SFSpeechRecognizer.requestAuthorization callback bridging.
|
||||||
|
//
|
||||||
|
// Contract:
|
||||||
|
// • `requestMicPermission()` returns true if the user has authorised
|
||||||
|
// or *just* authorised; false otherwise. Idempotent within a
|
||||||
|
// process — the second call will not prompt again if the user has
|
||||||
|
// already answered.
|
||||||
|
// • `requestSpeechPermission()` mirrors the same shape but for
|
||||||
|
// SFSpeechRecognizer.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
import Speech
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class PermissionManager: @unchecked Sendable {
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
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`.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
public func requestSpeechPermission() async -> Bool {
|
||||||
|
await withCheckedContinuation { (cont: CheckedContinuation<Bool, Never>) in
|
||||||
|
SFSpeechRecognizer.requestAuthorization { status in
|
||||||
|
switch status {
|
||||||
|
case .authorized: cont.resume(returning: true)
|
||||||
|
case .denied, .restricted, .notDetermined:
|
||||||
|
cont.resume(returning: false)
|
||||||
|
@unknown default:
|
||||||
|
cont.resume(returning: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// PolishingService.swift
|
|
||||||
// OSGKeyboard · Keyboard Extension
|
|
||||||
//
|
|
||||||
// Takes raw ASR transcript and runs it through the user's configured LLM
|
|
||||||
// to produce polished, well-punctuated text. Falls back to the raw transcript
|
|
||||||
// if the LLM call fails or times out.
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import OSGKeyboardShared
|
|
||||||
|
|
||||||
public actor PolishingService {
|
|
||||||
|
|
||||||
public enum PolishError: Error {
|
|
||||||
case noTranscript
|
|
||||||
case timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
private let store: AppGroupStore
|
|
||||||
private let timeout: TimeInterval
|
|
||||||
|
|
||||||
public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 15) {
|
|
||||||
self.store = store
|
|
||||||
self.timeout = timeout
|
|
||||||
}
|
|
||||||
|
|
||||||
public func polish(_ raw: String) async throws -> String {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
|
||||||
|
|
||||||
let client = store.makeClient()
|
|
||||||
let prompt = store.systemPrompt
|
|
||||||
|
|
||||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
|
||||||
group.addTask {
|
|
||||||
try await client.polish(trimmed, systemPrompt: prompt)
|
|
||||||
}
|
|
||||||
group.addTask {
|
|
||||||
try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000))
|
|
||||||
throw PolishError.timeout
|
|
||||||
}
|
|
||||||
let result = try await group.next()!
|
|
||||||
group.cancelAll()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
|
|||||||
state.setLocale(newId)
|
state.setLocale(newId)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
StatusBadge(phase: state.phase)
|
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||||
Button(action: state.openSettings) {
|
Button(action: state.openSettings) {
|
||||||
Image(systemName: "gearshape.fill")
|
Image(systemName: "gearshape.fill")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
@@ -229,8 +229,8 @@ private struct TranscriptLine: View {
|
|||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
}
|
}
|
||||||
case .error(let msg):
|
case .error(_, let msg):
|
||||||
Text(msg)
|
Text(msg ?? "")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(palette.warning)
|
.foregroundStyle(palette.warning)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -287,6 +287,11 @@ private struct StatusBadge: View {
|
|||||||
@Environment(\.themePalette) private var palette: ThemePalette
|
@Environment(\.themePalette) private var palette: ThemePalette
|
||||||
|
|
||||||
let phase: KeyboardViewController.State.Phase
|
let phase: KeyboardViewController.State.Phase
|
||||||
|
/// Reflects whether the active ASR session is on-device. We surface
|
||||||
|
/// a small ⚠️ during recording so the user knows their audio is
|
||||||
|
/// going to the cloud for this locale (and so devs catch it during
|
||||||
|
/// QA without staring at the Xcode console).
|
||||||
|
let onDeviceSupported: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -296,7 +301,11 @@ private struct StatusBadge: View {
|
|||||||
case .requestingPermissions:
|
case .requestingPermissions:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
case .recording:
|
case .recording:
|
||||||
|
if onDeviceSupported {
|
||||||
dot(color: palette.recordRed, label: "REC")
|
dot(color: palette.recordRed, label: "REC")
|
||||||
|
} else {
|
||||||
|
dot(color: palette.warning, label: "REC ⚠️", showWarning: true)
|
||||||
|
}
|
||||||
case .processing:
|
case .processing:
|
||||||
dot(color: palette.accent, label: "···")
|
dot(color: palette.accent, label: "···")
|
||||||
case .error:
|
case .error:
|
||||||
@@ -307,11 +316,16 @@ private struct StatusBadge: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dot(color: Color, label: String) -> some View {
|
private func dot(color: Color, label: String, showWarning: Bool = false) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(color)
|
.fill(color)
|
||||||
.frame(width: 6, height: 6)
|
.frame(width: 6, height: 6)
|
||||||
|
if showWarning {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 9, weight: .bold))
|
||||||
|
.foregroundStyle(palette.warning)
|
||||||
|
}
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(TypeStyle.caption2)
|
.font(TypeStyle.caption2)
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>BNDL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// KeyboardStateTests.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension Tests
|
||||||
|
//
|
||||||
|
// Target existence tests for the new `OSGKeyboardExtTests` target
|
||||||
|
// (TEST-4). We focus on `KeyboardState` (formerly `KeyboardViewController.State`,
|
||||||
|
// now extracted to `OSGKeyboardShared`) because it's the highest-leverage
|
||||||
|
// thing to test: it owns the published view model that every keyboard UI
|
||||||
|
// view reads from.
|
||||||
|
//
|
||||||
|
// The `KeyboardViewController` itself is hard to instantiate in a test
|
||||||
|
// host because it derives from `UIInputViewController` and needs a real
|
||||||
|
// input view, microphone permission prompts, etc. We deliberately
|
||||||
|
// *don't* attempt that here — the State class is what we care about
|
||||||
|
// for correctness.
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class KeyboardStateTests: XCTestCase {
|
||||||
|
|
||||||
|
func testTargetCompiles() {
|
||||||
|
// Pure existence check — the build itself proves the target links.
|
||||||
|
// This test exists so `xcodebuild test` for `OSGKeyboardExtTests`
|
||||||
|
// has at least one passing assertion.
|
||||||
|
XCTAssertTrue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialState() {
|
||||||
|
let s = KeyboardState()
|
||||||
|
XCTAssertEqual(s.phase, .idle)
|
||||||
|
XCTAssertEqual(s.mode, .polish)
|
||||||
|
XCTAssertEqual(s.localeId, "auto")
|
||||||
|
XCTAssertEqual(s.lastTranscript, "")
|
||||||
|
XCTAssertEqual(s.level, 0)
|
||||||
|
XCTAssertFalse(s.onDeviceSupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPhaseTransitionsIdleToRequestingPermissionsAndBack() {
|
||||||
|
let s = KeyboardState()
|
||||||
|
s.phase = .requestingPermissions
|
||||||
|
XCTAssertNotEqual(s.phase, .idle)
|
||||||
|
s.phase = .recording
|
||||||
|
XCTAssertEqual(s.phase, .recording)
|
||||||
|
s.phase = .processing
|
||||||
|
XCTAssertEqual(s.phase, .processing)
|
||||||
|
s.phase = .idle
|
||||||
|
XCTAssertEqual(s.phase, .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testStructuredErrorCarriesLLMError() {
|
||||||
|
let s = KeyboardState()
|
||||||
|
let underlying = LLMError.http(status: 401)
|
||||||
|
s.phase = .error(.llm(underlying), message: "API Key 无效")
|
||||||
|
if case .error(let kind, let msg) = s.phase {
|
||||||
|
XCTAssertEqual(kind, .llm(underlying))
|
||||||
|
XCTAssertEqual(msg, "API Key 无效")
|
||||||
|
} else {
|
||||||
|
XCTFail("expected structured .error phase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testModeSwitchFromPolishToOff() {
|
||||||
|
let s = KeyboardState()
|
||||||
|
XCTAssertEqual(s.mode, .polish)
|
||||||
|
s.mode = .off
|
||||||
|
XCTAssertEqual(s.mode, .off)
|
||||||
|
s.mode = .transcribe
|
||||||
|
XCTAssertEqual(s.mode, .transcribe)
|
||||||
|
s.mode = .polish
|
||||||
|
XCTAssertEqual(s.mode, .polish)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInputModeRoundTripsThroughRawValue() {
|
||||||
|
// The mode is persisted by rawValue (see `AppGroupStore.setModeId`)
|
||||||
|
// so the round-trip is part of the public contract.
|
||||||
|
for mode in KeyboardState.InputMode.allCases {
|
||||||
|
let raw = mode.rawValue
|
||||||
|
XCTAssertNotNil(KeyboardState.InputMode(rawValue: raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,18 +232,4 @@ public extension View {
|
|||||||
.foregroundStyle(Palette.textPrimary)
|
.foregroundStyle(Palette.textPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Legacy alias for older call sites.
|
|
||||||
func cardStyle() -> some View { cardSurface() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Backwards compat (legacy callers in old code)
|
|
||||||
|
|
||||||
public enum Theme {
|
|
||||||
public static let background = Palette.background
|
|
||||||
public static let card = Palette.surface
|
|
||||||
public static let accent = Palette.accent
|
|
||||||
public static let danger = Palette.danger
|
|
||||||
public static let textPrimary = Palette.textPrimary
|
|
||||||
public static let textSecondary = Palette.textSecondary
|
|
||||||
public static let divider = Palette.divider
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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. `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.
|
||||||
|
@Published public var onDeviceSupported: Bool = false
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public enum LLMError: Error, LocalizedError, Sendable {
|
public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
||||||
case invalidURL
|
case invalidURL
|
||||||
case noAPIKey
|
case noAPIKey
|
||||||
case http(status: Int)
|
case http(status: Int)
|
||||||
@@ -30,6 +30,12 @@ public enum LLMError: Error, LocalizedError, Sendable {
|
|||||||
|
|
||||||
public protocol LLMClient: Sendable {
|
public protocol LLMClient: Sendable {
|
||||||
func polish(_ text: String, systemPrompt: String) async throws -> String
|
func polish(_ text: String, systemPrompt: String) async throws -> String
|
||||||
|
|
||||||
|
/// Single source of truth for the upper bound on a single LLM HTTP
|
||||||
|
/// round-trip. Both the `URLRequest` we send and any wrapping
|
||||||
|
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
|
||||||
|
/// must read from this property so the two never disagree.
|
||||||
|
var requestTimeout: TimeInterval { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - OpenAI-compatible implementation
|
// MARK: - OpenAI-compatible implementation
|
||||||
@@ -40,6 +46,12 @@ public struct OpenAICompatibleClient: LLMClient {
|
|||||||
public let model: String
|
public let model: String
|
||||||
public let session: URLSession
|
public let session: URLSession
|
||||||
|
|
||||||
|
/// Canonical request timeout for a single LLM HTTP round-trip. Both
|
||||||
|
/// the `URLRequest.timeoutInterval` we set below and any external
|
||||||
|
/// race that wants to bound the total time spent waiting on the LLM
|
||||||
|
/// (e.g. `PolishingService`) should derive from this constant.
|
||||||
|
public let requestTimeout: TimeInterval = 15
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
baseURL: String,
|
baseURL: String,
|
||||||
apiKey: String,
|
apiKey: String,
|
||||||
@@ -74,7 +86,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
|||||||
req.httpMethod = "POST"
|
req.httpMethod = "POST"
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
req.timeoutInterval = 15
|
req.timeoutInterval = requestTimeout
|
||||||
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
req.httpBody = try encoder.encode(request)
|
req.httpBody = try encoder.encode(request)
|
||||||
@@ -122,4 +134,13 @@ public enum LLMClientFactory {
|
|||||||
model: config.model
|
model: config.model
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Single source of truth for the LLM request timeout, shared by
|
||||||
|
/// `LLMClient.requestTimeout` implementations and any caller that
|
||||||
|
/// wants to bound total time spent waiting on the LLM (e.g.
|
||||||
|
/// `PolishingService`'s safety-net `withThrowingTaskGroup`). Use
|
||||||
|
/// this instead of hard-coding `15` so all timeouts stay aligned.
|
||||||
|
public static var defaultRequestTimeout: TimeInterval {
|
||||||
|
OpenAICompatibleClient(baseURL: "", apiKey: "", model: "").requestTimeout
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// PolishingService.swift
|
||||||
|
// OSGKeyboard · Shared
|
||||||
|
//
|
||||||
|
// Takes raw ASR transcript and runs it through the user's configured LLM
|
||||||
|
// to produce polished, well-punctuated text. Falls back to the raw transcript
|
||||||
|
// if the LLM call fails or times out.
|
||||||
|
//
|
||||||
|
// Mode-aware: when `modeId == "off"` the service short-circuits and returns
|
||||||
|
// the trimmed input without touching the network. This is the runtime
|
||||||
|
// guarantee behind the keyboard's "Off · 关闭" mode.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public actor PolishingService {
|
||||||
|
|
||||||
|
public enum PolishError: Error, Equatable {
|
||||||
|
case noTranscript
|
||||||
|
case timeout
|
||||||
|
case modeOff
|
||||||
|
}
|
||||||
|
|
||||||
|
private let store: AppGroupStore
|
||||||
|
private let timeout: TimeInterval
|
||||||
|
/// Optional injected client (mostly for testing). When nil we build
|
||||||
|
/// one from `store.makeClient()` per call.
|
||||||
|
private let injectedClient: LLMClient?
|
||||||
|
|
||||||
|
/// Default `timeout` is `LLMClient.requestTimeout + 1` second so the
|
||||||
|
/// safety-net `withThrowingTaskGroup` never wins the race against
|
||||||
|
/// the URL request itself; if the request times out cleanly the
|
||||||
|
/// network error reaches us first. The +1 is the single point of
|
||||||
|
/// slack between the two clocks — keep it here, not in `LLMClient`.
|
||||||
|
public init(
|
||||||
|
store: AppGroupStore = AppGroupStore(),
|
||||||
|
client: LLMClient? = nil,
|
||||||
|
timeout: TimeInterval? = nil
|
||||||
|
) {
|
||||||
|
self.store = store
|
||||||
|
self.injectedClient = client
|
||||||
|
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func polish(_ raw: String) async throws -> String {
|
||||||
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||||
|
|
||||||
|
// Mode-aware short-circuit. When the user has selected "Off", the
|
||||||
|
// keyboard must never hit the network — we return the trimmed
|
||||||
|
// input as-is. This is the same value the view controller would
|
||||||
|
// produce if it skipped `polish()` entirely, but having the
|
||||||
|
// guarantee at the service layer means future call sites (CLI,
|
||||||
|
// tests, alternate keyboards) inherit it for free.
|
||||||
|
if store.modeId == "off" {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = injectedClient ?? store.makeClient()
|
||||||
|
let prompt = store.systemPrompt
|
||||||
|
|
||||||
|
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||||
|
group.addTask {
|
||||||
|
try await client.polish(trimmed, systemPrompt: prompt)
|
||||||
|
}
|
||||||
|
group.addTask {
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000))
|
||||||
|
throw PolishError.timeout
|
||||||
|
}
|
||||||
|
let result = try await group.next()!
|
||||||
|
group.cancelAll()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -127,15 +127,24 @@ final class LLMClientTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testPolishThrowsOnTransportTimeout() async {
|
func testPolishThrowsOnTransportTimeout() async {
|
||||||
// StubURLProtocol completes synchronously, so we simulate a timeout
|
// Stub the transport so it never replies in time. The client has a
|
||||||
// by cancelling the task before the response arrives. The client
|
// 15 s `requestTimeout` on the URLRequest; we arrange for the stub
|
||||||
// surfaces this as `LLMError.cancelled`.
|
// to take 5 s (well under that) and instead *cancel* the in-flight
|
||||||
|
// task ourselves before the stub wins the race. That's how the
|
||||||
|
// KeyboardViewController triggers cancellation in real life (mode
|
||||||
|
// switch mid-polish) and is the surface `LLMError.cancelled` was
|
||||||
|
// added to cover. We also assert the client *throws* — i.e. the
|
||||||
|
// old "stub returns 200 synchronously and we never see the error"
|
||||||
|
// failure mode is gone.
|
||||||
StubURLProtocolStorage.config = (200, Data())
|
StubURLProtocolStorage.config = (200, Data())
|
||||||
defer { StubURLProtocolStorage.config = nil }
|
StubURLProtocolStorage.delaySeconds = 5
|
||||||
|
defer {
|
||||||
|
StubURLProtocolStorage.config = nil
|
||||||
|
StubURLProtocolStorage.delaySeconds = 0
|
||||||
|
}
|
||||||
|
|
||||||
let cfg = URLSessionConfiguration.ephemeral
|
let cfg = URLSessionConfiguration.ephemeral
|
||||||
cfg.protocolClasses = [StubURLProtocol.self]
|
cfg.protocolClasses = [StubURLProtocol.self]
|
||||||
cfg.timeoutIntervalForRequest = 0.05
|
|
||||||
let session = URLSession(configuration: cfg)
|
let session = URLSession(configuration: cfg)
|
||||||
|
|
||||||
let client = OpenAICompatibleClient(
|
let client = OpenAICompatibleClient(
|
||||||
@@ -144,24 +153,43 @@ final class LLMClientTests: XCTestCase {
|
|||||||
model: "m",
|
model: "m",
|
||||||
session: session
|
session: session
|
||||||
)
|
)
|
||||||
// We don't assert a specific error type here — URLSession's
|
|
||||||
// cancellation surface is platform-quirky. The contract under test
|
let task = Task<Bool, Error> {
|
||||||
// is just "throws something instead of silently returning the
|
|
||||||
// raw transcript"; that something is then handled by
|
|
||||||
// KeyboardViewController.handleFinalTranscript's catch ladder.
|
|
||||||
do {
|
do {
|
||||||
_ = try await client.polish("hi", systemPrompt: "p")
|
_ = try await client.polish("hi", systemPrompt: "p")
|
||||||
// The stub returns 200 with empty body immediately, which would
|
return false // completed — unexpected
|
||||||
// decode to a valid empty content. That still proves the
|
|
||||||
// path doesn't crash — so we don't XCTFail if the stub won the
|
|
||||||
// race. The other tests (noAPIKey, 401, 429) already cover
|
|
||||||
// the typed-error ladder.
|
|
||||||
} catch {
|
} catch {
|
||||||
// Any throwable counts as success for the "doesn't crash"
|
throw error
|
||||||
// contract.
|
|
||||||
_ = error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Give the request a head start so it's already on the wire when
|
||||||
|
// we cancel.
|
||||||
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
var threw = false
|
||||||
|
var caughtTransportish = false
|
||||||
|
do {
|
||||||
|
_ = try await task.value
|
||||||
|
} catch is CancellationError {
|
||||||
|
threw = true
|
||||||
|
} catch let err as LLMError {
|
||||||
|
threw = true
|
||||||
|
// We accept any of: cancelled, transport, decoding — the URL
|
||||||
|
// stack is platform-quirky about how it surfaces a cancelled
|
||||||
|
// request from inside URLSession's protocol handler.
|
||||||
|
switch err {
|
||||||
|
case .cancelled, .transport, .decoding:
|
||||||
|
caughtTransportish = true
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
threw = true
|
||||||
|
}
|
||||||
|
XCTAssertTrue(threw, "expected client.polish to throw on cancelled transport")
|
||||||
|
XCTAssertTrue(caughtTransportish, "expected .cancelled / .transport / .decoding — got something else")
|
||||||
|
}
|
||||||
|
|
||||||
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
/// Cross-process App Group contract: what `ProviderConfig` writes must
|
||||||
/// be readable through `AppGroupStore` (and vice-versa) on the same
|
/// be readable through `AppGroupStore` (and vice-versa) on the same
|
||||||
@@ -219,6 +247,69 @@ final class LLMClientTests: XCTestCase {
|
|||||||
XCTFail("wrong error: \(error)")
|
XCTFail("wrong error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - TEST-2: mode = .off short-circuits PolishingService
|
||||||
|
|
||||||
|
/// `PolishingService.polish()` must not invoke the underlying
|
||||||
|
/// `LLMClient` when the App Group store reports `modeId == "off"`.
|
||||||
|
/// We verify both halves of that contract:
|
||||||
|
/// 1. The return value is the trimmed input (not a polished round-trip).
|
||||||
|
/// 2. The `LLMClient` is never asked to talk to the network.
|
||||||
|
func testPolisherSkipsNetworkWhenModeOff() async throws {
|
||||||
|
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||||
|
|
||||||
|
// modeId = "off" — this is the switch we care about.
|
||||||
|
defaults.set("off", forKey: "config.modeId")
|
||||||
|
defaults.set("https://example.com/v1", forKey: "config.baseURL")
|
||||||
|
defaults.set("sk-should-not-be-used", forKey: "config.apiKey")
|
||||||
|
defaults.set("gpt-4o-mini", forKey: "config.model")
|
||||||
|
|
||||||
|
// Counter LLMClient: if `polish()` is ever called, this trips.
|
||||||
|
let counter = CallCounter()
|
||||||
|
let countingClient = CountingLLMClient(counter: counter) { _, _ in
|
||||||
|
XCTFail("LLMClient.polish was invoked under mode=off — short-circuit failed")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
let store = AppGroupStore(defaults: defaults)
|
||||||
|
let polisher = PolishingService(
|
||||||
|
store: store,
|
||||||
|
client: countingClient,
|
||||||
|
timeout: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try await polisher.polish(" hello world ")
|
||||||
|
XCTAssertEqual(result, "hello world", "mode=off must return trimmed input, not polished output")
|
||||||
|
let calls = await counter.value()
|
||||||
|
XCTAssertEqual(calls, 0, "LLMClient.polish must not be called when modeId == \"off\"")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test helpers
|
||||||
|
|
||||||
|
/// Thread-safe counter for proving a call site never invoked the LLM.
|
||||||
|
private actor CallCounter {
|
||||||
|
private(set) var n = 0
|
||||||
|
func bump() { n += 1 }
|
||||||
|
func value() -> Int { n }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal `LLMClient` that records each call and forwards to a user-
|
||||||
|
/// supplied closure. Used by tests that need to prove a particular
|
||||||
|
/// code path *did not* invoke the client.
|
||||||
|
private struct CountingLLMClient: LLMClient {
|
||||||
|
let counter: CallCounter
|
||||||
|
let body: @Sendable (String, String) async throws -> String
|
||||||
|
|
||||||
|
var requestTimeout: TimeInterval { 15 }
|
||||||
|
|
||||||
|
func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||||
|
await counter.bump()
|
||||||
|
return try await body(text, systemPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - URLProtocol stub
|
// MARK: - URLProtocol stub
|
||||||
@@ -227,6 +318,7 @@ final class LLMClientTests: XCTestCase {
|
|||||||
/// before invoking the code under test, then reset to nil in cleanup.
|
/// before invoking the code under test, then reset to nil in cleanup.
|
||||||
private enum StubURLProtocolStorage {
|
private enum StubURLProtocolStorage {
|
||||||
nonisolated(unsafe) static var config: (statusCode: Int, body: Data)?
|
nonisolated(unsafe) static var config: (statusCode: Int, body: Data)?
|
||||||
|
nonisolated(unsafe) static var delaySeconds: Double = 0
|
||||||
nonisolated(unsafe) static var lastRequest: URLRequest?
|
nonisolated(unsafe) static var lastRequest: URLRequest?
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,16 +328,26 @@ private final class StubURLProtocol: URLProtocol, @unchecked Sendable {
|
|||||||
|
|
||||||
override func startLoading() {
|
override func startLoading() {
|
||||||
let cfg = StubURLProtocolStorage.config ?? (statusCode: 200, body: Data())
|
let cfg = StubURLProtocolStorage.config ?? (statusCode: 200, body: Data())
|
||||||
|
let delay = StubURLProtocolStorage.delaySeconds
|
||||||
StubURLProtocolStorage.lastRequest = request
|
StubURLProtocolStorage.lastRequest = request
|
||||||
|
|
||||||
|
// Simulate a slow transport. We honour URLProtocol.stopLoading() so
|
||||||
|
// cancellation doesn't leave the test hanging, and we yield to the
|
||||||
|
// run loop so `URLSession.data(for:)` actually observes the delay
|
||||||
|
// (a busy-wait would never let the cooperative scheduler time out).
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard self.client != nil else { return }
|
||||||
let response = HTTPURLResponse(
|
let response = HTTPURLResponse(
|
||||||
url: request.url!,
|
url: self.request.url!,
|
||||||
statusCode: cfg.statusCode,
|
statusCode: cfg.statusCode,
|
||||||
httpVersion: "HTTP/1.1",
|
httpVersion: "HTTP/1.1",
|
||||||
headerFields: ["Content-Type": "application/json"]
|
headerFields: ["Content-Type": "application/json"]
|
||||||
)!
|
)!
|
||||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||||
client?.urlProtocol(self, didLoad: cfg.body)
|
self.client?.urlProtocol(self, didLoad: cfg.body)
|
||||||
client?.urlProtocolDidFinishLoading(self)
|
self.client?.urlProtocolDidFinishLoading(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func stopLoading() {}
|
override func stopLoading() {}
|
||||||
|
|||||||
+17
@@ -151,6 +151,22 @@ targets:
|
|||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# 键盘扩展单元测试 (TEST-4)
|
||||||
|
# =========================================================
|
||||||
|
OSGKeyboardExtTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: iOS
|
||||||
|
sources:
|
||||||
|
- path: OSGKeyboardExtTests
|
||||||
|
info:
|
||||||
|
path: OSGKeyboardExtTests/Info.plist
|
||||||
|
dependencies:
|
||||||
|
- target: OSGKeyboardShared
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.ext.tests
|
||||||
|
|
||||||
schemes:
|
schemes:
|
||||||
OSGKeyboard:
|
OSGKeyboard:
|
||||||
build:
|
build:
|
||||||
@@ -163,5 +179,6 @@ schemes:
|
|||||||
config: Debug
|
config: Debug
|
||||||
targets:
|
targets:
|
||||||
- OSGKeyboardTests
|
- OSGKeyboardTests
|
||||||
|
- OSGKeyboardExtTests
|
||||||
archive:
|
archive:
|
||||||
config: Release
|
config: Release
|
||||||
|
|||||||
Reference in New Issue
Block a user