diff --git a/CHANGELOG.md b/CHANGELOG.md index f52c82a..27d8fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > unchanged from v0.1.0. ### 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`. 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. - **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. diff --git a/OSGKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy index d0b7c49..1ea7348 100644 --- a/OSGKeyboard/PrivacyInfo.xcprivacy +++ b/OSGKeyboard/PrivacyInfo.xcprivacy @@ -16,24 +16,6 @@ NSPrivacyAccessedAPITypeReasons CA92.1 - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryFileTimestamp - NSPrivacyAccessedAPITypeReasons - C617.1 - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategoryDiskSpace - NSPrivacyAccessedAPITypeReasons - E174.1 - - - NSPrivacyAccessedAPIType - NSPrivacyAccessedAPICategorySystemBootTime - NSPrivacyAccessedAPITypeReasons - 35F9.1 - - + \ No newline at end of file diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift index efb4678..f2ddd95 100644 --- a/OSGKeyboard/Views/APISettingsCard.swift +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -27,7 +27,6 @@ struct APISettingsCard: View { title: "Base URL", placeholder: "https://api.openai.com/v1", text: $config.baseURL, - keyboard: .URL, autocap: false ) Divider().background(palette.divider) @@ -37,7 +36,6 @@ struct APISettingsCard: View { title: "Model", placeholder: "gpt-4o-mini", text: $config.model, - keyboard: .default, autocap: false ) if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { @@ -97,6 +95,7 @@ struct APISettingsCard: View { SecureField("sk-…", text: $config.apiKey) } } + .keyboardType(.asciiCapable) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) .font(TypeStyle.body) @@ -113,15 +112,20 @@ struct APISettingsCard: View { title: String, placeholder: String, text: Binding, - keyboard: UIKeyboardType, autocap: Bool ) -> some View { VStack(alignment: .leading, spacing: 6) { Text(title) .font(TypeStyle.caption) .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) - .keyboardType(keyboard) + .keyboardType(.asciiCapable) .autocorrectionDisabled(true) .textInputAutocapitalization(autocap ? .sentences : .never) .font(TypeStyle.body) diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 37f15b2..d0443ee 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -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? private var levelTask: Task? - private var didRequestMicOnce: Bool = false private var hosting: UIHostingController! @@ -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 = "" - } 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) 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 diff --git a/OSGKeyboardExt/PrivacyInfo.xcprivacy b/OSGKeyboardExt/PrivacyInfo.xcprivacy index f6553ba..19a8d70 100644 --- a/OSGKeyboardExt/PrivacyInfo.xcprivacy +++ b/OSGKeyboardExt/PrivacyInfo.xcprivacy @@ -14,6 +14,12 @@ NSPrivacyAccessedAPITypeReasons CA92.1 + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryActiveKeyboards + NSPrivacyAccessedAPITypeReasons + DDA9.1 + - + \ No newline at end of file diff --git a/OSGKeyboardExt/Services/ASRService.swift b/OSGKeyboardExt/Services/ASRService.swift index ac04782..58144f6 100644 --- a/OSGKeyboardExt/Services/ASRService.swift +++ b/OSGKeyboardExt/Services/ASRService.swift @@ -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 { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift new file mode 100644 index 0000000..3d6b34f --- /dev/null +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -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 = "" + } 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) + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/Services/PermissionManager.swift b/OSGKeyboardExt/Services/PermissionManager.swift new file mode 100644 index 0000000..cb8ed62 --- /dev/null +++ b/OSGKeyboardExt/Services/PermissionManager.swift @@ -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) 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) + } + } + } + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/Services/PolishingService.swift b/OSGKeyboardExt/Services/PolishingService.swift deleted file mode 100644 index d111731..0000000 --- a/OSGKeyboardExt/Services/PolishingService.swift +++ /dev/null @@ -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 - } - } -} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index f9b4f80..22a1182 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -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) diff --git a/OSGKeyboardExtTests/Info.plist b/OSGKeyboardExtTests/Info.plist new file mode 100644 index 0000000..2d98bea --- /dev/null +++ b/OSGKeyboardExtTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + \ No newline at end of file diff --git a/OSGKeyboardExtTests/KeyboardStateTests.swift b/OSGKeyboardExtTests/KeyboardStateTests.swift new file mode 100644 index 0000000..7a9189d --- /dev/null +++ b/OSGKeyboardExtTests/KeyboardStateTests.swift @@ -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)) + } + } +} \ No newline at end of file diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index c9cfbec..070ee05 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -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 -} + } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift new file mode 100644 index 0000000..a89429d --- /dev/null +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -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 +} \ No newline at end of file diff --git a/OSGKeyboardShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift index 650a746..d54eac5 100644 --- a/OSGKeyboardShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -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 + } } diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift new file mode 100644 index 0000000..bc70666 --- /dev/null +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -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 + } + } +} \ No newline at end of file diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index de38231..32b1f83 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -127,15 +127,24 @@ final class LLMClientTests: XCTestCase { } func testPolishThrowsOnTransportTimeout() async { - // StubURLProtocol completes synchronously, so we simulate a timeout - // by cancelling the task before the response arrives. The client - // surfaces this as `LLMError.cancelled`. + // Stub the transport so it never replies in time. The client has a + // 15 s `requestTimeout` on the URLRequest; we arrange for the stub + // 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()) - defer { StubURLProtocolStorage.config = nil } + StubURLProtocolStorage.delaySeconds = 5 + defer { + StubURLProtocolStorage.config = nil + StubURLProtocolStorage.delaySeconds = 0 + } let cfg = URLSessionConfiguration.ephemeral cfg.protocolClasses = [StubURLProtocol.self] - cfg.timeoutIntervalForRequest = 0.05 let session = URLSession(configuration: cfg) let client = OpenAICompatibleClient( @@ -144,23 +153,42 @@ final class LLMClientTests: XCTestCase { model: "m", session: session ) - // We don't assert a specific error type here — URLSession's - // cancellation surface is platform-quirky. The contract under test - // is just "throws something instead of silently returning the - // raw transcript"; that something is then handled by - // KeyboardViewController.handleFinalTranscript's catch ladder. - do { - _ = try await client.polish("hi", systemPrompt: "p") - // The stub returns 200 with empty body immediately, which would - // 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 { - // Any throwable counts as success for the "doesn't crash" - // contract. - _ = error + + let task = Task { + do { + _ = try await client.polish("hi", systemPrompt: "p") + return false // completed — unexpected + } catch { + throw 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 @@ -219,6 +247,69 @@ final class LLMClientTests: XCTestCase { 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 @@ -227,6 +318,7 @@ final class LLMClientTests: XCTestCase { /// before invoking the code under test, then reset to nil in cleanup. private enum StubURLProtocolStorage { nonisolated(unsafe) static var config: (statusCode: Int, body: Data)? + nonisolated(unsafe) static var delaySeconds: Double = 0 nonisolated(unsafe) static var lastRequest: URLRequest? } @@ -236,16 +328,26 @@ private final class StubURLProtocol: URLProtocol, @unchecked Sendable { override func startLoading() { let cfg = StubURLProtocolStorage.config ?? (statusCode: 200, body: Data()) + let delay = StubURLProtocolStorage.delaySeconds StubURLProtocolStorage.lastRequest = request - let response = HTTPURLResponse( - url: request.url!, - statusCode: cfg.statusCode, - httpVersion: "HTTP/1.1", - headerFields: ["Content-Type": "application/json"] - )! - client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) - client?.urlProtocol(self, didLoad: cfg.body) - client?.urlProtocolDidFinishLoading(self) + + // 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( + url: self.request.url!, + statusCode: cfg.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: cfg.body) + self.client?.urlProtocolDidFinishLoading(self) + } } override func stopLoading() {} diff --git a/project.yml b/project.yml index a185429..ea921d0 100644 --- a/project.yml +++ b/project.yml @@ -151,6 +151,22 @@ targets: base: 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: OSGKeyboard: build: @@ -163,5 +179,6 @@ schemes: config: Debug targets: - OSGKeyboardTests + - OSGKeyboardExtTests archive: config: Release