Commit Graph

22 Commits

Author SHA1 Message Date
Rocky bf844caa7f feat(dictionary): add iCloud KVS sync, cloud ASR, and lexicon expansion
Mirror the personal dictionary through iCloud Key-Value Store with
deterministic merge rules, main-app-only sync UI, and App Group as the
keyboard runtime cache. Add cloud-engine ASR with dictionary bias and
expand the bundled custom language model lexicon.
2026-07-06 22:44:14 +08:00
Cursor Agent 13cd7b30f9 feat(flow): implement ABCD session policy and host return whitelist
- Scheme A: on-demand session start, inactivity-based expiry, handoff auto-recording
- Scheme B: cold-start overlay with swipe guidance and return alert
- Scheme C+D: HostAppURLRegistry (20 apps) and sourceApplication capture
- Settings: skip app switch toggle and inactivity duration picker
- Add LSApplicationQueriesSchemes for canOpenURL checks

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
2026-07-06 07:38:26 +00:00
Rocky 537a68552a perf(asr): speed up local Flow dictation and land CLM/keyboard refactor
Reduce perceived latency from key release to final text:
- Adaptive chunking: 2.5s first chunk + 5s follow-ups so short
  utterances start on-device recognition while still recording.
- Session-level ASR warmup and audio-format cache reuse to remove
  per-utterance cold-start of SpeechAnalyzer.
- Mirror live pipelined partials to the keyboard transcript line via
  a new flow.transcriptionPartial App Group key + Darwin ping.

Also commits the accumulated custom language model, Flow session,
keyboard extension restructure, and Xiaomi MiMo provider work in
progress on this branch.
2026-07-06 00:00:19 +08:00
Rocky 05e005e9ce feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree.

- feat(keyboard): CursorNavigation + CursorDragPad for caret movement;
  KeyboardSoundFeedback for system key click sounds
- feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet;
  TranscriptPostProcessor quality gate; retire DictionaryLearner
- feat(ui): TabBarVisibility handling; drop PageHeaderRow /
  PageHeaderConfirmButton; refresh views and localizable strings
- fix(security): move the hardcoded DeepSeek key out of
  PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift
  (seeded from .example by generate-xcodeproj.sh)
- docs(agents): add Conventional Commits versioning + bilingual changelog rules
- chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache

Custom language model / lexicon work stays on
feature/custom-language-model-asr. Changelog bullets added under
[Unreleased]; no version bump.
2026-07-05 18:27:23 +08:00
Cursor Agent 4ab60ba3dc Remove manual polish scenario UI; rely on auto AppContext only
- Hide AppContext chip and remove manual override from keyboard
- Remove polish scenario picker and custom system prompt from settings
- Remove scenario section from onboarding
- Translation prompt uses auto-detected AppContext instead of polish scenario
- Delete ScenarioPrompt, ScenarioStyleDirective, PolishScenario, and related views

