From b2c6764472cef795f43e59373f84bc6fd2550ef1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 04:04:40 +0700 Subject: [PATCH] Fix #295: open files from the macOS Finder and Dock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finder, Open With, a drop on the Dock icon and `open -a` failed with "VectorCraft cannot open files in the PDF document format": - The release Info.plist declared no document types, so Launch Services refused the files before the app saw them. Info.plist.in now declares every format File › Open reads (the engine's OPEN_EXTS; a test keeps them in step): VectorCraft owns its own documents and templates and is an alternate for the others, so it takes over no other app's files. `cargo xtask bundle` now fills in the same template instead of its own, shorter plist. - macOS delivers such files as open-document events, not arguments. winit 0.30's application delegate has no `application:openURLs:`, so AppKit fell back to NSDocumentController, which refuses every file of a non-NSDocument app (the error in both issues). The new `open_documents` module adds that method to the delegate's class when the app is about to finish launching (before AppKit delivers launch files), queues the paths and wakes the UI, which opens them with `io::open_path` like command-line files; errors go to the status bar too. New direct macOS dependencies `objc2-foundation` 0.3 and `block2` 0.6 were already in Cargo.lock (through winit, muda and rfd). The module allows `unsafe_code` for the Objective-C runtime call. Fixes #354 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + ROADMAP.md | 3 +- apps/vectorcraft/Cargo.toml | 5 +- apps/vectorcraft/src/main.rs | 54 +++++++++++-- apps/vectorcraft/src/open_documents.rs | 105 +++++++++++++++++++++++++ docs/releasing.md | 8 +- packaging/macos/Info.plist.in | 71 ++++++++++++++++- xtask/src/bundle.rs | 45 ++++------- 8 files changed, 254 insertions(+), 39 deletions(-) create mode 100644 apps/vectorcraft/src/open_documents.rs diff --git a/Cargo.lock b/Cargo.lock index 1bd86c24..0052d34e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4251,6 +4251,7 @@ name = "vectorcraft" version = "0.4.0" dependencies = [ "arboard", + "block2 0.6.2", "clipboard-win", "eframe", "egui", @@ -4259,6 +4260,7 @@ dependencies = [ "muda", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rfd", "serde_json", "vectorcraft-engine", diff --git a/ROADMAP.md b/ROADMAP.md index 1475b745..ca89a32a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -42,7 +42,7 @@ since 2026-10-01. | **Bundled content:** brush, symbol, style and swatch libraries | ~30% | Ours are original and generated in code, and far fewer than Illustrator ships; brush and symbol libraries are still missing | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | -| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work; hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending | +| **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); hybrid-graphics laptops render on the power-saving GPU by default (#306); web drops land in the document they were dropped on (#359); Windows/Linux packaging and accessibility are pending | | **Agent automation** | beyond Illustrator | Every command, gesture and dialog is drivable over MCP, the CLI and the control channel | ### Where we're lacking (in priority order) @@ -98,6 +98,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done. - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland) and Japanese ship complete, and Czech and Brazilian Portuguese cover every menu label (and more), with system-locale detection on macOS, Windows and Linux, a VectorCraft ▸ Language menu and a Language preference. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. + - macOS: Finder opens every format File › Open reads with VectorCraft (double-click, Open With, a drop on the Dock icon, `open -a`), at launch or while it runs; the release and `cargo xtask bundle` apps share one `Info.plist` that declares those document types, owning only VectorCraft's own formats. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 045d5c69..1ff93988 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -31,7 +31,10 @@ clipboard-win = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } objc2 = "0.6" -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext"] } +# Files opened from Finder and the Dock (src/open_documents.rs); both already come with winit, muda and rfd. +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSString", "NSURL"] } +block2 = "0.6" [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 15b755fa..6d4d26cc 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -11,6 +11,8 @@ mod clipboard; mod control_server; #[cfg(target_os = "macos")] mod native_menu; +#[cfg(target_os = "macos")] +mod open_documents; mod printing; mod window; @@ -30,6 +32,7 @@ impl eframe::App for App { if let Some(m) = &mut self.1 { m.poll(&mut self.0); } + open_files(&mut self.0, open_documents::take()); } self.0.logic(ctx); window::track(ctx, &mut self.0.ui.window); @@ -52,6 +55,17 @@ impl eframe::App for App { } } +/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that +/// can't be opened is reported in the status bar and on stderr; the others still open. +fn open_files(app: &mut VectorcraftApp, files: Vec) { + for f in files { + if let Err(e) = vectorcraft_ui_egui::io::open_path(app, &f) { + eprintln!("vectorcraft: {f}: {e}"); + app.status(format!("Couldn't open {}: {e}", fileio::file_name(&f))); + } + } +} + /// Tell the macOS input method to drop its composition (the Type tool kept the marked text as /// typed). winit's IME toggle only clears its own copy, so the IME would type it again. #[cfg(target_os = "macos")] @@ -302,6 +316,9 @@ fn main() -> eframe::Result { if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { create.power_preference = power; } + // Files opened from Finder and the Dock arrive as events, not arguments. + #[cfg(target_os = "macos")] + open_documents::install(); eframe::run_native( "VectorCraft", options, @@ -334,11 +351,9 @@ fn main() -> eframe::Result { let rx = control_server::start(port, cc.egui_ctx.clone()); app = app.with_control(rx); } - for f in files { - if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) { - eprintln!("vectorcraft: {f}: {e}"); - } - } + #[cfg(target_os = "macos")] + open_documents::set_ui(&cc.egui_ctx); + open_files(&mut app, files); Ok(Box::new(App( app, #[cfg(target_os = "macos")] @@ -367,6 +382,35 @@ mod tests { assert_eq!(power_preference(None, Some(PowerPreference::None)), PowerPreference::None); } + /// The file extensions the macOS bundle declares: its document types and its own exported type. + fn plist_extensions(plist: &str) -> Vec<&str> { + ["CFBundleTypeExtensions", "public.filename-extension"] + .iter() + .flat_map(|key| plist.split(key).skip(1)) + .filter_map(|rest| rest.split("").next()) + .flat_map(|array| array.split("").skip(1)) + .filter_map(|s| s.split("").next()) + .collect() + } + + /// Finder offers the app for every file File › Open reads (#295, #354), takes over no other + /// app's files, and hands them to the app rather than to AppKit's document machinery. + #[test] + fn the_macos_bundle_opens_every_readable_format() { + let plist = include_str!("../../../packaging/macos/Info.plist.in"); + let declared = plist_extensions(plist); + for e in fileio::OPEN_EXTS { + assert!(declared.contains(e), "Info.plist.in doesn't declare .{e}"); + } + for e in &declared { + assert!(fileio::OPEN_EXTS.contains(e), "Info.plist.in declares .{e}, which the app doesn't open"); + } + assert!(!plist.contains("NSDocumentClass"), "not an NSDocument app: AppKit would refuse the files"); + let types = plist.matches("CFBundleTypeName").count(); + assert!(types > 1 && plist.matches("LSHandlerRank").count() == types, "every document type has a rank"); + assert_eq!(plist.matches("Owner").count(), 2, "only VectorCraft documents and templates are owned"); + } + /// The preference the engine saves is the one the app reads back before the window opens. #[test] fn the_saved_engine_preference_is_found() { diff --git a/apps/vectorcraft/src/open_documents.rs b/apps/vectorcraft/src/open_documents.rs new file mode 100644 index 00000000..b4b563b2 --- /dev/null +++ b/apps/vectorcraft/src/open_documents.rs @@ -0,0 +1,105 @@ +//! macOS: files opened from Finder (double-click, Open With, a drop on the Dock icon) or with +//! `open -a VectorCraft file`. +//! +//! macOS doesn't pass these on the command line: AppKit hands them to the application delegate's +//! `application:openURLs:`, and when the delegate has no such method it asks `NSDocumentController`, +//! which fails with "VectorCraft cannot open files in the … format" because VectorCraft isn't an +//! `NSDocument` app. winit 0.30's delegate doesn't implement the method, so [`install`] adds it to +//! the delegate's class when the app is about to finish launching: after winit set the delegate and +//! before AppKit delivers the files the app was launched with. The handler queues the paths and +//! wakes the UI, which opens them with [`take`] like files named on the command line. +//! +//! Adding a method to another crate's Objective-C class has no safe API, hence the scoped +//! `unsafe_code` allowance (winit overrides `sendEvent:` on `NSApplication` the same way). +#![allow(unsafe_code)] + +use std::ptr::NonNull; +use std::sync::{Mutex, OnceLock, PoisonError}; + +use block2::RcBlock; +use objc2::MainThreadMarker; +use objc2::runtime::{AnyObject, Imp, Sel}; +use objc2::sel; +use objc2_app_kit::{NSApplication, NSApplicationWillFinishLaunchingNotification}; +use objc2_foundation::{NSArray, NSNotification, NSNotificationCenter, NSURL}; + +/// Paths that arrived and haven't been opened yet. +static PENDING: Mutex> = Mutex::new(Vec::new()); +/// The UI to wake when files arrive (unset until the window exists; the first frame opens them). +static UI: OnceLock = OnceLock::new(); + +/// Handle open-document events from now on. Call before the event loop runs. +pub fn install() { + let block = RcBlock::new(|_: NonNull| add_open_urls_method()); + // SAFETY: the name is AppKit's notification constant; no object or queue filter, so the block + // runs on the posting thread (AppKit posts it on the main thread). The notification center + // keeps the registration (and the block) for the life of the app. + unsafe { + NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock( + Some(NSApplicationWillFinishLaunchingNotification), + None, + None, + &block, + ); + } +} + +/// Wake `ctx` when files arrive while the app runs. +pub fn set_ui(ctx: &egui::Context) { + // Set once; a second call would hand over the same context. + let _ = UI.set(ctx.clone()); +} + +/// The paths that arrived since the last call. +pub fn take() -> Vec { + std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner)) +} + +/// `application:openURLs:` on the application delegate's class. +fn add_open_urls_method() { + let Some(mtm) = MainThreadMarker::new() else { return }; + let Some(delegate) = NSApplication::sharedApplication(mtm).delegate() else { + log::warn!("vectorcraft: no application delegate; files opened from Finder won't open"); + return; + }; + let class = AsRef::::as_ref(&*delegate).class(); + let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray) = open_urls; + // SAFETY: `Imp` is the untyped form of a method implementation; the Objective-C runtime calls + // it with the arguments the type encoding declares, which match `open_urls`: no return value + // (`v`), the receiver (`@`), the selector (`:`), the application (`@`) and the URL array (`@`). + let added = unsafe { + let imp = std::mem::transmute::), Imp>(handler); + objc2::ffi::class_addMethod(std::ptr::from_ref(class).cast_mut(), sel!(application:openURLs:), imp, c"v@:@@".as_ptr()) + }; + if !added.as_bool() { + log::warn!("vectorcraft: the application delegate already handles open-document events"); + } +} + +/// Queue the files among `urls` and wake the UI. Never panics: it runs inside AppKit. +extern "C-unwind" fn open_urls(_delegate: &AnyObject, _cmd: Sel, _app: &AnyObject, urls: &NSArray) { + let paths = urls.iter().filter_map(|u| u.to_file_path()).map(|p| p.to_string_lossy().into_owned()); + PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(paths); + if let Some(ctx) = UI.get() { + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use objc2::runtime::NSObject; + use objc2_foundation::NSString; + + use super::*; + + /// Files queue until the UI takes them; URLs that aren't files are left out. + #[test] + fn opened_files_wait_for_the_ui() { + let web = NSURL::URLWithString(&NSString::from_str("https://example.com/a.svg")).unwrap(); + let urls = NSArray::from_retained_slice(&[NSURL::from_file_path("/tmp/a b.pdf").unwrap(), web, NSURL::from_file_path("/tmp/c.ai").unwrap()]); + let any = NSObject::new(); + open_urls(&any, sel!(application:openURLs:), &any, &urls); + assert_eq!(take(), ["/tmp/a b.pdf", "/tmp/c.ai"]); + assert!(take().is_empty()); + } +} diff --git a/docs/releasing.md b/docs/releasing.md index 31e9579a..2d2af082 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -67,7 +67,13 @@ The rules are in craftrules `standards/fonts.md`, the build option in - `Info.plist` is generated from `Info.plist.in` (bundle id `ai.storyteller.vectorcraft`, `LSMinimumSystemVersion` 11.0, the version and the build commit). The icon is - `assets/app-icon/vectorcraft.icns`. + `assets/app-icon/vectorcraft.icns`. `cargo xtask bundle` fills in the same template for its + development app. +- **Document types:** `Info.plist.in` declares every format File › Open reads, so Finder opens + them with VectorCraft (double-click, Open With, a drop on the Dock icon). VectorCraft owns its own + formats and is an alternate for the others. A test keeps the list in step with the engine's + `OPEN_EXTS`. AppKit hands the files to the app as open-document events, which + `apps/vectorcraft/src/open_documents.rs` opens like files named on the command line. - **Signing** goes inside-out with the hardened runtime and a secure timestamp: the executable first, then the bundle, with no `--deep` on the final signature. The entitlements (`entitlements.plist`) are deliberately empty. diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index c6eda447..b54d2163 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -1,7 +1,7 @@ - + CFBundleDevelopmentRegion @@ -45,6 +45,73 @@ NSHumanReadableCopyright Copyright © the VectorCraft authors. MIT OR Apache-2.0. + + CFBundleDocumentTypes + + + CFBundleTypeNameVectorCraft Document + LSItemContentTypesai.storyteller.vectorcraft.document + CFBundleTypeRoleEditor + LSHandlerRankOwner + + + CFBundleTypeNameVectorCraft Template + CFBundleTypeExtensionsvctemplate + CFBundleTypeRoleEditor + LSHandlerRankOwner + + + CFBundleTypeNameSVG + CFBundleTypeExtensionssvgsvgz + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNamePDF + CFBundleTypeExtensionspdf + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNamePDF-compatible .ai + CFBundleTypeExtensionsaiait + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNameEPS + CFBundleTypeExtensionseps + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNameDXF + CFBundleTypeExtensionsdxf + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNameEMF and WMF + CFBundleTypeExtensionsemfwmf + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + CFBundleTypeNameImage + CFBundleTypeExtensions + + pngjpgjpeggif + webptiftiffbmp + + CFBundleTypeRoleEditor + LSHandlerRankAlternate + + + UTExportedTypeDeclarations diff --git a/xtask/src/bundle.rs b/xtask/src/bundle.rs index 14ddf815..d3a6d611 100644 --- a/xtask/src/bundle.rs +++ b/xtask/src/bundle.rs @@ -21,35 +21,20 @@ pub fn run(root: &Path) -> Result<(), String> { std::fs::copy(target.join("release/vectorcraft"), app.join("MacOS/VectorCraft")).map_err(|e| format!("copy app: {e}"))?; std::fs::copy(target.join("release/vectorcraft-cli"), app.join("MacOS/vectorcraft-cli")).map_err(|e| format!("copy cli: {e}"))?; std::fs::copy(root.join("assets/app-icon/vectorcraft.icns"), app.join("Resources/VectorCraft.icns")).map_err(|e| format!("copy icon: {e}"))?; - std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"))).map_err(|e| e.to_string())?; + let sha = std::env::var("VECTORCRAFT_BUILD_SHA").unwrap_or_else(|_| "unknown".into()); + std::fs::write(app.join("Info.plist"), info_plist(env!("CARGO_PKG_VERSION"), &sha)).map_err(|e| e.to_string())?; println!("built {}", root.join("dist/VectorCraft.app").display()); Ok(()) } -/// The development bundle's `Info.plist`. -fn info_plist(version: &str) -> String { - format!( - r#" - - -CFBundleNameVectorCraft -CFBundleDisplayNameVectorCraft -CFBundleIdentifierai.storyteller.vectorcraft -CFBundleExecutableVectorCraft -CFBundleIconFileVectorCraft -CFBundlePackageTypeAPPL -CFBundleShortVersionString{version} -CFBundleVersion{version} -NSHighResolutionCapable -LSMinimumSystemVersion11.0 -CFBundleDocumentTypes - CFBundleTypeNameVectorCraft DocumentCFBundleTypeExtensionsvectorcraftCFBundleTypeRoleEditor - CFBundleTypeNameSVGCFBundleTypeExtensionssvgCFBundleTypeRoleEditor - CFBundleTypeNamePDF DocumentCFBundleTypeExtensionspdfaiCFBundleTypeRoleViewer - - -"# - ) +/// The release `Info.plist` template, which `packaging/macos/package.sh` fills in the same way: the +/// development bundle declares the same document types (Finder opens them with the app). +const INFO_PLIST: &str = include_str!("../../packaging/macos/Info.plist.in"); + +/// The bundle's `Info.plist`: [`INFO_PLIST`] for `version` and the build commit `sha`. +fn info_plist(version: &str, sha: &str) -> String { + let short = version.split('-').next().unwrap_or(version); + INFO_PLIST.replace("@VERSION@", version).replace("@SHORT_VERSION@", short).replace("@BUILD_SHA@", sha) } #[cfg(test)] @@ -88,9 +73,11 @@ mod tests { } #[test] - fn dev_bundle_plist_names_types_neutrally() { - let p = info_plist("1.2.3"); - assert!(p.contains("CFBundleShortVersionString1.2.3")); - assert!(p.contains("VectorCraft Document") && p.contains("PDF Document")); + fn dev_bundle_plist_is_the_release_one() { + let p = info_plist("1.2.3-rc.1", "abc123"); + assert!(p.contains("1.2.3") && p.contains("1.2.3-rc.1"), "{p}"); + assert!(p.contains("abc123")); + assert!(!p.contains('@'), "every placeholder filled"); + assert!(p.contains("CFBundleDocumentTypes") && p.contains("pdf"), "Finder opens files with it"); } }