Skip to content

Implement Deep-Link Remote Control via Actor Dispatch (#1540)#1699

Open
denaev-dev wants to merge 2 commits intoCapSoftware:mainfrom
denaev-dev:feature/remote-control
Open

Implement Deep-Link Remote Control via Actor Dispatch (#1540)#1699
denaev-dev wants to merge 2 commits intoCapSoftware:mainfrom
denaev-dev:feature/remote-control

Conversation

@denaev-dev
Copy link
Copy Markdown

@denaev-dev denaev-dev commented Mar 31, 2026

Implement URI remote control via direct actor dispatch for desktop orchestration.

This improvement refactors the URI handling logic to communicate directly with the Rust-side actor system, ensuring reliable command execution and window restoration from the background.

Key Changes

  • Actor Integration: Direct message passing from the URI handler to the Tauri command dispatcher.
  • Process Orchestration: Hardened logic for restoring application state from the system tray.
  • Safety Guardrails: Validation of incoming URI commands for secure remote execution.
  • Refactoring: Removal of legacy recording-based command triggers in favor of direct dispatch.

Comment on lines +80 to +93
Self::TogglePauseRecording => {
app.emit("recording-action", "toggle-pause")
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
}
Self::StopRecording => {
app.emit("recording-action", "stop")
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
}
Self::TakeScreenshot => {
app.emit("recording-action", "screenshot")
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
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.

P1 recording-action event has no listener — core feature is non-functional

TogglePauseRecording, StopRecording, and TakeScreenshot all emit a recording-action event, but a codebase-wide search finds zero listeners for this event anywhere in the frontend or backend. The actual recording functions (toggle_pause_recording, stop_recording, take_screenshot) are Tauri commands and are called directly in hotkeys.rs and tray.rs — they are never wired to consume this event.

As a result, a deep-link like cap-desktop://toggle-pause will silently do nothing: the event is emitted and immediately dropped.

The implementation should directly call the recording functions (as hotkeys do) rather than relying on an event no-one subscribes to, e.g.:

Self::StopRecording => {
    crate::recording::stop_recording(app.clone(), app.state())
        .await
        .map_err(|e| anyhow::anyhow!(e))?;
    Ok(())
}

The same pattern applies to TogglePauseRecordingtoggle_pause_recording and TakeScreenshottake_screenshot.

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

Comment:
**`recording-action` event has no listener — core feature is non-functional**

`TogglePauseRecording`, `StopRecording`, and `TakeScreenshot` all emit a `recording-action` event, but a codebase-wide search finds **zero listeners** for this event anywhere in the frontend or backend. The actual recording functions (`toggle_pause_recording`, `stop_recording`, `take_screenshot`) are Tauri commands and are called directly in `hotkeys.rs` and `tray.rs` — they are never wired to consume this event.

As a result, a deep-link like `cap-desktop://toggle-pause` will silently do nothing: the event is emitted and immediately dropped.

The implementation should directly call the recording functions (as hotkeys do) rather than relying on an event no-one subscribes to, e.g.:
```rust
Self::StopRecording => {
    crate::recording::stop_recording(app.clone(), app.state())
        .await
        .map_err(|e| anyhow::anyhow!(e))?;
    Ok(())
}
```
The same pattern applies to `TogglePauseRecording``toggle_pause_recording` and `TakeScreenshot``take_screenshot`.

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

Comment on lines +178 to +183
stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
for (const urlString of event.payload) {
if (signal.aborted) return;
settle(parseAuthParams(new URL(urlString)));
}
});
})) as unknown as () => void;
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.

P1 Unhandled ZodError for non-auth deep-link URLs

parseAuthParams calls paramsValidator.parse(...), which throws a ZodError (not returns null) when the URL doesn't match the expected schema. Now that all cap-desktop:// URLs — including recording-control ones like cap-desktop://toggle-pause — are emitted on tauri://deep-link, any such URL received while the sign-in listener is active will throw an uncaught exception inside the event callback. This will surface as an unhandled error in the console and could leave the sign-in complete promise hanging indefinitely.

Use safeParse to avoid the throw:

Suggested change
stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
for (const urlString of event.payload) {
if (signal.aborted) return;
settle(parseAuthParams(new URL(urlString)));
}
});
})) as unknown as () => void;
stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
for (const urlString of event.payload) {
if (signal.aborted) return;
const result = paramsValidator.safeParse(
[...new URL(urlString).searchParams].reduce(
(acc, [key, value]) => { acc[key] = value; return acc; },
{} as Record<string, string>
)
);
if (result.success) settle(result.data);
}
})) as unknown as () => void;
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/auth.ts
Line: 178-183

Comment:
**Unhandled `ZodError` for non-auth deep-link URLs**

`parseAuthParams` calls `paramsValidator.parse(...)`, which **throws a `ZodError`** (not returns null) when the URL doesn't match the expected schema. Now that all `cap-desktop://` URLs — including recording-control ones like `cap-desktop://toggle-pause` — are emitted on `tauri://deep-link`, any such URL received while the sign-in listener is active will throw an uncaught exception inside the event callback. This will surface as an unhandled error in the console and could leave the sign-in `complete` promise hanging indefinitely.

