Mavis 0401f67a89 feat(keyboard): in-keyboard onboarding overlay + AppContext chip
Two long-standing UX papercuts, fixed without leaving the keyboard:

1. **First-launch onboarding inside the keyboard** — iOS keyboard
   extensions *cannot* programmatically switch back to the previous
   app after jumping out, so the old flow (jump to host app → user
   has to manually navigate back) was 5+ taps of friction. The new
   `KeyboardOnboardingOverlay` keeps the user inside the keyboard
   for steps 1 (welcome), 2 (mic permission), 3 (speech permission),
   and 5 (API key hint). The only step that *must* leave is step 4
   ("Enable Keyboard"), which jumps to `Settings.app` via
   `UIApplication.openSettingsURLString` — on return,
   `viewWillAppear` calls `autoAdvancePastKeyboardSetupStepIfNeeded`
   which silently advances past that step if the keyboard is now
   enabled. Net UX: user types in their app, keyboard walks them
   through setup, normal UI appears as soon as setup is done.

2. **Per-app context chip on the keyboard top bar** — the v0.3.0
   intelligent-prompt pipeline already adapted tone to detected
   context (code/email/chat/document), but without a UI cue the user
   had no way to know which mode was active or override the heuristic
   when it guessed wrong. The new `AppContextChip` surfaces the
   detected context; tap-to-override writes back to
   `AppGroupStore.setDetectedAppContext` so the next LLM call
   picks up the new tone immediately. Wired into `pressBegan` so
   the chip updates in real time as the user types into different
   fields.

### Files added
- `OSGKeyboardExt/Views/AppContextChip.swift` — chip + Menu override
- `OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift` — 5-step overlay
- `OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift` — round-trip + enum surface tests

### Files modified
- `OSGKeyboardShared/Services/KeyboardState.swift`
  + `hasCompletedOnboarding`, `onboardingPage`, `appContext`
  + `setAppContext`, `advanceOnboarding`, `completeOnboarding`
  + `requestMicPermission`, `requestSpeechPermission`, `openSystemSettings`
- `OSGKeyboardShared/Services/AppGroupStore.swift`
  + `hasCompletedOnboarding` / `onboardingPage` accessors (mirror of
    `ProviderConfig` keys, so the keyboard extension never has to
    instantiate the host-app config)
- `OSGKeyboardExt/KeyboardViewController.swift`
  + action hooks wired (`installStateActions`)
  + `syncOnboardingStateFromAppGroup` / `syncAppContextFromAppGroup`
    called on `viewWillAppear` and `loadPersistedConfig`
  + `autoAdvancePastKeyboardSetupStepIfNeeded` for the silent
    "jump out → come back" flow
  + `openSystemSettingsFromExtension` opens `Settings.app` via
    `UIApplication.openSettingsURLString` (the only system URL
    the extension is allowed to open)
  + `detectAndStoreAppContext` mirrors to `state.appContext` so
    the chip updates without waiting for `viewWillAppear`
- `OSGKeyboardExt/Views/KeyboardRootView.swift`
  + overlay mounted in `ZStack` over normal UI (animated)
  + AppContextChip in top bar (hidden during onboarding)
- `OSGKeyboardExt/{en,zh-Hans}.lproj/Keyboard.strings`
  + onboarding copy + chip labels (35 keys per language)

### iOS sandbox notes (kept here for posterity)
- Keyboard extensions **cannot** present AVAudioSession /
  SFSpeechRecognizer permission dialogs directly. The overlay's
  step 2/3 buttons optimistically advance; the actual permission
  is granted when the user first opens the host app (which the
  step-5 "Open OSGKeyboard" button triggers). This is the same
  pattern the previous "jump to host app" flow used — just
  without the broken return trip.
- `UIApplication.openSettingsURLString` is the only system URL
  reachable from `extensionContext.open`. Both step 4 and the
  "Open Settings" button route through `HostAppLauncher` so the
  responder-chain fallback also kicks in if needed.

Co-authored-by: Mavis <Mavis@hkgood.dev>
2026-07-03 07:55:12 +00:00
2026-06-17 23:08:58 +08:00

OSGKeyboard

Tap to talk, tap to stop — AI-polished text appears at your cursor in any app. A source-available, custom-keyboard-based voice input tool for iOS 26+, inspired by Typeless and OpenLess.

Platform Swift License CI Version

中文 README · Privacy Policy


What is it?

OSGKeyboard is a free, source-available alternative to commercial voice-input tools. It runs as a Custom Keyboard Extension on iOS, so you can use it in any app — Messages, Notes, Mail, WeChat, ChatGPT, Claude, Cursor, you name it.

  1. Tap the mic to start recording
  2. Speak naturally (up to 60 seconds per take)
  3. Tap again to stop — the AI polishes your words into clean text and inserts at the cursor

Audio is transcribed on-device by Apple's SpeechAnalyzer + DictationTranscriber (iOS 26+). Only the polished transcript is sent to your chosen cloud LLM. No audio ever leaves your phone.

Under the hood, OSGKeyboard uses a Flow session model: a long-lived audio session runs in the host app, the keyboard extension writes tiny "start / stop" signals to the App Group, and the polished text is delivered back to the keyboard for insertion. You do not need to jump back to the host app between recordings.


Features

  • 🎙 Tap-to-toggle recording with a Typeless-style circular mic button, 60-second per-take cap with live countdown
  • 🧠 On-device ASR (SpeechAnalyzer + DictationTranscriber, iOS 26+)
  • ✍️ AI polishing — adds structure, punctuation, fixes grammar, optionally produces lists
  • 🧩 Local + cloud polish toggle — local engine is ASR-only by default; opt into a post-ASR cloud polish step (DeepSeek by default) when the iOS speech recognition isn't strong enough for your environment (noisy far-field audio, strong accents, etc.)
  • 🔌 Bring-your-own API — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, Moonshot, Zhipu, your own self-hosted server, …)
  • 🔒 Privacy first — audio never leaves your device; only the final transcript is sent to the LLM you choose
  • 🎨 Native SwiftUI — dark theme, frosted glass, pure Swift 6, ~3,600 lines of code
  • 🪶 Zero dependencies — no SwiftPM packages, no CocoaPods, no Carthage
  • 🔁 Flow session — keep recording across multiple takes without bouncing back to the host app

Quick start

Requirements

  • macOS with Xcode 26 (matches project.yml deployment target iOS 26)
  • iPhone running iOS 26.0+
  • XcodeGen: brew install xcodegen
  • An OpenAI-compatible API key (e.g. from OpenAI, DeepSeek, or Qwen DashScope). Not needed if you stay on the "local ASR only" engine.

Build & run

git clone https://github.com/hkgood/OSGKeyboard.git
cd OSGKeyboard
./Scripts/generate-xcodeproj.sh   # generates OSGKeyboard.xcodeproj via XcodeGen
open OSGKeyboard.xcodeproj        # or build via CLI:
xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
  -destination 'generic/platform=iOS Simulator' build

The OSGKeyboard.xcodeproj is not committed — it is regenerated from project.yml by Scripts/generate-xcodeproj.sh. Always re-run the script after git pull if project.yml has changed.

Enable the keyboard in iOS

The host app walks you through a 5-step onboarding:

  1. Welcome — intro to OSGKeyboard
  2. Microphone — request mic access
  3. Speech recognition — request on-device speech recognition access
  4. Enable keyboard + Full Access — open iOS Settings to add OSGKeyboard and allow Full Access
  5. Engine + API — pick the local or cloud engine, then paste your API key (cloud / cloud-polish only)

After onboarding, in any text field, tap 🌐 to switch to OSGKeyboard, then tap the circular mic to start, speak, and tap again to stop.

"Allow Full Access" is required. Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see PrivacyInfo.xcprivacy and our Privacy Policy.


Architecture

OSGKeyboard/
├── OSGKeyboard/                 # Main iOS app (host of the Flow session)
│   ├── Services/                # FlowSessionManager, AppPermissions, SpeechHistoryStore, …
│   ├── Views/                   # SwiftUI: OnboardingView, HomeView, SettingsView, HistoryView, …
│   ├── OSGKeyboardApp.swift     # @main entry, owns the FlowSessionManager
│   ├── PrivacyInfo.xcprivacy    # Required privacy manifest
│   └── OSGKeyboard.entitlements # App Group + Keychain Group
├── OSGKeyboardExt/              # Custom Keyboard Extension
│   ├── KeyboardViewController.swift   # Principal class (drives SwiftUI)
│   ├── Services/                # AppGroupPersistor, HostAppLauncher, AudioCaptureService (legacy, unused)
│   ├── Views/                   # KeyboardRootView, RecordButton, WaveformView
│   └── PrivacyInfo.xcprivacy
├── OSGKeyboardShared/           # Framework shared by app + extension (APPLICATION_EXTENSION_API_ONLY=YES)
│   ├── Services/                # FlowSessionBridge, FlowSessionDarwin, LLMClient, PolishingService, ASRService, Keychain, AppGroupStore, …
│   ├── Models/                  # LLMProvider, ProviderConfig, TranscriptionDelivery, AudioBufferSnapshot, …
│   ├── DesignSystem/            # Theme, ThemedRoot
│   └── Constants/               # AppGroup identifier
├── OSGKeyboardTests/            # XCTest unit tests (LLM, Keychain, ASR, Flow bridge, …)
├── OSGKeyboardExtTests/         # Keyboard-extension-side unit tests
├── Scripts/                     # generate-xcodeproj.sh, patch-icon-composer.sh
├── docs/                        # GitHub Pages site (privacy policy + landing)
├── project.yml                  # XcodeGen project definition (source of truth)
└── .github/workflows/ci.yml     # Lint + build CI

Data flow — Flow session model

