Skip to content

Improve desktop recording device selection and stop handling#1780

Merged
richiemcilroy merged 5 commits intomainfrom
misc-desktop-changes
May 4, 2026
Merged

Improve desktop recording device selection and stop handling#1780
richiemcilroy merged 5 commits intomainfrom
misc-desktop-changes

Conversation

@richiemcilroy
Copy link
Copy Markdown
Member

@richiemcilroy richiemcilroy commented May 4, 2026

  • Add persisted per-device camera and microphone format settings in the desktop recording UI
  • Apply selected camera resolution/FPS and mic sample rate/channel settings through the Tauri and recording pipelines
  • Defer device/capture startup work until the main window or menus need it
  • Treat macOS sharing-control stops as clean external recording stops instead of recording failures

Greptile Summary

This PR wires per-device camera resolution/FPS and microphone sample-rate/channel settings through the Tauri and recording pipelines, defers device startup until the UI needs it, and treats macOS sharing-control stops as clean recording completions rather than failures.

  • P1 — PipelineStoppedByUser dead code: PipelineStoppedByUser is defined and stop() contains a handler for it, but the error type is never placed in any error chain. The user_stopped guard in resolve_pipeline_completion returns the same Ok(()) as the unguarded arm beneath it, and the is_caused_by::<PipelineStoppedByUser>() check in stop() can therefore never match. If any pipeline task exits with an error when pipeline_cancel is cancelled after a user stop (e.g. an interrupted muxer write), the recording will fail rather than completing cleanly.
  • The previous inline comments about MicDetailsCache non-optional fields and select_preferred_config silent fallback remain open.

Confidence Score: 3/5

Safe to merge for settings persistence and UI changes; the user-stop flow works in the clean-exit path but has an incomplete error-suppression mechanism that could surface recording failures on edge-case cancellation errors.

One P1 finding: PipelineStoppedByUser is defined and its handler is wired in stop(), but the error is never actually generated, leaving the is_caused_by guard unreachable. Two P1 findings from prior review rounds also remain unresolved (MicDetailsCache type mismatch, select_preferred_config silent drop).

crates/recording/src/output_pipeline/core.rs — PipelineStoppedByUser never placed in error chain; crates/recording/src/feeds/microphone.rs — silent settings drop on partial mismatch (prior comment)

Important Files Changed

Filename Overview
crates/recording/src/output_pipeline/core.rs Adds PipelineStopSignal for user-initiated stop tracking, but PipelineStoppedByUser error is never placed in the error chain, leaving the is_caused_by guard in stop() unreachable and the user_stopped guard in resolve_pipeline_completion redundant.
crates/recording/src/sources/screen_capture/macos.rs Adds is_user_stop_error handler in both video and audio source loops; correctly calls mark_user_stopped() and cancels pipeline_cancel on macOS sharing-control stops.
crates/recording/src/feeds/camera.rs Adds CameraDeviceSettings and select_preferred_camera_format with multi-tier fallback logic; threads settings through spawn_camera_setup and setup_camera on both macOS and non-macOS paths.
crates/recording/src/feeds/microphone.rs Adds MicrophoneDeviceSettings and select_preferred_config with channel+rate fallback logic; wires settings through SetInput, list_with_settings, and get_usable_device. Silent fallback on partial mismatch flagged in earlier review.
apps/desktop/src-tauri/src/recording_settings.rs Adds camera_device_settings and microphone_device_settings HashMaps to the persisted settings store with per-device lookup helpers.
apps/desktop/src-tauri/src/recording.rs Adds microphone_format_infos helper and MicrophoneFormatInfo struct; threads camera/mic settings from RecordingSettingsStore into SetInput at recording start.
apps/desktop/src/routes/(window-chrome)/new-main/index.tsx Adds device settings panel UI with CameraDeviceSettings/MicrophoneDeviceSettings types, per-device settings storage, and new settings shortcut buttons on camera/mic list items.
apps/desktop/src/utils/devices.ts Adds enabled accessor to createDevicesQuery/createStableDevicesQuery, extends camera/mic detail caches to include formats, and threads format info through the stable query. MicDetailsCache still has the non-optional number types noted in the prior review.

Sequence Diagram

sequenceDiagram
    participant UI as Desktop UI
    participant RS as RecordingSettingsStore
    participant Pipeline as OutputPipeline
    participant SC as ScreenCaptureSource (macOS)
    participant PS as PipelineStopSignal

    UI->>RS: save camera/mic settings (per device key)
    UI->>Pipeline: start_recording (SetInput with settings)
    Pipeline->>SC: run video/audio capture loop

    Note over SC: macOS sharing controls stop
    SC->>SC: is_user_stop_error(err)?
    SC->>PS: mark_user_stopped()
    SC->>Pipeline: pipeline_cancel.cancel()
    SC-->>Pipeline: break Ok(())

    Pipeline->>Pipeline: resolve_pipeline_completion(Ok(()), Ok(Ok(())), &stop_signal)
    Note over Pipeline: user_stopped guard returns Ok(())
    Note over Pipeline: same as unguarded arm — guard is redundant
    Pipeline-->>UI: done_fut resolves Ok(())

    Note over Pipeline: stop() checks is_caused_by PipelineStoppedByUser
    Note over Pipeline: always false — PipelineStoppedByUser never generated
Loading

Comments Outside Diff (1)

  1. crates/recording/src/sources/screen_capture/macos.rs, line 1112-1118 (link)

    P1 Unguarded slice indexing in rebuilt capturer's audio callback

    The rebuild_capturer function handles audio frames using direct slice indexing — &slice[i * data_bytes_size as usize .. (i + 1) * data_bytes_size as usize] — and calls copy_from_slice without checking that the destination length matches data_bytes_size. The original capturer callback (built in to_sources) uses slice.get() with a bounds check and verifies destination.len() == source.len() before copying. If the audio buffer is shorter than expected or the ffmpeg frame plane size differs from data_bytes_size, the rebuilt callback will panic, crashing the recording process on capture restart.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/recording/src/sources/screen_capture/macos.rs
    Line: 1112-1118
    
    Comment:
    **Unguarded slice indexing in rebuilt capturer's audio callback**
    
    The `rebuild_capturer` function handles audio frames using direct slice indexing — `&slice[i * data_bytes_size as usize .. (i + 1) * data_bytes_size as usize]` — and calls `copy_from_slice` without checking that the destination length matches `data_bytes_size`. The original capturer callback (built in `to_sources`) uses `slice.get()` with a bounds check and verifies `destination.len() == source.len()` before copying. If the audio buffer is shorter than expected or the ffmpeg frame plane size differs from `data_bytes_size`, the rebuilt callback will panic, crashing the recording process on capture restart.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/recording/src/output_pipeline/core.rs:2519-2566
**`PipelineStoppedByUser` is defined but never placed in an error chain**

`PipelineStoppedByUser` is defined and `stop()` contains a handler `Ok(Err(error)) if error.is_caused_by::<PipelineStoppedByUser>() => {}` to suppress it. However `PipelineStoppedByUser` is never used to produce an error anywhere — `mark_user_stopped()` only sets an `AtomicBool`, and `resolve_pipeline_completion`'s guarded arm `(_, Ok(Ok(()))) if stop_signal.user_stopped() => Ok(())` returns exactly the same `Ok(())` as the unguarded arm below it, making the guard redundant. The `is_caused_by` check in `stop()` can therefore never match.

If any pipeline task exits with an error when `pipeline_cancel` is cancelled after a user stop (e.g. a closed channel or an interrupted muxer write), the recording will fail instead of completing cleanly. To make the suppression mechanism work, the guarded arm would need to return `Err(anyhow::Error::new(PipelineStoppedByUser))` rather than `Ok(())`.

Reviews (2): Last reviewed commit: "comments + clippy" | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

@paragon-review
Copy link
Copy Markdown

paragon-review Bot commented May 4, 2026

Paragon Review Skipped

Hi @richiemcilroy! Your Polarity credit balance is insufficient to complete this review.

Please visit https://app.paragon.run to finish your review.

Comment on lines +73 to 76
type MicDetailsCache = Record<
string,
{ sampleRate: number; channels: number; formats?: MicrophoneFormatInfo[] }
>;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 MicDetailsCache type vs actual values mismatch

MicDetailsCache declares sampleRate: number and channels: number as required non-optional fields, but getMicrophoneInfo may return those fields as undefined (consistent with MicrophoneWithDetails at line 34–39 which marks both optional). At runtime, the detail object stored under micDetailsCache[name] can contain undefined in place of a required number, silently propagating stale or missing format data into the stable devices store.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/devices.ts
Line: 73-76

Comment:
**`MicDetailsCache` type vs actual values mismatch**

