From b617734d5ca37bbf388d2e97bf412ccbb1c747d8 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 7 Sep 2026 16:44:44 +0200 Subject: [PATCH 1/2] macOS: catch a Finder open aimed at the already-running app (#1039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Double-click / "Open With" never puts the file on argv — Finder always delivers it as a kAEOpenDocuments Apple Event instead, which nothing in this app listened for, so it just showed "cannot open files in this format" even though the file association itself was registered correctly. The standard fix, implementing application:openURLs: on this app's own NSApplicationDelegate, isn't available here: winit already installs its own delegate to own window/lifecycle events, NSApplication has exactly one delegate slot, and winit's class doesn't implement the open-document method. This bypasses the delegate protocol and registers directly with the lower-level Apple Event Manager (AEInstallEventHandler) instead — the same mechanism AppKit's delegate forwarding is itself built on, and independent of who owns the delegate. Registration happens on the iced Subscription's first poll rather than in main() before app::run() — registering earlier gets silently overwritten by winit's own NSApplication startup, confirmed by testing against a real `open -a` rather than assumed from Apple's docs. This handles a file opened while the app is already running. A cold launch (app not running yet) needs a different mechanism, in the following commit. Co-Authored-By: Claude Sonnet 5 --- src/app/view/mod.rs | 10 ++ src/io/macos_open_events.rs | 283 ++++++++++++++++++++++++++++++++++++ src/io/mod.rs | 2 + 3 files changed, 295 insertions(+) create mode 100644 src/io/macos_open_events.rs diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index aea6fcc41..ec0114a9b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2346,6 +2346,15 @@ impl OpenCADStudio { let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); #[cfg(target_arch = "wasm32")] let single_instance = Subscription::none(); + // A second file opened via Finder while this process is already + // running (#1039) — delivered as a warm Apple Event straight to this + // process, bypassing `src/bin/ocs_launcher.rs` entirely (that only + // runs for the initial cold launch). See `io::macos_open_events`. + #[cfg(target_os = "macos")] + let macos_open_events = + crate::io::macos_open_events::subscribe().map(Message::OpenExternal); + #[cfg(not(target_os = "macos"))] + let macos_open_events = Subscription::none(); // Drain plugin-to-host requests on a timer when any plugin is loaded. // This lets long-lived plugin sessions (e.g. the Python REPL) mutate the // host document without requiring a user-generated message. @@ -2521,6 +2530,7 @@ impl OpenCADStudio { autosave, plugin_drain, single_instance, + macos_open_events, hatch_pattern_keys, keyboard_events, ]) diff --git a/src/io/macos_open_events.rs b/src/io/macos_open_events.rs new file mode 100644 index 000000000..ae1fd93e2 --- /dev/null +++ b/src/io/macos_open_events.rs @@ -0,0 +1,283 @@ +//! macOS: catch a `kAEOpenDocuments` Apple Event sent directly to this +//! already-running process — the *warm* half of #1039 ("Cannot OPEN WITH in +//! FINDER MAC OS"). +//! +//! [`src/bin/ocs_launcher.rs`] is the `.app`'s actual `CFBundleExecutable` +//! and stays running for the whole session — Launch Services keeps +//! addressing every open (cold *or* warm) to that one process's Application +//! Serial Number, so it relays each one to this binary over the existing +//! `io::single_instance` port rather than over an Apple Event of its own. +//! +//! That covers every open Finder initiates. What it doesn't cover: a plain +//! `open -a OpenCADStudio-App file.dwg`, or an AppleScript `tell application +//! … to open`, aimed at this binary directly rather than at the launcher — +//! bypassing the launcher (and its relay) entirely. Verified against a real +//! second "Open With" while already running (not assumed): Launch Services +//! can deliver a `kAEOpenDocuments` event straight to an already-running, +//! cleanly-launched instance of this binary, and without a handler here that +//! goes unhandled the same way the original bug described. +//! +//! The standard fix — implement `application:openURLs:` on this app's own +//! `NSApplicationDelegate` — isn't available here: winit already installs +//! its own delegate (`platform_impl::macos::app_state::ApplicationDelegate`) +//! to own window/lifecycle events, `NSApplication` has exactly one delegate +//! slot, and winit's class doesn't implement the open-document method. So +//! this bypasses the delegate protocol and registers directly with the +//! lower-level Apple Event Manager (`AEInstallEventHandler`) instead — the +//! same mechanism AppKit's delegate forwarding is itself built on, and +//! independent of who owns the delegate. +//! +//! Registration timing is counter-intuitive and was pinned down by testing +//! against a real `open -a`, not by reasoning from Apple's docs: registering +//! before anything has touched `NSApplication` (e.g. from `main()`, before +//! `app::run()`) does not stick — winit's own `EventLoop::new()`/`-run` +//! startup sequence re-registers AppKit's own default (no-op) handler for +//! the same event afterwards and silently wins. So [`subscribe`]'s stream +//! registers on its own first poll instead, which iced only reaches once the +//! window/application lifecycle has already finished starting — late enough +//! that nothing overwrites it afterwards. (A *cold*-launch event specifically +//! still isn't caught this way, even registered as the first statement of +//! `main()` — which is exactly why that case needs the separate launcher.) + +use std::ffi::{c_void, OsString}; +use std::os::raw::c_long; +use std::os::unix::ffi::OsStringExt; +use std::path::PathBuf; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Mutex, OnceLock}; + +// ── Apple Event Manager FFI ───────────────────────────────────────────── +// +// Hand-rolled, matching the existing macOS FFI in `file_association.rs` +// rather than pulling in an objc2 dependency for a handful of C calls. Types +// and constants below are taken from the real SDK headers (verified against +// `AE.framework/Headers/{AppleEvents,AEDataModel}.h`, not from memory) so the +// field widths match this target's actual C ABI. + +type OSErr = i16; +type OSType = u32; +type AEEventClass = OSType; +type AEEventID = OSType; +type AEKeyword = OSType; +type DescType = OSType; +/// `SRefCon` is `void *` on this (LP64) target — only ever passed as `0` +/// here, so its exact pointee type doesn't matter. +type SRefCon = *mut c_void; +/// Carbon's `Size` is `typedef long Size;` — 8 bytes on 64-bit macOS. +type Size = c_long; +type Boolean = u8; + +/// Layout must match the real C `struct AEDesc` (`descriptorType` then +/// `dataHandle`) exactly, including the compiler-inserted padding before the +/// 8-byte pointer field — `repr(C)` reproduces that automatically here. +#[repr(C)] +struct AEDesc { + descriptor_type: DescType, + data_handle: *mut c_void, +} +type AppleEvent = AEDesc; +type AEDescList = AEDesc; + +const K_CORE_EVENT_CLASS: AEEventClass = 0x6165_7674; // 'aevt' +const K_AE_OPEN_DOCUMENTS: AEEventID = 0x6F64_6F63; // 'odoc' +const KEY_DIRECT_OBJECT: AEKeyword = 0x2D2D_2D2D; // '----' +const TYPE_AE_LIST: DescType = 0x6C69_7374; // 'list' +/// Coercing a list item to this type is what makes `AEGetNthDesc` hand back +/// the file's location — as a percent-encoded `file://` URL in practice +/// (measured against a real Finder-equivalent open), not the plain path the +/// `typeFileURL` comment in the real `AEDataModel.h` claims. +const TYPE_FILE_URL: DescType = 0x6675_726C; // 'furl' + +type AEEventHandlerProcPtr = unsafe extern "C" fn( + the_apple_event: *const AppleEvent, + reply: *mut AppleEvent, + handler_refcon: SRefCon, +) -> OSErr; + +#[link(name = "CoreServices", kind = "framework")] +extern "C" { + fn AEInstallEventHandler( + the_ae_event_class: AEEventClass, + the_ae_event_id: AEEventID, + handler: AEEventHandlerProcPtr, + handler_refcon: SRefCon, + is_sys_handler: Boolean, + ) -> OSErr; + + fn AEGetParamDesc( + the_apple_event: *const AppleEvent, + the_ae_keyword: AEKeyword, + desired_type: DescType, + result: *mut AEDesc, + ) -> OSErr; + + fn AECountItems(the_ae_desc_list: *const AEDescList, the_count: *mut c_long) -> OSErr; + + fn AEGetNthDesc( + the_ae_desc_list: *const AEDescList, + index: c_long, + desired_type: DescType, + the_ae_keyword: *mut AEKeyword, + result: *mut AEDesc, + ) -> OSErr; + + fn AEGetDescDataSize(the_ae_desc: *const AEDesc) -> Size; + + fn AEGetDescData(the_ae_desc: *const AEDesc, data_ptr: *mut c_void, maximum_size: Size) -> OSErr; + + fn AEDisposeDesc(the_ae_desc: *mut AEDesc) -> OSErr; +} + +fn null_desc() -> AEDesc { + AEDesc { + descriptor_type: 0, + data_handle: std::ptr::null_mut(), + } +} + +/// The Apple Event Manager callback. Runs synchronously, inside whichever +/// native run-loop pass just received the event — kept short, and every path +/// just gets handed off to [`send_opened_path`] rather than touched further +/// here. +unsafe extern "C" fn handle_open_documents( + the_apple_event: *const AppleEvent, + _reply: *mut AppleEvent, + _handler_refcon: SRefCon, +) -> OSErr { + let mut doc_list = null_desc(); + let err = + unsafe { AEGetParamDesc(the_apple_event, KEY_DIRECT_OBJECT, TYPE_AE_LIST, &mut doc_list) }; + if err != 0 { + return err; + } + + let mut count: c_long = 0; + if unsafe { AECountItems(&doc_list, &mut count) } == 0 { + for index in 1..=count { + let mut item = null_desc(); + let mut keyword: AEKeyword = 0; + let got = + unsafe { AEGetNthDesc(&doc_list, index, TYPE_FILE_URL, &mut keyword, &mut item) }; + if got == 0 { + let size = unsafe { AEGetDescDataSize(&item) }; + if size > 0 { + let mut buf = vec![0u8; size as usize]; + let read = + unsafe { AEGetDescData(&item, buf.as_mut_ptr() as *mut c_void, size) }; + if read == 0 { + if let Some(path) = path_from_bytes(buf) { + send_opened_path(path); + } + } + } + unsafe { AEDisposeDesc(&mut item) }; + } + } + } + unsafe { AEDisposeDesc(&mut doc_list) }; + 0 // noErr +} + +/// `typeFileURL` data is a percent-encoded `file://` URL (measured, not +/// assumed — see the module doc). `file:///abs/path` has an empty host, so +/// the part after the scheme already starts with the path's own leading `/`. +fn path_from_bytes(bytes: Vec) -> Option { + let s = std::str::from_utf8(&bytes).ok()?; + let encoded_path = s.strip_prefix("file://")?; + let decoded = percent_decode(encoded_path.as_bytes()); + if decoded.is_empty() { + return None; + } + Some(PathBuf::from(OsString::from_vec(decoded))) +} + +/// Minimal `%XX` decoder. Decodes byte-for-byte rather than per-codepoint, so +/// a multi-byte UTF-8 sequence in the original filename (each byte percent- +/// encoded separately) round-trips correctly through `OsString::from_vec` +/// even though the decoded bytes aren't validated as UTF-8 here. +fn percent_decode(input: &[u8]) -> Vec { + let mut out = Vec::with_capacity(input.len()); + let mut i = 0; + while i < input.len() { + if input[i] == b'%' && i + 2 < input.len() { + if let (Some(hi), Some(lo)) = (hex_val(input[i + 1]), hex_val(input[i + 2])) { + out.push((hi << 4) | lo); + i += 3; + continue; + } + } + out.push(input[i]); + i += 1; + } + out +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +// ── Apple Event callback → iced Subscription ──────────────────────────── +// +// Same shape as `single_instance`'s "blocking OS primitive → iced stream" +// bridge: a channel filled from an external source (there, a TCP accept loop +// on its own thread; here, the AE callback), drained on a dedicated thread so +// the async wrapper in [`subscribe`] never blocks. + +static SENDER: OnceLock>> = OnceLock::new(); + +fn send_opened_path(path: PathBuf) { + if let Some(tx) = SENDER.get() { + let _ = tx.lock().unwrap_or_else(|e| e.into_inner()).send(path); + } +} + +/// Files handed to this already-running process via a warm `kAEOpenDocuments` +/// Apple Event. Feeds the same [`crate::app::Message`] path as +/// `single_instance::subscribe` (`Message::OpenExternal`) — see +/// `app::view::subscription`. +pub fn subscribe() -> iced::Subscription { + iced::Subscription::run(worker) +} + +type PathSender = iced::futures::channel::mpsc::Sender; + +fn worker() -> impl iced::futures::Stream { + iced::stream::channel(8, drain_opened_paths) +} + +/// Registers the Apple Event handler on this stream's first poll (see the +/// module doc for why here, of all places, rather than at process startup), +/// then forwards every path the handler receives for the rest of the +/// process's life. +async fn drain_opened_paths(out: PathSender) { + let (tx, rx) = channel(); + if SENDER.set(Mutex::new(tx)).is_err() { + // `Subscription::run` keys a recipe's identity off `worker`'s function + // pointer, so iced runs this at most once — this is just a guard + // against that invariant changing out from under us. Park rather than + // end the stream, so iced does not re-run the recipe. + std::future::pending::<()>().await; + return; + } + unsafe { + AEInstallEventHandler( + K_CORE_EVENT_CLASS, + K_AE_OPEN_DOCUMENTS, + handle_open_documents, + std::ptr::null_mut(), + 0, + ); + } + std::thread::spawn(move || { + let mut out = out; + for path in rx { + let _ = out.try_send(path); + } + }); + std::future::pending::<()>().await; +} diff --git a/src/io/mod.rs b/src/io/mod.rs index a1713d21c..b672b5879 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -9,6 +9,8 @@ pub mod edit_lock; pub mod obj; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; +#[cfg(target_os = "macos")] +pub mod macos_open_events; pub mod pdf_export; pub mod plot_style; pub mod print_to_printer; From e767507850594cf9c0ed7d8403c5f7aa300f2d64 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 7 Sep 2026 16:45:02 +0200 Subject: [PATCH 2/2] macOS: add a launcher shim so a cold Finder open also works (#1039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's Apple Event handler only ever catches an open sent to an already-running process. A cold double-click (app not running yet) still failed: every registration point tried inside the real binary — before app::run(), on the Subscription's first poll, even the literal first statement of main() — reliably caught a warm open but never the original cold-launch event. A raw AEInstallEventHandler in a separate, minimal, non-AppKit process (no winit/iced, just a manual CFRunLoopRunInMode pump) still missed it too, ruling out "the real binary's heavy dependency graph loading before main() wins the race" as the explanation. What does reliably catch a cold launch, verified against real Finder-equivalent opens: a from-scratch NSApplication with its own delegate implementing application:openURLs: — in a process where we own setDelegate: from the start, with no contention over the single delegate slot winit already claims. That has to be a separate process from the real GUI binary (only one -[NSApplication run] call can own a process), so src/bin/ocs_launcher.rs is now the .app's actual CFBundleExecutable: a small, dependency-light binary (no iced, wgpu, or cadkernel) whose only job is to catch the open and relay it. Relaying by replacing this process's image (exec, same PID) or by spawning a child and exiting turned out to both be dead ends: Launch Services tracks "the running instance of this bundle" by the Application Serial Number it handed out to whichever process it actually launched — this launcher, not whatever it execs into or spawns. Once the launcher's own ASN stopped pointing at a live, listening process, every later open Finder tried to deliver silently went nowhere (measured with lsappinfo against a real second "Open With", not assumed). So the launcher now stays running for the app's session — invisible, via NSApplicationActivationPolicyAccessory, since it owns no window — and relays every open (cold or warm) over the same channel a second CLI invocation already uses: io::single_instance. That relay only worked once single_instance's rendezvous key stopped being computed from the running executable's own path. That was fine when one binary always did the job, but the launcher and the real binary are different executables and independently computed different ports, so they could never find each other. Anchor the key on the enclosing .app bundle instead, shared by both. packaging/build_macos_signed.sh and .github/workflows/release.yml now build ocs_launcher and place it at Contents/MacOS/OpenCADStudio (the CFBundleExecutable Info.plist already names), with the real GUI binary renamed to Contents/MacOS/OpenCADStudio-App alongside it. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/release.yml | 12 ++- Cargo.lock | 3 + Cargo.toml | 8 ++ packaging/build_macos_signed.sh | 9 +- src/bin/ocs_launcher.rs | 175 ++++++++++++++++++++++++++++++++ src/io/single_instance.rs | 56 ++++++++-- 6 files changed, 249 insertions(+), 14 deletions(-) create mode 100644 src/bin/ocs_launcher.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c1102fd7..51056ad38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -337,6 +337,9 @@ jobs: run: | cargo build --locked --release --target aarch64-apple-darwin cargo build --locked --release --target aarch64-apple-darwin -p dwg-thumbnailer + # The launcher shim is the .app's actual CFBundleExecutable (#1039) + # — see src/bin/ocs_launcher.rs for why. + cargo build --locked --release --target aarch64-apple-darwin --bin ocs_launcher - name: Build .icns from SVG run: | @@ -395,8 +398,13 @@ jobs: APP=OpenCADStudio.app rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/PlugIns" - cp target/aarch64-apple-darwin/release/OpenCADStudio "$APP/Contents/MacOS/OpenCADStudio" - chmod +x "$APP/Contents/MacOS/OpenCADStudio" + # The launcher is CFBundleExecutable (Finder always launches it, + # for both document opens and plain launches); it execs into the + # real GUI binary, renamed alongside it, over argv. See + # src/bin/ocs_launcher.rs. + cp target/aarch64-apple-darwin/release/ocs_launcher "$APP/Contents/MacOS/OpenCADStudio" + cp target/aarch64-apple-darwin/release/OpenCADStudio "$APP/Contents/MacOS/OpenCADStudio-App" + chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp DWG.icns DXF.icns "$APP/Contents/Resources/" # QuickLook thumbnail extension. diff --git a/Cargo.lock b/Cargo.lock index 7d4975fe2..2a7ae6212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,9 @@ dependencies = [ "lyon_tessellation", "meshopt", "naga 27.0.3", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", "ocs_doc_api", "ocs_plugin_api", "open", diff --git a/Cargo.toml b/Cargo.toml index 5ccd655d6..0c616dd56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,14 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } +# For `src/bin/ocs_launcher.rs` only (the .app's real CFBundleExecutable, +# see that file for why) — already in the tree transitively via winit, pinned +# here to the same versions Cargo already resolves for it. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSResponder", "NSRunningApplication"] } +objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString", "NSObject", "NSNotification", "NSThread", "NSEnumerator"] } + [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index d4547f740..24a08c3a4 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -30,11 +30,13 @@ fi [ -n "$DEVELOPER_ID" ] || { echo "No Developer ID identity found; set DEVELOPER_ID (or '-' for ad-hoc)." >&2; exit 1; } echo "==> Version $VERSION, signing as: $DEVELOPER_ID" -echo "==> cargo build (app + thumbnailer staticlib)" +echo "==> cargo build (app + thumbnailer staticlib + launcher)" cargo build --release --target "$TARGET" # The staticlib crate-type is only emitted when the crate is built as a # target (as a plain dependency cargo produces just the rlib). cargo build --release --target "$TARGET" -p dwg-thumbnailer +# CFBundleExecutable (#1039) — see src/bin/ocs_launcher.rs. +cargo build --release --target "$TARGET" --bin ocs_launcher echo "==> icons" rm -rf "$DIST" && mkdir -p "$DIST" @@ -82,8 +84,9 @@ echo "==> assemble .app" APP="$DIST/OpenCADStudio.app" rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/PlugIns" -cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio" -chmod +x "$APP/Contents/MacOS/OpenCADStudio" +cp "target/$TARGET/release/ocs_launcher" "$APP/Contents/MacOS/OpenCADStudio" +cp "target/$TARGET/release/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" +chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" cp "$DIST/AppIcon.icns" "$DIST/DWG.icns" "$DIST/DXF.icns" "$APP/Contents/Resources/" cp -R "$EXT" "$APP/Contents/PlugIns/" sed "s/__VERSION__/$VERSION/g" packaging/Info.plist > "$APP/Contents/Info.plist" diff --git a/src/bin/ocs_launcher.rs b/src/bin/ocs_launcher.rs new file mode 100644 index 000000000..7f538b68e --- /dev/null +++ b/src/bin/ocs_launcher.rs @@ -0,0 +1,175 @@ +//! macOS-only launcher shim, packaged as the .app's `CFBundleExecutable` +//! (`packaging/build_macos_signed.sh`) instead of the real GUI binary +//! (#1039 — "Cannot OPEN WITH in FINDER MAC OS"). +//! +//! ## Why this exists +//! +//! Finder never delivers an opened file as a command-line argument — a +//! double-click, "Open With", and even a plain `open -a OpenCADStudio +//! file.dwg` (no `--args`) all resolve it, then hand the running/launching +//! process the path as a `kAEOpenDocuments` Apple Event instead. The real +//! binary only ever reads `std::env::args()` (see `src/cli.rs`), so none of +//! those reached it — Finder just showed "cannot open files in this format". +//! +//! The standard, documented fix is an `NSApplicationDelegate` implementing +//! `application:openURLs:`. That is not available inside the real binary: +//! winit already installs its own delegate to own window/lifecycle events +//! (`platform_impl::macos::app_state::ApplicationDelegate`), `NSApplication` +//! has exactly one delegate slot, and winit's class doesn't implement the +//! open-document method. Several other approaches were tried and measured, +//! not assumed, against real Finder-equivalent opens: +//! +//! * Registering a raw `AEInstallEventHandler` callback (bypassing the +//! delegate protocol) from inside the real binary, at every point +//! tried — before `app::run()`, on `iced::Subscription`'s first poll, +//! even as the literal first statement of `main()` — reliably caught +//! every Apple Event sent to an *already-running, cleanly-launched* +//! process, but never a cold-launch event. `io::macos_open_events` still +//! carries this: it handles a *second* file opened while this process +//! is already running. +//! * A raw `AEInstallEventHandler` in a separate, minimal, non-AppKit +//! process (no winit/iced, just a manual `CFRunLoopRunInMode` pump) +//! still missed the cold-launch event, ruling out "the real binary's +//! heavy dependency graph loading before `main()` wins the race". +//! * A from-scratch `NSApplication` with our own delegate (this file) does +//! catch the cold-launch event reliably. But relaying by replacing this +//! process's image (`exec`, same PID) turned out to have its own +//! failure mode: Launch Services tracks "the running instance of this +//! bundle" by the Application Serial Number it handed out at launch — +//! tied to *this* process, not to the real binary. `exec` doesn't change +//! that ASN, but a later document open sent to it after the `exec` +//! silently never arrived anywhere (measured via a real second "Open +//! With", not assumed) — and a plain `spawn`'d child in place of `exec` +//! didn't help either: `lsappinfo` showed Launch Services still +//! addressing *this* launcher's ASN for the next open, now pointed at a +//! process that had already exited. +//! +//! So this process has to stay alive for the app's session — it is, as far +//! as Launch Services is concerned, "the app". Every open it receives (cold +//! or warm) is relayed to the real GUI binary over the exact channel a +//! second CLI invocation already uses (`io::single_instance`): hand off to +//! an already-running editor if one answers, otherwise launch a fresh one +//! with the files on argv. `NSApplicationActivationPolicyAccessory` keeps it +//! out of the Dock and Cmd-Tab — it owns no window and has nothing to show. +//! +//! Kept intentionally free of every other dependency in this crate (iced, +//! wgpu, cadkernel, …) beyond what relaying needs, so its own startup is as +//! close to instant as possible — plausibly part of why the isolated-process +//! approach above catches the cold-launch event when the full binary didn't. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +use objc2::rc::Retained; +use objc2::runtime::ProtocolObject; +use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate}; +use objc2_foundation::{ + MainThreadMarker, NSArray, NSNotification, NSObject, NSObjectProtocol, NSURL, +}; + +use OpenCADStudio::io::single_instance; + +/// Name of the real GUI binary inside `Contents/MacOS/`, sibling to this +/// launcher. Must match the packaging script's bundle assembly step. +const REAL_BINARY_NAME: &str = "OpenCADStudio-App"; + +/// How long to wait, after `applicationDidFinishLaunching:`, for an +/// `application:openURLs:` callback before concluding this particular launch +/// came with no documents (Dock icon, `open OpenCADStudio.app` with no +/// file). `application:openURLs:` arrives as part of the same startup +/// sequence when it's coming at all — essentially immediately, not after a +/// meaningful delay — so this window is slack, not a user-visible wait. +const NO_DOCUMENTS_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400); + +/// Set once `application:openURLs:` has handled a launch's documents, so the +/// delayed "no documents" fallback in `applicationDidFinishLaunching:` knows +/// to stay out of the way instead of *also* launching a bare instance. +static DOCS_HANDLED: AtomicBool = AtomicBool::new(false); + +fn real_binary_path() -> PathBuf { + let exe = std::env::current_exe().expect("could not resolve the launcher's own path"); + exe.parent() + .expect("launcher executable has no parent directory") + .join(REAL_BINARY_NAME) +} + +/// Deliver `files` to a running editor if one answers on the single-instance +/// port, otherwise launch a fresh one with `files` on argv — the same +/// channel a second `OpenCADStudio file.dwg` from a terminal already uses. +/// This process (the launcher) is never itself a candidate to serve that +/// port: `try_connect_existing` only ever connects, never binds, so it can't +/// steal the port out from under the real binary that's either already +/// running or about to claim it on its own. +fn deliver_or_launch(files: &[String]) { + let paths: Vec = files.iter().map(PathBuf::from).collect(); + if let Some(stream) = single_instance::try_connect_existing() { + if single_instance::handoff(stream, &paths) { + return; + } + } + if let Err(err) = std::process::Command::new(real_binary_path()).args(files).spawn() { + eprintln!("OpenCADStudio launcher: failed to launch the real binary: {err}"); + } +} + +declare_class!( + struct Delegate; + + unsafe impl ClassType for Delegate { + type Super = NSObject; + type Mutability = mutability::MainThreadOnly; + const NAME: &'static str = "OCSLauncherDelegate"; + } + + impl DeclaredClass for Delegate {} + + unsafe impl NSObjectProtocol for Delegate {} + + unsafe impl NSApplicationDelegate for Delegate { + #[method(applicationDidFinishLaunching:)] + fn did_finish_launching(&self, _notification: &NSNotification) { + std::thread::spawn(|| { + std::thread::sleep(NO_DOCUMENTS_TIMEOUT); + if !DOCS_HANDLED.swap(true, Ordering::SeqCst) { + deliver_or_launch(&[]); + } + }); + } + + #[method(application:openURLs:)] + fn open_urls(&self, _app: &NSApplication, urls: &NSArray) { + DOCS_HANDLED.store(true, Ordering::SeqCst); + let files: Vec = urls + .iter() + .filter_map(|url| unsafe { url.path() }) + .map(|path| path.to_string()) + .collect(); + deliver_or_launch(&files); + } + } +); + +impl Delegate { + fn new(mtm: MainThreadMarker) -> Retained { + let this = mtm.alloc(); + unsafe { msg_send_id![this, init] } + } +} + +fn main() { + let mtm = MainThreadMarker::new().expect("the launcher must run on the main thread"); + let app = NSApplication::sharedApplication(mtm); + // No window, no Dock icon, no menu bar — this process only relays. + app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + + let delegate = Delegate::new(mtm); + let proto: &ProtocolObject = ProtocolObject::from_ref(&*delegate); + app.setDelegate(Some(proto)); + + // Stays running for the app's session — see the module doc for why: + // Launch Services keeps addressing future document-opens to this + // process's Application Serial Number, not to whatever it spawns. + // SAFETY: called once, on the main thread, immediately after `setDelegate`. + unsafe { app.run() }; +} diff --git a/src/io/single_instance.rs b/src/io/single_instance.rs index 9b485452d..bef6fb88f 100644 --- a/src/io/single_instance.rs +++ b/src/io/single_instance.rs @@ -28,6 +28,8 @@ use std::io::{BufRead, BufReader, Write}; use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}; use std::path::PathBuf; +#[cfg(target_os = "macos")] +use std::path::Path; use std::sync::Mutex; use std::time::Duration; @@ -57,6 +59,33 @@ pub enum Claim { Existing(TcpStream), } +/// The stable path this rendezvous is anchored to: normally the running +/// executable itself, but on macOS, when it lives inside a `.app` bundle's +/// `Contents/MacOS/`, the bundle's own path instead. That makes this +/// launcher shim (`src/bin/ocs_launcher.rs`) and the real `OpenCADStudio-App` +/// binary it relays to — two different executables inside the same bundle — +/// rendezvous on the same port (#1039), rather than each computing a +/// different one from their own distinct `current_exe()`. Falls back to the +/// executable path itself when there's no enclosing bundle (a `cargo run` +/// dev binary, or a non-macOS platform), preserving the anti-collision +/// guarantee below unchanged there. +fn rendezvous_anchor() -> PathBuf { + let exe = std::env::current_exe().unwrap_or_default(); + #[cfg(target_os = "macos")] + { + // .app/Contents/MacOS/ -> .../MacOS -> .../Contents -> .app + if let Some(bundle) = exe + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("app")) + { + return bundle.to_path_buf(); + } + } + exe +} + /// What makes two launches "the same editor, for the same user, right here". /// /// All three parts are load-bearing: @@ -64,8 +93,8 @@ pub enum Claim { /// one user's drawing would surface on another user's screen; /// * session — the same user on two seats (or an SSH-forwarded display) must /// not have files delivered to the other display; -/// * executable path — otherwise `cargo run` silently hands your test file to -/// an installed copy, which makes this feature hostile to maintain. +/// * rendezvous anchor — otherwise `cargo run` silently hands your test file +/// to an installed copy, which makes this feature hostile to maintain. fn rendezvous_key() -> String { let user = std::env::var("USER") .or_else(|_| std::env::var("USERNAME")) @@ -74,10 +103,8 @@ fn rendezvous_key() -> String { .or_else(|_| std::env::var("WAYLAND_DISPLAY")) .or_else(|_| std::env::var("DISPLAY")) .unwrap_or_default(); - let exe = std::env::current_exe() - .map(|p| p.to_string_lossy().into_owned()) - .unwrap_or_default(); - format!("{user}|{session}|{exe}") + let anchor = rendezvous_anchor().to_string_lossy().into_owned(); + format!("{user}|{session}|{anchor}") } /// Deterministic port for this rendezvous key (FNV-1a, folded into a fixed @@ -111,16 +138,27 @@ pub fn claim() -> Claim { *LISTENER.lock().unwrap_or_else(|e| e.into_inner()) = Some(l); Claim::Primary } - Err(_) => match TcpStream::connect_timeout(&addr(), IO_TIMEOUT) { - Ok(s) => Claim::Existing(s), + Err(_) => match try_connect_existing() { + Some(s) => Claim::Existing(s), // Bound a moment ago, gone now: the holder exited between our bind // and our connect. We hold no listener, so this window cannot serve // — the next launch binds properly. Self-healing. - Err(_) => Claim::Primary, + None => Claim::Primary, }, } } +/// Try to reach an already-running editor, without attempting to bind the +/// port ourselves. Unlike [`claim`], this never competes for the port — for +/// a caller that isn't itself prepared to serve it and go on to become +/// Primary (the macOS launcher shim, `src/bin/ocs_launcher.rs`, which relays +/// to a real editor process rather than being one), calling `claim` would +/// risk binding the port out from under the GUI process that's about to +/// start up and claim it for itself. +pub fn try_connect_existing() -> Option { + TcpStream::connect_timeout(&addr(), IO_TIMEOUT).ok() +} + /// Hand `paths` to the editor on the other end. `true` once it has acknowledged. /// /// Takes the whole selection in one message: `%F` in the desktop entry hands