Merge PR #1055: handle macOS Finder document opens

This commit is contained in:
Hakan Seven committed 2026-09-07 18:53:29 +03:00
commit ceaf9ebe5b
6 files changed
+213 -20

No files matched your search

+7 -2
View File
@@ -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.
Generated
+3
View File
@@ -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",
+8
View File
@@ -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" }
+10 -5
View File
@@ -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"
+148
View File
@@ -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<PathBuf> = 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<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);
}
}
);
#[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() {
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() {}
+37 -13
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,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")]
{
// <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 +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> {
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:?}"
);
}