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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions apps/desktop/src-tauri/DEEPLINKS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# Cap Desktop Deeplinks

Cap desktop handles action deeplinks in this format:

`cap-desktop://action?value=<url-encoded-json>`

The `value` parameter is JSON for a `DeepLinkAction`.

## Recording controls

- `"start_current_recording"`
- `"stop_recording"`
- `"pause_recording"`
- `"resume_recording"`

`start_current_recording` uses the current saved recording settings.

## Device switching

Switch microphone:

```json
{"switch_microphone":{"mic_label":null}}
```

When `mic_label` is `null`, Cap rotates to the next available microphone.

```json
{"switch_microphone":{"mic_label":"Shure MV7"}}
```

Switch camera:

```json
{"switch_camera":{"camera_selector":null}}
```

When `camera_selector` is `null`, Cap rotates to the next available camera.

```json
{"switch_camera":{"camera_selector":"FaceTime HD Camera"}}
```

`camera_selector` can be camera display name, device id, or model id.

## Other actions

```json
{"open_settings":{"page":"general"}}
```

```json
{"open_editor":{"project_path":"/absolute/path/to/project.cap"}}
```

```json
{"start_recording":{"capture_mode":{"screen":"Built-in Retina Display"},"camera":null,"mic_label":null,"capture_system_audio":true,"mode":"studio"}}
```
194 changes: 187 additions & 7 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
use cap_recording::{
RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget,
RecordingMode,
feeds::{camera::DeviceOrModelID, microphone::MicrophoneFeed},
sources::screen_capture::ScreenCaptureTarget,
};
use scap_targets::Display;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tracing::trace;

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

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum CaptureMode {
Screen(String),
Window(String),
}

#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum DeepLinkAction {
StartCurrentRecording {
mode: Option<RecordingMode>,
},
StartRecording {
capture_mode: CaptureMode,
camera: Option<DeviceOrModelID>,
Expand All @@ -26,6 +32,14 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
SwitchMicrophone {
mic_label: Option<String>,
},
SwitchCamera {
camera_selector: Option<String>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -87,10 +101,12 @@ impl TryFrom<&Url> for DeepLinkAction {
});
}

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

