[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:
@@ -232,18 +232,4 @@ public extension View {
|
||||
.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
|
||||
|
||||
public enum LLMError: Error, LocalizedError, Sendable {
|
||||
public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
||||
case invalidURL
|
||||
case noAPIKey
|
||||
case http(status: Int)
|
||||
@@ -30,6 +30,12 @@ public enum LLMError: Error, LocalizedError, Sendable {
|
||||
|
||||
public protocol LLMClient: Sendable {
|
||||
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
|
||||
@@ -40,6 +46,12 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
public let model: String
|
||||
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(
|
||||
baseURL: String,
|
||||
apiKey: String,
|
||||
@@ -74,7 +86,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
req.timeoutInterval = 15
|
||||
req.timeoutInterval = requestTimeout
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
req.httpBody = try encoder.encode(request)
|
||||
@@ -122,4 +134,13 @@ public enum LLMClientFactory {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user