[Tap mic in keyboard]
  └─► KeyboardViewController.pressBegan
        └─► FlowSessionBridge.setRecordingState(.recording)  [App Group UserDefaults]
        └─► Darwin notification: "recordingState changed"
              └─► FlowSessionManager (host app) sees the signal
                    └─► FlowContinuousCapture feeds 16 kHz PCM into ChunkedUtterancePipeline
                          └─► ASRService.transcribe (iOS 26 SpeechAnalyzer)
                                └─► ASREvent.partial / .final
                                      └─► UtteranceTranscriptStitcher stitches the chunks
                                            └─► PolishingService (LLMClient)  [optional, configurable]
                                                  └─► FlowSessionBridge.storeTranscriptionResult
[Keyboard polls + Darwin notif]
  └─► KeyboardViewController sees the result
        └─► textDocumentProxy.insertText(polished)

Engine modes:

  • cloud (default) — on-device ASR via SpeechAnalyzer, transcript is sent to your configured LLM for polish.
  • local — on-device ASR via SpeechAnalyzer only; transcript is inserted as-is. No network round-trip.
  • local + "Cloud polish after ASR" toggle (Settings → Engine) — same on-device ASR, but the transcript is routed through your configured LLM before insertion. Useful when iOS speech recognition isn't accurate enough in your environment.

Cross-process plumbing (host app ↔ keyboard extension):

  • App Group group.com.osgkeyboard.sharedUserDefaults for the live Flow session state, recording state, audio levels, transcription delivery, and most preferences.
  • Shared Keychain group com.osgkeyboard.shared — the LLM API key is written by the host app's Settings, read by both processes before every LLM call.
  • Darwin notifications (CFNotificationCenter) — light-weight "something changed" pings; payloads still travel through the App Group.

Adding a new LLM provider

Open OSGKeyboardShared/Models/LLMProvider.swift and append a new LLMProvider to the presets array. The default OpenAICompatibleClient handles any endpoint that speaks the POST /chat/completions protocol.

LLMProvider(
    id: "groq",
    name: "Groq",
    defaultBaseURL: "https://api.groq.com/openai/v1",
    defaultModel: "llama-3.1-70b-versatile",
    apiKeyURL: URL(string: "https://console.groq.com/keys")
)

That's it — no other code changes required.

To set it as the new default for first-time users, also bump the defaultProviderId constant used by ProviderConfig.


Known limitations

  • iOS 26+ only. Earlier iOS versions are not supported. We dropped the pre-26 SFSpeechRecognizer / AVAudioSession branching so the entire ASR path can use the iOS 26 SpeechAnalyzer API exclusively.
  • ~60 MB memory cap for the keyboard extension (iOS sandbox). The Flow session is hosted in the main app, so audio buffers and ASR models live there, not in the extension.
  • "Allow Full Access" required. Without it, the keyboard can't reach the microphone or make network requests for cloud polish.
  • Password fields and some WKWebView textareas are blocked by iOS itself — not something we can work around.
  • 60-second per-take cap. A long take is automatically stopped and dispatched for transcription; a new take can be started immediately.
  • 3-minute per-utterance ASR cap. If you exceed it, the pipeline gracefully splits into multiple stitched chunks.
  • No on-device LLM polish. The local engine is ASR-only; "AI polish" is always cloud-based and configurable. On-device model support was explored in v0.2.0 and rolled back in v0.2.1 to keep the dependency surface at zero SPM packages.
  • URL scheme osgkeyboard:// can be opened by any app on the device. We don't trust it for anything beyond "wake the host app and (re)start the Flow session"; it never carries your API key or other secrets.

Development

  • Build setup — see the Build Setup section at the top of this file. Run ./Scripts/generate-xcodeproj.sh after any project.yml change.
  • Testsxcodebuild test -project OSGKeyboard.xcodeproj -scheme OSGKeyboard -destination 'platform=iOS Simulator,name=iPhone 17' runs both OSGKeyboardTests and OSGKeyboardExtTests targets.
  • CI.github/workflows/ci.yml runs SwiftLint, a clean Debug build, and the test suite on every push to 0.1 / 0.2 and PRs.
  • Loggingprint is debug-only; release builds use NSLog for the few cross-process status messages.

Project status

  • Current release: v0.2.1 (2026-06-24)
  • Default branch: 0.2 (renamed from main on 2026-06-24; the previous main is preserved as 0.1).
  • See CHANGELOG.md for the full release history and TYPEWHISPER_FLOW_MIGRATION_TRACKER.md for the architecture-decision log behind the Flow session model.

License

OSGKeyboard Source Available License — personal learning and non-commercial local use only. No commercial use, redistribution, or public forks without permission. Commercial licensing: rocky.hk@gmail.com.


Acknowledgements

S
Description
No description provided
Readme 57 MiB
Languages
Swift 93.7%
Python 4.6%
Shell 0.8%
HTML 0.5%
Objective-C++ 0.3%