#5980: macOS save sheet uses alloc/init instead of +savePanel

+[NSSavePanel savePanel] returned nil inside VectorCraft on macOS 15.7 and
objc2 panicked; build the panel with init (and Option fallbacks) so Save As
shows again, and do not hand a refused panel to rfd.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jared Smith
2026-11-06 15:18:21 -07:00
co-authored by Cursor
parent 36ef34dfdd
commit 3617987e35
2 changed files with 63 additions and 13 deletions
+5 -2
View File
@@ -298,13 +298,16 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
// up its Format menu before the sheet begins (#866); rfd shows the others.
PickRequest::Save(pick) => match mac_save_format::begin(window, &pick) {
// The dialog thread blocks here until the sheet answers.
Some(picked) => Box::pin(async move { picked.recv().unwrap_or_default() }),
None => {
Ok(mac_save_format::Begin::Shown(picked)) => Box::pin(async move { picked.recv().unwrap_or_default() }),
Ok(mac_save_format::Begin::DeferToRfd) => {
// Nothing is added to the sheet once rfd has begun it: an accessory view set then
// made recent macOS versions dismiss the sheet at once (#1800).
let picked = dialog.save_file();
Box::pin(async move { picked.await.iter().map(path).collect() })
}
// AppKit returned nil for the panel. rfd uses the same `+savePanel` and would panic
// on that nil; treat the pick as cancelled instead (#5980).
Err(()) => Box::pin(async move { Vec::new() }),
},
PickRequest::Folder => {
let picked = dialog.pick_folder();
+58 -11
View File
@@ -21,7 +21,7 @@ use std::sync::mpsc::{Receiver, channel};
use block2::RcBlock;
use objc2::rc::{Retained, Weak};
use objc2::{DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2::{ClassType, DefinedClass, MainThreadMarker, MainThreadOnly, define_class, msg_send, sel};
use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePanel, NSTextField, NSView};
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
@@ -108,21 +108,42 @@ fn set_allowed_types(panel: &NSSavePanel, filters: &[Filter], selected: usize) {
panel.setAllowedFileTypes(Some(&NSArray::from_retained_slice(&types)));
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu, and answer on the
/// receiver with the path picked (none when canceled). `None` when the request has fewer than two
/// named file types, or `window` has no AppKit window; nothing is shown then.
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver<Vec<String>>> {
let mtm = MainThreadMarker::new()?;
/// How [`begin`] decided to show a save sheet.
#[derive(Debug)]
pub enum Begin {
/// The Format-menu sheet is open; wait on the receiver for the path (empty when canceled).
Shown(Receiver<Vec<String>>),
/// Fewer than two named file types: the caller should show an rfd panel instead.
DeferToRfd,
}
/// Show the save panel for `pick` as a sheet on `window`, with a Format menu.
///
/// `Ok(Begin::Shown(…))` when the sheet is open. `Ok(Begin::DeferToRfd)` when the request has
/// fewer than two named file types (or no AppKit window yet) so rfd should show the panel.
/// `Err(())` when AppKit returned no panel: the caller must not call rfd, whose `+savePanel`
/// panics on the same nil (#5980).
pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Result<Begin, ()> {
let Some(mtm) = MainThreadMarker::new() else {
return Ok(Begin::DeferToRfd);
};
let filters = menu_formats(&pick.filters);
if filters.is_empty() {
return None;
return Ok(Begin::DeferToRfd);
}
let RawWindowHandle::AppKit(handle) = window.window_handle().ok()?.as_raw() else { return None };
let Some(RawWindowHandle::AppKit(handle)) = window.window_handle().ok().map(|h| h.as_raw()) else {
return Ok(Begin::DeferToRfd);
};
// SAFETY: the handle's view is the window's content view, which AppKit keeps alive while the
// window is open, and winit hands out the handle only while it is.
let view = unsafe { handle.ns_view.cast::<NSView>().as_ref() };
let parent = view.window()?;
let panel = NSSavePanel::savePanel(mtm);
let Some(parent) = view.window() else {
return Ok(Begin::DeferToRfd);
};
let Some(panel) = new_panel(mtm) else {
log::error!("macOS save panel unavailable: AppKit returned nil for NSSavePanel (#5980)");
return Err(());
};
let selected = initial_format(&pick.name, &filters);
set_allowed_types(&panel, &filters, selected);
if let Some(folder) = &pick.folder {
@@ -145,7 +166,23 @@ pub fn begin(window: &winit::window::Window, pick: &FilePick) -> Option<Receiver
// The panel and its accessory are set up; AppKit keeps the handler (and the panel it holds)
// until the sheet closes.
panel.beginSheetModalForWindow_completionHandler(&parent, &handler);
Some(rx)
Ok(Begin::Shown(rx))
}
/// An `NSSavePanel` without panicking when AppKit returns nil (#5980).
///
/// Prefers `-[NSSavePanel init]` (Apple's current Swift API) over `+[NSSavePanel savePanel]`,
/// which objc2 turns into a panic on nil. Falls back to the class method, still as `Option`.
fn new_panel(mtm: MainThreadMarker) -> Option<Retained<NSSavePanel>> {
// SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses.
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::alloc(mtm), init] };
if panel.is_some() {
return panel;
}
// SAFETY: class method; returns an autoreleased panel or nil. Typed as Option so nil is not a
// panic (unlike `NSSavePanel::savePanel`).
let panel: Option<Retained<NSSavePanel>> = unsafe { msg_send![NSSavePanel::class(), savePanel] };
panel
}
/// The "Format:" label and menu of `filters`, `selected` chosen, that rename `panel`'s file.
@@ -234,6 +271,16 @@ mod tests {
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
}
/// A nil AppKit panel must be `None`, not a panic. `NSSavePanel::savePanel` used to panic here
/// (#5980); `new_panel` types the same message send as `Option`.
#[test]
fn a_null_save_panel_pointer_is_none_not_a_panic() {
let null: *mut NSSavePanel = std::ptr::null_mut();
// SAFETY: retaining a null object pointer is defined to yield None.
let panel: Option<Retained<NSSavePanel>> = unsafe { Retained::retain(null) };
assert!(panel.is_none());
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {