Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 112 additions & 11 deletions apps/desktop/src-tauri/src/hotkeys.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
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};
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;
Expand Down Expand Up @@ -87,6 +89,93 @@ impl HotkeysStore {
pub struct OnEscapePress;

pub type HotkeysState = Mutex<HotkeysStore>;

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::<RecordingStartSafetySettings>(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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor robustness: if the dialog callback never fires (plugin failure, window closes, etc), this await will hang the hotkey task. Consider adding a timeout and treating it as cancel.

Suggested change
receiver.await.unwrap_or(false)
tokio::time::timeout(std::time::Duration::from_secs(60), receiver)
.await
.ok()
.and_then(Result::ok)
.unwrap_or(false)

}

async fn start_recording_from_hotkey(
app: AppHandle,
mode: cap_recording::RecordingMode,
) -> Result<(), String> {
if app
.state::<ArcLock<App>>()
.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()
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -283,3 +364,23 @@ pub fn set_hotkey(app: AppHandle, action: HotkeyAction, hotkey: Option<Hotkey>)

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));
}
}
53 changes: 48 additions & 5 deletions apps/desktop/src/routes/(window-chrome)/settings/general.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<Show when={store.state === "ready" && ([store()] as const)}>
{(store) => <Inner initialStore={store()[0] ?? null} />}
<Show when={stores()} keyed>
{(stores) => (
<Inner
initialStore={stores[0] ?? null}
initialRecordingStartSafety={
stores[1] ?? RECORDING_START_SAFETY_DEFAULTS
}
/>
)}
</Show>
);
}
Expand Down Expand Up @@ -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<ExtendedGeneralSettingsStore>(
deriveGeneralSettings(props.initialStore),
);
const [
confirmBeforeRecordingWithoutMicrophone,
setConfirmBeforeRecordingWithoutMicrophone,
] = createSignal(
props.initialRecordingStartSafety.confirmBeforeRecordingWithoutMicrophone,
);
const auth = authStore.createQuery();
const hasCapPro = createMemo(() => {
const plan = auth.data?.plan;
Expand Down Expand Up @@ -312,6 +336,19 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
}
};

const handleRecordingStartSafetyChange = async (value: boolean) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case: if the user toggles this quickly, multiple recordingStartSafetyStore.set(...) calls can overlap and resolve out-of-order (older write finishing last), leaving the persisted value different from the last toggle. Might be worth serializing writes (promise chain) or disabling the toggle while a save is in-flight so the last intent wins deterministically.

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(() =>
Expand Down Expand Up @@ -552,6 +589,12 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
{ text: "10 seconds", value: 10 },
]}
/>
<ToggleSettingItem
label="Confirm before recording without a microphone"
description="Require confirmation when no microphone is selected or the selected microphone is unavailable."
value={confirmBeforeRecordingWithoutMicrophone()}
onChange={handleRecordingStartSafetyChange}
/>
<SelectSettingItem
label="Main window when recording starts"
description="What happens to the main window once a recording begins."
Expand Down
Loading
Loading