Skip to content

feat(desktop): custom recordings storage path#1964

Merged
richiemcilroy merged 2 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/custom-recordings-path
Jul 2, 2026
Merged

feat(desktop): custom recordings storage path#1964
richiemcilroy merged 2 commits into
CapSoftware:mainfrom
ManthanNimodiya:feat/custom-recordings-path

Conversation

@ManthanNimodiya

@ManthanNimodiya ManthanNimodiya commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Adds a setting that lets users choose where Cap saves recordings, instead of always using the default Application Support directory.

A new Storage section in General Settings shows the current recordings folder with a Choose Folder button (native folder picker) and a Reset to Default button that appears when a custom path is active.
The chosen path persists across restarts.
Logging and crash recovery also respect the custom path.

Greptile Summary

This PR adds a user-configurable recordings storage path in General Settings, replacing the hardcoded Application Support directory. The chosen path is persisted in the settings store, and logging and crash-recovery scanning are updated to respect it.

  • GeneralSettingsStore::recordings_dir() centralises path resolution: it reads the stored custom path, validates it is absolute, falls back to the default with a warning on failure, and ensures the directory exists via create_dir_all.
  • Two new Tauri commands (pick_recordings_folder / reset_recordings_folder) drive a new StorageSection component with "Choose Folder" and "Reset to Default" buttons; both async handlers have proper try/catch with toast notifications on error.
  • All three former hardcoded app_data_dir().join("recordings") sites (lib.rs, logging.rs, recovery.rs) are unified through the new helper.

Confidence Score: 5/5

Safe to merge; the core path-resolution and security logic is sound and all three previously hardcoded directory sites are consistently unified.

The change is internally consistent: error paths log warnings, the absolute-path validation guards against relative inputs, and the existing symlink/traversal protections in delete_recording_directory are unaffected. The two findings are minor UX gaps (a platform-mismatched label and deferred write-permission feedback) that don't affect data integrity or correctness.

The platform label in general.tsx and the write-permission check in lib.rs (pick_recordings_folder) are worth a second look before shipping to Windows/Linux users.

Important Files Changed

Filename Overview
apps/desktop/src-tauri/src/general_settings.rs Adds recordings_path: Option<String> field and a new recordings_dir() helper that validates the stored path is absolute, logs on error, and falls back to the default Application Support directory.
apps/desktop/src-tauri/src/lib.rs Adds pick_recordings_folder and reset_recordings_folder Tauri commands; delegates recordings_path() to the new GeneralSettingsStore::recordings_dir() helper.
apps/desktop/src-tauri/src/logging.rs Updates upload_log_file to derive the recordings directory from GeneralSettingsStore::recordings_dir instead of the hardcoded Application Support path.
apps/desktop/src-tauri/src/recovery.rs Updates find_incomplete_recordings to use GeneralSettingsStore::recordings_dir for crash recovery scanning, correctly respecting custom paths.
apps/desktop/src/routes/(window-chrome)/settings/general.tsx Adds the StorageSection component with path display, Choose Folder button, and Reset to Default button; both async handlers have try/catch with toast.error on failure. The default path label is macOS-specific.
apps/desktop/src/utils/tauri.ts Adds generated pickRecordingsFolder/resetRecordingsFolder bindings and extends GeneralSettingsStore type with the new recordingsPath field.

Reviews (2): Last reviewed commit: "fix: add error logging, safe path conver..." | Re-trigger Greptile

Comment on lines +334 to +338
pub fn recordings_dir(app: &AppHandle<Wry>) -> std::path::PathBuf {
let custom = Self::get(app)
.ok()
.flatten()
.and_then(|s| s.recordings_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 When Self::get(app) returns an Err (e.g. the settings store is corrupted or fails to deserialize), .ok() silently converts it to None and the custom path is dropped — the user's choice is ignored without any indication in the logs. Adding a tracing::warn! here makes the silent fallback visible when diagnosing "why isn't my custom folder being used?"

Suggested change
pub fn recordings_dir(app: &AppHandle<Wry>) -> std::path::PathBuf {
let custom = Self::get(app)
.ok()
.flatten()
.and_then(|s| s.recordings_path)
pub fn recordings_dir(app: &AppHandle<Wry>) -> std::path::PathBuf {
let custom = Self::get(app)
.map_err(|e| tracing::warn!("Failed to load general settings, using default recordings path: {e}"))
.ok()
.flatten()
.and_then(|s| s.recordings_path)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/general_settings.rs
Line: 334-338

Comment:
When `Self::get(app)` returns an `Err` (e.g. the settings store is corrupted or fails to deserialize), `.ok()` silently converts it to `None` and the custom path is dropped — the user's choice is ignored without any indication in the logs. Adding a `tracing::warn!` here makes the silent fallback visible when diagnosing "why isn't my custom folder being used?"

```suggestion
    pub fn recordings_dir(app: &AppHandle<Wry>) -> std::path::PathBuf {
        let custom = Self::get(app)
            .map_err(|e| tracing::warn!("Failed to load general settings, using default recordings path: {e}"))
            .ok()
            .flatten()
            .and_then(|s| s.recordings_path)
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +4265 to +4274
app.dialog()
.file()
.set_title("Choose Recordings Folder")
.pick_folder(move |path| {
let _ = tx.send(
path.as_ref()
.and_then(|p| p.as_path())
.map(|p| p.to_string_lossy().to_string()),
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 to_string_lossy() replaces invalid UTF-8 bytes with \u{FFFD}. On Windows, if the user picks a folder whose path cannot round-trip through UTF-8, the stored string is silently corrupted; on the next launch recordings_dir will try to create a directory with \u{FFFD} in its name and may silently fall through to the default. Prefer to_str().map(|s| s.to_string()) and surface the error instead.

Suggested change
app.dialog()
.file()
.set_title("Choose Recordings Folder")
.pick_folder(move |path| {
let _ = tx.send(
path.as_ref()
.and_then(|p| p.as_path())
.map(|p| p.to_string_lossy().to_string()),
);
});
app.dialog()
.file()
.set_title("Choose Recordings Folder")
.pick_folder(move |path| {
let _ = tx.send(
path.as_ref()
.and_then(|p| p.as_path())
.and_then(|p| p.to_str().map(|s| s.to_string())),
);
});
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src-tauri/src/lib.rs
Line: 4265-4274

Comment:
`to_string_lossy()` replaces invalid UTF-8 bytes with `\u{FFFD}`. On Windows, if the user picks a folder whose path cannot round-trip through UTF-8, the stored string is silently corrupted; on the next launch `recordings_dir` will try to create a directory with `\u{FFFD}` in its name and may silently fall through to the default. Prefer `to_str().map(|s| s.to_string())` and surface the error instead.

```suggestion
    app.dialog()
        .file()
        .set_title("Choose Recordings Folder")
        .pick_folder(move |path| {
            let _ = tx.send(
                path.as_ref()
                    .and_then(|p| p.as_path())
                    .and_then(|p| p.to_str().map(|s| s.to_string())),
            );
        });
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +642 to +654
<StorageSection
recordingsPath={settings.recordingsPath ?? null}
onPick={async () => {
const path = await commands.pickRecordingsFolder();
if (path !== null) {
setSettings("recordingsPath", path);
}
}}
onReset={async () => {
await commands.resetRecordingsFolder();
setSettings("recordingsPath", null);
}}
/>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Both onPick and onReset are async but have no try/catch. If the Tauri command throws (e.g. the store write fails), the rejection goes unhandled, setSettings is never called, and the user gets no feedback. A minimal console.error at minimum would surface the failure for debugging.

Suggested change
<StorageSection
recordingsPath={settings.recordingsPath ?? null}
onPick={async () => {
const path = await commands.pickRecordingsFolder();
if (path !== null) {
setSettings("recordingsPath", path);
}
}}
onReset={async () => {
await commands.resetRecordingsFolder();
setSettings("recordingsPath", null);
}}
/>
<StorageSection
recordingsPath={settings.recordingsPath ?? null}
onPick={async () => {
try {
const path = await commands.pickRecordingsFolder();
if (path !== null) {
setSettings("recordingsPath", path);
}
} catch (e) {
console.error("Failed to pick recordings folder", e);
}
}}
onReset={async () => {
try {
await commands.resetRecordingsFolder();
setSettings("recordingsPath", null);
} catch (e) {
console.error("Failed to reset recordings folder", e);
}
}}
/>
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/routes/(window-chrome)/settings/general.tsx
Line: 642-654

Comment:
Both `onPick` and `onReset` are async but have no `try/catch`. If the Tauri command throws (e.g. the store write fails), the rejection goes unhandled, `setSettings` is never called, and the user gets no feedback. A minimal `console.error` at minimum would surface the failure for debugging.

```suggestion
				<StorageSection
					recordingsPath={settings.recordingsPath ?? null}
					onPick={async () => {
						try {
							const path = await commands.pickRecordingsFolder();
							if (path !== null) {
								setSettings("recordingsPath", path);
							}
						} catch (e) {
							console.error("Failed to pick recordings folder", e);
						}
					}}
					onReset={async () => {
						try {
							await commands.resetRecordingsFolder();
							setSettings("recordingsPath", null);
						} catch (e) {
							console.error("Failed to reset recordings folder", e);
						}
					}}
				/>
```

How can I resolve this? If you propose a fix, please make it concise.

let path = custom.unwrap_or_else(|| {
app.path().app_data_dir().unwrap().join("recordings")
});
std::fs::create_dir_all(&path).unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Swallowing create_dir_all errors makes it hard to diagnose why recording later fails (especially for a custom path). Logging the error here would help a lot.

Suggested change
std::fs::create_dir_all(&path).unwrap_or_default();
if let Err(e) = std::fs::create_dir_all(&path) {
tracing::warn!(?path, %e, "Failed to create recordings directory");
}

});

let result = rx.await.map_err(|e| e.to_string())?;
if let Some(ref path) = result {

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: recordings_dir() ignores non-absolute stored paths, but this command will persist whatever string comes back. Validating before saving avoids a confusing "UI shows custom path but app uses default" mismatch (and lets you surface errors early).

Suggested change
if let Some(ref path) = result {
if let Some(ref path) = result {
let path_buf = std::path::PathBuf::from(path);
if !path_buf.is_absolute() {
return Err("Picked recordings folder must be an absolute path".to_string());
}
std::fs::create_dir_all(&path_buf)
.map_err(|e| format!("Failed to create recordings folder '{path}': {e}"))?;
general_settings::GeneralSettingsStore::update(&app, |s| {
s.recordings_path = Some(path.clone());
})?;
}


<StorageSection
recordingsPath={settings.recordingsPath ?? null}
onPick={async () => {

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 adding a small try/catch here so a failed invoke (store write error, dialog failure, etc) doesn’t become an unhandled rejection. Since this page already imports toast, surfacing the error is pretty cheap.

Suggested change
onPick={async () => {
onPick={async () => {
try {
const path = await commands.pickRecordingsFolder();
if (path !== null) {
setSettings("recordingsPath", path);
}
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(`Failed to choose recordings folder: ${message}`);
}
}}
onReset={async () => {
try {
await commands.resetRecordingsFolder();
setSettings("recordingsPath", null);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(`Failed to reset recordings folder: ${message}`);
}
}}

@ManthanNimodiya

Copy link
Copy Markdown
Contributor Author

please re-review @greptileai

.and_then(|p| {
let path = std::path::PathBuf::from(&p);
if path.is_absolute() { Some(path) } else { None }
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

app.path().app_data_dir().unwrap() will panic if path resolution fails (rare, but then any recording/logging path lookup could crash the app). Since this is already best-effort (warn on create_dir_all), it probably makes sense to warn + fall back instead.

Suggested change
});
let path = custom.unwrap_or_else(|| match app.path().app_data_dir() {
Ok(dir) => dir.join("recordings"),
Err(e) => {
tracing::warn!("Failed to get app data dir, falling back to temp dir: {e}");
std::env::temp_dir().join("cap").join("recordings")
}
});

#[tauri::command]
#[specta::specta]
#[instrument(skip(app))]
async fn pick_recordings_folder(app: AppHandle) -> Result<Option<String>, String> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small UX/diagnostics edge case: if the user picks a folder whose path can’t round-trip through UTF-8, to_str() returns None and this currently looks identical to a cancel (Ok(None)). Returning an error instead lets the UI show a toast (you already added try/catch there).

Suggested change
async fn pick_recordings_folder(app: AppHandle) -> Result<Option<String>, String> {
let (tx, rx) = tokio::sync::oneshot::channel::<Result<Option<String>, String>>();
app.dialog()
.file()
.set_title("Choose Recordings Folder")
.pick_folder(move |path| {
let _ = tx.send((|| {
let Some(path) = path else {
return Ok(None);
};
let Some(path) = path.as_path() else {
return Err("Invalid path returned from dialog".to_string());
};
let Some(path) = path.to_str() else {
return Err("Selected folder path is not valid UTF-8".to_string());
};
Ok(Some(path.to_string()))
})());
});
let result = rx.await.map_err(|e| e.to_string())??;

@richiemcilroy richiemcilroy merged commit 4aa3486 into CapSoftware:main Jul 2, 2026
12 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants