Fix #295: open files from the macOS Finder and Dock

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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 05:28:50 +07:00
1 parent accfec7146
commit b2c6764472
8 files changed
+254 -39

No files matched your search

Generated
+2
View File
@@ -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",
+2 -1
View File
@@ -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.
+4 -1
View File
@@ -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.
+49 -5
View File
@@ -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<String>) {
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> {
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
.iter()
.flat_map(|key| plist.split(key).skip(1))
.filter_map(|rest| rest.split("</array>").next())
.flat_map(|array| array.split("<string>").skip(1))
.filter_map(|s| s.split("</string>").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("<key>NSDocumentClass</key>"), "not an NSDocument app: AppKit would refuse the files");
let types = plist.matches("<key>CFBundleTypeName</key>").count();
assert!(types > 1 && plist.matches("<key>LSHandlerRank</key>").count() == types, "every document type has a rank");
assert_eq!(plist.matches("<string>Owner</string>").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() {
+105
View File
@@ -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<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// Handle open-document events from now on. Call before the event loop runs.
pub fn install() {
let block = RcBlock::new(|_: NonNull<NSNotification>| 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<String> {
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::<AnyObject>::as_ref(&*delegate).class();
let handler: extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>) = 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::<extern "C-unwind" fn(&AnyObject, Sel, &AnyObject, &NSArray<NSURL>), 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<NSURL>) {
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());
}
}
+7 -1
View File
@@ -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.
+69 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Template: packaging/macos/package.sh substitutes @VERSION@ (full, e.g. 0.2.0-rc.1),
@SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
<!-- Template: packaging/macos/package.sh (release) and `cargo xtask bundle` (development)
substitute @VERSION@ (full, e.g. 0.2.0-rc.1), @SHORT_VERSION@ (numeric X.Y.Z) and @BUILD_SHA@. -->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
@@ -45,6 +45,73 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the VectorCraft authors. MIT OR Apache-2.0.</string>
<!-- Every format File › Open reads (OPEN_EXTS in crates/engine/src/cmd/fileio/mod.rs; a test in
apps/vectorcraft checks this list), so Finder offers VectorCraft for them (Open With,
double-click, Dock drop). VectorCraft owns its own formats; for the others it is an
alternate, so it doesn't take over another app's files. The app isn't NSDocument-based (no
NSDocumentClass): it opens these files itself (apps/vectorcraft/src/open_documents.rs). -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key><string>VectorCraft Document</string>
<key>LSItemContentTypes</key><array><string>ai.storyteller.vectorcraft.document</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Owner</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>VectorCraft Template</string>
<key>CFBundleTypeExtensions</key><array><string>vctemplate</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Owner</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>SVG</string>
<key>CFBundleTypeExtensions</key><array><string>svg</string><string>svgz</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>PDF</string>
<key>CFBundleTypeExtensions</key><array><string>pdf</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>PDF-compatible .ai</string>
<key>CFBundleTypeExtensions</key><array><string>ai</string><string>ait</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>EPS</string>
<key>CFBundleTypeExtensions</key><array><string>eps</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>DXF</string>
<key>CFBundleTypeExtensions</key><array><string>dxf</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>EMF and WMF</string>
<key>CFBundleTypeExtensions</key><array><string>emf</string><string>wmf</string></array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
<dict>
<key>CFBundleTypeName</key><string>Image</string>
<key>CFBundleTypeExtensions</key>
<array>
<string>png</string><string>jpg</string><string>jpeg</string><string>gif</string>
<string>webp</string><string>tif</string><string>tiff</string><string>bmp</string>
</array>
<key>CFBundleTypeRole</key><string>Editor</string>
<key>LSHandlerRank</key><string>Alternate</string>
</dict>
</array>
<!-- VectorCraft's own format (.drawcraft is its name from before the rename). -->
<key>UTExportedTypeDeclarations</key>
<array>
+16 -29
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>VectorCraft</string>
<key>CFBundleDisplayName</key><string>VectorCraft</string>
<key>CFBundleIdentifier</key><string>ai.storyteller.vectorcraft</string>
<key>CFBundleExecutable</key><string>VectorCraft</string>
<key>CFBundleIconFile</key><string>VectorCraft</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>NSHighResolutionCapable</key><true/>
<key>LSMinimumSystemVersion</key><string>11.0</string>
<key>CFBundleDocumentTypes</key><array>
<dict><key>CFBundleTypeName</key><string>VectorCraft Document</string><key>CFBundleTypeExtensions</key><array><string>vectorcraft</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>SVG</string><key>CFBundleTypeExtensions</key><array><string>svg</string></array><key>CFBundleTypeRole</key><string>Editor</string></dict>
<dict><key>CFBundleTypeName</key><string>PDF Document</string><key>CFBundleTypeExtensions</key><array><string>pdf</string><string>ai</string></array><key>CFBundleTypeRole</key><string>Viewer</string></dict>
</array>
</dict></plist>
"#
)
/// 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("<key>CFBundleShortVersionString</key><string>1.2.3</string>"));
assert!(p.contains("<string>VectorCraft Document</string>") && p.contains("<string>PDF Document</string>"));
fn dev_bundle_plist_is_the_release_one() {
let p = info_plist("1.2.3-rc.1", "abc123");
assert!(p.contains("<string>1.2.3</string>") && p.contains("<string>1.2.3-rc.1</string>"), "{p}");
assert!(p.contains("<string>abc123</string>"));
assert!(!p.contains('@'), "every placeholder filled");
assert!(p.contains("<key>CFBundleDocumentTypes</key>") && p.contains("<string>pdf</string>"), "Finder opens files with it");
}
}