3c11ce2903
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
197 lines
6.5 KiB
YAML
197 lines
6.5 KiB
YAML
# XcodeGen configuration for OSGKeyboard
|
|
# Run `xcodegen generate` to create OSGKeyboard.xcodeproj
|
|
# Repo only tracks project.yml; .xcodeproj is gitignored.
|
|
|
|
name: OSGKeyboard
|
|
options:
|
|
bundleIdPrefix: com.osgkeyboard
|
|
deploymentTarget:
|
|
iOS: "18.0"
|
|
developmentLanguage: en
|
|
createIntermediateGroups: true
|
|
generateEmptyDirectories: true
|
|
groupSortPosition: top
|
|
|
|
settings:
|
|
base:
|
|
SWIFT_VERSION: "6.0"
|
|
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
|
|
ENABLE_USER_SCRIPT_SANDBOXING: YES
|
|
SWIFT_STRICT_CONCURRENCY: complete
|
|
GENERATE_INFOPLIST_FILE: NO
|
|
ENABLE_MODULE_VERIFIER: YES
|
|
CODE_SIGN_STYLE: Automatic
|
|
CODE_SIGN_IDENTITY: "Apple Development"
|
|
DEVELOPMENT_TEAM: ""
|
|
MARKETING_VERSION: "0.1.0"
|
|
CURRENT_PROJECT_VERSION: "1"
|
|
|
|
targets:
|
|
|
|
# =========================================================
|
|
# 主 App
|
|
# =========================================================
|
|
OSGKeyboard:
|
|
type: application
|
|
platform: iOS
|
|
sources:
|
|
- path: OSGKeyboard
|
|
entitlements:
|
|
path: OSGKeyboard/OSGKeyboard.entitlements
|
|
properties:
|
|
com.apple.security.application-groups:
|
|
- group.com.osgkeyboard.shared
|
|
com.apple.security.device.audio-input: true
|
|
# Shared Keychain access group: the host app writes the LLM API
|
|
# key here in Settings; the keyboard extension reads it before
|
|
# each request. The first entry below becomes each process's
|
|
# default access group, so the Keychain helper does not need to
|
|
# specify kSecAttrAccessGroup explicitly.
|
|
com.apple.security.keychain-access-groups:
|
|
- com.osgkeyboard.shared
|
|
resources:
|
|
- path: OSGKeyboard/Assets.xcassets
|
|
info:
|
|
path: OSGKeyboard/Info.plist
|
|
properties:
|
|
CFBundleDisplayName: OSGKeyboard
|
|
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
|
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
|
UILaunchScreen:
|
|
UIColorName: "BackgroundColor"
|
|
UISupportedInterfaceOrientations:
|
|
- UIInterfaceOrientationPortrait
|
|
UIApplicationSceneManifest:
|
|
UIApplicationSupportsMultipleScenes: false
|
|
NSMicrophoneUsageDescription: "OSGKeyboard needs microphone access to transcribe your voice into text."
|
|
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your dictation. Audio never leaves your device."
|
|
NSAppTransportSecurity:
|
|
NSAllowsArbitraryLoads: false
|
|
CFBundleURLTypes:
|
|
- CFBundleURLName: com.osgkeyboard.ios.settings
|
|
CFBundleURLSchemes:
|
|
- osgkeyboard
|
|
settings:
|
|
base:
|
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios
|
|
TARGETED_DEVICE_FAMILY: "1,2"
|
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
|
|
dependencies:
|
|
- target: OSGKeyboardShared
|
|
embed: true
|
|
- target: OSGKeyboardExt
|
|
# Keyboard Extension is a plugin of the main App; embed it.
|
|
|
|
# =========================================================
|
|
# Keyboard Extension
|
|
# =========================================================
|
|
OSGKeyboardExt:
|
|
type: app-extension
|
|
platform: iOS
|
|
sources:
|
|
- path: OSGKeyboardExt
|
|
settings:
|
|
base:
|
|
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
|
|
entitlements:
|
|
path: OSGKeyboardExt/OSGKeyboardExt.entitlements
|
|
properties:
|
|
com.apple.security.application-groups:
|
|
- group.com.osgkeyboard.shared
|
|
# Shared with the host app so the keyboard extension can read the
|
|
# user's LLM API key. See OSGKeyboard/OSGKeyboard.entitlements
|
|
# for the full rationale.
|
|
com.apple.security.keychain-access-groups:
|
|
- com.osgkeyboard.shared
|
|
info:
|
|
path: OSGKeyboardExt/Info.plist
|
|
properties:
|
|
CFBundleDisplayName: OSGKeyboard
|
|
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
|
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
|
NSMicrophoneUsageDescription: "OSGKeyboard needs microphone access to transcribe your voice into text."
|
|
NSSpeechRecognitionUsageDescription: "OSGKeyboard uses on-device speech recognition to transcribe your dictation. Audio never leaves your device."
|
|
NSExtension:
|
|
NSExtensionAttributes:
|
|
IsASCIICapable: false
|
|
PrefersRightToLeft: false
|
|
PrimaryLanguage: "en-US"
|
|
RequestsOpenAccess: true
|
|
NSExtensionPointIdentifier: com.apple.keyboard-service
|
|
NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).KeyboardViewController
|
|
settings:
|
|
base:
|
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard
|
|
TARGETED_DEVICE_FAMILY: "1,2"
|
|
dependencies:
|
|
- target: OSGKeyboardShared
|
|
embed: false
|
|
|
|
# =========================================================
|
|
# Shared Framework (主 App 与扩展共用)
|
|
# =========================================================
|
|
OSGKeyboardShared:
|
|
type: framework
|
|
platform: iOS
|
|
sources:
|
|
- path: OSGKeyboardShared
|
|
info:
|
|
path: OSGKeyboardShared/Info.plist
|
|
settings:
|
|
base:
|
|
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared
|
|
DEFINES_MODULE: YES
|
|
SKIP_INSTALL: YES
|
|
BUILD_LIBRARY_FOR_DISTRIBUTION: NO
|
|
DYLIB_INSTALL_NAME_BASE: "@rpath"
|
|
APPLICATION_EXTENSION_API_ONLY: YES
|
|
ENABLE_MODULE_VERIFIER: YES
|
|
|
|
# =========================================================
|
|
# 单元测试
|
|
# =========================================================
|
|
OSGKeyboardTests:
|
|
type: bundle.unit-test
|
|
platform: iOS
|
|
sources:
|
|
- path: OSGKeyboardTests
|
|
info:
|
|
path: OSGKeyboardTests/Info.plist
|
|
dependencies:
|
|
- target: OSGKeyboard
|
|
settings:
|
|
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:
|
|
targets:
|
|
OSGKeyboard: all
|
|
OSGKeyboardExt: all
|
|
run:
|
|
config: Debug
|
|
test:
|
|
config: Debug
|
|
targets:
|
|
- OSGKeyboardTests
|
|
- OSGKeyboardExtTests
|
|
archive:
|
|
config: Release
|