diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77afebc97f..f3e5bec0bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,9 +103,8 @@ jobs: settings: - target: aarch64-apple-darwin runner: macos-latest - # Windows can't take the disk usage lol - # - target: x86_64-pc-windows-msvc - # runner: windows-latest + - target: x86_64-pc-windows-msvc + runner: windows-latest runs-on: ${{ matrix.settings.runner }} permissions: contents: read diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 9f54332817..d6bb1b45df 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"] + "recommendations": ["biomejs.biome", "rust-lang.rust-analyzer"] } diff --git a/AGENTS.md b/AGENTS.md index f09db99711..c73653976b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,26 @@ - Runtime: Node 20, pnpm 10.x, Rust 1.88+, Docker for MySQL/MinIO. - **NO COMMENTS**: Never add comments to code (`//`, `/* */`, `///`, `//!`, `#`, etc.). Code must be self-explanatory through naming, types, and structure. This applies to all languages (TypeScript, Rust, JavaScript, etc.). +## Rust Clippy Rules (Workspace Lints) +All Rust code must respect these workspace-level lints defined in `Cargo.toml`: + +**Rust compiler lints:** +- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them. + +**Clippy lints (all denied):** +- `dbg_macro` — Never use `dbg!()` in code; use proper logging instead. +- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them. +- `unchecked_duration_subtraction` — Use `saturating_sub` instead of `-` for `Duration` to avoid panics. +- `collapsible_if` — Merge nested `if` statements: use `if a && b { }` instead of `if a { if b { } }`. +- `clone_on_copy` — Don't call `.clone()` on `Copy` types; just copy them directly. +- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`. +- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec` or `&String` in function parameters. +- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0`. +- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` when return is unit. +- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` for cheap values. +- `needless_range_loop` — Use `for item in &collection` instead of `for i in 0..collection.len()` when index isn't needed. +- `manual_clamp` — Use `.clamp(min, max)` instead of manual `if` chains or `.min().max()` patterns. + ## Testing - TS/JS: Vitest where present (e.g., desktop). Name tests `*.test.ts(x)` near sources. - Rust: `cargo test` per crate; tests in `src` or `tests`. diff --git a/CLAUDE.md b/CLAUDE.md index ee2f0b29f0..c6279ee04b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -371,6 +371,66 @@ Minimize `useEffect` usage: compute during render, handle logic in event handler - Strict TypeScript; avoid `any`; leverage shared types - Use Biome for linting/formatting; match existing formatting +## Rust Clippy Rules (Workspace Lints) +All Rust code must respect these workspace-level lints defined in `Cargo.toml`. Violating any of these will fail CI: + +**Rust compiler lints:** +- `unused_must_use = "deny"` — Always handle `Result`/`Option` or types marked `#[must_use]`; never ignore them. + +**Clippy lints (all denied — code MUST NOT contain these patterns):** +- `dbg_macro` — Never use `dbg!()` in code; use proper logging (`tracing::debug!`, etc.) instead. +- `let_underscore_future` — Never write `let _ = async_fn()` which silently drops futures; await or explicitly handle them. +- `unchecked_duration_subtraction` — Use `duration.saturating_sub(other)` instead of `duration - other` to avoid panics on underflow. +- `collapsible_if` — Merge nested `if` statements: write `if a && b { }` instead of `if a { if b { } }`. +- `clone_on_copy` — Don't call `.clone()` on `Copy` types (integers, bools, etc.); just copy them directly. +- `redundant_closure` — Use function references directly: `iter.map(foo)` instead of `iter.map(|x| foo(x))`. +- `ptr_arg` — Accept `&[T]` or `&str` instead of `&Vec` or `&String` in function parameters for flexibility. +- `len_zero` — Use `.is_empty()` instead of `.len() == 0` or `.len() > 0` / `.len() != 0`. +- `let_unit_value` — Don't assign `()` to a variable: write `foo();` instead of `let _ = foo();` or `let x = foo();` when return is unit. +- `unnecessary_lazy_evaluations` — Use `.unwrap_or(val)` instead of `.unwrap_or_else(|| val)` when the default is a simple/cheap value. +- `needless_range_loop` — Use `for item in &collection` or `for (i, item) in collection.iter().enumerate()` instead of `for i in 0..collection.len()`. +- `manual_clamp` — Use `value.clamp(min, max)` instead of manual `if` chains or `.min(max).max(min)` patterns. + +**Examples of violations to avoid:** + +```rust +dbg!(value); +let _ = some_async_function(); +let duration = duration_a - duration_b; +if condition { + if other_condition { + do_something(); + } +} +let x = 5.clone(); +vec.iter().map(|x| process(x)) +fn example(v: &Vec) { } +if vec.len() == 0 { } +let _ = returns_unit(); +option.unwrap_or_else(|| 42) +for i in 0..vec.len() { println!("{}", vec[i]); } +value.min(max).max(min) +``` + +**Correct alternatives:** + +```rust +tracing::debug!(?value); +some_async_function().await; +let duration = duration_a.saturating_sub(duration_b); +if condition && other_condition { + do_something(); +} +let x = 5; +vec.iter().map(process) +fn example(v: &[i32]) { } +if vec.is_empty() { } +returns_unit(); +option.unwrap_or(42) +for item in &vec { println!("{}", item); } +value.clamp(min, max) +``` + ## Security & Privacy Considerations ### Data Handling diff --git a/Cargo.lock b/Cargo.lock index 4dde011982..41f63e0cf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -177,9 +177,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -1174,6 +1174,7 @@ dependencies = [ name = "cap-desktop" version = "0.4.0" dependencies = [ + "aho-corasick", "anyhow", "async-stream", "axum", @@ -1221,9 +1222,11 @@ dependencies = [ "png 0.17.16", "posthog-rs", "rand 0.8.5", + "regex", "relative-path", "reqwest 0.12.24", "rodio", + "sanitize-filename", "scap-direct3d", "scap-screencapturekit", "scap-targets", @@ -1617,12 +1620,14 @@ dependencies = [ name = "cap-utils" version = "0.1.0" dependencies = [ + "aho-corasick", "directories 5.0.1", "flume", "futures", "nix 0.29.0", "serde", "serde_json", + "tempfile", "tokio", "tracing", "uuid", @@ -7657,6 +7662,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + [[package]] name = "scap-cpal" version = "0.1.0" @@ -8515,6 +8529,7 @@ version = "2.0.0-rc.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ccbb212565d2dc177bc15ecb7b039d66c4490da892436a4eee5b394d620c9bc" dependencies = [ + "chrono", "paste", "serde_json", "specta-macros", diff --git a/Cargo.toml b/Cargo.toml index 661b3b85fd..7eae47f5d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["apps/cli", "apps/desktop/src-tauri", "crates/*", "crates/workspace-hack"] +members = [ + "apps/cli", + "apps/desktop/src-tauri", + "crates/*", + "crates/workspace-hack", +] [workspace.dependencies] anyhow = { version = "1.0.86" } @@ -22,6 +27,7 @@ specta = { version = "=2.0.0-rc.20", features = [ "derive", "serde_json", "uuid", + "chrono" ] } serde = { version = "1", features = ["derive"] } @@ -40,6 +46,7 @@ sentry = { version = "0.42.0", features = [ ] } tracing = "0.1.41" futures = "0.3.31" +aho-corasick = "1.1.4" cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8", features = [ "macos_12_7", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e8d13e2828..df574378e5 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -20,11 +20,11 @@ swift-rs = { version = "1.0.6", features = ["build"] } [dependencies] tauri = { workspace = true, features = [ - "macos-private-api", - "protocol-asset", - "tray-icon", - "image-png", - "devtools", + "macos-private-api", + "protocol-asset", + "tray-icon", + "image-png", + "devtools", ] } tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] } tauri-plugin-dialog = "2.2.0" @@ -60,6 +60,7 @@ tracing.workspace = true tempfile = "3.9.0" ffmpeg.workspace = true chrono = { version = "0.4.31", features = ["serde"] } +regex = "1.10.4" rodio = "0.19.0" png = "0.17.13" device_query = "4.0.1" @@ -106,22 +107,24 @@ tauri-plugin-sentry = "0.5.0" thiserror.workspace = true bytes = "1.10.1" async-stream = "0.3.6" +sanitize-filename = "0.6.0" tracing-futures = { version = "0.2.5", features = ["futures-03"] } tracing-opentelemetry = "0.32.0" opentelemetry = "0.31.0" -opentelemetry-otlp = "0.31.0" #{ version = , features = ["http-proto", "reqwest-client"] } +opentelemetry-otlp = "0.31.0" #{ version = , features = ["http-proto", "reqwest-client"] } opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio", "trace"] } posthog-rs = "0.3.7" workspace-hack = { version = "0.1", path = "../../../crates/workspace-hack" } +aho-corasick.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-graphics = "0.24.0" core-foundation = "0.10.0" objc2-app-kit = { version = "0.3.0", features = [ - "NSWindow", - "NSResponder", - "NSHapticFeedback", + "NSWindow", + "NSResponder", + "NSHapticFeedback", ] } cocoa = "0.26.0" objc = "0.2.7" @@ -131,10 +134,10 @@ cidre = { workspace = true } [target.'cfg(target_os= "windows")'.dependencies] windows = { workspace = true, features = [ - "Win32_Foundation", - "Win32_System", - "Win32_UI_WindowsAndMessaging", - "Win32_Graphics_Gdi", + "Win32_Foundation", + "Win32_System", + "Win32_UI_WindowsAndMessaging", + "Win32_Graphics_Gdi", ] } windows-sys = { workspace = true } diff --git a/apps/desktop/src-tauri/src/audio_meter.rs b/apps/desktop/src-tauri/src/audio_meter.rs index e7cd067dea..c383f37513 100644 --- a/apps/desktop/src-tauri/src/audio_meter.rs +++ b/apps/desktop/src-tauri/src/audio_meter.rs @@ -140,6 +140,6 @@ fn samples_to_f64(samples: &MicrophoneSamples) -> impl Iterator + us SampleFormat::F64 => f64::from_ne_bytes([ data[0], data[1], data[2], data[3], data[4], data[5], data[6], data[7], ]), - _ => todo!(), + _ => 0.0, }) } diff --git a/apps/desktop/src-tauri/src/camera.rs b/apps/desktop/src-tauri/src/camera.rs index 6cb00db296..9532807114 100644 --- a/apps/desktop/src-tauri/src/camera.rs +++ b/apps/desktop/src-tauri/src/camera.rs @@ -17,8 +17,9 @@ use tokio::{ runtime::Runtime, sync::{broadcast, oneshot}, task::LocalSet, + time::{Duration, Instant}, }; -use tracing::{error, info, trace}; +use tracing::{error, info, trace, warn}; use wgpu::{CompositeAlphaMode, SurfaceTexture}; static TOOLBAR_HEIGHT: f32 = 56.0; // also defined in Typescript @@ -43,9 +44,9 @@ pub enum CameraPreviewShape { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Type)] pub struct CameraPreviewState { - size: f32, - shape: CameraPreviewShape, - mirrored: bool, + pub size: f32, + pub shape: CameraPreviewShape, + pub mirrored: bool, } impl Default for CameraPreviewState { @@ -496,8 +497,23 @@ impl Renderer { return; }; + let start_time = Instant::now(); + let startup_timeout = Duration::from_secs(5); + let mut received_first_frame = false; + let mut state = default_state; while let Some(event) = loop { + let timeout_remaining = if received_first_frame { + Duration::MAX + } else { + startup_timeout.saturating_sub(start_time.elapsed()) + }; + + if timeout_remaining.is_zero() { + warn!("Camera preview timed out waiting for first frame, closing window"); + break None; + } + tokio::select! { frame = camera_rx.recv_async() => break frame.ok().map(Ok), result = reconfigure.recv() => { @@ -507,10 +523,15 @@ impl Renderer { continue; } }, + _ = tokio::time::sleep(timeout_remaining) => { + warn!("Camera preview timed out waiting for first frame, closing window"); + break None; + } } } { match event { Ok(frame) => { + received_first_frame = true; let aspect_ratio = frame.inner.width() as f32 / frame.inner.height() as f32; self.sync_ratio_uniform_and_resize_window_to_it(&window, &state, aspect_ratio); diff --git a/apps/desktop/src-tauri/src/captions.rs b/apps/desktop/src-tauri/src/captions.rs index a0224c4ea0..80a135e5b8 100644 --- a/apps/desktop/src-tauri/src/captions.rs +++ b/apps/desktop/src-tauri/src/captions.rs @@ -115,30 +115,28 @@ async fn extract_audio_from_video(video_path: &str, output_path: &PathBuf) -> Re if mixed_samples.is_empty() { mixed_samples = audio.samples().to_vec(); channel_count = audio.channels() as usize; - } else { - if audio.channels() as usize != channel_count { - log::info!( - "Channel count mismatch: {} vs {}, mixing to mono", - channel_count, - audio.channels() - ); - - if channel_count > 1 { - let mono_samples = convert_to_mono(&mixed_samples, channel_count); - mixed_samples = mono_samples; - channel_count = 1; - } + } else if audio.channels() as usize != channel_count { + log::info!( + "Channel count mismatch: {} vs {}, mixing to mono", + channel_count, + audio.channels() + ); - let samples = if audio.channels() > 1 { - convert_to_mono(audio.samples(), audio.channels() as usize) - } else { - audio.samples().to_vec() - }; + if channel_count > 1 { + let mono_samples = convert_to_mono(&mixed_samples, channel_count); + mixed_samples = mono_samples; + channel_count = 1; + } - mix_samples(&mut mixed_samples, &samples); + let samples = if audio.channels() > 1 { + convert_to_mono(audio.samples(), audio.channels() as usize) } else { - mix_samples(&mut mixed_samples, audio.samples()); - } + audio.samples().to_vec() + }; + + mix_samples(&mut mixed_samples, &samples); + } else { + mix_samples(&mut mixed_samples, audio.samples()); } } Err(e) => { @@ -1012,13 +1010,11 @@ fn start_whisperx_server( std::thread::spawn(move || { use std::io::BufRead; let reader = std::io::BufReader::new(stderr); - for line in reader.lines() { - if let Ok(line) = line { - if line.starts_with("STDERR:") { - log::info!("[WhisperX] {}", &line[7..]); - } else { - log::info!("[WhisperX stderr] {}", line); - } + for line in reader.lines().flatten() { + if let Some(stripped) = line.strip_prefix("STDERR:") { + log::info!("[WhisperX] {}", stripped); + } else { + log::info!("[WhisperX stderr] {}", line); } } }); diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index a342d17769..b9226028c2 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -1,6 +1,6 @@ use crate::{FramesRendered, get_video_metadata}; use cap_export::ExporterBase; -use cap_project::{RecordingMeta, XY}; +use cap_project::RecordingMeta; use serde::Deserialize; use specta::Type; use std::path::PathBuf; diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs index 1b193e75b3..1d3fc7fa61 100644 --- a/apps/desktop/src-tauri/src/general_settings.rs +++ b/apps/desktop/src-tauri/src/general_settings.rs @@ -122,6 +122,8 @@ pub struct GeneralSettingsStore { pub delete_instant_recordings_after_upload: bool, #[serde(default = "default_instant_mode_max_resolution")] pub instant_mode_max_resolution: u32, + #[serde(default)] + pub default_project_name_template: Option, } fn default_enable_native_camera_preview() -> bool { @@ -187,6 +189,7 @@ impl Default for GeneralSettingsStore { excluded_windows: default_excluded_windows(), delete_instant_recordings_after_upload: false, instant_mode_max_resolution: 1920, + default_project_name_template: None, } } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 12c876e2fe..49f0253ed5 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ mod screenshot_editor; mod target_select_overlay; mod thumbnails; mod tray; +mod update_project_names; mod upload; mod web_api; mod window_exclusion; @@ -456,12 +457,17 @@ async fn set_camera_input( .map_err(|e| e.to_string())?; } Some(id) => { + { + let app = &mut *state.write().await; + app.selected_camera_id = Some(id.clone()); + app.camera_in_use = true; + app.camera_cleanup_done = false; + } + let mut attempts = 0; - loop { + let init_result: Result<(), String> = loop { attempts += 1; - // We first ask the actor to set the input - // This returns a future that resolves when the camera is actually ready let request = camera_feed .ask(feeds::camera::SetInput { id: id.clone() }) .await @@ -473,10 +479,10 @@ async fn set_camera_input( }; match result { - Ok(_) => break, + Ok(_) => break Ok(()), Err(e) => { if attempts >= 3 { - return Err(format!( + break Err(format!( "Failed to initialize camera after {} attempts: {}", attempts, e )); @@ -488,6 +494,13 @@ async fn set_camera_input( tokio::time::sleep(Duration::from_millis(500)).await; } } + }; + + if let Err(e) = init_result { + let app = &mut *state.write().await; + app.selected_camera_id = None; + app.camera_in_use = false; + return Err(e); } ShowCapWindow::Camera @@ -2388,7 +2401,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { target_select_overlay::display_information, target_select_overlay::get_window_icon, target_select_overlay::focus_window, - editor_delete_project + editor_delete_project, + format_project_name, ]) .events(tauri_specta::collect_events![ RecordingOptionsChanged, @@ -2533,6 +2547,11 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { .invoke_handler(specta_builder.invoke_handler()) .setup(move |app| { let app = app.handle().clone(); + + if let Err(err) = update_project_names::migrate_if_needed(&app) { + tracing::error!("Failed to migrate project file names: {}", err); + } + specta_builder.mount_events(&app); hotkeys::init(&app); general_settings::init(&app); @@ -2758,7 +2777,13 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { let state = app.state::>(); let app_state = &mut *state.write().await; - if !app_state.is_recording_active_or_pending() { + let camera_window_open = + CapWindowId::Camera.get(&app).is_some(); + + if !app_state.is_recording_active_or_pending() + && !camera_window_open + && !app_state.camera_in_use + { let _ = app_state.mic_feed.ask(microphone::RemoveInput).await; let _ = app_state @@ -2768,7 +2793,6 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) { app_state.selected_mic_label = None; app_state.selected_camera_id = None; - app_state.camera_in_use = false; } }); } @@ -3153,6 +3177,24 @@ async fn write_clipboard_string( .map_err(|e| format!("Failed to write text to clipboard: {e}")) } +#[tauri::command(async)] +#[specta::specta] +fn format_project_name( + template: Option, + target_name: String, + target_kind: String, + recording_mode: RecordingMode, + datetime: Option>, +) -> String { + recording::format_project_name( + template.as_deref(), + target_name.as_str(), + target_kind.as_str(), + recording_mode, + datetime, + ) +} + trait EventExt: tauri_specta::Event { fn listen_any_spawn( app: &AppHandle, diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs index a5a0ecc00c..f0378595bc 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -3,10 +3,10 @@ use cap_fail::fail; use cap_project::CursorMoveEvent; use cap_project::cursor::SHORT_CURSOR_SHAPE_DEBOUNCE_MS; use cap_project::{ - CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, ProjectConfiguration, - RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, StudioRecordingStatus, - TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, ZoomSegment, - cursor::CursorEvents, + CameraShape, CursorClickEvent, InstantRecordingMeta, MultipleSegments, Platform, + ProjectConfiguration, RecordingMeta, RecordingMetaInner, SharingMeta, StudioRecordingMeta, + StudioRecordingStatus, TimelineConfiguration, TimelineSegment, UploadMeta, ZoomMode, + ZoomSegment, cursor::CursorEvents, }; use cap_recording::feeds::camera::CameraFeedLock; #[cfg(target_os = "macos")] @@ -23,10 +23,13 @@ use cap_recording::{ studio_recording, }; use cap_rendering::ProjectRecordingsMeta; -use cap_utils::{ensure_dir, spawn_actor}; +use cap_utils::{ensure_dir, moment_format_to_chrono, spawn_actor}; use futures::{FutureExt, stream}; +use lazy_static::lazy_static; +use regex::Regex; use serde::{Deserialize, Serialize}; use specta::Type; +use std::borrow::Cow; use std::{ any::Any, collections::{HashMap, VecDeque}, @@ -42,6 +45,7 @@ use tauri_plugin_dialog::{DialogExt, MessageDialogBuilder}; use tauri_specta::Event; use tracing::*; +use crate::camera::{CameraPreviewManager, CameraPreviewShape}; use crate::web_api::AuthedApiError; use crate::{ App, CurrentRecordingChanged, MutableState, NewStudioRecordingAdded, RecordingState, @@ -349,6 +353,82 @@ pub enum RecordingAction { UpgradeRequired, } +pub fn format_project_name<'a>( + template: Option<&str>, + target_name: &'a str, + target_kind: &'a str, + recording_mode: RecordingMode, + datetime: Option>, +) -> String { + const DEFAULT_FILENAME_TEMPLATE: &str = "{target_name} ({target_kind}) {date} {time}"; + let datetime = datetime.unwrap_or(chrono::Local::now()); + + lazy_static! { + static ref DATE_REGEX: Regex = Regex::new(r"\{date(?::([^}]+))?\}").unwrap(); + static ref TIME_REGEX: Regex = Regex::new(r"\{time(?::([^}]+))?\}").unwrap(); + static ref MOMENT_REGEX: Regex = Regex::new(r"\{moment(?::([^}]+))?\}").unwrap(); + static ref AC: aho_corasick::AhoCorasick = { + aho_corasick::AhoCorasick::new([ + "{recording_mode}", + "{mode}", + "{target_kind}", + "{target_name}", + ]) + .expect("Failed to build AhoCorasick automaton") + }; + } + let haystack = template.unwrap_or(DEFAULT_FILENAME_TEMPLATE); + + // Get recording mode information + let (recording_mode, mode) = match recording_mode { + RecordingMode::Studio => ("Studio", "studio"), + RecordingMode::Instant => ("Instant", "instant"), + RecordingMode::Screenshot => ("Screenshot", "screenshot"), + }; + + let result = AC + .try_replace_all(haystack, &[recording_mode, mode, target_kind, target_name]) + .expect("AhoCorasick replace should never fail with default configuration"); + + let result = DATE_REGEX.replace_all(&result, |caps: ®ex::Captures| { + datetime + .format( + &caps + .get(1) + .map(|m| m.as_str()) + .map(moment_format_to_chrono) + .unwrap_or(Cow::Borrowed("%Y-%m-%d")), + ) + .to_string() + }); + + let result = TIME_REGEX.replace_all(&result, |caps: ®ex::Captures| { + datetime + .format( + &caps + .get(1) + .map(|m| m.as_str()) + .map(moment_format_to_chrono) + .unwrap_or(Cow::Borrowed("%I:%M %p")), + ) + .to_string() + }); + + let result = MOMENT_REGEX.replace_all(&result, |caps: ®ex::Captures| { + datetime + .format( + &caps + .get(1) + .map(|m| m.as_str()) + .map(moment_format_to_chrono) + .unwrap_or(Cow::Borrowed("%Y-%m-%d %H:%M")), + ) + .to_string() + }); + + result.into_owned() +} + #[tauri::command] #[specta::specta] #[tracing::instrument(name = "recording", skip_all)] @@ -361,34 +441,41 @@ pub async fn start_recording( return Err("Recording already in progress".to_string()); } - let id = uuid::Uuid::new_v4().to_string(); let general_settings = GeneralSettingsStore::get(&app).ok().flatten(); let general_settings = general_settings.as_ref(); - let recording_dir = app - .path() - .app_data_dir() - .unwrap() - .join("recordings") - .join(format!("{id}.cap")); + let project_name = format_project_name( + general_settings + .and_then(|s| s.default_project_name_template.clone()) + .as_deref(), + inputs + .capture_target + .title() + .as_deref() + .unwrap_or("Unknown"), + inputs.capture_target.kind_str(), + inputs.mode, + None, + ); - ensure_dir(&recording_dir).map_err(|e| format!("Failed to create recording directory: {e}"))?; + let filename = project_name.replace(":", "."); + let filename = format!("{}.cap", sanitize_filename::sanitize(&filename)); + + let recordings_base_dir = app.path().app_data_dir().unwrap().join("recordings"); + + let project_file_path = recordings_base_dir.join(&cap_utils::ensure_unique_filename( + &filename, + &recordings_base_dir, + )?); + + ensure_dir(&project_file_path) + .map_err(|e| format!("Failed to create recording directory: {e}"))?; state_mtx .write() .await - .add_recording_logging_handle(&recording_dir.join("recording-logs.log")) + .add_recording_logging_handle(&project_file_path.join("recording-logs.log")) .await?; - let target_name = { - let title = inputs.capture_target.title(); - - match inputs.capture_target.clone() { - ScreenCaptureTarget::Area { .. } => title.unwrap_or_else(|| "Area".to_string()), - ScreenCaptureTarget::Window { .. } => title.unwrap_or_else(|| "Window".to_string()), - ScreenCaptureTarget::Display { .. } => title.unwrap_or_else(|| "Screen".to_string()), - } - }; - if let Some(window) = CapWindowId::Camera.get(&app) { let _ = window.set_content_protected(matches!(inputs.mode, RecordingMode::Studio)); } @@ -402,10 +489,7 @@ pub async fn start_recording( &app, false, None, - Some(format!( - "{target_name} {}", - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - )), + Some(project_name.clone()), None, inputs.organization_id.clone(), ) @@ -444,17 +528,10 @@ pub async fn start_recording( RecordingMode::Screenshot => return Err("Use take_screenshot for screenshots".to_string()), }; - let date_time = if cfg!(windows) { - // Windows doesn't support colon in file paths - chrono::Local::now().format("%Y-%m-%d %H.%M.%S") - } else { - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - }; - let meta = RecordingMeta { platform: Some(Platform::default()), - project_path: recording_dir.clone(), - pretty_name: format!("{target_name} {date_time}"), + project_path: project_file_path.clone(), + pretty_name: project_name.clone(), inner: match inputs.mode { RecordingMode::Studio => { RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { @@ -544,8 +621,7 @@ pub async fn start_recording( let actor_task = { let state_mtx = Arc::clone(&state_mtx); let general_settings = general_settings.cloned(); - let recording_dir = recording_dir.clone(); - let target_name = target_name.clone(); + let recording_dir = project_file_path.clone(); let inputs = inputs.clone(); async move { fail!("recording::spawn_actor"); @@ -607,7 +683,7 @@ pub async fn start_recording( acquire_shareable_content_for_target(&inputs.capture_target).await?; let common = InProgressRecordingCommon { - target_name, + target_name: project_name, inputs: inputs.clone(), recording_dir: recording_dir.clone(), }; @@ -770,15 +846,25 @@ pub async fn start_recording( Ok(Ok(rx)) => rx, Ok(Err(err)) => { let message = format!("{err:#}"); - handle_spawn_failure(&app, &state_mtx, recording_dir.as_path(), message.clone()) - .await?; + handle_spawn_failure( + &app, + &state_mtx, + project_file_path.as_path(), + message.clone(), + ) + .await?; return Err(message); } Err(panic) => { let panic_msg = panic_message(panic); let message = format!("Failed to spawn recording actor: {panic_msg}"); - handle_spawn_failure(&app, &state_mtx, recording_dir.as_path(), message.clone()) - .await?; + handle_spawn_failure( + &app, + &state_mtx, + project_file_path.as_path(), + message.clone(), + ) + .await?; return Err(message); } }; @@ -819,7 +905,7 @@ pub async fn start_recording( dialog.blocking_show(); // this clears the current recording for us - handle_recording_end(app, Err(e.to_string()), &mut state, recording_dir) + handle_recording_end(app, Err(e.to_string()), &mut state, project_file_path) .await .ok(); } @@ -1036,6 +1122,19 @@ pub async fn take_screenshot( use image::ImageEncoder; use std::time::Instant; + let general_settings = GeneralSettingsStore::get(&app).ok().flatten(); + let general_settings = general_settings.as_ref(); + + let project_name = format_project_name( + general_settings + .and_then(|s| s.default_project_name_template.clone()) + .as_deref(), + target.title().as_deref().unwrap_or("Unknown"), + target.kind_str(), + RecordingMode::Screenshot, + None, + ); + let image = capture_screenshot(target) .await .map_err(|e| format!("Failed to capture screenshot: {e}"))?; @@ -1044,23 +1143,22 @@ pub async fn take_screenshot( let image_height = image.height(); let image_data = image.into_raw(); - let screenshots_dir = app.path().app_data_dir().unwrap().join("screenshots"); + let filename = project_name.replace(":", "."); + let filename = format!("{}.cap", sanitize_filename::sanitize(&filename)); - std::fs::create_dir_all(&screenshots_dir).map_err(|e| e.to_string())?; + let screenshots_base_dir = app.path().app_data_dir().unwrap().join("screenshots"); - let date_time = if cfg!(windows) { - chrono::Local::now().format("%Y-%m-%d %H.%M.%S") - } else { - chrono::Local::now().format("%Y-%m-%d %H:%M:%S") - }; + let project_file_path = screenshots_base_dir.join(&cap_utils::ensure_unique_filename( + &filename, + &screenshots_base_dir, + )?); - let id = uuid::Uuid::new_v4().to_string(); - let cap_dir = screenshots_dir.join(format!("{id}.cap")); - std::fs::create_dir_all(&cap_dir).map_err(|e| e.to_string())?; + ensure_dir(&project_file_path) + .map_err(|e| format!("Failed to create screenshots directory: {e}"))?; let image_filename = "original.png"; - let image_path = cap_dir.join(image_filename); - let cap_dir_key = cap_dir.to_string_lossy().to_string(); + let image_path = project_file_path.join(image_filename); + let cap_dir_key = project_file_path.to_string_lossy().to_string(); let pending_screenshots = app.state::(); pending_screenshots.insert( @@ -1090,8 +1188,8 @@ pub async fn take_screenshot( let meta = cap_project::RecordingMeta { platform: Some(Platform::default()), - project_path: cap_dir.clone(), - pretty_name: format!("Screenshot {}", date_time), + project_path: project_file_path.clone(), + pretty_name: project_name, sharing: None, inner: cap_project::RecordingMetaInner::Studio( cap_project::StudioRecordingMeta::SingleSegment { segment }, @@ -1103,7 +1201,7 @@ pub async fn take_screenshot( .map_err(|e| format!("Failed to save recording meta: {e}"))?; cap_project::ProjectConfiguration::default() - .write(&cap_dir) + .write(&project_file_path) .map_err(|e| format!("Failed to save project config: {e}"))?; let is_large_capture = (image_width as u64).saturating_mul(image_height as u64) > 8_000_000; @@ -1680,6 +1778,24 @@ fn project_config_from_recording( let mut config = default_config.unwrap_or_default(); + let camera_preview_manager = CameraPreviewManager::new(app); + if let Ok(camera_preview_state) = camera_preview_manager.get_state() { + match camera_preview_state.shape { + CameraPreviewShape::Round => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 100.0; + } + CameraPreviewShape::Square => { + config.camera.shape = CameraShape::Square; + config.camera.rounding = 25.0; + } + CameraPreviewShape::Full => { + config.camera.shape = CameraShape::Source; + config.camera.rounding = 25.0; + } + } + } + let timeline_segments = recordings .segments .iter() diff --git a/apps/desktop/src-tauri/src/screenshot_editor.rs b/apps/desktop/src-tauri/src/screenshot_editor.rs index 591b17a34c..e52510b7dd 100644 --- a/apps/desktop/src-tauri/src/screenshot_editor.rs +++ b/apps/desktop/src-tauri/src/screenshot_editor.rs @@ -30,6 +30,7 @@ pub struct ScreenshotEditorInstance { pub ws_shutdown_token: CancellationToken, pub config_tx: watch::Sender, pub path: PathBuf, + pub pretty_name: String, } impl ScreenshotEditorInstance { @@ -140,8 +141,31 @@ impl ScreenshotEditorInstances { let rgba_img: image::RgbaImage = rgb_img.convert(); (rgba_img.into_raw(), width, height) } else { - let img = - image::open(&path).map_err(|e| format!("Failed to open image: {e}"))?; + let image_path = if path.is_dir() { + let original = path.join("original.png"); + if original.exists() { + original + } else { + std::fs::read_dir(&path) + .ok() + .and_then(|dir| { + dir.flatten() + .find(|e| { + e.path().extension().and_then(|s| s.to_str()) + == Some("png") + }) + .map(|e| e.path()) + }) + .ok_or_else(|| { + format!("No PNG file found in directory: {:?}", path) + })? + } + } else { + path.clone() + }; + + let img = image::open(&image_path) + .map_err(|e| format!("Failed to open image: {e}"))?; let (w, h) = img.dimensions(); if w > MAX_DIMENSION || h > MAX_DIMENSION { @@ -156,15 +180,22 @@ impl ScreenshotEditorInstances { } }; - // Try to load existing meta if in a .cap directory - let (recording_meta, loaded_config) = if let Some(parent) = path.parent() { + let cap_dir = if path.extension().and_then(|s| s.to_str()) == Some("cap") { + Some(path.clone()) + } else if let Some(parent) = path.parent() { if parent.extension().and_then(|s| s.to_str()) == Some("cap") { - let meta = RecordingMeta::load_for_project(parent).ok(); - let config = ProjectConfiguration::load(parent).ok(); - (meta, config) + Some(parent.to_path_buf()) } else { - (None, None) + None } + } else { + None + }; + + let (recording_meta, loaded_config) = if let Some(cap_dir) = &cap_dir { + let meta = RecordingMeta::load_for_project(cap_dir).ok(); + let config = ProjectConfiguration::load(cap_dir).ok(); + (meta, config) } else { (None, None) }; @@ -264,6 +295,7 @@ impl ScreenshotEditorInstances { ws_shutdown_token, config_tx, path: path.clone(), + pretty_name: recording_meta.pretty_name.clone(), }); // Spawn render loop @@ -375,6 +407,7 @@ pub struct SerializedScreenshotEditorInstance { pub frames_socket_url: String, pub path: PathBuf, pub config: Option, + pub pretty_name: String, } #[tauri::command] @@ -404,6 +437,7 @@ pub async fn create_screenshot_editor_instance( frames_socket_url: format!("ws://localhost:{}", instance.ws_port), path: instance.path.clone(), config: Some(config), + pretty_name: instance.pretty_name.clone(), }) } diff --git a/apps/desktop/src-tauri/src/tray.rs b/apps/desktop/src-tauri/src/tray.rs index c7268e5bb4..8cf7e57cfa 100644 --- a/apps/desktop/src-tauri/src/tray.rs +++ b/apps/desktop/src-tauri/src/tray.rs @@ -1,14 +1,17 @@ use crate::{ - RecordingStarted, RecordingStopped, RequestOpenRecordingPicker, RequestOpenSettings, recording, + NewScreenshotAdded, NewStudioRecordingAdded, RecordingStarted, RecordingStopped, + RequestOpenRecordingPicker, RequestOpenSettings, recording, recording_settings::RecordingTargetMode, windows::ShowCapWindow, }; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, +use cap_project::{RecordingMeta, RecordingMetaInner}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, }; use tauri::Manager; -use tauri::menu::{MenuId, PredefinedMenuItem}; +use tauri::menu::{IconMenuItem, MenuId, PredefinedMenuItem, Submenu}; use tauri::{ AppHandle, image::Image, @@ -16,18 +19,26 @@ use tauri::{ tray::TrayIconBuilder, }; use tauri_plugin_dialog::DialogExt; +use tauri_plugin_opener::OpenerExt; use tauri_specta::Event; +const PREVIOUS_ITEM_PREFIX: &str = "previous_item_"; +const MAX_PREVIOUS_ITEMS: usize = 6; +const MAX_TITLE_LENGTH: usize = 30; +const THUMBNAIL_SIZE: u32 = 32; + +#[derive(Debug)] pub enum TrayItem { OpenCap, RecordDisplay, RecordWindow, RecordArea, - PreviousRecordings, - PreviousScreenshots, + ViewAllRecordings, + ViewAllScreenshots, OpenSettings, UploadLogs, Quit, + PreviousItem(String), } impl From for MenuId { @@ -37,11 +48,14 @@ impl From for MenuId { TrayItem::RecordDisplay => "record_display", TrayItem::RecordWindow => "record_window", TrayItem::RecordArea => "record_area", - TrayItem::PreviousRecordings => "previous_recordings", - TrayItem::PreviousScreenshots => "previous_screenshots", + TrayItem::ViewAllRecordings => "view_all_recordings", + TrayItem::ViewAllScreenshots => "view_all_screenshots", TrayItem::OpenSettings => "open_settings", TrayItem::UploadLogs => "upload_logs", TrayItem::Quit => "quit", + TrayItem::PreviousItem(id) => { + return format!("{PREVIOUS_ITEM_PREFIX}{id}").into(); + } } .into() } @@ -51,13 +65,19 @@ impl TryFrom for TrayItem { type Error = String; fn try_from(value: MenuId) -> Result { - match value.0.as_str() { + let id_str = value.0.as_str(); + + if let Some(path) = id_str.strip_prefix(PREVIOUS_ITEM_PREFIX) { + return Ok(TrayItem::PreviousItem(path.to_string())); + } + + match id_str { "open_cap" => Ok(TrayItem::OpenCap), "record_display" => Ok(TrayItem::RecordDisplay), "record_window" => Ok(TrayItem::RecordWindow), "record_area" => Ok(TrayItem::RecordArea), - "previous_recordings" => Ok(TrayItem::PreviousRecordings), - "previous_screenshots" => Ok(TrayItem::PreviousScreenshots), + "view_all_recordings" => Ok(TrayItem::ViewAllRecordings), + "view_all_screenshots" => Ok(TrayItem::ViewAllScreenshots), "open_settings" => Ok(TrayItem::OpenSettings), "upload_logs" => Ok(TrayItem::UploadLogs), "quit" => Ok(TrayItem::Quit), @@ -66,8 +86,226 @@ impl TryFrom for TrayItem { } } -pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { - let menu = Menu::with_items( +#[derive(Debug, Clone)] +enum PreviousItemType { + StudioRecording, + InstantRecording { + #[allow(dead_code)] + link: Option, + }, + Screenshot, +} + +#[derive(Clone)] +struct CachedPreviousItem { + path: PathBuf, + pretty_name: String, + thumbnail: Option>, + thumbnail_width: u32, + thumbnail_height: u32, + item_type: PreviousItemType, + created_at: std::time::SystemTime, +} + +#[derive(Default)] +struct PreviousItemsCache { + items: Vec, +} + +fn recordings_path(app: &AppHandle) -> PathBuf { + let path = app.path().app_data_dir().unwrap().join("recordings"); + std::fs::create_dir_all(&path).unwrap_or_default(); + path +} + +fn screenshots_path(app: &AppHandle) -> PathBuf { + let path = app.path().app_data_dir().unwrap().join("screenshots"); + std::fs::create_dir_all(&path).unwrap_or_default(); + path +} + +fn truncate_title(title: &str) -> String { + if title.chars().count() <= MAX_TITLE_LENGTH { + title.to_string() + } else { + let truncate_at = MAX_TITLE_LENGTH - 1; + let byte_index = title + .char_indices() + .nth(truncate_at) + .map(|(i, _)| i) + .unwrap_or(title.len()); + format!("{}…", &title[..byte_index]) + } +} + +fn load_thumbnail_data(path: &PathBuf) -> Option<(Vec, u32, u32)> { + use image::imageops::FilterType; + use image::{GenericImageView, RgbaImage}; + + let image_data = std::fs::read(path).ok()?; + let img = image::load_from_memory(&image_data).ok()?; + + let (orig_w, orig_h) = img.dimensions(); + let size = THUMBNAIL_SIZE; + + let scale = (size as f32 / orig_w as f32).max(size as f32 / orig_h as f32); + let scaled_w = (orig_w as f32 * scale).round() as u32; + let scaled_h = (orig_h as f32 * scale).round() as u32; + + let scaled = img.resize_exact(scaled_w, scaled_h, FilterType::Triangle); + + let x_offset = (scaled_w.saturating_sub(size)) / 2; + let y_offset = (scaled_h.saturating_sub(size)) / 2; + + let mut result = RgbaImage::new(size, size); + for y in 0..size { + for x in 0..size { + let src_x = x + x_offset; + let src_y = y + y_offset; + if src_x < scaled_w && src_y < scaled_h { + result.put_pixel(x, y, scaled.get_pixel(src_x, src_y)); + } + } + } + + Some((result.into_raw(), size, size)) +} + +fn load_single_item( + path: &PathBuf, + screenshots_dir: &PathBuf, + load_thumbnail: bool, +) -> Option { + if !path.is_dir() { + return None; + } + + let meta = RecordingMeta::load_for_project(path).ok()?; + let created_at = path + .metadata() + .and_then(|m| m.created()) + .unwrap_or_else(|_| std::time::SystemTime::now()); + + let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") + && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); + + let (thumbnail_path, item_type) = if is_screenshot { + let png_path = std::fs::read_dir(path).ok().and_then(|dir| { + dir.flatten() + .find(|e| e.path().extension().and_then(|s| s.to_str()) == Some("png")) + .map(|e| e.path()) + }); + (png_path, PreviousItemType::Screenshot) + } else { + let thumb = path.join("screenshots/display.jpg"); + let thumb_path = if thumb.exists() { Some(thumb) } else { None }; + let item_type = match &meta.inner { + RecordingMetaInner::Studio(_) => PreviousItemType::StudioRecording, + RecordingMetaInner::Instant(_) => PreviousItemType::InstantRecording { + link: meta.sharing.as_ref().map(|s| s.link.clone()), + }, + }; + (thumb_path, item_type) + }; + + let (thumbnail, thumbnail_width, thumbnail_height) = if load_thumbnail { + thumbnail_path + .as_ref() + .and_then(load_thumbnail_data) + .map(|(data, w, h)| (Some(data), w, h)) + .unwrap_or((None, 0, 0)) + } else { + (None, 0, 0) + }; + + Some(CachedPreviousItem { + path: path.clone(), + pretty_name: meta.pretty_name, + thumbnail, + thumbnail_width, + thumbnail_height, + item_type, + created_at, + }) +} + +fn load_all_previous_items(app: &AppHandle, load_thumbnails: bool) -> Vec { + let mut items = Vec::new(); + let screenshots_dir = screenshots_path(app); + + let recordings_dir = recordings_path(app); + if recordings_dir.exists() + && let Ok(entries) = std::fs::read_dir(&recordings_dir) + { + for entry in entries.flatten() { + if let Some(item) = load_single_item(&entry.path(), &screenshots_dir, load_thumbnails) { + items.push(item); + } + } + } + + if screenshots_dir.exists() + && let Ok(entries) = std::fs::read_dir(&screenshots_dir) + { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("cap") + && let Some(item) = load_single_item(&path, &screenshots_dir, load_thumbnails) + { + items.push(item); + } + } + } + + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + items.truncate(MAX_PREVIOUS_ITEMS); + items +} + +fn create_previous_submenu( + app: &AppHandle, + cache: &PreviousItemsCache, +) -> tauri::Result> { + if cache.items.is_empty() { + let submenu = Submenu::with_id(app, "previous", "Previous", false)?; + submenu.append(&MenuItem::with_id( + app, + "previous_empty", + "No recent items", + false, + None::<&str>, + )?)?; + return Ok(submenu); + } + + let submenu = Submenu::with_id(app, "previous", "Previous", true)?; + + for item in &cache.items { + let id = TrayItem::PreviousItem(item.path.to_string_lossy().to_string()); + let title = truncate_title(&item.pretty_name); + + let type_indicator = match &item.item_type { + PreviousItemType::StudioRecording => "🎬 ", + PreviousItemType::InstantRecording { .. } => "⚡ ", + PreviousItemType::Screenshot => "📷 ", + }; + let display_title = format!("{type_indicator}{title}"); + + let icon = item.thumbnail.as_ref().map(|data| { + Image::new_owned(data.clone(), item.thumbnail_width, item.thumbnail_height) + }); + + let menu_item = IconMenuItem::with_id(app, id, display_title, true, icon, None::<&str>)?; + submenu.append(&menu_item)?; + } + + Ok(submenu) +} + +fn build_tray_menu(app: &AppHandle, cache: &PreviousItemsCache) -> tauri::Result> { + let previous_submenu = create_previous_submenu(app, cache)?; + + Menu::with_items( app, &[ &MenuItem::with_id( @@ -93,17 +331,19 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { )?, &MenuItem::with_id(app, TrayItem::RecordArea, "Record Area", true, None::<&str>)?, &PredefinedMenuItem::separator(app)?, - // &MenuItem::with_id( - // app, - // TrayItem::TakeScreenshot, - // "Take Screenshot", - // true, - // None::<&str>, - // )?, + &previous_submenu, + &PredefinedMenuItem::separator(app)?, &MenuItem::with_id( app, - TrayItem::PreviousRecordings, - "Previous Recordings", + TrayItem::ViewAllRecordings, + "View all recordings", + true, + None::<&str>, + )?, + &MenuItem::with_id( + app, + TrayItem::ViewAllScreenshots, + "View all screenshots", true, None::<&str>, )?, @@ -119,9 +359,103 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { )?, &MenuItem::with_id(app, TrayItem::Quit, "Quit Cap", true, None::<&str>)?, ], - )?; + ) +} + +fn add_new_item_to_cache(cache: &Arc>, app: &AppHandle, path: PathBuf) { + let screenshots_dir = screenshots_path(app); + + let Some(new_item) = load_single_item(&path, &screenshots_dir, true) else { + return; + }; + + let mut cache_guard = cache.lock().unwrap(); + + cache_guard.items.retain(|item| item.path != path); + + cache_guard.items.insert(0, new_item); + + cache_guard.items.truncate(MAX_PREVIOUS_ITEMS); +} + +fn refresh_tray_menu(app: &AppHandle, cache: &Arc>) { + let app_clone = app.clone(); + let cache_clone = cache.clone(); + + let _ = app.run_on_main_thread(move || { + let Some(tray) = app_clone.tray_by_id("tray") else { + return; + }; + + let cache_guard = cache_clone.lock().unwrap(); + if let Ok(menu) = build_tray_menu(&app_clone, &cache_guard) { + let _ = tray.set_menu(Some(menu)); + } + }); +} + +fn handle_previous_item_click(app: &AppHandle, path_str: &str) { + let path = PathBuf::from(path_str); + + let screenshots_dir = screenshots_path(app); + let is_screenshot = path.extension().and_then(|s| s.to_str()) == Some("cap") + && path.parent().map(|p| p == screenshots_dir).unwrap_or(false); + + if is_screenshot { + let app = app.clone(); + let screenshot_path = path; + tokio::spawn(async move { + let _ = ShowCapWindow::ScreenshotEditor { + path: screenshot_path, + } + .show(&app) + .await; + }); + return; + } + + let meta = match RecordingMeta::load_for_project(&path) { + Ok(m) => m, + Err(e) => { + tracing::error!("Failed to load recording meta for previous item: {e}"); + return; + } + }; + + match &meta.inner { + RecordingMetaInner::Studio(_) => { + let app = app.clone(); + let project_path = path.clone(); + tokio::spawn(async move { + let _ = ShowCapWindow::Editor { project_path }.show(&app).await; + }); + } + RecordingMetaInner::Instant(_) => { + if let Some(sharing) = &meta.sharing { + let _ = app.opener().open_url(&sharing.link, None::); + } else { + let mp4_path = path.join("content/output.mp4"); + if mp4_path.exists() { + let _ = app + .opener() + .open_path(mp4_path.to_str().unwrap_or_default(), None::); + } + } + } + } +} + +pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { + let items = load_all_previous_items(app, false); + let cache = Arc::new(Mutex::new(PreviousItemsCache { items })); + + let menu = { + let cache_guard = cache.lock().unwrap(); + build_tray_menu(app, &cache_guard)? + }; let app = app.clone(); let is_recording = Arc::new(AtomicBool::new(false)); + let _ = TrayIconBuilder::with_id("tray") .icon(Image::from_bytes(include_bytes!( "../icons/tray-default-icon.png" @@ -159,13 +493,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { } .emit(&app_handle); } - Ok(TrayItem::PreviousRecordings) => { + Ok(TrayItem::ViewAllRecordings) => { let _ = RequestOpenSettings { page: "recordings".to_string(), } .emit(&app_handle); } - Ok(TrayItem::PreviousScreenshots) => { + Ok(TrayItem::ViewAllScreenshots) => { let _ = RequestOpenSettings { page: "screenshots".to_string(), } @@ -197,6 +531,9 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { Ok(TrayItem::Quit) => { app.exit(0); } + Ok(TrayItem::PreviousItem(path)) => { + handle_previous_item_click(app, &path); + } _ => {} } }) @@ -218,6 +555,49 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { }) .build(&app); + { + let app_clone = app.clone(); + let cache_clone = cache.clone(); + std::thread::spawn(move || { + let screenshots_dir = screenshots_path(&app_clone); + let items_needing_thumbnails: Vec = { + let cache_guard = cache_clone.lock().unwrap(); + cache_guard + .items + .iter() + .filter(|item| item.thumbnail.is_none()) + .map(|item| item.path.clone()) + .collect() + }; + + if items_needing_thumbnails.is_empty() { + return; + } + + for path in items_needing_thumbnails { + if let Some(updated_item) = load_single_item(&path, &screenshots_dir, true) { + let mut cache_guard = cache_clone.lock().unwrap(); + if let Some(existing) = cache_guard.items.iter_mut().find(|i| i.path == path) { + existing.thumbnail = updated_item.thumbnail; + existing.thumbnail_width = updated_item.thumbnail_width; + existing.thumbnail_height = updated_item.thumbnail_height; + } + } + } + + let app_for_refresh = app_clone.clone(); + let cache_for_refresh = cache_clone.clone(); + let _ = app_clone.run_on_main_thread(move || { + if let Some(tray) = app_for_refresh.tray_by_id("tray") { + let cache_guard = cache_for_refresh.lock().unwrap(); + if let Ok(menu) = build_tray_menu(&app_for_refresh, &cache_guard) { + let _ = tray.set_menu(Some(menu)); + } + } + }); + }); + } + RecordingStarted::listen_any(&app, { let app = app.clone(); let is_recording = is_recording.clone(); @@ -248,5 +628,31 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> { } }); + NewStudioRecordingAdded::listen_any(&app, { + let app_handle = app.clone(); + let cache_clone = cache.clone(); + move |event| { + add_new_item_to_cache(&cache_clone, &app_handle, event.payload.path.clone()); + refresh_tray_menu(&app_handle, &cache_clone); + } + }); + + NewScreenshotAdded::listen_any(&app, { + let app_handle = app.clone(); + let cache_clone = cache.clone(); + move |event| { + let path = if event.payload.path.extension().and_then(|s| s.to_str()) == Some("png") { + event.payload.path.parent().map(|p| p.to_path_buf()) + } else { + Some(event.payload.path.clone()) + }; + + if let Some(path) = path { + add_new_item_to_cache(&cache_clone, &app_handle, path); + refresh_tray_menu(&app_handle, &cache_clone); + } + } + }); + Ok(()) } diff --git a/apps/desktop/src-tauri/src/update_project_names.rs b/apps/desktop/src-tauri/src/update_project_names.rs new file mode 100644 index 0000000000..723ad80b87 --- /dev/null +++ b/apps/desktop/src-tauri/src/update_project_names.rs @@ -0,0 +1,326 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +use cap_project::RecordingMeta; +use futures::StreamExt; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; +use tokio::{fs, sync::Mutex}; + +use crate::recordings_path; + +const STORE_KEY: &str = "uuid_projects_migrated"; + +pub fn migrate_if_needed(app: &AppHandle) -> Result<(), String> { + let store = app + .store("store") + .map_err(|e| format!("Failed to access store: {}", e))?; + + if store + .get(STORE_KEY) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + return Ok(()); + } + + let app_handle = app.clone(); + tokio::spawn(async move { + if let Err(err) = migrate(&app_handle).await { + tracing::error!("Updating project names failed: {err}"); + } + + if let Ok(store) = app_handle.store("store") { + store.set(STORE_KEY, true); + if let Err(e) = store.save() { + tracing::error!("Failed to save store after migration: {}", e); + } + } + }); + + Ok(()) +} + +use std::time::Instant; + +/// Performs a one-time migration of all UUID-named projects to pretty name-based naming. +pub async fn migrate(app: &AppHandle) -> Result<(), String> { + let recordings_dir = recordings_path(app); + if !fs::try_exists(&recordings_dir) + .await + .map_err(|e| format!("Failed to check recordings directory: {}", e))? + { + return Ok(()); + } + + let uuid_projects = collect_uuid_projects(&recordings_dir).await?; + if uuid_projects.is_empty() { + tracing::debug!("No UUID-named projects found to migrate"); + return Ok(()); + } + + tracing::info!( + "Found {} UUID-named projects to migrate", + uuid_projects.len() + ); + + let total_found = uuid_projects.len(); + let concurrency_limit = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .clamp(2, 16) + .min(total_found); + tracing::debug!("Using concurrency limit of {}", concurrency_limit); + + let wall_start = Instant::now(); + let in_flight_bases = Arc::new(Mutex::new(HashSet::new())); + + // (project_name, result, duration) + let migration_results = futures::stream::iter(uuid_projects) + .map(|project_path| { + let in_flight = in_flight_bases.clone(); + async move { + let project_name = project_path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| project_path.display().to_string()); + + let start = Instant::now(); + let res = migrate_single_project(project_path, in_flight).await; + let dur = start.elapsed(); + + (project_name, res, dur) + } + }) + .buffer_unordered(concurrency_limit) + .collect::>() + .await; + + let wall_elapsed = wall_start.elapsed(); + + let mut migrated = 0usize; + let mut skipped = 0usize; + let mut failed = 0usize; + + let mut total_ms: u128 = 0; + let mut per_project: Vec<(String, std::time::Duration)> = + Vec::with_capacity(migration_results.len()); + + for (name, result, dur) in migration_results.into_iter() { + match result { + Ok(ProjectMigrationResult::Migrated) => migrated += 1, + Ok(ProjectMigrationResult::Skipped) => skipped += 1, + Err(_) => failed += 1, + } + total_ms += dur.as_millis(); + per_project.push((name, dur)); + } + + let avg_ms = if total_found > 0 { + (total_ms as f64) / (total_found as f64) + } else { + 0.0 + }; + + // Sort by duration descending to pick slowest + per_project.sort_by(|a, b| b.1.cmp(&a.1)); + + tracing::info!( + total_found = total_found, + migrated = migrated, + skipped = skipped, + failed = failed, + wall_ms = wall_elapsed.as_millis(), + avg_per_project_ms = ?avg_ms, + "Migration complete" + ); + + // Log top slowest N (choose 5 or less) + let top_n = 5.min(per_project.len()); + if top_n > 0 { + tracing::info!("Top {} slowest project migrations:", top_n); + for (name, dur) in per_project.into_iter().take(top_n) { + tracing::info!(project = %name, ms = dur.as_millis()); + } + } + + Ok(()) +} + +async fn collect_uuid_projects(recordings_dir: &Path) -> Result, String> { + let mut uuid_projects = Vec::new(); + let mut entries = fs::read_dir(recordings_dir) + .await + .map_err(|e| format!("Failed to read recordings directory: {}", e))?; + + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("Failed to read directory entry: {}", e))? + { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let Some(filename) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + + if filename.ends_with(".cap") && fast_is_project_filename_uuid(filename) { + uuid_projects.push(path); + } + } + + Ok(uuid_projects) +} + +#[derive(Debug)] +enum ProjectMigrationResult { + Migrated, + Skipped, +} + +async fn migrate_single_project( + path: PathBuf, + in_flight_basis: Arc>>, +) -> Result { + let filename = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); + + let meta = match RecordingMeta::load_for_project(&path) { + Ok(meta) => meta, + Err(e) => { + tracing::warn!("Failed to load metadata for {}: {}", filename, e); + return Err(format!("Failed to load metadata: {}", e)); + } + }; + + // Lock on the base sanitized name to prevent concurrent migrations with same target + let base_name = sanitize_filename::sanitize(meta.pretty_name.replace(":", ".")); + { + let mut in_flight = in_flight_basis.lock().await; + let mut wait_count = 0; + while !in_flight.insert(base_name.clone()) { + wait_count += 1; + if wait_count == 1 { + tracing::debug!( + "Project {} waiting for concurrent migration of base name \"{}\"", + filename, + base_name + ); + } + drop(in_flight); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + in_flight = in_flight_basis.lock().await; + } + if wait_count > 0 { + tracing::debug!( + "Project {} acquired lock for \"{}\" after {} waits", + filename, + base_name, + wait_count + ); + } + } + + let result = migrate_project_filename_async(&path, &meta).await; + + in_flight_basis.lock().await.remove(&base_name); + + match result { + Ok(new_path) => { + if new_path != path { + let new_name = new_path.file_name().unwrap().to_string_lossy(); + tracing::info!("Updated name: \"{}\" -> \"{}\"", filename, new_name); + Ok(ProjectMigrationResult::Migrated) + } else { + Ok(ProjectMigrationResult::Skipped) + } + } + Err(e) => { + tracing::error!("Failed to migrate {}: {}", filename, e); + Err(e) + } + } +} + +/// Migrates a project filename from UUID to sanitized pretty name +async fn migrate_project_filename_async( + project_path: &Path, + meta: &RecordingMeta, +) -> Result { + let sanitized = sanitize_filename::sanitize(meta.pretty_name.replace(":", ".")); + + let filename = if sanitized.ends_with(".cap") { + sanitized + } else { + format!("{}.cap", sanitized) + }; + + let parent_dir = project_path + .parent() + .ok_or("Project path has no parent directory")?; + + let unique_filename = cap_utils::ensure_unique_filename(&filename, parent_dir) + .map_err(|e| format!("Failed to ensure unique filename: {}", e))?; + + let final_path = parent_dir.join(&unique_filename); + + fs::rename(project_path, &final_path) + .await + .map_err(|e| format!("Failed to rename project directory: {}", e))?; + + Ok(final_path) +} + +pub fn fast_is_project_filename_uuid(filename: &str) -> bool { + if filename.len() != 40 || !filename.ends_with(".cap") { + return false; + } + + let uuid_part = &filename[..36]; + + if uuid_part.as_bytes()[8] != b'-' + || uuid_part.as_bytes()[13] != b'-' + || uuid_part.as_bytes()[18] != b'-' + || uuid_part.as_bytes()[23] != b'-' + { + return false; + } + + uuid_part.chars().all(|c| c.is_ascii_hexdigit() || c == '-') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_project_filename_uuid() { + // Valid UUID + assert!(fast_is_project_filename_uuid( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890.cap" + )); + assert!(fast_is_project_filename_uuid( + "00000000-0000-0000-0000-000000000000.cap" + )); + + // Invalid cases + assert!(!fast_is_project_filename_uuid("my-project-name.cap")); + assert!(!fast_is_project_filename_uuid( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + )); + assert!(!fast_is_project_filename_uuid( + "a1b2c3d4-e5f6-7890-abcd-ef1234567890.txt" + )); + assert!(!fast_is_project_filename_uuid( + "g1b2c3d4-e5f6-7890-abcd-ef1234567890.cap" + )); + } +} diff --git a/apps/desktop/src-tauri/src/windows.rs b/apps/desktop/src-tauri/src/windows.rs index 8fa1eb331f..0e817aa101 100644 --- a/apps/desktop/src-tauri/src/windows.rs +++ b/apps/desktop/src-tauri/src/windows.rs @@ -444,6 +444,9 @@ impl ShowCapWindow { if let Some(main) = CapWindowId::Main.get(app) { let _ = main.close(); }; + if let Some(camera) = CapWindowId::Camera.get(app) { + let _ = camera.close(); + }; self.window_builder(app, "/editor") .maximizable(true) @@ -455,6 +458,9 @@ impl ShowCapWindow { if let Some(main) = CapWindowId::Main.get(app) { let _ = main.close(); }; + if let Some(camera) = CapWindowId::Camera.get(app) { + let _ = camera.close(); + }; self.window_builder(app, "/screenshot-editor") .maximizable(true) @@ -705,7 +711,7 @@ impl ShowCapWindow { .maximized(false) .resizable(false) .fullscreen(false) - .shadow(!cfg!(windows)) + .shadow(false) .always_on_top(true) .transparent(true) .visible_on_all_workspaces(true) @@ -727,6 +733,8 @@ impl ShowCapWindow { crate::platform::set_window_level(window.as_ref().window(), 1000); } + fake_window::spawn_fake_window_listener(app.clone(), window.clone()); + window } Self::RecordingsOverlay => { diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx index 9d45814b74..d5d7936754 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetMenuGrid.tsx @@ -90,7 +90,7 @@ function ViewAllButton(props: { onClick: () => void; label: string }) { + ); + } + + return ( +
+
+
+

Default Project Name

+

+ Choose the template to use as the default project and file name. +

+
+
+ + + +
+
+ +
+ { + setInputValue(e.currentTarget.value); + updatePreview(e.currentTarget.value); + }} + /> + +
+ +

{preview()}

+
+ + + + +

How to customize?

+
+ + +

+ Use placeholders in your template that will be automatically + filled in. +

+ +
+

Recording Mode

+

+ {"{recording_mode}"} → "Studio", "Instant", + or "Screenshot" +

+

+ {"{mode}"} → "studio", "instant", or + "screenshot" +

+
+ +
+

Target

+

+ {"{target_kind}"} → "Display", "Window", or + "Area" +

+

+ {"{target_name}"} → The name of the monitor + or the title of the app depending on the recording mode. +

+
+ +
+

Date & Time

+

+ {"{date}"} → {dateString} +

+

+ {"{time}"} →{" "} + {macos ? "09:41 AM" : "12:00 PM"} +

+
+ +
+

Custom Formats

+

+ You can also use a custom format for time. The placeholders are + case-sensitive. For 24-hour time, use{" "} + {"{moment:HH:mm}"} or use lower cased{" "} + hh for 12-hour format. +

+

+ {MOMENT_EXAMPLE_TEMPLATE} →{" "} + {momentExample()} +

