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