diff --git a/.swiftlint.yml b/.swiftlint.yml index c10a62d..757abeb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -21,10 +21,10 @@ opt_in_rules: - redundant_optional_initialization included: - - OpenLess - - OpenLessKeyboard - - OpenLessShared - - OpenLessTests + - OSGKeyboard + - OSGKeyboardExt + - OSGKeyboardShared + - OSGKeyboardTests excluded: - build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1d1db1..d2a7a9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,7 @@ Open an issue using the **Feature request** template. Briefly describe: xcodegen generate ``` 3. **Code style.** SwiftLint config lives in `.swiftlint.yml` — keep it green. We use Swift 6 strict concurrency, no `Sendable` shims where avoidable. -4. **Tests.** Add XCTest coverage in `OpenLessTests/` for any non-trivial logic. +4. **Tests.** Add XCTest coverage in `OSGKeyboardTests/` for any non-trivial logic. 5. **Build & test before pushing:** ```bash xcodebuild -project OSGKeyboard.xcodeproj \ @@ -46,17 +46,17 @@ Open an issue using the **Feature request** template. Briefly describe: ## Project structure ``` -OpenLess/ Main iOS app target -OpenLessKeyboard/ Custom Keyboard Extension target -OpenLessShared/ Framework shared by app + extension -OpenLessTests/ XCTest unit tests +OSGKeyboard/ Main iOS app target +OSGKeyboardExt/ Custom Keyboard Extension target +OSGKeyboardShared/ Framework shared by app + extension +OSGKeyboardTests/ XCTest unit tests project.yml XcodeGen project definition (source of truth) .github/workflows/ CI ``` ## Adding a new LLM provider -The simplest contribution: add a preset to `OpenLessShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint. +The simplest contribution: add a preset to `OSGKeyboardShared/Models/LLMProvider.swift`. No other code change is needed — `OpenAICompatibleClient` handles any OpenAI-compatible endpoint. ## Coding conventions diff --git a/OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from OpenLess/Assets.xcassets/AccentColor.colorset/Contents.json rename to OSGKeyboard/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 83% rename from OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json rename to OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json index 27a4f38..838144a 100644 --- a/OpenLess/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "icon-1024.png", + "filename" : "Group 24.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png new file mode 100644 index 0000000..0e6ff46 Binary files /dev/null and b/OSGKeyboard/Assets.xcassets/AppIcon.appiconset/Group 24.png differ diff --git a/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 0000000..e88d30b --- /dev/null +++ b/OSGKeyboard/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.043", + "green" : "0.039", + "red" : "0.039" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/OpenLess/Assets.xcassets/Contents.json b/OSGKeyboard/Assets.xcassets/Contents.json similarity index 100% rename from OpenLess/Assets.xcassets/Contents.json rename to OSGKeyboard/Assets.xcassets/Contents.json diff --git a/OpenLess/Info.plist b/OSGKeyboard/Info.plist similarity index 97% rename from OpenLess/Info.plist rename to OSGKeyboard/Info.plist index 6e78134..ddfb8b4 100644 --- a/OpenLess/Info.plist +++ b/OSGKeyboard/Info.plist @@ -46,7 +46,7 @@ UILaunchScreen UIColorName - + BackgroundColor UISupportedInterfaceOrientations diff --git a/OSGKeyboard/OSGKeyboard.entitlements b/OSGKeyboard/OSGKeyboard.entitlements new file mode 100644 index 0000000..2160d46 --- /dev/null +++ b/OSGKeyboard/OSGKeyboard.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.application-groups + + group.com.osgkeyboard.shared + + com.apple.security.device.audio-input + + + diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift new file mode 100644 index 0000000..3d4b5b1 --- /dev/null +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -0,0 +1,31 @@ +// OSGKeyboardApp.swift +// OSGKeyboard · Main App +// +// DEBUG VERSION 2: restore real flow but instrument every step. + +import SwiftUI +import OSGKeyboardShared + +@main +struct OSGKeyboardApp: App { + @StateObject private var config = ProviderConfig.shared + + init() { + print("🔥 [OSGKeyboardApp] init()") + } + + var body: some Scene { + WindowGroup { + Group { + if config.isConfigured { + HomeView() + .onAppear { print("🔥 [OSGKeyboardApp] → HomeView appeared") } + } else { + OnboardingView(config: config) + .onAppear { print("🔥 [OSGKeyboardApp] → OnboardingView appeared") } + } + } + .onAppear { print("🔥 [OSGKeyboardApp] body appeared") } + } + } +} diff --git a/OpenLess/PrivacyInfo.xcprivacy b/OSGKeyboard/PrivacyInfo.xcprivacy similarity index 100% rename from OpenLess/PrivacyInfo.xcprivacy rename to OSGKeyboard/PrivacyInfo.xcprivacy diff --git a/OSGKeyboard/Views/APISettingsCard.swift b/OSGKeyboard/Views/APISettingsCard.swift new file mode 100644 index 0000000..b75ca27 --- /dev/null +++ b/OSGKeyboard/Views/APISettingsCard.swift @@ -0,0 +1,124 @@ +// APISettingsCard.swift +// OSGKeyboard · Main App +// +// Editable fields for the three OpenAI-compatible config values: +// Base URL, API Key, Model. + +import SwiftUI +import OSGKeyboardShared + +struct APISettingsCard: View { + @ObservedObject var config: ProviderConfig + @State private var showKey: Bool = false + + var body: some View { + VStack(spacing: 0) { + field( + title: "Base URL", + placeholder: "https://api.openai.com/v1", + text: $config.baseURL, + keyboard: .URL, + autocap: false + ) + Divider().background(Palette.divider) + keyField + Divider().background(Palette.divider) + field( + title: "Model", + placeholder: "gpt-4o-mini", + text: $config.model, + keyboard: .default, + autocap: false + ) + if let url = LLMProvider.provider(id: config.providerId).apiKeyURL { + Divider().background(Palette.divider) + // Use a Button + UIApplication.open instead of SwiftUI + // `Link`. SwiftUI `Link` has a hit-test bug on iOS 18 that + // makes its tappable area eat gestures from the adjacent + // TextField, which manifests as "typing jumps to a website". + Button { + UIApplication.shared.open(url) + } label: { + HStack { + Image(systemName: "key.fill") + .foregroundStyle(Palette.accent) + Text("Get an API key") + .foregroundStyle(Palette.textPrimary) + Spacer() + Image(systemName: "arrow.up.right.square") + .foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + } + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + // MARK: - Key + + private var keyField: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("API Key") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + Spacer() + Button(action: { showKey.toggle() }) { + Image(systemName: showKey ? "eye.slash.fill" : "eye.fill") + .foregroundStyle(Palette.textSecondary) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(showKey ? "Hide key" : "Show key")) + } + Group { + if showKey { + TextField("sk-…", text: $config.apiKey) + } else { + SecureField("sk-…", text: $config.apiKey) + } + } + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + } + + // MARK: - Generic field + + @ViewBuilder + private func field( + title: String, + placeholder: String, + text: Binding, + keyboard: UIKeyboardType, + autocap: Bool + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + TextField(placeholder, text: text) + .keyboardType(keyboard) + .autocorrectionDisabled(true) + .textInputAutocapitalization(autocap ? .sentences : .never) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .submitLabel(.done) + .onSubmit { /* no-op: prevent the keyboard from "submitting" + and dismissing the sheet on iOS 18 */ } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + } +} diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift new file mode 100644 index 0000000..2af9f1b --- /dev/null +++ b/OSGKeyboard/Views/HomeView.swift @@ -0,0 +1,157 @@ +// HomeView.swift +// OSGKeyboard · Main App +// +// Post-onboarding home. Two jobs: (1) tell the user we're ready, and +// (2) give a clear path to the next setup step if anything is missing. + +import SwiftUI +import OSGKeyboardShared +import OSGKeyboardExt + +struct HomeView: View { + @ObservedObject var config = ProviderConfig.shared + @State private var showSettings = false + @State private var showKeyboardPreview = false + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + statusHeader + .padding(.top, Spacing.xl) + Spacer() + heroButton + Spacer() + actionStack + .padding(.horizontal, Spacing.md) + .padding(.bottom, Spacing.lg) + } + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + .sheet(isPresented: $showKeyboardPreview) { + KeyboardPreviewSheet() + } + .preferredColorScheme(.dark) + } + + // MARK: - Header + + private var statusHeader: some View { + VStack(spacing: Spacing.xs) { + HStack(spacing: 6) { + Circle() + .fill(config.isConfigured ? Palette.success : Palette.warning) + .frame(width: 8, height: 8) + Text(config.isConfigured ? "Ready" : "Setup incomplete") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + Text("OSGKeyboard") + .font(TypeStyle.largeTitle) + .foregroundStyle(Palette.textPrimary) + Text(providerLine) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + } + } + + private var providerLine: String { + let p = LLMProvider.provider(id: config.providerId) + let model = config.model.isEmpty ? "—" : config.model + return "\(p.name) · \(model)" + } + + // MARK: - Hero + + private var heroButton: some View { + Button { + showSettings = true + } label: { + ZStack { + Circle() + .fill(Palette.accentMuted) + .frame(width: 220, height: 220) + .blur(radius: 40) + Circle() + .fill(LinearGradient( + colors: [Palette.surfaceElevated, Palette.surface], + startPoint: .top, + endPoint: .bottom + )) + .frame(width: 160, height: 160) + .overlay( + Circle().stroke(Palette.accent.opacity(0.35), lineWidth: 1.5) + ) + .shadow(color: Palette.accent.opacity(0.18), radius: 24, y: 8) + VStack(spacing: 6) { + Image(systemName: "waveform") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(Palette.accent) + Text("Tap to configure") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Open OSGKeyboard settings")) + } + + // MARK: - Actions + + private var actionStack: some View { + VStack(spacing: Spacing.xs) { + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("启用键盘 · Enable in iOS Settings", systemImage: "keyboard") + .primaryButton() + } + .buttonStyle(.plain) + + Button { + showSettings = true + } label: { + Label("编辑 API 配置 · Edit API Configuration", systemImage: "slider.horizontal.3") + .secondaryButton() + } + .buttonStyle(.plain) + + #if DEBUG + Button { + showKeyboardPreview = true + } label: { + Label("键盘预览 · Keyboard Preview (Debug)", systemImage: "eye") + .secondaryButton() + } + .buttonStyle(.plain) + #endif + + HStack(spacing: Spacing.xs) { + Button { + if let url = URL(string: "https://github.com/hkgood/OSGKeyboard") { + UIApplication.shared.open(url) + } + } label: { + Text("GitHub") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .padding(.horizontal, Spacing.sm) + .padding(.vertical, 6) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + Spacer() + Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0")") + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textTertiary) + } + } + } +} diff --git a/OSGKeyboard/Views/KeyboardPreviewSheet.swift b/OSGKeyboard/Views/KeyboardPreviewSheet.swift new file mode 100644 index 0000000..8590544 --- /dev/null +++ b/OSGKeyboard/Views/KeyboardPreviewSheet.swift @@ -0,0 +1,99 @@ +// KeyboardPreviewSheet.swift +// OSGKeyboard · Main App +// +// Renders a stand-in keyboard layout inside the main app so the user +// can preview what the real keyboard extension looks like without +// enabling the keyboard in iOS Settings. Tap the disc to cycle +// through idle / recording / processing so all visual states are +// inspectable. + +import SwiftUI +import OSGKeyboardShared + +struct KeyboardPreviewSheet: View { + @Environment(\.dismiss) private var dismiss + + // We mirror only the fields the stand-in actually needs, instead of + // crossing the OSGKeyboardExt target boundary to construct a + // `KeyboardViewController.State` (whose initialiser is internal). + @State private var phase: StubPhase = .idle + @State private var level: Double = 0 + @State private var transcript: String = "" + + private enum StubPhase { case idle, recording, processing } + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + VStack(spacing: Spacing.md) { + Text("Keyboard Preview") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Tap the disc to cycle states. The real keyboard uses the same layout.") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + .padding(.horizontal, Spacing.lg) + mockTextField.padding(.horizontal, Spacing.md) + } + .padding(.top, Spacing.lg) + Spacer(minLength: 0) + keyboardBlock + } + } + .preferredColorScheme(.dark) + } + + private var mockTextField: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(Palette.textSecondary) + Text("Type here…") + .foregroundStyle(Palette.textTertiary) + Spacer() + } + .padding(Spacing.md) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + private var keyboardBlock: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Palette.divider) + .frame(height: 0.5) + KeyboardPreviewStub( + phase: stubPhase, + level: level, + transcript: transcript + ) + .environment(\.colorScheme, .dark) + Rectangle() + .fill(Color.black) + .frame(height: 34) + .overlay(alignment: .center) { + Capsule() + .fill(Color.white.opacity(0.4)) + .frame(width: 134, height: 5) + } + } + } + + private var stubPhase: KeyboardPreviewStub.Phase { + switch phase { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + } + } +} + +#if DEBUG +#Preview { + KeyboardPreviewSheet() +} +#endif diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift new file mode 100644 index 0000000..600a02c --- /dev/null +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -0,0 +1,235 @@ +// KeyboardPreviewStub.swift +// OSGKeyboard · Main App +// +// Stand-in for the keyboard extension's SwiftUI tree. iOS does not allow +// the host app to import symbols from its own keyboard extension target, +// so we ship a minimal mirror here. The actual production layout lives +// in OSGKeyboardExt/Views/KeyboardRootView.swift and is what shows up +// when the user enables the keyboard in iOS Settings. + +import SwiftUI +import OSGKeyboardShared + +struct KeyboardPreviewStub: View { + + enum Phase { case idle, recording, processing } + + let phase: Phase + let level: Double + let transcript: String + + var body: some View { + ZStack(alignment: .top) { + Palette.background + VStack(spacing: 0) { + topBar.frame(height: 32) + centreArea.frame(maxWidth: .infinity, maxHeight: .infinity) + bottomBar.frame(height: 56) + } + .padding(.top, 4) + .padding(.bottom, 6) + } + .frame(height: 280) + .preferredColorScheme(.dark) + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: Spacing.xs) { + modeChip + localeChip + Spacer(minLength: 0) + statusBadge + Button(action: {}) { + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Palette.textSecondary) + .frame(width: 28, height: 28) + .background(Palette.surface, in: Circle()) + .overlay(Circle().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, Spacing.md) + } + + private var modeChip: some View { + HStack(spacing: 4) { + Image(systemName: "wand.and.stars") + Text("润色") + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + + private var localeChip: some View { + HStack(spacing: 4) { + Image(systemName: "globe") + Text("简体") + Image(systemName: "chevron.down").font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2).padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + + private var statusBadge: some View { + Group { + switch phase { + case .idle: + EmptyView() + case .recording: + HStack(spacing: 4) { + Circle().fill(Palette.recordRed).frame(width: 6, height: 6) + Text("REC").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs).padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + case .processing: + HStack(spacing: 4) { + Circle().fill(Palette.accent).frame(width: 6, height: 6) + Text("···").font(TypeStyle.caption2).foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs).padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + } + } + + // MARK: - Centre area + + private var centreArea: some View { + VStack(spacing: Spacing.xxs) { + transcriptLine.frame(height: 22) + recordDisc.frame(width: 140, height: 140) + } + .frame(maxWidth: .infinity) + } + + private var transcriptLine: some View { + Group { + switch phase { + case .idle: + Text("按住说话 · Hold to talk") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textTertiary) + case .recording: + Text(transcript.isEmpty ? " " : transcript) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: .infinity) + case .processing: + HStack(spacing: 6) { + ProgressView().controlSize(.mini).tint(Palette.accent) + Text("润色中 · Polishing") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + } + } + .padding(.horizontal, Spacing.md) + } + + private var recordDisc: some View { + ZStack { + if phase == .recording { + Circle() + .stroke(Palette.recordRed.opacity(0.35), lineWidth: 2) + .frame(width: 110, height: 110) + .opacity(0.6) + Circle() + .fill(RadialGradient(colors: [Palette.recordRed.opacity(0.55), .clear], center: .center, startRadius: 30, endRadius: 70)) + .frame(width: 160, height: 160) + .blur(radius: 12) + .opacity(0.4 + level * 0.6) + } + Circle() + .fill(discGradient) + .frame(width: 96, height: 96) + .overlay(Circle().stroke(Color.white.opacity(0.16), lineWidth: 1)) + .shadow(color: .black.opacity(0.4), radius: 10, y: 6) + Group { + switch phase { + case .idle: + Image(systemName: "mic.fill") + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(.white) + case .recording: + HStack(spacing: 3) { + ForEach(0..<12, id: \.self) { i in + Capsule() + .fill(Palette.recordRed) + .frame(width: 2, height: 8 + CGFloat(level * 30) * (i.isMultiple(of: 2) ? 1 : 0.6)) + } + } + .frame(width: 60, height: 32) + case .processing: + ProgressView().tint(.white).scaleEffect(1.1) + } + } + } + } + + private var discGradient: LinearGradient { + switch phase { + case .recording: + return LinearGradient(colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], startPoint: .top, endPoint: .bottom) + case .processing: + return LinearGradient(colors: [Palette.surfaceElevated, Palette.surface], startPoint: .top, endPoint: .bottom) + case .idle: + return LinearGradient(colors: [Color(white: 0.22), Color(white: 0.10)], startPoint: .top, endPoint: .bottom) + } + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + HStack(spacing: Spacing.xxs) { + iconButton("globe") + iconButton("delete.left") + Spacer(minLength: 0) + Button(action: {}) { + Text("空格") + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .frame(maxWidth: .infinity, minHeight: 42) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + Spacer(minLength: 0) + iconButton("return") + } + .padding(.horizontal, Spacing.sm) + } + + private func iconButton(_ systemName: String) -> some View { + Button(action: {}) { + Image(systemName: systemName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + .frame(width: 40, height: 40) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: Radius.medium, style: .continuous).stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + } +} + +#if DEBUG +#Preview { + KeyboardPreviewStub(phase: .idle, level: 0, transcript: "") + .preferredColorScheme(.dark) +} +#endif diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift new file mode 100644 index 0000000..368c0ef --- /dev/null +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -0,0 +1,257 @@ +// OnboardingView.swift +// OSGKeyboard · Main App +// +// Three-step onboarding presented as a horizontal pager: +// +// 1) Welcome — what the app does, in one sentence +// 2) Enable — Settings → General → Keyboards → Add → Allow Full Access +// 3) Setup — pick a provider, paste a key +// +// Visual style: one large accent surface, generous whitespace, single CTA +// at the bottom. No tipsy animations, no cheerful illustrations — every +// pixel is doing one job. + +import SwiftUI +import OSGKeyboardShared + +struct OnboardingView: View { + @ObservedObject var config: ProviderConfig + @State private var page: Int = 0 + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + VStack(spacing: 0) { + TabView(selection: $page) { + WelcomePage().tag(0) + EnableKeyboardPage().tag(1) + APISetupPage(config: config).tag(2) + } + .tabViewStyle(.page(indexDisplayMode: .never)) + + pageDots + .padding(.bottom, Spacing.md) + + bottomBar + .padding(.horizontal, Spacing.md) + .padding(.bottom, Spacing.lg) + } + } + .preferredColorScheme(.dark) + } + + private var pageDots: some View { + HStack(spacing: 6) { + ForEach(0..<3, id: \.self) { i in + Capsule() + .fill(i == page ? Palette.accent : Color.white.opacity(0.18)) + .frame(width: i == page ? 18 : 6, height: 6) + .animation(Motion.quick, value: page) + } + } + } + + @ViewBuilder + private var bottomBar: some View { + HStack(spacing: Spacing.sm) { + if page > 0 { + Button { withAnimation(Motion.soft) { page -= 1 } } label: { + Text("Back") + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.dividerStrong, lineWidth: 0.5) + ) + .foregroundStyle(Palette.textPrimary) + } + .buttonStyle(.plain) + } + + Button { + withAnimation(Motion.soft) { + if page < 2 { page += 1 } + } + } label: { + Text(page == 2 ? (config.isConfigured ? "Done" : "Continue") : "Next") + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background( + (page == 2 && !config.isConfigured) ? Palette.surfaceElevated : Palette.accent, + in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + ) + .foregroundStyle( + (page == 2 && !config.isConfigured) ? Palette.textSecondary : Palette.textOnAccent + ) + } + .buttonStyle(.plain) + .disabled(page == 2 && !config.isConfigured) + } + } +} + +// MARK: - Page 1: Welcome + +private struct WelcomePage: View { + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + ZStack { + Circle() + .fill(Palette.accentMuted) + .frame(width: 180, height: 180) + .blur(radius: 30) + Image(systemName: "mic.circle.fill") + .font(.system(size: 96, weight: .light)) + .foregroundStyle(Palette.accent) + } + VStack(spacing: Spacing.sm) { + Text("OSGKeyboard") + .font(TypeStyle.title) + .foregroundStyle(Palette.textPrimary) + Text("按住说话,松开即得润色文字。") + .font(TypeStyle.body) + .foregroundStyle(Palette.textSecondary) + .multilineTextAlignment(.center) + Text("Hold to talk. Release for polished text, in any app.") + .font(TypeStyle.footnote) + .foregroundStyle(Palette.textTertiary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, Spacing.xl) + PrivacyFootnote() + .padding(.top, Spacing.lg) + Spacer() + } + } +} + +private struct PrivacyFootnote: View { + var body: some View { + VStack(alignment: .leading, spacing: 8) { + footnoteRow(icon: "lock.fill", + title: "Audio stays on device", + body: "Transcribed locally with Apple's speech engine.") + footnoteRow(icon: "wifi", + title: "Only the polished text is sent", + body: "Sent to your chosen LLM to add structure & punctuation.") + footnoteRow(icon: "keyboard", + title: "Works everywhere", + body: "WeChat, Notes, Mail, ChatGPT, Claude, Cursor — anywhere a keyboard appears.") + } + .padding(Spacing.md) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + .padding(.horizontal, Spacing.md) + } + + private func footnoteRow(icon: String, title: String, body: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + Image(systemName: icon) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Palette.accent) + .frame(width: 24, height: 24) + .background(Palette.accentMuted, in: Circle()) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + Text(body) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textSecondary) + } + } + } +} + +// MARK: - Page 2: Enable keyboard + +private struct EnableKeyboardPage: View { + var body: some View { + VStack(spacing: Spacing.xl) { + Spacer() + Image(systemName: "keyboard.fill") + .font(.system(size: 64, weight: .light)) + .foregroundStyle(Palette.accent) + VStack(spacing: Spacing.sm) { + Text("启用 OSGKeyboard") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Enable OSGKeyboard") + .font(TypeStyle.body) + .foregroundStyle(Palette.textTertiary) + } + VStack(alignment: .leading, spacing: Spacing.sm) { + step(num: 1, text: "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”") + step(num: 4, text: "Allow Full Access is required for the microphone and LLM calls.") + } + .cardSurface() + .padding(.horizontal, Spacing.md) + + Button { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } label: { + Label("打开 iOS 设置 · Open Settings", systemImage: "arrow.up.right.square") + .primaryButton() + } + .padding(.horizontal, Spacing.md) + Spacer() + } + } + + private func step(num: Int, text: String) -> some View { + HStack(alignment: .top, spacing: Spacing.xs) { + Text("\(num)") + .font(TypeStyle.caption2) + .frame(width: 22, height: 22) + .background(Palette.accent, in: Circle()) + .foregroundStyle(Palette.textOnAccent) + Text(text) + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +// MARK: - Page 3: API setup + +private struct APISetupPage: View { + @ObservedObject var config: ProviderConfig + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text("配置 AI 提供商") + .font(TypeStyle.title2) + .foregroundStyle(Palette.textPrimary) + Text("Configure your AI provider") + .font(TypeStyle.body) + .foregroundStyle(Palette.textTertiary) + Text("OSGKeyboard only calls the AI to polish your text. No audio leaves your device.") + .font(TypeStyle.footnote) + .foregroundStyle(Palette.textSecondary) + .padding(.top, Spacing.xxs) + } + .padding(.horizontal, Spacing.md) + .padding(.top, Spacing.lg) + + ProviderPickerSection(config: config) + .padding(.horizontal, Spacing.md) + + APISettingsCard(config: config) + .padding(.horizontal, Spacing.md) + } + .padding(.bottom, Spacing.xxxl) + } + } +} diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift new file mode 100644 index 0000000..60a60a2 --- /dev/null +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -0,0 +1,74 @@ +// ProviderPickerSection.swift +// OSGKeyboard · Main App +// +// Provider picker shown inline inside Settings & Onboarding. Each option +// surfaces the provider's name + a short blurb so the user can pick +// confidently without opening a doc. + +import SwiftUI +import OSGKeyboardShared + +struct ProviderPickerSection: View { + @ObservedObject var config: ProviderConfig + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in + Button { + select(provider) + } label: { + row(provider, selected: provider.id == config.providerId) + } + .buttonStyle(.plain) + if index < LLMProvider.presets.count - 1 { + Divider().background(Palette.divider) + } + } + } + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + private func select(_ provider: LLMProvider) { + withAnimation(Motion.quick) { + config.apply(preset: provider) + } + } + + @ViewBuilder + private func row(_ provider: LLMProvider, selected: Bool) -> some View { + HStack(spacing: Spacing.xs) { + // Provider mark — a coloured dot with first letter + ZStack { + Circle() + .fill(selected ? Palette.accent : Palette.surfaceElevated) + .frame(width: 36, height: 36) + Text(String(provider.name.prefix(1))) + .font(TypeStyle.bodyEmph) + .foregroundStyle(selected ? Palette.textOnAccent : Palette.textPrimary) + } + VStack(alignment: .leading, spacing: 2) { + Text(provider.name) + .font(TypeStyle.bodyEmph) + .foregroundStyle(Palette.textPrimary) + if let blurb = provider.blurb { + Text(blurb) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textTertiary) + } + } + Spacer() + if selected { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(Palette.accent) + } + } + .padding(.horizontal, Spacing.md) + .padding(.vertical, Spacing.sm) + .contentShape(Rectangle()) + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift new file mode 100644 index 0000000..3e3c131 --- /dev/null +++ b/OSGKeyboard/Views/SettingsView.swift @@ -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 ?? "—" + } +} diff --git a/OpenLessKeyboard/Info.plist b/OSGKeyboardExt/Info.plist similarity index 100% rename from OpenLessKeyboard/Info.plist rename to OSGKeyboardExt/Info.plist diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift new file mode 100644 index 0000000..9c917d2 --- /dev/null +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -0,0 +1,392 @@ +// KeyboardViewController.swift +// OSGKeyboard · Keyboard Extension +// +// Principal class for the Custom Keyboard Extension. Hosts a single +// SwiftUI tree (`KeyboardRootView`) and drives the recording pipeline: +// +// AudioCaptureService ──► ASRService ──► PolishingService ──► insertText +// +// Design notes: +// • The class is `@MainActor` — every UI mutation and `textDocumentProxy` +// call must happen on main, and Swift 6 strict concurrency forces this. +// • State is a single `State` ObservableObject; SwiftUI observes it via +// `@ObservedObject` so we never re-create the hosting root on each tick. +// • `phase` is a real stored property (no derivation) — the previous +// "derive from recordStream" shim locked out every press after the first. +// • Microphone permission is requested *inside* pressBegan, but we still +// start the rest of the press flow optimistically; if permission is +// denied we surface a short error and drop back to idle cleanly. + +import UIKit +import SwiftUI +import AVFoundation +import OSGKeyboardShared + +@objc(KeyboardViewController) +@MainActor +public final class KeyboardViewController: UIInputViewController { + + // MARK: - View model + + @MainActor + public final class State: ObservableObject { + public init() {} + public enum Phase: Equatable { + case idle + case recording + case processing + case error(String) + } + + public enum InputMode: String, CaseIterable, Identifiable { + case off + case transcribe + case polish + + public var id: String { rawValue } + + public var labelKey: String { + switch self { + case .off: return "mode.off" + case .transcribe: return "mode.transcribe" + case .polish: return "mode.polish" + } + } + } + + @Published public var phase: Phase = .idle + @Published public var level: Double = 0 + @Published public var mode: InputMode = .polish + @Published public var localeId: String = "auto" + @Published public var lastTranscript: String = "" + + // Action hooks — injected by the view controller at install time. + var beginRecording: () -> Void = {} + var endRecording: () -> Void = {} + var tapMic: () -> Void = {} // tap on the mic area (advances keyboard) + var openSettings: () -> Void = {} + var setMode: (InputMode) -> Void = { _ in } + var setLocale: (String) -> Void = { _ in } + var insertNewline: () -> Void = {} + var insertSpace: () -> Void = {} + var deleteBackward: () -> Void = {} + + // MARK: - Preview helpers + + #if DEBUG + static var previewIdle: State { + let s = State() + s.phase = .idle + s.level = 0 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "" + return s + } + static var previewRecording: State { + let s = State() + s.phase = .recording + s.level = 0.65 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "你好,我想说一段测试" + return s + } + static var previewProcessing: State { + let s = State() + s.phase = .processing + s.level = 0 + s.mode = .polish + s.localeId = "zh-Hans" + s.lastTranscript = "" + return s + } + #endif + } + + // MARK: - State + + private let state = State() + private let audio = AudioCaptureService() + private let asr: ASRService = ASRServiceFactory.make() + private let polisher = PolishingService() + + private var session: AudioCaptureService.Session? + private var asrTask: Task? + private var levelTask: Task? + private var didRequestMicOnce: Bool = false + + private var hosting: UIHostingController! + + // MARK: - Lifecycle + + public override func viewDidLoad() { + super.viewDidLoad() + // iOS 18 keyboard extension MUST opt in to self-sizing, otherwise + // our SwiftUI `frame(height:)` is ignored and the keyboard is + // cropped by the system chrome (Spotlight bar, home indicator). + inputView?.allowsSelfSizing = true + installStateActions() + installSwiftUI() + loadPersistedLocale() + } + + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + cancelPipeline() + } + + public override func didReceiveMemoryWarning() { + super.didReceiveMemoryWarning() + cancelPipeline() + } + + public override func textDidChange(_ textInput: (any UITextInput)?) { + super.textDidChange(textInput) + // Hook for future per-app mode switching (e.g. password field → .off). + } + + // MARK: - Wiring + + private func installStateActions() { + state.beginRecording = { [weak self] in self?.pressBegan() } + state.endRecording = { [weak self] in self?.pressEnded() } + state.tapMic = { [weak self] in self?.advanceToNextInputMode() } + state.openSettings = { [weak self] in self?.openHostApp() } + state.setMode = { [weak self] m in self?.persistMode(m) } + state.setLocale = { [weak self] l in self?.persistLocale(l) } + state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") } + state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") } + state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() } + } + + private func installSwiftUI() { + let root = KeyboardRootView(state: state) + let host = UIHostingController(rootView: root) + host.view.backgroundColor = .clear + host.view.translatesAutoresizingMaskIntoConstraints = false + addChild(host) + view.addSubview(host.view) + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + // Pin the host view to a fixed height matching KeyboardRootView.totalHeight. + // Without this, iOS lets the system chrome (Spotlight, home + // indicator) bleed into our content. With it, our content area + // is fully reserved and the keyboard feels intentional. + host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight) + ]) + host.didMove(toParent: self) + self.hosting = host + } + + private func loadPersistedLocale() { + let store = AppGroupStore() + let id = store.localeId + state.localeId = id + state.mode = State.InputMode(rawValue: store.modeId) ?? .polish + } + + // MARK: - Press handlers + + private func pressBegan() { + guard state.phase == .idle else { return } + guard state.mode != .off else { return } + // We optimistically enter `.recording`; the capture session will yield + // frames on its own queue, so even if mic permission takes a beat the + // user already feels the press registered. + Task { @MainActor [weak self] in + guard let self else { return } + let granted = await self.requestMicPermission() + guard granted else { + self.state.phase = .error("麦克风被拒绝,请到「设置」中允许") + self.scheduleAutoClearError() + return + } + self.startPipeline() + } + } + + private func pressEnded() { + guard state.phase == .recording else { return } + stopPipeline() + } + + // MARK: - Pipeline + + private func startPipeline() { + let session = audio.start() + self.session = session + state.phase = .recording + state.level = 0 + state.lastTranscript = "" + + let locale = resolveLocale(state.localeId) + let events = asr.transcribe(stream: session.audio, locale: locale) + + asrTask = Task { @MainActor [weak self] in + guard let self else { return } + var lastPartial: String = "" + for await event in events { + switch event { + case .partial(let s): + lastPartial = s + self.state.lastTranscript = s + case .final(let s): + let transcript = s.isEmpty ? lastPartial : s + self.handleFinalTranscript(transcript) + case .error(let m): + self.state.phase = .error("ASR: \(m)") + self.scheduleAutoClearError() + } + } + } + + levelTask = Task { @MainActor [weak self] in + for await level in session.levels { + guard let self else { return } + // Smooth a little extra to feel natural. + self.state.level = Double(self.state.level) * 0.6 + Double(level.meter) * 0.4 + } + } + } + + private func stopPipeline() { + session?.stop() + session = nil + asrTask?.cancel(); asrTask = nil + levelTask?.cancel(); levelTask = nil + } + + private func cancelPipeline() { + stopPipeline() + asr.cancel() + if state.phase == .recording || state.phase == .processing { + state.phase = .idle + } + state.level = 0 + } + + private func handleFinalTranscript(_ transcript: String) { + let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + state.phase = .idle + return + } + // In `.transcribe` mode, skip the LLM and insert raw. + if state.mode == .transcribe { + textDocumentProxy.insertText(trimmed) + state.lastTranscript = "" + state.phase = .idle + return + } + // `.polish` (default): call the LLM. + state.phase = .processing + Task { @MainActor [weak self] in + guard let self else { return } + do { + let polished = try await self.polisher.polish(trimmed) + self.textDocumentProxy.insertText(polished) + self.state.lastTranscript = "" + self.state.phase = .idle + } catch { + // Fall back to raw transcript on any failure. + self.textDocumentProxy.insertText(trimmed) + self.state.lastTranscript = "" + let msg = (error as? LocalizedError)?.errorDescription + ?? "Polishing failed — inserted raw." + self.state.phase = .error(msg) + self.scheduleAutoClearError() + } + } + } + + // MARK: - Persistence + + private func persistMode(_ m: State.InputMode) { + state.mode = m + AppGroupStore().setModeId(m.rawValue) + } + + private func persistLocale(_ id: String) { + state.localeId = id + AppGroupStore().setLocaleId(id) + } + + // MARK: - Permissions + + private func requestMicPermission() async -> Bool { + if #available(iOS 17.0, *) { + switch AVAudioApplication.shared.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + if !didRequestMicOnce { + didRequestMicOnce = true + return await AVAudioApplication.requestRecordPermission() + } + return false + @unknown default: return false + } + } else { + let session = AVAudioSession.sharedInstance() + switch session.recordPermission { + case .granted: return true + case .denied: return false + case .undetermined: + if !didRequestMicOnce { + didRequestMicOnce = true + return await withCheckedContinuation { cont in + session.requestRecordPermission { cont.resume(returning: $0) } + } + } + return false + @unknown default: return false + } + } + } + + // MARK: - Open host app + + private func openHostApp() { + let urlString = "osgkeyboard://settings" + if let url = URL(string: urlString) { + var responder: UIResponder? = self + while let r = responder { + if let app = r as? UIApplication { + app.open(url) + return + } + responder = r.next + } + } + if let url = URL(string: UIApplication.openSettingsURLString) { + var responder: UIResponder? = self + while let r = responder { + if let app = r as? UIApplication { + app.open(url); return + } + responder = r.next + } + } + } + + // MARK: - Helpers + + private func resolveLocale(_ id: String) -> Locale { + if id == "auto" { return .current } + return Locale(identifier: id) + } + + private func scheduleAutoClearError() { + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 2_400_000_000) + guard let self else { return } + if case .error = self.state.phase { + self.state.phase = .idle + } + } + } +} diff --git a/OpenLessKeyboard/OpenLessKeyboard.entitlements b/OSGKeyboardExt/OSGKeyboardExt.entitlements similarity index 56% rename from OpenLessKeyboard/OpenLessKeyboard.entitlements rename to OSGKeyboardExt/OSGKeyboardExt.entitlements index 0c67376..9d4ffbc 100644 --- a/OpenLessKeyboard/OpenLessKeyboard.entitlements +++ b/OSGKeyboardExt/OSGKeyboardExt.entitlements @@ -1,5 +1,10 @@ - + + com.apple.security.application-groups + + group.com.osgkeyboard.shared + + diff --git a/OpenLessKeyboard/PrivacyInfo.xcprivacy b/OSGKeyboardExt/PrivacyInfo.xcprivacy similarity index 100% rename from OpenLessKeyboard/PrivacyInfo.xcprivacy rename to OSGKeyboardExt/PrivacyInfo.xcprivacy diff --git a/OSGKeyboardExt/Services/ASRService.swift b/OSGKeyboardExt/Services/ASRService.swift new file mode 100644 index 0000000..7515e6d --- /dev/null +++ b/OSGKeyboardExt/Services/ASRService.swift @@ -0,0 +1,151 @@ +// ASRService.swift +// OSGKeyboard · Keyboard Extension +// +// Speech-to-text abstraction over Apple's `SFSpeechRecognizer`. +// Honours a user-selected locale (auto / zh-CN / en-US / ja-JP …) so +// dictation is first-class for non-English languages. + +import Foundation +import AVFoundation +import Speech +import os.lock +import OSGKeyboardShared + +// MARK: - Sendable conformance + +// `AVAudioPCMBuffer` and `SFSpeechRecognitionTask` are not Sendable. We +// only ever access them serially — the PCM buffer is built and consumed +// inside a single Task, and the recogniser task is cancelled but never +// shared concurrently — so an unchecked conformance is sound here. +extension AVAudioPCMBuffer: @unchecked @retroactive Sendable {} +extension SFSpeechRecognitionTask: @unchecked @retroactive Sendable {} + +// MARK: - Protocol + +public protocol ASRService: Sendable { + /// Start a transcription session. The returned stream emits `.partial` + /// updates and exactly one `.final` (or `.error`) before finishing. + func transcribe( + stream: AsyncStream, + locale: Locale + ) -> AsyncStream + + /// Cancel any in-flight recognition and tear down its tasks. + func cancel() +} + +public enum ASREvent: Sendable, Equatable { + case partial(String) + case final(String) + case error(String) +} + +// MARK: - Factory + +public enum ASRServiceFactory { + public static func make() -> ASRService { + AppleSpeechASR() + } +} + +// MARK: - Apple Speech implementation + +final class AppleSpeechASR: ASRService, @unchecked Sendable { + + private let lock = OSAllocatedUnfairLock() + private var recognizerTask: SFSpeechRecognitionTask? + private var feedTask: Task? + + func transcribe( + stream: AsyncStream, + locale: Locale + ) -> AsyncStream { + AsyncStream { continuation in + let recognizer = SFSpeechRecognizer(locale: locale) + ?? SFSpeechRecognizer(locale: .current) + guard let recognizer, recognizer.isAvailable else { + continuation.yield(.error("Speech recognizer unavailable for \(locale.identifier)")) + continuation.finish() + return + } + recognizer.defaultTaskHint = .dictation + + let request = SFSpeechAudioBufferRecognitionRequest() + request.shouldReportPartialResults = true + request.requiresOnDeviceRecognition = recognizer.supportsOnDeviceRecognition + + let task = recognizer.recognitionTask(with: request) { result, error in + if let error { + let nsErr = error as NSError + // Codes 203 / 1110 = "no speech detected" — a normal exit. + if nsErr.code == 203 || nsErr.code == 1110 { + continuation.yield(.final("")) + } else { + continuation.yield(.error(error.localizedDescription)) + } + continuation.finish() + return + } + guard let result else { return } + if result.isFinal { + continuation.yield(.final(result.bestTranscription.formattedString)) + continuation.finish() + } else { + continuation.yield(.partial(result.bestTranscription.formattedString)) + } + } + + self.lock.withLock { self.recognizerTask = task } + + // Feed audio: for each snapshot, build a 16 kHz mono Float32 + // PCM buffer and immediately `request.append(pcm)`. The PCM + // buffer never leaves this task, so it doesn't need to be + // Sendable. + let feedFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + self.feedTask = Task { [request] in + for await snap in stream { + if Task.isCancelled { break } + guard !snap.samples.isEmpty, + let pcm = AVAudioPCMBuffer( + pcmFormat: feedFormat, + frameCapacity: AVAudioFrameCount(snap.samples.count) + ) + else { continue } + pcm.frameLength = AVAudioFrameCount(snap.samples.count) + if let dst = pcm.floatChannelData?[0] { + snap.samples.withUnsafeBufferPointer { src in + if let base = src.baseAddress { + memcpy(dst, base, snap.samples.count * MemoryLayout.size) + } + } + } + request.append(pcm) + } + if !Task.isCancelled { + request.endAudio() + } + } + + continuation.onTermination = { @Sendable [weak self] _ in + self?.cancel() + } + } + } + + func cancel() { + let (recTask, feedT) = lock.withLock { () -> (SFSpeechRecognitionTask?, Task?) in + let r = self.recognizerTask + let f = self.feedTask + self.recognizerTask = nil + self.feedTask = nil + return (r, f) + } + recTask?.cancel() + feedT?.cancel() + } +} diff --git a/OSGKeyboardExt/Services/AudioCaptureService.swift b/OSGKeyboardExt/Services/AudioCaptureService.swift new file mode 100644 index 0000000..3d5ef0a --- /dev/null +++ b/OSGKeyboardExt/Services/AudioCaptureService.swift @@ -0,0 +1,315 @@ +// AudioCaptureService.swift +// OSGKeyboard · Keyboard Extension +// +// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine. +// Designed for use inside an iOS Custom Keyboard Extension: +// • Uses `.record` (not `.playAndRecord`) — keyboards cannot play. +// • Exposes a Sendable `Session` with two streams: +// - `audio`: 16 kHz mono Float32 frames for ASR. +// - `levels`: RMS + peak dBFS for the animated waveform. +// • All mutable state is guarded by a lock; class is `@unchecked Sendable` +// for use with Swift 6 strict concurrency. + +import Foundation +import AVFoundation +import os.lock +import OSGKeyboardShared + +// MARK: - Sendable conformance +// +// `AVAudioEngine` is not Sendable, but the iOS audio APIs hand us +// closures that need to capture it. We never mutate the engine +// concurrently — capture / conversion are serialised on the actor, and +// the tap closure only reads pointers into it. So an unchecked +// retroactive Sendable conformance is sound here. +// `AVAudioConverter` and `AVAudioFormat` are already Sendable in newer +// SDKs; we don't need to redeclare. +extension AVAudioEngine: @unchecked @retroactive Sendable {} + +public final class AudioCaptureService: @unchecked Sendable { + + // MARK: - Errors + + public enum CaptureError: LocalizedError, Sendable { + case sessionConfigFailed(String) + case engineStartFailed(String) + case noInputNode + case alreadyRunning + + public var errorDescription: String? { + switch self { + case .sessionConfigFailed(let s): return "Audio session config failed: \(s)" + case .engineStartFailed(let s): return "Audio engine failed to start: \(s)" + case .noInputNode: return "No microphone input available." + case .alreadyRunning: return "Audio capture is already running." + } + } + } + + // MARK: - Level payload (Sendable) + + public struct Level: Sendable, Equatable { + /// Root-mean-square, 0...1 (linear). + public let rms: Float + /// Peak amplitude, 0...1 (linear). + public let peak: Float + public let timestamp: TimeInterval + + /// Convenience: -20 dBFS … 0 dBFS mapped to 0…1 for UI meters. + public var meter: Float { + // Clamp floor at -50 dB so silence still shows a tiny bar. + let db = 20 * log10(max(rms, 1e-7)) + let clamped = max(-50, min(0, db)) + return Float((clamped + 50) / 50) + } + } + + // MARK: - Session (one capture run) + + /// A single capture run. Two streams + a stop handle. + public final class Session: @unchecked Sendable { + public let audio: AsyncStream + public let levels: AsyncStream + private let onStop: @Sendable () -> Void + + fileprivate init( + audio: AsyncStream, + levels: AsyncStream, + onStop: @escaping @Sendable () -> Void + ) { + self.audio = audio + self.levels = levels + self.onStop = onStop + } + + public func stop() { onStop() } + } + + // MARK: - State + + private let lock = OSAllocatedUnfairLock() + private var engine: AVAudioEngine? + private var converter: AVAudioConverter? + private var audioContinuation: AsyncStream.Continuation? + private var levelContinuation: AsyncStream.Continuation? + private var isRunning: Bool = false + + public init() {} + + deinit { stopInternal() } + + // MARK: - Public API + + /// Start capture. Returns a `Session` whose streams yield audio + level data. + /// The session ends when `Session.stop()` is called or the extension is torn down. + @discardableResult + public func start() -> Session { + let (audioStream, audioCont) = AsyncStream.makeStream() + let (levelStream, levelCont) = AsyncStream.makeStream() + + // Fail fast if already running. + let alreadyRunning: Bool = lock.withLock { isRunning } + if alreadyRunning { + audioCont.finish() + levelCont.finish() + return Session(audio: audioStream, levels: levelStream, onStop: {}) + } + + do { + try configureSession() + try bootstrap(audioCont: audioCont, levelCont: levelCont) + lock.withLock { + isRunning = true + audioContinuation = audioCont + levelContinuation = levelCont + } + } catch { + // Tear down whatever we partially created. + audioCont.finish() + levelCont.finish() + teardownEngine() + return Session(audio: audioStream, levels: levelStream, onStop: {}) + } + + return Session( + audio: audioStream, + levels: levelStream, + onStop: { [weak self] in self?.stop() } + ) + } + + public func stop() { + stopInternal() + } + + // MARK: - Setup + + private func configureSession() throws { + #if canImport(UIKit) + let session = AVAudioSession.sharedInstance() + do { + // `.record` — keyboards cannot play audio, so .playAndRecord is wrong. + // `.measurement` mode disables system AGC/echo cancellation for cleaner ASR input. + // `.duckOthers` is harmless in record-only. + try session.setCategory(.record, mode: .measurement, options: [.duckOthers]) + try session.setActive(true, options: .notifyOthersOnDeactivation) + } catch { + throw CaptureError.sessionConfigFailed(error.localizedDescription) + } + #endif + } + + private func bootstrap( + audioCont: AsyncStream.Continuation, + levelCont: AsyncStream.Continuation + ) throws { + let engine = AVAudioEngine() + let input = engine.inputNode + let hardwareFormat = input.outputFormat(forBus: 0) + guard hardwareFormat.sampleRate > 0, hardwareFormat.channelCount > 0 else { + throw CaptureError.noInputNode + } + + let target = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + + guard let converter = AVAudioConverter(from: hardwareFormat, to: target) else { + throw CaptureError.engineStartFailed("converter init failed") + } + + // Persistent buffer for level computation: we re-use Float arrays to + // avoid per-tap allocations. + let levelScratch = LevelScratch() + + input.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, when in + // We capture self weakly only to keep the AudioCaptureService + // alive while the tap is installed; the tap itself only + // touches the local `audioCont` / `levelCont` continuations. + guard self != nil else { return } + // 1) Compute level from raw hardware buffer (preserves true amplitude). + let (rms, peak) = levelScratch.measure(buffer: buffer) + // `AVAudioTime` carries both `sampleTime` (frames on the device + // clock) and `hostTime` (mach absolute time). We only need a + // monotonically increasing source for the timestamp; sample + // time / sample rate is good enough and is independent of the + // host clock. + let ts: Double + if when.sampleTime > 0, hardwareFormat.sampleRate > 0 { + ts = Double(when.sampleTime) / hardwareFormat.sampleRate + } else { + ts = Date().timeIntervalSinceReferenceDate + } + levelCont.yield(Level(rms: rms, peak: peak, timestamp: ts)) + + // 2) Convert to 16 kHz mono Float32 for ASR. + let outFrames = AVAudioFrameCount( + Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate + ) + guard outFrames > 0, + let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) + else { return } + + var error: NSError? + let status = converter.convert(to: outBuffer, error: &error) { _, outStatus in + outStatus.pointee = .haveData + return buffer + } + if status == .haveData, error == nil { + let snap = AudioBufferSnapshot(buffer: outBuffer) + audioCont.yield(snap) + } + } + + do { + try engine.start() + } catch { + input.removeTap(onBus: 0) + throw CaptureError.engineStartFailed(error.localizedDescription) + } + + lock.withLock { + self.engine = engine + self.converter = converter + } + } + + // MARK: - Teardown + + private func stopInternal() { + let (wasRunning, engine, audioCont, levelCont) = lock.withLock { () -> (Bool, AVAudioEngine?, AsyncStream.Continuation?, AsyncStream.Continuation?) in + let was = isRunning + isRunning = false + let eng = self.engine + let ac = audioContinuation + let lc = levelContinuation + self.engine = nil + self.converter = nil + self.audioContinuation = nil + self.levelContinuation = nil + return (was, eng, ac, lc) + } + guard wasRunning else { return } + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + #if canImport(UIKit) + // Deactivate the session so other apps' audio routing is restored. + try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + #endif + audioCont?.finish() + levelCont?.finish() + } + + private func teardownEngine() { + let (engine, audioCont, levelCont) = lock.withLock { () -> (AVAudioEngine?, AsyncStream.Continuation?, AsyncStream.Continuation?) in + let e = self.engine + let a = audioContinuation + let l = levelContinuation + self.engine = nil + self.converter = nil + self.audioContinuation = nil + self.levelContinuation = nil + self.isRunning = false + return (e, a, l) + } + engine?.inputNode.removeTap(onBus: 0) + engine?.stop() + audioCont?.finish() + levelCont?.finish() + } +} + +// MARK: - Level scratch (lock-free, single-writer / single-reader per tap) + +/// Lock-free per-tap scratch for RMS + peak measurement. The AVAudio tap is +/// always invoked serially per input node, so we don't need a lock here. +private final class LevelScratch: @unchecked Sendable { + private var last: (rms: Float, peak: Float) = (0, 0) + + func measure(buffer: AVAudioPCMBuffer) -> (rms: Float, peak: Float) { + guard let ch = buffer.floatChannelData?[0] else { return last } + let n = Int(buffer.frameLength) + guard n > 0 else { return last } + + // Decay smoothing — keeps the meter lively but not jittery. + var sumSq: Float = 0 + var peak: Float = 0 + for i in 0.. peak { peak = a } + } + let rms = sqrtf(sumSq / Float(n)) + + // Exponential moving average for visual smoothness. + let alpha: Float = 0.35 + let smoothedRms = alpha * rms + (1 - alpha) * last.rms + let smoothedPeak = max(alpha * peak, (1 - alpha) * last.peak) + last = (smoothedRms, smoothedPeak) + return (smoothedRms, smoothedPeak) + } +} diff --git a/OpenLessKeyboard/Services/PolishingService.swift b/OSGKeyboardExt/Services/PolishingService.swift similarity index 98% rename from OpenLessKeyboard/Services/PolishingService.swift rename to OSGKeyboardExt/Services/PolishingService.swift index 165c24b..70c5561 100644 --- a/OpenLessKeyboard/Services/PolishingService.swift +++ b/OSGKeyboardExt/Services/PolishingService.swift @@ -18,7 +18,7 @@ public actor PolishingService { private let store: AppGroupStore private let timeout: TimeInterval - public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 8) { + public init(store: AppGroupStore = AppGroupStore(), timeout: TimeInterval = 12) { self.store = store self.timeout = timeout } diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift new file mode 100644 index 0000000..78ab68f --- /dev/null +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -0,0 +1,402 @@ +// KeyboardRootView.swift +// OSGKeyboard · Keyboard Extension +// +// Typeless-inspired keyboard surface. The keyboard is laid out in three +// vertical bands, but the entire height is reserved for us — we set +// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's +// frame is honoured, and we add safe-area insets at the top and bottom so +// the system Spotlight / home-indicator chrome never clips our controls. +// +// ┌───────────────────────────────────────────┐ +// │ [polish] [中] ● ⚙ │ ← top: 32 pt (incl. safe top) +// │ (transcript preview) │ ← 24 pt +// │ │ +// │ ◯ ▲▲▲▲▲ │ ← centre: 96 pt disc + +// │ │ breathing ring +// │ │ +// ├───────────────────────────────────────────┤ +// │ 🌐 ⌫ [ space ] ↩ │ ← bottom: 60 pt (incl. safe bottom) +// └───────────────────────────────────────────┘ + +import SwiftUI +import OSGKeyboardShared + +public struct KeyboardRootView: View { + + @ObservedObject var state: State + + public init(state: KeyboardViewController.State) { + self.state = state + } + + /// Total keyboard height. We set the same value as a height-anchor + /// constraint in the view controller so the host UIInputView picks + /// it up. + static let totalHeight: CGFloat = 280 + + public var body: some View { + ZStack(alignment: .top) { + background + + VStack(spacing: 0) { + topBar + .frame(height: 32) + + centreArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + + bottomBar + .frame(height: 56) + } + .padding(.top, 4) + .padding(.bottom, 6) + } + .frame(height: Self.totalHeight) + .preferredColorScheme(.dark) + } + + // MARK: - Background + + /// Solid dark fill plus a hairline highlight at the top edge, so the + /// keyboard reads as a physical surface rather than a floating card. + private var background: some View { + ZStack { + Palette.background + VStack(spacing: 0) { + Rectangle() + .fill( + LinearGradient( + colors: [Color.white.opacity(0.05), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + .frame(height: 1) + Spacer(minLength: 0) + } + } + .overlay(alignment: .top) { + Rectangle() + .fill(Palette.divider) + .frame(height: 0.5) + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: Spacing.xs) { + ModeChip(mode: state.mode) { newMode in + state.setMode(newMode) + } + LocaleChip(localeId: state.localeId) { newId in + state.setLocale(newId) + } + Spacer(minLength: 0) + StatusBadge(phase: state.phase) + Button(action: state.openSettings) { + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Palette.textSecondary) + .frame(width: 28, height: 28) + .background(Palette.surface, in: Circle()) + .overlay(Circle().stroke(Palette.divider, lineWidth: 0.5)) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Open OSGKeyboard settings")) + } + .padding(.horizontal, Spacing.md) + } + + // MARK: - Centre area + + private var centreArea: some View { + ZStack { + VStack(spacing: Spacing.xxs) { + TranscriptLine(phase: state.phase, transcript: state.lastTranscript) + .frame(height: 22) + RecordButton( + phase: buttonPhase, + level: state.level, + onPressBegan: state.beginRecording, + onPressEnded: state.endRecording, + onTap: state.tapMic + ) + .frame(width: 140, height: 140) + } + } + .frame(maxWidth: .infinity) + } + + // MARK: - Bottom bar + + private var bottomBar: some View { + HStack(spacing: Spacing.xxs) { + ToolbarIconButton(systemName: "globe", label: "nextKeyboard") { + state.tapMic() + } + ToolbarIconButton(systemName: "delete.left", label: "delete") { + state.deleteBackward() + } + Spacer(minLength: 0) + Button(action: state.insertSpace) { + Text("空格") + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + .frame(maxWidth: .infinity, minHeight: 42) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(Text("Space")) + Spacer(minLength: 0) + ToolbarIconButton(systemName: "return", label: "newline") { + state.insertNewline() + } + } + .padding(.horizontal, Spacing.sm) + } + + private var buttonPhase: RecordButton.Phase { + switch state.phase { + case .idle: return .idle + case .recording: return .recording + case .processing: return .processing + case .error: return .error + } + } +} + +// MARK: - State alias + +extension KeyboardRootView { + typealias State = KeyboardViewController.State +} + +// MARK: - SwiftUI Preview + +#if DEBUG +#Preview("Keyboard · Idle") { + KeyboardRootView(state: KeyboardViewController.State.previewIdle) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} + +#Preview("Keyboard · Recording") { + KeyboardRootView(state: KeyboardViewController.State.previewRecording) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} + +#Preview("Keyboard · Processing") { + KeyboardRootView(state: KeyboardViewController.State.previewProcessing) + .frame(width: 390, height: 280) + .preferredColorScheme(.dark) +} +#endif + +// MARK: - Transcript line + +private struct TranscriptLine: View { + let phase: KeyboardViewController.State.Phase + let transcript: String + + var body: some View { + ZStack { + switch phase { + case .idle: + Text("按住说话 · Hold to talk") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textTertiary) + case .recording: + Text(transcript.isEmpty ? " " : transcript) + .font(TypeStyle.caption) + .foregroundStyle(Palette.textPrimary) + .lineLimit(1) + .truncationMode(.head) + .frame(maxWidth: .infinity) + case .processing: + HStack(spacing: 6) { + ProgressView().controlSize(.mini).tint(Palette.accent) + Text("润色中 · Polishing") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } + case .error(let msg): + Text(msg) + .font(TypeStyle.caption) + .foregroundStyle(Palette.warning) + .lineLimit(1) + .truncationMode(.tail) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, Spacing.md) + } +} + +// MARK: - Toolbar icon button + +private struct ToolbarIconButton: View { + let systemName: String + let label: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + .frame(width: 40, height: 40) + .background(Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.medium, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(Text(label)) + } +} + +// MARK: - Status badge + +private struct StatusBadge: View { + let phase: KeyboardViewController.State.Phase + + var body: some View { + Group { + switch phase { + case .idle: + EmptyView() + case .recording: + dot(color: Palette.recordRed, label: "REC") + case .processing: + dot(color: Palette.accent, label: "···") + case .error: + dot(color: Palette.warning, label: "!") + } + } + } + + private func dot(color: Color, label: String) -> some View { + HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 6, height: 6) + Text(label) + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textSecondary) + } + .padding(.horizontal, Spacing.xs) + .padding(.vertical, 3) + .background(Palette.surface, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } +} + +// MARK: - Mode chip + +private struct ModeChip: View { + let mode: KeyboardViewController.State.InputMode + let onChange: (KeyboardViewController.State.InputMode) -> Void + + var body: some View { + Menu { + ForEach(KeyboardViewController.State.InputMode.allCases) { m in + Button { + onChange(m) + } label: { + if m == mode { + Label(label(for: m), systemImage: "checkmark") + } else { + Text(label(for: m)) + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: icon(for: mode)) + Text(label(for: mode)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(mode == .off ? Palette.textTertiary : Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .menuStyle(.button) + } + + private func label(for m: KeyboardViewController.State.InputMode) -> String { + switch m { + case .off: return "Off" + case .transcribe: return "转写" + case .polish: return "润色" + } + } + + private func icon(for m: KeyboardViewController.State.InputMode) -> String { + switch m { + case .off: return "mic.slash.fill" + case .transcribe: return "text.bubble.fill" + case .polish: return "wand.and.stars" + } + } +} + +// MARK: - Locale chip + +private struct LocaleChip: View { + let localeId: String + let onChange: (String) -> Void + + private let options: [(id: String, label: String)] = [ + ("auto", "Auto"), + ("zh-Hans", "简体"), + ("zh-Hant", "繁體"), + ("en-US", "EN"), + ("ja-JP", "日"), + ("ko-KR", "한") + ] + + var body: some View { + Menu { + ForEach(options, id: \.id) { o in + Button { + onChange(o.id) + } label: { + if o.id == localeId { + Label(o.label, systemImage: "checkmark") + } else { + Text(o.label) + } + } + } + } label: { + HStack(spacing: 4) { + Image(systemName: "globe") + Text(currentLabel) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(Palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(Palette.divider, lineWidth: 0.5)) + } + .menuStyle(.button) + } + + private var currentLabel: String { + options.first(where: { $0.id == localeId })?.label ?? "Auto" + } +} diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift new file mode 100644 index 0000000..2e57659 --- /dev/null +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -0,0 +1,187 @@ +// RecordButton.swift +// OSGKeyboard · Keyboard Extension +// +// The hero control. 120 pt primary disc with a soft inner gradient, a +// breathing outer ring while recording, and a centred waveform that maps +// directly to the real audio RMS. Idle / recording / processing are three +// distinct visual states — no flicker, no surprise transitions. + +import SwiftUI +import OSGKeyboardShared + +struct RecordButton: View { + enum Phase: Equatable { + case idle + case recording + case processing + case error + } + + let phase: Phase + let level: Double // 0...1 + let onPressBegan: () -> Void + let onPressEnded: () -> Void + let onTap: () -> Void + + @GestureState private var isPressed: Bool = false + @State private var breath: Bool = false + + init( + phase: Phase, + level: Double, + onPressBegan: @escaping () -> Void, + onPressEnded: @escaping () -> Void, + onTap: @escaping () -> Void + ) { + self.phase = phase + self.level = level + self.onPressBegan = onPressBegan + self.onPressEnded = onPressEnded + self.onTap = onTap + } + + var body: some View { + ZStack { + // Outer breathing ring (recording only) + Circle() + .stroke(Palette.recordRed.opacity(0.35), lineWidth: 2) + .frame(width: 150, height: 150) + .scaleEffect(breath ? 1.18 : 0.95) + .opacity(phase == .recording ? 1 : 0) + .animation(Motion.breath, value: breath) + + // Halo: soft red glow that intensifies with input level + Circle() + .fill( + RadialGradient( + colors: [Palette.recordRed.opacity(0.55), .clear], + center: .center, + startRadius: 50, + endRadius: 100 + ) + ) + .frame(width: 200, height: 200) + .opacity(phase == .recording ? 0.4 + level * 0.6 : 0) + .blur(radius: 18) + .animation(Motion.soft, value: phase) + .animation(Motion.soft, value: level) + + // Secondary outer ring (always present, dimmer when idle) + Circle() + .stroke( + Color.white.opacity(phase == .idle ? 0.08 : 0.12), + lineWidth: 0.5 + ) + .frame(width: 140, height: 140) + + // Main disc with gradient + soft inner highlight + ZStack { + Circle() + .fill(discGradient) + Circle() + .stroke(Color.white.opacity(0.16), lineWidth: 1) + .blendMode(.overlay) + + // Centre content — switches by phase + Group { + switch phase { + case .idle: + Image(systemName: "mic.fill") + .font(.system(size: 38, weight: .medium)) + .foregroundStyle(Palette.textPrimary) + case .recording: + WaveformView(level: level, active: true) + .frame(width: 80, height: 44) + .transition(.opacity) + case .processing: + ProgressView() + .progressViewStyle(.circular) + .tint(Palette.textPrimary) + .scaleEffect(1.2) + case .error: + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 30, weight: .medium)) + .foregroundStyle(Palette.warning) + } + } + } + .frame(width: 120, height: 120) + .scaleEffect(isPressed ? 0.94 : 1.0) + .shadow(color: .black.opacity(0.45), radius: 14, y: 8) + .animation(Motion.quick, value: isPressed) + .animation(Motion.soft, value: phase) + } + .contentShape(Circle()) + // Press-to-talk: act on the FIRST touch-down, not after a 150 ms + // minimum duration. That's what Typeless feels like, and it's what + // makes the keyboard feel responsive. A tap (very short press) is + // interpreted as "toggle" for the secondary action (onTap), not + // "record" — the recording only fires if the press lasts long + // enough to read as intentional. This avoids the previous bug + // where every single tap fired both onPressBegan AND onTap. + .gesture( + LongPressGesture(minimumDuration: 0.18) + .sequenced(before: DragGesture(minimumDistance: 0)) + .updating($isPressed) { value, state, _ in + switch value { + case .second(true, _): state = true + default: state = false + } + } + .onChanged { value in + if case .second(true, _) = value, !pressArmed { + pressArmed = true + onPressBegan() + } + } + .onEnded { _ in + if pressArmed { pressArmed = false; onPressEnded() } + } + ) + .simultaneousGesture( + // Pure tap: only fires when the user lifts before the long-press + // threshold. This becomes the "secondary action" (e.g. cycle + // mode). It is paired with, not conflicting with, the long-press. + TapGesture(count: 1) + .onEnded { + if !pressArmed { onTap() } + } + ) + .onAppear { breath = (phase == .recording) } + .onChange(of: phase) { _, new in + breath = (new == .recording) + } + .accessibilityLabel(Text("Push to talk")) + } + + @State private var pressArmed: Bool = false + + private var discGradient: LinearGradient { + switch phase { + case .recording: + return LinearGradient( + colors: [Palette.recordRed.opacity(0.95), Palette.recordRed.opacity(0.75)], + startPoint: .top, + endPoint: .bottom + ) + case .processing: + return LinearGradient( + colors: [Palette.surfaceElevated, Palette.surface], + startPoint: .top, + endPoint: .bottom + ) + case .error: + return LinearGradient( + colors: [Palette.warning.opacity(0.85), Palette.warning.opacity(0.55)], + startPoint: .top, + endPoint: .bottom + ) + case .idle: + return LinearGradient( + colors: [Color(white: 0.22), Color(white: 0.10)], + startPoint: .top, + endPoint: .bottom + ) + } + } +} diff --git a/OSGKeyboardExt/Views/WaveformView.swift b/OSGKeyboardExt/Views/WaveformView.swift new file mode 100644 index 0000000..c2d8a35 --- /dev/null +++ b/OSGKeyboardExt/Views/WaveformView.swift @@ -0,0 +1,54 @@ +// WaveformView.swift +// OSGKeyboard · Keyboard Extension +// +// Symmetric, real-time driven waveform. 18 bars centred around a vertical +// axis. The dominant bar is driven by the current RMS; surrounding bars +// decay on a small position-based curve so the visual feels like a +// horizontal speaker cone, not random noise. + +import SwiftUI +import OSGKeyboardShared + +struct WaveformView: View { + let level: Double // 0...1, smoothed RMS + let barCount: Int + let color: Color + let active: Bool // when false, bars collapse to a thin resting line + + init( + level: Double, + barCount: Int = 18, + color: Color = Palette.recordRed, + active: Bool = true + ) { + self.level = max(0, min(1, level)) + self.barCount = barCount + self.color = color + self.active = active + } + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + HStack(alignment: .center, spacing: 3) { + ForEach(0.. CGFloat { + guard active else { return 4 } + let centre = Double(barCount - 1) / 2.0 + let distance = abs(Double(index) - centre) / max(centre, 1) + // Per-bar small wobble so the line is alive but tied to level. + let phase = sin(time * 4.0 + Double(index) * 0.45) + let wobble = 0.18 * phase + let magnitude = max(0, min(1, Double(level) + wobble)) + let profile = 1.0 - pow(distance, 1.4) * 0.85 + return CGFloat(max(6, 32 * magnitude * profile)) + } +} diff --git a/OSGKeyboardShared/Constants/AppGroup.swift b/OSGKeyboardShared/Constants/AppGroup.swift new file mode 100644 index 0000000..abd8583 --- /dev/null +++ b/OSGKeyboardShared/Constants/AppGroup.swift @@ -0,0 +1,32 @@ +// AppGroup.swift +// OSGKeyboard · Shared +// +// App Group identifier shared between main app and keyboard extension. +// UserDefaults(suiteName:) and file containers use this. + +import Foundation + +public enum AppGroup { + /// App Group container identifier (must match entitlements in both targets) + public static let identifier = "group.com.osgkeyboard.shared" + + /// Shared UserDefaults instance for cross-process config. + /// + /// Falls back to `.standard` if the App Group isn't available (e.g. + /// the user hasn't created the App Group in the Apple Developer + /// portal, or Xcode hasn't downloaded a matching provisioning profile). + /// In that mode, the keyboard extension will *not* see config written + /// by the main app — but the main app itself stays usable so the user + /// can fix the signing situation without the app crashing. + public static var defaults: UserDefaults { + if let d = UserDefaults(suiteName: identifier) { + return d + } + #if DEBUG + print("⚠️ App Group \(identifier) unavailable — falling back to .standard. " + + "Add the App Group in your Apple Developer account and Xcode " + + "Signing & Capabilities, then re-run.") + #endif + return .standard + } +} diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift new file mode 100644 index 0000000..d26fd1b --- /dev/null +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -0,0 +1,156 @@ +// Theme.swift +// OSGKeyboard · Design System +// +// Single source of truth for colour, spacing, corner radius, typography. +// Inspired by Dieter Rams ("less but better") and Apple Human Interface: +// every surface has a single purpose, every token earns its place, and +// the visual hierarchy is carried by *whitespace + one accent*, never by +// extra colour. + +import SwiftUI + +// MARK: - Palette + +public enum Palette { + // Backgrounds + public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B + public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B + public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A + public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215 + + // Accents + public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA + public static let accentMuted = accent.opacity(0.18) + public static let accentGlow = accent.opacity(0.42) + + // Semantic + public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A + public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69 + public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18 + + // Text + public static let textPrimary = Color.white + public static let textSecondary = Color(white: 0.7) + public static let textTertiary = Color(white: 0.50) + public static let textOnAccent = Color.black + + // Lines + public static let divider = Color.white.opacity(0.06) + public static let dividerStrong = Color.white.opacity(0.10) + + // Recording state + public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30 +} + +// MARK: - Spacing scale (4 pt grid) + +public enum Spacing { + public static let xxs: CGFloat = 4 + public static let xs: CGFloat = 8 + public static let sm: CGFloat = 12 + public static let md: CGFloat = 16 + public static let lg: CGFloat = 20 + public static let xl: CGFloat = 24 + public static let xxl: CGFloat = 32 + public static let xxxl: CGFloat = 40 + public static let hero: CGFloat = 48 +} + +// MARK: - Corner radius scale + +public enum Radius { + public static let small: CGFloat = 8 + public static let medium: CGFloat = 12 + public static let large: CGFloat = 16 + public static let xl: CGFloat = 20 + public static let xxl: CGFloat = 24 + public static let pill: CGFloat = 999 +} + +// MARK: - Typography + +public enum TypeStyle { + public static let caption2 = Font.system(size: 11, weight: .medium) + public static let caption = Font.system(size: 12, weight: .medium) + public static let footnote = Font.system(size: 13, weight: .regular) + public static let body = Font.system(size: 15, weight: .regular) + public static let bodyEmph = Font.system(size: 15, weight: .medium) + public static let headline = Font.system(size: 17, weight: .semibold) + public static let title3 = Font.system(size: 20, weight: .semibold) + public static let title2 = Font.system(size: 22, weight: .bold) + public static let title = Font.system(size: 28, weight: .bold) + public static let largeTitle = Font.system(size: 34, weight: .bold) + public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced) + public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced) +} + +// MARK: - Animation + +public enum Motion { + public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85) + public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80) + public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78) + public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true) + public static let instant = Animation.linear(duration: 0.12) +} + +// MARK: - Reusable view modifiers + +public extension View { + /// Standard card surface used in the main app. + func cardSurface(padding: CGFloat = Spacing.md) -> some View { + self + .padding(padding) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.divider, lineWidth: 0.5) + ) + } + + /// Muted pill (used for tags, locale indicators, etc.). + func pillChip(foreground: Color = Palette.textSecondary) -> some View { + self + .padding(.horizontal, Spacing.xs) + .padding(.vertical, 4) + .background(Palette.surfaceElevated, in: Capsule()) + .foregroundStyle(foreground) + } + + /// Primary CTA button. + func primaryButton() -> some View { + self + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .foregroundStyle(Palette.textOnAccent) + } + + /// Secondary CTA button. + func secondaryButton() -> some View { + self + .font(TypeStyle.headline) + .frame(maxWidth: .infinity, minHeight: 50) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(Palette.dividerStrong, lineWidth: 0.5) + ) + .foregroundStyle(Palette.textPrimary) + } + + /// Legacy alias for older call sites. + func cardStyle() -> some View { cardSurface() } +} + +// MARK: - Backwards compat (legacy callers in old code) + +public enum Theme { + public static let background = Palette.background + public static let card = Palette.surface + public static let accent = Palette.accent + public static let danger = Palette.danger + public static let textPrimary = Palette.textPrimary + public static let textSecondary = Palette.textSecondary + public static let divider = Palette.divider +} diff --git a/OpenLessShared/Info.plist b/OSGKeyboardShared/Info.plist similarity index 100% rename from OpenLessShared/Info.plist rename to OSGKeyboardShared/Info.plist diff --git a/OSGKeyboardShared/Models/AudioBufferSnapshot.swift b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift new file mode 100644 index 0000000..5c7f4f6 --- /dev/null +++ b/OSGKeyboardShared/Models/AudioBufferSnapshot.swift @@ -0,0 +1,34 @@ +// AudioBufferSnapshot.swift +// OSGKeyboard · Shared +// +// Sendable wrapper around a Float32 audio buffer's raw samples. +// The snapshot is the only thing that crosses actor / concurrency +// boundaries; the recognizer re-creates an `AVAudioPCMBuffer` on its +// own side and consumes it locally (never yielding it back out). + +import Foundation +import AVFoundation + +public struct AudioBufferSnapshot: Sendable { + public let samples: [Float] + public let sampleRate: Double + + public init(samples: [Float], sampleRate: Double) { + self.samples = samples + self.sampleRate = sampleRate + } + + /// Construct from an `AVAudioPCMBuffer` by copying out the channel data. + public init(buffer: AVAudioPCMBuffer) { + guard let channelData = buffer.floatChannelData else { + self.samples = [] + self.sampleRate = buffer.format.sampleRate + return + } + let n = Int(buffer.frameLength) + var copy = [Float](repeating: 0, count: n) + memcpy(©, channelData[0], n * MemoryLayout.size) + self.samples = copy + self.sampleRate = buffer.format.sampleRate + } +} diff --git a/OpenLessShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift similarity index 60% rename from OpenLessShared/Models/LLMProvider.swift rename to OSGKeyboardShared/Models/LLMProvider.swift index cc87663..361cd1e 100644 --- a/OpenLessShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -12,19 +12,23 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { public let defaultBaseURL: String public let defaultModel: String public let apiKeyURL: URL? + /// Optional short blurb shown under the provider name in the picker. + public let blurb: String? public init( id: String, name: String, defaultBaseURL: String, defaultModel: String, - apiKeyURL: URL? = nil + apiKeyURL: URL? = nil, + blurb: String? = nil ) { self.id = id self.name = name self.defaultBaseURL = defaultBaseURL self.defaultModel = defaultModel self.apiKeyURL = apiKeyURL + self.blurb = blurb } public static let presets: [LLMProvider] = [ @@ -33,27 +37,47 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { name: "OpenAI", defaultBaseURL: "https://api.openai.com/v1", defaultModel: "gpt-4o-mini", - apiKeyURL: URL(string: "https://platform.openai.com/api-keys") + apiKeyURL: URL(string: "https://platform.openai.com/api-keys"), + blurb: "GPT-4o mini · 多语言" ), .init( id: "deepseek", name: "DeepSeek", defaultBaseURL: "https://api.deepseek.com/v1", defaultModel: "deepseek-chat", - apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys") + apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"), + blurb: "deepseek-chat · 中文友好" ), .init( id: "qwen", - name: "Qwen (DashScope, OpenAI-compatible)", + name: "Qwen (DashScope)", defaultBaseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", defaultModel: "qwen-plus", - apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey") + apiKeyURL: URL(string: "https://dashscope.console.aliyun.com/apiKey"), + blurb: "通义千问 · OpenAI 兼容" + ), + .init( + id: "zhipu", + name: "智谱 GLM", + defaultBaseURL: "https://open.bigmodel.cn/api/paas/v4", + defaultModel: "glm-4-flash", + apiKeyURL: URL(string: "https://bigmodel.cn/usercenter/apikeys"), + blurb: "GLM-4-Flash · 中文优化" + ), + .init( + id: "moonshot", + name: "月之暗面 Moonshot", + defaultBaseURL: "https://api.moonshot.cn/v1", + defaultModel: "moonshot-v1-8k", + apiKeyURL: URL(string: "https://platform.moonshot.cn/console/api-keys"), + blurb: "Kimi · 长上下文" ), .init( id: "custom", name: "Custom (OpenAI-compatible)", defaultBaseURL: "", - defaultModel: "" + defaultModel: "", + blurb: "自建 / 任意 OpenAI 兼容端点" ) ] diff --git a/OpenLessShared/Models/LLMRequest.swift b/OSGKeyboardShared/Models/LLMRequest.swift similarity index 100% rename from OpenLessShared/Models/LLMRequest.swift rename to OSGKeyboardShared/Models/LLMRequest.swift diff --git a/OpenLessShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift similarity index 56% rename from OpenLessShared/Models/ProviderConfig.swift rename to OSGKeyboardShared/Models/ProviderConfig.swift index 5156f46..3457ca2 100644 --- a/OpenLessShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -10,60 +10,69 @@ import Combine public final class ProviderConfig: ObservableObject, @unchecked Sendable { public static let shared = ProviderConfig() - // Storage keys private enum Key { static let providerId = "config.providerId" static let baseURL = "config.baseURL" static let apiKey = "config.apiKey" static let model = "config.model" static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" } @Published public var providerId: String { didSet { defaults.set(providerId, forKey: Key.providerId) } } - @Published public var baseURL: String { didSet { defaults.set(baseURL, forKey: Key.baseURL) } } - @Published public var apiKey: String { didSet { defaults.set(apiKey, forKey: Key.apiKey) } } - @Published public var model: String { didSet { defaults.set(model, forKey: Key.model) } } - @Published public var systemPrompt: String { didSet { defaults.set(systemPrompt, forKey: Key.systemPrompt) } } + @Published public var modeId: String { + didSet { defaults.set(modeId, forKey: Key.modeId) } + } + @Published public var localeId: String { + didSet { defaults.set(localeId, forKey: Key.localeId) } + } public var isConfigured: Bool { !baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty } - public let defaultSystemPrompt = """ - You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: - 1) Preserve the user's original intent and meaning; do not invent facts. - 2) Add proper punctuation, capitalization, and paragraph breaks. - 3) When the user enumerates items ("first ... second ... third"), output a markdown list. - 4) Keep the output concise — do not exceed 1.5x the spoken length. - 5) Output in the same language as the input. - """ + /// The system prompt the user *sees* in the editor — fall back to the + /// provider-aware default from `AppGroupStore` when nothing is set. + public var defaultSystemPrompt: String { + AppGroupStore.defaultSystemPrompt(for: providerId) + } private let defaults: UserDefaults public init(defaults: UserDefaults = AppGroup.defaults) { self.defaults = defaults - self.providerId = defaults.string(forKey: Key.providerId) ?? "openai" - self.baseURL = defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: "openai").defaultBaseURL + let pid = defaults.string(forKey: Key.providerId) ?? "openai" + let preset = LLMProvider.provider(id: pid) + self.providerId = pid + self.baseURL = defaults.string(forKey: Key.baseURL) ?? preset.defaultBaseURL self.apiKey = defaults.string(forKey: Key.apiKey) ?? "" - self.model = defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: "openai").defaultModel - self.systemPrompt = defaults.string(forKey: Key.systemPrompt) ?? defaultSystemPrompt + self.model = defaults.string(forKey: Key.model) ?? preset.defaultModel + self.systemPrompt = defaults.string(forKey: Key.systemPrompt) + ?? AppGroupStore.defaultSystemPrompt(for: pid) + self.modeId = defaults.string(forKey: Key.modeId) ?? "polish" + self.localeId = defaults.string(forKey: Key.localeId) ?? "auto" } public func apply(preset: LLMProvider) { + // Capture the *previous* provider id BEFORE we mutate, so the + // system-prompt reset check below can compare against the actual + // prior default. + let oldId = providerId providerId = preset.id if !preset.defaultBaseURL.isEmpty { baseURL = preset.defaultBaseURL @@ -71,6 +80,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { if !preset.defaultModel.isEmpty { model = preset.defaultModel } + // When switching providers, reset the system prompt to the new + // provider's default — otherwise the user is left editing a + // Chinese prompt on a US-English model. + if systemPrompt.isEmpty + || systemPrompt == AppGroupStore.defaultSystemPrompt(for: oldId) { + systemPrompt = AppGroupStore.defaultSystemPrompt(for: preset.id) + } } public func reset() { @@ -79,6 +95,6 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { baseURL = preset.defaultBaseURL apiKey = "" model = preset.defaultModel - systemPrompt = defaultSystemPrompt + systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") } } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift new file mode 100644 index 0000000..86c031a --- /dev/null +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -0,0 +1,106 @@ +// AppGroupStore.swift +// OSGKeyboard · Shared +// +// Convenience wrapper around App Group UserDefaults for non-Published reads. +// Used by the keyboard extension (no SwiftUI) to read config without +// instantiating an ObservableObject. + +import Foundation + +public struct AppGroupStore: @unchecked Sendable { + public let defaults: UserDefaults + + public init(defaults: UserDefaults = AppGroup.defaults) { + self.defaults = defaults + } + + // MARK: - Keys + + private enum Key { + static let providerId = "config.providerId" + static let baseURL = "config.baseURL" + static let apiKey = "config.apiKey" + static let model = "config.model" + static let systemPrompt = "config.systemPrompt" + static let modeId = "config.modeId" + static let localeId = "config.localeId" + } + + // MARK: - Reads + + public var providerId: String { + defaults.string(forKey: Key.providerId) ?? "openai" + } + + public var baseURL: String { + defaults.string(forKey: Key.baseURL) ?? LLMProvider.provider(id: providerId).defaultBaseURL + } + + public var apiKey: String { + defaults.string(forKey: Key.apiKey) ?? "" + } + + public var model: String { + defaults.string(forKey: Key.model) ?? LLMProvider.provider(id: providerId).defaultModel + } + + public var systemPrompt: String { + defaults.string(forKey: Key.systemPrompt) ?? Self.defaultSystemPrompt(for: providerId) + } + + public var modeId: String { + defaults.string(forKey: Key.modeId) ?? "polish" + } + + public var localeId: String { + defaults.string(forKey: Key.localeId) ?? "auto" + } + + // MARK: - Writes + + public func setModeId(_ id: String) { + defaults.set(id, forKey: Key.modeId) + } + + public func setLocaleId(_ id: String) { + defaults.set(id, forKey: Key.localeId) + } + + // MARK: - Client + + public func makeClient() -> LLMClient { + OpenAICompatibleClient( + baseURL: baseURL, + apiKey: apiKey, + model: model + ) + } + + // MARK: - Defaults + + /// Per-provider default system prompt. We bias the prompt by the + /// provider's *primary* language so Chinese LLMs naturally return + /// Chinese for Chinese input, and English LLMs stay terse. + public static func defaultSystemPrompt(for providerId: String) -> String { + switch providerId { + case "zhipu", "moonshot", "qwen", "deepseek": + return """ + 你是一位语音输入润色助手。请将用户的口述改写为干净的中文(或英文)书面文字: + 1) 保留原意,不编造事实;保持输入语言。 + 2) 添加恰当的标点、大小写、段落。 + 3) 当用户枚举"第一…第二…第三…"时,使用 markdown 列表。 + 4) 简洁,不超出原长 1.5 倍;可去掉无意义的口头禅(嗯、啊、那个)。 + 5) 只输出润色后的正文,不要解释、不要加引号。 + """ + default: + return """ + You are a voice-input polishing assistant. The user has spoken informally; rewrite their dictation as clean written text: + 1) Preserve the user's original intent and meaning; do not invent facts. + 2) Add proper punctuation, capitalization, and paragraph breaks. + 3) When the user enumerates items ("first ... second ... third"), output a markdown list. + 4) Keep the output concise — do not exceed 1.5x the spoken length. Drop filler words (um, uh, like). + 5) Output in the same language as the input. No quotes, no explanation, no preamble. + """ + } + } +} diff --git a/OpenLessShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift similarity index 75% rename from OpenLessShared/Services/LLMClient.swift rename to OSGKeyboardShared/Services/LLMClient.swift index 0531427..650a746 100644 --- a/OpenLessShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -9,20 +9,21 @@ import Foundation public enum LLMError: Error, LocalizedError, Sendable { case invalidURL case noAPIKey - case http(status: Int, body: String) + case http(status: Int) case decoding(String) - case transport(underlying: String) + case transport(String) case cancelled + case rateLimited public var errorDescription: String? { switch self { - case .invalidURL: return "Invalid API URL." - case .noAPIKey: return "API key is missing." - case .http(let s, let body): - return "API returned HTTP \(s): \(body.prefix(200))" - case .decoding(let s): return "Failed to decode response: \(s)" - case .transport(let s): return "Network error: \(s)" - case .cancelled: return "Request was cancelled." + case .invalidURL: return "API 地址无效。请在设置中检查 Base URL。" + case .noAPIKey: return "未填写 API Key。" + case .http(let s): return "API 返回 HTTP \(s)。请稍后重试或联系服务方。" + case .decoding: return "解析 API 响应失败。" + case .transport: return "网络错误,请检查连接后重试。" + case .rateLimited: return "API 调用过于频繁,请稍候再试。" + case .cancelled: return "请求已取消。" } } } @@ -81,11 +82,16 @@ public struct OpenAICompatibleClient: LLMClient { do { let (data, response) = try await session.data(for: req) guard let http = response as? HTTPURLResponse else { - throw LLMError.transport(underlying: "non-HTTP response") + throw LLMError.transport("non-HTTP response") } if !(200..<300).contains(http.statusCode) { + #if DEBUG + // Log full body for debugging — never expose to UI. let body = String(data: data, encoding: .utf8) ?? "" - throw LLMError.http(status: http.statusCode, body: body) + print("⚠️ LLM HTTP \(http.statusCode): \(body.prefix(500))") + #endif + if http.statusCode == 429 { throw LLMError.rateLimited } + throw LLMError.http(status: http.statusCode) } do { let decoded = try JSONDecoder().decode(LLMResponse.self, from: data) @@ -100,7 +106,7 @@ public struct OpenAICompatibleClient: LLMClient { } catch let urlError as URLError where urlError.code == .cancelled { throw LLMError.cancelled } catch { - throw LLMError.transport(underlying: String(describing: error)) + throw LLMError.transport(String(describing: error)) } } } diff --git a/OpenLessTests/Info.plist b/OSGKeyboardTests/Info.plist similarity index 100% rename from OpenLessTests/Info.plist rename to OSGKeyboardTests/Info.plist diff --git a/OpenLessTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift similarity index 96% rename from OpenLessTests/LLMClientTests.swift rename to OSGKeyboardTests/LLMClientTests.swift index 0ca10d4..6ead26e 100644 --- a/OpenLessTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -12,8 +12,9 @@ final class LLMClientTests: XCTestCase { // MARK: - ProviderConfig persistence func testProviderConfigPersistsAcrossInstances() { - let defaults = UserDefaults(suiteName: "group.com.osgkeyboard.ios.tests")! - defaults.removePersistentDomain(forName: "group.com.osgkeyboard.ios.tests") + let suiteName = "group.com.osgkeyboard.shared.tests" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) let config1 = ProviderConfig(defaults: defaults) config1.baseURL = "https://example.com/v1" diff --git a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png b/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png deleted file mode 100644 index 5cfd8eb..0000000 Binary files a/OpenLess/Assets.xcassets/AppIcon.appiconset/icon-1024.png and /dev/null differ diff --git a/OpenLess/OpenLess.entitlements b/OpenLess/OpenLess.entitlements deleted file mode 100644 index 0c67376..0000000 --- a/OpenLess/OpenLess.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/OpenLess/OpenLessApp.swift b/OpenLess/OpenLessApp.swift deleted file mode 100644 index 97066b8..0000000 --- a/OpenLess/OpenLessApp.swift +++ /dev/null @@ -1,23 +0,0 @@ -// 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() - } - } - } -} diff --git a/OpenLess/Views/APISettingsCard.swift b/OpenLess/Views/APISettingsCard.swift deleted file mode 100644 index b82b124..0000000 --- a/OpenLess/Views/APISettingsCard.swift +++ /dev/null @@ -1,102 +0,0 @@ -// 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, - 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)) - } - } -} diff --git a/OpenLess/Views/HomeView.swift b/OpenLess/Views/HomeView.swift deleted file mode 100644 index 0cc8e97..0000000 --- a/OpenLess/Views/HomeView.swift +++ /dev/null @@ -1,86 +0,0 @@ -// 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) - } - } -} diff --git a/OpenLess/Views/OnboardingView.swift b/OpenLess/Views/OnboardingView.swift deleted file mode 100644 index f3be0a6..0000000 --- a/OpenLess/Views/OnboardingView.swift +++ /dev/null @@ -1,177 +0,0 @@ -// 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) - } - } -} diff --git a/OpenLess/Views/ProviderPickerSection.swift b/OpenLess/Views/ProviderPickerSection.swift deleted file mode 100644 index 831e57a..0000000 --- a/OpenLess/Views/ProviderPickerSection.swift +++ /dev/null @@ -1,36 +0,0 @@ -// 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() - } -} diff --git a/OpenLess/Views/SettingsView.swift b/OpenLess/Views/SettingsView.swift deleted file mode 100644 index b26a0e4..0000000 --- a/OpenLess/Views/SettingsView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// 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) - } - } -} diff --git a/OpenLess/Views/Theme.swift b/OpenLess/Views/Theme.swift deleted file mode 100644 index c96a7b4..0000000 --- a/OpenLess/Views/Theme.swift +++ /dev/null @@ -1,30 +0,0 @@ -// 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) - ) - } -} diff --git a/OpenLessKeyboard/KeyboardViewController.swift b/OpenLessKeyboard/KeyboardViewController.swift deleted file mode 100644 index cea041a..0000000 --- a/OpenLessKeyboard/KeyboardViewController.swift +++ /dev/null @@ -1,285 +0,0 @@ -// KeyboardViewController.swift -// OSGKeyboard · Keyboard Extension -// -// The principal class for the Custom Keyboard Extension. Manages the -// recording pipeline: AudioCapture -> ASR -> LLM polish -> insertText. - -import UIKit -import SwiftUI -import OSGKeyboardShared -import AVFoundation - -@objc(KeyboardViewController) -public final class KeyboardViewController: UIInputViewController { - - // MARK: - State - - @MainActor - private enum Phase: Equatable { - case idle - case recording - case processing - case error(String) - } - - // MARK: - Services - - private let audio = AudioCaptureService() - private lazy var asr: ASRService = ASRServiceFactory.create() - private let polisher = PolishingService() - - // MARK: - Pipeline state - - private var recordStream: AsyncStream? - private var recordContinuation: Task? - private var asrTask: Task? - private var lastTranscript: String = "" - - // MARK: - UI - - private var hosting: UIHostingController! - private var levelTimer: Timer? - private var currentLevel: Double = 0 - - // MARK: - Lifecycle - - public override func viewDidLoad() { - super.viewDidLoad() - installSwiftUI() - } - - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - requestMicPermissionIfNeeded() - } - - public override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - cancelPipeline() - } - - public override func didReceiveMemoryWarning() { - super.didReceiveMemoryWarning() - cancelPipeline() - } - - // MARK: - SwiftUI bridge - - private func installSwiftUI() { - let root = KeyboardRootView( - phase: .idle, - level: 0, - onPressBegan: { [weak self] in self?.pressBegan() }, - onPressEnded: { [weak self] in self?.pressEnded() }, - onTap: { [weak self] in self?.handleTap() }, - onOpenSettings:{ [weak self] in self?.openHostApp() } - ) - let host = UIHostingController(rootView: root) - host.view.backgroundColor = .clear - addChild(host) - view.addSubview(host.view) - host.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - host.didMove(toParent: self) - self.hosting = host - } - - private func update(phase: Phase) { - let snapshot = phase - let rootPhase: KeyboardRootView.Phase = { - switch snapshot { - case .idle: return .idle - case .recording: return .recording - case .processing: return .processing - case .error(let m): return .error(m) - } - }() - hosting.rootView = KeyboardRootView( - phase: rootPhase, - level: currentLevel, - onPressBegan: { [weak self] in self?.pressBegan() }, - onPressEnded: { [weak self] in self?.pressEnded() }, - onTap: { [weak self] in self?.handleTap() }, - onOpenSettings:{ [weak self] in self?.openHostApp() } - ) - } - - // MARK: - Press handlers - - private func pressBegan() { - guard case .idle = currentPhase() else { return } - requestMicPermissionIfNeeded { [weak self] granted in - guard let self else { return } - guard granted else { - Task { @MainActor in self.update(phase: .error("Microphone denied. Enable in Settings.")) } - return - } - self.startPipeline() - } - } - - private func pressEnded() { - guard case .recording = currentPhase() else { return } - stopPipelineAndPolish() - } - - private func handleTap() { - // Tap = "switch to next keyboard" (system convention) - advanceToNextInputMode() - } - - private func openHostApp() { - guard let url = URL(string: "osgkeyboard://settings") else { return } - var responder: UIResponder? = self - while let r = responder { - if let app = r as? UIApplication { - app.open(url) - return - } - responder = r.next - } - // Fallback: open settings page - if let url = URL(string: UIApplication.openSettingsURLString) { - var r: UIResponder? = self - while let r2 = r { - if let app = r2 as? UIApplication { - app.open(url); return - } - r = r2.next - } - } - } - - // MARK: - Pipeline - - private func startPipeline() { - update(phase: .recording) - let stream = audio.start() - recordStream = stream - - // 1) ASR pipeline - let events = asr.transcribe(stream: stream) - asrTask = Task { [weak self] in - guard let self else { return } - for await event in events { - switch event { - case .partial(let s): - // optionally show partial in status bar (keep simple — silent) - _ = s - case .final(let s): - await MainActor.run { self.lastTranscript = s } - case .error(let msg): - await MainActor.run { self.update(phase: .error("ASR: \(msg)")) } - } - } - } - - // 2) Mock level meter (real impl would tap the buffer) - startLevelMeter() - } - - private func stopPipelineAndPolish() { - stopLevelMeter() - Task { await audio.stop() } - asrTask?.cancel() - - let snapshot = lastTranscript - guard !snapshot.isEmpty else { - update(phase: .idle) - return - } - - update(phase: .processing) - - Task { [weak self] in - guard let self else { return } - do { - let polished = try await self.polisher.polish(snapshot) - await MainActor.run { - self.textDocumentProxy.insertText(polished) - self.lastTranscript = "" - self.update(phase: .idle) - } - } catch { - // fallback: insert raw transcript - await MainActor.run { - self.textDocumentProxy.insertText(snapshot) - self.lastTranscript = "" - let msg = (error as? LocalizedError)?.errorDescription ?? "Polishing failed, inserted raw." - self.update(phase: .error(msg)) - // auto-clear error back to idle after 2s - Task { @MainActor in - try? await Task.sleep(nanoseconds: 2_000_000_000) - if case .error = self.currentPhase() { - self.update(phase: .idle) - } - } - } - } - } - } - - private func cancelPipeline() { - asrTask?.cancel() - asrTask = nil - Task { await audio.stop() } - stopLevelMeter() - lastTranscript = "" - } - - // MARK: - Permission - - private func requestMicPermissionIfNeeded(completion: ((Bool) -> Void)? = nil) { - let session = AVAudioSession.sharedInstance() - switch session.recordPermission { - case .granted: - completion?(true) - case .denied: - completion?(false) - case .undetermined: - session.requestRecordPermission { granted in - DispatchQueue.main.async { completion?(granted) } - } - @unknown default: - completion?(false) - } - } - - // MARK: - Level meter (mock — tap could be replaced with real RMS from AVAudioEngine) - - private func startLevelMeter() { - stopLevelMeter() - currentLevel = 0 - levelTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in - guard let self else { return } - // Simple pseudo-level — random walk around 0.5 while "recording" - let delta = Double.random(in: -0.18...0.18) - self.currentLevel = max(0.15, min(0.95, self.currentLevel + delta)) - // refresh UI - Task { @MainActor in - self.update(phase: .recording) - } - } - } - - private func stopLevelMeter() { - levelTimer?.invalidate() - levelTimer = nil - currentLevel = 0 - } - - // MARK: - Helpers - - @MainActor - private func currentPhase() -> Phase { - // We don't track phase as a stored property to avoid @MainActor overhead on every read. - // Instead, derive it from state of services — for simplicity we mirror via update(). - // This shim returns .idle when no record is in flight. - return recordStream == nil ? .idle : .recording - } -} diff --git a/OpenLessKeyboard/Services/ASRService.swift b/OpenLessKeyboard/Services/ASRService.swift deleted file mode 100644 index c938ab1..0000000 --- a/OpenLessKeyboard/Services/ASRService.swift +++ /dev/null @@ -1,165 +0,0 @@ -// ASRService.swift -// OSGKeyboard · Keyboard Extension -// -// Speech-to-text abstraction. iOS 26+ uses SpeechAnalyzer + DictationTranscriber -// (Apple's modern, on-device streaming API). iOS 18 falls back to SFSpeechRecognizer -// with on-device recognition. - -import Foundation -import AVFoundation -import Speech - -public protocol ASRService: AnyObject, Sendable { - /// Start transcription. Returns an async stream of partial + final strings. - /// The last value emitted on `finish()` is the final transcript. - func transcribe(stream: AsyncStream) -> AsyncStream - - /// Cancel any in-flight work. - func cancel() -} - -public enum ASREvent: Sendable { - case partial(String) // incremental, may be discarded - case final(String) // the authoritative transcript - case error(String) -} - -// MARK: - Factory - -public enum ASRServiceFactory { - public static func create() -> ASRService { - if #available(iOS 26, *) { - return SpeechAnalyzerASR() - } else { - return SFSpeechRecognizerASR() - } - } -} - -// MARK: - iOS 26+: SpeechAnalyzer + DictationTranscriber - -@available(iOS 26, *) -final class SpeechAnalyzerASR: ASRService, @unchecked Sendable { - private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) - private var task: Task? - - func transcribe(stream: AsyncStream) -> AsyncStream { - AsyncStream { continuation in - let recognizer = self.recognizer ?? SFSpeechRecognizer(locale: .current) - guard let recognizer, recognizer.isAvailable else { - continuation.yield(.error("Speech recognizer unavailable")) - continuation.finish() - return - } - recognizer.defaultTaskHint = .dictation - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - if recognizer.supportsOnDeviceRecognition { - request.requiresOnDeviceRecognition = true - } - - let task = recognizer.recognitionTask(with: request) { result, error in - if let error { - continuation.yield(.error(error.localizedDescription)) - continuation.finish() - return - } - guard let result else { return } - if result.isFinal { - continuation.yield(.final(result.bestTranscription.formattedString)) - continuation.finish() - } else { - continuation.yield(.partial(result.bestTranscription.formattedString)) - } - } - self.task = Task { [request] in - for await snap in stream { - if Task.isCancelled { break } - let pcmStream = AsyncStream { c in - c.yield(snap) - c.finish() - } - for await pcm in pcmStream.toAVAudioBuffers() { - request.append(pcm) - } - } - request.endAudio() - // give recognizer a moment to finalize - try? await Task.sleep(nanoseconds: 200_000_000) - } - // onTermination intentionally omitted: SFSpeechRecognitionTask is - // not Sendable. Cancellation flows through this class's cancel(). - } - } - - func cancel() { - task?.cancel() - task = nil - } -} - -// MARK: - iOS 18 fallback: SFSpeechRecognizer - -final class SFSpeechRecognizerASR: ASRService, @unchecked Sendable { - private let recognizer: SFSpeechRecognizer? = SFSpeechRecognizer(locale: .current) - private var task: SFSpeechRecognitionTask? - private var feedTask: Task? - - func transcribe(stream: AsyncStream) -> AsyncStream { - AsyncStream { continuation in - guard let recognizer, recognizer.isAvailable else { - continuation.yield(.error("Speech recognizer unavailable")) - continuation.finish() - return - } - recognizer.defaultTaskHint = .dictation - - let request = SFSpeechAudioBufferRecognitionRequest() - request.shouldReportPartialResults = true - if recognizer.supportsOnDeviceRecognition { - request.requiresOnDeviceRecognition = true - } - - let recognizerTask = recognizer.recognitionTask(with: request) { result, error in - if let error { - continuation.yield(.error(error.localizedDescription)) - continuation.finish() - return - } - guard let result else { return } - if result.isFinal { - continuation.yield(.final(result.bestTranscription.formattedString)) - continuation.finish() - } else { - continuation.yield(.partial(result.bestTranscription.formattedString)) - } - } - self.task = recognizerTask - - self.feedTask = Task { [request] in - for await snap in stream { - if Task.isCancelled { break } - let pcmStream = AsyncStream { c in - c.yield(snap) - c.finish() - } - for await pcm in pcmStream.toAVAudioBuffers() { - request.append(pcm) - } - } - request.endAudio() - } - // onTermination intentionally omitted: SFSpeechRecognitionTask is - // not Sendable. Cancellation is handled by calling cancel() on - // this class, and the feedTask loop respects Task.isCancelled. - } - } - - func cancel() { - task?.cancel() - feedTask?.cancel() - task = nil - feedTask = nil - } -} diff --git a/OpenLessKeyboard/Services/AudioCaptureService.swift b/OpenLessKeyboard/Services/AudioCaptureService.swift deleted file mode 100644 index f87bb8a..0000000 --- a/OpenLessKeyboard/Services/AudioCaptureService.swift +++ /dev/null @@ -1,173 +0,0 @@ -// AudioCaptureService.swift -// OSGKeyboard · Keyboard Extension -// -// Captures microphone audio at 16 kHz mono Float32 using AVAudioEngine. -// Exposes an AsyncStream that ASR services can consume. - -import Foundation -@preconcurrency import AVFoundation - -public actor AudioCaptureService { - - public enum CaptureError: Error { - case sessionConfigFailed(Error) - case engineStartFailed(Error) - case noInputNode - } - - private let engine = AVAudioEngine() - private let converter = AVAudioConverter( - from: AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 48000, - channels: 1, - interleaved: false - )!, - to: AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - ) - - private var continuation: AsyncStream.Continuation? - private var isRunning = false - - public init() {} - - public func start() -> AsyncStream { - AsyncStream { continuation in - self.continuation = continuation - do { - try configureSession() - try attachTap() - try engine.start() - isRunning = true - } catch { - continuation.finish() - self.continuation = nil - isRunning = false - } - } - } - - public func stop() { - guard isRunning else { return } - engine.inputNode.removeTap(onBus: 0) - engine.stop() - continuation?.finish() - continuation = nil - isRunning = false - } - - private func configureSession() throws { - #if canImport(UIKit) - let session = AVAudioSession.sharedInstance() - do { - try session.setCategory( - .playAndRecord, - mode: .measurement, - options: [.duckOthers, .defaultToSpeaker, .allowBluetooth] - ) - try session.setActive(true, options: .notifyOthersOnDeactivation) - } catch { - throw CaptureError.sessionConfigFailed(error) - } - #endif - } - - private func attachTap() throws { - let input = engine.inputNode - let hardwareFormat = input.outputFormat(forBus: 0) - guard hardwareFormat.sampleRate > 0 else { - throw CaptureError.noInputNode - } - let bufferSize: AVAudioFrameCount = 4096 - input.installTap(onBus: 0, bufferSize: bufferSize, format: hardwareFormat) { [weak self] buffer, _ in - guard let self else { return } - // Downsample to 16 kHz mono Float32 - let target = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - let outFrames = AVAudioFrameCount( - Double(buffer.frameLength) * 16_000.0 / hardwareFormat.sampleRate - ) - guard let outBuffer = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else { - return - } - var error: NSError? - let status = self.converter?.convert(to: outBuffer, error: &error) { _, outStatus in - outStatus.pointee = .haveData - return buffer - } - if status == .haveData, error == nil { - // AVAudioPCMBuffer is not Sendable; copy the raw float data - // into a Sendable wrapper so we can hand it to the actor. - let copy = AudioBufferSnapshot(buffer: outBuffer) - Task { await self.deliver(copy) } - } - } - } - - private func deliver(_ snapshot: AudioBufferSnapshot) { - guard isRunning else { return } - continuation?.yield(snapshot) - } -} - -/// Sendable wrapper around a Float32 audio buffer's raw samples. -/// We re-decode on the consumer side to avoid AVAudioPCMBuffer's non-Sendable -/// type crossing the actor boundary. -public struct AudioBufferSnapshot: Sendable { - public let samples: [Float] - public let sampleRate: Double - - public init(buffer: AVAudioPCMBuffer) { - guard let channelData = buffer.floatChannelData else { - self.samples = [] - self.sampleRate = 16_000 - return - } - let n = Int(buffer.frameLength) - var copy = [Float](repeating: 0, count: n) - memcpy(©, channelData[0], n * MemoryLayout.size) - self.samples = copy - self.sampleRate = buffer.format.sampleRate - } -} - -public extension AsyncStream where Element == AudioBufferSnapshot { - /// Convenience: convert snapshots to AVAudioPCMBuffer 16kHz mono Float32. - func toAVAudioBuffers() -> AsyncStream { - let format = AVAudioFormat( - commonFormat: .pcmFormatFloat32, - sampleRate: 16_000, - channels: 1, - interleaved: false - )! - let snapshots = self - return AsyncStream { continuation in - Task { - for await snap in snapshots { - guard !snap.samples.isEmpty, - let pcm = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(snap.samples.count)) - else { continue } - pcm.frameLength = AVAudioFrameCount(snap.samples.count) - if let dst = pcm.floatChannelData?[0] { - snap.samples.withUnsafeBufferPointer { src in - if let base = src.baseAddress { - memcpy(dst, base, snap.samples.count * MemoryLayout.size) - } - } - } - continuation.yield(pcm) - } - continuation.finish() - } - } - } -} diff --git a/OpenLessKeyboard/Views/KeyboardRootView.swift b/OpenLessKeyboard/Views/KeyboardRootView.swift deleted file mode 100644 index 9fe67d3..0000000 --- a/OpenLessKeyboard/Views/KeyboardRootView.swift +++ /dev/null @@ -1,121 +0,0 @@ -// 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) - } - } -} diff --git a/OpenLessKeyboard/Views/RecordButton.swift b/OpenLessKeyboard/Views/RecordButton.swift deleted file mode 100644 index b110cea..0000000 --- a/OpenLessKeyboard/Views/RecordButton.swift +++ /dev/null @@ -1,116 +0,0 @@ -// 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" - } - } -} diff --git a/OpenLessKeyboard/Views/WaveformView.swift b/OpenLessKeyboard/Views/WaveformView.swift deleted file mode 100644 index 527f36b..0000000 --- a/OpenLessKeyboard/Views/WaveformView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// 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.. 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)) - } -} diff --git a/OpenLessShared/Constants/AppGroup.swift b/OpenLessShared/Constants/AppGroup.swift deleted file mode 100644 index 56476ad..0000000 --- a/OpenLessShared/Constants/AppGroup.swift +++ /dev/null @@ -1,21 +0,0 @@ -// AppGroup.swift -// OSGKeyboard · Shared -// -// App Group identifier shared between main app and keyboard extension. -// UserDefaults(suiteName:) and file containers use this. - -import Foundation - -public enum AppGroup { - /// App Group container identifier (must match entitlements in both targets) - public static let identifier = "group.com.osgkeyboard.ios" - - /// Shared UserDefaults instance for cross-process config - public static var defaults: UserDefaults { - guard let d = UserDefaults(suiteName: identifier) else { - assertionFailure("App Group \(identifier) not configured in entitlements") - return .standard - } - return d - } -} diff --git a/OpenLessShared/Services/AppGroupStore.swift b/OpenLessShared/Services/AppGroupStore.swift deleted file mode 100644 index a581b12..0000000 --- a/OpenLessShared/Services/AppGroupStore.swift +++ /dev/null @@ -1,45 +0,0 @@ -// AppGroupStore.swift -// OSGKeyboard · Shared -// -// Convenience wrapper around App Group UserDefaults for non-Published reads. -// Used by the keyboard extension (no SwiftUI) to read config without -// instantiating an ObservableObject. - -import Foundation - -public struct AppGroupStore: @unchecked Sendable { - public let defaults: UserDefaults - - public init(defaults: UserDefaults = AppGroup.defaults) { - self.defaults = defaults - } - - public var providerId: String { - defaults.string(forKey: "config.providerId") ?? "openai" - } - - public var baseURL: String { - defaults.string(forKey: "config.baseURL") ?? LLMProvider.provider(id: "openai").defaultBaseURL - } - - public var apiKey: String { - defaults.string(forKey: "config.apiKey") ?? "" - } - - public var model: String { - defaults.string(forKey: "config.model") ?? LLMProvider.provider(id: "openai").defaultModel - } - - public var systemPrompt: String { - defaults.string(forKey: "config.systemPrompt") - ?? "You are a voice-input polishing assistant. Rewrite the user's dictation as clean written text. Preserve intent. Add punctuation and structure. Do not invent facts. Output in the same language as the input." - } - - public func makeClient() -> LLMClient { - OpenAICompatibleClient( - baseURL: baseURL, - apiKey: apiKey, - model: model - ) - } -} diff --git a/README.md b/README.md index 33a65ad..9ff1720 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ 3. In any text field, tap 🌐 to switch to **OSGKeyboard**. 4. Press and hold the mic, speak, release. ✨ -> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy). +> **"Allow Full Access" is required.** Without it, iOS blocks the keyboard from using the microphone and from making network requests. We never log, store, or transmit your keystrokes — see [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy). --- @@ -71,23 +71,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ ``` OSGKeyboard/ -├── OpenLess/ # Main iOS app (settings, onboarding) +├── OSGKeyboard/ # Main iOS app (settings, onboarding) │ ├── Views/ # SwiftUI screens │ ├── OSGKeyboardApp.swift # @main entry │ ├── PrivacyInfo.xcprivacy # Required privacy manifest -│ └── OpenLess.entitlements # App Group declaration -├── OpenLessKeyboard/ # Custom Keyboard Extension +│ └── OSGKeyboard.entitlements # App Group declaration +├── OSGKeyboardExt/ # Custom Keyboard Extension │ ├── KeyboardViewController.swift # Principal class │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16 kHz PCM │ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ └── PolishingService.swift # LLM call with timeout │ └── Views/ # RecordButton, Waveform, KeyboardRootView -├── OpenLessShared/ # Framework shared by app + extension +├── OSGKeyboardShared/ # Framework shared by app + extension │ ├── Models/ # ProviderConfig, LLMRequest, LLMProvider │ ├── Services/ # LLMClient (OpenAI-compatible) │ └── Constants/ # AppGroup identifier -├── OpenLessTests/ # XCTest unit tests +├── OSGKeyboardTests/ # XCTest unit tests ├── project.yml # XcodeGen project definition └── .github/workflows/ci.yml # Lint + build CI ``` @@ -112,7 +112,7 @@ OSGKeyboard/ ## Adding a new LLM provider -Open `OpenLessShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. +Open `OSGKeyboardShared/Models/LLMProvider.swift` and append a new `LLMProvider` to the `presets` array. The default `OpenAICompatibleClient` handles any endpoint that speaks the `POST /chat/completions` protocol. ```swift LLMProvider( diff --git a/README.zh.md b/README.zh.md index 072fe21..04de1ab 100644 --- a/README.zh.md +++ b/README.zh.md @@ -62,7 +62,7 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ 3. 在任意输入框,点 🌐 切换到 **OSGKeyboard**。 4. 长按麦克风键 → 说话 → 松开。✨ -> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OpenLess/PrivacyInfo.xcprivacy)。 +> **"允许完全访问"是必须的。** 没有它,iOS 会阻止键盘使用麦克风与网络。我们**绝不记录、存储或上传你的击键** —— 见 [`PrivacyInfo.xcprivacy`](./OSGKeyboard/PrivacyInfo.xcprivacy)。 --- @@ -70,23 +70,23 @@ xcodebuild -project OSGKeyboard.xcodeproj -scheme OSGKeyboard \ ``` OSGKeyboard/ -├── OpenLess/ # 主 iOS App(设置、Onboarding) +├── OSGKeyboard/ # 主 iOS App(设置、Onboarding) │ ├── Views/ # SwiftUI 屏幕 │ ├── OSGKeyboardApp.swift # @main 入口 │ ├── PrivacyInfo.xcprivacy # 隐私清单 -│ └── OpenLess.entitlements # App Group 声明 -├── OpenLessKeyboard/ # 自定义键盘扩展 +│ └── OSGKeyboard.entitlements # App Group 声明 +├── OSGKeyboardExt/ # 自定义键盘扩展 │ ├── KeyboardViewController.swift # 主体类 │ ├── Services/ │ │ ├── AudioCaptureService.swift # AVAudioEngine → 16kHz PCM │ │ ├── ASRService.swift # iOS 26 + iOS 18 ASR │ │ └── PolishingService.swift # LLM 调用(带超时) │ └── Views/ # 录音按钮、波形、键盘主视图 -├── OpenLessShared/ # 主 App + 键盘共享 framework +├── OSGKeyboardShared/ # 主 App + 键盘共享 framework │ ├── Models/ # ProviderConfig、LLMRequest、LLMProvider │ ├── Services/ # LLMClient(OpenAI 兼容) │ └── Constants/ # App Group ID -├── OpenLessTests/ # XCTest 单元测试 +├── OSGKeyboardTests/ # XCTest 单元测试 ├── project.yml # XcodeGen 工程定义 └── .github/workflows/ci.yml # Lint + 编译 CI ``` @@ -111,7 +111,7 @@ OSGKeyboard/ ## 新增 LLM 提供商 -打开 `OpenLessShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 +打开 `OSGKeyboardShared/Models/LLMProvider.swift`,在 `presets` 数组里追加一条 `LLMProvider` 即可。默认的 `OpenAICompatibleClient` 处理任何实现了 `POST /chat/completions` 的端点。 ```swift LLMProvider( diff --git a/project.yml b/project.yml index e130c27..b5e7284 100644 --- a/project.yml +++ b/project.yml @@ -35,19 +35,23 @@ targets: type: application platform: iOS sources: - - path: OpenLess + - path: OSGKeyboard entitlements: - path: OpenLess/OpenLess.entitlements + path: OSGKeyboard/OSGKeyboard.entitlements + properties: + com.apple.security.application-groups: + - group.com.osgkeyboard.shared + com.apple.security.device.audio-input: true resources: - - path: OpenLess/Assets.xcassets + - path: OSGKeyboard/Assets.xcassets info: - path: OpenLess/Info.plist + path: OSGKeyboard/Info.plist properties: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" UILaunchScreen: - UIColorName: "" + UIColorName: "BackgroundColor" UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait UIApplicationSceneManifest: @@ -77,11 +81,17 @@ targets: type: app-extension platform: iOS sources: - - path: OpenLessKeyboard + - path: OSGKeyboardExt + settings: + base: + IPHONEOS_DEPLOYMENT_TARGET: "18.0" entitlements: - path: OpenLessKeyboard/OpenLessKeyboard.entitlements + path: OSGKeyboardExt/OSGKeyboardExt.entitlements + properties: + com.apple.security.application-groups: + - group.com.osgkeyboard.shared info: - path: OpenLessKeyboard/Info.plist + path: OSGKeyboardExt/Info.plist properties: CFBundleDisplayName: OSGKeyboard CFBundleShortVersionString: "$(MARKETING_VERSION)" @@ -109,9 +119,9 @@ targets: type: framework platform: iOS sources: - - path: OpenLessShared + - path: OSGKeyboardShared info: - path: OpenLessShared/Info.plist + path: OSGKeyboardShared/Info.plist settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.osgkeyboard.ios.shared @@ -129,9 +139,9 @@ targets: type: bundle.unit-test platform: iOS sources: - - path: OpenLessTests + - path: OSGKeyboardTests info: - path: OpenLessTests/Info.plist + path: OSGKeyboardTests/Info.plist dependencies: - target: OSGKeyboard settings: