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
64 changes: 51 additions & 13 deletions apps/desktop/src-tauri/src/deeplink_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ pub enum DeepLinkAction {
mode: RecordingMode,
},
StopRecording,
PauseRecording,
ResumeRecording,
TogglePauseRecording,
SetCamera {
camera: Option<DeviceOrModelID>,
},
SetMicrophone {
mic_label: Option<String>,
},
TakeScreenshot {
capture_mode: CaptureMode,
},
OpenEditor {
project_path: PathBuf,
},
Expand All @@ -49,7 +61,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 Down Expand Up @@ -104,6 +115,21 @@ impl TryFrom<&Url> for DeepLinkAction {
}
}

fn resolve_capture_target(capture_mode: &CaptureMode) -> Result<ScreenCaptureTarget, String> {
match capture_mode {
CaptureMode::Screen(name) => cap_recording::screen_capture::list_displays()
.into_iter()
.find(|(s, _)| s.name == name.as_str())
.map(|(s, _)| ScreenCaptureTarget::Display { id: s.id })
.ok_or_else(|| format!("No screen with name \"{}\" (must match exactly)", name)),
CaptureMode::Window(name) => cap_recording::screen_capture::list_windows()
.into_iter()
.find(|(w, _)| w.name == name.as_str())
.map(|(w, _)| ScreenCaptureTarget::Window { id: w.id })
.ok_or_else(|| format!("No window with name \"{}\" (must match exactly)", name)),
}
}

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

let inputs = StartRecordingInputs {
mode,
Expand All @@ -146,6 +161,29 @@ 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::SetCamera { camera } => {
let state = app.state::<ArcLock<App>>();
crate::set_camera_input(app.clone(), state, camera, None).await
}
DeepLinkAction::SetMicrophone { mic_label } => {
let state = app.state::<ArcLock<App>>();
crate::set_mic_input(state, mic_label).await
}
DeepLinkAction::TakeScreenshot { capture_mode } => {
let capture_target = resolve_capture_target(&capture_mode)?;
crate::recording::take_screenshot(app.clone(), capture_target)
.await
.map(|_| ())
}
DeepLinkAction::OpenEditor { project_path } => {
crate::open_project_from_path(Path::new(&project_path), app.clone())
}
Expand Down
2 changes: 2 additions & 0 deletions raycast/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
.raycast/
Binary file added 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.
93 changes: 93 additions & 0 deletions raycast/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"$schema": "https://www.raycast.com/schemas/extension.json",
"name": "cap",
"title": "Cap",
"description": "Control Cap screen recorder via deeplinks - start, stop, pause, resume recording, switch camera/microphone, and take screenshots.",
"icon": "command-icon.png",
"author": "cap",
"categories": ["Productivity", "Applications"],
"license": "AGPL-3.0-or-later",
"commands": [
{
"name": "start-recording",
"title": "Start Recording",
"subtitle": "Cap",
"description": "Start a new screen recording in Cap",
"mode": "view"
},
{
"name": "stop-recording",
"title": "Stop Recording",
"subtitle": "Cap",
"description": "Stop the current recording in Cap",
"mode": "no-view"
},
{
"name": "pause-recording",
"title": "Pause Recording",
"subtitle": "Cap",
"description": "Pause the current recording in Cap",
"mode": "no-view"
},
{
"name": "resume-recording",
"title": "Resume Recording",
"subtitle": "Cap",
"description": "Resume a paused recording in Cap",
"mode": "no-view"
},
{
"name": "toggle-pause-recording",
"title": "Toggle Pause Recording",
"subtitle": "Cap",
"description": "Toggle pause/resume on the current recording in Cap",
"mode": "no-view"
},
{
"name": "switch-microphone",
"title": "Switch Microphone",
"subtitle": "Cap",
"description": "Switch the active microphone in Cap",
"mode": "view"
},
{
"name": "switch-camera",
"title": "Switch Camera",
"subtitle": "Cap",
"description": "Switch the active camera in Cap",
"mode": "view"
},
{
"name": "take-screenshot",
"title": "Take Screenshot",
"subtitle": "Cap",
"description": "Take a screenshot with Cap",
"mode": "view"
},
{
"name": "open-settings",
"title": "Open Settings",
"subtitle": "Cap",
"description": "Open Cap settings",
"mode": "no-view"
}
],
"dependencies": {
"@raycast/api": "^1.93.2",
"@raycast/utils": "^1.19.1"
},
"devDependencies": {
"@raycast/eslint-config": "^1.0.11",
"@types/node": "22.13.1",
"@types/react": "19.0.8",
"eslint": "^9.18.0",
"prettier": "^3.4.2",
"typescript": "^5.7.3"
},
"scripts": {
"build": "ray build",
"dev": "ray develop",
"fix-lint": "ray lint --fix",
"lint": "ray lint"
}
}
12 changes: 12 additions & 0 deletions raycast/src/open-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { executeDeepLink } from "./utils";

