feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese

This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).

Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
  (OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
  OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
  strict-concurrency checks while still serialising engine + converter
  state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
  Motion) lifted into OSGKeyboardShared so the host app and the
  keyboard extension stay in lock-step.

Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
  Automatic Signing. They are now declared in project.yml so
  'xcodegen generate' re-emits them every time. iOS Developer
  Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
  that locked out every press after the first because
  recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
  Task) so the press flow optimistically enters .recording;
  permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
  TapGesture pair separated by pressArmed, so a single tap no
  longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
  pseudo-random walk); the visible waveform is now driven by
  actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
  (auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
  first-class Chinese / English / Japanese / Korean dictation,
  with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
  routing is restored.

Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
  ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
  no longer crops the keyboard under the Spotlight bar / home
  indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
  (Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
  balanced.
- RecordButton onPressEnded is now safe to fire from a quick
  press; pressArmed prevents double-firing.

LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
  (server body is now logged at debug, never surfaced to UI);
  added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
  domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
  (Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).

Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
  new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
  Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
  blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
  reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
  has a hit-test bug on iOS 18 that ate gestures from adjacent
  TextFields (manifested as "typing jumps to a website"). It is
  now an explicit Button + contentShape + .submitLabel(.done) on
  the fields.

Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
  persistence + OpenAI request/response + HTTP error + missing
  key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
  *before* mutating, so switching providers actually resets the
  system prompt to the new default.

Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
  iOS 27 beta aborts with __abort_with_payload (dispatch
  library ABI mismatch); use an iOS 18 real device or the
  iOS 26 simulator for now.

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 01:22:12 +08:00
parent 07067ca6c5
commit bec36befa2
59 changed files with 3205 additions and 1550 deletions
+4 -4
View File
@@ -21,10 +21,10 @@ opt_in_rules:
- redundant_optional_initialization
included:
- OpenLess
- OpenLessKeyboard
- OpenLessShared
- OpenLessTests
- OSGKeyboard
- OSGKeyboardExt
- OSGKeyboardShared
- OSGKeyboardTests
excluded:
- build
+6 -6
View File
@@ -32,7 +32,7 @@ Open an issue using the **Feature request** template. Briefly describe:
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.
4. **Tests.** Add XCTest coverage in `OSGKeyboardTests/` for any non-trivial logic.
5. **Build & test before pushing:**
```bash
xcodebuild -project OSGKeyboard.xcodeproj \
@@ -46,17 +46,17 @@ Open an issue using the **Feature request** template. Briefly describe:
## Project structure
```
OpenLess/ Main iOS app target
OpenLessKeyboard/ Custom Keyboard Extension target
OpenLessShared/ Framework shared by app + extension
OpenLessTests/ XCTest unit tests
OSGKeyboard/ Main iOS app target
OSGKeyboardExt/ Custom Keyboard Extension target
OSGKeyboardShared/ Framework shared by app + extension
OSGKeyboardTests/ 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.
The simplest contribution: add a preset to `OSGKeyboardShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint.
## Coding conventions
@@ -1,7 +1,7 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"filename" : "Group 24.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.043",
"green" : "0.039",
"red" : "0.039"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -46,7 +46,7 @@
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
<string>BackgroundColor</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
+12
View File
@@ -0,0 +1,12 @@
<?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>com.apple.security.application-groups</key>
<array>
<string>group.com.osgkeyboard.shared</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+31
View File
@@ -0,0 +1,31 @@
// OSGKeyboardApp.swift
// OSGKeyboard · Main App
//
// DEBUG VERSION 2: restore real flow but instrument every step.
import SwiftUI
import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
@StateObject private var config = ProviderConfig.shared
init() {
print("🔥 [OSGKeyboardApp] init()")
}
var body: some Scene {
WindowGroup {
Group {
if config.isConfigured {
HomeView()
.onAppear { print("🔥 [OSGKeyboardApp] → HomeView appeared") }
} else {
OnboardingView(config: config)
.onAppear { print("🔥 [OSGKeyboardApp] → OnboardingView appeared") }
}
}
.onAppear { print("🔥 [OSGKeyboardApp] body appeared") }
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// APISettingsCard.swift
// OSGKeyboard · Main App
//
// Editable fields for the three OpenAI-compatible config values:
// Base URL, API Key, Model.
import SwiftUI
import OSGKeyboardShared
struct APISettingsCard: View {
@ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false
var body: some View {
VStack(spacing: 0) {
field(
title: "Base URL",
placeholder: "https://api.openai.com/v1",
text: $config.baseURL,
keyboard: .URL,
autocap: false
)
Divider().background(Palette.divider)
keyField
Divider().background(Palette.divider)
field(
title: "Model",
placeholder: "gpt-4o-mini",
text: $config.model,
keyboard: .default,
autocap: false
)
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
Divider().background(Palette.divider)
// Use a Button + UIApplication.open instead of SwiftUI
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that
// makes its tappable area eat gestures from the adjacent
// TextField, which manifests as "typing jumps to a website".
Button {
UIApplication.shared.open(url)
} label: {
HStack {
Image(systemName: "key.fill")
.foregroundStyle(Palette.accent)
Text("Get an API key")
.foregroundStyle(Palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
// MARK: - Key
private var keyField: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("API Key")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
Spacer()
Button(action: { showKey.toggle() }) {
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
.foregroundStyle(Palette.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(showKey ? "Hide key" : "Show key"))
}
Group {
if showKey {
TextField("sk-…", text: $config.apiKey)
} else {
SecureField("sk-…", text: $config.apiKey)
}
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
// MARK: - Generic field
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType,
autocap: Bool
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
TextField(placeholder, text: text)
.keyboardType(keyboard)
.autocorrectionDisabled(true)
.textInputAutocapitalization(autocap ? .sentences : .never)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.submitLabel(.done)
.onSubmit { /* no-op: prevent the keyboard from "submitting"
and dismissing the sheet on iOS 18 */ }
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
+157
View File
@@ -0,0 +1,157 @@
// HomeView.swift
// OSGKeyboard · Main App
//
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
// (2) give a clear path to the next setup step if anything is missing.
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardExt
struct HomeView: View {
@ObservedObject var config = ProviderConfig.shared
@State private var showSettings = false
@State private var showKeyboardPreview = false
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
statusHeader
.padding(.top, Spacing.xl)
Spacer()
heroButton
Spacer()
actionStack
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showKeyboardPreview) {
KeyboardPreviewSheet()
}
.preferredColorScheme(.dark)
}
// MARK: - Header
private var statusHeader: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
Circle()
.fill(config.isConfigured ? Palette.success : Palette.warning)
.frame(width: 8, height: 8)
Text(config.isConfigured ? "Ready" : "Setup incomplete")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
Text("OSGKeyboard")
.font(TypeStyle.largeTitle)
.foregroundStyle(Palette.textPrimary)
Text(providerLine)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
}
}
private var providerLine: String {
let p = LLMProvider.provider(id: config.providerId)
let model = config.model.isEmpty ? "" : config.model
return "\(p.name) · \(model)"
}
// MARK: - Hero
private var heroButton: some View {
Button {
showSettings = true
} label: {
ZStack {
Circle()
.fill(Palette.accentMuted)
.frame(width: 220, height: 220)
.blur(radius: 40)
Circle()
.fill(LinearGradient(
colors: [Palette.surfaceElevated, Palette.surface],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 160, height: 160)
.overlay(
Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5)
)
.shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8)
VStack(spacing: 6) {
Image(systemName: "waveform")
.font(.system(size: 36, weight: .light))
.foregroundStyle(Palette.accent)
Text("Tap to configure")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Open OSGKeyboard settings"))
}
// MARK: - Actions
private var actionStack: some View {
VStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard")
.primaryButton()
}
.buttonStyle(.plain)
Button {
showSettings = true
} label: {
Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3")
.secondaryButton()
}
.buttonStyle(.plain)
#if DEBUG
Button {
showKeyboardPreview = true
} label: {
Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye")
.secondaryButton()
}
.buttonStyle(.plain)
#endif
HStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
UIApplication.shared.open(url)
}
} label: {
Text("GitHub")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 6)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
Spacer()
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
}
}
@@ -0,0 +1,99 @@
// KeyboardPreviewSheet.swift
// OSGKeyboard · Main App
//
// Renders a stand-in keyboard layout inside the main app so the user
// can preview what the real keyboard extension looks like without
// enabling the keyboard in iOS Settings. Tap the disc to cycle
// through idle / recording / processing so all visual states are
// inspectable.
import SwiftUI
import OSGKeyboardShared
struct KeyboardPreviewSheet: View {
@Environment(\.dismiss) private var dismiss
// We mirror only the fields the stand-in actually needs, instead of
// crossing the OSGKeyboardExt target boundary to construct a
// `KeyboardViewController.State` (whose initialiser is internal).
@State private var phase: StubPhase = .idle
@State private var level: Double = 0
@State private var transcript: String = ""
private enum StubPhase { case idle, recording, processing }
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
VStack(spacing: Spacing.md) {
Text("Keyboard Preview")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Tap the disc to cycle states. The real keyboard uses the same layout.")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
mockTextField.padding(.horizontal, Spacing.md)
}
.padding(.top, Spacing.lg)
Spacer(minLength: 0)
keyboardBlock
}
}
.preferredColorScheme(.dark)
}
private var mockTextField: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Palette.textSecondary)
Text("Type here…")
.foregroundStyle(Palette.textTertiary)
Spacer()
}
.padding(Spacing.md)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
private var keyboardBlock: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Palette.divider)
.frame(height: 0.5)
KeyboardPreviewStub(
phase: stubPhase,
level: level,
transcript: transcript
)
.environment(\.colorScheme, .dark)
Rectangle()
.fill(Color.black)
.frame(height: 34)
.overlay(alignment: .center) {
Capsule()
.fill(Color.white.opacity(0.4))
.frame(width: 134, height: 5)
}
}
}
private var stubPhase: KeyboardPreviewStub.Phase {
switch phase {
case .idle: return .idle
case .recording: return .recording
case .processing: return .processing
}
}
}
#if DEBUG
#Preview {
KeyboardPreviewSheet()
}
#endif
+235
View File
@@ -0,0 +1,235 @@
// KeyboardPreviewStub.swift
// OSGKeyboard · Main App
//
// Stand-in for the keyboard extension's SwiftUI tree. iOS does not allow
// the host app to import symbols from its own keyboard extension target,
// so we ship a minimal mirror here. The actual production layout lives
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
// when the user enables the keyboard in iOS Settings.
import SwiftUI
import OSGKeyboardShared
struct KeyboardPreviewStub: View {
enum Phase { case idle, recording, processing }
let phase: Phase
let level: Double
let transcript: String
var body: some View {
ZStack(alignment: .top) {
Palette.background
VStack(spacing: 0) {
topBar.frame(height: 32)
centreArea.frame(maxWidth: .infinity, maxHeight: .infinity)
bottomBar.frame(height: 56)
}
.padding(.top, 4)
.padding(.bottom, 6)
}
.frame(height: 280)
.preferredColorScheme(.dark)
}
// MARK: - Top bar
private var topBar: some View {
HStack(spacing: Spacing.xs) {
modeChip
localeChip
Spacer(minLength: 0)
statusBadge
Button(action: {}) {
Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Palette.textSecondary)
.frame(width: 28, height: 28)
.background(Palette.surface, in: Circle())
.overlay(Circle().stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
}
.padding(.horizontal, Spacing.md)
}
private var modeChip: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
Text("润色")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
private var localeChip: some View {
HStack(spacing: 4) {
Image(systemName: "globe")
Text("简体")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
private var statusBadge: some View {
Group {
switch phase {
case .idle:
EmptyView()
case .recording:
HStack(spacing: 4) {
Circle().fill(Palette.recordRed).frame(width: 6, height: 6)
Text("REC").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
case .processing:
HStack(spacing: 4) {
Circle().fill(Palette.accent).frame(width: 6, height: 6)
Text("···").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
}
}
// MARK: - Centre area
private var centreArea: some View {
VStack(spacing: Spacing.xxs) {
transcriptLine.frame(height: 22)
recordDisc.frame(width: 140, height: 140)
}
.frame(maxWidth: .infinity)
}
private var transcriptLine: some View {
Group {
switch phase {
case .idle:
Text("按住说话 · Hold to talk")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textTertiary)
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(Palette.accent)
Text("润色中 · Polishing")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
}
}
.padding(.horizontal, Spacing.md)
}
private var recordDisc: some View {
ZStack {
if phase == .recording {
Circle()
.stroke(Palette.recordRed.opacity(0.35), lineWidth: 2)
.frame(width: 110, height: 110)
.opacity(0.6)
Circle()
.fill(RadialGradient(colors: [Palette.recordRed.opacity(0.55), .clear], center: .center, startRadius: 30, endRadius: 70))
.frame(width: 160, height: 160)
.blur(radius: 12)
.opacity(0.4 + level * 0.6)
}
Circle()
.fill(discGradient)
.frame(width: 96, height: 96)
.overlay(Circle().stroke(Color.white.opacity(0.16), lineWidth: 1))
.shadow(color: .black.opacity(0.4), radius: 10, y: 6)
Group {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(.white)
case .recording:
HStack(spacing: 3) {
ForEach(0..<12, id: \.self) { i in
Capsule()
.fill(Palette.recordRed)
.frame(width: 2, height: 8 + CGFloat(level * 30) * (i.isMultiple(of: 2) ? 1 : 0.6))
}
}
.frame(width: 60, height: 32)
case .processing:
ProgressView().tint(.white).scaleEffect(1.1)
}
}
}
}
private var discGradient: LinearGradient {
switch phase {
case .recording:
return LinearGradient(colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], startPoint: .top, endPoint: .bottom)
case .processing:
return LinearGradient(colors: [Palette.surfaceElevated, Palette.surface], startPoint: .top, endPoint: .bottom)
case .idle:
return LinearGradient(colors: [Color(white: 0.22), Color(white: 0.10)], startPoint: .top, endPoint: .bottom)
}
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack(spacing: Spacing.xxs) {
iconButton("globe")
iconButton("delete.left")
Spacer(minLength: 0)
Button(action: {}) {
Text("空格")
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
Spacer(minLength: 0)
iconButton("return")
}
.padding(.horizontal, Spacing.sm)
}
private func iconButton(_ systemName: String) -> some View {
Button(action: {}) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Palette.textPrimary)
.frame(width: 40, height: 40)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
}
}
#if DEBUG
#Preview {
KeyboardPreviewStub(phase: .idle, level: 0, transcript: "")
.preferredColorScheme(.dark)
}
#endif
+257
View File
@@ -0,0 +1,257 @@
// OnboardingView.swift
// OSGKeyboard · Main App
//
// Three-step onboarding presented as a horizontal pager:
//
// 1) Welcome what the app does, in one sentence
// 2) Enable Settings General Keyboards Add Allow Full Access
// 3) Setup pick a provider, paste a key
//
// Visual style: one large accent surface, generous whitespace, single CTA
// at the bottom. No tipsy animations, no cheerful illustrations every
// pixel is doing one job.
import SwiftUI
import OSGKeyboardShared
struct OnboardingView: View {
@ObservedObject var config: ProviderConfig
@State private var page: Int = 0
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $page) {
WelcomePage().tag(0)
EnableKeyboardPage().tag(1)
APISetupPage(config: config).tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
pageDots
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
}
}
.preferredColorScheme(.dark)
}
private var pageDots: some View {
HStack(spacing: 6) {
ForEach(0..<3, id: \.self) { i in
Capsule()
.fill(i == page ? Palette.accent : Color.white.opacity(0.18))
.frame(width: i == page ? 18 : 6, height: 6)
.animation(Motion.quick, value: page)
}
}
}
@ViewBuilder
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if page > 0 {
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
Text("Back")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.dividerStrong, lineWidth: 0.5)
)
.foregroundStyle(Palette.textPrimary)
}
.buttonStyle(.plain)
}
Button {
withAnimation(Motion.soft) {
if page < 2 { page += 1 }
}
} label: {
Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
(page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.foregroundStyle(
(page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent
)
}
.buttonStyle(.plain)
.disabled(page == 2 && !config.isConfigured)
}
}
}
// MARK: - Page 1: Welcome
private struct WelcomePage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
ZStack {
Circle()
.fill(Palette.accentMuted)
.frame(width: 180, height: 180)
.blur(radius: 30)
Image(systemName: "mic.circle.fill")
.font(.system(size: 96, weight: .light))
.foregroundStyle(Palette.accent)
}
VStack(spacing: Spacing.sm) {
Text("OSGKeyboard")
.font(TypeStyle.title)
.foregroundStyle(Palette.textPrimary)
Text("按住说话,松开即得润色文字。")
.font(TypeStyle.body)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
Text("Hold to talk. Release for polished text, in any app.")
.font(TypeStyle.footnote)
.foregroundStyle(Palette.textTertiary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, Spacing.xl)
PrivacyFootnote()
.padding(.top, Spacing.lg)
Spacer()
}
}
}
private struct PrivacyFootnote: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
footnoteRow(icon: "lock.fill",
title: "Audio stays on device",
body: "Transcribed locally with Apple's speech engine.")
footnoteRow(icon: "wifi",
title: "Only the polished text is sent",
body: "Sent to your chosen LLM to add structure & punctuation.")
footnoteRow(icon: "keyboard",
title: "Works everywhere",
body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.")
}
.padding(Spacing.md)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
.padding(.horizontal, Spacing.md)
}
private func footnoteRow(icon: String, title: String, body: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Palette.accent)
.frame(width: 24, height: 24)
.background(Palette.accentMuted, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textPrimary)
Text(body)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textSecondary)
}
}
}
}
// MARK: - Page 2: Enable keyboard
private struct EnableKeyboardPage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
Image(systemName: "keyboard.fill")
.font(.system(size: 64, weight: .light))
.foregroundStyle(Palette.accent)
VStack(spacing: Spacing.sm) {
Text("启用 OSGKeyboard")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Enable OSGKeyboard")
.font(TypeStyle.body)
.foregroundStyle(Palette.textTertiary)
}
VStack(alignment: .leading, spacing: Spacing.sm) {
step(num: 1, text: "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”")
step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.")
}
.cardSurface()
.padding(.horizontal, Spacing.md)
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
.primaryButton()
}
.padding(.horizontal, Spacing.md)
Spacer()
}
}
private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Text("\(num)")
.font(TypeStyle.caption2)
.frame(width: 22, height: 22)
.background(Palette.accent, in: Circle())
.foregroundStyle(Palette.textOnAccent)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Page 3: API setup
private struct APISetupPage: View {
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("配置 AI 提供商")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Configure your AI provider")
.font(TypeStyle.body)
.foregroundStyle(Palette.textTertiary)
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
.font(TypeStyle.footnote)
.foregroundStyle(Palette.textSecondary)
.padding(.top, Spacing.xxs)
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.lg)
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.md)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.md)
}
.padding(.bottom, Spacing.xxxl)
}
}
}
@@ -0,0 +1,74 @@
// ProviderPickerSection.swift
// OSGKeyboard · Main App
//
// Provider picker shown inline inside Settings & Onboarding. Each option
// surfaces the provider's name + a short blurb so the user can pick
// confidently without opening a doc.
import SwiftUI
import OSGKeyboardShared
struct ProviderPickerSection: View {
@ObservedObject var config: ProviderConfig
var body: some View {
VStack(spacing: 0) {
ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in
Button {
select(provider)
} label: {
row(provider, selected: provider.id == config.providerId)
}
.buttonStyle(.plain)
if index < LLMProvider.presets.count - 1 {
Divider().background(Palette.divider)
}
}
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
private func select(_ provider: LLMProvider) {
withAnimation(Motion.quick) {
config.apply(preset: provider)
}
}
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
HStack(spacing: Spacing.xs) {
// Provider mark a coloured dot with first letter
ZStack {
Circle()
.fill(selected ? Palette.accent : Palette.surfaceElevated)
.frame(width: 36, height: 36)
Text(String(provider.name.prefix(1)))
.font(TypeStyle.bodyEmph)
.foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text(provider.name)
.font(TypeStyle.bodyEmph)
.foregroundStyle(Palette.textPrimary)
if let blurb = provider.blurb {
Text(blurb)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
Spacer()
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.contentShape(Rectangle())
}
}
+227
View File
@@ -0,0 +1,227 @@
// SettingsView.swift
// OSGKeyboard · Main App
//
// Sheet that hosts the API configuration. Single scrollable column, every
// field earns its space.
import SwiftUI
import OSGKeyboardShared
struct SettingsView: View {
@ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss
@State private var showResetConfirm = false
var body: some View {
NavigationStack {
ZStack {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(spacing: Spacing.md) {
providerSection
apiSection
languageSection
promptSection
resetButton
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
}
.navigationTitle("设置 · Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.font(TypeStyle.headline)
.foregroundStyle(Palette.accent)
}
}
.preferredColorScheme(.dark)
}
.confirmationDialog(
"Reset all settings?",
isPresented: $showResetConfirm,
titleVisibility: .visible
) {
Button("Reset", role: .destructive) {
config.reset()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("API key, model, and base URL will be cleared.")
}
}
// MARK: - Provider
private var providerSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("Provider · 提供商", subtitle: "Pick the LLM that polishes your dictation.")
ProviderPickerSection(config: config)
}
}
// MARK: - API
private var apiSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("API · 接口", subtitle: nil)
APISettingsCard(config: config)
}
}
// MARK: - Language (ASR + mode)
private var languageSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("Language · 语言", subtitle: "Choose ASR locale and dictation mode.")
VStack(spacing: 0) {
PickerRow(
title: "Mode",
options: modeOptions,
selection: Binding(
get: { config.modeId },
set: { config.modeId = $0 }
)
)
Divider().background(Palette.divider)
PickerRow(
title: "ASR locale",
options: localeOptions,
selection: Binding(
get: { config.localeId },
set: { config.localeId = $0 }
)
)
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
}
private var modeOptions: [(id: String, label: String)] {
[
("off", "Off · 关闭"),
("transcribe", "Transcribe · 仅转写"),
("polish", "Polish · 润色")
]
}
private var localeOptions: [(id: String, label: String)] {
[
("auto", "Auto · 跟随系统"),
("zh-Hans", "中文(简体)"),
("zh-Hant", "中文(繁體)"),
("en-US", "English (US)"),
("ja-JP", "日本語"),
("ko-KR", "한국어")
]
}
// MARK: - Prompt
private var promptSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
sectionHeader("System Prompt · 系统提示", subtitle: nil)
Spacer()
Button("Reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2)
.foregroundStyle(Palette.accent)
}
VStack(alignment: .leading, spacing: Spacing.xs) {
TextEditor(text: $config.systemPrompt)
.font(TypeStyle.mono)
.scrollContentBackground(.hidden)
.frame(minHeight: 140)
.padding(Spacing.xs)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
.cardSurface()
}
}
// MARK: - Reset
private var resetButton: some View {
Button(role: .destructive) {
showResetConfirm = true
} label: {
Text("Reset all settings")
.font(TypeStyle.caption)
.foregroundStyle(Palette.danger)
.frame(maxWidth: .infinity, minHeight: 40)
}
.buttonStyle(.plain)
}
// MARK: - Header
private func sectionHeader(_ title: String, subtitle: String?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textSecondary)
.textCase(.uppercase)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Picker row
private struct PickerRow: View {
let title: String
let options: [(id: String, label: String)]
@Binding var selection: String
var body: some View {
HStack {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
Spacer()
Menu {
ForEach(options, id: \.id) { o in
Button {
selection = o.id
} label: {
if o.id == selection {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(Palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Palette.textTertiary)
}
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
private var currentLabel: String {
options.first(where: { $0.id == selection })?.label ?? ""
}
}
+392
View File
@@ -0,0 +1,392 @@
// KeyboardViewController.swift
// OSGKeyboard · Keyboard Extension
//
// Principal class for the Custom Keyboard Extension. Hosts a single
// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline:
//
// AudioCaptureService ASRService PolishingService insertText
//
// Design notes:
// The class is `@MainActor` every UI mutation and `textDocumentProxy`
// call must happen on main, and Swift 6 strict concurrency forces this.
// State is a single `State` ObservableObject; SwiftUI observes it via
// `@ObservedObject` so we never re-create the hosting root on each tick.
// `phase` is a real stored property (no derivation) the previous
// "derive from recordStream" shim locked out every press after the first.
// Microphone permission is requested *inside* pressBegan, but we still
// start the rest of the press flow optimistically; if permission is
// denied we surface a short error and drop back to idle cleanly.
import UIKit
import SwiftUI
import AVFoundation
import OSGKeyboardShared
@objc(KeyboardViewController)
@MainActor
public final class KeyboardViewController: UIInputViewController {
// MARK: - View model
@MainActor
public final class State: ObservableObject {
public init() {}
public enum Phase: Equatable {
case idle
case recording
case processing
case error(String)
}
public enum InputMode: String, CaseIterable, Identifiable {
case off
case transcribe
case polish
public var id: String { rawValue }
public var labelKey: String {
switch self {
case .off: return "mode.off"
case .transcribe: return "mode.transcribe"
case .polish: return "mode.polish"
}
}
}
@Published public var phase: Phase = .idle
@Published public var level: Double = 0
@Published public var mode: InputMode = .polish
@Published public var localeId: String = "auto"
@Published public var lastTranscript: String = ""
// Action hooks injected by the view controller at install time.
var beginRecording: () -> Void = {}
var endRecording: () -> Void = {}
var tapMic: () -> Void = {} // tap on the mic area (advances keyboard)
var openSettings: () -> Void = {}
var setMode: (InputMode) -> Void = { _ in }
var setLocale: (String) -> Void = { _ in }
var insertNewline: () -> Void = {}
var insertSpace: () -> Void = {}
var deleteBackward: () -> Void = {}
// MARK: - Preview helpers
#if DEBUG
static var previewIdle: State {
let s = State()
s.phase = .idle
s.level = 0
s.mode = .polish
s.localeId = "zh-Hans"
s.lastTranscript = ""
return s
}
static var previewRecording: State {
let s = State()
s.phase = .recording
s.level = 0.65
s.mode = .polish
s.localeId = "zh-Hans"
s.lastTranscript = "你好,我想说一段测试"
return s
}
static var previewProcessing: State {
let s = State()
s.phase = .processing
s.level = 0
s.mode = .polish
s.localeId = "zh-Hans"
s.lastTranscript = ""
return s
}
#endif
}
// MARK: - State
private let state = State()
private let audio = AudioCaptureService()
private let asr: ASRService = ASRServiceFactory.make()
private let polisher = PolishingService()
private var session: AudioCaptureService.Session?
private var asrTask: Task<Void, Never>?
private var levelTask: Task<Void, Never>?
private var didRequestMicOnce: Bool = false
private var hosting: UIHostingController<KeyboardRootView>!
// MARK: - Lifecycle
public override func viewDidLoad() {
super.viewDidLoad()
// iOS 18 keyboard extension MUST opt in to self-sizing, otherwise
// our SwiftUI `frame(height:)` is ignored and the keyboard is
// cropped by the system chrome (Spotlight bar, home indicator).
inputView?.allowsSelfSizing = true
installStateActions()
installSwiftUI()
loadPersistedLocale()
}
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
cancelPipeline()
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
}
public override func textDidChange(_ textInput: (any UITextInput)?) {
super.textDidChange(textInput)
// Hook for future per-app mode switching (e.g. password field .off).
}
// MARK: - Wiring
private func installStateActions() {
state.beginRecording = { [weak self] in self?.pressBegan() }
state.endRecording = { [weak self] in self?.pressEnded() }
state.tapMic = { [weak self] in self?.advanceToNextInputMode() }
state.openSettings = { [weak self] in self?.openHostApp() }
state.setMode = { [weak self] m in self?.persistMode(m) }
state.setLocale = { [weak self] l in self?.persistLocale(l) }
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
}
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let host = UIHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
addChild(host)
view.addSubview(host.view)
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),
// Pin the host view to a fixed height matching KeyboardRootView.totalHeight.
// Without this, iOS lets the system chrome (Spotlight, home
// indicator) bleed into our content. With it, our content area
// is fully reserved and the keyboard feels intentional.
host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight)
])
host.didMove(toParent: self)
self.hosting = host
}
private func loadPersistedLocale() {
let store = AppGroupStore()
let id = store.localeId
state.localeId = id
state.mode = State.InputMode(rawValue: store.modeId) ?? .polish
}
// MARK: - Press handlers
private func pressBegan() {
guard state.phase == .idle else { return }
guard state.mode != .off else { return }
// We optimistically enter `.recording`; the capture session will yield
// frames on its own queue, so even if mic permission takes a beat the
// user already feels the press registered.
Task { @MainActor [weak self] in
guard let self else { return }
let granted = await self.requestMicPermission()
guard granted else {
self.state.phase = .error("麦克风被拒绝,请到「设置」中允许")
self.scheduleAutoClearError()
return
}
self.startPipeline()
}
}
private func pressEnded() {
guard state.phase == .recording else { return }
stopPipeline()
}
// MARK: - Pipeline
private func startPipeline() {
let session = audio.start()
self.session = session
state.phase = .recording
state.level = 0
state.lastTranscript = ""
let locale = resolveLocale(state.localeId)
let events = asr.transcribe(stream: session.audio, locale: locale)
asrTask = Task { @MainActor [weak self] in
guard let self else { return }
var lastPartial: String = ""
for await event in events {
switch event {
case .partial(let s):
lastPartial = s
self.state.lastTranscript = s
case .final(let s):
let transcript = s.isEmpty ? lastPartial : s
self.handleFinalTranscript(transcript)
case .error(let m):
self.state.phase = .error("ASR: \(m)")
self.scheduleAutoClearError()
}
}
}
levelTask = Task { @MainActor [weak self] in
for await level in session.levels {
guard let self else { return }
// Smooth a little extra to feel natural.
self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4
}
}
}
private func stopPipeline() {
session?.stop()
session = nil
asrTask?.cancel(); asrTask = nil
levelTask?.cancel(); levelTask = nil
}
private func cancelPipeline() {
stopPipeline()
asr.cancel()
if state.phase == .recording || state.phase == .processing {
state.phase = .idle
}
state.level = 0
}
private func handleFinalTranscript(_ transcript: String) {
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
state.phase = .idle
return
}
// In `.transcribe` mode, skip the LLM and insert raw.
if state.mode == .transcribe {
textDocumentProxy.insertText(trimmed)
state.lastTranscript = ""
state.phase = .idle
return
}
// `.polish` (default): call the LLM.
state.phase = .processing
Task { @MainActor [weak self] in
guard let self else { return }
do {
let polished = try await self.polisher.polish(trimmed)
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
self.state.phase = .idle
} catch {
// Fall back to raw transcript on any failure.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
let msg = (error as? LocalizedError)?.errorDescription
?? "Polishing failed — inserted raw."
self.state.phase = .error(msg)
self.scheduleAutoClearError()
}
}
}
// MARK: - Persistence
private func persistMode(_ m: State.InputMode) {
state.mode = m
AppGroupStore().setModeId(m.rawValue)
}
private func persistLocale(_ id: String) {
state.localeId = id
AppGroupStore().setLocaleId(id)
}
// MARK: - Permissions
private func requestMicPermission() async -> Bool {
if #available(iOS 17.0, *) {
switch AVAudioApplication.shared.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
if !didRequestMicOnce {
didRequestMicOnce = true
return await AVAudioApplication.requestRecordPermission()
}
return false
@unknown default: return false
}
} else {
let session = AVAudioSession.sharedInstance()
switch session.recordPermission {
case .granted: return true
case .denied: return false
case .undetermined:
if !didRequestMicOnce {
didRequestMicOnce = true
return await withCheckedContinuation { cont in
session.requestRecordPermission { cont.resume(returning: $0) }
}
}
return false
@unknown default: return false
}
}
}
// MARK: - Open host app
private func openHostApp() {
let urlString = "osgkeyboard://settings"
if let url = URL(string: urlString) {
var responder: UIResponder? = self
while let r = responder {
if let app = r as? UIApplication {
app.open(url)
return
}
responder = r.next
}
}
if let url = URL(string: UIApplication.openSettingsURLString) {
var responder: UIResponder? = self
while let r = responder {
if let app = r as? UIApplication {
app.open(url); return
}
responder = r.next
}
}
}
// MARK: - Helpers
private func resolveLocale(_ id: String) -> Locale {
if id == "auto" { return .current }
return Locale(identifier: id)
}
private func scheduleAutoClearError() {
Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: 2_400_000_000)
guard let self else { return }
if case .error = self.state.phase {
self.state.phase = .idle
}
}
}
}
@@ -1,5 +1,10 @@
<?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/>
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.osgkeyboard.shared</string>
</array>
</dict>
</plist>
+151
View File
@@ -0,0 +1,151 @@
// ASRService.swift
// OSGKeyboard · Keyboard Extension
//
// Speech-to-text abstraction over Apple's `SFSpeechRecognizer`.
// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP ) so
// dictation is first-class for non-English languages.
import Foundation
import AVFoundation
import Speech
import os.lock
import OSGKeyboardShared
// MARK: - Sendable conformance
// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We
// only ever access them serially the PCM buffer is built and consumed
// inside a single Task, and the recogniser task is cancelled but never
// shared concurrently so an unchecked conformance is sound here.
extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {}
// MARK: - Protocol
public protocol ASRService: Sendable {
/// Start a transcription session. The returned stream emits `.partial`
/// updates and exactly one `.final` (or `.error`) before finishing.
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent>
/// Cancel any in-flight recognition and tear down its tasks.
func cancel()
}
public enum ASREvent: Sendable, Equatable {
case partial(String)
case final(String)
case error(String)
}
// MARK: - Factory
public enum ASRServiceFactory {
public static func make() -> ASRService {
AppleSpeechASR()
}
}
// MARK: - Apple Speech implementation
final class AppleSpeechASR: ASRService, @unchecked Sendable {
private let lock = OSAllocatedUnfairLock()
private var recognizerTask: SFSpeechRecognitionTask?
private var feedTask: Task<Void, Never>?
func transcribe(
stream: AsyncStream<AudioBufferSnapshot>,
locale: Locale
) -> AsyncStream<ASREvent> {
AsyncStream { continuation in
let recognizer = SFSpeechRecognizer(locale: locale)
?? SFSpeechRecognizer(locale: .current)
guard let recognizer, recognizer.isAvailable else {
continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)"))
continuation.finish()
return
}
recognizer.defaultTaskHint = .dictation
let request = SFSpeechAudioBufferRecognitionRequest()
request.shouldReportPartialResults = true
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
let task = recognizer.recognitionTask(with: request) { result, error in
if let error {
let nsErr = error as NSError
// Codes 203 / 1110 = "no speech detected" a normal exit.
if nsErr.code == 203 || nsErr.code == 1110 {
continuation.yield(.final(""))
} else {
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.lock.withLock { self.recognizerTask = task }
// Feed audio: for each snapshot, build a 16 kHz mono Float32
// PCM buffer and immediately `request.append(pcm)`. The PCM
// buffer never leaves this task, so it doesn't need to be
// Sendable.
let feedFormat = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
self.feedTask = Task { [request] in
for await snap in stream {
if Task.isCancelled { break }
guard !snap.samples.isEmpty,
let pcm = AVAudioPCMBuffer(
pcmFormat: feedFormat,
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)
}
}
}
request.append(pcm)
}
if !Task.isCancelled {
request.endAudio()
}
}
continuation.onTermination = { @Sendable [weak self] _ in
self?.cancel()
}
}
}
func cancel() {
let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task<Void, Never>?) in
let r = self.recognizerTask
let f = self.feedTask
self.recognizerTask = nil
self.feedTask = nil
return (r, f)
}
recTask?.cancel()
feedT?.cancel()
}
}
@@ -0,0 +1,315 @@
// AudioCaptureService.swift
// OSGKeyboard · Keyboard Extension
//
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
// Designed for use inside an iOS Custom Keyboard Extension:
// Uses `.record` (not `.playAndRecord`) keyboards cannot play.
// Exposes a Sendable `Session` with two streams:
// - `audio`: 16 kHz mono Float32 frames for ASR.
// - `levels`: RMS + peak dBFS for the animated waveform.
// All mutable state is guarded by a lock; class is `@unchecked Sendable`
// for use with Swift 6 strict concurrency.
import Foundation
import AVFoundation
import os.lock
import OSGKeyboardShared
// MARK: - Sendable conformance
//
// `AVAudioEngine` is not Sendable, but the iOS audio APIs hand us
// closures that need to capture it. We never mutate the engine
// concurrently capture / conversion are serialised on the actor, and
// the tap closure only reads pointers into it. So an unchecked
// retroactive Sendable conformance is sound here.
// `AVAudioConverter` and `AVAudioFormat` are already Sendable in newer
// SDKs; we don't need to redeclare.
extension AVAudioEngine: @unchecked @retroactive Sendable {}
public final class AudioCaptureService: @unchecked Sendable {
// MARK: - Errors
public enum CaptureError: LocalizedError, Sendable {
case sessionConfigFailed(String)
case engineStartFailed(String)
case noInputNode
case alreadyRunning
public var errorDescription: String? {
switch self {
case .sessionConfigFailed(let s): return "Audio session config failed: \(s)"
case .engineStartFailed(let s): return "Audio engine failed to start: \(s)"
case .noInputNode: return "No microphone input available."
case .alreadyRunning: return "Audio capture is already running."
}
}
}
// MARK: - Level payload (Sendable)
public struct Level: Sendable, Equatable {
/// Root-mean-square, 0...1 (linear).
public let rms: Float
/// Peak amplitude, 0...1 (linear).
public let peak: Float
public let timestamp: TimeInterval
/// Convenience: -20 dBFS 0 dBFS mapped to 01 for UI meters.
public var meter: Float {
// Clamp floor at -50 dB so silence still shows a tiny bar.
let db = 20 * log10(max(rms, 1e-7))
let clamped = max(-50, min(0, db))
return Float((clamped + 50) / 50)
}
}
// MARK: - Session (one capture run)
/// A single capture run. Two streams + a stop handle.
public final class Session: @unchecked Sendable {
public let audio: AsyncStream<AudioBufferSnapshot>
public let levels: AsyncStream<Level>
private let onStop: @Sendable () -> Void
fileprivate init(
audio: AsyncStream<AudioBufferSnapshot>,
levels: AsyncStream<Level>,
onStop: @escaping @Sendable () -> Void
) {
self.audio = audio
self.levels = levels
self.onStop = onStop
}
public func stop() { onStop() }
}
// MARK: - State
private let lock = OSAllocatedUnfairLock()
private var engine: AVAudioEngine?
private var converter: AVAudioConverter?
private var audioContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
private var levelContinuation: AsyncStream<Level>.Continuation?
private var isRunning: Bool = false
public init() {}
deinit { stopInternal() }
// MARK: - Public API
/// Start capture. Returns a `Session` whose streams yield audio + level data.
/// The session ends when `Session.stop()` is called or the extension is torn down.
@discardableResult
public func start() -> Session {
let (audioStream, audioCont) = AsyncStream<AudioBufferSnapshot>.makeStream()
let (levelStream, levelCont) = AsyncStream<Level>.makeStream()
// Fail fast if already running.
let alreadyRunning: Bool = lock.withLock { isRunning }
if alreadyRunning {
audioCont.finish()
levelCont.finish()
return Session(audio: audioStream, levels: levelStream, onStop: {})
}
do {
try configureSession()
try bootstrap(audioCont: audioCont, levelCont: levelCont)
lock.withLock {
isRunning = true
audioContinuation = audioCont
levelContinuation = levelCont
}
} catch {
// Tear down whatever we partially created.
audioCont.finish()
levelCont.finish()
teardownEngine()
return Session(audio: audioStream, levels: levelStream, onStop: {})
}
return Session(
audio: audioStream,
levels: levelStream,
onStop: { [weak self] in self?.stop() }
)
}
public func stop() {
stopInternal()
}
// MARK: - Setup
private func configureSession() throws {
#if canImport(UIKit)
let session = AVAudioSession.sharedInstance()
do {
// `.record` keyboards cannot play audio, so .playAndRecord is wrong.
// `.measurement` mode disables system AGC/echo cancellation for cleaner ASR input.
// `.duckOthers` is harmless in record-only.
try session.setCategory(.record, mode: .measurement, options: [.duckOthers])
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
throw CaptureError.sessionConfigFailed(error.localizedDescription)
}
#endif
}
private func bootstrap(
audioCont: AsyncStream<AudioBufferSnapshot>.Continuation,
levelCont: AsyncStream<Level>.Continuation
) throws {
let engine = AVAudioEngine()
let input = engine.inputNode
let hardwareFormat = input.outputFormat(forBus: 0)
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
throw CaptureError.noInputNode
}
let target = AVAudioFormat(
commonFormat: .pcmFormatFloat32,
sampleRate: 16_000,
channels: 1,
interleaved: false
)!
guard let converter = AVAudioConverter(from: hardwareFormat, to: target) else {
throw CaptureError.engineStartFailed("converter init failed")
}
// Persistent buffer for level computation: we re-use Float arrays to
// avoid per-tap allocations.
let levelScratch = LevelScratch()
input.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, when in
// We capture self weakly only to keep the AudioCaptureService
// alive while the tap is installed; the tap itself only
// touches the local `audioCont` / `levelCont` continuations.
guard self != nil else { return }
// 1) Compute level from raw hardware buffer (preserves true amplitude).
let (rms, peak) = levelScratch.measure(buffer: buffer)
// `AVAudioTime` carries both `sampleTime` (frames on the device
// clock) and `hostTime` (mach absolute time). We only need a
// monotonically increasing source for the timestamp; sample
// time / sample rate is good enough and is independent of the
// host clock.
let ts: Double
if when.sampleTime > 0, hardwareFormat.sampleRate > 0 {
ts = Double(when.sampleTime) / hardwareFormat.sampleRate
} else {
ts = Date().timeIntervalSinceReferenceDate
}
levelCont.yield(Level(rms: rms, peak: peak, timestamp: ts))
// 2) Convert to 16 kHz mono Float32 for ASR.
let outFrames = AVAudioFrameCount(
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
)
guard outFrames > 0,
let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames)
else { return }
var error: NSError?
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
outStatus.pointee = .haveData
return buffer
}
if status == .haveData, error == nil {
let snap = AudioBufferSnapshot(buffer: outBuffer)
audioCont.yield(snap)
}
}
do {
try engine.start()
} catch {
input.removeTap(onBus: 0)
throw CaptureError.engineStartFailed(error.localizedDescription)
}
lock.withLock {
self.engine = engine
self.converter = converter
}
}
// MARK: - Teardown
private func stopInternal() {
let (wasRunning, engine, audioCont, levelCont) = lock.withLock { () -> (Bool, AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
let was = isRunning
isRunning = false
let eng = self.engine
let ac = audioContinuation
let lc = levelContinuation
self.engine = nil
self.converter = nil
self.audioContinuation = nil
self.levelContinuation = nil
return (was, eng, ac, lc)
}
guard wasRunning else { return }
engine?.inputNode.removeTap(onBus: 0)
engine?.stop()
#if canImport(UIKit)
// Deactivate the session so other apps' audio routing is restored.
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
#endif
audioCont?.finish()
levelCont?.finish()
}
private func teardownEngine() {
let (engine, audioCont, levelCont) = lock.withLock { () -> (AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
let e = self.engine
let a = audioContinuation
let l = levelContinuation
self.engine = nil
self.converter = nil
self.audioContinuation = nil
self.levelContinuation = nil
self.isRunning = false
return (e, a, l)
}
engine?.inputNode.removeTap(onBus: 0)
engine?.stop()
audioCont?.finish()
levelCont?.finish()
}
}
// MARK: - Level scratch (lock-free, single-writer / single-reader per tap)
/// Lock-free per-tap scratch for RMS + peak measurement. The AVAudio tap is
/// always invoked serially per input node, so we don't need a lock here.
private final class LevelScratch: @unchecked Sendable {
private var last: (rms: Float, peak: Float) = (0, 0)
func measure(buffer: AVAudioPCMBuffer) -> (rms: Float, peak: Float) {
guard let ch = buffer.floatChannelData?[0] else { return last }
let n = Int(buffer.frameLength)
guard n > 0 else { return last }
// Decay smoothing keeps the meter lively but not jittery.
var sumSq: Float = 0
var peak: Float = 0
for i in 0..<n {
let s = ch[i]
sumSq += s * s
let a = abs(s)
if a > peak { peak = a }
}
let rms = sqrtf(sumSq / Float(n))
// Exponential moving average for visual smoothness.
let alpha: Float = 0.35
let smoothedRms = alpha * rms + (1 - alpha) * last.rms
let smoothedPeak = max(alpha * peak, (1 - alpha) * last.peak)
last = (smoothedRms, smoothedPeak)
return (smoothedRms, smoothedPeak)
}
}
@@ -18,7 +18,7 @@ public actor PolishingService {
private let store: AppGroupStore
private let timeout: TimeInterval
public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 8) {
public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 12) {
self.store = store
self.timeout = timeout
}
+402
View File
@@ -0,0 +1,402 @@
// KeyboardRootView.swift
// OSGKeyboard · Keyboard Extension
//
// Typeless-inspired keyboard surface. The keyboard is laid out in three
// vertical bands, but the entire height is reserved for us we set
// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's
// frame is honoured, and we add safe-area insets at the top and bottom so
// the system Spotlight / home-indicator chrome never clips our controls.
//
//
// [polish] [] top: 32 pt (incl. safe top)
// (transcript preview) 24 pt
//
// centre: 96 pt disc +
// breathing ring
//
//
// 🌐 [ space ] bottom: 60 pt (incl. safe bottom)
//
import SwiftUI
import OSGKeyboardShared
public struct KeyboardRootView: View {
@ObservedObject var state: State
public init(state: KeyboardViewController.State) {
self.state = state
}
/// Total keyboard height. We set the same value as a height-anchor
/// constraint in the view controller so the host UIInputView picks
/// it up.
static let totalHeight: CGFloat = 280
public var body: some View {
ZStack(alignment: .top) {
background
VStack(spacing: 0) {
topBar
.frame(height: 32)
centreArea
.frame(maxWidth: .infinity, maxHeight: .infinity)
bottomBar
.frame(height: 56)
}
.padding(.top, 4)
.padding(.bottom, 6)
}
.frame(height: Self.totalHeight)
.preferredColorScheme(.dark)
}
// MARK: - Background
/// Solid dark fill plus a hairline highlight at the top edge, so the
/// keyboard reads as a physical surface rather than a floating card.
private var background: some View {
ZStack {
Palette.background
VStack(spacing: 0) {
Rectangle()
.fill(
LinearGradient(
colors: [Color.white.opacity(0.05), .clear],
startPoint: .top,
endPoint: .bottom
)
)
.frame(height: 1)
Spacer(minLength: 0)
}
}
.overlay(alignment: .top) {
Rectangle()
.fill(Palette.divider)
.frame(height: 0.5)
}
}
// MARK: - Top bar
private var topBar: some View {
HStack(spacing: Spacing.xs) {
ModeChip(mode: state.mode) { newMode in
state.setMode(newMode)
}
LocaleChip(localeId: state.localeId) { newId in
state.setLocale(newId)
}
Spacer(minLength: 0)
StatusBadge(phase: state.phase)
Button(action: state.openSettings) {
Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Palette.textSecondary)
.frame(width: 28, height: 28)
.background(Palette.surface, in: Circle())
.overlay(Circle().stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Open OSGKeyboard settings"))
}
.padding(.horizontal, Spacing.md)
}
// MARK: - Centre area
private var centreArea: some View {
ZStack {
VStack(spacing: Spacing.xxs) {
TranscriptLine(phase: state.phase, transcript: state.lastTranscript)
.frame(height: 22)
RecordButton(
phase: buttonPhase,
level: state.level,
onPressBegan: state.beginRecording,
onPressEnded: state.endRecording,
onTap: state.tapMic
)
.frame(width: 140, height: 140)
}
}
.frame(maxWidth: .infinity)
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack(spacing: Spacing.xxs) {
ToolbarIconButton(systemName: "globe", label: "nextKeyboard") {
state.tapMic()
}
ToolbarIconButton(systemName: "delete.left", label: "delete") {
state.deleteBackward()
}
Spacer(minLength: 0)
Button(action: state.insertSpace) {
Text("空格")
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Space"))
Spacer(minLength: 0)
ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline()
}
}
.padding(.horizontal, Spacing.sm)
}
private var buttonPhase: RecordButton.Phase {
switch state.phase {
case .idle: return .idle
case .recording: return .recording
case .processing: return .processing
case .error: return .error
}
}
}
// MARK: - State alias
extension KeyboardRootView {
typealias State = KeyboardViewController.State
}
// MARK: - SwiftUI Preview
#if DEBUG
#Preview("Keyboard · Idle") {
KeyboardRootView(state: KeyboardViewController.State.previewIdle)
.frame(width: 390, height: 280)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Recording") {
KeyboardRootView(state: KeyboardViewController.State.previewRecording)
.frame(width: 390, height: 280)
.preferredColorScheme(.dark)
}
#Preview("Keyboard · Processing") {
KeyboardRootView(state: KeyboardViewController.State.previewProcessing)
.frame(width: 390, height: 280)
.preferredColorScheme(.dark)
}
#endif
// MARK: - Transcript line
private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase
let transcript: String
var body: some View {
ZStack {
switch phase {
case .idle:
Text("按住说话 · Hold to talk")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textTertiary)
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(Palette.accent)
Text("润色中 · Polishing")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
case .error(let msg):
Text(msg)
.font(TypeStyle.caption)
.foregroundStyle(Palette.warning)
.lineLimit(1)
.truncationMode(.tail)
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
}
// MARK: - Toolbar icon button
private struct ToolbarIconButton: View {
let systemName: String
let label: String
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Palette.textPrimary)
.frame(width: 40, height: 40)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(label))
}
}
// MARK: - Status badge
private struct StatusBadge: View {
let phase: KeyboardViewController.State.Phase
var body: some View {
Group {
switch phase {
case .idle:
EmptyView()
case .recording:
dot(color: Palette.recordRed, label: "REC")
case .processing:
dot(color: Palette.accent, label: "···")
case .error:
dot(color: Palette.warning, label: "!")
}
}
}
private func dot(color: Color, label: String) -> some View {
HStack(spacing: 4) {
Circle()
.fill(color)
.frame(width: 6, height: 6)
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 3)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
}
// MARK: - Mode chip
private struct ModeChip: View {
let mode: KeyboardViewController.State.InputMode
let onChange: (KeyboardViewController.State.InputMode) -> Void
var body: some View {
Menu {
ForEach(KeyboardViewController.State.InputMode.allCases) { m in
Button {
onChange(m)
} label: {
if m == mode {
Label(label(for: m), systemImage: "checkmark")
} else {
Text(label(for: m))
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: icon(for: mode))
Text(label(for: mode))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(mode == .off ? Palette.textTertiary : Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
.menuStyle(.button)
}
private func label(for m: KeyboardViewController.State.InputMode) -> String {
switch m {
case .off: return "Off"
case .transcribe: return "转写"
case .polish: return "润色"
}
}
private func icon(for m: KeyboardViewController.State.InputMode) -> String {
switch m {
case .off: return "mic.slash.fill"
case .transcribe: return "text.bubble.fill"
case .polish: return "wand.and.stars"
}
}
}
// MARK: - Locale chip
private struct LocaleChip: View {
let localeId: String
let onChange: (String) -> Void
private let options: [(id: String, label: String)] = [
("auto", "Auto"),
("zh-Hans", "简体"),
("zh-Hant", "繁體"),
("en-US", "EN"),
("ja-JP", ""),
("ko-KR", "")
]
var body: some View {
Menu {
ForEach(options, id: \.id) { o in
Button {
onChange(o.id)
} label: {
if o.id == localeId {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "globe")
Text(currentLabel)
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2)
.padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
.menuStyle(.button)
}
private var currentLabel: String {
options.first(where: { $0.id == localeId })?.label ?? "Auto"
}
}
+187
View File
@@ -0,0 +1,187 @@
// RecordButton.swift
// OSGKeyboard · Keyboard Extension
//
// The hero control. 120 pt primary disc with a soft inner gradient, a
// breathing outer ring while recording, and a centred waveform that maps
// directly to the real audio RMS. Idle / recording / processing are three
// distinct visual states no flicker, no surprise transitions.
import SwiftUI
import OSGKeyboardShared
struct RecordButton: View {
enum Phase: Equatable {
case idle
case recording
case processing
case error
}
let phase: Phase
let level: Double // 0...1
let onPressBegan: () -> Void
let onPressEnded: () -> Void
let onTap: () -> Void
@GestureState private var isPressed: Bool = false
@State private var breath: Bool = false
init(
phase: Phase,
level: Double,
onPressBegan: @escaping () -> Void,
onPressEnded: @escaping () -> Void,
onTap: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.onPressBegan = onPressBegan
self.onPressEnded = onPressEnded
self.onTap = onTap
}
var body: some View {
ZStack {
// Outer breathing ring (recording only)
Circle()
.stroke(Palette.recordRed.opacity(0.35), lineWidth: 2)
.frame(width: 150, height: 150)
.scaleEffect(breath ? 1.18 : 0.95)
.opacity(phase == .recording ? 1 : 0)
.animation(Motion.breath, value: breath)
// Halo: soft red glow that intensifies with input level
Circle()
.fill(
RadialGradient(
colors: [Palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 50,
endRadius: 100
)
)
.frame(width: 200, height: 200)
.opacity(phase == .recording ? 0.4 + level * 0.6 : 0)
.blur(radius: 18)
.animation(Motion.soft, value: phase)
.animation(Motion.soft, value: level)
// Secondary outer ring (always present, dimmer when idle)
Circle()
.stroke(
Color.white.opacity(phase == .idle ? 0.08 : 0.12),
lineWidth: 0.5
)
.frame(width: 140, height: 140)
// Main disc with gradient + soft inner highlight
ZStack {
Circle()
.fill(discGradient)
Circle()
.stroke(Color.white.opacity(0.16), lineWidth: 1)
.blendMode(.overlay)
// Centre content switches by phase
Group {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 38, weight: .medium))
.foregroundStyle(Palette.textPrimary)
case .recording:
WaveformView(level: level, active: true)
.frame(width: 80, height: 44)
.transition(.opacity)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(Palette.textPrimary)
.scaleEffect(1.2)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 30, weight: .medium))
.foregroundStyle(Palette.warning)
}
}
}
.frame(width: 120, height: 120)
.scaleEffect(isPressed ? 0.94 : 1.0)
.shadow(color: .black.opacity(0.45), radius: 14, y: 8)
.animation(Motion.quick, value: isPressed)
.animation(Motion.soft, value: phase)
}
.contentShape(Circle())
// Press-to-talk: act on the FIRST touch-down, not after a 150 ms
// minimum duration. That's what Typeless feels like, and it's what
// makes the keyboard feel responsive. A tap (very short press) is
// interpreted as "toggle" for the secondary action (onTap), not
// "record" the recording only fires if the press lasts long
// enough to read as intentional. This avoids the previous bug
// where every single tap fired both onPressBegan AND onTap.
.gesture(
LongPressGesture(minimumDuration: 0.18)
.sequenced(before: DragGesture(minimumDistance: 0))
.updating($isPressed) { value, state, _ in
switch value {
case .second(true, _): state = true
default: state = false
}
}
.onChanged { value in
if case .second(true, _) = value, !pressArmed {
pressArmed = true
onPressBegan()
}
}
.onEnded { _ in
if pressArmed { pressArmed = false; onPressEnded() }
}
)
.simultaneousGesture(
// Pure tap: only fires when the user lifts before the long-press
// threshold. This becomes the "secondary action" (e.g. cycle
// mode). It is paired with, not conflicting with, the long-press.
TapGesture(count: 1)
.onEnded {
if !pressArmed { onTap() }
}
)
.onAppear { breath = (phase == .recording) }
.onChange(of: phase) { _, new in
breath = (new == .recording)
}
.accessibilityLabel(Text("Push to talk"))
}
@State private var pressArmed: Bool = false
private var discGradient: LinearGradient {
switch phase {
case .recording:
return LinearGradient(
colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)],
startPoint: .top,
endPoint: .bottom
)
case .processing:
return LinearGradient(
colors: [Palette.surfaceElevated, Palette.surface],
startPoint: .top,
endPoint: .bottom
)
case .error:
return LinearGradient(
colors: [Palette.warning.opacity(0.85), Palette.warning.opacity(0.55)],
startPoint: .top,
endPoint: .bottom
)
case .idle:
return LinearGradient(
colors: [Color(white: 0.22), Color(white: 0.10)],
startPoint: .top,
endPoint: .bottom
)
}
}
}
+54
View File
@@ -0,0 +1,54 @@
// WaveformView.swift
// OSGKeyboard · Keyboard Extension
//
// Symmetric, real-time driven waveform. 18 bars centred around a vertical
// axis. The dominant bar is driven by the current RMS; surrounding bars
// decay on a small position-based curve so the visual feels like a
// horizontal speaker cone, not random noise.
import SwiftUI
import OSGKeyboardShared
struct WaveformView: View {
let level: Double // 0...1, smoothed RMS
let barCount: Int
let color: Color
let active: Bool // when false, bars collapse to a thin resting line
init(
level: Double,
barCount: Int = 18,
color: Color = Palette.recordRed,
active: Bool = true
) {
self.level = max(0, min(1, level))
self.barCount = barCount
self.color = color
self.active = active
}
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
HStack(alignment: .center, spacing: 3) {
ForEach(0..<barCount, id: \.self) { i in
Capsule()
.fill(color)
.frame(width: 2.4, height: height(for: i, time: context.date.timeIntervalSinceReferenceDate))
.opacity(active ? 1.0 : 0.45)
}
}
}
}
private func height(for index: Int, time: TimeInterval) -> CGFloat {
guard active else { return 4 }
let centre = Double(barCount - 1) / 2.0
let distance = abs(Double(index) - centre) / max(centre, 1)
// Per-bar small wobble so the line is alive but tied to level.
let phase = sin(time * 4.0 + Double(index) * 0.45)
let wobble = 0.18 * phase
let magnitude = max(0, min(1, Double(level) + wobble))
let profile = 1.0 - pow(distance, 1.4) * 0.85
return CGFloat(max(6, 32 * magnitude * profile))
}
}
@@ -0,0 +1,32 @@
// 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.shared"
/// Shared UserDefaults instance for cross-process config.
///
/// Falls back to `.standard` if the App Group isn't available (e.g.
/// the user hasn't created the App Group in the Apple Developer
/// portal, or Xcode hasn't downloaded a matching provisioning profile).
/// In that mode, the keyboard extension will *not* see config written
/// by the main app but the main app itself stays usable so the user
/// can fix the signing situation without the app crashing.
public static var defaults: UserDefaults {
if let d = UserDefaults(suiteName: identifier) {
return d
}
#if DEBUG
print("⚠️ App Group \(identifier) unavailable — falling back to .standard. " +
"Add the App Group in your Apple Developer account and Xcode " +
"Signing & Capabilities, then re-run.")
#endif
return .standard
}
}
+156
View File
@@ -0,0 +1,156 @@
// Theme.swift
// OSGKeyboard · Design System
//
// Single source of truth for colour, spacing, corner radius, typography.
// Inspired by Dieter Rams ("less but better") and Apple Human Interface:
// every surface has a single purpose, every token earns its place, and
// the visual hierarchy is carried by *whitespace + one accent*, never by
// extra colour.
import SwiftUI
// MARK: - Palette
public enum Palette {
// Backgrounds
public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B
public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B
public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A
public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215
// Accents
public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA
public static let accentMuted = accent.opacity(0.18)
public static let accentGlow = accent.opacity(0.42)
// Semantic
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
// Text
public static let textPrimary = Color.white
public static let textSecondary = Color(white: 0.7)
public static let textTertiary = Color(white: 0.50)
public static let textOnAccent = Color.black
// Lines
public static let divider = Color.white.opacity(0.06)
public static let dividerStrong = Color.white.opacity(0.10)
// Recording state
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
}
// MARK: - Spacing scale (4 pt grid)
public enum Spacing {
public static let xxs: CGFloat = 4
public static let xs: CGFloat = 8
public static let sm: CGFloat = 12
public static let md: CGFloat = 16
public static let lg: CGFloat = 20
public static let xl: CGFloat = 24
public static let xxl: CGFloat = 32
public static let xxxl: CGFloat = 40
public static let hero: CGFloat = 48
}
// MARK: - Corner radius scale
public enum Radius {
public static let small: CGFloat = 8
public static let medium: CGFloat = 12
public static let large: CGFloat = 16
public static let xl: CGFloat = 20
public static let xxl: CGFloat = 24
public static let pill: CGFloat = 999
}
// MARK: - Typography
public enum TypeStyle {
public static let caption2 = Font.system(size: 11, weight: .medium)
public static let caption = Font.system(size: 12, weight: .medium)
public static let footnote = Font.system(size: 13, weight: .regular)
public static let body = Font.system(size: 15, weight: .regular)
public static let bodyEmph = Font.system(size: 15, weight: .medium)
public static let headline = Font.system(size: 17, weight: .semibold)
public static let title3 = Font.system(size: 20, weight: .semibold)
public static let title2 = Font.system(size: 22, weight: .bold)
public static let title = Font.system(size: 28, weight: .bold)
public static let largeTitle = Font.system(size: 34, weight: .bold)
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
}
// MARK: - Animation
public enum Motion {
public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85)
public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80)
public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78)
public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true)
public static let instant = Animation.linear(duration: 0.12)
}
// MARK: - Reusable view modifiers
public extension View {
/// Standard card surface used in the main app.
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
self
.padding(padding)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
/// Muted pill (used for tags, locale indicators, etc.).
func pillChip(foreground: Color = Palette.textSecondary) -> some View {
self
.padding(.horizontal, Spacing.xs)
.padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.foregroundStyle(foreground)
}
/// Primary CTA button.
func primaryButton() -> some View {
self
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.foregroundStyle(Palette.textOnAccent)
}
/// Secondary CTA button.
func secondaryButton() -> some View {
self
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.dividerStrong, lineWidth: 0.5)
)
.foregroundStyle(Palette.textPrimary)
}
/// Legacy alias for older call sites.
func cardStyle() -> some View { cardSurface() }
}
// MARK: - Backwards compat (legacy callers in old code)
public enum Theme {
public static let background = Palette.background
public static let card = Palette.surface
public static let accent = Palette.accent
public static let danger = Palette.danger
public static let textPrimary = Palette.textPrimary
public static let textSecondary = Palette.textSecondary
public static let divider = Palette.divider
}
@@ -0,0 +1,34 @@
// AudioBufferSnapshot.swift
// OSGKeyboard · Shared
//
// Sendable wrapper around a Float32 audio buffer's raw samples.
// The snapshot is the only thing that crosses actor / concurrency
// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its
// own side and consumes it locally (never yielding it back out).
import Foundation
import AVFoundation
public struct AudioBufferSnapshot: Sendable {
public let samples: [Float]
public let sampleRate: Double
public init(samples: [Float], sampleRate: Double) {
self.samples = samples
self.sampleRate = sampleRate
}
/// Construct from an `AVAudioPCMBuffer` by copying out the channel data.
public init(buffer: AVAudioPCMBuffer) {
guard let channelData = buffer.floatChannelData else {
self.samples = []
self.sampleRate = buffer.format.sampleRate
return
}
let n = Int(buffer.frameLength)
var copy = [Float](repeating: 0, count: n)
memcpy(&copy, channelData[0], n * MemoryLayout<Float>.size)
self.samples = copy
self.sampleRate = buffer.format.sampleRate
}
}
@@ -12,19 +12,23 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
public let defaultBaseURL: String
public let defaultModel: String
public let apiKeyURL: URL?
/// Optional short blurb shown under the provider name in the picker.
public let blurb: String?
public init(
id: String,
name: String,
defaultBaseURL: String,
defaultModel: String,
apiKeyURL: URL? = nil
apiKeyURL: URL? = nil,
blurb: String? = nil
) {
self.id = id
self.name = name
self.defaultBaseURL = defaultBaseURL
self.defaultModel = defaultModel
self.apiKeyURL = apiKeyURL
self.blurb = blurb
}
public static let presets: [LLMProvider] = [
@@ -33,27 +37,47 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
name: "OpenAI",
defaultBaseURL: "https://api.openai.com/v1",
defaultModel: "gpt-4o-mini",
apiKeyURL: URL(string: "https://platform.openai.com/api-keys")
apiKeyURL: URL(string: "https://platform.openai.com/api-keys"),
blurb: "GPT-4o mini · 多语言"
),
.init(
id: "deepseek",
name: "DeepSeek",
defaultBaseURL: "https://api.deepseek.com/v1",
defaultModel: "deepseek-chat",
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys")
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
blurb: "deepseek-chat · 中文友好"
),
.init(
id: "qwen",
name: "Qwen (DashScope, OpenAI-compatible)",
name: "Qwen (DashScope)",
defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
defaultModel: "qwen-plus",
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey")
apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"),
blurb: "通义千问 · OpenAI 兼容"
),
.init(
id: "zhipu",
name: "智谱 GLM",
defaultBaseURL: "https://open.bigmodel.cn/api/paas/v4",
defaultModel: "glm-4-flash",
apiKeyURL: URL(string: "https://bigmodel.cn/usercenter/apikeys"),
blurb: "GLM-4-Flash · 中文优化"
),
.init(
id: "moonshot",
name: "月之暗面 Moonshot",
defaultBaseURL: "https://api.moonshot.cn/v1",
defaultModel: "moonshot-v1-8k",
apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"),
blurb: "Kimi · 长上下文"
),
.init(
id: "custom",
name: "Custom (OpenAI-compatible)",
defaultBaseURL: "",
defaultModel: ""
defaultModel: "",
blurb: "自建 / 任意 OpenAI 兼容端点"
)
]
@@ -10,60 +10,69 @@ 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"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
}
@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) }
}
@Published public var modeId: String {
didSet { defaults.set(modeId, forKey: Key.modeId) }
}
@Published public var localeId: String {
didSet { defaults.set(localeId, forKey: Key.localeId) }
}
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.
"""
/// The system prompt the user *sees* in the editor fall back to the
/// provider-aware default from `AppGroupStore` when nothing is set.
public var defaultSystemPrompt: String {
AppGroupStore.defaultSystemPrompt(for: providerId)
}
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
let pid = defaults.string(forKey: Key.providerId) ?? "openai"
let preset = LLMProvider.provider(id: pid)
self.providerId = pid
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.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
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
?? AppGroupStore.defaultSystemPrompt(for: pid)
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
}
public func apply(preset: LLMProvider) {
// Capture the *previous* provider id BEFORE we mutate, so the
// system-prompt reset check below can compare against the actual
// prior default.
let oldId = providerId
providerId = preset.id
if !preset.defaultBaseURL.isEmpty {
baseURL = preset.defaultBaseURL
@@ -71,6 +80,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
if !preset.defaultModel.isEmpty {
model = preset.defaultModel
}
// When switching providers, reset the system prompt to the new
// provider's default otherwise the user is left editing a
// Chinese prompt on a US-English model.
if systemPrompt.isEmpty
|| systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) {
systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id)
}
}
public func reset() {
@@ -79,6 +95,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
baseURL = preset.defaultBaseURL
apiKey = ""
model = preset.defaultModel
systemPrompt = defaultSystemPrompt
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
}
}
@@ -0,0 +1,106 @@
// 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
}
// MARK: - 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"
static let modeId = "config.modeId"
static let localeId = "config.localeId"
}
// MARK: - Reads
public var providerId: String {
defaults.string(forKey: Key.providerId) ?? "openai"
}
public var baseURL: String {
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
}
public var apiKey: String {
defaults.string(forKey: Key.apiKey) ?? ""
}
public var model: String {
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
}
public var systemPrompt: String {
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
}
public var modeId: String {
defaults.string(forKey: Key.modeId) ?? "polish"
}
public var localeId: String {
defaults.string(forKey: Key.localeId) ?? "auto"
}
// MARK: - Writes
public func setModeId(_ id: String) {
defaults.set(id, forKey: Key.modeId)
}
public func setLocaleId(_ id: String) {
defaults.set(id, forKey: Key.localeId)
}
// MARK: - Client
public func makeClient() -> LLMClient {
OpenAICompatibleClient(
baseURL: baseURL,
apiKey: apiKey,
model: model
)
}
// MARK: - Defaults
/// Per-provider default system prompt. We bias the prompt by the
/// provider's *primary* language so Chinese LLMs naturally return
/// Chinese for Chinese input, and English LLMs stay terse.
public static func defaultSystemPrompt(for providerId: String) -> String {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
return """
():
1) ,;
2)
3) "第一…第二…第三…",使 markdown
4) , 1.5 ;()
5) ,
"""
default:
return """
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. Drop filler words (um, uh, like).
5) Output in the same language as the input. No quotes, no explanation, no preamble.
"""
}
}
}
@@ -9,20 +9,21 @@ import Foundation
public enum LLMError: Error, LocalizedError, Sendable {
case invalidURL
case noAPIKey
case http(status: Int, body: String)
case http(status: Int)
case decoding(String)
case transport(underlying: String)
case transport(String)
case cancelled
case rateLimited
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."
case .invalidURL: return "API 地址无效。请在设置中检查 Base URL"
case .noAPIKey: return "未填写 API Key"
case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。"
case .decoding: return "解析 API 响应失败。"
case .transport: return "网络错误,请检查连接后重试。"
case .rateLimited: return "API 调用过于频繁,请稍候再试。"
case .cancelled: return "请求已取消。"
}
}
}
@@ -81,11 +82,16 @@ public struct OpenAICompatibleClient: LLMClient {
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw LLMError.transport(underlying: "non-HTTP response")
throw LLMError.transport("non-HTTP response")
}
if !(200..<300).contains(http.statusCode) {
#if DEBUG
// Log full body for debugging never expose to UI.
let body = String(data: data, encoding: .utf8) ?? ""
throw LLMError.http(status: http.statusCode, body: body)
print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))")
#endif
if http.statusCode == 429 { throw LLMError.rateLimited }
throw LLMError.http(status: http.statusCode)
}
do {
let decoded = try JSONDecoder().decode(LLMResponse.self, from: data)
@@ -100,7 +106,7 @@ public struct OpenAICompatibleClient: LLMClient {
} catch let urlError as URLError where urlError.code == .cancelled {
throw LLMError.cancelled
} catch {
throw LLMError.transport(underlying: String(describing: error))
throw LLMError.transport(String(describing: error))
}
}
}
@@ -12,8 +12,9 @@ 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 suiteName = "group.com.osgkeyboard.shared.tests"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
let config1 = ProviderConfig(defaults: defaults)
config1.baseURL = "https://example.com/v1"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-5
View File
@@ -1,5 +0,0 @@
<?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>
-23
View File
@@ -1,23 +0,0 @@
// 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()
}
}
}
}
-102
View File
@@ -1,102 +0,0 @@
// 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))
}
}
}
-86
View File
@@ -1,86 +0,0 @@
// 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)
}
}
}
-177
View File
@@ -1,177 +0,0 @@
// 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)
}
}
}
@@ -1,36 +0,0 @@
// 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()
}
}
-50
View File
@@ -1,50 +0,0 @@
// 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)
}
}
}
-30
View File
@@ -1,30 +0,0 @@
// 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)
)
}
}
@@ -1,285 +0,0 @@
// 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
}
}
-165
View File
@@ -1,165 +0,0 @@
// 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
}
}
@@ -1,173 +0,0 @@
// 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(&copy, 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()
}
}
}
}
@@ -1,121 +0,0 @@
// 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)
}
}
}
-116
View File
@@ -1,116 +0,0 @@
// 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"
}
}
}
-37
View File
@@ -1,37 +0,0 @@
// 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))
}
}
-21
View File
@@ -1,21 +0,0 @@
// 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
}
}
@@ -1,45 +0,0 @@
// 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
)
}
}
+7 -7
View File
@@ -63,7 +63,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
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).
> **"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`](./OSGKeyboard/PrivacyInfo.xcprivacy).
---
@@ -71,23 +71,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
```
OSGKeyboard/
├── OpenLess/ # Main iOS app (settings, onboarding)
├── OSGKeyboard/ # 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
│ └── OSGKeyboard.entitlements # App Group declaration
├── OSGKeyboardExt/ # Custom Keyboard Extension
│ ├── KeyboardViewController.swift # Principal class
│ ├── Services/
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR
│ │ └── PolishingService.swift # LLM call with timeout
│ └── Views/ # RecordButton, Waveform, KeyboardRootView
├── OpenLessShared/ # Framework shared by app + extension
├── OSGKeyboardShared/ # Framework shared by app + extension
│ ├── Models/ # ProviderConfig, LLMRequest, LLMProvider
│ ├── Services/ # LLMClient (OpenAI-compatible)
│ └── Constants/ # AppGroup identifier
├── OpenLessTests/ # XCTest unit tests
├── OSGKeyboardTests/ # XCTest unit tests
├── project.yml # XcodeGen project definition
└── .github/workflows/ci.yml # Lint + build CI
```
@@ -112,7 +112,7 @@ OSGKeyboard/
## 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.
Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol.
```swift
LLMProvider(
+7 -7
View File
@@ -62,7 +62,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
3. 在任意输入框,点 🌐 切换到 **OSGKeyboard**
4. 长按麦克风键 → 说话 → 松开。✨
> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy)。
> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy)。
---
@@ -70,23 +70,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
```
OSGKeyboard/
├── OpenLess/ # 主 iOS App(设置、Onboarding
├── OSGKeyboard/ # 主 iOS App(设置、Onboarding
│ ├── Views/ # SwiftUI 屏幕
│ ├── OSGKeyboardApp.swift # @main 入口
│ ├── PrivacyInfo.xcprivacy # 隐私清单
│ └── OpenLess.entitlements # App Group 声明
├── OpenLessKeyboard/ # 自定义键盘扩展
│ └── OSGKeyboard.entitlements # App Group 声明
├── OSGKeyboardExt/ # 自定义键盘扩展
│ ├── KeyboardViewController.swift # 主体类
│ ├── Services/
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR
│ │ └── PolishingService.swift # LLM 调用(带超时)
│ └── Views/ # 录音按钮、波形、键盘主视图
├── OpenLessShared/ # 主 App + 键盘共享 framework
├── OSGKeyboardShared/ # 主 App + 键盘共享 framework
│ ├── Models/ # ProviderConfig、LLMRequest、LLMProvider
│ ├── Services/ # LLMClientOpenAI 兼容)
│ └── Constants/ # App Group ID
├── OpenLessTests/ # XCTest 单元测试
├── OSGKeyboardTests/ # XCTest 单元测试
├── project.yml # XcodeGen 工程定义
└── .github/workflows/ci.yml # Lint + 编译 CI
```
@@ -111,7 +111,7 @@ OSGKeyboard/
## 新增 LLM 提供商
打开 `OpenLessShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。
打开 `OSGKeyboardShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。
```swift
LLMProvider(
+22 -12
View File
@@ -35,19 +35,23 @@ targets:
type: application
platform: iOS
sources:
- path: OpenLess
- path: OSGKeyboard
entitlements:
path: OpenLess/OpenLess.entitlements
path: OSGKeyboard/OSGKeyboard.entitlements
properties:
com.apple.security.application-groups:
- group.com.osgkeyboard.shared
com.apple.security.device.audio-input: true
resources:
- path: OpenLess/Assets.xcassets
- path: OSGKeyboard/Assets.xcassets
info:
path: OpenLess/Info.plist
path: OSGKeyboard/Info.plist
properties:
CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
UILaunchScreen:
UIColorName: ""
UIColorName: "BackgroundColor"
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
UIApplicationSceneManifest:
@@ -77,11 +81,17 @@ targets:
type: app-extension
platform: iOS
sources:
- path: OpenLessKeyboard
- path: OSGKeyboardExt
settings:
base:
IPHONEOS_DEPLOYMENT_TARGET: "18.0"
entitlements:
path: OpenLessKeyboard/OpenLessKeyboard.entitlements
path: OSGKeyboardExt/OSGKeyboardExt.entitlements
properties:
com.apple.security.application-groups:
- group.com.osgkeyboard.shared
info:
path: OpenLessKeyboard/Info.plist
path: OSGKeyboardExt/Info.plist
properties:
CFBundleDisplayName: OSGKeyboard
CFBundleShortVersionString: "$(MARKETING_VERSION)"
@@ -109,9 +119,9 @@ targets:
type: framework
platform: iOS
sources:
- path: OpenLessShared
- path: OSGKeyboardShared
info:
path: OpenLessShared/Info.plist
path: OSGKeyboardShared/Info.plist
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared
@@ -129,9 +139,9 @@ targets:
type: bundle.unit-test
platform: iOS
sources:
- path: OpenLessTests
- path: OSGKeyboardTests
info:
path: OpenLessTests/Info.plist
path: OSGKeyboardTests/Info.plist
dependencies:
- target: OSGKeyboard
settings: