Commit Graph

55 Commits

Author SHA1 Message Date
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 0e3b75ed2d Merge pull request #4 from hkgood/docs/refresh-readme-and-pages-for-v0.2.1
docs: refresh README and GitHub Pages for v0.2.1
2026-07-03 15:14:10 +08:00
Mavis 2e6e70247a docs: refresh README and GitHub Pages for v0.2.1
Sync project documentation with the current v0.2.1 implementation
(Tap-to-toggle recording, 5-step onboarding, Flow session model,
SpeechAnalyzer-only local engine, deepseek-v4-flash default).

README.md / README.zh.md
- Replace 'press-and-hold / 按住说话' with the v0.2.0+ tap-to-toggle
  interaction; add the 60-second per-take cap to feature bullets.
- Update 3-step onboarding to the real 5-step flow.
- Correct architecture diagram: AudioCaptureService in the extension
  is legacy/unused, PolishingService lives in OSGKeyboardShared, and
  the FlowSession* / LiveDictation* services are now in the tree.
- Replace the legacy single-pipeline data flow with the actual Flow
  session data flow (keyboard -> App Group -> host app -> chunked
  ASR -> LLM polish -> App Group -> insertText).
- Move the stale 'main -> 0.2 branch rename' banner into a project
  status section under the new 'v0.2.1' badge.
- Expand 'Known limitations' with 60s/3min caps, 60MB sandbox note,
  URL scheme caveat, and the v0.2.0->v0.2.1 on-device LLM rollback.
- Add a 'Development' section pointing to tests, CI, logging policy.
- Add version badge, license link, privacy policy link.

docs/index.html (GitHub Pages landing)
- Mirror the same copy fixes in both EN and ZH i18n tables
  (verified: 50 keys each, no missing translations).
- Grow feature grid from 6 to 8 cards (add 'Flow session' and
  'Local + cloud polish'), grow steps from 3 to 5.
- Bump footer to 'v0.2.1 · source available, non-commercial',
  add License link.

docs/privacy.html + docs/privacy/index.html
- Update 'Last updated' to July 3, 2026, tag v0.2.1.
- Add explicit iOS 26+ requirement, Flow session explanation,
  children's privacy section, policy-changes section, license
  reference, and rocky.hk@gmail.com contact.
- Make the 'no raw audio upload to any server' claim explicit.

Verification
- grep confirms no leftover 'press and hold' / '按住' / 'press to'.
- HTML structure validated (well-formed on all three pages).
- EN/ZH i18n keys are symmetric (50 each).
2026-07-03 05:25:55 +00:00
Rocky 17572fa928 Merge pull request #3 from hkgood/feature/translation-polish-2
Feature/translation polish 2
2026-07-03 13:25:51 +08: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 4fec0da7f0 feat(translation-polish-2): local-engine dedicated section + PreconfiguredKeys for DeepSeek
- Add PreconfiguredKeys.swift: placeholder constant for the DeepSeek
  API key the local engine uses. DEBUG build asserts at launch when
  the placeholder is still in place so nobody ships an always-401
  build by accident. Replace TODO_FILL_LATER_DEEPSEEK_KEY before
  distributing.

- PolishingService.polishRemote:
  * Adopted (A) path: when effective provider is DeepSeek, take the
    apiKey from PreconfiguredKeys.deepseek instead of store.apiKey.
    Refuse the round-trip with PolishError.missingAPIKey when the
    placeholder is still in place.
  * Adopted fix for pre-existing typo: store.model.isEmpty ?
    preset.defaultModel : store.model (the right-hand side was
    defaultModel on both branches, silently ignoring the user's
    custom model field).

- LocalEngineSettingsRows.LocalModelsGroup:
  * Dropped the inline 'uses DeepSeek' caption — it added visual
    weight without telling the user anything they couldn't infer.
  * Translation row now lives inside the group so the local engine
    reads as one cohesive card.
  * Group owns its surface card chrome (palette.surface + rounded
    border) — callers no longer need to wrap it externally.

- SettingsView:
  * languageAndModelsSection: dropped the trailing TranslationPickerRow
    and the inner LocalModelsGroup (now lifted to its own section).
  * New localEngineSettingsSection: renders only when
    engineMode == 'local', wrapping LocalModelsGroup with a
    'settings.localEngine.title' header.
  * body: added the new section between languageAndModelsSection and
    the cloud-only systemPromptLinkSection.

- Localizable.strings (en + zh-Hans):
  * Removed settings.localModels.cloudPolish.caption (no references).
  * Added settings.localEngine.title = 'Local engine' / '本地引擎'.

DeepSeek key pre-fill tripwire + local-engine UI cohesion. No main
merge, no PR.
2026-06-25 17:34:02 +08:00
rocky 93b6aa6c02 feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup
- Add LLMProvider.isUserSelectable (default true) and filter
  ProviderPickerSection on it; next pass can hide non-user presets
  (e.g. a future DeepSeek key-preset) without changing call sites.
