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:
diff --git a/dragonfly/windows/darwin_window.py b/dragonfly/windows/darwin_window.py
new file mode 100644
index 00000000..b04dc1a7
--- /dev/null
+++ b/dragonfly/windows/darwin_window.py
@@ -0,0 +1,271 @@
+# coding=utf-8
+#
+# 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
+import logging
+import psutil
+
+from six import binary_type, string_types, integer_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.
+
+ """
+
+ _log = logging.getLogger("window")
+
+ #-----------------------------------------------------------------------
+ # 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):
+ 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.
+
+ 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, 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
+
+ #-----------------------------------------------------------------------
+ # Methods and properties for window attributes.
+
+ 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
+ on error errmess
+ log errmess
+ end try
+ end tell
+ ''' % self._id
+ properties = applescript.AppleScript(script).run()
+ if not properties:
+ return {}
+
+ 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_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_properties().get('pnam', '')
+
+ def _get_class_name(self):
+ 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):
+ 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):
+ return self.get_attribute('miniaturized')
+
+ @property
+ def is_maximized(self):
+ return self.get_attribute('zoomed')
+
+ @property
+ def is_visible(self):
+ return self.get_attribute('visible')
+
+ #-----------------------------------------------------------------------
+ # Methods related to window geometry.
+
+ def get_position(self):
+ props = self.get_properties()
+ return Rectangle(props['posn'][0], props['posn'][1],
+ props['ptsz'][0], props['ptsz'][1])
+
+ def set_position(self, rectangle):
+ assert isinstance(rectangle, Rectangle)
+ 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.
+
+ 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):
+ return self._press_window_button("AXMinimizeButton", "AXPress")
+
+ def maximize(self):
+ 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):
+ # 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):
+ return self._press_window_button("AXCloseButton", "AXPress")
+
+ def set_foreground(self):
+ script = '''
+ tell application "%s"
+ activate window 1
+ end tell
+ ''' % self._id
+ applescript.AppleScript(script).run()
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
diff --git a/setup.py b/setup.py
index d4577fbf..c5f99e2a 100644
--- a/setup.py
+++ b/setup.py
@@ -106,6 +106,8 @@ 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'",
+ "psutil >= 5.5.1;platform_system=='Darwin'",
# RPC requirements
"json-rpc",