-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: Full Deeplink support + Raycast Extension with Superior Sync #1788
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
da71030
ec7543c
d6314fe
abbf4f9
5153c61
9e04d55
af6805f
1d2dcb8
53a0d31
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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")] | ||
|
|
@@ -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>) { | ||
|
|
@@ -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() | ||
|
|
@@ -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, | ||
|
|
@@ -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?; | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The lock is read, the paused state is checked, then the lock is dropped, and an async Prompt To Fix With AIThis 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Prompt To Fix With AIThis 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()) | ||
|
|
||
| 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" | ||
| } | ||
| } |
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
RecordingStateapp_state.recording_state.is_paused()will not compile.RecordingStateonly definesis_recording_active_or_pending(); it has nois_paused()method. Additionally,state.read().unwrap()is incorrect here becauseArcLock<App>isArc<tokio::sync::RwLock<App>>andtokio::sync::RwLock::read()is anasync fn— calling.unwrap()on the returned future is a type error. The existingtoggle_pause_recordinginrecording.rs(lines 1539–1556) handles this correctly by usingstate.read().awaitand then callingrecording.is_paused().awaiton the innerInProgressRecording.Prompt To Fix With AI