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
24 changes: 24 additions & 0 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,
TogglePause,
SwitchMic {
mic_label: String,
},
SwitchCamera {
camera_id: DeviceOrModelID,
},
OpenEditor {
project_path: PathBuf,
},
Expand Down Expand Up @@ -147,6 +156,21 @@ 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::TogglePause => {
crate::recording::toggle_pause_recording(app.clone(), app.state()).await
}
DeepLinkAction::SwitchMic { mic_label } => {
crate::set_mic_input(app.state(), Some(mic_label)).await
}
DeepLinkAction::SwitchCamera { camera_id } => {
crate::set_camera_input(app.clone(), app.state(), Some(camera_id), None).await
}
Comment on lines +159 to +173
Copy link
Contributor

Choose a reason for hiding this comment

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

New Rust enum variants are unreachable through URL parsing

The execute implementations for PauseRecording, ResumeRecording, TogglePause, SwitchMic, and SwitchCamera are well-written, but they can never be reached from the existing TryFrom<&Url> parser (lines 88–114). That parser only returns Ok(…) for file:// URLs on macOS; for all other schemes (including cap-desktop://) both match arms return Err(…), so every non-file deep link is unconditionally dropped before execute is ever called.

The actual deep-link handling for these actions is implemented exclusively in the TypeScript layer (recording-deeplinks.ts), which calls the Tauri commands directly. The Rust additions are therefore dead code in the current form.

Either fix the TryFrom<&Url> implementation to parse cap-desktop://record/pause etc. and route them to these variants, or remove the Rust additions and keep the logic solely in the TypeScript handler to avoid confusion.

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

Comment:
**New Rust enum variants are unreachable through URL parsing**

The `execute` implementations for `PauseRecording`, `ResumeRecording`, `TogglePause`, `SwitchMic`, and `SwitchCamera` are well-written, but they can never be reached from the existing `TryFrom<&Url>` parser (lines 88–114). That parser only returns `Ok(…)` for `file://` URLs on macOS; for all other schemes (including `cap-desktop://`) both match arms return `Err(…)`, so every non-file deep link is unconditionally dropped before `execute` is ever called.

The actual deep-link handling for these actions is implemented exclusively in the TypeScript layer (`recording-deeplinks.ts`), which calls the Tauri commands directly. The Rust additions are therefore dead code in the current form.

Either fix the `TryFrom<&Url>` implementation to parse `cap-desktop://record/pause` etc. and route them to these variants, or remove the Rust additions and keep the logic solely in the TypeScript handler to avoid confusion.

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
5 changes: 5 additions & 0 deletions apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import "./styles/theme.css";
import { CapErrorBoundary } from "./components/CapErrorBoundary";
import { generalSettingsStore } from "./store";
import { initAnonymousUser } from "./utils/analytics";
import { initRecordingControlDeepLinks } from "./utils/recording-deeplinks";
import { type AppTheme, commands } from "./utils/tauri";
import titlebar from "./utils/titlebar-state";

Expand Down Expand Up @@ -102,6 +103,10 @@ function Inner() {

onMount(() => {
initAnonymousUser();
// Initialize deep link listener for recording controls
Copy link

Choose a reason for hiding this comment

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

Since you already export disposeRecordingControlDeepLinks(), it’d be nice to wire it up with onCleanup so we don’t keep a stale listener around during reload/unmount.

initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
Comment on lines 105 to +108
Copy link

Choose a reason for hiding this comment

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

Small thing: this repo avoids code comments, so I'd drop the inline // ... here.

Suggested change
initAnonymousUser();
// Initialize deep link listener for recording controls
initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
initRecordingControlDeepLinks().catch((err) =>
console.error("[App] Failed to init deep links:", err),
);

);
});

