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
192 changes: 176 additions & 16 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ use cap_recording::{
RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget,
};
use serde::{Deserialize, Serialize};
use std::future::Future;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use tracing::trace;

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

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -26,6 +32,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
TakeScreenshot {
capture_mode: CaptureMode,
},
SetMicrophone {
mic_label: Option<String>,
},
SetCamera {
camera: Option<DeviceOrModelID>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -70,6 +88,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
});
}

#[derive(Debug)]
pub enum ActionParseFromUrlError {
ParseFailed(String),
Invalid,
Expand All @@ -88,9 +107,10 @@ impl TryFrom<&Url> for DeepLinkAction {
.map_err(|_| ActionParseFromUrlError::Invalid);
}

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

let params = url
Expand All @@ -115,23 +135,13 @@ impl DeepLinkAction {
capture_system_audio,
mode,
} => {
confirm_sensitive_action(app, "start a recording")?;
let state = app.state::<ArcLock<App>>();

crate::set_camera_input(app.clone(), state.clone(), camera, None).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 @@ -145,8 +155,42 @@ impl DeepLinkAction {
.map(|_| ())
}
DeepLinkAction::StopRecording => {
confirm_sensitive_action(app, "stop the active recording")?;
crate::recording::stop_recording(app.clone(), app.state()).await
}
DeepLinkAction::PauseRecording => {
confirm_sensitive_action(app, "pause the active recording")?;
recording::pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::ResumeRecording => {
confirm_sensitive_action(app, "resume the active recording")?;
recording::resume_recording(app.clone(), app.state()).await
}
DeepLinkAction::TogglePauseRecording => {
confirm_sensitive_action(app, "toggle recording pause")?;
recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
confirm_sensitive_action(app, "take a screenshot")?;
let target = resolve_capture_target(capture_mode)?;
let path = recording::take_screenshot(app.clone(), target).await?;
let _ = ShowCapWindow::ScreenshotEditor { path }.show(app).await;
Ok(())
}
DeepLinkAction::SetMicrophone { mic_label } => {
confirm_sensitive_action(app, "change the active microphone")?;
with_recording_paused_for_input_change(app, async {
crate::set_mic_input(app.state(), mic_label).await
})
.await
}
DeepLinkAction::SetCamera { camera } => {
confirm_sensitive_action(app, "change the active camera")?;
with_recording_paused_for_input_change(app, async {
crate::set_camera_input(app.clone(), app.state(), camera, Some(true)).await
})
.await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand All @@ -156,3 +200,119 @@ impl DeepLinkAction {
}
}
}

fn confirm_sensitive_action(app: &AppHandle, action: &str) -> Result<(), String> {
let approved = app
.dialog()
.message(format!(
"An external app or website requested to {action} in Cap."
))
.title("Confirm Cap action")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"Allow".to_string(),
"Cancel".to_string(),
))
.blocking_show();

if approved {
Ok(())
} else {
Err("Cap action cancelled".to_string())
}
}

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(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)),
}
}

async fn with_recording_paused_for_input_change<F>(
app: &AppHandle,
input_change: F,
) -> Result<(), String>
where
F: Future<Output = Result<(), String>>,
{
let should_resume = {
let state = app.state::<ArcLock<App>>();
let state = state.read().await;
match state.current_recording() {
Some(recording) => !recording.is_paused().await.map_err(|e| e.to_string())?,
None => false,
}
};

if should_resume {
recording::pause_recording(app.clone(), app.state()).await?;
}

match input_change.await {
Ok(()) => {
if should_resume {
recording::resume_recording(app.clone(), app.state()).await?;
}
Ok(())
}
Err(err) => {
if should_resume {
recording::resume_recording(app.clone(), app.state())
.await
.map_err(|resume_err| {
format!("{err}; failed to resume recording after input change error: {resume_err}")
})?;
}
Err(err)
}
}
}

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

fn action_from(value: &str) -> DeepLinkAction {
let mut url = Url::parse("cap-desktop://action").unwrap();
url.query_pairs_mut().append_pair("value", value);
DeepLinkAction::try_from(&url).unwrap()
}

#[test]
fn parses_action_host_links() {
let action = action_from(r#"{"pause_recording":null}"#);

assert!(matches!(action, DeepLinkAction::PauseRecording));
}

#[test]
fn keeps_signin_links_out_of_action_handler() {
let url = Url::parse("cap-desktop://signin?token=abc").unwrap();

assert!(matches!(
DeepLinkAction::try_from(&url),
Err(ActionParseFromUrlError::NotAction)
));
}

#[test]
fn parses_nullable_input_actions() {
let mic = action_from(r#"{"set_microphone":{"mic_label":null}}"#);
let camera = action_from(r#"{"set_camera":{"camera":null}}"#);

assert!(matches!(
mic,
DeepLinkAction::SetMicrophone { mic_label: None }
));
assert!(matches!(camera, DeepLinkAction::SetCamera { camera: None }));
}
}
1 change: 1 addition & 0 deletions apps/raycast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
raycast-env.d.ts
12 changes: 12 additions & 0 deletions apps/raycast/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Cap Raycast Extension

This extension controls Cap Desktop through the `cap-desktop://action?value=...` deeplink contract.

Commands:

- `Control Recording`: pause, resume, toggle pause, or stop the active recording.
- `Start Recording`: start a Studio or Instant recording by screen/window name.
- `Take Screenshot`: capture a screen/window by name.
- `Switch Input Device`: switch or disable the active microphone/camera.

Camera switching expects the Cap camera device ID. Microphone switching expects the device label shown by Cap.
Binary file added apps/raycast/assets/command-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
51 changes: 51 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "cap-raycast",
"version": "0.1.0",
"private": true,
"title": "Cap",
"description": "Raycast controls for Cap Desktop",
"icon": "command-icon.png",
"author": "cap",
"categories": [
"Productivity"
],
"license": "MIT",
"scripts": {
"build": "ray build",
"dev": "ray develop",
"lint": "ray lint"
},
"dependencies": {
"@raycast/api": "^1.104.17"
},
"devDependencies": {
"@types/node": "22.15.17",
"typescript": "^5.8.3"
},
"commands": [
{
"name": "cap-control",
"title": "Control Recording",
"description": "Pause, resume, toggle, or stop the active Cap recording",
"mode": "view"
},
{
"name": "start-recording",
"title": "Start Recording",
"description": "Start a Cap recording by display or window name",
"mode": "view"
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"description": "Capture a display or window in Cap",
"mode": "view"
},
{
"name": "switch-device",
"title": "Switch Input Device",
"description": "Switch the active Cap microphone or camera",
"mode": "view"
}
]
}
23 changes: 23 additions & 0 deletions apps/raycast/src/cap-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Action, ActionPanel, List } from "@raycast/api";
import { recordingActions, runCapAction } from "./lib/deeplinks";

export default function Command() {
return (
<List>
{recordingActions.map((item) => (
<List.Item
key={item.title}
title={item.title}
actions={
<ActionPanel>
<Action
title={item.title}
onAction={() => runCapAction(item.action, item.title)}
/>
</ActionPanel>
}
/>
))}
</List>
);
}
Loading