`MicDetailsCache` declares `sampleRate: number` and `channels: number` as required non-optional fields, but `getMicrophoneInfo` may return those fields as `undefined` (consistent with `MicrophoneWithDetails` at line 34–39 which marks both optional). At runtime, the detail object stored under `micDetailsCache[name]` can contain `undefined` in place of a required `number`, silently propagating stale or missing format data into the stable devices store.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +410 to +428
fn select_preferred_config(
configs: &[SupportedStreamConfigRange],
settings: &MicrophoneDeviceSettings,
) -> Option<SupportedStreamConfig> {
let rate = settings.sample_rate.map(cpal::SampleRate);

configs
.iter()
.find(|config| {
ffmpeg_sample_format_for(config.sample_format()).is_some()
&& settings
.channels
.is_none_or(|channels| config.channels() == channels)
&& rate.is_none_or(|rate| {
config.min_sample_rate().0 <= rate.0 && config.max_sample_rate().0 >= rate.0
})
})
.map(|config| config.with_sample_rate(rate.unwrap_or_else(|| select_sample_rate(config))))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 select_preferred_config silently drops settings on partial mismatch

When settings.channels is set but no config with that exact channel count is found, select_preferred_config returns None and get_usable_device falls through to the fallback path which ignores both requested channels and sample rate entirely. The user's persisted format preferences are silently discarded with no warning log.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/feeds/microphone.rs
Line: 410-428

Comment:
**`select_preferred_config` silently drops settings on partial mismatch**

When `settings.channels` is set but no config with that exact channel count is found, `select_preferred_config` returns `None` and `get_usable_device` falls through to the fallback path which ignores both requested channels and sample rate entirely. The user's persisted format preferences are silently discarded with no warning log.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread apps/desktop/src/utils/devices.ts Outdated
Comment on lines 194 to 201
const extendedInfo = info as typeof info & {
formats?: MicrophoneFormatInfo[];
};
const details = {
sampleRate: info.sampleRate,
channels: info.channels,
formats: extendedInfo.formats,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If getMicrophoneInfo now returns formats (looks like MicrophoneInfo was updated), you can drop the cast and just read info.formats.

Suggested change
const extendedInfo = info as typeof info & {
formats?: MicrophoneFormatInfo[];
};
const details = {
sampleRate: info.sampleRate,
channels: info.channels,
formats: extendedInfo.formats,
};
const details = {
sampleRate: info.sampleRate,
channels: info.channels,
formats: info.formats,
};

Comment on lines +366 to +369
.ask(microphone::SetInput {
label: name,
settings: None,
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Worth keeping the selected mic format on reconnect (sample rate/channels) instead of resetting to None here, otherwise a transient stall can silently switch the stream format away from what the user picked.

) -> anyhow::Result<()> {
match (task_result, muxer_result) {
(Err(error), _) | (_, Err(error)) => Err(error),
(_, Ok(Ok(()))) if stop_signal.user_stopped() => Err(anyhow!(PipelineStoppedByUser)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This makes a macOS-sharing-controls stop look like a pipeline failure to any done_fut consumer (e.g. the studio watcher treats required track Err as fatal). If the goal is a clean external stop, consider either returning Ok(()) here, or ensuring upstream explicitly treats PipelineStoppedByUser as non-fatal in completion handling (not just in OutputPipeline::stop()).

@richiemcilroy
Copy link
Copy Markdown
Member Author

please re-review the pr @greptileai

Comment on lines 2519 to +2566
@@ -2488,6 +2542,17 @@ impl std::error::Error for PipelineDoneError {
}
}

impl PipelineDoneError {
pub fn is_caused_by<T>(&self) -> bool
where
T: std::error::Error + 'static,
{
self.0
.chain()
.any(|cause| cause.downcast_ref::<T>().is_some())
}
}

impl OutputPipeline {
pub fn path(&self) -> &PathBuf {
&self.path
@@ -2498,6 +2563,7 @@ impl OutputPipeline {

const PIPELINE_STOP_TIMEOUT: Duration = Duration::from_secs(10);
match tokio::time::timeout(PIPELINE_STOP_TIMEOUT, self.done_fut.clone()).await {
Ok(Err(error)) if error.is_caused_by::<PipelineStoppedByUser>() => {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 PipelineStoppedByUser is defined but never placed in an error chain

PipelineStoppedByUser is defined and stop() contains a handler Ok(Err(error)) if error.is_caused_by::<PipelineStoppedByUser>() => {} to suppress it. However PipelineStoppedByUser is never used to produce an error anywhere — mark_user_stopped() only sets an AtomicBool, and resolve_pipeline_completion's guarded arm (_, Ok(Ok(()))) if stop_signal.user_stopped() => Ok(()) returns exactly the same Ok(()) as the unguarded arm below it, making the guard redundant. The is_caused_by check in stop() can therefore never match.

If any pipeline task exits with an error when pipeline_cancel is cancelled after a user stop (e.g. a closed channel or an interrupted muxer write), the recording will fail instead of completing cleanly. To make the suppression mechanism work, the guarded arm would need to return Err(anyhow::Error::new(PipelineStoppedByUser)) rather than Ok(()).

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/recording/src/output_pipeline/core.rs
Line: 2519-2566

Comment:
**`PipelineStoppedByUser` is defined but never placed in an error chain**

`PipelineStoppedByUser` is defined and `stop()` contains a handler `Ok(Err(error)) if error.is_caused_by::<PipelineStoppedByUser>() => {}` to suppress it. However `PipelineStoppedByUser` is never used to produce an error anywhere — `mark_user_stopped()` only sets an `AtomicBool`, and `resolve_pipeline_completion`'s guarded arm `(_, Ok(Ok(()))) if stop_signal.user_stopped() => Ok(())` returns exactly the same `Ok(())` as the unguarded arm below it, making the guard redundant. The `is_caused_by` check in `stop()` can therefore never match.

If any pipeline task exits with an error when `pipeline_cancel` is cancelled after a user stop (e.g. a closed channel or an interrupted muxer write), the recording will fail instead of completing cleanly. To make the suppression mechanism work, the guarded arm would need to return `Err(anyhow::Error::new(PipelineStoppedByUser))` rather than `Ok(())`.

How can I resolve this? If you propose a fix, please make it concise.

@richiemcilroy richiemcilroy merged commit 5b7cd01 into main May 4, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant