diff --git a/Cargo.lock b/Cargo.lock index 0d759a4c6e..1465878c16 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.3.84" 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", @@ -1598,12 +1601,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", @@ -7638,6 +7643,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" @@ -8496,6 +8510,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 0b35f66d45..34b18cf08f 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 46024fd0ab..cf8a414ff1 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/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 e3c3f1ffa0..b268fb7b4f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod recording_settings; mod target_select_overlay; mod thumbnails; mod tray; +mod update_project_names; mod upload; mod web_api; mod window_exclusion; @@ -2298,7 +2299,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, @@ -2442,6 +2444,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); @@ -3052,6 +3059,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 1c821f87b2..8deb4f55d6 100644 --- a/apps/desktop/src-tauri/src/recording.rs +++ b/apps/desktop/src-tauri/src/recording.rs @@ -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}, @@ -349,6 +352,122 @@ pub enum RecordingAction { UpgradeRequired, } +/// Formats the project name using a template string. +/// +/// # Template Variables +/// +/// The template supports the following variables that will be replaced with actual values: +/// +/// ## Recording Mode Variables +/// - `{recording_mode}` - The recording mode: "Studio" or "Instant" +/// - `{mode}` - Short form of recording mode: "studio" or "instant" +/// +/// ## Target Variables +/// - `{target_kind}` - The type of capture target: "Display", "Window", or "Area" +/// - `{target_name}` - The specific name of the target (e.g., "Built-in Retina Display", "Chrome", etc.) +/// +/// ## Date/Time Variables +/// - `{date}` - Current date in YYYY-MM-DD format (e.g., "2025-09-11") +/// - `{time}` - Current time in HH:MM AM/PM format (e.g., "3:23 PM") +/// +/// ## Customizable Date/Time Formats +/// You can customize date and time formats by adding moment format specifiers: +/// - `{moment:YYYY-MM-DD}` - Custom date format +/// - `{moment:HH:mm}` - 24-hour time format +/// - `{moment:hh:mm A}` - 12-hour time with AM/PM +/// - `{moment:YYYY-MM-DD HH:mm}` - Combined custom format +/// +/// ## Example +/// +/// `{recording_mode} Recording {target_kind} ({target_name}) {date} {time}` +/// -> "Instant Recording Display (Built-in Retina Display) 2025-11-12 3:23 PM" +/// +/// # Arguments +/// +/// * `template` - The template string with variables to replace +/// * `target_name` - The specific name of the capture target +/// * `target_kind` - The type of capture target (Display, Window, or Area) +/// * `recording_mode` - The recording mode (Studio or Instant) +/// * `datetime` - Optional datetime to use for formatting; defaults to current time +/// +/// # Returns +/// +/// Returns `String` with the formatted project name +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"), + }; + + 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,16 +480,32 @@ 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, + ); + + 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 recording_dir = recordings_base_dir.join(&cap_utils::ensure_unique_filename( + &filename, + &recordings_base_dir, + )?); ensure_dir(&recording_dir).map_err(|e| format!("Failed to create recording directory: {e}"))?; state_mtx @@ -379,16 +514,6 @@ pub async fn start_recording( .add_recording_logging_handle(&recording_dir.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 +527,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(), ) @@ -443,17 +565,10 @@ pub async fn start_recording( RecordingMode::Studio => None, }; - 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}"), + pretty_name: project_name.clone(), inner: match inputs.mode { RecordingMode::Studio => { RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments { @@ -541,7 +656,7 @@ pub async fn start_recording( 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 project_name = project_name.clone(); let inputs = inputs.clone(); async move { fail!("recording::spawn_actor"); @@ -603,7 +718,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(), }; 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..f52737ab76 --- /dev/null +++ b/apps/desktop/src-tauri/src/update_project_names.rs @@ -0,0 +1,322 @@ +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; + +use cap_project::RecordingMeta; +use futures::StreamExt; +use tauri::AppHandle; +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> { + use tauri_plugin_store::StoreExt; + + 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(()); + } + + if let Err(err) = futures::executor::block_on(migrate(app)) { + tracing::error!("Updating project names failed: {err}"); + } + + store.set(STORE_KEY, true); + store + .save() + .map_err(|e| format!("Failed to save store: {}", 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/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx index 08b4846400..c4b21dd445 100644 --- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx +++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx @@ -6,6 +6,7 @@ import { } from "@tauri-apps/plugin-notification"; import { type OsType, type } from "@tauri-apps/plugin-os"; import "@total-typescript/ts-reset/filter-boolean"; +import { Collapsible } from "@kobalte/core/collapsible"; import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu"; import { confirm } from "@tauri-apps/plugin-dialog"; import { cx } from "cva"; @@ -13,7 +14,9 @@ import { createEffect, createMemo, createResource, + createSignal, For, + onMount, type ParentProps, Show, } from "solid-js"; @@ -99,6 +102,9 @@ const INSTANT_MODE_RESOLUTION_OPTIONS = [ label: string; }[]; +const DEFAULT_PROJECT_NAME_TEMPLATE = + "{target_name} ({target_kind}) {date} {time}"; + export default function GeneralSettings() { const [store] = createResource(() => generalSettingsStore.get()); @@ -537,6 +543,13 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) { /> + + handleChange("defaultProjectNameTemplate", value) + } + value={settings.defaultProjectNameTemplate ?? null} + /> + Promise; +}) { + const MOMENT_EXAMPLE_TEMPLATE = "{moment:DDDD, MMMM D, YYYY h:mm A}"; + const macos = type() === "macos"; + const today = new Date(); + const datetime = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + macos ? 9 : 12, + macos ? 41 : 0, + 0, + 0, + ).toISOString(); + + let inputRef: HTMLInputElement | undefined; + + const dateString = today.toISOString().split("T")[0]; + const initialTemplate = () => props.value ?? DEFAULT_PROJECT_NAME_TEMPLATE; + + const [inputValue, setInputValue] = createSignal(initialTemplate()); + const [preview, setPreview] = createSignal(null); + const [momentExample, setMomentExample] = createSignal(""); + + async function updatePreview(val = inputValue()) { + const formatted = await commands.formatProjectName( + val, + macos ? "Safari" : "Chrome", + "Window", + "instant", + datetime, + ); + setPreview(formatted); + } + + onMount(() => { + commands + .formatProjectName( + MOMENT_EXAMPLE_TEMPLATE, + macos ? "Safari" : "Chrome", + "Window", + "instant", + datetime, + ) + .then(setMomentExample); + + const seed = initialTemplate(); + setInputValue(seed); + if (inputRef) inputRef.value = seed; + updatePreview(seed); + }); + + const isSaveDisabled = () => { + const input = inputValue(); + return ( + !input || + input === (props.value ?? DEFAULT_PROJECT_NAME_TEMPLATE) || + input.length <= 3 + ); + }; + + function CodeView(props: { children: string }) { + return ( + + ); + } + + 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" or + "Instant" +

+

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

+
+ +
+

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[]; @@ -704,7 +919,7 @@ function ExcludedWindowsCard(props: {

-
+