return (
Expand Down
216 changes: 216 additions & 0 deletions apps/desktop/src/utils/recording-deeplinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { commands } from "./tauri";
import type { ScreenCaptureTarget, RecordingMode } from "./tauri";

type DeepLinkCommand =
| { action: "record"; subaction: "start"; params: StartParams }
| { action: "record"; subaction: "stop"; params: Record<string, string> }
| { action: "record"; subaction: "pause"; params: Record<string, string> }
| { action: "record"; subaction: "resume"; params: Record<string, string> }
| { action: "record"; subaction: "toggle"; params: Record<string, string> }
| { action: "devices"; subaction: "mic"; params: { name: string } }
| { action: "devices"; subaction: "camera"; params: { id: string } };

interface StartParams {
target?: string;
displayId?: string;
windowId?: string;
bounds?: string;
mode?: RecordingMode;
}

let stopListening: (() => void) | undefined;

/**
* Initialize deep link listener for recording controls
* Routes:
* - cap-desktop://record/start?target=display&displayId=1
* - cap-desktop://record/start?target=window&windowId=abc
* - cap-desktop://record/start?target=area&displayId=1&bounds={"x":0,"y":0,"width":1920,"height":1080}
* - cap-desktop://record/stop
* - cap-desktop://record/pause
* - cap-desktop://record/resume
* - cap-desktop://record/toggle
* - cap-desktop://devices/mic?name=Built-in Microphone
* - cap-desktop://devices/camera?id=faceTimeHD
*/
export async function initRecordingControlDeepLinks() {
Comment on lines +24 to +37
Copy link

Choose a reason for hiding this comment

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

Repo convention here is no inline code comments; consider moving the route examples to a doc/README and keeping the code comment-free.

Suggested change
/**
* Initialize deep link listener for recording controls
* Routes:
* - cap-desktop://record/start?target=display&displayId=1
* - cap-desktop://record/start?target=window&windowId=abc
* - cap-desktop://record/start?target=area&displayId=1&bounds={"x":0,"y":0,"width":1920,"height":1080}
* - cap-desktop://record/stop
* - cap-desktop://record/pause
* - cap-desktop://record/resume
* - cap-desktop://record/toggle
* - cap-desktop://devices/mic?name=Built-in Microphone
* - cap-desktop://devices/camera?id=faceTimeHD
*/
export async function initRecordingControlDeepLinks() {
export async function initRecordingControlDeepLinks() {

if (stopListening) {
console.log("[DeepLink] Recording controls already initialized");
return;
}

console.log("[DeepLink] Initializing recording control deep links...");

stopListening = await onOpenUrl(async (urls) => {
for (const urlString of urls) {
try {
console.log(`[DeepLink] Received: ${urlString}`);
const url = new URL(urlString);
const command = parseDeepLinkUrl(url);

if (command) {
await executeDeepLinkCommand(command);
} else {
console.warn(`[DeepLink] Unknown command: ${url.pathname}`);
}
} catch (error) {
console.error(`[DeepLink] Error processing ${urlString}:`, error);
}
}
});
Comment on lines +45 to +61
Copy link
Contributor

Choose a reason for hiding this comment

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

Deep link listener registered in every webview window

App.tsx/Inner is the root component for all Cap windows (main window, camera overlay, editor, etc.), each of which runs in its own independent JavaScript runtime. Because initRecordingControlDeepLinks is called unconditionally in onMount, every open window will register its own onOpenUrl handler via the Tauri deep-link plugin.

When a deep link such as cap-desktop://record/stop arrives, the plugin emits the event to all registered listeners simultaneously. That means every currently-open window will independently call commands.stopRecording(), commands.pauseRecording(), etc. The first call will succeed; subsequent calls from other windows will fail (no recording in progress) and produce error-level console noise at minimum, with unpredictable side effects depending on how the backend handles concurrent calls.

Consider restricting initialization to the main application window only, for example by checking the current window label before calling initRecordingControlDeepLinks:

import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

onMount(() => {
  initAnonymousUser();
  const win = getCurrentWebviewWindow();
  if (win.label === "main") {
    initRecordingControlDeepLinks().catch((err) =>
      console.error("[App] Failed to init deep links:", err),
    );
  }
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 45-61

Comment:
**Deep link listener registered in every webview window**

`App.tsx`/`Inner` is the root component for all Cap windows (main window, camera overlay, editor, etc.), each of which runs in its own independent JavaScript runtime. Because `initRecordingControlDeepLinks` is called unconditionally in `onMount`, every open window will register its own `onOpenUrl` handler via the Tauri deep-link plugin.

When a deep link such as `cap-desktop://record/stop` arrives, the plugin emits the event to all registered listeners simultaneously. That means every currently-open window will independently call `commands.stopRecording()`, `commands.pauseRecording()`, etc. The first call will succeed; subsequent calls from other windows will fail (no recording in progress) and produce error-level console noise at minimum, with unpredictable side effects depending on how the backend handles concurrent calls.

Consider restricting initialization to the main application window only, for example by checking the current window label before calling `initRecordingControlDeepLinks`:

```ts
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";

onMount(() => {
  initAnonymousUser();
  const win = getCurrentWebviewWindow();
  if (win.label === "main") {
    initRecordingControlDeepLinks().catch((err) =>
      console.error("[App] Failed to init deep links:", err),
    );
  }
});
```

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


console.log("[DeepLink] Recording control deep links initialized");
}

export async function disposeRecordingControlDeepLinks() {
if (stopListening) {
stopListening();
stopListening = undefined;
console.log("[DeepLink] Recording control deep links disposed");
}
}

function parseDeepLinkUrl(url: URL): DeepLinkCommand | null {
const pathParts = url.pathname.split("/").filter(Boolean);
const params = Object.fromEntries(url.searchParams);

if (pathParts[0] !== "record" && pathParts[0] !== "devices") {
return null;
}

const [action, subaction] = pathParts;

Comment on lines +75 to +83
Copy link

Choose a reason for hiding this comment

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

cap-desktop://record/stop parses as host=record, pathname=/stop. With the current url.pathname splitting, pathParts[0] becomes stop, so this will never match the documented routes.

Suggested change
const pathParts = url.pathname.split("/").filter(Boolean);
const params = Object.fromEntries(url.searchParams);
if (pathParts[0] !== "record" && pathParts[0] !== "devices") {
return null;
}
const [action, subaction] = pathParts;
const action = url.hostname;
const [subaction] = url.pathname.split("/").filter(Boolean);
const params = Object.fromEntries(url.searchParams);
if (action !== "record" && action !== "devices") {
return null;
}

switch (action) {
case "record":
if (subaction === "start") {
return {
action: "record",
subaction: "start",
params: {
target: params.target,
displayId: params.displayId,
windowId: params.windowId,
bounds: params.bounds,
mode: (params.mode as RecordingMode) || "studio",
Copy link

Choose a reason for hiding this comment

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

params.mode as RecordingMode will allow any string through at runtime. Might be worth validating and defaulting cleanly.

Suggested change
mode: (params.mode as RecordingMode) || "studio",
mode:
params.mode === "studio" ||
params.mode === "instant" ||
params.mode === "screenshot"
? params.mode
: "studio",

},
};
}
if (["stop", "pause", "resume", "toggle"].includes(subaction)) {
return {
action: "record",
subaction: subaction as "stop" | "pause" | "resume" | "toggle",
params,
};
}
break;

case "devices":
if (subaction === "mic" && params.name) {
return {
action: "devices",
subaction: "mic",
params: { name: params.name },
};
}
if (subaction === "camera" && params.id) {
return {
action: "devices",
subaction: "camera",
params: { id: params.id },
};
}
break;
}

return null;
}

async function executeDeepLinkCommand(command: DeepLinkCommand) {
console.log(`[DeepLink] Executing: ${command.action}/${command.subaction}`);

switch (command.action) {
case "record":
await executeRecordingCommand(command);
break;
case "devices":
await executeDeviceCommand(command);
break;
}
}

async function executeRecordingCommand(command: Extract<DeepLinkCommand, { action: "record" }>) {
switch (command.subaction) {
case "start": {
const { target, displayId, windowId, bounds, mode } = command.params;

let captureTarget: ScreenCaptureTarget | null = null;

if (target === "display" && displayId) {
captureTarget = { variant: "display", id: displayId };
} else if (target === "window" && windowId) {
captureTarget = { variant: "window", id: windowId };
} else if (target === "area" && displayId && bounds) {
try {
const boundsObj = JSON.parse(bounds);
captureTarget = {
variant: "area",
screen: displayId,
bounds: boundsObj,
};
} catch (e) {
console.error("[DeepLink] Invalid bounds JSON:", e);
return;
Comment on lines +153 to +163
Copy link
Contributor

Choose a reason for hiding this comment

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

No structural validation on parsed bounds object

JSON.parse(bounds) produces an any value that is passed directly as captureTarget.bounds with no validation that it actually contains the expected { x, y, width, height } fields (or that those fields are numbers). A malformed or adversarially crafted deep link like cap-desktop://record/start?target=area&displayId=1&bounds={"x":"evil"} will pass through to the backend without any client-side rejection.

Add a basic shape guard before constructing the captureTarget:

        try {
          const boundsObj = JSON.parse(bounds);
          if (
            typeof boundsObj?.x !== "number" ||
            typeof boundsObj?.y !== "number" ||
            typeof boundsObj?.width !== "number" ||
            typeof boundsObj?.height !== "number"
          ) {
            console.error("[DeepLink] bounds object has unexpected shape:", boundsObj);
            return;
          }
          captureTarget = {
            variant: "area",
            screen: displayId,
            bounds: boundsObj,
          };
        } catch (e) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 153-163

Comment:
**No structural validation on parsed `bounds` object**

`JSON.parse(bounds)` produces an `any` value that is passed directly as `captureTarget.bounds` with no validation that it actually contains the expected `{ x, y, width, height }` fields (or that those fields are numbers). A malformed or adversarially crafted deep link like `cap-desktop://record/start?target=area&displayId=1&bounds={"x":"evil"}` will pass through to the backend without any client-side rejection.

Add a basic shape guard before constructing the `captureTarget`:

```ts
        try {
          const boundsObj = JSON.parse(bounds);
          if (
            typeof boundsObj?.x !== "number" ||
            typeof boundsObj?.y !== "number" ||
            typeof boundsObj?.width !== "number" ||
            typeof boundsObj?.height !== "number"
          ) {
            console.error("[DeepLink] bounds object has unexpected shape:", boundsObj);
            return;
          }
          captureTarget = {
            variant: "area",
            screen: displayId,
            bounds: boundsObj,
          };
        } catch (e) {
```

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

}
} else if (target === "cameraOnly") {
captureTarget = { variant: "cameraOnly" };
}

if (captureTarget) {
const result = await commands.startRecording({
capture_target: captureTarget,
capture_system_audio: true,
mode: mode || "studio",
});
Comment on lines +170 to +174
Copy link
Contributor

Choose a reason for hiding this comment

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

capture_system_audio hardcoded to true

capture_system_audio: true is unconditionally forced on for all deep-link-initiated recordings, regardless of the user's configured preference in settings. A user who has intentionally disabled system audio capture will find that preference silently overridden whenever recording is started via a deep link (e.g. through the Raycast extension).

The value should be read from the application settings store (the same source that the normal recording flow consults) rather than being hardcoded.

Suggested change
const result = await commands.startRecording({
capture_target: captureTarget,
capture_system_audio: true,
mode: mode || "studio",
});
const result = await commands.startRecording({
capture_target: captureTarget,
capture_system_audio: true, // TODO: read from user settings
mode: mode || "studio",
});

For example, the existing recording flow reads this preference via the settings store — the same approach should be used here so the deep-link path stays in sync with user configuration.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/recording-deeplinks.ts
Line: 170-174

Comment:
**`capture_system_audio` hardcoded to `true`**

`capture_system_audio: true` is unconditionally forced on for all deep-link-initiated recordings, regardless of the user's configured preference in settings. A user who has intentionally disabled system audio capture will find that preference silently overridden whenever recording is started via a deep link (e.g. through the Raycast extension).

The value should be read from the application settings store (the same source that the normal recording flow consults) rather than being hardcoded.

```suggestion
        const result = await commands.startRecording({
          capture_target: captureTarget,
          capture_system_audio: true, // TODO: read from user settings
          mode: mode || "studio",
        });
```

For example, the existing recording flow reads this preference via the settings store — the same approach should be used here so the deep-link path stays in sync with user configuration.

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

console.log("[DeepLink] Start recording result:", result);
} else {
console.warn("[DeepLink] No valid target specified for start recording");
}
break;
}

case "stop":
await commands.stopRecording();
console.log("[DeepLink] Recording stopped");
break;

case "pause":
await commands.pauseRecording();
console.log("[DeepLink] Recording paused");
break;

case "resume":
await commands.resumeRecording();
console.log("[DeepLink] Recording resumed");
break;

case "toggle":
await commands.togglePauseRecording();
console.log("[DeepLink] Recording pause/resume toggled");
break;
}
}

async function executeDeviceCommand(command: Extract<DeepLinkCommand, { action: "devices" }>) {
switch (command.subaction) {
case "mic":
await commands.setMicInput(command.params.name);
console.log(`[DeepLink] Microphone set to: ${command.params.name}`);
break;

case "camera":
await commands.setCameraInput({ DeviceID: command.params.id }, false);
console.log(`[DeepLink] Camera set to: ${command.params.id}`);
break;
}
}