diff --git a/src/pywinctl/_main.py b/src/pywinctl/_main.py index 93019d4..a734933 100644 --- a/src/pywinctl/_main.py +++ b/src/pywinctl/_main.py @@ -1054,4 +1054,3 @@ class _WINDICT(TypedDict): # noqa: PYI049 # Private symbol imported by internal "PyWinCtl currently does not support this platform. " + "If you think you can help, please contribute! https://github.com/Kalmat/PyWinCtl" ) -Window = Window diff --git a/src/pywinctl/_pywinctl_linux.py b/src/pywinctl/_pywinctl_linux.py index cc4f385..81f03b1 100644 --- a/src/pywinctl/_pywinctl_linux.py +++ b/src/pywinctl/_pywinctl_linux.py @@ -30,7 +30,7 @@ # WARNING: Changes are not immediately applied, specially for hide/show (unmap/map) # You may set wait to True in case you need to effectively know if/when change has been applied. -WAIT_ATTEMPTS = 10 +WAIT_ATTEMPTS = 15 WAIT_DELAY = 0.025 # Will be progressively increased on every retry diff --git a/src/pywinctl/_pywinctl_macos.py b/src/pywinctl/_pywinctl_macos.py index 9801454..4a42787 100644 --- a/src/pywinctl/_pywinctl_macos.py +++ b/src/pywinctl/_pywinctl_macos.py @@ -33,6 +33,25 @@ WAIT_ATTEMPTS = 10 WAIT_DELAY = 0.025 # Will be progressively increased on every retry +_OSASCRIPT_TIMEOUT = WAIT_DELAY * WAIT_ATTEMPTS * (WAIT_ATTEMPTS + 1) / 2 # Matches max wait-loop duration + + +def _run_osascript(script: str, *args: str, strict: bool = False) -> str: + """Run an AppleScript via osascript stdin, returning raw stdout. Returns '' on timeout.""" + cmd: list[str] = ['osascript'] + if strict: + cmd += ['-s', 's'] + if args: + cmd += ['-', *args] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') + try: + ret, _ = proc.communicate(script, timeout=_OSASCRIPT_TIMEOUT) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + return "" + return ret + def checkPermissions(activate: bool = False) -> bool: """ @@ -61,9 +80,7 @@ def checkPermissions(activate: bool = False) -> bool: set UI_enabled to UI elements enabled end tell return UI_enabled""" - proc = subprocess.Popen(['osascript'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "") + ret = _run_osascript(cmd).replace("\n", "") return ret == "true" @@ -90,9 +107,7 @@ def getActiveWindow() -> MacOSWindow | None: end try return {appID, winName} end run""" - proc = subprocess.Popen(['osascript'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - entries = ret.replace("\n", "").split(", ") + entries = _run_osascript(cmd).replace("\n", "").split(", ") appID = entries[0] # Thanks to Anthony Molinaro (djnym) for pointing out this bug and provide the solution!!! # sometimes the title of the window contains ',' characters, so just get the first entry as the appName and @@ -148,16 +163,17 @@ def getAllTitles() -> list[str]: :return: list of titles as strings """ - cmd = """osascript -s 's' -e 'tell application "System Events" - set winNames to {} - try - set winNames to {name of every window} of (every process whose background only is false) - end try - end tell - return winNames'""" - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").replace("\n", "") \ - .replace('missing value', '"missing value"') \ - .replace("{", "[").replace("}", "]") + script = """tell application "System Events" + set winNames to {} + try + set winNames to {name of every window} of (every process whose background only is false) + end try + end tell + return winNames""" + ret = _run_osascript(script, strict=True) + if not ret: + return [] + ret = ret.replace("\n", "").replace('missing value', '"missing value"').replace("{", "[").replace("}", "]") res = ast.literal_eval(ret) matches: list[str] = [] if len(res) > 0: @@ -227,16 +243,17 @@ def getAllAppsNames() -> list[str]: :return: list of names as strings """ - cmd = """osascript -s 's' -e 'tell application "System Events" - set winNames to {} - try - set winNames to name of every process whose background only is false - end try - end tell - return winNames'""" - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").replace("\n", "") \ - .replace('missing value', '"missing value"') \ - .replace("{", "[").replace("}", "]") + script = """tell application "System Events" + set winNames to {} + try + set winNames to name of every process whose background only is false + end try + end tell + return winNames""" + ret = _run_osascript(script, strict=True) + if not ret: + return [] + ret = ret.replace("\n", "").replace('missing value', '"missing value"').replace("{", "[").replace("}", "]") res = ast.literal_eval(ret) return res or [] @@ -295,16 +312,17 @@ def getAllAppsWindowsTitles(): :return: python dictionary """ - cmd = """osascript -s 's' -e 'tell application "System Events" - set winNames to {} - try - set winNames to {name, (name of every window)} of (every process whose background only is false) - end try - end tell - return winNames'""" - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8") \ - .replace('missing value', '"missing value"') \ - .replace("\n", "").replace("{", "[").replace("}", "]") + script = """tell application "System Events" + set winNames to {} + try + set winNames to {name, (name of every window)} of (every process whose background only is false) + end try + end tell + return winNames""" + ret = _run_osascript(script, strict=True) + if not ret: + return {} + ret = ret.replace('missing value', '"missing value"').replace("\n", "").replace("{", "[").replace("}", "]") res: tuple[list[str], list[list[str]]] = ast.literal_eval(ret) result: dict[str, list[str]] = {} if res and len(res) > 0: @@ -334,16 +352,15 @@ def getAllWindowsDict(tryToFilter: bool = False) -> dict[str, _WINDICT]: :return: python dictionary """ windows = getAllWindows() - cmd = """osascript -s 's' -e 'tell application "System Events" - set winNames to {} - try - set winNames to {unix id, name, ({name, position, size} of every window)} of (every process whose background only is false) - end try - end tell - return winNames'""" - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").replace("\n", "") \ - .replace('missing value', '"missing value"') \ - .replace("{", "[").replace("}", "]") + script = """tell application "System Events" + set winNames to {} + try + set winNames to {unix id, name, ({name, position, size} of every window)} of (every process whose background only is false) + end try + end tell + return winNames""" + raw = _run_osascript(script, strict=True) + ret = raw.replace("\n", "").replace('missing value', '"missing value"').replace("{", "[").replace("}", "]") res = ast.literal_eval(ret) result: dict[str, _WINDICT] = {} if len(res) > 0: @@ -433,9 +450,7 @@ def _getAppWindowsTitles(app: AppKit.NSRunningApplication): end try return winNames end run""" - proc = subprocess.Popen(['osascript', '-s', 's', '-', pid], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, pid, strict=True) ret = ret.replace("\n", "").replace('missing value', '"missing value"').replace("{", "[").replace("}", "]") res = ast.literal_eval(ret) return res or [] @@ -443,18 +458,19 @@ def _getAppWindowsTitles(app: AppKit.NSRunningApplication): def _getWindowTitles() -> list[list[str]]: # https://gist.github.com/qur2/5729056 - qur2 - cmd = """osascript -s 's' -e 'tell application "System Events" - set winNames to {} - try - set winNames to {unix id, ({name, position, size} of (every window))} of (every process whose background only is false) - end try - end tell - return winNames'""" - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").replace("\n", "") \ - .replace('missing value', '"missing value"') \ - .replace("{", "[").replace("}", "]") - res = ast.literal_eval(ret) + script = """tell application "System Events" + set winNames to {} + try + set winNames to {unix id, ({name, position, size} of (every window))} of (every process whose background only is false) + end try + end tell + return winNames""" + ret = _run_osascript(script, strict=True) result: list[list[str]] = [] + if not ret: + return result + ret = ret.replace("\n", "").replace('missing value', '"missing value"').replace("{", "[").replace("}", "]") + res = ast.literal_eval(ret) if len(res) > 0: for i, pID in enumerate(res[0]): try: @@ -522,10 +538,7 @@ def getProcName(self, appPID): end try return procName end run""" - proc = subprocess.Popen(['osascript', '-', str(appPID)], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - return str(ret.replace("\n", "")) + return _run_osascript(cmd, str(appPID)).replace("\n", "") def getExtraFrameSize(self, includeBorder: bool = True) -> tuple[int, int, int, int]: """ @@ -617,9 +630,7 @@ def close(self, force: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) if force and self.isAlive: self._app.terminate() return not self.isAlive @@ -643,9 +654,7 @@ def minimize(self, wait: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) retries = 0 while wait and retries < WAIT_ATTEMPTS and not self.isMinimized: retries += 1 @@ -684,9 +693,7 @@ def maximize(self, wait: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) retries = 0 while wait and retries < WAIT_ATTEMPTS and not self.isMaximized: retries += 1 @@ -715,9 +722,7 @@ def restore(self, wait: bool = False, user: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) else: cmd = """on run {arg1, arg2} set appName to arg1 as string @@ -728,9 +733,7 @@ def restore(self, wait: bool = False, user: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) elif self.isMinimized: cmd = """on run {arg1, arg2} set appName to arg1 as string @@ -741,9 +744,7 @@ def restore(self, wait: bool = False, user: bool = False) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) retries = 0 while wait and retries < WAIT_ATTEMPTS and (self.isMinimized or self.isMaximized): retries += 1 @@ -773,9 +774,7 @@ def show(self, wait: bool = False) -> bool: end try return (isDone as string) end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, self._appName, self._winTitle) ret = ret.replace("\n", "") if ret != "true": self._app.unhide() @@ -808,9 +807,7 @@ def hide(self, wait: bool = False) -> bool: end try return (isDone as string) end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, self._appName, self._winTitle) ret = ret.replace("\n", "") if ret != "true": self._app.hide() @@ -847,9 +844,7 @@ def activate(self, wait: bool = False, user: bool = True) -> bool: end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, self._appName, self._winTitle) retries = 0 while wait and retries < WAIT_ATTEMPTS and not self.isActive: retries += 1 @@ -991,10 +986,8 @@ def lowerWindow(self): end repeat end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - return not err + _run_osascript(cmd, self._appName, self._winTitle) + return True def raiseWindow(self): """ @@ -1017,10 +1010,8 @@ def raiseWindow(self): end tell end try end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - return not err + _run_osascript(cmd, self._appName, self._winTitle) + return True def sendBehind(self, sb: bool = True) -> bool: """ @@ -1073,10 +1064,7 @@ def getParent(self) -> tuple[str, str]: end try return {parentRole, parentName} end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "") + ret = _run_osascript(cmd, self._appName, self._winTitle).replace("\n", "") entries = ret.replace("\n", "").split(", ") role = entries[0] parent = ", ".join(entries[1:]) @@ -1119,10 +1107,7 @@ def getChildren(self): end try return winChildren end run""" - proc = subprocess.Popen(['osascript', '-s', 's', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "").replace("{", "['").replace("}", "']").replace('"', '').replace(", ", "', '").replace('missing value', '"missing value"') + ret = _run_osascript(cmd, self._appName, self._winTitle, strict=True).replace("\n", "").replace("{", "['").replace("}", "']").replace('"', '').replace(", ", "', '").replace('missing value', '"missing value"') ret = ast.literal_eval(ret) for item in ret: if item.startswith("window"): @@ -1147,15 +1132,17 @@ def getPID(self) -> int | None: :return: application PID or None if it couldn't be retrieved """ - cmd = """osascript -s 's' -e 'tell application "System Events" - set appPID to "0" - try - set appPID to unix id of first application process whose name is "%s" - end try - end tell - return appPID'""" % self._appName - ret = subprocess.check_output(cmd, shell=True).decode(encoding="utf-8").replace("\n", "") \ - .replace('missing value', "0") + script = """on run {arg1} + set procName to arg1 as string + set appPID to "0" + try + tell application "System Events" + set appPID to unix id of first application process whose name is procName + end tell + end try + return appPID + end run""" + ret = _run_osascript(script, self._appName, strict=True).replace("\n", "").replace('missing value', "0") if ret and ret != "0": return int(ret) return None @@ -1218,11 +1205,7 @@ def isMinimized(self) -> bool: end try return (isMin as string) end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "") - return ret == "true" + return _run_osascript(cmd, self._appName, self._winTitle).replace("\n", "") == "true" @property def isMaximized(self) -> bool: @@ -1258,11 +1241,7 @@ def isMaximized(self) -> bool: end try return (isFull as string) end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "") - return ret == "true" + return _run_osascript(cmd, self._appName, self._winTitle).replace("\n", "") == "true" @property def isActive(self) -> bool: @@ -1347,11 +1326,7 @@ def isAlive(self) -> bool: end try return (isDone as string) end run""" - proc = subprocess.Popen(['osascript', '-', self._appName, self._winTitle], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) - ret = ret.replace("\n", "") - return ret == "true" + return _run_osascript(cmd, self._appName, self._winTitle).replace("\n", "") == "true" # @property # def isAlerting(self) -> bool: @@ -1463,9 +1438,7 @@ def findit(): """ % (subCmd1, subCmd2, subCmd3, subCmd4) # https://stackoverflow.com/questions/69774133/how-to-use-global-variables-inside-of-an-applescript-function-for-a-python-code # Didn't find a way to get the "injected code" working if passed this way - proc = subprocess.Popen(['osascript', '-s', 's', '-', str(self._parent._app.localizedName())], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, str(self._parent._app.localizedName()), strict=True) if addItemInfo: ret = ret.replace("\n", "").replace("\t", "").replace('missing value', '"missing value"') \ .replace("{", "[").replace("}", "]").replace("value:", "'") \ @@ -1475,7 +1448,7 @@ def findit(): .replace("{", "[").replace("}", "]") item = ast.literal_eval(ret) - if err is None and not self._isListEmpty(item[0]): + if not self._isListEmpty(item[0]): nameList.append(item[0]) sizeList.append(item[1]) posList.append(item[2]) @@ -1604,9 +1577,7 @@ def clickMenuItem(self, itemPath: Sequence[str] | None = None, wID: int = 0) -> end run """ % subCmd - proc = subprocess.Popen(['osascript', '-s', 's', '-', str(self._parent._app.localizedName())], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + _run_osascript(cmd, str(self._parent._app.localizedName()), strict=True) return found @@ -1667,9 +1638,7 @@ def getMenuItemCount(self, hSubMenu: int) -> int: end run """ % subCmd - proc = subprocess.Popen(['osascript', '-s', 's', '-', str(self._parent._app.localizedName())], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, str(self._parent._app.localizedName()), strict=True) ret = ret.replace("\n", "") if ret.isnumeric(): count = int(ret) @@ -1728,9 +1697,7 @@ class of attribute """ % subCmd # https://stackoverflow.com/questions/69774133/how-to-use-global-variables-inside-of-an-applescript-function-for-a-python-code # Didn't find a way to get the "injected code" working if passed this way - proc = subprocess.Popen(['osascript', '-s', 's', '-', str(self._parent._app.localizedName())], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, str(self._parent._app.localizedName()), strict=True) itemInfo = self._parseAttr(ret) return itemInfo @@ -1773,9 +1740,7 @@ def getMenuItemRect(self, hSubMenu: int, wID: int) -> Rect: end run """ % subCmd - proc = subprocess.Popen(['osascript', '-s', 's', '-', str(self._parent._app.localizedName())], - stdin=subprocess.PIPE, stdout=subprocess.PIPE, encoding='utf8') - ret, err = proc.communicate(cmd) + ret = _run_osascript(cmd, str(self._parent._app.localizedName()), strict=True) ret = ret.replace("\n", "").replace("{", "[").replace("}", "]").replace('missing value', '"missing value"') rect = ast.literal_eval(ret) x, y = rect[0] diff --git a/tests/test_pywinctl.py b/tests/test_pywinctl.py index f784d0e..c1b747d 100644 --- a/tests/test_pywinctl.py +++ b/tests/test_pywinctl.py @@ -15,6 +15,9 @@ class GetWindowKwargs(TypedDict): def test_basic() -> None: + if sys.platform == "darwin": + if not pywinctl.checkPermissions(activate=True): + sys.exit(1) print("PLATFORM:", sys.platform) print() @@ -43,8 +46,6 @@ def test_basic() -> None: "condition": pywinctl.Re.ENDSWITH, } elif sys.platform == "darwin": - if not pywinctl.checkPermissions(activate=True): - exit() process = ["open", "-a", "TextEdit", __file__] get_window_kwargs: GetWindowKwargs = { "title": "test_pywinctl.py",