forked from 1b-agent/opencadstudio-upstream
Merge PR #1055: handle macOS Finder document opens
This commit is contained in:
commit
ceaf9ebe5b
6 files changed
+213
-20
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user