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
-
+