+
+
+
+
+
+ ); +} + function ExcludedWindowsCard(props: { excludedWindows: WindowExclusion[]; availableWindows: CaptureWindow[]; @@ -735,7 +951,7 @@ function ExcludedWindowsCard(props: {

-
+
-
+
@@ -593,7 +595,7 @@ export function CaptionsTab() { }>
- Font Family + Font Family options={fontOptions.map((f) => f.value)} value={getSetting("font")} @@ -617,7 +619,7 @@ export function CaptionsTab() { )} > - + > {(state) => fontOptions.find( @@ -644,7 +646,7 @@ export function CaptionsTab() {
- Size + Size updateCaptionSetting("size", v[0])} @@ -656,7 +658,7 @@ export function CaptionsTab() {
- Font Color + Font Color updateCaptionSetting("color", value)} @@ -668,7 +670,7 @@ export function CaptionsTab() { }>
- Background Color + Background Color @@ -678,7 +680,7 @@ export function CaptionsTab() {
- Background Opacity + Background Opacity @@ -717,7 +719,7 @@ export function CaptionsTab() { )} > - + > {(state) => ( @@ -749,7 +751,7 @@ export function CaptionsTab() { }>
- Highlight Color + Highlight Color @@ -758,7 +760,7 @@ export function CaptionsTab() { />
- Fade Duration + Fade Duration @@ -769,7 +771,7 @@ export function CaptionsTab() { step={1} disabled={!hasCaptions()} /> - + {(getSetting("fadeDuration") * 1000).toFixed(0)}ms
@@ -792,7 +794,7 @@ export function CaptionsTab() {
- Outline Color + Outline Color @@ -832,11 +834,11 @@ export function CaptionsTab() {
{(segment) => ( -
+
-
-