macOS: projects open from Finder; .pcraft is PhotoCraft's again

The app bundle exported ai.storyteller.effectcraft.document with the
`.pcraft` extension, PhotoCraft's document format. Installing EffectCraft
took `.pcraft` over, and since EffectCraft declared no document types,
neither app was offered for PhotoCraft documents in Finder. EffectCraft's
own projects (.ecproj, .ecprojx) had no file type at all.

- Info.plist: the exported type now tags .ecproj and .ecprojx ("EffectCraft
  Project", conforming to public.data and public.content rather than
  public.image), and CFBundleDocumentTypes makes EffectCraft their owner.
- Finder double-click, Open With, Dock drops and `open -a EffectCraft
  x.ecproj` send a kAEOpenDocuments Apple event that winit 0.30 doesn't
  handle. Register it with the audited fmv-macos-events crate that
  PhotoCraft and PdfCraft use (it keeps the unsafe Objective-C interop out
  of this workspace). Opened paths go through the dropped-file handling, so
  a project opens after the unsaved-changes prompt and media is imported. A
  quit event closes the window.
- The event that launches the app arrives before the UI draws: the first
  frames skip the panels until the fonts are installed, which dropped it.
  The paths wait for EffectcraftApp::is_ready().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
lloydandClaude Opus 5.5 committed 2026-10-10 14:22:22 +02:00
1 parent 813c7c4650
commit f041aa7782
6 files changed
+141 -4

No files matched your search

Generated
+12
View File
@@ -1142,6 +1142,7 @@ dependencies = [
"effectcraft-ui-egui",
"eframe",
"egui",
"fmv-macos-events",
"log",
"muda",
"objc2 0.6.4",
@@ -2280,6 +2281,17 @@ dependencies = [
"zlib-rs",
]
[[package]]
name = "fmv-macos-events"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
[[package]]
name = "fnv"
version = "1.0.7"
+5
View File
@@ -35,3 +35,8 @@ muda = { version = "0.21", default-features = false }
objc2 = "0.6"
# NSApplication hide / hide others / show all, and the pasteboard for Edit ▸ Paste in text fields.
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSPasteboard"] }
# Finder double-click, Open With, Dock drops and `open -a EffectCraft x.ecproj` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
# PhotoCraft and PdfCraft) registers the handler (Objective-C interop needs unsafe, which our
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
fmv-macos-events = "=0.1.0"
+80
View File
@@ -0,0 +1,80 @@
//! macOS open-documents and quit Apple events.
//!
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a EffectCraft x.ecproj` don't
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
//! `NSApplicationDelegate`, so the project never opened. Handling it ourselves needs Objective-C
//! class declarations, i.e. `unsafe`, which this workspace forbids. The audited `fmv-macos-events`
//! crate (also used by PhotoCraft and PdfCraft) wraps exactly that, an `NSAppleEventManager`
//! handler registered before Finder's launch event that leaves winit's delegate alone, behind a
//! safe main-thread API.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fmv_macos_events::{Event, Inbox, Registration};
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
pub struct AppleEvents {
_registration: Registration,
inbox: Inbox,
}
impl AppleEvents {
/// Register the handlers. Call on the main thread before the event loop starts, so the event
/// that launched the app (a Finder double-click) is caught too.
pub fn install() -> Self {
let (registration, inbox) = Registration::install();
Self { _registration: registration, inbox }
}
/// The queue the app drains every frame ([`Opened::feed`]); events arriving later wake `ctx`.
pub fn connect(&self, ctx: &egui::Context) -> Opened {
let ctx = ctx.clone();
self.inbox.set_wake(move || ctx.request_repaint());
Opened { inbox: self.inbox.clone(), pending: Vec::new() }
}
}
/// Documents macOS asked us to open, waiting for the UI to take them.
pub struct Opened {
inbox: Inbox,
/// Paths that arrived before the UI was ready (the Finder event that launched the app comes
/// before the first frame is drawn).
pending: Vec<PathBuf>,
}
impl Opened {
/// Hand the documents to the dropped-file handling once the UI is `ready` (a project opens,
/// asking about unsaved changes first; media is imported), and act on quit requests.
pub fn feed(&mut self, ready: bool, ctx: &egui::Context, raw: &mut egui::RawInput) {
for e in self.inbox.drain() {
match e {
Event::Open(paths) => self.pending.extend(paths),
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
}
}
if self.pending.is_empty() {
return;
}
if !ready {
ctx.request_repaint();
return;
}
raw.dropped_files.extend(self.pending.drain(..).map(|p| Arc::new(OpenedFile(p)) as egui::DroppedFileHandle));
}
}
/// A document macOS asked us to open, presented to egui as a dropped file.
#[derive(Debug)]
struct OpenedFile(PathBuf);
impl egui::DroppedFile for OpenedFile {
fn path(&self) -> &Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}
+15
View File
@@ -13,6 +13,8 @@
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
mod appimage;
#[cfg(target_os = "macos")]
mod apple_events;
mod audio_out;
mod control_server;
mod launch_guard;
@@ -101,6 +103,12 @@ fn main() -> eframe::Result {
// Start-up milestones: with `RUST_LOG=info` they go to stderr, so a window that never
// appears shows how far start-up got (#234).
log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
// Registered before the event loop starts, so it catches the Finder event that launched us as
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
#[cfg(target_os = "macos")]
let apple_events = apple_events::AppleEvents::install();
#[cfg(target_os = "macos")]
let apple_events = &apple_events;
let result = eframe::run_native(
"EffectCraft",
options,
@@ -207,6 +215,8 @@ fn main() -> eframe::Result {
Ok(Box::new(Desktop {
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar::new(&cc.egui_ctx),
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&cc.egui_ctx),
app,
launch,
drawn: false,
@@ -274,6 +284,9 @@ struct Desktop {
app: EffectcraftApp,
#[cfg(target_os = "macos")]
menu: native_menu::NativeBar,
/// Documents opened from Finder, the Dock or `open` (macOS).
#[cfg(target_os = "macos")]
apple_events: apple_events::Opened,
launch: launch_guard::Launch,
/// The first frame was drawn (logged once).
drawn: bool,
@@ -300,6 +313,8 @@ impl eframe::App for Desktop {
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
#[cfg(target_os = "macos")]
self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
self.app.raw_input_hook(ctx, raw_input);
}
}
+6
View File
@@ -510,6 +510,12 @@ impl EffectcraftApp {
&& self.ui.viewer.extended.is_none()
}
/// The UI draws its panels and handles input such as dropped files. Until the fonts are
/// installed (the first frames) it draws nothing, and that input goes unhandled.
pub fn is_ready(&self) -> bool {
self.fonts_ready
}
/// Provide the executable/embedding host's device notifications. The host installs the
/// handlers once; this frontend never replaces handlers on a borrowed device.
pub fn set_gpu_failure_bridge(&mut self, bridge: gpu_failure::GpuFailureBridge) {
+23 -4
View File
@@ -45,25 +45,44 @@
<key>NSHumanReadableCopyright</key>
<string>Copyright © the EffectCraft authors. MIT OR Apache-2.0.</string>
<!-- Effectcraft's own format. -->
<!-- Projects open from Finder (double-click, Open With, Dock); src/apple_events.rs. -->
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>EffectCraft Project</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Owner</string>
<key>CFBundleTypeIconFile</key>
<string>EffectCraft</string>
<key>LSItemContentTypes</key>
<array><string>ai.storyteller.effectcraft.document</string></array>
</dict>
</array>
<!-- EffectCraft's own format: the project (.ecproj) and its XML copy (.ecprojx). -->
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>ai.storyteller.effectcraft.document</string>
<key>UTTypeDescription</key>
<string>EffectCraft Document</string>
<string>EffectCraft Project</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.image</string>
<string>public.content</string>
</array>
<key>UTTypeIconFile</key>
<string>EffectCraft</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array><string>pcraft</string></array>
<array>
<string>ecproj</string>
<string>ecprojx</string>
</array>
<key>public.mime-type</key>
<array><string>application/x-effectcraft</string></array>
</dict>