[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:
@@ -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: "!")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user