Skip to content
Closed
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
100 changes: 94 additions & 6 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ use cap_recording::{
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tauri::{AppHandle, Manager, Url};
use tauri::{AppHandle, Manager, Url, Runtime, Emitter};
use tracing::trace;

use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow};
use crate::{App, ArcLock, recording::StartRecordingInputs, windows::ShowCapWindow, MutableState};
use crate::recording::{InProgressRecording, RecordingEvent, RecordingState};

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
Expand All @@ -26,12 +27,27 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
SwitchCamera {
camera_id: DeviceOrModelID,
},
SwitchMic {
mic_label: String,
},
OpenEditor {
project_path: PathBuf,
},
OpenSettings {
page: Option<String>,
},
GetStatus,
}

#[derive(Debug, Clone, Serialize)]
struct RecordingStatusPayload {
status: String,
}

pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
Expand All @@ -49,7 +65,6 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
ActionParseFromUrlError::Invalid => {
eprintln!("Invalid deep link format \"{}\"", &url)
}
// Likely login action, not handled here.
ActionParseFromUrlError::NotAction => {}
})
.ok()
Expand All @@ -70,6 +85,12 @@ pub fn handle(app_handle: &AppHandle, urls: Vec<Url>) {
});
}

fn emit_status_change<R: Runtime>(app: &AppHandle<R>, status: &str) {
let _ = app.emit_all("cap://recording-status", RecordingStatusPayload {
status: status.to_string(),
});
}

pub enum ActionParseFromUrlError {
ParseFailed(String),
Invalid,
Expand Down Expand Up @@ -115,7 +136,7 @@ impl DeepLinkAction {
capture_system_audio,
mode,
} => {
let state = app.state::<ArcLock<App>>();
let state: MutableState<'_, App> = app.state();

crate::set_camera_input(app.clone(), state.clone(), camera, None).await?;
crate::set_mic_input(state.clone(), mic_label).await?;
Expand All @@ -142,10 +163,77 @@ impl DeepLinkAction {

crate::recording::start_recording(app.clone(), state, inputs)
.await
.map(|_| ())
.map(|_| {
emit_status_change(app, "recording");
})
}
DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
let state: MutableState<'_, App> = app.state();
crate::recording::stop_recording(app.clone(), state).await.map(|_| {
emit_status_change(app, "idle");
})
}
DeepLinkAction::PauseRecording => {
let state: MutableState<'_, App> = app.state();
let mut state_guard = state.write().await;
if let RecordingState::Active(recording) = &mut state_guard.recording_state {
recording.pause().await.map_err(|e| e.to_string())?;
RecordingEvent::Paused.emit(app).ok();
emit_status_change(app, "paused");
}
Ok(())
}
DeepLinkAction::ResumeRecording => {
let state: MutableState<'_, App> = app.state();
let mut state_guard = state.write().await;
if let RecordingState::Active(recording) = &mut state_guard.recording_state {
recording.resume().await.map_err(|e| e.to_string())?;
RecordingEvent::Resumed.emit(app).ok();
emit_status_change(app, "recording");
}
Ok(())
}
DeepLinkAction::TogglePauseRecording => {
let state: MutableState<'_, App> = app.state();
let mut state_guard = state.write().await;
if let RecordingState::Active(recording) = &mut state_guard.recording_state {
let is_paused = recording.is_paused().await.map_err(|e| e.to_string())?;
if is_paused {
recording.resume().await.map_err(|e| e.to_string())?;
RecordingEvent::Resumed.emit(app).ok();
emit_status_change(app, "recording");
} else {
recording.pause().await.map_err(|e| e.to_string())?;
RecordingEvent::Paused.emit(app).ok();
emit_status_change(app, "paused");
}
}
Comment on lines +196 to +210
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.

P0 Compile error: nonexistent methods on RecordingState

app_state.recording_state.is_paused() will not compile. RecordingState only defines is_recording_active_or_pending(); it has no is_paused() method. Additionally, state.read().unwrap() is incorrect here because ArcLock<App> is Arc<tokio::sync::RwLock<App>> and tokio::sync::RwLock::read() is an async fn — calling .unwrap() on the returned future is a type error. The existing toggle_pause_recording in recording.rs (lines 1539–1556) handles this correctly by using state.read().await and then calling recording.is_paused().await on the inner InProgressRecording.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 186-199

Comment:
**Compile error: nonexistent methods on `RecordingState`**

`app_state.recording_state.is_paused()` will not compile. `RecordingState` only defines `is_recording_active_or_pending()`; it has no `is_paused()` method. Additionally, `state.read().unwrap()` is incorrect here because `ArcLock<App>` is `Arc<tokio::sync::RwLock<App>>` and `tokio::sync::RwLock::read()` is an `async fn` — calling `.unwrap()` on the returned future is a type error. The existing `toggle_pause_recording` in `recording.rs` (lines 1539–1556) handles this correctly by using `state.read().await` and then calling `recording.is_paused().await` on the inner `InProgressRecording`.

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

Comment on lines +196 to +210
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 TOCTOU race in TogglePauseRecording

The lock is read, the paused state is checked, then the lock is dropped, and an async pause_recording / resume_recording call is made. If two TogglePauseRecording deep links arrive in quick succession, both can observe the same state before either modifies it, causing a double-pause or double-resume. The existing toggle_pause_recording in recording.rs avoids this by holding the read lock across the entire check-then-act sequence.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 186-199

Comment:
**TOCTOU race in `TogglePauseRecording`**

The lock is read, the paused state is checked, then the lock is dropped, and an async `pause_recording` / `resume_recording` call is made. If two `TogglePauseRecording` deep links arrive in quick succession, both can observe the same state before either modifies it, causing a double-pause or double-resume. The existing `toggle_pause_recording` in `recording.rs` avoids this by holding the read lock across the entire check-then-act sequence.

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

Ok(())
}
DeepLinkAction::SwitchCamera { camera_id } => {
let state: MutableState<'_, App> = app.state();
crate::set_camera_input(app.clone(), state, Some(camera_id), None).await
}
DeepLinkAction::SwitchMic { mic_label } => {
let state: MutableState<'_, App> = app.state();
crate::set_mic_input(state, Some(mic_label)).await
}
DeepLinkAction::GetStatus => {
let state: MutableState<'_, App> = app.state();
let state_guard = state.read().await;
let status = match &state_guard.recording_state {
RecordingState::None => "idle",
RecordingState::Pending { .. } => "pending",
RecordingState::Active(recording) => {
if recording.is_paused().await.unwrap_or(false) {
"paused"
} else {
"recording"
}
}
};
emit_status_change(app, status);
Ok(())
Comment on lines +221 to +236
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.

P0 Compile error: nonexistent methods and wrong lock usage in GetStatus

app_state.recording_state.is_recording() and app_state.recording_state.is_paused() do not exist on RecordingState. The same state.read().unwrap() on a tokio::sync::RwLock issue applies here. To get paused status, you need state.read().await.current_recording() to obtain the InProgressRecording and then call .is_paused().await on it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/deeplink_actions.rs
Line: 209-218

Comment:
**Compile error: nonexistent methods and wrong lock usage in `GetStatus`**

`app_state.recording_state.is_recording()` and `app_state.recording_state.is_paused()` do not exist on `RecordingState`. The same `state.read().unwrap()` on a `tokio::sync::RwLock` issue applies here. To get paused status, you need `state.read().await.current_recording()` to obtain the `InProgressRecording` and then call `.is_paused().await` on it.

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

}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
Expand Down
38 changes: 38 additions & 0 deletions apps/raycast-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "cap-raycast",
"title": "Cap",
"description": "Control Cap recording via Raycast",
"icon": "command-icon.png",
"author": "Angelebeats",
"categories": [
"Productivity"
],
"license": "MIT",
"commands": [
{
"name": "index",
"title": "Control Recording",
"description": "Start, stop, pause, or resume Cap recording",
"mode": "menu-bar"
}
],
"dependencies": {
"@raycast/api": "^1.72.0",
"@raycast/utils": "^1.15.0"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.8",
"@types/node": "20.8.10",
"@types/react": "18.2.27",
"eslint": "^8.51.0",
"prettier": "^3.0.3",
"typescript": "^5.2.2"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}
52 changes: 52 additions & 0 deletions apps/raycast-extension/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { MenuBarExtra, open } from "@raycast/api";
import { useCachedState } from "@raycast/utils";

export default function Command() {
const [status, setStatus] = useCachedState<"idle" | "recording" | "paused">("recording-status", "idle");

const sendAction = async (action: string, value: object = {}) => {
const json = JSON.stringify({ [action]: value });
const url = `cap://action?value=${encodeURIComponent(json)}`;
await open(url);

// Optimistic update for immediate feedback
if (action === "stop_recording") setStatus("idle");
if (action === "start_recording") setStatus("recording");
if (action === "pause_recording") setStatus("paused");
if (action === "resume_recording") setStatus("recording");
};

return (
<MenuBarExtra
icon={status === "recording" ? "🔴" : status === "paused" ? "⏸️" : "📷"}
tooltip="Cap Recording Control"
>
{status === "idle" ? (
<MenuBarExtra.Item
title="Start Recording"
onAction={() => sendAction("start_recording", {
capture_mode: { screen: "Display 1" },
mode: "video",
capture_system_audio: true
})}
/>
) : (
<>
<MenuBarExtra.Item
title="Stop Recording"
onAction={() => sendAction("stop_recording")}
/>
<MenuBarExtra.Item
title={status === "paused" ? "Resume Recording" : "Pause Recording"}
onAction={() => sendAction(status === "paused" ? "resume_recording" : "pause_recording")}
/>
</>
)}
<MenuBarExtra.Separator />
<MenuBarExtra.Item
title="Open Settings"
onAction={() => sendAction("open_settings")}
/>
</MenuBarExtra>
);
}