Skip to content

Commit aecf47f

Browse files
Use JXA for window status on macOS, include url in event data (#52)
Co-authored-by: Erik Bjäreholt <erik@bjareho.lt>
1 parent 44e1904 commit aecf47f

File tree

10 files changed

+332
-161
lines changed

10 files changed

+332
-161
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ To install the latest git version directly from github without cloning, run
1515
`pip install git+https://github.com/ActivityWatch/aw-watcher-window.git`
1616

1717
To install from a cloned version, cd into the directory and run
18-
`poetry install` to install inside an virtualenv. If you want to install it
19-
system-wide it can be installed with `pip install .`, but that has the issue
20-
that it might not get the exact version of the dependencies due to not reading
21-
the poetry.lock file.
18+
`poetry install` to install inside an virtualenv. You can run the binary via `aw-watcher-window`.
19+
20+
If you want to install it system-wide it can be installed with `pip install .`, but that has the issue
21+
that it might not get the exact version of the dependencies due to not reading the poetry.lock file.
2222

2323
## Note to macOS users
2424

aw_watcher_window/config.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
from configparser import ConfigParser
2+
import argparse
23

34
from aw_core.config import load_config as _load_config
45

5-
66
def load_config():
77
default_client_config = ConfigParser()
8-
default_client_config["aw-watcher-window"] = {
9-
"exclude_title": False,
10-
"poll_time": "1.0"
11-
}
12-
default_client_config["aw-watcher-window-testing"] = {
8+
default_client_config["aw-watcher-window"] = default_client_config["aw-watcher-window-testing"] = {
139
"exclude_title": False,
14-
"poll_time": "1.0"
10+
"poll_time": "1.0",
11+
"strategy_macos": "jxa"
1512
}
1613

1714
# TODO: Handle so aw-watcher-window testing gets loaded instead of testing is on
1815
return _load_config("aw-watcher-window", default_client_config)["aw-watcher-window"]
16+
17+
def parse_args():
18+
config = load_config()
19+
20+
default_poll_time = config.getfloat("poll_time")
21+
default_exclude_title = config.getboolean("exclude_title")
22+
default_strategy_macos = config.get("strategy_macos")
23+
24+
parser = argparse.ArgumentParser("A cross platform window watcher for Activitywatch.\nSupported on: Linux (X11), macOS and Windows.")
25+
parser.add_argument("--testing", dest="testing", action="store_true")
26+
parser.add_argument("--exclude-title", dest="exclude_title", action="store_true", default=default_exclude_title)
27+
parser.add_argument("--verbose", dest="verbose", action="store_true")
28+
parser.add_argument("--poll-time", dest="poll_time", type=float, default=default_poll_time)
29+
parser.add_argument("--strategy", dest="strategy", default=default_strategy_macos, choices=["jxa", "applescript"], help="(macOS only) strategy to use for retrieving the active window")
30+
parsed_args = parser.parse_args()
31+
return parsed_args

aw_watcher_window/lib.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
def get_current_window_linux() -> Optional[dict]:
66
from . import xlib
7+
78
window = xlib.get_current_window()
89

910
if window is None:
@@ -13,20 +14,28 @@ def get_current_window_linux() -> Optional[dict]:
1314
cls = xlib.get_window_class(window)
1415
name = xlib.get_window_name(window)
1516

16-
return {"appname": cls, "title": name}
17+
return {"app": cls, "title": name}
18+
1719

20+
def get_current_window_macos(strategy: str) -> Optional[dict]:
21+
# TODO should we use unknown when the title is blank like the other platforms?
1822

19-
def get_current_window_macos() -> Optional[dict]:
20-
from . import macos
21-
info = macos.getInfo()
22-
app = macos.getApp(info)
23-
title = macos.getTitle(info)
23+
# `jxa` is the default & preferred strategy. It includes the url + incognito status
24+
if strategy == "jxa":
25+
from . import macos_jxa
2426

25-
return {"title": title, "appname": app}
27+
return macos_jxa.getInfo()
28+
elif strategy == "applescript":
29+
from . import macos_applescript
30+
31+
return macos_applescript.getInfo()
32+
else:
33+
raise ValueError(f"invalid strategy '{strategy}'")
2634

2735

2836
def get_current_window_windows() -> Optional[dict]:
2937
from . import windows
38+
3039
window_handle = windows.get_active_window_handle()
3140
app = windows.get_app_name(window_handle)
3241
title = windows.get_window_title(window_handle)
@@ -36,14 +45,14 @@ def get_current_window_windows() -> Optional[dict]:
3645
if title is None:
3746
title = "unknown"
3847

39-
return {"appname": app, "title": title}
48+
return {"app": app, "title": title}
4049

4150

42-
def get_current_window() -> Optional[dict]:
51+
def get_current_window(strategy: str = None) -> Optional[dict]:
4352
if sys.platform.startswith("linux"):
4453
return get_current_window_linux()
4554
elif sys.platform == "darwin":
46-
return get_current_window_macos()
55+
return get_current_window_macos(strategy)
4756
elif sys.platform in ["win32", "cygwin"]:
4857
return get_current_window_windows()
4958
else:

aw_watcher_window/macos.py

Lines changed: 0 additions & 45 deletions
This file was deleted.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
import subprocess
3+
from subprocess import PIPE
4+
from typing import Dict
5+
6+
7+
# the applescript version of the macos strategy is kept here until the jxa
8+
# approach is proven out in production environments
9+
# https://github.com/ActivityWatch/aw-watcher-window/pull/52
10+
11+
12+
def getInfo() -> Dict[str, str]:
13+
cmd = [
14+
"osascript",
15+
os.path.join(os.path.dirname(os.path.realpath(__file__)), "printAppTitle.scpt"),
16+
]
17+
p = subprocess.run(cmd, stdout=PIPE)
18+
info = str(p.stdout, "utf8").strip()
19+
20+
app = getApp(info)
21+
title = getTitle(info)
22+
23+
return {"app": app, "title": title}
24+
25+
26+
def getApp(info: str) -> str:
27+
return info.split('","')[0][1:]
28+
29+
30+
def getTitle(info: str) -> str:
31+
return info.split('","')[1][:-1]

aw_watcher_window/macos_jxa.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import os
2+
import json
3+
import logging
4+
from typing import Dict
5+
6+
logger = logging.getLogger(__name__)
7+
script = None
8+
9+
10+
def compileScript():
11+
# https://stackoverflow.com/questions/44209057/how-can-i-run-jxa-from-swift
12+
# https://stackoverflow.com/questions/16065162/calling-applescript-from-python-without-using-osascript-or-appscript
13+
from OSAKit import OSAScript, OSALanguage
14+
15+
scriptPath = os.path.join(
16+
os.path.dirname(os.path.realpath(__file__)), "printAppStatus.jxa"
17+
)
18+
scriptContents = open(scriptPath, mode="r").read()
19+
javascriptLanguage = OSALanguage.languageForName_("JavaScript")
20+
21+
script = OSAScript.alloc().initWithSource_language_(
22+
scriptContents, javascriptLanguage
23+
)
24+
(success, err) = script.compileAndReturnError_(None)
25+
26+
# should only occur if jxa was modified incorrectly
27+
if not success:
28+
raise Exception("error compiling jxa script")
29+
30+
return script
31+
32+
33+
def getInfo() -> Dict[str, str]:
34+
# use a global variable to cache the compiled script for performance
35+
global script
36+
if not script:
37+
script = compileScript()
38+
39+
(result, err) = script.executeAndReturnError_(None)
40+
41+
if err:
42+
# error structure:
43+
# {
44+
# NSLocalizedDescription = "Error: Error: Can't get object.";
45+
# NSLocalizedFailureReason = "Error: Error: Can't get object.";
46+
# OSAScriptErrorBriefMessageKey = "Error: Error: Can't get object.";
47+
# OSAScriptErrorMessageKey = "Error: Error: Can't get object.";
48+
# OSAScriptErrorNumberKey = "-1728";
49+
# OSAScriptErrorRangeKey = "NSRange: {0, 0}";
50+
# }
51+
52+
raise Exception("jxa error: {}".format(err["NSLocalizedDescription"]))
53+
54+
return json.loads(result.stringValue())
55+
56+
57+
def background_ensure_permissions() -> None:
58+
from multiprocessing import Process
59+
60+
permission_process = Process(target=ensure_permissions, args=(()))
61+
permission_process.start()
62+
return
63+
64+
65+
def ensure_permissions() -> None:
66+
from ApplicationServices import AXIsProcessTrusted
67+
from AppKit import NSAlert, NSAlertFirstButtonReturn, NSWorkspace, NSURL
68+
69+
accessibility_permissions = AXIsProcessTrusted()
70+
if not accessibility_permissions:
71+
title = "Missing accessibility permissions"
72+
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."
73+
74+
alert = NSAlert.new()
75+
alert.setMessageText_(title)
76+
alert.setInformativeText_(info)
77+
78+
ok_button = alert.addButtonWithTitle_("Open accessibility settings")
79+
80+
alert.addButtonWithTitle_("Close")
81+
choice = alert.runModal()
82+
print(choice)
83+
if choice == NSAlertFirstButtonReturn:
84+
NSWorkspace.sharedWorkspace().openURL_(
85+
NSURL.URLWithString_(
86+
"x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
87+
)
88+
)
89+
90+
91+
if __name__ == "__main__":
92+
print(getInfo())
93+
print("Waiting 5 seconds...")
94+
import time
95+
96+
time.sleep(5)
97+
print(getInfo())

aw_watcher_window/main.py

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import argparse
21
import logging
32
import traceback
43
import sys
@@ -11,18 +10,17 @@
1110
from aw_client import ActivityWatchClient
1211

1312
from .lib import get_current_window
14-
from .config import load_config
13+
from .config import parse_args
1514

1615
logger = logging.getLogger(__name__)
1716

17+
# run with LOG_LEVEL=DEBUG
18+
log_level = os.environ.get('LOG_LEVEL')
19+
if log_level:
20+
logger.setLevel(logging.__getattribute__(log_level.upper()))
1821

1922
def main():
20-
# Read settings from config
21-
config = load_config()
22-
args = parse_args(
23-
default_poll_time=config.getfloat("poll_time"),
24-
default_exclude_title=config.getboolean("exclude_title"),
25-
)
23+
args = parse_args()
2624

2725
if sys.platform.startswith("linux") and ("DISPLAY" not in os.environ or not os.environ["DISPLAY"]):
2826
raise Exception("DISPLAY environment variable not set")
@@ -45,43 +43,30 @@ def main():
4543

4644
sleep(1) # wait for server to start
4745
with client:
48-
heartbeat_loop(client, bucket_id, poll_time=args.poll_time, exclude_title=args.exclude_title)
46+
heartbeat_loop(client, bucket_id, poll_time=args.poll_time, strategy=args.strategy, exclude_title=args.exclude_title)
4947

50-
51-
def parse_args(default_poll_time: float, default_exclude_title: bool):
52-
"""config contains defaults loaded from the config file"""
53-
parser = argparse.ArgumentParser("A cross platform window watcher for Activitywatch.\nSupported on: Linux (X11), macOS and Windows.")
54-
parser.add_argument("--testing", dest="testing", action="store_true")
55-
parser.add_argument("--exclude-title", dest="exclude_title", action="store_true", default=default_exclude_title)
56-
parser.add_argument("--verbose", dest="verbose", action="store_true")
57-
parser.add_argument("--poll-time", dest="poll_time", type=float, default=default_poll_time)
58-
return parser.parse_args()
59-
60-
61-
def heartbeat_loop(client, bucket_id, poll_time, exclude_title=False):
48+
def heartbeat_loop(client, bucket_id, poll_time, strategy, exclude_title=False):
6249
while True:
6350
if os.getppid() == 1:
6451
logger.info("window-watcher stopped because parent process died")
6552
break
6653

6754
try:
68-
current_window = get_current_window()
55+
current_window = get_current_window(strategy)
6956
logger.debug(current_window)
7057
except Exception as e:
7158
logger.error("Exception thrown while trying to get active window: {}".format(e))
7259
traceback.print_exc()
73-
current_window = {"appname": "unknown", "title": "unknown"}
60+
current_window = {"app": "unknown", "title": "unknown"}
7461

7562
now = datetime.now(timezone.utc)
7663
if current_window is None:
7764
logger.debug('Unable to fetch window, trying again on next poll')
7865
else:
79-
# Create current_window event
80-
data = {
81-
"app": current_window["appname"],
82-
"title": current_window["title"] if not exclude_title else "excluded"
83-
}
84-
current_window_event = Event(timestamp=now, data=data)
66+
if exclude_title:
67+
current_window["title"] = "excluded"
68+
69+
current_window_event = Event(timestamp=now, data=current_window)
8570

8671
# Set pulsetime to 1 second more than the poll_time
8772
# This since the loop takes more time than poll_time

0 commit comments

Comments
 (0)