macOS: add a launcher shim so a cold Finder open also works (#1039)

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 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-07 16:45:02 +02:00
1 parent b617734d5c
commit e767507850
6 files changed
+249 -14

No files matched your search

+47 -9
View File
@@ -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")]
{
// <bundle>.app/Contents/MacOS/<executable> -> .../MacOS -> .../Contents -> <bundle>.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> {
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