From 2100f7fb7cdfefbc163b8a9f9e7f739d44d91193 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 01/28] feat(export): extract ExportSettings into settings module --- crates/export/src/lib.rs | 1 + crates/export/src/settings.rs | 41 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 crates/export/src/settings.rs diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 24c4ee57302..58d567229a3 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -2,6 +2,7 @@ pub mod gif; pub mod mov; pub mod mp4; pub mod preview; +pub mod settings; use cap_editor::SegmentMedia; use cap_project::{ diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs new file mode 100644 index 00000000000..755f2a6c153 --- /dev/null +++ b/crates/export/src/settings.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; + +use crate::gif::GifExportSettings; +use crate::mov::MovExportSettings; +use crate::mp4::Mp4ExportSettings; + +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Type)] +#[serde(tag = "format")] +pub enum ExportSettings { + #[serde(alias = "mp4")] + Mp4(Mp4ExportSettings), + #[serde(alias = "gif")] + Gif(GifExportSettings), + #[serde(alias = "mov")] + Mov(MovExportSettings), +} + +impl ExportSettings { + pub fn fps(&self) -> u32 { + match self { + Self::Mp4(s) => s.fps, + Self::Gif(s) => s.fps, + Self::Mov(s) => s.fps, + } + } + + pub fn force_ffmpeg_decoder(&self) -> bool { + match self { + Self::Mp4(s) => s.force_ffmpeg_decoder, + Self::Gif(_) | Self::Mov(_) => false, + } + } + + pub fn cursor_only(&self) -> bool { + match self { + Self::Mov(s) => s.cursor_only, + _ => false, + } + } +} From 3a67c782664d3dea227a6a07c4badd6be4cbb5fb Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 02/28] feat(automation): add cap-automation crate with rule engine --- crates/automation/Cargo.toml | 21 + crates/automation/src/lib.rs | 456 +++++++++++++++++++ crates/automation/src/tests.rs | 798 +++++++++++++++++++++++++++++++++ crates/automation/src/types.rs | 212 +++++++++ 4 files changed, 1487 insertions(+) create mode 100644 crates/automation/Cargo.toml create mode 100644 crates/automation/src/lib.rs create mode 100644 crates/automation/src/tests.rs create mode 100644 crates/automation/src/types.rs diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml new file mode 100644 index 00000000000..98b9500ce48 --- /dev/null +++ b/crates/automation/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cap-automation" +version = "0.1.0" +edition = "2024" + +[dependencies] +cap-project = { path = "../project" } + +serde = { workspace = true } +serde_json.workspace = true +specta.workspace = true +uuid = { version = "1.18.1", features = ["v4"] } +tracing.workspace = true +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs new file mode 100644 index 00000000000..763ec0b99f8 --- /dev/null +++ b/crates/automation/src/lib.rs @@ -0,0 +1,456 @@ +mod types; + +pub use types::*; + +use std::path::PathBuf; +use tracing::{info, warn}; + +#[derive(Debug)] +pub struct TriggerContext { + pub project_path: Option, + pub image_path: Option, + pub output_path: Option, + pub capture_target: Option, + pub recording_mode: Option, + pub duration_secs: Option, + pub share_link: Option, + pub share_id: Option, + pub organization_id: Option, + pub window_title: Option, +} + +impl TriggerContext { + pub fn new() -> Self { + Self { + project_path: None, + image_path: None, + output_path: None, + capture_target: None, + recording_mode: None, + duration_secs: None, + share_link: None, + share_id: None, + organization_id: None, + window_title: None, + } + } + + pub fn with_project_path(mut self, path: PathBuf) -> Self { + self.project_path = Some(path); + self + } + + pub fn with_image_path(mut self, path: PathBuf) -> Self { + self.image_path = Some(path); + self + } + + pub fn with_output_path(mut self, path: PathBuf) -> Self { + self.output_path = Some(path); + self + } + + pub fn with_capture_target(mut self, target: CaptureTargetKind) -> Self { + self.capture_target = Some(target); + self + } + + pub fn with_recording_mode(mut self, mode: AutomationRecordingMode) -> Self { + self.recording_mode = Some(mode); + self + } + + pub fn with_duration(mut self, secs: f64) -> Self { + self.duration_secs = Some(secs); + self + } + + pub fn with_share_link(mut self, link: String) -> Self { + self.share_link = Some(link); + self + } + + pub fn with_share_id(mut self, id: String) -> Self { + self.share_id = Some(id); + self + } + + pub fn with_organization_id(mut self, id: String) -> Self { + self.organization_id = Some(id); + self + } + + pub fn with_window_title(mut self, title: String) -> Self { + self.window_title = Some(title); + self + } +} + +impl Default for TriggerContext { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Capability { + CopyToClipboard, + SaveToLocation, + Export, + Upload, + RevealInFileManager, + OpenFile, + RunCommand, + Webhook, + RecognizeText, + Notify, + OpenEditor, + ApplyPreset, + DeleteLocalFiles, +} + +impl Action { + /// The host capability an action needs to run, or `None` for control-only actions that are + /// always a no-op in the engine (`SkipEditor`). Returning `None` keeps such actions from being + /// reported as "unsupported" on hosts that lack an editor (e.g. the CLI). + pub fn required_capability(&self) -> Option { + Some(match self { + Action::CopyToClipboard { .. } => Capability::CopyToClipboard, + Action::SaveToLocation { .. } => Capability::SaveToLocation, + Action::Export { .. } => Capability::Export, + Action::Upload { .. } => Capability::Upload, + Action::RevealInFileManager => Capability::RevealInFileManager, + Action::OpenFile => Capability::OpenFile, + Action::RunCommand { .. } => Capability::RunCommand, + Action::Webhook { .. } => Capability::Webhook, + Action::RecognizeTextToClipboard => Capability::RecognizeText, + Action::Notify { .. } => Capability::Notify, + Action::OpenEditor => Capability::OpenEditor, + Action::SkipEditor => return None, + Action::ApplyPreset { .. } => Capability::ApplyPreset, + Action::DeleteLocalFiles => Capability::DeleteLocalFiles, + }) + } +} + +pub fn evaluate( + store: &AutomationsStore, + trigger: &Trigger, + ctx: &TriggerContext, +) -> Vec<(String, Vec)> { + let mut matched = Vec::new(); + for rule in &store.rules { + if !rule.enabled { + continue; + } + if rule.trigger != *trigger { + continue; + } + if rule.conditions.is_empty() || check_conditions(&rule.conditions, rule.match_mode, ctx) { + matched.push((rule.id.clone(), rule.actions.clone())); + } + } + matched +} + +fn check_conditions(conditions: &[Condition], mode: MatchMode, ctx: &TriggerContext) -> bool { + match mode { + MatchMode::All => conditions.iter().all(|c| evaluate_condition(c, ctx)), + MatchMode::Any => conditions.iter().any(|c| evaluate_condition(c, ctx)), + } +} + +fn evaluate_condition(condition: &Condition, ctx: &TriggerContext) -> bool { + match condition { + Condition::CaptureTargetIs { target } => ctx.capture_target.as_ref() == Some(target), + Condition::RecordingModeIs { mode } => ctx.recording_mode.as_ref() == Some(mode), + Condition::DurationAtLeast { secs } => ctx.duration_secs.is_some_and(|d| d >= *secs), + Condition::DurationAtMost { secs } => ctx.duration_secs.is_some_and(|d| d <= *secs), + Condition::WindowTitleContains { pattern } => ctx + .window_title + .as_ref() + .is_some_and(|t| t.to_lowercase().contains(&pattern.to_lowercase())), + Condition::OrganizationIs { id } => ctx.organization_id.as_ref() == Some(id), + } +} + +pub trait AutomationHost: Send + Sync { + fn capabilities(&self) -> &[Capability]; + + fn copy_to_clipboard( + &self, + ctx: &TriggerContext, + source: &ClipboardSource, + ) -> impl std::future::Future> + Send; + + fn save_to_location( + &self, + ctx: &TriggerContext, + dir: &str, + filename_template: Option<&str>, + ) -> impl std::future::Future> + Send; + + fn export( + &self, + ctx: &TriggerContext, + profile: &ExportProfile, + destination: &ExportDestination, + ) -> impl std::future::Future> + Send; + + fn upload( + &self, + ctx: &TriggerContext, + organization_id: Option<&str>, + copy_link: bool, + open_in_browser: bool, + ) -> impl std::future::Future> + Send; + + fn reveal_in_file_manager( + &self, + ctx: &TriggerContext, + ) -> impl std::future::Future> + Send; + + fn open_file( + &self, + ctx: &TriggerContext, + ) -> impl std::future::Future> + Send; + + fn run_command( + &self, + ctx: &TriggerContext, + program: &str, + args: &[String], + cwd: Option<&str>, + env: &std::collections::HashMap, + use_shell: bool, + ) -> impl std::future::Future> + Send; + + fn webhook( + &self, + ctx: &TriggerContext, + url: &str, + method: &str, + headers: &std::collections::HashMap, + body_template: Option<&str>, + ) -> impl std::future::Future> + Send; + + fn recognize_text_to_clipboard( + &self, + ctx: &TriggerContext, + ) -> impl std::future::Future> + Send; + + fn notify( + &self, + ctx: &TriggerContext, + title_template: &str, + body_template: &str, + ) -> impl std::future::Future> + Send; + + fn open_editor( + &self, + ctx: &TriggerContext, + ) -> impl std::future::Future> + Send; + + fn apply_preset( + &self, + ctx: &TriggerContext, + name: &str, + ) -> impl std::future::Future> + Send; + + fn delete_local_files( + &self, + ctx: &TriggerContext, + ) -> impl std::future::Future> + Send; +} + +pub struct RunResult { + pub rule_id: String, + pub action_results: Vec, +} + +pub struct ActionResult { + pub action: Action, + pub success: bool, + pub error: Option, +} + +pub fn has_skip_editor(store: &AutomationsStore, trigger: &Trigger, ctx: &TriggerContext) -> bool { + let matched = evaluate(store, trigger, ctx); + matched + .iter() + .any(|(_, actions)| actions.iter().any(|a| matches!(a, Action::SkipEditor))) +} + +pub fn has_open_editor(store: &AutomationsStore, trigger: &Trigger, ctx: &TriggerContext) -> bool { + let matched = evaluate(store, trigger, ctx); + matched + .iter() + .any(|(_, actions)| actions.iter().any(|a| matches!(a, Action::OpenEditor))) +} + +pub async fn run( + host: &H, + store: &AutomationsStore, + trigger: &Trigger, + ctx: &TriggerContext, +) -> Vec { + let matched = evaluate(store, trigger, ctx); + let caps = host.capabilities(); + let mut results = Vec::new(); + + for (rule_id, actions) in matched { + info!(rule_id = %rule_id, trigger = ?trigger, "Running automation rule"); + let mut action_results = Vec::new(); + + for action in &actions { + if let Some(cap) = action.required_capability() + && !caps.contains(&cap) + { + warn!( + rule_id = %rule_id, + action = ?action, + capability = ?cap, + "Skipping action: host does not support required capability" + ); + action_results.push(ActionResult { + action: action.clone(), + success: false, + error: Some(format!("Unsupported capability: {cap:?}")), + }); + continue; + } + + let result = execute_action(host, action, ctx).await; + let (success, error) = match result { + Ok(()) => (true, None), + Err(e) => { + warn!( + rule_id = %rule_id, + action = ?action, + error = %e, + "Automation action failed" + ); + (false, Some(e)) + } + }; + action_results.push(ActionResult { + action: action.clone(), + success, + error, + }); + } + + results.push(RunResult { + rule_id, + action_results, + }); + } + + results +} + +async fn execute_action( + host: &H, + action: &Action, + ctx: &TriggerContext, +) -> Result<(), String> { + match action { + Action::CopyToClipboard { source } => host.copy_to_clipboard(ctx, source).await, + Action::SaveToLocation { + dir, + filename_template, + } => { + host.save_to_location(ctx, dir, filename_template.as_deref()) + .await + } + Action::Export { + profile, + destination, + } => host.export(ctx, profile, destination).await, + Action::Upload { + organization_id, + copy_link, + open_in_browser, + } => { + host.upload( + ctx, + organization_id.as_deref(), + *copy_link, + *open_in_browser, + ) + .await + } + Action::RevealInFileManager => host.reveal_in_file_manager(ctx).await, + Action::OpenFile => host.open_file(ctx).await, + Action::RunCommand { + program, + args, + cwd, + env, + use_shell, + } => { + host.run_command(ctx, program, args, cwd.as_deref(), env, *use_shell) + .await + } + Action::Webhook { + url, + method, + headers, + body_template, + } => { + host.webhook(ctx, url, method, headers, body_template.as_deref()) + .await + } + Action::RecognizeTextToClipboard => host.recognize_text_to_clipboard(ctx).await, + Action::Notify { + title_template, + body_template, + } => host.notify(ctx, title_template, body_template).await, + Action::OpenEditor => host.open_editor(ctx).await, + Action::SkipEditor => Ok(()), + Action::ApplyPreset { name } => host.apply_preset(ctx, name).await, + Action::DeleteLocalFiles => host.delete_local_files(ctx).await, + } +} + +pub fn load_store_from_json(value: &serde_json::Value) -> Option { + value + .get("automations") + .and_then(|v| serde_json::from_value(v.clone()).ok()) +} + +/// Build a single shell command line from a program and its arguments, quoting each token so that +/// arguments containing spaces or shell metacharacters survive as written instead of being re-split +/// by the shell. Used by hosts running `RunCommand { use_shell: true }`. +pub fn shell_command_line(program: &str, args: &[String]) -> String { + std::iter::once(program) + .chain(args.iter().map(String::as_str)) + .map(quote_shell_arg) + .collect::>() + .join(" ") +} + +fn is_shell_safe_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b"-_./:=@%+,".contains(&b) +} + +#[cfg(not(target_os = "windows"))] +fn quote_shell_arg(arg: &str) -> String { + if !arg.is_empty() && arg.bytes().all(is_shell_safe_byte) { + arg.to_string() + } else { + format!("'{}'", arg.replace('\'', "'\\''")) + } +} + +#[cfg(target_os = "windows")] +fn quote_shell_arg(arg: &str) -> String { + if !arg.is_empty() && arg.bytes().all(is_shell_safe_byte) { + arg.to_string() + } else { + format!("\"{}\"", arg.replace('"', "\"\"")) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs new file mode 100644 index 00000000000..95f8e2d8eee --- /dev/null +++ b/crates/automation/src/tests.rs @@ -0,0 +1,798 @@ +use super::*; +use std::collections::HashMap; +use std::sync::Mutex; + +struct MockHost { + caps: Vec, + actions_run: Mutex>, +} + +impl MockHost { + fn new(caps: Vec) -> Self { + Self { + caps, + actions_run: Mutex::new(Vec::new()), + } + } + + fn actions_run(&self) -> Vec { + self.actions_run.lock().unwrap().clone() + } + + fn record(&self, name: &str) { + self.actions_run.lock().unwrap().push(name.to_string()); + } +} + +impl AutomationHost for MockHost { + fn capabilities(&self) -> &[Capability] { + &self.caps + } + + async fn copy_to_clipboard( + &self, + _ctx: &TriggerContext, + source: &ClipboardSource, + ) -> Result<(), String> { + self.record(&format!("copy_to_clipboard:{source:?}")); + Ok(()) + } + + async fn save_to_location( + &self, + _ctx: &TriggerContext, + dir: &str, + tmpl: Option<&str>, + ) -> Result<(), String> { + self.record(&format!("save_to_location:{dir}:{tmpl:?}")); + Ok(()) + } + + async fn export( + &self, + _ctx: &TriggerContext, + _profile: &ExportProfile, + _dest: &ExportDestination, + ) -> Result<(), String> { + self.record("export"); + Ok(()) + } + + async fn upload( + &self, + _ctx: &TriggerContext, + _org: Option<&str>, + _copy: bool, + _open: bool, + ) -> Result<(), String> { + self.record("upload"); + Ok(()) + } + + async fn reveal_in_file_manager(&self, _ctx: &TriggerContext) -> Result<(), String> { + self.record("reveal"); + Ok(()) + } + + async fn open_file(&self, _ctx: &TriggerContext) -> Result<(), String> { + self.record("open_file"); + Ok(()) + } + + async fn run_command( + &self, + _ctx: &TriggerContext, + prog: &str, + _args: &[String], + _cwd: Option<&str>, + _env: &HashMap, + _shell: bool, + ) -> Result<(), String> { + self.record(&format!("run_command:{prog}")); + Ok(()) + } + + async fn webhook( + &self, + _ctx: &TriggerContext, + url: &str, + _method: &str, + _headers: &HashMap, + _body: Option<&str>, + ) -> Result<(), String> { + self.record(&format!("webhook:{url}")); + Ok(()) + } + + async fn recognize_text_to_clipboard(&self, _ctx: &TriggerContext) -> Result<(), String> { + self.record("ocr"); + Ok(()) + } + + async fn notify(&self, _ctx: &TriggerContext, title: &str, body: &str) -> Result<(), String> { + self.record(&format!("notify:{title}:{body}")); + Ok(()) + } + + async fn open_editor(&self, _ctx: &TriggerContext) -> Result<(), String> { + self.record("open_editor"); + Ok(()) + } + + async fn apply_preset(&self, _ctx: &TriggerContext, name: &str) -> Result<(), String> { + self.record(&format!("apply_preset:{name}")); + Ok(()) + } + + async fn delete_local_files(&self, _ctx: &TriggerContext) -> Result<(), String> { + self.record("delete_local_files"); + Ok(()) + } +} + +fn screenshot_copy_rule() -> AutomationRule { + AutomationRule { + id: "rule-1".to_string(), + name: "Auto-copy screenshot".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![ + Action::CopyToClipboard { + source: ClipboardSource::Raw, + }, + Action::Notify { + title_template: "Screenshot".to_string(), + body_template: "Copied to clipboard".to_string(), + }, + ], + } +} + +fn studio_export_rule() -> AutomationRule { + AutomationRule { + id: "rule-2".to_string(), + name: "Auto-export studio".to_string(), + enabled: true, + trigger: Trigger::StudioRecordingFinished, + match_mode: MatchMode::All, + conditions: vec![Condition::DurationAtLeast { secs: 5.0 }], + actions: vec![Action::Export { + profile: ExportProfile { + format: ExportFormat::Mp4, + fps: 60, + resolution_base: cap_project::XY { x: 1920, y: 1080 }, + compression: Some(AutomationExportCompression::Web), + preset_name: None, + }, + destination: ExportDestination::ProjectFolder, + }], + } +} + +#[test] +fn evaluate_matches_trigger_and_returns_actions() { + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule(), studio_export_rule()], + }; + + let matched = evaluate(&store, &Trigger::ScreenshotTaken, &TriggerContext::new()); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].0, "rule-1"); + assert_eq!(matched[0].1.len(), 2); +} + +#[test] +fn evaluate_skips_disabled_rules() { + let mut rule = screenshot_copy_rule(); + rule.enabled = false; + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let matched = evaluate(&store, &Trigger::ScreenshotTaken, &TriggerContext::new()); + assert!(matched.is_empty()); +} + +#[test] +fn evaluate_skips_wrong_trigger() { + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule()], + }; + + let matched = evaluate( + &store, + &Trigger::StudioRecordingFinished, + &TriggerContext::new(), + ); + assert!(matched.is_empty()); +} + +#[test] +fn condition_duration_at_least() { + let store = AutomationsStore { + version: 1, + rules: vec![studio_export_rule()], + }; + + let short_ctx = TriggerContext::new().with_duration(3.0); + let long_ctx = TriggerContext::new().with_duration(10.0); + + let matched_short = evaluate(&store, &Trigger::StudioRecordingFinished, &short_ctx); + assert!(matched_short.is_empty()); + + let matched_long = evaluate(&store, &Trigger::StudioRecordingFinished, &long_ctx); + assert_eq!(matched_long.len(), 1); +} + +#[test] +fn condition_capture_target() { + let rule = AutomationRule { + id: "rule-target".to_string(), + name: "Window only".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![Condition::CaptureTargetIs { + target: CaptureTargetKind::Window, + }], + actions: vec![Action::CopyToClipboard { + source: ClipboardSource::Raw, + }], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let display_ctx = TriggerContext::new().with_capture_target(CaptureTargetKind::Display); + let window_ctx = TriggerContext::new().with_capture_target(CaptureTargetKind::Window); + + assert!(evaluate(&store, &Trigger::ScreenshotTaken, &display_ctx).is_empty()); + assert_eq!( + evaluate(&store, &Trigger::ScreenshotTaken, &window_ctx).len(), + 1 + ); +} + +#[test] +fn condition_window_title_contains() { + let rule = AutomationRule { + id: "rule-title".to_string(), + name: "Slack screenshots".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![Condition::WindowTitleContains { + pattern: "slack".to_string(), + }], + actions: vec![Action::CopyToClipboard { + source: ClipboardSource::Raw, + }], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let no_title = TriggerContext::new(); + let slack_ctx = TriggerContext::new().with_window_title("Slack - #general".to_string()); + let vscode_ctx = TriggerContext::new().with_window_title("VS Code".to_string()); + + assert!(evaluate(&store, &Trigger::ScreenshotTaken, &no_title).is_empty()); + assert_eq!( + evaluate(&store, &Trigger::ScreenshotTaken, &slack_ctx).len(), + 1 + ); + assert!(evaluate(&store, &Trigger::ScreenshotTaken, &vscode_ctx).is_empty()); +} + +#[test] +fn match_mode_any_matches_on_first_true() { + let rule = AutomationRule { + id: "rule-any".to_string(), + name: "Display or window".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::Any, + conditions: vec![ + Condition::CaptureTargetIs { + target: CaptureTargetKind::Display, + }, + Condition::CaptureTargetIs { + target: CaptureTargetKind::Window, + }, + ], + actions: vec![Action::CopyToClipboard { + source: ClipboardSource::Raw, + }], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let area_ctx = TriggerContext::new().with_capture_target(CaptureTargetKind::Area); + let display_ctx = TriggerContext::new().with_capture_target(CaptureTargetKind::Display); + + assert!(evaluate(&store, &Trigger::ScreenshotTaken, &area_ctx).is_empty()); + assert_eq!( + evaluate(&store, &Trigger::ScreenshotTaken, &display_ctx).len(), + 1 + ); +} + +#[test] +fn has_skip_editor_detects_skip_action() { + let rule = AutomationRule { + id: "headless".to_string(), + name: "Headless screenshot".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![ + Action::CopyToClipboard { + source: ClipboardSource::Raw, + }, + Action::SkipEditor, + ], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + assert!(has_skip_editor( + &store, + &Trigger::ScreenshotTaken, + &TriggerContext::new() + )); + assert!(!has_skip_editor( + &store, + &Trigger::StudioRecordingFinished, + &TriggerContext::new() + )); +} + +#[tokio::test] +async fn run_executes_actions_in_order() { + let host = MockHost::new(vec![Capability::CopyToClipboard, Capability::Notify]); + + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule()], + }; + + let results = run( + &host, + &store, + &Trigger::ScreenshotTaken, + &TriggerContext::new(), + ) + .await; + + assert_eq!(results.len(), 1); + assert_eq!(results[0].action_results.len(), 2); + assert!(results[0].action_results.iter().all(|r| r.success)); + + let actions = host.actions_run(); + assert_eq!(actions[0], "copy_to_clipboard:Raw"); + assert_eq!(actions[1], "notify:Screenshot:Copied to clipboard"); +} + +#[tokio::test] +async fn run_skips_unsupported_capabilities() { + let host = MockHost::new(vec![Capability::CopyToClipboard]); + + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule()], + }; + + let results = run( + &host, + &store, + &Trigger::ScreenshotTaken, + &TriggerContext::new(), + ) + .await; + + assert_eq!(results[0].action_results.len(), 2); + assert!(results[0].action_results[0].success); + assert!(!results[0].action_results[1].success); + + let actions = host.actions_run(); + assert_eq!(actions.len(), 1); + assert_eq!(actions[0], "copy_to_clipboard:Raw"); +} + +#[tokio::test] +async fn skip_editor_action_is_noop_and_never_opens_editor() { + let rule = AutomationRule { + id: "headless".to_string(), + name: "Headless screenshot".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![ + Action::CopyToClipboard { + source: ClipboardSource::Raw, + }, + Action::SkipEditor, + ], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let host = MockHost::new(vec![Capability::CopyToClipboard, Capability::OpenEditor]); + let results = run( + &host, + &store, + &Trigger::ScreenshotTaken, + &TriggerContext::new(), + ) + .await; + + assert!(results[0].action_results.iter().all(|r| r.success)); + let actions = host.actions_run(); + assert_eq!(actions, vec!["copy_to_clipboard:Raw".to_string()]); + assert!(!actions.iter().any(|a| a == "open_editor")); +} + +#[test] +fn serialize_roundtrip() { + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule(), studio_export_rule()], + }; + + let json = serde_json::to_string_pretty(&store).unwrap(); + let parsed: AutomationsStore = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.rules.len(), 2); + assert_eq!(parsed.version, 1); +} + +#[test] +fn serialize_all_condition_and_action_shapes_roundtrip() { + use std::collections::HashMap; + + let rule = AutomationRule { + id: "everything".to_string(), + name: "All shapes".to_string(), + enabled: true, + trigger: Trigger::StudioRecordingFinished, + match_mode: MatchMode::Any, + conditions: vec![ + Condition::CaptureTargetIs { + target: CaptureTargetKind::Window, + }, + Condition::RecordingModeIs { + mode: AutomationRecordingMode::Studio, + }, + Condition::DurationAtLeast { secs: 5.0 }, + Condition::DurationAtMost { secs: 600.0 }, + Condition::WindowTitleContains { + pattern: "slack".to_string(), + }, + Condition::OrganizationIs { + id: "org_1".to_string(), + }, + ], + actions: vec![ + Action::CopyToClipboard { + source: ClipboardSource::Rendered, + }, + Action::SaveToLocation { + dir: "/tmp/cap".to_string(), + filename_template: Some("{date}-{window}".to_string()), + }, + Action::Export { + profile: ExportProfile { + format: ExportFormat::Mp4, + fps: 60, + resolution_base: cap_project::XY { x: 1920, y: 1080 }, + compression: Some(AutomationExportCompression::Web), + preset_name: Some("My Preset".to_string()), + }, + destination: ExportDestination::CustomPath { + dir: "/tmp/out".to_string(), + }, + }, + Action::Upload { + organization_id: Some("org_1".to_string()), + copy_link: true, + open_in_browser: false, + }, + Action::RevealInFileManager, + Action::OpenFile, + Action::RunCommand { + program: "echo".to_string(), + args: vec!["hi".to_string()], + cwd: None, + env: HashMap::new(), + use_shell: false, + }, + Action::Webhook { + url: "https://example.com/hook".to_string(), + method: "POST".to_string(), + headers: HashMap::new(), + body_template: Some("{share_link}".to_string()), + }, + Action::RecognizeTextToClipboard, + Action::Notify { + title_template: "Done".to_string(), + body_template: "Recording finished".to_string(), + }, + Action::OpenEditor, + Action::SkipEditor, + Action::ApplyPreset { + name: "My Preset".to_string(), + }, + Action::DeleteLocalFiles, + ], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + let json = serde_json::to_value(&store).unwrap(); + let conditions = &json["rules"][0]["conditions"]; + assert_eq!(conditions[0]["type"], "captureTargetIs"); + assert_eq!(conditions[0]["target"], "window"); + assert_eq!(conditions[1]["type"], "recordingModeIs"); + assert_eq!(conditions[1]["mode"], "studio"); + + let actions = &json["rules"][0]["actions"]; + assert_eq!(actions[0]["type"], "copyToClipboard"); + assert_eq!(actions[0]["source"], "rendered"); + assert_eq!(actions[4]["type"], "revealInFileManager"); + + // Struct-variant fields must serialize camelCase to match the desktop+CLI frontends; serde's + // enum-level rename_all does not cover variant fields, so each multi-word variant carries its own. + assert_eq!(actions[1]["type"], "saveToLocation"); + assert!(actions[1].get("filenameTemplate").is_some()); + assert!(actions[1].get("filename_template").is_none()); + assert!(actions[3].get("organizationId").is_some()); + assert!(actions[3].get("copyLink").is_some()); + assert!(actions[3].get("openInBrowser").is_some()); + assert!(actions[6].get("useShell").is_some()); + assert!(actions[7].get("bodyTemplate").is_some()); + assert!(actions[9].get("titleTemplate").is_some()); + + let parsed: AutomationsStore = serde_json::from_value(json).unwrap(); + assert_eq!(parsed.rules.len(), 1); + assert_eq!(parsed.rules[0].conditions.len(), 6); + assert_eq!(parsed.rules[0].actions.len(), 14); +} + +#[test] +fn load_store_from_json_extracts_automations_key() { + let store = AutomationsStore { + version: 1, + rules: vec![screenshot_copy_rule()], + }; + + let wrapper = serde_json::json!({ + "general_settings": {}, + "automations": store, + }); + + let loaded = load_store_from_json(&wrapper).unwrap(); + assert_eq!(loaded.rules.len(), 1); + assert_eq!(loaded.rules[0].id, "rule-1"); +} + +#[test] +fn load_store_from_json_returns_none_on_missing_key() { + let wrapper = serde_json::json!({ + "general_settings": {}, + }); + assert!(load_store_from_json(&wrapper).is_none()); +} + +fn desktop_capabilities() -> Vec { + vec![ + Capability::CopyToClipboard, + Capability::SaveToLocation, + Capability::Export, + Capability::Upload, + Capability::RevealInFileManager, + Capability::OpenFile, + Capability::RunCommand, + Capability::Webhook, + Capability::RecognizeText, + Capability::Notify, + Capability::OpenEditor, + Capability::ApplyPreset, + Capability::DeleteLocalFiles, + ] +} + +fn cli_capabilities() -> Vec { + vec![ + Capability::SaveToLocation, + Capability::Export, + Capability::Upload, + Capability::RevealInFileManager, + Capability::OpenFile, + Capability::RunCommand, + Capability::Webhook, + Capability::ApplyPreset, + Capability::DeleteLocalFiles, + ] +} + +// Both surfaces share the same engine + store, so they must match the same rules and run the same +// actions in the same order — the only difference being that surface-specific actions (clipboard on the +// CLI) are skipped, never silently reordered or dropped. +#[tokio::test] +async fn desktop_and_cli_parity_on_screenshot_rule() { + let rule = AutomationRule { + id: "parity".to_string(), + name: "Auto-handle screenshot".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![Condition::CaptureTargetIs { + target: CaptureTargetKind::Window, + }], + actions: vec![ + Action::SaveToLocation { + dir: "/tmp/shots".to_string(), + filename_template: None, + }, + Action::CopyToClipboard { + source: ClipboardSource::Raw, + }, + Action::RunCommand { + program: "true".to_string(), + args: vec![], + cwd: None, + env: HashMap::new(), + use_shell: false, + }, + ], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + let ctx = TriggerContext::new().with_capture_target(CaptureTargetKind::Window); + + // Both surfaces evaluate to the same matched rule + ordered actions (engine is host-independent). + let matched = evaluate(&store, &Trigger::ScreenshotTaken, &ctx); + assert_eq!(matched.len(), 1); + assert_eq!(matched[0].1.len(), 3); + + let desktop = MockHost::new(desktop_capabilities()); + let desktop_results = run(&desktop, &store, &Trigger::ScreenshotTaken, &ctx).await; + assert!(desktop_results[0].action_results.iter().all(|r| r.success)); + assert_eq!( + desktop.actions_run(), + vec![ + "save_to_location:/tmp/shots:None".to_string(), + "copy_to_clipboard:Raw".to_string(), + "run_command:true".to_string(), + ] + ); + + let cli = MockHost::new(cli_capabilities()); + let cli_results = run(&cli, &store, &Trigger::ScreenshotTaken, &ctx).await; + // Same three action slots reported, in order; the clipboard one is marked unsupported, not dropped. + assert_eq!(cli_results[0].action_results.len(), 3); + assert!(cli_results[0].action_results[0].success); + assert!(!cli_results[0].action_results[1].success); + assert!(cli_results[0].action_results[2].success); + assert_eq!( + cli.actions_run(), + vec![ + "save_to_location:/tmp/shots:None".to_string(), + "run_command:true".to_string(), + ] + ); +} + +#[tokio::test] +async fn skip_editor_runs_without_open_editor_capability() { + let rule = AutomationRule { + id: "cli-skip".to_string(), + name: "Headless".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![Action::SkipEditor], + }; + let store = AutomationsStore { + version: 1, + rules: vec![rule], + }; + + // Host without `OpenEditor` capability, mirroring the CLI. `SkipEditor` must still succeed as a + // no-op rather than being reported unsupported. + let host = MockHost::new(vec![Capability::SaveToLocation]); + let results = run( + &host, + &store, + &Trigger::ScreenshotTaken, + &TriggerContext::new(), + ) + .await; + + assert_eq!(results[0].action_results.len(), 1); + assert!(results[0].action_results[0].success); + assert!(results[0].action_results[0].error.is_none()); + assert!(host.actions_run().is_empty()); +} + +#[test] +fn shell_command_line_quotes_args_with_spaces() { + let line = shell_command_line("echo", &["hello world".to_string(), "plain".to_string()]); + #[cfg(not(target_os = "windows"))] + assert_eq!(line, "echo 'hello world' plain"); + #[cfg(target_os = "windows")] + assert_eq!(line, "echo \"hello world\" plain"); +} + +#[test] +fn shell_command_line_escapes_embedded_quotes() { + #[cfg(not(target_os = "windows"))] + { + let line = shell_command_line("printf", &["it's".to_string()]); + assert_eq!(line, "printf 'it'\\''s'"); + assert_eq!(shell_command_line("x", &[String::new()]), "x ''"); + } + #[cfg(target_os = "windows")] + { + let line = shell_command_line("echo", &["a\"b".to_string()]); + assert_eq!(line, "echo \"a\"\"b\""); + } +} + +#[test] +fn multiple_rules_same_trigger() { + let rule1 = AutomationRule { + id: "a".to_string(), + name: "Rule A".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![Action::CopyToClipboard { + source: ClipboardSource::Raw, + }], + }; + let rule2 = AutomationRule { + id: "b".to_string(), + name: "Rule B".to_string(), + enabled: true, + trigger: Trigger::ScreenshotTaken, + match_mode: MatchMode::All, + conditions: vec![], + actions: vec![Action::RevealInFileManager], + }; + + let store = AutomationsStore { + version: 1, + rules: vec![rule1, rule2], + }; + + let matched = evaluate(&store, &Trigger::ScreenshotTaken, &TriggerContext::new()); + assert_eq!(matched.len(), 2); +} diff --git a/crates/automation/src/types.rs b/crates/automation/src/types.rs new file mode 100644 index 00000000000..6193b029276 --- /dev/null +++ b/crates/automation/src/types.rs @@ -0,0 +1,212 @@ +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::collections::HashMap; + +use cap_project::XY; + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct AutomationsStore { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub rules: Vec, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AutomationRule { + pub id: String, + pub name: String, + #[serde(default = "default_true")] + pub enabled: bool, + pub trigger: Trigger, + #[serde(default)] + pub match_mode: MatchMode, + #[serde(default)] + pub conditions: Vec, + #[serde(default)] + pub actions: Vec, +} + +fn default_true() -> bool { + true +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum MatchMode { + #[default] + All, + Any, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[serde(rename_all = "camelCase")] +pub enum Trigger { + ScreenshotTaken, + StudioRecordingFinished, + InstantRecordingFinished, + RecordingStarted, + UploadCompleted, + VideoImported, + RecordingDeleted, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Condition { + CaptureTargetIs { target: CaptureTargetKind }, + RecordingModeIs { mode: AutomationRecordingMode }, + DurationAtLeast { secs: f64 }, + DurationAtMost { secs: f64 }, + WindowTitleContains { pattern: String }, + OrganizationIs { id: String }, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CaptureTargetKind { + Display, + Window, + Area, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AutomationRecordingMode { + Studio, + Instant, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +#[serde(rename_all = "camelCase", tag = "type")] +pub enum Action { + CopyToClipboard { + #[serde(default)] + source: ClipboardSource, + }, + #[serde(rename_all = "camelCase")] + SaveToLocation { + dir: String, + #[serde(default)] + filename_template: Option, + }, + Export { + profile: ExportProfile, + #[serde(default)] + destination: ExportDestination, + }, + #[serde(rename_all = "camelCase")] + Upload { + #[serde(default)] + organization_id: Option, + #[serde(default = "default_true")] + copy_link: bool, + #[serde(default)] + open_in_browser: bool, + }, + RevealInFileManager, + OpenFile, + #[serde(rename_all = "camelCase")] + RunCommand { + program: String, + #[serde(default)] + args: Vec, + #[serde(default)] + cwd: Option, + #[serde(default)] + env: HashMap, + #[serde(default)] + use_shell: bool, + }, + #[serde(rename_all = "camelCase")] + Webhook { + url: String, + #[serde(default = "default_post")] + method: String, + #[serde(default)] + headers: HashMap, + #[serde(default)] + body_template: Option, + }, + RecognizeTextToClipboard, + #[serde(rename_all = "camelCase")] + Notify { + #[serde(default = "default_notify_title")] + title_template: String, + #[serde(default)] + body_template: String, + }, + OpenEditor, + SkipEditor, + ApplyPreset { + name: String, + }, + DeleteLocalFiles, +} + +fn default_post() -> String { + "POST".to_string() +} + +fn default_notify_title() -> String { + "Cap Automation".to_string() +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum ClipboardSource { + #[default] + Raw, + Rendered, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ExportProfile { + pub format: ExportFormat, + #[serde(default = "default_fps")] + pub fps: u32, + #[serde(default = "default_resolution")] + pub resolution_base: XY, + #[serde(default)] + pub compression: Option, + #[serde(default)] + pub preset_name: Option, +} + +fn default_fps() -> u32 { + 30 +} + +fn default_resolution() -> XY { + XY { x: 1920, y: 1080 } +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ExportFormat { + Mp4, + Gif, + Mov, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AutomationExportCompression { + Maximum, + Social, + Web, + Potato, +} + +#[derive(Serialize, Deserialize, Type, Debug, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ExportDestination { + #[default] + ProjectFolder, + CustomPath { + dir: String, + }, +} From df3d460ef83e9023742a8cc9b37aaaa9cf931e91 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 03/28] chore: update Cargo.lock for cap-automation dependencies --- Cargo.lock | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fe96b2f588c..b97e6c4e85c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1089,11 +1089,13 @@ dependencies = [ name = "cap" version = "0.1.0" dependencies = [ + "cap-automation", "cap-camera", "cap-cli-install", "cap-export", "cap-project", "cap-recording", + "chrono", "cidre", "clap", "clap_complete", @@ -1143,6 +1145,20 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-automation" +version = "0.1.0" +dependencies = [ + "cap-project", + "serde", + "serde_json", + "specta", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "cap-camera" version = "0.1.0" @@ -1308,6 +1324,7 @@ dependencies = [ "bytemuck", "bytes", "cap-audio", + "cap-automation", "cap-camera", "cap-camera-effects", "cap-camera-ffmpeg", From ebb53d85412ff4d593cb8373e8a5623128bf05d5 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 04/28] feat(desktop): add preset lookup by name --- apps/desktop/src-tauri/src/presets.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/desktop/src-tauri/src/presets.rs b/apps/desktop/src-tauri/src/presets.rs index 3b0c3d06697..c3d8d2f3c8c 100644 --- a/apps/desktop/src-tauri/src/presets.rs +++ b/apps/desktop/src-tauri/src/presets.rs @@ -57,6 +57,14 @@ impl PresetsStore { Ok(this.presets.get(default_i as usize).cloned()) } + pub fn get_by_name(app: &AppHandle, name: &str) -> Result, String> { + let Some(this) = Self::get(app)? else { + return Ok(None); + }; + + Ok(this.presets.into_iter().find(|p| p.name == name)) + } + #[allow(unused)] pub fn update(app: &AppHandle, update: impl FnOnce(&mut Self)) -> Result<(), String> { let Ok(store) = app.store("store") else { From 2aa39593a03ebdab74f0723ad00514914c0579d4 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 05/28] feat(desktop): derive Deserialize on import progress events --- apps/desktop/src-tauri/src/import.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/import.rs b/apps/desktop/src-tauri/src/import.rs index 9ef0e4f8279..2a1f4390b87 100644 --- a/apps/desktop/src-tauri/src/import.rs +++ b/apps/desktop/src-tauri/src/import.rs @@ -18,7 +18,7 @@ use ffmpeg::{ }; use image::ImageEncoder; use relative_path::{Component as RelativeComponent, RelativePathBuf}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use specta::Type; use std::{ collections::HashMap, @@ -43,7 +43,7 @@ const KEYBOARD_IMPORT_EXTENSIONS: &[&str] = &["bin", "json"]; const CURSOR_EVENTS_IMPORT_EXTENSIONS: &[&str] = &["json"]; const MAX_IMAGE_DIMENSION: u32 = 16_384; -#[derive(Serialize, Type, Clone, Debug)] +#[derive(Serialize, Deserialize, Type, Clone, Debug)] pub enum ImportStage { Probing, Converting, @@ -52,7 +52,7 @@ pub enum ImportStage { Failed, } -#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)] +#[derive(Serialize, Deserialize, Type, tauri_specta::Event, Clone, Debug)] pub struct VideoImportProgress { pub project_path: String, pub stage: ImportStage, From 62d336fdbe19c940014ff75ca578b7564187a1da Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 06/28] feat(desktop): add OCR from image path for automations --- .../src-tauri/src/screenshot_editor.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 331a9051771..d3e39fe8fd6 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -757,10 +757,19 @@ pub async fn create_screenshot_editor_instance( /// that cost into background startup time; the compiled pipelines are cached on the /// shared device, so the first editor open renders immediately. pub async fn prewarm_screenshot_renderer() { + use std::sync::atomic::{AtomicBool, Ordering}; + + static PREWARMED: AtomicBool = AtomicBool::new(false); + if PREWARMED.swap(true, Ordering::SeqCst) { + return; + } + let Some(gpu) = gpu_context::get_shared_gpu().await else { return; }; + let _ = tokio::task::spawn_blocking(cap_rendering::prewarm_fonts).await; + let started = Instant::now(); let shared = cap_rendering::SharedWgpuDevice { @@ -952,6 +961,35 @@ pub async fn recognize_screenshot_text( Ok(result) } +pub async fn recognize_text_from_image_path(path: &std::path::Path) -> Result { + let dynamic = image::open(path).map_err(|e| format!("Failed to open image for OCR: {e}"))?; + let rgba = dynamic.to_rgba8(); + let width = rgba.width(); + let height = rgba.height(); + + if width == 0 || height == 0 { + return Err("Image is empty".to_string()); + } + + let rgba_bytes = rgba.into_raw(); + let mut bgra = vec![0u8; rgba_bytes.len()]; + for (src, dst) in rgba_bytes.chunks_exact(4).zip(bgra.chunks_exact_mut(4)) { + dst[0] = src[2]; + dst[1] = src[1]; + dst[2] = src[0]; + dst[3] = src[3]; + } + + let image = ScreenshotOcrImage { + bgra, + width, + height, + }; + + let result = recognize_screenshot_ocr_image(image).await?; + Ok(result.text) +} + fn clamp_screenshot_ocr_region( region: ScreenshotOcrRegion, image_width: u32, From 4cdeeae2229a7bf37af3ab00a4ca1b58015c0c83 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 07/28] fix(camera): size preview texture to cover viewport region --- apps/desktop/src-tauri/src/camera.rs | 42 +++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 221e189de97..8656bc310fd 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -170,11 +170,14 @@ fn camera_preview_frame_due(last_render_at: Option, now: Instant) -> bo fn camera_preview_texture_dimensions( source_width: u32, source_height: u32, - window_px: u32, + region_width: u32, + region_height: u32, blur_enabled: bool, ) -> (u32, u32) { let source_width = source_width.max(1); let source_height = source_height.max(1); + let region_width = region_width.max(1); + let region_height = region_height.max(1); let (max_width, max_height) = if blur_enabled { ( CAMERA_PREVIEW_BLUR_MAX_TEXTURE_WIDTH, @@ -186,7 +189,9 @@ fn camera_preview_texture_dimensions( CAMERA_PREVIEW_MAX_TEXTURE_HEIGHT, ) }; - let requested_width = window_px + let source_aspect = source_width as f64 / source_height as f64; + let cover_width = (region_width as f64).max(region_height as f64 * source_aspect); + let requested_width = (cover_width.ceil() as u32) .max(CAMERA_PREVIEW_MIN_TEXTURE_WIDTH) .div_ceil(CAMERA_PREVIEW_TEXTURE_WIDTH_BUCKET) .saturating_mul(CAMERA_PREVIEW_TEXTURE_WIDTH_BUCKET) @@ -984,14 +989,17 @@ impl Renderer { let surface_result = self.acquire_surface_texture(); if let Some(surface) = surface_result { - let window_px = (clamp_size(state.size) as f64 - * self.surface_scale.max(1.0)) - .round() as u32; + let surface_scale = self.surface_scale.max(1.0); + let toolbar_px = (TOOLBAR_HEIGHT as f64 * surface_scale).round() as u32; + let region_width = self.surface_config.width.max(1); + let region_height = + self.surface_config.height.saturating_sub(toolbar_px).max(1); let blur_mode = blur_mode_from_project(state.background_blur); let (output_width, output_height) = camera_preview_texture_dimensions( source_width, source_height, - window_px, + region_width, + region_height, blur_mode.is_some(), ); @@ -1713,11 +1721,31 @@ async fn resize_window( #[cfg(test)] mod tests { - use super::{preferred_alpha_mode, wait_for_shutdown_signal}; + use super::{ + CAMERA_PREVIEW_MAX_TEXTURE_HEIGHT, CAMERA_PREVIEW_MAX_TEXTURE_WIDTH, + camera_preview_texture_dimensions, preferred_alpha_mode, wait_for_shutdown_signal, + }; use std::thread; use tokio::{runtime::Runtime, sync::oneshot, time::Duration}; use wgpu::CompositeAlphaMode; + #[test] + fn texture_covers_square_region_without_upscaling() { + let (width, height) = camera_preview_texture_dimensions(1280, 720, 460, 460, false); + let cover_scale = (460.0 / width as f64).max(460.0 / height as f64); + assert!( + cover_scale <= 1.0, + "texture {width}x{height} would upscale to cover a 460x460 region (scale {cover_scale})" + ); + } + + #[test] + fn texture_dimensions_stay_within_caps() { + let (width, height) = camera_preview_texture_dimensions(1920, 1080, 4000, 2200, false); + assert!(width <= CAMERA_PREVIEW_MAX_TEXTURE_WIDTH); + assert!(height <= CAMERA_PREVIEW_MAX_TEXTURE_HEIGHT); + } + #[test] fn preferred_alpha_mode_avoids_unsupported_inherit_fallback() { assert_eq!( From 17aeec818ff2c75ca1b645342feaed434478600d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:04 +0100 Subject: [PATCH 08/28] feat(desktop): add desktop automation host module --- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/automation.rs | 913 +++++++++++++++++++++++ 2 files changed, 914 insertions(+) create mode 100644 apps/desktop/src-tauri/src/automation.rs diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index cffef9dd615..f5e1751ec40 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -87,6 +87,7 @@ cap-media = { path = "../../../crates/media" } cap-flags = { path = "../../../crates/flags" } cap-recording = { path = "../../../crates/recording" } cap-export = { path = "../../../crates/export" } +cap-automation = { path = "../../../crates/automation" } cap-cli-install = { path = "../../../crates/cli-install", features = ["specta"] } cap-enc-ffmpeg = { path = "../../../crates/enc-ffmpeg" } cap-media-info = { path = "../../../crates/media-info" } diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs new file mode 100644 index 00000000000..7d238e257d8 --- /dev/null +++ b/apps/desktop/src-tauri/src/automation.rs @@ -0,0 +1,913 @@ +use cap_automation::{ + AutomationExportCompression, AutomationHost, AutomationRecordingMode, AutomationsStore, + Capability, CaptureTargetKind, ClipboardSource, ExportDestination, ExportFormat, ExportProfile, + Trigger, TriggerContext, +}; +use cap_recording::sources::screen_capture::ScreenCaptureTarget; +use clipboard_rs::Clipboard; +use clipboard_rs::common::RustImage; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tauri::{AppHandle, Manager, Wry}; +use tauri_plugin_store::StoreExt; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +use crate::ClipboardContext; +use crate::general_settings::PostStudioRecordingBehaviour; + +const WEBHOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +pub struct DesktopAutomationHost { + app: AppHandle, + clipboard: Arc>, +} + +impl DesktopAutomationHost { + pub fn new(app: AppHandle, clipboard: Arc>) -> Self { + Self { app, clipboard } + } +} + +impl AutomationHost for DesktopAutomationHost { + fn capabilities(&self) -> &[Capability] { + &[ + Capability::CopyToClipboard, + Capability::SaveToLocation, + Capability::Export, + Capability::Upload, + Capability::RevealInFileManager, + Capability::OpenFile, + Capability::RunCommand, + Capability::Webhook, + Capability::Notify, + Capability::OpenEditor, + Capability::ApplyPreset, + Capability::DeleteLocalFiles, + #[cfg(any(target_os = "macos", target_os = "windows"))] + Capability::RecognizeText, + ] + } + + async fn copy_to_clipboard( + &self, + ctx: &TriggerContext, + source: &ClipboardSource, + ) -> Result<(), String> { + let path = match source { + ClipboardSource::Raw => ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No image or output path available for clipboard copy")?, + ClipboardSource::Rendered => ctx + .output_path + .as_ref() + .or(ctx.image_path.as_ref()) + .ok_or("No output path available for clipboard copy")?, + }; + + let path_str = path.to_string_lossy().to_string(); + info!(path = %path_str, "Automation: copying to clipboard"); + + let img_data = clipboard_rs::RustImageData::from_path(&path_str) + .map_err(|e| format!("Failed to load image for clipboard: {e}"))?; + self.clipboard + .write() + .await + .set_image(img_data) + .map_err(|e| format!("Failed to set clipboard image: {e}"))?; + + Ok(()) + } + + async fn save_to_location( + &self, + ctx: &TriggerContext, + dir: &str, + filename_template: Option<&str>, + ) -> Result<(), String> { + let src = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No file path available for save")?; + + let filename = if let Some(tmpl) = filename_template { + apply_filename_template(tmpl, ctx) + } else { + src.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "capture.png".to_string()) + }; + + let dst = PathBuf::from(dir).join(&filename); + info!(src = %src.display(), dst = %dst.display(), "Automation: saving to location"); + + if let Some(parent) = dst.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("Failed to create directory: {e}"))?; + } + + tokio::fs::copy(src, &dst) + .await + .map_err(|e| format!("Failed to copy file: {e}"))?; + + Ok(()) + } + + async fn export( + &self, + ctx: &TriggerContext, + profile: &ExportProfile, + destination: &ExportDestination, + ) -> Result<(), String> { + let project_path = ctx + .project_path + .as_ref() + .ok_or("No project path available for export")?; + + info!( + project = %project_path.display(), + format = ?profile.format, + "Automation: exporting" + ); + + let settings = build_desktop_export_settings(profile); + + let output_path = match destination { + ExportDestination::ProjectFolder => None, + ExportDestination::CustomPath { dir } => { + let ext = match profile.format { + ExportFormat::Mp4 => "mp4", + ExportFormat::Gif => "gif", + ExportFormat::Mov => "mov", + }; + let name = project_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "export".to_string()); + Some(PathBuf::from(dir).join(format!("{name}.{ext}"))) + } + }; + + let mut builder = cap_export::ExporterBase::builder(project_path.clone()); + + if let Some(ref out) = output_path { + builder = builder.with_output_path(out.clone()); + } + + let base = builder.build().await.map_err(|e| format!("{e}"))?; + + let result_path = match settings { + crate::export::ExportSettings::Mp4(s) => s.export(base, |_| true).await, + crate::export::ExportSettings::Gif(s) => s.export(base, |_| true).await, + crate::export::ExportSettings::Mov(s) => s.export(base, |_| true).await, + } + .map_err(|e| format!("Export failed: {e}"))?; + + info!(output = %result_path.display(), "Automation: export complete"); + Ok(()) + } + + async fn upload( + &self, + ctx: &TriggerContext, + organization_id: Option<&str>, + copy_link: bool, + open_in_browser: bool, + ) -> Result<(), String> { + let link = if let Some(image_path) = ctx.image_path.as_ref() { + info!(path = %image_path.display(), "Automation: uploading screenshot"); + let uploaded = crate::upload::upload_image(&self.app, image_path.clone()) + .await + .map_err(|e| format!("Upload failed: {e}"))?; + + if let Some(project_path) = ctx.project_path.as_ref() + && let Ok(mut meta) = cap_project::RecordingMeta::load_for_project(project_path) + { + meta.sharing = Some(cap_project::SharingMeta { + link: uploaded.link.clone(), + id: uploaded.id.clone(), + }); + let _ = meta.save_for_project(); + } + + uploaded.link + } else if let Some(existing) = ctx.share_link.as_ref() { + info!(link = %existing, "Automation: recording already uploaded, reusing existing link"); + existing.clone() + } else if let Some(project_path) = ctx.project_path.as_ref() { + info!(path = %project_path.display(), "Automation: uploading recording"); + let channel = tauri::ipc::Channel::new(|_| Ok(())); + let result = crate::upload_exported_video( + self.app.clone(), + project_path.clone(), + crate::UploadMode::Initial { + pre_created_video: None, + }, + channel, + organization_id.map(|s| s.to_string()), + ) + .await?; + + match result { + crate::UploadResult::Success(link) => link, + crate::UploadResult::NotAuthenticated => { + return Err("Not authenticated for upload".to_string()); + } + crate::UploadResult::UpgradeRequired => { + return Err("Upgrade required for upload".to_string()); + } + crate::UploadResult::PlanCheckFailed => { + return Err("Plan check failed for upload".to_string()); + } + } + } else { + return Err("No image or project path available for upload".to_string()); + }; + + if copy_link { + self.clipboard + .write() + .await + .set_text(link.clone()) + .map_err(|e| format!("Failed to copy link: {e}"))?; + } + + if open_in_browser { + let _ = crate::open_external_link(self.app.clone(), link.clone()); + } + + Ok(()) + } + + async fn reveal_in_file_manager(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .or(ctx.project_path.as_ref()) + .ok_or("No path available to reveal")?; + + reveal_path(path) + } + + async fn open_file(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No file path available to open")?; + + reveal_path(path) + } + + async fn run_command( + &self, + ctx: &TriggerContext, + program: &str, + args: &[String], + cwd: Option<&str>, + env: &HashMap, + use_shell: bool, + ) -> Result<(), String> { + info!(program, "Automation: running command"); + + let mut cmd = if use_shell { + let shell_line = cap_automation::shell_command_line(program, args); + + #[cfg(target_os = "windows")] + let mut c = tokio::process::Command::new("cmd"); + #[cfg(target_os = "windows")] + c.args(["/C", &shell_line]); + + #[cfg(not(target_os = "windows"))] + let mut c = tokio::process::Command::new("sh"); + #[cfg(not(target_os = "windows"))] + c.args(["-c", &shell_line]); + + c + } else { + let mut c = tokio::process::Command::new(program); + c.args(args); + c + }; + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + + for (k, v) in env { + cmd.env(k, v); + } + + if let Some(ref p) = ctx.project_path { + cmd.env("CAP_PROJECT_PATH", p); + } + if let Some(ref p) = ctx.image_path { + cmd.env("CAP_IMAGE_PATH", p); + } + if let Some(ref p) = ctx.output_path { + cmd.env("CAP_OUTPUT_PATH", p); + } + if let Some(ref l) = ctx.share_link { + cmd.env("CAP_SHARE_LINK", l); + } + + // Kill the child if the timeout drops the future, so a hung command can't outlive the run. + cmd.kill_on_drop(true); + let output = tokio::time::timeout(COMMAND_TIMEOUT, cmd.output()) + .await + .map_err(|_| format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))? + .map_err(|e| format!("Failed to run command: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Command exited with {}: {}", output.status, stderr)); + } + + Ok(()) + } + + async fn webhook( + &self, + ctx: &TriggerContext, + url: &str, + method: &str, + headers: &HashMap, + body_template: Option<&str>, + ) -> Result<(), String> { + info!(url, method, "Automation: sending webhook"); + + let client = reqwest::Client::builder() + .timeout(WEBHOOK_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let method = method + .parse::() + .map_err(|e| format!("Invalid HTTP method: {e}"))?; + + let body = body_template + .map(|tmpl| apply_body_template(tmpl, ctx)) + .unwrap_or_else(|| { + serde_json::to_string(&serde_json::json!({ + "project_path": ctx.project_path, + "image_path": ctx.image_path, + "output_path": ctx.output_path, + "share_link": ctx.share_link, + })) + .unwrap_or_default() + }); + + let mut req = client.request(method, url).body(body); + for (k, v) in headers { + req = req.header(k, v); + } + + let resp = req + .send() + .await + .map_err(|e| format!("Webhook request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("Webhook returned status {}", resp.status())); + } + + Ok(()) + } + + async fn recognize_text_to_clipboard(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No image path available for OCR")?; + + info!(path = %path.display(), "Automation: recognizing text"); + + let text = crate::screenshot_editor::recognize_text_from_image_path(path).await?; + + if text.trim().is_empty() { + return Err("No text recognized in image".to_string()); + } + + self.clipboard + .write() + .await + .set_text(text) + .map_err(|e| format!("Failed to set clipboard text: {e}"))?; + + Ok(()) + } + + async fn notify( + &self, + _ctx: &TriggerContext, + title_template: &str, + body_template: &str, + ) -> Result<(), String> { + use tauri_plugin_notification::NotificationExt; + + let enabled = crate::general_settings::GeneralSettingsStore::get(&self.app) + .map(|s| s.is_some_and(|s| s.enable_notifications)) + .unwrap_or(false); + + if !enabled { + return Ok(()); + } + + self.app + .notification() + .builder() + .title(title_template) + .body(body_template) + .show() + .map_err(|e| format!("Failed to send notification: {e}"))?; + + Ok(()) + } + + async fn open_editor(&self, ctx: &TriggerContext) -> Result<(), String> { + if let Some(image_path) = ctx.image_path.as_ref() { + let _ = crate::windows::ShowCapWindow::ScreenshotEditor { + path: image_path.clone(), + } + .show(&self.app) + .await; + return Ok(()); + } + + let path = ctx + .project_path + .as_ref() + .ok_or("No project path for editor")?; + + let _ = crate::windows::ShowCapWindow::Editor { + project_path: path.clone(), + } + .show(&self.app) + .await; + + Ok(()) + } + + async fn apply_preset(&self, ctx: &TriggerContext, name: &str) -> Result<(), String> { + let project_path = ctx + .project_path + .as_ref() + .ok_or("No project path for preset")?; + + let preset = crate::presets::PresetsStore::get_by_name(&self.app, name)? + .ok_or_else(|| format!("Preset '{name}' not found"))?; + + info!(preset = name, project = %project_path.display(), "Automation: applying preset"); + + let existing_timeline = cap_project::ProjectConfiguration::load(project_path) + .ok() + .and_then(|c| c.timeline); + + let mut config = preset.config; + if config.timeline.is_none() { + config.timeline = existing_timeline; + } + + config + .write(project_path) + .map_err(|e| format!("Failed to write project config: {e}"))?; + + Ok(()) + } + + async fn delete_local_files(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .project_path + .as_ref() + .ok_or("No project path for deletion")?; + + info!(path = %path.display(), "Automation: deleting local files"); + tokio::fs::remove_dir_all(path) + .await + .map_err(|e| format!("Failed to delete: {e}"))?; + + Ok(()) + } +} + +fn reveal_path(path: &Path) -> Result<(), String> { + let path_str = path.to_str().ok_or("Invalid path")?; + + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("-R") + .arg(path_str) + .spawn() + .map_err(|e| format!("Failed to reveal: {e}"))?; + } + + #[cfg(target_os = "windows")] + { + std::process::Command::new("explorer") + .args(["/select,", path_str]) + .spawn() + .map_err(|e| format!("Failed to reveal: {e}"))?; + } + + #[cfg(target_os = "linux")] + { + let parent = path.parent().and_then(|p| p.to_str()).unwrap_or(path_str); + std::process::Command::new("xdg-open") + .arg(parent) + .spawn() + .map_err(|e| format!("Failed to reveal: {e}"))?; + } + + Ok(()) +} + +fn build_desktop_export_settings(profile: &ExportProfile) -> crate::export::ExportSettings { + let compression = profile + .compression + .map(|c| match c { + AutomationExportCompression::Maximum => cap_export::mp4::ExportCompression::Maximum, + AutomationExportCompression::Social => cap_export::mp4::ExportCompression::Social, + AutomationExportCompression::Web => cap_export::mp4::ExportCompression::Web, + AutomationExportCompression::Potato => cap_export::mp4::ExportCompression::Potato, + }) + .unwrap_or(cap_export::mp4::ExportCompression::Web); + + match profile.format { + ExportFormat::Mp4 => { + crate::export::ExportSettings::Mp4(cap_export::mp4::Mp4ExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + compression, + custom_bpp: None, + force_ffmpeg_decoder: false, + optimize_filesize: false, + }) + } + ExportFormat::Gif => { + crate::export::ExportSettings::Gif(cap_export::gif::GifExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + quality: None, + }) + } + ExportFormat::Mov => { + crate::export::ExportSettings::Mov(cap_export::mov::MovExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + cursor_only: false, + }) + } + } +} + +fn apply_filename_template(template: &str, ctx: &TriggerContext) -> String { + let now = chrono::Local::now(); + let mut result = template.to_string(); + result = result.replace("{date}", &now.format("%Y-%m-%d").to_string()); + result = result.replace("{time}", &now.format("%H-%M-%S").to_string()); + result = result.replace("{datetime}", &now.format("%Y-%m-%d_%H-%M-%S").to_string()); + if let Some(ref title) = ctx.window_title { + result = result.replace("{window}", title); + } + result +} + +fn apply_body_template(template: &str, ctx: &TriggerContext) -> String { + let mut result = template.to_string(); + if let Some(ref p) = ctx.project_path { + result = result.replace("{project_path}", &p.to_string_lossy()); + } + if let Some(ref p) = ctx.image_path { + result = result.replace("{image_path}", &p.to_string_lossy()); + } + if let Some(ref p) = ctx.output_path { + result = result.replace("{output_path}", &p.to_string_lossy()); + } + if let Some(ref l) = ctx.share_link { + result = result.replace("{share_link}", l); + } + result +} + +pub fn capture_target_kind(target: &ScreenCaptureTarget) -> Option { + match target { + ScreenCaptureTarget::Window { .. } => Some(CaptureTargetKind::Window), + ScreenCaptureTarget::Display { .. } => Some(CaptureTargetKind::Display), + ScreenCaptureTarget::Area { .. } => Some(CaptureTargetKind::Area), + ScreenCaptureTarget::CameraOnly => None, + } +} + +fn build_host(app: &AppHandle) -> Option { + let clipboard = app + .try_state::>>() + .map(|s| s.inner().clone())?; + Some(DesktopAutomationHost::new(app.clone(), clipboard)) +} + +pub async fn run_trigger(app: &AppHandle, trigger: Trigger, ctx: TriggerContext) { + let store = match get_store(app) { + Ok(Some(store)) => store, + Ok(None) => return, + Err(e) => { + error!("Failed to load automations store: {e}"); + return; + } + }; + + if store.rules.is_empty() { + return; + } + + let Some(host) = build_host(app) else { + warn!("Automation host unavailable (clipboard state missing)"); + return; + }; + + let results = cap_automation::run(&host, &store, &trigger, &ctx).await; + for result in &results { + for action in &result.action_results { + if let Some(error) = &action.error { + warn!( + rule_id = %result.rule_id, + error = %error, + "Automation action did not complete" + ); + } + } + } +} + +pub fn should_open_screenshot_editor(app: &AppHandle, target: &ScreenCaptureTarget) -> bool { + let store = match get_store(app) { + Ok(Some(store)) => store, + _ => return true, + }; + + if store.rules.is_empty() { + return true; + } + + let mut ctx = TriggerContext::new(); + if let Some(kind) = capture_target_kind(target) { + ctx = ctx.with_capture_target(kind); + } + if let Some(title) = target.title() { + ctx = ctx.with_window_title(title); + } + + !cap_automation::has_skip_editor(&store, &Trigger::ScreenshotTaken, &ctx) +} + +// `None` means a matching `SkipEditor` rule asked for a headless flow, so the caller must suppress +// both the editor and the recordings overlay. Automation rules win over the user's default behaviour. +pub fn studio_recording_editor_behaviour( + app: &AppHandle, + project_path: &Path, + duration_secs: f64, + default: PostStudioRecordingBehaviour, +) -> Option { + let store = match get_store(app) { + Ok(Some(store)) if !store.rules.is_empty() => store, + _ => return Some(default), + }; + + let mut ctx = TriggerContext::new() + .with_project_path(project_path.to_path_buf()) + .with_recording_mode(AutomationRecordingMode::Studio); + if duration_secs > 0.0 { + ctx = ctx.with_duration(duration_secs); + } + + if cap_automation::has_skip_editor(&store, &Trigger::StudioRecordingFinished, &ctx) { + None + } else if cap_automation::has_open_editor(&store, &Trigger::StudioRecordingFinished, &ctx) { + Some(PostStudioRecordingBehaviour::OpenEditor) + } else { + Some(default) + } +} + +pub fn run_screenshot_automations( + app: AppHandle, + image_path: PathBuf, + target: &ScreenCaptureTarget, +) { + let project_path = image_path.parent().map(Path::to_path_buf); + let capture_target = capture_target_kind(target); + let window_title = target.title(); + + tokio::spawn(async move { + let mut ctx = TriggerContext::new().with_image_path(image_path); + if let Some(project_path) = project_path { + ctx = ctx.with_project_path(project_path); + } + if let Some(kind) = capture_target { + ctx = ctx.with_capture_target(kind); + } + if let Some(title) = window_title { + ctx = ctx.with_window_title(title); + } + + run_trigger(&app, Trigger::ScreenshotTaken, ctx).await; + }); +} + +pub fn run_studio_recording_automations(app: AppHandle, project_path: PathBuf, duration_secs: f64) { + tokio::spawn(async move { + let mut ctx = TriggerContext::new() + .with_project_path(project_path) + .with_recording_mode(AutomationRecordingMode::Studio); + if duration_secs > 0.0 { + ctx = ctx.with_duration(duration_secs); + } + run_trigger(&app, Trigger::StudioRecordingFinished, ctx).await; + }); +} + +pub fn run_instant_recording_automations( + app: AppHandle, + project_path: PathBuf, + share_link: Option, + share_id: Option, +) { + tokio::spawn(async move { + let mut ctx = TriggerContext::new() + .with_project_path(project_path) + .with_recording_mode(AutomationRecordingMode::Instant); + if let Some(link) = share_link { + ctx = ctx.with_share_link(link); + } + if let Some(id) = share_id { + ctx = ctx.with_share_id(id); + } + run_trigger(&app, Trigger::InstantRecordingFinished, ctx).await; + }); +} + +pub fn run_upload_completed_automations( + app: AppHandle, + project_path: PathBuf, + share_link: Option, + share_id: Option, +) { + tokio::spawn(async move { + let mut ctx = TriggerContext::new().with_project_path(project_path); + if let Some(link) = share_link { + ctx = ctx.with_share_link(link); + } + if let Some(id) = share_id { + ctx = ctx.with_share_id(id); + } + run_trigger(&app, Trigger::UploadCompleted, ctx).await; + }); +} + +pub fn run_video_imported_automations(app: AppHandle, project_path: PathBuf) { + tokio::spawn(async move { + let ctx = TriggerContext::new().with_project_path(project_path); + run_trigger(&app, Trigger::VideoImported, ctx).await; + }); +} + +pub fn run_recording_started_automations(app: AppHandle) { + tokio::spawn(async move { + run_trigger(&app, Trigger::RecordingStarted, TriggerContext::new()).await; + }); +} + +pub fn run_recording_deleted_automations(app: AppHandle, project_path: PathBuf) { + tokio::spawn(async move { + let ctx = TriggerContext::new().with_project_path(project_path); + run_trigger(&app, Trigger::RecordingDeleted, ctx).await; + }); +} + +pub fn get_store(app: &AppHandle) -> Result, String> { + match app.store("store").map(|s| s.get("automations")) { + Ok(Some(store)) => match serde_json::from_value(store) { + Ok(settings) => Ok(Some(settings)), + Err(e) => { + error!("Failed to deserialize automations store: {e}"); + Ok(None) + } + }, + _ => Ok(None), + } +} + +#[tauri::command] +#[specta::specta] +pub async fn get_automations(app: AppHandle) -> Result { + Ok(get_store(&app)?.unwrap_or_default()) +} + +#[tauri::command] +#[specta::specta] +pub async fn set_automations(app: AppHandle, store: AutomationsStore) -> Result<(), String> { + let tauri_store = app.store("store").map_err(|e| e.to_string())?; + tauri_store.set("automations", json!(store)); + tauri_store.save().map_err(|e| e.to_string()) +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct AutomationActionCheck { + pub action_type: String, + pub capability: String, + pub supported: bool, +} + +#[derive(serde::Serialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub struct AutomationTestReport { + pub rule_id: String, + pub rule_name: String, + pub action_checks: Vec, +} + +#[tauri::command] +#[specta::specta] +pub async fn test_automation( + app: AppHandle, + rule_id: String, +) -> Result { + let store = get_store(&app)?.unwrap_or_default(); + let rule = store + .rules + .iter() + .find(|r| r.id == rule_id) + .ok_or_else(|| format!("Rule '{rule_id}' not found"))?; + + let clipboard = app + .try_state::>>() + .map(|s| s.inner().clone()); + + let supported: Vec = if let Some(clipboard) = clipboard { + let host = DesktopAutomationHost::new(app.clone(), clipboard); + host.capabilities().to_vec() + } else { + Vec::new() + }; + + let action_checks = rule + .actions + .iter() + .map(|action| { + let cap = action.required_capability(); + AutomationActionCheck { + action_type: format!("{action:?}") + .split_whitespace() + .next() + .unwrap_or("Unknown") + .to_string(), + capability: cap.map_or_else(|| "None".to_string(), |c| format!("{c:?}")), + supported: cap.is_none_or(|c| supported.contains(&c)), + } + }) + .collect(); + + Ok(AutomationTestReport { + rule_id: rule.id.clone(), + rule_name: rule.name.clone(), + action_checks, + }) +} + +#[tauri::command] +#[specta::specta] +pub async fn automation_should_open_screenshot_editor( + app: AppHandle, + target: ScreenCaptureTarget, +) -> bool { + should_open_screenshot_editor(&app, &target) +} + +#[tauri::command] +#[specta::specta] +pub async fn list_automation_capabilities() -> Vec { + vec![ + "CopyToClipboard".to_string(), + "SaveToLocation".to_string(), + "Export".to_string(), + "Upload".to_string(), + "RevealInFileManager".to_string(), + "OpenFile".to_string(), + "RunCommand".to_string(), + "Webhook".to_string(), + "RecognizeText".to_string(), + "Notify".to_string(), + "OpenEditor".to_string(), + "ApplyPreset".to_string(), + "DeleteLocalFiles".to_string(), + ] +} From 37f77ff3eb2e4f45fe859f3faa63b4ad3365162d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 09/28] feat(desktop): register automation commands types and event listeners --- apps/desktop/src-tauri/src/lib.rs | 41 ++++++++++++++++++++++++++++--- apps/desktop/src/utils/tauri.ts | 30 ++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 33d0dc2e071..e83ed12b7f3 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod api; mod audio; mod audio_meter; mod auth; +mod automation; mod camera; mod camera_legacy; #[cfg(target_os = "macos")] @@ -2130,9 +2131,8 @@ pub struct NewStudioRecordingAdded { path: PathBuf, } -#[derive(specta::Type, tauri_specta::Event, Debug, Clone, Serialize)] +#[derive(Deserialize, specta::Type, tauri_specta::Event, Debug, Clone, Serialize)] pub struct RecordingDeleted { - #[allow(unused)] path: PathBuf, } @@ -4338,6 +4338,11 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { recovery::find_incomplete_recordings, recovery::recover_recording, recovery::discard_incomplete_recording, + automation::get_automations, + automation::set_automations, + automation::test_automation, + automation::automation_should_open_screenshot_editor, + automation::list_automation_capabilities, ]) .events(tauri_specta::collect_events![ RecordingOptionsChanged, @@ -4374,7 +4379,20 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .typ::() .typ::() .typ::() - .typ::(); + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::() + .typ::(); #[cfg(debug_assertions)] if let Err(err) = specta_builder.export( @@ -4777,6 +4795,23 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { } }); + RecordingStarted::listen_any_spawn(&app, async |_event, app| { + crate::automation::run_recording_started_automations(app); + }); + + RecordingDeleted::listen_any_spawn(&app, async |event, app| { + crate::automation::run_recording_deleted_automations(app, event.path); + }); + + import::VideoImportProgress::listen_any_spawn(&app, async |event, app| { + if matches!(event.stage, import::ImportStage::Complete) { + crate::automation::run_video_imported_automations( + app, + std::path::PathBuf::from(event.project_path), + ); + } + }); + let app_handle = app.clone(); app.deep_link().on_open_url(move |event| { deeplink_actions::handle(&app_handle, event.urls()); diff --git a/apps/desktop/src/utils/tauri.ts b/apps/desktop/src/utils/tauri.ts index 0add9196ab7..a1eb5452d64 100644 --- a/apps/desktop/src/utils/tauri.ts +++ b/apps/desktop/src/utils/tauri.ts @@ -411,6 +411,21 @@ async recoverRecording(projectPath: string) : Promise { }, async discardIncompleteRecording(projectPath: string) : Promise { return await TAURI_INVOKE("discard_incomplete_recording", { projectPath }); +}, +async getAutomations() : Promise { + return await TAURI_INVOKE("get_automations"); +}, +async setAutomations(store: AutomationsStore) : Promise { + return await TAURI_INVOKE("set_automations", { store }); +}, +async testAutomation(ruleId: string) : Promise { + return await TAURI_INVOKE("test_automation", { ruleId }); +}, +async automationShouldOpenScreenshotEditor(target: ScreenCaptureTarget) : Promise { + return await TAURI_INVOKE("automation_should_open_screenshot_editor", { target }); +}, +async listAutomationCapabilities() : Promise { + return await TAURI_INVOKE("list_automation_capabilities"); } } @@ -477,6 +492,7 @@ videoImportProgress: "video-import-progress" /** user-defined types **/ +export type Action = { type: "copyToClipboard"; source?: ClipboardSource } | { type: "saveToLocation"; dir: string; filenameTemplate?: string | null } | { type: "export"; profile: ExportProfile; destination?: ExportDestination } | { type: "upload"; organizationId?: string | null; copyLink?: boolean; openInBrowser?: boolean } | { type: "revealInFileManager" } | { type: "openFile" } | { type: "runCommand"; program: string; args?: string[]; cwd?: string | null; env?: { [key in string]: string }; useShell?: boolean } | { type: "webhook"; url: string; method?: string; headers?: { [key in string]: string }; bodyTemplate?: string | null } | { type: "recognizeTextToClipboard" } | { type: "notify"; titleTemplate?: string; bodyTemplate?: string } | { type: "openEditor" } | { type: "skipEditor" } | { type: "applyPreset"; name: string } | { type: "deleteLocalFiles" } export type Annotation = { id: string; type: AnnotationType; x: number; y: number; width: number; height: number; strokeColor: string; strokeWidth: number; fillColor: string; opacity: number; rotation: number; text: string | null; maskType?: MaskType | null; maskLevel?: number | null } export type AnnotationType = "arrow" | "circle" | "rectangle" | "text" | "mask" export type AppTheme = "system" | "light" | "dark" @@ -510,6 +526,12 @@ export type AudioInputLevelChange = number export type AudioMeta = { path: string; start_time?: number | null; device_id?: string | null; gap_summary?: AudioGapSummary | null } export type AuthSecret = { api_key: string } | { token: string; expires: number } export type AuthStore = { secret: AuthSecret; user_id: string | null; plan: Plan | null; organizations?: Organization[]; organizations_updated_at?: number | null } +export type AutomationActionCheck = { actionType: string; capability: string; supported: boolean } +export type AutomationExportCompression = "maximum" | "social" | "web" | "potato" +export type AutomationRecordingMode = "studio" | "instant" +export type AutomationRule = { id: string; name: string; enabled?: boolean; trigger: Trigger; matchMode?: MatchMode; conditions?: Condition[]; actions?: Action[] } +export type AutomationTestReport = { ruleId: string; ruleName: string; actionChecks: AutomationActionCheck[] } +export type AutomationsStore = { version?: number; rules?: AutomationRule[] } export type BackgroundBlurConfig = { mode: BackgroundBlurMode } export type BackgroundBlurMode = "off" | "light" | "heavy" export type BackgroundConfiguration = { source: BackgroundSource; blur: number; padding: number; rounding: number; roundingType: CornerStyle; inset: number; crop: Crop | null; shadow: number; advancedShadow: ShadowConfiguration | null; border: BorderConfiguration | null } @@ -534,6 +556,7 @@ export type CaptionWord = { text: string; start: number; end: number } export type CaptionsData = { segments: CaptionSegment[]; settings: CaptionSettings } export type CaptureDisplay = { id: DisplayId; name: string; refresh_rate: number } export type CaptureDisplayWithThumbnail = { id: DisplayId; name: string; refresh_rate: number; thumbnail: string | null } +export type CaptureTargetKind = "display" | "window" | "area" export type CaptureWindow = { id: WindowId; owner_name: string; name: string; bounds: LogicalBounds; refresh_rate: number; bundle_identifier: string | null } export type CaptureWindowWithThumbnail = { id: WindowId; owner_name: string; name: string; bounds: LogicalBounds; refresh_rate: number; thumbnail: string | null; app_icon: string | null; bundle_identifier: string | null } export type CliInstallStatus = { installDir: string; shimPath: string; targetPath: string; installed: boolean; onPath: boolean; conflict: string | null; pathEntry: string; shellCommand: string; @@ -545,7 +568,9 @@ pathConfigured: boolean } export type ClickSpringConfig = { tension: number; mass: number; friction: number } export type ClipConfiguration = { index: number; offsets: ClipOffsets } export type ClipOffsets = { camera?: number; mic?: number; system_audio?: number } +export type ClipboardSource = "raw" | "rendered" export type CommercialLicense = { licenseKey: string; expiryDate: number | null; refresh: number; activatedOn: number } +export type Condition = { type: "captureTargetIs"; target: CaptureTargetKind } | { type: "recordingModeIs"; mode: AutomationRecordingMode } | { type: "durationAtLeast"; secs: number } | { type: "durationAtMost"; secs: number } | { type: "windowTitleContains"; pattern: string } | { type: "organizationIs"; id: string } export type CornerStyle = "squircle" | "rounded" export type Crop = { position: XY; size: XY } export type CurrentRecording = { target: CurrentRecordingTarget; mode: RecordingMode; status: RecordingStatus } @@ -564,9 +589,12 @@ export type DownloadProgress = { progress: number; message: string } export type EditorPreviewQuality = "quarter" | "half" | "full" export type EditorStateChanged = { playhead_position: number } export type ExportCompression = "Maximum" | "Social" | "Web" | "Potato" +export type ExportDestination = "projectFolder" | { customPath: { dir: string } } export type ExportEstimates = { duration_seconds: number; estimated_time_seconds: number; estimated_size_mb: number } +export type ExportFormat = "mp4" | "gif" | "mov" export type ExportPreviewResult = { jpeg_base64: string; estimated_size_mb: number; actual_width: number; actual_height: number; frame_render_time_ms: number; total_frames: number } export type ExportPreviewSettings = { fps: number; resolution_base: XY; compression_bpp: number; cursor_only?: boolean } +export type ExportProfile = { format: ExportFormat; fps?: number; resolutionBase?: XY; compression?: AutomationExportCompression | null; presetName?: string | null } export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format: "Gif" } & GifExportSettings) | ({ format: "Mov" } & MovExportSettings) export type FileType = "recording" | "screenshot" export type Flags = { captions: boolean } @@ -608,6 +636,7 @@ export type MaskScalarKeyframe = { time: number; value: number } export type MaskSegment = { start: number; end: number; track?: number; enabled?: boolean; maskType: MaskKind; center: XY; size: XY; feather?: number; opacity?: number; pixelation?: number; darkness?: number; fadeDuration?: number; keyframes?: MaskKeyframes } export type MaskType = "blur" | "pixelate" export type MaskVectorKeyframe = { time: number; x: number; y: number } +export type MatchMode = "all" | "any" export type MicrophoneDeviceSettings = { sampleRate: number | null; channels: number | null } export type MicrophoneFormatInfo = { sampleRate: number; channels: number } export type MicrophoneInfo = { name: string; sampleRate: number; channels: number; formats: MicrophoneFormatInfo[] } @@ -682,6 +711,7 @@ export type TextSegment = { start: number; end: number; track?: number; enabled? export type TimelineConfiguration = { segments: TimelineSegment[]; zoomSegments: ZoomSegment[]; sceneSegments?: SceneSegment[]; maskSegments?: MaskSegment[]; textSegments?: TextSegment[]; captionSegments?: CaptionTrackSegment[]; keyboardSegments?: KeyboardTrackSegment[] } export type TimelineSegment = { recordingSegment?: number; timescale: number; start: number; end: number } export type TranscriptionEngine = "Whisper" | "Parakeet" +export type Trigger = "screenshotTaken" | "studioRecordingFinished" | "instantRecordingFinished" | "recordingStarted" | "uploadCompleted" | "videoImported" | "recordingDeleted" export type UploadMeta = { state: "MultipartUpload"; video_id: string; file_path: string; pre_created_video: VideoUploadInfo; recording_dir: string } | { state: "SinglePartUpload"; video_id: string; recording_dir: string; file_path: string; screenshot_path: string } | { state: "SegmentUpload"; video_id: string; pre_created_video: VideoUploadInfo; recording_dir: string } | { state: "Failed"; error: string } | { state: "Complete" } export type UploadMode = { Initial: { pre_created_video: VideoUploadInfo | null } } | "Reupload" export type UploadProgress = { progress: number } From 1e7c737195064b4f25601f7acbb4b7a208e13c65 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 10/28] feat(desktop): wire automations into recording and screenshot flows --- apps/desktop/src-tauri/src/recording.rs | 163 +++++++++++++++--------- 1 file changed, 106 insertions(+), 57 deletions(-) diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index 2788e3a0df9..dc38a4e8fcb 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -2573,6 +2573,8 @@ pub async fn take_screenshot( tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + let automation_target = target.clone(); + let image = capture_screenshot(target) .await .map_err(|e| format!("Failed to capture screenshot: {e}"))?; @@ -2702,6 +2704,12 @@ pub async fn take_screenshot( } .emit(&app_handle); + crate::automation::run_screenshot_automations( + app_handle.clone(), + image_path_for_emit.clone(), + &automation_target, + ); + notifications::send_notification( &app_handle, notifications::NotificationType::ScreenshotSaved, @@ -2876,6 +2884,63 @@ async fn handle_recording_end( Ok(()) } +fn compute_studio_duration_secs(recording_dir: &std::path::Path) -> f64 { + let Ok(meta) = RecordingMeta::load_for_project(recording_dir) else { + return 0.0; + }; + let Some(studio_meta) = meta.studio_meta() else { + return 0.0; + }; + ProjectRecordingsMeta::new(&recording_dir.to_path_buf(), studio_meta) + .map(|r| r.duration()) + .unwrap_or(0.0) +} + +async fn apply_post_studio_editor_behaviour( + app: &AppHandle, + recording_dir: PathBuf, + duration_secs: f64, +) { + let default = GeneralSettingsStore::get(app) + .ok() + .flatten() + .map(|v| v.post_studio_recording_behaviour) + .unwrap_or(PostStudioRecordingBehaviour::OpenEditor); + + match crate::automation::studio_recording_editor_behaviour( + app, + &recording_dir, + duration_secs, + default, + ) { + Some(PostStudioRecordingBehaviour::OpenEditor) => { + let _ = ShowCapWindow::Editor { + project_path: recording_dir, + } + .show(app) + .await; + } + Some(PostStudioRecordingBehaviour::ShowOverlay) => { + let _ = ShowCapWindow::RecordingsOverlay.show(app).await; + + let app = AppHandle::clone(app); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1000)).await; + let _ = NewStudioRecordingAdded { + path: recording_dir, + } + .emit(&app); + }); + } + None => { + let _ = NewStudioRecordingAdded { + path: recording_dir, + } + .emit(app); + } + } +} + // runs when a recording successfully finishes async fn handle_recording_finish( app: &AppHandle, @@ -2911,34 +2976,8 @@ async fn handle_recording_finish( let finalizing_state = app.state::(); finalizing_state.start_finalizing(recording_dir.clone()); - let post_behaviour = GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|v| v.post_studio_recording_behaviour) - .unwrap_or(PostStudioRecordingBehaviour::OpenEditor); - - match post_behaviour { - PostStudioRecordingBehaviour::OpenEditor => { - let _ = ShowCapWindow::Editor { - project_path: recording_dir.clone(), - } - .show(app) - .await; - } - PostStudioRecordingBehaviour::ShowOverlay => { - let _ = ShowCapWindow::RecordingsOverlay.show(app).await; - - let app_clone = AppHandle::clone(app); - let recording_dir_clone = recording_dir.clone(); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(1000)).await; - let _ = NewStudioRecordingAdded { - path: recording_dir_clone, - } - .emit(&app_clone); - }); - } - } + let duration = compute_studio_duration_secs(&recording_dir); + apply_post_studio_editor_behaviour(app, recording_dir.clone(), duration).await; AppSounds::StopRecording.play(); @@ -2961,8 +3000,17 @@ async fn handle_recording_finish( ) .await; - if let Err(e) = result { - error!("Failed to finalize recording: {e}"); + match result { + Ok(()) => { + let duration = + compute_studio_duration_secs(&recording_dir_for_finalize); + crate::automation::run_studio_recording_automations( + app.clone(), + recording_dir_for_finalize.clone(), + duration, + ); + } + Err(e) => error!("Failed to finalize recording: {e}"), } app.state::() @@ -3079,6 +3127,12 @@ async fn handle_recording_finish( if upload_succeeded { info!("Segment upload succeeded"); + crate::automation::run_upload_completed_automations( + app.clone(), + recording_dir.clone(), + Some(video_upload_info.link.clone()), + Some(video_upload_info.id.clone()), + ); } else { crate::upload::emit_upload_complete(&app, &video_upload_info.id); } @@ -3137,6 +3191,8 @@ async fn handle_recording_finish( } }; + let instant_share = sharing.as_ref().map(|s| (s.link.clone(), s.id.clone())); + if let RecordingMetaInner::Instant(_) = &meta_inner && let Ok(mut meta) = RecordingMeta::load_for_project(&recording_dir).map_err(|err| { error!("Failed to load recording meta while saving finished recording: {err}") @@ -3148,34 +3204,27 @@ async fn handle_recording_finish( .map_err(|e| format!("Failed to save recording meta: {e}"))?; } - if let RecordingMetaInner::Studio(_) = meta_inner { - match GeneralSettingsStore::get(app) - .ok() - .flatten() - .map(|v| v.post_studio_recording_behaviour) - .unwrap_or(PostStudioRecordingBehaviour::OpenEditor) - { - PostStudioRecordingBehaviour::OpenEditor => { - let _ = ShowCapWindow::Editor { - project_path: recording_dir, - } - .show(app) - .await; - } - PostStudioRecordingBehaviour::ShowOverlay => { - let _ = ShowCapWindow::RecordingsOverlay.show(app).await; - - let app = AppHandle::clone(app); - tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(1000)).await; - - let _ = NewStudioRecordingAdded { - path: recording_dir.clone(), - } - .emit(&app); - }); - } + if let RecordingMetaInner::Instant(_) = &meta_inner { + let (link, id) = match instant_share { + Some((link, id)) => (Some(link), Some(id)), + None => (None, None), }; + crate::automation::run_instant_recording_automations( + app.clone(), + recording_dir.clone(), + link, + id, + ); + } + + if let RecordingMetaInner::Studio(_) = meta_inner { + let duration = compute_studio_duration_secs(&recording_dir); + crate::automation::run_studio_recording_automations( + app.clone(), + recording_dir.clone(), + duration, + ); + apply_post_studio_editor_behaviour(app, recording_dir, duration).await; } // Play sound to indicate recording has stopped From dcc00c7cac897bbe92005d233109bcc561796a20 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 11/28] feat(desktop): respect automation editor actions in hotkeys and tray --- apps/desktop/src-tauri/src/hotkeys.rs | 12 ++++++++---- apps/desktop/src-tauri/src/tray.rs | 7 +++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src-tauri/src/hotkeys.rs b/apps/desktop/src-tauri/src/hotkeys.rs index eccd9e700bf..97c9718a2b3 100644 --- a/apps/desktop/src-tauri/src/hotkeys.rs +++ b/apps/desktop/src-tauri/src/hotkeys.rs @@ -210,9 +210,11 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin let display = Display::get_containing_cursor().unwrap_or_else(Display::primary); let target = ScreenCaptureTarget::Display { id: display.id() }; - match recording::take_screenshot(app.clone(), target).await { + match recording::take_screenshot(app.clone(), target.clone()).await { Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + if crate::automation::should_open_screenshot_editor(&app, &target) { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + } Ok(()) } Err(e) => Err(format!("Failed to take screenshot: {e}")), @@ -227,9 +229,11 @@ async fn handle_hotkey(app: AppHandle, action: HotkeyAction) -> Result<(), Strin ScreenCaptureTarget::Window { id: window.id() } }; - match recording::take_screenshot(app.clone(), target).await { + match recording::take_screenshot(app.clone(), target.clone()).await { Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + if crate::automation::should_open_screenshot_editor(&app, &target) { + let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + } Ok(()) } Err(e) => Err(format!("Failed to take screenshot: {e}")), diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index 348145a2519..2f0e85302e4 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -724,9 +724,12 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Display::get_containing_cursor().unwrap_or_else(Display::primary); let target = ScreenCaptureTarget::Display { id: display.id() }; - match recording::take_screenshot(app.clone(), target).await { + match recording::take_screenshot(app.clone(), target.clone()).await { Ok(path) => { - let _ = ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + if crate::automation::should_open_screenshot_editor(&app, &target) { + let _ = + ShowCapWindow::ScreenshotEditor { path }.show(&app).await; + } } Err(e) => { tracing::error!("Failed to take screenshot: {e}"); From 1a25426d26d27321a84de1edd20f5751bfc45ad2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 12/28] feat(desktop): add automations settings utilities --- apps/desktop/src/utils/automations.ts | 268 ++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 apps/desktop/src/utils/automations.ts diff --git a/apps/desktop/src/utils/automations.ts b/apps/desktop/src/utils/automations.ts new file mode 100644 index 00000000000..7b5f993aa69 --- /dev/null +++ b/apps/desktop/src/utils/automations.ts @@ -0,0 +1,268 @@ +import type { + Action as ActionBinding, + AutomationActionCheck, + AutomationExportCompression, + AutomationRecordingMode, + AutomationRule as AutomationRuleBinding, + AutomationsStore as AutomationsStoreBinding, + AutomationTestReport, + CaptureTargetKind, + ClipboardSource, + Condition, + ExportDestination, + ExportFormat, + ExportProfile as ExportProfileBinding, + MatchMode, + Trigger, +} from "~/utils/tauri"; +import { commands } from "~/utils/tauri"; + +export type { + AutomationActionCheck, + AutomationRecordingMode, + AutomationTestReport, + CaptureTargetKind, + ClipboardSource, + Condition, + ExportDestination, + ExportFormat, + MatchMode, + Trigger, +}; + +// The specta-generated bindings in `tauri.ts` are the single source of truth for the automation +// data model. Their fields are optional (serde `#[serde(default)]`), but the editor always builds +// fully-populated objects, so strict, fully-required variants are derived here. A change to the Rust +// types regenerates `tauri.ts` and flows through automatically — there is no parallel definition to +// drift out of sync. +type DeepRequired = T extends (infer U)[] + ? DeepRequired[] + : T extends object + ? { [K in keyof T]-?: DeepRequired } + : T; + +export type ExportCompression = AutomationExportCompression; +export type ExportProfile = DeepRequired; +export type Action = DeepRequired; +export type ActionType = Action["type"]; +export type AutomationRule = DeepRequired; +export type AutomationsStore = DeepRequired; + +export const TRIGGER_LABELS: Record = { + screenshotTaken: "On screenshot taken", + studioRecordingFinished: "On studio recording finished", + instantRecordingFinished: "On instant recording finished", + recordingStarted: "On recording started", + uploadCompleted: "On upload completed", + videoImported: "On video imported", + recordingDeleted: "On recording deleted", +}; + +export const ACTION_LABELS: Record = { + copyToClipboard: "Copy to clipboard", + saveToLocation: "Save to location", + export: "Export with profile", + upload: "Upload + copy link", + revealInFileManager: "Reveal in file manager", + openFile: "Open file", + runCommand: "Run command", + webhook: "Send webhook", + recognizeTextToClipboard: "Recognize text (OCR) to clipboard", + notify: "Show notification", + openEditor: "Open editor", + skipEditor: "Skip editor (headless)", + applyPreset: "Apply editor preset", + deleteLocalFiles: "Delete local files", +}; + +export const CONDITION_LABELS: Record = { + captureTargetIs: "Capture target is", + recordingModeIs: "Recording mode is", + durationAtLeast: "Duration at least (seconds)", + durationAtMost: "Duration at most (seconds)", + windowTitleContains: "Window title contains", + organizationIs: "Organization is", +}; + +export const DANGEROUS_ACTIONS: ActionType[] = ["runCommand", "webhook"]; + +type TriggerContextField = + | "captureTarget" + | "windowTitle" + | "recordingMode" + | "duration" + | "projectPath" + | "filePath" + | "shareLink"; + +// The contextual data each trigger actually provides at runtime, mirroring the Rust `TriggerContext` +// populated per trigger in `automation.rs`. Used to flag conditions/actions that depend on data a +// trigger never supplies, so they can be surfaced as no-ops in the editor instead of failing silently. +const TRIGGER_CONTEXT: Record = { + screenshotTaken: ["captureTarget", "windowTitle", "projectPath", "filePath"], + studioRecordingFinished: ["recordingMode", "duration", "projectPath"], + instantRecordingFinished: ["recordingMode", "projectPath", "shareLink"], + recordingStarted: [], + uploadCompleted: ["projectPath", "shareLink"], + videoImported: ["projectPath"], + recordingDeleted: ["projectPath"], +}; + +const CONDITION_REQUIRES: Record< + Condition["type"], + TriggerContextField | null +> = { + captureTargetIs: "captureTarget", + recordingModeIs: "recordingMode", + durationAtLeast: "duration", + durationAtMost: "duration", + windowTitleContains: "windowTitle", + organizationIs: null, +}; + +// Each action lists the context fields it can consume; it applies when the trigger provides at least +// one of them. Actions with no entry (notify, runCommand, webhook) always apply. +const ACTION_REQUIRES: Partial< + Record +> = { + copyToClipboard: ["filePath"], + saveToLocation: ["filePath"], + openFile: ["filePath"], + recognizeTextToClipboard: ["filePath"], + export: ["projectPath"], + applyPreset: ["projectPath"], + deleteLocalFiles: ["projectPath"], + revealInFileManager: ["filePath", "projectPath"], + openEditor: ["filePath", "projectPath"], + upload: ["filePath", "projectPath"], +}; + +// `skipEditor` only does anything for the two triggers whose post-capture window is gated on it. +const SKIP_EDITOR_TRIGGERS: readonly Trigger[] = [ + "screenshotTaken", + "studioRecordingFinished", +]; + +export function conditionAppliesToTrigger( + type: Condition["type"], + trigger: Trigger, +): boolean { + const required = CONDITION_REQUIRES[type]; + if (required === null) return false; + return TRIGGER_CONTEXT[trigger].includes(required); +} + +export function actionAppliesToTrigger( + type: ActionType, + trigger: Trigger, +): boolean { + if (type === "skipEditor") return SKIP_EDITOR_TRIGGERS.includes(trigger); + const required = ACTION_REQUIRES[type]; + if (!required) return true; + const provided = TRIGGER_CONTEXT[trigger]; + return required.some((field) => provided.includes(field)); +} + +export function defaultActionForType(type: ActionType): Action { + switch (type) { + case "copyToClipboard": + return { type, source: "raw" }; + case "saveToLocation": + return { type, dir: "", filenameTemplate: null }; + case "export": + return { + type, + profile: { + format: "mp4", + fps: 30, + resolutionBase: { x: 1920, y: 1080 }, + compression: "web", + presetName: null, + }, + destination: "projectFolder", + }; + case "upload": + return { + type, + organizationId: null, + copyLink: true, + openInBrowser: false, + }; + case "revealInFileManager": + return { type }; + case "openFile": + return { type }; + case "runCommand": + return { + type, + program: "", + args: [], + cwd: null, + env: {}, + useShell: false, + }; + case "webhook": + return { + type, + url: "", + method: "POST", + headers: {}, + bodyTemplate: null, + }; + case "recognizeTextToClipboard": + return { type }; + case "notify": + return { type, titleTemplate: "Cap", bodyTemplate: "" }; + case "openEditor": + return { type }; + case "skipEditor": + return { type }; + case "applyPreset": + return { type, name: "" }; + case "deleteLocalFiles": + return { type }; + } +} + +export function defaultConditionForType(type: Condition["type"]): Condition { + switch (type) { + case "captureTargetIs": + return { type, target: "window" }; + case "recordingModeIs": + return { type, mode: "studio" }; + case "durationAtLeast": + return { type, secs: 5 }; + case "durationAtMost": + return { type, secs: 300 }; + case "windowTitleContains": + return { type, pattern: "" }; + case "organizationIs": + return { type, id: "" }; + } +} + +export function createEmptyRule(): AutomationRule { + return { + id: crypto.randomUUID(), + name: "", + enabled: true, + trigger: "screenshotTaken", + matchMode: "all", + conditions: [], + actions: [{ type: "copyToClipboard", source: "raw" }], + }; +} + +export async function getAutomations(): Promise { + return (await commands.getAutomations()) as AutomationsStore; +} + +export async function setAutomations(store: AutomationsStore): Promise { + await commands.setAutomations(store); +} + +export async function testAutomation( + ruleId: string, +): Promise { + return await commands.testAutomation(ruleId); +} From 8e48fec8c0929ff6cb93de2106e7caaf48f6b233 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 13/28] feat(desktop): add automations settings page --- .../(window-chrome)/settings/automations.tsx | 1476 +++++++++++++++++ 1 file changed, 1476 insertions(+) create mode 100644 apps/desktop/src/routes/(window-chrome)/settings/automations.tsx diff --git a/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx b/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx new file mode 100644 index 00000000000..2c47899ea24 --- /dev/null +++ b/apps/desktop/src/routes/(window-chrome)/settings/automations.tsx @@ -0,0 +1,1476 @@ +import { Button } from "@cap/ui-solid"; +import { CheckMenuItem, Menu } from "@tauri-apps/api/menu"; +import { open } from "@tauri-apps/plugin-dialog"; +import { cx } from "cva"; +import { + type Component, + createResource, + createSignal, + For, + type JSX, + Show, + Suspense, +} from "solid-js"; +import { createStore, produce } from "solid-js/store"; +import { Dynamic } from "solid-js/web"; +import toast from "solid-toast"; +import { Toggle } from "~/components/Toggle"; +import { presetsStore } from "~/store"; +import { + ACTION_LABELS, + type Action, + type ActionType, + type AutomationRecordingMode, + type AutomationRule, + type AutomationsStore, + type AutomationTestReport, + actionAppliesToTrigger, + type CaptureTargetKind, + type ClipboardSource, + CONDITION_LABELS, + type Condition, + conditionAppliesToTrigger, + createEmptyRule, + DANGEROUS_ACTIONS, + defaultActionForType, + defaultConditionForType, + type ExportCompression, + type ExportFormat, + getAutomations, + type MatchMode, + setAutomations, + TRIGGER_LABELS, + type Trigger, + testAutomation, +} from "~/utils/automations"; +import IconLucideBell from "~icons/lucide/bell"; +import IconLucideChevronDown from "~icons/lucide/chevron-down"; +import IconLucideChevronUp from "~icons/lucide/chevron-up"; +import IconLucideCirclePlay from "~icons/lucide/circle-play"; +import IconLucideClapperboard from "~icons/lucide/clapperboard"; +import IconLucideCloudUpload from "~icons/lucide/cloud-upload"; +import IconLucideCopy from "~icons/lucide/copy"; +import IconLucideFilm from "~icons/lucide/film"; +import IconLucideFolderDown from "~icons/lucide/folder-down"; +import IconLucideFolderOpen from "~icons/lucide/folder-open"; +import IconLucideImage from "~icons/lucide/image"; +import IconLucideImport from "~icons/lucide/import"; +import IconLucideLink from "~icons/lucide/link"; +import IconLucidePlus from "~icons/lucide/plus"; +import IconLucideScanText from "~icons/lucide/scan-text"; +import IconLucideTrash2 from "~icons/lucide/trash-2"; +import IconLucideWebhook from "~icons/lucide/webhook"; +import IconLucideX from "~icons/lucide/x"; +import IconLucideZap from "~icons/lucide/zap"; +import { Section, SectionCard, SettingsPageContent } from "./Setting"; + +const ALL_TRIGGERS: Trigger[] = [ + "screenshotTaken", + "studioRecordingFinished", + "instantRecordingFinished", + "recordingStarted", + "uploadCompleted", + "videoImported", + "recordingDeleted", +]; + +const ALL_ACTION_TYPES: ActionType[] = [ + "copyToClipboard", + "saveToLocation", + "export", + "upload", + "revealInFileManager", + "openFile", + "recognizeTextToClipboard", + "notify", + "openEditor", + "skipEditor", + "applyPreset", + "runCommand", + "webhook", + "deleteLocalFiles", +]; + +const ALL_CONDITION_TYPES: Condition["type"][] = [ + "captureTargetIs", + "recordingModeIs", + "durationAtLeast", + "durationAtMost", + "windowTitleContains", + "organizationIs", +]; + +type IconComponent = Component<{ class?: string }>; + +const TRIGGER_ICONS: Record = { + screenshotTaken: IconLucideImage, + studioRecordingFinished: IconLucideClapperboard, + instantRecordingFinished: IconLucideZap, + recordingStarted: IconLucideCirclePlay, + uploadCompleted: IconLucideCloudUpload, + videoImported: IconLucideImport, + recordingDeleted: IconLucideTrash2, +}; + +const TRIGGER_PHRASE: Record = { + screenshotTaken: "Screenshot taken", + studioRecordingFinished: "Studio recording ends", + instantRecordingFinished: "Instant recording ends", + recordingStarted: "Recording starts", + uploadCompleted: "Upload completes", + videoImported: "Video imported", + recordingDeleted: "Recording deleted", +}; + +const ACTION_SHORT: Record = { + copyToClipboard: "Copy to clipboard", + saveToLocation: "Save to folder", + export: "Export", + upload: "Upload & copy link", + revealInFileManager: "Reveal in file manager", + openFile: "Open file", + recognizeTextToClipboard: "Copy text (OCR)", + notify: "Notify", + openEditor: "Open editor", + skipEditor: "Skip editor", + applyPreset: "Apply preset", + runCommand: "Run command", + webhook: "Send webhook", + deleteLocalFiles: "Delete local files", +}; + +const TRIGGER_NOUN: Record = { + screenshotTaken: "Screenshot", + studioRecordingFinished: "Studio recording", + instantRecordingFinished: "Instant recording", + recordingStarted: "Recording start", + uploadCompleted: "Upload", + videoImported: "Import", + recordingDeleted: "Deletion", +}; + +const ACTION_NOUN: Record = { + copyToClipboard: "Clipboard", + saveToLocation: "Folder", + export: "Export", + upload: "Upload", + revealInFileManager: "Reveal", + openFile: "Open", + recognizeTextToClipboard: "Text", + notify: "Notify", + openEditor: "Editor", + skipEditor: "Skip editor", + applyPreset: "Preset", + runCommand: "Command", + webhook: "Webhook", + deleteLocalFiles: "Delete", +}; + +const FPS_PRESETS = [15, 30, 60] as const; + +const RESOLUTION_PRESETS = [ + { label: "720p", value: "720p", x: 1280, y: 720 }, + { label: "1080p", value: "1080p", x: 1920, y: 1080 }, + { label: "1440p", value: "1440p", x: 2560, y: 1440 }, + { label: "4K", value: "4k", x: 3840, y: 2160 }, +] as const; + +type Template = { + id: string; + name: string; + description: string; + icon: IconComponent; + build: () => AutomationRule; +}; + +function buildRule(opts: { + name: string; + trigger: Trigger; + actions: Action[]; + conditions?: Condition[]; + matchMode?: MatchMode; +}): AutomationRule { + return { + id: crypto.randomUUID(), + name: opts.name, + enabled: true, + trigger: opts.trigger, + matchMode: opts.matchMode ?? "all", + conditions: opts.conditions ?? [], + actions: opts.actions, + }; +} + +const TEMPLATES: Template[] = [ + { + id: "copy-screenshot", + name: "Auto-copy new screenshots to clipboard", + description: "Snap a screenshot and it's right there, ready to paste.", + icon: IconLucideCopy, + build: () => + buildRule({ + name: "Auto-copy new screenshots to clipboard", + trigger: "screenshotTaken", + actions: [{ type: "copyToClipboard", source: "raw" }], + }), + }, + { + id: "ocr-screenshot", + name: "Pull the text out of screenshots", + description: "Cap reads the text in your screenshot and copies it for you.", + icon: IconLucideScanText, + build: () => + buildRule({ + name: "Pull the text out of screenshots", + trigger: "screenshotTaken", + actions: [{ type: "recognizeTextToClipboard" }], + }), + }, + { + id: "save-screenshot", + name: "Tuck screenshots into a folder", + description: "Send every new screenshot straight to a folder you pick.", + icon: IconLucideFolderDown, + build: () => + buildRule({ + name: "Tuck screenshots into a folder", + trigger: "screenshotTaken", + actions: [defaultActionForType("saveToLocation")], + }), + }, + { + id: "reveal-screenshot", + name: "Jump to each new screenshot", + description: "Pop open every screenshot in Finder the moment you take it.", + icon: IconLucideFolderOpen, + build: () => + buildRule({ + name: "Jump to each new screenshot", + trigger: "screenshotTaken", + actions: [{ type: "revealInFileManager" }], + }), + }, + { + id: "export-studio", + name: "Auto-export when you finish recording", + description: "Render an MP4 the second a Studio recording wraps up.", + icon: IconLucideFilm, + build: () => + buildRule({ + name: "Auto-export when you finish recording", + trigger: "studioRecordingFinished", + actions: [defaultActionForType("export")], + }), + }, + { + id: "upload-share", + name: "Upload and grab the share link", + description: + "Finish a recording and the link is waiting on your clipboard.", + icon: IconLucideLink, + build: () => + buildRule({ + name: "Upload and grab the share link", + trigger: "studioRecordingFinished", + actions: [defaultActionForType("upload")], + }), + }, + { + id: "notify-upload", + name: "Ping me when an upload is ready", + description: "Get a gentle desktop nudge once your recording is shareable.", + icon: IconLucideBell, + build: () => + buildRule({ + name: "Ping me when an upload is ready", + trigger: "uploadCompleted", + actions: [ + { + type: "notify", + titleTemplate: "Cap", + bodyTemplate: "Your recording is ready to share.", + }, + ], + }), + }, + { + id: "webhook-share", + name: "Tell Slack when you share something", + description: "Send the share link to Slack, Discord, or your own webhook.", + icon: IconLucideWebhook, + build: () => + buildRule({ + name: "Tell Slack when you share something", + trigger: "instantRecordingFinished", + actions: [ + { + type: "webhook", + url: "", + method: "POST", + headers: {}, + bodyTemplate: '{"text":"{share_link}"}', + }, + ], + }), + }, +]; + +function ruleSummary(rule: AutomationRule): string { + const trigger = TRIGGER_PHRASE[rule.trigger]; + if (rule.actions.length === 0) return `${trigger} → no actions yet`; + const actions = rule.actions.map((a) => ACTION_SHORT[a.type]).join(", "); + return `${trigger} → ${actions}`; +} + +function autoRuleName(rule: AutomationRule): string { + const trigger = TRIGGER_NOUN[rule.trigger]; + const first = rule.actions[0]; + if (!first) return `${trigger} automation`; + return `${trigger} → ${ACTION_NOUN[first.type]}`; +} + +function ruleDisplayName(rule: AutomationRule): string { + return rule.name.trim() || autoRuleName(rule); +} + +const inputClass = + "w-full px-2.5 h-8 text-[13px] rounded-lg bg-gray-1 border border-gray-3 text-gray-12 outline-none transition-colors focus:border-gray-6 placeholder:text-gray-9"; + +function TextInput(props: { + value: string; + placeholder?: string; + onInput: (v: string) => void; +}) { + return ( + props.onInput(e.currentTarget.value)} + /> + ); +} + +function NumberInput(props: { value: number; onInput: (v: number) => void }) { + return ( + props.onInput(Number(e.currentTarget.value) || 0)} + /> + ); +} + +function SelectInput(props: { + value: T; + options: { value: T; label: string }[]; + onChange: (v: T) => void; + class?: string; +}) { + const current = () => props.options.find((o) => o.value === props.value); + + const openMenu = async () => { + const items = await Promise.all( + props.options.map((option) => + CheckMenuItem.new({ + text: option.label, + checked: option.value === props.value, + action: () => props.onChange(option.value), + }), + ), + ); + const menu = await Menu.new({ items }); + await menu.popup(); + await menu.close(); + }; + + return ( + + ); +} + +function Field(props: { label: string; children: JSX.Element }) { + return ( + + ); +} + +function GroupLabel(props: { children: JSX.Element }) { + return ( + + {props.children} + + ); +} + +function RowButton(props: { + onClick: () => void; + title: string; + children: JSX.Element; + disabled?: boolean; +}) { + return ( + + ); +} + +export default function AutomationsSettings() { + const [store, setStore] = createStore({ + version: 1, + rules: [], + }); + const [loaded, setLoaded] = createSignal(false); + const [expandedId, setExpandedId] = createSignal(null); + const [testReports, setTestReports] = createStore< + Record + >({}); + + const [initial] = createResource(async () => { + const data = await getAutomations(); + setStore({ + version: data.version ?? 1, + rules: data.rules ?? [], + }); + setLoaded(true); + return data; + }); + + const persist = async () => { + try { + await setAutomations({ + version: store.version, + rules: store.rules, + }); + } catch (e) { + console.error("Failed to save automations", e); + toast.error("Failed to save automations"); + } + }; + + const mutate = (fn: (s: AutomationsStore) => void) => { + setStore(produce(fn)); + void persist(); + }; + + const addRule = (rule: AutomationRule) => { + mutate((s) => { + s.rules.push(rule); + }); + setExpandedId(rule.id); + }; + + const addFromTemplate = (template: Template) => { + addRule(template.build()); + toast.success(`Added "${template.name}"`); + }; + + const removeRule = (id: string) => { + mutate((s) => { + const index = s.rules.findIndex((r) => r.id === id); + if (index >= 0) s.rules.splice(index, 1); + }); + if (expandedId() === id) setExpandedId(null); + }; + + const runTest = async (ruleId: string) => { + try { + const report = await testAutomation(ruleId); + setTestReports(ruleId, report); + const unsupported = report.actionChecks.filter((c) => !c.supported); + if (unsupported.length === 0) { + toast.success("All actions supported on this device"); + } else { + toast( + `${unsupported.length} action(s) not supported here: ${unsupported + .map((c) => c.actionType) + .join(", ")}`, + ); + } + } catch (e) { + console.error("Failed to test automation", e); + toast.error("Failed to test automation"); + } + }; + + return ( +
+ +
+ } + > + + 0} + fallback={ + addRule(createEmptyRule())} /> + } + > +
+ + {(rule, index) => ( + + setExpandedId((id) => + id === rule.id ? null : rule.id, + ) + } + onTest={() => runTest(rule.id)} + onRemove={() => removeRule(rule.id)} + onChange={(fn) => mutate((s) => fn(s.rules[index()]))} + /> + )} + + addRule(createEmptyRule())} /> +
+
+
+
+
+ +
+
+ + {(template) => ( + addFromTemplate(template)} + /> + )} + +
+
+
+
+ ); +} + +function EmptyState(props: { onCreate: () => void }) { + return ( + +
+
+ +
+

No automations yet

+

+ Pick a template below to get started in one click, or build your own + from scratch. +

+ +
+
+ ); +} + +function AddRuleButton(props: { onClick: () => void }) { + return ( + + ); +} + +function TemplateCard(props: { template: Template; onAdd: () => void }) { + return ( + + ); +} + +function RuleCard(props: { + rule: AutomationRule; + report?: AutomationTestReport; + expanded: boolean; + onToggleExpand: () => void; + onChange: (fn: (rule: AutomationRule) => void) => void; + onRemove: () => void; + onTest: () => void; +}) { + return ( + +
+
+ +
+ + + props.onChange((r) => { + r.enabled = v; + }) + } + /> + +
+ + +
+ +
+
+
+ ); +} + +function RuleEditorBody(props: { + rule: AutomationRule; + report?: AutomationTestReport; + onChange: (fn: (rule: AutomationRule) => void) => void; + onRemove: () => void; + onTest: () => void; +}) { + const hasDangerous = () => + props.rule.actions.some((a) => DANGEROUS_ACTIONS.includes(a.type)); + + const addCondition = () => + props.onChange((r) => { + const type = + ALL_CONDITION_TYPES.find((t) => + conditionAppliesToTrigger(t, r.trigger), + ) ?? "captureTargetIs"; + r.conditions.push(defaultConditionForType(type)); + }); + + const addAction = () => + props.onChange((r) => { + const type = actionAppliesToTrigger("copyToClipboard", r.trigger) + ? "copyToClipboard" + : (ALL_ACTION_TYPES.find((t) => actionAppliesToTrigger(t, r.trigger)) ?? + "notify"); + r.actions.push(defaultActionForType(type)); + }); + + return ( +
+ + + props.onChange((r) => { + r.name = v; + }) + } + /> + + +
+ When this happens + + value={props.rule.trigger} + options={ALL_TRIGGERS.map((t) => ({ + value: t, + label: TRIGGER_LABELS[t], + }))} + onChange={(v) => + props.onChange((r) => { + r.trigger = v; + }) + } + /> +
+ +
+
+ Only run if +
+ 1}> + + class="w-28" + value={props.rule.matchMode} + options={[ + { value: "all", label: "Match all" }, + { value: "any", label: "Match any" }, + ]} + onChange={(v) => + props.onChange((r) => { + r.matchMode = v; + }) + } + /> + + +
+
+ 0} + fallback={ +

+ Runs for every {TRIGGER_PHRASE[props.rule.trigger].toLowerCase()}. +

+ } + > +
+ + {(condition, ci) => ( + + props.onChange((r) => fn(r.conditions[ci()])) + } + onReplace={(next) => + props.onChange((r) => { + r.conditions[ci()] = next; + }) + } + onRemove={() => + props.onChange((r) => { + r.conditions.splice(ci(), 1); + }) + } + /> + )} + +
+
+
+ +
+
+ Then do this + +
+
+ + {(action, ai) => ( + props.onChange((r) => fn(r.actions[ai()]))} + onReplace={(next) => + props.onChange((r) => { + r.actions[ai()] = next; + }) + } + onRemove={() => + props.onChange((r) => { + r.actions.splice(ai(), 1); + }) + } + onMove={(dir) => + props.onChange((r) => { + const to = ai() + dir; + if (to < 0 || to >= r.actions.length) return; + const [moved] = r.actions.splice(ai(), 1); + r.actions.splice(to, 0, moved); + }) + } + /> + )} + +
+
+ + +

+ This automation runs commands or sends network requests. Only use + values you trust — they execute automatically with your permissions. +

+
+ +
+ + + + +
+
+ ); +} + +function ConditionRow(props: { + condition: Condition; + trigger: Trigger; + onChange: (fn: (condition: Condition) => void) => void; + onReplace: (next: Condition) => void; + onRemove: () => void; +}) { + const applies = () => + conditionAppliesToTrigger(props.condition.type, props.trigger); + return ( +
+
+
+ + value={props.condition.type} + options={ALL_CONDITION_TYPES.map((t) => ({ + value: t, + label: CONDITION_LABELS[t], + }))} + onChange={(t) => props.onReplace(defaultConditionForType(t))} + /> + +
+ + + +
+ +

+ This condition never matches for the selected trigger. +

+
+
+ ); +} + +function ConditionValue(props: { + condition: Condition; + onChange: (fn: (condition: Condition) => void) => void; +}) { + const c = props.condition; + switch (c.type) { + case "captureTargetIs": + return ( + + value={c.target} + options={[ + { value: "display", label: "Display" }, + { value: "window", label: "Window" }, + { value: "area", label: "Area" }, + ]} + onChange={(v) => + props.onChange((cond) => { + if (cond.type === "captureTargetIs") cond.target = v; + }) + } + /> + ); + case "recordingModeIs": + return ( + + value={c.mode} + options={[ + { value: "studio", label: "Studio" }, + { value: "instant", label: "Instant" }, + ]} + onChange={(v) => + props.onChange((cond) => { + if (cond.type === "recordingModeIs") cond.mode = v; + }) + } + /> + ); + case "durationAtLeast": + case "durationAtMost": + return ( + + props.onChange((cond) => { + if ( + cond.type === "durationAtLeast" || + cond.type === "durationAtMost" + ) + cond.secs = v; + }) + } + /> + ); + case "windowTitleContains": + return ( + + props.onChange((cond) => { + if (cond.type === "windowTitleContains") cond.pattern = v; + }) + } + /> + ); + case "organizationIs": + return ( + + props.onChange((cond) => { + if (cond.type === "organizationIs") cond.id = v; + }) + } + /> + ); + } +} + +function ActionRow(props: { + action: Action; + trigger: Trigger; + isFirst: boolean; + isLast: boolean; + support?: boolean; + onChange: (fn: (action: Action) => void) => void; + onReplace: (next: Action) => void; + onRemove: () => void; + onMove: (dir: -1 | 1) => void; +}) { + const applies = () => + actionAppliesToTrigger(props.action.type, props.trigger); + return ( +
+
+ + class="flex-1" + value={props.action.type} + options={ALL_ACTION_TYPES.map((t) => ({ + value: t, + label: ACTION_LABELS[t], + }))} + onChange={(t) => props.onReplace(defaultActionForType(t))} + /> + + + Skipped here + + + props.onMove(-1)} + title="Move up" + disabled={props.isFirst} + > + + + props.onMove(1)} + title="Move down" + disabled={props.isLast} + > + + + + + +
+ + +

+ This action has no effect for the selected trigger. +

+
+
+ ); +} + +function ActionParams(props: { + action: Action; + onChange: (fn: (action: Action) => void) => void; +}) { + const a = props.action; + switch (a.type) { + case "copyToClipboard": + return ( + + + value={a.source} + options={[ + { value: "raw", label: "Original capture" }, + { value: "rendered", label: "Edited / rendered" }, + ]} + onChange={(v) => + props.onChange((act) => { + if (act.type === "copyToClipboard") act.source = v; + }) + } + /> + + ); + case "saveToLocation": + return ( +
+ +
+ + props.onChange((act) => { + if (act.type === "saveToLocation") act.dir = v; + }) + } + /> + +
+
+ + + props.onChange((act) => { + if (act.type === "saveToLocation") + act.filenameTemplate = v.length > 0 ? v : null; + }) + } + /> + +
+ ); + case "export": + return ; + case "upload": + return ( +
+ + + props.onChange((act) => { + if (act.type === "upload") + act.organizationId = v.length > 0 ? v : null; + }) + } + /> + +
+ + +
+
+ ); + case "runCommand": + return ( +
+
+ + + props.onChange((act) => { + if (act.type === "runCommand") act.program = v; + }) + } + /> + + + + props.onChange((act) => { + if (act.type === "runCommand") + act.args = v.length > 0 ? v.split(" ") : []; + }) + } + /> + +
+ +
+ ); + case "webhook": + return ( +
+
+ + + props.onChange((act) => { + if (act.type === "webhook") act.url = v; + }) + } + /> + + + + class="w-28" + value={a.method} + options={[ + { value: "POST", label: "POST" }, + { value: "PUT", label: "PUT" }, + { value: "GET", label: "GET" }, + ]} + onChange={(v) => + props.onChange((act) => { + if (act.type === "webhook") act.method = v; + }) + } + /> + +
+ + + props.onChange((act) => { + if (act.type === "webhook") + act.bodyTemplate = v.length > 0 ? v : null; + }) + } + /> + +
+ ); + case "notify": + return ( +
+ + + props.onChange((act) => { + if (act.type === "notify") act.titleTemplate = v; + }) + } + /> + + + + props.onChange((act) => { + if (act.type === "notify") act.bodyTemplate = v; + }) + } + /> + +
+ ); + case "applyPreset": + return ( + + + props.onChange((act) => { + if (act.type === "applyPreset") act.name = name; + }) + } + /> + + ); + default: + return null; + } +} + +function PresetSelect(props: { + value: string; + allowNone?: boolean; + onChange: (name: string) => void; +}) { + const presets = presetsStore.createQuery(); + const names = () => presets.data?.presets.map((p) => p.name) ?? []; + const options = () => [ + ...(props.allowNone ? [{ value: "", label: "None" }] : []), + ...names().map((n) => ({ value: n, label: n })), + ]; + + return ( + 0} + fallback={ +

+ No presets yet — create one in the editor first. +

+ } + > + +
+ ); +} + +function ExportParams(props: { + action: Extract; + onChange: (fn: (action: Action) => void) => void; +}) { + const a = props.action; + const updateProfile = (fn: (p: typeof a.profile) => void) => + props.onChange((act) => { + if (act.type === "export") fn(act.profile); + }); + + const resolutionValue = () => + RESOLUTION_PRESETS.find( + (r) => + r.x === a.profile.resolutionBase.x && + r.y === a.profile.resolutionBase.y, + )?.value ?? "1080p"; + + return ( +
+
+ + + value={a.profile.format} + options={[ + { value: "mp4", label: "MP4" }, + { value: "gif", label: "GIF" }, + { value: "mov", label: "MOV" }, + ]} + onChange={(v) => + updateProfile((p) => { + p.format = v; + }) + } + /> + + + ({ + value: r.value, + label: r.label, + }))} + onChange={(v) => { + const preset = RESOLUTION_PRESETS.find((r) => r.value === v); + if (preset) + updateProfile((p) => { + p.resolutionBase = { x: preset.x, y: preset.y }; + }); + }} + /> + +
+
+ + ({ + value: String(f), + label: `${f} FPS`, + }))} + onChange={(v) => + updateProfile((p) => { + p.fps = Number(v); + }) + } + /> + + + + + value={a.profile.compression ?? "web"} + options={[ + { value: "maximum", label: "Maximum" }, + { value: "social", label: "Social" }, + { value: "web", label: "Web" }, + { value: "potato", label: "Potato" }, + ]} + onChange={(v) => + updateProfile((p) => { + p.compression = v; + }) + } + /> + + +
+ +
+ + props.onChange((act) => { + if (act.type === "export") + act.destination = + v.length > 0 ? { customPath: { dir: v } } : "projectFolder"; + }) + } + /> + +
+
+
+ ); +} From 759a6d4b2efa284632c21fb6ffbc66d06d10f991 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 14/28] feat(desktop): add automations route and settings nav entry --- apps/desktop/src/App.tsx | 4 ++++ apps/desktop/src/routes/(window-chrome)/settings.tsx | 6 ++++++ apps/desktop/src/store.ts | 2 ++ 3 files changed, 12 insertions(+) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 65d7debf377..495784a4790 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -40,6 +40,9 @@ const SettingsTranscriptionPage = lazy( const SettingsScreenshotsPage = lazy( () => import("./routes/(window-chrome)/settings/screenshots"), ); +const SettingsAutomationsPage = lazy( + () => import("./routes/(window-chrome)/settings/automations"), +); const SettingsHotkeysPage = lazy( () => import("./routes/(window-chrome)/settings/hotkeys"), ); @@ -178,6 +181,7 @@ function Inner() { component={SettingsTranscriptionPage} /> + diff --git a/apps/desktop/src/routes/(window-chrome)/settings.tsx b/apps/desktop/src/routes/(window-chrome)/settings.tsx index bc4392a92ee..108167ad6c8 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings.tsx @@ -30,6 +30,7 @@ import { } from "~/utils/web-api"; import IconLucideTerminal from "~icons/lucide/terminal"; import IconLucideUserRound from "~icons/lucide/user-round"; +import IconLucideZap from "~icons/lucide/zap"; const USER_PROFILE_CACHE_GC_MS = 2 * 60 * 60 * 1000; const USER_PROFILE_REFRESH_INTERVAL_MS = 5 * 60 * 1000; @@ -216,6 +217,11 @@ export default function Settings(props: RouteSectionProps) { name: "Screenshots", icon: IconLucideImage, }, + { + href: "automations", + name: "Automations", + icon: IconLucideZap, + }, { href: "transcription", name: "Transcription", diff --git a/apps/desktop/src/store.ts b/apps/desktop/src/store.ts index 06a22d576bb..0bfd56852f9 100644 --- a/apps/desktop/src/store.ts +++ b/apps/desktop/src/store.ts @@ -1,6 +1,7 @@ import { createQuery } from "@tanstack/solid-query"; import { Store } from "@tauri-apps/plugin-store"; import { onCleanup } from "solid-js"; +import type { AutomationsStore } from "~/utils/automations"; import type { GeneralSettingsStore } from "~/utils/general-settings"; import type { AuthStore, @@ -73,6 +74,7 @@ function declareStore(name: string, defaults?: T) { export const presetsStore = declareStore("presets"); export const authStore = declareStore("auth"); +export const automationsStore = declareStore("automations"); export const userProfileStore = declareStore("user_profile"); export const hotkeysStore = declareStore("hotkeys"); export const generalSettingsStore = From 13e443f131b242ada517ab142499af7f4b753853 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 15/28] feat(desktop): gate screenshot editor on automation rules in overlay --- .../src/routes/target-select-overlay.tsx | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/routes/target-select-overlay.tsx b/apps/desktop/src/routes/target-select-overlay.tsx index 21b58e77a64..01f8e9df4ae 100644 --- a/apps/desktop/src/routes/target-select-overlay.tsx +++ b/apps/desktop/src/routes/target-select-overlay.tsx @@ -4,7 +4,6 @@ import { createElementSize } from "@solid-primitives/resize-observer"; import { makePersisted } from "@solid-primitives/storage"; import { useSearchParams } from "@solidjs/router"; import { createMutation, useQuery } from "@tanstack/solid-query"; -import { invoke } from "@tauri-apps/api/core"; import { LogicalPosition, type PhysicalPosition, @@ -1073,10 +1072,12 @@ function Inner() { } await new Promise((resolve) => setTimeout(resolve, 50)); - const path = await invoke("take_screenshot", { - target, - }); - await commands.showWindow({ ScreenshotEditor: { path } }); + const path = await commands.takeScreenshot(target); + const shouldOpenEditor = + await commands.automationShouldOpenScreenshotEditor(target); + if (shouldOpenEditor) { + await commands.showWindow({ ScreenshotEditor: { path } }); + } await commands.closeTargetSelectOverlays(); } catch (e) { const message = e instanceof Error ? e.message : String(e); @@ -1716,10 +1717,14 @@ function RecordingControls(props: { } } - const path = await invoke("take_screenshot", { - target: props.target, - }); - await commands.showWindow({ ScreenshotEditor: { path } }); + const path = await commands.takeScreenshot(props.target); + const shouldOpenEditor = + await commands.automationShouldOpenScreenshotEditor( + props.target, + ); + if (shouldOpenEditor) { + await commands.showWindow({ ScreenshotEditor: { path } }); + } await commands.closeTargetSelectOverlays(); } catch (e) { const message = e instanceof Error ? e.message : String(e); From 995692a5de2a11ab32fca9b0bf5148691fa6dbcc Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 16/28] feat(cli): add automation host module --- apps/cli/Cargo.toml | 2 + apps/cli/src/automation.rs | 565 +++++++++++++++++++++++++++++++++++++ 2 files changed, 567 insertions(+) create mode 100644 apps/cli/src/automation.rs diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index d6afc5a4b1c..9d7f7d024bc 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -9,6 +9,7 @@ clap_complete = "4.5.38" cap-project = { path = "../../crates/project" } cap-recording = { path = "../../crates/recording" } cap-export = { path = "../../crates/export" } +cap-automation = { path = "../../crates/automation" } cap-camera = { path = "../../crates/camera" } cap-cli-install = { path = "../../crates/cli-install" } scap-targets = { path = "../../crates/scap-targets" } @@ -24,6 +25,7 @@ flume = { workspace = true } futures = { workspace = true } dirs = "6.0.0" image = "0.25.2" +chrono = "0.4.31" tracing.workspace = true tracing-subscriber = "0.3.19" workspace-hack = { version = "0.1", path = "../../crates/workspace-hack" } diff --git a/apps/cli/src/automation.rs b/apps/cli/src/automation.rs new file mode 100644 index 00000000000..90a682b8de9 --- /dev/null +++ b/apps/cli/src/automation.rs @@ -0,0 +1,565 @@ +//! Runs Cap automation rules from the CLI, sharing the exact rule model and engine the desktop app +//! uses (`cap_automation`). Rules are authored in Cap Desktop and persisted to its tauri-plugin-store +//! file; the CLI reads that file directly (same approach as `credentials.rs`) so a rule like +//! "on screenshot, save to ~/Shots" is honored whether the capture came from the app or `cap`. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use cap_automation::{ + AutomationExportCompression, AutomationHost, AutomationRecordingMode, AutomationsStore, + Capability, ClipboardSource, ExportDestination, ExportFormat, ExportProfile, Trigger, + TriggerContext, +}; +use cap_recording::screen_capture::ScreenCaptureTarget; +use serde_json::Value; + +const DESKTOP_BUNDLE_IDS: [&str; 2] = ["so.cap.desktop", "so.cap.desktop.dev"]; + +const WEBHOOK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +const COMMAND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +fn load_desktop_store_value() -> Option { + let data_dir = dirs::data_dir()?; + DESKTOP_BUNDLE_IDS.into_iter().find_map(|id| { + let bytes = std::fs::read(data_dir.join(id).join("store")).ok()?; + serde_json::from_slice::(&bytes).ok() + }) +} + +pub fn load_store() -> Option { + cap_automation::load_store_from_json(&load_desktop_store_value()?) +} + +/// `(total_rules, enabled_rules)` configured in Cap Desktop, for `cap doctor`. +pub fn rule_counts() -> (usize, usize) { + let store = load_store().unwrap_or_default(); + let enabled = store.rules.iter().filter(|r| r.enabled).count(); + (store.rules.len(), enabled) +} + +fn capture_target_kind(target: &ScreenCaptureTarget) -> Option { + match target { + ScreenCaptureTarget::Window { .. } => Some(cap_automation::CaptureTargetKind::Window), + ScreenCaptureTarget::Display { .. } => Some(cap_automation::CaptureTargetKind::Display), + ScreenCaptureTarget::Area { .. } => Some(cap_automation::CaptureTargetKind::Area), + ScreenCaptureTarget::CameraOnly => None, + } +} + +struct CliAutomationHost; + +impl AutomationHost for CliAutomationHost { + fn capabilities(&self) -> &[Capability] { + &[ + Capability::SaveToLocation, + Capability::Export, + Capability::Upload, + Capability::RevealInFileManager, + Capability::OpenFile, + Capability::RunCommand, + Capability::Webhook, + Capability::ApplyPreset, + Capability::DeleteLocalFiles, + ] + } + + async fn copy_to_clipboard( + &self, + _ctx: &TriggerContext, + _source: &ClipboardSource, + ) -> Result<(), String> { + Err("Clipboard is not available from the CLI".to_string()) + } + + async fn save_to_location( + &self, + ctx: &TriggerContext, + dir: &str, + filename_template: Option<&str>, + ) -> Result<(), String> { + let src = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No file path available for save")?; + + let filename = match filename_template { + Some(tmpl) => apply_filename_template(tmpl, ctx), + None => src + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "capture".to_string()), + }; + + let dst = PathBuf::from(dir).join(filename); + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create dir: {e}"))?; + } + std::fs::copy(src, &dst).map_err(|e| format!("Failed to copy file: {e}"))?; + tracing::info!(dst = %dst.display(), "automation: saved file"); + Ok(()) + } + + async fn export( + &self, + ctx: &TriggerContext, + profile: &ExportProfile, + destination: &ExportDestination, + ) -> Result<(), String> { + let project_path = ctx + .project_path + .as_ref() + .ok_or("No project path available for export")?; + + let output_path = match destination { + ExportDestination::ProjectFolder => None, + ExportDestination::CustomPath { dir } => { + let ext = match profile.format { + ExportFormat::Mp4 => "mp4", + ExportFormat::Gif => "gif", + ExportFormat::Mov => "mov", + }; + let name = project_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "export".to_string()); + Some(PathBuf::from(dir).join(format!("{name}.{ext}"))) + } + }; + + let mut builder = cap_export::ExporterBase::builder(project_path.clone()); + if let Some(ref out) = output_path { + builder = builder.with_output_path(out.clone()); + } + let base = builder.build().await.map_err(|e| format!("{e}"))?; + + let result = match profile.format { + ExportFormat::Mp4 => { + cap_export::mp4::Mp4ExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + compression: map_compression(profile.compression), + custom_bpp: None, + force_ffmpeg_decoder: false, + optimize_filesize: false, + } + .export(base, |_| true) + .await + } + ExportFormat::Gif => { + cap_export::gif::GifExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + quality: None, + } + .export(base, |_| true) + .await + } + ExportFormat::Mov => { + cap_export::mov::MovExportSettings { + fps: profile.fps, + resolution_base: profile.resolution_base, + cursor_only: false, + } + .export(base, |_| true) + .await + } + } + .map_err(|e| format!("Export failed: {e}"))?; + + tracing::info!(output = %result.display(), "automation: export complete"); + Ok(()) + } + + async fn upload( + &self, + ctx: &TriggerContext, + _organization_id: Option<&str>, + _copy_link: bool, + open_in_browser: bool, + ) -> Result<(), String> { + if let Some(link) = ctx.share_link.as_deref() { + tracing::info!(link = %link, "automation: recording already uploaded, reusing link"); + if open_in_browser { + open_path_or_url(link)?; + } + return Ok(()); + } + + let project_path = ctx + .project_path + .as_ref() + .ok_or("CLI upload supports recordings only (no project path available)")?; + + let meta = cap_project::RecordingMeta::load_for_project(project_path) + .map_err(|e| format!("Failed to load project: {e}"))?; + let output = meta.output_path(); + if !output.exists() { + return Err(format!( + "No exported video at {}; add an Export action before Upload", + output.display() + )); + } + + let link = + crate::upload::upload_video_path(&output, Some(meta.pretty_name.clone())).await?; + tracing::info!(link = %link, "automation: upload complete"); + if open_in_browser { + open_path_or_url(&link)?; + } + Ok(()) + } + + async fn reveal_in_file_manager(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .or(ctx.project_path.as_ref()) + .ok_or("No path available to reveal")?; + open_path_or_url(&path.to_string_lossy()) + } + + async fn open_file(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .image_path + .as_ref() + .or(ctx.output_path.as_ref()) + .ok_or("No file path available to open")?; + open_path_or_url(&path.to_string_lossy()) + } + + async fn run_command( + &self, + ctx: &TriggerContext, + program: &str, + args: &[String], + cwd: Option<&str>, + env: &HashMap, + use_shell: bool, + ) -> Result<(), String> { + let mut cmd = if use_shell { + let shell_line = cap_automation::shell_command_line(program, args); + #[cfg(target_os = "windows")] + let mut c = tokio::process::Command::new("cmd"); + #[cfg(target_os = "windows")] + c.args(["/C", &shell_line]); + #[cfg(not(target_os = "windows"))] + let mut c = tokio::process::Command::new("sh"); + #[cfg(not(target_os = "windows"))] + c.args(["-c", &shell_line]); + c + } else { + let mut c = tokio::process::Command::new(program); + c.args(args); + c + }; + + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + for (k, v) in env { + cmd.env(k, v); + } + for (key, value) in context_env(ctx) { + cmd.env(key, value); + } + + // Kill the child if the timeout drops the future, so a hung command can't outlive the run. + cmd.kill_on_drop(true); + let output = tokio::time::timeout(COMMAND_TIMEOUT, cmd.output()) + .await + .map_err(|_| format!("Command timed out after {}s", COMMAND_TIMEOUT.as_secs()))? + .map_err(|e| format!("Failed to run command: {e}"))?; + if !output.status.success() { + return Err(format!( + "Command exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(()) + } + + async fn webhook( + &self, + ctx: &TriggerContext, + url: &str, + method: &str, + headers: &HashMap, + body_template: Option<&str>, + ) -> Result<(), String> { + let client = reqwest::Client::builder() + .timeout(WEBHOOK_TIMEOUT) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let method = method + .parse::() + .map_err(|e| format!("Invalid HTTP method: {e}"))?; + let body = body_template + .map(|tmpl| apply_body_template(tmpl, ctx)) + .unwrap_or_else(|| { + serde_json::to_string(&serde_json::json!({ + "project_path": ctx.project_path, + "image_path": ctx.image_path, + "output_path": ctx.output_path, + "share_link": ctx.share_link, + })) + .unwrap_or_default() + }); + + let mut req = client.request(method, url).body(body); + for (k, v) in headers { + req = req.header(k, v); + } + let resp = req + .send() + .await + .map_err(|e| format!("Webhook request failed: {e}"))?; + if !resp.status().is_success() { + return Err(format!("Webhook returned status {}", resp.status())); + } + Ok(()) + } + + async fn recognize_text_to_clipboard(&self, _ctx: &TriggerContext) -> Result<(), String> { + Err("OCR is not available from the CLI".to_string()) + } + + async fn notify( + &self, + _ctx: &TriggerContext, + _title_template: &str, + _body_template: &str, + ) -> Result<(), String> { + Err("Notifications are not available from the CLI".to_string()) + } + + async fn open_editor(&self, _ctx: &TriggerContext) -> Result<(), String> { + Err("Opening the editor is not available from the CLI".to_string()) + } + + async fn apply_preset(&self, ctx: &TriggerContext, name: &str) -> Result<(), String> { + let project_path = ctx + .project_path + .as_ref() + .ok_or("No project path for preset")?; + + let store = load_desktop_store_value().ok_or("Cap Desktop store not found")?; + let presets = store + .get("presets") + .and_then(|p| p.get("presets")) + .and_then(Value::as_array) + .ok_or("No presets found in Cap Desktop store")?; + + let preset = presets + .iter() + .find(|p| p.get("name").and_then(Value::as_str) == Some(name)) + .ok_or_else(|| format!("Preset '{name}' not found"))?; + + let config_value = preset.get("config").ok_or("Preset has no config")?; + let mut config: cap_project::ProjectConfiguration = + serde_json::from_value(config_value.clone()) + .map_err(|e| format!("Failed to parse preset config: {e}"))?; + + if config.timeline.is_none() { + config.timeline = cap_project::ProjectConfiguration::load(project_path) + .ok() + .and_then(|c| c.timeline); + } + + config + .write(project_path) + .map_err(|e| format!("Failed to write project config: {e}"))?; + Ok(()) + } + + async fn delete_local_files(&self, ctx: &TriggerContext) -> Result<(), String> { + let path = ctx + .project_path + .as_ref() + .ok_or("No project path for deletion")?; + std::fs::remove_dir_all(path).map_err(|e| format!("Failed to delete: {e}")) + } +} + +fn map_compression( + compression: Option, +) -> cap_export::mp4::ExportCompression { + match compression { + Some(AutomationExportCompression::Maximum) => cap_export::mp4::ExportCompression::Maximum, + Some(AutomationExportCompression::Social) => cap_export::mp4::ExportCompression::Social, + Some(AutomationExportCompression::Web) | None => cap_export::mp4::ExportCompression::Web, + Some(AutomationExportCompression::Potato) => cap_export::mp4::ExportCompression::Potato, + } +} + +fn context_env(ctx: &TriggerContext) -> Vec<(&'static str, String)> { + let mut env = Vec::new(); + if let Some(p) = &ctx.project_path { + env.push(("CAP_PROJECT_PATH", p.to_string_lossy().to_string())); + } + if let Some(p) = &ctx.image_path { + env.push(("CAP_IMAGE_PATH", p.to_string_lossy().to_string())); + } + if let Some(p) = &ctx.output_path { + env.push(("CAP_OUTPUT_PATH", p.to_string_lossy().to_string())); + } + if let Some(l) = &ctx.share_link { + env.push(("CAP_SHARE_LINK", l.clone())); + } + env +} + +fn apply_filename_template(template: &str, ctx: &TriggerContext) -> String { + let now = chrono::Local::now(); + let mut result = template.to_string(); + result = result.replace("{date}", &now.format("%Y-%m-%d").to_string()); + result = result.replace("{time}", &now.format("%H-%M-%S").to_string()); + result = result.replace("{datetime}", &now.format("%Y-%m-%d_%H-%M-%S").to_string()); + if let Some(title) = &ctx.window_title { + result = result.replace("{window}", title); + } + result +} + +fn apply_body_template(template: &str, ctx: &TriggerContext) -> String { + let mut result = template.to_string(); + if let Some(p) = &ctx.project_path { + result = result.replace("{project_path}", &p.to_string_lossy()); + } + if let Some(p) = &ctx.image_path { + result = result.replace("{image_path}", &p.to_string_lossy()); + } + if let Some(p) = &ctx.output_path { + result = result.replace("{output_path}", &p.to_string_lossy()); + } + if let Some(l) = &ctx.share_link { + result = result.replace("{share_link}", l); + } + result +} + +fn open_path_or_url(target: &str) -> Result<(), String> { + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(target_os = "windows")] + let mut cmd = std::process::Command::new("explorer"); + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("xdg-open"); + + cmd.arg(target) + .spawn() + .map(|_| ()) + .map_err(|e| format!("Failed to open: {e}")) +} + +async fn run_trigger(trigger: Trigger, ctx: TriggerContext) { + let Some(store) = load_store() else { + return; + }; + if store.rules.is_empty() { + return; + } + + let host = CliAutomationHost; + let results = cap_automation::run(&host, &store, &trigger, &ctx).await; + for result in &results { + for action in &result.action_results { + if let Some(error) = &action.error { + tracing::warn!( + rule_id = %result.rule_id, + error = %error, + "automation action skipped or failed" + ); + } + } + } +} + +pub async fn run_screenshot(path: &Path, target: &ScreenCaptureTarget) { + if load_store().is_none() { + return; + } + + let mut ctx = TriggerContext::new().with_image_path(path.to_path_buf()); + if let Some(kind) = capture_target_kind(target) { + ctx = ctx.with_capture_target(kind); + } + if let Some(title) = target.title() { + ctx = ctx.with_window_title(title); + } + run_trigger(Trigger::ScreenshotTaken, ctx).await; +} + +pub async fn run_recording_finished(project_path: &Path, mode: AutomationRecordingMode) { + if load_store().is_none() { + return; + } + + let trigger = match mode { + AutomationRecordingMode::Studio => Trigger::StudioRecordingFinished, + AutomationRecordingMode::Instant => Trigger::InstantRecordingFinished, + }; + + let mut ctx = TriggerContext::new() + .with_project_path(project_path.to_path_buf()) + .with_recording_mode(mode); + + if let Ok(meta) = cap_project::RecordingMeta::load_for_project(project_path) + && let Some(sharing) = meta.sharing + { + ctx = ctx.with_share_link(sharing.link).with_share_id(sharing.id); + } + + run_trigger(trigger, ctx).await; +} + +pub async fn run_upload_completed(project_path: &Path, link: &str, id: &str) { + if load_store().is_none() { + return; + } + + let ctx = TriggerContext::new() + .with_project_path(project_path.to_path_buf()) + .with_share_link(link.to_string()) + .with_share_id(id.to_string()); + run_trigger(Trigger::UploadCompleted, ctx).await; +} + +/// `cap automations list` — print the automation rules shared with Cap Desktop. +pub fn list(format: crate::OutputFormat) -> Result<(), String> { + let store = load_store().unwrap_or_default(); + + match format { + crate::OutputFormat::Json => crate::write_json(&store), + crate::OutputFormat::Text => { + if store.rules.is_empty() { + println!( + "No automations configured. Add them in Cap Desktop under Settings > Automations." + ); + return Ok(()); + } + for rule in &store.rules { + let status = if rule.enabled { "enabled" } else { "disabled" }; + println!("{} [{}]", rule.name, status); + println!(" trigger: {:?}", rule.trigger); + if !rule.conditions.is_empty() { + println!(" conditions ({:?}):", rule.match_mode); + for condition in &rule.conditions { + println!(" - {condition:?}"); + } + } + println!(" actions:"); + for action in &rule.actions { + match action.required_capability() { + Some(cap) => println!(" - {cap:?}"), + None => println!(" - SkipEditor"), + } + } + } + Ok(()) + } + } +} From 6126b6b68bc8b13e2cc3b95af102259b2f2f4f97 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 17/28] feat(cli): add automations list subcommand --- apps/cli/src/main.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 55787e08780..36c54ee3a2c 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -1,3 +1,4 @@ +mod automation; mod credentials; mod doctor; mod export; @@ -128,6 +129,8 @@ enum Commands { Desktop(DesktopArgs), /// Print the machine-readable capability & JSON-schema manifest for agents Guide(FormatArgs), + /// List automation rules shared with Cap Desktop + Automations(AutomationsArgs), /// Generate shell completion scripts Completions(CompletionsArgs), } @@ -298,6 +301,18 @@ enum AuthCommands { Status(FormatArgs), } +#[derive(Args)] +struct AutomationsArgs { + #[command(subcommand)] + command: AutomationsCommands, +} + +#[derive(Subcommand)] +enum AutomationsCommands { + /// List the automation rules configured in Cap Desktop + List(FormatArgs), +} + #[derive(Args)] struct CompletionsArgs { #[arg(value_enum)] @@ -435,6 +450,12 @@ async fn run(cli: Cli) -> Result<(), String> { let format = resolve_format(json, args.format); finish_json(format, guide::run(format)) } + Commands::Automations(args) => match args.command { + AutomationsCommands::List(a) => { + let format = resolve_format(json, a.format); + finish_json(format, automation::list(format)) + } + }, Commands::Completions(args) => { args.run(); Ok(()) From aac1a5c646d331deebbc3febde28aa3cd2dbfd77 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 18/28] feat(cli): run automations after screenshot record and upload --- apps/cli/src/record.rs | 17 +++++++++++++++++ apps/cli/src/screenshot.rs | 4 ++++ apps/cli/src/upload.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/apps/cli/src/record.rs b/apps/cli/src/record.rs index 2ae52a02a81..fff1ae7a53c 100644 --- a/apps/cli/src/record.rs +++ b/apps/cli/src/record.rs @@ -228,9 +228,21 @@ async fn foreground_inner(params: RecordParams, format: OutputFormat) -> Result< } let completed = finalize(actor, params.duration, interactive, None).await?; + crate::automation::run_recording_finished( + completed.project_path(), + automation_mode(params.mode), + ) + .await; emit_stopped(format, &completed) } +fn automation_mode(mode: RecordMode) -> cap_automation::AutomationRecordingMode { + match mode { + RecordMode::Studio => cap_automation::AutomationRecordingMode::Studio, + RecordMode::Instant => cap_automation::AutomationRecordingMode::Instant, + } +} + async fn run_detached(params: RecordParams, format: OutputFormat) -> Result<(), String> { match detached_inner(params, format).await { Ok(()) => Ok(()), @@ -432,6 +444,11 @@ async fn session_worker(params: RecordParams, recording_id: &str) -> Result<(), let stop_path = session::stop_file(recording_id)?; let completed = finalize(actor, params.duration, false, Some(&stop_path)).await?; + crate::automation::run_recording_finished( + completed.project_path(), + automation_mode(params.mode), + ) + .await; let recording_meta_exists = completed .project_path() .join("recording-meta.json") diff --git a/apps/cli/src/screenshot.rs b/apps/cli/src/screenshot.rs index 118d58ea960..3dcc8a13738 100644 --- a/apps/cli/src/screenshot.rs +++ b/apps/cli/src/screenshot.rs @@ -57,6 +57,8 @@ impl Screenshot { } }; + let automation_target = target.clone(); + let image = capture_screenshot(target) .await .map_err(|e| format!("Screenshot failed: {e}"))?; @@ -65,6 +67,8 @@ impl Screenshot { .save(&self.path) .map_err(|e| format!("Failed to write screenshot to {}: {e}", self.path.display()))?; + crate::automation::run_screenshot(&self.path, &automation_target).await; + match format { OutputFormat::Json => write_json(&ScreenshotResult { path: self.path, diff --git a/apps/cli/src/upload.rs b/apps/cli/src/upload.rs index 5187ff0cafa..8c4c818a4b8 100644 --- a/apps/cli/src/upload.rs +++ b/apps/cli/src/upload.rs @@ -81,6 +81,16 @@ impl UploadArgs { upload_file(&http, &put_url, &file_path).await?; let link = format!("{server}/s/{video_id}"); + + let is_project = self.file.is_dir() + || self + .file + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("cap")); + if is_project { + crate::automation::run_upload_completed(&self.file, &link, &video_id).await; + } + match format { OutputFormat::Json => write_json(&UploadEvent::Uploaded { id: &video_id, @@ -133,6 +143,30 @@ impl UploadArgs { } } +/// Upload an existing MP4 video file and return its shareable link. Reuses the same credential +/// resolution and signed-upload flow as `cap upload`, so automation-driven uploads behave identically. +pub async fn upload_video_path(file_path: &Path, name: Option) -> Result { + if !is_mp4(file_path) { + return Err(format!( + "Automation upload only supports MP4 files; {} is not an .mp4", + file_path.display() + )); + } + + let creds = credentials::resolve()?; + let server = creds.server.clone(); + let meta = probe_video_meta(file_path)?; + + let http = Client::new(); + let auth = format!("Bearer {}", creds.api_key); + + let video_id = create_video(&http, &server, &auth, &name, None, &meta).await?; + let put_url = presign_put(&http, &server, &auth, &video_id, &meta).await?; + upload_file(&http, &put_url, file_path).await?; + + Ok(format!("{server}/s/{video_id}")) +} + fn probe_video_meta(path: &Path) -> Result { ffmpeg::init().map_err(|e| format!("Failed to initialise FFmpeg: {e}"))?; From c9bc08a23c942c00b47148e28d0d09acf69ae371 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 19/28] feat(cli): report automation rule counts in doctor output --- apps/cli/src/doctor.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/cli/src/doctor.rs b/apps/cli/src/doctor.rs index f821265eab5..0e035eea9f1 100644 --- a/apps/cli/src/doctor.rs +++ b/apps/cli/src/doctor.rs @@ -386,6 +386,13 @@ fn capture_ready(permissions: &Permissions) -> bool { } } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AutomationsInfo { + pub rule_count: usize, + pub enabled_count: usize, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Doctor { @@ -395,6 +402,8 @@ pub struct Doctor { #[serde(skip_serializing_if = "Option::is_none")] pub install: Option, pub checks: Vec, + /// Automation rules shared with Cap Desktop that the CLI honors after capture/upload. + pub automations: AutomationsInfo, /// Overall health: false when a required check (e.g. ffmpeg) failed. pub ok: bool, /// Whether a screen recording can start right now (screen recording permission granted). @@ -451,12 +460,18 @@ pub fn run_doctor(format: OutputFormat) -> Result<(), String> { .any(|check| matches!(check.status, CheckStatus::Fail)); let capture_ready = capture_ready(&permissions); + let (rule_count, enabled_count) = crate::automation::rule_counts(); + let doctor = Doctor { schema_version: SCHEMA_VERSION, version, permissions, install: install.ok(), checks, + automations: AutomationsInfo { + rule_count, + enabled_count, + }, ok, capture_ready, }; @@ -473,6 +488,10 @@ pub fn run_doctor(format: OutputFormat) -> Result<(), String> { println!(" executable: {path}"); } println!(" capture ready: {}", doctor.capture_ready); + println!( + " automations: {} rule(s), {} enabled", + doctor.automations.rule_count, doctor.automations.enabled_count + ); println!(); for check in &doctor.checks { println!( From 66c0eebb06015c61026fcd19b71396ed673550f9 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:40:13 +0100 Subject: [PATCH 20/28] docs(cli): document automations in README skill and guide --- apps/cli/README.md | 11 +++++++++++ apps/cli/skill/cap/SKILL.md | 14 ++++++++++++++ apps/cli/src/guide.rs | 9 +++++++++ 3 files changed, 34 insertions(+) diff --git a/apps/cli/README.md b/apps/cli/README.md index 02fa97474fb..2934dc22c7d 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -81,7 +81,18 @@ step. - `cap upload` — upload a `.cap` project or video file and get a shareable link. - `cap update` — download and install the latest Cap Desktop bundle, then repair the `cap` shim. - `cap doctor` / `version` / `guide` — diagnostics, version info, and the agent capability manifest. +- `cap automations list` — list the automation rules configured in Cap Desktop that the CLI honors. - `cap desktop status|install-cli|uninstall-cli` — manage the `cap` shim on PATH. - `cap completions ` — shell completion scripts (bash/zsh/fish/powershell). +## Automations + +Automations are `trigger → (conditions) → actions` rules authored in Cap Desktop (Settings → +Automations) and persisted to its store. Because the CLI shares that store (and the `cap-automation` +engine), it runs the same rules automatically after `cap screenshot`, a `cap record` finish, and +`cap upload` — e.g. "on screenshot, save a copy to `~/Shots` and POST a webhook". Clipboard, OCR, +notification, and open-editor actions are desktop-only and are skipped on the CLI; everything else +(save, export, upload, run command, webhook, reveal, apply preset, delete) runs. Inspect the active +rules with `cap automations list --json`. + Run `cap --help` or `cap --help` for full flag documentation. diff --git a/apps/cli/skill/cap/SKILL.md b/apps/cli/skill/cap/SKILL.md index f01015d3883..606a2c75fef 100644 --- a/apps/cli/skill/cap/SKILL.md +++ b/apps/cli/skill/cap/SKILL.md @@ -71,6 +71,20 @@ cap upload out.mp4 --json # -> {"type":"uploaded","id","lin or set `CAP_API_KEY` (a Cap auth key from Settings) for headless use. `cap upload --export --json` exports then uploads in one step. +## Automations + +Automations are `trigger -> (conditions) -> actions` rules authored in Cap Desktop (Settings → +Automations) and shared with the CLI. After `cap screenshot`, a `cap record` finish, and `cap upload`, +the CLI evaluates the matching rules and runs their actions (save to a folder, run a command, send a +webhook, export, etc.). + +```sh +cap automations list --json # the rules the CLI will honor +``` + +Desktop-only actions (copy to clipboard, OCR, notifications, open editor) are skipped on the CLI; all +others run. `cap doctor --json` reports the configured rule count under `automations`. + ## Conventions to rely on - Add `--json` to any command; it overrides each command's `--format`. diff --git a/apps/cli/src/guide.rs b/apps/cli/src/guide.rs index 9f32a1a6c58..32cc01e0a24 100644 --- a/apps/cli/src/guide.rs +++ b/apps/cli/src/guide.rs @@ -255,6 +255,12 @@ fn build() -> Guide { OutputMode::SingleJson, &[], ), + cmd( + "automations list", + "List the automation rules configured in Cap Desktop (Settings > Automations) that the CLI honors after screenshot/record/upload.", + OutputMode::SingleJson, + &[], + ), cmd( "completions", "Print a shell completion script for bash/zsh/fish/powershell.", @@ -267,6 +273,9 @@ fn build() -> Guide { ProjectConfiguration, export NDJSON) preserve their original field casing.", "`cap completions ` prints a shell completion script.", "Recording without --duration requires either --detach or an interactive terminal.", + "Automations authored in Cap Desktop run automatically after `cap screenshot`, `cap record` \ + finishes, and `cap upload`. Clipboard/OCR/notification/editor actions are desktop-only and \ + are skipped on the CLI. List them with `cap automations list`.", ], } } From ad4ed6726215133561fff033108c637b768d8f8e Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:12:46 +0100 Subject: [PATCH 21/28] docs(automation): document OrganizationIs condition as reserved --- crates/automation/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 763ec0b99f8..f1b9fc39fc1 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -170,6 +170,9 @@ fn evaluate_condition(condition: &Condition, ctx: &TriggerContext) -> bool { .window_title .as_ref() .is_some_and(|t| t.to_lowercase().contains(&pattern.to_lowercase())), + // Reserved for future per-organization filtering: no trigger currently populates + // `organization_id`, and the desktop UI hides this condition (CONDITION_REQUIRES maps it to + // null), so this arm stays inert until org context is plumbed through the trigger pipeline. Condition::OrganizationIs { id } => ctx.organization_id.as_ref() == Some(id), } } From 146c89a0d0c5c9b7b41c846c361b812d8cf59116 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:12:56 +0100 Subject: [PATCH 22/28] fix(automation): sanitize window titles in save-to-location filenames --- apps/cli/src/automation.rs | 4 ++-- apps/desktop/src-tauri/src/automation.rs | 4 ++-- crates/automation/src/lib.rs | 16 ++++++++++++++++ crates/automation/src/tests.rs | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/automation.rs b/apps/cli/src/automation.rs index 90a682b8de9..73df2cab118 100644 --- a/apps/cli/src/automation.rs +++ b/apps/cli/src/automation.rs @@ -9,7 +9,7 @@ use std::path::{Path, PathBuf}; use cap_automation::{ AutomationExportCompression, AutomationHost, AutomationRecordingMode, AutomationsStore, Capability, ClipboardSource, ExportDestination, ExportFormat, ExportProfile, Trigger, - TriggerContext, + TriggerContext, sanitize_filename_component, }; use cap_recording::screen_capture::ScreenCaptureTarget; use serde_json::Value; @@ -419,7 +419,7 @@ fn apply_filename_template(template: &str, ctx: &TriggerContext) -> String { result = result.replace("{time}", &now.format("%H-%M-%S").to_string()); result = result.replace("{datetime}", &now.format("%Y-%m-%d_%H-%M-%S").to_string()); if let Some(title) = &ctx.window_title { - result = result.replace("{window}", title); + result = result.replace("{window}", &sanitize_filename_component(title)); } result } diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index 7d238e257d8..4f7915e2291 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -1,7 +1,7 @@ use cap_automation::{ AutomationExportCompression, AutomationHost, AutomationRecordingMode, AutomationsStore, Capability, CaptureTargetKind, ClipboardSource, ExportDestination, ExportFormat, ExportProfile, - Trigger, TriggerContext, + Trigger, TriggerContext, sanitize_filename_component, }; use cap_recording::sources::screen_capture::ScreenCaptureTarget; use clipboard_rs::Clipboard; @@ -576,7 +576,7 @@ fn apply_filename_template(template: &str, ctx: &TriggerContext) -> String { result = result.replace("{time}", &now.format("%H-%M-%S").to_string()); result = result.replace("{datetime}", &now.format("%Y-%m-%d_%H-%M-%S").to_string()); if let Some(ref title) = ctx.window_title { - result = result.replace("{window}", title); + result = result.replace("{window}", &sanitize_filename_component(title)); } result } diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index f1b9fc39fc1..614f2064991 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -422,6 +422,22 @@ pub fn load_store_from_json(value: &serde_json::Value) -> Option String { + value + .chars() + .map(|c| match c { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + other if other.is_control() => '_', + other => other, + }) + .collect() +} + /// Build a single shell command line from a program and its arguments, quoting each token so that /// arguments containing spaces or shell metacharacters survive as written instead of being re-split /// by the shell. Used by hosts running `RunCommand { use_shell: true }`. diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 95f8e2d8eee..be89562c5d3 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -765,6 +765,20 @@ fn shell_command_line_escapes_embedded_quotes() { } } +#[test] +fn sanitize_filename_component_neutralizes_traversal_and_reserved_chars() { + assert_eq!( + sanitize_filename_component("../../etc/passwd"), + ".._.._etc_passwd" + ); + assert_eq!(sanitize_filename_component("C:\\Users\\me"), "C__Users_me"); + assert_eq!( + sanitize_filename_component("Slack | #general"), + "Slack _ #general" + ); + assert_eq!(sanitize_filename_component("plain title"), "plain title"); +} + #[test] fn multiple_rules_same_trigger() { let rule1 = AutomationRule { From 6630b19445c37e872c4319295c8686a0b6ccfcb2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:13:03 +0100 Subject: [PATCH 23/28] fix(automation): open files directly instead of revealing them --- apps/desktop/src-tauri/src/automation.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index 4f7915e2291..2d7e4d3637c 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -258,13 +258,21 @@ impl AutomationHost for DesktopAutomationHost { } async fn open_file(&self, ctx: &TriggerContext) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let path = ctx .image_path .as_ref() .or(ctx.output_path.as_ref()) .ok_or("No file path available to open")?; - reveal_path(path) + let path_str = path.to_str().ok_or("Invalid path")?; + self.app + .opener() + .open_path(path_str, None::) + .map_err(|e| format!("Failed to open file: {e}"))?; + + Ok(()) } async fn run_command( From e4dd48d2523f7e75254930caa5e1bb015d884e0b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:13:12 +0100 Subject: [PATCH 24/28] fix(automation): substitute template variables in notifications --- apps/desktop/src-tauri/src/automation.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index 2d7e4d3637c..86465539fab 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -415,7 +415,7 @@ impl AutomationHost for DesktopAutomationHost { async fn notify( &self, - _ctx: &TriggerContext, + ctx: &TriggerContext, title_template: &str, body_template: &str, ) -> Result<(), String> { @@ -429,11 +429,14 @@ impl AutomationHost for DesktopAutomationHost { return Ok(()); } + let title = apply_body_template(&apply_filename_template(title_template, ctx), ctx); + let body = apply_body_template(&apply_filename_template(body_template, ctx), ctx); + self.app .notification() .builder() - .title(title_template) - .body(body_template) + .title(title) + .body(body) .show() .map_err(|e| format!("Failed to send notification: {e}"))?; From 791654c0a73001a6be7b514ead018e5392e1b222 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:13:20 +0100 Subject: [PATCH 25/28] fix(automation): surface webhook body serialization errors --- apps/cli/src/automation.rs | 22 +++++++++++----------- apps/desktop/src-tauri/src/automation.rs | 22 +++++++++++----------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/apps/cli/src/automation.rs b/apps/cli/src/automation.rs index 73df2cab118..8b3572d1c9f 100644 --- a/apps/cli/src/automation.rs +++ b/apps/cli/src/automation.rs @@ -297,17 +297,17 @@ impl AutomationHost for CliAutomationHost { let method = method .parse::() .map_err(|e| format!("Invalid HTTP method: {e}"))?; - let body = body_template - .map(|tmpl| apply_body_template(tmpl, ctx)) - .unwrap_or_else(|| { - serde_json::to_string(&serde_json::json!({ - "project_path": ctx.project_path, - "image_path": ctx.image_path, - "output_path": ctx.output_path, - "share_link": ctx.share_link, - })) - .unwrap_or_default() - }); + let body = if let Some(tmpl) = body_template { + apply_body_template(tmpl, ctx) + } else { + serde_json::to_string(&serde_json::json!({ + "project_path": ctx.project_path, + "image_path": ctx.image_path, + "output_path": ctx.output_path, + "share_link": ctx.share_link, + })) + .map_err(|e| format!("Failed to serialize webhook body: {e}"))? + }; let mut req = client.request(method, url).body(body); for (k, v) in headers { diff --git a/apps/desktop/src-tauri/src/automation.rs b/apps/desktop/src-tauri/src/automation.rs index 86465539fab..c669255f455 100644 --- a/apps/desktop/src-tauri/src/automation.rs +++ b/apps/desktop/src-tauri/src/automation.rs @@ -360,17 +360,17 @@ impl AutomationHost for DesktopAutomationHost { .parse::() .map_err(|e| format!("Invalid HTTP method: {e}"))?; - let body = body_template - .map(|tmpl| apply_body_template(tmpl, ctx)) - .unwrap_or_else(|| { - serde_json::to_string(&serde_json::json!({ - "project_path": ctx.project_path, - "image_path": ctx.image_path, - "output_path": ctx.output_path, - "share_link": ctx.share_link, - })) - .unwrap_or_default() - }); + let body = if let Some(tmpl) = body_template { + apply_body_template(tmpl, ctx) + } else { + serde_json::to_string(&serde_json::json!({ + "project_path": ctx.project_path, + "image_path": ctx.image_path, + "output_path": ctx.output_path, + "share_link": ctx.share_link, + })) + .map_err(|e| format!("Failed to serialize webhook body: {e}"))? + }; let mut req = client.request(method, url).body(body); for (k, v) in headers { From a423e519c95afcc5230b63dc95c52250549bbf3b Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:45:59 +0100 Subject: [PATCH 26/28] fix(automation): close brace injection and harden filename sanitizer --- crates/automation/src/lib.rs | 14 ++++++++++++-- crates/automation/src/tests.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 614f2064991..c9e31114c06 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -428,14 +428,24 @@ pub fn load_store_from_json(value: &serde_json::Value) -> Option String { - value + let sanitized: String = value .chars() .map(|c| match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', + // Braces are template markers; replacing them stops a window title like `{image_path}` + // from acting as a live variable when the substituted value is run through a second + // template pass (e.g. notifications compose the filename and body templates). + '{' | '}' => '_', other if other.is_control() => '_', other => other, }) - .collect() + .take(128) + .collect(); + + // Windows rejects filenames ending in a space or '.', so trim those after clamping the length. + sanitized + .trim_end_matches(|c: char| c == ' ' || c == '.') + .to_string() } /// Build a single shell command line from a program and its arguments, quoting each token so that diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index be89562c5d3..22830f5c9f4 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -777,6 +777,18 @@ fn sanitize_filename_component_neutralizes_traversal_and_reserved_chars() { "Slack _ #general" ); assert_eq!(sanitize_filename_component("plain title"), "plain title"); + assert_eq!(sanitize_filename_component("{image_path}"), "_image_path_"); + assert_eq!(sanitize_filename_component("report. "), "report"); + assert_eq!( + sanitize_filename_component("trailing dots..."), + "trailing dots" + ); + assert_eq!( + sanitize_filename_component(&"a".repeat(200)) + .chars() + .count(), + 128 + ); } #[test] From 8de106540705caef2cac3e86204f79d7cdcf8d55 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:51:37 +0100 Subject: [PATCH 27/28] fix(automation): avoid empty sanitized filename components --- crates/automation/src/lib.rs | 9 ++++++--- crates/automation/src/tests.rs | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index c9e31114c06..e032fb16453 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -443,9 +443,12 @@ pub fn sanitize_filename_component(value: &str) -> String { .collect(); // Windows rejects filenames ending in a space or '.', so trim those after clamping the length. - sanitized - .trim_end_matches(|c: char| c == ' ' || c == '.') - .to_string() + let trimmed = sanitized.trim_end_matches(|c: char| c == ' ' || c == '.'); + if trimmed.is_empty() { + "_".to_string() + } else { + trimmed.to_string() + } } /// Build a single shell command line from a program and its arguments, quoting each token so that diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 22830f5c9f4..58ab5bbc89b 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -789,6 +789,9 @@ fn sanitize_filename_component_neutralizes_traversal_and_reserved_chars() { .count(), 128 ); + assert_eq!(sanitize_filename_component("..."), "_"); + assert_eq!(sanitize_filename_component(" "), "_"); + assert_eq!(sanitize_filename_component(""), "_"); } #[test] From 707fb860a9e144093a8e05aba37db86f3e6306f0 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:42:53 +0100 Subject: [PATCH 28/28] style(automation): use char-array pattern for filename trim --- crates/automation/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index e032fb16453..51f5e35169f 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -443,7 +443,7 @@ pub fn sanitize_filename_component(value: &str) -> String { .collect(); // Windows rejects filenames ending in a space or '.', so trim those after clamping the length. - let trimmed = sanitized.trim_end_matches(|c: char| c == ' ' || c == '.'); + let trimmed = sanitized.trim_end_matches([' ', '.']); if trimmed.is_empty() { "_".to_string() } else {