Skip to content
Open
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
247 changes: 229 additions & 18 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,55 @@ use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tracing::trace;

use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};
use crate::{
App, ArcLock, feeds::microphone::MicrophoneFeed, recording::StartRecordingInputs,
windows::ShowCapWindow,
};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkStartRecordingResult {
pub success: bool,
pub error: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkRecordingStatus {
pub is_recording: bool,
pub is_paused: bool,
pub recording_mode: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkDevices {
pub cameras: Vec<DeepLinkCamera>,
pub microphones: Vec<String>,
pub screens: Vec<DeepLinkScreen>,
pub windows: Vec<DeepLinkWindow>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkCamera {
pub name: String,
pub id: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkScreen {
pub name: String,
pub id: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct DeepLinkWindow {
pub name: String,
pub owner_name: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -26,6 +74,21 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
RestartRecording,
TakeScreenshot {
capture_mode: CaptureMode,
},
SetMicrophone {
label: Option<String>,
},
SetCamera {
id: Option<DeviceOrModelID>,
},
ListDevices,
GetStatus,
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -88,8 +151,9 @@ impl TryFrom<&Url> for DeepLinkAction {
}

match url.domain() {
Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction),
_ => Err(ActionParseFromUrlError::Invalid),
Some("action") => Ok(()),
Some(_) => Err(ActionParseFromUrlError::NotAction),
None => Err(ActionParseFromUrlError::Invalid),
}?;

let params = url
Expand All @@ -104,6 +168,21 @@ impl TryFrom<&Url> for DeepLinkAction {
}
}

fn resolve_capture_target(capture_mode: &CaptureMode) -> Result<ScreenCaptureTarget, String> {
match capture_mode {
CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
.into_iter()
.find(|(s, _)| s.name == *name)
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or_else(|| format!("No screen with name \"{}\"", name)),
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
.into_iter()
.find(|(w, _)| w.name == *name)
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
.ok_or_else(|| format!("No window with name \"{}\"", name)),
}
}

impl DeepLinkAction {
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
match self {
Expand All @@ -119,18 +198,7 @@ impl DeepLinkAction {
crate::set_camera_input(app.clone(), state.clone(), camera).await?;
crate::set_mic_input(state.clone(), mic_label).await?;

let capture_target: ScreenCaptureTarget = match capture_mode {
CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
.into_iter()
.find(|(s, _)| s.name == name)
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or(format!("No screen with name \"{}\"", &name))?,
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
.into_iter()
.find(|(w, _)| w.name == name)
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
.ok_or(format!("No window with name \"{}\"", &name))?,
};
let capture_target = resolve_capture_target(&capture_mode)?;

let inputs = StartRecordingInputs {
mode,
Expand All @@ -139,13 +207,98 @@ impl DeepLinkAction {
organization_id: None,
};

crate::recording::start_recording(app.clone(), state, inputs)
.await
.map(|_| ())
let result = crate::recording::start_recording(app.clone(), state, inputs).await;
match result {
Ok(crate::recording::RecordingAction::Started) => {
write_deeplink_response(app, &DeepLinkStartRecordingResult {
success: true,
error: None,
})
}
Ok(crate::recording::RecordingAction::InvalidAuthentication) => {
write_deeplink_response(app, &DeepLinkStartRecordingResult {
success: false,
error: Some("Please sign in to Cap to use instant recording".to_string()),
})
}
Ok(crate::recording::RecordingAction::UpgradeRequired) => {
write_deeplink_response(app, &DeepLinkStartRecordingResult {
success: false,
error: Some("A Cap upgrade is required to use this feature".to_string()),
})
}
Err(error) => {
let _ = write_deeplink_response(app, &DeepLinkStartRecordingResult {
success: false,
error: Some(error.clone()),
});
Err(error)
}
}
}
DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
}
DeepLinkAction::PauseRecording => {
crate::recording::pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.clone(), app.state()).await
}
DeepLinkAction::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::RestartRecording => {
crate::recording::restart_recording(app.clone(), app.state())
.await
.map(|_| ())
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let capture_target = resolve_capture_target(&capture_mode)?;

crate::recording::take_screenshot(app.clone(), capture_target)
.await
.map(|_| ())
}
DeepLinkAction::SetMicrophone { label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, label).await
}
DeepLinkAction::SetCamera { id } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, id).await
}
DeepLinkAction::ListDevices => {
let devices = get_available_devices();
write_deeplink_response(app, &devices)
}
DeepLinkAction::GetStatus => {
let state = app.state::<ArcLock<App>>();
let app_state = state.read().await;
let status = if let Some(recording) = app_state.current_recording() {
let is_paused = recording.is_paused().await.unwrap_or(false);
Copy link

Choose a reason for hiding this comment

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

unwrap_or(false) will silently hide failures from is_paused() and report an incorrect status. It might be better to surface the error through the deeplink response.

Suggested change
let is_paused = recording.is_paused().await.unwrap_or(false);
let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?;

let mode = match recording {
crate::recording::InProgressRecording::Instant { .. } => {
Some("instant".to_string())
}
crate::recording::InProgressRecording::Studio { .. } => {
Some("studio".to_string())
}
};
DeepLinkRecordingStatus {
is_recording: true,
is_paused,
recording_mode: mode,
}
} else {
DeepLinkRecordingStatus {
is_recording: false,
is_paused: false,
recording_mode: None,
}
};
write_deeplink_response(app, &status)
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand All @@ -155,3 +308,61 @@ impl DeepLinkAction {
}
}
}

fn write_deeplink_response<T: Serialize>(app: &AppHandle, data: &T) -> Result<(), String> {
let response_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Failed to get app data dir: {e}"))?;

std::fs::create_dir_all(&response_dir)
.map_err(|e| format!("Failed to create app data dir: {e}"))?;

let response_path = response_dir.join("deeplink-response.json");
let temp_path = response_dir.join("deeplink-response.json.tmp");

let json = serde_json::to_string(data).map_err(|e| e.to_string())?;

// Atomic write: write to temp file, then rename
std::fs::write(&temp_path, &json).map_err(|e| format!("Failed to write response file: {e}"))?;
let _ = std::fs::remove_file(&response_path);
std::fs::rename(&temp_path, &response_path)
.map_err(|e| format!("Failed to rename response file: {e}"))?;
Comment on lines +329 to +330
Copy link

Choose a reason for hiding this comment

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

std::fs::rename fails on Windows if the destination already exists. Removing the old response file first keeps the atomic-write pattern working cross-platform.

Suggested change
std::fs::rename(&temp_path, &response_path)
.map_err(|e| format!("Failed to rename response file: {e}"))?;
let _ = std::fs::remove_file(&response_path);
std::fs::rename(&temp_path, &response_path)
.map_err(|e| format!("Failed to rename response file: {e}"))?;


trace!("Wrote deeplink response to {:?}", response_path);
Ok(())
}

fn get_available_devices() -> DeepLinkDevices {
let cameras: Vec<DeepLinkCamera> = cap_camera::list_cameras()
.map(|c| DeepLinkCamera {
name: c.display_name().to_string(),
id: c.device_id().to_string(),
})
.collect();

let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
Copy link

Choose a reason for hiding this comment

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

Microphone list ordering can be nondeterministic (map key iteration). Sorting makes the output stable.

Suggested change
let microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
let mut microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();
microphones.sort();


let screens: Vec<DeepLinkScreen> = cap_recording::screen_capture::list_displays()
.into_iter()
.map(|(s, _)| DeepLinkScreen {
name: s.name,
id: s.id.to_string(),
})
.collect();

let windows: Vec<DeepLinkWindow> = cap_recording::screen_capture::list_windows()
.into_iter()
.map(|(w, _)| DeepLinkWindow {
name: w.name,
owner_name: w.owner_name,
})
.collect();

DeepLinkDevices {
cameras,
microphones,
screens,
windows,
}
}
Loading
Loading