diff --git a/.github/screenshots/627/01-permissions-soft-gate.png b/.github/screenshots/627/01-permissions-soft-gate.png new file mode 100644 index 00000000..f546226e Binary files /dev/null and b/.github/screenshots/627/01-permissions-soft-gate.png differ diff --git a/.github/screenshots/627/02-playground-in-app-record.png b/.github/screenshots/627/02-playground-in-app-record.png new file mode 100644 index 00000000..e7399e63 Binary files /dev/null and b/.github/screenshots/627/02-playground-in-app-record.png differ diff --git a/.github/screenshots/627/03-settings-clipboard-only.png b/.github/screenshots/627/03-settings-clipboard-only.png new file mode 100644 index 00000000..c415da4c Binary files /dev/null and b/.github/screenshots/627/03-settings-clipboard-only.png differ diff --git a/Sources/Fluid/ContentView.swift b/Sources/Fluid/ContentView.swift index 051c4f19..16f882ea 100644 --- a/Sources/Fluid/ContentView.swift +++ b/Sources/Fluid/ContentView.swift @@ -350,6 +350,11 @@ struct ContentView: View { .onReceive(NotificationCenter.default.publisher(for: .settingsBackupDidRestore)) { _ in self.reloadSettingsStateAfterBackupRestore() } + .onChange(of: self.settings.copyTranscriptionToClipboard) { _, newValue in + // Keep Settings toggle in sync when onboarding skip (or other writers) + // update SettingsStore directly. + self.copyToClipboard = newValue + } .toolbar { if !self.settings.shouldShowOnboarding { ToolbarItemGroup(placement: .primaryAction) { @@ -619,6 +624,15 @@ struct ContentView: View { self.audioObserver.startObserving() self.asr.initialize() self.menuBarManager.configure(asrService: self.appServices.asr) + self.menuBarManager.configureDictationControls( + start: { [self] in + self.startRecording() + }, + stop: { [self] in + let route = self.currentDictationOutputRouteForHotkeyStop() + await self.stopAndProcessTranscription(route: route) + } + ) self.refreshDevices() if self.selectedInputUID.isEmpty, let defIn = AudioDevice.getDefaultInputDevice()?.uid { @@ -1330,6 +1344,10 @@ struct ContentView: View { openAccessibilitySettings: self.openAccessibilitySettings, restartApp: self.restartApp, menuBarManager: self.menuBarManager, + stopAndProcessTranscription: { + await self.stopAndProcessTranscription(route: .onboardingSandbox) + }, + startRecording: self.startRecording, activeShortcutRecordingTarget: self.$activeShortcutRecordingTarget, shortcutRecordingMessage: self.$shortcutRecordingMessage, theme: self.theme @@ -2028,6 +2046,11 @@ struct ContentView: View { DebugLogger.shared.info("Output route selected: \(route.rawValue)", source: "ContentView") self.appBench("stop_path_enter route=\(route.rawValue)") + // Disable menu Start/Stop for the full stop window — including hotkey and + // in-app stops, where isRunning flips false before transcription finishes. + self.menuBarManager.beginDictationStopProcessing() + defer { self.menuBarManager.endDictationStopProcessing() } + // Check if we're in rewrite or command mode let modeAtStop = self.activeRecordingMode let wasRewriteMode = modeAtStop == .edit || self.isRecordingForRewrite @@ -2363,28 +2386,41 @@ struct ContentView: View { model: transcriptionModelInfo.model ) } - // When FluidVoice itself is frontmost, the bound editor already receives `finalText`. - // Avoid re-inserting or overwriting the clipboard in that self-target case. - let shouldCopyToClipboard = shouldPersistOutputs && - SettingsStore.shared.copyTranscriptionToClipboard && - !isFluidFrontmost + // When FluidVoice itself is frontmost and Accessibility works, the bound + // editor already receives `finalText` — skip clipboard overwrite there. + // In clipboard-only mode (!Accessibility), still copy even when Fluid is + // frontmost so in-app / menu-bar recordings are not dropped. + let accessibilityTrusted = AXIsProcessTrusted() + let shouldForceClipboardFallback = shouldPersistOutputs && !accessibilityTrusted + let shouldCopyToClipboard = shouldPersistOutputs && ( + shouldForceClipboardFallback || + (!isFluidFrontmost && SettingsStore.shared.copyTranscriptionToClipboard) + ) + var didCopyToClipboard = false if shouldCopyToClipboard { - ClipboardService.copyToClipboard(finalText) - AnalyticsService.shared.capture( - .outputDelivered, - properties: [ - "mode": AnalyticsMode.dictation.rawValue, - "method": AnalyticsOutputMethod.clipboard.rawValue, - ] - ) + didCopyToClipboard = ClipboardService.copyToClipboard(finalText) + if didCopyToClipboard { + AnalyticsService.shared.capture( + .outputDelivered, + properties: [ + "mode": AnalyticsMode.dictation.rawValue, + "method": AnalyticsOutputMethod.clipboard.rawValue, + "clipboard_only_output": shouldForceClipboardFallback, + ] + ) + } + } + + if shouldForceClipboardFallback, didCopyToClipboard { + NotificationService.showClipboardOnlyOutput() } var didTypeExternally = false - let shouldTypeExternally = shouldPersistOutputs && !isFluidFrontmost + let shouldTypeExternally = shouldPersistOutputs && !isFluidFrontmost && accessibilityTrusted DebugLogger.shared.debug( - "Typing decision → frontmost: \(frontmostName), fluidFrontmost: \(isFluidFrontmost), editorFocused: \(self.isTranscriptionFocused), willTypeExternally: \(shouldTypeExternally)", + "Typing decision → frontmost: \(frontmostName), fluidFrontmost: \(isFluidFrontmost), editorFocused: \(self.isTranscriptionFocused), accessibilityTrusted: \(accessibilityTrusted), willTypeExternally: \(shouldTypeExternally)", source: "ContentView" ) @@ -2434,7 +2470,7 @@ struct ContentView: View { aiProvider: modelInfo.provider ) } else if shouldPersistOutputs, - SettingsStore.shared.copyTranscriptionToClipboard == false, + !didCopyToClipboard, SettingsStore.shared.saveTranscriptionHistory { AnalyticsService.shared.capture( @@ -2735,16 +2771,25 @@ struct ContentView: View { let frontmostApp = NSWorkspace.shared.frontmostApplication let isFluidFrontmost = frontmostApp?.bundleIdentifier == Bundle.main.bundleIdentifier + let accessibilityTrusted = AXIsProcessTrusted() + let shouldForceClipboardFallback = !accessibilityTrusted + let shouldCopyToClipboard = shouldForceClipboardFallback || + (!isFluidFrontmost && SettingsStore.shared.copyTranscriptionToClipboard) + + var didCopyToClipboard = false + if shouldCopyToClipboard { + didCopyToClipboard = ClipboardService.copyToClipboard(finalText) + } - if SettingsStore.shared.copyTranscriptionToClipboard, !isFluidFrontmost { - ClipboardService.copyToClipboard(finalText) + if shouldForceClipboardFallback, didCopyToClipboard { + NotificationService.showClipboardOnlyOutput() } let focusedPID = TypingService.captureSystemFocusedPID() ?? NSWorkspace.shared.frontmostApplication?.processIdentifier NotchContentState.shared.recordingTargetPID = focusedPID - let shouldTypeExternally = !isFluidFrontmost + let shouldTypeExternally = !isFluidFrontmost && accessibilityTrusted if shouldTypeExternally { let typingTarget = self.resolveTypingTargetPID() if typingTarget.shouldRestoreOriginalFocus { @@ -2850,8 +2895,9 @@ struct ContentView: View { self.pendingAIReprocessText = nil } + var didCopyToClipboard = false if SettingsStore.shared.copyTranscriptionToClipboard { - ClipboardService.copyToClipboard(finalText) + didCopyToClipboard = ClipboardService.copyToClipboard(finalText) } let focusedPID = TypingService.captureSystemFocusedPID() @@ -2860,7 +2906,17 @@ struct ContentView: View { let frontmostApp = NSWorkspace.shared.frontmostApplication let isFluidFrontmost = frontmostApp?.bundleIdentifier?.contains("fluid") == true - let shouldTypeExternally = !isFluidFrontmost || self.isTranscriptionFocused == false + let accessibilityTrusted = AXIsProcessTrusted() + let shouldForceClipboardFallback = !accessibilityTrusted + if shouldForceClipboardFallback { + if !didCopyToClipboard { + didCopyToClipboard = ClipboardService.copyToClipboard(finalText) + } + if didCopyToClipboard { + NotificationService.showClipboardOnlyOutput() + } + } + let shouldTypeExternally = (!isFluidFrontmost || self.isTranscriptionFocused == false) && accessibilityTrusted if shouldTypeExternally { let typingTarget = self.resolveTypingTargetPID() if typingTarget.shouldRestoreOriginalFocus { @@ -2901,35 +2957,44 @@ struct ContentView: View { if !self.rewriteModeService.rewrittenText.isEmpty { DebugLogger.shared.info("Rewrite successful, typing result (chars: \(self.rewriteModeService.rewrittenText.count))", source: "ContentView") - // Copy to clipboard as backup - if SettingsStore.shared.copyTranscriptionToClipboard { - ClipboardService.copyToClipboard(self.rewriteModeService.rewrittenText) + // Copy to clipboard as backup (or as primary delivery without Accessibility) + let accessibilityTrusted = AXIsProcessTrusted() + var didCopyToClipboard = false + if SettingsStore.shared.copyTranscriptionToClipboard || !accessibilityTrusted { + didCopyToClipboard = ClipboardService.copyToClipboard(self.rewriteModeService.rewrittenText) + if didCopyToClipboard { + AnalyticsService.shared.capture( + .outputDelivered, + properties: [ + "mode": AnalyticsMode.rewrite.rawValue, + "method": AnalyticsOutputMethod.clipboard.rawValue, + "clipboard_only_output": !accessibilityTrusted, + ] + ) + } + } + + // Type the rewritten text when Accessibility is available + if accessibilityTrusted { + let typingTarget = self.resolveTypingTargetPID() + if typingTarget.shouldRestoreOriginalFocus { + await self.restoreFocusToRecordingTarget() + } + self.asr.typeTextToActiveField( + self.rewriteModeService.rewrittenText, + preferredTargetPID: typingTarget.pid + ) AnalyticsService.shared.capture( .outputDelivered, properties: [ "mode": AnalyticsMode.rewrite.rawValue, - "method": AnalyticsOutputMethod.clipboard.rawValue, + "method": AnalyticsOutputMethod.typed.rawValue, ] ) + } else if didCopyToClipboard { + NotificationService.showClipboardOnlyOutput() } - // Type the rewritten text - let typingTarget = self.resolveTypingTargetPID() - if typingTarget.shouldRestoreOriginalFocus { - await self.restoreFocusToRecordingTarget() - } - self.asr.typeTextToActiveField( - self.rewriteModeService.rewrittenText, - preferredTargetPID: typingTarget.pid - ) - AnalyticsService.shared.capture( - .outputDelivered, - properties: [ - "mode": AnalyticsMode.rewrite.rawValue, - "method": AnalyticsOutputMethod.typed.rawValue, - ] - ) - // Clear the rewrite service state for next use self.rewriteModeService.clearState() self.hideOverlayAfterOutput() @@ -3760,10 +3825,6 @@ extension ContentView { self.asr.micStatus == .authorized } - private var onboardingAccessibilityReady: Bool { - self.accessibilityEnabled - } - private var onboardingAIReady: Bool { self.settings.onboardingAISkipped || DictationAIPostProcessingGate.isProviderConfigured() } @@ -3822,9 +3883,6 @@ extension ContentView { if !self.onboardingMicrophoneReady { missing.append("microphone access") } - if !self.onboardingAccessibilityReady { - missing.append("Accessibility access") - } if !allowsAIConfiguration, !self.onboardingAIReady { missing.append("AI choice") } diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 4cb7c599..8de477d8 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -1772,7 +1772,10 @@ final class SettingsStore: ObservableObject { var copyTranscriptionToClipboard: Bool { get { self.defaults.bool(forKey: Keys.copyTranscriptionToClipboard) } - set { self.defaults.set(newValue, forKey: Keys.copyTranscriptionToClipboard) } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.copyTranscriptionToClipboard) + } } var preferredInputDeviceUID: String? { @@ -2336,6 +2339,14 @@ final class SettingsStore: ObservableObject { } } + var onboardingAccessibilitySkipped: Bool { + get { self.defaults.bool(forKey: Keys.onboardingAccessibilitySkipped) } + set { + objectWillChange.send() + self.defaults.set(newValue, forKey: Keys.onboardingAccessibilitySkipped) + } + } + var onboardingSelectedLanguageID: String { get { let stored = self.defaults.string(forKey: Keys.onboardingSelectedLanguageID)? @@ -2396,6 +2407,7 @@ final class SettingsStore: ObservableObject { self.defaults.set(false, forKey: Keys.onboardingAISkipped) self.defaults.set(false, forKey: Keys.onboardingPlaygroundValidated) self.defaults.set(false, forKey: Keys.onboardingPlaygroundSkipped) + self.defaults.set(false, forKey: Keys.onboardingAccessibilitySkipped) self.defaults.set("en", forKey: Keys.onboardingSelectedLanguageID) } else { self.defaults.set(true, forKey: Keys.onboardingCompleted) @@ -2403,6 +2415,7 @@ final class SettingsStore: ObservableObject { self.defaults.set(false, forKey: Keys.onboardingAISkipped) self.defaults.set(false, forKey: Keys.onboardingPlaygroundValidated) self.defaults.set(false, forKey: Keys.onboardingPlaygroundSkipped) + self.defaults.set(false, forKey: Keys.onboardingAccessibilitySkipped) self.defaults.set("en", forKey: Keys.onboardingSelectedLanguageID) } } @@ -2416,6 +2429,7 @@ final class SettingsStore: ObservableObject { self.defaults.set(false, forKey: Keys.onboardingAISkipped) self.defaults.set(false, forKey: Keys.onboardingPlaygroundValidated) self.defaults.set(false, forKey: Keys.onboardingPlaygroundSkipped) + self.defaults.set(false, forKey: Keys.onboardingAccessibilitySkipped) self.defaults.set("en", forKey: Keys.onboardingSelectedLanguageID) self.defaults.set(false, forKey: Keys.playgroundUsed) } @@ -2443,6 +2457,7 @@ final class SettingsStore: ObservableObject { self.defaults.set(false, forKey: Keys.onboardingAISkipped) self.defaults.set(false, forKey: Keys.onboardingPlaygroundValidated) self.defaults.set(false, forKey: Keys.onboardingPlaygroundSkipped) + self.defaults.set(false, forKey: Keys.onboardingAccessibilitySkipped) } private func hasLegacyUsageSignals() -> Bool { @@ -4857,6 +4872,7 @@ private extension SettingsStore { static let onboardingAISkipped = "OnboardingAISkipped" static let onboardingPlaygroundValidated = "OnboardingPlaygroundValidated" static let onboardingPlaygroundSkipped = "OnboardingPlaygroundSkipped" + static let onboardingAccessibilitySkipped = "OnboardingAccessibilitySkipped" static let onboardingSelectedLanguageID = "OnboardingSelectedLanguageID" // Command Mode Keys diff --git a/Sources/Fluid/Services/MenuBarManager.swift b/Sources/Fluid/Services/MenuBarManager.swift index 6621b54e..fd832d06 100644 --- a/Sources/Fluid/Services/MenuBarManager.swift +++ b/Sources/Fluid/Services/MenuBarManager.swift @@ -17,6 +17,7 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { // Cached menu items to avoid rebuilding entire menu private var statusMenuItem: NSMenuItem? + private var startStopDictationMenuItem: NSMenuItem? private var copyLastTranscriptMenuItem: NSMenuItem? private var rollbackMenuItem: NSMenuItem? private var microphoneMenuItem: NSMenuItem? @@ -25,6 +26,8 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { // References to app state private weak var asrService: ASRService? private var cancellables = Set() + private var startDictationCallback: (() -> Void)? + private var stopDictationCallback: (() async -> Void)? /// Overlay management (persistent, independent of window lifecycle) private var overlayVisible: Bool = false @@ -34,6 +37,15 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { /// overlay. During post-processing we want the overlay to stay visible until processing ends. private var isProcessingActive: Bool = false + /// Depth of in-flight stop→transcribe work (menu, hotkey, or in-app). + /// `isRunning` flips false before that work finishes; without this the menu becomes Start + /// and can begin a new recording mid-stop (mirrors GlobalHotkeyManager.`isProcessingStop`). + private var dictationStopProcessingCount = 0 + + private var isProcessingDictationStop: Bool { + self.dictationStopProcessingCount > 0 + } + @Published var isRecording: Bool = false /// One-shot navigation requests from the menu bar into the main window UI. @@ -85,6 +97,13 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } .store(in: &self.cancellables) + asrService.$isDictionaryTrainingCaptureActive + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.updateMenu() + } + .store(in: &self.cancellables) + // Subscribe to partial transcription updates for streaming preview asrService.$partialTranscription .receive(on: DispatchQueue.main) @@ -97,6 +116,15 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { .store(in: &self.cancellables) } + func configureDictationControls( + start: @escaping () -> Void, + stop: @escaping () async -> Void + ) { + self.startDictationCallback = start + self.stopDictationCallback = stop + self.updateMenuItemsText() + } + private func handleOverlayState(isRunning: Bool, asrService: ASRService) { self.overlayBench("handle_state isRunning=\(isRunning) overlayVisible=\(self.overlayVisible) processing=\(self.isProcessingActive) mode=\(self.currentOverlayMode.rawValue)") @@ -366,6 +394,24 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } } + /// Keeps Start/Stop Dictation disabled for the full stop→transcribe window, + /// including stops initiated from hotkeys or in-app Record (not only the menu item). + /// Nested begin/end is supported so menu + ContentView can both track the same stop. + func beginDictationStopProcessing() { + self.dictationStopProcessingCount += 1 + if self.dictationStopProcessingCount == 1 { + self.updateMenuItemsText() + } + } + + func endDictationStopProcessing() { + guard self.dictationStopProcessingCount > 0 else { return } + self.dictationStopProcessingCount -= 1 + if self.dictationStopProcessingCount == 0 { + self.updateMenuItemsText() + } + } + /// Ends processing and waits for the recording overlay's exit transition. /// Output paths normally call this asynchronously after insertion dispatch /// so the exit animation cannot delay text delivery. @@ -466,6 +512,15 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { menu.addItem(statusItem) } + let startStopItem = NSMenuItem( + title: "Start Dictation", + action: #selector(toggleDictationFromMenu(_:)), + keyEquivalent: "" + ) + startStopItem.target = self + menu.addItem(startStopItem) + self.startStopDictationMenuItem = startStopItem + let copyLastTranscriptItem = NSMenuItem( title: "Copy Last Transcript", action: #selector(copyLastTranscript(_:)), @@ -549,12 +604,29 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { } } + private var canToggleDictationFromMenu: Bool { + guard self.startDictationCallback != nil, self.stopDictationCallback != nil else { + return false + } + // Dictionary training owns recording; diverting stop into the dictation + // pipeline would desync training UI and emit the sample as a transcript. + if self.asrService?.isDictionaryTrainingCaptureActive == true { + return false + } + if self.isProcessingDictationStop { + return false + } + return true + } + private func updateMenuItemsText() { // Update status text with hotkey info let hotkeyDisplay = SettingsStore.shared.primaryDictationShortcutDisplayString let hotkeyInfo = hotkeyDisplay.isEmpty ? "" : " (\(hotkeyDisplay))" let statusTitle = self.isRecording ? "Recording...\(hotkeyInfo)" : "Ready to Record\(hotkeyInfo)" self.statusMenuItem?.title = statusTitle + self.startStopDictationMenuItem?.title = self.isRecording ? "Stop Dictation" : "Start Dictation" + self.startStopDictationMenuItem?.isEnabled = self.canToggleDictationFromMenu self.copyLastTranscriptMenuItem?.isEnabled = self.canCopyLastTranscript self.microphoneMenuItem?.isEnabled = true @@ -644,6 +716,32 @@ final class MenuBarManager: NSObject, ObservableObject, NSMenuDelegate { DebugLogger.shared.info("Menu action: Copied latest transcription to clipboard", source: "MenuBarManager") } + @objc private func toggleDictationFromMenu(_ sender: Any?) { + guard self.canToggleDictationFromMenu else { + DebugLogger.shared.info( + "Menu action: Start/Stop Dictation ignored (callbacks missing, training active, or stop in flight)", + source: "MenuBarManager" + ) + return + } + + if self.isRecording { + guard let stopDictationCallback else { return } + DebugLogger.shared.info("Menu action: Stop Dictation", source: "MenuBarManager") + // Begin immediately so Start stays disabled before the Task runs; + // ContentView also begins/ends around stopAndProcessTranscription. + self.beginDictationStopProcessing() + Task { @MainActor in + defer { self.endDictationStopProcessing() } + await stopDictationCallback() + } + } else { + guard let startDictationCallback else { return } + DebugLogger.shared.info("Menu action: Start Dictation", source: "MenuBarManager") + startDictationCallback() + } + } + @objc private func selectMicrophone(_ sender: NSMenuItem) { guard self.isRecording == false else { return } guard let uid = sender.representedObject as? String, !uid.isEmpty else { return } diff --git a/Sources/Fluid/Services/NotificationService.swift b/Sources/Fluid/Services/NotificationService.swift index e0922581..4234660a 100644 --- a/Sources/Fluid/Services/NotificationService.swift +++ b/Sources/Fluid/Services/NotificationService.swift @@ -10,6 +10,7 @@ enum NotificationService { static let aiProcessingFallback = "aiProcessingFallback" static let audioCaptureFallback = "audioCaptureFallback" static let commandModeFailure = "commandModeFailure" + static let clipboardOnlyOutput = "clipboardOnlyOutput" } static func showAudioCaptureFallback( @@ -111,6 +112,34 @@ enum NotificationService { } } + static func showClipboardOnlyOutput() { + let center = UNUserNotificationCenter.current() + center.getNotificationSettings { settings in + switch settings.authorizationStatus { + case .authorized, .provisional, .ephemeral: + self.deliverClipboardOnlyOutput(using: center) + case .notDetermined: + center.requestAuthorization(options: [.alert]) { granted, requestError in + if let requestError { + DebugLogger.shared.warning( + "Notification permission request failed: \(requestError.localizedDescription)", + source: "NotificationService" + ) + } + guard granted else { return } + self.deliverClipboardOnlyOutput(using: center) + } + case .denied: + DebugLogger.shared.debug( + "Skipping clipboard-only notification because notification permission is denied", + source: "NotificationService" + ) + @unknown default: + break + } + } + } + private static func deliverAIProcessingFallback(error: String, using center: UNUserNotificationCenter) { let content = UNMutableNotificationContent() content.title = "AI Enhancement failed" @@ -189,4 +218,29 @@ enum NotificationService { } } } + + private static func deliverClipboardOnlyOutput(using center: UNUserNotificationCenter) { + let content = UNMutableNotificationContent() + content.title = "Copied to clipboard" + content.body = "Enable Accessibility for auto-paste and global hotkeys." + content.sound = nil + content.userInfo = [UserInfoKey.kind: Kind.clipboardOnlyOutput] + + // Stable ID so repeated clipboard-only dictations replace the previous + // banner instead of stacking identical Notification Center entries. + let request = UNNotificationRequest( + identifier: "clipboard-only-output", + content: content, + trigger: nil + ) + + center.add(request) { addError in + if let addError { + DebugLogger.shared.warning( + "Failed to show clipboard-only notification: \(addError.localizedDescription)", + source: "NotificationService" + ) + } + } + } } diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 3d76d70e..d345ca49 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -296,9 +296,15 @@ final class TypingService { // Check accessibility permissions first guard AXIsProcessTrusted() else { - self.bench("request_return reason=accessibility_not_trusted") - self.log("[TypingService] ERROR: Accessibility permissions required for text injection") - self.log("[TypingService] Current accessibility status: \(AXIsProcessTrusted())") + self.bench("request_return reason=accessibility_not_trusted clipboard_fallback=true") + self.log("[TypingService] Accessibility unavailable — copying transcript to clipboard instead of injecting") + let copied = ClipboardService.copyToClipboard(text) + self.log("[TypingService] Clipboard fallback \(copied ? "succeeded" : "failed") for \(text.count) characters") + if copied { + Task { @MainActor in + NotificationService.showClipboardOnlyOutput() + } + } return } diff --git a/Sources/Fluid/UI/OnboardingTryoutStepView.swift b/Sources/Fluid/UI/OnboardingTryoutStepView.swift index ee5f0166..1c72e231 100644 --- a/Sources/Fluid/UI/OnboardingTryoutStepView.swift +++ b/Sources/Fluid/UI/OnboardingTryoutStepView.swift @@ -10,7 +10,9 @@ struct OnboardingTryoutStepView: View { let isRecordingShortcut: Bool let shortcutRecordingMessage: String? let footerHint: String? + let showsInAppRecordControl: Bool let onToggleShortcut: () -> Void + let onToggleRecording: (() -> Void)? @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.theme) private var theme @@ -19,6 +21,7 @@ struct OnboardingTryoutStepView: View { @State private var isShortcutKeyPressed = false @State private var isShortcutGlowActive = false @State private var shortcutAnimationRevision = 0 + @State private var isRecordHovered = false init( finalText: Binding, @@ -29,7 +32,9 @@ struct OnboardingTryoutStepView: View { isRecordingShortcut: Bool, shortcutRecordingMessage: String?, footerHint: String? = nil, - onToggleShortcut: @escaping () -> Void + showsInAppRecordControl: Bool = false, + onToggleShortcut: @escaping () -> Void, + onToggleRecording: (() -> Void)? = nil ) { self._finalText = finalText self.language = language @@ -39,7 +44,9 @@ struct OnboardingTryoutStepView: View { self.isRecordingShortcut = isRecordingShortcut self.shortcutRecordingMessage = shortcutRecordingMessage self.footerHint = footerHint + self.showsInAppRecordControl = showsInAppRecordControl self.onToggleShortcut = onToggleShortcut + self.onToggleRecording = onToggleRecording } private static let languageExamples: [String: [String]] = [ @@ -162,27 +169,41 @@ struct OnboardingTryoutStepView: View { let shape = RoundedRectangle(cornerRadius: 18, style: .continuous) return VStack(spacing: 14) { - HStack { - Spacer() - self.changeShortcutButton - } - .frame(height: 0) - .offset(y: 8) - - self.shortcutVisual - .padding(.top, 10) + if self.showsInAppRecordControl { + self.inAppRecordControl + .padding(.top, 10) - self.actionHintRow - - if let shortcutRecordingMessage, - self.isRecordingShortcut, - !shortcutRecordingMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - Label(shortcutRecordingMessage, systemImage: "exclamationmark.triangle.fill") - .font(self.theme.typography.captionSmall) - .foregroundStyle(Color.orange.opacity(0.92)) - .lineLimit(1) + Text(self.isRunning ? "Listening… Speak now, then stop when you're done." : "Use Record to try dictation without Accessibility.") + .font(self.theme.typography.captionStrong) + .foregroundStyle(Color.white.opacity(0.62)) + .multilineTextAlignment(.center) + .lineLimit(2) .minimumScaleFactor(0.82) + .frame(maxWidth: .infinity) + .padding(.top, 2) + } else { + HStack { + Spacer() + self.changeShortcutButton + } + .frame(height: 0) + .offset(y: 8) + + self.shortcutVisual + .padding(.top, 10) + + self.actionHintRow + + if let shortcutRecordingMessage, + self.isRecordingShortcut, + !shortcutRecordingMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + Label(shortcutRecordingMessage, systemImage: "exclamationmark.triangle.fill") + .font(self.theme.typography.captionSmall) + .foregroundStyle(Color.orange.opacity(0.92)) + .lineLimit(1) + .minimumScaleFactor(0.82) + } } self.editorPanel @@ -202,7 +223,58 @@ struct OnboardingTryoutStepView: View { ) ) .accessibilityElement(children: .combine) - .accessibilityLabel("Dictation shortcut \(self.shortcutDisplay). Press once to start. Press again to stop.") + .accessibilityLabel( + self.showsInAppRecordControl + ? "In-app dictation tryout. Record to start. Stop to finish." + : "Dictation shortcut \(self.shortcutDisplay). Press once to start. Press again to stop." + ) + } + + private var inAppRecordControl: some View { + let shape = Capsule() + let title = self.isRunning ? "Stop Recording" : "Start Recording" + let fillOpacity = self.isRecordHovered ? 0.16 : 0.10 + let ringOpacity = self.isRecordHovered ? 0.50 : 0.18 + + return Button { + self.onToggleRecording?() + } label: { + HStack(spacing: 10) { + Image(systemName: self.isRunning ? "stop.fill" : "mic.fill") + .font(.system(size: 14, weight: .bold)) + Text(title) + .font(.system(size: 15, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(.white.opacity(0.94)) + .padding(.horizontal, 22) + .frame(height: 46) + .background( + shape + .fill(self.isRunning ? Color.red.opacity(0.78) : FluidOnboardingLandingColors.blue.opacity(0.92)) + .overlay(shape.fill(Color.white.opacity(fillOpacity))) + .overlay( + shape.stroke( + FluidOnboardingLandingColors.blue.opacity(ringOpacity), + lineWidth: self.isRecordHovered ? 1.4 : 1 + ) + ) + .shadow( + color: (self.isRunning ? Color.red : FluidOnboardingLandingColors.blue) + .opacity(self.isRecordHovered ? 0.36 : 0.18), + radius: self.isRecordHovered ? 18 : 12, + x: 0, + y: 6 + ) + ) + .contentShape(shape) + } + .buttonStyle(.plain) + .focusable(false) + .onHover { isHovered in + self.isRecordHovered = isHovered + } + .accessibilityLabel(title) } private var changeShortcutButton: some View { diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 356b5a7e..5c0b15d5 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -1011,13 +1011,14 @@ struct SettingsView: View { HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(self.theme.palette.warning) - Text("Accessibility permissions required") + Text("Clipboard-only mode") .font(self.theme.typography.bodyStrong) .foregroundStyle(self.theme.palette.warning) } - Text("Required for global hotkey functionality") + Text("Accessibility is required for global hotkeys and auto-paste. Until then, use menu bar Start/Stop Dictation or in-app Record — transcripts copy to the clipboard.") .font(self.theme.typography.bodySmall) .foregroundStyle(self.settingsSecondaryText) + .fixedSize(horizontal: false, vertical: true) } Spacer() @@ -1053,6 +1054,17 @@ struct SettingsView: View { .buttonStyle(.bordered) .controlSize(.small) } + + Divider().opacity(0.2) + + self.optionToggleRow( + title: "Copy to Clipboard", + description: "Keeps transcripts available for ⌘V. Recommended while Accessibility is off.", + isOn: self.$copyToClipboard + ) + .onChange(of: self.copyToClipboard) { _, newValue in + SettingsStore.shared.copyTranscriptionToClipboard = newValue + } } } } diff --git a/Sources/Fluid/UI/WelcomeView.swift b/Sources/Fluid/UI/WelcomeView.swift index 7d70c779..0e125182 100644 --- a/Sources/Fluid/UI/WelcomeView.swift +++ b/Sources/Fluid/UI/WelcomeView.swift @@ -642,6 +642,8 @@ struct OnboardingFlowView: View { let openAccessibilitySettings: () -> Void let restartApp: () -> Void let menuBarManager: MenuBarManager + let stopAndProcessTranscription: () async -> Void + let startRecording: () -> Void @Binding var activeShortcutRecordingTarget: ShortcutRecordingTarget? @Binding var shortcutRecordingMessage: String? let theme: AppTheme @@ -723,11 +725,11 @@ struct OnboardingFlowView: View { case .voiceModel: return "Choose the best local engine for your language." case .permissions: - return "Allow FluidVoice to listen and type into other apps." + return "Allow FluidVoice to listen. Accessibility unlocks hotkeys and auto-paste." case .aiEnhancement: return "Optional: Configure AI post-processing or skip this step." case .playground: - return "Use your dictation shortcut once before finishing setup." + return "Try dictation once before finishing setup." } } } @@ -863,6 +865,16 @@ struct OnboardingFlowView: View { self.isMicrophoneReady && self.isAccessibilityReady } + private var canSkipAccessibility: Bool { + self.isMicrophoneReady && !self.isAccessibilityReady + } + + private var shouldUseInAppPlaygroundRecording: Bool { + // Prefer hotkey tryout whenever Accessibility is available now, even if + // the user previously skipped it earlier in this onboarding session. + !self.isAccessibilityReady + } + private var isAIReady: Bool { self.settings.onboardingAISkipped || DictationAIPostProcessingGate.isProviderConfigured() } @@ -1389,7 +1401,8 @@ struct OnboardingFlowView: View { continueAction: @escaping () -> Void, skipTitle: String? = nil, canSkip: Bool = false, - skipAction: (() -> Void)? = nil + skipAction: (() -> Void)? = nil, + skipButtonWidth: CGFloat = 132 ) -> some View { let canNavigateBack = !self.isModelPreparationInProgress && !self.asr.isRunning && !self.isRecordingAnyShortcut @@ -1409,7 +1422,8 @@ struct OnboardingFlowView: View { self.cinematicFooterButton( title: skipTitle, kind: .skip, - isEnabled: canSkip + isEnabled: canSkip, + width: skipButtonWidth ) { skipAction() } @@ -1432,6 +1446,7 @@ struct OnboardingFlowView: View { title: String, kind: OnboardingFooterButton, isEnabled: Bool, + width: CGFloat = 132, action: @escaping () -> Void ) -> some View { let isPrimary = kind == .next @@ -1442,7 +1457,7 @@ struct OnboardingFlowView: View { title: title, systemImage: nil, tone: isPrimary ? .primary : .secondary, - width: 132, + width: width, height: 48, fontSize: 16, iconSize: 14, @@ -1688,9 +1703,10 @@ struct OnboardingFlowView: View { .lineSpacing(4) .padding(.bottom, 16) - Text("Two quick permissions make dictation work anywhere.") + Text("Microphone is required. Accessibility is recommended for hotkeys and auto-paste.") .font(.system(size: 15, weight: .medium)) .foregroundStyle(Color.white.opacity(0.62)) + .multilineTextAlignment(.center) .padding(.bottom, 28) VStack(spacing: 14) { @@ -1720,10 +1736,18 @@ struct OnboardingFlowView: View { } if !self.isAccessibilityReady { - Text("Already enabled it? FluidVoice will update when macOS confirms access.") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.white.opacity(0.42)) - .padding(.top, 2) + VStack(alignment: .leading, spacing: 8) { + Text("Already enabled it? FluidVoice will update when macOS confirms access.") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.42)) + + Text("Without Accessibility you won't get global hotkeys, auto-paste into other apps, or Edit Mode selection. You can still dictate from the app or menu bar — transcripts copy to the clipboard for ⌘V.") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.white.opacity(0.52)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 2) } } .frame(width: 560) @@ -1735,10 +1759,18 @@ struct OnboardingFlowView: View { self.cinematicFooter( continueTitle: "Continue", - canContinue: self.canContinue - ) { - self.handlePrimaryAction() - } + canContinue: self.canContinue, + continueAction: { + self.settings.onboardingAccessibilitySkipped = false + self.handlePrimaryAction() + }, + skipTitle: self.canSkipAccessibility ? "Continue without Accessibility" : nil, + canSkip: self.canSkipAccessibility, + skipAction: self.canSkipAccessibility ? { + self.skipAccessibilityPermission() + } : nil, + skipButtonWidth: 248 + ) } FluidOnboardingLandingHoverTracker( @@ -1820,7 +1852,11 @@ struct OnboardingFlowView: View { isRunning: self.asr.isRunning, isRecordingShortcut: self.isRecordingPrimaryShortcut, shortcutRecordingMessage: self.isRecordingPrimaryShortcut ? self.shortcutRecordingMessage : nil, - onToggleShortcut: self.togglePrimaryShortcutRecording + showsInAppRecordControl: self.shouldUseInAppPlaygroundRecording, + onToggleShortcut: self.togglePrimaryShortcutRecording, + onToggleRecording: self.shouldUseInAppPlaygroundRecording ? { + self.toggleInAppPlaygroundRecording() + } : nil ) } .frame(maxWidth: .infinity) @@ -1883,7 +1919,7 @@ struct OnboardingFlowView: View { if self.accessibilitySetupInProgress { return "Use the floating guide to drag \(self.appDisplayName) into the Accessibility apps list." } - return "Open Settings, then use the floating guide to add \(self.appDisplayName)." + return "Recommended for global hotkeys and auto-paste. Open Settings, then use the floating guide to add \(self.appDisplayName)." } private var appDisplayName: String { @@ -2675,6 +2711,22 @@ struct OnboardingFlowView: View { } } + private func toggleInAppPlaygroundRecording() { + if self.asr.isRunning { + Task { + await self.stopAndProcessTranscription() + } + } else { + self.startRecording() + } + } + + private func skipAccessibilityPermission() { + self.settings.onboardingAccessibilitySkipped = true + self.settings.copyTranscriptionToClipboard = true + self.goNext() + } + private func resetTryoutValidationForSetupChange() { self.settings.onboardingPlaygroundValidated = false self.settings.onboardingPlaygroundSkipped = false