From e767507850594cf9c0ed7d8403c5f7aa300f2d64 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Mon, 7 Sep 2026 16:45:02 +0200 Subject: [PATCH] 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