feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese
This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).
Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
(OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
strict-concurrency checks while still serialising engine + converter
state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
Motion) lifted into OSGKeyboardShared so the host app and the
keyboard extension stay in lock-step.
Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
Automatic Signing. They are now declared in project.yml so
'xcodegen generate' re-emits them every time. iOS Developer
Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
that locked out every press after the first because
recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
Task) so the press flow optimistically enters .recording;
permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
TapGesture pair separated by pressArmed, so a single tap no
longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
pseudo-random walk); the visible waveform is now driven by
actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
(auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
first-class Chinese / English / Japanese / Korean dictation,
with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
routing is restored.
Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
no longer crops the keyboard under the Spotlight bar / home
indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
(Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
balanced.
- RecordButton onPressEnded is now safe to fire from a quick
press; pressArmed prevents double-firing.
LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
(server body is now logged at debug, never surfaced to UI);
added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
(Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).
Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
has a hit-test bug on iOS 18 that ate gestures from adjacent
TextFields (manifested as "typing jumps to a website"). It is
now an explicit Button + contentShape + .submitLabel(.done) on
the fields.
Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
persistence + OpenAI request/response + HTTP error + missing
key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
*before* mutating, so switching providers actually resets the
system prompt to the new default.
Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
iOS 27 beta aborts with __abort_with_payload (dispatch
library ABI mismatch); use an iOS 18 real device or the
iOS 26 simulator for now.
🤖 Generated with Claude Code
This commit is contained in:
+4
-4
@@ -21,10 +21,10 @@ opt_in_rules:
|
||||
- redundant_optional_initialization
|
||||
|
||||
included:
|
||||
- OpenLess
|
||||
- OpenLessKeyboard
|
||||
- OpenLessShared
|
||||
- OpenLessTests
|
||||
- OSGKeyboard
|
||||
- OSGKeyboardExt
|
||||
- OSGKeyboardShared
|
||||
- OSGKeyboardTests
|
||||
|
||||
excluded:
|
||||
- build
|
||||
|
||||
+6
-6
@@ -32,7 +32,7 @@ Open an issue using the **Feature request** template. Briefly describe:
|
||||
xcodegen generate
|
||||
```
|
||||
3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable.
|
||||
4. **Tests.** Add XCTest coverage in `OpenLessTests/` for any non-trivial logic.
|
||||
4. **Tests.** Add XCTest coverage in `OSGKeyboardTests/` for any non-trivial logic.
|
||||
5. **Build & test before pushing:**
|
||||
```bash
|
||||
xcodebuild -project OSGKeyboard.xcodeproj \
|
||||
@@ -46,17 +46,17 @@ Open an issue using the **Feature request** template. Briefly describe:
|
||||
## Project structure
|
||||
|
||||
```
|
||||
OpenLess/ Main iOS app target
|
||||
OpenLessKeyboard/ Custom Keyboard Extension target
|
||||
OpenLessShared/ Framework shared by app + extension
|
||||
OpenLessTests/ XCTest unit tests
|
||||
OSGKeyboard/ Main iOS app target
|
||||
OSGKeyboardExt/ Custom Keyboard Extension target
|
||||
OSGKeyboardShared/ Framework shared by app + extension
|
||||
OSGKeyboardTests/ XCTest unit tests
|
||||
project.yml XcodeGen project definition (source of truth)
|
||||
.github/workflows/ CI
|
||||
```
|
||||
|
||||
## Adding a new LLM provider
|
||||
|
||||
The simplest contribution: add a preset to `OpenLessShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint.
|
||||
The simplest contribution: add a preset to `OSGKeyboardShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint.
|
||||
|
||||
## Coding conventions
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-1024.png",
|
||||
"filename" : "Group 24.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0.043",
|
||||
"green" : "0.039",
|
||||
"red" : "0.039"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string></string>
|
||||
<string>BackgroundColor</string>
|
||||
</dict>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// APISettingsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Editable fields for the three OpenAI-compatible config values:
|
||||
// Base URL, API Key, Model.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct APISettingsCard: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
field(
|
||||
title: "Base URL",
|
||||
placeholder: "https://api.openai.com/v1",
|
||||
text: $config.baseURL,
|
||||
keyboard: .URL,
|
||||
autocap: false
|
||||
)
|
||||
Divider().background(Palette.divider)
|
||||
keyField
|
||||
Divider().background(Palette.divider)
|
||||
field(
|
||||
title: "Model",
|
||||
placeholder: "gpt-4o-mini",
|
||||
text: $config.model,
|
||||
keyboard: .default,
|
||||
autocap: false
|
||||
)
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Divider().background(Palette.divider)
|
||||
// Use a Button + UIApplication.open instead of SwiftUI
|
||||
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that
|
||||
// makes its tappable area eat gestures from the adjacent
|
||||
// TextField, which manifests as "typing jumps to a website".
|
||||
Button {
|
||||
UIApplication.shared.open(url)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "key.fill")
|
||||
.foregroundStyle(Palette.accent)
|
||||
Text("Get an API key")
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right.square")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Key
|
||||
|
||||
private var keyField: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text("API Key")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Spacer()
|
||||
Button(action: { showKey.toggle() }) {
|
||||
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text(showKey ? "Hide key" : "Show key"))
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
TextField("sk-…", text: $config.apiKey)
|
||||
} else {
|
||||
SecureField("sk-…", text: $config.apiKey)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
|
||||
// MARK: - Generic field
|
||||
|
||||
@ViewBuilder
|
||||
private func field(
|
||||
title: String,
|
||||
placeholder: String,
|
||||
text: Binding<String>,
|
||||
keyboard: UIKeyboardType,
|
||||
autocap: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
TextField(placeholder, text: text)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled(true)
|
||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.submitLabel(.done)
|
||||
.onSubmit { /* no-op: prevent the keyboard from "submitting"
|
||||
and dismissing the sheet on iOS 18 */ }
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// HomeView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
|
||||
// (2) give a clear path to the next setup step if anything is missing.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import OSGKeyboardExt
|
||||
|
||||
struct HomeView: View {
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
@State private var showSettings = false
|
||||
@State private var showKeyboardPreview = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
statusHeader
|
||||
.padding(.top, Spacing.xl)
|
||||
Spacer()
|
||||
heroButton
|
||||
Spacer()
|
||||
actionStack
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView()
|
||||
}
|
||||
.sheet(isPresented: $showKeyboardPreview) {
|
||||
KeyboardPreviewSheet()
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var statusHeader: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(config.isConfigured ? Palette.success : Palette.warning)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(config.isConfigured ? "Ready" : "Setup incomplete")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.largeTitle)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text(providerLine)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
private var providerLine: String {
|
||||
let p = LLMProvider.provider(id: config.providerId)
|
||||
let model = config.model.isEmpty ? "—" : config.model
|
||||
return "\(p.name) · \(model)"
|
||||
}
|
||||
|
||||
// MARK: - Hero
|
||||
|
||||
private var heroButton: some View {
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Palette.accentMuted)
|
||||
.frame(width: 220, height: 220)
|
||||
.blur(radius: 40)
|
||||
Circle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Palette.surfaceElevated, Palette.surface],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
))
|
||||
.frame(width: 160, height: 160)
|
||||
.overlay(
|
||||
Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8)
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "waveform")
|
||||
.font(.system(size: 36, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
Text("Tap to configure")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Open OSGKeyboard settings"))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private var actionStack: some View {
|
||||
VStack(spacing: Spacing.xs) {
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard")
|
||||
.primaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Button {
|
||||
showSettings = true
|
||||
} label: {
|
||||
Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3")
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
#if DEBUG
|
||||
Button {
|
||||
showKeyboardPreview = true
|
||||
} label: {
|
||||
Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye")
|
||||
.secondaryButton()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
#endif
|
||||
|
||||
HStack(spacing: Spacing.xs) {
|
||||
Button {
|
||||
if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Text("GitHub")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.padding(.horizontal, Spacing.sm)
|
||||
.padding(.vertical, 6)
|
||||
.background(Palette.surface, in: Capsule())
|
||||
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer()
|
||||
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// KeyboardPreviewSheet.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Renders a stand-in keyboard layout inside the main app so the user
|
||||
// can preview what the real keyboard extension looks like without
|
||||
// enabling the keyboard in iOS Settings. Tap the disc to cycle
|
||||
// through idle / recording / processing so all visual states are
|
||||
// inspectable.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct KeyboardPreviewSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// We mirror only the fields the stand-in actually needs, instead of
|
||||
// crossing the OSGKeyboardExt target boundary to construct a
|
||||
// `KeyboardViewController.State` (whose initialiser is internal).
|
||||
@State private var phase: StubPhase = .idle
|
||||
@State private var level: Double = 0
|
||||
@State private var transcript: String = ""
|
||||
|
||||
private enum StubPhase { case idle, recording, processing }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: Spacing.md) {
|
||||
Text("Keyboard Preview")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Tap the disc to cycle states. The real keyboard uses the same layout.")
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Spacing.lg)
|
||||
mockTextField.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
.padding(.top, Spacing.lg)
|
||||
Spacer(minLength: 0)
|
||||
keyboardBlock
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var mockTextField: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
Text("Type here…")
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.medium)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private var keyboardBlock: some View {
|
||||
VStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Palette.divider)
|
||||
.frame(height: 0.5)
|
||||
KeyboardPreviewStub(
|
||||
phase: stubPhase,
|
||||
level: level,
|
||||
transcript: transcript
|
||||
)
|
||||
.environment(\.colorScheme, .dark)
|
||||
Rectangle()
|
||||
.fill(Color.black)
|
||||
.frame(height: 34)
|
||||
.overlay(alignment: .center) {
|
||||
Capsule()
|
||||
.fill(Color.white.opacity(0.4))
|
||||
.frame(width: 134, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var stubPhase: KeyboardPreviewStub.Phase {
|
||||
switch phase {
|
||||
case .idle: return .idle
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
KeyboardPreviewSheet()
|
||||
}
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -0,0 +1,257 @@
|
||||
// OnboardingView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Three-step onboarding presented as a horizontal pager:
|
||||
//
|
||||
// 1) Welcome — what the app does, in one sentence
|
||||
// 2) Enable — Settings → General → Keyboards → Add → Allow Full Access
|
||||
// 3) Setup — pick a provider, paste a key
|
||||
//
|
||||
// Visual style: one large accent surface, generous whitespace, single CTA
|
||||
// at the bottom. No tipsy animations, no cheerful illustrations — every
|
||||
// pixel is doing one job.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var page: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Palette.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $page) {
|
||||
WelcomePage().tag(0)
|
||||
EnableKeyboardPage().tag(1)
|
||||
APISetupPage(config: config).tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
pageDots
|
||||
.padding(.bottom, Spacing.md)
|
||||
|
||||
bottomBar
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.bottom, Spacing.lg)
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var pageDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == page ? Palette.accent : Color.white.opacity(0.18))
|
||||
.frame(width: i == page ? 18 : 6, height: 6)
|
||||
.animation(Motion.quick, value: page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bottomBar: some View {
|
||||
HStack(spacing: Spacing.sm) {
|
||||
if page > 0 {
|
||||
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
|
||||
Text("Back")
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Button {
|
||||
withAnimation(Motion.soft) {
|
||||
if page < 2 { page += 1 }
|
||||
}
|
||||
} label: {
|
||||
Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next")
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(
|
||||
(page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent,
|
||||
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
)
|
||||
.foregroundStyle(
|
||||
(page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(page == 2 && !config.isConfigured)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 1: Welcome
|
||||
|
||||
private struct WelcomePage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Spacer()
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Palette.accentMuted)
|
||||
.frame(width: 180, height: 180)
|
||||
.blur(radius: 30)
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.system(size: 96, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Text("OSGKeyboard")
|
||||
.font(TypeStyle.title)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("按住说话,松开即得润色文字。")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("Hold to talk. Release for polished text, in any app.")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.horizontal, Spacing.xl)
|
||||
PrivacyFootnote()
|
||||
.padding(.top, Spacing.lg)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PrivacyFootnote: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
footnoteRow(icon: "lock.fill",
|
||||
title: "Audio stays on device",
|
||||
body: "Transcribed locally with Apple's speech engine.")
|
||||
footnoteRow(icon: "wifi",
|
||||
title: "Only the polished text is sent",
|
||||
body: "Sent to your chosen LLM to add structure & punctuation.")
|
||||
footnoteRow(icon: "keyboard",
|
||||
title: "Works everywhere",
|
||||
body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.")
|
||||
}
|
||||
.padding(Spacing.md)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
|
||||
private func footnoteRow(icon: String, title: String, body: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(Palette.accent)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(Palette.accentMuted, in: Circle())
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(TypeStyle.caption)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text(body)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 2: Enable keyboard
|
||||
|
||||
private struct EnableKeyboardPage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: Spacing.xl) {
|
||||
Spacer()
|
||||
Image(systemName: "keyboard.fill")
|
||||
.font(.system(size: 64, weight: .light))
|
||||
.foregroundStyle(Palette.accent)
|
||||
VStack(spacing: Spacing.sm) {
|
||||
Text("启用 OSGKeyboard")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Enable OSGKeyboard")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: Spacing.sm) {
|
||||
step(num: 1, text: "Settings → General → Keyboard → Keyboards")
|
||||
step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard")
|
||||
step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access”")
|
||||
step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.")
|
||||
}
|
||||
.cardSurface()
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
|
||||
.primaryButton()
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func step(num: Int, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: Spacing.xs) {
|
||||
Text("\(num)")
|
||||
.font(TypeStyle.caption2)
|
||||
.frame(width: 22, height: 22)
|
||||
.background(Palette.accent, in: Circle())
|
||||
.foregroundStyle(Palette.textOnAccent)
|
||||
Text(text)
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Page 3: API setup
|
||||
|
||||
private struct APISetupPage: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: Spacing.md) {
|
||||
VStack(alignment: .leading, spacing: Spacing.xxs) {
|
||||
Text("配置 AI 提供商")
|
||||
.font(TypeStyle.title2)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
Text("Configure your AI provider")
|
||||
.font(TypeStyle.body)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
|
||||
.font(TypeStyle.footnote)
|
||||
.foregroundStyle(Palette.textSecondary)
|
||||
.padding(.top, Spacing.xxs)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.top, Spacing.lg)
|
||||
|
||||
ProviderPickerSection(config: config)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, Spacing.md)
|
||||
}
|
||||
.padding(.bottom, Spacing.xxxl)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// ProviderPickerSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Provider picker shown inline inside Settings & Onboarding. Each option
|
||||
// surfaces the provider's name + a short blurb so the user can pick
|
||||
// confidently without opening a doc.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ProviderPickerSection: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in
|
||||
Button {
|
||||
select(provider)
|
||||
} label: {
|
||||
row(provider, selected: provider.id == config.providerId)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if index < LLMProvider.presets.count - 1 {
|
||||
Divider().background(Palette.divider)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
private func select(_ provider: LLMProvider) {
|
||||
withAnimation(Motion.quick) {
|
||||
config.apply(preset: provider)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
// Provider mark — a coloured dot with first letter
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(selected ? Palette.accent : Palette.surfaceElevated)
|
||||
.frame(width: 36, height: 36)
|
||||
Text(String(provider.name.prefix(1)))
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(provider.name)
|
||||
.font(TypeStyle.bodyEmph)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
if let blurb = provider.blurb {
|
||||
Text(blurb)
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(Palette.textTertiary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if selected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(Palette.accent)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
@@ -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 ?? "—"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
// KeyboardViewController.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Principal class for the Custom Keyboard Extension. Hosts a single
|
||||
// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline:
|
||||
//
|
||||
// AudioCaptureService ──► ASRService ──► PolishingService ──► insertText
|
||||
//
|
||||
// Design notes:
|
||||
// • The class is `@MainActor` — every UI mutation and `textDocumentProxy`
|
||||
// call must happen on main, and Swift 6 strict concurrency forces this.
|
||||
// • State is a single `State` ObservableObject; SwiftUI observes it via
|
||||
// `@ObservedObject` so we never re-create the hosting root on each tick.
|
||||
// • `phase` is a real stored property (no derivation) — the previous
|
||||
// "derive from recordStream" shim locked out every press after the first.
|
||||
// • Microphone permission is requested *inside* pressBegan, but we still
|
||||
// start the rest of the press flow optimistically; if permission is
|
||||
// denied we surface a short error and drop back to idle cleanly.
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
import OSGKeyboardShared
|
||||
|
||||
@objc(KeyboardViewController)
|
||||
@MainActor
|
||||
public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
// MARK: - View model
|
||||
|
||||
@MainActor
|
||||
public final class State: ObservableObject {
|
||||
public init() {}
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case processing
|
||||
case error(String)
|
||||
}
|
||||
|
||||
public enum InputMode: String, CaseIterable, Identifiable {
|
||||
case off
|
||||
case transcribe
|
||||
case polish
|
||||
|
||||
public var id: String { rawValue }
|
||||
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .off: return "mode.off"
|
||||
case .transcribe: return "mode.transcribe"
|
||||
case .polish: return "mode.polish"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published public var phase: Phase = .idle
|
||||
@Published public var level: Double = 0
|
||||
@Published public var mode: InputMode = .polish
|
||||
@Published public var localeId: String = "auto"
|
||||
@Published public var lastTranscript: String = ""
|
||||
|
||||
// Action hooks — injected by the view controller at install time.
|
||||
var beginRecording: () -> Void = {}
|
||||
var endRecording: () -> Void = {}
|
||||
var tapMic: () -> Void = {} // tap on the mic area (advances keyboard)
|
||||
var openSettings: () -> Void = {}
|
||||
var setMode: (InputMode) -> Void = { _ in }
|
||||
var setLocale: (String) -> Void = { _ in }
|
||||
var insertNewline: () -> Void = {}
|
||||
var insertSpace: () -> Void = {}
|
||||
var deleteBackward: () -> Void = {}
|
||||
|
||||
// MARK: - Preview helpers
|
||||
|
||||
#if DEBUG
|
||||
static var previewIdle: State {
|
||||
let s = State()
|
||||
s.phase = .idle
|
||||
s.level = 0
|
||||
s.mode = .polish
|
||||
s.localeId = "zh-Hans"
|
||||
s.lastTranscript = ""
|
||||
return s
|
||||
}
|
||||
static var previewRecording: State {
|
||||
let s = State()
|
||||
s.phase = .recording
|
||||
s.level = 0.65
|
||||
s.mode = .polish
|
||||
s.localeId = "zh-Hans"
|
||||
s.lastTranscript = "你好,我想说一段测试"
|
||||
return s
|
||||
}
|
||||
static var previewProcessing: State {
|
||||
let s = State()
|
||||
s.phase = .processing
|
||||
s.level = 0
|
||||
s.mode = .polish
|
||||
s.localeId = "zh-Hans"
|
||||
s.lastTranscript = ""
|
||||
return s
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private let state = State()
|
||||
private let audio = AudioCaptureService()
|
||||
private let asr: ASRService = ASRServiceFactory.make()
|
||||
private let polisher = PolishingService()
|
||||
|
||||
private var session: AudioCaptureService.Session?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var levelTask: Task<Void, Never>?
|
||||
private var didRequestMicOnce: Bool = false
|
||||
|
||||
private var hosting: UIHostingController<KeyboardRootView>!
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
// iOS 18 keyboard extension MUST opt in to self-sizing, otherwise
|
||||
// our SwiftUI `frame(height:)` is ignored and the keyboard is
|
||||
// cropped by the system chrome (Spotlight bar, home indicator).
|
||||
inputView?.allowsSelfSizing = true
|
||||
installStateActions()
|
||||
installSwiftUI()
|
||||
loadPersistedLocale()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
public override func textDidChange(_ textInput: (any UITextInput)?) {
|
||||
super.textDidChange(textInput)
|
||||
// Hook for future per-app mode switching (e.g. password field → .off).
|
||||
}
|
||||
|
||||
// MARK: - Wiring
|
||||
|
||||
private func installStateActions() {
|
||||
state.beginRecording = { [weak self] in self?.pressBegan() }
|
||||
state.endRecording = { [weak self] in self?.pressEnded() }
|
||||
state.tapMic = { [weak self] in self?.advanceToNextInputMode() }
|
||||
state.openSettings = { [weak self] in self?.openHostApp() }
|
||||
state.setMode = { [weak self] m in self?.persistMode(m) }
|
||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
||||
}
|
||||
|
||||
private func installSwiftUI() {
|
||||
let root = KeyboardRootView(state: state)
|
||||
let host = UIHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
addChild(host)
|
||||
view.addSubview(host.view)
|
||||
NSLayoutConstraint.activate([
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
// Pin the host view to a fixed height matching KeyboardRootView.totalHeight.
|
||||
// Without this, iOS lets the system chrome (Spotlight, home
|
||||
// indicator) bleed into our content. With it, our content area
|
||||
// is fully reserved and the keyboard feels intentional.
|
||||
host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight)
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
self.hosting = host
|
||||
}
|
||||
|
||||
private func loadPersistedLocale() {
|
||||
let store = AppGroupStore()
|
||||
let id = store.localeId
|
||||
state.localeId = id
|
||||
state.mode = State.InputMode(rawValue: store.modeId) ?? .polish
|
||||
}
|
||||
|
||||
// MARK: - Press handlers
|
||||
|
||||
private func pressBegan() {
|
||||
guard state.phase == .idle else { return }
|
||||
guard state.mode != .off else { return }
|
||||
// We optimistically enter `.recording`; the capture session will yield
|
||||
// frames on its own queue, so even if mic permission takes a beat the
|
||||
// user already feels the press registered.
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let granted = await self.requestMicPermission()
|
||||
guard granted else {
|
||||
self.state.phase = .error("麦克风被拒绝,请到「设置」中允许")
|
||||
self.scheduleAutoClearError()
|
||||
return
|
||||
}
|
||||
self.startPipeline()
|
||||
}
|
||||
}
|
||||
|
||||
private func pressEnded() {
|
||||
guard state.phase == .recording else { return }
|
||||
stopPipeline()
|
||||
}
|
||||
|
||||
// MARK: - Pipeline
|
||||
|
||||
private func startPipeline() {
|
||||
let session = audio.start()
|
||||
self.session = session
|
||||
state.phase = .recording
|
||||
state.level = 0
|
||||
state.lastTranscript = ""
|
||||
|
||||
let locale = resolveLocale(state.localeId)
|
||||
let events = asr.transcribe(stream: session.audio, locale: locale)
|
||||
|
||||
asrTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var lastPartial: String = ""
|
||||
for await event in events {
|
||||
switch event {
|
||||
case .partial(let s):
|
||||
lastPartial = s
|
||||
self.state.lastTranscript = s
|
||||
case .final(let s):
|
||||
let transcript = s.isEmpty ? lastPartial : s
|
||||
self.handleFinalTranscript(transcript)
|
||||
case .error(let m):
|
||||
self.state.phase = .error("ASR: \(m)")
|
||||
self.scheduleAutoClearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
levelTask = Task { @MainActor [weak self] in
|
||||
for await level in session.levels {
|
||||
guard let self else { return }
|
||||
// Smooth a little extra to feel natural.
|
||||
self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPipeline() {
|
||||
session?.stop()
|
||||
session = nil
|
||||
asrTask?.cancel(); asrTask = nil
|
||||
levelTask?.cancel(); levelTask = nil
|
||||
}
|
||||
|
||||
private func cancelPipeline() {
|
||||
stopPipeline()
|
||||
asr.cancel()
|
||||
if state.phase == .recording || state.phase == .processing {
|
||||
state.phase = .idle
|
||||
}
|
||||
state.level = 0
|
||||
}
|
||||
|
||||
private func handleFinalTranscript(_ transcript: String) {
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
state.phase = .idle
|
||||
return
|
||||
}
|
||||
// In `.transcribe` mode, skip the LLM and insert raw.
|
||||
if state.mode == .transcribe {
|
||||
textDocumentProxy.insertText(trimmed)
|
||||
state.lastTranscript = ""
|
||||
state.phase = .idle
|
||||
return
|
||||
}
|
||||
// `.polish` (default): call the LLM.
|
||||
state.phase = .processing
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let polished = try await self.polisher.polish(trimmed)
|
||||
self.textDocumentProxy.insertText(polished)
|
||||
self.state.lastTranscript = ""
|
||||
self.state.phase = .idle
|
||||
} catch {
|
||||
// Fall back to raw transcript on any failure.
|
||||
self.textDocumentProxy.insertText(trimmed)
|
||||
self.state.lastTranscript = ""
|
||||
let msg = (error as? LocalizedError)?.errorDescription
|
||||
?? "Polishing failed — inserted raw."
|
||||
self.state.phase = .error(msg)
|
||||
self.scheduleAutoClearError()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func persistMode(_ m: State.InputMode) {
|
||||
state.mode = m
|
||||
AppGroupStore().setModeId(m.rawValue)
|
||||
}
|
||||
|
||||
private func persistLocale(_ id: String) {
|
||||
state.localeId = id
|
||||
AppGroupStore().setLocaleId(id)
|
||||
}
|
||||
|
||||
// MARK: - Permissions
|
||||
|
||||
private func requestMicPermission() async -> Bool {
|
||||
if #available(iOS 17.0, *) {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined:
|
||||
if !didRequestMicOnce {
|
||||
didRequestMicOnce = true
|
||||
return await AVAudioApplication.requestRecordPermission()
|
||||
}
|
||||
return false
|
||||
@unknown default: return false
|
||||
}
|
||||
} else {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
case .granted: return true
|
||||
case .denied: return false
|
||||
case .undetermined:
|
||||
if !didRequestMicOnce {
|
||||
didRequestMicOnce = true
|
||||
return await withCheckedContinuation { cont in
|
||||
session.requestRecordPermission { cont.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
return false
|
||||
@unknown default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Open host app
|
||||
|
||||
private func openHostApp() {
|
||||
let urlString = "osgkeyboard://settings"
|
||||
if let url = URL(string: urlString) {
|
||||
var responder: UIResponder? = self
|
||||
while let r = responder {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(url)
|
||||
return
|
||||
}
|
||||
responder = r.next
|
||||
}
|
||||
}
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
var responder: UIResponder? = self
|
||||
while let r = responder {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(url); return
|
||||
}
|
||||
responder = r.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func resolveLocale(_ id: String) -> Locale {
|
||||
if id == "auto" { return .current }
|
||||
return Locale(identifier: id)
|
||||
}
|
||||
|
||||
private func scheduleAutoClearError() {
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||||
guard let self else { return }
|
||||
if case .error = self.state.phase {
|
||||
self.state.phase = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.osgkeyboard.shared</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,151 @@
|
||||
// ASRService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Speech-to-text abstraction over Apple's `SFSpeechRecognizer`.
|
||||
// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP …) so
|
||||
// dictation is first-class for non-English languages.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
import os.lock
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Sendable conformance
|
||||
|
||||
// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We
|
||||
// only ever access them serially — the PCM buffer is built and consumed
|
||||
// inside a single Task, and the recogniser task is cancelled but never
|
||||
// shared concurrently — so an unchecked conformance is sound here.
|
||||
extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {}
|
||||
extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {}
|
||||
|
||||
// MARK: - Protocol
|
||||
|
||||
public protocol ASRService: Sendable {
|
||||
/// Start a transcription session. The returned stream emits `.partial`
|
||||
/// updates and exactly one `.final` (or `.error`) before finishing.
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent>
|
||||
|
||||
/// Cancel any in-flight recognition and tear down its tasks.
|
||||
func cancel()
|
||||
}
|
||||
|
||||
public enum ASREvent: Sendable, Equatable {
|
||||
case partial(String)
|
||||
case final(String)
|
||||
case error(String)
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
public enum ASRServiceFactory {
|
||||
public static func make() -> ASRService {
|
||||
AppleSpeechASR()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Apple Speech implementation
|
||||
|
||||
final class AppleSpeechASR: ASRService, @unchecked Sendable {
|
||||
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var recognizerTask: SFSpeechRecognitionTask?
|
||||
private var feedTask: Task<Void, Never>?
|
||||
|
||||
func transcribe(
|
||||
stream: AsyncStream<AudioBufferSnapshot>,
|
||||
locale: Locale
|
||||
) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { continuation in
|
||||
let recognizer = SFSpeechRecognizer(locale: locale)
|
||||
?? SFSpeechRecognizer(locale: .current)
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
recognizer.defaultTaskHint = .dictation
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition
|
||||
|
||||
let task = recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
let nsErr = error as NSError
|
||||
// Codes 203 / 1110 = "no speech detected" — a normal exit.
|
||||
if nsErr.code == 203 || nsErr.code == 1110 {
|
||||
continuation.yield(.final(""))
|
||||
} else {
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
}
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
guard let result else { return }
|
||||
if result.isFinal {
|
||||
continuation.yield(.final(result.bestTranscription.formattedString))
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(.partial(result.bestTranscription.formattedString))
|
||||
}
|
||||
}
|
||||
|
||||
self.lock.withLock { self.recognizerTask = task }
|
||||
|
||||
// Feed audio: for each snapshot, build a 16 kHz mono Float32
|
||||
// PCM buffer and immediately `request.append(pcm)`. The PCM
|
||||
// buffer never leaves this task, so it doesn't need to be
|
||||
// Sendable.
|
||||
let feedFormat = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
self.feedTask = Task { [request] in
|
||||
for await snap in stream {
|
||||
if Task.isCancelled { break }
|
||||
guard !snap.samples.isEmpty,
|
||||
let pcm = AVAudioPCMBuffer(
|
||||
pcmFormat: feedFormat,
|
||||
frameCapacity: AVAudioFrameCount(snap.samples.count)
|
||||
)
|
||||
else { continue }
|
||||
pcm.frameLength = AVAudioFrameCount(snap.samples.count)
|
||||
if let dst = pcm.floatChannelData?[0] {
|
||||
snap.samples.withUnsafeBufferPointer { src in
|
||||
if let base = src.baseAddress {
|
||||
memcpy(dst, base, snap.samples.count * MemoryLayout<Float>.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
request.append(pcm)
|
||||
}
|
||||
if !Task.isCancelled {
|
||||
request.endAudio()
|
||||
}
|
||||
}
|
||||
|
||||
continuation.onTermination = { @Sendable [weak self] _ in
|
||||
self?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task<Void, Never>?) in
|
||||
let r = self.recognizerTask
|
||||
let f = self.feedTask
|
||||
self.recognizerTask = nil
|
||||
self.feedTask = nil
|
||||
return (r, f)
|
||||
}
|
||||
recTask?.cancel()
|
||||
feedT?.cancel()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
// AudioCaptureService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
|
||||
// Designed for use inside an iOS Custom Keyboard Extension:
|
||||
// • Uses `.record` (not `.playAndRecord`) — keyboards cannot play.
|
||||
// • Exposes a Sendable `Session` with two streams:
|
||||
// - `audio`: 16 kHz mono Float32 frames for ASR.
|
||||
// - `levels`: RMS + peak dBFS for the animated waveform.
|
||||
// • All mutable state is guarded by a lock; class is `@unchecked Sendable`
|
||||
// for use with Swift 6 strict concurrency.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import os.lock
|
||||
import OSGKeyboardShared
|
||||
|
||||
// MARK: - Sendable conformance
|
||||
//
|
||||
// `AVAudioEngine` is not Sendable, but the iOS audio APIs hand us
|
||||
// closures that need to capture it. We never mutate the engine
|
||||
// concurrently — capture / conversion are serialised on the actor, and
|
||||
// the tap closure only reads pointers into it. So an unchecked
|
||||
// retroactive Sendable conformance is sound here.
|
||||
// `AVAudioConverter` and `AVAudioFormat` are already Sendable in newer
|
||||
// SDKs; we don't need to redeclare.
|
||||
extension AVAudioEngine: @unchecked @retroactive Sendable {}
|
||||
|
||||
public final class AudioCaptureService: @unchecked Sendable {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
public enum CaptureError: LocalizedError, Sendable {
|
||||
case sessionConfigFailed(String)
|
||||
case engineStartFailed(String)
|
||||
case noInputNode
|
||||
case alreadyRunning
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .sessionConfigFailed(let s): return "Audio session config failed: \(s)"
|
||||
case .engineStartFailed(let s): return "Audio engine failed to start: \(s)"
|
||||
case .noInputNode: return "No microphone input available."
|
||||
case .alreadyRunning: return "Audio capture is already running."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level payload (Sendable)
|
||||
|
||||
public struct Level: Sendable, Equatable {
|
||||
/// Root-mean-square, 0...1 (linear).
|
||||
public let rms: Float
|
||||
/// Peak amplitude, 0...1 (linear).
|
||||
public let peak: Float
|
||||
public let timestamp: TimeInterval
|
||||
|
||||
/// Convenience: -20 dBFS … 0 dBFS mapped to 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<AudioBufferSnapshot>
|
||||
public let levels: AsyncStream<Level>
|
||||
private let onStop: @Sendable () -> Void
|
||||
|
||||
fileprivate init(
|
||||
audio: AsyncStream<AudioBufferSnapshot>,
|
||||
levels: AsyncStream<Level>,
|
||||
onStop: @escaping @Sendable () -> Void
|
||||
) {
|
||||
self.audio = audio
|
||||
self.levels = levels
|
||||
self.onStop = onStop
|
||||
}
|
||||
|
||||
public func stop() { onStop() }
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private let lock = OSAllocatedUnfairLock()
|
||||
private var engine: AVAudioEngine?
|
||||
private var converter: AVAudioConverter?
|
||||
private var audioContinuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var levelContinuation: AsyncStream<Level>.Continuation?
|
||||
private var isRunning: Bool = false
|
||||
|
||||
public init() {}
|
||||
|
||||
deinit { stopInternal() }
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Start capture. Returns a `Session` whose streams yield audio + level data.
|
||||
/// The session ends when `Session.stop()` is called or the extension is torn down.
|
||||
@discardableResult
|
||||
public func start() -> Session {
|
||||
let (audioStream, audioCont) = AsyncStream<AudioBufferSnapshot>.makeStream()
|
||||
let (levelStream, levelCont) = AsyncStream<Level>.makeStream()
|
||||
|
||||
// Fail fast if already running.
|
||||
let alreadyRunning: Bool = lock.withLock { isRunning }
|
||||
if alreadyRunning {
|
||||
audioCont.finish()
|
||||
levelCont.finish()
|
||||
return Session(audio: audioStream, levels: levelStream, onStop: {})
|
||||
}
|
||||
|
||||
do {
|
||||
try configureSession()
|
||||
try bootstrap(audioCont: audioCont, levelCont: levelCont)
|
||||
lock.withLock {
|
||||
isRunning = true
|
||||
audioContinuation = audioCont
|
||||
levelContinuation = levelCont
|
||||
}
|
||||
} catch {
|
||||
// Tear down whatever we partially created.
|
||||
audioCont.finish()
|
||||
levelCont.finish()
|
||||
teardownEngine()
|
||||
return Session(audio: audioStream, levels: levelStream, onStop: {})
|
||||
}
|
||||
|
||||
return Session(
|
||||
audio: audioStream,
|
||||
levels: levelStream,
|
||||
onStop: { [weak self] in self?.stop() }
|
||||
)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
stopInternal()
|
||||
}
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func configureSession() throws {
|
||||
#if canImport(UIKit)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
// `.record` — keyboards cannot play audio, so .playAndRecord is wrong.
|
||||
// `.measurement` mode disables system AGC/echo cancellation for cleaner ASR input.
|
||||
// `.duckOthers` is harmless in record-only.
|
||||
try session.setCategory(.record, mode: .measurement, options: [.duckOthers])
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw CaptureError.sessionConfigFailed(error.localizedDescription)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func bootstrap(
|
||||
audioCont: AsyncStream<AudioBufferSnapshot>.Continuation,
|
||||
levelCont: AsyncStream<Level>.Continuation
|
||||
) throws {
|
||||
let engine = AVAudioEngine()
|
||||
let input = engine.inputNode
|
||||
let hardwareFormat = input.outputFormat(forBus: 0)
|
||||
guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else {
|
||||
throw CaptureError.noInputNode
|
||||
}
|
||||
|
||||
let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
|
||||
guard let converter = AVAudioConverter(from: hardwareFormat, to: target) else {
|
||||
throw CaptureError.engineStartFailed("converter init failed")
|
||||
}
|
||||
|
||||
// Persistent buffer for level computation: we re-use Float arrays to
|
||||
// avoid per-tap allocations.
|
||||
let levelScratch = LevelScratch()
|
||||
|
||||
input.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, when in
|
||||
// We capture self weakly only to keep the AudioCaptureService
|
||||
// alive while the tap is installed; the tap itself only
|
||||
// touches the local `audioCont` / `levelCont` continuations.
|
||||
guard self != nil else { return }
|
||||
// 1) Compute level from raw hardware buffer (preserves true amplitude).
|
||||
let (rms, peak) = levelScratch.measure(buffer: buffer)
|
||||
// `AVAudioTime` carries both `sampleTime` (frames on the device
|
||||
// clock) and `hostTime` (mach absolute time). We only need a
|
||||
// monotonically increasing source for the timestamp; sample
|
||||
// time / sample rate is good enough and is independent of the
|
||||
// host clock.
|
||||
let ts: Double
|
||||
if when.sampleTime > 0, hardwareFormat.sampleRate > 0 {
|
||||
ts = Double(when.sampleTime) / hardwareFormat.sampleRate
|
||||
} else {
|
||||
ts = Date().timeIntervalSinceReferenceDate
|
||||
}
|
||||
levelCont.yield(Level(rms: rms, peak: peak, timestamp: ts))
|
||||
|
||||
// 2) Convert to 16 kHz mono Float32 for ASR.
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
|
||||
)
|
||||
guard outFrames > 0,
|
||||
let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames)
|
||||
else { return }
|
||||
|
||||
var error: NSError?
|
||||
let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
if status == .haveData, error == nil {
|
||||
let snap = AudioBufferSnapshot(buffer: outBuffer)
|
||||
audioCont.yield(snap)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try engine.start()
|
||||
} catch {
|
||||
input.removeTap(onBus: 0)
|
||||
throw CaptureError.engineStartFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
self.engine = engine
|
||||
self.converter = converter
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Teardown
|
||||
|
||||
private func stopInternal() {
|
||||
let (wasRunning, engine, audioCont, levelCont) = lock.withLock { () -> (Bool, AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
|
||||
let was = isRunning
|
||||
isRunning = false
|
||||
let eng = self.engine
|
||||
let ac = audioContinuation
|
||||
let lc = levelContinuation
|
||||
self.engine = nil
|
||||
self.converter = nil
|
||||
self.audioContinuation = nil
|
||||
self.levelContinuation = nil
|
||||
return (was, eng, ac, lc)
|
||||
}
|
||||
guard wasRunning else { return }
|
||||
engine?.inputNode.removeTap(onBus: 0)
|
||||
engine?.stop()
|
||||
#if canImport(UIKit)
|
||||
// Deactivate the session so other apps' audio routing is restored.
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
audioCont?.finish()
|
||||
levelCont?.finish()
|
||||
}
|
||||
|
||||
private func teardownEngine() {
|
||||
let (engine, audioCont, levelCont) = lock.withLock { () -> (AVAudioEngine?, AsyncStream<AudioBufferSnapshot>.Continuation?, AsyncStream<Level>.Continuation?) in
|
||||
let e = self.engine
|
||||
let a = audioContinuation
|
||||
let l = levelContinuation
|
||||
self.engine = nil
|
||||
self.converter = nil
|
||||
self.audioContinuation = nil
|
||||
self.levelContinuation = nil
|
||||
self.isRunning = false
|
||||
return (e, a, l)
|
||||
}
|
||||
engine?.inputNode.removeTap(onBus: 0)
|
||||
engine?.stop()
|
||||
audioCont?.finish()
|
||||
levelCont?.finish()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level scratch (lock-free, single-writer / single-reader per tap)
|
||||
|
||||
/// Lock-free per-tap scratch for RMS + peak measurement. The AVAudio tap is
|
||||
/// always invoked serially per input node, so we don't need a lock here.
|
||||
private final class LevelScratch: @unchecked Sendable {
|
||||
private var last: (rms: Float, peak: Float) = (0, 0)
|
||||
|
||||
func measure(buffer: AVAudioPCMBuffer) -> (rms: Float, peak: Float) {
|
||||
guard let ch = buffer.floatChannelData?[0] else { return last }
|
||||
let n = Int(buffer.frameLength)
|
||||
guard n > 0 else { return last }
|
||||
|
||||
// Decay smoothing — keeps the meter lively but not jittery.
|
||||
var sumSq: Float = 0
|
||||
var peak: Float = 0
|
||||
for i in 0..<n {
|
||||
let s = ch[i]
|
||||
sumSq += s * s
|
||||
let a = abs(s)
|
||||
if a > peak { peak = a }
|
||||
}
|
||||
let rms = sqrtf(sumSq / Float(n))
|
||||
|
||||
// Exponential moving average for visual smoothness.
|
||||
let alpha: Float = 0.35
|
||||
let smoothedRms = alpha * rms + (1 - alpha) * last.rms
|
||||
let smoothedPeak = max(alpha * peak, (1 - alpha) * last.peak)
|
||||
last = (smoothedRms, smoothedPeak)
|
||||
return (smoothedRms, smoothedPeak)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// WaveformView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Symmetric, real-time driven waveform. 18 bars centred around a vertical
|
||||
// axis. The dominant bar is driven by the current RMS; surrounding bars
|
||||
// decay on a small position-based curve so the visual feels like a
|
||||
// horizontal speaker cone, not random noise.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct WaveformView: View {
|
||||
let level: Double // 0...1, smoothed RMS
|
||||
let barCount: Int
|
||||
let color: Color
|
||||
let active: Bool // when false, bars collapse to a thin resting line
|
||||
|
||||
init(
|
||||
level: Double,
|
||||
barCount: Int = 18,
|
||||
color: Color = Palette.recordRed,
|
||||
active: Bool = true
|
||||
) {
|
||||
self.level = max(0, min(1, level))
|
||||
self.barCount = barCount
|
||||
self.color = color
|
||||
self.active = active
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
|
||||
HStack(alignment: .center, spacing: 3) {
|
||||
ForEach(0..<barCount, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: 2.4, height: height(for: i, time: context.date.timeIntervalSinceReferenceDate))
|
||||
.opacity(active ? 1.0 : 0.45)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func height(for index: Int, time: TimeInterval) -> CGFloat {
|
||||
guard active else { return 4 }
|
||||
let centre = Double(barCount - 1) / 2.0
|
||||
let distance = abs(Double(index) - centre) / max(centre, 1)
|
||||
// Per-bar small wobble so the line is alive but tied to level.
|
||||
let phase = sin(time * 4.0 + Double(index) * 0.45)
|
||||
let wobble = 0.18 * phase
|
||||
let magnitude = max(0, min(1, Double(level) + wobble))
|
||||
let profile = 1.0 - pow(distance, 1.4) * 0.85
|
||||
return CGFloat(max(6, 32 * magnitude * profile))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// AppGroup.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// App Group identifier shared between main app and keyboard extension.
|
||||
// UserDefaults(suiteName:) and file containers use this.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppGroup {
|
||||
/// App Group container identifier (must match entitlements in both targets)
|
||||
public static let identifier = "group.com.osgkeyboard.shared"
|
||||
|
||||
/// Shared UserDefaults instance for cross-process config.
|
||||
///
|
||||
/// Falls back to `.standard` if the App Group isn't available (e.g.
|
||||
/// the user hasn't created the App Group in the Apple Developer
|
||||
/// portal, or Xcode hasn't downloaded a matching provisioning profile).
|
||||
/// In that mode, the keyboard extension will *not* see config written
|
||||
/// by the main app — but the main app itself stays usable so the user
|
||||
/// can fix the signing situation without the app crashing.
|
||||
public static var defaults: UserDefaults {
|
||||
if let d = UserDefaults(suiteName: identifier) {
|
||||
return d
|
||||
}
|
||||
#if DEBUG
|
||||
print("⚠️ App Group \(identifier) unavailable — falling back to .standard. " +
|
||||
"Add the App Group in your Apple Developer account and Xcode " +
|
||||
"Signing & Capabilities, then re-run.")
|
||||
#endif
|
||||
return .standard
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Theme.swift
|
||||
// OSGKeyboard · Design System
|
||||
//
|
||||
// Single source of truth for colour, spacing, corner radius, typography.
|
||||
// Inspired by Dieter Rams ("less but better") and Apple Human Interface:
|
||||
// every surface has a single purpose, every token earns its place, and
|
||||
// the visual hierarchy is carried by *whitespace + one accent*, never by
|
||||
// extra colour.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
public enum Palette {
|
||||
// Backgrounds
|
||||
public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B
|
||||
public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B
|
||||
public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A
|
||||
public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215
|
||||
|
||||
// Accents
|
||||
public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA
|
||||
public static let accentMuted = accent.opacity(0.18)
|
||||
public static let accentGlow = accent.opacity(0.42)
|
||||
|
||||
// Semantic
|
||||
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
|
||||
public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69
|
||||
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
|
||||
|
||||
// Text
|
||||
public static let textPrimary = Color.white
|
||||
public static let textSecondary = Color(white: 0.7)
|
||||
public static let textTertiary = Color(white: 0.50)
|
||||
public static let textOnAccent = Color.black
|
||||
|
||||
// Lines
|
||||
public static let divider = Color.white.opacity(0.06)
|
||||
public static let dividerStrong = Color.white.opacity(0.10)
|
||||
|
||||
// Recording state
|
||||
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
||||
}
|
||||
|
||||
// MARK: - Spacing scale (4 pt grid)
|
||||
|
||||
public enum Spacing {
|
||||
public static let xxs: CGFloat = 4
|
||||
public static let xs: CGFloat = 8
|
||||
public static let sm: CGFloat = 12
|
||||
public static let md: CGFloat = 16
|
||||
public static let lg: CGFloat = 20
|
||||
public static let xl: CGFloat = 24
|
||||
public static let xxl: CGFloat = 32
|
||||
public static let xxxl: CGFloat = 40
|
||||
public static let hero: CGFloat = 48
|
||||
}
|
||||
|
||||
// MARK: - Corner radius scale
|
||||
|
||||
public enum Radius {
|
||||
public static let small: CGFloat = 8
|
||||
public static let medium: CGFloat = 12
|
||||
public static let large: CGFloat = 16
|
||||
public static let xl: CGFloat = 20
|
||||
public static let xxl: CGFloat = 24
|
||||
public static let pill: CGFloat = 999
|
||||
}
|
||||
|
||||
// MARK: - Typography
|
||||
|
||||
public enum TypeStyle {
|
||||
public static let caption2 = Font.system(size: 11, weight: .medium)
|
||||
public static let caption = Font.system(size: 12, weight: .medium)
|
||||
public static let footnote = Font.system(size: 13, weight: .regular)
|
||||
public static let body = Font.system(size: 15, weight: .regular)
|
||||
public static let bodyEmph = Font.system(size: 15, weight: .medium)
|
||||
public static let headline = Font.system(size: 17, weight: .semibold)
|
||||
public static let title3 = Font.system(size: 20, weight: .semibold)
|
||||
public static let title2 = Font.system(size: 22, weight: .bold)
|
||||
public static let title = Font.system(size: 28, weight: .bold)
|
||||
public static let largeTitle = Font.system(size: 34, weight: .bold)
|
||||
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
|
||||
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
|
||||
}
|
||||
|
||||
// MARK: - Animation
|
||||
|
||||
public enum Motion {
|
||||
public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85)
|
||||
public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80)
|
||||
public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78)
|
||||
public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true)
|
||||
public static let instant = Animation.linear(duration: 0.12)
|
||||
}
|
||||
|
||||
// MARK: - Reusable view modifiers
|
||||
|
||||
public extension View {
|
||||
/// Standard card surface used in the main app.
|
||||
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
|
||||
self
|
||||
.padding(padding)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
/// Muted pill (used for tags, locale indicators, etc.).
|
||||
func pillChip(foreground: Color = Palette.textSecondary) -> some View {
|
||||
self
|
||||
.padding(.horizontal, Spacing.xs)
|
||||
.padding(.vertical, 4)
|
||||
.background(Palette.surfaceElevated, in: Capsule())
|
||||
.foregroundStyle(foreground)
|
||||
}
|
||||
|
||||
/// Primary CTA button.
|
||||
func primaryButton() -> some View {
|
||||
self
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.foregroundStyle(Palette.textOnAccent)
|
||||
}
|
||||
|
||||
/// Secondary CTA button.
|
||||
func secondaryButton() -> some View {
|
||||
self
|
||||
.font(TypeStyle.headline)
|
||||
.frame(maxWidth: .infinity, minHeight: 50)
|
||||
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
||||
.stroke(Palette.dividerStrong, lineWidth: 0.5)
|
||||
)
|
||||
.foregroundStyle(Palette.textPrimary)
|
||||
}
|
||||
|
||||
/// Legacy alias for older call sites.
|
||||
func cardStyle() -> some View { cardSurface() }
|
||||
}
|
||||
|
||||
// MARK: - Backwards compat (legacy callers in old code)
|
||||
|
||||
public enum Theme {
|
||||
public static let background = Palette.background
|
||||
public static let card = Palette.surface
|
||||
public static let accent = Palette.accent
|
||||
public static let danger = Palette.danger
|
||||
public static let textPrimary = Palette.textPrimary
|
||||
public static let textSecondary = Palette.textSecondary
|
||||
public static let divider = Palette.divider
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// AudioBufferSnapshot.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Sendable wrapper around a Float32 audio buffer's raw samples.
|
||||
// The snapshot is the only thing that crosses actor / concurrency
|
||||
// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its
|
||||
// own side and consumes it locally (never yielding it back out).
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
public struct AudioBufferSnapshot: Sendable {
|
||||
public let samples: [Float]
|
||||
public let sampleRate: Double
|
||||
|
||||
public init(samples: [Float], sampleRate: Double) {
|
||||
self.samples = samples
|
||||
self.sampleRate = sampleRate
|
||||
}
|
||||
|
||||
/// Construct from an `AVAudioPCMBuffer` by copying out the channel data.
|
||||
public init(buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
self.samples = []
|
||||
self.sampleRate = buffer.format.sampleRate
|
||||
return
|
||||
}
|
||||
let n = Int(buffer.frameLength)
|
||||
var copy = [Float](repeating: 0, count: n)
|
||||
memcpy(©, channelData[0], n * MemoryLayout<Float>.size)
|
||||
self.samples = copy
|
||||
self.sampleRate = buffer.format.sampleRate
|
||||
}
|
||||
}
|
||||
+30
-6
@@ -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 兼容端点"
|
||||
)
|
||||
]
|
||||
|
||||
+34
-18
@@ -10,60 +10,69 @@ import Combine
|
||||
public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
public static let shared = ProviderConfig()
|
||||
|
||||
// Storage keys
|
||||
private enum Key {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
static let apiKey = "config.apiKey"
|
||||
static let model = "config.model"
|
||||
static let systemPrompt = "config.systemPrompt"
|
||||
static let modeId = "config.modeId"
|
||||
static let localeId = "config.localeId"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
didSet { defaults.set(providerId, forKey: Key.providerId) }
|
||||
}
|
||||
|
||||
@Published public var baseURL: String {
|
||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||
}
|
||||
|
||||
@Published public var apiKey: String {
|
||||
didSet { defaults.set(apiKey, forKey: Key.apiKey) }
|
||||
}
|
||||
|
||||
@Published public var model: String {
|
||||
didSet { defaults.set(model, forKey: Key.model) }
|
||||
}
|
||||
|
||||
@Published public var systemPrompt: String {
|
||||
didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) }
|
||||
}
|
||||
@Published public var modeId: String {
|
||||
didSet { defaults.set(modeId, forKey: Key.modeId) }
|
||||
}
|
||||
@Published public var localeId: String {
|
||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
||||
}
|
||||
|
||||
public var isConfigured: Bool {
|
||||
!baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||
}
|
||||
|
||||
public let defaultSystemPrompt = """
|
||||
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
|
||||
1) Preserve the user's original intent and meaning; do not invent facts.
|
||||
2) Add proper punctuation, capitalization, and paragraph breaks.
|
||||
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
|
||||
4) Keep the output concise — do not exceed 1.5x the spoken length.
|
||||
5) Output in the same language as the input.
|
||||
"""
|
||||
/// The system prompt the user *sees* in the editor — fall back to the
|
||||
/// provider-aware default from `AppGroupStore` when nothing is set.
|
||||
public var defaultSystemPrompt: String {
|
||||
AppGroupStore.defaultSystemPrompt(for: providerId)
|
||||
}
|
||||
|
||||
private let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
self.providerId = defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
self.baseURL = defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: "openai").defaultBaseURL
|
||||
let pid = defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
let preset = LLMProvider.provider(id: pid)
|
||||
self.providerId = pid
|
||||
self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL
|
||||
self.apiKey = defaults.string(forKey: Key.apiKey) ?? ""
|
||||
self.model = defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: "openai").defaultModel
|
||||
self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? defaultSystemPrompt
|
||||
self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.systemPrompt = defaults.string(forKey: Key.systemPrompt)
|
||||
?? AppGroupStore.defaultSystemPrompt(for: pid)
|
||||
self.modeId = defaults.string(forKey: Key.modeId) ?? "polish"
|
||||
self.localeId = defaults.string(forKey: Key.localeId) ?? "auto"
|
||||
}
|
||||
|
||||
public func apply(preset: LLMProvider) {
|
||||
// Capture the *previous* provider id BEFORE we mutate, so the
|
||||
// system-prompt reset check below can compare against the actual
|
||||
// prior default.
|
||||
let oldId = providerId
|
||||
providerId = preset.id
|
||||
if !preset.defaultBaseURL.isEmpty {
|
||||
baseURL = preset.defaultBaseURL
|
||||
@@ -71,6 +80,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if !preset.defaultModel.isEmpty {
|
||||
model = preset.defaultModel
|
||||
}
|
||||
// When switching providers, reset the system prompt to the new
|
||||
// provider's default — otherwise the user is left editing a
|
||||
// Chinese prompt on a US-English model.
|
||||
if systemPrompt.isEmpty
|
||||
|| systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) {
|
||||
systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id)
|
||||
}
|
||||
}
|
||||
|
||||
public func reset() {
|
||||
@@ -79,6 +95,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
baseURL = preset.defaultBaseURL
|
||||
apiKey = ""
|
||||
model = preset.defaultModel
|
||||
systemPrompt = defaultSystemPrompt
|
||||
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// AppGroupStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||
// instantiating an ObservableObject.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupStore: @unchecked Sendable {
|
||||
public let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private enum Key {
|
||||
static let providerId = "config.providerId"
|
||||
static let baseURL = "config.baseURL"
|
||||
static let apiKey = "config.apiKey"
|
||||
static let model = "config.model"
|
||||
static let systemPrompt = "config.systemPrompt"
|
||||
static let modeId = "config.modeId"
|
||||
static let localeId = "config.localeId"
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
public var providerId: String {
|
||||
defaults.string(forKey: Key.providerId) ?? "openai"
|
||||
}
|
||||
|
||||
public var baseURL: String {
|
||||
defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL
|
||||
}
|
||||
|
||||
public var apiKey: String {
|
||||
defaults.string(forKey: Key.apiKey) ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel
|
||||
}
|
||||
|
||||
public var systemPrompt: String {
|
||||
defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId)
|
||||
}
|
||||
|
||||
public var modeId: String {
|
||||
defaults.string(forKey: Key.modeId) ?? "polish"
|
||||
}
|
||||
|
||||
public var localeId: String {
|
||||
defaults.string(forKey: Key.localeId) ?? "auto"
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.modeId)
|
||||
}
|
||||
|
||||
public func setLocaleId(_ id: String) {
|
||||
defaults.set(id, forKey: Key.localeId)
|
||||
}
|
||||
|
||||
// MARK: - Client
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Defaults
|
||||
|
||||
/// Per-provider default system prompt. We bias the prompt by the
|
||||
/// provider's *primary* language so Chinese LLMs naturally return
|
||||
/// Chinese for Chinese input, and English LLMs stay terse.
|
||||
public static func defaultSystemPrompt(for providerId: String) -> String {
|
||||
switch providerId {
|
||||
case "zhipu", "moonshot", "qwen", "deepseek":
|
||||
return """
|
||||
你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字:
|
||||
1) 保留原意,不编造事实;保持输入语言。
|
||||
2) 添加恰当的标点、大小写、段落。
|
||||
3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。
|
||||
4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。
|
||||
5) 只输出润色后的正文,不要解释、不要加引号。
|
||||
"""
|
||||
default:
|
||||
return """
|
||||
You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text:
|
||||
1) Preserve the user's original intent and meaning; do not invent facts.
|
||||
2) Add proper punctuation, capitalization, and paragraph breaks.
|
||||
3) When the user enumerates items ("first ... second ... third"), output a markdown list.
|
||||
4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like).
|
||||
5) Output in the same language as the input. No quotes, no explanation, no preamble.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-12
@@ -9,20 +9,21 @@ import Foundation
|
||||
public enum LLMError: Error, LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
case noAPIKey
|
||||
case http(status: Int, body: String)
|
||||
case http(status: Int)
|
||||
case decoding(String)
|
||||
case transport(underlying: String)
|
||||
case transport(String)
|
||||
case cancelled
|
||||
case rateLimited
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid API URL."
|
||||
case .noAPIKey: return "API key is missing."
|
||||
case .http(let s, let body):
|
||||
return "API returned HTTP \(s): \(body.prefix(200))"
|
||||
case .decoding(let s): return "Failed to decode response: \(s)"
|
||||
case .transport(let s): return "Network error: \(s)"
|
||||
case .cancelled: return "Request was cancelled."
|
||||
case .invalidURL: return "API 地址无效。请在设置中检查 Base URL。"
|
||||
case .noAPIKey: return "未填写 API Key。"
|
||||
case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。"
|
||||
case .decoding: return "解析 API 响应失败。"
|
||||
case .transport: return "网络错误,请检查连接后重试。"
|
||||
case .rateLimited: return "API 调用过于频繁,请稍候再试。"
|
||||
case .cancelled: return "请求已取消。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,11 +82,16 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
do {
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw LLMError.transport(underlying: "non-HTTP response")
|
||||
throw LLMError.transport("non-HTTP response")
|
||||
}
|
||||
if !(200..<300).contains(http.statusCode) {
|
||||
#if DEBUG
|
||||
// Log full body for debugging — never expose to UI.
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw LLMError.http(status: http.statusCode, body: body)
|
||||
print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))")
|
||||
#endif
|
||||
if http.statusCode == 429 { throw LLMError.rateLimited }
|
||||
throw LLMError.http(status: http.statusCode)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(LLMResponse.self, from: data)
|
||||
@@ -100,7 +106,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
} catch let urlError as URLError where urlError.code == .cancelled {
|
||||
throw LLMError.cancelled
|
||||
} catch {
|
||||
throw LLMError.transport(underlying: String(describing: error))
|
||||
throw LLMError.transport(String(describing: error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,9 @@ final class LLMClientTests: XCTestCase {
|
||||
// MARK: - ProviderConfig persistence
|
||||
|
||||
func testProviderConfigPersistsAcrossInstances() {
|
||||
let defaults = UserDefaults(suiteName: "group.com.osgkeyboard.ios.tests")!
|
||||
defaults.removePersistentDomain(forName: "group.com.osgkeyboard.ios.tests")
|
||||
let suiteName = "group.com.osgkeyboard.shared.tests"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
|
||||
let config1 = ProviderConfig(defaults: defaults)
|
||||
config1.baseURL = "https://example.com/v1"
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
// APISettingsCard.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Editable fields for the four OpenAI-compatible config values:
|
||||
// Base URL, API Key, Model, System Prompt.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct APISettingsCard: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
@State private var showKey: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
field("Base URL", text: $config.baseURL, isSecure: false,
|
||||
keyboard: .URL, autocap: false)
|
||||
keyField
|
||||
field("Model", text: $config.model, isSecure: false,
|
||||
keyboard: .default, autocap: false)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("System Prompt")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
TextEditor(text: $config.systemPrompt)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.frame(minHeight: 110)
|
||||
.padding(8)
|
||||
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
|
||||
Button("Reset to default") {
|
||||
config.systemPrompt = config.defaultSystemPrompt
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(Theme.accent)
|
||||
}
|
||||
|
||||
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
|
||||
Link(destination: url) {
|
||||
Label("Get an API key", systemImage: "key.fill")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
private var keyField: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("API Key")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Spacer()
|
||||
Button(action: { showKey.toggle() }) {
|
||||
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
Group {
|
||||
if showKey {
|
||||
TextField("sk-...", text: $config.apiKey)
|
||||
} else {
|
||||
SecureField("sk-...", text: $config.apiKey)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.padding(10)
|
||||
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func field(
|
||||
_ title: String,
|
||||
text: Binding<String>,
|
||||
isSecure: Bool,
|
||||
keyboard: UIKeyboardType,
|
||||
autocap: Bool
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Group {
|
||||
if isSecure {
|
||||
SecureField("", text: text)
|
||||
} else {
|
||||
TextField("", text: text)
|
||||
.keyboardType(keyboard)
|
||||
.autocorrectionDisabled(true)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(autocap ? .sentences : .never)
|
||||
.padding(10)
|
||||
.background(Color.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// OnboardingView.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Three-page horizontal onboarding:
|
||||
// 1) Welcome
|
||||
// 2) Enable keyboard + Allow Full Access
|
||||
// 3) Pick provider + enter API key
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct OnboardingView: View {
|
||||
@ObservedObject var config = ProviderConfig.shared
|
||||
@State private var page: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Theme.background.ignoresSafeArea()
|
||||
VStack(spacing: 0) {
|
||||
TabView(selection: $page) {
|
||||
WelcomePage().tag(0)
|
||||
EnableKeyboardPage().tag(1)
|
||||
APISetupPage(config: config) {
|
||||
// completion — root switches to HomeView
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
|
||||
pageDots
|
||||
.padding(.bottom, 12)
|
||||
|
||||
bottomBar
|
||||
}
|
||||
}
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
|
||||
private var pageDots: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == page ? Theme.accent : Color.white.opacity(0.2))
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
if page > 0 {
|
||||
Button("Back") { withAnimation { page -= 1 } }
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
}
|
||||
Spacer()
|
||||
if page < 2 {
|
||||
Button {
|
||||
withAnimation { page += 1 }
|
||||
} label: {
|
||||
Text("Next")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 20).padding(.vertical, 10)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
// finalise — ProviderConfig is already bound to App Group
|
||||
} label: {
|
||||
Text("Done")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 24).padding(.vertical, 10)
|
||||
.background(config.isConfigured ? Theme.accent : Color.gray.opacity(0.4),
|
||||
in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.disabled(!config.isConfigured)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 18)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WelcomePage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
Image(systemName: "mic.circle.fill")
|
||||
.font(.system(size: 80))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("OSGKeyboard")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
Text("Hold the mic key, speak, and let AI polish your words into clean text — in every app.")
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.horizontal, 28)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnableKeyboardPage: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 18) {
|
||||
Spacer()
|
||||
Image(systemName: "keyboard.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(Theme.accent)
|
||||
Text("Enable OSGKeyboard")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
step(num: 1, text: "Open Settings → General → Keyboard → Keyboards")
|
||||
step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard")
|
||||
step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access” (needed for mic + LLM)")
|
||||
}
|
||||
.padding(.horizontal, 22)
|
||||
|
||||
Button {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
Label("Open iOS Settings", systemImage: "arrow.up.right.square")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 10)
|
||||
.background(Theme.accent, in: Capsule())
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func step(num: Int, text: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Text("\(num)")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
.frame(width: 20, height: 20)
|
||||
.background(Theme.accent, in: Circle())
|
||||
.foregroundStyle(.black)
|
||||
Text(text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct APISetupPage: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
let onDone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 14) {
|
||||
Text("Configure your AI provider")
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(Theme.textPrimary)
|
||||
.padding(.top, 18)
|
||||
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
|
||||
.font(.caption)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
ProviderPickerSection(config: config)
|
||||
APISettingsCard(config: config)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// ProviderPickerSection.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// A picker that swaps in the right BaseURL/Model defaults for a chosen
|
||||
// provider, while letting the user override each field.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct ProviderPickerSection: View {
|
||||
@ObservedObject var config: ProviderConfig
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Provider")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(Theme.textSecondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Picker("Provider", selection: $config.providerId) {
|
||||
ForEach(LLMProvider.presets) { p in
|
||||
Text(p.name).tag(p.id)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.tint(Theme.accent)
|
||||
.onChange(of: config.providerId) { _, newId in
|
||||
let preset = LLMProvider.provider(id: newId)
|
||||
config.apply(preset: preset)
|
||||
}
|
||||
}
|
||||
.cardStyle()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// Theme.swift
|
||||
// OSGKeyboard · Main App
|
||||
//
|
||||
// Centralised colours, fonts, and reusable modifiers so the app looks
|
||||
// coherent. Inspired by Typeless: dark base, soft frosted surfaces,
|
||||
// generous whitespace, large rounded buttons.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum Theme {
|
||||
static let background = Color(red: 0.07, green: 0.07, blue: 0.08)
|
||||
static let card = Color(white: 0.12)
|
||||
static let accent = Color(red: 0.36, green: 0.78, blue: 0.98) // soft cyan
|
||||
static let danger = Color(red: 0.97, green: 0.42, blue: 0.45)
|
||||
static let textPrimary = Color.white
|
||||
static let textSecondary = Color(white: 0.7)
|
||||
static let divider = Color(white: 0.18)
|
||||
}
|
||||
|
||||
extension View {
|
||||
func cardStyle() -> some View {
|
||||
self
|
||||
.padding(16)
|
||||
.background(Theme.card, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Theme.divider, lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
// KeyboardViewController.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// The principal class for the Custom Keyboard Extension. Manages the
|
||||
// recording pipeline: AudioCapture -> ASR -> LLM polish -> insertText.
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
import AVFoundation
|
||||
|
||||
@objc(KeyboardViewController)
|
||||
public final class KeyboardViewController: UIInputViewController {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
@MainActor
|
||||
private enum Phase: Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case processing
|
||||
case error(String)
|
||||
}
|
||||
|
||||
// MARK: - Services
|
||||
|
||||
private let audio = AudioCaptureService()
|
||||
private lazy var asr: ASRService = ASRServiceFactory.create()
|
||||
private let polisher = PolishingService()
|
||||
|
||||
// MARK: - Pipeline state
|
||||
|
||||
private var recordStream: AsyncStream<AudioBufferSnapshot>?
|
||||
private var recordContinuation: Task<Void, Never>?
|
||||
private var asrTask: Task<Void, Never>?
|
||||
private var lastTranscript: String = ""
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private var hosting: UIHostingController<KeyboardRootView>!
|
||||
private var levelTimer: Timer?
|
||||
private var currentLevel: Double = 0
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
installSwiftUI()
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
requestMicPermissionIfNeeded()
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
public override func didReceiveMemoryWarning() {
|
||||
super.didReceiveMemoryWarning()
|
||||
cancelPipeline()
|
||||
}
|
||||
|
||||
// MARK: - SwiftUI bridge
|
||||
|
||||
private func installSwiftUI() {
|
||||
let root = KeyboardRootView(
|
||||
phase: .idle,
|
||||
level: 0,
|
||||
onPressBegan: { [weak self] in self?.pressBegan() },
|
||||
onPressEnded: { [weak self] in self?.pressEnded() },
|
||||
onTap: { [weak self] in self?.handleTap() },
|
||||
onOpenSettings:{ [weak self] in self?.openHostApp() }
|
||||
)
|
||||
let host = UIHostingController(rootView: root)
|
||||
host.view.backgroundColor = .clear
|
||||
addChild(host)
|
||||
view.addSubview(host.view)
|
||||
host.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
host.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
host.didMove(toParent: self)
|
||||
self.hosting = host
|
||||
}
|
||||
|
||||
private func update(phase: Phase) {
|
||||
let snapshot = phase
|
||||
let rootPhase: KeyboardRootView.Phase = {
|
||||
switch snapshot {
|
||||
case .idle: return .idle
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
case .error(let m): return .error(m)
|
||||
}
|
||||
}()
|
||||
hosting.rootView = KeyboardRootView(
|
||||
phase: rootPhase,
|
||||
level: currentLevel,
|
||||
onPressBegan: { [weak self] in self?.pressBegan() },
|
||||
onPressEnded: { [weak self] in self?.pressEnded() },
|
||||
onTap: { [weak self] in self?.handleTap() },
|
||||
onOpenSettings:{ [weak self] in self?.openHostApp() }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Press handlers
|
||||
|
||||
private func pressBegan() {
|
||||
guard case .idle = currentPhase() else { return }
|
||||
requestMicPermissionIfNeeded { [weak self] granted in
|
||||
guard let self else { return }
|
||||
guard granted else {
|
||||
Task { @MainActor in self.update(phase: .error("Microphone denied. Enable in Settings.")) }
|
||||
return
|
||||
}
|
||||
self.startPipeline()
|
||||
}
|
||||
}
|
||||
|
||||
private func pressEnded() {
|
||||
guard case .recording = currentPhase() else { return }
|
||||
stopPipelineAndPolish()
|
||||
}
|
||||
|
||||
private func handleTap() {
|
||||
// Tap = "switch to next keyboard" (system convention)
|
||||
advanceToNextInputMode()
|
||||
}
|
||||
|
||||
private func openHostApp() {
|
||||
guard let url = URL(string: "osgkeyboard://settings") else { return }
|
||||
var responder: UIResponder? = self
|
||||
while let r = responder {
|
||||
if let app = r as? UIApplication {
|
||||
app.open(url)
|
||||
return
|
||||
}
|
||||
responder = r.next
|
||||
}
|
||||
// Fallback: open settings page
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
var r: UIResponder? = self
|
||||
while let r2 = r {
|
||||
if let app = r2 as? UIApplication {
|
||||
app.open(url); return
|
||||
}
|
||||
r = r2.next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pipeline
|
||||
|
||||
private func startPipeline() {
|
||||
update(phase: .recording)
|
||||
let stream = audio.start()
|
||||
recordStream = stream
|
||||
|
||||
// 1) ASR pipeline
|
||||
let events = asr.transcribe(stream: stream)
|
||||
asrTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
for await event in events {
|
||||
switch event {
|
||||
case .partial(let s):
|
||||
// optionally show partial in status bar (keep simple — silent)
|
||||
_ = s
|
||||
case .final(let s):
|
||||
await MainActor.run { self.lastTranscript = s }
|
||||
case .error(let msg):
|
||||
await MainActor.run { self.update(phase: .error("ASR: \(msg)")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Mock level meter (real impl would tap the buffer)
|
||||
startLevelMeter()
|
||||
}
|
||||
|
||||
private func stopPipelineAndPolish() {
|
||||
stopLevelMeter()
|
||||
Task { await audio.stop() }
|
||||
asrTask?.cancel()
|
||||
|
||||
let snapshot = lastTranscript
|
||||
guard !snapshot.isEmpty else {
|
||||
update(phase: .idle)
|
||||
return
|
||||
}
|
||||
|
||||
update(phase: .processing)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
let polished = try await self.polisher.polish(snapshot)
|
||||
await MainActor.run {
|
||||
self.textDocumentProxy.insertText(polished)
|
||||
self.lastTranscript = ""
|
||||
self.update(phase: .idle)
|
||||
}
|
||||
} catch {
|
||||
// fallback: insert raw transcript
|
||||
await MainActor.run {
|
||||
self.textDocumentProxy.insertText(snapshot)
|
||||
self.lastTranscript = ""
|
||||
let msg = (error as? LocalizedError)?.errorDescription ?? "Polishing failed, inserted raw."
|
||||
self.update(phase: .error(msg))
|
||||
// auto-clear error back to idle after 2s
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
if case .error = self.currentPhase() {
|
||||
self.update(phase: .idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPipeline() {
|
||||
asrTask?.cancel()
|
||||
asrTask = nil
|
||||
Task { await audio.stop() }
|
||||
stopLevelMeter()
|
||||
lastTranscript = ""
|
||||
}
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
private func requestMicPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
switch session.recordPermission {
|
||||
case .granted:
|
||||
completion?(true)
|
||||
case .denied:
|
||||
completion?(false)
|
||||
case .undetermined:
|
||||
session.requestRecordPermission { granted in
|
||||
DispatchQueue.main.async { completion?(granted) }
|
||||
}
|
||||
@unknown default:
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Level meter (mock — tap could be replaced with real RMS from AVAudioEngine)
|
||||
|
||||
private func startLevelMeter() {
|
||||
stopLevelMeter()
|
||||
currentLevel = 0
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
// Simple pseudo-level — random walk around 0.5 while "recording"
|
||||
let delta = Double.random(in: -0.18...0.18)
|
||||
self.currentLevel = max(0.15, min(0.95, self.currentLevel + delta))
|
||||
// refresh UI
|
||||
Task { @MainActor in
|
||||
self.update(phase: .recording)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopLevelMeter() {
|
||||
levelTimer?.invalidate()
|
||||
levelTimer = nil
|
||||
currentLevel = 0
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func currentPhase() -> Phase {
|
||||
// We don't track phase as a stored property to avoid @MainActor overhead on every read.
|
||||
// Instead, derive it from state of services — for simplicity we mirror via update().
|
||||
// This shim returns .idle when no record is in flight.
|
||||
return recordStream == nil ? .idle : .recording
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
// ASRService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Speech-to-text abstraction. iOS 26+ uses SpeechAnalyzer + DictationTranscriber
|
||||
// (Apple's modern, on-device streaming API). iOS 18 falls back to SFSpeechRecognizer
|
||||
// with on-device recognition.
|
||||
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import Speech
|
||||
|
||||
public protocol ASRService: AnyObject, Sendable {
|
||||
/// Start transcription. Returns an async stream of partial + final strings.
|
||||
/// The last value emitted on `finish()` is the final transcript.
|
||||
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent>
|
||||
|
||||
/// Cancel any in-flight work.
|
||||
func cancel()
|
||||
}
|
||||
|
||||
public enum ASREvent: Sendable {
|
||||
case partial(String) // incremental, may be discarded
|
||||
case final(String) // the authoritative transcript
|
||||
case error(String)
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
public enum ASRServiceFactory {
|
||||
public static func create() -> ASRService {
|
||||
if #available(iOS 26, *) {
|
||||
return SpeechAnalyzerASR()
|
||||
} else {
|
||||
return SFSpeechRecognizerASR()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber
|
||||
|
||||
@available(iOS 26, *)
|
||||
final class SpeechAnalyzerASR: ASRService, @unchecked Sendable {
|
||||
private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { continuation in
|
||||
let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current)
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
continuation.yield(.error("Speech recognizer unavailable"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
recognizer.defaultTaskHint = .dictation
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
if recognizer.supportsOnDeviceRecognition {
|
||||
request.requiresOnDeviceRecognition = true
|
||||
}
|
||||
|
||||
let task = recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
guard let result else { return }
|
||||
if result.isFinal {
|
||||
continuation.yield(.final(result.bestTranscription.formattedString))
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(.partial(result.bestTranscription.formattedString))
|
||||
}
|
||||
}
|
||||
self.task = Task { [request] in
|
||||
for await snap in stream {
|
||||
if Task.isCancelled { break }
|
||||
let pcmStream = AsyncStream<AudioBufferSnapshot> { c in
|
||||
c.yield(snap)
|
||||
c.finish()
|
||||
}
|
||||
for await pcm in pcmStream.toAVAudioBuffers() {
|
||||
request.append(pcm)
|
||||
}
|
||||
}
|
||||
request.endAudio()
|
||||
// give recognizer a moment to finalize
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
// onTermination intentionally omitted: SFSpeechRecognitionTask is
|
||||
// not Sendable. Cancellation flows through this class's cancel().
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iOS 18 fallback: SFSpeechRecognizer
|
||||
|
||||
final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable {
|
||||
private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current)
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
private var feedTask: Task<Void, Never>?
|
||||
|
||||
func transcribe(stream: AsyncStream<AudioBufferSnapshot>) -> AsyncStream<ASREvent> {
|
||||
AsyncStream { continuation in
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
continuation.yield(.error("Speech recognizer unavailable"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
recognizer.defaultTaskHint = .dictation
|
||||
|
||||
let request = SFSpeechAudioBufferRecognitionRequest()
|
||||
request.shouldReportPartialResults = true
|
||||
if recognizer.supportsOnDeviceRecognition {
|
||||
request.requiresOnDeviceRecognition = true
|
||||
}
|
||||
|
||||
let recognizerTask = recognizer.recognitionTask(with: request) { result, error in
|
||||
if let error {
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
guard let result else { return }
|
||||
if result.isFinal {
|
||||
continuation.yield(.final(result.bestTranscription.formattedString))
|
||||
continuation.finish()
|
||||
} else {
|
||||
continuation.yield(.partial(result.bestTranscription.formattedString))
|
||||
}
|
||||
}
|
||||
self.task = recognizerTask
|
||||
|
||||
self.feedTask = Task { [request] in
|
||||
for await snap in stream {
|
||||
if Task.isCancelled { break }
|
||||
let pcmStream = AsyncStream<AudioBufferSnapshot> { c in
|
||||
c.yield(snap)
|
||||
c.finish()
|
||||
}
|
||||
for await pcm in pcmStream.toAVAudioBuffers() {
|
||||
request.append(pcm)
|
||||
}
|
||||
}
|
||||
request.endAudio()
|
||||
}
|
||||
// onTermination intentionally omitted: SFSpeechRecognitionTask is
|
||||
// not Sendable. Cancellation is handled by calling cancel() on
|
||||
// this class, and the feedTask loop respects Task.isCancelled.
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
task?.cancel()
|
||||
feedTask?.cancel()
|
||||
task = nil
|
||||
feedTask = nil
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
// AudioCaptureService.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine.
|
||||
// Exposes an AsyncStream<AVAudioPCMBuffer> that ASR services can consume.
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import AVFoundation
|
||||
|
||||
public actor AudioCaptureService {
|
||||
|
||||
public enum CaptureError: Error {
|
||||
case sessionConfigFailed(Error)
|
||||
case engineStartFailed(Error)
|
||||
case noInputNode
|
||||
}
|
||||
|
||||
private let engine = AVAudioEngine()
|
||||
private let converter = AVAudioConverter(
|
||||
from: AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 48000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!,
|
||||
to: AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
)
|
||||
|
||||
private var continuation: AsyncStream<AudioBufferSnapshot>.Continuation?
|
||||
private var isRunning = false
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() -> AsyncStream<AudioBufferSnapshot> {
|
||||
AsyncStream { continuation in
|
||||
self.continuation = continuation
|
||||
do {
|
||||
try configureSession()
|
||||
try attachTap()
|
||||
try engine.start()
|
||||
isRunning = true
|
||||
} catch {
|
||||
continuation.finish()
|
||||
self.continuation = nil
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
guard isRunning else { return }
|
||||
engine.inputNode.removeTap(onBus: 0)
|
||||
engine.stop()
|
||||
continuation?.finish()
|
||||
continuation = nil
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
private func configureSession() throws {
|
||||
#if canImport(UIKit)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .measurement,
|
||||
options: [.duckOthers, .defaultToSpeaker, .allowBluetooth]
|
||||
)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
} catch {
|
||||
throw CaptureError.sessionConfigFailed(error)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private func attachTap() throws {
|
||||
let input = engine.inputNode
|
||||
let hardwareFormat = input.outputFormat(forBus: 0)
|
||||
guard hardwareFormat.sampleRate > 0 else {
|
||||
throw CaptureError.noInputNode
|
||||
}
|
||||
let bufferSize: AVAudioFrameCount = 4096
|
||||
input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in
|
||||
guard let self else { return }
|
||||
// Downsample to 16 kHz mono Float32
|
||||
let target = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
let outFrames = AVAudioFrameCount(
|
||||
Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate
|
||||
)
|
||||
guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
|
||||
return
|
||||
}
|
||||
var error: NSError?
|
||||
let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in
|
||||
outStatus.pointee = .haveData
|
||||
return buffer
|
||||
}
|
||||
if status == .haveData, error == nil {
|
||||
// AVAudioPCMBuffer is not Sendable; copy the raw float data
|
||||
// into a Sendable wrapper so we can hand it to the actor.
|
||||
let copy = AudioBufferSnapshot(buffer: outBuffer)
|
||||
Task { await self.deliver(copy) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deliver(_ snapshot: AudioBufferSnapshot) {
|
||||
guard isRunning else { return }
|
||||
continuation?.yield(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sendable wrapper around a Float32 audio buffer's raw samples.
|
||||
/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable
|
||||
/// type crossing the actor boundary.
|
||||
public struct AudioBufferSnapshot: Sendable {
|
||||
public let samples: [Float]
|
||||
public let sampleRate: Double
|
||||
|
||||
public init(buffer: AVAudioPCMBuffer) {
|
||||
guard let channelData = buffer.floatChannelData else {
|
||||
self.samples = []
|
||||
self.sampleRate = 16_000
|
||||
return
|
||||
}
|
||||
let n = Int(buffer.frameLength)
|
||||
var copy = [Float](repeating: 0, count: n)
|
||||
memcpy(©, channelData[0], n * MemoryLayout<Float>.size)
|
||||
self.samples = copy
|
||||
self.sampleRate = buffer.format.sampleRate
|
||||
}
|
||||
}
|
||||
|
||||
public extension AsyncStream where Element == AudioBufferSnapshot {
|
||||
/// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32.
|
||||
func toAVAudioBuffers() -> AsyncStream<AVAudioPCMBuffer> {
|
||||
let format = AVAudioFormat(
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
sampleRate: 16_000,
|
||||
channels: 1,
|
||||
interleaved: false
|
||||
)!
|
||||
let snapshots = self
|
||||
return AsyncStream<AVAudioPCMBuffer> { continuation in
|
||||
Task {
|
||||
for await snap in snapshots {
|
||||
guard !snap.samples.isEmpty,
|
||||
let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count))
|
||||
else { continue }
|
||||
pcm.frameLength = AVAudioFrameCount(snap.samples.count)
|
||||
if let dst = pcm.floatChannelData?[0] {
|
||||
snap.samples.withUnsafeBufferPointer { src in
|
||||
if let base = src.baseAddress {
|
||||
memcpy(dst, base, snap.samples.count * MemoryLayout<Float>.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.yield(pcm)
|
||||
}
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
// KeyboardRootView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// The single SwiftUI view that backs the keyboard. Shows status text,
|
||||
// the record button, waveform (when active), and a settings shortcut.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct KeyboardRootView: View {
|
||||
public enum Phase: Equatable {
|
||||
case idle
|
||||
case recording
|
||||
case processing
|
||||
case error(String)
|
||||
}
|
||||
|
||||
public let phase: Phase
|
||||
public let level: Double // 0...1, used while recording
|
||||
public let onPressBegan: () -> Void
|
||||
public let onPressEnded: () -> Void
|
||||
public let onTap: () -> Void
|
||||
public let onOpenSettings: () -> Void
|
||||
|
||||
public init(
|
||||
phase: Phase,
|
||||
level: Double,
|
||||
onPressBegan: @escaping () -> Void,
|
||||
onPressEnded: @escaping () -> Void,
|
||||
onTap: @escaping () -> Void,
|
||||
onOpenSettings: @escaping () -> Void
|
||||
) {
|
||||
self.phase = phase
|
||||
self.level = level
|
||||
self.onPressBegan = onPressBegan
|
||||
self.onPressEnded = onPressEnded
|
||||
self.onTap = onTap
|
||||
self.onOpenSettings = onOpenSettings
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ZStack {
|
||||
// frosted glass background
|
||||
Rectangle()
|
||||
.fill(.ultraThinMaterial)
|
||||
.ignoresSafeArea()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
statusLine
|
||||
Spacer()
|
||||
recordButton
|
||||
Spacer()
|
||||
settingsButton
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
.frame(height: 56)
|
||||
}
|
||||
|
||||
private var statusLine: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
switch phase {
|
||||
case .idle:
|
||||
Text("Hold to talk")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
case .recording:
|
||||
HStack(spacing: 6) {
|
||||
WaveformView(level: level, barCount: 7, color: .red)
|
||||
Text("Recording…")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
case .processing:
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Polishing…")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
case .error(let msg):
|
||||
Text(msg)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.orange)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var recordButton: some View {
|
||||
RecordButton(
|
||||
phase: recordState,
|
||||
onPressBegan: onPressBegan,
|
||||
onPressEnded: onPressEnded,
|
||||
onTap: onTap
|
||||
)
|
||||
}
|
||||
|
||||
private var settingsButton: some View {
|
||||
Button(action: onOpenSettings) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
.background(Color.white.opacity(0.06), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Open OSGKeyboard settings"))
|
||||
}
|
||||
|
||||
private var recordState: RecordButton.Phase {
|
||||
switch phase {
|
||||
case .idle: return .idle
|
||||
case .recording: return .recording
|
||||
case .processing: return .processing
|
||||
case .error(let s): return .error(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// WaveformView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Simple animated waveform that responds to a 0-1 audio level.
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct WaveformView: View {
|
||||
public let level: Double // 0...1
|
||||
public let barCount: Int
|
||||
public let color: Color
|
||||
|
||||
public init(level: Double, barCount: Int = 5, color: Color = .red) {
|
||||
self.level = max(0, min(1, level))
|
||||
self.barCount = barCount
|
||||
self.color = color
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(0..<barCount, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(color)
|
||||
.frame(width: 3, height: heightFor(index: i))
|
||||
.animation(.easeInOut(duration: 0.18), value: level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func heightFor(index: Int) -> CGFloat {
|
||||
// Center bars taller; outer shorter — symmetric pattern
|
||||
let center = Double(barCount - 1) / 2.0
|
||||
let distance = abs(Double(index) - center) / max(center, 1)
|
||||
let base = max(6, 28 * level)
|
||||
return CGFloat(base * (1.0 - distance * 0.4))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
// AppGroup.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// App Group identifier shared between main app and keyboard extension.
|
||||
// UserDefaults(suiteName:) and file containers use this.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppGroup {
|
||||
/// App Group container identifier (must match entitlements in both targets)
|
||||
public static let identifier = "group.com.osgkeyboard.ios"
|
||||
|
||||
/// Shared UserDefaults instance for cross-process config
|
||||
public static var defaults: UserDefaults {
|
||||
guard let d = UserDefaults(suiteName: identifier) else {
|
||||
assertionFailure("App Group \(identifier) not configured in entitlements")
|
||||
return .standard
|
||||
}
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// AppGroupStore.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Convenience wrapper around App Group UserDefaults for non-Published reads.
|
||||
// Used by the keyboard extension (no SwiftUI) to read config without
|
||||
// instantiating an ObservableObject.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct AppGroupStore: @unchecked Sendable {
|
||||
public let defaults: UserDefaults
|
||||
|
||||
public init(defaults: UserDefaults = AppGroup.defaults) {
|
||||
self.defaults = defaults
|
||||
}
|
||||
|
||||
public var providerId: String {
|
||||
defaults.string(forKey: "config.providerId") ?? "openai"
|
||||
}
|
||||
|
||||
public var baseURL: String {
|
||||
defaults.string(forKey: "config.baseURL") ?? LLMProvider.provider(id: "openai").defaultBaseURL
|
||||
}
|
||||
|
||||
public var apiKey: String {
|
||||
defaults.string(forKey: "config.apiKey") ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
defaults.string(forKey: "config.model") ?? LLMProvider.provider(id: "openai").defaultModel
|
||||
}
|
||||
|
||||
public var systemPrompt: String {
|
||||
defaults.string(forKey: "config.systemPrompt")
|
||||
?? "You are a voice-input polishing assistant. Rewrite the user's dictation as clean written text. Preserve intent. Add punctuation and structure. Do not invent facts. Output in the same language as the input."
|
||||
}
|
||||
|
||||
public func makeClient() -> LLMClient {
|
||||
OpenAICompatibleClient(
|
||||
baseURL: baseURL,
|
||||
apiKey: apiKey,
|
||||
model: model
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
|
||||
3. In any text field, tap 🌐 to switch to **OSGKeyboard**.
|
||||
4. Press and hold the mic, speak, release. ✨
|
||||
|
||||
> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy).
|
||||
> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy).
|
||||
|
||||
---
|
||||
|
||||
@@ -71,23 +71,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \
|
||||
|
||||
```
|
||||
OSGKeyboard/
|
||||
├── OpenLess/ # Main iOS app (settings, onboarding)
|
||||
├── OSGKeyboard/ # Main iOS app (settings, onboarding)
|
||||
│ ├── Views/ # SwiftUI screens
|
||||
│ ├── OSGKeyboardApp.swift # @main entry
|
||||
│ ├── PrivacyInfo.xcprivacy # Required privacy manifest
|
||||
│ └── OpenLess.entitlements # App Group declaration
|
||||
├── OpenLessKeyboard/ # Custom Keyboard Extension
|
||||
│ └── OSGKeyboard.entitlements # App Group declaration
|
||||
├── OSGKeyboardExt/ # Custom Keyboard Extension
|
||||
│ ├── KeyboardViewController.swift # Principal class
|
||||
│ ├── Services/
|
||||
│ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM
|
||||
│ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR
|
||||
│ │ └── PolishingService.swift # LLM call with timeout
|
||||
│ └── Views/ # RecordButton, Waveform, KeyboardRootView
|
||||
├── OpenLessShared/ # Framework shared by app + extension
|
||||
├── OSGKeyboardShared/ # Framework shared by app + extension
|
||||
│ ├── Models/ # ProviderConfig, LLMRequest, LLMProvider
|
||||
│ ├── Services/ # LLMClient (OpenAI-compatible)
|
||||
│ └── Constants/ # AppGroup identifier
|
||||
├── OpenLessTests/ # XCTest unit tests
|
||||
├── OSGKeyboardTests/ # XCTest unit tests
|
||||
├── project.yml # XcodeGen project definition
|
||||
└── .github/workflows/ci.yml # Lint + build CI
|
||||
```
|
||||
@@ -112,7 +112,7 @@ OSGKeyboard/
|
||||
|
||||
## Adding a new LLM provider
|
||||
|
||||
Open `OpenLessShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol.
|
||||
Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol.
|
||||
|
||||
```swift
|
||||
LLMProvider(
|
||||
|
||||
+7
-7
@@ -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(
|
||||
|
||||
+22
-12
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user