export default async function OpenSettings() {
await executeDeepLink(
{
open_settings: {
page: null,
},
},
"Opening Cap settings",
);
}
5 changes: 5 additions & 0 deletions raycast/src/pause-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { executeDeepLink } from "./utils";

export default async function PauseRecording() {
await executeDeepLink("pause_recording", "Pausing recording in Cap");
}
5 changes: 5 additions & 0 deletions raycast/src/resume-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { executeDeepLink } from "./utils";

export default async function ResumeRecording() {
await executeDeepLink("resume_recording", "Resuming recording in Cap");
}
54 changes: 54 additions & 0 deletions raycast/src/start-recording.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { ActionPanel, Action, List } from "@raycast/api";
import { useExec } from "@raycast/utils";
import { executeDeepLink } from "./utils";

function parseDisplays(stdout: string): string[] {
const displays: string[] = [];
const lines = stdout.split("\n");
for (const line of lines) {
const match = line.match(/^\s{8}(\S.*):$/);
if (match && match[1]) {
displays.push(match[1]);
}
}
return displays;
}

async function startRecordingOnDisplay(displayName: string) {
await executeDeepLink(
{
start_recording: {
capture_mode: { screen: displayName },
camera: null,
mic_label: null,
capture_system_audio: false,
mode: "studio",
},
},
`Starting recording on "${displayName}" in Cap`,
);
}

export default function StartRecording() {
const { data, isLoading } = useExec("system_profiler", ["SPDisplaysDataType", "-detailLevel", "mini"], {
parseOutput: ({ stdout }) => parseDisplays(stdout),
});

const displays = data ?? [];

return (
<List isLoading={isLoading} searchBarPlaceholder="Select a display to record...">
{displays.map((display) => (
<List.Item
key={display}
title={display}
actions={
<ActionPanel>
<Action title={`Record ${display}`} onAction={() => startRecordingOnDisplay(display)} />
</ActionPanel>
}
/>
))}
</List>
);
}
5 changes: 5 additions & 0 deletions raycast/src/stop-recording.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { executeDeepLink } from "./utils";

export default async function StopRecording() {
await executeDeepLink("stop_recording", "Stopping recording in Cap");
}
88 changes: 88 additions & 0 deletions raycast/src/switch-camera.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Action, ActionPanel, List } from "@raycast/api";
import { useExec } from "@raycast/utils";
import { executeDeepLink } from "./utils";

interface Camera {
name: string;
uniqueId: string;
}

async function switchCamera(camera: Camera) {
await executeDeepLink(
{ set_camera: { camera: { DeviceID: camera.uniqueId } } },
`Switching camera to "${camera.name}" in Cap`,
);
}

async function disableCamera() {
await executeDeepLink(
{ set_camera: { camera: null } },
"Disabling camera in Cap",
);
}

export default function SwitchCamera() {
const { data, isLoading } = useExec("system_profiler", ["SPCameraDataType", "-detailLevel", "mini"], {
parseOutput: ({ stdout }) => {
const cameras: Camera[] = [];
const lines = stdout.split("\n");
let currentName: string | null = null;
let currentUniqueId: string | null = null;

for (const line of lines) {
if (line.match(/^\s{4}\S/) && line.includes(":")) {
if (currentName && currentUniqueId) {
cameras.push({ name: currentName, uniqueId: currentUniqueId });
}
const name = line.trim().replace(/:$/, "");
if (name.length > 0 && name !== "Camera") {
currentName = name;
currentUniqueId = null;
} else {
currentName = null;
currentUniqueId = null;
}
}

const uniqueIdMatch = line.match(/^\s+Unique ID:\s*(.+)/);
if (uniqueIdMatch && currentName) {
currentUniqueId = uniqueIdMatch[1].trim();
}
}

if (currentName && currentUniqueId) {
cameras.push({ name: currentName, uniqueId: currentUniqueId });
}

return cameras;
},
});

const cameras = data ?? [];

return (
<List isLoading={isLoading} searchBarPlaceholder="Search cameras...">
<List.Item
key="disable"
title="Disable Camera"
subtitle="Turn off camera input"
actions={
<ActionPanel>
<Action title="Disable Camera" onAction={disableCamera} />
</ActionPanel>
}
/>
{cameras.map((cam) => (
<List.Item
key={cam.uniqueId}
title={cam.name}
actions={
<ActionPanel>
<Action title={`Switch to ${cam.name}`} onAction={() => switchCamera(cam)} />
</ActionPanel>
}
/>
))}
</List>
);
}
Loading