ahkohd / tauri-macos-spotlight-example

An example macOS Spotlight app built with Tauri
MIT License
148 stars 9 forks source link

Can a solution be provided to prevent the window from stealing focus from the currently active window #4

Closed zzzze closed 1 year ago

zzzze commented 1 year ago

Can a solution be provided to prevent the window from stealing focus from the currently active window, as mentioned in this issue?

I'd like to create a window/app that behaves like macOS Spotlight:

  1. It shows over fullscreen apps
  2. It doesn't deactivate the active app. If the window opens while you have Chrome focused, the Chrome menu bar is still shown and the Chrome window looks like it's still focused.

On macOS I think this kind of window is an NSPanel

ahkohd commented 1 year ago

You don't need to worry about stealing focus if you're going to restore the focus.

I have implemented a solution that ensures the active window remains in focus. You can see this in the commit 03a3077.

The new logic ensures that when the dock window loses focus, the application automatically restores focus to the previously active window, providing an uninterrupted user experience.

See the demo: Screen Recording 2023-02-12 at 1 31 39 AM

Here's how it works:

elanzini commented 1 year ago

One problem with this. If for example I have VS Code in focus, I open the Tauri app and then I click on a different app (e.g. Firefox), it will give the focus back on VS Code instead of focusing on Firefox.

https://user-images.githubusercontent.com/37532050/218305158-db04b263-ec2f-47fe-b3c7-2f40440b099e.mov

zzzze commented 1 year ago

One problem with this. If for example I have VS Code in focus, I open the Tauri app and then I click on a different app (e.g. Firefox), it will give the focus back on VS Code instead of focusing on Firefox.

Screen.Recording.2023-02-12.at.10.15.27.mov

This problem can be resolved by moving the logic from the "backdrop" function to the "register_shortcut" function, so that it will only be affected by the shortcut that hides the app.

image

I have an even trickier problem. The workaround uses the "open" API to reopen the previously active application, but each app has its own unique logic for reopening, and some of them hide the app.

zzzze commented 1 year ago

@ahkohd I resolved the issue by using the Object-C API [app activateWithOptions:NSApplicationActivateAllWindows]. Your solution seems to be even better. I'm looking forward to your solution.

ahkohd commented 1 year ago

Fixed; see commit 303913c.

This commit effectively resolves the bugs detected in the previous commit. It employs the Objective-C "Copy Window List" API to retrieve the list of windows displayed on the screen, locates the spotlight window, and subsequently activates the next window in the list, excluding any windows designated as a menubar or always-on-top.

elanzini commented 1 year ago

This is unfortunately still not behaving as expected. I am typing in a text box and then I open the application with Command + K. I then click on the browser that is in the background to get the focus back to the text box but that is not what is happening.

As a first step, would it be possible to register the escape key to close the application? So it simulates how Spotlight works on that aspect as well?

ahkohd commented 1 year ago

Hi, I don't understand what you mean in the first paragraph, is the focus not restoring to the window you stole focus from?

You can create an issue for the escape key to tuck away the spotlight light window.

elanzini commented 1 year ago

Created #5 - it will be easier to reproduce what I mean once the escape feature is implemented. Looking forward to it ☺️

zzzze commented 1 year ago

I found a bug where if there are notifications on the screen, it is not possible to activate the previous window. To reproduce this issue, you can use the following osascript to show a fake notification: osascript -e 'display notification "Lorem ipsum dolor sit amet" with title "Hello World"'

image

PS: If you set the notification to "Alerts," it will remain on the screen until you manually close it.

image
ahkohd commented 1 year ago

I had to reimplement how to refocus the window behind the spotlight window when it's tucked away. See commit dde828c.

Despite trying to use NSRunningApplication:activateWith:options, I found that this approach wasn't working correctly on multiple screens. After some troubleshooting, I decided to re-implement the code using macOS accessibility API. This allowed me to focus on the window using the owner process id and the window id, which proved to be a more reliable solution.

ahkohd commented 1 year ago

