egui icon indicating copy to clipboard operation
egui copied to clipboard

Expose NSApp on macOS to add app delegates (e.g. "Open With ... " functionality)

Open hacknus opened this issue 1 year ago • 3 comments

Is your feature request related to a problem? Please describe. I want to create a GUI application that can open CSV files. On macOS, if you put the following lines in the info.plist of the Application.app:

 <key>CFBundleDocumentTypes</key>
  <array>
      <dict>
        <key>CFBundleTypeExtensions</key>
        <array>
          <string>csv</string>
        </array>
        <key>CFBundleTypeName</key>
        <string>Comma separated values</string>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
      </dict>
    </array>

This then tells Finder that you can "Open With ..." and drag/drop files on the icon of the app (not in the window - this is handled by egui with dropped_files).

However, upon dropping or using "Open With ...", it fails because internally no app-delegate is defined.

Describe the solution you'd like

As far as I understand, an app-delegate somewhat like this:

unsafe {
      // Initialize NSApplication
      let app = NSApp();
      app.setActivationPolicy(cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular);

      // Set up the delegate
      let delegate = create_app_delegate();
      let _: () = msg_send![app, setDelegate: delegate];

      // Run the macOS application (This is blocking)
      app.run();
  }

would need to be defined. But as mentioned in #3411 this cannot be done outside of egui, since egui already creates the NSApp.

Describe alternatives you've considered I tried putting the code outside of egui in a thread in my main() function and this is the error:

winit requires control over the principal class. You must create the event loop before other parts of your application initialize NSApplication

Additional context It probably needs to be handled inside egui somewhere around https://github.com/emilk/egui/blob/cf965aaa30987a5b6fa2380f37c0ce8cb869347d/crates/eframe/src/native/app_icon.rs#L202 and exposing the possibility to add app-delegates can also then open up the possibility to add native menu items (#3411)

I am not sure if my approach is going in the correct direction, but I would be fond of the possibility to easily implement "Open With ..." and "Menu Items".

hacknus avatar Jan 20 '25 10:01 hacknus

An inspiration: https://github.com/neovide/neovide/blob/38fd9d75419dc00dd2733965f7aa753d759021af/src/window/macos.rs#L512

hacknus avatar Jan 20 '25 12:01 hacknus

Okay so by changing the function inside egui to this:

/// Set icon & app title for `MacOS` applications.
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconStatus {
    use crate::icon_data::IconDataExt as _;
    profiling::function_scope!();

    use objc2::{
        msg_send,
        rc::{autoreleasepool, Retained},
        runtime::{AnyClass, AnyObject, ClassBuilder},
        sel, ClassType,
    };
    use objc2_app_kit::{NSApplication, NSImage};
    use objc2::runtime::Sel;
    use objc2_foundation::{
        MainThreadMarker, NSArray, NSData, NSDictionary, NSString, NSUserDefaults,
    };

    let png_bytes = if let Some(icon_data) = icon_data {
        match icon_data.to_png_bytes() {
            Ok(png_bytes) => Some(png_bytes),
            Err(err) => {
                log::warn!("Failed to convert IconData to png: {err}");
                return AppIconStatus::NotSetIgnored;
            }
        }
    } else {
        None
    };

    // SAFETY: we don't do anything dangerous here

    extern "C" fn handle_open_files(_this: &mut AnyObject, _sel: Sel, _sender: &objc2::runtime::AnyObject, files: &mut NSArray<NSString>) {
        autoreleasepool(|pool| {
            for file in files.iter() {
                let path = file.as_str(pool).to_owned();
                println!("Received file via Open With: {}", path);
                // Your logic to open and handle the CSV file
            }
        });
    }

    let mtm = MainThreadMarker::new().expect("File handler must be registered on main thread.");
    unsafe {
        let app = NSApplication::sharedApplication(mtm);

        if let Some(png_bytes) = png_bytes {
            let data = NSData::from_vec(png_bytes);

            log::trace!("NSImage::initWithData…");
            let app_icon = NSImage::initWithData(NSImage::alloc(), &data);

            profiling::scope!("setApplicationIconImage_");
            log::trace!("setApplicationIconImage…");
            app.setApplicationIconImage(app_icon.as_deref());
        }

        let delegate = app.delegate().unwrap();

        // Find out class of the NSApplicationDelegate
        let class: &AnyClass = msg_send![&delegate, class];

        // register subclass of whatever was in delegate
        let mut my_class = ClassBuilder::new("ApplicationDelegate", class).unwrap();
        my_class.add_method(
            sel!(application:openFiles:),
            handle_open_files as unsafe extern "C" fn(_, _, _, _) -> _,
        );
        let class = my_class.register();

        // this should be safe as:
        //  * our class is a subclass
        //  * no new ivars
        //  * overriden methods are compatible with old (we implement protocol method)
        let delegate_obj = Retained::cast::<AnyObject>(delegate);
        AnyObject::set_class(&delegate_obj, class);

        // Prevent AppKit from interpreting our command line.
        let key = NSString::from_str("NSTreatUnknownArgumentsAsOpen");
        let keys = vec![key.as_ref()];
        let objects = vec![Retained::cast::<AnyObject>(NSString::from_str("YES"))];
        let dict = NSDictionary::from_vec(&keys, objects);
        NSUserDefaults::standardUserDefaults().registerDefaults(dict.as_ref());

        // Change the title in the top bar - for python processes this would be again "python" otherwise.
        if let Some(main_menu) = app.mainMenu() {
            if let Some(item) = main_menu.itemAtIndex(0) {
                if let Some(app_menu) = item.submenu() {
                    profiling::scope!("setTitle_");
                    app_menu.setTitle(&NSString::from_str(title));
                }
            }
        }
    }

    // The title in the Dock apparently can't be changed.
    // At least these people didn't figure it out either:
    // https://stackoverflow.com/questions/69831167/qt-change-application-title-dynamically-on-macos
    // https://stackoverflow.com/questions/28808226/changing-cocoa-app-icon-title-and-menu-labels-at-runtime

    AppIconStatus::Set
}

I managed to get the "drop on icon" feature to work. However, it appears that this function gets called too late in egui, such that the delegates are not immediately set up after app launch. Thus, when you call "Open with ..." macOS does not see the delegate and reports that this application cannot open this type of file.

Is there any way to change the call of this method in egui such that it is earlier? I also notice that when launching the app, the icon appears significantly late - which would confirm my theory that this is the culprit.

hacknus avatar Jan 20 '25 13:01 hacknus

This will not be necessary once we will upgrade winit. There is a discussion going on here: https://github.com/rust-windowing/winit/issues/4260

hacknus avatar Jun 05 '25 23:06 hacknus