Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
aw-watcher-window
=================
# aw-watcher-window

Cross-platform window-Watcher for Linux (X11), macOS, Windows.

Expand All @@ -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.

2 changes: 1 addition & 1 deletion aw-watcher-window.spec
Original file line number Diff line number Diff line change
Expand Up @@ -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=[],
Expand Down
33 changes: 21 additions & 12 deletions aw_watcher_window/lib.py
Original file line number Diff line number Diff line change
@@ -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()

Expand All @@ -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)
Expand All @@ -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))
213 changes: 182 additions & 31 deletions aw_watcher_window/macos.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions aw_watcher_window/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Binary file removed aw_watcher_window/printAppTitle.scpt
Binary file not shown.