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:
@@ -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 ?? "—"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user