From cdfe9529b877b4e1cb4fe69f16365b7f386b6722 Mon Sep 17 00:00:00 2001 From: Den A Ev Date: Tue, 31 Mar 2026 17:43:54 +0400 Subject: [PATCH 1/2] feat: stabilize URI remote control via direct actor management --- Cargo.lock | 2 +- apps/desktop/package.json | 1 - .../desktop/src-tauri/src/deeplink_actions.rs | 190 +++++++++++------- apps/desktop/src-tauri/src/lib.rs | 73 ++++--- apps/desktop/src/utils/auth.ts | 12 +- 5 files changed, 167 insertions(+), 111 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6775f75e7b..e33d77a1fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "cap-desktop" -version = "0.4.81" +version = "0.4.82" dependencies = [ "aho-corasick", "anyhow", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 49eb8f37a8..7b3f3fee63 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -42,7 +42,6 @@ "@tanstack/solid-query": "^5.51.21", "@tauri-apps/api": "2.8.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.0", - "@tauri-apps/plugin-deep-link": "^2.4.1", "@tauri-apps/plugin-dialog": "^2.4.0", "@tauri-apps/plugin-fs": "^2.4.1", "@tauri-apps/plugin-http": "^2.5.1", diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..07f4749155 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -25,7 +25,11 @@ pub enum DeepLinkAction { capture_system_audio: bool, mode: RecordingMode, }, + PauseRecording, + ResumeRecording, + TogglePauseRecording, StopRecording, + TakeScreenshot, OpenEditor { project_path: PathBuf, }, @@ -34,44 +38,8 @@ pub enum DeepLinkAction { }, } -pub fn handle(app_handle: &AppHandle, urls: Vec) { - trace!("Handling deep actions for: {:?}", &urls); - - let actions: Vec<_> = urls - .into_iter() - .filter(|url| !url.as_str().is_empty()) - .filter_map(|url| { - DeepLinkAction::try_from(&url) - .map_err(|e| match e { - ActionParseFromUrlError::ParseFailed(msg) => { - eprintln!("Failed to parse deep link \"{}\": {}", &url, msg) - } - ActionParseFromUrlError::Invalid => { - eprintln!("Invalid deep link format \"{}\"", &url) - } - // Likely login action, not handled here. - ActionParseFromUrlError::NotAction => {} - }) - .ok() - }) - .collect(); - - if actions.is_empty() { - return; - } - - let app_handle = app_handle.clone(); - tauri::async_runtime::spawn(async move { - for action in actions { - if let Err(e) = action.execute(&app_handle).await { - eprintln!("Failed to handle deep link action: {e}"); - } - } - }); -} - pub enum ActionParseFromUrlError { - ParseFailed(String), + ParseFailed, Invalid, NotAction, } @@ -80,35 +48,74 @@ impl TryFrom<&Url> for DeepLinkAction { type Error = ActionParseFromUrlError; fn try_from(url: &Url) -> Result { - #[cfg(target_os = "macos")] - if url.scheme() == "file" { - return url - .to_file_path() - .map(|project_path| Self::OpenEditor { project_path }) - .map_err(|_| ActionParseFromUrlError::Invalid); + if url.scheme() != "cap-desktop" { + return Err(ActionParseFromUrlError::NotAction); } match url.domain() { - Some(v) if v != "action" => Err(ActionParseFromUrlError::NotAction), - _ => Err(ActionParseFromUrlError::Invalid), - }?; - - let params = url - .query_pairs() - .collect::>(); - 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) + Some("pause") => Ok(Self::PauseRecording), + Some("resume") => Ok(Self::ResumeRecording), + Some("toggle-pause") => Ok(Self::TogglePauseRecording), + Some("stop") => Ok(Self::StopRecording), + Some("screenshot") => Ok(Self::TakeScreenshot), + _ => { + if url.domain() == Some("action") { + let params = url.query_pairs().collect::>(); + let json_value = params.get("value").ok_or(ActionParseFromUrlError::Invalid)?; + let action: Self = serde_json::from_str(json_value) + .map_err(|_| ActionParseFromUrlError::ParseFailed)?; + Ok(action) + } else { + Err(ActionParseFromUrlError::Invalid) + } + } + } } } impl DeepLinkAction { pub async fn execute(self, app: &AppHandle) -> Result<(), String> { + trace!("Executing deep link action: {:?}", self); + match self { - DeepLinkAction::StartRecording { + Self::PauseRecording => { + let state = app.state::>(); + crate::recording::pause_recording(app.clone(), state.clone()).await?; + let _ = ShowCapWindow::Main { init_target_mode: None }.show(app).await; + Ok(()) + } + Self::ResumeRecording => { + let state = app.state::>(); + crate::recording::resume_recording(app.clone(), state.clone()).await?; + let _ = ShowCapWindow::Main { init_target_mode: None }.show(app).await; + Ok(()) + } + Self::TogglePauseRecording => { + let state = app.state::>(); + crate::recording::toggle_pause_recording(app.clone(), state.clone()).await?; + let _ = ShowCapWindow::Main { init_target_mode: None }.show(app).await; + Ok(()) + } + Self::StopRecording => { + let state = app.state::>(); + crate::recording::stop_recording(app.clone(), state.clone()).await?; + let _ = ShowCapWindow::Main { init_target_mode: None }.show(app).await; + Ok(()) + } + Self::TakeScreenshot => { + use scap_targets::Display; + let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); + let target = ScreenCaptureTarget::Display { id: display.id() }; + + match crate::recording::take_screenshot(app.clone(), target).await { + Ok(path) => { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(app).await; + Ok(()) + } + Err(e) => Err(format!("Failed to take screenshot: {e}")), + } + } + Self::StartRecording { capture_mode, camera, mic_label, @@ -117,42 +124,77 @@ impl DeepLinkAction { } => { let state = app.state::>(); - 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() + let capture_target = match capture_mode { + CaptureMode::Screen(name) => cap_recording::sources::screen_capture::list_displays() .into_iter() - .find(|(s, _)| s.name == name) + .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() + CaptureMode::Window(name) => cap_recording::sources::screen_capture::list_windows() .into_iter() - .find(|(w, _)| w.name == name) + .find(|(w, _)| w.name == *name) .map(|(w, _)| ScreenCaptureTarget::Window { id: w.id }) .ok_or(format!("No window with name \"{}\"", &name))?, }; let inputs = StartRecordingInputs { - mode, capture_target, capture_system_audio, + mode, organization_id: None, }; - crate::recording::start_recording(app.clone(), state, inputs) + if let Some(camera_id) = camera { + crate::set_camera_input(app.clone(), state.clone(), Some(camera_id.clone()), None) + .await + .map_err(|e| e.to_string())?; + } + + if let Some(mic) = mic_label { + crate::set_mic_input(state.clone(), Some(mic.clone())) + .await + .map_err(|e| e.to_string())?; + } + + crate::recording::start_recording(app.clone(), state.clone(), inputs) .await - .map(|_| ()) - } - DeepLinkAction::StopRecording => { - crate::recording::stop_recording(app.clone(), app.state()).await + .map_err(|e| e.to_string())?; + Ok(()) } - DeepLinkAction::OpenEditor { project_path } => { + Self::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) + .map_err(|e| e.to_string())?; + Ok(()) } - DeepLinkAction::OpenSettings { page } => { - crate::show_window(app.clone(), ShowCapWindow::Settings { page }).await + Self::OpenSettings { page } => { + crate::show_window(app.clone(), ShowCapWindow::Settings { page: page.clone() }) + .await + .map_err(|e| e.to_string())?; + Ok(()) } } } } + +pub fn handle(app_handle: &AppHandle, urls: Vec) { + trace!("Handling deep actions for: {:?}", &urls); + + let actions: Vec<_> = urls + .into_iter() + .filter(|url| !url.as_str().is_empty()) + .filter_map(|url| DeepLinkAction::try_from(&url).ok()) + .collect(); + + if actions.is_empty() { + return; + } + + let app_handle = app_handle.clone(); + tauri::async_runtime::spawn(async move { + for action in actions { + if let Err(e) = action.execute(&app_handle).await { + trace!("Failed to handle deep link action: {}", e); + } + } + }); +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 8b80b97055..41c8365da5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -87,8 +87,9 @@ use std::{ }, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tauri::{AppHandle, Manager, State, Window, WindowEvent, ipc::Channel}; -use tauri_plugin_deep_link::DeepLinkExt; +use tauri::{ + ipc::Channel, AppHandle, Emitter, Manager, State, Url, Window, WindowEvent, +}; use tauri_plugin_dialog::DialogExt; use tauri_plugin_global_shortcut::GlobalShortcutExt; use tauri_plugin_notification::{NotificationExt, PermissionState}; @@ -3295,29 +3296,47 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { tauri::async_runtime::set(tokio::runtime::Handle::current()); #[allow(unused_mut)] - let mut builder = - tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { - trace!("Single instance invoked with args {args:?}"); - - // This is also handled as a deeplink on some platforms (eg macOS), see deeplink_actions - let Some(cap_file) = args - .iter() - .find(|arg| arg.ends_with(".cap")) - .map(PathBuf::from) - else { - let app = app.clone(); - tokio::spawn(async move { - ShowCapWindow::Main { - init_target_mode: None, - } - .show(&app) - .await - }); - return; - }; + let mut builder = tauri::Builder::default().plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { + info!("Single instance invoked with args {:?}", &args); + let app_handle = app.clone(); + + // 1. Handle Remote Control URLs + if let Some(arg) = args.get(1) { + if arg.starts_with("cap-desktop://") { + if let Ok(url) = Url::parse(arg) { + crate::deeplink_actions::handle(app, vec![url]); + app.emit("tauri://deep-link", vec![arg.clone()]).ok(); + + let ah = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = crate::windows::ShowCapWindow::Main { + init_target_mode: None, + } + .show(&ah) + .await; + }); + } + } + } - let _ = open_project_from_path(&cap_file, app.clone()); - })); + // 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 ah = app_handle.clone(); + tauri::async_runtime::spawn(async move { + let _ = crate::windows::ShowCapWindow::Main { + init_target_mode: None, + } + .show(&ah) + .await; + }); + })); #[cfg(target_os = "macos")] { @@ -3335,7 +3354,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(flags::plugin::init()) - .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_opener::init()) @@ -3597,11 +3615,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { prewarmer.request(event.force).await; }); - let app_handle = app.clone(); - app.deep_link().on_open_url(move |event| { - deeplink_actions::handle(&app_handle, event.urls()); - }); - Ok(()) }) .on_window_event(|window, event| { diff --git a/apps/desktop/src/utils/auth.ts b/apps/desktop/src/utils/auth.ts index 6a615611d4..5515c80e44 100644 --- a/apps/desktop/src/utils/auth.ts +++ b/apps/desktop/src/utils/auth.ts @@ -2,7 +2,6 @@ import { createMutation } from "@tanstack/solid-query"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { onOpenUrl } from "@tauri-apps/plugin-deep-link"; import * as shell from "@tauri-apps/plugin-shell"; import { z } from "zod"; import callbackTemplate from "~/components/callback.template"; @@ -176,12 +175,14 @@ async function startDeepLinkSession(signal: AbortSignal) { resolvePromise(value); }; - stopListening = await onOpenUrl(async (urls) => { - for (const urlString of urls) { + const unlisten = await listen("tauri://deep-link", (event) => { + for (const urlString of event.payload) { if (signal.aborted) return; - settle(parseAuthParams(new URL(urlString))); + const params = parseAuthParams(new URL(urlString)); + if (params) settle(params); } }); + stopListening = unlisten; const dispose = async () => { stopListening?.(); @@ -195,7 +196,7 @@ async function startDeepLinkSession(signal: AbortSignal) { } function parseAuthParams(url: URL) { - return paramsValidator.parse( + const parsed = paramsValidator.safeParse( [...url.searchParams].reduce( (acc, [key, value]) => { acc[key] = value; @@ -204,6 +205,7 @@ function parseAuthParams(url: URL) { {} as Record, ), ); + return parsed.success ? parsed.data : null; } async function processAuthData(data: AuthParams) { From 82661e328858e03f0ee5479df26d8042f37ee489 Mon Sep 17 00:00:00 2001 From: Den A Ev Date: Wed, 1 Apr 2026 10:36:02 +0400 Subject: [PATCH 2/2] chore: fix missing optimize_filesize field in tests, examples, and CLI --- apps/cli/src/main.rs | 1 + crates/cap-test/src/suites/performance.rs | 1 + crates/export/examples/export-benchmark-runner.rs | 1 + crates/export/examples/export_startup_time.rs | 1 + crates/export/tests/export_benchmark.rs | 1 + crates/export/tests/long_video_export.rs | 1 + 6 files changed, 6 insertions(+) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index f02a7c31ac..79d9eedfea 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -169,6 +169,7 @@ impl Export { compression: cap_export::mp4::ExportCompression::Maximum, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, } .export(exporter_base, move |_f| { // print!("\rrendered frame {f}"); diff --git a/crates/cap-test/src/suites/performance.rs b/crates/cap-test/src/suites/performance.rs index b8ea76e94f..05aa8bc58f 100644 --- a/crates/cap-test/src/suites/performance.rs +++ b/crates/cap-test/src/suites/performance.rs @@ -546,6 +546,7 @@ async fn benchmark_export(recording_path: &Path) -> Result { compression: ExportCompression::Social, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, }; let total_frames = exporter_base.total_frames(settings.fps); diff --git a/crates/export/examples/export-benchmark-runner.rs b/crates/export/examples/export-benchmark-runner.rs index a55d8006e0..ce47f74209 100644 --- a/crates/export/examples/export-benchmark-runner.rs +++ b/crates/export/examples/export-benchmark-runner.rs @@ -428,6 +428,7 @@ async fn run_mp4_export( compression, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, }; let total_frames = exporter_base.total_frames(fps); diff --git a/crates/export/examples/export_startup_time.rs b/crates/export/examples/export_startup_time.rs index a7c6b044ac..b7e958515e 100644 --- a/crates/export/examples/export_startup_time.rs +++ b/crates/export/examples/export_startup_time.rs @@ -18,6 +18,7 @@ async fn main() -> Result<(), String> { compression: ExportCompression::Maximum, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, }; let temp_out = tempfile::Builder::new() diff --git a/crates/export/tests/export_benchmark.rs b/crates/export/tests/export_benchmark.rs index 99540b6cd6..ecdc8227ea 100644 --- a/crates/export/tests/export_benchmark.rs +++ b/crates/export/tests/export_benchmark.rs @@ -27,6 +27,7 @@ async fn run_export(project_path: PathBuf) -> Result<(PathBuf, Duration, u32), S compression: ExportCompression::Maximum, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, }; let start = Instant::now(); diff --git a/crates/export/tests/long_video_export.rs b/crates/export/tests/long_video_export.rs index 136bfe7b84..8c4631c7bd 100644 --- a/crates/export/tests/long_video_export.rs +++ b/crates/export/tests/long_video_export.rs @@ -133,6 +133,7 @@ async fn run_export(project_path: PathBuf, fps: u32) -> Result<(PathBuf, Duratio compression: ExportCompression::Potato, custom_bpp: None, force_ffmpeg_decoder: false, + optimize_filesize: false, }; let total_frames = exporter_base.total_frames(fps);