let params = url
.query_pairs()
Expand All @@ -107,6 +123,30 @@ impl TryFrom<&Url> for DeepLinkAction {
impl DeepLinkAction {
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
match self {
DeepLinkAction::StartCurrentRecording { mode } => {
let settings = crate::recording_settings::RecordingSettingsStore::get(app)
.ok()
.flatten()
.unwrap_or_default();

crate::set_mic_input(app.state(), settings.mic_name).await?;
Copy link

Choose a reason for hiding this comment

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

settings.mic_name / settings.camera_id move out of settings, so using settings.mode/target/... afterwards becomes a partial-move and won’t compile.

Suggested change
crate::set_mic_input(app.state(), settings.mic_name).await?;
let settings = crate::recording_settings::RecordingSettingsStore::get(app)
.ok()
.flatten()
.unwrap_or_default();
let crate::recording_settings::RecordingSettingsStore {
target,
mic_name,
camera_id,
mode: saved_mode,
system_audio,
organization_id,
} = settings;
crate::set_mic_input(app.state(), mic_name).await?;
crate::set_camera_input(app.clone(), app.state(), camera_id, None).await?;
let inputs = StartRecordingInputs {
mode: mode.or(saved_mode).unwrap_or(RecordingMode::Studio),
capture_target: target.unwrap_or_else(|| {
ScreenCaptureTarget::Display {
id: Display::primary().id(),
}
}),
capture_system_audio: system_audio,
organization_id,
};

crate::set_camera_input(app.clone(), app.state(), settings.camera_id, None).await?;

let inputs = StartRecordingInputs {
mode: mode.or(settings.mode).unwrap_or(RecordingMode::Studio),
capture_target: settings.target.unwrap_or_else(|| {
ScreenCaptureTarget::Display {
id: Display::primary().id(),
}
}),
capture_system_audio: settings.system_audio,
organization_id: settings.organization_id,
};

crate::recording::start_recording(app.clone(), app.state(), inputs)
.await
.map(|_| ())
}
DeepLinkAction::StartRecording {
capture_mode,
camera,
Expand Down Expand Up @@ -146,6 +186,68 @@ impl DeepLinkAction {
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::SwitchMicrophone { mic_label } => {
if let Some(mic_label) = mic_label {
return crate::set_mic_input(app.state(), Some(mic_label)).await;
}

let current_mic = app
.state::<ArcLock<App>>()
.read()
.await
.selected_mic_label
.clone();
let mut microphones: Vec<String> = MicrophoneFeed::list().keys().cloned().collect();

if microphones.is_empty() {
return Err("No microphone devices found".to_string());
}

microphones.sort_unstable();
let next_mic = next_item(microphones, current_mic.as_ref())
.ok_or("No microphone devices found".to_string())?;

crate::set_mic_input(app.state(), Some(next_mic)).await
}
DeepLinkAction::SwitchCamera { camera_selector } => {
if let Some(camera_selector) = camera_selector {
let camera_id = find_camera_by_selector(&camera_selector)
.ok_or(format!("No camera matching \"{}\"", camera_selector))?;

return crate::set_camera_input(
app.clone(),
app.state(),
Some(camera_id),
None,
)
.await;
}

let camera_ids: Vec<DeviceOrModelID> = cap_camera::list_cameras()
.map(|camera| DeviceOrModelID::from_info(&camera))
.collect();

if camera_ids.is_empty() {
return Err("No camera devices found".to_string());
}

let current_camera = app
.state::<ArcLock<App>>()
.read()
.await
.selected_camera_id
.clone();
let next_camera = next_item(camera_ids, current_camera.as_ref())
.ok_or("No camera devices found".to_string())?;

crate::set_camera_input(app.clone(), app.state(), Some(next_camera), None).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand All @@ -155,3 +257,81 @@ impl DeepLinkAction {
}
}
}

fn find_camera_by_selector(selector: &str) -> Option<DeviceOrModelID> {
cap_camera::list_cameras().find_map(|camera| {
let model_id = camera.model_id().map(|id| id.to_string());
if camera.display_name() == selector
|| camera.device_id() == selector
|| model_id.as_deref() == Some(selector)
{
Some(DeviceOrModelID::from_info(&camera))
} else {
None
}
})
}

fn next_item<T: Clone + PartialEq>(items: Vec<T>, current: Option<&T>) -> Option<T> {
if items.is_empty() {
return None;
}

let next_index = current
.and_then(|value| items.iter().position(|item| item == value))
.map(|index| (index + 1) % items.len())
.unwrap_or(0);

items.get(next_index).cloned()
}

#[cfg(test)]
mod tests {
use super::*;

fn action_url(action: DeepLinkAction) -> Url {
let value = serde_json::to_string(&action).expect("serialize action");
let mut url = Url::parse("cap-desktop://action").expect("parse base url");
url.query_pairs_mut().append_pair("value", &value);
url
}

#[test]
fn parses_action_urls() {
let action = DeepLinkAction::PauseRecording;
let parsed = DeepLinkAction::try_from(&action_url(action.clone())).expect("parse action");
assert_eq!(parsed, action);
}

#[test]
fn parses_action_with_payload() {
let action = DeepLinkAction::SwitchMicrophone {
mic_label: Some("Shure MV7".to_string()),
};
let parsed = DeepLinkAction::try_from(&action_url(action.clone())).expect("parse action");
assert_eq!(parsed, action);
}

#[test]
fn parses_start_current_recording() {
let action = DeepLinkAction::StartCurrentRecording {
mode: Some(RecordingMode::Studio),
};
let parsed = DeepLinkAction::try_from(&action_url(action.clone())).expect("parse action");
assert_eq!(parsed, action);
}

#[test]
fn returns_not_action_for_non_action_host() {
let url = Url::parse("cap-desktop://signin?token=abc").expect("parse url");
let parsed = DeepLinkAction::try_from(&url);
assert!(matches!(parsed, Err(ActionParseFromUrlError::NotAction)));
}

#[test]
fn returns_invalid_without_value() {
let url = Url::parse("cap-desktop://action").expect("parse url");
let parsed = DeepLinkAction::try_from(&url);
assert!(matches!(parsed, Err(ActionParseFromUrlError::Invalid)));
}
}
33 changes: 33 additions & 0 deletions extensions/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Cap Raycast Extension

Raycast commands for controlling Cap desktop through deeplinks.

## Commands

- Start Recording
- Stop Recording
- Pause Recording
- Resume Recording
- Switch Microphone
- Switch Camera

Switch commands accept optional arguments.

- `Switch Microphone` argument: microphone label
- `Switch Camera` argument: camera display name, device id, or model id

If no argument is provided, Cap switches to the next available device.

## Development

Install dependencies:

```bash
pnpm install
```

Typecheck extension sources:

```bash
pnpm --dir extensions/raycast typecheck
```
Binary file added extensions/raycast/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading