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

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

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

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

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

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

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

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

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

🤖 Generated with Claude Code
This commit is contained in:
Rocky
2026-06-18 01:22:12 +08:00
parent 07067ca6c5
commit bec36befa2
59 changed files with 3205 additions and 1550 deletions
@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.980",
"green" : "0.780",
"red" : "0.360"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "Group 24.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
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
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.osgkeyboard.ios.settings</string>
<key>CFBundleURLSchemes</key>
<array>
<string>osgkeyboard</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>BackgroundColor</string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.osgkeyboard.shared</string>
</array>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+31
View File
@@ -0,0 +1,31 @@
// OSGKeyboardApp.swift
// OSGKeyboard · Main App
//
// DEBUG VERSION 2: restore real flow but instrument every step.
import SwiftUI
import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
@StateObject private var config = ProviderConfig.shared
init() {
print("🔥 [OSGKeyboardApp] init()")
}
var body: some Scene {
WindowGroup {
Group {
if config.isConfigured {
HomeView()
.onAppear { print("🔥 [OSGKeyboardApp] → HomeView appeared") }
} else {
OnboardingView(config: config)
.onAppear { print("🔥 [OSGKeyboardApp] → OnboardingView appeared") }
}
}
.onAppear { print("🔥 [OSGKeyboardApp] body appeared") }
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedUsageTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>CA92.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>C617.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>E174.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>35F9.1</string>
</dict>
</array>
</dict>
</plist>
+124
View File
@@ -0,0 +1,124 @@
// APISettingsCard.swift
// OSGKeyboard · Main App
//
// Editable fields for the three OpenAI-compatible config values:
// Base URL, API Key, Model.
import SwiftUI
import OSGKeyboardShared
struct APISettingsCard: View {
@ObservedObject var config: ProviderConfig
@State private var showKey: Bool = false
var body: some View {
VStack(spacing: 0) {
field(
title: "Base URL",
placeholder: "https://api.openai.com/v1",
text: $config.baseURL,
keyboard: .URL,
autocap: false
)
Divider().background(Palette.divider)
keyField
Divider().background(Palette.divider)
field(
title: "Model",
placeholder: "gpt-4o-mini",
text: $config.model,
keyboard: .default,
autocap: false
)
if let url = LLMProvider.provider(id: config.providerId).apiKeyURL {
Divider().background(Palette.divider)
// Use a Button + UIApplication.open instead of SwiftUI
// `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that
// makes its tappable area eat gestures from the adjacent
// TextField, which manifests as "typing jumps to a website".
Button {
UIApplication.shared.open(url)
} label: {
HStack {
Image(systemName: "key.fill")
.foregroundStyle(Palette.accent)
Text("Get an API key")
.foregroundStyle(Palette.textPrimary)
Spacer()
Image(systemName: "arrow.up.right.square")
.foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
// MARK: - Key
private var keyField: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text("API Key")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
Spacer()
Button(action: { showKey.toggle() }) {
Image(systemName: showKey ? "eye.slash.fill" : "eye.fill")
.foregroundStyle(Palette.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(Text(showKey ? "Hide key" : "Show key"))
}
Group {
if showKey {
TextField("sk-…", text: $config.apiKey)
} else {
SecureField("sk-…", text: $config.apiKey)
}
}
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
// MARK: - Generic field
@ViewBuilder
private func field(
title: String,
placeholder: String,
text: Binding<String>,
keyboard: UIKeyboardType,
autocap: Bool
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
TextField(placeholder, text: text)
.keyboardType(keyboard)
.autocorrectionDisabled(true)
.textInputAutocapitalization(autocap ? .sentences : .never)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.submitLabel(.done)
.onSubmit { /* no-op: prevent the keyboard from "submitting"
and dismissing the sheet on iOS 18 */ }
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
}
+157
View File
@@ -0,0 +1,157 @@
// HomeView.swift
// OSGKeyboard · Main App
//
// Post-onboarding home. Two jobs: (1) tell the user we're ready, and
// (2) give a clear path to the next setup step if anything is missing.
import SwiftUI
import OSGKeyboardShared
import OSGKeyboardExt
struct HomeView: View {
@ObservedObject var config = ProviderConfig.shared
@State private var showSettings = false
@State private var showKeyboardPreview = false
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
statusHeader
.padding(.top, Spacing.xl)
Spacer()
heroButton
Spacer()
actionStack
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
}
}
.sheet(isPresented: $showSettings) {
SettingsView()
}
.sheet(isPresented: $showKeyboardPreview) {
KeyboardPreviewSheet()
}
.preferredColorScheme(.dark)
}
// MARK: - Header
private var statusHeader: some View {
VStack(spacing: Spacing.xs) {
HStack(spacing: 6) {
Circle()
.fill(config.isConfigured ? Palette.success : Palette.warning)
.frame(width: 8, height: 8)
Text(config.isConfigured ? "Ready" : "Setup incomplete")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
Text("OSGKeyboard")
.font(TypeStyle.largeTitle)
.foregroundStyle(Palette.textPrimary)
Text(providerLine)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
}
}
private var providerLine: String {
let p = LLMProvider.provider(id: config.providerId)
let model = config.model.isEmpty ? "" : config.model
return "\(p.name) · \(model)"
}
// MARK: - Hero
private var heroButton: some View {
Button {
showSettings = true
} label: {
ZStack {
Circle()
.fill(Palette.accentMuted)
.frame(width: 220, height: 220)
.blur(radius: 40)
Circle()
.fill(LinearGradient(
colors: [Palette.surfaceElevated, Palette.surface],
startPoint: .top,
endPoint: .bottom
))
.frame(width: 160, height: 160)
.overlay(
Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5)
)
.shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8)
VStack(spacing: 6) {
Image(systemName: "waveform")
.font(.system(size: 36, weight: .light))
.foregroundStyle(Palette.accent)
Text("Tap to configure")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
}
}
.buttonStyle(.plain)
.accessibilityLabel(Text("Open OSGKeyboard settings"))
}
// MARK: - Actions
private var actionStack: some View {
VStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard")
.primaryButton()
}
.buttonStyle(.plain)
Button {
showSettings = true
} label: {
Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3")
.secondaryButton()
}
.buttonStyle(.plain)
#if DEBUG
Button {
showKeyboardPreview = true
} label: {
Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye")
.secondaryButton()
}
.buttonStyle(.plain)
#endif
HStack(spacing: Spacing.xs) {
Button {
if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") {
UIApplication.shared.open(url)
}
} label: {
Text("GitHub")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 6)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
Spacer()
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")")
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
}
}
@@ -0,0 +1,99 @@
// KeyboardPreviewSheet.swift
// OSGKeyboard · Main App
//
// Renders a stand-in keyboard layout inside the main app so the user
// can preview what the real keyboard extension looks like without
// enabling the keyboard in iOS Settings. Tap the disc to cycle
// through idle / recording / processing so all visual states are
// inspectable.
import SwiftUI
import OSGKeyboardShared
struct KeyboardPreviewSheet: View {
@Environment(\.dismiss) private var dismiss
// We mirror only the fields the stand-in actually needs, instead of
// crossing the OSGKeyboardExt target boundary to construct a
// `KeyboardViewController.State` (whose initialiser is internal).
@State private var phase: StubPhase = .idle
@State private var level: Double = 0
@State private var transcript: String = ""
private enum StubPhase { case idle, recording, processing }
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
VStack(spacing: Spacing.md) {
Text("Keyboard Preview")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Tap the disc to cycle states. The real keyboard uses the same layout.")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.lg)
mockTextField.padding(.horizontal, Spacing.md)
}
.padding(.top, Spacing.lg)
Spacer(minLength: 0)
keyboardBlock
}
}
.preferredColorScheme(.dark)
}
private var mockTextField: some View {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Palette.textSecondary)
Text("Type here…")
.foregroundStyle(Palette.textTertiary)
Spacer()
}
.padding(Spacing.md)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
private var keyboardBlock: some View {
VStack(spacing: 0) {
Rectangle()
.fill(Palette.divider)
.frame(height: 0.5)
KeyboardPreviewStub(
phase: stubPhase,
level: level,
transcript: transcript
)
.environment(\.colorScheme, .dark)
Rectangle()
.fill(Color.black)
.frame(height: 34)
.overlay(alignment: .center) {
Capsule()
.fill(Color.white.opacity(0.4))
.frame(width: 134, height: 5)
}
}
}
private var stubPhase: KeyboardPreviewStub.Phase {
switch phase {
case .idle: return .idle
case .recording: return .recording
case .processing: return .processing
}
}
}
#if DEBUG
#Preview {
KeyboardPreviewSheet()
}
#endif
+235
View File
@@ -0,0 +1,235 @@
// KeyboardPreviewStub.swift
// OSGKeyboard · Main App
//
// Stand-in for the keyboard extension's SwiftUI tree. iOS does not allow
// the host app to import symbols from its own keyboard extension target,
// so we ship a minimal mirror here. The actual production layout lives
// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up
// when the user enables the keyboard in iOS Settings.
import SwiftUI
import OSGKeyboardShared
struct KeyboardPreviewStub: View {
enum Phase { case idle, recording, processing }
let phase: Phase
let level: Double
let transcript: String
var body: some View {
ZStack(alignment: .top) {
Palette.background
VStack(spacing: 0) {
topBar.frame(height: 32)
centreArea.frame(maxWidth: .infinity, maxHeight: .infinity)
bottomBar.frame(height: 56)
}
.padding(.top, 4)
.padding(.bottom, 6)
}
.frame(height: 280)
.preferredColorScheme(.dark)
}
// MARK: - Top bar
private var topBar: some View {
HStack(spacing: Spacing.xs) {
modeChip
localeChip
Spacer(minLength: 0)
statusBadge
Button(action: {}) {
Image(systemName: "gearshape.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(Palette.textSecondary)
.frame(width: 28, height: 28)
.background(Palette.surface, in: Circle())
.overlay(Circle().stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
}
.padding(.horizontal, Spacing.md)
}
private var modeChip: some View {
HStack(spacing: 4) {
Image(systemName: "wand.and.stars")
Text("润色")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
private var localeChip: some View {
HStack(spacing: 4) {
Image(systemName: "globe")
Text("简体")
Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textPrimary)
.padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4)
.background(Palette.surfaceElevated, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
private var statusBadge: some View {
Group {
switch phase {
case .idle:
EmptyView()
case .recording:
HStack(spacing: 4) {
Circle().fill(Palette.recordRed).frame(width: 6, height: 6)
Text("REC").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
case .processing:
HStack(spacing: 4) {
Circle().fill(Palette.accent).frame(width: 6, height: 6)
Text("···").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary)
}
.padding(.horizontal, Spacing.xs).padding(.vertical, 3)
.background(Palette.surface, in: Capsule())
.overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5))
}
}
}
// MARK: - Centre area
private var centreArea: some View {
VStack(spacing: Spacing.xxs) {
transcriptLine.frame(height: 22)
recordDisc.frame(width: 140, height: 140)
}
.frame(maxWidth: .infinity)
}
private var transcriptLine: some View {
Group {
switch phase {
case .idle:
Text("按住说话 · Hold to talk")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textTertiary)
case .recording:
Text(transcript.isEmpty ? " " : transcript)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textPrimary)
.lineLimit(1)
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(Palette.accent)
Text("润色中 · Polishing")
.font(TypeStyle.caption)
.foregroundStyle(Palette.textSecondary)
}
}
}
.padding(.horizontal, Spacing.md)
}
private var recordDisc: some View {
ZStack {
if phase == .recording {
Circle()
.stroke(Palette.recordRed.opacity(0.35), lineWidth: 2)
.frame(width: 110, height: 110)
.opacity(0.6)
Circle()
.fill(RadialGradient(colors: [Palette.recordRed.opacity(0.55), .clear], center: .center, startRadius: 30, endRadius: 70))
.frame(width: 160, height: 160)
.blur(radius: 12)
.opacity(0.4 + level * 0.6)
}
Circle()
.fill(discGradient)
.frame(width: 96, height: 96)
.overlay(Circle().stroke(Color.white.opacity(0.16), lineWidth: 1))
.shadow(color: .black.opacity(0.4), radius: 10, y: 6)
Group {
switch phase {
case .idle:
Image(systemName: "mic.fill")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(.white)
case .recording:
HStack(spacing: 3) {
ForEach(0..<12, id: \.self) { i in
Capsule()
.fill(Palette.recordRed)
.frame(width: 2, height: 8 + CGFloat(level * 30) * (i.isMultiple(of: 2) ? 1 : 0.6))
}
}
.frame(width: 60, height: 32)
case .processing:
ProgressView().tint(.white).scaleEffect(1.1)
}
}
}
}
private var discGradient: LinearGradient {
switch phase {
case .recording:
return LinearGradient(colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], startPoint: .top, endPoint: .bottom)
case .processing:
return LinearGradient(colors: [Palette.surfaceElevated, Palette.surface], startPoint: .top, endPoint: .bottom)
case .idle:
return LinearGradient(colors: [Color(white: 0.22), Color(white: 0.10)], startPoint: .top, endPoint: .bottom)
}
}
// MARK: - Bottom bar
private var bottomBar: some View {
HStack(spacing: Spacing.xxs) {
iconButton("globe")
iconButton("delete.left")
Spacer(minLength: 0)
Button(action: {}) {
Text("空格")
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
Spacer(minLength: 0)
iconButton("return")
}
.padding(.horizontal, Spacing.sm)
}
private func iconButton(_ systemName: String) -> some View {
Button(action: {}) {
Image(systemName: systemName)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(Palette.textPrimary)
.frame(width: 40, height: 40)
.background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5))
}
.buttonStyle(.plain)
}
}
#if DEBUG
#Preview {
KeyboardPreviewStub(phase: .idle, level: 0, transcript: "")
.preferredColorScheme(.dark)
}
#endif
+257
View File
@@ -0,0 +1,257 @@
// OnboardingView.swift
// OSGKeyboard · Main App
//
// Three-step onboarding presented as a horizontal pager:
//
// 1) Welcome what the app does, in one sentence
// 2) Enable Settings General Keyboards Add Allow Full Access
// 3) Setup pick a provider, paste a key
//
// Visual style: one large accent surface, generous whitespace, single CTA
// at the bottom. No tipsy animations, no cheerful illustrations every
// pixel is doing one job.
import SwiftUI
import OSGKeyboardShared
struct OnboardingView: View {
@ObservedObject var config: ProviderConfig
@State private var page: Int = 0
var body: some View {
ZStack {
Palette.background.ignoresSafeArea()
VStack(spacing: 0) {
TabView(selection: $page) {
WelcomePage().tag(0)
EnableKeyboardPage().tag(1)
APISetupPage(config: config).tag(2)
}
.tabViewStyle(.page(indexDisplayMode: .never))
pageDots
.padding(.bottom, Spacing.md)
bottomBar
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.lg)
}
}
.preferredColorScheme(.dark)
}
private var pageDots: some View {
HStack(spacing: 6) {
ForEach(0..<3, id: \.self) { i in
Capsule()
.fill(i == page ? Palette.accent : Color.white.opacity(0.18))
.frame(width: i == page ? 18 : 6, height: 6)
.animation(Motion.quick, value: page)
}
}
}
@ViewBuilder
private var bottomBar: some View {
HStack(spacing: Spacing.sm) {
if page > 0 {
Button { withAnimation(Motion.soft) { page -= 1 } } label: {
Text("Back")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.dividerStrong, lineWidth: 0.5)
)
.foregroundStyle(Palette.textPrimary)
}
.buttonStyle(.plain)
}
Button {
withAnimation(Motion.soft) {
if page < 2 { page += 1 }
}
} label: {
Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next")
.font(TypeStyle.headline)
.frame(maxWidth: .infinity, minHeight: 50)
.background(
(page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.foregroundStyle(
(page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent
)
}
.buttonStyle(.plain)
.disabled(page == 2 && !config.isConfigured)
}
}
}
// MARK: - Page 1: Welcome
private struct WelcomePage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
ZStack {
Circle()
.fill(Palette.accentMuted)
.frame(width: 180, height: 180)
.blur(radius: 30)
Image(systemName: "mic.circle.fill")
.font(.system(size: 96, weight: .light))
.foregroundStyle(Palette.accent)
}
VStack(spacing: Spacing.sm) {
Text("OSGKeyboard")
.font(TypeStyle.title)
.foregroundStyle(Palette.textPrimary)
Text("按住说话,松开即得润色文字。")
.font(TypeStyle.body)
.foregroundStyle(Palette.textSecondary)
.multilineTextAlignment(.center)
Text("Hold to talk. Release for polished text, in any app.")
.font(TypeStyle.footnote)
.foregroundStyle(Palette.textTertiary)
.multilineTextAlignment(.center)
}
.padding(.horizontal, Spacing.xl)
PrivacyFootnote()
.padding(.top, Spacing.lg)
Spacer()
}
}
}
private struct PrivacyFootnote: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
footnoteRow(icon: "lock.fill",
title: "Audio stays on device",
body: "Transcribed locally with Apple's speech engine.")
footnoteRow(icon: "wifi",
title: "Only the polished text is sent",
body: "Sent to your chosen LLM to add structure & punctuation.")
footnoteRow(icon: "keyboard",
title: "Works everywhere",
body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.")
}
.padding(Spacing.md)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
.padding(.horizontal, Spacing.md)
}
private func footnoteRow(icon: String, title: String, body: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Palette.accent)
.frame(width: 24, height: 24)
.background(Palette.accentMuted, in: Circle())
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption)
.foregroundStyle(Palette.textPrimary)
Text(body)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textSecondary)
}
}
}
}
// MARK: - Page 2: Enable keyboard
private struct EnableKeyboardPage: View {
var body: some View {
VStack(spacing: Spacing.xl) {
Spacer()
Image(systemName: "keyboard.fill")
.font(.system(size: 64, weight: .light))
.foregroundStyle(Palette.accent)
VStack(spacing: Spacing.sm) {
Text("启用 OSGKeyboard")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Enable OSGKeyboard")
.font(TypeStyle.body)
.foregroundStyle(Palette.textTertiary)
}
VStack(alignment: .leading, spacing: Spacing.sm) {
step(num: 1, text: "Settings → General → Keyboard → Keyboards")
step(num: 2, text: "Tap “Add New Keyboard…” and choose OSGKeyboard")
step(num: 3, text: "Tap OSGKeyboard and enable “Allow Full Access”")
step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.")
}
.cardSurface()
.padding(.horizontal, Spacing.md)
Button {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
} label: {
Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square")
.primaryButton()
}
.padding(.horizontal, Spacing.md)
Spacer()
}
}
private func step(num: Int, text: String) -> some View {
HStack(alignment: .top, spacing: Spacing.xs) {
Text("\(num)")
.font(TypeStyle.caption2)
.frame(width: 22, height: 22)
.background(Palette.accent, in: Circle())
.foregroundStyle(Palette.textOnAccent)
Text(text)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Page 3: API setup
private struct APISetupPage: View {
@ObservedObject var config: ProviderConfig
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text("配置 AI 提供商")
.font(TypeStyle.title2)
.foregroundStyle(Palette.textPrimary)
Text("Configure your AI provider")
.font(TypeStyle.body)
.foregroundStyle(Palette.textTertiary)
Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.")
.font(TypeStyle.footnote)
.foregroundStyle(Palette.textSecondary)
.padding(.top, Spacing.xxs)
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.lg)
ProviderPickerSection(config: config)
.padding(.horizontal, Spacing.md)
APISettingsCard(config: config)
.padding(.horizontal, Spacing.md)
}
.padding(.bottom, Spacing.xxxl)
}
}
}
@@ -0,0 +1,74 @@
// ProviderPickerSection.swift
// OSGKeyboard · Main App
//
// Provider picker shown inline inside Settings & Onboarding. Each option
// surfaces the provider's name + a short blurb so the user can pick
// confidently without opening a doc.
import SwiftUI
import OSGKeyboardShared
struct ProviderPickerSection: View {
@ObservedObject var config: ProviderConfig
var body: some View {
VStack(spacing: 0) {
ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in
Button {
select(provider)
} label: {
row(provider, selected: provider.id == config.providerId)
}
.buttonStyle(.plain)
if index < LLMProvider.presets.count - 1 {
Divider().background(Palette.divider)
}
}
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
private func select(_ provider: LLMProvider) {
withAnimation(Motion.quick) {
config.apply(preset: provider)
}
}
@ViewBuilder
private func row(_ provider: LLMProvider, selected: Bool) -> some View {
HStack(spacing: Spacing.xs) {
// Provider mark a coloured dot with first letter
ZStack {
Circle()
.fill(selected ? Palette.accent : Palette.surfaceElevated)
.frame(width: 36, height: 36)
Text(String(provider.name.prefix(1)))
.font(TypeStyle.bodyEmph)
.foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary)
}
VStack(alignment: .leading, spacing: 2) {
Text(provider.name)
.font(TypeStyle.bodyEmph)
.foregroundStyle(Palette.textPrimary)
if let blurb = provider.blurb {
Text(blurb)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
Spacer()
if selected {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(Palette.accent)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.contentShape(Rectangle())
}
}
+227
View File
@@ -0,0 +1,227 @@
// SettingsView.swift
// OSGKeyboard · Main App
//
// Sheet that hosts the API configuration. Single scrollable column, every
// field earns its space.
import SwiftUI
import OSGKeyboardShared
struct SettingsView: View {
@ObservedObject var config = ProviderConfig.shared
@Environment(\.dismiss) private var dismiss
@State private var showResetConfirm = false
var body: some View {
NavigationStack {
ZStack {
Palette.background.ignoresSafeArea()
ScrollView {
VStack(spacing: Spacing.md) {
providerSection
apiSection
languageSection
promptSection
resetButton
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
}
}
.navigationTitle("设置 · Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.font(TypeStyle.headline)
.foregroundStyle(Palette.accent)
}
}
.preferredColorScheme(.dark)
}
.confirmationDialog(
"Reset all settings?",
isPresented: $showResetConfirm,
titleVisibility: .visible
) {
Button("Reset", role: .destructive) {
config.reset()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("API key, model, and base URL will be cleared.")
}
}
// MARK: - Provider
private var providerSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("Provider · 提供商", subtitle: "Pick the LLM that polishes your dictation.")
ProviderPickerSection(config: config)
}
}
// MARK: - API
private var apiSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("API · 接口", subtitle: nil)
APISettingsCard(config: config)
}
}
// MARK: - Language (ASR + mode)
private var languageSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
sectionHeader("Language · 语言", subtitle: "Choose ASR locale and dictation mode.")
VStack(spacing: 0) {
PickerRow(
title: "Mode",
options: modeOptions,
selection: Binding(
get: { config.modeId },
set: { config.modeId = $0 }
)
)
Divider().background(Palette.divider)
PickerRow(
title: "ASR locale",
options: localeOptions,
selection: Binding(
get: { config.localeId },
set: { config.localeId = $0 }
)
)
}
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
}
private var modeOptions: [(id: String, label: String)] {
[
("off", "Off · 关闭"),
("transcribe", "Transcribe · 仅转写"),
("polish", "Polish · 润色")
]
}
private var localeOptions: [(id: String, label: String)] {
[
("auto", "Auto · 跟随系统"),
("zh-Hans", "中文(简体)"),
("zh-Hant", "中文(繁體)"),
("en-US", "English (US)"),
("ja-JP", "日本語"),
("ko-KR", "한국어")
]
}
// MARK: - Prompt
private var promptSection: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack {
sectionHeader("System Prompt · 系统提示", subtitle: nil)
Spacer()
Button("Reset") { config.systemPrompt = config.defaultSystemPrompt }
.font(TypeStyle.caption2)
.foregroundStyle(Palette.accent)
}
VStack(alignment: .leading, spacing: Spacing.xs) {
TextEditor(text: $config.systemPrompt)
.font(TypeStyle.mono)
.scrollContentBackground(.hidden)
.frame(minHeight: 140)
.padding(Spacing.xs)
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
.stroke(Palette.divider, lineWidth: 0.5)
)
}
.cardSurface()
}
}
// MARK: - Reset
private var resetButton: some View {
Button(role: .destructive) {
showResetConfirm = true
} label: {
Text("Reset all settings")
.font(TypeStyle.caption)
.foregroundStyle(Palette.danger)
.frame(maxWidth: .infinity, minHeight: 40)
}
.buttonStyle(.plain)
}
// MARK: - Header
private func sectionHeader(_ title: String, subtitle: String?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textSecondary)
.textCase(.uppercase)
if let subtitle {
Text(subtitle)
.font(TypeStyle.caption2)
.foregroundStyle(Palette.textTertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Picker row
private struct PickerRow: View {
let title: String
let options: [(id: String, label: String)]
@Binding var selection: String
var body: some View {
HStack {
Text(title)
.font(TypeStyle.body)
.foregroundStyle(Palette.textPrimary)
Spacer()
Menu {
ForEach(options, id: \.id) { o in
Button {
selection = o.id
} label: {
if o.id == selection {
Label(o.label, systemImage: "checkmark")
} else {
Text(o.label)
}
}
}
} label: {
HStack(spacing: 4) {
Text(currentLabel)
.font(TypeStyle.body)
.foregroundStyle(Palette.textSecondary)
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Palette.textTertiary)
}
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
}
private var currentLabel: String {
options.first(where: { $0.id == selection })?.label ?? ""
}
}