- KeyboardRootView: hide TranslationChip when off (matches user's
  mental model of an opt-in feature), drop the 'warming' branch
  (Qwen3 download UX was removed with the backend in v0.2.0), unify
  chip pill height to minHeight 28 + vertical 6 for visual rhythm
  across all topbar chips.
- TranslationChip: drop isLocal warning path — both engines now
  run the translate-and-polish step (local routes through DeepSeek
  via ProviderConfig.localModeProviderId).
- OnboardingView: cloud engine branch now wraps the translation
  row in the same surface card chrome as the local branch.
- Strings: drop keyboard.models.warming (no longer referenced).

DeepSeek key pre-fill deferred to a follow-up.
2026-06-25 15:16:33 +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
zhongshu 4d92999347 feat(translation-ui): tighten settings/onboarding UX around the translation feature
UI refinements on top of the translation pipeline (feature/translation@HEAD):

1. Onboarding engine page now hosts a translation row.
   APISetupPage renders the same TranslationPickerRow used in the
   language tab, so first-time users can pick a target language
   before they ever see the keyboard. Same persisted bindings; same
   'needs cloud' hint when the local engine is active.

2. Local engine hides the provider / API card unconditionally.
   Removed the 'local + cloud polish on → show API fields' branch
   from SettingsView. Provider/base URL/API key/model controls have
   no use in local mode (translation is cloud-only anyway), and
   exposing them invited users to fill in a DeepSeek key they
   can't use.

3. 'Cloud polish after ASR' toggle loses its long subtitle.
   The descriptive copy in LocalEngineSettingsRows.cloudPolishRow
   was a wall of text that explained things visible elsewhere in
   Settings. Title + switch is enough; the CloudPolishDisclosureBanner
   (rendered by EnginePickerSection when cloud is active) already
   covers the 'this sends text to your API' disclosure.

4. Translation row becomes a single dropdown with a 'Don't
   translate' default.
   TranslationPickerRow replaced with a one-row Menu picker:
   '不翻译 / English / 中文 (简体) / 中文 (繁體) / 日本語 / 한국어 /
   Français / Deutsch / Español / Русский / Português'.
   '不翻译' maps to translationEnabled=false; any locale maps to
   translationEnabled=true + translationTargetLocaleId=<id>.
   TranslationLanguageCatalog gains an 'off' sentinel so the picker's
   single binding stays a plain String.

5. Language tab reorder.
   SettingsView.languageAndModelsSection: ASR locale ('识别语言')
   now sits above the local-models block; translation row sits at
   the bottom. The reading order follows the pipeline direction
   (input → post-processing → post-post-processing).

Localization:
  - 'settings.translation.title' → '翻译' / 'Translation'
  - new 'settings.translation.off' / 'settings.translation.hint.needsCloud'
  - dropped unused subtitle / target-language keys

xcodebuild scheme=OSGKeyboard config=Debug destination=iPhone 17
Simulator: BUILD SUCCEEDED (0 warning, 0 error).
2026-06-25 13:23:54 +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 dc9697bf3d docs(zh): 同步 banner 和 XcodeGen 构建说明到中文 README 2026-06-24 17:06:45 +08:00
rocky bdb9379ab0 docs: add XcodeGen build setup instructions to README 2026-06-24 16:29:07 +08:00
rocky 752d2741dc docs: add banner about default branch rename main→0.2 2026-06-24 15:59:12 +08:00
rocky 5dff95a227 ci: trigger on 0.1/0.2 branches after rename 2026-06-24 15:58:59 +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 39690c0a93 Merge pull request #2 from hkgood/cursor/qwen3-coreml-on-device-asr
feat: migrate on-device Qwen3 ASR to CoreML for background Flow dicta…
2026-06-23 22:46:33 +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 993784d223 feat: polish first-launch onboarding and post-setup permission guidance
Streamline the five-step flow with smarter skip logic, a centered welcome intro, clearer zh copy, keyboard setup detection via the extension, and home tips when permissions are still missing after onboarding completes.
2026-06-21 16:14:13 +08:00
Rocky 642c97c540 fix: privacy manifests, history/settings headers, and dock contrast
Resolve TestFlight ITMS-91056 by correcting privacy manifest keys and reason arrays. Add left-aligned page headers with circular confirm buttons, keep the keyboard surface transparent, and improve unselected dock icon contrast in light and dark mode.
2026-06-20 14:10:18 +08:00
Rocky db5151ad9a feat: flow session reliability, keyboard UX, and audio pre-roll fix
Renew voice sessions while the host app stays foreground, auto-start flow from the keyboard with a Start action, and prevent leading audio loss via pre-roll buffering and faster signal polling.
2026-06-20 00:57:16 +08:00
Rocky 38a36ed504 feat: tab shell, home/onboarding UI, settings and keyboard polish
Introduce MainTabView with history and Liquid Glass dock; refresh home and
onboarding layouts; unify accent green and provider localization; refine
settings/API rows; redesign keyboard mic and flanking controls; set Utility
iPhone-only targets and Flow session reliability fixes.
2026-06-20 00:31:58 +08:00
Rocky e2ccc10cfd fix: embed Keyboard.strings in extension bundle for keyboard l10n
XcodeGen merged Localizable.strings into the main app only, so the keyboard
extension had no .lproj files and NSLocalizedString returned raw keys. Use a
dedicated Keyboard.strings table and explicit buildPhase: resources entries.
2026-06-19 18:44:32 +08:00
Rocky 6b749802f0 fix: stop actool crash from AppIcon.icon + appiconset conflict
Icon Composer AppIcon.icon must not be compiled alongside AppIcon.appiconset;
actool crashes with a nil insert error. Use appiconset only and exclude .icon
bundles from the target.
2026-06-19 18:39:33 +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 275fc81104 feat: complete Phase 4 batch F, keyboard i18n, and Pages app icon
Remove KeyboardL10n hard-coded fallbacks in favor of ExtL10n and extension
Localizable.strings. Add Flow session expiry hints, Darwin cross-process
notifications, session monitor on the keyboard, and app icon on GitHub Pages.
2026-06-19 18:20:25 +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 6148d05093 chore: align iOS 26 capability docs and UI localization
Keep repository messaging consistent with the implemented iOS 26 SpeechAnalyzer path, and remove mixed hardcoded copy by routing remaining UI/error text through localized string keys.
2026-06-18 22:27:22 +08:00
Rocky d22154e119 fix: preview installTap crash on simulator (0 Hz AURemoteIO output)
The previous build crashed on the first record-tap with:

  *** Terminating app due to uncaught exception
      'com.apple.coreaudio.avfaudio', reason:
      'Failed to create tap due to format mismatch,
       <AVAudioFormat: 1 ch, 48000 Hz, Float32>'

The crash surfaced at `[AVAudioNode installTap:...]` but the
*root* cause was one frame deeper, in the underlying
`AURemoteIO::enable` call:

  AURemoteIO.cpp:1135  failed: -10851
    (enable 1, outf< 2 ch, 0 Hz, Float32, deinterleaved>
           inf< 1 ch, 48000 Hz, Float32>)

`AURemoteIO` is a two-direction Audio Unit (input + output).
`installTap` triggers `AURemoteIO::enable(1)` which tries to
initialize *both* directions at once. On the iOS Simulator, the
output direction reports a `0 Hz` "speaker" because the
simulator has no real speaker, and `enable` fails with
`kAudioUnitErr_FormatNotSupported` (-10851). The error then
bubbles up through `installTap` as the misleading "format
mismatch" — the input format we passed was correct, but the
output side broke the whole `enable` call.

The previous code asked the audio session for `.playAndRecord`
mode, which requires the output direction to be enabled. On a
real device, the speaker is 44100/48000 Hz and `.playAndRecord`
works. On the simulator, it traps.

Fix (two parts):

1) `start()` audio session now uses `.record` instead of
   `.playAndRecord`. The preview never plays back audio — it
   just records from the mic and hands the buffers to
   `SpeechAnalyzer`. `.record` skips the output direction
   entirely, so the simulator's 0 Hz speaker isn't a problem.
   The real keyboard extension (`OSGKeyboardExt`) keeps
   `.playAndRecord` because it runs on real devices and may
   want to play click sounds / haptic feedback — only the
   preview needs the simulator-friendly category.

2) `startEngineAndASR` adds a pre-flight check: refuse to
   call `installTap` when the input bus reports
   `sampleRate == 0` or `channelCount == 0`. The `installTap`
   failure mode is an `NSException` (Objective-C), not a Swift
   `Error` — `try`/`catch` can't intercept it, so the only safe
   defence is to never call it with a placeholder/unconfigured
   bus. We saw `0 Hz` inputs on the simulator when the host
   mic permission wasn't granted to CoreSimulator, and on
   devices with an unexpected audio-session state. The check
   sets a clear `.error` phase ("Microphone unavailable (hw
   format X Hz / Y ch)") instead of trapping the app.

Why this also unblocks voice-to-text: the previous
"no transcription" symptom was just the crash at step 7 of
the start pipeline — the ASR never saw a single audio frame
because the tap never installed. With the crash fixed, the
`SpeechAnalyzer` pipeline can now actually receive buffers
and emit `.partial` / `.final` events. Whether the *content*
of those events is meaningful depends on the simulator's mic
quality and the chosen locale (both separate concerns from
this fix).

Tests: 27/27 pass (no behavioural test for the pre-flight
check — the check is 4 lines of obvious defence, and
testing it would require either mocking the audio engine
or installing CoreSimulator's host-mic-bridge, neither of
which is worth the cost).
Build: BUILD SUCCEEDED.

🤖 Generated with Claude Code
2026-06-18 21:41:56 +08:00
Rocky 12608fe618 design: drop disc drop-shadow, scale up processing-state spinner
Two design cleanups, both for visual polish on the record disc
(the hero control of the keyboard preview AND the live keyboard
extension). The shadows were a leftover from the iOS 18 mockup
that visually fought the brand-green disc; the processing-state
spinner was the system default at 1.1x, which on a 96-120pt disc
reads as "a dot in the middle" rather than "an active process".

Changes:

1) Remove the disc body's drop shadow on BOTH surfaces.
   - `KeyboardPreviewStub.swift:244` — `.shadow(color: .black.opacity(0.4),
     radius: 10, y: 6)` deleted. Hardcoded dark shadow on a
     light surface, completely theme-blind (looked like a 2009
     skeuomorphic button).
   - `RecordButton.swift:112` — `.shadow(color: .black.opacity(0.45),
     radius: 14, y: 8)` deleted. Same problem on the real
     keyboard's disc, plus the shadow competed with the existing
     "halo" radial gradient and the outer breathing ring.
   The internal "ambient glow" (radial green gradient inside the
   disc on idle) is NOT touched — that's brand colour, not a
   drop shadow, and matches the live keyboard's idle accent.

2) Scale up the processing-state spinner on BOTH surfaces.
   - Preview: `ProgressView().tint(.white).scaleEffect(1.1)` →
     `ProgressView().progressViewStyle(.circular).tint(palette.textPrimary)
     .scaleEffect(2.5)`.
     - 1.1x (~22pt) → 2.5x (~50pt) = 52% of the 96pt disc.
     - Tint changed from hardcoded `.white` to `palette.textPrimary`
       because the processing-state disc gradient is
       `surfaceElevated → surface` (light grey in light mode);
       a white spinner on a light grey disc is invisible regardless
       of scale.
   - Real keyboard: `ProgressView().scaleEffect(1.2)` →
     `ProgressView().scaleEffect(2.5)`. Same absolute spinner
     size (~50pt) — 42% of the 120pt disc — so the user gets the
     same visual weight whether they're looking at the in-app
     preview or the live keyboard. Tint was already
     `palette.textPrimary` so no change there.

Proportion rationale: ~50% of the disc diameter is the sweet
spot. 100% would crowd the disc's edge, <30% reads as
"decoration" rather than "process". 50pt absolute size matches
the same spinner across both surfaces — same weight, different
disc sizes — so the user doesn't have to re-learn the visual
language when they switch from preview to real keyboard.

Tests: 27/27 pass (no behavioural change, just visual).
Build: BUILD SUCCEEDED.

🤖 Generated with Claude Code
2026-06-18 21:22:33 +08:00
Rocky 56d0da0a51 fix: preview disc stuck at .processing after stop
The previous code path for the keyboard preview's ASR controller
cancelled the consumer task at the exact moment it closed the
audio stream:

    asrTask?.cancel()       // ← kills the .final consumer
    asrTask = nil
    ...
    bufferContinuation?.finish()   // tells ASR "no more audio"

The cancellation cascaded: the for-await on the events stream
exited → the AsyncStream's `continuation.onTermination` fired →
ASR.cancel() ran → producer task was marked cancelled → the
producer's `if !Task.isCancelled { yield(.final) }` guard
suppressed the .final event. Net result: nobody told the UI to
leave `.processing`, and the disc sat there forever.

Fix (4 changes):

1) `stop()` no longer cancels the consumer. The consumer task
   exits naturally when the events stream finishes, sees the
   `.final` event the producer still yields, and transitions
   the phase out of `.processing`. This is the primary fix.

2) `start()` cancels any leftover `asrTask` at the entry point
   as a safety net — covers the "user smashes the disc twice
   quickly" race where a previous consumer is still draining.

3) `stop()` schedules a 3-second safety-net Task: if the ASR
   pipeline never produces a `.final` (analyzer hang, system
   glitch), force the phase back to `.idle` so the user isn't
   stuck. Normal recordings complete well under 3 seconds, so
   the timeout is only hit on the unhappy path.

4) `KeyboardPreviewSheet` adds `.onDisappear { asr.stop() }`
   so closing the sheet mid-recording releases the
   AVAudioSession and mic. `stop()` is idempotent (no-op on
   non-recording phases), safe to call here.

State machine: `phase = .processing` now has TWO transition
paths out — the consumer receiving `.final` (fast path) and
the 3-second safety net (fallback). Both are required; the
fast path is the common case, the fallback is the
"guaranteed-progress" guarantee.

Testability: `asrTask` was `private`; relaxed to `internal` so
the regression test in
`OSGKeyboardTests/PreviewASRControllerStateTests.swift` can
install a known consumer task and assert `stop()` does not
cancel it. The class is `@MainActor` so Swift 6 isolation
rules still prevent production code outside the class from
racing on it.

Tests:
- `testStopDoesNotCancelConsumerTask` — primary fix regression.
- `testStopIsIdempotent` — `.onDisappear` after a manual stop
  doesn't misbehave.
