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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user