summary history branches tags files
commit:aec55f557f04c198d8d762a3e5ac73f3bf736873
author:Trevor Bentley
committer:Trevor Bentley
date:Wed Feb 13 20:26:05 2019 +0100
parents:8d3eabdcccb346b520bbed9905b0dd440863fb91
Add support for custom plist blocks and Apple event callbacks
diff --git a/examples/register_url.rs b/examples/register_url.rs
line changes: +114/-0
index 0000000..9af714f
--- /dev/null
+++ b/examples/register_url.rs
@@ -0,0 +1,114 @@
+/// Example that launches as Mac App with custom URL scheme handler
+///
+/// In one terminal, build and run:
+///
+/// $ cargo build --features=logging --examples
+/// $ ./target/debug/examples/register_url && tail -f ~/.fruitbasket_register_url.log
+///
+/// In a second terminal, open custom URL:
+///
+/// $ open fruitbasket://test
+///
+/// Log output will show that the example has received and printed the custom URL.
+///
+extern crate fruitbasket;
+use fruitbasket::ActivationPolicy;
+use fruitbasket::Trampoline;
+use fruitbasket::FruitApp;
+use fruitbasket::InstallDir;
+use fruitbasket::RunPeriod;
+use fruitbasket::FruitError;
+use fruitbasket::FruitCallbackKey;
+use std::time::Duration;
+use std::path::PathBuf;
+
+#[macro_use]
+extern crate log;
+
+fn main() {
+    let _ = fruitbasket::create_logger(".fruitbasket_register_url.log", fruitbasket::LogDir::Home, 5, 2).unwrap();
+
+    // Find the icon file from the Cargo project dir
+    let icon = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+        .join("examples").join("icon.png");
+
+    // Re-launch self in an app bundle if not already running from one.
+    info!("Executable must run from App bundle.  Let's try:");
+    let mut app = match Trampoline::new("fruitbasket_register_url", "fruitbasket", "com.trevorbentley.fruitbasket_register_url")
+        .version("2.1.3")
+        .icon("fruitbasket.icns")
+        .plist_key("CFBundleSpokenName","\"fruit basket\"")
+        .plist_keys(&vec![
+            ("LSMinimumSystemVersion", "10.12.0"),
+            ("LSBackgroundOnly", "1"),
+        ])
+        // Register "fruitbasket://" and "fbasket://" URL schemes in Info.plist
+        .plist_raw_string("
+CFBundleURLTypes = ( {
+  CFBundleTypeRole = \"Viewer\";
+  CFBundleURLName = \"Fruitbasket Example URL\";
+  CFBundleURLSchemes = (\"fruitbasket\", \"fbasket\");
+} );\n".into())
+        .resource(icon.to_str().unwrap())
+        .build(InstallDir::Temp) {
+            Err(FruitError::UnsupportedPlatform(_)) => {
+                info!("This is not a Mac.  App bundling is not supported.");
+                info!("It is still safe to use FruitApp::new(), though the dummy app will do nothing.");
+                FruitApp::new()
+            },
+            Err(FruitError::IOError(e)) => {
+                info!("IO error! {}", e);
+                std::process::exit(1);
+            },
+            Err(FruitError::GeneralError(e)) => {
+                info!("General error! {}", e);
+                std::process::exit(1);
+            },
+            Ok(app) => app,
+        };
+
+    // App is guaranteed to be running in a bundle now!
+
+    // Make it a regular app in the dock.
+    // Note: Because 'LSBackgroundOnly' is set to true in the Info.plist, the
+    // app will launch backgrounded and will not take focus.  If we only did
+    // that, the app would stay in 'Prohibited' mode and would not create a dock
+    // icon.  By overriding the activation policy now, it will stay background
+    // but create the Dock and menu bar entries.  This basically implements a
+    // "pop-under" behavior.
+    app.set_activation_policy(ActivationPolicy::Regular);
+
+    // Register a callback for when the ObjC application finishes launching
+    let stopper = app.stopper();
+    app.register_callback(FruitCallbackKey::Method("applicationWillFinishLaunching:"),
+                          Box::new(move |_event| {
+                              info!("applicationDidFinishLaunching.");
+                              stopper.stop();
+                          }));
+
+    // Run until callback is called
+    info!("Spawned process started.  Run until applicationDidFinishLaunching.");
+    let _ = app.run(RunPeriod::Forever);
+
+    info!("Application launched.  Registering URL callbacks.");
+    // Register a callback to get receive custom URL schemes from any Mac program
+    app.register_apple_event(fruitbasket::kInternetEventClass, fruitbasket::kAEGetURL);
+    let stopper = app.stopper();
+    app.register_callback(FruitCallbackKey::Method("handleEvent:withReplyEvent:"),
+                          Box::new(move |event| {
+                              // Event is a raw NSAppleEventDescriptor.
+                              // Fruitbasket has a parser for URLs.  Call that to get the URL:
+                              let url: String = fruitbasket::parse_url_event(event);
+                              info!("Received URL: {}", url);
+                              stopper.stop();
+                          }));
+
+    // Run 'forever', until the URL callback fires
+    info!("Spawned process running!");
+    let _ = app.run(RunPeriod::Forever);
+    info!("Run loop stopped after URL callback.");
+
+    // Cleanly terminate
+    fruitbasket::FruitApp::terminate(0);
+    info!("This will not print.");
+}

diff --git a/src/lib.rs b/src/lib.rs
line changes: +31/-0
index dd0da39..69aff76
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -91,6 +91,16 @@ pub const FORBIDDEN_PLIST: &'static [&'static str] = & [
     "CFBundleVersion",
 ];
 
+/// Apple kInternetEventClass constant
+#[allow(non_upper_case_globals)]
+pub const kInternetEventClass: u32 = 0x4755524c;
+/// Apple kAEGetURL constant
+#[allow(non_upper_case_globals)]
+pub const kAEGetURL: u32 = 0x4755524c;
+/// Apple keyDirectObject constant
+#[allow(non_upper_case_globals)]
+pub const keyDirectObject: u32 = 0x2d2d2d2d;
+
 #[cfg(all(target_os = "macos", not(feature="dummy")))]
 mod osx;
 
@@ -100,6 +110,20 @@ pub use osx::FruitApp;
 #[cfg(all(target_os = "macos", not(feature="dummy")))]
 pub use osx::Trampoline;
 
+#[cfg(all(target_os = "macos", not(feature="dummy")))]
+pub use osx::FruitObjcCallback;
+
+#[cfg(all(target_os = "macos", not(feature="dummy")))]
+pub use osx::FruitCallbackKey;
+
+#[cfg(all(target_os = "macos", not(feature="dummy")))]
+pub use osx::parse_url_event;
+
+#[cfg(any(not(target_os = "macos"), feature="dummy"))]
+pub enum FruitCallbackKey {}
+
+#[cfg(any(not(target_os = "macos"), feature="dummy"))]
+pub type FruitObjcCallback = Box<Fn(*mut u64)>;
 
 /// Main interface for controlling and interacting with the AppKit app
 ///
@@ -119,6 +143,10 @@ impl FruitApp {
         FruitApp{ tx: tx, rx: rx}
     }
     /// Docs in OS X build.
+    pub fn register_callback(&mut self, key: FruitCallbackKey, cb: FruitObjcCallback) {}
+    /// Docs in OS X build.
+    pub fn register_apple_event(&mut self, class: u32, id: u32) {}
+    /// Docs in OS X build.
     pub fn set_activation_policy(&self, _policy: ActivationPolicy) {}
     /// Docs in OS X build.
     pub fn terminate(exit_code: i32) {
@@ -156,6 +184,9 @@ impl FruitApp {
     pub fn bundled_resource_path(_name: &str, _extension: &str) -> Option<String> { None }
 }
 
+#[cfg(any(not(target_os = "macos"), feature="dummy"))]
+pub fn parse_url_event(event: *mut Object) -> String { "".into() }
+
 /// API to move the executable into a Mac app bundle and relaunch (if necessary)
 ///
 /// Dummy implementation for non-OSX platforms.  See OS X build for proper

diff --git a/src/osx.rs b/src/osx.rs
line changes: +256/-0
index d458cc6..4786f29
--- a/src/osx.rs
+++ b/src/osx.rs
@@ -44,6 +44,7 @@ use std::cell::Cell;
 use std::sync::mpsc::channel;
 use std::sync::mpsc::Receiver;
 use std::sync::mpsc::Sender;
+use std::collections::HashMap;
 
 use super::FruitError;
 use super::ActivationPolicy;
@@ -59,6 +60,19 @@ extern crate objc;
 use objc::runtime::Object;
 use objc::runtime::Class;
 
+extern crate objc_id;
+use self::objc_id::Id;
+use self::objc_id::WeakId;
+use self::objc_id::Shared;
+
+extern crate objc_foundation;
+use std::sync::{Once, ONCE_INIT};
+use objc::Message;
+use objc::declare::ClassDecl;
+use objc::runtime::{Sel};
+use self::objc_foundation::{INSObject, NSObject};
+
+
 #[allow(non_upper_case_globals)]
 const nil: *mut Object = 0 as *mut Object;
 
@@ -94,6 +108,65 @@ pub struct FruitApp {
     run_mode: *mut Object,
     tx: Sender<()>,
     rx: Receiver<()>,
+    objc: Box<ObjcWrapper>,
+}
+
+/// A boxed Fn type for receiving Rust callbacks from ObjC events
+pub type FruitObjcCallback = Box<Fn(*mut Object)>;
+
+/// Key into the ObjC callback hash map
+///
+/// You can register to receive callbacks from ObjectiveC based on these keys.
+///
+/// Callbacks that are not tied to objects can be registered with static
+/// selector strings.  For instance, if your app has registered itself as a URL
+/// handler, you would use:
+///   FruitCallbackKey::Method("handleEvent:withReplyEvent:")
+///
+/// Other pre-defined selectors are:
+///   FruitCallbackKey::Method("applicationWillFinishlaunching:")
+///   FruitCallbackKey::Method("applicationDidFinishlaunching:")
+///
+/// The Object variant is currently unused, and reserved for the future.
+/// If the callback will be from a particular object, you use the Object type
+/// with the ObjC object included.  For example, if you want to register for
+/// callbacks from a particular NSButton instance, you would add it to the
+/// callback map with:
+///   let button1: *mut Object = <create an NSButton>;
+///   app.register_callback(FruitCallbackKey::Object(button),
+///     Box::new(|button1| {
+///       println!("got callback from button1, address: {:x}", button1 as u64);
+///   }));
+///
+#[derive(PartialEq, Eq, Hash)]
+pub enum FruitCallbackKey {
+    /// A callback tied to a generic selector
+    Method(&'static str),
+    /// A callback from a specific object instance
+    Object(*mut Object),
+}
+
+/// Rust class for wrapping Objective-C callback class
+///
+/// There is one Objective-C object, implemented in Rust but registered with and
+/// owned by the Objective-C runtime, which handles ObjC callbacks such as those
+/// for the NSApplication delegate.  This is a native Rust class that wraps the
+/// ObjC object.
+///
+/// There should be exactly one of this object, and it must be stored on the
+/// heap (i.e. in a Box).  This is because the ObjC object calls into this class
+/// via raw function pointers, and its address must not change.
+///
+struct ObjcWrapper {
+    objc: Id<ObjcSubclass, Shared>,
+    map: HashMap<FruitCallbackKey, FruitObjcCallback>,
+}
+
+impl ObjcWrapper {
+    fn take(&mut self) -> Id<ObjcSubclass, Shared> {
+        let weak = WeakId::new(&self.objc);
+        weak.load().unwrap()
+    }
 }
 
 /// API to move the executable into a Mac app bundle and relaunch (if necessary)
@@ -126,6 +199,7 @@ pub struct Trampoline {
     icon: String,
     version: String,
     keys: Vec<(String,String)>,
+    plist_raw_strings: Vec<String>,
     resources: Vec<String>,
 }
 
@@ -249,6 +323,21 @@ impl Trampoline {
         }
         self
     }
+    /// Add a 'raw', preformatted string to Info.plist
+    ///
+    /// Pastes a raw, unedited string into the Info.plist file.  This is
+    /// dangerous, and should be used with care.  Use this for adding nested
+    /// structures, such as when registering URI schemes.
+    ///
+    /// *MUST* be in the JSON plist format.  If coming from XML format, you can
+    /// use `plutil -convert json -r Info.plist` to convert.
+    ///
+    /// Take care not to override any of the keys in fruitbasket::FORBIDDEN_PLIST
+    /// unless you really know what you are doing.
+    pub fn plist_raw_string(&mut self, s: String) -> &mut Self {
+        self.plist_raw_strings.push(s);
+        self
+    }
     /// Add file to Resources directory of app bundle
     ///
     /// Specify full path to a file to copy into the Resources directory of the
@@ -390,6 +479,12 @@ impl Trampoline {
                     write!(&mut f, "  {} = {};\n", key, val)?;
                 }
             }
+
+            // Write raw plist fields
+            for raw in &self.plist_raw_strings {
+                write!(&mut f, "{}\n", raw)?;
+            }
+
             write!(&mut f, "}}\n")?;
 
             // Launch newly created bundle
@@ -438,6 +533,13 @@ impl FruitApp {
                                                   initWithBytes:rust_runmode.as_ptr()
                                                   length:rust_runmode.len()
                                                   encoding: 4]; // UTF8_ENCODING
+            let objc = ObjcSubclass::new().share();
+            let rustobjc = Box::new(ObjcWrapper {
+                objc: objc,
+                map: HashMap::new(),
+            });
+            let ptr: u64 = &*rustobjc as *const ObjcWrapper as u64;
+            let _:() = msg_send![rustobjc.objc, setRustWrapper: ptr];
             FruitApp {
                 app: app,
                 pool: Cell::new(pool),
@@ -445,10 +547,45 @@ impl FruitApp {
                 run_mode: run_mode,
                 tx: tx,
                 rx: rx,
+                objc: rustobjc,
             }
         }
     }
 
