From 3617987e353aa67ced3ac1317bbae731c3f548cf Mon Sep 17 00:00:00 2001 From: Jared Smith Date: Fri, 6 Nov 2026 15:18:21 -0700 Subject: [PATCH 1/2] #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() { From 21249399c650661f0946cbbf1f46c61ab40798fa Mon Sep 17 00:00:00 2001 From: Luis <43929895+Luis-0508@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:07:42 +0100 Subject: [PATCH 2/2] Release: refresh contributor credits for each release (#5997) (#5998) About > Contributors still showed an old snapshot, because nothing regenerated contributors/contributors.json before a release. Opt in to the shared refresh from craft-common-actions: a read-only credits job regenerates the snapshot for the release commit, and the shared release workflow installs it before every source build. The snapshot is not committed back. --- .github/workflows/release.yml | 9 ++++++++- docs/contributors.md | 3 ++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bbb0b8d79..0a57e0e6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,6 +20,12 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main git-sources: name: Git dependencies come from storytold runs-on: ubuntu-latest @@ -60,7 +66,7 @@ jobs: PY release: - needs: git-sources + needs: [git-sources, credits] permissions: contents: write packages: write @@ -72,6 +78,7 @@ jobs: craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 web-craft-fonts: true version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/docs/contributors.md b/docs/contributors.md index a51eaf20c..e63d7a1cc 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers. - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username. Never hand-edit. + username. Never hand-edit. Release builds regenerate it for the release commit + (`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced