Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
hacknus opened this issue Jan 20, 2025 · 2 comments

Comments

@hacknus
Copy link
Contributor

hacknus commented Jan 20, 2025

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

fn set_title_and_icon_mac(title: &str, icon_data: Option<&IconData>) -> AppIconStatus {

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 hacknus changed the title Expose NSApp on macOS to add app delegates Expose NSApp on macOS to add app delegates (e.g. "Open With ... " functionality) Jan 20, 2025
@hacknus
Copy link
Contributor Author

hacknus commented Jan 20, 2025

@hacknus
Copy link
Contributor Author

hacknus commented Jan 20, 2025

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant