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
164 changes: 143 additions & 21 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
ToggleMicrophone {
mic_label: Option<String>,
},
ToggleCamera {
camera: Option<DeviceOrModelID>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -89,19 +98,115 @@ impl TryFrom<&Url> for DeepLinkAction {
}

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

let params = url
.query_pairs()
.collect::<std::collections::HashMap<_, _>>();
let json_value = params
.get("value")
.ok_or(ActionParseFromUrlError::Invalid)?;
let action: Self = serde_json::from_str(json_value)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()))?;
Ok(action)
}
}
}

fn parse_action_url(url: &Url) -> Result<DeepLinkAction, ActionParseFromUrlError> {
let params = url
.query_pairs()
.collect::<std::collections::HashMap<_, _>>();

if let Some(action) = params.get("action").or_else(|| params.get("value")) {
return serde_json::from_str::<DeepLinkAction>(action)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()));
}

let command = url
.path_segments()
.and_then(|mut segments| segments.next())
.filter(|segment| !segment.is_empty())
.ok_or(ActionParseFromUrlError::Invalid)?;

match command {
"record" | "start-recording" | "start_recording" => Ok(DeepLinkAction::StartRecording {
capture_mode: parse_capture_mode(&params)?,
camera: parse_camera(&params)?,
mic_label: params
.get("mic")
.or_else(|| params.get("mic_label"))
.map(|v| v.to_string()),
capture_system_audio: parse_bool_param(&params, "system_audio", false),
mode: parse_recording_mode(&params)?,
}),
"stop" | "stop-recording" | "stop_recording" => Ok(DeepLinkAction::StopRecording),
"pause" | "pause-recording" | "pause_recording" => Ok(DeepLinkAction::PauseRecording),
"resume" | "resume-recording" | "resume_recording" => Ok(DeepLinkAction::ResumeRecording),
"toggle-pause" | "toggle_pause" | "toggle-pause-recording" | "toggle_pause_recording" => {
Ok(DeepLinkAction::TogglePauseRecording)
}
"toggle-microphone" | "toggle_microphone" | "mic" => Ok(DeepLinkAction::ToggleMicrophone {
mic_label: params
.get("mic")
.or_else(|| params.get("mic_label"))
.map(|v| v.to_string()),
}),
"toggle-camera" | "toggle_camera" | "camera" => Ok(DeepLinkAction::ToggleCamera {
camera: parse_camera(&params)?,
}),
"settings" | "open-settings" | "open_settings" => Ok(DeepLinkAction::OpenSettings {
page: params.get("page").map(|v| v.to_string()),
}),
_ => Err(ActionParseFromUrlError::Invalid),
}
}

fn parse_capture_mode(
params: &std::collections::HashMap<std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>>,
) -> Result<CaptureMode, ActionParseFromUrlError> {
if let Some(display) = params.get("display").or_else(|| params.get("screen")) {
return Ok(CaptureMode::Screen(display.to_string()));
}

if let Some(window) = params.get("window") {
return Ok(CaptureMode::Window(window.to_string()));
}

Err(ActionParseFromUrlError::Invalid)
}

fn parse_camera(
params: &std::collections::HashMap<std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>>,
) -> Result<Option<DeviceOrModelID>, ActionParseFromUrlError> {
if let Some(device_id) = params
.get("camera_device_id")
.or_else(|| params.get("camera"))
{
return Ok(Some(DeviceOrModelID::DeviceID(device_id.to_string())));
}

if let Some(model_id) = params.get("camera_model_id") {
return serde_json::from_value(serde_json::json!({ "ModelID": model_id.to_string() }))
.map(Some)
.map_err(|e| ActionParseFromUrlError::ParseFailed(e.to_string()));
}

Ok(None)
}

fn parse_bool_param(
params: &std::collections::HashMap<std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>>,
key: &str,
default: bool,
) -> bool {
params
.get(key)
.and_then(|value| value.parse::<bool>().ok())
.unwrap_or(default)
}
Comment on lines +190 to +199
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.

P2 parse_bool_param only accepts lowercase "true"/"false"

Rust's str::parse::<bool>() is case-sensitive and recognises exactly "true" and "false". Values like "1", "0", "True", or "yes" silently fall back to default. For a URL-based API called by scripts or integrations outside the Raycast extension, this can cause unexpected silent misbehaviour.

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

Comment:
**`parse_bool_param` only accepts lowercase `"true"`/`"false"`**

Rust's `str::parse::<bool>()` is case-sensitive and recognises exactly `"true"` and `"false"`. Values like `"1"`, `"0"`, `"True"`, or `"yes"` silently fall back to `default`. For a URL-based API called by scripts or integrations outside the Raycast extension, this can cause unexpected silent misbehaviour.

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


fn parse_recording_mode(
params: &std::collections::HashMap<std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>>,
) -> Result<RecordingMode, ActionParseFromUrlError> {
match params.get("mode").map(|v| v.as_ref()) {
Some("instant") => Ok(RecordingMode::Instant),
Some("studio") | None => Ok(RecordingMode::Studio),
Some(value) => Err(ActionParseFromUrlError::ParseFailed(format!(
"Unsupported recording mode: {value}"
))),
}
}

Expand All @@ -121,16 +226,20 @@ impl DeepLinkAction {
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))?,
CaptureMode::Screen(name) => {
cap_recording::sources::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::sources::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 inputs = StartRecordingInputs {
Expand All @@ -147,6 +256,19 @@ impl DeepLinkAction {
DeepLinkAction::StopRecording => {
crate::recording::stop_recording(app.clone(), app.state()).await
}
DeepLinkAction::PauseRecording => crate::recording::pause_recording(app.state()).await,
DeepLinkAction::ResumeRecording => {
crate::recording::resume_recording(app.state()).await
}
DeepLinkAction::TogglePauseRecording => {
crate::recording::toggle_pause_recording(app.state()).await
}
DeepLinkAction::ToggleMicrophone { mic_label } => {
crate::set_mic_input(app.state(), mic_label).await
}
DeepLinkAction::ToggleCamera { camera } => {
crate::set_camera_input(app.clone(), app.state(), camera, None).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
3 changes: 3 additions & 0 deletions apps/raycast/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": ["@raycast"]
}
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.
75 changes: 75 additions & 0 deletions apps/raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap-raycast",
"title": "Cap",
"description": "Control Cap recordings with deeplinks.",
"icon": "command-icon.png",
"author": "Cap",
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"description": "Start a Cap recording with a display or window target.",
"mode": "view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"description": "Stop the current Cap recording.",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"description": "Pause the current Cap recording.",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"description": "Resume the current Cap recording.",
"mode": "no-view"
},
{
"name": "toggle-pause-recording",
"title": "Toggle Pause Recording",
"description": "Toggle pause for the current Cap recording.",
"mode": "no-view"
},
{
"name": "set-microphone",
"title": "Set Microphone",
"description": "Set or clear Cap's microphone input.",
"mode": "view"
},
{
"name": "set-camera",
"title": "Set Camera",
"description": "Set or clear Cap's camera input.",
"mode": "view"
},
{
"name": "open-settings",
"title": "Open Settings",
"description": "Open Cap settings.",
"mode": "view"
}
],
"dependencies": {
"@raycast/api": "^1.83.2"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "^20.17.6",
"eslint": "^8.57.1",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"lint": "ray lint",
"fix-lint": "ray lint --fix"
}
}
Comment on lines +1 to +75
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.

P2 Missing platforms field and package-lock.json

The Raycast manifest recommends declaring a platforms field (e.g., ["macOS"]) so users on other platforms are informed the extension is macOS-only. Additionally, Raycast's store CI builds extensions with npm using package-lock.json; without one the lockfile is missing and dependency versions aren't pinned for store validation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/package.json
Line: 1-75

Comment:
**Missing `platforms` field and `package-lock.json`**

The Raycast manifest recommends declaring a `platforms` field (e.g., `["macOS"]`) so users on other platforms are informed the extension is macOS-only. Additionally, Raycast's store CI builds extensions with `npm` using `package-lock.json`; without one the lockfile is missing and dependency versions aren't pinned for store validation.

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

52 changes: 52 additions & 0 deletions apps/raycast/raycast-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/// <reference types="@raycast/api">

/* 🚧 🚧 🚧
* This file is auto-generated from the extension's manifest.
* Do not modify manually. Instead, update the `package.json` file.
* 🚧 🚧 🚧 */

/* eslint-disable @typescript-eslint/ban-types */

type ExtensionPreferences = {}

/** Preferences accessible in all the extension's commands */
declare type Preferences = ExtensionPreferences

declare namespace Preferences {
/** Preferences accessible in the `start-recording` command */
export type StartRecording = ExtensionPreferences & {}
/** Preferences accessible in the `stop-recording` command */
export type StopRecording = ExtensionPreferences & {}
/** Preferences accessible in the `pause-recording` command */
export type PauseRecording = ExtensionPreferences & {}
/** Preferences accessible in the `resume-recording` command */
export type ResumeRecording = ExtensionPreferences & {}
/** Preferences accessible in the `toggle-pause-recording` command */
export type TogglePauseRecording = ExtensionPreferences & {}
/** Preferences accessible in the `set-microphone` command */
export type SetMicrophone = ExtensionPreferences & {}
/** Preferences accessible in the `set-camera` command */
export type SetCamera = ExtensionPreferences & {}
/** Preferences accessible in the `open-settings` command */
export type OpenSettings = ExtensionPreferences & {}
}

declare namespace Arguments {
/** Arguments passed to the `start-recording` command */
export type StartRecording = {}
/** Arguments passed to the `stop-recording` command */
export type StopRecording = {}
/** Arguments passed to the `pause-recording` command */
export type PauseRecording = {}
/** Arguments passed to the `resume-recording` command */
export type ResumeRecording = {}
/** Arguments passed to the `toggle-pause-recording` command */
export type TogglePauseRecording = {}
/** Arguments passed to the `set-microphone` command */
export type SetMicrophone = {}
/** Arguments passed to the `set-camera` command */
export type SetCamera = {}
/** Arguments passed to the `open-settings` command */
export type OpenSettings = {}
}

21 changes: 21 additions & 0 deletions apps/raycast/src/cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { open, showHUD } from "@raycast/api";

const SCHEME = "cap-desktop://action";

export type QueryParams = Record<string, string | boolean | undefined>;

export async function openCapAction(action: string, params: QueryParams = {}) {
const url = new URL(`${SCHEME}/${action}`);

for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "")
url.searchParams.set(key, String(value));
}
Comment on lines +10 to +13
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.

P2 false boolean values are always appended to URLs even when they are the server-side default

The guard value !== undefined && value !== "" passes false booleans through, so start-recording always appends system_audio=false to the URL. In parse_bool_param the default is already false, so this is redundant. Consider also filtering out false unless the param is required.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast/src/cap.ts
Line: 10-13

Comment:
**`false` boolean values are always appended to URLs even when they are the server-side default**

The guard `value !== undefined && value !== ""` passes `false` booleans through, so `start-recording` always appends `system_audio=false` to the URL. In `parse_bool_param` the default is already `false`, so this is redundant. Consider also filtering out `false` unless the param is required.

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


await open(url.toString());
}

export async function runNoViewAction(action: string, hudTitle: string) {
await openCapAction(action);
await showHUD(hudTitle);
}
30 changes: 30 additions & 0 deletions apps/raycast/src/open-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Action, ActionPanel, Form, showToast, Toast } from "@raycast/api";
import { openCapAction } from "./cap";

type Values = { page?: string };

export default function Command() {
async function submit(values: Values) {
await openCapAction("settings", { page: values.page?.trim() });
await showToast({
style: Toast.Style.Success,
title: "Opening Cap settings",
});
}

return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm title="Open Settings" onSubmit={submit} />
</ActionPanel>
}
>
<Form.TextField
id="page"
title="Page"
placeholder="Optional settings page"
/>
</Form>
);
}
4 changes: 4 additions & 0 deletions apps/raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { runNoViewAction } from "./cap";
export default async function Command() {
await runNoViewAction("pause", "Pausing Cap recording");
}
4 changes: 4 additions & 0 deletions apps/raycast/src/resume-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { runNoViewAction } from "./cap";
export default async function Command() {
await runNoViewAction("resume", "Resuming Cap recording");
}
Loading