Co-authored-by: Rocky <hkgood@users.noreply.github.com>
2026-07-03 09:26:51 +00:00
Mavis 947d6220ff Merge branch 'feature/intelligent-polish-and-personal-dict' into main
Resolved real conflicts introduced by translation-polish-2 (#3) and
docs refresh (#4) being merged to main during development:

- OSGKeyboardShared/Models/ProviderConfig.swift
  Add polishIntensity field alongside translationTargetLocaleId /
  polishScenarioId / handednessPreference. Both new fields coexist.

- OSGKeyboardShared/Services/AppGroupStore.swift
  Keep main's translation setters; add polishIntensity setter,
  detectedAppContext setter, and personalDictionary getter/setter.

- OSGKeyboardShared/Services/PolishingService.swift
  Merge v0.2.1 translate-and-polish path with v0.3.0 context-aware
  intelligent prompt. New polish() entry point accepts both modes;
  translation mode still uses TranslationPrompt, polish mode now
  uses buildPrompt(for:context:).

- OSGKeyboard/Views/SettingsView.swift
  Compose localEngineSettingsSection (main) with polishIntensitySection,
  languageAndModelsSection, systemPromptLinkSection, personalDictionaryLinkSection (feature).

- OSGKeyboardShared/{en,zh-Hans}.lproj/Shared.strings
  Concat scenario keys + polish intensity / app context / dictionary keys.

Auto-merged without conflict:
- AppGroupStore new methods (localASRBackend, etc.)
- FlowSessionManager, KeyboardViewController (auto-merged)
- MaterialIcon, HistoryView (auto-merged, my icons added on top)

Co-authored-by: Mavis <Mavis@hkgood.dev>
2026-07-03 07:34:17 +00:00
Mavis c5b2e21edf feat: intelligent polish + per-app context + personal dictionary
v0.3.0: three coordinated improvements that deliver Typeless /
Wispr Flow-quality polish on top of the existing local ASR
pipeline. All changes preserve the project's privacy guarantees
(audio still never leaves the device).

## 1. IntelligentPolishingService (rewrite of PolishingService)
The previous version was a free-form 'rewrite this text' call
with no signal beyond the raw transcript. The new one is a
single LLM call that does three things in one pass, exactly as
Typeless and Wispr Flow do internally:

  1. ASR error correction (homophones, near-misses, missing chars)
  2. Polish (drop filler words, fix grammar, add punctuation)
  3. Style adaptation per app context (code / email / chat / doc)

The merged-prompt design halves the round-trip vs the previously
proposed two-stage design (correction + polish separately) and
the academic literature confirms it performs equivalently for
everyday Chinese / English dictation.

## 2. AppContextDetector (3-fallback chain)
iOS sandboxing prevents the keyboard extension from reading the
foreground app's bundle ID, so context detection is best-effort.
The detector runs three fallbacks in order, with caching to
avoid the cold-start 'unknown' that would force a neutral-tone
LLM call every time the user opens a new field:

  1. Heuristic on the text at the cursor (code / email / chat / doc)
  2. 30-minute cache of the last successful detection
  3. Time-of-day + weekend heuristic as a soft default

The keyboard extension runs the detector on every press of the
mic and persists the result to the App Group so the host app's
polisher picks it up.

## 3. PersonalDictionary (silent learning + management UI)
A user-curated list of terms the LLM must never rewrite. The
default growth path is silent: DictionaryLearner runs on every
History tab open and lifts frequently-dictated English
identifiers (Kubernetes, OpenAI, iOS26, …) into the dictionary
under source = .history. Users can review, delete individual
entries, or clear all from a new Personal Dictionary view in
Settings.

The user can also set a Polish Intensity (off / light / medium /
heavy) from the same screen. Default is medium, which is what
Typeless and Wispr Flow also use.

## Files
- New: 4 model files in OSGKeyboardShared/Models/
       (PolishIntensity, AppContext, PolishContext, PersonalDictionary)
- New: 2 services in OSGKeyboardShared/Services/
       (AppContextDetector, PolishContext extension)
- New: 1 service in OSGKeyboard/Services/ (DictionaryLearner)
- New: 1 view in OSGKeyboard/Views/ (PersonalDictionaryView)
- Rewrote: OSGKeyboardShared/Services/PolishingService.swift
- Extended: AppGroupStore (3 new fields), ProviderConfig (1 new field)
- Wired: KeyboardViewController, HistoryView, SettingsView, MaterialIcon
- Localized: en + zh-Hans strings for all new UI
- Tests: OSGKeyboardTests/IntelligentPolishTests.swift (16 tests)

## Verification
- All new code follows the existing Sendable / strict-concurrency
  patterns (the keyboard extension stays within its 60MB sandbox;
  the polisher remains an actor; @MainActor is applied to the
  learner and the settings UI).
- Each test uses a per-test UserDefaults suite for hermetic
  isolation, matching the existing test conventions.
- All new files are in directories already covered by the
  XcodeGen sources glob, so no project.yml change is needed.

## Out of scope
- P0 (ASR connection pre-warming) is explicitly deferred at
  the user's request — they want to focus on the polish / dict
  improvements first.
- The Cloud polish (WebSocket) work is not touched.

## Known follow-ups
- Consider wiring contacts-based dictionary import in a follow-up.
- Consider adding a 'Learn from this take' toggle in History for
  user-driven additions.
- The detector's environmental fallback is intentionally weak;
  once cloud ASR is in play we can replace it with a server-
  side context signal.
2026-07-03 07:18:02 +00:00
Rocky bc77cabb62 feat: keyboard bottom-row layout, handedness preference, and screen wake lock
Rework the action cluster to a mic-above-bottom-row layout, add left/right
handedness setting that swaps delete and return, and keep the screen awake
during Flow recording sessions.
2026-07-02 21:01:54 +08:00
Rocky 1bdb8824ac feat: polish scenarios and stabilize keyboard layout height
Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip)
with ScenarioPrompt and style directives; drive keyboard height from
content (240pt) and use viewIsAppearing encapsulated-height offset for
smoother keyboard switches; remove redundant StatusBadge and hide system
dictation via hasDictationKey.
2026-06-29 00:08:00 +08:00
rocky 0956bb8534 feat(translation-v2): final review — dual-engine translation row + DeepSeek polish override + dead code cleanup 2026-06-25 14:25:06 +08:00
rocky 9b759281cc feat(translation-v2): refactor translationEnabled into computed + onboarding row visibility 2026-06-25 13:52:13 +08:00
rocky deddb49d56 feat(translation): add post-ASR translation mode for cloud engine
Adds an opt-in translation pipeline that reuses the existing
PolishingService + LLMClient + AppGroupStore chain. Translation is
implemented as a new PolishMode (.translate(targetLocaleId:)); all
existing call sites are unchanged.

Settings:
- New TranslationPickerRow in the language tab (Toggle + 10-locale
  picker: en/zh-Hans/zh-Hant/ja/ko/fr/de/es/ru/pt), persisted to the
  App Group so the keyboard extension can read it during live dictation.
- 5 new strings per language (en + zh-Hans).

Keyboard:
- New TranslationChip on the top bar to the right of LocaleChip;
  same Menu pattern, lets users toggle or quickly switch target
  language without leaving the keyboard.
- PolishingService dispatches .translate with a parameterised prompt
  (en/zh variants selected by provider id); PolishingService.error
  gains a translationNotAvailable case so local-engine users get a
  clear inline warning when the toggle is on but cloud is off.
- 6 new strings per language (en + zh-Hans) for the chip + banner.

Local engine policy:
- Translation is cloud-only by design (local engine stays ASR-only
  to honour the no-roundtrip promise). Chip shows a 'cloud required'
  state and raw transcript still inserts on failure — no data loss.

Build:
- OSGKeyboardShared adds TranslationLanguage enum (10 locales) and
  TranslationPrompt factory.
- 4 new files, 9 modified. xcodebuild scheme=OSGKeyboard
  config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED
  (0 warning, 0 error).

Also pins DEVELOPMENT_TEAM in project.yml for TestFlight uploads
(3 targets; Team X329MZU23S).
2026-06-25 12:45:44 +08:00
rocky c07cf4db9f refactor: drop Qwen3 CoreML ASR, add local-engine cloud polish toggle
Rolls back the v0.2.0 Qwen3 CoreML on-device ASR stack and replaces the
'local engine' UX with iOS 26 SpeechAnalyzer + DictationTranscriber only.

The 'Cloud polish after ASR' toggle (ProviderConfig.localModeCloudPolishEnabled)
lets users opt into a post-ASR DeepSeek round-trip from the local engine.
Defaults to off so the local engine stays genuinely local. New PolishError.missingAPIError
surfaces an inline 'fill in your key' warning when the toggle is on but the
Keychain is empty. DeepSeek preset default model bumped to deepseek-v4-flash.

Deleted:
  - OSGKeyboard/ThirdParty/Qwen3Speech/ (74 files, ~16k LoC)
  - OSGKeyboard/Services/ModelManager.swift (492)
  - OSGKeyboard/Services/OnDeviceModelWarmup.swift (197)
  - OSGKeyboard/Services/Qwen3ASRService.swift (257)
  - OSGKeyboard/Services/ModelDownloadSourcePicker.swift (126)
  - OSGKeyboard/Views/OnDeviceModelsView.swift (184)
  - OSGKeyboard/Views/DownloadConfirmSheet.swift (96)
  - OSGKeyboardShared/Models/OnDeviceModel.swift (140)
  - OSGKeyboardShared/Services/OnDeviceModelStatus.swift (104)
  - Qwen3ASRServiceProvider registration in OSGKeyboardApp
  - Qwen3Speech package declaration in project.yml
  - 5 .qwen3ASR enum / branch reference sites in HomeView, OnboardingView,
    LocalEngineSettingsRows, FlowSessionManager, ASRService, EngineServiceLabel
  - Two pre-existing Swift 6 strict-concurrency errors in
    LiveDictationController + FlowSessionManager (the weak [weak self] in
    detached-task MainActor.run blocks) that were blocking clean builds

Added:
  - LocalModelsGroup: 'Built-in iOS SpeechAnalyzer' badge + 'Cloud polish
    after ASR' Switch toggle
  - PolishingService: honour localModeCloudPolishEnabled; new .missingAPIKey
    error case with localised warning
  - AppGroupStore.localModeCloudPolishEnabled (mirrored into App Group
    so the keyboard extension honours the toggle during live dictation)
  - SettingsView: show provider/api sections when local-mode cloud polish
    is on so the user can paste a DeepSeek key
  - FlowSessionManager: route through PolishingService for local + polish-on
    flow; translate missingAPIKey into a polished warning
  - KeyboardViewController: handle PolishingService.PolishError.missingAPIKey
    in the keyboard-side live polish path
  - CHANGELOG v0.2.1: documents the rollback + new toggle
  - README.md / README.zh.md: engine matrix section, data flow note

Verified: xcodebuild -scheme OSGKeyboard -destination 'generic/platform=iOS Simulator'
build succeeds under SWIFT_STRICT_CONCURRENCY=complete.
2026-06-24 01:51:34 +08:00
Rocky df1c5ff32c feat: migrate on-device Qwen3 ASR to CoreML for background Flow dictation
Replace MLX GPU inference with CoreML bundles so transcription continues
while the host app is backgrounded. Adds model download and warm-up,
vendored Qwen3Speech, and updates onboarding, settings, and copy for the
~1.6 GB CoreML package (iOS 18+).
2026-06-23 00:46:58 +08:00
Rocky 5e5122f172 chore: adopt source-available license and strengthen privacy disclosures
Replace MIT with a restrictive source-available license and align README/docs copy. Add cloud polish acknowledgment, privacy manifest updates, voice history disclosure, export compliance metadata, and support links for App Store readiness.
2026-06-21 16:45:29 +08:00
Rocky ef8115508c fix: onboarding permission refresh, keyboard l10n, and flow recording
Refresh mic/speech status when returning from TCC dialogs, persist onboarding
page across Settings trips, load extension strings from .lproj bundles via
ExtL10n, and keep flow result polling alive across host-app jumps.
2026-06-19 18:35:33 +08:00
Rocky 7f059dbd45 feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start,
tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group
IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
2026-06-19 18:05:50 +08:00
Rocky 81581f0e5f chore: drop iOS 18–25 / non-iPhone support, require iOS 26
Two related cleanups the user asked for in one shot:

1. iPhone-only is now enforced at every target — Mac Catalyst and
   visionOS were never configured in `project.yml`, but the
   `OSGKeyboardShared` framework and the two test bundles were
   still defaulting to `TARGETED_DEVICE_FAMILY = "1,2"` (iPhone +
   iPad). All four targets now explicitly set `"1"`. SDK is
   `iphoneos` for everyone, no `xros` / `macosx`.

2. Deployment target bumped from iOS 18.0 to iOS 26.0 across the
   board (`project.yml` + the ext's per-target setting). With
   iOS 26 as the floor, the iOS 18–25 SFSpeechRecognizer path
   became dead code and several `#available` checks became
   always-true. Removed:

   - `AppleSpeechASR` (the entire SFSpeechRecognizer-based ASR
     backend) and the `#available(iOS 26.0, *)` factory branch.
     `ASRServiceFactory.make()` now returns `SpeechAnalyzerASR()`
     directly. SpeechAnalyzer is always fully on-device, which
     also made the `requiresOnDevice` flag meaningless.
   - `requiresOnDevice` from the `ASRService.transcribe` protocol
     signature, from `ProviderConfig`, `AppGroupStore`,
     `KeyboardState`, `AppGroupPersistor`, and the ext's
     `KeyboardViewController` (`state.requiresOnDevice`,
     `state.setRequiresOnDevice`, `persistRequiresOnDevice`).
   - `#available(iOS 17.0, *)` branch in
     `PreviewASRController.requestMicrophonePermission` and the
     ext's `PermissionManager.requestMicPermission` — both now
     just call the iOS 17+ `AVAudioApplication` API directly.
   - The `else` (iOS 18–25) branch in `SettingsView.asrEngineRow`
     — the on-device-only toggle is gone, the row is a static
     "SpeechAnalyzer active" badge. Same for the `else` branch
     in `EnginePickerSection.localSubtitle`.
   - `makeMicAuthHandler` (the iOS < 17 mic permission callback
     wrapper) from `PreviewASRController`.

   No `#available` / `@available` checks remain in the codebase
   except for the SpeechAnalyzer class itself (now unnecessary
   too, but kept for clarity — `AVAudioApplication` and
   `SpeechAnalyzer` are both iOS 17+ / iOS 26+ respectively,
   and the deployment target of 26 makes the explicit
   `@available` redundant; I left the SpeechAnalyzer class
   un-`@available` and removed the `@available(iOS 26.0, *)`
   decoration since it's no longer needed).

   The Keyboard ext's existing ASRService usage
   (`asr.transcribe(stream:locale:)`) is unchanged at the
   call-site level — just the third argument is gone.

3. Updated `info.plist` UISupportedInterfaceOrientations is
   already `[UIInterfaceOrientationPortrait]` only, which is
   correct for an iPhone-only app; no change needed.

Build: BUILD SUCCEEDED.
Tests: 22/22 pass.
Verified: `TARGETED_DEVICE_FAMILY = 1` on all four targets,
`SDKROOT = iphoneos` on all four.

🤖 Generated with Claude Code
2026-06-18 20:24:26 +08:00
Rocky a803a27a88 fix: 3 review issues from the keyboard preview / onboarding flow
1) Preview chips weren't actually buttons.
   `modeChip` and `localeChip` in `KeyboardPreviewStub` were
   decorative HStacks — no `Button`, no action, no callback. The
   chevron-down glyph made them *look* like pickers, so a user
   tapping them got nothing. The screenshot the user shared
   ("润色 ▾" / "中文(简体) ▾") shows exactly that surface.

   Fix: wrap each chip in a `Button(action: ...)` with
   `.buttonStyle(.plain)`. The stub now takes `modeId`, `localeId`,
   `onModeCycle`, `onLocaleCycle` and the sheet's `cycleMode` /
   `cycleLocale` advance the config:
     - mode cycles [off → transcribe → polish] (mirrors Settings)
     - locale cycles [auto → zh-Hans → zh-Hant → en-US → ja-JP → ko-KR]
   Mid-recording locale switches call `asr.stop()` because ASR
   sessions are bound to the locale they were started with.
   `modeId == "off"` also stops any in-flight recording so the
   disc isn't recording into a mode that won't insert.

   The mode chip's icon also follows the mode (mic.slash /
   mic / wand) as a redundant visual cue, and both chips get
   accessibility labels (preview.modeChip.cycle /
   preview.localeChip.cycle) so VoiceOver users can use them.

2) Onboarding's "Next" stays enabled when local engine is picked
   but no API key is filled in. Root cause: `ProviderConfig.isConfigured`
   checks `!apiKey.isEmpty && !baseURL.isEmpty && !model.isEmpty` —
   it never asks whether the user *needs* a key. The local engine
   (on-device ASR) doesn't round-trip through the LLM, so an
   empty key on the local path is correct, not a configuration gap.

   Fix: short-circuit `isConfigured` to `true` when
   `engineMode == "local"`. The onboarding "Next" button is
   already disabled on the API page when `!isConfigured`; this
   just makes the gate respect the engine choice. New test
   `testIsConfiguredTrueForLocalEngineWithoutAPIKey` locks the
   behaviour in (local → true, cloud → false, flip back).

3) Add the two new accessibility keys to all four
   `Localizable.strings` files (en + zh-Hans, main app + ext)
   so VoiceOver and the cycle button labels resolve in both
   languages.

Build: BUILD SUCCEEDED.
Tests: 22/22 pass (1 new).

🤖 Generated with Claude Code
2026-06-18 20:17:30 +08:00
Rocky b1635d5d35 feat: iOS-style localization + onboarding engine picker + UX fixes
Five user-flagged issues addressed in this commit. Some of the
uncommitted files belong to a prior agent pass and are included as-is
so this is a clean working tree.

1. Remove globe (nextKeyboard) button from keyboard bottom bar.
   iOS already provides a globe key in the system keyboard strip
   for next-keyboard switching, so the in-extension one was
   redundant. The bar is now: ⌫ (delete) [space] ↩ (return).
   Affected: OSGKeyboardExt/Views/KeyboardRootView.swift and
   OSGKeyboard/Views/KeyboardPreviewStub.swift (preview mirrors).

2. Fix TabView with .page style auto-jumping on TextField focus.
   SwiftUI's `.tabViewStyle(.page(...))` wraps content in a
   UIPageViewController, which has a long-standing iOS 18 bug
   where the keyboard-showing layout reflow on a TextField focus
   is misread as a horizontal swipe — the page jumps back to
   step 1 the moment the user starts typing. Replaced the
   TabView with a ZStack + conditional view + transition. We give
   up swipe-to-page, but Back/Next buttons + page dots are the
   canonical onboarding affordance and the user is one tap from
   the next page anyway.

3. Onboarding APISetupPage now offers Engine choice (Local vs
   Cloud), matching the in-app Settings page. First-run users can
   pick the on-device engine and skip the API-key setup entirely.
   Extracted the engine section from SettingsView into a shared
   `EnginePickerSection` component used by both. Cloud path
   keeps the provider + API fields; Local path shows a
   "no API key needed" confirmation card.

4. APISettingsCard test-connection error messages are now built
   with NSLocalizedString + String.localizedStringWithFormat (for
   the interpolated HTTP status / reply preview). Status badge
   labels are also NSLocalizedString-backed.

5. iOS-standard localization.
   - New `en.lproj/Localizable.strings` and `zh-Hans.lproj/
     Localizable.strings` (in both the main app and the keyboard
     extension bundles — each .appex has its own bundle).
   - `project.yml` sets `CFBundleDevelopmentRegion: en` and
     `CFBundleLocalizations: [en, zh-Hans]`; the two .lproj dirs
     are added as resources.
   - Every `Text("...")` / `Button("...")` / `Label("...")` /
     `accessibilityLabel(Text("..."))` in user-facing views was
     rewritten to use `Text("key")` (SwiftUI auto-resolves
     string-literal `LocalizedStringKey`s against the strings
     file) or `NSLocalizedString("key", comment: "")` for
     interpolated / dynamic values. The hardcoded
     "中 · EN" / "EN · 中" pattern is gone.
   - Two helper signatures that took `String` for the title/body
     of a row (`footnoteRow`, `sectionHeader`) are now
     `LocalizedStringKey` so the row labels are looked up.
   - `Label("key", systemImage: "...")` and
     `.confirmationDialog(LocalizedStringKey("key"), ...)` and
     `.navigationTitle(LocalizedStringKey("key"))` are used where
     `String` would just print the key.
   - Preview-onboarding `APISetupPage` updated to use the same
     engine picker as Settings (issue 3), with a section that
     only renders the provider + API fields when the user picks
     Cloud.

Verified on iOS 26 simulator with system language set to both
zh-Hans (default) and en: the same screen renders "按住说话,松开
即得润色文字。" / "下一步" in Chinese, and "Hold to talk. Release
for polished text, in any app." / "Next" in English — no
"中 · EN" doubling, no missing keys.

Build: BUILD SUCCEEDED.
Tests: 21/21 pass.

🤖 Generated with Claude Code
2026-06-18 19:57:45 +08:00
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
Rocky bec36befa2 feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese
This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).

Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
  (OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
  OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
  strict-concurrency checks while still serialising engine + converter
  state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
  Motion) lifted into OSGKeyboardShared so the host app and the
  keyboard extension stay in lock-step.

Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
  Automatic Signing. They are now declared in project.yml so
  'xcodegen generate' re-emits them every time. iOS Developer
  Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
  that locked out every press after the first because
  recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
  Task) so the press flow optimistically enters .recording;
  permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
  TapGesture pair separated by pressArmed, so a single tap no
  longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
  pseudo-random walk); the visible waveform is now driven by
  actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
  (auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
  first-class Chinese / English / Japanese / Korean dictation,
  with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
  routing is restored.

Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
  ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
  no longer crops the keyboard under the Spotlight bar / home
  indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
  (Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
  balanced.
- RecordButton onPressEnded is now safe to fire from a quick
  press; pressArmed prevents double-firing.

LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
  (server body is now logged at debug, never surfaced to UI);
  added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
  domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
  (Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).

Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
  new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
  Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
  blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
  reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
  has a hit-test bug on iOS 18 that ate gestures from adjacent
  TextFields (manifested as "typing jumps to a website"). It is
  now an explicit Button + contentShape + .submitLabel(.done) on
  the fields.

Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
  persistence + OpenAI request/response + HTTP error + missing
  key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
  *before* mutating, so switching providers actually resets the
  system prompt to the new default.

Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
  iOS 27 beta aborts with __abort_with_payload (dispatch
  library ABI mismatch); use an iOS 18 real device or the
  iOS 26 simulator for now.

🤖 Generated with Claude Code
2026-06-18 01:22:12 +08:00