Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5207fbb
Fix trim handle flickering in editor
MinitJain Apr 4, 2026
34cd3f4
Fix local build: remove Spacedrive.framework ref and disable updater …
MinitJain Apr 4, 2026
c2ecf24
Merge remote-tracking branch 'upstream/main'
MinitJain Apr 5, 2026
386ed05
Merge upstream/main into main
MinitJain Apr 8, 2026
092c0cc
feat(editor): add Cap recording import for timeline stitching (#1712)
MinitJain Apr 9, 2026
c462553
fix: apply Biome formatting to Timeline/index.tsx
MinitJain Apr 9, 2026
05b46dd
fix: collapse nested if statements to satisfy Clippy deny
MinitJain Apr 9, 2026
37c7059
fix: address Greptile review issues
MinitJain Apr 9, 2026
6fdc529
Merge upstream/main into feat/recording-track
MinitJain May 13, 2026
bafd548
fix: revert unintentional config changes and guard import loading state
MinitJain Jun 19, 2026
cdf2607
fix(project): store external recording paths as relative
MinitJain Jun 19, 2026
dde84ab
fix(import): canonicalize paths and guard against self-import
MinitJain Jun 19, 2026
4cb97b3
fix(import): fail fast when existing external recording is not studio…
MinitJain Jun 19, 2026
38cc5fe
fix(import): add missing name field to TimelineSegment initializer
MinitJain Jun 20, 2026
a69f066
chore: merge upstream main into feat/recording-track
MinitJain Jun 20, 2026
c23e33c
style: expand single-line if/else blocks to satisfy rustfmt
MinitJain Jun 20, 2026
f5fcd8c
fix(import): canonicalize dedup check, reject non-UTF-8 paths, wire T…
MinitJain Jun 20, 2026
cd872f9
fix(import): overlay hover-only, zero-segment guard, Windows cross-dr…
MinitJain Jun 20, 2026
ffc59ea
style: fix rustfmt formatting on dedup check closure
MinitJain Jun 20, 2026
0aeb48a
fix(project,editor): address clippy and bot review findings
MinitJain Jun 20, 2026
1f4b33f
fix(import): replace lock().unwrap() with propagated error
MinitJain Jun 20, 2026
52e87a3
chore: merge upstream/main; fix pre-existing typecheck errors
MinitJain Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1464,7 +1464,7 @@ async fn generate_export_preview_inner(
settings: ExportPreviewSettings,
) -> Result<ExportPreviewResult, String> {
use base64::{Engine, engine::general_purpose::STANDARD};
use cap_editor::create_segments;
use cap_editor::create_all_segments;
use std::time::Instant;

let recording_meta = RecordingMeta::load_for_project(&project_path)
Expand All @@ -1474,12 +1474,16 @@ async fn generate_export_preview_inner(
return Err("Cannot preview non-studio recordings".to_string());
};

let project_config =
export_project_config(recording_meta.project_config(), settings.cursor_only);
let source_project_config = recording_meta.project_config();
let project_config = export_project_config(source_project_config.clone(), settings.cursor_only);

let recordings = Arc::new(
ProjectRecordingsMeta::new(&recording_meta.project_path, studio_meta)
.map_err(|e| format!("Failed to load recordings: {e}"))?,
ProjectRecordingsMeta::new_with_external(
&recording_meta.project_path,
studio_meta,
&source_project_config.external_recordings,
)
.map_err(|e| format!("Failed to load recordings: {e}"))?,
);

let render_constants = Arc::new(
Expand All @@ -1499,9 +1503,14 @@ async fn generate_export_preview_inner(
"Starting export preview"
);

let segments = create_segments(&recording_meta, studio_meta, force_ffmpeg)
.await
.map_err(|e| format!("Failed to create segments: {e}"))?;
let segments = create_all_segments(
&recording_meta,
studio_meta,
&source_project_config.external_recordings,
force_ffmpeg,
)
.await
.map_err(|e| format!("Failed to create segments: {e}"))?;

let render_segments: Vec<RenderSegment> = segments
.iter()
Expand Down
204 changes: 202 additions & 2 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2799,7 +2799,10 @@ async fn create_editor_instance(window: Window) -> Result<SerializedEditorInstan

let path = {
let window_ids = EditorWindowIds::get(window.app_handle());
let window_ids = window_ids.ids.lock().unwrap();
let window_ids = window_ids
.ids
.lock()
.map_err(|_| "Failed to lock editor window ids".to_string())?;

let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else {
return Err("Editor instance not found".to_string());
Expand Down Expand Up @@ -2836,7 +2839,10 @@ async fn get_editor_project_path(window: Window) -> Result<PathBuf, String> {
};

let window_ids = EditorWindowIds::get(window.app_handle());
let window_ids = window_ids.ids.lock().unwrap();
let window_ids = window_ids
.ids
.lock()
.map_err(|_| "Failed to lock editor window ids".to_string())?;

let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else {
return Err("Editor instance not found".to_string());
Expand All @@ -2845,6 +2851,198 @@ async fn get_editor_project_path(window: Window) -> Result<PathBuf, String> {
Ok(path.clone())
}

#[derive(Serialize, Type, tauri_specta::Event, Clone, Debug)]
pub struct CapRecordingImported {
pub project_path: String,
}

fn relative_path_from(base: &std::path::Path, target: &std::path::Path) -> PathBuf {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relative_path_from assumes base/target share a common prefix. On Windows, importing from a different drive (or UNC vs disk) will produce a nonsense path like ..\..\C:\... and then break subsequent loads.

Suggested change
fn relative_path_from(base: &std::path::Path, target: &std::path::Path) -> PathBuf {
fn relative_path_from(base: &std::path::Path, target: &std::path::Path) -> PathBuf {
#[cfg(windows)]
{
use std::path::Component;
if let (Some(Component::Prefix(a)), Some(Component::Prefix(b))) =
(base.components().next(), target.components().next())
{
if a != b {
return target.to_path_buf();
}
}
}
let base: Vec<_> = base.components().collect();
let target: Vec<_> = target.components().collect();
let common = base.iter().zip(&target).take_while(|(a, b)| a == b).count();
let mut rel = PathBuf::new();
for _ in 0..(base.len() - common) {
rel.push("..");
}
for c in &target[common..] {
rel.push(c);
}
rel
}

#[cfg(windows)]
{
use std::path::Component;
if let (Some(Component::Prefix(a)), Some(Component::Prefix(b))) =
(base.components().next(), target.components().next())
{
if a != b {
return target.to_path_buf();
}
}
}
let base: Vec<_> = base.components().collect();
let target: Vec<_> = target.components().collect();
let common = base.iter().zip(&target).take_while(|(a, b)| a == b).count();
let mut rel = PathBuf::new();
for _ in 0..(base.len() - common) {
rel.push("..");
}
for c in &target[common..] {
rel.push(c);
}
rel
}

fn resolve_recording_path(stored: &str, project_path: &std::path::Path) -> PathBuf {
let p = std::path::Path::new(stored);
if p.is_absolute() {
p.to_path_buf()
} else {
project_path.join(p)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Stored external recording paths are not sanitized, allowing path traversal

Unsanitized external recording path strings from project config are joined against the project directory, allowing traversal outside the project scope.

Validate that resolved external recording paths are contained within an allowed base directory before loading.

AI prompt
Check if this security scanner issue is valid. If so, understand the root cause and fix it. If appropriate, update or add tests. Keep the change focused and preserve intended behavior.

<file name="apps/desktop/src-tauri/src/lib.rs">
<violation number="1" location="apps/desktop/src-tauri/src/lib.rs:2883">
<priority>P2</priority>
<title>Stored external recording paths are not sanitized, allowing path traversal</title>
<evidence>The resolve_recording_path function and the corresponding loading logic in ProjectRecordingsMeta::new_with_external and create_all_segments resolve ExternalRecordingReference.path directly via Path::new and Path::join without sanitizing .. components. A malicious project configuration could reference files outside the project directory.</evidence>
<recommendation>Before resolving any stored external recording path, normalize it and ensure the resulting absolute path is within the project directory or the user's explicitly selected import scope. Reject paths that escape the intended sandbox.</recommendation>
</violation>
</file>

}
}

#[tauri::command]
#[specta::specta]
async fn import_cap_recording(window: Window, recording_path: PathBuf) -> Result<(), String> {
let CapWindowId::Editor { id } =
CapWindowId::from_str(window.label()).map_err(|e| e.to_string())?
else {
return Err("Invalid window".to_string());
};

let project_path = {
let window_ids = EditorWindowIds::get(window.app_handle());
let window_ids = window_ids
.ids
.lock()
.map_err(|_| "Failed to lock editor window ids".to_string())?;
let Some((path, _)) = window_ids.iter().find(|(_, _id)| *_id == id) else {
return Err("Editor instance not found".to_string());
};
path.clone()
};

if !recording_path.exists() || !recording_path.join("recording-meta.json").exists() {
return Err("Not a valid Cap recording".to_string());
}

let recording_path = recording_path
.canonicalize()
.map_err(|e| format!("Failed to resolve recording path: {e}"))?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth guarding against importing the project into itself (user selects the current .cap folder). Without this, we can append duplicate clips/segments and end up with confusing timeline state.

Suggested change
.map_err(|e| format!("Failed to resolve recording path: {e}"))?;
let recording_path = recording_path
.canonicalize()
.map_err(|e| format!("Failed to resolve recording path: {e}"))?;
let project_path = project_path
.canonicalize()
.map_err(|e| format!("Failed to resolve project path: {e}"))?;
if recording_path == project_path {
return Err("Cannot import a recording into itself".to_string());
}

let project_path = project_path
.canonicalize()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor edge case: since project_path gets canonicalized before relative_path_from(&project_path, &recording_path), the stored relative path is based on the realpath. If the project is later opened via a symlink / different casing path, resolving the stored .. path against that non-canonical base can point somewhere else. Consider keeping the original project_path for relative-path computation (and using canonical paths only for equality/validation), or storing an absolute canonical path.

.map_err(|e| format!("Failed to resolve project path: {e}"))?;

if recording_path == project_path {
return Err("Cannot import a recording into itself".to_string());
}

let ext_meta = RecordingMeta::load_for_project(&recording_path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but helpful for dedupe/stability: canonicalize recording_path once after the existence check. This avoids treating the same folder as distinct due to symlinks, path separators, or case differences (especially on Windows), and also means you store the normalized absolute path in external_recordings.

Suggested change
let ext_meta = RecordingMeta::load_for_project(&recording_path)
let recording_path = recording_path
.canonicalize()
.map_err(|e| format!("Failed to resolve recording path: {e}"))?;
let ext_meta = RecordingMeta::load_for_project(&recording_path)

.map_err(|e| format!("Failed to load recording meta: {e}"))?;
Comment on lines +2929 to +2930

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth guarding against importing the project into itself (or via a symlink). Right now selecting the current project’s .cap folder will duplicate timeline segments and add a self-reference.

Suggested change
let ext_meta = RecordingMeta::load_for_project(&recording_path)
.map_err(|e| format!("Failed to load recording meta: {e}"))?;
let project_path_canon = project_path
.canonicalize()
.map_err(|e| format!("Failed to resolve project path: {e}"))?;
if recording_path == project_path_canon {
return Err("Cannot import the current project recording".to_string());
}
let ext_meta = RecordingMeta::load_for_project(&recording_path)
.map_err(|e| format!("Failed to load recording meta: {e}"))?;

let RecordingMetaInner::Studio(ext_studio_meta) = &ext_meta.inner else {
return Err("External recording is not a studio recording".to_string());
};

let primary_meta = RecordingMeta::load_for_project(&project_path)
.map_err(|e| format!("Failed to load project meta: {e}"))?;
let RecordingMetaInner::Studio(primary_studio_meta) = &primary_meta.inner else {
return Err("Project is not a studio recording".to_string());
};

let primary_recordings =
cap_rendering::ProjectRecordingsMeta::new(&primary_meta.project_path, primary_studio_meta)
.map_err(|e| format!("Failed to load primary recordings: {e}"))?;
let ext_recordings =
cap_rendering::ProjectRecordingsMeta::new(&recording_path, ext_studio_meta)
.map_err(|e| format!("Failed to load external recordings: {e}"))?;

let Some(primary_first) = primary_recordings.segments.first() else {
return Err("Project has no segments".to_string());
};
let Some(ext_first) = ext_recordings.segments.first() else {
return Err("External recording has no segments".to_string());
};
if ext_first.display.width != primary_first.display.width
|| ext_first.display.height != primary_first.display.height
{
return Err(format!(
"Recording resolution {}x{} does not match project resolution {}x{}",
ext_first.display.width,
ext_first.display.height,
primary_first.display.width,
primary_first.display.height,
));
}

let mut project_config = ProjectConfiguration::load(&project_path)
.map_err(|e| format!("Failed to load project config: {e}"))?;

if project_config.external_recordings.iter().any(|r| {
resolve_recording_path(&r.path, &project_path)
.canonicalize()
.ok()
.as_deref()
== Some(recording_path.as_path())
}) {
return Err("This recording has already been imported".to_string());
}

let ext_segment_counts = project_config
.external_recordings
.iter()
.enumerate()
.map(|(i, r)| {
let p = resolve_recording_path(&r.path, &project_path);
let m = RecordingMeta::load_for_project(&p)
.map_err(|e| format!("existing external recording {i}: {e}"))?;
let studio = m.studio_meta().ok_or_else(|| {
format!("existing external recording {i}: not a studio recording")
})?;
Ok(match studio {
cap_project::StudioRecordingMeta::SingleSegment { .. } => 1usize,
cap_project::StudioRecordingMeta::MultipleSegments { inner } => {
inner.segments.len()
}
})
})
.collect::<Result<Vec<_>, String>>()?;

let clip_index_offset =
(primary_recordings.segments.len() + ext_segment_counts.iter().sum::<usize>()) as u32;

let label = ext_meta.pretty_name.clone();

Comment on lines +3001 to +3003

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to_string_lossy() will silently mangle non-UTF-8 paths; failing fast here is safer since you persist this into the config.

Suggested change
let label = ext_meta.pretty_name.clone();
path: relative_path_from(&project_path, &recording_path)
.to_str()
.ok_or_else(|| "Recording path is not valid UTF-8".to_string())?
.to_string(),

project_config
.external_recordings
.push(cap_project::ExternalRecordingReference {
path: relative_path_from(&project_path, &recording_path)
.to_str()
.ok_or("Recording path contains non-UTF-8 characters")?
.to_string(),
Comment on lines +3007 to +3010

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, but to_string_lossy() can silently mangle non-UTF-8 paths and then you’ll persist something you can’t resolve on next open.

Suggested change
path: relative_path_from(&project_path, &recording_path)
.to_string_lossy()
.to_string(),
path: relative_path_from(&project_path, &recording_path)
.to_str()
.ok_or("Recording path is not valid UTF-8")?
.to_string(),

label: Some(label),
});

let timeline = project_config.timeline.get_or_insert_with(Default::default);

let ext_segment_count = ext_recordings.segments.len();
for i in 0..ext_segment_count {
let duration = ext_recordings.segments[i].duration();
timeline.segments.push(cap_project::TimelineSegment {
recording_clip: clip_index_offset + i as u32,
start: 0.0,
end: duration,
timescale: 1.0,
name: None,
});
}

project_config
.write(&project_path)
.map_err(|e| format!("Failed to save project config: {e}"))?;

EditorInstances::remove(window.clone()).await;

CapRecordingImported {
project_path: project_path
.to_str()
.ok_or("Project path contains non-UTF-8 characters")?
.to_string(),
}
.emit(&window)
.map_err(|e| format!("Failed to emit event: {e}"))?;

Ok(())
}

#[tauri::command]
#[specta::specta]
#[instrument(skip(editor))]
Expand Down Expand Up @@ -4320,6 +4518,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
import::add_existing_recording_to_editor,
import::start_image_import,
import::check_import_ready,
import_cap_recording,
copy_file_to_path,
copy_video_to_clipboard,
copy_screenshot_to_clipboard,
Expand Down Expand Up @@ -4441,6 +4640,7 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
hotkeys::OnEscapePress,
upload::UploadProgressEvent,
import::VideoImportProgress,
CapRecordingImported,
SetCaptureAreaPending,
DevicesUpdated,
])
Expand Down
Loading
Loading