diff --git a/.swiftlint.yml b/.swiftlint.yml
index c10a62d..757abeb 100644
--- a/.swiftlint.yml
+++ b/.swiftlint.yml
@@ -21,10 +21,10 @@ opt_in_rules:
- redundant_optional_initialization
included:
- - OpenLess
- - OpenLessKeyboard
- - OpenLessShared
- - OpenLessTests
+ - OSGKeyboard
+ - OSGKeyboardExt
+ - OSGKeyboardShared
+ - OSGKeyboardTests
excluded:
- build
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d1d1db1..d2a7a9f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json
similarity index 100%
rename from OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json
rename to OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json
diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json
similarity index 83%
rename from OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json
rename to OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json
index 27a4f38..838144a 100644
--- a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "icon-1024.png",
+ "filename" : "Group 24.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
diff --git a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png
new file mode 100644
index 0000000..0e6ff46
Binary files /dev/null and b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png differ
diff --git a/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json
new file mode 100644
index 0000000..e88d30b
--- /dev/null
+++ b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json
@@ -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
+ }
+}
diff --git a/OpenLess/Assets.xcassets/Contents.json b/OSGKeyboard/Assets.xcassets/Contents.json
similarity index 100%
rename from OpenLess/Assets.xcassets/Contents.json
rename to OSGKeyboard/Assets.xcassets/Contents.json
diff --git a/OpenLess/Info.plist b/OSGKeyboard/Info.plist
similarity index 97%
rename from OpenLess/Info.plist
rename to OSGKeyboard/Info.plist
index 6e78134..ddfb8b4 100644
--- a/OpenLess/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -46,7 +46,7 @@
UILaunchScreen
UIColorName
-
+ BackgroundColor
UISupportedInterfaceOrientations
diff --git a/OSGKeyboard/OSGKeyboard.entitlements b/OSGKeyboard/OSGKeyboard.entitlements
new file mode 100644
index 0000000..2160d46
--- /dev/null
+++ b/OSGKeyboard/OSGKeyboard.entitlements
@@ -0,0 +1,12 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.com.osgkeyboard.shared
+
+ com.apple.security.device.audio-input
+
+
+
diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift
new file mode 100644
index 0000000..3d4b5b1
--- /dev/null
+++ b/OSGKeyboard/OSGKeyboardApp.swift
@@ -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") }
+ }
+ }
+}
diff --git a/OpenLess/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy
similarity index 100%
rename from OpenLess/PrivacyInfo.xcprivacy
rename to OSGKeyboard/PrivacyInfo.xcprivacy
diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift
new file mode 100644
index 0000000..b75ca27
--- /dev/null
+++ b/OSGKeyboard/Views/APISettingsCard.swift
@@ -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,
+ 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)
+ }
+}
diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift
new file mode 100644
index 0000000..2af9f1b
--- /dev/null
+++ b/OSGKeyboard/Views/HomeView.swift
@@ -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)
+ }
+ }
+ }
+}
diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift
new file mode 100644
index 0000000..8590544
--- /dev/null
+++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift
@@ -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
diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift
new file mode 100644
index 0000000..600a02c
--- /dev/null
+++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift
@@ -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
diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift
new file mode 100644
index 0000000..368c0ef
--- /dev/null
+++ b/OSGKeyboard/Views/OnboardingView.swift
@@ -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)
+ }
+ }
+}
diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift
new file mode 100644
index 0000000..60a60a2
--- /dev/null
+++ b/OSGKeyboard/Views/ProviderPickerSection.swift
@@ -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())
+ }
+}
diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift
new file mode 100644
index 0000000..3e3c131
--- /dev/null
+++ b/OSGKeyboard/Views/SettingsView.swift
@@ -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 ?? "—"
+ }
+}
diff --git a/OpenLessKeyboard/Info.plist b/OSGKeyboardExt/Info.plist
similarity index 100%
rename from OpenLessKeyboard/Info.plist
rename to OSGKeyboardExt/Info.plist
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
new file mode 100644
index 0000000..9c917d2
--- /dev/null
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -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?
+ private var levelTask: Task?
+ private var didRequestMicOnce: Bool = false
+
+ private var hosting: UIHostingController!
+
+ // 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
+ }
+ }
+ }
+}
diff --git a/OpenLessKeyboard/OpenLessKeyboard.entitlements b/OSGKeyboardExt/OSGKeyboardExt.entitlements
similarity index 56%
rename from OpenLessKeyboard/OpenLessKeyboard.entitlements
rename to OSGKeyboardExt/OSGKeyboardExt.entitlements
index 0c67376..9d4ffbc 100644
--- a/OpenLessKeyboard/OpenLessKeyboard.entitlements
+++ b/OSGKeyboardExt/OSGKeyboardExt.entitlements
@@ -1,5 +1,10 @@
-
+
+ com.apple.security.application-groups
+
+ group.com.osgkeyboard.shared
+
+
diff --git a/OpenLessKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboardExt/PrivacyInfo.xcprivacy
similarity index 100%
rename from OpenLessKeyboard/PrivacyInfo.xcprivacy
rename to OSGKeyboardExt/PrivacyInfo.xcprivacy
diff --git a/OSGKeyboardExt/Services/ASRService.swift b/OSGKeyboardExt/Services/ASRService.swift
new file mode 100644
index 0000000..7515e6d
--- /dev/null
+++ b/OSGKeyboardExt/Services/ASRService.swift
@@ -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,
+ locale: Locale
+ ) -> AsyncStream
+
+ /// 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?
+
+ func transcribe(
+ stream: AsyncStream,
+ locale: Locale
+ ) -> AsyncStream {
+ 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.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?) in
+ let r = self.recognizerTask
+ let f = self.feedTask
+ self.recognizerTask = nil
+ self.feedTask = nil
+ return (r, f)
+ }
+ recTask?.cancel()
+ feedT?.cancel()
+ }
+}
diff --git a/OSGKeyboardExt/Services/AudioCaptureService.swift b/OSGKeyboardExt/Services/AudioCaptureService.swift
new file mode 100644
index 0000000..3d5ef0a
--- /dev/null
+++ b/OSGKeyboardExt/Services/AudioCaptureService.swift
@@ -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 0…1 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
+ public let levels: AsyncStream
+ private let onStop: @Sendable () -> Void
+
+ fileprivate init(
+ audio: AsyncStream,
+ levels: AsyncStream,
+ 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.Continuation?
+ private var levelContinuation: AsyncStream.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.makeStream()
+ let (levelStream, levelCont) = AsyncStream.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.Continuation,
+ levelCont: AsyncStream.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.Continuation?, AsyncStream.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.Continuation?, AsyncStream.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.. 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)
+ }
+}
diff --git a/OpenLessKeyboard/Services/PolishingService.swift b/OSGKeyboardExt/Services/PolishingService.swift
similarity index 98%
rename from OpenLessKeyboard/Services/PolishingService.swift
rename to OSGKeyboardExt/Services/PolishingService.swift
index 165c24b..70c5561 100644
--- a/OpenLessKeyboard/Services/PolishingService.swift
+++ b/OSGKeyboardExt/Services/PolishingService.swift
@@ -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
}
diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift
new file mode 100644
index 0000000..78ab68f
--- /dev/null
+++ b/OSGKeyboardExt/Views/KeyboardRootView.swift
@@ -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"
+ }
+}
diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift
new file mode 100644
index 0000000..2e57659
--- /dev/null
+++ b/OSGKeyboardExt/Views/RecordButton.swift
@@ -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
+ )
+ }
+ }
+}
diff --git a/OSGKeyboardExt/Views/WaveformView.swift b/OSGKeyboardExt/Views/WaveformView.swift
new file mode 100644
index 0000000..c2d8a35
--- /dev/null
+++ b/OSGKeyboardExt/Views/WaveformView.swift
@@ -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.. 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))
+ }
+}
diff --git a/OSGKeyboardShared/Constants/AppGroup.swift b/OSGKeyboardShared/Constants/AppGroup.swift
new file mode 100644
index 0000000..abd8583
--- /dev/null
+++ b/OSGKeyboardShared/Constants/AppGroup.swift
@@ -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
+ }
+}
diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift
new file mode 100644
index 0000000..d26fd1b
--- /dev/null
+++ b/OSGKeyboardShared/DesignSystem/Theme.swift
@@ -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
+}
diff --git a/OpenLessShared/Info.plist b/OSGKeyboardShared/Info.plist
similarity index 100%
rename from OpenLessShared/Info.plist
rename to OSGKeyboardShared/Info.plist
diff --git a/OSGKeyboardShared/Models/AudioBufferSnapshot.swift b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift
new file mode 100644
index 0000000..5c7f4f6
--- /dev/null
+++ b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift
@@ -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(©, channelData[0], n * MemoryLayout.size)
+ self.samples = copy
+ self.sampleRate = buffer.format.sampleRate
+ }
+}
diff --git a/OpenLessShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift
similarity index 60%
rename from OpenLessShared/Models/LLMProvider.swift
rename to OSGKeyboardShared/Models/LLMProvider.swift
index cc87663..361cd1e 100644
--- a/OpenLessShared/Models/LLMProvider.swift
+++ b/OSGKeyboardShared/Models/LLMProvider.swift
@@ -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 兼容端点"
)
]
diff --git a/OpenLessShared/Models/LLMRequest.swift b/OSGKeyboardShared/Models/LLMRequest.swift
similarity index 100%
rename from OpenLessShared/Models/LLMRequest.swift
rename to OSGKeyboardShared/Models/LLMRequest.swift
diff --git a/OpenLessShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift
similarity index 56%
rename from OpenLessShared/Models/ProviderConfig.swift
rename to OSGKeyboardShared/Models/ProviderConfig.swift
index 5156f46..3457ca2 100644
--- a/OpenLessShared/Models/ProviderConfig.swift
+++ b/OSGKeyboardShared/Models/ProviderConfig.swift
@@ -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")
}
}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
new file mode 100644
index 0000000..86c031a
--- /dev/null
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -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.
+ """
+ }
+ }
+}
diff --git a/OpenLessShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift
similarity index 75%
rename from OpenLessShared/Services/LLMClient.swift
rename to OSGKeyboardShared/Services/LLMClient.swift
index 0531427..650a746 100644
--- a/OpenLessShared/Services/LLMClient.swift
+++ b/OSGKeyboardShared/Services/LLMClient.swift
@@ -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))
}
}
}
diff --git a/OpenLessTests/Info.plist b/OSGKeyboardTests/Info.plist
similarity index 100%
rename from OpenLessTests/Info.plist
rename to OSGKeyboardTests/Info.plist
diff --git a/OpenLessTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift
similarity index 96%
rename from OpenLessTests/LLMClientTests.swift
rename to OSGKeyboardTests/LLMClientTests.swift
index 0ca10d4..6ead26e 100644
--- a/OpenLessTests/LLMClientTests.swift
+++ b/OSGKeyboardTests/LLMClientTests.swift
@@ -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"
diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png
deleted file mode 100644
index 5cfd8eb..0000000
Binary files a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png and /dev/null differ
diff --git a/OpenLess/OpenLess.entitlements b/OpenLess/OpenLess.entitlements
deleted file mode 100644
index 0c67376..0000000
--- a/OpenLess/OpenLess.entitlements
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/OpenLess/OpenLessApp.swift b/OpenLess/OpenLessApp.swift
deleted file mode 100644
index 97066b8..0000000
--- a/OpenLess/OpenLessApp.swift
+++ /dev/null
@@ -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()
- }
- }
- }
-}
diff --git a/OpenLess/Views/APISettingsCard.swift b/OpenLess/Views/APISettingsCard.swift
deleted file mode 100644
index b82b124..0000000
--- a/OpenLess/Views/APISettingsCard.swift
+++ /dev/null
@@ -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,
- isSecure: Bool,
- keyboard: UIKeyboardType,
- autocap: Bool
- ) -> some View {
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.caption)
- .foregroundStyle(Theme.textSecondary)
- Group {
- if isSecure {
- SecureField("", text: text)
- } else {
- TextField("", text: text)
- .keyboardType(keyboard)
- .autocorrectionDisabled(true)
- }
- }
- .textInputAutocapitalization(autocap ? .sentences : .never)
- .padding(10)
- .background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
- }
- }
-}
diff --git a/OpenLess/Views/HomeView.swift b/OpenLess/Views/HomeView.swift
deleted file mode 100644
index 0cc8e97..0000000
--- a/OpenLess/Views/HomeView.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/OpenLess/Views/OnboardingView.swift b/OpenLess/Views/OnboardingView.swift
deleted file mode 100644
index f3be0a6..0000000
--- a/OpenLess/Views/OnboardingView.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/OpenLess/Views/ProviderPickerSection.swift b/OpenLess/Views/ProviderPickerSection.swift
deleted file mode 100644
index 831e57a..0000000
--- a/OpenLess/Views/ProviderPickerSection.swift
+++ /dev/null
@@ -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()
- }
-}
diff --git a/OpenLess/Views/SettingsView.swift b/OpenLess/Views/SettingsView.swift
deleted file mode 100644
index b26a0e4..0000000
--- a/OpenLess/Views/SettingsView.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/OpenLess/Views/Theme.swift b/OpenLess/Views/Theme.swift
deleted file mode 100644
index c96a7b4..0000000
--- a/OpenLess/Views/Theme.swift
+++ /dev/null
@@ -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)
- )
- }
-}
diff --git a/OpenLessKeyboard/KeyboardViewController.swift b/OpenLessKeyboard/KeyboardViewController.swift
deleted file mode 100644
index cea041a..0000000
--- a/OpenLessKeyboard/KeyboardViewController.swift
+++ /dev/null
@@ -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?
- private var recordContinuation: Task?
- private var asrTask: Task?
- private var lastTranscript: String = ""
-
- // MARK: - UI
-
- private var hosting: UIHostingController!
- private var levelTimer: Timer?
- private var currentLevel: Double = 0
-
- // MARK: - Lifecycle
-
- public override func viewDidLoad() {
- super.viewDidLoad()
- installSwiftUI()
- }
-
- public override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- requestMicPermissionIfNeeded()
- }
-
- public override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- cancelPipeline()
- }
-
- public override func didReceiveMemoryWarning() {
- super.didReceiveMemoryWarning()
- cancelPipeline()
- }
-
- // MARK: - SwiftUI bridge
-
- private func installSwiftUI() {
- let root = KeyboardRootView(
- phase: .idle,
- level: 0,
- onPressBegan: { [weak self] in self?.pressBegan() },
- onPressEnded: { [weak self] in self?.pressEnded() },
- onTap: { [weak self] in self?.handleTap() },
- onOpenSettings:{ [weak self] in self?.openHostApp() }
- )
- let host = UIHostingController(rootView: root)
- host.view.backgroundColor = .clear
- addChild(host)
- view.addSubview(host.view)
- host.view.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- host.view.topAnchor.constraint(equalTo: view.topAnchor),
- host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- ])
- host.didMove(toParent: self)
- self.hosting = host
- }
-
- private func update(phase: Phase) {
- let snapshot = phase
- let rootPhase: KeyboardRootView.Phase = {
- switch snapshot {
- case .idle: return .idle
- case .recording: return .recording
- case .processing: return .processing
- case .error(let m): return .error(m)
- }
- }()
- hosting.rootView = KeyboardRootView(
- phase: rootPhase,
- level: currentLevel,
- onPressBegan: { [weak self] in self?.pressBegan() },
- onPressEnded: { [weak self] in self?.pressEnded() },
- onTap: { [weak self] in self?.handleTap() },
- onOpenSettings:{ [weak self] in self?.openHostApp() }
- )
- }
-
- // MARK: - Press handlers
-
- private func pressBegan() {
- guard case .idle = currentPhase() else { return }
- requestMicPermissionIfNeeded { [weak self] granted in
- guard let self else { return }
- guard granted else {
- Task { @MainActor in self.update(phase: .error("Microphone denied. Enable in Settings.")) }
- return
- }
- self.startPipeline()
- }
- }
-
- private func pressEnded() {
- guard case .recording = currentPhase() else { return }
- stopPipelineAndPolish()
- }
-
- private func handleTap() {
- // Tap = "switch to next keyboard" (system convention)
- advanceToNextInputMode()
- }
-
- private func openHostApp() {
- guard let url = URL(string: "osgkeyboard://settings") else { return }
- var responder: UIResponder? = self
- while let r = responder {
- if let app = r as? UIApplication {
- app.open(url)
- return
- }
- responder = r.next
- }
- // Fallback: open settings page
- if let url = URL(string: UIApplication.openSettingsURLString) {
- var r: UIResponder? = self
- while let r2 = r {
- if let app = r2 as? UIApplication {
- app.open(url); return
- }
- r = r2.next
- }
- }
- }
-
- // MARK: - Pipeline
-
- private func startPipeline() {
- update(phase: .recording)
- let stream = audio.start()
- recordStream = stream
-
- // 1) ASR pipeline
- let events = asr.transcribe(stream: stream)
- asrTask = Task { [weak self] in
- guard let self else { return }
- for await event in events {
- switch event {
- case .partial(let s):
- // optionally show partial in status bar (keep simple — silent)
- _ = s
- case .final(let s):
- await MainActor.run { self.lastTranscript = s }
- case .error(let msg):
- await MainActor.run { self.update(phase: .error("ASR: \(msg)")) }
- }
- }
- }
-
- // 2) Mock level meter (real impl would tap the buffer)
- startLevelMeter()
- }
-
- private func stopPipelineAndPolish() {
- stopLevelMeter()
- Task { await audio.stop() }
- asrTask?.cancel()
-
- let snapshot = lastTranscript
- guard !snapshot.isEmpty else {
- update(phase: .idle)
- return
- }
-
- update(phase: .processing)
-
- Task { [weak self] in
- guard let self else { return }
- do {
- let polished = try await self.polisher.polish(snapshot)
- await MainActor.run {
- self.textDocumentProxy.insertText(polished)
- self.lastTranscript = ""
- self.update(phase: .idle)
- }
- } catch {
- // fallback: insert raw transcript
- await MainActor.run {
- self.textDocumentProxy.insertText(snapshot)
- self.lastTranscript = ""
- let msg = (error as? LocalizedError)?.errorDescription ?? "Polishing failed, inserted raw."
- self.update(phase: .error(msg))
- // auto-clear error back to idle after 2s
- Task { @MainActor in
- try? await Task.sleep(nanoseconds: 2_000_000_000)
- if case .error = self.currentPhase() {
- self.update(phase: .idle)
- }
- }
- }
- }
- }
- }
-
- private func cancelPipeline() {
- asrTask?.cancel()
- asrTask = nil
- Task { await audio.stop() }
- stopLevelMeter()
- lastTranscript = ""
- }
-
- // MARK: - Permission
-
- private func requestMicPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) {
- let session = AVAudioSession.sharedInstance()
- switch session.recordPermission {
- case .granted:
- completion?(true)
- case .denied:
- completion?(false)
- case .undetermined:
- session.requestRecordPermission { granted in
- DispatchQueue.main.async { completion?(granted) }
- }
- @unknown default:
- completion?(false)
- }
- }
-
- // MARK: - Level meter (mock — tap could be replaced with real RMS from AVAudioEngine)
-
- private func startLevelMeter() {
- stopLevelMeter()
- currentLevel = 0
- levelTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
- guard let self else { return }
- // Simple pseudo-level — random walk around 0.5 while "recording"
- let delta = Double.random(in: -0.18...0.18)
- self.currentLevel = max(0.15, min(0.95, self.currentLevel + delta))
- // refresh UI
- Task { @MainActor in
- self.update(phase: .recording)
- }
- }
- }
-
- private func stopLevelMeter() {
- levelTimer?.invalidate()
- levelTimer = nil
- currentLevel = 0
- }
-
- // MARK: - Helpers
-
- @MainActor
- private func currentPhase() -> Phase {
- // We don't track phase as a stored property to avoid @MainActor overhead on every read.
- // Instead, derive it from state of services — for simplicity we mirror via update().
- // This shim returns .idle when no record is in flight.
- return recordStream == nil ? .idle : .recording
- }
-}
diff --git a/OpenLessKeyboard/Services/ASRService.swift b/OpenLessKeyboard/Services/ASRService.swift
deleted file mode 100644
index c938ab1..0000000
--- a/OpenLessKeyboard/Services/ASRService.swift
+++ /dev/null
@@ -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) -> AsyncStream
-
- /// Cancel any in-flight work.
- func cancel()
-}
-
-public enum ASREvent: Sendable {
- case partial(String) // incremental, may be discarded
- case final(String) // the authoritative transcript
- case error(String)
-}
-
-// MARK: - Factory
-
-public enum ASRServiceFactory {
- public static func create() -> ASRService {
- if #available(iOS 26, *) {
- return SpeechAnalyzerASR()
- } else {
- return SFSpeechRecognizerASR()
- }
- }
-}
-
-// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber
-
-@available(iOS 26, *)
-final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
- private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
- private var task: Task?
-
- func transcribe(stream: AsyncStream) -> AsyncStream {
- AsyncStream { continuation in
- let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current)
- guard let recognizer, recognizer.isAvailable else {
- continuation.yield(.error("Speech recognizer unavailable"))
- continuation.finish()
- return
- }
- recognizer.defaultTaskHint = .dictation
-
- let request = SFSpeechAudioBufferRecognitionRequest()
- request.shouldReportPartialResults = true
- if recognizer.supportsOnDeviceRecognition {
- request.requiresOnDeviceRecognition = true
- }
-
- let task = recognizer.recognitionTask(with: request) { result, error in
- if let error {
- continuation.yield(.error(error.localizedDescription))
- continuation.finish()
- return
- }
- guard let result else { return }
- if result.isFinal {
- continuation.yield(.final(result.bestTranscription.formattedString))
- continuation.finish()
- } else {
- continuation.yield(.partial(result.bestTranscription.formattedString))
- }
- }
- self.task = Task { [request] in
- for await snap in stream {
- if Task.isCancelled { break }
- let pcmStream = AsyncStream { c in
- c.yield(snap)
- c.finish()
- }
- for await pcm in pcmStream.toAVAudioBuffers() {
- request.append(pcm)
- }
- }
- request.endAudio()
- // give recognizer a moment to finalize
- try? await Task.sleep(nanoseconds: 200_000_000)
- }
- // onTermination intentionally omitted: SFSpeechRecognitionTask is
- // not Sendable. Cancellation flows through this class's cancel().
- }
- }
-
- func cancel() {
- task?.cancel()
- task = nil
- }
-}
-
-// MARK: - iOS 18 fallback: SFSpeechRecognizer
-
-final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable {
- private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
- private var task: SFSpeechRecognitionTask?
- private var feedTask: Task?
-
- func transcribe(stream: AsyncStream) -> AsyncStream {
- AsyncStream { continuation in
- guard let recognizer, recognizer.isAvailable else {
- continuation.yield(.error("Speech recognizer unavailable"))
- continuation.finish()
- return
- }
- recognizer.defaultTaskHint = .dictation
-
- let request = SFSpeechAudioBufferRecognitionRequest()
- request.shouldReportPartialResults = true
- if recognizer.supportsOnDeviceRecognition {
- request.requiresOnDeviceRecognition = true
- }
-
- let recognizerTask = recognizer.recognitionTask(with: request) { result, error in
- if let error {
- continuation.yield(.error(error.localizedDescription))
- continuation.finish()
- return
- }
- guard let result else { return }
- if result.isFinal {
- continuation.yield(.final(result.bestTranscription.formattedString))
- continuation.finish()
- } else {
- continuation.yield(.partial(result.bestTranscription.formattedString))
- }
- }
- self.task = recognizerTask
-
- self.feedTask = Task { [request] in
- for await snap in stream {
- if Task.isCancelled { break }
- let pcmStream = AsyncStream { c in
- c.yield(snap)
- c.finish()
- }
- for await pcm in pcmStream.toAVAudioBuffers() {
- request.append(pcm)
- }
- }
- request.endAudio()
- }
- // onTermination intentionally omitted: SFSpeechRecognitionTask is
- // not Sendable. Cancellation is handled by calling cancel() on
- // this class, and the feedTask loop respects Task.isCancelled.
- }
- }
-
- func cancel() {
- task?.cancel()
- feedTask?.cancel()
- task = nil
- feedTask = nil
- }
-}
diff --git a/OpenLessKeyboard/Services/AudioCaptureService.swift b/OpenLessKeyboard/Services/AudioCaptureService.swift
deleted file mode 100644
index f87bb8a..0000000
--- a/OpenLessKeyboard/Services/AudioCaptureService.swift
+++ /dev/null
@@ -1,173 +0,0 @@
-// AudioCaptureService.swift
-// OSGKeyboard · Keyboard Extension
-//
-// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
-// Exposes an AsyncStream that ASR services can consume.
-
-import Foundation
-@preconcurrency import AVFoundation
-
-public actor AudioCaptureService {
-
- public enum CaptureError: Error {
- case sessionConfigFailed(Error)
- case engineStartFailed(Error)
- case noInputNode
- }
-
- private let engine = AVAudioEngine()
- private let converter = AVAudioConverter(
- from: AVAudioFormat(
- commonFormat: .pcmFormatFloat32,
- sampleRate: 48000,
- channels: 1,
- interleaved: false
- )!,
- to: AVAudioFormat(
- commonFormat: .pcmFormatFloat32,
- sampleRate: 16_000,
- channels: 1,
- interleaved: false
- )!
- )
-
- private var continuation: AsyncStream.Continuation?
- private var isRunning = false
-
- public init() {}
-
- public func start() -> AsyncStream {
- AsyncStream { continuation in
- self.continuation = continuation
- do {
- try configureSession()
- try attachTap()
- try engine.start()
- isRunning = true
- } catch {
- continuation.finish()
- self.continuation = nil
- isRunning = false
- }
- }
- }
-
- public func stop() {
- guard isRunning else { return }
- engine.inputNode.removeTap(onBus: 0)
- engine.stop()
- continuation?.finish()
- continuation = nil
- isRunning = false
- }
-
- private func configureSession() throws {
- #if canImport(UIKit)
- let session = AVAudioSession.sharedInstance()
- do {
- try session.setCategory(
- .playAndRecord,
- mode: .measurement,
- options: [.duckOthers, .defaultToSpeaker, .allowBluetooth]
- )
- try session.setActive(true, options: .notifyOthersOnDeactivation)
- } catch {
- throw CaptureError.sessionConfigFailed(error)
- }
- #endif
- }
-
- private func attachTap() throws {
- let input = engine.inputNode
- let hardwareFormat = input.outputFormat(forBus: 0)
- guard hardwareFormat.sampleRate > 0 else {
- throw CaptureError.noInputNode
- }
- let bufferSize: AVAudioFrameCount = 4096
- input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
- guard let self else { return }
- // Downsample to 16 kHz mono Float32
- let target = AVAudioFormat(
- commonFormat: .pcmFormatFloat32,
- sampleRate: 16_000,
- channels: 1,
- interleaved: false
- )!
- let outFrames = AVAudioFrameCount(
- Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
- )
- guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
- return
- }
- var error: NSError?
- let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in
- outStatus.pointee = .haveData
- return buffer
- }
- if status == .haveData, error == nil {
- // AVAudioPCMBuffer is not Sendable; copy the raw float data
- // into a Sendable wrapper so we can hand it to the actor.
- let copy = AudioBufferSnapshot(buffer: outBuffer)
- Task { await self.deliver(copy) }
- }
- }
- }
-
- private func deliver(_ snapshot: AudioBufferSnapshot) {
- guard isRunning else { return }
- continuation?.yield(snapshot)
- }
-}
-
-/// Sendable wrapper around a Float32 audio buffer's raw samples.
-/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable
-/// type crossing the actor boundary.
-public struct AudioBufferSnapshot: Sendable {
- public let samples: [Float]
- public let sampleRate: Double
-
- public init(buffer: AVAudioPCMBuffer) {
- guard let channelData = buffer.floatChannelData else {
- self.samples = []
- self.sampleRate = 16_000
- return
- }
- let n = Int(buffer.frameLength)
- var copy = [Float](repeating: 0, count: n)
- memcpy(©, channelData[0], n * MemoryLayout.size)
- self.samples = copy
- self.sampleRate = buffer.format.sampleRate
- }
-}
-
-public extension AsyncStream where Element == AudioBufferSnapshot {
- /// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32.
- func toAVAudioBuffers() -> AsyncStream {
- let format = AVAudioFormat(
- commonFormat: .pcmFormatFloat32,
- sampleRate: 16_000,
- channels: 1,
- interleaved: false
- )!
- let snapshots = self
- return AsyncStream { continuation in
- Task {
- for await snap in snapshots {
- guard !snap.samples.isEmpty,
- let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count))
- else { continue }
- pcm.frameLength = AVAudioFrameCount(snap.samples.count)
- if let dst = pcm.floatChannelData?[0] {
- snap.samples.withUnsafeBufferPointer { src in
- if let base = src.baseAddress {
- memcpy(dst, base, snap.samples.count * MemoryLayout.size)
- }
- }
- }
- continuation.yield(pcm)
- }
- continuation.finish()
- }
- }
- }
-}
diff --git a/OpenLessKeyboard/Views/KeyboardRootView.swift b/OpenLessKeyboard/Views/KeyboardRootView.swift
deleted file mode 100644
index 9fe67d3..0000000
--- a/OpenLessKeyboard/Views/KeyboardRootView.swift
+++ /dev/null
@@ -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)
- }
- }
-}
diff --git a/OpenLessKeyboard/Views/RecordButton.swift b/OpenLessKeyboard/Views/RecordButton.swift
deleted file mode 100644
index b110cea..0000000
--- a/OpenLessKeyboard/Views/RecordButton.swift
+++ /dev/null
@@ -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"
- }
- }
-}
diff --git a/OpenLessKeyboard/Views/WaveformView.swift b/OpenLessKeyboard/Views/WaveformView.swift
deleted file mode 100644
index 527f36b..0000000
--- a/OpenLessKeyboard/Views/WaveformView.swift
+++ /dev/null
@@ -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.. CGFloat {
- // Center bars taller; outer shorter — symmetric pattern
- let center = Double(barCount - 1) / 2.0
- let distance = abs(Double(index) - center) / max(center, 1)
- let base = max(6, 28 * level)
- return CGFloat(base * (1.0 - distance * 0.4))
- }
-}
diff --git a/OpenLessShared/Constants/AppGroup.swift b/OpenLessShared/Constants/AppGroup.swift
deleted file mode 100644
index 56476ad..0000000
--- a/OpenLessShared/Constants/AppGroup.swift
+++ /dev/null
@@ -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
- }
-}
diff --git a/OpenLessShared/Services/AppGroupStore.swift b/OpenLessShared/Services/AppGroupStore.swift
deleted file mode 100644
index a581b12..0000000
--- a/OpenLessShared/Services/AppGroupStore.swift
+++ /dev/null
@@ -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
- )
- }
-}
diff --git a/README.md b/README.md
index 33a65ad..9ff1720 100644
--- a/README.md
+++ b/README.md
@@ -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(
diff --git a/README.zh.md b/README.zh.md
index 072fe21..04de1ab 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -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/ # LLMClient(OpenAI 兼容)
│ └── 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(
diff --git a/project.yml b/project.yml
index e130c27..b5e7284 100644
--- a/project.yml
+++ b/project.yml
@@ -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: