diff --git a/README.md b/README.md index 8cfd46d9..bec7ea84 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. @@ -24,4 +23,3 @@ the poetry.lock file. 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.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=[], diff --git a/aw_watcher_window/lib.py b/aw_watcher_window/lib.py index 010fb802..56463a4b 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, Any -def get_current_window_linux() -> Optional[dict]: +def get_current_window_linux() -> Dict[str, str]: from . import xlib window = xlib.get_current_window() @@ -16,16 +16,14 @@ def get_current_window_linux() -> Optional[dict]: return {"appname": cls, "title": name} -def get_current_window_macos() -> Optional[dict]: +def get_current_window_macos() -> Dict[str, str]: from . import macos - info = macos.getInfo() - app = macos.getApp(info) - title = macos.getTitle(info) + # TODO: This should return the latest event, or block until there is one + # TODO: This currently discards the event timestamp, but it should propagate upwards... + return macos.get_window_event()[1] - return {"title": title, "appname": app} - -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) @@ -39,12 +37,23 @@ def get_current_window_windows() -> Optional[dict]: return {"appname": app, "title": title} -def get_current_window() -> Optional[dict]: +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() elif sys.platform == "darwin": return get_current_window_macos() elif sys.platform in ["win32", "cygwin"]: return get_current_window_windows() - else: - raise Exception("Unknown platform: {}".format(sys.platform)) + raise Exception("Unknown platform: {}".format(sys.platform)) diff --git a/aw_watcher_window/macos.py b/aw_watcher_window/macos.py index 968d4920..c60a3e17 100644 --- a/aw_watcher_window/macos.py +++ b/aw_watcher_window/macos.py @@ -1,45 +1,196 @@ -import subprocess -from subprocess import PIPE -import os +from threading import Thread +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, + kCGNullWindowID +) +from PyObjCTools import AppHelper -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() +logger = logging.getLogger(__name__) +watcher: 'Watcher' -def getApp(info: str) -> str: - return info.split('","')[0][1:] +def init(callback: Callable) -> None: + print("Initializing...") + background_ensure_permissions() -def getTitle(info: str) -> str: - return info.split('","')[1][:-1] + global watcher + watcher = Watcher() + hand_over_main(watcher, callback) # will take control of the main thread and spawn a second one for pushing heartbeats -def background_ensure_permissions() -> None: - from multiprocessing import Process - permission_process = Process(target=ensure_permissions, args=(())) - permission_process.start() - return + +def get_window_event() -> Tuple[float, Dict[str, str]]: + return watcher.get_next_event() + + +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) -> Tuple[float, Dict[str, str]]: + return self.observer.queue.get() + + +def get_app_name(app: NSRunningApplication) -> str: + return app.localizedName() + + +def get_app_title(app: NSRunningApplication) -> str: + pid = app.processIdentifier() + options = kCGWindowListOptionOnScreenOnly + windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) + + for window in windowList: + lookupPid = window['kCGWindowOwnerPID'] + if (lookupPid == pid): + 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 '' + + logger.warning("Couldn't find title by PID") + 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() diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index af365e6f..36193a60 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -89,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) diff --git a/aw_watcher_window/printAppTitle.scpt b/aw_watcher_window/printAppTitle.scpt deleted file mode 100644 index d004b036..00000000 Binary files a/aw_watcher_window/printAppTitle.scpt and /dev/null differ