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
+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 ?? ""
}
}