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"); } }