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
2026-06-17 23:08:58 +08:00
2026-06-17 23:08:58 +08:00
2026-06-17 23:08:58 +08:00
2026-06-17 23:08:58 +08:00

OSGKeyboard

Hold a key, speak, release — AI-polished text appears at your cursor in any app. An open-source, custom-keyboard-based voice input tool for iOS 18+, inspired by Typeless and OpenLess.

Platform Swift License CI

中文 README


What is it?

OSGKeyboard is a free, open 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, ChatGPT, Claude, Cursor, you name it.

  1. Press and hold the mic key
  2. Speak naturally
  3. Release — the AI polishes your words into clean text and inserts it at the cursor

The audio stays on-device (transcribed by Apple's on-device SFSpeechRecognizer on iOS 18/19; iOS 26+ SpeechAnalyzer planned for the next release). Only the polished transcript is sent to your chosen cloud LLM. No audio ever leaves your phone.


Features

  • 🎙 Push-to-talk with a Typeless-style circular mic button
  • 🧠 On-device ASR (iOS 18/19 SFSpeechRecognizer; iOS 26+ SpeechAnalyzer + DictationTranscriber planned)
  • ✍️ AI polishing — adds structure, punctuation, fixes grammar, optionally produces lists
  • 🔌 Bring-your-own API — works with any OpenAI-compatible endpoint (OpenAI, DeepSeek, Qwen DashScope, your own self-hosted server, …)
  • 🔒 Privacy first — audio never leaves your device; transcripts only sent to the LLM you choose
  • 🎨 Native SwiftUI — dark theme, frosted glass, ~2000 lines of Swift
  • 🪶 Zero dependencies — no SwiftPM packages, no CocoaPods, no Carthage

Quick start

Requirements

Build & run

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

Enable the keyboard in iOS

  1. Run the app on your device or simulator.
  2. Follow the 3-step onboarding: enable the keyboard in iOS Settings, then allow Full Access (required for the mic and LLM calls), then paste your API key.
  3. In any text field, tap 🌐 to switch to OSGKeyboard.
  4. Press and hold the mic, speak, release.

"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.


Architecture

OSGKeyboard/
├── OSGKeyboard/                 # Main iOS app (settings, onboarding)
│   ├── Views/                   # SwiftUI screens
│   ├── OSGKeyboardApp.swift     # @main entry
│   ├── PrivacyInfo.xcprivacy    # Required privacy manifest
│   └── OSGKeyboard.entitlements # App Group declaration
├── OSGKeyboardExt/              # Custom Keyboard Extension
│   ├── KeyboardViewController.swift   # Principal class
│   ├── Services/
│   │   ├── AudioCaptureService.swift  # AVAudioEngine → 16 kHz PCM
│   │   ├── ASRService.swift           # iOS 26 + iOS 18 ASR
│   │   └── PolishingService.swift     # LLM call with timeout
│   └── Views/                   # RecordButton, Waveform, KeyboardRootView
├── OSGKeyboardShared/           # Framework shared by app + extension
│   ├── Models/                  # ProviderConfig, LLMRequest, LLMProvider
│   ├── Services/                # LLMClient (OpenAI-compatible)
│   └── Constants/               # AppGroup identifier
├── OSGKeyboardTests/            # XCTest unit tests
├── project.yml                  # XcodeGen project definition
└── .github/workflows/ci.yml     # Lint + build CI

Data flow

[Long-press mic] → AudioCaptureService → AudioBufferSnapshot (16 kHz mono)
                                          ↓
                                  ASRService.transcribe()
                                          ↓
                                ASREvent.final(rawTranscript)
                                          ↓
                              PolishingService.polish()
                                          ↓
                            LLMClient (OpenAI-compatible)
                                          ↓
                          textDocumentProxy.insertText(polished)

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.


Limitations

  • iOS sandboxes keyboard extensions: ~60 MB memory cap, Full Access required.
  • The keyboard does not work in password fields or some WKWebView textareas (iOS limitation).
  • iOS 18/19 ships with SFSpeechRecognizer for on-device ASR. iOS 26+ SpeechAnalyzer is planned for the next release — it is significantly faster and supports more locales.
  • iOS 26+ users in v0.1.1 use the iOS 18 SFSpeechRecognizer path; the iOS 26 SpeechAnalyzer is planned for 0.2.0.

License

MIT — use it, fork it, ship it. No warranty.


Acknowledgements


Note: the project is published at hkgood/OSGKeyboard; badges and git clone URLs already point there.

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%