Blob Blame History Raw
/*
    Copyright (c) 2014, Lukas Holecek <hluk@email.cz>

    This file is part of CopyQ.

    CopyQ is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CopyQ is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CopyQ.  If not, see <http://www.gnu.org/licenses/>.
*/

#include "macplatformwindow.h"

#include "common/log.h"

#include <AppKit/NSGraphics.h>
#include <Cocoa/Cocoa.h>
#include <Carbon/Carbon.h>
#include <dispatch/dispatch.h>

#include <QApplication>
#include <QSet>

namespace {
    template<typename T> inline T* objc_cast(id from)
    {
        if ([from isKindOfClass:[T class]]) {
            return static_cast<T*>(from);
        }
        return nil;
    }

    void sendShortcut(int modifier, int key) {
        CGEventSourceRef sourceRef = CGEventSourceCreate(
            kCGEventSourceStateCombinedSessionState);

        CGEventRef commandDown = CGEventCreateKeyboardEvent(sourceRef, modifier, YES);
        CGEventRef VDown = CGEventCreateKeyboardEvent(sourceRef, key, YES);

        CGEventRef VUp = CGEventCreateKeyboardEvent(sourceRef, key, NO);
        CGEventRef commandUp = CGEventCreateKeyboardEvent(sourceRef, modifier, NO);

        // 0x000008 is a hack to fix pasting in Emacs?
        // https://github.com/TermiT/Flycut/pull/18
        CGEventSetFlags(VDown,CGEventFlags(kCGEventFlagMaskCommand|0x000008));
        CGEventSetFlags(VUp,CGEventFlags(kCGEventFlagMaskCommand|0x000008));

        CGEventPost(kCGHIDEventTap, commandDown);
        CGEventPost(kCGHIDEventTap, VDown);
        CGEventPost(kCGHIDEventTap, VUp);
        CGEventPost(kCGHIDEventTap, commandUp);

        CFRelease(commandDown);
        CFRelease(VDown);
        CFRelease(VUp);
        CFRelease(commandUp);
        CFRelease(sourceRef);
    }

    /**
     * Delays a sending Command+key operation for delayInMS, and retries up to 'tries' times.
     *
     * This function is necessary in order to check that the intended window has come
     * to the foreground. The "isActive" property only changes when the app gets back
     * to the "run loop", so we can't block and check it.
     */
    void delayedSendShortcut(int modifier, int key, int64_t delayInMS, uint tries, NSWindow *window) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delayInMS * NSEC_PER_MSEC), dispatch_get_main_queue(), ^(void){
            if (window && ![window isKeyWindow]) {
                if (tries > 0) {
                    delayedSendShortcut(modifier, key, delayInMS, tries - 1, window);
                } else {
                    log("Failed to raise application, will not paste.", LogWarning);
                }
            } else {
                sendShortcut(modifier, key);
            }
        });
    }

    pid_t getPidForWid(WId find_wid) {
        // Build a set of "normal" windows. This is necessary as "NSWindowList" gets things like the
        // menubar (which can be "owned" by various apps).
        NSArray *array = (__bridge NSArray*) CGWindowListCopyWindowInfo(kCGWindowListOptionAll | kCGWindowListExcludeDesktopElements, kCGNullWindowID);
        for (NSDictionary* dict in array) {
            long int pid = [(NSNumber*)[dict objectForKey:@"kCGWindowOwnerPID"] longValue];
            unsigned long int wid = (unsigned long) [(NSNumber*)[dict objectForKey:@"kCGWindowNumber"] longValue];

            if (wid == find_wid) {
                return pid;
            }
        }

        return 0;
    }


    long int getTopWindow(pid_t process_pid) {
        // Build a set of "normal" windows. This is necessary as "NSWindowList" gets things like the
        // menubar (which can be "owned" by various apps).
        QSet<long int> widsForProcess;
        NSArray *array = (__bridge NSArray*) CGWindowListCopyWindowInfo(kCGWindowListOptionAll | kCGWindowListExcludeDesktopElements, kCGNullWindowID);
        for (NSDictionary* dict in array) {
            long int pid = [(NSNumber*)[dict objectForKey:@"kCGWindowOwnerPID"] longValue];
            long int wid = [(NSNumber*)[dict objectForKey:@"kCGWindowNumber"] longValue];
            long int layer = [(NSNumber*)[dict objectForKey:@"kCGWindowLayer"] longValue];

            if (pid == process_pid && layer == 0) {
                widsForProcess.insert(wid);
            }
        }

        // Now look through the windows in NSWindowList (which are ordered from front to back)
        // the first window in this list which is also in widsForProcess is our frontmost "normal" window
        long int wid = -1;
        for (NSNumber *window : [NSWindow windowNumbersWithOptions:0]) {
            const long int wid2 = [window longValue];
            if (widsForProcess.contains(wid2)) {
                wid = wid2;
                break;
            }
        }
        return wid;
    }

    QString getTitleFromWid(long int wid) {
        QString title;

        if (wid < 0) {
            return title;
        }

        uint32_t windowid[1] = {static_cast<uint32_t>(wid)};
        CFArrayRef windowArray = CFArrayCreate ( NULL, (const void **)windowid, 1 ,NULL);
        NSArray *array = (__bridge NSArray*) CGWindowListCreateDescriptionFromArray(windowArray);

        // Should only be one
        for (NSDictionary* dict in array) {
            title = QString::fromNSString([dict objectForKey:@"kCGWindowName"]);
        }
        CFRelease(windowArray);

        return title;
    }
} // namespace

