[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:
2026-06-18 11:51:26 +08:00
parent ed11a11329
commit 9fa64223af
5 changed files with 159 additions and 8 deletions
+48 -7
View File
@@ -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
}
}
}
@@ -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: "!")
}
}
}