- 27/27 tests pass (25 existing + 2 new).
- BUILD SUCCEEDED.

🤖 Generated with Claude Code
2026-06-18 21:00:18 +08:00
Rocky a227309059 fix: feed DictationTranscriber Int16 PCM, not Float32
The keyboard preview crashed on first record with a
`__abort_with_payload` deep inside Speech's
`DictationTranscriber`. The disassembly surfaced three
preconditions checked before a `brk #0x1`:

  +620  "Audio sample data must be 16-bit signed integers"
  +848  "Multi-channel audio is not supported"
  +1072 "Client info not fully initialized"

We hit the first one. `DictationTranscriber` (iOS 26's new
`SpeechAnalyzer`-backed engine) is strict about its input
format: only Int16 PCM, not the Float32 PCM that the iOS 18
`SFSpeechRecognizer` path accepted. Our audio-tap and
`AudioBufferSnapshot.samples: [Float]` are Float32 all the
way down — that was the SFSpeech shape, and the previous
`AppleSpeechASR` adapted internally. With iOS 26 as the
deployment target, the only ASR backend is
`SpeechAnalyzerASR`, and the conversion needed to happen at
the `AnalyzerInput` boundary.

Fix:
- `transcribe` builds the `AVAudioFormat` as
  `.pcmFormatInt16, 16 kHz, 1 ch, interleaved: true` (the
  canonical layout for Int16 Speech input).
- `makeInputStream` runs the per-sample conversion
  `Int16(round(clamp(s * 32767, -32768, 32767)))` into the
  `AVAudioPCMBuffer`'s `int16ChannelData[0]`. The explicit
  clip is required (a `s == 1.5` from a gain-overflow at the
  audio-engine boundary would otherwise wrap to a negative
  Int16 after the implicit truncation). `round()` (not
  truncate) preserves DC balance — `0.5` quantises to
  `+16384`, not `+16383`, matching what audio DAWs expect.
- The conversion helper is exposed as
  `ASRServiceFactory.convertFloat32ToInt16` so unit tests
  can lock the math without instantiating the full pipeline.

Why not change `AudioBufferSnapshot` to `[Int16]` instead
(see earlier first-principles discussion): the snapshot is a
transport format that both `AudioCaptureService` (in the
ext) and `PreviewASRController` (in the main app) produce.
Float32 is the natural shape coming out of `AVAudioEngine`,
and pushing the conversion to the ASR service keeps the
transport contract platform-agnostic — a future second
backend with different format needs can have its own
adaptation without dragging everyone else.

Tests:
- `testFloat32ToInt16EdgeCases` — 0, ±1, ±0.5, ±1.5
  (gain-overflow case).
- `testFloat32ToInt16RoundTrip` — quantisation step is
  1/32767 (so the asymmetric Int16 range is honoured: -32768
  has no exact Float source).
- `testFloat32ToInt16Empty` — `sourceCount == 0` with nil
  pointers is a no-op (function guards on count before
  dereferencing).
- All 25 tests pass (22 existing + 3 new).
- BUILD SUCCEEDED.

🤖 Generated with Claude Code
2026-06-18 20:41:12 +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 ffeb18c8d3 fix: SFSpeechRecognizer callback dispatch_assert_queue (function ref)
Same `dispatch_assert_queue_fail` family as `3e3eb0f` (audio tap),
but on a different code path: `SFSpeechRecognizer.requestAuthorization`.
The crash signature was the giveaway:

  closure #1 in closure #2 in PreviewASRController.start(locale:)+96
  thunk for @escaping @callee_guaranteed (@unowned
      SFSpeechRecognizerAuthorizationStatus) -> ()
  __TCCAccessRequest_block_invoke_8

`closure #2` is the `withCheckedContinuation` body, `closure #1` is
the requestAuthorization callback. Both end up attributed to
`start(locale:)` — which is `@MainActor` — because Swift 6 *inlined*
the `nonisolated static func` helper (`e8a0310`'s fix) back into the
caller. After inlining, the inner closure is retyped in the
caller's @MainActor context, the runtime asserts main-queue, and
TCC's reply queue (where the callback actually fires) trips the
assert.

Why `nonisolated static` alone is not enough: the optimizer
aggressively inlines small async helpers, and when it does, the
isolation of the closures inside the body re-infers from the
inlined context. Same trap as the audio tap — function bodies
defined inside a `@MainActor` method can't escape @MainActor just
because their enclosing function was marked nonisolated. The
*function-reference* pattern (the fix in `3e3eb0f`) is what works:

  SFSpeechRecognizer.requestAuthorization(
      Self.makeSpeechAuthHandler(continuation: cont)
  )

`makeSpeechAuthHandler` is `nonisolated static`, returns the
`(Status) -> Void` handler, and crucially the closure body is
*constructed* in a nonisolated context. When Swift tries to inline
the callback, it has nothing to inline — only a function reference
to a non-inlinable (because it crosses an isolation boundary
implicitly) helper. The runtime sees a nonisolated closure on a
non-main queue and is satisfied.

Same treatment for the iOS < 17 mic path (`makeMicAuthHandler`),
which has the same TCC-reply-queue dispatch shape.

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

🤖 Generated with Claude Code
2026-06-18 20:15:08 +08:00
Rocky 3e3eb0f766 fix: audio tap dispatch_assert_queue crash (nonisolated function ref)
Same dispatch_assert_queue_fail as the prior SFSpeechRecognizer
crash, but in a different code path: `AVAudioNode.installTap`'s
callback fires on the AVAudioEngine real-time audio thread. The
previous fix (`e8a0310`, extract permission callbacks to
`nonisolated static func`) only addressed the once-and-done
TCC callback — audio taps are *continuous*, so the runtime
reached the audio thread before the user had any chance to
back off the recording.

Root cause: Swift 6 strict concurrency. The closure literal
passed to `installTap` was defined inside a `@MainActor` method,
so the compiler inferred the closure body as `@MainActor`-
isolated. AVAudioEngine calls it from its real-time audio
thread, not main, so `dispatch_assert_queue_fail` fires on
the *very first buffer delivery*. Wrapping the inner state
updates in `Task { @MainActor in ... }` (the prior fix) was not
enough — the runtime checks the OUTER closure's isolation, not
just the inner accesses.

Fix: build the tap body inside a `nonisolated static func` that
returns a function reference. Swift 6 function references
never carry inferred isolation, so the dispatch runtime sees
the closure as non-isolated and is happy to run it on the audio
thread. State updates to `self.level` and the AsyncStream
continuation hop back to main via `Task { @MainActor in … }`,
which is itself safe to invoke from a non-isolated context.

`makeAudioTapBlock` takes the format / converter / sample-rate
values plus two `@Sendable` callbacks (`onMeter`, `onSnapshot`)
and returns a `@Sendable` closure suitable for the installTap
block parameter. No state escapes the audio thread; the only
back-channel is through those callbacks.

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

🤖 Generated with Claude Code
2026-06-18 20:09:31 +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 e8a031075b fix: dispatch_assert_queue crash in PreviewASRController.start
Crash: EXC_BREAKPOINT on first call to `requestSpeechAuthorization`.
Backtrace:

  closure #1 in closure #2 in PreviewASRController.start(locale:) + 96
  thunk for @escaping (@unowned SFSpeechRecognizerAuthorizationStatus) -> ()
  __TCCAccessRequest_block_invoke_8
  _dispatch_assert_queue_fail
  _swift_task_checkIsolatedSwift

Root cause: `SFSpeechRecognizer.requestAuthorization` (and the
iOS < 17 `AVAudioSession.requestRecordPermission`) deliver their
callbacks on a TCC reply queue, NOT the main queue. My previous
commit wrapped those callbacks inline inside `start(locale:)`,
which is `@MainActor`. Swift 6 strict concurrency infers the inner
closure body as `@MainActor`, so as soon as TCC delivers the
callback on its own queue, the runtime's
`_swift_task_checkIsolatedSwift` asserts we're on @MainActor, sees
we're not, and traps.

Fix: extract both permission dances to `private nonisolated static
func` helpers. The helpers have no isolation, the callback
closures defined inside them have no isolation, and
`CheckedContinuation.resume` is itself thread-safe, so the TCC
queue can resume it without dispatching through the main actor.

  private nonisolated static func requestMicrophonePermission() async -> Bool
  private nonisolated static func requestSpeechRecognitionPermission() async -> Bool

`start(locale:)` now just `await`s those two helpers and proceeds
to the engine / ASR setup on @MainActor as before. No behavior
change; the user-visible flow is identical.

Build: BUILD SUCCEEDED.
Tests: 21/21 pass.
Runtime: app launches and stays running on the simulator without
the EXC_BREAKPOINT that the previous build hit immediately after
tapping the keyboard preview's record disc.

🤖 Generated with Claude Code
2026-06-18 19:39:06 +08:00
Rocky aeb28f4b36 fix: green accent + real iOS ASR in preview + bilingual audit
Three user-flagged fixes, scoped tightly to the files each affects.
The uncommitted Engine-mode wiring / locale picker / etc. from a
prior agent pass is intentionally not included in this commit.

1. Revert accent to brand green (#3AA05A).
   Last commit flipped `AccentColor` + `Palette.light.accent` to
   Apple system blue (#007AFF) on the assumption that "green CTA
   on a near-white surface looks wrong." Review pushed back:
   the brand *is* the green, and the system tint should match it
   so that NavStack Done buttons, Toggles, and our custom
   `primaryButton()` modifier all read as the same colour. Restored
   `#3AA05A` in both `AccentColor.colorset/Contents.json` and
   `Palette.light.accent` (plus the muted / glow variants).

2. Real iOS ASR in `KeyboardPreviewSheet`.
   The previous fix only swapped the static placeholder for a
   `TextField` and routed a hardcoded stub through it — review
   asked, fairly, "are you actually calling `SFSpeechRecognizer`?"
   Answer: no. This change makes the preview *run real ASR*:

   - `ASRService` (+ the iOS 26 `SpeechAnalyzer` path) moves from
     `OSGKeyboardExt/Services/` to `OSGKeyboardShared/Services/`,
     so the host app can import the same `ASRServiceFactory.make()`
     the keyboard extension uses.
   - `OSGKeyboardShared` gains `Speech.framework` and
     `AVFoundation.framework` as SDK dependencies in `project.yml`.
   - New `OSGKeyboard/Views/PreviewASRController.swift` (the
     extension's `AudioCaptureService` is app-extension-only, so
     the preview owns its own `AVAudioEngine` + `AVAudioSession`
     and downsamples to 16 kHz mono Float32 via `AVAudioConverter`).
   - `KeyboardPreviewSheet.cyclePhase()` now calls
     `asr.start(locale:)` / `asr.stop()` instead of toggling a
     `StubPhase`. The disc's level meter is driven by RMS from the
     actual audio tap; the transcript line under the chips shows
     the live `SFSpeechRecognizer` partial; the top textbox
     receives the `.final` transcript via `onChange(of: lastFinal)`.

3. Record disc in BOTH stubs now uses the green accent for idle.
   Was `Color(white: 0.22)` dark-gray, which read as "inert
   surface" rather than "tap me". The keyboard extension and the
   in-app preview now share the same brand-green disc gradient so
   the keyboard's primary CTA is the same colour in both modes.

4. Bilingual audit across every user-facing string.
   Every Text() in the main-app and extension views is now
   `中文 · English` or carries an English secondary line. Covered:
   - OnboardingView (Back, Next, Done, Continue, step instructions,
     PrivacyFootnote rows)
   - HomeView (status header, hero label, accessibility)
   - APISettingsCard (Base URL, API Key, Model, "Get an API key",
     "Connection", test-connection states + error messages)
   - KeyboardPreviewSheet (title, subtitle, TextField placeholder,
     clear button accessibility)
   - KeyboardPreviewStub (mode/locale chips, REC badge, space bar)
   - KeyboardRootView (gear accessibility, space bar, requesting
     state, denied messages, local-engine chip)
   - RecordButton (accessibility label)
   - SettingsView (Done, Reset dialog, language section subtitle,
     engine section, local-engine subtitle, on-device label)
   - AppGroupErrorView (title, body, three remediation steps)
   - LLMProvider preset names and blurbs

   Where the prior pattern was "Chinese headline + English footnote"
   (e.g. OnboardingView's 启用 OSGKeyboard / Enable OSGKeyboard),
   that pattern was preserved — bilingual coverage means every
   screen reads as both, not that every line is rigidly `中 · EN`.

Build: BUILD SUCCEEDED on iPhone 17 Pro / iOS 26 simulator.
Tests: 21/21 pass (no test changes).
Visual: light-mode home shot at /tmp/osgk_light_green.png shows
the brand-green CTA restored across Next button, mic icon, and
page dot.

🤖 Generated with Claude Code
2026-06-18 19:34:53 +08:00
Rocky 23689925dd fix: light-mode accent + keyboard preview textbox
Two review-driven fixes, scoped narrowly to the two files the user
flagged. The broader uncommitted changes (Engine mode wiring,
SpeechAnalyzer, locale picker, etc.) belong to a prior agent pass and
are intentionally left out of this commit.

1. Light-mode accent is no longer the dark-mode green (#3AA05A).
   Both the system `AccentColor` asset and `Palette.light.accent`
   (plus `.accentMuted` / `.accentGlow`) now use Apple system blue
   (#007AFF). On a near-white surface the green read as "garden
   centre" and clashed with every iOS HIG-styled control. Dark
   palette's accent stays green — the keyboard extension is always
   dark, and green-on-dark is the more legible pairing for the
   polish/active affordance.

2. The keyboard preview's "textbox" is now a real `TextField` and
   the recognized text actually lands in it.

   Before: `mockTextField` was a static `HStack` (icon + "Type
   here…" placeholder). `cyclePhase()` had a comment
   "// Local engine skips processing — insert happens
   immediately." but the insert was never wired up — the user
   tapped the disc, the stub transcript appeared briefly in the
   transcript line, then vanished on the next tap with nothing
   landing in the top box.

   After:
   - `mockTextField` is a real `TextField(text: $typedText, axis: .vertical)`
     with a clear-X button, vertical growth (1-4 lines), and the
     accent-coloured cursor. The user can also type into it directly.
   - `cyclePhase()` calls `insertRecognizedText()` on the
     recording→idle (local) and processing→idle (cloud) transitions,
     so the (mock) recognized transcript appends to the textbox with
     a leading space when the existing text doesn't already end in
     whitespace — matches what `textDocumentProxy.insertText` does
     for the real keyboard.

   This only changes the in-app preview; the real keyboard extension
   already inserts via `textDocumentProxy` and is untouched.

Build: BUILD SUCCEEDED on iPhone 17 Pro / iOS 26 simulator.
Tests: 21/21 pass (no test changes — visual + App-Group code paths
already covered).

🤖 Generated with Claude Code
2026-06-18 19:19:57 +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
hkgood 79be7384dd [JJC-20260618-005-D] P1/P2 cleanup: structured errors, privacy audit, timeout SSOT, view-model tests
13 items, 555-line diff, build + 15/15 tests green.

ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.

ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.

ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.

ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.

RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.

TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).

TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.

TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.

TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
2026-06-18 12:27:15 +08:00
rocky 38ad66f07d [JJC-20260618-005-C-2] Refactor: replace 166 Palette.xxx with @Environment(\.themePalette) (ARCH-A2) 2026-06-18 12:02:58 +08:00
rocky ccf2ea3f90 [JJC-20260618-005-C-1] Sync NSSpeechRecognitionUsageDescription into project.yml
- Add NSSpeechRecognitionUsageDescription to OSGKeyboard target
- Add NSMicrophoneUsageDescription + NSSpeechRecognitionUsageDescription to OSGKeyboardExt target
- Regenerated OSGKeyboardExt/Info.plist reflects project.yml (single source of truth)
- BUILD SUCCEEDED, 8/8 tests pass
2026-06-18 11:55:23 +08:00
rocky 9fa64223af [JJC-20260618-005-B] Fix 6 critical bugs from review
- BUG-1: Restore KeyboardRootView top highlight + divider
- BUG-2: App Group unavailable -> friendly error view (no fatalError)
- BUG-3: pressBegan race condition (sync phase to .requestingPermissions)
- BUG-4: cancelled LLMError no longer re-inserts original text
- BUG-6: mode switch during recording flushes partial
- ARCH-A1: State.Phase add .requestingPermissions + .denied(Reason)

Notes:
- 8/8 tests pass (LLMClientTests)
- BUILD SUCCEEDED for both OSGKeyboard and OSGKeyboardExt
- New file AppGroupErrorView.swift auto-registered via XcodeGen
- Info.plist left untouched (NSSpeechRecognitionUsageDescription kept from A block)
2026-06-18 11:51:26 +08:00
rocky ed11a11329 [JJC-20260618-005-A] Review-driven P0 polish
6 review-driven small fixes to the P0 commit series (e0b92ff / e93bab5 /
2e2d8e3 / 013c498). No behavior changes, no new features, no push yet.

Code:
- RED-1: Remove empty init() from OSGKeyboardApp; @StateObject is initialized
  inline and the only thing init() did was print a DEBUG log.
- RED-4: Re-add .preferredColorScheme(.dark) to the 3 keyboard extension
  #Preview blocks. Custom keyboards always render dark; the preview should
  match.
- RED-6: ThemedRoot #Preview blocks now use the new EnvironmentKey
  (\.themePalette) via a private ThemedPreviewContent, instead of hard-coding
  Palette.dark / Palette.light.
- BUG-5: ThemedRoot body now also fills the background with the active
  palette's background color (repeats the colorScheme ternary — accepted
  because body must be a single expression).

Docs:
- DOC-1: CHANGELOG [Unreleased] top note: this is a v0.1.0 → v0.1.1 polish,
  nothing removed, iOS 26 SpeechAnalyzer still 0.2.0.
- DOC-2: README + README.zh.md Limitations: explicit note for iOS 26+ users
  in v0.1.1.

Verified: xcodebuild Debug-iphonesimulator BUILD SUCCEEDED, 8/8 unit
tests pass on iPhone 17 sim (iOS 26.3.1).
2026-06-18 11:29:14 +08:00
Zhongshu 013c498a12 [P0-④] DEBUG cleanup + README / CHANGELOG typos
DEBUG cleanup (rolled into P0-① and P0-② already, formalised here):
- All 3 surviving `print(` calls live inside `#if DEBUG` blocks:
    * OSGKeyboardApp.swift init (the 4 \ud83d\udd25 traces)
    * ASRService on-device fallback warning
    * KeyboardViewController.loadPersistedLocale masked-config dump
  The 2 in AppGroup.swift + LLMClient.swift were already DEBUG-wrapped.

Docs:
- README.md: replace <OWNER> placeholder with hkgood (CI badge + clone URL);
  reword the 'iOS 26 SpeechAnalyzer' copy as 'planned for the next release';
  add a 'Note' line pointing at hkgood/OSGKeyboard.
- README.zh.md: same edits in Chinese — 'iOS 26+ 的 SpeechAnalyzer 计划下版接入'.

CHANGELOG (already fixed in P0-② commit): the old 'group.com.osgkeyboard.ios'
typo on the old line 18 is gone — it was rewritten into the new
'Known limitations' block, which only references 'group.com.osgkeyboard.shared'
in spirit.

xcodebuild iOS Simulator: SUCCEEDED
2026-06-18 10:55:23 +08:00