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
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
// KeychainTests.swift
|
||||
// OSGKeyboard · Tests
|
||||
//
|
||||
// Unit tests for the Keychain helper and the one-time migration from
|
||||
// the legacy UserDefaults slot. The Keychain is process-global in the
|
||||
// simulator, so every test cleans up after itself.
|
||||
|
||||
import XCTest
|
||||
@testable import OSGKeyboardShared
|
||||
|
||||
final class KeychainTests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
try? Keychain.deleteAPIKey()
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
try? Keychain.deleteAPIKey()
|
||||
}
|
||||
|
||||
// MARK: - Round-trip
|
||||
|
||||
func testRoundTripWriteReadDelete() throws {
|
||||
// Nothing stored yet.
|
||||
XCTAssertNil(Keychain.apiKey(), "Keychain should start empty after cleanup")
|
||||
|
||||
// Write → read.
|
||||
try Keychain.setAPIKey("sk-roundtrip-1")
|
||||
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-1")
|
||||
|
||||
// Overwrite → read new value (no orphan entries).
|
||||
try Keychain.setAPIKey("sk-roundtrip-2")
|
||||
XCTAssertEqual(Keychain.apiKey(), "sk-roundtrip-2")
|
||||
|
||||
// Delete → read nil.
|
||||
try Keychain.deleteAPIKey()
|
||||
XCTAssertNil(Keychain.apiKey())
|
||||
}
|
||||
|
||||
/// Empty string must DELETE the entry, not store an empty placeholder.
|
||||
/// Otherwise `Keychain.apiKey() ?? ""` would always return "" for any
|
||||
/// missing item, and the LLM client couldn't tell "stored but empty"
|
||||
/// (user error) from "not stored" (onboarding state).
|
||||
func testEmptyStringDeletes() throws {
|
||||
try Keychain.setAPIKey("sk-temp")
|
||||
XCTAssertEqual(Keychain.apiKey(), "sk-temp")
|
||||
|
||||
try Keychain.setAPIKey("")
|
||||
XCTAssertNil(Keychain.apiKey(), "Empty write must delete, not store empty string")
|
||||
}
|
||||
|
||||
/// Deleting a non-existent entry must be a no-op (idempotent), not an
|
||||
/// error — callers like `ProviderConfig.reset()` invoke it
|
||||
/// unconditionally.
|
||||
func testDeleteIsIdempotent() throws {
|
||||
// No prior write — should not throw.
|
||||
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
||||
XCTAssertNoThrow(try Keychain.deleteAPIKey())
|
||||
}
|
||||
|
||||
// MARK: - AppGroupStore reading
|
||||
|
||||
/// `AppGroupStore.apiKey` must consult the Keychain (not UserDefaults),
|
||||
/// otherwise the keyboard extension never sees the key set in the
|
||||
/// host app's Settings UI.
|
||||
func testAppGroupStoreReadsFromKeychain() throws {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.kc.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
try Keychain.setAPIKey("sk-from-store")
|
||||
let store = AppGroupStore(defaults: defaults)
|
||||
XCTAssertEqual(store.apiKey, "sk-from-store")
|
||||
}
|
||||
|
||||
// MARK: - Legacy migration
|
||||
|
||||
/// Pre-Keychain versions of the app stored the API key in
|
||||
/// `config.apiKey` (UserDefaults). On first init after upgrade, that
|
||||
/// value must be moved to the Keychain and removed from UserDefaults
|
||||
/// — otherwise a fresh install in a clean simulator would inherit the
|
||||
/// stale plaintext value.
|
||||
func testLegacyUserDefaultsKeyIsMigratedToKeychain() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.migration.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
// Pre-upgrade state: apiKey lives in UserDefaults.
|
||||
defaults.set("sk-legacy-plaintext", forKey: "config.apiKey")
|
||||
|
||||
// First init after upgrade: triggers the one-shot migration.
|
||||
let config = ProviderConfig(defaults: defaults)
|
||||
|
||||
XCTAssertEqual(config.apiKey, "sk-legacy-plaintext",
|
||||
"Migrated key must surface through ProviderConfig")
|
||||
XCTAssertEqual(Keychain.apiKey(), "sk-legacy-plaintext",
|
||||
"Legacy value must land in Keychain after migration")
|
||||
XCTAssertNil(defaults.string(forKey: "config.apiKey"),
|
||||
"Legacy UserDefaults entry must be cleared after migration")
|
||||
|
||||
// Second init (no legacy value left) reads from Keychain only.
|
||||
let config2 = ProviderConfig(defaults: defaults)
|
||||
XCTAssertEqual(config2.apiKey, "sk-legacy-plaintext")
|
||||
}
|
||||
|
||||
/// If both the Keychain and the legacy UserDefaults slot have a value
|
||||
/// — possible on a downgrade or a torn update — Keychain wins. We
|
||||
/// don't delete the UserDefaults value, but the running app reads from
|
||||
/// the Keychain only.
|
||||
func testKeychainWinsOverLegacyWhenBothPresent() {
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests.migration2.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
// Set both.
|
||||
try? Keychain.setAPIKey("sk-new")
|
||||
defaults.set("sk-old", forKey: "config.apiKey")
|
||||
|
||||
let config = ProviderConfig(defaults: defaults)
|
||||
XCTAssertEqual(config.apiKey, "sk-new", "Keychain value must take precedence")
|
||||
// Migration only fires when Keychain was nil — we did NOT delete
|
||||
// the legacy entry here. That's fine because Keychain is the
|
||||
// source of truth from now on; the legacy value is dead weight
|
||||
// but not incorrect.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user