Use `safeParse` to avoid the throw:
```suggestion
	stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
		for (const urlString of event.payload) {
			if (signal.aborted) return;
			const result = paramsValidator.safeParse(
				[...new URL(urlString).searchParams].reduce(
					(acc, [key, value]) => { acc[key] = value; return acc; },
					{} as Record<string, string>
				)
			);
			if (result.success) settle(result.data);
		}
	})) as unknown as () => void;
```

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

Comment on lines +3319 to +3328
// 2. Handle .cap files
let cap_file = args.iter()
.find(|arg| arg.ends_with(".cap"))
.map(PathBuf::from);

if let Some(file) = cap_file {
let _ = open_project_from_path(&file, app.clone());
}

let _ = app.get_webview_window("main").map(|w| w.set_focus());
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.

P1 Main window not shown when second instance is activated with no arguments

The previous implementation always brought up the main window when a second instance was launched with no .cap file:

// old code
ShowCapWindow::Main { init_target_mode: None }.show(&app).await;

The new code replaces this with:

let _ = app.get_webview_window("main").map(|w| w.set_focus());

get_webview_window("main") returns None if the window has been closed (which is normal in the app — the tray icon is the persistent entry point). In that case map is a no-op and the window is never shown, leaving the user with no visual feedback after activating the app.

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

Comment:
**Main window not shown when second instance is activated with no arguments**

The previous implementation always brought up the main window when a second instance was launched with no `.cap` file:
```rust
// old code
ShowCapWindow::Main { init_target_mode: None }.show(&app).await;
```
The new code replaces this with:
```rust
let _ = app.get_webview_window("main").map(|w| w.set_focus());
```
`get_webview_window("main")` returns `None` if the window has been closed (which is normal in the app — the tray icon is the persistent entry point). In that case `map` is a no-op and the window is never shown, leaving the user with no visual feedback after activating the app.

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

Comment on lines +178 to +183
stopListening = (await listen<string[]>("tauri://deep-link", (event) => {
for (const urlString of event.payload) {
if (signal.aborted) return;
settle(parseAuthParams(new URL(urlString)));
}
});
})) as unknown as () => void;
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 as unknown as () => void double-cast bypasses type safety

listen<string[]> returns Promise<UnlistenFn> where UnlistenFn = () => void. After await, the value should already be () => void. The as unknown as () => void double-cast suppresses TypeScript's type checker rather than fixing the underlying mismatch. The stopListening variable should be typed as UnlistenFn to align with the Tauri API's return type.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/utils/auth.ts
Line: 178-183

Comment:
**`as unknown as () => void` double-cast bypasses type safety**

`listen<string[]>` returns `Promise<UnlistenFn>` where `UnlistenFn = () => void`. After `await`, the value should already be `() => void`. The `as unknown as () => void` double-cast suppresses TypeScript's type checker rather than fixing the underlying mismatch. The `stopListening` variable should be typed as `UnlistenFn` to align with the Tauri API's return type.

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

Comment on lines +28 to +33
TogglePauseRecording,
TakeScreenshot,
SetCamera { id: String },
SetMicrophone { label: String },
ListCameras,
ListMicrophones,
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 New enum variants are unreachable and unimplemented

SetCamera, SetMicrophone, ListCameras, and ListMicrophones are added to DeepLinkAction but:

  1. The URL parser has no matching domain() arm for them, so they can never be constructed from a URL.
  2. The execute implementation routes them to a _ => catch-all that only logs a warning.

These variants are dead code as written. Either add the corresponding URL parsing and execution logic, or remove them until they are needed.

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

Comment:
**New enum variants are unreachable and unimplemented**

`SetCamera`, `SetMicrophone`, `ListCameras`, and `ListMicrophones` are added to `DeepLinkAction` but:
1. The URL parser has no matching `domain()` arm for them, so they can never be constructed from a URL.
2. The `execute` implementation routes them to a `_ =>` catch-all that only logs a warning.

These variants are dead code as written. Either add the corresponding URL parsing and execution logic, or remove them until they are needed.

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

@denaev-dev denaev-dev force-pushed the feature/remote-control branch from b73d2bc to e280c0e Compare March 31, 2026 13:57
@denaev-dev denaev-dev changed the title Implement Remote Control and Deep-Link Support (#1540) Implement Deep-Link Remote Control via Actor Dispatch (#1540) Mar 31, 2026
@denaev-dev denaev-dev force-pushed the feature/remote-control branch 2 times, most recently from d35aec1 to b194e16 Compare April 1, 2026 05:12
@denaev-dev denaev-dev force-pushed the feature/remote-control branch from b194e16 to cdfe952 Compare April 1, 2026 05:51
@denaev-dev
Copy link
Copy Markdown
Author

Work is ready for review. Could a maintainer please authorize the Vercel deployment check so we can verify the preview build?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant