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
+102
View File
@@ -0,0 +1,102 @@
// 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))
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// 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)
}
}
}
+177
View File
@@ -0,0 +1,177 @@
// 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)
}
}
}
@@ -0,0 +1,36 @@
// 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()
}
}
+50
View File
@@ -0,0 +1,50 @@
// 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)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
// 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)
)
}
}