[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:
@@ -35,6 +35,11 @@ public protocol ASRService: Sendable {
|
||||
}
|
||||
|
||||
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 final(String)
|
||||
case error(String)
|
||||
@@ -73,11 +78,16 @@ final class AppleSpeechASR: ASRService, @unchecked Sendable {
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||
if !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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user