@elanzini @zzzze, please verify if all is correct. I have tested thoroughly on my end everything works fine. I tried against multiple screens, zoomed windows, push notifications, and floating windows.

elanzini commented 1 year ago

@ahkohd Thanks so much for all the effort. Unfortunately, I see that the focus still doesn't go back to the text box where I am typing.

https://user-images.githubusercontent.com/37532050/218962173-ad437052-d7f8-4e36-9f27-3cefd1307e10.mov

ahkohd commented 1 year ago

@ahkohd Thanks so much for all the effort. Unfortunately, I see that the focus still doesn't go back to the text box where I am typing.

https://user-images.githubusercontent.com/37532050/218962173-ad437052-d7f8-4e36-9f27-3cefd1307e10.mov

Did you grant accessibility permission?

elanzini commented 1 year ago

Did you grant accessibility permission?

I didn't get any popup asking for it when running npm run tauri dev

ahkohd commented 1 year ago

Did you grant accessibility permission?

I didn't get any popup asking for it when running npm run tauri dev

Can you grant it manually to your terminal? I'll look into the code that requests it, perhaps it's broken

elanzini commented 1 year ago

Can you grant it manually to your terminal? I'll look into the code that requests it, perhaps it's broken

I go to the Accessibility screen in Settings but not sure what to do after.

Screenshot 2023-02-15 at 08 12 49
ahkohd commented 1 year ago

See this guide https://support.apple.com/en-ng/guide/mac-help/mh43185/mac

elanzini commented 1 year ago

See this guide https://support.apple.com/en-ng/guide/mac-help/mh43185/mac

Both my Terminal and VS Code (from which I am running the app) have Accessibility permissions.

ahkohd commented 1 year ago

See this guide https://support.apple.com/en-ng/guide/mac-help/mh43185/mac

Both my Terminal and VS Code (from which I am running the app) have Accessibility permissions.

That explains why you did not get the prompt. Just for sanity checks can you revoke it? So that it can request again when you run the code. But I doubt that will fix your issue.

elanzini commented 1 year ago

See this guide https://support.apple.com/en-ng/guide/mac-help/mh43185/mac

Both my Terminal and VS Code (from which I am running the app) have Accessibility permissions.

That explains why you did not get the prompt. Just for sanity checks can you revoke it? So that it can request again when you run the code. But I doubt that will fix your issue.

Yeah, unfortunately I am still seeing the same issue.

ahkohd commented 1 year ago

See this guide https://support.apple.com/en-ng/guide/mac-help/mh43185/mac

Both my Terminal and VS Code (from which I am running the app) have Accessibility permissions.

That explains why you did not get the prompt. Just for sanity checks can you revoke it? So that it can request again when you run the code. But I doubt that will fix your issue.

Yeah, unfortunately I am still seeing the same issue.

You got the prompt right?

elanzini commented 1 year ago

You got the prompt right?

Correct, a got a prompt asking to grant Accessibility to VS Code

zzzze commented 1 year ago
image

@ahkohd It looks like the active logic might not be taking effect when there is a floating widget with a height greater than 80. It is caused by the following piece of code.

image
ahkohd commented 1 year ago

Ahaha I always know that might not work! I'll push a fix soon, I have made some research, and I have the solution to fix this using the window layer.

image image
ahkohd commented 1 year ago

Fixed! See commit bbf4d9b. I hope all is good this time. πŸ˜…

This refactor removes the hacks I wrote previously. The new implementation uses the window layer/level to correctly identify the window to focus.

@zzzze @elanzini please help test.

elanzini commented 1 year ago

Fixed! See commit bbf4d9b. I hope all is good this time. πŸ˜…

This refactor removes the hacks I wrote previously. The new implementation uses the window layer/level to correctly identify the window to focus.

@zzzze @elanzini please help test.

I tested the focus on the text box and unfortunately still behaves like https://github.com/ahkohd/tauri-macos-spotlight-example/issues/4#issuecomment-1430880402

ahkohd commented 1 year ago

Fixed! See commit bbf4d9b. I hope all is good this time. πŸ˜… This refactor removes the hacks I wrote previously. The new implementation uses the window layer/level to correctly identify the window to focus. @zzzze @elanzini please help test.

