forked from 1b-agent/opencadstudio-upstream
191 lines
7.1 KiB
Rust
191 lines
7.1 KiB
Rust
//! 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<PathBuf> = files.iter().map(PathBuf::from).collect();
|
|
if let Some(stream) = single_instance::try_connect_existing() {
|
|
if single_instance::handoff(stream, &paths) {
|
|
reassert_accessory_policy();
|
|
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}");
|
|
}
|
|
}
|
|
reassert_accessory_policy();
|
|
}
|
|
|
|
/// Keep the windowless relay out of the Dock after an open event activates it.
|
|
#[cfg(target_os = "macos")]
|
|
fn reassert_accessory_policy() {
|
|
if let Some(mtm) = MainThreadMarker::new() {
|
|
NSApplication::sharedApplication(mtm)
|
|
.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
|
}
|
|
}
|
|
|
|
#[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<NSURL>) {
|
|
DOCS_HANDLED.store(true, Ordering::SeqCst);
|
|
let files: Vec<String> = urls
|
|
.iter()
|
|
.filter_map(|url| unsafe { url.path() })
|
|
.map(|path| path.to_string())
|
|
.collect();
|
|
deliver_or_launch(&files);
|
|
}
|
|
|
|
/// Start or activate the GUI when the bundle is reopened without files.
|
|
#[method(applicationShouldHandleReopen:hasVisibleWindows:)]
|
|
fn should_handle_reopen(&self, _sender: &NSApplication, has_visible_windows: bool) -> bool {
|
|
if !has_visible_windows {
|
|
deliver_or_launch(&[]);
|
|
}
|
|
true
|
|
}
|
|
}
|
|
);
|
|
|
|
#[cfg(target_os = "macos")]
|
|
impl Delegate {
|
|
fn new(mtm: MainThreadMarker) -> Retained<Self> {
|
|
let this = mtm.alloc();
|
|
unsafe { msg_send_id![this, init] }
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
fn main() {
|
|
// Explicit CLI invocations must reach the real argument parser before
|
|
// AppKit starts. In particular, --mcp needs this process's stdin/stdout,
|
|
// and --serve, --export, --help and --version must not launch a bare GUI.
|
|
// Keep arguments as OsString so file names need not be valid UTF-8.
|
|
// Finder may supply only its legacy process serial number; that still
|
|
// belongs to the persistent document-open relay below.
|
|
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
|
let finder_launch =
|
|
args.is_empty() || (args.len() == 1 && args[0].as_encoded_bytes().starts_with(b"-psn_"));
|
|
if !finder_launch {
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
// Replacing the process preserves stdio, PID, signals and exit status.
|
|
// Do not exec on a normal Finder launch: its AppKit delegate must stay
|
|
// alive to receive subsequent document-open events.
|
|
let error = std::process::Command::new(real_binary_path())
|
|
.args(args)
|
|
.exec();
|
|
eprintln!("OpenCADStudio launcher: failed to execute the application: {error}");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
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<dyn NSApplicationDelegate> = 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() {}
|