From 3617987e353aa67ced3ac1317bbae731c3f548cf Mon Sep 17 00:00:00 2001 From: Jared Smith Date: Fri, 6 Nov 2026 15:18:21 -0700 Subject: [PATCH] #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 --- apps/vectorcraft/src/host_services.rs | 7 ++- apps/vectorcraft/src/mac_save_format.rs | 69 +++++++++++++++++++++---- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 978308b9e..187c42cde 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -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(); diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index fe2097157..17bad11ef 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -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>> { - 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>), + /// 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 { + 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::().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 Option> { + // SAFETY: designated initializer for a bare save panel; returns nil when AppKit refuses. + let panel: Option> = 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> = 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> = 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() {