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:
@@ -125,7 +125,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
// MARK: - Press handlers
|
||||
|
||||
private func pressBegan() {
|
||||
guard state.phase == .idle else { return }
|
||||
// Allow re-entry from `.denied` and from a finished/cleared
|
||||
// `.error` so the user can simply press the mic again after
|
||||
// returning from Settings with permission granted — they
|
||||
// shouldn't have to wait for an auto-clear timer.
|
||||
switch state.phase {
|
||||
case .idle, .denied, .error:
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
guard state.mode != .off else { return }
|
||||
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
|
||||
// press (before the first Task has had a chance to flip phase to
|
||||
@@ -138,7 +147,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
let micGranted = await self.permissions.requestMicPermission()
|
||||
guard micGranted else {
|
||||
self.state.phase = .denied(.mic)
|
||||
self.scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
// iOS 18 SFSpeechRecognizer path: we explicitly ask for Speech
|
||||
@@ -152,7 +160,6 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
let speechGranted = await self.permissions.requestSpeechPermission()
|
||||
guard speechGranted else {
|
||||
self.state.phase = .denied(.speech)
|
||||
self.scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
self.startPipeline()
|
||||
@@ -357,11 +364,12 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||||
guard let self else { return }
|
||||
// Clear both .error (transient error message) and .denied
|
||||
// (permission was rejected — show the message, then return
|
||||
// to idle so the user can navigate away).
|
||||
// Only transient errors auto-clear. `.denied` is sticky: the
|
||||
// user needs the message long enough to read it AND decide
|
||||
// whether to tap "去设置" or tap the mic to retry. They
|
||||
// dismiss it implicitly by doing either of those things.
|
||||
switch self.state.phase {
|
||||
case .error, .denied:
|
||||
case .error:
|
||||
self.state.phase = .idle
|
||||
default:
|
||||
break
|
||||
|
||||
@@ -6,5 +6,9 @@
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
<key>com.apple.security.keychain-access-groups</key>
|
||||
<array>
|
||||
<string>com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -106,7 +106,11 @@ public struct KeyboardRootView: View {
|
||||
private var centreArea: some View {
|
||||
ZStack {
|
||||
VStack(spacing: Spacing.xxs) {
|
||||
TranscriptLine(phase: state.phase, transcript: state.lastTranscript)
|
||||
TranscriptLine(
|
||||
phase: state.phase,
|
||||
transcript: state.lastTranscript,
|
||||
openSettings: state.openSettings
|
||||
)
|
||||
.frame(height: 22)
|
||||
RecordButton(
|
||||
phase: buttonPhase,
|
||||
@@ -200,6 +204,7 @@ private struct TranscriptLine: View {
|
||||
|
||||
let phase: KeyboardViewController.State.Phase
|
||||
let transcript: String
|
||||
let openSettings: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -236,11 +241,21 @@ private struct TranscriptLine: View {
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
case .denied(let reason):
|
||||
Text(deniedMessage(for: reason))
|
||||
Button(action: openSettings) {
|
||||
HStack(spacing: 4) {
|
||||
Text(deniedMessage(for: reason))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
}
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(palette.warning)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(Text("Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
@@ -249,8 +264,8 @@ private struct TranscriptLine: View {
|
||||
|
||||
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
|
||||
switch reason {
|
||||
case .mic: return "麦克风被拒绝 · 请到「设置」中允许"
|
||||
case .speech: return "语音识别被拒绝 · 请到「设置」中允许"
|
||||
case .mic: return "麦克风被拒绝"
|
||||
case .speech: return "语音识别被拒绝"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user