From aac4f88bb54fa39c41f2b33db61bdaa8cbb8cc56 Mon Sep 17 00:00:00 2001 From: Ian Obermiller Date: Sat, 10 Feb 2018 21:42:44 -0800 Subject: [PATCH 01/10] Update MacOS title logic - Switch from the opaque scpt file to a normal JS file using JavaScript for Automation. - Grab the first window (which is the active one) and get its name as the title. This worked across all apps I tried, including Sublime, Atom, Chrome, Safari, and iTerm - Clean up the api to the macos.py file (have it return the dict directly) - Fix a typo in README --- .gitignore | 1 + README.md | 6 ++--- aw_watcher_window/lib.py | 6 +---- aw_watcher_window/macos.py | 35 ++++++++++++++++++--------- aw_watcher_window/printAppTitle.scpt | Bin 3278 -> 0 bytes 5 files changed, 27 insertions(+), 21 deletions(-) delete mode 100644 aw_watcher_window/printAppTitle.scpt diff --git a/.gitignore b/.gitignore index f13f69ce..99a8446f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__ build dist *.swp +.mypy_cache diff --git a/README.md b/README.md index 2a420a49..27e813dc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,4 @@ -aw-watcher-window -================= +# aw-watcher-window Cross-platform window-Watcher for Linux (X11), macOS, Windows. @@ -7,6 +6,5 @@ Cross-platform window-Watcher for Linux (X11), macOS, Windows. ### Note to macOS users -To log current window title the teminal needs access to macOS accessibility API. +To log current window title the terminal needs access to macOS accessibility API. This can be enabled in System Preferences > Security & Privacy > Accessibility, then add the Terminal to this list. If this is not enabled the watcher can only log current application, and not window title. - diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index 010fb802..b0c42736 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -18,11 +18,7 @@ def get_current_window_linux() -> Optional[dict]: def get_current_window_macos() -> Optional[dict]: from . import macos - info = macos.getInfo() - app = macos.getApp(info) - title = macos.getTitle(info) - - return {"title": title, "appname": app} + return macos.getInfo() def get_current_window_windows() -> Optional[dict]: diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 9cc04d9a..46dda4d2 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,17 +1,28 @@ -import subprocess -from subprocess import PIPE -import os +from typing import Optional +from AppKit import NSWorkspace +from Quartz import ( + CGWindowListCopyWindowInfo, + kCGWindowListOptionOnScreenOnly, + kCGNullWindowID +) +def getInfo() -> Optional[dict]: + app_name = '' + title = '' -def getInfo() -> str: - cmd = ["osascript", os.path.join(os.path.dirname(os.path.realpath(__file__)), "printAppTitle.scpt")] - p = subprocess.run(cmd, stdout=PIPE) - return str(p.stdout, "utf8").strip() + app = NSWorkspace.sharedWorkspace().activeApplication() + if app: + pid = app['NSApplicationProcessIdentifier'] -def getApp(info) -> str: - return info.split('","')[0][1:] + options = kCGWindowListOptionOnScreenOnly + window_list = CGWindowListCopyWindowInfo(options, kCGNullWindowID) + for window in window_list: + if pid == window['kCGWindowOwnerPID']: + # We could use app['NSApplicationName'], but this value is more + # accurate and matches other methods (like applescript) + app_name = window['kCGWindowOwnerName'] + title = window.get('kCGWindowName', u'') + break - -def getTitle(info) -> str: - return info.split('","')[1][:-1] + return {"appname": app_name, "title": title} diff --git a/aw_watcher_window/printAppTitle.scpt b/aw_watcher_window/printAppTitle.scpt deleted file mode 100644 index d004b036791ed371a0841c31edf9af5670c9fb1f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3278 zcmb_e`*##Y7XG?tgwUPpq>~^4SsbEKc7Qx2vXE?m0Kuq0kR&ES9(IO?3`}NnX2!7m z=AW=<|BXHSgCe`W7i2{QAH)Zst5FeDp5g;VRIXojPlV%|WIe~4Q{U9>x>fg4_ui^i zUT$kwOGm7}roK*h2SBt$1O`k*3=W_;Hg@v6Zc!6a&fJ7aieLy8Bu?aKlP&~?#_6Ga{MYgr1OiUr`5AwY&KVU{3>|M$9 z*37PaGN1BCiB3(J@;#`|Bz6n7B1BRgv&mKYPOcbo6)@yG4OcX$&0Y+p2nEgv9Qjth zQE*^p19KuML8&4b0|9Nob>PBy3}r6Lv_nc^WP&NkWm_(ftD+&_YU$r->59D^6^al@ zkD?t@fQE8%*3#Vg-A_PhnVH$41Ohqsk8lY#V;t!am z2xbG%s|dyNC2q9{QEM>a4JzCknnO3q7ZLdahQchE&4^H|3dAs55sZ53;Gk44S#n7) zP3E>K%np~jD3{MI`J9K`m&pv}vyb=+Gq1biqAeH4Jo_26pbEDsLSR89RlOb6ieRQG zw34RWfjbo;P+~f6kqeew&`K~s6z&WKRbslFx8?j8f)uK+D|F75a}!3|ZB%TIoK=_; zp1>@6{Evt$LgWD|R*f~7i+OTJ@h>=|IarDLs8NK#lqRe|E$S2@Fr^7CsK;H35SX$Q zE%KQqpV5>dFST0XE`3Nq6PC)SSYQ#NmY2_G6&8e^ZpIQcIN(V>hPz$d%_C_+4?2-V z9x2f6b=R%@#FkISjX;GO9d0+#!bMmpr{$Ew!Z2?1mp@^VBA5*Z9YI4s#$t;QwGzxa zg~g#tamFY7^SP=KO^OiMT|@Q%jAlg$?5@WG`N)!wezv=EOoF{+r7-iGG?i^dks8VPF(E+XXiXb*3-Rg zG9vHGdzOr#vc}6LvVJxji?fBZe}x_!@;*~j-qXv^=;iTH%UP(B|DwerFa)!SoT4s4 z+A~+=U0dFrD756P-fmi`bCmvHUV`PurJ_*!?}<5oCvJ6eAAO50p=?)!U_+p#;B_XlFFLw-7+tIe`6a z$-bAcWuP4nBuU+xah!cF~m`tbdJg4VUWKP%wW9fSWD<=<6MTb z{p4o)ZgqQv5tn_i~AIzKyShQU_H8jl-@-1*D69`zL&SrIz=eZ-FN`&6~WLq<2pLE z!yzzRJ6%>Mi#|=zV;RI0)`v_Tco1C+E{RE~!bVgnE)TM)8$I%dEpJSaO^RhX zklM@D-443-hCME;gQd4)(g&Na4maI|%`$Au@C0@7zHrMe=tHlAUcKc87aORf-m=P+ zH}!EG)4>jw!Ad1y|x@2UJ~w$aF9 zY&RLk`VrJ`GEf-dvd@%#+8GD5GhWaJZ`I)Q1?0-Vv0R=wHHE`GFb6*7fH1acXgwvho!BwzpJa= zgX^KJ`cSop^6LG>is~K3m|Eg6-Wd8jT Date: Tue, 27 Mar 2018 14:45:49 +0200 Subject: [PATCH 02/10] Removed .scpt from PyInstaller datas argument --- aw-watcher-window.spec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aw-watcher-window.spec b/aw-watcher-window.spec index be81152e..8793f0af 100644 --- a/aw-watcher-window.spec +++ b/aw-watcher-window.spec @@ -6,7 +6,7 @@ block_cipher = None a = Analysis(['aw_watcher_window/__main__.py'], pathex=[], binaries=None, - datas=[("aw_watcher_window/printAppTitle.scpt", "aw_watcher_window")], + datas=[], hiddenimports=[], hookspath=[], runtime_hooks=[], From 4e88cec3ab04a5defdaee2730c79af9ce12ee716 Mon Sep 17 00:00:00 2001 From: Kerkko Pelttari Date: Sun, 22 Mar 2020 11:49:37 +0200 Subject: [PATCH 03/10] Build on PR --- aw_watcher_window/macos.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 46dda4d2..6e3c8b44 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Dict, Optional from AppKit import NSWorkspace from Quartz import ( CGWindowListCopyWindowInfo, @@ -6,23 +6,25 @@ kCGNullWindowID ) -def getInfo() -> Optional[dict]: - app_name = '' - title = '' +def getInfo() -> Optional[Dict[str, str]]: + app = NSWorkspace.sharedWorkspace().frontmostApplication() + if app: + app_name = app.localizedName() + title = getTitle(app.processIdentifier()) - app = NSWorkspace.sharedWorkspace().activeApplication() + print("appname: " + app_name + ", title: "+ title) + return {"appname": app_name, "title": title} - if app: - pid = app['NSApplicationProcessIdentifier'] + else: + return None + +def getTitle(pid: int) -> str: + options = kCGWindowListOptionOnScreenOnly + windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) - options = kCGWindowListOptionOnScreenOnly - window_list = CGWindowListCopyWindowInfo(options, kCGNullWindowID) - for window in window_list: - if pid == window['kCGWindowOwnerPID']: - # We could use app['NSApplicationName'], but this value is more - # accurate and matches other methods (like applescript) - app_name = window['kCGWindowOwnerName'] - title = window.get('kCGWindowName', u'') - break + for window in windowList: + lookupPid = window['kCGWindowOwnerPID'] + if (pid == lookupPid): + return str(window.get('kCGWindowName', 'Non-detected window title')) + return "" - return {"appname": app_name, "title": title} From 38591c560cfd1dd6132a4f5d65214a05a756dd3c Mon Sep 17 00:00:00 2001 From: Kerkko Pelttari Date: Tue, 24 Mar 2020 15:31:44 +0200 Subject: [PATCH 04/10] Standardize macos window lib with other OS window libs --- aw_watcher_window/lib.py | 5 ++++- aw_watcher_window/macos.py | 23 ++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index b0c42736..b002a6a1 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -18,7 +18,10 @@ def get_current_window_linux() -> Optional[dict]: def get_current_window_macos() -> Optional[dict]: from . import macos - return macos.getInfo() + + app = macos.get_current_app() + print ("appname" + macos.get_app_name(app) + ", title" + macos.get_app_title(app)) + return {"appname": macos.get_app_name(app), "title": macos.get_app_title(app)} def get_current_window_windows() -> Optional[dict]: diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 6e3c8b44..4326d161 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,30 +1,27 @@ from typing import Dict, Optional -from AppKit import NSWorkspace +from AppKit import NSWorkspace, NSRunningApplication from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID ) -def getInfo() -> Optional[Dict[str, str]]: - app = NSWorkspace.sharedWorkspace().frontmostApplication() - if app: - app_name = app.localizedName() - title = getTitle(app.processIdentifier()) +def get_current_app() -> Optional[NSRunningApplication]: + return NSWorkspace.sharedWorkspace().frontmostApplication() - print("appname: " + app_name + ", title: "+ title) - return {"appname": app_name, "title": title} - else: - return None +def get_app_name(app: NSRunningApplication) -> str: + return app.localizedName() -def getTitle(pid: int) -> str: + +def get_app_title(app: NSRunningApplication) -> str: + pid = app.processIdentifier() options = kCGWindowListOptionOnScreenOnly windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) for window in windowList: lookupPid = window['kCGWindowOwnerPID'] - if (pid == lookupPid): - return str(window.get('kCGWindowName', 'Non-detected window title')) + if (lookupPid == pid): + return window.get('kCGWindowName', 'Non-detected window title') return "" From 01a5f18aede761751a6afa59ea74ccafbed2886b Mon Sep 17 00:00:00 2001 From: Kerkko Pelttari Date: Tue, 24 Mar 2020 16:49:51 +0200 Subject: [PATCH 05/10] try using a notification observer on macos. still not working --- aw_watcher_window/macos.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 4326d161..5a27b707 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,13 +1,32 @@ from typing import Dict, Optional -from AppKit import NSWorkspace, NSRunningApplication +from AppKit import NSObject, NSWorkspace, NSRunningApplication, NSWorkspaceDidActivateApplicationNotification from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, kCGNullWindowID ) +from PyObjCTools import AppHelper -def get_current_app() -> Optional[NSRunningApplication]: - return NSWorkspace.sharedWorkspace().frontmostApplication() + +class Observer(NSObject): + app = NSWorkspace.sharedWorkspace().frontmostApplication() + + def get_front_app(self) -> NSRunningApplication: + return self.app + def set_front_app_(self) -> NSRunningApplication: + self.app = NSWorkspace.sharedWorkspace().frontmostApplication() + +observer = Observer.new() +NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_( + observer, + "set_front_app:", + NSWorkspaceDidActivateApplicationNotification, + None) + +AppHelper.runConsoleEventLoop() + +def get_current_app() -> NSRunningApplication: + return observer.get_front_app() def get_app_name(app: NSRunningApplication) -> str: @@ -22,6 +41,8 @@ def get_app_title(app: NSRunningApplication) -> str: for window in windowList: lookupPid = window['kCGWindowOwnerPID'] if (lookupPid == pid): + print(window) return window.get('kCGWindowName', 'Non-detected window title') - return "" + + return "Couldn't find title by pid" From 7ffb3fc229376c8399b68faa72a4452501ebbc5e Mon Sep 17 00:00:00 2001 From: Kerkko Pelttari Date: Wed, 25 Mar 2020 20:26:50 +0200 Subject: [PATCH 06/10] Try a side-effect-ful import approach to initializing macOS event loop --- aw_watcher_window/lib.py | 2 +- aw_watcher_window/macos.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index b002a6a1..526716d1 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -18,7 +18,7 @@ def get_current_window_linux() -> Optional[dict]: def get_current_window_macos() -> Optional[dict]: from . import macos - + # The side effectful import breaks thigns here app = macos.get_current_app() print ("appname" + macos.get_app_name(app) + ", title" + macos.get_app_title(app)) return {"appname": macos.get_app_name(app), "title": macos.get_app_title(app)} diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 5a27b707..e1406ae1 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,5 +1,6 @@ -from typing import Dict, Optional -from AppKit import NSObject, NSWorkspace, NSRunningApplication, NSWorkspaceDidActivateApplicationNotification +from threading import Thread +from typing import Dict, Optional, NoReturn +from AppKit import NSObject, NSNotification, NSWorkspace, NSRunningApplication, NSWorkspaceDidActivateApplicationNotification from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, @@ -13,16 +14,18 @@ class Observer(NSObject): def get_front_app(self) -> NSRunningApplication: return self.app - def set_front_app_(self) -> NSRunningApplication: + + def handle_(self, noti: NSNotification) -> None: + self._set_front_app() + def _set_front_app(self) -> None: self.app = NSWorkspace.sharedWorkspace().frontmostApplication() observer = Observer.new() NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_( - observer, - "set_front_app:", - NSWorkspaceDidActivateApplicationNotification, - None) - + observer, + "handle:", + NSWorkspaceDidActivateApplicationNotification, + None) AppHelper.runConsoleEventLoop() def get_current_app() -> NSRunningApplication: @@ -41,7 +44,6 @@ def get_app_title(app: NSRunningApplication) -> str: for window in windowList: lookupPid = window['kCGWindowOwnerPID'] if (lookupPid == pid): - print(window) return window.get('kCGWindowName', 'Non-detected window title') return "Couldn't find title by pid" From 52852aea64443106e4b311989b3d860af468881a Mon Sep 17 00:00:00 2001 From: Kerkko Pelttari Date: Sat, 28 Mar 2020 15:44:47 +0200 Subject: [PATCH 07/10] Try to use a higher order function to initialize macos spesific event stuff --- aw_watcher_window/lib.py | 26 +++++++++++++------------- aw_watcher_window/main.py | 3 ++- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index 526716d1..ceaf4a68 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -1,8 +1,8 @@ import sys -from typing import Optional +from typing import Callable, Dict, Optional -def get_current_window_linux() -> Optional[dict]: +def get_current_window_linux() -> Dict[str, str]: from . import xlib window = xlib.get_current_window() @@ -16,15 +16,15 @@ def get_current_window_linux() -> Optional[dict]: return {"appname": cls, "title": name} -def get_current_window_macos() -> Optional[dict]: +def initialize_get_macos_window() -> Callable[[], Dict[str, str]]: from . import macos - # The side effectful import breaks thigns here - app = macos.get_current_app() - print ("appname" + macos.get_app_name(app) + ", title" + macos.get_app_title(app)) - return {"appname": macos.get_app_name(app), "title": macos.get_app_title(app)} + def get_current_window_macos() -> Dict[str, str]: + app = macos.get_current_app() + print ("appname" + macos.get_app_name(app) + ", title" + macos.get_app_title(app)) + return {"appname": macos.get_app_name(app), "title": macos.get_app_title(app)} + return get_current_window_macos - -def get_current_window_windows() -> Optional[dict]: +def get_current_window_windows() -> Dict[str, str]: from . import windows window_handle = windows.get_active_window_handle() app = windows.get_app_name(window_handle) @@ -38,12 +38,12 @@ def get_current_window_windows() -> Optional[dict]: return {"appname": app, "title": title} -def get_current_window() -> Optional[dict]: +def get_current_window() -> Optional[Callable[[], Dict[str, str]]]: if sys.platform.startswith("linux"): - return get_current_window_linux() + return get_current_window_linux elif sys.platform == "darwin": - return get_current_window_macos() + return initialize_get_macos_window() elif sys.platform in ["win32", "cygwin"]: - return get_current_window_windows() + return get_current_window_windows else: raise Exception("Unknown platform: {}".format(sys.platform)) diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index f22ce497..10839f1e 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -54,13 +54,14 @@ def parse_args(default_poll_time: float, default_exclude_title: bool): def heartbeat_loop(client, bucket_id, poll_time, exclude_title=False): + current_window_fn = get_current_window() while True: if os.getppid() == 1: logger.info("window-watcher stopped because parent process died") break try: - current_window = get_current_window() + current_window = current_window_fn() logger.debug(current_window) except Exception as e: logger.error("Exception thrown while trying to get active window: {}".format(e)) From eb457e6f66e87affd5ae6147fedf8804f493fb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 1 Feb 2021 14:35:52 +0100 Subject: [PATCH 08/10] fix: worked on getting window titles on macOS without applescript --- aw_watcher_window/lib.py | 35 ++++--- aw_watcher_window/macos.py | 198 +++++++++++++++++++++++++++++-------- 2 files changed, 179 insertions(+), 54 deletions(-) diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index ceaf4a68..9660249c 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -1,5 +1,5 @@ import sys -from typing import Callable, Dict, Optional +from typing import Callable, Dict, Optional, Any def get_current_window_linux() -> Dict[str, str]: @@ -16,13 +16,11 @@ def get_current_window_linux() -> Dict[str, str]: return {"appname": cls, "title": name} -def initialize_get_macos_window() -> Callable[[], Dict[str, str]]: +def get_current_window_macos() -> Dict[str, str]: from . import macos - def get_current_window_macos() -> Dict[str, str]: - app = macos.get_current_app() - print ("appname" + macos.get_app_name(app) + ", title" + macos.get_app_title(app)) - return {"appname": macos.get_app_name(app), "title": macos.get_app_title(app)} - return get_current_window_macos + # TODO: This should return the latest event, or block until there is one + return macos.get_window_event() + def get_current_window_windows() -> Dict[str, str]: from . import windows @@ -38,12 +36,23 @@ def get_current_window_windows() -> Dict[str, str]: return {"appname": app, "title": title} -def get_current_window() -> Optional[Callable[[], Dict[str, str]]]: +def init(callback: Callable[[], Any]): + """ + Initializes whatever is needed. + Might block main thread, but will return control to `callback` on a new thread (or on the main thread, if not needed for something else). + """ + if sys.platform == "darwin": + from . import macos + return macos.init(callback) + else: + callback() + + +def get_current_window() -> Dict[str, str]: if sys.platform.startswith("linux"): - return get_current_window_linux + return get_current_window_linux() elif sys.platform == "darwin": - return initialize_get_macos_window() + return get_current_window_macos() elif sys.platform in ["win32", "cygwin"]: - return get_current_window_windows - else: - raise Exception("Unknown platform: {}".format(sys.platform)) + return get_current_window_windows() + raise Exception("Unknown platform: {}".format(sys.platform)) diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 9da77b35..94f31537 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,6 +1,14 @@ from threading import Thread -from typing import Dict, Optional, NoReturn -from AppKit import NSObject, NSNotification, NSWorkspace, NSRunningApplication, NSWorkspaceDidActivateApplicationNotification +from _thread import interrupt_main +from typing import Dict, Tuple, Callable +from time import sleep +from queue import Queue +from datetime import datetime, timezone +import logging + +from ApplicationServices import AXIsProcessTrusted +from AppKit import NSObject, NSNotification, NSWorkspace, NSRunningApplication, NSWorkspaceDidActivateApplicationNotification, NSWorkspaceDidDeactivateApplicationNotification +from AppKit import NSAlert, NSAlertFirstButtonReturn, NSURL from Quartz import ( CGWindowListCopyWindowInfo, kCGWindowListOptionOnScreenOnly, @@ -8,31 +16,70 @@ ) from PyObjCTools import AppHelper +logger = logging.getLogger(__name__) -class Observer(NSObject): - app = NSWorkspace.sharedWorkspace().frontmostApplication() +watcher: 'Watcher' - def get_front_app(self) -> NSRunningApplication: - return self.app - def handle_(self, noti: NSNotification) -> None: - self._set_front_app() +def init(callback: Callable) -> None: + print("Initializing...") + background_ensure_permissions() - def _set_front_app(self) -> None: - self.app = NSWorkspace.sharedWorkspace().frontmostApplication() + global watcher + watcher = Watcher() + hand_over_main(watcher, callback) # will take control of the main thread and spawn a second one for pushing heartbeats -observer = Observer.new() -NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_( - observer, - "handle:", - NSWorkspaceDidActivateApplicationNotification, - None) -AppHelper.runConsoleEventLoop() +def get_current_window() -> Dict[str, str]: + return watcher.get_next_event() -def get_current_app() -> NSRunningApplication: - return observer.get_front_app() + +class Observer(NSObject): + queue: "Queue[Tuple[float, Dict[str, str]]]" = Queue() + + def handle_(self, noti: NSNotification) -> None: + # called by the main event loop + print(f"Event received {noti}") + self._set_front_app() + + def _set_front_app(self) -> None: + app = NSWorkspace.sharedWorkspace().frontmostApplication() + self.queue.put(( + datetime.now(tz=timezone.utc).timestamp(), + {"app": get_app_name(app), "title": get_app_title(app)} + )) + + +class Watcher: + def __init__(self): + self.observer = Observer.new() + + def run_loop(self): + """Runs the main event loop, needs to run in the main thread.""" + # NOTE: This event doesn't trigger for window changes nor for application deactivations. + nc = NSWorkspace.sharedWorkspace().notificationCenter() + nc.addObserver_selector_name_object_( + self.observer, + "handle:", + NSWorkspaceDidActivateApplicationNotification, + None) + # Redundant, they fire at the same time. + # nc.addObserver_selector_name_object_( + # self.observer, + # "handle:", + # NSWorkspaceDidDeactivateApplicationNotification, + # None) + try: + AppHelper.runConsoleEventLoop() + except KeyboardInterrupt: + print("Main thread was asked to stop, quitting.") + + def stop(self): + AppHelper.stopEventLoop() + + def get_next_event(self) -> Dict[str, str]: + return self.observer.queue.get() def get_app_name(app: NSRunningApplication) -> str: @@ -47,34 +94,103 @@ def get_app_title(app: NSRunningApplication) -> str: for window in windowList: lookupPid = window['kCGWindowOwnerPID'] if (lookupPid == pid): - return window.get('kCGWindowName', 'Non-detected window title') + print(AXIsProcessTrusted()) + print(window) + title = window.get('kCGWindowName', None) + if title: + return title + else: + # This has a risk of spamming the user + logger.warning("Couldn't get window title, check accessibility permissions") + return '' - return "Couldn't find title by pid" + logger.warning("Couldn't find title by PID") + return '' -def background_ensure_permissions() -> None: - from multiprocessing import Process - permission_process = Process(target=ensure_permissions, args=(())) - permission_process.start() - return +def hand_over_main(watcher, callback): + """Initializes the main thread and calls back""" + Thread(target=callback, daemon=True).start() + watcher.run_loop() -def ensure_permissions() -> None: - from ApplicationServices import AXIsProcessTrusted - from AppKit import NSAlert, NSAlertFirstButtonReturn, NSWorkspace, NSURL +def background_ensure_permissions() -> None: + # For some reason I get a SIGSEGV when trying to fork here. + # Python mailinglists indicate that macOS APIs don't like fork(), and that one should use 'forkserver' instead. + # However, the Python docs also state that 'spawn' and 'forkserver' cannot be used with 'frozen' executables (as produced by PyInstaller). + # I guess we'll see... + from multiprocessing import Process, set_start_method + set_start_method('spawn') # should be the default in Python 3.8 + + print("Checking if we have accessibility permissions... ", end='') accessibility_permissions = AXIsProcessTrusted() - if not accessibility_permissions: - title = "Missing accessibility permissions" - info = "To let ActivityWatch capture window titles grant it accessibility permissions. \n If you've already given ActivityWatch accessibility permissions and are still seeing this dialog, try removing and re-adding them." + if accessibility_permissions: + print("We do!") + else: + print("We don't, showing dialog.") + Process(target=show_screencapture_permissions_dialog, args=(())).start() + + # Needed on macOS 10.15+ + # TODO: Add macOS version check to ensure it doesn't break on lower versions + # This would have been nice, but can't seem to call it through pyobjc... ('unknown location') + # from Quartz.CoreGraphics import CGRequestScreenCaptureAccess + # screencapture_permissions = CGRequestScreenCaptureAccess() + Process(target=show_screencapture_permissions_dialog, args=(())).start() + + +def show_accessibility_permissions_dialog() -> None: + title = "Missing accessibility permissions" + info = ("To let ActivityWatch capture window titles grant it accessibility permissions." + + "\n\nIf you've already given ActivityWatch accessibility permissions and are still seeing this dialog, try removing and re-adding them (not just checking/unchecking the box).") + + alert = NSAlert.new() + alert.setMessageText_(title) + alert.setInformativeText_(info) + + ok_button = alert.addButtonWithTitle_("Open accessibility settings") + + alert.addButtonWithTitle_("Close") + choice = alert.runModal() + if choice == NSAlertFirstButtonReturn: + NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")) + + +def show_screencapture_permissions_dialog() -> None: + # FIXME: When settings are opened, there's no way to add the application. + # Probably need to trigger the permission somehow. + title = "Missing screen capture permissions" + info = ("To let ActivityWatch capture window titles it needs screen capture permissions (since macOS 10.15+)." + + "\n\nIf you've already given ActivityWatch screen capture permissions and are still seeing this dialog, try removing and re-adding them (not just checking/unchecking the box).") + + alert = NSAlert.new() + alert.setMessageText_(title) + alert.setInformativeText_(info) + + ok_button = alert.addButtonWithTitle_("Open screen capture settings") + + alert.addButtonWithTitle_("Close") + choice = alert.runModal() + if choice == NSAlertFirstButtonReturn: + NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture")) + + +def test_secondthread(): + print("In second thread") + print("Listening for events...") + + while True: + event = watcher.get_next_event() + print(event) + + print("Exiting") + watcher.stop() + #interrupt_main() + +def test(): + # Used in debugging - alert = NSAlert.new() - alert.setMessageText_(title) - alert.setInformativeText_(info) + init(test_secondthread) - ok_button = alert.addButtonWithTitle_("Open accessibility settings") - alert.addButtonWithTitle_("Close") - choice = alert.runModal() - print(choice) - if choice == NSAlertFirstButtonReturn: - NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")) +if __name__ == "__main__": + test() From 94bc9695f0918907c954311c0e2261b9737f38db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 1 Feb 2021 14:38:23 +0100 Subject: [PATCH 09/10] fix: misc fixes to WIP macOS window title stuff --- aw_watcher_window/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index 269d5870..36193a60 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -59,14 +59,13 @@ def parse_args(default_poll_time: float, default_exclude_title: bool): def heartbeat_loop(client, bucket_id, poll_time, exclude_title=False): - current_window_fn = get_current_window() while True: if os.getppid() == 1: logger.info("window-watcher stopped because parent process died") break try: - current_window = current_window_fn() + current_window = get_current_window() logger.debug(current_window) except Exception as e: logger.error("Exception thrown while trying to get active window: {}".format(e)) @@ -90,4 +89,5 @@ def heartbeat_loop(client, bucket_id, poll_time, exclude_title=False): client.heartbeat(bucket_id, current_window_event, pulsetime=poll_time + 1.0, queued=True) + # TODO: Don't sleep on macOS where get_current_window is blocking and events are collected by underlying thread sleep(poll_time) From 9af99932aee107c4d44779f2d2284136d0269217 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20Bj=C3=A4reholt?= Date: Mon, 1 Feb 2021 14:44:20 +0100 Subject: [PATCH 10/10] fix: fixed things I broke when refactoring --- aw_watcher_window/lib.py | 3 ++- aw_watcher_window/macos.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index 9660249c..56463a4b 100644 --- a/aw_watcher_window/lib.py +++ b/aw_watcher_window/lib.py @@ -19,7 +19,8 @@ def get_current_window_linux() -> Dict[str, str]: def get_current_window_macos() -> Dict[str, str]: from . import macos # TODO: This should return the latest event, or block until there is one - return macos.get_window_event() + # TODO: This currently discards the event timestamp, but it should propagate upwards... + return macos.get_window_event()[1] def get_current_window_windows() -> Dict[str, str]: diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 94f31537..c60a3e17 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -31,7 +31,7 @@ def init(callback: Callable) -> None: hand_over_main(watcher, callback) # will take control of the main thread and spawn a second one for pushing heartbeats -def get_current_window() -> Dict[str, str]: +def get_window_event() -> Tuple[float, Dict[str, str]]: return watcher.get_next_event() @@ -78,7 +78,7 @@ def run_loop(self): def stop(self): AppHelper.stopEventLoop() - def get_next_event(self) -> Dict[str, str]: + def get_next_event(self) -> Tuple[float, Dict[str, str]]: return self.observer.queue.get()