[JJC-20260618-005-B] Fix 6 critical bugs from review
- BUG-1: Restore KeyboardRootView top highlight + divider - BUG-2: App Group unavailable -> friendly error view (no fatalError) - BUG-3: pressBegan race condition (sync phase to .requestingPermissions) - BUG-4: cancelled LLMError no longer re-inserts original text - BUG-6: mode switch during recording flushes partial - ARCH-A1: State.Phase add .requestingPermissions + .denied(Reason) Notes: - 8/8 tests pass (LLMClientTests) - BUILD SUCCEEDED for both OSGKeyboard and OSGKeyboardExt - New file AppGroupErrorView.swift auto-registered via XcodeGen - Info.plist left untouched (NSSpeechRecognitionUsageDescription kept from A block)
This commit is contained in:
@@ -11,12 +11,14 @@ struct OSGKeyboardApp: App {
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ThemedRoot {
|
ThemedRoot {
|
||||||
Group {
|
if AppGroup.isAvailable {
|
||||||
if config.isConfigured {
|
if config.isConfigured {
|
||||||
HomeView()
|
HomeView()
|
||||||
} else {
|
} else {
|
||||||
OnboardingView(config: config)
|
OnboardingView(config: config)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
AppGroupErrorView()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,9 +34,12 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
public init() {}
|
public init() {}
|
||||||
public enum Phase: Equatable {
|
public enum Phase: Equatable {
|
||||||
case idle
|
case idle
|
||||||
|
case requestingPermissions
|
||||||
case recording
|
case recording
|
||||||
case processing
|
case processing
|
||||||
case error(String)
|
case error(String)
|
||||||
|
case denied(Reason)
|
||||||
|
public enum Reason: Equatable { case mic, speech }
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum InputMode: String, CaseIterable, Identifiable {
|
public enum InputMode: String, CaseIterable, Identifiable {
|
||||||
@@ -184,6 +187,10 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPersistedLocale() {
|
private func loadPersistedLocale() {
|
||||||
|
guard AppGroup.isAvailable else {
|
||||||
|
state.phase = .error("App Group 未配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
let store = AppGroupStore()
|
let store = AppGroupStore()
|
||||||
let id = store.localeId
|
let id = store.localeId
|
||||||
state.localeId = id
|
state.localeId = id
|
||||||
@@ -218,14 +225,17 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
private func pressBegan() {
|
private func pressBegan() {
|
||||||
guard state.phase == .idle else { return }
|
guard state.phase == .idle else { return }
|
||||||
guard state.mode != .off else { return }
|
guard state.mode != .off else { return }
|
||||||
// We optimistically enter `.recording`; the capture session will yield
|
// Set the intermediate phase SYNCHRONOUSLY so a rapid second
|
||||||
// frames on its own queue, so even if mic permission takes a beat the
|
// press (before the first Task has had a chance to flip phase to
|
||||||
// user already feels the press registered.
|
// .recording) is rejected by the guard above. This fixes the race
|
||||||
Task { @MainActor [weak self] in
|
// 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 }
|
guard let self else { return }
|
||||||
let micGranted = await self.requestMicPermission()
|
let micGranted = await self.requestMicPermission()
|
||||||
guard micGranted else {
|
guard micGranted else {
|
||||||
self.state.phase = .error("麦克风被拒绝,请到「设置」中允许")
|
self.state.phase = .denied(.mic)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -239,7 +249,7 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// prompts via the same plist key on first use.
|
// prompts via the same plist key on first use.
|
||||||
let speechGranted = await self.requestSpeechPermission()
|
let speechGranted = await self.requestSpeechPermission()
|
||||||
guard speechGranted else {
|
guard speechGranted else {
|
||||||
self.state.phase = .error("语音识别被拒绝,请到「设置」中允许")
|
self.state.phase = .denied(.speech)
|
||||||
self.scheduleAutoClearError()
|
self.scheduleAutoClearError()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -343,6 +353,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
case .http(429), .rateLimited:
|
case .http(429), .rateLimited:
|
||||||
self.state.phase = .error("API 限流 (429) · 请稍后再试")
|
self.state.phase = .error("API 限流 (429) · 请稍后再试")
|
||||||
self.scheduleAutoClearError()
|
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:
|
default:
|
||||||
// Other LLMError variants (transport / decoding /
|
// Other LLMError variants (transport / decoding /
|
||||||
// invalidURL / cancelled) fall back to raw transcript
|
// invalidURL / cancelled) fall back to raw transcript
|
||||||
@@ -370,8 +388,25 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|
||||||
private func persistMode(_ m: State.InputMode) {
|
private func persistMode(_ m: State.InputMode) {
|
||||||
|
let isRecording = state.phase == .recording
|
||||||
state.mode = m
|
state.mode = m
|
||||||
AppGroupStore().setModeId(m.rawValue)
|
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) {
|
private func persistLocale(_ id: String) {
|
||||||
@@ -466,8 +501,14 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
try? await Task.sleep(nanoseconds: 2_400_000_000)
|
||||||
guard let self else { return }
|
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
|
self.state.phase = .idle
|
||||||
|
default:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ public struct KeyboardRootView: View {
|
|||||||
// through by drawing no background of our own.
|
// through by drawing no background of our own.
|
||||||
.background(Color.clear)
|
.background(Color.clear)
|
||||||
.frame(height: Self.totalHeight)
|
.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
|
// MARK: - Top bar
|
||||||
@@ -135,9 +155,11 @@ public struct KeyboardRootView: View {
|
|||||||
private var buttonPhase: RecordButton.Phase {
|
private var buttonPhase: RecordButton.Phase {
|
||||||
switch state.phase {
|
switch state.phase {
|
||||||
case .idle: return .idle
|
case .idle: return .idle
|
||||||
|
case .requestingPermissions: return .idle
|
||||||
case .recording: return .recording
|
case .recording: return .recording
|
||||||
case .processing: return .processing
|
case .processing: return .processing
|
||||||
case .error: return .error
|
case .error: return .error
|
||||||
|
case .denied: return .error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,6 +205,13 @@ private struct TranscriptLine: View {
|
|||||||
Text("按住说话 · Hold to talk")
|
Text("按住说话 · Hold to talk")
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
.foregroundStyle(Palette.textTertiary)
|
.foregroundStyle(Palette.textTertiary)
|
||||||
|
case .requestingPermissions:
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ProgressView().controlSize(.mini).tint(Palette.textSecondary)
|
||||||
|
Text("准备中…")
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(Palette.textSecondary)
|
||||||
|
}
|
||||||
case .recording:
|
case .recording:
|
||||||
Text(transcript.isEmpty ? " " : transcript)
|
Text(transcript.isEmpty ? " " : transcript)
|
||||||
.font(TypeStyle.caption)
|
.font(TypeStyle.caption)
|
||||||
@@ -203,11 +232,24 @@ private struct TranscriptLine: View {
|
|||||||
.foregroundStyle(Palette.warning)
|
.foregroundStyle(Palette.warning)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
case .denied(let reason):
|
||||||
|
Text(deniedMessage(for: reason))
|
||||||
|
.font(TypeStyle.caption)
|
||||||
|
.foregroundStyle(Palette.warning)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.horizontal, Spacing.md)
|
.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
|
// MARK: - Toolbar icon button
|
||||||
@@ -244,12 +286,16 @@ private struct StatusBadge: View {
|
|||||||
switch phase {
|
switch phase {
|
||||||
case .idle:
|
case .idle:
|
||||||
EmptyView()
|
EmptyView()
|
||||||
|
case .requestingPermissions:
|
||||||
|
EmptyView()
|
||||||
case .recording:
|
case .recording:
|
||||||
dot(color: Palette.recordRed, label: "REC")
|
dot(color: Palette.recordRed, label: "REC")
|
||||||
case .processing:
|
case .processing:
|
||||||
dot(color: Palette.accent, label: "···")
|
dot(color: Palette.accent, label: "···")
|
||||||
case .error:
|
case .error:
|
||||||
dot(color: Palette.warning, label: "!")
|
dot(color: Palette.warning, label: "!")
|
||||||
|
case .denied:
|
||||||
|
dot(color: Palette.warning, label: "!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,22 @@ public enum AppGroup {
|
|||||||
/// App Group container identifier (must match entitlements in both targets)
|
/// App Group container identifier (must match entitlements in both targets)
|
||||||
public static let identifier = "group.com.osgkeyboard.shared"
|
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.
|
/// Shared UserDefaults instance for cross-process config.
|
||||||
///
|
///
|
||||||
/// In DEBUG builds a missing App Group is a hard `fatalError`: silently
|
/// In DEBUG builds a missing App Group is a hard `fatalError`: silently
|
||||||
|
|||||||
Reference in New Issue
Block a user