[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:
hkgood
2026-06-18 12:27:15 +08:00
parent 38ad66f07d
commit 79be7384dd
18 changed files with 696 additions and 303 deletions
+36 -180
View File
@@ -20,7 +20,6 @@
import UIKit
import SwiftUI
import AVFoundation
import Speech
import OSGKeyboardShared
@objc(KeyboardViewController)
@@ -29,84 +28,11 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - View model
@MainActor
public final class State: ObservableObject {
public init() {}
public enum Phase: Equatable {
case idle
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
}
/// Typealias so existing call sites (`KeyboardViewController.State`)
/// keep compiling unchanged. The actual class lives in
/// `OSGKeyboardShared` so unit tests can `@testable import` it
/// without dragging in the `app-extension` linking surface.
public typealias State = KeyboardState
// MARK: - State
@@ -114,11 +40,12 @@ public final class KeyboardViewController: UIInputViewController {
private let audio = AudioCaptureService()
private let asr: ASRService = ASRServiceFactory.make()
private let polisher = PolishingService()
private let permissions = PermissionManager()
private let persistor = AppGroupPersistor()
private var session: AudioCaptureService.Session?
private var asrTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var didRequestMicOnce: Bool = false
private var hosting: UIHostingController<KeyboardRootView>!
@@ -132,7 +59,7 @@ public final class KeyboardViewController: UIInputViewController {
inputView?.allowsSelfSizing = true
installStateActions()
installSwiftUI()
loadPersistedLocale()
loadPersistedConfig()
}
public override func viewWillDisappear(_ animated: Bool) {
@@ -186,38 +113,13 @@ public final class KeyboardViewController: UIInputViewController {
self.hosting = host
}
private func loadPersistedLocale() {
guard AppGroup.isAvailable else {
state.phase = .error("App Group 未配置")
return
private func loadPersistedConfig() {
switch persistor.load(into: state) {
case .loaded:
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
@@ -233,7 +135,7 @@ public final class KeyboardViewController: UIInputViewController {
state.phase = .requestingPermissions
Task { @MainActor [weak self] in
guard let self else { return }
let micGranted = await self.requestMicPermission()
let micGranted = await self.permissions.requestMicPermission()
guard micGranted else {
self.state.phase = .denied(.mic)
self.scheduleAutoClearError()
@@ -247,7 +149,7 @@ public final class KeyboardViewController: UIInputViewController {
// iOS 26 SpeechAnalyzer path (planned for the next release)
// does not expose an explicit request API the framework
// prompts via the same plist key on first use.
let speechGranted = await self.requestSpeechPermission()
let speechGranted = await self.permissions.requestSpeechPermission()
guard speechGranted else {
self.state.phase = .denied(.speech)
self.scheduleAutoClearError()
@@ -279,6 +181,8 @@ public final class KeyboardViewController: UIInputViewController {
var lastPartial: String = ""
for await event in events {
switch event {
case .capability(let onDevice):
self.state.onDeviceSupported = onDevice
case .partial(let s):
lastPartial = s
self.state.lastTranscript = s
@@ -286,7 +190,7 @@ public final class KeyboardViewController: UIInputViewController {
let transcript = s.isEmpty ? lastPartial : s
self.handleFinalTranscript(transcript)
case .error(let m):
self.state.phase = .error("ASR: \(m)")
self.state.phase = .error(.asr(m))
self.scheduleAutoClearError()
}
}
@@ -315,6 +219,9 @@ public final class KeyboardViewController: UIInputViewController {
state.phase = .idle
}
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) {
@@ -339,19 +246,19 @@ public final class KeyboardViewController: UIInputViewController {
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
self.state.phase = .idle
} catch LLMError.noAPIKey {
// Don't silently insert the raw transcript the user
// thinks they're getting polished text when really no
// key is configured. Show a precise, actionable error.
self.state.phase = .error("未配置 API Key · 请在主 App 设置中填写")
self.scheduleAutoClearError()
} catch let error as LLMError {
switch error {
case .noAPIKey:
// Don't silently insert the raw transcript the user
// thinks they're getting polished text when really no
// key is configured. Show a precise, actionable error.
self.state.phase = .error(.llm(error), message: "未配置 API Key · 请在主 App 设置中填写")
self.scheduleAutoClearError()
case .http(401):
self.state.phase = .error("API Key 无效 (401) · 请检查主 App 设置")
self.state.phase = .error(.llm(error), message: "API Key 无效 (401) · 请检查主 App 设置")
self.scheduleAutoClearError()
case .http(429), .rateLimited:
self.state.phase = .error("API 限流 (429) · 请稍后再试")
self.state.phase = .error(.llm(error), message: "API 限流 (429) · 请稍后再试")
self.scheduleAutoClearError()
case .cancelled:
// User-initiated cancellation (e.g. mode switch mid-
@@ -363,12 +270,12 @@ public final class KeyboardViewController: UIInputViewController {
return
default:
// Other LLMError variants (transport / decoding /
// invalidURL / cancelled) fall back to raw transcript
// + generic error badge, same as the catch-all below.
// invalidURL) fall back to raw transcript + generic
// error badge, same as the catch-all below.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
let msg = error.errorDescription ?? "Polishing failed — inserted raw."
self.state.phase = .error(msg)
self.state.phase = .error(.llm(error), message: msg)
self.scheduleAutoClearError()
}
} catch {
@@ -379,7 +286,7 @@ public final class KeyboardViewController: UIInputViewController {
self.state.lastTranscript = ""
let msg = (error as? LocalizedError)?.errorDescription
?? "Polishing failed — inserted raw."
self.state.phase = .error(msg)
self.state.phase = .error(.unknown(msg), message: msg)
self.scheduleAutoClearError()
}
}
@@ -390,7 +297,7 @@ public final class KeyboardViewController: UIInputViewController {
private func persistMode(_ m: State.InputMode) {
let isRecording = state.phase == .recording
state.mode = m
AppGroupStore().setModeId(m.rawValue)
persistor.persist(mode: m)
if isRecording {
if m == .off {
// Switching to .off while recording: drop the partial
@@ -411,58 +318,7 @@ public final class KeyboardViewController: UIInputViewController {
private func persistLocale(_ id: String) {
state.localeId = id
AppGroupStore().setLocaleId(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)
}
}
}
persistor.persist(localeId: id)
}
// MARK: - Open host app
+7 -1
View File
@@ -14,6 +14,12 @@
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>CA92.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryActiveKeyboards</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>DDA9.1</string>
</dict>
</array>
</dict>
</plist>
</plist>
+11 -1
View File
@@ -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
}
}
}
+19 -5
View File
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
state.setLocale(newId)
}
Spacer(minLength: 0)
StatusBadge(phase: state.phase)
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium))
@@ -229,8 +229,8 @@ private struct TranscriptLine: View {
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
}
case .error(let msg):
Text(msg)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
@@ -287,6 +287,11 @@ private struct StatusBadge: View {
@Environment(\.themePalette) private var palette: ThemePalette
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 {
Group {
@@ -296,7 +301,11 @@ private struct StatusBadge: View {
case .requestingPermissions:
EmptyView()
case .recording:
dot(color: palette.recordRed, label: "REC")
if onDeviceSupported {
dot(color: palette.recordRed, label: "REC")
} else {
dot(color: palette.warning, label: "REC ⚠️", showWarning: true)
}
case .processing:
dot(color: palette.accent, label: "···")
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) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
if showWarning {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.warning)
}
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)