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)
This commit is contained in:
@@ -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.
|
||||
|
||||
```
|
||||
<logs here>
|
||||
```
|
||||
|
||||
## Screenshots / recordings
|
||||
|
||||
If applicable, add screenshots or a short screen recording.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+51
@@ -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/
|
||||
@@ -0,0 +1 @@
|
||||
6.0
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 · <Target>` → 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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.osgkeyboard.ios.settings</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>osgkeyboard</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedUsageTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>CA92.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>C617.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>E174.1</string>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>35F9.1</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<String>,
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>OSGKeyboard</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>IsASCIICapable</key>
|
||||
<false/>
|
||||
<key>PrefersRightToLeft</key>
|
||||
<false/>
|
||||
<key>PrimaryLanguage</key>
|
||||
<string>en-US</string>
|
||||
<key>RequestsOpenAccess</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.keyboard-service</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).KeyboardViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<AudioBufferSnapshot>?
|
||||
private var recordContinuation: Task<Void, Never>?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var lastTranscript: String = ""
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private var hosting: UIHostingController<KeyboardRootView>!
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyCollectedUsageTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<string>CA92.1</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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<AudioBufferSnapshot>) -> AsyncStream<ASREvent>
|
||||
|
||||
/// 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<Void, Never>?
|
||||
|
||||
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
|
||||
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<AudioBufferSnapshot> { 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<Void, Never>?
|
||||
|
||||
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
|
||||
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<AudioBufferSnapshot> { 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// AudioCaptureService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
|
||||
// Exposes an AsyncStream<AVAudioPCMBuffer> 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<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() -> AsyncStream<AudioBufferSnapshot> {
|
||||
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<Float>.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<AVAudioPCMBuffer> {
|
||||
let format = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
let snapshots = self
|
||||
return AsyncStream<AVAudioPCMBuffer> { 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<Float>.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.yield(pcm)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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..<barCount, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: 3, height: heightFor(index: i))
|
||||
.animation(.easeInOut(duration: 0.18), value: level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func heightFor(index: Int) -> 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[中文 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/<OWNER>/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 `<OWNER>` in badges and the git clone URL with your GitHub username.
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# OSGKeyboard
|
||||
|
||||
> 按住说话,松开即得 AI 润色文字,插入任意 App 的光标处。
|
||||
> 一款开源的 iOS 自定义键盘语音输入工具,灵感来自 [Typeless](https://typeless.com) 和 [OpenLess](https://github.com/Open-Less/openless)。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[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 用户名。
|
||||
+154
@@ -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
|
||||
Reference in New Issue
Block a user