diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c1102fd7..9264aa5a5 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,10 @@ 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" + # Finder opens the launcher, which relays files to the sibling GUI. + 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 b1dbfbfc2..37a3f6990 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 fac73d49e..2051beb5f 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..bbed9a20c 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" @@ -93,9 +96,11 @@ if [ "$DEVELOPER_ID" = "-" ]; then # CI-parity ad-hoc signature; cannot be notarized. codesign --force --deep --sign - --timestamp=none "$APP" else - # Inside-out: the appex first (sandbox entitlement is REQUIRED for a - # QuickLook extension to run), then the outer bundle. Hardened runtime + # Sign nested code before the outer bundle. The sandbox entitlement is + # required for the QuickLook extension. Use hardened runtime # and a secure timestamp on every layer for notarization. + codesign --force --timestamp --options runtime \ + -s "$DEVELOPER_ID" "$APP/Contents/MacOS/OpenCADStudio-App" codesign --force --timestamp --options runtime \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ -s "$DEVELOPER_ID" "$APP/Contents/PlugIns/DWGThumbnail.appex" diff --git a/src/bin/ocs_launcher.rs b/src/bin/ocs_launcher.rs new file mode 100644 index 000000000..1d52bb56e --- /dev/null +++ b/src/bin/ocs_launcher.rs @@ -0,0 +1,148 @@ +//! Receives macOS document-open callbacks before the GUI runtime starts. +//! +//! The app bundle uses this helper as its `CFBundleExecutable`. It forwards +//! Finder URLs to a running editor or starts the sibling GUI binary, then +//! stays alive so later opens keep reaching the same AppKit delegate. + +#[cfg(target_os = "macos")] +use std::path::PathBuf; +#[cfg(target_os = "macos")] +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(target_os = "macos")] +use objc2::rc::Retained; +#[cfg(target_os = "macos")] +use objc2::runtime::ProtocolObject; +#[cfg(target_os = "macos")] +use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +#[cfg(target_os = "macos")] +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate}; +#[cfg(target_os = "macos")] +use objc2_foundation::{ + MainThreadMarker, NSArray, NSNotification, NSObject, NSObjectProtocol, NSURL, +}; + +#[cfg(target_os = "macos")] +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. +#[cfg(target_os = "macos")] +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. +#[cfg(target_os = "macos")] +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. +#[cfg(target_os = "macos")] +static DOCS_HANDLED: AtomicBool = AtomicBool::new(false); + +/// The first spawned GUI owns the launcher lifetime. +#[cfg(target_os = "macos")] +static GUI_STARTED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "macos")] +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) +} + +/// Forward to a running editor or start the bundled GUI. +#[cfg(target_os = "macos")] +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; + } + } + match std::process::Command::new(real_binary_path()) + .args(files) + .spawn() + { + Ok(mut child) if !GUI_STARTED.swap(true, Ordering::SeqCst) => { + std::thread::spawn(move || { + let _ = child.wait(); + std::process::exit(0); + }); + } + Ok(_) => {} + Err(err) => { + eprintln!("OpenCADStudio launcher: failed to launch the GUI: {err}"); + } + } +} + +#[cfg(target_os = "macos")] +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); + } + } +); + +#[cfg(target_os = "macos")] +impl Delegate { + fn new(mtm: MainThreadMarker) -> Retained { + let this = mtm.alloc(); + unsafe { msg_send_id![this, init] } + } +} + +#[cfg(target_os = "macos")] +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)); + + // SAFETY: called once, on the main thread, immediately after `setDelegate`. + unsafe { app.run() }; +} + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/src/io/single_instance.rs b/src/io/single_instance.rs index 9b485452d..9883faa3e 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,25 @@ pub enum Claim { Existing(TcpStream), } +/// Use the app bundle path on macOS so its launcher and GUI share a port. +/// Development builds and other platforms remain scoped by executable path. +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 +85,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 +95,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 +130,21 @@ 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, }, } } +/// Reach an editor without claiming its port. Used by the macOS launcher. +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 @@ -300,15 +324,15 @@ mod tests { } #[test] - fn rendezvous_key_carries_user_session_and_exe() { + fn rendezvous_key_carries_user_session_and_anchor() { // All three parts must be present, or the isolation the key exists for // is silently gone. let k = rendezvous_key(); assert_eq!(k.matches('|').count(), 2, "key shape changed: {k:?}"); - let exe = std::env::current_exe().unwrap(); + let anchor = rendezvous_anchor(); assert!( - k.ends_with(&*exe.to_string_lossy()), - "key must pin the executable: {k:?}" + k.ends_with(&*anchor.to_string_lossy()), + "key must pin the rendezvous anchor: {k:?}" ); }