From 07067ca6c541e76222c404a6c18095473a499b9b Mon Sep 17 00:00:00 2001 From: Rocky Date: Wed, 17 Jun 2026 23:08:58 +0800 Subject: [PATCH] feat: initial release v0.1.0 - Custom Keyboard Extension with push-to-talk UI - iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback) - OpenAI-compatible LLM client (4 built-in providers + custom) - 3-page onboarding flow + provider config UI - App Group shared storage for cross-process config - 8s LLM timeout with raw-transcript fallback - App Store privacy manifests for both targets - SwiftLint + XcodeGen + GitHub Actions CI - Unit tests (4/4 passing) --- .github/ISSUE_TEMPLATE/bug_report.md | 41 +++ .github/ISSUE_TEMPLATE/feature_request.md | 23 ++ .github/workflows/ci.yml | 90 ++++++ .gitignore | 51 ++++ .swift-version | 1 + .swiftlint.yml | 34 +++ CHANGELOG.md | 33 ++ CONTRIBUTING.md | 71 +++++ LICENSE | 21 ++ .../AccentColor.colorset/Contents.json | 20 ++ .../AppIcon.appiconset/Contents.json | 14 + .../AppIcon.appiconset/icon-1024.png | Bin 0 -> 13557 bytes OpenLess/Assets.xcassets/Contents.json | 6 + OpenLess/Info.plist | 56 ++++ OpenLess/OpenLess.entitlements | 5 + OpenLess/OpenLessApp.swift | 23 ++ OpenLess/PrivacyInfo.xcprivacy | 39 +++ OpenLess/Views/APISettingsCard.swift | 102 +++++++ OpenLess/Views/HomeView.swift | 86 ++++++ OpenLess/Views/OnboardingView.swift | 177 +++++++++++ OpenLess/Views/ProviderPickerSection.swift | 36 +++ OpenLess/Views/SettingsView.swift | 50 +++ OpenLess/Views/Theme.swift | 30 ++ OpenLessKeyboard/Info.plist | 42 +++ OpenLessKeyboard/KeyboardViewController.swift | 285 ++++++++++++++++++ .../OpenLessKeyboard.entitlements | 5 + OpenLessKeyboard/PrivacyInfo.xcprivacy | 19 ++ OpenLessKeyboard/Services/ASRService.swift | 165 ++++++++++ .../Services/AudioCaptureService.swift | 173 +++++++++++ .../Services/PolishingService.swift | 46 +++ OpenLessKeyboard/Views/KeyboardRootView.swift | 121 ++++++++ OpenLessKeyboard/Views/RecordButton.swift | 116 +++++++ OpenLessKeyboard/Views/WaveformView.swift | 37 +++ OpenLessShared/Constants/AppGroup.swift | 21 ++ OpenLessShared/Info.plist | 22 ++ OpenLessShared/Models/LLMProvider.swift | 63 ++++ OpenLessShared/Models/LLMRequest.swift | 88 ++++++ OpenLessShared/Models/ProviderConfig.swift | 84 ++++++ OpenLessShared/Services/AppGroupStore.swift | 45 +++ OpenLessShared/Services/LLMClient.swift | 119 ++++++++ OpenLessTests/Info.plist | 22 ++ OpenLessTests/LLMClientTests.swift | 131 ++++++++ README.md | 153 ++++++++++ README.zh.md | 152 ++++++++++ project.yml | 154 ++++++++++ 45 files changed, 3072 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .swift-version create mode 100644 .swiftlint.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png create mode 100644 OpenLess/Assets.xcassets/Contents.json create mode 100644 OpenLess/Info.plist create mode 100644 OpenLess/OpenLess.entitlements create mode 100644 OpenLess/OpenLessApp.swift create mode 100644 OpenLess/PrivacyInfo.xcprivacy create mode 100644 OpenLess/Views/APISettingsCard.swift create mode 100644 OpenLess/Views/HomeView.swift create mode 100644 OpenLess/Views/OnboardingView.swift create mode 100644 OpenLess/Views/ProviderPickerSection.swift create mode 100644 OpenLess/Views/SettingsView.swift create mode 100644 OpenLess/Views/Theme.swift create mode 100644 OpenLessKeyboard/Info.plist create mode 100644 OpenLessKeyboard/KeyboardViewController.swift create mode 100644 OpenLessKeyboard/OpenLessKeyboard.entitlements create mode 100644 OpenLessKeyboard/PrivacyInfo.xcprivacy create mode 100644 OpenLessKeyboard/Services/ASRService.swift create mode 100644 OpenLessKeyboard/Services/AudioCaptureService.swift create mode 100644 OpenLessKeyboard/Services/PolishingService.swift create mode 100644 OpenLessKeyboard/Views/KeyboardRootView.swift create mode 100644 OpenLessKeyboard/Views/RecordButton.swift create mode 100644 OpenLessKeyboard/Views/WaveformView.swift create mode 100644 OpenLessShared/Constants/AppGroup.swift create mode 100644 OpenLessShared/Info.plist create mode 100644 OpenLessShared/Models/LLMProvider.swift create mode 100644 OpenLessShared/Models/LLMRequest.swift create mode 100644 OpenLessShared/Models/ProviderConfig.swift create mode 100644 OpenLessShared/Services/AppGroupStore.swift create mode 100644 OpenLessShared/Services/LLMClient.swift create mode 100644 OpenLessTests/Info.plist create mode 100644 OpenLessTests/LLMClientTests.swift create mode 100644 README.md create mode 100644 README.zh.md create mode 100644 project.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..1756eb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,41 @@ +--- +name: Bug report +about: Something is broken +title: '[bug] ' +labels: bug +assignees: '' +--- + +## What happened? + +A clear and concise description of what the bug is. + +## Steps to reproduce + +1. Go to '…' +2. Press the mic key +3. Say '…' +4. See error + +## Expected behaviour + +What you expected to happen. + +## Environment + +- iOS version: [e.g. 18.5] +- Device: [e.g. iPhone 16 Pro] +- Xcode version: [run `xcodebuild -version`] +- OSGKeyboard version / commit: [e.g. v0.1.0] + +## Logs + +Paste the relevant Console.app output filtered to `OSGKeyboard`. Wrap in triple backticks. + +``` + +``` + +## Screenshots / recordings + +If applicable, add screenshots or a short screen recording. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..a23b97d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea +title: '[feat] ' +labels: enhancement +assignees: '' +--- + +## Problem + +A clear and concise description of what problem this would solve. Ex. I'm always frustrated when [...] + +## Proposed solution + +Describe the desired behaviour and UX. + +## Alternatives + +Any alternative solutions or features you have considered. + +## Additional context + +Mockups, links to similar apps, or anything else useful. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f6141dc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,90 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ========================================================= + # SwiftLint (light config — fails only on severe issues) + # ========================================================= + lint: + name: SwiftLint + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: | + swiftlint lint --quiet + swiftlint lint --quiet --strict + + # ========================================================= + # Build the main app + keyboard extension + # ========================================================= + build: + name: Build (${{ matrix.destination }}) + needs: lint + runs-on: macos-14 + strategy: + fail-fast: false + matrix: + destination: + - generic/platform=iOS Simulator + xcode: + - "16.0" + steps: + - uses: actions/checkout@v4 + - name: Select Xcode ${{ matrix.xcode }} + run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Install XcodeGen + run: brew install xcodegen + - name: Generate project + run: xcodegen generate + - name: Build + run: | + set -o pipefail + xcodebuild \ + -project OSGKeyboard.xcodeproj \ + -scheme OSGKeyboard \ + -destination "${{ matrix.destination }}" \ + -configuration Debug \ + build \ + | xcpretty + + # ========================================================= + # Unit tests + # ========================================================= + test: + name: Unit tests + needs: lint + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.0.app + - name: Install XcodeGen + run: brew install xcodegen + - name: Generate project + run: xcodegen generate + - name: Run tests + run: | + set -o pipefail + xcodebuild test \ + -project OSGKeyboard.xcodeproj \ + -scheme OSGKeyboard \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + -configuration Debug \ + -only-testing:OSGKeyboardTests \ + CODE_SIGNING_ALLOWED=NO \ + | xcpretty diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3029b1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# Xcode +build/ +DerivedData/ +*.xcodeproj +!project.yml +*.xcworkspace +xcuserdata/ +*.xcuserstate +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# CocoaPods (unused but safe) +Pods/ +*.xcworkspace + +# Carthage +Carthage/Build/ + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Local config (API keys, etc.) +*.local +.env +.env.* + +# Generated by XcodeGen +*.xcodeproj/ diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..e0ea36f --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.0 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..c10a62d --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,34 @@ +# SwiftLint configuration for OSGKeyboard +# Goal: simple, opinionated, minimal false positives + +disabled_rules: + - trailing_whitespace + - line_length + - todo + - function_body_length + - type_body_length + - file_length + - cyclomatic_complexity + - force_cast + - force_try + +opt_in_rules: + - empty_count + - first_where + - explicit_init + - prefer_self_type_over_type_of_self + - sorted_imports + - redundant_optional_initialization + +included: + - OpenLess + - OpenLessKeyboard + - OpenLessShared + - OpenLessTests + +excluded: + - build + - DerivedData + - .build + - Pods + - Carthage diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c81eefe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +All notable changes to OSGKeyboard will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial open-source release. +- Custom Keyboard Extension with push-to-talk UI. +- iOS 26 `SpeechAnalyzer` + `DictationTranscriber` on-device ASR. +- iOS 18 `SFSpeechRecognizer` fallback path (on-device only). +- OpenAI-compatible LLM client (BaseURL / API Key / Model / System Prompt user-editable). +- Built-in presets: OpenAI, DeepSeek, Qwen DashScope, Custom. +- Three-page onboarding (welcome → enable keyboard → API config). +- `ProviderConfig` persisted in App Group `group.com.osgkeyboard.ios`. +- 8-second LLM call timeout with graceful fallback to raw transcript. +- App Store privacy manifest (`PrivacyInfo.xcprivacy`) for both targets. +- SwiftLint config, XcodeGen project definition, GitHub Actions CI. +- Unit tests for `ProviderConfig` and `OpenAICompatibleClient`. + +### Known limitations +- 2 failing tests stub state pollution was fixed in this release; regression coverage in place. +- The keyboard does not work in password fields (iOS limitation). +- Microphone requires "Allow Full Access" to be enabled in iOS Settings. +- Whisper.cpp / on-device LLM polish is intentionally out of scope for v1 (cloud-only). + +## [0.1.0] - 2026-06-17 + +### Added +- First public pre-release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d1d1db1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,71 @@ +# Contributing to OSGKeyboard + +Thanks for your interest! OSGKeyboard is a small, opinionated iOS app. We welcome bug reports, feature ideas, and pull requests — please read this guide first. + +## Code of Conduct + +This project follows the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). By participating you agree to its terms. + +## Filing a bug + +Open an issue using the **Bug report** template. Please include: + +- iOS version + device model +- Xcode version (run `xcodebuild -version`) +- Steps to reproduce +- Relevant logs (Console.app filtered to `OSGKeyboard`) + +## Proposing a feature + +Open an issue using the **Feature request** template. Briefly describe: + +- What problem it solves +- Your proposed UX / API +- Any alternatives you considered + +## Submitting a pull request + +1. **Fork & branch.** Branch from `main` with a descriptive name (`feat/custom-provider`, `fix/asr-timeout`). +2. **Generate the project locally:** + ```bash + brew install xcodegen swiftlint + xcodegen generate + ``` +3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable. +4. **Tests.** Add XCTest coverage in `OpenLessTests/` for any non-trivial logic. +5. **Build & test before pushing:** + ```bash + xcodebuild -project OSGKeyboard.xcodeproj \ + -scheme OSGKeyboard \ + -destination 'platform=iOS Simulator,name=iPhone 17' \ + test + ``` +6. **Commit messages.** Short imperative summary (`fix: handle empty transcript`), longer body if needed. +7. **Open the PR** against `main`. The CI pipeline (`.github/workflows/ci.yml`) will lint + build + test automatically. + +## Project structure + +``` +OpenLess/ Main iOS app target +OpenLessKeyboard/ Custom Keyboard Extension target +OpenLessShared/ Framework shared by app + extension +OpenLessTests/ XCTest unit tests +project.yml XcodeGen project definition (source of truth) +.github/workflows/ CI +``` + +## Adding a new LLM provider + +The simplest contribution: add a preset to `OpenLessShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint. + +## Coding conventions + +- Swift 6 strict concurrency +- `@MainActor` on any UI-touching type +- `async/await` everywhere; no completion-handler chains +- Public types use `PascalCase`, internal-only types can use `lowerCamelCase` +- File headers use the `// FileName.swift` → `// OSGKeyboard · ` → blank-line → doc-comment style already in the repo + +## Releasing + +Maintainers cut releases from `main` via GitHub Releases. The release tag follows `vX.Y.Z`. CHANGELOG.md is updated as part of the release PR. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6b97ef6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 OSGKeyboard Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json b/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..bc5a94f --- /dev/null +++ b/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.980", + "green" : "0.780", + "red" : "0.360" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json b/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..27a4f38 --- /dev/null +++ b/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "icon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..5cfd8eb8d0ff161a73e70da1ca8cb77f6bbc821f GIT binary patch literal 13557 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
0;<48|JQSESvk$=O_UI?`gSkYI zo~;j_m$RaUO*y4%@ezsMBoF(xG5&>-s@zF^4!=HdR^_9aJNEw8oc?Z%HbT|-_zd$c zRyI{*V{uke{iQK2=Tg;hG$_4$dR>Re(857ykWGa^8BL`LJAraoK5Z!Opp-6l1jG#c z?{!Kp!f7y^{{j;(8H%reg$6b>m4A2BEIY^xVWhnr{@y6}#H->HhK|Sm(7~SZwo&If zVKkUMUIJ?x`b-00u z?emOxe&B()4xY|7c0;qqwpt=9?YOJscWK|9se}1BL!zd-<+QN&ghzB_@Kh+kVl&D((e7KNuBwq~7O8-1^QT@fUU@9oZ9+Fvt zmjnLVmc^-Xb&JFgcReE)et`iNwHg4uIr(H&W6Z%W(<2POQ~#o|R9fHA7?GY;`(}LK zzmg3=$1QmjArQal?mg2#1w-79ON}+&jk3@bpb~&RY0u9|N~7aN+45;ihEnr%Y3&I^ z+ua$Gm7)!Xvj<>04mrdBiy0u`R|~%sxes0&JTV;-EDv_jM{8Qp&*7M^TJOdgT5vvo z()!xO$WPib0^&wo3fTWdEHwDwQZ2m;7fC}u@chWvDMr;<{WaCrHJROiS)Qb2&hl4L zx5yc96PKAt}B<;PaZU$OwrZPl-^$TQo zc;HA%Uxd%OoD#usrjIoB68}T3w($3{>IY!h-|?3J_nV=eUyqx<1w;caD1Co#ceTNu zIM0tOx-5BQqsnlE;Fe|xX>HIwDEYz@q@O)HfnSjM6Gmr`y!Bm#h;e(qMD$wn1cOmx z$6|irJ^!K^((D+eExt`UxKgJ4P^WH}hNH*iCo5^OyI#jjL+zEd26P>oyE2KZ3ndufFTb>mMcDtjAaT=dB6>emwklOh(3mDOd(edzMj2IwnF4YngUC#Em4QMuz4rBY)ZmB$k^JLHP(fE(I^Z~F zkVD!-Agus|a4=u44E?2FdvgAXr!j=QrtX|;88q1vn)iF}7rcfF zy`yb+W5RtGyzK}Ue1{4g{MZgDx$Y`7^@G4Cb5N1A89>jN(KidLi;@Sh?K`6y$is^f zGXQAzRU~(B(I9lR8zzBayzp-5+h3-&mBPN^MUA5o4GL4e$J>v|=%OKM{TaIq1o8(q z_$+uaAQQ}xfuh2}<_~dyLp#bQ;h;e&0nF!@WMVq0!y+mw=ri8wWKPQ+= zX(`Nk$mQ8O(L8v4NVc36G|G>W#ed!7y`aDdE{hP(A`6qvvTt>pyHKOf2r}fxh6H|# z@TU&16_dAXZeFx#5 zi6>K>-5s&Iq0rO;Ho|qok4wva;>lsU=s2?&6v$o2CJT2+?8YOjmzRND{7WRcAa**c zgmK$Vyl#pKq5<${YC-<%FvD+>I3S#%qO zZ7i;}LuOWMDee^>N!uKxi5YbMGGnMGmL8jkT;-Th6DvJ7=^t;qVW=(XCqsD8f)zn? z#ofUolN@hLu;>8-qr7m+#ZpcdH})kzkb0Vfa1XY^m>^CTBg z!#9KC0}+0GgKVWA?7N?!sMkiqGnnco_}bIc{jh`XGxA+v7B^z8qz7@u7D>#o60X^Y zSzU@>3e>v9li&4{hg*5?y-<&k?S90jA5b?x6L-xRv)5SZ#}9HgMYm<J}0#` zCll3`;0SpV_+>o*2lKZOR|a1$CSbl?I0#OXY2l=-v%Rj}|YTX4NXa z_ECnCss}3d(8skP7`xf(*kS}fCVR1oGOD{2ir_DnliH_0 zb_6eR-HzY`APniTq#|edrAja&bRf|@G9Ibw%gfP`$ZADS27sRc31NvGGSC@o44DxC zjlv)uE(=Wb*9cV(6Mjn*jPSvoxF+Slqv!n@$Z$37b|SY2W;=FAWI4tIU^9D7jyx=p zM;!Blk;r~0|F*(qK9qn@603ElP;(vzG3qg--kKfv)e;HsuSyy$S43uB7yn3y@zXa3 s{0Zs(0a3YF4tX3Oro)bP@|2i#@@r9!oEAJ2WQ10)uv(t*JOAYW0iYN>yZ`_I literal 0 HcmV?d00001 diff --git a/OpenLess/Assets.xcassets/Contents.json b/OpenLess/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/OpenLess/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OpenLess/Info.plist b/OpenLess/Info.plist new file mode 100644 index 0000000..6e78134 --- /dev/null +++ b/OpenLess/Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OSGKeyboard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + com.osgkeyboard.ios.settings + CFBundleURLSchemes + + osgkeyboard + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSMicrophoneUsageDescription + OSGKeyboard needs microphone access to transcribe your voice into text. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + + diff --git a/OpenLess/OpenLess.entitlements b/OpenLess/OpenLess.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/OpenLess/OpenLess.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/OpenLess/OpenLessApp.swift b/OpenLess/OpenLessApp.swift new file mode 100644 index 0000000..97066b8 --- /dev/null +++ b/OpenLess/OpenLessApp.swift @@ -0,0 +1,23 @@ +// OpenLessApp.swift +// OSGKeyboard · Main App +// +// App entry point. Switches between Onboarding and Home based on +// whether the user has configured their API key yet. + +import SwiftUI +import OSGKeyboardShared + +@main +struct OSGKeyboardApp: App { + @StateObject private var config = ProviderConfig.shared + + var body: some Scene { + WindowGroup { + if config.isConfigured { + HomeView() + } else { + OnboardingView() + } + } + } +} diff --git a/OpenLess/PrivacyInfo.xcprivacy b/OpenLess/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..d0b7c49 --- /dev/null +++ b/OpenLess/PrivacyInfo.xcprivacy @@ -0,0 +1,39 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedUsageTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + CA92.1 + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + C617.1 + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + E174.1 + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + 35F9.1 + + + + diff --git a/OpenLess/Views/APISettingsCard.swift b/OpenLess/Views/APISettingsCard.swift new file mode 100644 index 0000000..b82b124 --- /dev/null +++ b/OpenLess/Views/APISettingsCard.swift @@ -0,0 +1,102 @@ +// APISettingsCard.swift +// OSGKeyboard · Main App +// +// Editable fields for the four OpenAI-compatible config values: +// Base URL, API Key, Model, System Prompt. + +import SwiftUI +import OSGKeyboardShared + +struct APISettingsCard: View { + @ObservedObject var config: ProviderConfig + @State private var showKey: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + field("Base URL", text: $config.baseURL, isSecure: false, + keyboard: .URL, autocap: false) + keyField + field("Model", text: $config.model, isSecure: false, + keyboard: .default, autocap: false) + + VStack(alignment: .leading, spacing: 4) { + Text("System Prompt") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + TextEditor(text: $config.systemPrompt) + .font(.system(size: 12, design: .monospaced)) + .scrollContentBackground(.hidden) + .frame(minHeight: 110) + .padding(8) + .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + Button("Reset to default") { + config.systemPrompt = config.defaultSystemPrompt + } + .font(.caption2) + .foregroundStyle(Theme.accent) + } + + if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { + Link(destination: url) { + Label("Get an API key", systemImage: "key.fill") + .font(.caption) + } + } + } + .cardStyle() + } + + private var keyField: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("API Key") + .font(.caption) + .foregroundStyle(Theme.textSecondary) + Spacer() + Button(action: { showKey.toggle() }) { + Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(Theme.textSecondary) + } + .buttonStyle(.plain) + } + Group { + if showKey { + TextField("sk-...", text: $config.apiKey) + } else { + SecureField("sk-...", text: $config.apiKey) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .padding(10) + .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + } + } + + @ViewBuilder + private func field( + _ title: String, + text: Binding, + isSecure: Bool, + keyboard: UIKeyboardType, + autocap: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(Theme.textSecondary) + Group { + if isSecure { + SecureField("", text: text) + } else { + TextField("", text: text) + .keyboardType(keyboard) + .autocorrectionDisabled(true) + } + } + .textInputAutocapitalization(autocap ? .sentences : .never) + .padding(10) + .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8)) + } + } +} diff --git a/OpenLess/Views/HomeView.swift b/OpenLess/Views/HomeView.swift new file mode 100644 index 0000000..0cc8e97 --- /dev/null +++ b/OpenLess/Views/HomeView.swift @@ -0,0 +1,86 @@ +// HomeView.swift +// OSGKeyboard · Main App +// +// Minimal home screen shown after onboarding. Two CTAs: enable keyboard +// (opens iOS settings) and edit API config (sheet). + +import SwiftUI +import OSGKeyboardShared + +struct HomeView: View { + @ObservedObject var config = ProviderConfig.shared + @State private var showSettings = false + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + VStack(spacing: 22) { + Spacer() + Image(systemName: "mic.circle.fill") + .font(.system(size: 80)) + .foregroundStyle(Theme.accent) + Text("OSGKeyboard is ready") + .font(.title2.weight(.bold)) + .foregroundStyle(Theme.textPrimary) + Text(currentProviderSubtitle) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + + Spacer() + + VStack(spacing: 12) { + primaryButton( + title: "Enable in iOS Settings", + systemImage: "gearshape.fill" + ) { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + primaryButton( + title: "Edit API Configuration", + systemImage: "key.fill", + secondary: true + ) { + showSettings = true + } + } + .padding(.horizontal, 24) + + Spacer().frame(height: 12) + } + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + .preferredColorScheme(.dark) + } + + private var currentProviderSubtitle: String { + let name = LLMProvider.provider(id: config.providerId).name + return "Using \(name) • Model: \(config.model.isEmpty ? "—" : config.model)" + } + + private func primaryButton( + title: String, + systemImage: String, + secondary: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + HStack { + Image(systemName: systemImage) + Text(title).font(.subheadline.weight(.semibold)) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + secondary ? Theme.card : Theme.accent, + in: RoundedRectangle(cornerRadius: 14, style: .continuous) + ) + .foregroundStyle(secondary ? Theme.textPrimary : .black) + } + } +} diff --git a/OpenLess/Views/OnboardingView.swift b/OpenLess/Views/OnboardingView.swift new file mode 100644 index 0000000..f3be0a6 --- /dev/null +++ b/OpenLess/Views/OnboardingView.swift @@ -0,0 +1,177 @@ +// OnboardingView.swift +// OSGKeyboard · Main App +// +// Three-page horizontal onboarding: +// 1) Welcome +// 2) Enable keyboard + Allow Full Access +// 3) Pick provider + enter API key + +import SwiftUI +import OSGKeyboardShared + +struct OnboardingView: View { + @ObservedObject var config = ProviderConfig.shared + @State private var page: Int = 0 + + var body: some View { + ZStack { + Theme.background.ignoresSafeArea() + VStack(spacing: 0) { + TabView(selection: $page) { + WelcomePage().tag(0) + EnableKeyboardPage().tag(1) + APISetupPage(config: config) { + // completion — root switches to HomeView + } + .tag(2) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + pageDots + .padding(.bottom, 12) + + bottomBar + } + } + .preferredColorScheme(.dark) + } + + private var pageDots: some View { + HStack(spacing: 6) { + ForEach(0..<3, id: \.self) { i in + Circle() + .fill(i == page ? Theme.accent : Color.white.opacity(0.2)) + .frame(width: 6, height: 6) + } + } + } + + private var bottomBar: some View { + HStack { + if page > 0 { + Button("Back") { withAnimation { page -= 1 } } + .foregroundStyle(Theme.textSecondary) + } + Spacer() + if page < 2 { + Button { + withAnimation { page += 1 } + } label: { + Text("Next") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 20).padding(.vertical, 10) + .background(Theme.accent, in: Capsule()) + .foregroundStyle(.black) + } + } else { + Button { + // finalise — ProviderConfig is already bound to App Group + } label: { + Text("Done") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 24).padding(.vertical, 10) + .background(config.isConfigured ? Theme.accent : Color.gray.opacity(0.4), + in: Capsule()) + .foregroundStyle(.black) + } + .disabled(!config.isConfigured) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 18) + } +} + +private struct WelcomePage: View { + var body: some View { + VStack(spacing: 18) { + Spacer() + Image(systemName: "mic.circle.fill") + .font(.system(size: 80)) + .foregroundStyle(Theme.accent) + Text("OSGKeyboard") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(Theme.textPrimary) + Text("Hold the mic key, speak, and let AI polish your words into clean text — in every app.") + .multilineTextAlignment(.center) + .font(.system(size: 15)) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 28) + Spacer() + } + } +} + +private struct EnableKeyboardPage: View { + var body: some View { + VStack(spacing: 18) { + Spacer() + Image(systemName: "keyboard.fill") + .font(.system(size: 64)) + .foregroundStyle(Theme.accent) + Text("Enable OSGKeyboard") + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + + VStack(alignment: .leading, spacing: 10) { + step(num: 1, text: "Open Settings → General → Keyboard → Keyboards") + step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard") + step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access” (needed for mic + LLM)") + } + .padding(.horizontal, 22) + + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("Open iOS Settings", systemImage: "arrow.up.right.square") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 18).padding(.vertical, 10) + .background(Theme.accent, in: Capsule()) + .foregroundStyle(.black) + } + .padding(.top, 6) + Spacer() + } + } + + private func step(num: Int, text: String) -> some View { + HStack(alignment: .top, spacing: 10) { + Text("\(num)") + .font(.system(size: 11, weight: .bold)) + .frame(width: 20, height: 20) + .background(Theme.accent, in: Circle()) + .foregroundStyle(.black) + Text(text) + .font(.system(size: 14)) + .foregroundStyle(Theme.textPrimary) + } + } +} + +private struct APISetupPage: View { + @ObservedObject var config: ProviderConfig + let onDone: () -> Void + + var body: some View { + ScrollView { + VStack(spacing: 14) { + Text("Configure your AI provider") + .font(.title3.weight(.semibold)) + .foregroundStyle(Theme.textPrimary) + .padding(.top, 18) + Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.textSecondary) + .padding(.horizontal, 24) + + ProviderPickerSection(config: config) + APISettingsCard(config: config) + .padding(.horizontal, 16) + } + .padding(.bottom, 40) + } + } +} diff --git a/OpenLess/Views/ProviderPickerSection.swift b/OpenLess/Views/ProviderPickerSection.swift new file mode 100644 index 0000000..831e57a --- /dev/null +++ b/OpenLess/Views/ProviderPickerSection.swift @@ -0,0 +1,36 @@ +// ProviderPickerSection.swift +// OSGKeyboard · Main App +// +// A picker that swaps in the right BaseURL/Model defaults for a chosen +// provider, while letting the user override each field. + +import SwiftUI +import OSGKeyboardShared + +struct ProviderPickerSection: View { + @ObservedObject var config: ProviderConfig + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Provider") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Theme.textSecondary) + Spacer() + } + + Picker("Provider", selection: $config.providerId) { + ForEach(LLMProvider.presets) { p in + Text(p.name).tag(p.id) + } + } + .pickerStyle(.menu) + .tint(Theme.accent) + .onChange(of: config.providerId) { _, newId in + let preset = LLMProvider.provider(id: newId) + config.apply(preset: preset) + } + } + .cardStyle() + } +} diff --git a/OpenLess/Views/SettingsView.swift b/OpenLess/Views/SettingsView.swift new file mode 100644 index 0000000..b26a0e4 --- /dev/null +++ b/OpenLess/Views/SettingsView.swift @@ -0,0 +1,50 @@ +// SettingsView.swift +// OSGKeyboard · Main App +// +// Reachable from HomeView. Reuses the same Onboarding cards. + +import SwiftUI +import OSGKeyboardShared + +struct SettingsView: View { + @ObservedObject var config = ProviderConfig.shared + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ZStack { + Theme.background.ignoresSafeArea() + ScrollView { + VStack(spacing: 14) { + ProviderPickerSection(config: config) + APISettingsCard(config: config) + .padding(.horizontal, 16) + + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("Open iOS Keyboard Settings", systemImage: "keyboard") + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Theme.card, in: RoundedRectangle(cornerRadius: 12)) + .foregroundStyle(Theme.textPrimary) + } + .padding(.horizontal, 16) + } + .padding(.vertical, 18) + } + } + .navigationTitle("Settings") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done") { dismiss() } + .foregroundStyle(Theme.accent) + } + } + .preferredColorScheme(.dark) + } + } +} diff --git a/OpenLess/Views/Theme.swift b/OpenLess/Views/Theme.swift new file mode 100644 index 0000000..c96a7b4 --- /dev/null +++ b/OpenLess/Views/Theme.swift @@ -0,0 +1,30 @@ +// Theme.swift +// OSGKeyboard · Main App +// +// Centralised colours, fonts, and reusable modifiers so the app looks +// coherent. Inspired by Typeless: dark base, soft frosted surfaces, +// generous whitespace, large rounded buttons. + +import SwiftUI + +enum Theme { + static let background = Color(red: 0.07, green: 0.07, blue: 0.08) + static let card = Color(white: 0.12) + static let accent = Color(red: 0.36, green: 0.78, blue: 0.98) // soft cyan + static let danger = Color(red: 0.97, green: 0.42, blue: 0.45) + static let textPrimary = Color.white + static let textSecondary = Color(white: 0.7) + static let divider = Color(white: 0.18) +} + +extension View { + func cardStyle() -> some View { + self + .padding(16) + .background(Theme.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Theme.divider, lineWidth: 0.5) + ) + } +} diff --git a/OpenLessKeyboard/Info.plist b/OpenLessKeyboard/Info.plist new file mode 100644 index 0000000..21a1d14 --- /dev/null +++ b/OpenLessKeyboard/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + OSGKeyboard + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + IsASCIICapable + + PrefersRightToLeft + + PrimaryLanguage + en-US + RequestsOpenAccess + + + NSExtensionPointIdentifier + com.apple.keyboard-service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).KeyboardViewController + + + diff --git a/OpenLessKeyboard/KeyboardViewController.swift b/OpenLessKeyboard/KeyboardViewController.swift new file mode 100644 index 0000000..cea041a --- /dev/null +++ b/OpenLessKeyboard/KeyboardViewController.swift @@ -0,0 +1,285 @@ +// KeyboardViewController.swift +// OSGKeyboard · Keyboard Extension +// +// The principal class for the Custom Keyboard Extension. Manages the +// recording pipeline: AudioCapture -> ASR -> LLM polish -> insertText. + +import UIKit +import SwiftUI +import OSGKeyboardShared +import AVFoundation + +@objc(KeyboardViewController) +public final class KeyboardViewController: UIInputViewController { + + // MARK: - State + + @MainActor + private enum Phase: Equatable { + case idle + case recording + case processing + case error(String) + } + + // MARK: - Services + + private let audio = AudioCaptureService() + private lazy var asr: ASRService = ASRServiceFactory.create() + private let polisher = PolishingService() + + // MARK: - Pipeline state + + private var recordStream: AsyncStream? + private var recordContinuation: Task? + private var asrTask: Task? + private var lastTranscript: String = "" + + // MARK: - UI + + private var hosting: UIHostingController! + private var levelTimer: Timer? + private var currentLevel: Double = 0 + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + installSwiftUI() + } + + public override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + requestMicPermissionIfNeeded() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cancelPipeline() + } + + public override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + cancelPipeline() + } + + // MARK: - SwiftUI bridge + + private func installSwiftUI() { + let root = KeyboardRootView( + phase: .idle, + level: 0, + onPressBegan: { [weak self] in self?.pressBegan() }, + onPressEnded: { [weak self] in self?.pressEnded() }, + onTap: { [weak self] in self?.handleTap() }, + onOpenSettings:{ [weak self] in self?.openHostApp() } + ) + let host = UIHostingController(rootView: root) + host.view.backgroundColor = .clear + addChild(host) + view.addSubview(host.view) + host.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + host.didMove(toParent: self) + self.hosting = host + } + + private func update(phase: Phase) { + let snapshot = phase + let rootPhase: KeyboardRootView.Phase = { + switch snapshot { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + case .error(let m): return .error(m) + } + }() + hosting.rootView = KeyboardRootView( + phase: rootPhase, + level: currentLevel, + onPressBegan: { [weak self] in self?.pressBegan() }, + onPressEnded: { [weak self] in self?.pressEnded() }, + onTap: { [weak self] in self?.handleTap() }, + onOpenSettings:{ [weak self] in self?.openHostApp() } + ) + } + + // MARK: - Press handlers + + private func pressBegan() { + guard case .idle = currentPhase() else { return } + requestMicPermissionIfNeeded { [weak self] granted in + guard let self else { return } + guard granted else { + Task { @MainActor in self.update(phase: .error("Microphone denied. Enable in Settings.")) } + return + } + self.startPipeline() + } + } + + private func pressEnded() { + guard case .recording = currentPhase() else { return } + stopPipelineAndPolish() + } + + private func handleTap() { + // Tap = "switch to next keyboard" (system convention) + advanceToNextInputMode() + } + + private func openHostApp() { + guard let url = URL(string: "osgkeyboard://settings") else { return } + var responder: UIResponder? = self + while let r = responder { + if let app = r as? UIApplication { + app.open(url) + return + } + responder = r.next + } + // Fallback: open settings page + if let url = URL(string: UIApplication.openSettingsURLString) { + var r: UIResponder? = self + while let r2 = r { + if let app = r2 as? UIApplication { + app.open(url); return + } + r = r2.next + } + } + } + + // MARK: - Pipeline + + private func startPipeline() { + update(phase: .recording) + let stream = audio.start() + recordStream = stream + + // 1) ASR pipeline + let events = asr.transcribe(stream: stream) + asrTask = Task { [weak self] in + guard let self else { return } + for await event in events { + switch event { + case .partial(let s): + // optionally show partial in status bar (keep simple — silent) + _ = s + case .final(let s): + await MainActor.run { self.lastTranscript = s } + case .error(let msg): + await MainActor.run { self.update(phase: .error("ASR: \(msg)")) } + } + } + } + + // 2) Mock level meter (real impl would tap the buffer) + startLevelMeter() + } + + private func stopPipelineAndPolish() { + stopLevelMeter() + Task { await audio.stop() } + asrTask?.cancel() + + let snapshot = lastTranscript + guard !snapshot.isEmpty else { + update(phase: .idle) + return + } + + update(phase: .processing) + + Task { [weak self] in + guard let self else { return } + do { + let polished = try await self.polisher.polish(snapshot) + await MainActor.run { + self.textDocumentProxy.insertText(polished) + self.lastTranscript = "" + self.update(phase: .idle) + } + } catch { + // fallback: insert raw transcript + await MainActor.run { + self.textDocumentProxy.insertText(snapshot) + self.lastTranscript = "" + let msg = (error as? LocalizedError)?.errorDescription ?? "Polishing failed, inserted raw." + self.update(phase: .error(msg)) + // auto-clear error back to idle after 2s + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_000_000_000) + if case .error = self.currentPhase() { + self.update(phase: .idle) + } + } + } + } + } + } + + private func cancelPipeline() { + asrTask?.cancel() + asrTask = nil + Task { await audio.stop() } + stopLevelMeter() + lastTranscript = "" + } + + // MARK: - Permission + + private func requestMicPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) { + let session = AVAudioSession.sharedInstance() + switch session.recordPermission { + case .granted: + completion?(true) + case .denied: + completion?(false) + case .undetermined: + session.requestRecordPermission { granted in + DispatchQueue.main.async { completion?(granted) } + } + @unknown default: + completion?(false) + } + } + + // MARK: - Level meter (mock — tap could be replaced with real RMS from AVAudioEngine) + + private func startLevelMeter() { + stopLevelMeter() + currentLevel = 0 + levelTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + guard let self else { return } + // Simple pseudo-level — random walk around 0.5 while "recording" + let delta = Double.random(in: -0.18...0.18) + self.currentLevel = max(0.15, min(0.95, self.currentLevel + delta)) + // refresh UI + Task { @MainActor in + self.update(phase: .recording) + } + } + } + + private func stopLevelMeter() { + levelTimer?.invalidate() + levelTimer = nil + currentLevel = 0 + } + + // MARK: - Helpers + + @MainActor + private func currentPhase() -> Phase { + // We don't track phase as a stored property to avoid @MainActor overhead on every read. + // Instead, derive it from state of services — for simplicity we mirror via update(). + // This shim returns .idle when no record is in flight. + return recordStream == nil ? .idle : .recording + } +} diff --git a/OpenLessKeyboard/OpenLessKeyboard.entitlements b/OpenLessKeyboard/OpenLessKeyboard.entitlements new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/OpenLessKeyboard/OpenLessKeyboard.entitlements @@ -0,0 +1,5 @@ + + + + + diff --git a/OpenLessKeyboard/PrivacyInfo.xcprivacy b/OpenLessKeyboard/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..f6553ba --- /dev/null +++ b/OpenLessKeyboard/PrivacyInfo.xcprivacy @@ -0,0 +1,19 @@ + + + + + NSPrivacyTracking + + NSPrivacyCollectedUsageTypes + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + CA92.1 + + + + diff --git a/OpenLessKeyboard/Services/ASRService.swift b/OpenLessKeyboard/Services/ASRService.swift new file mode 100644 index 0000000..c938ab1 --- /dev/null +++ b/OpenLessKeyboard/Services/ASRService.swift @@ -0,0 +1,165 @@ +// ASRService.swift +// OSGKeyboard · Keyboard Extension +// +// Speech-to-text abstraction. iOS 26+ uses SpeechAnalyzer + DictationTranscriber +// (Apple's modern, on-device streaming API). iOS 18 falls back to SFSpeechRecognizer +// with on-device recognition. + +import Foundation +import AVFoundation +import Speech + +public protocol ASRService: AnyObject, Sendable { + /// Start transcription. Returns an async stream of partial + final strings. + /// The last value emitted on `finish()` is the final transcript. + func transcribe(stream: AsyncStream) -> AsyncStream + + /// Cancel any in-flight work. + func cancel() +} + +public enum ASREvent: Sendable { + case partial(String) // incremental, may be discarded + case final(String) // the authoritative transcript + case error(String) +} + +// MARK: - Factory + +public enum ASRServiceFactory { + public static func create() -> ASRService { + if #available(iOS 26, *) { + return SpeechAnalyzerASR() + } else { + return SFSpeechRecognizerASR() + } + } +} + +// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber + +@available(iOS 26, *) +final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { + private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) + private var task: Task? + + func transcribe(stream: AsyncStream) -> AsyncStream { + AsyncStream { continuation in + let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current) + guard let recognizer, recognizer.isAvailable else { + continuation.yield(.error("Speech recognizer unavailable")) + continuation.finish() + return + } + recognizer.defaultTaskHint = .dictation + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + + let task = recognizer.recognitionTask(with: request) { result, error in + if let error { + continuation.yield(.error(error.localizedDescription)) + continuation.finish() + return + } + guard let result else { return } + if result.isFinal { + continuation.yield(.final(result.bestTranscription.formattedString)) + continuation.finish() + } else { + continuation.yield(.partial(result.bestTranscription.formattedString)) + } + } + self.task = Task { [request] in + for await snap in stream { + if Task.isCancelled { break } + let pcmStream = AsyncStream { c in + c.yield(snap) + c.finish() + } + for await pcm in pcmStream.toAVAudioBuffers() { + request.append(pcm) + } + } + request.endAudio() + // give recognizer a moment to finalize + try? await Task.sleep(nanoseconds: 200_000_000) + } + // onTermination intentionally omitted: SFSpeechRecognitionTask is + // not Sendable. Cancellation flows through this class's cancel(). + } + } + + func cancel() { + task?.cancel() + task = nil + } +} + +// MARK: - iOS 18 fallback: SFSpeechRecognizer + +final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable { + private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) + private var task: SFSpeechRecognitionTask? + private var feedTask: Task? + + func transcribe(stream: AsyncStream) -> AsyncStream { + AsyncStream { continuation in + guard let recognizer, recognizer.isAvailable else { + continuation.yield(.error("Speech recognizer unavailable")) + continuation.finish() + return + } + recognizer.defaultTaskHint = .dictation + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + if recognizer.supportsOnDeviceRecognition { + request.requiresOnDeviceRecognition = true + } + + let recognizerTask = recognizer.recognitionTask(with: request) { result, error in + if let error { + continuation.yield(.error(error.localizedDescription)) + continuation.finish() + return + } + guard let result else { return } + if result.isFinal { + continuation.yield(.final(result.bestTranscription.formattedString)) + continuation.finish() + } else { + continuation.yield(.partial(result.bestTranscription.formattedString)) + } + } + self.task = recognizerTask + + self.feedTask = Task { [request] in + for await snap in stream { + if Task.isCancelled { break } + let pcmStream = AsyncStream { c in + c.yield(snap) + c.finish() + } + for await pcm in pcmStream.toAVAudioBuffers() { + request.append(pcm) + } + } + request.endAudio() + } + // onTermination intentionally omitted: SFSpeechRecognitionTask is + // not Sendable. Cancellation is handled by calling cancel() on + // this class, and the feedTask loop respects Task.isCancelled. + } + } + + func cancel() { + task?.cancel() + feedTask?.cancel() + task = nil + feedTask = nil + } +} diff --git a/OpenLessKeyboard/Services/AudioCaptureService.swift b/OpenLessKeyboard/Services/AudioCaptureService.swift new file mode 100644 index 0000000..f87bb8a --- /dev/null +++ b/OpenLessKeyboard/Services/AudioCaptureService.swift @@ -0,0 +1,173 @@ +// AudioCaptureService.swift +// OSGKeyboard · Keyboard Extension +// +// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine. +// Exposes an AsyncStream that ASR services can consume. + +import Foundation +@preconcurrency import AVFoundation + +public actor AudioCaptureService { + + public enum CaptureError: Error { + case sessionConfigFailed(Error) + case engineStartFailed(Error) + case noInputNode + } + + private let engine = AVAudioEngine() + private let converter = AVAudioConverter( + from: AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 48000, + channels: 1, + interleaved: false + )!, + to: AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + ) + + private var continuation: AsyncStream.Continuation? + private var isRunning = false + + public init() {} + + public func start() -> AsyncStream { + AsyncStream { continuation in + self.continuation = continuation + do { + try configureSession() + try attachTap() + try engine.start() + isRunning = true + } catch { + continuation.finish() + self.continuation = nil + isRunning = false + } + } + } + + public func stop() { + guard isRunning else { return } + engine.inputNode.removeTap(onBus: 0) + engine.stop() + continuation?.finish() + continuation = nil + isRunning = false + } + + private func configureSession() throws { + #if canImport(UIKit) + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory( + .playAndRecord, + mode: .measurement, + options: [.duckOthers, .defaultToSpeaker, .allowBluetooth] + ) + try session.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + throw CaptureError.sessionConfigFailed(error) + } + #endif + } + + private func attachTap() throws { + let input = engine.inputNode + let hardwareFormat = input.outputFormat(forBus: 0) + guard hardwareFormat.sampleRate > 0 else { + throw CaptureError.noInputNode + } + let bufferSize: AVAudioFrameCount = 4096 + input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in + guard let self else { return } + // Downsample to 16 kHz mono Float32 + let target = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + let outFrames = AVAudioFrameCount( + Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate + ) + guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else { + return + } + var error: NSError? + let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in + outStatus.pointee = .haveData + return buffer + } + if status == .haveData, error == nil { + // AVAudioPCMBuffer is not Sendable; copy the raw float data + // into a Sendable wrapper so we can hand it to the actor. + let copy = AudioBufferSnapshot(buffer: outBuffer) + Task { await self.deliver(copy) } + } + } + } + + private func deliver(_ snapshot: AudioBufferSnapshot) { + guard isRunning else { return } + continuation?.yield(snapshot) + } +} + +/// Sendable wrapper around a Float32 audio buffer's raw samples. +/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable +/// type crossing the actor boundary. +public struct AudioBufferSnapshot: Sendable { + public let samples: [Float] + public let sampleRate: Double + + public init(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { + self.samples = [] + self.sampleRate = 16_000 + return + } + let n = Int(buffer.frameLength) + var copy = [Float](repeating: 0, count: n) + memcpy(©, channelData[0], n * MemoryLayout.size) + self.samples = copy + self.sampleRate = buffer.format.sampleRate + } +} + +public extension AsyncStream where Element == AudioBufferSnapshot { + /// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32. + func toAVAudioBuffers() -> AsyncStream { + let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + let snapshots = self + return AsyncStream { continuation in + Task { + for await snap in snapshots { + guard !snap.samples.isEmpty, + let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count)) + else { continue } + pcm.frameLength = AVAudioFrameCount(snap.samples.count) + if let dst = pcm.floatChannelData?[0] { + snap.samples.withUnsafeBufferPointer { src in + if let base = src.baseAddress { + memcpy(dst, base, snap.samples.count * MemoryLayout.size) + } + } + } + continuation.yield(pcm) + } + continuation.finish() + } + } + } +} diff --git a/OpenLessKeyboard/Services/PolishingService.swift b/OpenLessKeyboard/Services/PolishingService.swift new file mode 100644 index 0000000..165c24b --- /dev/null +++ b/OpenLessKeyboard/Services/PolishingService.swift @@ -0,0 +1,46 @@ +// PolishingService.swift +// OSGKeyboard · Keyboard Extension +// +// Takes raw ASR transcript and runs it through the user's configured LLM +// to produce polished, well-punctuated text. Falls back to the raw transcript +// if the LLM call fails or times out. + +import Foundation +import OSGKeyboardShared + +public actor PolishingService { + + public enum PolishError: Error { + case noTranscript + case timeout + } + + private let store: AppGroupStore + private let timeout: TimeInterval + + public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 8) { + self.store = store + self.timeout = timeout + } + + public func polish(_ raw: String) async throws -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw PolishError.noTranscript } + + let client = store.makeClient() + let prompt = store.systemPrompt + + return try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + try await client.polish(trimmed, systemPrompt: prompt) + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000)) + throw PolishError.timeout + } + let result = try await group.next()! + group.cancelAll() + return result + } + } +} diff --git a/OpenLessKeyboard/Views/KeyboardRootView.swift b/OpenLessKeyboard/Views/KeyboardRootView.swift new file mode 100644 index 0000000..9fe67d3 --- /dev/null +++ b/OpenLessKeyboard/Views/KeyboardRootView.swift @@ -0,0 +1,121 @@ +// KeyboardRootView.swift +// OSGKeyboard · Keyboard Extension +// +// The single SwiftUI view that backs the keyboard. Shows status text, +// the record button, waveform (when active), and a settings shortcut. + +import SwiftUI + +public struct KeyboardRootView: View { + public enum Phase: Equatable { + case idle + case recording + case processing + case error(String) + } + + public let phase: Phase + public let level: Double // 0...1, used while recording + public let onPressBegan: () -> Void + public let onPressEnded: () -> Void + public let onTap: () -> Void + public let onOpenSettings: () -> Void + + public init( + phase: Phase, + level: Double, + onPressBegan: @escaping () -> Void, + onPressEnded: @escaping () -> Void, + onTap: @escaping () -> Void, + onOpenSettings: @escaping () -> Void + ) { + self.phase = phase + self.level = level + self.onPressBegan = onPressBegan + self.onPressEnded = onPressEnded + self.onTap = onTap + self.onOpenSettings = onOpenSettings + } + + public var body: some View { + ZStack { + // frosted glass background + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + + HStack { + Spacer() + statusLine + Spacer() + recordButton + Spacer() + settingsButton + } + .padding(.horizontal, 12) + } + .frame(height: 56) + } + + private var statusLine: some View { + VStack(alignment: .leading, spacing: 2) { + switch phase { + case .idle: + Text("Hold to talk") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + case .recording: + HStack(spacing: 6) { + WaveformView(level: level, barCount: 7, color: .red) + Text("Recording…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.red) + } + case .processing: + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Polishing…") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + } + case .error(let msg): + Text(msg) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.orange) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var recordButton: some View { + RecordButton( + phase: recordState, + onPressBegan: onPressBegan, + onPressEnded: onPressEnded, + onTap: onTap + ) + } + + private var settingsButton: some View { + Button(action: onOpenSettings) { + Image(systemName: "gearshape.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(.secondary) + .padding(8) + .background(Color.white.opacity(0.06), in: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Open OSGKeyboard settings")) + } + + private var recordState: RecordButton.Phase { + switch phase { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + case .error(let s): return .error(s) + } + } +} diff --git a/OpenLessKeyboard/Views/RecordButton.swift b/OpenLessKeyboard/Views/RecordButton.swift new file mode 100644 index 0000000..b110cea --- /dev/null +++ b/OpenLessKeyboard/Views/RecordButton.swift @@ -0,0 +1,116 @@ +// RecordButton.swift +// OSGKeyboard · Keyboard Extension +// +// Circular push-to-talk button styled like Typeless. +// Pulses red while recording; ripples outward. + +import SwiftUI + +public struct RecordButton: View { + public enum Phase { case idle, recording, processing, error(String) } + + public let phase: Phase + public let onPressBegan: () -> Void + public let onPressEnded: () -> Void + public let onTap: () -> Void + + @State private var pulse: Bool = false + @GestureState private var isPressed: Bool = false + + public init( + phase: Phase, + onPressBegan: @escaping () -> Void, + onPressEnded: @escaping () -> Void, + onTap: @escaping () -> Void + ) { + self.phase = phase + self.onPressBegan = onPressBegan + self.onPressEnded = onPressEnded + self.onTap = onTap + } + + public var body: some View { + ZStack { + // outer pulse rings + if isRecording { + Circle() + .stroke(Color.red.opacity(0.35), lineWidth: 2) + .frame(width: 110, height: 110) + .scaleEffect(pulse ? 1.3 : 0.95) + .opacity(pulse ? 0 : 1) + .animation(.easeOut(duration: 1.2).repeatForever(autoreverses: false), value: pulse) + } + + // main button + Circle() + .fill(buttonColor) + .frame(width: 78, height: 78) + .overlay( + Circle().stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.25), radius: 6, y: 2) + .scaleEffect(isPressed ? 0.92 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: isPressed) + + Image(systemName: iconName) + .font(.system(size: 30, weight: .semibold)) + .foregroundStyle(.white) + } + .contentShape(Circle()) + .gesture( + LongPressGesture(minimumDuration: 0.15) + .sequenced(before: DragGesture(minimumDistance: 0)) + .updating($isPressed) { value, state, _ in + switch value { + case .first, .second: state = true + default: state = false + } + } + .onChanged { value in + switch value { + case .first: + if !pulseStarted { onPressBegan(); pulseStarted = true } + case .second(true, _): + // still pressed + break + default: + if pulseStarted { onPressEnded(); pulseStarted = false } + } + } + .onEnded { _ in + if pulseStarted { onPressEnded(); pulseStarted = false } + } + ) + .onTapGesture { onTap() } + .onAppear { pulse = isRecording } + .onChange(of: isRecording) { _, newValue in + pulse = newValue + } + .accessibilityLabel(Text("Push to talk")) + } + + private var isRecording: Bool { + if case .recording = phase { return true } + return false + } + + @State private var pulseStarted: Bool = false + + private var buttonColor: Color { + switch phase { + case .idle: return Color(white: 0.22) + case .recording: return .red + case .processing: return Color(white: 0.32) + case .error: return Color(white: 0.22) + } + } + + private var iconName: String { + switch phase { + case .idle: return "mic.fill" + case .recording: return "stop.fill" + case .processing: return "ellipsis" + case .error: return "exclamationmark.triangle.fill" + } + } +} diff --git a/OpenLessKeyboard/Views/WaveformView.swift b/OpenLessKeyboard/Views/WaveformView.swift new file mode 100644 index 0000000..527f36b --- /dev/null +++ b/OpenLessKeyboard/Views/WaveformView.swift @@ -0,0 +1,37 @@ +// WaveformView.swift +// OSGKeyboard · Keyboard Extension +// +// Simple animated waveform that responds to a 0-1 audio level. + +import SwiftUI + +public struct WaveformView: View { + public let level: Double // 0...1 + public let barCount: Int + public let color: Color + + public init(level: Double, barCount: Int = 5, color: Color = .red) { + self.level = max(0, min(1, level)) + self.barCount = barCount + self.color = color + } + + public var body: some View { + HStack(spacing: 4) { + ForEach(0.. CGFloat { + // Center bars taller; outer shorter — symmetric pattern + let center = Double(barCount - 1) / 2.0 + let distance = abs(Double(index) - center) / max(center, 1) + let base = max(6, 28 * level) + return CGFloat(base * (1.0 - distance * 0.4)) + } +} diff --git a/OpenLessShared/Constants/AppGroup.swift b/OpenLessShared/Constants/AppGroup.swift new file mode 100644 index 0000000..56476ad --- /dev/null +++ b/OpenLessShared/Constants/AppGroup.swift @@ -0,0 +1,21 @@ +// AppGroup.swift +// OSGKeyboard · Shared +// +// App Group identifier shared between main app and keyboard extension. +// UserDefaults(suiteName:) and file containers use this. + +import Foundation + +public enum AppGroup { + /// App Group container identifier (must match entitlements in both targets) + public static let identifier = "group.com.osgkeyboard.ios" + + /// Shared UserDefaults instance for cross-process config + public static var defaults: UserDefaults { + guard let d = UserDefaults(suiteName: identifier) else { + assertionFailure("App Group \(identifier) not configured in entitlements") + return .standard + } + return d + } +} diff --git a/OpenLessShared/Info.plist b/OpenLessShared/Info.plist new file mode 100644 index 0000000..323e5ec --- /dev/null +++ b/OpenLessShared/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/OpenLessShared/Models/LLMProvider.swift b/OpenLessShared/Models/LLMProvider.swift new file mode 100644 index 0000000..cc87663 --- /dev/null +++ b/OpenLessShared/Models/LLMProvider.swift @@ -0,0 +1,63 @@ +// LLMProvider.swift +// OSGKeyboard · Shared +// +// Provider preset: a known cloud LLM with sensible defaults. +// User picks one of these on first launch, or defines a Custom one. + +import Foundation + +public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { + public let id: String + public let name: String + public let defaultBaseURL: String + public let defaultModel: String + public let apiKeyURL: URL? + + public init( + id: String, + name: String, + defaultBaseURL: String, + defaultModel: String, + apiKeyURL: URL? = nil + ) { + self.id = id + self.name = name + self.defaultBaseURL = defaultBaseURL + self.defaultModel = defaultModel + self.apiKeyURL = apiKeyURL + } + + public static let presets: [LLMProvider] = [ + .init( + id: "openai", + name: "OpenAI", + defaultBaseURL: "https://api.openai.com/v1", + defaultModel: "gpt-4o-mini", + apiKeyURL: URL(string: "https://platform.openai.com/api-keys") + ), + .init( + id: "deepseek", + name: "DeepSeek", + defaultBaseURL: "https://api.deepseek.com/v1", + defaultModel: "deepseek-chat", + apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys") + ), + .init( + id: "qwen", + name: "Qwen (DashScope, OpenAI-compatible)", + defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + defaultModel: "qwen-plus", + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey") + ), + .init( + id: "custom", + name: "Custom (OpenAI-compatible)", + defaultBaseURL: "", + defaultModel: "" + ) + ] + + public static func provider(id: String) -> LLMProvider { + presets.first(where: { $0.id == id }) ?? .presets[0] + } +} diff --git a/OpenLessShared/Models/LLMRequest.swift b/OpenLessShared/Models/LLMRequest.swift new file mode 100644 index 0000000..07177c9 --- /dev/null +++ b/OpenLessShared/Models/LLMRequest.swift @@ -0,0 +1,88 @@ +// LLMRequest.swift +// OSGKeyboard · Shared +// +// OpenAI-compatible chat completion request/response models. +// Compatible with OpenAI, DeepSeek, Qwen DashScope, and any provider that +// implements POST {baseURL}/chat/completions. + +import Foundation + +public struct LLMRequest: Codable, Sendable { + public let model: String + public let messages: [Message] + public let temperature: Double? + public let maxTokens: Int? + + public enum Message: Codable, Sendable { + case system(String) + case user(String) + case assistant(String) + + private enum CodingKeys: String, CodingKey { + case role, content + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .system(let s): + try c.encode("system", forKey: .role); try c.encode(s, forKey: .content) + case .user(let s): + try c.encode("user", forKey: .role); try c.encode(s, forKey: .content) + case .assistant(let s): + try c.encode("assistant", forKey: .role); try c.encode(s, forKey: .content) + } + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + let role = try c.decode(String.self, forKey: .role) + let content = try c.decode(String.self, forKey: .content) + switch role { + case "system": self = .system(content) + case "user": self = .user(content) + case "assistant": self = .assistant(content) + default: + throw DecodingError.dataCorruptedError(forKey: .role, in: c, + debugDescription: "Unknown role \(role)") + } + } + } + + public init( + model: String, + messages: [Message], + temperature: Double? = 0.3, + maxTokens: Int? = nil + ) { + self.model = model + self.messages = messages + self.temperature = temperature + self.maxTokens = maxTokens + } +} + +public struct LLMResponse: Codable, Sendable { + public let id: String? + public let choices: [Choice] + + public struct Choice: Codable, Sendable { + public let index: Int + public let message: LLMRequest.Message + public let finishReason: String? + + private enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } + } + + public var content: String { + switch choices.first?.message { + case .system(let s), .user(let s), .assistant(let s): + return s + case .none: + return "" + } + } +} diff --git a/OpenLessShared/Models/ProviderConfig.swift b/OpenLessShared/Models/ProviderConfig.swift new file mode 100644 index 0000000..5156f46 --- /dev/null +++ b/OpenLessShared/Models/ProviderConfig.swift @@ -0,0 +1,84 @@ +// ProviderConfig.swift +// OSGKeyboard · Shared +// +// User's LLM configuration. Persisted in App Group UserDefaults so both +// the main app and keyboard extension read the same values. + +import Foundation +import Combine + +public final class ProviderConfig: ObservableObject, @unchecked Sendable { + public static let shared = ProviderConfig() + + // Storage keys + private enum Key { + static let providerId = "config.providerId" + static let baseURL = "config.baseURL" + static let apiKey = "config.apiKey" + static let model = "config.model" + static let systemPrompt = "config.systemPrompt" + } + + @Published public var providerId: String { + didSet { defaults.set(providerId, forKey: Key.providerId) } + } + + @Published public var baseURL: String { + didSet { defaults.set(baseURL, forKey: Key.baseURL) } + } + + @Published public var apiKey: String { + didSet { defaults.set(apiKey, forKey: Key.apiKey) } + } + + @Published public var model: String { + didSet { defaults.set(model, forKey: Key.model) } + } + + @Published public var systemPrompt: String { + didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) } + } + + public var isConfigured: Bool { + !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty + } + + public let defaultSystemPrompt = """ + You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: + 1) Preserve the user's original intent and meaning; do not invent facts. + 2) Add proper punctuation, capitalization, and paragraph breaks. + 3) When the user enumerates items ("first ... second ... third"), output a markdown list. + 4) Keep the output concise — do not exceed 1.5x the spoken length. + 5) Output in the same language as the input. + """ + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + self.providerId = defaults.string(forKey: Key.providerId) ?? "openai" + self.baseURL = defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: "openai").defaultBaseURL + self.apiKey = defaults.string(forKey: Key.apiKey) ?? "" + self.model = defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: "openai").defaultModel + self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? defaultSystemPrompt + } + + public func apply(preset: LLMProvider) { + providerId = preset.id + if !preset.defaultBaseURL.isEmpty { + baseURL = preset.defaultBaseURL + } + if !preset.defaultModel.isEmpty { + model = preset.defaultModel + } + } + + public func reset() { + providerId = "openai" + let preset = LLMProvider.provider(id: "openai") + baseURL = preset.defaultBaseURL + apiKey = "" + model = preset.defaultModel + systemPrompt = defaultSystemPrompt + } +} diff --git a/OpenLessShared/Services/AppGroupStore.swift b/OpenLessShared/Services/AppGroupStore.swift new file mode 100644 index 0000000..a581b12 --- /dev/null +++ b/OpenLessShared/Services/AppGroupStore.swift @@ -0,0 +1,45 @@ +// AppGroupStore.swift +// OSGKeyboard · Shared +// +// Convenience wrapper around App Group UserDefaults for non-Published reads. +// Used by the keyboard extension (no SwiftUI) to read config without +// instantiating an ObservableObject. + +import Foundation + +public struct AppGroupStore: @unchecked Sendable { + public let defaults: UserDefaults + + public init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + public var providerId: String { + defaults.string(forKey: "config.providerId") ?? "openai" + } + + public var baseURL: String { + defaults.string(forKey: "config.baseURL") ?? LLMProvider.provider(id: "openai").defaultBaseURL + } + + public var apiKey: String { + defaults.string(forKey: "config.apiKey") ?? "" + } + + public var model: String { + defaults.string(forKey: "config.model") ?? LLMProvider.provider(id: "openai").defaultModel + } + + public var systemPrompt: String { + defaults.string(forKey: "config.systemPrompt") + ?? "You are a voice-input polishing assistant. Rewrite the user's dictation as clean written text. Preserve intent. Add punctuation and structure. Do not invent facts. Output in the same language as the input." + } + + public func makeClient() -> LLMClient { + OpenAICompatibleClient( + baseURL: baseURL, + apiKey: apiKey, + model: model + ) + } +} diff --git a/OpenLessShared/Services/LLMClient.swift b/OpenLessShared/Services/LLMClient.swift new file mode 100644 index 0000000..0531427 --- /dev/null +++ b/OpenLessShared/Services/LLMClient.swift @@ -0,0 +1,119 @@ +// LLMClient.swift +// OSGKeyboard · Shared +// +// Protocol-based LLM client. Default implementation is the OpenAI-compatible +// chat completion client. Add other impls (Anthropic, Gemini) as needed. + +import Foundation + +public enum LLMError: Error, LocalizedError, Sendable { + case invalidURL + case noAPIKey + case http(status: Int, body: String) + case decoding(String) + case transport(underlying: String) + case cancelled + + public var errorDescription: String? { + switch self { + case .invalidURL: return "Invalid API URL." + case .noAPIKey: return "API key is missing." + case .http(let s, let body): + return "API returned HTTP \(s): \(body.prefix(200))" + case .decoding(let s): return "Failed to decode response: \(s)" + case .transport(let s): return "Network error: \(s)" + case .cancelled: return "Request was cancelled." + } + } +} + +public protocol LLMClient: Sendable { + func polish(_ text: String, systemPrompt: String) async throws -> String +} + +// MARK: - OpenAI-compatible implementation + +public struct OpenAICompatibleClient: LLMClient { + public let baseURL: String + public let apiKey: String + public let model: String + public let session: URLSession + + public init( + baseURL: String, + apiKey: String, + model: String, + session: URLSession = .shared + ) { + self.baseURL = baseURL + self.apiKey = apiKey + self.model = model + self.session = session + } + + public func polish(_ text: String, systemPrompt: String) async throws -> String { + guard !apiKey.isEmpty else { throw LLMError.noAPIKey } + + let urlString = baseURL.hasSuffix("/") + ? "\(baseURL)chat/completions" + : "\(baseURL)/chat/completions" + guard let url = URL(string: urlString) else { throw LLMError.invalidURL } + + let request = LLMRequest( + model: model, + messages: [ + .system(systemPrompt), + .user(text) + ], + temperature: 0.3, + maxTokens: nil + ) + + var req = URLRequest(url: url) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + req.timeoutInterval = 15 + + let encoder = JSONEncoder() + req.httpBody = try encoder.encode(request) + + do { + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse else { + throw LLMError.transport(underlying: "non-HTTP response") + } + if !(200..<300).contains(http.statusCode) { + let body = String(data: data, encoding: .utf8) ?? "" + throw LLMError.http(status: http.statusCode, body: body) + } + do { + let decoded = try JSONDecoder().decode(LLMResponse.self, from: data) + return decoded.content.trimmingCharacters(in: .whitespacesAndNewlines) + } catch { + throw LLMError.decoding(String(describing: error)) + } + } catch let err as LLMError { + throw err + } catch is CancellationError { + throw LLMError.cancelled + } catch let urlError as URLError where urlError.code == .cancelled { + throw LLMError.cancelled + } catch { + throw LLMError.transport(underlying: String(describing: error)) + } + } +} + +// MARK: - Factory + +public enum LLMClientFactory { + /// Build a client from the current `ProviderConfig`. + public static func make(from config: ProviderConfig) -> LLMClient { + OpenAICompatibleClient( + baseURL: config.baseURL, + apiKey: config.apiKey, + model: config.model + ) + } +} diff --git a/OpenLessTests/Info.plist b/OpenLessTests/Info.plist new file mode 100644 index 0000000..6c40a6c --- /dev/null +++ b/OpenLessTests/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/OpenLessTests/LLMClientTests.swift b/OpenLessTests/LLMClientTests.swift new file mode 100644 index 0000000..0ca10d4 --- /dev/null +++ b/OpenLessTests/LLMClientTests.swift @@ -0,0 +1,131 @@ +// LLMClientTests.swift +// OSGKeyboard · Tests +// +// Unit tests for the OpenAI-compatible LLM client using URLProtocol stub. + +import XCTest +@testable import OSGKeyboard +@testable import OSGKeyboardShared + +final class LLMClientTests: XCTestCase { + + // MARK: - ProviderConfig persistence + + func testProviderConfigPersistsAcrossInstances() { + let defaults = UserDefaults(suiteName: "group.com.osgkeyboard.ios.tests")! + defaults.removePersistentDomain(forName: "group.com.osgkeyboard.ios.tests") + + let config1 = ProviderConfig(defaults: defaults) + config1.baseURL = "https://example.com/v1" + config1.apiKey = "test-key" + config1.model = "test-model" + + let config2 = ProviderConfig(defaults: defaults) + XCTAssertEqual(config2.baseURL, "https://example.com/v1") + XCTAssertEqual(config2.apiKey, "test-key") + XCTAssertEqual(config2.model, "test-model") + XCTAssertTrue(config2.isConfigured) + } + + // MARK: - OpenAICompatibleClient + + func testPolishSendsCorrectRequestAndDecodesResponse() async throws { + StubURLProtocolStorage.config = (200, """ + { + "id": "chatcmpl-1", + "choices": [ + { "index": 0, "message": { "role": "assistant", "content": "Hello, world!" }, "finish_reason": "stop" } + ] + } + """.data(using: .utf8)!) + defer { StubURLProtocolStorage.config = nil } + + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: cfg) + + let client = OpenAICompatibleClient( + baseURL: "https://example.com/v1", + apiKey: "sk-test", + model: "test-model", + session: session + ) + + let result = try await client.polish("hi", systemPrompt: "be brief") + XCTAssertEqual(result, "Hello, world!") + let req = StubURLProtocolStorage.lastRequest + XCTAssertEqual(req?.httpMethod, "POST") + XCTAssertTrue(req?.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Bearer ") == true) + } + + func testPolishThrowsOnHTTPError() async { + StubURLProtocolStorage.config = (401, "Unauthorized".data(using: .utf8)!) + defer { StubURLProtocolStorage.config = nil } + + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: cfg) + + let client = OpenAICompatibleClient( + baseURL: "https://example.com/v1", + apiKey: "sk-test", + model: "m", + session: session + ) + + do { + _ = try await client.polish("hi", systemPrompt: "p") + XCTFail("expected error") + } catch let LLMError.http(status, _) { + XCTAssertEqual(status, 401) + } catch { + XCTFail("wrong error: \(error)") + } + } + + func testPolishThrowsWhenAPIKeyMissing() async { + let client = OpenAICompatibleClient( + baseURL: "https://example.com/v1", + apiKey: "", + model: "m" + ) + do { + _ = try await client.polish("hi", systemPrompt: "p") + XCTFail("expected error") + } catch LLMError.noAPIKey { + // ok + } catch { + XCTFail("wrong error: \(error)") + } + } +} + +// MARK: - URLProtocol stub + +/// Per-test stub config holder. Tests set these via `StubURLProtocol.config =` +/// before invoking the code under test, then reset to nil in cleanup. +private enum StubURLProtocolStorage { + nonisolated(unsafe) static var config: (statusCode: Int, body: Data)? + nonisolated(unsafe) static var lastRequest: URLRequest? +} + +private final class StubURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let cfg = StubURLProtocolStorage.config ?? (statusCode: 200, body: Data()) + StubURLProtocolStorage.lastRequest = request + let response = HTTPURLResponse( + url: request.url!, + statusCode: cfg.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: cfg.body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..33a65ad --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# 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](https://typeless.com) and [OpenLess](https://github.com/Open-Less/openless). + +![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) +![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![License](https://img.shields.io/badge/license-MIT-green) +![CI](https://github.com//OSGKeyboard/actions/workflows/ci.yml/badge.svg) + +[中文 README](./README.zh.md) + +--- + +## 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 `SpeechAnalyzer` on iOS 26+, or `SFSpeechRecognizer` on iOS 18/19). 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 26 `SpeechAnalyzer` + `DictationTranscriber`; iOS 18 `SFSpeechRecognizer` fallback) +- ✍️ **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 + +- macOS with **Xcode 16+** (Xcode 26 recommended) +- iPhone running **iOS 18.0+** (iOS 26+ for the best on-device ASR) +- [XcodeGen](https://github.com/yonaskolb/XcodeGen): `brew install xcodegen` +- An OpenAI-compatible API key (e.g. from [OpenAI](https://platform.openai.com/api-keys), [DeepSeek](https://platform.deepseek.com/api_keys), or [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey)) + +### Build & run + +```bash +git clone https://github.com//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`](./OpenLess/PrivacyInfo.xcprivacy). + +--- + +## Architecture + +``` +OSGKeyboard/ +├── OpenLess/ # Main iOS app (settings, onboarding) +│ ├── Views/ # SwiftUI screens +│ ├── OSGKeyboardApp.swift # @main entry +│ ├── PrivacyInfo.xcprivacy # Required privacy manifest +│ └── OpenLess.entitlements # App Group declaration +├── OpenLessKeyboard/ # 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 +├── OpenLessShared/ # Framework shared by app + extension +│ ├── Models/ # ProviderConfig, LLMRequest, LLMProvider +│ ├── Services/ # LLMClient (OpenAI-compatible) +│ └── Constants/ # AppGroup identifier +├── OpenLessTests/ # 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 `OpenLessShared/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. + +```swift +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 uses `SFSpeechRecognizer` instead of `SpeechAnalyzer`; on iOS 26, `SpeechAnalyzer` is significantly faster and supports more locales. + +--- + +## License + +[MIT](./LICENSE) — use it, fork it, ship it. No warranty. + +--- + +## Acknowledgements + +- Inspired by [Typeless](https://typeless.com) and the desktop open-source [OpenLess](https://github.com/Open-Less/openless) +- Built with [XcodeGen](https://github.com/yonaskolb/XcodeGen) +- Powered by Apple's [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) and [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) + +--- + +**Note:** replace `` in badges and the git clone URL with your GitHub username. diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..072fe21 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,152 @@ +# OSGKeyboard + +> 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。 +> 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。 + +![Platform](https://img.shields.io/badge/platform-iOS%2018%2B-0078D4?logo=apple) +![Swift](https://img.shields.io/badge/Swift-6.0-FA7343?logo=swift) +![License](https://img.shields.io/badge/license-MIT-green) + +[English README](./README.md) + +--- + +## 这是什么? + +OSGKeyboard 是商业语音输入工具的免费开源替代。它以 **iOS 自定义键盘扩展** 的形式运行,所以你可以在 **任何 App** 里使用 —— 微信、备忘录、邮件、ChatGPT、Claude、Cursor,无所不能。 + +1. 长按麦克风键 +2. 自由说话 +3. 松开 —— AI 帮你整理成干净的文字,自动插入光标处 + +**音频始终在设备本地转写**(iOS 26+ 用 `SpeechAnalyzer`,iOS 18/19 用 `SFSpeechRecognizer`),**只有润色后的文本** 会发到你选择的云端 LLM。**音频永不离开你的手机。** + +--- + +## 特性 + +- 🎙 **按住说话**,Typeless 风格的圆形麦克风按钮 +- 🧠 **端侧 ASR**(iOS 26 `SpeechAnalyzer` + `DictationTranscriber`;iOS 18 退回 `SFSpeechRecognizer`) +- ✍️ **AI 润色** —— 自动加结构、补标点、修正语法、可生成列表 +- 🔌 **自带 API 接入** —— 兼容任何 OpenAI 兼容协议端点(OpenAI / DeepSeek / Qwen DashScope / 自建服务器 ……) +- 🔒 **隐私优先** —— 音频不离开设备;只有润色文本会发给你选择的 LLM +- 🎨 **原生 SwiftUI** —— 暗色主题、毛玻璃、约 2000 行 Swift +- 🪶 **零依赖** —— 无 SwiftPM 包、无 CocoaPods、无 Carthage + +--- + +## 快速开始 + +### 环境要求 + +- macOS + **Xcode 16+**(推荐 Xcode 26) +- iPhone 运行 **iOS 18.0+**(iOS 26+ 体验最佳) +- [XcodeGen](https://github.com/yonaskolb/XcodeGen):`brew install xcodegen` +- 一个 OpenAI 兼容 API Key([OpenAI](https://platform.openai.com/api-keys) / [DeepSeek](https://platform.deepseek.com/api_keys) / [Qwen DashScope](https://dashscope.console.aliyun.com/apiKey) 任一) + +### 编译与运行 + +```bash +git clone https://github.com/<你的用户名>/OSGKeyboard.git +cd OSGKeyboard +xcodegen generate # 生成 OSGKeyboard.xcodeproj +open OSGKeyboard.xcodeproj # 或命令行编译: +xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ + -destination 'generic/platform=iOS Simulator' build +``` + +### 在 iOS 中启用键盘 + +1. 在真机/模拟器上运行 App。 +2. 按 3 步引导:**启用键盘** → **允许完全访问**(麦克风 + LLM 调用必须) → **粘贴 API Key**。 +3. 在任意输入框,点 🌐 切换到 **OSGKeyboard**。 +4. 长按麦克风键 → 说话 → 松开。✨ + +> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy)。 + +--- + +## 架构 + +``` +OSGKeyboard/ +├── OpenLess/ # 主 iOS App(设置、Onboarding) +│ ├── Views/ # SwiftUI 屏幕 +│ ├── OSGKeyboardApp.swift # @main 入口 +│ ├── PrivacyInfo.xcprivacy # 隐私清单 +│ └── OpenLess.entitlements # App Group 声明 +├── OpenLessKeyboard/ # 自定义键盘扩展 +│ ├── KeyboardViewController.swift # 主体类 +│ ├── Services/ +│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM +│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR +│ │ └── PolishingService.swift # LLM 调用(带超时) +│ └── Views/ # 录音按钮、波形、键盘主视图 +├── OpenLessShared/ # 主 App + 键盘共享 framework +│ ├── Models/ # ProviderConfig、LLMRequest、LLMProvider +│ ├── Services/ # LLMClient(OpenAI 兼容) +│ └── Constants/ # App Group ID +├── OpenLessTests/ # XCTest 单元测试 +├── project.yml # XcodeGen 工程定义 +└── .github/workflows/ci.yml # Lint + 编译 CI +``` + +### 数据流 + +``` +[长按麦克风] → AudioCaptureService → AudioBufferSnapshot (16kHz mono) + ↓ + ASRService.transcribe() + ↓ + ASREvent.final(原始转写文本) + ↓ + PolishingService.polish() + ↓ + LLMClient(OpenAI 兼容协议) + ↓ + textDocumentProxy.insertText(润色后文本) +``` + +--- + +## 新增 LLM 提供商 + +打开 `OpenLessShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 + +```swift +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") +) +``` + +仅此而已,**无需改动其他代码**。 + +--- + +## 限制 + +- iOS 沙盒:键盘扩展 ~60 MB 内存上限,必须开完全访问 +- 密码框与部分 `WKWebView` 输入框不可用(iOS 限制) +- iOS 18 用 `SFSpeechRecognizer` 退化路径;iOS 26 的 `SpeechAnalyzer` 更快、支持语种更多 + +--- + +## 许可 + +[MIT](./LICENSE) —— 使用、修改、商用均可。无任何担保。 + +--- + +## 致谢 + +- 灵感来源:[Typeless](https://typeless.com) 与桌面端开源版 [OpenLess](https://github.com/Open-Less/openless) +- 工程脚手架:[XcodeGen](https://github.com/yonaskolb/XcodeGen) +- 端侧 ASR:Apple [SpeechAnalyzer](https://developer.apple.com/documentation/speech/speechanalyzer) / [SFSpeechRecognizer](https://developer.apple.com/documentation/speech/sfspeechrecognizer) + +--- + +**注意**:把 `<你的用户名>` 替换成你的 GitHub 用户名。 diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..e130c27 --- /dev/null +++ b/project.yml @@ -0,0 +1,154 @@ +# XcodeGen configuration for OSGKeyboard +# Run `xcodegen generate` to create OSGKeyboard.xcodeproj +# Repo only tracks project.yml; .xcodeproj is gitignored. + +name: OSGKeyboard +options: + bundleIdPrefix: com.osgkeyboard + deploymentTarget: + iOS: "18.0" + developmentLanguage: en + createIntermediateGroups: true + generateEmptyDirectories: true + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "6.0" + IPHONEOS_DEPLOYMENT_TARGET: "18.0" + ENABLE_USER_SCRIPT_SANDBOXING: YES + SWIFT_STRICT_CONCURRENCY: complete + GENERATE_INFOPLIST_FILE: NO + ENABLE_MODULE_VERIFIER: YES + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "Apple Development" + DEVELOPMENT_TEAM: "" + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + +targets: + + # ========================================================= + # 主 App + # ========================================================= + OSGKeyboard: + type: application + platform: iOS + sources: + - path: OpenLess + entitlements: + path: OpenLess/OpenLess.entitlements + resources: + - path: OpenLess/Assets.xcassets + info: + path: OpenLess/Info.plist + properties: + CFBundleDisplayName: OSGKeyboard + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + UILaunchScreen: + UIColorName: "" + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + NSMicrophoneUsageDescription: "OSGKeyboard needs microphone access to transcribe your voice into text." + NSAppTransportSecurity: + NSAllowsArbitraryLoads: false + CFBundleURLTypes: + - CFBundleURLName: com.osgkeyboard.ios.settings + CFBundleURLSchemes: + - osgkeyboard + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES + dependencies: + - target: OSGKeyboardShared + embed: true + - target: OSGKeyboardExt + # Keyboard Extension is a plugin of the main App; embed it. + + # ========================================================= + # Keyboard Extension + # ========================================================= + OSGKeyboardExt: + type: app-extension + platform: iOS + sources: + - path: OpenLessKeyboard + entitlements: + path: OpenLessKeyboard/OpenLessKeyboard.entitlements + info: + path: OpenLessKeyboard/Info.plist + properties: + CFBundleDisplayName: OSGKeyboard + CFBundleShortVersionString: "$(MARKETING_VERSION)" + CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" + NSExtension: + NSExtensionAttributes: + IsASCIICapable: false + PrefersRightToLeft: false + PrimaryLanguage: "en-US" + RequestsOpenAccess: true + NSExtensionPointIdentifier: com.apple.keyboard-service + NSExtensionPrincipalClass: $(PRODUCT_MODULE_NAME).KeyboardViewController + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.keyboard + TARGETED_DEVICE_FAMILY: "1,2" + dependencies: + - target: OSGKeyboardShared + embed: false + + # ========================================================= + # Shared Framework (主 App 与扩展共用) + # ========================================================= + OSGKeyboardShared: + type: framework + platform: iOS + sources: + - path: OpenLessShared + info: + path: OpenLessShared/Info.plist + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared + DEFINES_MODULE: YES + SKIP_INSTALL: YES + BUILD_LIBRARY_FOR_DISTRIBUTION: NO + DYLIB_INSTALL_NAME_BASE: "@rpath" + APPLICATION_EXTENSION_API_ONLY: YES + ENABLE_MODULE_VERIFIER: YES + + # ========================================================= + # 单元测试 + # ========================================================= + OSGKeyboardTests: + type: bundle.unit-test + platform: iOS + sources: + - path: OpenLessTests + info: + path: OpenLessTests/Info.plist + dependencies: + - target: OSGKeyboard + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.tests + +schemes: + OSGKeyboard: + build: + targets: + OSGKeyboard: all + OSGKeyboardExt: all + run: + config: Debug + test: + config: Debug + targets: + - OSGKeyboardTests + archive: + config: Release