Files
OSGKeyboard/OSGKeyboardShared/Services/AppGroupStore.swift
T
Rocky 3c11ce2903 feat: API key in Keychain + actionable permission-denied UX
Security: API key moves from App Group UserDefaults (plaintext on disk)
to the iOS Keychain. The host app writes in Settings; the keyboard
extension reads before each request. Cross-process sharing is via a new
shared keychain-access-group declared in both targets' entitlements.

UX: when the user denies microphone or speech-recognition permission,
the message becomes a tappable row that opens the host app's settings.
Previously the message said "请到「设置」中允许" but the only way to
actually get there was a top-bar ⚙ button that wasn't obviously
related. Auto-clear (2.4s) is now suppressed for .denied so the user
has time to read it. Re-pressing the mic from .denied re-checks
permission so the user can simply press again after granting.

API Keychain migration
----------------------
- New `OSGKeyboardShared/Services/Keychain.swift` — minimal
  `kSecClassGenericPassword` wrapper for one item
  (service "com.osgkeyboard.apikey", account "current"), backed by
  `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (no iCloud sync).
  `setAPIKey("")` deletes the entry rather than storing an empty
  placeholder so "stored but empty" stays distinguishable from
  "not stored" for the noAPIKey error path.
- `ProviderConfig.apiKey` now reads/writes through Keychain instead
  of UserDefaults. `didSet` skips the round-trip when oldValue equals
  apiKey (init reads Keychain, then assigns — without this guard the
  init write would silently re-write the same value).
- One-shot migration: on first `ProviderConfig.init` after upgrade,
  a legacy `config.apiKey` UserDefaults entry is copied to Keychain
  and removed from UserDefaults. The legacy key is renamed in code to
  `apiKeyLegacy` so future reads of `config.apiKey` from UserDefaults
  would be a bug.
- `AppGroupStore.apiKey` reads from Keychain (was UserDefaults).
- Cross-process sharing: both targets' entitlements gain
  `com.apple.security.keychain-access-groups: ["com.osgkeyboard.shared"]`.
  `com.osgkeyboard.shared` is the first entry in both, so it becomes
  each process's default access group — Keychain queries don't need to
  specify `kSecAttrAccessGroup`.

Permission-denied UX
--------------------
- `KeyboardViewController.pressBegan` now accepts `.denied` and
  `.error` as starting states (previously only `.idle`), so pressing
  the mic after returning from Settings re-checks permission
  without waiting for an auto-clear.
- `scheduleAutoClearError` no longer clears `.denied` — only
  transient `.error` is timed. `.denied` is sticky until the user
  takes action (taps the row → settings, or presses mic → re-check).
- `TranscriptLine` `.denied` case now wraps the text in a Button
  that calls `state.openSettings`, with a `chevron.right` to make
  the affordance obvious. The text was shortened to
  "麦克风被拒绝" / "语音识别被拒绝" so the chevron has room and
  the action isn't implied twice (it was previously both in the
  text and via the top-bar ⚙ button).
- VoiceOver hint on the button: "Opens the OSGKeyboard settings
  page where you can grant microphone or speech recognition access."

Tests
-----
- New `OSGKeyboardTests/KeychainTests.swift` — 6 tests covering
  round-trip, empty-string-deletes, idempotent-delete,
  AppGroupStore-reads-from-Keychain, legacy UserDefaults → Keychain
  migration, and "Keychain wins when both are present".
- `LLMClientTests` setUp/tearDown now wipes the Keychain
  (`try? Keychain.deleteAPIKey()`) and clears `StubURLProtocolStorage`
  so tests are independent across runs in the same simulator process.
- All 21 tests pass (6 new + 15 existing).

🤖 Generated with Claude Code
2026-06-18 12:41:07 +08:00

113 lines
4.0 KiB
Swift

// AppGroupStore.swift
// OSGKeyboard · Shared
//
// Convenience wrapper around App Group UserDefaults for non-Published reads.
// Used by the keyboard extension (no SwiftUI) to read config without
// instantiating an ObservableObject.
//
// `apiKey` is NOT read from UserDefaults — see `Keychain.swift`. We
// share access between the host app and the keyboard extension via a
// shared keychain-access-group declared in both targets' entitlements.
import Foundation
public struct AppGroupStore: @unchecked Sendable {
public let defaults: UserDefaults
public init(defaults: UserDefaults = AppGroup.defaults) {
self.defaults = defaults
}
// MARK: - Keys
private enum Key {
static let providerId = "config.providerId"
static let baseURL = "config.baseURL"
static let model = "config.model"
static let systemPrompt = "config.systemPrompt"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
}
// MARK: - Reads
public var providerId: String {
defaults.string(forKey: Key.providerId) ?? "openai"
}
public var baseURL: String {
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
}
/// API key lives in the Keychain (cross-process, encrypted at rest).
/// Returns "" when nothing is stored so the LLMClient can surface a
/// `noAPIKey` error rather than firing off an obviously-bad request.
public var apiKey: String {
Keychain.apiKey() ?? ""
}
public var model: String {
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
}
public var systemPrompt: String {
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
}
public var modeId: String {
defaults.string(forKey: Key.modeId) ?? "polish"
}
public var localeId: String {
defaults.string(forKey: Key.localeId) ?? "auto"
}
// MARK: - Writes
public func setModeId(_ id: String) {
defaults.set(id, forKey: Key.modeId)
}
public func setLocaleId(_ id: String) {
defaults.set(id, forKey: Key.localeId)
}
// MARK: - Client
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model
)
}
// MARK: - Defaults
/// Per-provider default system prompt. We bias the prompt by the
/// provider's *primary* language so Chinese LLMs naturally return
/// Chinese for Chinese input, and English LLMs stay terse.
public static func defaultSystemPrompt(for providerId: String) -> String {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return """
你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字:
1) 保留原意,不编造事实;保持输入语言。
2) 添加恰当的标点、大小写、段落。
3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。
4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。
5) 只输出润色后的正文,不要解释、不要加引号。
"""
default:
return """
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
1) Preserve the user's original intent and meaning; do not invent facts.
2) Add proper punctuation, capitalization, and paragraph breaks.
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like).
5) Output in the same language as the input. No quotes, no explanation, no preamble.
"""
}
}
}