MacPlatformWindow::MacPlatformWindow(NSRunningApplication *runningApp):
    m_windowNumber(-1)
    , m_window(0)
    , m_runningApplication(0)
{
    if (runningApp) {
        m_runningApplication = runningApp;
        [runningApp retain];
        m_windowNumber = getTopWindow(runningApp.processIdentifier);
        COPYQ_LOG("Created platform window for non-copyq");
    } else {
        log("Failed to convert runningApplication to application", LogWarning);
    }
}

MacPlatformWindow::MacPlatformWindow(WId wid):
    m_windowNumber(-1)
    , m_window(0)
    , m_runningApplication(0)
{
    // Try using wid as an actual window ID
    pid_t pid = getPidForWid(wid);
    if (pid != 0) {
        m_runningApplication = [NSRunningApplication runningApplicationWithProcessIdentifier:pid];
        m_windowNumber = wid;
        // This will return 'nil' unless this process owns the window
        m_window = [NSApp windowWithWindowNumber: wid];
    } else if (NSView *view = objc_cast<NSView>((id)wid)) {
        // If given a view, its ours
        m_runningApplication = [NSRunningApplication currentApplication];
        m_window = [view window];
        [m_runningApplication retain];
        [m_window retain];
        m_windowNumber = [m_window windowNumber];
        COPYQ_LOG("Created platform window for copyq");
    } else {
        log("Failed to convert WId to window", LogWarning);
    }
}

MacPlatformWindow::MacPlatformWindow():
    m_windowNumber(-1)
    , m_window(0)
    , m_runningApplication(0)
{
}

MacPlatformWindow::~MacPlatformWindow() {
    // Releasing '0' or 'nil' is fine
    [m_runningApplication release];
    [m_window release];
}

QString MacPlatformWindow::getTitle()
{
    QString appTitle;
    if (m_runningApplication)
        appTitle = QString::fromNSString([m_runningApplication localizedName]);

    QString windowTitle;
    if (m_window)
        windowTitle = QString::fromNSString([m_window title]);
    if (windowTitle.isEmpty() && m_windowNumber >= 0)
        windowTitle = getTitleFromWid(m_windowNumber);

    // We have two separate titles, the application title (shown at the top
    // left in the menu bar and the window title (shown in the window bar).
    // Some apps put the app name into the window title as well, some don't. We
    // want to be able to match on the app name (e.g. Firefox, Safari, or
    // Terminal) when writing commands, so we'll ensure that the app name is at
    // the beginning of the returned window title.
    QString result;
    if (windowTitle.isEmpty()) {
        result = appTitle;
    } else if (appTitle.isEmpty() || windowTitle.startsWith(appTitle)) {
        result = windowTitle;
    } else {
        result = QString("%1 - %2").arg(appTitle, windowTitle);
    }
    return result;
}

void MacPlatformWindow::raise()
{
    if (m_window && m_runningApplication &&
            [m_runningApplication isEqual:[NSRunningApplication currentApplication]]) {
        COPYQ_LOG(QString("Raise CopyQ"));
        [NSApp activateIgnoringOtherApps:YES];
        [m_window makeKeyAndOrderFront:nil];
    } else if (m_runningApplication) {
        // Shouldn't need to unhide since we should have just been in this
        // application..
        //[m_runningApplication unhide];

        COPYQ_LOG(QString("Raise running app."));
        [m_runningApplication activateWithOptions:NSApplicationActivateIgnoringOtherApps];
    } else {
        ::log(QString("Tried to raise unknown window."), LogWarning);
    }
}

void MacPlatformWindow::pasteClipboard()
{
    if (!m_runningApplication) {
        log("Failed to paste to unknown window", LogWarning);
        return;
    }

    // Window MUST be raised, otherwise we can't send events to it
    raise();

    // Paste after after 100ms, try 5 times
    delayedSendShortcut(kVK_Command, kVK_ANSI_V, 100, 5, m_window);
}

void MacPlatformWindow::copy()
{
    if (!m_runningApplication) {
        log("Failed to copy from unknown window", LogWarning);
        return;
    }

    // Window MUST be raised, otherwise we can't send events to it
    raise();

    // Copy after after 100ms, try 5 times
    delayedSendShortcut(kVK_Command, kVK_ANSI_C, 100, 5, m_window);
}