From fc280dfd8cc0bfef3e29981badc9926b44234b0c Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Sat, 14 Dec 2019 00:01:43 +1100 Subject: [PATCH 1/7] Partially implement Mac OS Window class using code from Aenea This implements the following: - get_foreground() class method - get_position() method - class_name property via the _get_class_name() method - executable property via the _get_window_module() method - title property via the _get_window_text() method --- dragonfly/windows/darwin_window.py | 171 +++++++++++++++++++++++++++++ dragonfly/windows/window.py | 4 + 2 files changed, 175 insertions(+) create mode 100644 dragonfly/windows/darwin_window.py diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py new file mode 100644 index 00000000..7eb40338 --- /dev/null +++ b/dragonfly/windows/darwin_window.py @@ -0,0 +1,171 @@ +# +# This file is part of Aenea +# +# Aenea is free software: you can redistribute it and/or modify it under +# the terms of version 3 of the GNU Lesser General Public License as +# published by the Free Software Foundation. +# +# Aenea 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 Lesser General Public +# License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with Aenea. If not, see . +# +# Copyright (2014) Alex Roper +# Alex Roper +# +# Modified from Aenea's server_osx.py file. + +import locale + +from six import binary_type, string_types +import applescript + +from .base_window import BaseWindow +from .rectangle import Rectangle + + +class DarwinWindow(BaseWindow): + """ + The Window class is an interface to the macOS window control and + placement. + + """ + + #----------------------------------------------------------------------- + # Class methods to create new Window objects. + + @classmethod + def get_foreground(cls): + script = ''' + global frontApp, frontAppName + tell application "System Events" + set frontApp to first application process whose frontmost is true + set frontAppName to name of frontApp + tell process frontAppName + set mainWindow to missing value + repeat with win in windows + if attribute "AXMain" of win is true then + set mainWindow to win + exit repeat + end if + end repeat + end tell + end tell + return frontAppName + ''' + + # window_id isn't really a unique id, instead it's just the app name + # -- but still useful for automating through applescript + window_id = applescript.AppleScript(script).run() + if isinstance(window_id, binary_type): + window_id = window_id.decode(locale.getpreferredencoding()) + + return cls.get_window(id=window_id) + + @classmethod + def get_all_windows(cls): + # FIXME + return [] + + #----------------------------------------------------------------------- + # Methods for initialization and introspection. + + def __init__(self, id): + BaseWindow.__init__(self, id) + self._names.add(id) + + #----------------------------------------------------------------------- + # Methods that control attribute access. + + def _set_id(self, id): + if not isinstance(id, string_types): + raise TypeError("Window id/handle must be a string," + " but received {0!r}".format(id)) + self._id = id + self._windows_by_id[id] = self + + #----------------------------------------------------------------------- + # Methods and properties for window attributes. + + def _get_window_properties(self): + cmd = ''' + tell application "System Events" to tell application process "%s" + try + get properties of window 1 + on error errmess + log errmess + end try + end tell + ''' % self._id + script = applescript.AppleScript(cmd) + properties = script.run() + + result = {} + encoding = locale.getpreferredencoding() + for key, value in properties.items(): + key = key.code + if isinstance(key, binary_type): + key = key.decode(encoding) + if isinstance(value, applescript.AEType): + value = value.code + if isinstance(value, binary_type): + value = value.decode(encoding) + result[key] = value + return result + + def _get_window_text(self): + return self._get_window_properties()['pnam'] + + def _get_class_name(self): + return self._get_window_properties()['pcls'] + + def _get_window_module(self): + return self._id # The window ID is the app name on macOS. + + def _get_window_pid(self): + raise NotImplementedError() + + @property + def is_minimized(self): + raise NotImplementedError() + + @property + def is_maximized(self): + raise NotImplementedError() + + @property + def is_visible(self): + raise NotImplementedError() + + #----------------------------------------------------------------------- + # Methods related to window geometry. + + def get_position(self): + props = self._get_window_properties() + return Rectangle(props['posn'][0], props['posn'][1], + props['ptsz'][0], props['ptsz'][1]) + + def set_position(self, rectangle): + assert isinstance(rectangle, Rectangle) + raise NotImplementedError() + + #----------------------------------------------------------------------- + # Methods for miscellaneous window control. + + def minimize(self): + raise NotImplementedError() + + def maximize(self): + raise NotImplementedError() + + def restore(self): + raise NotImplementedError() + + def close(self): + raise NotImplementedError() + + def set_foreground(self): + raise NotImplementedError() diff --git a/dragonfly/windows/window.py b/dragonfly/windows/window.py index 191d84ba..b35fd479 100644 --- a/dragonfly/windows/window.py +++ b/dragonfly/windows/window.py @@ -29,6 +29,10 @@ elif os.environ.get("XDG_SESSION_TYPE") == "x11": from .x11_window import X11Window as Window +# Mac OS +elif sys.platform == "darwin": + from .darwin_window import DarwinWindow as Window + # Unsupported else: from .fake_window import FakeWindow as Window From f901463b0302de87762aad22a6db20cca730fc67 Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Sat, 14 Dec 2019 00:05:44 +1100 Subject: [PATCH 2/7] Add 'py-applescript' package to Mac OS dependencies AppleScript is used by the DarwinWindow class. --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index d4577fbf..07972836 100644 --- a/setup.py +++ b/setup.py @@ -106,6 +106,7 @@ def read(*names): # Mac OS dependencies. "pynput >= 1.4.2;platform_system=='Darwin'", "pyobjc >= 5.2;platform_system=='Darwin'", + "py-applescript == 1.0.0;platform_system=='Darwin'", # RPC requirements "json-rpc", From df5fe8cd59aeaaaad937902c37ad6d29e7f2cf8d Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Mon, 16 Dec 2019 23:56:27 +1100 Subject: [PATCH 3/7] Change DarwinWindow class to accept int and string window IDs This should allow app names and app bundle IDs to be used and makes the test suite pass again on Mac OS. --- dragonfly/windows/darwin_window.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py index 7eb40338..19945193 100644 --- a/dragonfly/windows/darwin_window.py +++ b/dragonfly/windows/darwin_window.py @@ -20,7 +20,7 @@ import locale -from six import binary_type, string_types +from six import binary_type, string_types, integer_types import applescript from .base_window import BaseWindow @@ -81,8 +81,8 @@ def __init__(self, id): # Methods that control attribute access. def _set_id(self, id): - if not isinstance(id, string_types): - raise TypeError("Window id/handle must be a string," + if not isinstance(id, (string_types, integer_types)): + raise TypeError("Window id/handle must be an int or string," " but received {0!r}".format(id)) self._id = id self._windows_by_id[id] = self From b57436e334e5139a5a221f4815e6ae24c56dafd9 Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Mon, 16 Dec 2019 23:57:41 +1100 Subject: [PATCH 4/7] Implement DarwinWindow.get_all_windows() class method The method only returns a Window object for each foreground application because Mac OS doesn't have easily accessible unique identifiers for each window. --- dragonfly/windows/darwin_window.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py index 19945193..626771ea 100644 --- a/dragonfly/windows/darwin_window.py +++ b/dragonfly/windows/darwin_window.py @@ -67,8 +67,20 @@ def get_foreground(cls): @classmethod def get_all_windows(cls): - # FIXME - return [] + script = ''' + global appIds + tell application "System Events" + set appIds to {} + repeat with theProcess in (application processes) + if not background only of theProcess then + set appIds to appIds & name of theProcess + end if + end repeat + end tell + return appIds + ''' + return [cls.get_window(app_id) for app_id in + applescript.AppleScript(script).run()] #----------------------------------------------------------------------- # Methods for initialization and introspection. From 6d88e61d99492c32c522a7f3ead518b3daeaef8e Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Tue, 17 Dec 2019 00:54:33 +1100 Subject: [PATCH 5/7] Implement DarwinWindow set_foreground() and set_position() methods --- dragonfly/windows/darwin_window.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py index 626771ea..b21e341d 100644 --- a/dragonfly/windows/darwin_window.py +++ b/dragonfly/windows/darwin_window.py @@ -162,7 +162,15 @@ def get_position(self): def set_position(self, rectangle): assert isinstance(rectangle, Rectangle) - raise NotImplementedError() + script = ''' + tell application "System Events" + set firstWindow to first window of application process "%s" + set position of firstWindow to {%d, %d} + set size of firstWindow to {%d, %d} + end tell + ''' % (self._id, rectangle.x, rectangle.y, rectangle.dx, + rectangle.dy) + applescript.AppleScript(script).run() #----------------------------------------------------------------------- # Methods for miscellaneous window control. @@ -180,4 +188,10 @@ def close(self): raise NotImplementedError() def set_foreground(self): - raise NotImplementedError() + script = ''' + tell application "%s" + set firstWindow to id of first window + activate firstWindow + end tell + ''' % self._id + applescript.AppleScript(script).run() From 84af7a4943b9310cbddfdc27f6d75d5a6932001c Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Wed, 18 Dec 2019 23:53:21 +1100 Subject: [PATCH 6/7] Implement remaining DarwinWindow methods and properties This also adds the full_screen(), get_attribute() and get_properties() methods. --- dragonfly/windows/darwin_window.py | 108 ++++++++++++++++++++++++----- setup.py | 1 + 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py index b21e341d..b04dc1a7 100644 --- a/dragonfly/windows/darwin_window.py +++ b/dragonfly/windows/darwin_window.py @@ -1,3 +1,4 @@ +# coding=utf-8 # # This file is part of Aenea # @@ -19,6 +20,8 @@ # Modified from Aenea's server_osx.py file. import locale +import logging +import psutil from six import binary_type, string_types, integer_types import applescript @@ -34,6 +37,8 @@ class DarwinWindow(BaseWindow): """ + _log = logging.getLogger("window") + #----------------------------------------------------------------------- # Class methods to create new Window objects. @@ -102,8 +107,14 @@ def _set_id(self, id): #----------------------------------------------------------------------- # Methods and properties for window attributes. - def _get_window_properties(self): - cmd = ''' + def get_properties(self): + """ + Method to get the properties of a macOS window. + + :rtype: dict + :returns: window properties + """ + script = ''' tell application "System Events" to tell application process "%s" try get properties of window 1 @@ -112,8 +123,9 @@ def _get_window_properties(self): end try end tell ''' % self._id - script = applescript.AppleScript(cmd) - properties = script.run() + properties = applescript.AppleScript(script).run() + if not properties: + return {} result = {} encoding = locale.getpreferredencoding() @@ -128,35 +140,65 @@ def _get_window_properties(self): result[key] = value return result + def get_attribute(self, attribute): + """ + Method to get an attribute of a macOS window. + + :param attribute: attribute name + :type attribute: string + :returns: attribute value + """ + script = ''' + tell application "%s" + try + get %s of window 1 + on error errmess + log errmess + end try + end tell + ''' % (self._id, attribute) + return applescript.AppleScript(script).run() + def _get_window_text(self): - return self._get_window_properties()['pnam'] + return self.get_properties().get('pnam', '') def _get_class_name(self): - return self._get_window_properties()['pcls'] + return self.get_properties().get('pcls', '') def _get_window_module(self): return self._id # The window ID is the app name on macOS. def _get_window_pid(self): - raise NotImplementedError() + if not (self._id and isinstance(self._id, string_types)): + # Can't match against numerical / empty / null app bundle ID. + return -1 + + for process in psutil.process_iter(attrs=['pid', 'exe']): + exe = process.info['exe'] + if exe and exe.endswith(self._id): + # Return the ID of the first matching process. + return process.info['pid'] + + # No match. + return -1 @property def is_minimized(self): - raise NotImplementedError() + return self.get_attribute('miniaturized') @property def is_maximized(self): - raise NotImplementedError() + return self.get_attribute('zoomed') @property def is_visible(self): - raise NotImplementedError() + return self.get_attribute('visible') #----------------------------------------------------------------------- # Methods related to window geometry. def get_position(self): - props = self._get_window_properties() + props = self.get_properties() return Rectangle(props['posn'][0], props['posn'][1], props['ptsz'][0], props['ptsz'][1]) @@ -175,23 +217,55 @@ def set_position(self, rectangle): #----------------------------------------------------------------------- # Methods for miscellaneous window control. + def _press_window_button(self, button_subrole, action): + # Note: The negation symbol ¬ is used to split long lines in + # AppleScript. + script = u''' + tell application "System Events" + perform action "%s" of (first button whose subrole is "%s") of ¬ + first window of process "%s" + end tell + ''' % (action, button_subrole, self._id) + try: + applescript.AppleScript(script).run() + return True + except applescript.ScriptError as err: + self._log.error("Failed to perform window button action " + "%s -> %s: %s", button_subrole, action, err) + return False + def minimize(self): - raise NotImplementedError() + return self._press_window_button("AXMinimizeButton", "AXPress") def maximize(self): - raise NotImplementedError() + return self._press_window_button("AXFullScreenButton", + "AXZoomWindow") + + def full_screen(self): + """ + Enable full screen mode for this window. + + **Note**: this doesn't allow transitioning out of full screen mode. + """ + return self._press_window_button("AXFullScreenButton", "AXPress") def restore(self): - raise NotImplementedError() + # Toggle maximized/minimized state if necessary. + if self.is_maximized: + return self.maximize() + + if self.is_minimized: + return self.minimize() + + return True def close(self): - raise NotImplementedError() + return self._press_window_button("AXCloseButton", "AXPress") def set_foreground(self): script = ''' tell application "%s" - set firstWindow to id of first window - activate firstWindow + activate window 1 end tell ''' % self._id applescript.AppleScript(script).run() diff --git a/setup.py b/setup.py index 07972836..c5f99e2a 100644 --- a/setup.py +++ b/setup.py @@ -107,6 +107,7 @@ def read(*names): "pynput >= 1.4.2;platform_system=='Darwin'", "pyobjc >= 5.2;platform_system=='Darwin'", "py-applescript == 1.0.0;platform_system=='Darwin'", + "psutil >= 5.5.1;platform_system=='Darwin'", # RPC requirements "json-rpc", From 2fab8a57f8c8db71b6a57a28045517a6076fc371 Mon Sep 17 00:00:00 2001 From: Dane Finlay Date: Wed, 18 Dec 2019 23:58:40 +1100 Subject: [PATCH 7/7] Add DarwinWindow to documentation build This also mocks the 'applescript' module in documentation/conf.py. --- documentation/conf.py | 2 +- documentation/window_classes.txt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/documentation/conf.py b/documentation/conf.py index 04f6c322..a9264512 100644 --- a/documentation/conf.py +++ b/documentation/conf.py @@ -44,7 +44,7 @@ def __getattr__(cls, name): "win32clipboard", "win32com", "win32com.client", "numpy", "win32com.client.gencache", "win32com.gen_py", "win32com.shell", "win32con", "win32event", "win32file", "win32gui", "winsound", - "winxpgui", "psutil", + "winxpgui", "psutil", "applescript", } for module_name in mock_modules: diff --git a/documentation/window_classes.txt b/documentation/window_classes.txt index e8e37706..6a63f691 100644 --- a/documentation/window_classes.txt +++ b/documentation/window_classes.txt @@ -19,3 +19,6 @@ The :class:`FakeWindow` class will be used on unsupported platforms. .. automodule:: dragonfly.windows.x11_window :members: + +.. automodule:: dragonfly.windows.darwin_window + :members: