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,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.980",
"green" : "0.780",
"red" : "0.360"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "icon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
+56
View File
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>OSGKeyboard</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.osgkeyboard.ios.settings</string>
<key>CFBundleURLSchemes</key>
<array>
<string>osgkeyboard</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
<key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard needs microphone access to transcribe your voice into text.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string></string>
</dict>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
</dict>
</plist>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
+23
View File
@@ -0,0 +1,23 @@
// OpenLessApp.swift
// OSGKeyboard · Main App
//
// App entry point. Switches between Onboarding and Home based on
// whether the user has configured their API key yet.
import SwiftUI
import OSGKeyboardShared
@main
struct OSGKeyboardApp: App {
@StateObject private var config = ProviderConfig.shared
var body: some Scene {
WindowGroup {
if config.isConfigured {
HomeView()
} else {
OnboardingView()
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedUsageTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>CA92.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>C617.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>E174.1</string>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<string>35F9.1</string>
</dict>
</array>
</dict>
</plist>
+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)
)
}
}