+    /// Register to receive a callback when the ObjC runtime raises one
+    ///
+    /// ObjCCallbackKey is used to specify the source of the callback, which
+    /// must be something registered with the ObjC runtime.
+    ///
+    pub fn register_callback(&mut self, key: FruitCallbackKey, cb: FruitObjcCallback) {
+        let _ = self.objc.map.insert(key, cb);
+    }
+
+    /// Register application to receive Apple events of the given type
+    ///
+    /// Register with the underlying NSAppleEventManager so this application gets
+    /// events matching the given Class/ID tuple.  This causes the internal ObjC
+    /// delegate to receive `handleEvent:withReplyEvent:` messages when the
+    /// specified event is sent to your application.
+    ///
+    /// This registers the event to be received internally.  To receive it in
+    /// your code, you must use FruitApp::register_callback to listen for the
+    /// selector by specifying key:
+    ///   FruitCallbackKey::Method("handleEvent:withReplyEvent:")
+    ///
+    pub fn register_apple_event(&mut self, class: u32, id: u32) {
+        unsafe {
+            let cls = Class::get("NSAppleEventManager").unwrap();
+            let manager: *mut Object = msg_send![cls, sharedAppleEventManager];
+            let objc = (*self.objc).take();
+            let _ = msg_send![manager,
+                              setEventHandler: objc
+                              andSelector: sel!(handleEvent:withReplyEvent:)
+                              forEventClass: class
+                              andEventID: id];
+        }
+    }
+
     /// Set the app "activation policy" controlling what UI it does/can present.
     pub fn set_activation_policy(&self, policy: ActivationPolicy) {
         let policy_int = match policy {
@@ -525,6 +662,10 @@ impl FruitApp {
             unsafe {
                 let run_count = self.run_count.get();
                 if run_count == 0 {
+                    let cls = objc::runtime::Class::get("NSApplication").unwrap();
+                    let app: *mut objc::runtime::Object = msg_send![cls, sharedApplication];
+                    let objc = (*self.objc).take();
+                    let _:() = msg_send![app, setDelegate: objc];
                     let _:() = msg_send![self.app, finishLaunching];
                 }
                 // Create a new release pool every once in a while, draining the old one
@@ -626,3 +767,118 @@ impl FruitApp {
         }
     }
 }
+
+/// Parse an Apple URL event into a URL string
+///
+/// Takes an NSAppleEventDescriptor from an Apple URL event, unwraps
+/// it, and returns the contained URL as a String.
+pub fn parse_url_event(event: *mut Object) -> String {
+    if event as u64 == 0u64 {
+        return "".into();
+    }
+    unsafe {
+        let class: u32 = msg_send![event, eventClass];
+        let id: u32 = msg_send![event, eventID];
+        if class != ::kInternetEventClass || id != ::kAEGetURL {
+            return "".into();
+        }
+        let subevent: *mut Object = msg_send![event, paramDescriptorForKeyword: ::keyDirectObject];
+        let nsstring: *mut Object = msg_send![subevent, stringValue];
+        let cstr: *const i8 = msg_send![nsstring, UTF8String];
+        if cstr != std::ptr::null() {
+            let rstr = std::ffi::CStr::from_ptr(cstr).to_string_lossy().into_owned();
+            return rstr;
+        }
+    }
+    "".into()
+}
+
+/// ObjcSubclass is a subclass of the objective-c NSObject base class.
+/// This is registered with the objc runtime, so instances of this class
+/// are "owned" by objc, and have no associated Rust data.
+///
+/// This can be wrapped with a ObjcWrapper, which is a proper Rust struct
+/// with its own storage, and holds an instance of ObjcSubclass.
+///
+/// An ObjcSubclass can "talk" to its Rust wrapper class through function
+/// pointers, as long as the storage is on the heap with a Box and the underlying
+/// memory address doesn't change.
+///
+enum ObjcSubclass {}
+
+unsafe impl Message for ObjcSubclass { }
+
+static OBJC_SUBCLASS_REGISTER_CLASS: Once = ONCE_INIT;
+
+impl ObjcSubclass {
+    /// Call a registered Rust callback
+    fn dispatch_cb(wrap_ptr: u64, key: FruitCallbackKey, obj: *mut Object) {
+        if wrap_ptr == 0 {
+            return;
+        }
+        let objcwrap: &mut ObjcWrapper = unsafe { &mut *(wrap_ptr as *mut ObjcWrapper) };
+        if let Some(ref cb) = objcwrap.map.get(&key) {
+            cb(obj);
+        }
+    }
+}
+
+/// Define an ObjC class and register it with the ObjC runtime
+impl INSObject for ObjcSubclass {
+    fn class() -> &'static Class {
+        OBJC_SUBCLASS_REGISTER_CLASS.call_once(|| {
+            let superclass = NSObject::class();
+            let mut decl = ClassDecl::new("ObjcSubclass", superclass).unwrap();
+            decl.add_ivar::<u64>("_rustwrapper");
+
+            /// Callback for events from Apple's NSAppleEventManager
+            extern fn objc_apple_event(this: &Object, _cmd: Sel, event: u64, _reply: u64) {
+                let ptr: u64 = unsafe { *this.get_ivar("_rustwrapper") };
+                ObjcSubclass::dispatch_cb(ptr,
+                                          FruitCallbackKey::Method("handleEvent:withReplyEvent:"),
+                                          event as *mut Object);
+            }
+            /// NSApplication delegate callback
+            extern fn objc_did_finish(this: &Object, _cmd: Sel, event: u64) {
+                let ptr: u64 = unsafe { *this.get_ivar("_rustwrapper") };
+                ObjcSubclass::dispatch_cb(ptr,
+                                          FruitCallbackKey::Method("applicationDidFinishLaunching:"),
+                                          event as *mut Object);
+            }
+            /// NSApplication delegate callback
+            extern fn objc_will_finish(this: &Object, _cmd: Sel, event: u64) {
+                let ptr: u64 = unsafe { *this.get_ivar("_rustwrapper") };
+                ObjcSubclass::dispatch_cb(ptr,
+                                          FruitCallbackKey::Method("applicationWillFinishLaunching:"),
+                                          event as *mut Object);
+            }
+            /// Register the Rust ObjcWrapper instance that wraps this object
+            ///
+            /// In order for an instance of this ObjC owned object to reach back
+            /// into "pure Rust", it needs to know the location of Rust
+            /// functions.  This is accomplished by wrapping it in a Rust struct,
+            /// which is itself in a Box on the heap to ensure a fixed location
+            /// in memory.  The address of this wrapping struct is given to this
+            /// object by casting the Box into a raw pointer, and then casting
+            /// that into a u64, which is stored here.
+            extern fn objc_set_rust_wrapper(this: &mut Object, _cmd: Sel, ptr: u64) {
+                unsafe {this.set_ivar("_rustwrapper", ptr);}
+            }
+            unsafe {
+                // Register all of the above handlers as true ObjC selectors:
+                let f: extern fn(&mut Object, Sel, u64) = objc_set_rust_wrapper;
+                decl.add_method(sel!(setRustWrapper:), f);
+                let f: extern fn(&Object, Sel, u64, u64) = objc_apple_event;
+                decl.add_method(sel!(handleEvent:withReplyEvent:), f);
+                let f: extern fn(&Object, Sel, u64) = objc_did_finish;
+                decl.add_method(sel!(applicationDidFinishLaunching:), f);
+                let f: extern fn(&Object, Sel, u64) = objc_will_finish;
+                decl.add_method(sel!(applicationWillFinishLaunching:), f);
+            }
+
+            decl.register();
+        });
+
+        Class::get("ObjcSubclass").unwrap()
+    }
+}