I tested the focus on the text box and unfortunately still behaves like #4 (comment)

Is the app that you want to refocus in fullscreen? Can you try other windows? maybe probe a little bit. I can create a new branch for you where I can add debug logs to be able to understand where the issue lies.

zzzze commented 1 year ago

I think we overcomplicated things, this is just a workaround. I believe that changing "open" to "activate" on the original plan would already be good enough. For example, using the following function instead of open:

fn active_another_app(bundle_url: &str) -> Result<(), Error> {
    let workspace = unsafe {
        if let Some(workspace_class) = Class::get("NSWorkspace") {
            let shared_workspace: *mut Object = msg_send![workspace_class, sharedWorkspace];
            shared_workspace
        } else {
            return Err(Error::FailedToGetNSWorkspaceClass);
        }
    };
    let running_apps = unsafe {
        let running_apps: *mut Object = msg_send![workspace, runningApplications];
        running_apps
    };
    let target_app = unsafe {
        let count = msg_send![running_apps, count];
        if let Some(ns_object_class) = Class::get("NSObject") {
            let mut target_app = msg_send![ns_object_class, alloc];
            for i in 0..count {
                let app: *mut Object = msg_send![running_apps, objectAtIndex: i];
                let app_bundle_url: id = msg_send![app, bundleURL];
                let path: id = msg_send![app_bundle_url, path];
                let app_bundle_url_str = nsstring_to_string!(path);
                if let Some(app_bundle_url_str) = app_bundle_url_str {
                    if app_bundle_url_str == bundle_url.to_string() {
                        target_app = app;
                        break;
                    }
                }
            }
            target_app
        } else {
            return Err(Error::FailedToGetNSObjectClass);
        }
    };
    unsafe {
        let _: () = msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps];
    };
    Ok(())
}

The key statement here is msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps].

Additionally, I turned this solution into a plugin and it works well in my own project.

ahkohd commented 1 year ago

I think we overcomplicated things, this is just a workaround. I believe that changing "open" to "activate" on the original plan would already be good enough. For example, using the following function instead of open:

fn active_another_app(bundle_url: &str) -> Result<(), Error> {
    let workspace = unsafe {
        if let Some(workspace_class) = Class::get("NSWorkspace") {
            let shared_workspace: *mut Object = msg_send![workspace_class, sharedWorkspace];
            shared_workspace
        } else {
            return Err(Error::FailedToGetNSWorkspaceClass);
        }
    };
    let running_apps = unsafe {
        let running_apps: *mut Object = msg_send![workspace, runningApplications];
        running_apps
    };
    let target_app = unsafe {
        let count = msg_send![running_apps, count];
        if let Some(ns_object_class) = Class::get("NSObject") {
            let mut target_app = msg_send![ns_object_class, alloc];
            for i in 0..count {
                let app: *mut Object = msg_send![running_apps, objectAtIndex: i];
                let app_bundle_url: id = msg_send![app, bundleURL];
                let path: id = msg_send![app_bundle_url, path];
                let app_bundle_url_str = nsstring_to_string!(path);
                if let Some(app_bundle_url_str) = app_bundle_url_str {
                    if app_bundle_url_str == bundle_url.to_string() {
                        target_app = app;
                        break;
                    }
                }
            }
            target_app
        } else {
            return Err(Error::FailedToGetNSObjectClass);
        }
    };
    unsafe {
        let _: () = msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps];
    };
    Ok(())
}

The key statement here is msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps].

Additionally, I turned this solution into a plugin and it works well in my own project.

This method may not work as expected in specific scenarios, such as when you have multiple windows from the same app open and visible on the same screen or across multiple screens. The method may focus on the wrong window or activate the wrong screen in these cases.

To address this issue, the method leverages the accessibility API to activate the app based on its process ID and bring a specific window to the front based on its window ID. Using these unique identifiers, the method can ensure that the correct window is brought into focus regardless of the number of windows or screens involved.

zzzze commented 1 year ago

I think we overcomplicated things, this is just a workaround. I believe that changing "open" to "activate" on the original plan would already be good enough. For example, using the following function instead of open:

fn active_another_app(bundle_url: &str) -> Result<(), Error> {
    let workspace = unsafe {
        if let Some(workspace_class) = Class::get("NSWorkspace") {
            let shared_workspace: *mut Object = msg_send![workspace_class, sharedWorkspace];
            shared_workspace
        } else {
            return Err(Error::FailedToGetNSWorkspaceClass);
        }
    };
    let running_apps = unsafe {
        let running_apps: *mut Object = msg_send![workspace, runningApplications];
        running_apps
    };
    let target_app = unsafe {
        let count = msg_send![running_apps, count];
        if let Some(ns_object_class) = Class::get("NSObject") {
            let mut target_app = msg_send![ns_object_class, alloc];
            for i in 0..count {
                let app: *mut Object = msg_send![running_apps, objectAtIndex: i];
                let app_bundle_url: id = msg_send![app, bundleURL];
                let path: id = msg_send![app_bundle_url, path];
                let app_bundle_url_str = nsstring_to_string!(path);
                if let Some(app_bundle_url_str) = app_bundle_url_str {
                    if app_bundle_url_str == bundle_url.to_string() {
                        target_app = app;
                        break;
                    }
                }
            }
            target_app
        } else {
            return Err(Error::FailedToGetNSObjectClass);
        }
    };
    unsafe {
        let _: () = msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps];
    };
    Ok(())
}

The key statement here is msg_send![target_app, activateWithOptions: NSApplicationActivateIgnoringOtherApps]. Additionally, I turned this solution into a plugin and it works well in my own project.

This method may not work as expected in specific scenarios, such as when you have multiple windows from the same app open and visible on the same screen or across multiple screens. The method may focus on the wrong window or activate the wrong screen in these cases.

To address this issue, the method leverages the accessibility API to activate the app based on its process ID and bring a specific window to the front based on its window ID. Using these unique identifiers, the method can ensure that the correct window is brought into focus regardless of the number of windows or screens involved.

My latest commit, located at https://github.com/zzzze/tauri-plugin-spotlight/commit/3028d5971cd76f2f525a36122eec8786572f79e9, includes a trick to determine if the spotlight window is currently open after another window in the app is activated. If this condition is met and the spotlight window is subsequently hidden, window.set_focus is used to directly activate the previous window. However, if the previous window is located on a different screen, this action will result in focus being directed to that screen. Overall, I don't believe this should cause any issues.

ahkohd commented 1 year ago

Hey @zzzze, and @elanzini could you help me by testing the latest refactor I made on the debug branch? I'd appreciate a second pair of eyes to make sure it works on your end.

I have added debug logs so it will be easy to see what's happening.

Regarding @elanzini's issue, I believe it may be an edge case with a full-screen window. I suspect it's an issue with detecting a full-screen window in the previous implementation.

See this commit on the debug branch https://github.com/ahkohd/tauri-macos-spotlight-example/commit/20f58370f7a801218f1270420e4f8add7620f773.

Here is a screenshot of my debug logs:

image
zzzze commented 1 year ago

Hey @zzzze, and @elanzini could you help me by testing the latest refactor I made on the debug branch? I'd appreciate a second pair of eyes to make sure it works on your end.

I have added debug logs so it will be easy to see what's happening.

Regarding @elanzini's issue, I believe it may be an edge case with a full-screen window. I suspect it's an issue with detecting a full-screen window in the previous implementation.

Okay, I'll do some tests and get back to you later.

ahkohd commented 1 year ago

So @zzzze, you're right about one thing! I don't need the accessibility API anymore.

Since I have correctly identified the owner_id of the window, the final piece of the implementation:

let app: id = unsafe {
            msg_send![
                class!(NSRunningApplication),
                runningApplicationWithProcessIdentifier: owner_id
            ]
        };
        unsafe {
            let _: () = msg_send![
                app,
                activateWithOptions:
                    NSApplicationActivationOptions::NSApplicationActivateIgnoringOtherApps
            ];

        };

See commit https://github.com/ahkohd/tauri-macos-spotlight-example/commit/2cd3d09cc0d532f8cdc1466b16cca04a769d6135.

ahkohd commented 1 year ago

@elanzini pull the updates from debug and test. I'm 101% sure this time that this issue is fixed! I can bet my money on it 🀣

@zzzze, @elanzini, let me know if it works on your end so I can merge it to the main branch.

ahkohd commented 1 year ago

@zzzze, excellent plugin! I'm excited to contribute. I read the source code; I could not understand the hack you did (https://github.com/ahkohd/tauri-macos-spotlight-example/issues/4#issuecomment-1435975998); I guess I need more attention so that I can understand. I could not build the example app as well.

zzzze commented 1 year ago

@zzzze, excellent plugin! I'm excited to contribute. I read the source code; I could not understand the hack you did (#4 (comment)); I guess I need more attention so that I can understand. I could not build the example app as well.

The function (https://github.com/ahkohd/tauri-macos-spotlight-example/issues/4#issuecomment-1435975998) simply searches for the previously activated application from among all currently running apps using the app_url stored in the plugin's state. Once it locates the application, it activates it.

I have resolved the issue, and you should now be able to run the example.

ahkohd commented 1 year ago

@zzzze, excellent plugin! I'm excited to contribute. I read the source code; I could not understand the hack you did (#4 (comment)); I guess I need more attention so that I can understand. I could not build the example app as well.

The function (#4 (comment)) simply searches for the previously activated application from among all currently running apps using the app_url stored in the plugin's state. Once it locates the application, it activates it.

I have resolved the issue, and you should now be able to run the example.

I have tested the example app, and it works!

I understand this. But how do you solve for when the frontmost window changes while you're using the spotlight window?

Edit: It's unimportant because if the frontmost window changes, the spotlight window will auto-hide.

ahkohd commented 1 year ago

@zzzze Let me know when you're done with the plugin; there are some improvements I'll love to make.

ahkohd commented 1 year ago

@zzzze, @elanzini, I pushed a new update to the main branch.

@zzzze have a look at the implementation; Usingprocess id and NSRunningApplicationrunningApplicationWithProcessIdentifier: owner_id suffice. See commit 42e38cb.

Let me know if this works on your end so we can close this issue.

ahkohd commented 1 year ago

I've decided to maintain the window-level implementation in the experiment branch, as it has proven to be effective. It may also be useful for other applications.

zzzze commented 1 year ago

@elanzini pull the updates from debug and test. I'm 101% sure this time that this issue is fixed! I can bet my money on it 🀣

@zzzze, @elanzini, let me know if it works on your end so I can merge it to the main branch.

I have tested it and it runs very well on my end.

zzzze commented 1 year ago

@zzzze Let me know when you're done with the plugin; there are some improvements I'll love to make.

Now it's just version 0.1.0, and the API may still need some adjustments. Your code contributions are welcome.

elanzini commented 1 year ago

@elanzini pull the updates from debug and test. I'm 101% sure this time that this issue is fixed! I can bet my money on it 🀣

@zzzze, @elanzini, let me know if it works on your end so I can merge it to the main branch.

Tested from latest master at https://github.com/ahkohd/tauri-macos-spotlight-example/commit/430dcb018d1f964e74e7f449df9c5e3a72ca0009 and it works! Thanks so much for all the work

ahkohd commented 1 year ago

I'll close the issue then; thanks, everyone.

ahkohd commented 1 year ago

Hey @zzzze and @elanzini, I have some exciting news! After a lot of hard work and experimentation, I have finally managed to create a fully functional NSPanel from Tauri's NSWindow. This is a major breakthrough that will help us address the issue we've been facing, no more hacks! Please take a look at #6 for more details. It would be great to get your feedback on this new development.

elanzini commented 1 year ago

Checked out locally and tested. Works like a charm 😍

ahkohd commented 1 year ago

Checked out locally and tested. Works like a charm 😍

Great!