alt-tab-macos icon indicating copy to clipboard operation
alt-tab-macos copied to clipboard

TotalFinder and XtraFinder show 2 windows for Finder

Open kristofferR opened this issue 4 years ago • 24 comments

Something about how TotalFinder works makes its windows appear twice in Alt-Tab: folder

kristofferR avatar Nov 12 '19 11:11 kristofferR

Looks like TotalFinder is doing tricks with Cocoa. If an app doesn't use proper semantics with the components they use, like using 2 NSWindow instead of 1 to do virtual tabs or something, then there is little we could do here.

I can imagine this app is quite strange behaving. How does minimizing it, hiding it, dragging it around (any strange artifacts there?), etc behave?

I tried to install it but apparently it needs to alter the kernel to function, and I can't do that on my laptop currently:

image

lwouis avatar Nov 13 '19 00:11 lwouis

@kristofferR could you try to reproduce on the newly released v2? It may have fixed the issue. Again, I can't reproduce myself :/

lwouis avatar Dec 27 '19 07:12 lwouis

@kristofferR could you try to reproduce on the newly released v2? It may have fixed the issue. Again, I can't reproduce myself :/

No change unfortunately

I can imagine this app is quite strange behaving. How does minimizing it, hiding it, dragging it around (any strange artifacts there?), etc behave?

It works just like all other apps, it used to have bugs a few years ago though. Resizing the window isn't as smooth as most other windows though.

kristofferR avatar Dec 30 '19 02:12 kristofferR

I just merged #105 which addressed #102. Given that this fix is about distinguishing "actual" windows better, I thought you may want to try to see if that release fixes the TotalFinder issue.

@kristofferR Could you please try out the new release 2.0.3 and let me know if that fixed it for you?

lwouis avatar Jan 03 '20 10:01 lwouis

The same thing happens with XtraFinder, even with AltTab version 2.2.0. But you'll see the same problem with HyperDock and other software that shows window previews in Alt-Tab, or with the feature to show window previews by hovering the mouse over the Finder icon in the Dock. I contacted the dev of XtraFinder over this but didn't get any response.

daitarn avatar Jan 04 '20 10:01 daitarn

Unfortunately I can't test XtraFinder or TotalFinder for myself because I can't disable SIP on my macbook.

It seems to me that if every software out there has this issue with these 2 apps, then we could consider that it's a bug of these apps, not window management apps such as alt-tab-macos.

What do you guys think? Is there any window manager out there dealing correctly with these Finder replacement apps?

lwouis avatar Jan 04 '20 10:01 lwouis

Well, they display just as normal in Expose/Mission Control.

kristofferR avatar Jan 07 '20 08:01 kristofferR

Well, they display just as normal in Expose/Mission Control.

AltTab is using the Accessibility APIs to manipulate windows. If this app is doing advanced hacky techniques, it will be hard to deal with. For example, i can imagine a screen reader for blind people would also not behave properly on such an app. That's the context. It's hacky so there are side-effects. You could argue that they should not have a double window hack and just play nicely, or you could argue that somehow other apps all have to build workarounds to handle these cases.

Now to move the discussion towards a potential workaround: can anyone investigate a bit how we could implement a fix? I can't do anything on my machine. Can someone run the app and add some logs of some AX values like .role, .subrole, or some other values that could help us remove the hacky fake window from our thumbnails?

lwouis avatar Jan 07 '20 09:01 lwouis

@lwouis

"Real" windows on macOS should report a kAXWindowRole of AXWindow, and kAXWindowSubrole of AXStandardWindow. In your case there might be other roles that should be considered valid windows, such as AXDialog etc. See the links below.

Window roles: https://developer.apple.com/documentation/applicationservices/carbon_accessibility/roles?language=objc

Window subroles: https://developer.apple.com/documentation/applicationservices/carbon_accessibility/subroles?language=objc

This is how I do this in yabai: https://github.com/koekeishiya/yabai/blob/master/src/window.c#L352

Of course this has the inverse issue, where applications that report the wrong role by mistake is not classified correctly as a real window when it should be, and I have a system in place that will allow the user to specify these using various filters. However, it is important to note that this is then an issue that the developer(s) of said application should consider a bug in their application.

Edit: There are also private APIs to detect if a window has a parent-window, which I assume would be the case for this particular TotalFinder window, by looking at the screenshot in the first post.

koekeishiya avatar Jan 08 '20 16:01 koekeishiya

@koekeishiya thanks for sharing! I actually arrived at the same conclusions by experimenting a lot. Here is the code I found the best so far when trying to distinguish an actual window from other UI elements of OS garbage. I tried to document in the comments so observations. In v2, I relied also on this check on the CG API layer property. In v3 I don't use a CG API anymore and rely fully on AX APIs, but it's still interesting to keep in mind. See also this interesting investigation I did to distinguish "infrastructure daemons" from background processes that could spawn windows that I should observe. In the end I realized I have to observe everything cause in the OS jungle, anything can spawn a window. It could be spotlight or a new iMessage daemon poping an OTP popup, who knows. Still interesting to see that there are facilities that are supposed to help here, but because they are not enforced, many apps including first-party apps don't respect them

lwouis avatar Jan 08 '20 17:01 lwouis

@kristofferR @daitarn could you possibly test the v3 PR #114 I just opened? I'm curious if the TotalFinder and XtraFinder issues still happen in this new approach.

lwouis avatar Jan 08 '20 17:01 lwouis

Still double windows showing with XtraFinder in v2.3.2, one with icons and one empty, same as before..

daitarn avatar Jan 08 '20 19:01 daitarn

@kristofferR @daitarn I release v3 today. It contains a huge number of improvements. It may have fixed this issue. Feel free to please try it out :)

lwouis avatar Mar 10 '20 13:03 lwouis

Ok, tried it and with XtraFinder 1.5.1 (not the latest), there is still a "ghost" window, while on latest PathFinder 9.0.8 all it's OK now.

But there is a big difference between the two, XtraFinder works kinda as a Finder plugin, while Path Finder is a "classic" app that replaces Finder. Now if only Path Finder wasn't so sluggish and have same bugs for years...

daitarn avatar Mar 10 '20 18:03 daitarn

@kristofferR is the issue still happening for you on TotalFinder?

@daitarn @kristofferR I just sent the following email to both companies:

I'm the main contributor of the AltTab app. Some users have reported an issue where [TotalFinder/XtraFinder] appears as 2 windows in AtlTab. I'm guessing that XtraFinder plays tricks with NSWindows in order to work.

Could you please check this issue for more details and screenshots of the symptoms?

Hopefully they give us some feedback. As for me, I still have no machine on which I can disable SIP to play around with these apps, which means either someone steps in and looks at this, or these companies fix their hacks, or it stays broken. I know it's not the best situation, but I can't help much here without being able to run these apps locally

lwouis avatar Jun 09 '20 10:06 lwouis

Hi, I'm the TotalFinder dev.

@lwouis TotalFinder composes own windows on top of Finder's own window using the same mechanism as "window sheets" internally work. In more complex situation when TotalFinder is switched to "dual mode" it adds two child windows on top of one Finder window. It uses addChildWindow Cocoa API[1] for that. Cocoa offers other related methods on NSWindow: parent, childWindows and removeChildWindow.

This parent-child window hierarchy in Cocoa API is implemented on lower level via window server's "window movement groups".

Since you are not in-process and from the sources I saw you are not afraid to do some functionality using undocumented CG APIs - you could be interested in this:

Private APIs for manipulating movement groups in window server:

extern CGError CGSAddWindowToWindowMovementGroup(const CGSConnection cid, CGSWindow wid, CGSWindowMovementGroup group);
extern CGError CGSRemoveWindowFromWindowMovementGroup(const CGSConnection cid, 
CGSWindow wid, CGSWindowMovementGroup group);

// and maybe some others, I think CGSGetParentWindowList looks like interesting method

So from what I saw in your code you need to modify two things:

  1. in user selection do not show windows which have some parent window (CGSGetParentWindowList should give you answer wheter particular window is a child window or not)
  2. when taking a screenshot of a window which has some child windows you probably have to fall back to CGWindowListCreateImage. I know that may be slower than your current method but this method should give you a composite screenshot of the window and all its child windows recursively. (I guess you don't want to implement this functionality by hand, I mean by taking screenshots of all windows in the parent-child hierarchy, consulting CG private APIs for their relative positions and compositing them by hand).

Good luck!


If CGSGetParentWindowList is not helpful. I looked at the disassembly of AppKit framework and they are doing something like this. So in theory you should be able to use CGSCopyWindowGroup with magic "movementGroup" to detect parent-child relationships. var_38 appears to be a plain C array of CG window IDs (don't forget to free it) and var_2C appears to be the count.

int __NSCGSWindowGetMovementChildrenFromServer(int arg0) {
    r14 = CGSMainConnectionID(arg0);
    var_2C = 0x0;
    var_38 = 0x0;
    rax = [arg0 windowID];
    rax = CGSCopyWindowGroup(r14, rax, @"movementGroup", &var_38, &var_2C);
    if (var_38 != 0x0) {
            r15 = [NSMutableArray arrayWithCapacity:sign_extend_64(var_2C)];
            if (var_2C != 0x0) {
                    rbx = 0x0;
                    do {
                            rax = [NSCGSWindow windowWithWindowID:*(int32_t *)(var_38 + rbx * 0x4)];
                            if (rax != 0x0) {
                                    [r15 addObject:rax];
                            }
                            rbx = rbx + 0x1;
                    } while (rbx < var_2C);
            }
            free(var_38);
    }
    else {
            r15 = *___NSArray0__struct;
    }
    rax = r15;
    return rax;
}

[1] https://developer.apple.com/documentation/appkit/nswindow/1419152-addchildwindow

darwin avatar Jun 09 '20 20:06 darwin

Hi @darwin! Thank you so much for such a quick and insightful reply!

I wanted to reply to you, and I thought that it would be easier to illustrate via showing my screen, rather than explaining with text and screenshots, so here is a screen recording.

Could you please check it out and let me know your thoughts?

Thanks!

lwouis avatar Jun 10 '20 01:06 lwouis

Thanks for the effort in making a test app and the video.

You are correct. I lay original Finder window on top of background "framing" TotalFinder window to achieve tabbed interface.

Please look at this more explanatory screenshot: https://box.binaryage.com/totalfinder-alttab-issue.png

In AltTab UI you see two Finder windows without frames. And one TotalFinder window with tabs, window framing and gray background. The composite can be seen below.

In this case TotalFinder is in dual mode. It has one parent window which renders tab and two "glued on" Finder windows on the left and right side (with removed borders). Finder is unaware of this, it still thinks it has separate windows, but TotalFinder does this trick to turn them into child windows and compose them over its own parent window. Finder windows are hacked to ignore dragging and instead redirect dragging to the parent window.

I agree with your point that in general maybe you want to treat child windows as separate windows and make special case for TotalFinder and XtraFinder only.

What about this? a) if window belongs to Finder process and window has some child windows and window has no parent window => do not display it in AltTab UI

This would work pretty well even in dual mode. AltTab UI would display two child Finder windows without frames and when selected TotalFinder would get activated and focus would properly go to the left or right side depending on which Finder window was selected.

The tricky part is to determine if a window is parent window / has child windows. But you have the test app where you can figure this out. I'm not fluent in Swift so I cannot really help with the code at this point, but I can definitely test it here on my machine when you have something working with your test app.

darwin avatar Jun 10 '20 13:06 darwin

I haven't tested the other functions mentioned above, but I do the following to determine the parent window id of a range of windows:

//
// extern declarations
//

extern CFTypeRef SLSWindowQueryWindows(int cid, CFArrayRef windows, int count);
extern CFTypeRef SLSWindowQueryResultCopyWindows(CFTypeRef window_query);
extern CGError SLSWindowIteratorAdvance(CFTypeRef iterator);
extern uint32_t SLSWindowIteratorGetParentID(CFTypeRef iterator);
extern uint32_t SLSWindowIteratorGetWindowID(CFTypeRef iterator);

//
// actually do stuff
//

// Get this from somewhere, or simply 1 if you only lookup a single window
int count = ..;
   
// Get this from somewhere, or simply &window_id if you only lookup a single window
uint32_t *window_list = .. 
    
// Takes a C-array and a count, and turns them into a 
// CFArrayRef of CFNumberRefs of the correct integer type.
CFArrayRef window_list_ref = cfarray_of_cfnumbers(window_list, sizeof(uint32_t), count, kCFNumberSInt32Type);

// Prepare window query
CFTypeRef query = SLSWindowQueryWindows(g_connection, window_list_ref, count);

// Execute query
CFTypeRef iterator = SLSWindowQueryResultCopyWindows(query);

// Traverse results of query operation
while (SLSWindowIteratorAdvance(iterator)) {
    uint32_t parent_wid = SLSWindowIteratorGetParentID(iterator);

    // This is just the id of the actual window; you already 
    // had this originally, but now you can at least know which
    // id has which parent. This is obviously not necessary if your 
    // initial query only included a single window in the first place
    uint32_t wid = SLSWindowIteratorGetWindowID(iterator);
}

// free stuff
CFRelease(query);
CFRelease(iterator);
CFRelease(window_list_ref);

koekeishiya avatar Jun 10 '20 14:06 koekeishiya

@darwin @lwouis I apologize for posting something out of topic in regards to this issue, but I'd just like to thank you both for this discussion as the mention of the window groups in the WindowServer has allowed me to solve a long and very painful issue I had with allowing outlined window borders in yabai (and borders were removed for this reason), and I think I am now able to add that feature back in a much more elegant revision. Thanks for sharing valuable information that I otherwise probably would not have dug into.

koekeishiya avatar Jun 10 '20 16:06 koekeishiya

@darwin

I've decompiled HyperSwitch, and noticed this function:

/* @class OCWindow */
-(char)isTotalFinderWindow {
    r13 = self;
    if ([[self ownerName] isEqualToString:@"Finder"] != 0x0) {
            var_2C = 0x0;
            r15 = calloc(0x14, 0x4);
            qword_10017e8e0((*qword_10017e860)(), [r13 cgWindowID], 0x3, r15, &var_2C, 0x0);
            rbx = var_2C == 0x2 ? 0x1 : 0x0;
            free(r15);
    }
    else {
            rbx = 0x0;
    }
    rax = rbx & 0xff;
    return rax;
}

I'm not an expert at reading decompiled code, but my understand here is that they have a function to avoid showing the extra TotalFinder window. It seems they check that the window owner is "Finder". Then they check some attribute which I'm not sure of, and based on that they exclude the window. That seems like a very easy way to exclude TotalFinder from the switcher.

Do you know which value they are checking?

I haven't been able to close this issue because I can't remove SIP on my machine so I can play around and debug with TotalFinder active. I need help from someone to find how to discriminate its windows. It seems simple, if HyperSwitch is doing properly.

lwouis avatar May 22 '22 16:05 lwouis

@lwouis not sure what that qword_10017e8e0 does. But I would guess they might be testing if a given window has any child windows.

darwin avatar May 22 '22 21:05 darwin

qword_10017e8e0 is CGSGetWorkspaceWindowGroup.

HyperSwitch loads functions at startup using dlsym and has for some reason encoded the string names of the private APIs that they use.

Here is a simple C program that decodes these:

#include <stdio.h>

static char function_name[] = "CHUJiy]vzt}{mpsfy\x7Fv\x82\x8B\\\x88\x86\x8D\x89";

static void decode_function_name(void)
{
    for (int i = 0; i < 26; ++i) {
        char v = function_name[i] - i;
        fputc(v, stdout);
    }
    fflush(stdout);
}

int main(int argc, char **argv)
{
    decode_function_name();
    return 0;
}

Here is the disassembly version in HyperSwitch that does the decode: Screenshot 2022-05-24 at 14 43 37

koekeishiya avatar May 24 '22 12:05 koekeishiya

The signature is: CGError CGSGetWorkspaceWindowGroup(int cid, uint32_t wid, int window_count, uint32_t *window_list, int *actual_window_count); and it works fine on macOS Monterey 12.4. It appears to return all windows that belong to the same ordering group as the window that is given as the input parameter. The function appears to return the ids across spaces, as long as they are in the same group.

window_list needs to point to enough memory to hold the amount of windows that you ask for with window_count, which is sizeof(uint32_t) * window_count. However, it is possible that there are either fewer or more windows than you asked for, which macOS will return in actual_window_count.

There is an equivalent version in SkyLight called SLSGetWorkspaceWindowGroup. From what I can tell it returns the same thing as: CFArrayRef SLSCopyAssociatedWindows(int cid, uint32_t wid);

Sample:

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>

extern int CGSMainConnectionID(void);
extern int CGSGetWorkspaceWindowGroup(int cid, uint32_t wid, int window_count, uint32_t *window_list, int *actual_window_count);

int main(int argc, char **argv)
{
    int cid = CGSMainConnectionID();
    uint32_t wid = 2860;
    uint32_t *window_list = calloc(10, sizeof(uint32_t));
    int window_count = 10;
    int actual_window_count = 0;

    int error = CGSGetWorkspaceWindowGroup(cid, wid, window_count, window_list, &actual_window_count);
    if (error == 0) {
        printf("actual_window_count: %d\n", actual_window_count);
        for (int i = 0; i < actual_window_count; ++i) {
            printf("%d: %d\n", i, window_list[i]);
        }
    } else {
        printf("FAIL: %d\n", error);
    }

    return 0;
}

Build:

clang sample.c -o sample -framework CoreGraphics

koekeishiya avatar May 24 '22 16:05 koekeishiya