Set non-owned window always on top - Like the app

2020-06-12 02:24发布

问题:

I have set up a global hotkey with RegisterEventHotkey. When the user presses it, it gets the currently focused window with CGWindowListCopyWindowInfo, and then I need to set it always on top.

If the current window is in my process (from which I am executing the code) I can simply convert the windowNumber from CGWindowListCopyWindowInfo to a NSWindow and do setLevel:

nswin = [NSApp windowWithWindowNumber:windowNumber]
[nswin setLevel: Int(CGWindowLevelForKey(kCGFloatingWindowLevelKey))]

My Problem I am not able to do this if the currently focused window is not in my process. Can you please show me how?

Stuff I tried:

  • This app here named "Afloat" used "SIMBL" to accomplish this. From any window you can hit Cmd + A and it will set always on top. However I am trying to do with C/ObjC from my normal desktop app without the use of helpers like SIMBL.
    • Source: Force keeping app window on top - Mac OS X
    • SIMBL: http://www.culater.net/software/SIMBL/SIMBL.php
    • Afloat: https://www.macupdate.com/app/mac/22237/afloat
  • I come across CGSSetWindowLevel in CGPrivate.h - undocumented stuff - https://gist.github.com/Noitidart/3664c5c2059c9aa6779f#file-cgsprivate-h-L63 - However I recall I tried something like this in the past but would get an error as I tried to connect to a window that wasn't in the calling process.

    • It says here - https://github.com/lipidity/CLIMac/blob/114dfee39d24809f62ccb000ea22dfda15c11ce8/src/CGS/CGSInternal/.svn/text-base/CGSConnection.h.svn-base#L82

      Only the owner of a window can manipulate it. So, Apple has the concept of a universal owner that owns all windows and can manipulate them all. There can only be one universal owner at a time (the Dock).

    • Maybe, is there anyway to pretend for my calling process to temporarily be the dock? Maybe CGSGetConnectionIDForPSN for the dock then use that connection?

My use: I'm trying to replicate the functionality my open source, free, browser addon - https://addons.mozilla.org/en-US/firefox/addon/topick/ - so my calling process if Firefox. It works on Windows and Linux right now, and just need to figure out how to do it in mac for non-Firefox windows.

回答1:

It seems you want to make an external process's window stay on top of all other applications, while the code I provide here does not accomplish exactly what you are looking for, it is at least somewhat similar, and might be good enough for what you need, depending on your use case. In this example I demonstrate how to keep a CGWindowID on top of a specific NSWindow *. Note - the NSWindow * is the parent window, and it will need to be owned by your app, but the CGWindowID used for the child window can belong to any application). If you want the NSWindow * to be the child window, change the NSWindowBelow option to NSWindowAbove.

There is a tiny problem with this solution and that is some minor flickering here and there, when the parent window is attempting to gain focus but then loses it immediately - the flicker happens very quickly and intermittently, perhaps it can be overlooked if you are super desperate.

Anyway, the code is...

cocoa.mm

#import "subclass.h"
#import <Cocoa/Cocoa.h>
#import <sys/types.h>

NSWindow *cocoa_window_from_wid(CGWindowID wid) {
  return [NSApp windowWithWindowNumber:wid];
}

CGWindowID cocoa_wid_from_window(NSWindow *window) {
  return [window windowNumber];
}

bool cocoa_wid_exists(CGWindowID wid) {
  bool result = false;
  const CGWindowLevel kScreensaverWindowLevel = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
  CFArrayRef windowArray = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
  CFIndex windowCount = 0;
  if ((windowCount = CFArrayGetCount(windowArray))) {
    for (CFIndex i = 0; i < windowCount; i++) {
      NSDictionary *windowInfoDictionary =
      (__bridge NSDictionary *)((CFDictionaryRef)CFArrayGetValueAtIndex(windowArray, i));
      NSNumber *ownerPID = (NSNumber *)(windowInfoDictionary[(id)kCGWindowOwnerPID]);
      NSNumber *level = (NSNumber *)(windowInfoDictionary[(id)kCGWindowLayer]);
      if (level.integerValue < kScreensaverWindowLevel) {
        NSNumber *windowID = windowInfoDictionary[(id)kCGWindowNumber];
        if (wid == windowID.integerValue) {
          result = true;
          break;
        }
      }
    }
  }
  CFRelease(windowArray);
  return result;
}

