From 3e9493b70c06f52d4c09c775eab6eceab6916805 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 11:59:49 +0000 Subject: [PATCH 01/12] feat: add Research Edition privacy filter for window titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in transform that classifies browser window titles into study categories and drops non-browser titles entirely, replicating the ERC fork approach without maintaining a separate fork. - New module `research_filter.py`: `is_browser()`, `classify_title()`, `transform()` — all pure functions, no dependencies beyond stdlib - Config: `research_enabled = false` (default off) + TOML table `[aw-watcher-window.research_category_map]` for the researcher's map - CLI: `--research` flag mirrors the config option - main.py: `heartbeat_loop` applies the transform after existing `exclude_title`/`exclude_titles` steps - 19 tests covering is_browser, classify_title, and transform (including input-immutability, url/incognito preservation, macOS JXA extra fields) macOS note: the `swift` strategy delegates to the compiled Swift helper and bypasses this Python transform. Use `--strategy jxa` or `--strategy applescript` on macOS for Research Edition support. Part of ActivityWatch Research Edition for the Lund/IIIEE study. --- aw_watcher_window/config.py | 11 +++ aw_watcher_window/main.py | 21 +++- aw_watcher_window/research_filter.py | 88 +++++++++++++++++ tests/test_research_filter.py | 138 +++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 aw_watcher_window/research_filter.py create mode 100644 tests/test_research_filter.py diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py index 81eabc28..ddbb44d4 100644 --- a/aw_watcher_window/config.py +++ b/aw_watcher_window/config.py @@ -8,6 +8,9 @@ exclude_titles = [] poll_time = 1.0 strategy_macos = "swift" +research_enabled = false + +[aw-watcher-window.research_category_map] """.strip() @@ -22,6 +25,7 @@ def parse_args(): default_exclude_title = config["exclude_title"] default_exclude_titles = config["exclude_titles"] default_strategy_macos = config["strategy_macos"] + default_research_enabled = config.get("research_enabled", False) parser = argparse.ArgumentParser( description="A cross platform window watcher for Activitywatch.\nSupported on: Linux (X11), macOS and Windows." @@ -53,5 +57,12 @@ def parse_args(): choices=["jxa", "applescript", "swift"], help="(macOS only) strategy to use for retrieving the active window", ) + parser.add_argument( + "--research", + dest="research_enabled", + action="store_true", + default=default_research_enabled, + help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file. Not supported with --strategy swift on macOS.", + ) parsed_args = parser.parse_args() return parsed_args diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index e271c703..031f45e7 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -11,9 +11,10 @@ from aw_core.log import setup_logging from aw_core.models import Event -from .config import parse_args +from .config import load_config, 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 @@ -98,6 +99,12 @@ def main(): print("KeyboardInterrupt") kill_process(p.pid) else: + config = load_config() + research_category_map = ( + dict(config.get("research_category_map", {})) + if args.research_enabled + else None + ) heartbeat_loop( client, bucket_id, @@ -109,11 +116,18 @@ def main(): for title in args.exclude_titles if title is not None ], + research_category_map=research_category_map, ) def heartbeat_loop( - client, bucket_id, poll_time, strategy, exclude_title=False, exclude_titles=[] + client, + bucket_id, + poll_time, + strategy, + exclude_title=False, + exclude_titles=[], + research_category_map=None, ): while True: if os.getppid() == 1: @@ -154,6 +168,9 @@ def heartbeat_loop( if exclude_title: current_window["title"] = "excluded" + if research_category_map is not None: + current_window = research_transform(current_window, research_category_map) + now = datetime.now(timezone.utc) current_window_event = Event(timestamp=now, data=current_window) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py new file mode 100644 index 00000000..a68038d7 --- /dev/null +++ b/aw_watcher_window/research_filter.py @@ -0,0 +1,88 @@ +""" +Research Edition: privacy-sensitive window-title → category transform. + +Replicates the approach used by AW research forks: +- Browser apps: window titles get classified into study categories. + Unmatched titles → 'excluded'. +- Non-browser apps: title is dropped entirely (only app name recorded). + +Enable via config (``research_enabled = true``) and supply a category map +in ``[aw-watcher-window.research_category_map]``. The map is left empty +by default so normal users see zero behaviour change. + +macOS note: the ``swift`` strategy delegates to the compiled Swift helper and +bypasses this Python transform. Use ``--strategy jxa`` or ``--strategy +applescript`` on macOS if you need Research Edition support there. +""" + +# Known browser applications (lowercase, for case-insensitive matching) +BROWSER_APPS = frozenset( + { + "chrome", + "google chrome", + "google chrome canary", + "chromium", + "brave browser", + "brave", + "firefox", + "firefox developer edition", + "safari", + "edge", + "opera", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", + "safari.exe", + } +) + + +def is_browser(app: str) -> bool: + """Return True if *app* is a known browser (case-insensitive).""" + return app.strip().lower() in BROWSER_APPS + + +def classify_title(title: str, category_map: dict) -> str: + """ + Classify a browser window *title* into a study category via substring matching. + + *category_map*: ``{substring: category_name}`` — first matching substring wins. + Returns the category name, or ``"excluded"`` if nothing matched. + """ + title_lower = title.lower() + for pattern, category in category_map.items(): + if pattern.lower() in title_lower: + return category + return "excluded" + + +def transform(window: dict, category_map: dict | None) -> dict: + """ + Apply Research Edition transforms to a window-data dict. + + *window*: ``{"app": str, "title": str}`` (may also carry *url*, + *incognito* on macOS JXA — those are preserved for browsers). + *category_map*: study-specific mapping dict, or ``None``/empty to no-op. + + Returns the transformed window dict (never mutates the input). + """ + if not category_map: + return window + + app = window.get("app", "") + + if is_browser(app): + # Browser: replace title with a study category + title = window.get("title", "") + category = classify_title(title, category_map) + return {**window, "title": category} + else: + # Non-browser: drop the title entirely (only app name recorded) + result = {"app": app} + # Preserve extra fields (url, incognito) that may exist on macOS JXA + for k in ("url", "incognito"): + if k in window: + result[k] = window[k] + return result diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py new file mode 100644 index 00000000..80e3317f --- /dev/null +++ b/tests/test_research_filter.py @@ -0,0 +1,138 @@ +"""Tests for aw-watcher-window Research Edition filter.""" + +import unittest + +from aw_watcher_window.research_filter import ( + BROWSER_APPS, + classify_title, + is_browser, + transform, +) + + +class TestIsBrowser(unittest.TestCase): + def test_known_browsers(self): + for app in BROWSER_APPS: + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + + def test_case_insensitive(self): + for app in ("Chrome", "GOOGLE CHROME", "Firefox", "Safari", "Edge"): + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + + def test_non_browsers(self): + for app in ("Slack", "Terminal", "iTerm2", "Code", "zoom.us", ""): + self.assertFalse(is_browser(app), f"{app!r} should not be a browser") + + +class TestClassifyTitle(unittest.TestCase): + CATEGORY_MAP = { + "youtube": "Youtube", + "facebook": "Facebook", + "twitter": "Twitter", + "reddit": "Forums & Blogs", + "gmail": "Email", + "outlook": "Email", + } + + def test_exact_substring_match(self): + self.assertEqual( + classify_title("YouTube - Google Chrome", self.CATEGORY_MAP), + "Youtube", + ) + + def test_case_insensitive_match(self): + self.assertEqual( + classify_title("YOUTUBE - Mozilla Firefox", self.CATEGORY_MAP), + "Youtube", + ) + + def test_title_with_notification_prefix(self): + self.assertEqual( + classify_title("Facebook (1) Notification - Brave Browser", self.CATEGORY_MAP), + "Facebook", + ) + + def test_no_match_returns_excluded(self): + self.assertEqual( + classify_title("systemd (SYSTEM) - man", self.CATEGORY_MAP), + "excluded", + ) + + def test_first_match_wins(self): + # Both "facebook" and "twitter" present — first pattern in iteration wins + result = classify_title("Facebook Twitter integration - Chrome", self.CATEGORY_MAP) + self.assertEqual(result, "Facebook") + + def test_empty_title(self): + self.assertEqual(classify_title("", self.CATEGORY_MAP), "excluded") + + +class TestTransform(unittest.TestCase): + CATEGORY_MAP = { + "youtube": "Youtube", + "facebook": "Facebook", + } + + def test_no_category_map_returns_unchanged(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + self.assertEqual(transform(window, None), window) + + def test_empty_category_map_returns_unchanged(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + self.assertEqual(transform(window, {}), window) + + def test_browser_title_classified(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], "Chrome") + self.assertEqual(result["title"], "Youtube") + + def test_browser_title_excluded_when_no_match(self): + window = {"app": "Firefox", "title": "Vim documentation"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], "Firefox") + self.assertEqual(result["title"], "excluded") + + def test_non_browser_drops_title(self): + window = {"app": "iTerm2", "title": "~/projects/foo"} + result = transform(window, self.CATEGORY_MAP) + self.assertNotIn("title", result) + self.assertEqual(result["app"], "iTerm2") + + def test_non_browser_preserves_extra_fields(self): + window = {"app": "Finder", "title": "Documents", "url": "file:///Users/"} + result = transform(window, self.CATEGORY_MAP) + self.assertNotIn("title", result) + self.assertEqual(result["app"], "Finder") + self.assertEqual(result["url"], "file:///Users/") + + def test_browser_preserves_url_and_incognito(self): + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com", + "incognito": False, + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertEqual(result["url"], "https://youtube.com") + self.assertEqual(result["incognito"], False) + + def test_non_browser_minimal(self): + window = {"app": "Terminal", "title": "bash"} + self.assertEqual(transform(window, self.CATEGORY_MAP), {"app": "Terminal"}) + + def test_browser_with_leading_trailing_spaces_in_app(self): + window = {"app": " Brave Browser ", "title": "Facebook"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Facebook") + + def test_input_not_mutated(self): + window = {"app": "Chrome", "title": "YouTube - Chrome"} + original = dict(window) + transform(window, self.CATEGORY_MAP) + self.assertEqual(window, original) + + +if __name__ == "__main__": + unittest.main() From ef488e805eff929a2c5f28bfe7066c53a6cad676 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 12:05:52 +0000 Subject: [PATCH 02/12] fix(research_filter): use Optional[dict] for Python 3.9 compat dict | None union syntax requires Python 3.10+; replace with Optional[dict] from typing to fix typecheck + runtime errors on py3.9. --- aw_watcher_window/research_filter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index a68038d7..c9c9c869 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -15,6 +15,8 @@ applescript`` on macOS if you need Research Edition support there. """ +from typing import Optional + # Known browser applications (lowercase, for case-insensitive matching) BROWSER_APPS = frozenset( { @@ -58,7 +60,7 @@ def classify_title(title: str, category_map: dict) -> str: return "excluded" -def transform(window: dict, category_map: dict | None) -> dict: +def transform(window: dict, category_map: Optional[dict]) -> dict: """ Apply Research Edition transforms to a window-data dict. From 36c21247bc243f9201321472962e5ccd87388726 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 12:11:54 +0000 Subject: [PATCH 03/12] fix(research): fail loudly for unsupported swift mode --- aw_watcher_window/config.py | 1 + aw_watcher_window/main.py | 19 +++++++++++++++--- aw_watcher_window/research_filter.py | 1 - tests/test_config.py | 23 ++++++++++++++++++++++ tests/test_main.py | 29 ++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 tests/test_config.py diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py index ddbb44d4..716ec4f3 100644 --- a/aw_watcher_window/config.py +++ b/aw_watcher_window/config.py @@ -65,4 +65,5 @@ def parse_args(): help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file. Not supported with --strategy swift on macOS.", ) parsed_args = parser.parse_args() + parsed_args.research_category_map = dict(config.get("research_category_map", {})) return parsed_args diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index 031f45e7..9c3d6fff 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -11,7 +11,7 @@ from aw_core.log import setup_logging from aw_core.models import Event -from .config import load_config, parse_args +from .config import parse_args from .exceptions import FatalError from .lib import get_current_window from .research_filter import transform as research_transform @@ -42,6 +42,19 @@ def try_compile_title_regex(title): exit(1) +def ensure_research_strategy_supported(args): + if ( + args.research_enabled + and sys.platform == "darwin" + and args.strategy == "swift" + ): + raise FatalError( + "Research Edition mode is not supported with the macOS swift strategy. " + "Use --strategy jxa or --strategy applescript so window titles are " + "filtered before upload." + ) + + def main(): args = parse_args() @@ -57,6 +70,7 @@ def main(): log_stderr=True, log_file=True, ) + ensure_research_strategy_supported(args) if sys.platform == "darwin": background_ensure_permissions() @@ -99,9 +113,8 @@ def main(): print("KeyboardInterrupt") kill_process(p.pid) else: - config = load_config() research_category_map = ( - dict(config.get("research_category_map", {})) + args.research_category_map if args.research_enabled else None ) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index c9c9c869..c1e1e48f 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -36,7 +36,6 @@ "firefox.exe", "msedge.exe", "opera.exe", - "safari.exe", } ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..f04d13ac --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,23 @@ +import sys + +from aw_watcher_window import config as config_module + + +def test_parse_args_attaches_research_category_map(monkeypatch): + monkeypatch.setattr( + config_module, + "load_config", + lambda: { + "exclude_title": False, + "exclude_titles": [], + "poll_time": 1.0, + "strategy_macos": "swift", + "research_enabled": True, + "research_category_map": {"youtube": "Entertainment"}, + }, + ) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window"]) + + args = config_module.parse_args() + + assert args.research_category_map == {"youtube": "Entertainment"} diff --git a/tests/test_main.py b/tests/test_main.py index 96e04b11..e2180713 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,4 +1,33 @@ +from types import SimpleNamespace + +import pytest + +import aw_watcher_window.main as main_module +from aw_watcher_window.exceptions import FatalError from aw_watcher_window.macos_cli import build_swift_command +from aw_watcher_window.main import ensure_research_strategy_supported + + +def test_research_mode_rejects_macos_swift_strategy(monkeypatch): + monkeypatch.setattr(main_module.sys, "platform", "darwin") + args = SimpleNamespace(research_enabled=True, strategy="swift") + + with pytest.raises(FatalError, match="not supported with the macOS swift strategy"): + ensure_research_strategy_supported(args) + + +def test_research_mode_allows_macos_jxa_strategy(monkeypatch): + monkeypatch.setattr(main_module.sys, "platform", "darwin") + args = SimpleNamespace(research_enabled=True, strategy="jxa") + + ensure_research_strategy_supported(args) + + +def test_normal_mode_allows_macos_swift_strategy(monkeypatch): + monkeypatch.setattr(main_module.sys, "platform", "darwin") + args = SimpleNamespace(research_enabled=False, strategy="swift") + + ensure_research_strategy_supported(args) def test_build_swift_command_omits_optional_filters(): From fc065129fd6be4b553b8f2f800b7ea03d61746e2 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 12:17:19 +0000 Subject: [PATCH 04/12] fix(research): keep empty maps privacy-preserving --- aw_watcher_window/config.py | 9 ++++++++- aw_watcher_window/research_filter.py | 12 +++++++----- tests/test_config.py | 20 ++++++++++++++++++++ tests/test_research_filter.py | 17 ++++++++++++++--- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py index 716ec4f3..136528ff 100644 --- a/aw_watcher_window/config.py +++ b/aw_watcher_window/config.py @@ -57,13 +57,20 @@ def parse_args(): choices=["jxa", "applescript", "swift"], help="(macOS only) strategy to use for retrieving the active window", ) - parser.add_argument( + research_group = parser.add_mutually_exclusive_group() + research_group.add_argument( "--research", dest="research_enabled", action="store_true", default=default_research_enabled, help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file. Not supported with --strategy swift on macOS.", ) + research_group.add_argument( + "--no-research", + dest="research_enabled", + action="store_false", + help="Disable Research Edition mode, even when enabled in the config file.", + ) parsed_args = parser.parse_args() parsed_args.research_category_map = dict(config.get("research_category_map", {})) return parsed_args diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index c1e1e48f..8f3b2cdc 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -6,9 +6,10 @@ Unmatched titles → 'excluded'. - Non-browser apps: title is dropped entirely (only app name recorded). -Enable via config (``research_enabled = true``) and supply a category map -in ``[aw-watcher-window.research_category_map]``. The map is left empty -by default so normal users see zero behaviour change. +Enable via config (``research_enabled = true``) and optionally supply a category +map in ``[aw-watcher-window.research_category_map]``. If the map is empty, +browser titles are classified as ``excluded`` and non-browser titles are still +dropped. macOS note: the ``swift`` strategy delegates to the compiled Swift helper and bypasses this Python transform. Use ``--strategy jxa`` or ``--strategy @@ -30,6 +31,7 @@ "firefox developer edition", "safari", "edge", + "microsoft edge", "opera", "chrome.exe", "brave.exe", @@ -65,11 +67,11 @@ def transform(window: dict, category_map: Optional[dict]) -> dict: *window*: ``{"app": str, "title": str}`` (may also carry *url*, *incognito* on macOS JXA — those are preserved for browsers). - *category_map*: study-specific mapping dict, or ``None``/empty to no-op. + *category_map*: study-specific mapping dict, or ``None`` to no-op. Returns the transformed window dict (never mutates the input). """ - if not category_map: + if category_map is None: return window app = window.get("app", "") diff --git a/tests/test_config.py b/tests/test_config.py index f04d13ac..7b30eba7 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,3 +21,23 @@ def test_parse_args_attaches_research_category_map(monkeypatch): args = config_module.parse_args() assert args.research_category_map == {"youtube": "Entertainment"} + + +def test_no_research_overrides_config_enabled(monkeypatch): + monkeypatch.setattr( + config_module, + "load_config", + lambda: { + "exclude_title": False, + "exclude_titles": [], + "poll_time": 1.0, + "strategy_macos": "swift", + "research_enabled": True, + "research_category_map": {}, + }, + ) + monkeypatch.setattr(sys, "argv", ["aw-watcher-window", "--no-research"]) + + args = config_module.parse_args() + + assert args.research_enabled is False diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index 80e3317f..9f64420d 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -16,7 +16,14 @@ def test_known_browsers(self): self.assertTrue(is_browser(app), f"{app!r} should be a browser") def test_case_insensitive(self): - for app in ("Chrome", "GOOGLE CHROME", "Firefox", "Safari", "Edge"): + for app in ( + "Chrome", + "GOOGLE CHROME", + "Firefox", + "Safari", + "Edge", + "Microsoft Edge", + ): self.assertTrue(is_browser(app), f"{app!r} should be a browser") def test_non_browsers(self): @@ -77,9 +84,13 @@ def test_no_category_map_returns_unchanged(self): window = {"app": "Chrome", "title": "YouTube - Chrome"} self.assertEqual(transform(window, None), window) - def test_empty_category_map_returns_unchanged(self): + def test_empty_category_map_excludes_browser_title(self): window = {"app": "Chrome", "title": "YouTube - Chrome"} - self.assertEqual(transform(window, {}), window) + self.assertEqual(transform(window, {}), {"app": "Chrome", "title": "excluded"}) + + def test_empty_category_map_still_drops_non_browser_title(self): + window = {"app": "Terminal", "title": "bash"} + self.assertEqual(transform(window, {}), {"app": "Terminal"}) def test_browser_title_classified(self): window = {"app": "Chrome", "title": "YouTube - Chrome"} From 35656fb58af2fbfe0aebb04c83f3e9f6fe9154d4 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 12:18:15 +0000 Subject: [PATCH 05/12] test(research): avoid duplicate main imports --- tests/test_main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index e2180713..2febbc51 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,7 +5,6 @@ import aw_watcher_window.main as main_module from aw_watcher_window.exceptions import FatalError from aw_watcher_window.macos_cli import build_swift_command -from aw_watcher_window.main import ensure_research_strategy_supported def test_research_mode_rejects_macos_swift_strategy(monkeypatch): @@ -13,21 +12,21 @@ def test_research_mode_rejects_macos_swift_strategy(monkeypatch): args = SimpleNamespace(research_enabled=True, strategy="swift") with pytest.raises(FatalError, match="not supported with the macOS swift strategy"): - ensure_research_strategy_supported(args) + main_module.ensure_research_strategy_supported(args) def test_research_mode_allows_macos_jxa_strategy(monkeypatch): monkeypatch.setattr(main_module.sys, "platform", "darwin") args = SimpleNamespace(research_enabled=True, strategy="jxa") - ensure_research_strategy_supported(args) + main_module.ensure_research_strategy_supported(args) def test_normal_mode_allows_macos_swift_strategy(monkeypatch): monkeypatch.setattr(main_module.sys, "platform", "darwin") args = SimpleNamespace(research_enabled=False, strategy="swift") - ensure_research_strategy_supported(args) + main_module.ensure_research_strategy_supported(args) def test_build_swift_command_omits_optional_filters(): From d54abf226bdf458c7f885a03d2063274eafdb227 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 12:47:44 +0000 Subject: [PATCH 06/12] fix(research): recognize Linux browser WM_CLASS names --- aw_watcher_window/research_filter.py | 9 +++++++++ tests/test_research_filter.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index 8f3b2cdc..cf00fc0d 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -24,14 +24,23 @@ "chrome", "google chrome", "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", "chromium", + "chromium-browser", "brave browser", "brave", + "brave-browser", "firefox", "firefox developer edition", + "firefox-esr", "safari", "edge", "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", "opera", "chrome.exe", "brave.exe", diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index 9f64420d..b0f547df 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -19,6 +19,7 @@ def test_case_insensitive(self): for app in ( "Chrome", "GOOGLE CHROME", + "Google-chrome", "Firefox", "Safari", "Edge", @@ -26,6 +27,15 @@ def test_case_insensitive(self): ): self.assertTrue(is_browser(app), f"{app!r} should be a browser") + def test_linux_wm_class_browser_names(self): + for app in ( + "Google-chrome", + "Brave-browser", + "Chromium-browser", + "Microsoft-edge", + ): + self.assertTrue(is_browser(app), f"{app!r} should be a browser") + def test_non_browsers(self): for app in ("Slack", "Terminal", "iTerm2", "Code", "zoom.us", ""): self.assertFalse(is_browser(app), f"{app!r} should not be a browser") @@ -138,6 +148,14 @@ def test_browser_with_leading_trailing_spaces_in_app(self): result = transform(window, self.CATEGORY_MAP) self.assertEqual(result["title"], "Facebook") + def test_linux_browser_wm_class_title_classified(self): + for app in ("Google-chrome", "Brave-browser"): + with self.subTest(app=app): + window = {"app": app, "title": "YouTube"} + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["app"], app) + self.assertEqual(result["title"], "Youtube") + def test_input_not_mutated(self): window = {"app": "Chrome", "title": "YouTube - Chrome"} original = dict(window) From 6e6f3c12160f27e4d2e889672956a3d187cdbcc2 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 13:09:09 +0000 Subject: [PATCH 07/12] fix(research): strip URLs from browser events for privacy On macOS JXA, browser events carry full URLs that can expose search queries, authentication tokens, and other sensitive path components. The previous implementation used `{**window, 'title': category}` which preserved the url field via dict spread, leaking URLs to the ActivityWatch server despite the privacy filter being active. Changed to explicitly construct the result dict, stripping the URL while preserving the incognito flag as non-sensitive metadata. Non-browser apps (e.g. Finder) continue to preserve the url field since file:// URLs are not a privacy concern. Updated test to verify URLs are NOT included in browser results. Fixes Greptile #130 P1 privacy finding. --- aw_watcher_window/research_filter.py | 7 ++++++- tests/test_research_filter.py | 8 +++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index cf00fc0d..d29d828c 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -89,7 +89,12 @@ def transform(window: dict, category_map: Optional[dict]) -> dict: # Browser: replace title with a study category title = window.get("title", "") category = classify_title(title, category_map) - return {**window, "title": category} + # Don't spread window dict — URLs must not be exposed for privacy + result = {"app": app, "title": category} + # Preserve incognito flag if present (metadata, not a privacy concern) + if "incognito" in window: + result["incognito"] = window["incognito"] + return result else: # Non-browser: drop the title entirely (only app name recorded) result = {"app": app} diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index b0f547df..504c199c 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -127,16 +127,18 @@ def test_non_browser_preserves_extra_fields(self): self.assertEqual(result["app"], "Finder") self.assertEqual(result["url"], "file:///Users/") - def test_browser_preserves_url_and_incognito(self): + def test_browser_strips_url_but_preserves_incognito(self): + # Privacy fix: browser URLs must be stripped to prevent leaking search queries + # and auth tokens; incognito flag is non-sensitive metadata and can be preserved window = { "app": "Chrome", "title": "YouTube - Google Chrome", - "url": "https://youtube.com", + "url": "https://youtube.com/search?q=secret", "incognito": False, } result = transform(window, self.CATEGORY_MAP) self.assertEqual(result["title"], "Youtube") - self.assertEqual(result["url"], "https://youtube.com") + self.assertNotIn("url", result, "Browser URLs must be stripped for privacy") self.assertEqual(result["incognito"], False) def test_non_browser_minimal(self): From 97f32b26e04d1e71123ba7170554945302e8b1d4 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 2 Jul 2026 13:48:13 +0000 Subject: [PATCH 08/12] fix(research): strip URLs from non-browser events for privacy Non-browser apps on macOS JXA can expose sensitive URLs via file:// and mailbox:// schemes (e.g., file:///Users/jdoe/sensitive-folder/ or mailbox://user@corp.com/inbox/). The privacy filter must strip these just as it strips browser URLs to prevent leaking sensitive path components to the ActivityWatch server. Changes: - Remove 'url' field from non-browser preserved fields - Keep 'incognito' flag preserved (non-sensitive metadata) - Update test to verify URL stripping for non-browser apps Fixes Greptile P1 security finding. --- aw_watcher_window/research_filter.py | 9 +++++---- tests/test_research_filter.py | 14 +++++++++++--- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index d29d828c..be19aaac 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -98,8 +98,9 @@ def transform(window: dict, category_map: Optional[dict]) -> dict: else: # Non-browser: drop the title entirely (only app name recorded) result = {"app": app} - # Preserve extra fields (url, incognito) that may exist on macOS JXA - for k in ("url", "incognito"): - if k in window: - result[k] = window[k] + # Preserve incognito flag if present (metadata, not a privacy concern) + # Note: URL is NOT preserved for non-browser apps — on macOS JXA, + # apps like Mail or file managers can expose sensitive URLs (mailbox://, file://) + if "incognito" in window: + result["incognito"] = window["incognito"] return result diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index 504c199c..6cfe7c76 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -120,12 +120,20 @@ def test_non_browser_drops_title(self): self.assertNotIn("title", result) self.assertEqual(result["app"], "iTerm2") - def test_non_browser_preserves_extra_fields(self): - window = {"app": "Finder", "title": "Documents", "url": "file:///Users/"} + def test_non_browser_strips_url_but_preserves_incognito(self): + # Privacy fix: non-browser URLs must be stripped to prevent leaking sensitive + # file paths or email URLs (file://, mailbox://); incognito flag is non-sensitive + window = { + "app": "Finder", + "title": "Documents", + "url": "file:///Users/jdoe/sensitive-folder/", + "incognito": False, + } result = transform(window, self.CATEGORY_MAP) self.assertNotIn("title", result) self.assertEqual(result["app"], "Finder") - self.assertEqual(result["url"], "file:///Users/") + self.assertNotIn("url", result, "Non-browser URLs must be stripped for privacy") + self.assertEqual(result["incognito"], False) def test_browser_strips_url_but_preserves_incognito(self): # Privacy fix: browser URLs must be stripped to prevent leaking search queries From f8e52f00c6e23e87f73b02d6d34ab112ef74749d Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 6 Jul 2026 11:08:43 +0000 Subject: [PATCH 09/12] fix(research): support privacy filter in macOS swift helper --- aw_watcher_window/config.py | 2 +- aw_watcher_window/macos.swift | 84 ++++++++++++++++++-- aw_watcher_window/macos_cli.py | 5 ++ aw_watcher_window/main.py | 26 ++----- aw_watcher_window/research_filter.py | 7 +- tests/test_main.py | 111 +++++++++++++++++++++++---- 6 files changed, 190 insertions(+), 45 deletions(-) diff --git a/aw_watcher_window/config.py b/aw_watcher_window/config.py index 136528ff..06789210 100644 --- a/aw_watcher_window/config.py +++ b/aw_watcher_window/config.py @@ -63,7 +63,7 @@ def parse_args(): dest="research_enabled", action="store_true", default=default_research_enabled, - help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file. Not supported with --strategy swift on macOS.", + help="Enable Research Edition mode: browser titles are classified into study categories, non-browser titles are dropped. Category map must be set in the config file.", ) research_group.add_argument( "--no-research", diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 5c0f22b4..8e631389 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -63,7 +63,7 @@ extension SBApplication: SafariApplication {} struct NetworkMessage: Codable, Equatable { var app: String - var title: String + var title: String? var url: String? } @@ -125,6 +125,37 @@ var clientName = "aw-watcher-window" var bucketName = "\(clientName)_\(clientHostname)" var excludeTitle = false var excludeTitlePatterns: [NSRegularExpression] = [] +var researchEnabled = false +var researchCategoryMap: [(pattern: String, category: String)] = [] + +let researchBrowserApps = Set([ + "chrome", + "google chrome", + "google chrome canary", + "google-chrome", + "google-chrome-beta", + "google-chrome-unstable", + "chromium", + "chromium-browser", + "brave browser", + "brave", + "brave-browser", + "firefox", + "firefox developer edition", + "firefox-esr", + "safari", + "edge", + "microsoft edge", + "microsoft-edge", + "microsoft-edge-beta", + "microsoft-edge-dev", + "opera", + "chrome.exe", + "brave.exe", + "firefox.exe", + "msedge.exe", + "opera.exe", +]) let main = MainThing() var oldHeartbeat: Heartbeat? @@ -173,6 +204,24 @@ func parseOptionalArguments(_ arguments: ArraySlice) { continue } + if argument == "--research" { + researchEnabled = true + index = arguments.index(after: index) + continue + } + + if argument == "--research-category" { + let patternIndex = arguments.index(after: index) + let categoryIndex = patternIndex < arguments.endIndex ? arguments.index(after: patternIndex) : arguments.endIndex + guard patternIndex < arguments.endIndex, categoryIndex < arguments.endIndex else { + error("Missing pattern/category values for --research-category") + exit(1) + } + researchCategoryMap.append((pattern: arguments[patternIndex], category: arguments[categoryIndex])) + index = arguments.index(after: categoryIndex) + continue + } + error("Unknown argument: \(argument)") exit(1) } @@ -185,6 +234,31 @@ func titleShouldBeExcluded(_ title: String) -> Bool { } } +func isResearchBrowser(_ app: String) -> Bool { + return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) +} + +func classifyResearchTitle(_ title: String) -> String { + for item in researchCategoryMap { + if title.range(of: item.pattern, options: [.caseInsensitive]) != nil { + return item.category + } + } + return "excluded" +} + +func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage { + if !researchEnabled { + return data + } + + if isResearchBrowser(data.app) { + return NetworkMessage(app: data.app, title: classifyResearchTitle(data.title ?? ""), url: nil) + } + + return NetworkMessage(app: data.app, title: nil, url: nil) +} + func start() { // Arguments should be: // - url + port @@ -195,7 +269,7 @@ func start() { // Check that we get the 4 required arguments plus any optional flags if arguments.count < 5 { - print("Usage: aw-watcher-window [--exclude-title] [--exclude-titles ...]") + print("Usage: aw-watcher-window [--exclude-title] [--exclude-titles ...] [--research] [--research-category ...]") exit(1) } @@ -310,7 +384,7 @@ func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws throw HeartbeatError.error(msg: "Failed to send heartbeat: \(response)") } - debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title), url: \(heartbeat.data.url ?? "")") + debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title ?? ""), url: \(heartbeat.data.url ?? "")") } class MainThing { @@ -434,11 +508,11 @@ class MainThing { } } - if excludeTitle || titleShouldBeExcluded(data.title) { + if excludeTitle || titleShouldBeExcluded(data.title ?? "") { data.title = "excluded" } - let heartbeat = Heartbeat(timestamp: nowTime, data: data) + let heartbeat = Heartbeat(timestamp: nowTime, data: applyResearchFilter(data)) sendHeartbeat(heartbeat) } diff --git a/aw_watcher_window/macos_cli.py b/aw_watcher_window/macos_cli.py index 1ba4a3e7..aac50aa2 100644 --- a/aw_watcher_window/macos_cli.py +++ b/aw_watcher_window/macos_cli.py @@ -6,10 +6,15 @@ def build_swift_command( client_name, exclude_title=False, exclude_titles=None, + research_category_map=None, ): command = [binpath, server_address, bucket_id, client_hostname, client_name] if exclude_title: command.append("--exclude-title") for title in exclude_titles or []: command.extend(["--exclude-titles", title]) + if research_category_map is not None: + command.append("--research") + for pattern, category in research_category_map.items(): + command.extend(["--research-category", pattern, category]) return command diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index 9c3d6fff..0a84cca3 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -42,19 +42,6 @@ def try_compile_title_regex(title): exit(1) -def ensure_research_strategy_supported(args): - if ( - args.research_enabled - and sys.platform == "darwin" - and args.strategy == "swift" - ): - raise FatalError( - "Research Edition mode is not supported with the macOS swift strategy. " - "Use --strategy jxa or --strategy applescript so window titles are " - "filtered before upload." - ) - - def main(): args = parse_args() @@ -70,8 +57,6 @@ def main(): log_stderr=True, log_file=True, ) - ensure_research_strategy_supported(args) - if sys.platform == "darwin": background_ensure_permissions() @@ -88,6 +73,11 @@ def main(): client.wait_for_start() with client: + research_category_map = ( + args.research_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( @@ -104,6 +94,7 @@ def main(): client.client_name, exclude_title=args.exclude_title, exclude_titles=args.exclude_titles, + research_category_map=research_category_map, ) ) # terminate swift process when this process dies @@ -113,11 +104,6 @@ def main(): print("KeyboardInterrupt") kill_process(p.pid) else: - research_category_map = ( - args.research_category_map - if args.research_enabled - else None - ) heartbeat_loop( client, bucket_id, diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index be19aaac..14668140 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -11,9 +11,8 @@ browser titles are classified as ``excluded`` and non-browser titles are still dropped. -macOS note: the ``swift`` strategy delegates to the compiled Swift helper and -bypasses this Python transform. Use ``--strategy jxa`` or ``--strategy -applescript`` on macOS if you need Research Edition support there. +macOS note: the default ``swift`` strategy applies the same privacy transform in +the compiled helper before upload. """ from typing import Optional @@ -75,7 +74,7 @@ def transform(window: dict, category_map: Optional[dict]) -> dict: Apply Research Edition transforms to a window-data dict. *window*: ``{"app": str, "title": str}`` (may also carry *url*, - *incognito* on macOS JXA — those are preserved for browsers). + *incognito* on macOS JXA — URLs are stripped and incognito is preserved). *category_map*: study-specific mapping dict, or ``None`` to no-op. Returns the transformed window dict (never mutates the input). diff --git a/tests/test_main.py b/tests/test_main.py index 2febbc51..85a0bd39 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,32 +1,67 @@ from types import SimpleNamespace -import pytest - import aw_watcher_window.main as main_module -from aw_watcher_window.exceptions import FatalError from aw_watcher_window.macos_cli import build_swift_command -def test_research_mode_rejects_macos_swift_strategy(monkeypatch): - monkeypatch.setattr(main_module.sys, "platform", "darwin") - args = SimpleNamespace(research_enabled=True, strategy="swift") +def test_research_mode_passes_map_to_macos_swift_strategy(monkeypatch): + commands = [] - with pytest.raises(FatalError, match="not supported with the macOS swift strategy"): - main_module.ensure_research_strategy_supported(args) + class FakeProcess: + pid = 123 + def wait(self): + return None -def test_research_mode_allows_macos_jxa_strategy(monkeypatch): - monkeypatch.setattr(main_module.sys, "platform", "darwin") - args = SimpleNamespace(research_enabled=True, strategy="jxa") + class FakeClient: + client_name = "aw-watcher-window" + client_hostname = "host.localdomain" + server_address = "http://localhost:5600" + + def __init__(self, *args, **kwargs): + pass + + def create_bucket(self, *args, **kwargs): + pass + + def wait_for_start(self): + pass - main_module.ensure_research_strategy_supported(args) + def __enter__(self): + return self + def __exit__(self, exc_type, exc, tb): + return False -def test_normal_mode_allows_macos_swift_strategy(monkeypatch): monkeypatch.setattr(main_module.sys, "platform", "darwin") - args = SimpleNamespace(research_enabled=False, strategy="swift") + monkeypatch.setattr(main_module, "background_ensure_permissions", lambda: None) + monkeypatch.setattr(main_module, "setup_logging", lambda **kwargs: None) + monkeypatch.setattr(main_module, "ActivityWatchClient", FakeClient) + monkeypatch.setattr(main_module.signal, "signal", lambda *args, **kwargs: None) + monkeypatch.setattr( + main_module.subprocess, + "Popen", + lambda command: commands.append(command) or FakeProcess(), + ) + monkeypatch.setattr( + main_module, + "parse_args", + lambda: SimpleNamespace( + testing=True, + verbose=False, + host=None, + port=None, + strategy="swift", + exclude_title=False, + exclude_titles=[], + research_enabled=True, + research_category_map={"youtube": "Youtube"}, + ), + ) + + main_module.main() - main_module.ensure_research_strategy_supported(args) + assert commands[0][-4:] == ["--research", "--research-category", "youtube", "Youtube"] def test_build_swift_command_omits_optional_filters(): @@ -70,3 +105,49 @@ def test_build_swift_command_passes_title_filters(): "--exclude-titles", "Slack.*huddle", ] + + +def test_build_swift_command_passes_empty_research_map(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + research_category_map={}, + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + "--research", + ] + + +def test_build_swift_command_passes_research_categories(): + command = build_swift_command( + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + research_category_map={"youtube": "Youtube", "gmail": "Email"}, + ) + + assert command == [ + "/tmp/aw-watcher-window-macos", + "http://localhost:5600", + "bucket", + "host.localdomain", + "aw-watcher-window", + "--research", + "--research-category", + "youtube", + "Youtube", + "--research-category", + "gmail", + "Email", + ] From 001f572a58b9335275548959a9967a2a381b876b Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 6 Jul 2026 11:43:15 +0000 Subject: [PATCH 10/12] fix(research): use browser URL for category classification when available The Swift strategy can capture browser URLs via accessibility APIs, giving more reliable classification than page titles (which change mid-load or show generic text like "New Tab"). Classify by URL first, fall back to title. Both Python transform() and Swift applyResearchFilter() now implement this: - classify_title() accepts optional url= param, tries URL patterns first - Swift classifyResearch() takes url parameter, checks it before title URL is still stripped from output in both implementations for privacy. --- aw_watcher_window/macos.swift | 12 ++++++-- aw_watcher_window/research_filter.py | 20 ++++++++++---- tests/test_research_filter.py | 41 ++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 8e631389..9163df13 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -238,7 +238,15 @@ func isResearchBrowser(_ app: String) -> Bool { return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) } -func classifyResearchTitle(_ title: String) -> String { +func classifyResearch(_ title: String, url: String?) -> String { + // Try URL first — more reliable than page title, which can change mid-load + if let url = url, !url.isEmpty { + for item in researchCategoryMap { + if url.range(of: item.pattern, options: [.caseInsensitive]) != nil { + return item.category + } + } + } for item in researchCategoryMap { if title.range(of: item.pattern, options: [.caseInsensitive]) != nil { return item.category @@ -253,7 +261,7 @@ func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage { } if isResearchBrowser(data.app) { - return NetworkMessage(app: data.app, title: classifyResearchTitle(data.title ?? ""), url: nil) + return NetworkMessage(app: data.app, title: classifyResearch(data.title ?? "", url: data.url), url: nil) } return NetworkMessage(app: data.app, title: nil, url: nil) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index 14668140..095648f1 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -11,8 +11,10 @@ browser titles are classified as ``excluded`` and non-browser titles are still dropped. -macOS note: the default ``swift`` strategy applies the same privacy transform in -the compiled helper before upload. +macOS note: the default ``swift`` strategy applies the privacy transform in the +compiled helper before upload. Since Swift can capture browser URLs via +accessibility APIs, it uses them for more reliable category classification before +stripping them from the output. """ from typing import Optional @@ -55,13 +57,20 @@ def is_browser(app: str) -> bool: return app.strip().lower() in BROWSER_APPS -def classify_title(title: str, category_map: dict) -> str: +def classify_title(title: str, category_map: dict, url: str = "") -> str: """ - Classify a browser window *title* into a study category via substring matching. + Classify a browser window into a study category via substring matching. + Tries *url* first when provided (more reliable than page titles, which can + change mid-load), then falls back to *title*. *category_map*: ``{substring: category_name}`` — first matching substring wins. Returns the category name, or ``"excluded"`` if nothing matched. """ + if url: + url_lower = url.lower() + for pattern, category in category_map.items(): + if pattern.lower() in url_lower: + return category title_lower = title.lower() for pattern, category in category_map.items(): if pattern.lower() in title_lower: @@ -87,7 +96,8 @@ def transform(window: dict, category_map: Optional[dict]) -> dict: if is_browser(app): # Browser: replace title with a study category title = window.get("title", "") - category = classify_title(title, category_map) + url = window.get("url", "") + category = classify_title(title, category_map, url=url) # Don't spread window dict — URLs must not be exposed for privacy result = {"app": app, "title": category} # Preserve incognito flag if present (metadata, not a privacy concern) diff --git a/tests/test_research_filter.py b/tests/test_research_filter.py index 6cfe7c76..78f19843 100644 --- a/tests/test_research_filter.py +++ b/tests/test_research_filter.py @@ -83,6 +83,25 @@ def test_first_match_wins(self): def test_empty_title(self): self.assertEqual(classify_title("", self.CATEGORY_MAP), "excluded") + def test_url_matches_when_title_does_not(self): + # URL is used for classification when title gives no match + result = classify_title("New Tab", self.CATEGORY_MAP, url="https://youtube.com/watch?v=abc") + self.assertEqual(result, "Youtube") + + def test_url_takes_priority_over_title(self): + # URL match wins even when title would match a different category + result = classify_title("Gmail - Google", self.CATEGORY_MAP, url="https://youtube.com/") + self.assertEqual(result, "Youtube") + + def test_url_fallback_to_title(self): + # If URL doesn't match any pattern, title is used + result = classify_title("Facebook - Social", self.CATEGORY_MAP, url="https://example.com/unrelated") + self.assertEqual(result, "Facebook") + + def test_url_case_insensitive(self): + result = classify_title("", self.CATEGORY_MAP, url="HTTPS://YOUTUBE.COM/WATCH") + self.assertEqual(result, "Youtube") + class TestTransform(unittest.TestCase): CATEGORY_MAP = { @@ -172,6 +191,28 @@ def test_input_not_mutated(self): transform(window, self.CATEGORY_MAP) self.assertEqual(window, original) + def test_browser_url_used_for_classification(self): + # URL is used for classification when title doesn't match (Swift advantage) + window = { + "app": "Safari", + "title": "New Tab", # generic title — won't match + "url": "https://youtube.com/watch?v=abc123", + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertNotIn("url", result) + + def test_browser_url_takes_priority_over_title(self): + # URL match takes precedence over title match + window = { + "app": "Chrome", + "title": "Facebook - Home", + "url": "https://youtube.com/", + } + result = transform(window, self.CATEGORY_MAP) + self.assertEqual(result["title"], "Youtube") + self.assertNotIn("url", result) + if __name__ == "__main__": unittest.main() From 06d716408004f81376fe790d0b8f1a70c0b428b1 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 6 Jul 2026 12:41:05 +0000 Subject: [PATCH 11/12] fix(research): prioritize research transform over title exclusions --- aw_watcher_window/macos.swift | 6 +++-- aw_watcher_window/main.py | 34 ++++++++++++++++++++------- tests/test_main.py | 44 +++++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 11 deletions(-) diff --git a/aw_watcher_window/macos.swift b/aw_watcher_window/macos.swift index 9163df13..e687c640 100644 --- a/aw_watcher_window/macos.swift +++ b/aw_watcher_window/macos.swift @@ -516,11 +516,13 @@ class MainThing { } } - if excludeTitle || titleShouldBeExcluded(data.title ?? "") { + if researchEnabled { + data = applyResearchFilter(data) + } else if excludeTitle || titleShouldBeExcluded(data.title ?? "") { data.title = "excluded" } - let heartbeat = Heartbeat(timestamp: nowTime, data: applyResearchFilter(data)) + let heartbeat = Heartbeat(timestamp: nowTime, data: data) sendHeartbeat(heartbeat) } diff --git a/aw_watcher_window/main.py b/aw_watcher_window/main.py index 0a84cca3..6edc9c75 100644 --- a/aw_watcher_window/main.py +++ b/aw_watcher_window/main.py @@ -160,15 +160,12 @@ def heartbeat_loop( if current_window is None: logger.debug("Unable to fetch window, trying again on next poll") else: - for pattern in exclude_titles: - if pattern.search(current_window["title"]): - current_window["title"] = "excluded" - - if exclude_title: - current_window["title"] = "excluded" - - if research_category_map is not None: - current_window = research_transform(current_window, research_category_map) + current_window = transform_window( + current_window, + exclude_title=exclude_title, + exclude_titles=exclude_titles, + research_category_map=research_category_map, + ) now = datetime.now(timezone.utc) current_window_event = Event(timestamp=now, data=current_window) @@ -181,3 +178,22 @@ def heartbeat_loop( ) sleep(poll_time) + + +def transform_window( + current_window, + exclude_title=False, + exclude_titles=None, + research_category_map=None, +): + if research_category_map is not None: + return research_transform(current_window, research_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 diff --git a/tests/test_main.py b/tests/test_main.py index 85a0bd39..55c07eee 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,3 +1,4 @@ +import re from types import SimpleNamespace import aw_watcher_window.main as main_module @@ -151,3 +152,46 @@ def test_build_swift_command_passes_research_categories(): "gmail", "Email", ] + + +def test_research_transform_takes_precedence_over_exclude_titles(): + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com/watch?v=abc", + } + + transformed = main_module.transform_window( + window, + exclude_titles=[re.compile("youtube", re.IGNORECASE)], + research_category_map={"youtube": "Youtube"}, + ) + + assert transformed == {"app": "Chrome", "title": "Youtube"} + + +def test_research_transform_takes_precedence_over_exclude_title(): + window = { + "app": "Chrome", + "title": "YouTube - Google Chrome", + "url": "https://youtube.com/watch?v=abc", + } + + transformed = main_module.transform_window( + window, + exclude_title=True, + research_category_map={"youtube": "Youtube"}, + ) + + assert transformed == {"app": "Chrome", "title": "Youtube"} + + +def test_legacy_exclude_titles_still_apply_without_research_mode(): + window = {"app": "Chrome", "title": "YouTube - Google Chrome"} + + transformed = main_module.transform_window( + window, + exclude_titles=[re.compile("youtube", re.IGNORECASE)], + ) + + assert transformed == {"app": "Chrome", "title": "excluded"} From 9237cc526dbc6e32b76930dfe57ba4a951b0629f Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 6 Jul 2026 13:24:48 +0000 Subject: [PATCH 12/12] =?UTF-8?q?docs:=20clarify=20macOS=20note=20?= =?UTF-8?q?=E2=80=94=20Swift=20captures=20URLs,=20Python=20filter=20applie?= =?UTF-8?q?s=20transform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- aw_watcher_window/research_filter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/aw_watcher_window/research_filter.py b/aw_watcher_window/research_filter.py index 095648f1..8dabf528 100644 --- a/aw_watcher_window/research_filter.py +++ b/aw_watcher_window/research_filter.py @@ -11,10 +11,10 @@ browser titles are classified as ``excluded`` and non-browser titles are still dropped. -macOS note: the default ``swift`` strategy applies the privacy transform in the -compiled helper before upload. Since Swift can capture browser URLs via -accessibility APIs, it uses them for more reliable category classification before -stripping them from the output. +macOS note: on macOS the default ``swift`` strategy captures browser URLs via +accessibility APIs and includes them in the window dict. This filter uses those +URLs for more reliable category classification (``classify_title`` prefers URL +over title) and strips them from the output before the event is recorded. """ from typing import Optional