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
19 changes: 19 additions & 0 deletions aw_watcher_window/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
exclude_titles = []
poll_time = 1.0
strategy_macos = "swift"
research_enabled = false

[aw-watcher-window.research_category_map]
""".strip()


Expand All @@ -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."
Expand Down Expand Up @@ -53,5 +57,20 @@ def parse_args():
choices=["jxa", "applescript", "swift"],
help="(macOS only) strategy to use for retrieving the active window",
)
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.",
)
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
92 changes: 88 additions & 4 deletions aw_watcher_window/macos.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ extension SBApplication: SafariApplication {}

struct NetworkMessage: Codable, Equatable {
var app: String
var title: String
var title: String?
var url: String?
}

Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -173,6 +204,24 @@ func parseOptionalArguments(_ arguments: ArraySlice<String>) {
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)
}
Expand All @@ -185,6 +234,39 @@ func titleShouldBeExcluded(_ title: String) -> Bool {
}
}

func isResearchBrowser(_ app: String) -> Bool {
return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}

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
}
}
return "excluded"
}

func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage {
if !researchEnabled {
return data
}

if isResearchBrowser(data.app) {
return NetworkMessage(app: data.app, title: classifyResearch(data.title ?? "", url: data.url), url: nil)
}

return NetworkMessage(app: data.app, title: nil, url: nil)
}

func start() {
// Arguments should be:
// - url + port
Expand All @@ -195,7 +277,7 @@ func start() {

// Check that we get the 4 required arguments plus any optional flags
if arguments.count < 5 {
print("Usage: aw-watcher-window <url> <bucket> <hostname> <client> [--exclude-title] [--exclude-titles <pattern> ...]")
print("Usage: aw-watcher-window <url> <bucket> <hostname> <client> [--exclude-title] [--exclude-titles <pattern> ...] [--research] [--research-category <pattern> <category> ...]")
exit(1)
}

Expand Down Expand Up @@ -310,7 +392,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 {
Expand Down Expand Up @@ -434,7 +516,9 @@ class MainThing {
}
}

if excludeTitle || titleShouldBeExcluded(data.title) {
if researchEnabled {
data = applyResearchFilter(data)
} else if excludeTitle || titleShouldBeExcluded(data.title ?? "") {
data.title = "excluded"
}

Expand Down
5 changes: 5 additions & 0 deletions aw_watcher_window/macos_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
48 changes: 40 additions & 8 deletions aw_watcher_window/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
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

Expand Down Expand Up @@ -56,7 +57,6 @@ def main():
log_stderr=True,
log_file=True,
)

if sys.platform == "darwin":
background_ensure_permissions()

Expand All @@ -73,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(
Expand All @@ -89,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
Expand All @@ -109,11 +115,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:
Expand Down Expand Up @@ -147,12 +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"
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)
Expand All @@ -165,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
Loading
Loading