diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index 97c9718a2b3..2c9a6eed18c 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -1,9 +1,10 @@ use crate::{ - RequestOpenRecordingPicker, RequestStartRecording, recording, + App, ArcLock, RequestOpenRecordingPicker, RequestStartRecording, recording, recording_settings::{RecordingSettingsStore, RecordingTargetMode}, tray, windows::ShowCapWindow, }; +use cap_recording::feeds::microphone::MicrophoneFeed; use cap_recording::screen_capture::ScreenCaptureTarget; use global_hotkey::HotKeyState; use serde::{Deserialize, Serialize}; @@ -11,6 +12,7 @@ use specta::Type; use std::collections::HashMap; use std::sync::Mutex; use tauri::{AppHandle, Manager}; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut}; use tauri_plugin_store::StoreExt; use tauri_specta::Event; @@ -87,6 +89,93 @@ impl HotkeysStore { pub struct OnEscapePress; pub type HotkeysState = Mutex; + +const RECORDING_START_SAFETY_STORE_KEY: &str = "recording_start_safety"; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", default)] +struct RecordingStartSafetySettings { + confirm_before_recording_without_microphone: bool, +} + +impl Default for RecordingStartSafetySettings { + fn default() -> Self { + Self { + confirm_before_recording_without_microphone: true, + } + } +} + +fn should_confirm_without_microphone(enabled: bool, microphone_available: bool) -> bool { + enabled && !microphone_available +} + +fn should_confirm_direct_recording(app: &AppHandle) -> bool { + let enabled = app + .store("store") + .ok() + .and_then(|store| store.get(RECORDING_START_SAFETY_STORE_KEY)) + .and_then(|value| serde_json::from_value::(value).ok()) + .unwrap_or_default() + .confirm_before_recording_without_microphone; + + if !enabled { + return false; + } + + let microphone_name = RecordingSettingsStore::get(app) + .ok() + .flatten() + .and_then(|settings| settings.mic_name); + let microphone_available = microphone_name + .as_deref() + .is_some_and(|name| MicrophoneFeed::list().contains_key(name)); + + should_confirm_without_microphone(enabled, microphone_available) +} + +async fn confirm_direct_recording_without_microphone(app: &AppHandle) -> bool { + if !should_confirm_direct_recording(app) { + return true; + } + + let (sender, receiver) = tokio::sync::oneshot::channel(); + app.dialog() + .message("This recording will not include your voice.") + .title("No microphone detected") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Record without microphone".to_string(), + "Cancel".to_string(), + )) + .show(move |confirmed| { + let _ = sender.send(confirmed); + }); + + receiver.await.unwrap_or(false) +} + +async fn start_recording_from_hotkey( + app: AppHandle, + mode: cap_recording::RecordingMode, +) -> Result<(), String> { + if app + .state::>() + .read() + .await + .is_recording_active_or_pending() + { + let _ = RequestStartRecording { mode }.emit(&app); + return Ok(()); + } + + if confirm_direct_recording_without_microphone(&app).await { + let _ = RequestStartRecording { mode }.emit(&app); + } + + Ok(()) +} + pub fn init(app: &AppHandle) { app.plugin( tauri_plugin_global_shortcut::Builder::new() @@ -139,18 +228,10 @@ pub fn init(app: &AppHandle) { async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), String> { match action { HotkeyAction::StartStudioRecording => { - let _ = RequestStartRecording { - mode: cap_recording::RecordingMode::Studio, - } - .emit(&app); - Ok(()) + start_recording_from_hotkey(app, cap_recording::RecordingMode::Studio).await } HotkeyAction::StartInstantRecording => { - let _ = RequestStartRecording { - mode: cap_recording::RecordingMode::Instant, - } - .emit(&app); - Ok(()) + start_recording_from_hotkey(app, cap_recording::RecordingMode::Instant).await } HotkeyAction::StopRecording => recording::stop_recording(app.clone(), app.state()).await, HotkeyAction::RestartRecording => recording::restart_recording(app.clone(), app.state()) @@ -283,3 +364,23 @@ pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option) Ok(()) } + +#[cfg(test)] +mod tests { + use super::should_confirm_without_microphone; + + #[test] + fn confirms_when_enabled_without_microphone() { + assert!(should_confirm_without_microphone(true, false)); + } + + #[test] + fn skips_confirmation_with_selected_microphone() { + assert!(!should_confirm_without_microphone(true, true)); + } + + #[test] + fn skips_confirmation_when_disabled() { + assert!(!should_confirm_without_microphone(false, false)); + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 28a8052ad28..face896e363 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -26,11 +26,17 @@ import themePreviewAuto from "~/assets/theme-previews/auto.jpg"; import themePreviewDark from "~/assets/theme-previews/dark.jpg"; import themePreviewLight from "~/assets/theme-previews/light.jpg"; import { Input } from "~/routes/editor/ui"; -import { authStore, generalSettingsStore } from "~/store"; +import { + authStore, + generalSettingsStore, + recordingStartSafetyStore, +} from "~/store"; import { clientEnv } from "~/utils/env"; import { deriveGeneralSettings, type GeneralSettingsStore, + RECORDING_START_SAFETY_DEFAULTS, + type RecordingStartSafetySettings, } from "~/utils/general-settings"; import { type AppTheme, @@ -126,11 +132,20 @@ const FREE_INSTANT_MODE_MAX_RESOLUTION = 1280; const PRO_INSTANT_MODE_MAX_RESOLUTION = 1920; export default function GeneralSettings() { - const [store] = createResource(() => generalSettingsStore.get()); + const [stores] = createResource(() => + Promise.all([generalSettingsStore.get(), recordingStartSafetyStore.get()]), + ); return ( - - {(store) => } + + {(stores) => ( + + )} ); } @@ -209,10 +224,19 @@ function AppearanceSection(props: { ); } -function Inner(props: { initialStore: GeneralSettingsStore | null }) { +function Inner(props: { + initialStore: GeneralSettingsStore | null; + initialRecordingStartSafety: RecordingStartSafetySettings; +}) { const [settings, setSettings] = createStore( deriveGeneralSettings(props.initialStore), ); + const [ + confirmBeforeRecordingWithoutMicrophone, + setConfirmBeforeRecordingWithoutMicrophone, + ] = createSignal( + props.initialRecordingStartSafety.confirmBeforeRecordingWithoutMicrophone, + ); const auth = authStore.createQuery(); const hasCapPro = createMemo(() => { const plan = auth.data?.plan; @@ -312,6 +336,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { } }; + const handleRecordingStartSafetyChange = async (value: boolean) => { + const previousValue = confirmBeforeRecordingWithoutMicrophone(); + setConfirmBeforeRecordingWithoutMicrophone(value); + try { + await recordingStartSafetyStore.set({ + confirmBeforeRecordingWithoutMicrophone: value, + }); + } catch (error) { + setConfirmBeforeRecordingWithoutMicrophone(previousValue); + console.error("Failed to update recording start safety", error); + } + }; + const ostype: OsType = type(); const excludedWindows = createMemo(() => settings.excludedWindows ?? []); const missingDefaultExclusions = createMemo(() => @@ -552,6 +589,12 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { { text: "10 seconds", value: 10 }, ]} /> + devices.data?.cameras ?? []); const mics = createMemo(() => devices.data?.microphones ?? []); const permissions = createMemo(() => devices.data?.permissions); @@ -1912,6 +1921,138 @@ function RecordingControls(props: { return mics().find((name) => name === rawOptions.micName) ?? null; }); + createEffect(() => { + if ( + !shouldConfirmRecordingWithoutMicrophone( + rawOptions.mode, + recordingStartSafety.data?.confirmBeforeRecordingWithoutMicrophone ?? + true, + selectedMicName(), + ) + ) { + setNoMicrophoneWarningOpen(false); + } + }); + + const startLoading = () => + devices.isPending || recordingStartSafety.isPending; + const startDisabled = () => !!props.disabled || startLoading(); + + const startRecording = async (confirmedWithoutMicrophone = false) => { + if (rawOptions.mode === "instant" && !auth.data) { + emit("start-sign-in"); + return; + } + if (startDisabled()) return; + + if ( + !confirmedWithoutMicrophone && + shouldConfirmRecordingWithoutMicrophone( + rawOptions.mode, + recordingStartSafety.data?.confirmBeforeRecordingWithoutMicrophone ?? + true, + selectedMicName(), + ) + ) { + setNoMicrophoneWarningOpen(true); + return; + } + + setNoMicrophoneWarningOpen(false); + + // Snapshot before onRecordingStart: dismissing the picker + // (targetMode: null) disposes the branch that owns + // this component, and the display/area target props call its + // narrowed accessor — reading props.target afterwards throws + // "Stale read from ." and the recording never starts. + const target = props.target; + + if (target.variant === "area") { + setOptions( + "captureTarget", + reconcile({ + variant: "area", + screen: target.screen, + bounds: { + position: { + x: target.bounds.position.x, + y: target.bounds.position.y, + }, + size: { + width: target.bounds.size.width, + height: target.bounds.size.height, + }, + }, + }), + ); + } + + props.onRecordingStart?.(); + + if (rawOptions.mode === "screenshot") { + try { + const allWindows = await WebviewWindow.getAll(); + for (const win of allWindows) { + if (win.label.startsWith("target-select-overlay-")) { + await win.setIgnoreCursorEvents(true); + await win.hide(); + } + } + + const path = await commands.takeScreenshot(target); + const shouldOpenEditor = + await commands.automationShouldOpenScreenshotEditor(target); + if (shouldOpenEditor) { + await commands.showWindow({ ScreenshotEditor: { path } }); + } + await commands.closeTargetSelectOverlays(); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + toast.error(`Failed to take screenshot: ${message}`); + console.error("Failed to take screenshot", e); + } + return; + } + + commands + .startRecording({ + capture_target: target, + mode: rawOptions.mode, + capture_system_audio: rawOptions.captureSystemAudio, + }) + .then((action) => { + // On success the backend closes the overlay windows; the + // non-Started actions leave our hidden windows behind. + // User-facing feedback for them arrives via the backend's + // StartFailed event in the main window. + if (action !== "Started") void commands.closeTargetSelectOverlays(); + }) + .catch((e: unknown) => { + const msg = e instanceof Error ? e.message : String(e); + // This webview is hidden by now, so the toast is a + // best-effort extra — the visible feedback comes from the + // backend's StartFailed event toasted in the main window. + if ( + msg.includes("no longer available") || + msg.includes("DeviceNotFound") + ) { + toast.error( + "Selected microphone is not available. Please select a different microphone in settings.", + ); + } else { + toast.error(`Failed to start recording: ${msg}`); + } + // An IPC-level rejection never reaches the backend, so no + // StartFailed event fires; the picker flow hid the main + // window and dismissal closed the overlays — without this, + // the whole app visually vanishes with no recording. + void commands.showWindow({ + Main: { init_target_mode: null }, + }); + void commands.closeTargetSelectOverlays(); + }); + }; + const menuModes = async () => await Menu.new({ items: [ @@ -1985,8 +2126,6 @@ function RecordingControls(props: { menu.then((menu) => menu.popup(new LogicalPosition(rect.x, rect.y + 40))); } - const startDisabled = () => !!props.disabled; - return ( <>
@@ -2009,154 +2148,89 @@ function RecordingControls(props: { >
-
{ - if (rawOptions.mode === "instant" && !auth.data) { - emit("start-sign-in"); - return; - } - if (startDisabled()) return; - - // Snapshot before onRecordingStart: dismissing the picker - // (targetMode: null) disposes the branch that owns - // this component, and the display/area target props call its - // narrowed accessor — reading props.target afterwards throws - // "Stale read from ." and the recording never starts. - const target = props.target; - - if (target.variant === "area") { - setOptions( - "captureTarget", - reconcile({ - variant: "area", - screen: target.screen, - bounds: { - position: { - x: target.bounds.position.x, - y: target.bounds.position.y, - }, - size: { - width: target.bounds.size.width, - height: target.bounds.size.height, - }, - }, - }), - ); - } - - props.onRecordingStart?.(); - - if (rawOptions.mode === "screenshot") { - try { - const allWindows = await WebviewWindow.getAll(); - for (const win of allWindows) { - if (win.label.startsWith("target-select-overlay-")) { - await win.setIgnoreCursorEvents(true); - await win.hide(); - } - } - - const path = await commands.takeScreenshot(target); - const shouldOpenEditor = - await commands.automationShouldOpenScreenshotEditor( - target, - ); - if (shouldOpenEditor) { - await commands.showWindow({ ScreenshotEditor: { path } }); - } - await commands.closeTargetSelectOverlays(); - } catch (e) { - const message = e instanceof Error ? e.message : String(e); - toast.error(`Failed to take screenshot: ${message}`); - console.error("Failed to take screenshot", e); - } - return; - } - - commands - .startRecording({ - capture_target: target, - mode: rawOptions.mode, - capture_system_audio: rawOptions.captureSystemAudio, - }) - .then((action) => { - // On success the backend closes the overlay windows; the - // non-Started actions leave our hidden windows behind. - // User-facing feedback for them arrives via the backend's - // StartFailed event in the main window. - if (action !== "Started") - void commands.closeTargetSelectOverlays(); - }) - .catch((e: unknown) => { - const msg = e instanceof Error ? e.message : String(e); - // This webview is hidden by now, so the toast is a - // best-effort extra — the visible feedback comes from the - // backend's StartFailed event toasted in the main window. - if ( - msg.includes("no longer available") || - msg.includes("DeviceNotFound") - ) { - toast.error( - "Selected microphone is not available. Please select a different microphone in settings.", - ); - } else { - toast.error(`Failed to start recording: ${msg}`); - } - // An IPC-level rejection never reaches the backend, so no - // StartFailed event fires; the picker flow hid the main - // window and dismissal closed the overlays — without this, - // the whole app visually vanishes with no recording. - void commands.showWindow({ - Main: { init_target_mode: null }, - }); - void commands.closeTargetSelectOverlays(); - }); - }} + -
void startRecording()} > - - - - - - - - - - - -
- - {(() => { - if (rawOptions.mode === "instant" && !auth.data) - return "Sign In To Use"; - if (rawOptions.mode === "screenshot") - return "Take Screenshot"; - return "Start Recording"; - })()} - - - {`${capitalize(rawOptions.mode)} Mode`} - +
+ + + + + + + + + + + +
+ + {(() => { + if (rawOptions.mode === "instant" && !auth.data) + return "Sign In To Use"; + if (startLoading()) return "Preparing..."; + if (rawOptions.mode === "screenshot") + return "Take Screenshot"; + return "Start Recording"; + })()} + + + {`${capitalize(rawOptions.mode)} Mode`} + +
-
-
showMenu(menuModes(), e)} - onClick={(e) => showMenu(menuModes(), e)} - > - -
-
+
showMenu(menuModes(), e)} + onClick={(e) => showMenu(menuModes(), e)} + > + +
+ + + +
+ +
+

+ No microphone detected +

+

+ This recording will not include your voice. Select a + microphone, or continue without one. +

+
+
+
+ + Go back + + +
+
+
+
showMenu(preRecordingMenu(), e)} diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index 905e951daea..c2b30eb6c56 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -2,7 +2,11 @@ import { createQuery } from "@tanstack/solid-query"; import { Store } from "@tauri-apps/plugin-store"; import { onCleanup } from "solid-js"; import type { AutomationsStore } from "~/utils/automations"; -import type { GeneralSettingsStore } from "~/utils/general-settings"; +import { + type GeneralSettingsStore, + RECORDING_START_SAFETY_DEFAULTS, + type RecordingStartSafetySettings, +} from "~/utils/general-settings"; import type { AuthStore, HotkeysStore, @@ -107,6 +111,11 @@ export const mainWindowUIStore = declareStore( export const hotkeysStore = declareStore("hotkeys"); export const generalSettingsStore = declareStore("general_settings"); +export const recordingStartSafetyStore = + declareStore( + "recording_start_safety", + RECORDING_START_SAFETY_DEFAULTS, + ); export const recordingSettingsStore = declareStore( "recording_settings", { diff --git a/apps/desktop/src/utils/general-settings.test.ts b/apps/desktop/src/utils/general-settings.test.ts index d8ae6c01c38..790a02409d4 100644 --- a/apps/desktop/src/utils/general-settings.test.ts +++ b/apps/desktop/src/utils/general-settings.test.ts @@ -6,6 +6,8 @@ import { formatTranscriptionHints, normalizeTranscriptionHints, parseTranscriptionHints, + RECORDING_START_SAFETY_DEFAULTS, + shouldConfirmRecordingWithoutMicrophone, } from "~/utils/general-settings"; describe("general-settings", () => { @@ -59,4 +61,50 @@ describe("general-settings", () => { custom_cursor_capture2: false, }); }); + + it("enables microphone confirmation by default", () => { + expect( + RECORDING_START_SAFETY_DEFAULTS.confirmBeforeRecordingWithoutMicrophone, + ).toBe(true); + }); + + it.each([ + { + mode: "studio" as const, + enabled: true, + microphone: null, + expected: true, + }, + { + mode: "instant" as const, + enabled: true, + microphone: null, + expected: true, + }, + { + mode: "studio" as const, + enabled: true, + microphone: "MacBook Microphone", + expected: false, + }, + { + mode: "instant" as const, + enabled: false, + microphone: null, + expected: false, + }, + { + mode: "screenshot" as const, + enabled: true, + microphone: null, + expected: false, + }, + ])( + "returns $expected for $mode with enabled=$enabled and microphone=$microphone", + ({ mode, enabled, microphone, expected }) => { + expect( + shouldConfirmRecordingWithoutMicrophone(mode, enabled, microphone), + ).toBe(expected); + }, + ); }); diff --git a/apps/desktop/src/utils/general-settings.ts b/apps/desktop/src/utils/general-settings.ts index 4479462cc3d..37dcbab6f95 100644 --- a/apps/desktop/src/utils/general-settings.ts +++ b/apps/desktop/src/utils/general-settings.ts @@ -1,4 +1,7 @@ -import type { GeneralSettingsStore as TauriGeneralSettingsStore } from "~/utils/tauri"; +import type { + RecordingMode, + GeneralSettingsStore as TauriGeneralSettingsStore, +} from "~/utils/tauri"; export type GeneralSettingsStore = TauriGeneralSettingsStore & { captureKeyboardEvents?: boolean; @@ -14,6 +17,26 @@ export const DEFAULT_TRANSCRIPTION_HINTS = [ "mywebsite.com", ]; +export type RecordingStartSafetySettings = { + confirmBeforeRecordingWithoutMicrophone: boolean; +}; + +export const RECORDING_START_SAFETY_DEFAULTS: RecordingStartSafetySettings = { + confirmBeforeRecordingWithoutMicrophone: true, +}; + +export function shouldConfirmRecordingWithoutMicrophone( + mode: RecordingMode, + confirmBeforeRecordingWithoutMicrophone: boolean, + selectedMicrophone: string | null, +): boolean { + return ( + mode !== "screenshot" && + confirmBeforeRecordingWithoutMicrophone && + selectedMicrophone === null + ); +} + export function createDefaultGeneralSettings(): GeneralSettingsStore { return { uploadIndividualFiles: false,