pid_t cocoa_pid_from_wid(CGWindowID wid) {
  pid_t pid;
  const CGWindowLevel kScreensaverWindowLevel = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
  CFArrayRef windowArray = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
  CFIndex windowCount = 0;
  if ((windowCount = CFArrayGetCount(windowArray))) {
    for (CFIndex i = 0; i < windowCount; i++) {
      NSDictionary *windowInfoDictionary =
      (__bridge NSDictionary *)((CFDictionaryRef)CFArrayGetValueAtIndex(windowArray, i));
      NSNumber *ownerPID = (NSNumber *)(windowInfoDictionary[(id)kCGWindowOwnerPID]);
      NSNumber *level = (NSNumber *)(windowInfoDictionary[(id)kCGWindowLayer]);
      if (level.integerValue < kScreensaverWindowLevel) {
        NSNumber *windowID = windowInfoDictionary[(id)kCGWindowNumber];
        if (wid == windowID.integerValue) {
          pid = ownerPID.integerValue;
          break;
        }
      }
    }
  }
  CFRelease(windowArray);
  return pid;
}

unsigned long cocoa_get_wid_or_pid(bool wid) {
  unsigned long result;
  const CGWindowLevel kScreensaverWindowLevel = CGWindowLevelForKey(kCGScreenSaverWindowLevelKey);
  CFArrayRef windowArray = CGWindowListCopyWindowInfo(kCGWindowListOptionAll, kCGNullWindowID);
  CFIndex windowCount = 0;
  if ((windowCount = CFArrayGetCount(windowArray))) {
    for (CFIndex i = 0; i < windowCount; i++) {
      NSDictionary *windowInfoDictionary =
      (__bridge NSDictionary *)((CFDictionaryRef)CFArrayGetValueAtIndex(windowArray, i));
      NSNumber *ownerPID = (NSNumber *)(windowInfoDictionary[(id)kCGWindowOwnerPID]);
      NSNumber *level = (NSNumber *)(windowInfoDictionary[(id)kCGWindowLayer]);
      if (level.integerValue == 0) {
        NSNumber *windowID = windowInfoDictionary[(id)kCGWindowNumber];
        result = wid ? windowID.integerValue : ownerPID.integerValue;
        break;
      }
    }
  }
  CFRelease(windowArray);
  return result;
}

void cocoa_wid_to_top(CGWindowID wid) {
  CFIndex appCount = [[[NSWorkspace sharedWorkspace] runningApplications] count];
  for (CFIndex i = 0; i < appCount; i++) {
    NSWorkspace *sharedWS = [NSWorkspace sharedWorkspace];
    NSArray *runningApps = [sharedWS runningApplications];
    NSRunningApplication *currentApp = [runningApps objectAtIndex:i];
    if (cocoa_pid_from_wid(wid) == [currentApp processIdentifier]) {
      NSRunningApplication *appWithPID = currentApp;
      NSUInteger options = NSApplicationActivateAllWindows;
      options |= NSApplicationActivateIgnoringOtherApps;
      [appWithPID activateWithOptions:options];
      break;
    }
  }
}

void cocoa_wid_set_pwid(CGWindowID wid, CGWindowID pwid) {
  [cocoa_window_from_wid(pwid) setChildWindowWithNumber:wid];
}

subclass.mm

#import "subclass.h"
#import <Cocoa/Cocoa.h>

CGWindowID cocoa_wid = kCGNullWindowID;
CGWindowID cocoa_pwid = kCGNullWindowID;

@implementation NSWindow(subclass)

- (void)setChildWindowWithNumber:(CGWindowID)wid {
  [[NSNotificationCenter defaultCenter] addObserver:self
    selector:@selector(windowDidBecomeKey:)
    name:NSWindowDidUpdateNotification object:self];
  cocoa_pwid = [self windowNumber]; cocoa_wid = wid;
  [self orderWindow:NSWindowBelow relativeTo:wid];
}

- (void)windowDidBecomeKey:(NSNotification *)notification {
  if (cocoa_wid_exists(cocoa_wid)) {
    [self setCanHide:NO];
    [self orderWindow:NSWindowBelow relativeTo:cocoa_wid];
  } else {
    cocoa_wid = kCGNullWindowID;
    [self setCanHide:YES];
  }
}

@end

subclass.h

#import <Cocoa/Cocoa.h>

bool cocoa_wid_exists(CGWindowID wid);

@interface NSWindow(subclass)

- (void)setChildWindowWithNumber:(CGWindowID)wid;
- (void)windowDidBecomeKey:(NSNotification *)notification;

@end

I went an extra mile and added some functions to help you retrieve the appropriate CGWindowID based on the frontmost CGWindowID, and if you know the correct CGWindowID beforehand, via AppleScript, or however you prefer, you may bring it to the front using cocoa_wid_to_top(wid), (if the user permits), however this doesn't play well with processes owning multiple visible windows simultaneously, because it brings all windows owned by the process id associated with the given CGWindowID to the top, so you might not have the CGWindowID you wanted to be on the absolute top of the window stack necessarily. The reason you may want the window to be brought on top of the stack is due to the fact there are cases in which a window may open that you would want to make a child window but it appeared on screen underneath your parent window, thus forcing you to click it before the parent/child relationship of windows can effectively take place.

Documentation below...

NSWindow *cocoa_window_from_wid(CGWindowID wid); Returns an NSWindow * from a given CGWindowID, provided the CGWindowID belongs to the current app, otherwise an invalid CGWindowID is returned, which can be represented with the constant kCGNullWindowID.

CGWindowID cocoa_wid_from_window(NSWindow *window); Returns a CGWindowID from a given NSWindow *, provided the NSWindow * belongs to the current app, otherwise I believe you will get a segfault. That's what happens in my testing when you know the value of an NSWindow * and attempt to use it in an app that it doesn't belong to, so don't even try.

bool cocoa_wid_exists(CGWindowID wid); Returns true if the a window based on a specified CGWindowID exists, excluding your screensaver and desktop elements, false if it doesn't.

pid_t cocoa_pid_from_wid(CGWindowID wid); A helper function for cocoa_wid_to_top(wid) which returns the process id, (or pid_t), associated with the given CGWindowID.

unsigned long cocoa_get_wid_or_pid(bool wid); Returns the frontmost CGWindowID if wid is true, otherwise the frontmost process id, (or pid_t), is the result. Note the return type unsigned long can be safely casted to and from a CGWindowID or pid_t as needed.

void cocoa_wid_to_top(CGWindowID wid); Attempts to bring all windows that belong to the process id, (or pid_t), associated with the given CGWindowID to be the topmost app.

Now for the most important function...

void cocoa_wid_set_pwid(CGWindowID wid, CGWindowID pwid); Assigns a parent window based on a specified CGWindowID to the given child window associated with the proper CGWindowID. The parent window id, (or pwid), must be owned by the current app, while the child window id, (or wid), may belong to any application, excluding the screensaver and desktop elements. If the parent or child window ceases to exist, they lose their parent and child relationship to avoid recycled CGWindowID's from inheriting the relationship. If the parent or child CGWindowID doesn't exist, they will be set to kCGNullWindowID, which reliably ends the relationship.

Note this code has been tested in Catalina and indeed works as advertised at the time of writing.

To use the cocoa functions I provided in your C or C++ code you may do this in a header:

typedef void NSWindow;
typedef unsigned long CGWindowID;

extern "C" NSWindow *cocoa_window_from_wid(CGWindowID wid);
extern "C" CGWindowID cocoa_wid_from_window(NSWindow *window);
extern "C" bool cocoa_wid_exists(CGWindowID wid);
extern "C" pid_t cocoa_pid_from_wid(CGWindowID wid);
extern "C" unsigned long cocoa_get_wid_or_pid(bool wid);
extern "C" void cocoa_wid_to_top(CGWindowID wid);
extern "C" void cocoa_wid_set_pwid(CGWindowID wid, CGWindowID pwid);