Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 21 additions & 5 deletions apps/desktop-gpui/src/editor_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -590,10 +590,20 @@ impl EditorWindow {
"mp4"
};
let default = format!("{pretty_name}.{ext}");
let chosen = std::env::var_os("CAP_GPUI_AUTO_EXPORT")
.map(PathBuf::from)
.or_else(|| platform::save_file_panel(&default, &[ext]));
if chosen.is_none() {
let chosen: Result<Option<PathBuf>, String> = match std::env::var_os("CAP_GPUI_AUTO_EXPORT") {
Some(path) => Ok(Some(PathBuf::from(path))),
None => {
#[cfg(target_os = "macos")]
{
platform::try_save_file_panel(&default, &[ext])
}
#[cfg(not(target_os = "macos"))]
{
Ok(platform::save_file_panel(&default, &[ext]))
}
}
};
if matches!(chosen, Ok(None)) {
let _ = this.update(cx, |this, cx| {
if let Some(ui) = this.export.as_mut() {
ui.phase = ExportPhase::Idle;
Expand All @@ -605,7 +615,13 @@ impl EditorWindow {
});
return;
}
chosen
match chosen {
Ok(path) => path,
Err(error) => {
tracing::warn!(error, "Save dialog unavailable; keeping the export in its project output folder");
None
}
}
} else {
None
};
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop-gpui/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1170,13 +1170,26 @@ mod mac {
}

pub fn save_file_panel(suggested: &str, extensions: &[&str]) -> Option<std::path::PathBuf> {
match try_save_file_panel(suggested, extensions) {
Ok(path) => path,
Err(error) => {
tracing::error!(error, "Save dialog failed");
None
}
}
}

pub fn try_save_file_panel(
suggested: &str,
extensions: &[&str],
) -> Result<Option<std::path::PathBuf>, String> {
use objc2::{class, msg_send};
use objc2_foundation::{NSArray, NSString};

unsafe {
let panel: *mut AnyObject = msg_send![class!(NSSavePanel), savePanel];
if panel.is_null() {
return None;
return Err("The save dialog is unavailable".to_string());
}
let _: () = msg_send![panel, setCanCreateDirectories: true];
let _: () = msg_send![panel, setNameFieldStringValue: &*NSString::from_str(suggested)];
Expand All @@ -1189,18 +1202,21 @@ mod mac {
let _: () = msg_send![panel, setAllowedFileTypes: &*types];
}
let response: isize = msg_send![panel, runModal];
if response == 0 {
return Ok(None);
}
if response != 1 {
return None;
return Err("The save dialog could not be displayed".to_string());
}
let url: *mut AnyObject = msg_send![panel, URL];
if url.is_null() {
return None;
return Err("The save dialog did not return a file path".to_string());
}
let path: *mut NSString = msg_send![url, path];
if path.is_null() {
return None;
return Err("The save dialog did not return a file path".to_string());
}
Some(std::path::PathBuf::from((*path).to_string()))
Ok(Some(std::path::PathBuf::from((*path).to_string())))
}
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ aho-corasick.workspace = true


[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.6.1"
core-graphics = "0.24.0"
core-foundation = "0.10.0"
objc2-app-kit = { version = "0.3.0", features = [
Expand Down
107 changes: 92 additions & 15 deletions apps/desktop/src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use std::{
},
};
use tauri::Manager;
#[cfg(not(target_os = "macos"))]
use tauri_plugin_dialog::DialogExt;
use tokio::io::AsyncBufReadExt;
use tokio_util::sync::CancellationToken;
Expand Down Expand Up @@ -179,6 +180,29 @@ impl ExportWorkerMode {
#[derive(Clone)]
struct ExportProgress(tauri::ipc::Channel<FramesRendered>);

#[derive(Debug, PartialEq, Eq)]
enum ExportSaveDestination {
Selected(PathBuf),
#[cfg(any(target_os = "macos", test))]
Default,
}

#[cfg(any(target_os = "macos", test))]
fn resolve_macos_export_destination(
result: Result<Option<PathBuf>, String>,
) -> Option<ExportSaveDestination> {
match result {
Ok(path) => path.map(ExportSaveDestination::Selected),
Err(error) => {
warn!(
error,
"Save dialog unavailable; keeping the export in its project output folder"
);
Some(ExportSaveDestination::Default)
}
}
}

struct ExportSaveDialogRequest {
app: tauri::AppHandle,
file_name: String,
Expand Down Expand Up @@ -1147,12 +1171,16 @@ async fn export_video_to_file_inner(
return Err("Save dialog cancelled".to_string());
};

info!(path = %save_path.display(), "Export save path selected");

let output_path =
export_video_inner(project_path, settings, editor, progress, cancel_token).await?;
copy_export_to_path(&output_path, &save_path).await?;
Ok(save_path)
match save_path {
ExportSaveDestination::Selected(path) => {
copy_export_to_path(&output_path, &path).await?;
Ok(path)
}
#[cfg(any(target_os = "macos", test))]
ExportSaveDestination::Default => Ok(output_path),
}
}

async fn export_video_inner(
Expand Down Expand Up @@ -1270,7 +1298,7 @@ async fn show_export_save_dialog(
app: &tauri::AppHandle,
file_name: String,
file_type: String,
) -> Result<Option<PathBuf>, String> {
) -> Result<Option<ExportSaveDestination>, String> {
info!(file_name, file_type, "Save file dialog requested");

let (name, extension) = match file_type.as_str() {
Expand All @@ -1285,19 +1313,46 @@ async fn show_export_save_dialog(

info!(file_name, name, extension, "Showing save file dialog");

#[cfg(target_os = "macos")]
let result = Ok(resolve_macos_export_destination(
show_macos_save_dialog(app, file_name, extension).await,
));
#[cfg(not(target_os = "macos"))]
let result = {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_title("Save File")
.set_file_name(file_name)
.add_filter(name, &[extension])
.save_file(move |path| {
let _ = tx.send(path.and_then(|p| p.as_path().map(PathBuf::from)));
});

rx.await
.map_err(|e| e.to_string())
.map(|path| path.map(ExportSaveDestination::Selected))
};
result.inspect(|result| {
info!(path = ?result, "Save file dialog completed");
})
}

#[cfg(target_os = "macos")]
pub(crate) async fn show_macos_save_dialog(
app: &tauri::AppHandle,
file_name: String,
extension: &'static str,
) -> Result<Option<PathBuf>, String> {
let (tx, rx) = tokio::sync::oneshot::channel();
app.dialog()
.file()
.set_title("Save File")
.set_file_name(file_name)
.add_filter(name, &[extension])
.save_file(move |path| {
let _ = tx.send(path.and_then(|p| p.as_path().map(PathBuf::from)));
app.run_on_main_thread(move || {
crate::macos_save_panel::show(&file_name, &[extension], move |result| {
let _ = tx.send(result);
});

rx.await.map_err(|e| e.to_string()).inspect(|result| {
info!(path = ?result, "Save file dialog completed");
})
.map_err(|error| format!("Unable to show save dialog: {error}"))?;
rx.await
.map_err(|error| format!("Save dialog stopped before completing: {error}"))?
}

async fn copy_export_to_path(src: &Path, dst: &Path) -> Result<(), String> {
Expand Down Expand Up @@ -1746,6 +1801,28 @@ mod tests {
use super::*;
use tempfile::tempdir;

#[test]
fn native_save_failure_uses_the_rendered_project_output() {
assert_eq!(
resolve_macos_export_destination(Err("NSSavePanel unavailable".to_string())),
Some(ExportSaveDestination::Default),
);
}

#[test]
fn cancelling_a_native_save_dialog_does_not_export() {
assert_eq!(resolve_macos_export_destination(Ok(None)), None);
}

#[test]
fn native_save_selection_keeps_the_requested_destination() {
let path = PathBuf::from("selected.mp4");
assert_eq!(
resolve_macos_export_destination(Ok(Some(path.clone()))),
Some(ExportSaveDestination::Selected(path)),
);
}

#[test]
fn export_estimates_use_source_duration_without_a_timeline() {
assert_eq!(
Expand Down
80 changes: 50 additions & 30 deletions apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ mod http_client;
mod import;
pub mod linux_instant_camera;
mod logging;
#[cfg(target_os = "macos")]
mod macos_save_panel;
mod notifications;
mod panel_manager;
mod permissions;
Expand Down Expand Up @@ -4728,6 +4730,7 @@ async fn save_file_dialog_inner(
file_name: String,
file_type: String,
) -> Result<Option<String>, String> {
#[cfg(not(target_os = "macos"))]
use tauri_plugin_dialog::DialogExt;

info!(file_name, file_type, "Save file dialog requested");
Expand All @@ -4742,6 +4745,8 @@ async fn save_file_dialog_inner(
"gif" => ("GIF Image", "gif"),
"mov" => ("MOV Video", "mov"),
"screenshot" | "png" => ("PNG Image", "png"),
"srt" => ("SubRip Subtitle", "srt"),
"vtt" => ("WebVTT", "vtt"),
_ => {
warn!(file_type, "Invalid save file dialog type");
return Err("Invalid file type".to_string());
Expand All @@ -4750,40 +4755,55 @@ async fn save_file_dialog_inner(

info!(file_name, name, extension, "Showing save file dialog");

// Use `tokio::sync::oneshot` so the async runtime worker yields while the native dialog
// is open instead of being parked by a synchronous `std::sync::mpsc` receive. The
// previous version blocked a runtime worker for the lifetime of the dialog which, in
// release builds with fewer/active workers, could starve other tasks and let an unrelated
// exit event slip through before the export session guard incremented.
let (tx, rx) = tokio::sync::oneshot::channel();

app.dialog()
.file()
.set_title("Save File")
.set_file_name(file_name)
.add_filter(name, &[extension])
.save_file(move |path| {
let _ = tx.send(
path.as_ref()
.and_then(|p| p.as_path())
.map(|p| p.to_string_lossy().to_string()),
);
});
#[cfg(target_os = "macos")]
let path = export::show_macos_save_dialog(&app, file_name, extension).await?;
#[cfg(not(target_os = "macos"))]
let path = {
// Use `tokio::sync::oneshot` so the async runtime worker yields while the native dialog
// is open instead of being parked by a synchronous `std::sync::mpsc` receive. The
// previous version blocked a runtime worker for the lifetime of the dialog which, in
// release builds with fewer/active workers, could starve other tasks and let an unrelated
// exit event slip through before the export session guard incremented.
let (tx, rx) = tokio::sync::oneshot::channel();

app.dialog()
.file()
.set_title("Save File")
.set_file_name(file_name)
.add_filter(name, &[extension])
.save_file(move |path| {
let _ = tx.send(
path.as_ref()
.and_then(|p| p.as_path())
.map(std::path::PathBuf::from),
);
});

match rx.await {
Ok(result) => {
info!(path = ?result, "Save file dialog completed");
Ok(result)
match rx.await {
Ok(result) => {
info!(path = ?result, "Save file dialog completed");
result
}
Err(e) => {
warn!(error = %e, "Save file dialog failed");
notifications::send_notification(
&app,
notifications::NotificationType::VideoSaveFailed,
);
return Err(e.to_string());
}
}
Err(e) => {
warn!(error = %e, "Save file dialog failed");
notifications::send_notification(
&app,
notifications::NotificationType::VideoSaveFailed,
);
Err(e.to_string())
};
if let Some(path) = &path {
use tauri_plugin_fs::FsExt;
if let Some(scope) = app.try_fs_scope() {
scope.allow_file(path).map_err(|error| error.to_string())?;
}
app.state::<tauri::scope::Scopes>()
.allow_file(path)
.map_err(|error| error.to_string())?;
}
Ok(path.map(|path| path.to_string_lossy().into_owned()))
}

#[derive(Serialize, specta::Type)]
Expand Down
Loading
Loading