From e67c648c9065d2ce034421f2ebd2398156f189af Mon Sep 17 00:00:00 2001 From: "Atlas (CapSoftware Bounty)" Date: Wed, 11 Mar 2026 12:03:19 +0800 Subject: [PATCH] feat: Add recording control deep links (pause/resume/toggle/device switch) - Extend DeepLinkAction enum with PauseRecording, ResumeRecording, TogglePause, SwitchMic, SwitchCamera - Implement execute handlers for new actions using existing recording commands - Add TypeScript deep link listener (recording-deeplinks.ts) for cap-desktop:// URLs - Initialize deep link handler in App.tsx on mount - Support URL formats: cap-desktop://record/pause, cap-desktop://devices/mic?name=xxx Part of #1540 bounty implementation - Day 1: Deeplinks Extension --- .../desktop/src-tauri/src/deeplink_actions.rs | 24 ++ apps/desktop/src/App.tsx | 5 + apps/desktop/src/utils/recording-deeplinks.ts | 216 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 apps/desktop/src/utils/recording-deeplinks.ts diff --git a/apps/desktop/src-tauri/src/deeplink_actions.rs b/apps/desktop/src-tauri/src/deeplink_actions.rs index a117028487..f92e85bb49 100644 --- a/apps/desktop/src-tauri/src/deeplink_actions.rs +++ b/apps/desktop/src-tauri/src/deeplink_actions.rs @@ -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, }, @@ -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 + } DeepLinkAction::OpenEditor { project_path } => { crate::open_project_from_path(Path::new(&project_path), app.clone()) } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 8e8eaf4327..473c4caedc 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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"; @@ -102,6 +103,10 @@ function Inner() { onMount(() => { initAnonymousUser(); + // Initialize deep link listener for recording controls + initRecordingControlDeepLinks().catch((err) => + console.error("[App] Failed to init deep links:", err), + ); }); return ( diff --git a/apps/desktop/src/utils/recording-deeplinks.ts b/apps/desktop/src/utils/recording-deeplinks.ts new file mode 100644 index 0000000000..6017f6eb21 --- /dev/null +++ b/apps/desktop/src/utils/recording-deeplinks.ts @@ -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 } + | { action: "record"; subaction: "pause"; params: Record } + | { action: "record"; subaction: "resume"; params: Record } + | { action: "record"; subaction: "toggle"; params: Record } + | { 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() { + 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); + } + } + }); + + 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; + + 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", + }, + }; + } + 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) { + 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; + } + } else if (target === "cameraOnly") { + captureTarget = { variant: "cameraOnly" }; + } + + if (captureTarget) { + const result = await commands.startRecording({ + capture_target: captureTarget, + capture_system_audio: true, + mode: mode || "studio", + }); + 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) { + 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; + } +}