Merge pull request #378 from storytold/fix/issue-295

Fix #295, fix #354: open files from the macOS Finder and Dock
This commit is contained in:
BFlat 2026-10-08 05:32:55 +07:00 committed by GitHub
commit ce12b4b0f5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 254 additions and 39 deletions

2
Cargo.lock generated
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",

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.

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.

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() {

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

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.

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>

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