From 3b72cbfb638ca7007fd3531aa749cdb0c9d2797d Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:36:16 +0100 Subject: [PATCH] fix: recover exports when macOS save panels are unavailable --- Cargo.lock | 1 + apps/desktop-gpui/src/editor_export.rs | 26 ++++- apps/desktop-gpui/src/platform.rs | 26 ++++- apps/desktop/src-tauri/Cargo.toml | 1 + apps/desktop/src-tauri/src/export.rs | 107 ++++++++++++++--- apps/desktop/src-tauri/src/lib.rs | 80 ++++++++----- .../desktop/src-tauri/src/macos_save_panel.rs | 108 ++++++++++++++++++ .../(window-chrome)/new-main/TargetCard.tsx | 15 +-- .../src/routes/editor/TranscriptPage.tsx | 14 +-- .../screenshot-editor/useScreenshotExport.ts | 9 +- 10 files changed, 307 insertions(+), 80 deletions(-) create mode 100644 apps/desktop/src-tauri/src/macos_save_panel.rs diff --git a/Cargo.lock b/Cargo.lock index 6f98ac0d924..e886813396a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1457,6 +1457,7 @@ dependencies = [ "async-stream", "axum", "base64 0.22.1", + "block2 0.6.1", "bytemuck", "bytes", "cap-audio", diff --git a/apps/desktop-gpui/src/editor_export.rs b/apps/desktop-gpui/src/editor_export.rs index 908904dd8a9..d733c7dde70 100644 --- a/apps/desktop-gpui/src/editor_export.rs +++ b/apps/desktop-gpui/src/editor_export.rs @@ -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, 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; @@ -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 }; diff --git a/apps/desktop-gpui/src/platform.rs b/apps/desktop-gpui/src/platform.rs index 186b1fde88b..d4fae62d253 100644 --- a/apps/desktop-gpui/src/platform.rs +++ b/apps/desktop-gpui/src/platform.rs @@ -1170,13 +1170,26 @@ mod mac { } pub fn save_file_panel(suggested: &str, extensions: &[&str]) -> Option { + 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, 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)]; @@ -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()))) } } diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 9ecd5508999..32cf4df5394 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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 = [ diff --git a/apps/desktop/src-tauri/src/export.rs b/apps/desktop/src-tauri/src/export.rs index aa5c89efa50..9fbfd0f57ea 100644 --- a/apps/desktop/src-tauri/src/export.rs +++ b/apps/desktop/src-tauri/src/export.rs @@ -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; @@ -179,6 +180,29 @@ impl ExportWorkerMode { #[derive(Clone)] struct ExportProgress(tauri::ipc::Channel); +#[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, String>, +) -> Option { + 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, @@ -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( @@ -1270,7 +1298,7 @@ async fn show_export_save_dialog( app: &tauri::AppHandle, file_name: String, file_type: String, -) -> Result, String> { +) -> Result, String> { info!(file_name, file_type, "Save file dialog requested"); let (name, extension) = match file_type.as_str() { @@ -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, 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> { @@ -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!( diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 96112732946..3c298bbfd1f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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; @@ -4728,6 +4730,7 @@ async fn save_file_dialog_inner( file_name: String, file_type: String, ) -> Result, String> { + #[cfg(not(target_os = "macos"))] use tauri_plugin_dialog::DialogExt; info!(file_name, file_type, "Save file dialog requested"); @@ -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()); @@ -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::() + .allow_file(path) + .map_err(|error| error.to_string())?; } + Ok(path.map(|path| path.to_string_lossy().into_owned())) } #[derive(Serialize, specta::Type)] diff --git a/apps/desktop/src-tauri/src/macos_save_panel.rs b/apps/desktop/src-tauri/src/macos_save_panel.rs new file mode 100644 index 00000000000..c975b5d4c18 --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_save_panel.rs @@ -0,0 +1,108 @@ +use block2::RcBlock; +use objc2::rc::Retained; +use objc2::{ClassType, MainThreadMarker, msg_send}; +use objc2_app_kit::{NSApplication, NSModalResponseCancel, NSModalResponseOK, NSSavePanel}; +use objc2_foundation::{NSArray, NSString}; +use std::cell::RefCell; +use std::path::PathBuf; + +fn response_result(response: isize, path: Option) -> Result, String> { + match response { + value if value == NSModalResponseCancel => Ok(None), + value if value == NSModalResponseOK => path + .map(Some) + .ok_or_else(|| "The save dialog did not return a file path".to_string()), + _ => Err("The save dialog could not be displayed".to_string()), + } +} + +pub fn show( + file_name: &str, + extensions: &[&str], + completion: impl FnOnce(Result, String>) + 'static, +) { + let Some(mtm) = MainThreadMarker::new() else { + completion(Err( + "The save dialog must run on the main thread".to_string() + )); + return; + }; + + // AppKit can return nil here even when the bundle signature is valid (Cap #2163). + let panel: Option> = + unsafe { msg_send![NSSavePanel::class(), savePanel] }; + let Some(panel) = panel else { + completion(Err("The save dialog is unavailable".to_string())); + return; + }; + + unsafe { + panel.setCanCreateDirectories(true); + panel.setTitle(Some(&NSString::from_str("Save File"))); + panel.setNameFieldStringValue(&NSString::from_str(file_name)); + if !extensions.is_empty() { + let extensions: Vec<_> = extensions + .iter() + .map(|extension| NSString::from_str(extension)) + .collect(); + let extensions = NSArray::from_retained_slice(&extensions); + let _: () = msg_send![&*panel, setAllowedFileTypes: &*extensions]; + } + } + + let completion = RefCell::new(Some(completion)); + let callback_panel = panel.clone(); + let callback = RcBlock::new(move |response: isize| { + let Some(completion) = completion.borrow_mut().take() else { + return; + }; + let path = if response == NSModalResponseOK { + unsafe { callback_panel.URL() } + .and_then(|url| unsafe { url.path() }) + .map(|path| PathBuf::from(path.to_string())) + } else { + None + }; + callback_panel.orderOut(None); + completion(response_result(response, path)); + }); + let app = NSApplication::sharedApplication(mtm); + let parent = app.keyWindow().or_else(|| unsafe { app.mainWindow() }); + unsafe { + if let Some(parent) = parent { + panel.beginSheetModalForWindow_completionHandler(&parent, &callback); + } else { + panel.beginWithCompletionHandler(&callback); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use objc2_app_kit::NSModalResponseAbort; + + #[test] + fn explicit_cancel_does_not_become_a_dialog_failure() { + assert_eq!(response_result(NSModalResponseCancel, None), Ok(None)); + } + + #[test] + fn native_failure_is_distinct_from_explicit_cancel() { + assert!(response_result(NSModalResponseAbort, None).is_err()); + } + + #[test] + fn accepted_dialog_requires_a_destination() { + assert!(response_result(NSModalResponseOK, None).is_err()); + } + + #[test] + fn accepted_dialog_preserves_the_selected_destination() { + let path = PathBuf::from("/tmp/My Export.mov"); + assert_eq!( + response_result(NSModalResponseOK, Some(path.clone())), + Ok(Some(path)) + ); + } +} diff --git a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx index c01e68f7593..f681ae82c59 100644 --- a/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx +++ b/apps/desktop/src/routes/(window-chrome)/new-main/TargetCard.tsx @@ -1,6 +1,6 @@ import { ProgressCircle } from "@cap/ui-solid"; import { convertFileSrc } from "@tauri-apps/api/core"; -import { ask, save } from "@tauri-apps/plugin-dialog"; +import { ask } from "@tauri-apps/plugin-dialog"; import { remove } from "@tauri-apps/plugin-fs"; import * as shell from "@tauri-apps/plugin-shell"; import { cx } from "cva"; @@ -243,15 +243,10 @@ export default function TargetCard(props: TargetCardProps) { const screenshot = screenshotTarget(); if (!screenshot) return; try { - const path = await save({ - defaultPath: `${screenshot.pretty_name}.png`, - filters: [ - { - name: "Image", - extensions: ["png"], - }, - ], - }); + const path = await commands.saveFileDialog( + `${screenshot.pretty_name}.png`, + "png", + ); if (!path) return; await commands.copyFileToPath(screenshot.path, path); toast.success("Screenshot saved"); diff --git a/apps/desktop/src/routes/editor/TranscriptPage.tsx b/apps/desktop/src/routes/editor/TranscriptPage.tsx index 37536cf51c3..651fc9736a9 100644 --- a/apps/desktop/src/routes/editor/TranscriptPage.tsx +++ b/apps/desktop/src/routes/editor/TranscriptPage.tsx @@ -1,6 +1,5 @@ import { createEventListener } from "@solid-primitives/event-listener"; import { makePersisted } from "@solid-primitives/storage"; -import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; import { cx } from "cva"; import { @@ -243,15 +242,10 @@ export function TranscriptPanel() { setExportingFormat(format); try { - const path = await save({ - defaultPath: captionExportDefaultPath(meta().prettyName, format), - filters: [ - { - name: format === "srt" ? "SubRip Subtitle" : "WebVTT", - extensions: [format], - }, - ], - }); + const path = await commands.saveFileDialog( + captionExportDefaultPath(meta().prettyName, format), + format, + ); if (!path) return; await writeTextFile(path, formatCaptionCues(cues, format)); diff --git a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts index dad98445232..d29d1cd0b67 100644 --- a/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts +++ b/apps/desktop/src/routes/screenshot-editor/useScreenshotExport.ts @@ -1,4 +1,3 @@ -import { save } from "@tauri-apps/plugin-dialog"; import { writeFile } from "@tauri-apps/plugin-fs"; import { createSignal } from "solid-js"; import { unwrap } from "solid-js/store"; @@ -200,10 +199,10 @@ export function useScreenshotExport() { if (destination === "file") { const buffer = await blob.arrayBuffer(); const uint8Array = new Uint8Array(buffer); - const savePath = await save({ - filters: [{ name: "PNG Image", extensions: ["png"] }], - defaultPath: `${editorCtx.prettyName}.png`, - }); + const savePath = await commands.saveFileDialog( + `${editorCtx.prettyName}.png`, + "png", + ); if (savePath) { await writeFile(savePath, uint8Array); toast.success("Screenshot saved!");