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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,8 @@ scripts/releases-backfill-data.txt
.opencode/
analysis/
analysis/plans/
.ralphy
.venv/
venv/
node_modules/
__pycache__/
*.pyc
59 changes: 55 additions & 4 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
SwitchMic {
label: Option<String>,
},
SwitchCamera {
id: Option<DeviceOrModelID>,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -87,11 +95,39 @@ impl TryFrom<&Url> for DeepLinkAction {
});
}

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

// Handle simple path-based deeplinks (e.g. cap://stop-recording)
match domain {
"stop-recording" => return Ok(Self::StopRecording),
"pause-recording" => return Ok(Self::PauseRecording),
"resume-recording" => return Ok(Self::ResumeRecording),
"switch-mic" => {
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
return Ok(Self::SwitchMic {
label: params.get("label").map(|v| v.to_string()),
});
}
"switch-camera" => {
let params: std::collections::HashMap<_, _> = url.query_pairs().collect();
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
if raw.is_empty() {
return None;
}
serde_json::from_str::<DeviceOrModelID>(raw)
.ok()
.or_else(|| Some(DeviceOrModelID::DeviceID(raw.to_string())))
});
Comment on lines 113 to 121
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DeviceOrModelID is an externally-tagged enum (DeviceID/ModelID), so deserializing from a plain string will always fail. As-is, cap://switch-camera?id=<some id> likely resolves to id: None and disables the camera instead of switching.

Suggested change
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
serde_json::from_str::<DeviceOrModelID>(raw)
.or_else(|_| {
serde_json::from_str::<DeviceOrModelID>(&format!(r#""{}""#, raw))
})
.ok()
});
let id = params.get("id").and_then(|v| {
let raw = v.as_ref();
if raw.is_empty() {
return None;
}
serde_json::from_str::<DeviceOrModelID>(raw)
.ok()
.or_else(|| Some(DeviceOrModelID::DeviceID(raw.to_string())))
});

return Ok(Self::SwitchCamera { id });
}
"action" => {
// Fall through to JSON-based parsing below.
}
_ => return Err(ActionParseFromUrlError::NotAction),
}

// Legacy JSON-based action parsing: cap://action?value=<json>
let params = url
.query_pairs()
.collect::<std::collections::HashMap<_, _>>();
Expand Down Expand Up @@ -146,6 +182,20 @@ 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::SwitchMic { label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, label).await
}
DeepLinkAction::SwitchCamera { id } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, id, None).await
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand All @@ -155,3 +205,4 @@ impl DeepLinkAction {
}
}
}

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"updater": { "active": false, "pubkey": "" },
"deep-link": {
"desktop": {
"schemes": ["cap-desktop"]
"schemes": ["cap-desktop", "cap"]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the global cap:// scheme means any app/site can trigger these actions once Cap is installed (e.g. starting/stopping recordings). Might be worth gating “remote control” deeplinks behind an explicit user setting, or at least limiting which actions are allowed without user confirmation.

}
}
},
Expand Down
115 changes: 115 additions & 0 deletions apps/raycast-extension/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recording from Raycast",
"icon": "extension-icon.png",
"author": "cap",
"categories": ["Media", "Productivity"],
"license": "MIT",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new Cap screen recording session",
"mode": "no-view",
"preferences": [
{
"name": "captureType",
"title": "Capture Type",
"description": "Whether to capture a screen or a window",
"type": "dropdown",
"required": true,
"default": "screen",
"data": [
{ "title": "Screen", "value": "screen" },
{ "title": "Window", "value": "window" }
]
},
{
"name": "captureName",
"title": "Screen or Window Name",
"description": "Exact name of the screen or window to capture",
"type": "textfield",
"required": false,
"default": ""
},
{
"name": "captureSystemAudio",
"title": "System Audio",
"description": "Capture system audio alongside the recording",
"type": "checkbox",
"required": false,
"default": false,
"label": "Capture system audio"
},
{
"name": "recordingMode",
"title": "Recording Mode",
"description": "Instant shares immediately; Studio lets you edit first",
"type": "dropdown",
"required": false,
"default": "studio",
"data": [
{ "title": "Instant", "value": "instant" },
{ "title": "Studio", "value": "studio" }
]
}
]
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current Cap screen recording",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current Cap screen recording",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume the paused Cap screen recording",
"mode": "no-view"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch the active microphone used by Cap",
"mode": "view"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch the active camera used by Cap",
"mode": "view"
}
],
"dependencies": {
"@raycast/api": "^1.68.0"
},
"devDependencies": {
"@raycast/utils": "^1.15.0",
"@types/node": "20.8.10",
"@types/react": "18.3.3",
"eslint": "^8.57.0",
"prettier": "^3.2.5",
"typescript": "^5.4.5"
},
"scripts": {
"build": "ray build -e dist",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint",
"publish": "npx @raycast/api@latest publish"
}
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/pause-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow, open, showHUD } from "@raycast/api";

export default async function Command() {
await closeMainWindow();
await open("cap://pause-recording");
await showHUD("Pausing recording…");
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/resume-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow, open, showHUD } from "@raycast/api";

export default async function Command() {
await closeMainWindow();
await open("cap://resume-recording");
await showHUD("Resuming recording…");
}
38 changes: 38 additions & 0 deletions apps/raycast-extension/src/start-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { closeMainWindow, getPreferenceValues, open, showHUD } from "@raycast/api";

interface Preferences {
captureType: "screen" | "window";
captureName: string;
captureSystemAudio: boolean;
recordingMode: "instant" | "studio";
}

export default async function Command() {
const prefs = getPreferenceValues<Preferences>();

const captureName = prefs.captureName?.trim();
if (!captureName) {
await showHUD("Set a screen/window name in Raycast preferences first");
return;
}
const captureMode =
prefs.captureType === "window"
? { window: captureName }
: { screen: captureName };

const action = {
start_recording: {
capture_mode: captureMode,
camera: null,
mic_label: null,
capture_system_audio: prefs.captureSystemAudio ?? false,
mode: prefs.recordingMode ?? "instant",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instant_capture doesn't match the Rust RecordingMode enum values (studio or instant)

Suggested change
mode: prefs.recordingMode ?? "instant",
mode: prefs.recordingMode ?? "instant",
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/raycast-extension/src/start-recording.tsx
Line: 25

Comment:
`instant_capture` doesn't match the Rust `RecordingMode` enum values (`studio` or `instant`)

```suggestion
      mode: prefs.recordingMode ?? "instant",
```

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

},
};

const url = `cap://action?value=${encodeURIComponent(JSON.stringify(action))}`;

await closeMainWindow();
await open(url);
await showHUD("Starting recording…");
}
7 changes: 7 additions & 0 deletions apps/raycast-extension/src/stop-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { closeMainWindow, open, showHUD } from "@raycast/api";

export default async function Command() {
await closeMainWindow();
await open("cap://stop-recording");
await showHUD("Stopping recording…");
}
35 changes: 35 additions & 0 deletions apps/raycast-extension/src/switch-camera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Action, ActionPanel, Form, closeMainWindow, open, showHUD } from "@raycast/api";

interface Values {
id: string;
}

export default function Command() {
async function handleSubmit(values: Values) {
const id = values.id.trim();
const url = id
? `cap://switch-camera?id=${encodeURIComponent(id)}`
: "cap://switch-camera";

await closeMainWindow();
await open(url);
await showHUD(id ? `Switching camera to: ${id}` : "Disabling camera");
}

return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm title="Switch Camera" onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField
id="id"
title="Camera ID"
placeholder="e.g. FaceTime HD Camera (leave blank to disable)"
autoFocus
/>
</Form>
);
}
35 changes: 35 additions & 0 deletions apps/raycast-extension/src/switch-microphone.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Action, ActionPanel, Form, closeMainWindow, open, showHUD } from "@raycast/api";

interface Values {
label: string;
}

export default function Command() {
async function handleSubmit(values: Values) {
const label = values.label.trim();
const url = label
? `cap://switch-mic?label=${encodeURIComponent(label)}`
: "cap://switch-mic";

await closeMainWindow();
await open(url);
await showHUD(label ? `Switching microphone to: ${label}` : "Disabling microphone");
}

return (
<Form
actions={
<ActionPanel>
<Action.SubmitForm title="Switch Microphone" onSubmit={handleSubmit} />
</ActionPanel>
}
>
<Form.TextField
id="label"
title="Microphone Label"
placeholder="e.g. Built-in Microphone (leave blank to disable)"
autoFocus
/>
</Form>
);
}
15 changes: 15 additions & 0 deletions apps/raycast-extension/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "CommonJS",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": "."
},
"include": ["src/**/*"]
}