diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift index bdeb0cf..e5ae260 100644 --- a/OSGKeyboard/OSGKeyboardApp.swift +++ b/OSGKeyboard/OSGKeyboardApp.swift @@ -11,12 +11,14 @@ struct OSGKeyboardApp: App { var body: some Scene { WindowGroup { ThemedRoot { - Group { + if AppGroup.isAvailable { if config.isConfigured { HomeView() } else { OnboardingView(config: config) } + } else { + AppGroupErrorView() } } } diff --git a/OSGKeyboard/Views/AppGroupErrorView.swift b/OSGKeyboard/Views/AppGroupErrorView.swift new file mode 100644 index 0000000..06df649 --- /dev/null +++ b/OSGKeyboard/Views/AppGroupErrorView.swift @@ -0,0 +1,46 @@ +// AppGroupErrorView.swift +// OSGKeyboard · Main App +// +// Shown in place of the normal Home/Onboarding flow when the App Group +// container is not configured. The whole app is unusable without it (the +// keyboard extension and the main app cannot share state), so we don't +// try to be clever — we show a clear, actionable error and stop. +// +// We deliberately do NOT fatalError in release: the developer might be +// running a TestFlight build with a stripped entitlement, and a friendly +// screen is much better than a crash loop. + +import SwiftUI +import OSGKeyboardShared + +struct AppGroupErrorView: View { + var body: some View { + VStack(spacing: Spacing.lg) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundStyle(Palette.danger) + Text("App Group 未配置") + .font(TypeStyle.title2) + Text("OSGKeyboard 需要 App Group 才能在键盘扩展和主 App 之间共享配置。") + .font(TypeStyle.body) + .multilineTextAlignment(.center) + .foregroundStyle(Palette.textSecondary) + VStack(alignment: .leading, spacing: Spacing.sm) { + Label("在 Apple Developer 后台创建 group.com.osgkeyboard.shared", systemImage: "1.circle") + Label("主 App 和键盘扩展都启用该 App Group", systemImage: "2.circle") + Label("重新生成 provisioning profile 并下载", systemImage: "3.circle") + } + .font(TypeStyle.body) + .foregroundStyle(Palette.textPrimary) + } + .padding(Spacing.lg) + } +} + +#if DEBUG +#Preview { + ThemedRoot { + AppGroupErrorView() + } +} +#endif diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index b80b762..37f15b2 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -34,9 +34,12 @@ public final class KeyboardViewController: UIInputViewController { public init() {} public enum Phase: Equatable { case idle + case requestingPermissions case recording case processing case error(String) + case denied(Reason) + public enum Reason: Equatable { case mic, speech } } public enum InputMode: String, CaseIterable, Identifiable { @@ -184,6 +187,10 @@ public final class KeyboardViewController: UIInputViewController { } private func loadPersistedLocale() { + guard AppGroup.isAvailable else { + state.phase = .error("App Group 未配置") + return + } let store = AppGroupStore() let id = store.localeId state.localeId = id @@ -218,14 +225,17 @@ public final class KeyboardViewController: UIInputViewController { 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 + // Set the intermediate phase SYNCHRONOUSLY so a rapid second + // press (before the first Task has had a chance to flip phase to + // .recording) is rejected by the guard above. This fixes the race + // where the user double-tapped the mic and we started two + // pipelines at once. + state.phase = .requestingPermissions + Task { @MainActor [weak self] in guard let self else { return } let micGranted = await self.requestMicPermission() guard micGranted else { - self.state.phase = .error("麦克风被拒绝,请到「设置」中允许") + self.state.phase = .denied(.mic) self.scheduleAutoClearError() return } @@ -239,7 +249,7 @@ public final class KeyboardViewController: UIInputViewController { // prompts via the same plist key on first use. let speechGranted = await self.requestSpeechPermission() guard speechGranted else { - self.state.phase = .error("语音识别被拒绝,请到「设置」中允许") + self.state.phase = .denied(.speech) self.scheduleAutoClearError() return } @@ -343,6 +353,14 @@ public final class KeyboardViewController: UIInputViewController { case .http(429), .rateLimited: self.state.phase = .error("API 限流 (429) · 请稍后再试") self.scheduleAutoClearError() + case .cancelled: + // User-initiated cancellation (e.g. mode switch mid- + // polish). Do NOT re-insert the original transcript — + // the user has already moved on and the partial is + // considered discarded. + self.state.phase = .idle + self.state.lastTranscript = "" + return default: // Other LLMError variants (transport / decoding / // invalidURL / cancelled) fall back to raw transcript @@ -370,8 +388,25 @@ public final class KeyboardViewController: UIInputViewController { // MARK: - Persistence private func persistMode(_ m: State.InputMode) { + let isRecording = state.phase == .recording state.mode = m AppGroupStore().setModeId(m.rawValue) + if isRecording { + if m == .off { + // Switching to .off while recording: drop the partial + // (no insertion, no LLM). User has explicitly disabled + // the keyboard, so we honour that immediately. + stopPipeline() + state.phase = .idle + state.lastTranscript = "" + } else if m == .transcribe { + // Switching to .transcribe while in .polish: end the + // recording, the partial will flow through + // handleFinalTranscript which inserts the raw text in + // .transcribe mode (no LLM call). + pressEnded() + } + } } private func persistLocale(_ id: String) { @@ -466,8 +501,14 @@ public final class KeyboardViewController: UIInputViewController { 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 { + // Clear both .error (transient error message) and .denied + // (permission was rejected — show the message, then return + // to idle so the user can navigate away). + switch self.state.phase { + case .error, .denied: self.state.phase = .idle + default: + break } } } diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 47da285..74e1b33 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -52,6 +52,26 @@ public struct KeyboardRootView: View { // through by drawing no background of our own. .background(Color.clear) .frame(height: Self.totalHeight) + // Top edge: subtle highlight gradient + 0.5pt divider line. + // These give the keyboard a "physical surface" feel and visually + // separate it from the host text field above. We overlay (not + // background) so the underlying color stays clear. + .overlay(alignment: .top) { + 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 @@ -135,9 +155,11 @@ public struct KeyboardRootView: View { private var buttonPhase: RecordButton.Phase { switch state.phase { case .idle: return .idle + case .requestingPermissions: return .idle case .recording: return .recording case .processing: return .processing case .error: return .error + case .denied: return .error } } } @@ -183,6 +205,13 @@ private struct TranscriptLine: View { Text("按住说话 · Hold to talk") .font(TypeStyle.caption) .foregroundStyle(Palette.textTertiary) + case .requestingPermissions: + HStack(spacing: 6) { + ProgressView().controlSize(.mini).tint(Palette.textSecondary) + Text("准备中…") + .font(TypeStyle.caption) + .foregroundStyle(Palette.textSecondary) + } case .recording: Text(transcript.isEmpty ? " " : transcript) .font(TypeStyle.caption) @@ -203,11 +232,24 @@ private struct TranscriptLine: View { .foregroundStyle(Palette.warning) .lineLimit(1) .truncationMode(.tail) + case .denied(let reason): + Text(deniedMessage(for: reason)) + .font(TypeStyle.caption) + .foregroundStyle(Palette.warning) + .lineLimit(1) + .truncationMode(.tail) } } .frame(maxWidth: .infinity) .padding(.horizontal, Spacing.md) } + + private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String { + switch reason { + case .mic: return "麦克风被拒绝 · 请到「设置」中允许" + case .speech: return "语音识别被拒绝 · 请到「设置」中允许" + } + } } // MARK: - Toolbar icon button @@ -244,12 +286,16 @@ private struct StatusBadge: View { switch phase { case .idle: EmptyView() + case .requestingPermissions: + EmptyView() case .recording: dot(color: Palette.recordRed, label: "REC") case .processing: dot(color: Palette.accent, label: "···") case .error: dot(color: Palette.warning, label: "!") + case .denied: + dot(color: Palette.warning, label: "!") } } } diff --git a/OSGKeyboardShared/Constants/AppGroup.swift b/OSGKeyboardShared/Constants/AppGroup.swift index b137c23..50ff542 100644 --- a/OSGKeyboardShared/Constants/AppGroup.swift +++ b/OSGKeyboardShared/Constants/AppGroup.swift @@ -10,6 +10,22 @@ public enum AppGroup { /// App Group container identifier (must match entitlements in both targets) public static let identifier = "group.com.osgkeyboard.shared" + /// Whether the App Group container is available on this device. + /// + /// Cached at first read — the underlying `UserDefaults(suiteName:)` + /// call is cheap, but main-app startup and every keyboard-extension + /// read hit it, so we memoize the result. + /// + /// Production code paths MUST go through `isAvailable` first and + /// surface a friendly error view (e.g. `AppGroupErrorView`) on the + /// main app, or the keyboard extension's persisted-locale load. + /// Calling `defaults` directly when the group is missing will trip + /// the DEBUG `fatalError` below — that path is reserved for + /// developer-only escape hatches and intentional debugging. + public static let isAvailable: Bool = { + UserDefaults(suiteName: identifier) != nil + }() + /// Shared UserDefaults instance for cross-process config. /// /// In DEBUG builds a missing App Group is a hard `fatalError`: silently