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
165 changes: 143 additions & 22 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
use cap_recording::{
RecordingMode, feeds::camera::DeviceOrModelID, sources::screen_capture::ScreenCaptureTarget,
RecordingMode,
feeds::{camera::DeviceOrModelID, microphone::MicrophoneFeed},
sources::screen_capture::ScreenCaptureTarget,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tracing::trace;
use tracing::{info, trace, warn};

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

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -26,6 +33,20 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
TakeScreenshot {
capture_mode: CaptureMode,
},
SetMicrophone {
mic_label: Option<String>,
},
SetCamera {
camera: Option<DeviceOrModelID>,
},
/// Writes `raycast-device-cache.json` under the app data dir (displays, windows, cameras, mics).
RefreshRaycastDeviceCache,
OpenEditor {
project_path: PathBuf,
},
Expand All @@ -44,10 +65,10 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
DeepLinkAction::try_from(&url)
.map_err(|e| match e {
ActionParseFromUrlError::ParseFailed(msg) => {
eprintln!("Failed to parse deep link \"{}\": {}", &url, msg)
warn!(%url, %msg, "failed to parse cap-desktop action deeplink (unknown fields / old app?)");
}
ActionParseFromUrlError::Invalid => {
eprintln!("Invalid deep link format \"{}\"", &url)
warn!(%url, "invalid cap-desktop action deeplink (missing value= or bad host/path)");
}
// Likely login action, not handled here.
ActionParseFromUrlError::NotAction => {}
Expand All @@ -64,7 +85,7 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
tauri::async_runtime::spawn(async move {
for action in actions {
if let Err(e) = action.execute(&app_handle).await {
eprintln!("Failed to handle deep link action: {e}");
warn!(error = %e, "failed to execute cap-desktop deeplink action");
}
}
});
Expand All @@ -76,6 +97,18 @@ pub enum ActionParseFromUrlError {
NotAction,
}

fn is_action_deeplink_host(url: &Url) -> bool {
// Canonical (macOS / most): `cap-desktop://action?value=...`
if url.host_str() == Some("action") {
return true;
}
// Windows / some handlers: `cap-desktop:/action?value=...` — empty host, path `/action`
if url.host_str().is_none() && url.path() == "/action" {
return true;
}
false
}

impl TryFrom<&Url> for DeepLinkAction {
type Error = ActionParseFromUrlError;

Expand All @@ -88,10 +121,13 @@ impl TryFrom<&Url> for DeepLinkAction {
.map_err(|_| ActionParseFromUrlError::Invalid);
}

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

let params = url
.query_pairs()
Expand All @@ -105,6 +141,29 @@ impl TryFrom<&Url> for DeepLinkAction {
}
}

fn capture_target_from_mode(capture_mode: &CaptureMode) -> Result<ScreenCaptureTarget, String> {
Ok(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}\""))?,
})
}

fn raycast_device_cache_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?;
Ok(dir.join("raycast-device-cache.json"))
}

impl DeepLinkAction {
pub async fn execute(self, app: &AppHandle) -> Result<(), String> {
match self {
Expand All @@ -120,18 +179,7 @@ impl DeepLinkAction {
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 = capture_target_from_mode(&capture_mode)?;

let inputs = StartRecordingInputs {
mode,
Expand All @@ -147,6 +195,79 @@ 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::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.clone(), app.state())
.await
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let target = capture_target_from_mode(&capture_mode)?;
crate::recording::take_screenshot(app.clone(), target).await?;
Ok(())
}
DeepLinkAction::SetMicrophone { mic_label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, mic_label).await
}
DeepLinkAction::SetCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, Some(true)).await
}
DeepLinkAction::RefreshRaycastDeviceCache => {
let displays = crate::recording::list_capture_displays().await;
let windows = crate::recording::list_capture_windows().await;
let cameras = crate::recording::list_cameras();
let microphones = if crate::permissions::do_permissions_check(false)
.microphone
.permitted()
{
MicrophoneFeed::list()
.keys()
.cloned()
.collect::<Vec<_>>()
} else {
vec![]
};

let cameras_json: Result<Vec<serde_json::Value>, String> = cameras
.iter()
.map(|c| {
let id = DeviceOrModelID::from_info(c);
Ok(json!({
"display_name": c.display_name(),
"device_or_model_id": serde_json::to_value(&id).map_err(|e| e.to_string())?,
}))
})
.collect();
let cameras_json = cameras_json?;

let payload = json!({
"generated_at": chrono::Utc::now().to_rfc3339(),
"displays": displays,
"windows": windows,
"cameras": cameras_json,
"microphones": microphones,
});

let path = raycast_device_cache_path(app)?;
// Async fs only: `execute` runs on Tokio; `std::fs` would block a worker thread.
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(&payload).map_err(|e| e.to_string())?;
tokio::fs::write(&path, json.as_bytes())
.await
.map_err(|e| e.to_string())?;
info!(path = %path.display(), "wrote Raycast device cache");
Ok(())
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
3 changes: 3 additions & 0 deletions extensions/cap/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
.DS_Store
39 changes: 39 additions & 0 deletions extensions/cap/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Cap Raycast extension (local)

Companion to desktop deeplinks (`cap-desktop://action?value=…`) implemented in `apps/desktop/src-tauri/src/deeplink_actions.rs`.

## Prereqs

- macOS with [Raycast](https://www.raycast.com/) (extensions target macOS).
- Cap desktop running (dev or prod) so `cap-desktop://` is registered.

## Install (dev)

```bash
cd extensions/cap
npm install
npm run dev
```

Pick the extension in Raycast, then run **Refresh Device Cache** once Cap is open so `raycast-device-cache.json` is written under Cap’s app data directory (`so.cap.desktop` or `so.cap.desktop.dev` on macOS).

## Commands

| Command | Deeplink payload |
|--------|-------------------|
| Start Recording | `start_recording` — `capture_mode` `{ "screen": "…" }` / `{ "window": "…" }`, `mode` `studio` \| `instant`, optional `mic_label`, `camera`, `capture_system_audio` |
| Stop / Pause / Resume / Toggle pause | `stop_recording`, `pause_recording`, `resume_recording`, `toggle_pause_recording` |
| Refresh Device Cache | `refresh_raycast_device_cache` |
| Take Screenshot | `take_screenshot` with `capture_mode` `screen` / `window` (CLI format `screen:Display Name`) |
| Set Microphone | `set_microphone.mic_label` (string or null) |
| Set Camera | `set_camera.camera` = JSON of `device_or_model_id` from cache |

Desktop parsing accepts both `cap-desktop://action?...` (host `action`) and **`cap-desktop:/action?...`** (empty host, path `/action`) — the second shape shows up from some Windows launchers.

On **Windows**, Raycast opens URLs via `rundll32 url.dll,FileProtocolHandler` (not `cmd /c start`, which corrupts `%`-encoded query strings).

**If the cache file stays empty:** Cap must be **installed** (URL scheme is registered by the installer), running, and check both `%AppData%\so.cap.desktop` and `%AppData%\so.cap.desktop.dev` if you mix prod vs dev builds.

## Bounty PR

Comment `/attempt #1540` on the issue, then open a PR against `CapSoftware/Cap` with `/claim #1540` in the body and a short demo video per Algora rules.
Binary file added extensions/cap/assets/extension-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