Some checks failed
Build / test on ubuntu-latest (py-3.9) (push) Failing after 2m36s
Build / typecheck (push) Successful in 1m38s
Build / test on macos-latest (py-3.9) (push) Has been cancelled
Build / test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
226 lines
7.2 KiB
Python
226 lines
7.2 KiB
Python
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from time import sleep
|
|
|
|
from aw_client import ActivityWatchClient
|
|
from aw_core.log import setup_logging
|
|
from aw_core.models import Event
|
|
|
|
from .config import parse_args
|
|
from .exceptions import FatalError
|
|
from .lib import get_current_window
|
|
from .research_filter import transform as research_transform
|
|
from .macos_cli import build_swift_command
|
|
from .macos_permissions import background_ensure_permissions
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# run with LOG_LEVEL=DEBUG
|
|
log_level = os.environ.get("LOG_LEVEL")
|
|
if log_level:
|
|
logger.setLevel(logging.__getattribute__(log_level.upper()))
|
|
|
|
|
|
def compute_pulsetime(poll_time: float) -> float:
|
|
"""Scale pulsetime with poll_time so OS scheduling jitter doesn't break heartbeat chains.
|
|
|
|
At poll_time=1s, jitter ~0.15s is well within 1s margin (poll_time+1).
|
|
At poll_time=5s, jitter ~0.75s exceeds the 1s margin ~10% of the time,
|
|
causing missing time in the timeline. max(poll_time*1.5, poll_time+1) keeps
|
|
backward compatibility at poll_time≤2s while fixing the problem at higher
|
|
polling intervals. See: https://github.com/ActivityWatch/activitywatch/issues/1177
|
|
"""
|
|
return max(poll_time * 1.5, poll_time + 1.0)
|
|
|
|
|
|
def kill_process(pid):
|
|
logger.info("Killing process {}".format(pid))
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
logger.info("Process {} already dead".format(pid))
|
|
|
|
|
|
def try_compile_title_regex(title):
|
|
try:
|
|
return re.compile(title, re.IGNORECASE)
|
|
except re.error:
|
|
logger.error(f"Invalid regex pattern: {title}")
|
|
exit(1)
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
if sys.platform.startswith("linux") and (
|
|
"DISPLAY" not in os.environ or not os.environ["DISPLAY"]
|
|
):
|
|
raise Exception("DISPLAY environment variable not set")
|
|
|
|
setup_logging(
|
|
name="aw-watcher-window",
|
|
testing=args.testing,
|
|
verbose=args.verbose,
|
|
log_stderr=True,
|
|
log_file=True,
|
|
)
|
|
if sys.platform == "darwin":
|
|
background_ensure_permissions()
|
|
|
|
client = ActivityWatchClient(
|
|
"aw-watcher-window", host=args.host, port=args.port, testing=args.testing
|
|
)
|
|
|
|
bucket_id = f"{client.client_name}_{client.client_hostname}"
|
|
event_type = "currentwindow"
|
|
|
|
client.create_bucket(bucket_id, event_type, queued=True)
|
|
|
|
logger.info("aw-watcher-window started")
|
|
client.wait_for_start()
|
|
|
|
with client:
|
|
research_category_map = (
|
|
args.research_category_map
|
|
if args.research_enabled
|
|
else None
|
|
)
|
|
research_app_category_map = (
|
|
args.research_app_category_map
|
|
if args.research_enabled
|
|
else None
|
|
)
|
|
if sys.platform == "darwin" and args.strategy == "swift":
|
|
logger.info("Using swift strategy, calling out to swift binary")
|
|
binpath = os.path.join(
|
|
os.path.dirname(os.path.realpath(__file__)), "aw-watcher-window-macos"
|
|
)
|
|
|
|
try:
|
|
p = subprocess.Popen(
|
|
build_swift_command(
|
|
binpath,
|
|
client.server_address,
|
|
bucket_id,
|
|
client.client_hostname,
|
|
client.client_name,
|
|
exclude_title=args.exclude_title,
|
|
exclude_titles=args.exclude_titles,
|
|
research_category_map=research_category_map,
|
|
research_app_category_map=research_app_category_map,
|
|
)
|
|
)
|
|
# terminate swift process when this process dies
|
|
signal.signal(signal.SIGTERM, lambda *_: kill_process(p.pid))
|
|
p.wait()
|
|
except KeyboardInterrupt:
|
|
print("KeyboardInterrupt")
|
|
kill_process(p.pid)
|
|
else:
|
|
heartbeat_loop(
|
|
client,
|
|
bucket_id,
|
|
poll_time=args.poll_time,
|
|
strategy=args.strategy,
|
|
exclude_title=args.exclude_title,
|
|
exclude_titles=[
|
|
try_compile_title_regex(title)
|
|
for title in args.exclude_titles
|
|
if title is not None
|
|
],
|
|
research_category_map=research_category_map,
|
|
research_app_category_map=research_app_category_map,
|
|
)
|
|
|
|
|
|
def heartbeat_loop(
|
|
client,
|
|
bucket_id,
|
|
poll_time,
|
|
strategy,
|
|
exclude_title=False,
|
|
exclude_titles=[],
|
|
research_category_map=None,
|
|
research_app_category_map=None,
|
|
):
|
|
while True:
|
|
if os.getppid() == 1:
|
|
logger.info("window-watcher stopped because parent process died")
|
|
break
|
|
|
|
current_window = None
|
|
try:
|
|
current_window = get_current_window(strategy)
|
|
logger.debug(current_window)
|
|
except (FatalError, OSError):
|
|
# Fatal exceptions should quit the program
|
|
try:
|
|
logger.exception("Fatal error, stopping")
|
|
except OSError:
|
|
pass
|
|
break
|
|
except Exception:
|
|
# Non-fatal exceptions should be logged
|
|
try:
|
|
# If stdout has been closed, this exception-print can cause (I think)
|
|
# OSError: [Errno 5] Input/output error
|
|
# See: https://github.com/ActivityWatch/activitywatch/issues/756#issue-1296352264
|
|
#
|
|
# However, I'm unable to reproduce the OSError in a test (where I close stdout before logging),
|
|
# so I'm in uncharted waters here... but this solution should work.
|
|
logger.exception("Exception thrown while trying to get active window")
|
|
except OSError:
|
|
break
|
|
|
|
if current_window is None:
|
|
logger.debug("Unable to fetch window, trying again on next poll")
|
|
else:
|
|
current_window = transform_window(
|
|
current_window,
|
|
exclude_title=exclude_title,
|
|
exclude_titles=exclude_titles,
|
|
research_category_map=research_category_map,
|
|
research_app_category_map=research_app_category_map,
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
current_window_event = Event(timestamp=now, data=current_window)
|
|
|
|
client.heartbeat(
|
|
bucket_id,
|
|
current_window_event,
|
|
pulsetime=compute_pulsetime(poll_time),
|
|
queued=True,
|
|
)
|
|
|
|
sleep(poll_time)
|
|
|
|
|
|
def transform_window(
|
|
current_window,
|
|
exclude_title=False,
|
|
exclude_titles=None,
|
|
research_category_map=None,
|
|
research_app_category_map=None,
|
|
):
|
|
if research_category_map is not None:
|
|
return research_transform(
|
|
current_window,
|
|
research_category_map,
|
|
app_category_map=research_app_category_map,
|
|
)
|
|
|
|
for pattern in exclude_titles or []:
|
|
if pattern.search(current_window["title"]):
|
|
current_window["title"] = "excluded"
|
|
|
|
if exclude_title:
|
|
current_window["title"] = "excluded"
|
|
|
|
return current_window
|