feat: initial release v0.1.0

- Custom Keyboard Extension with push-to-talk UI
- iOS 26 SpeechAnalyzer + DictationTranscriber (iOS 18 SF fallback)
- OpenAI-compatible LLM client (4 built-in providers + custom)
- 3-page onboarding flow + provider config UI
- App Group shared storage for cross-process config
- 8s LLM timeout with raw-transcript fallback
- App Store privacy manifests for both targets
- SwiftLint + XcodeGen + GitHub Actions CI
- Unit tests (4/4 passing)
This commit is contained in:
2026-06-17 23:08:58 +08:00
commit 07067ca6c5
45 changed files with 3072 additions and 0 deletions
@@ -0,0 +1,121 @@
// 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)
}
}
}
+116
View File
@@ -0,0 +1,116 @@
// 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"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
// 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))
}
}