snapshot of https://github.com/ActivityWatch/aw-watcher-window.git (master, 2026-09-16; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-16 00:21:37 +05:00
commit 8e9cceaeae
29 changed files with 4586 additions and 0 deletions

96
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,96 @@
name: Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
test:
name: test on ${{ matrix.os }} (py-${{ matrix.python_version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python_version: [3.9]
steps:
- uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python_version }}
- name: Create virtualenv
shell: bash
run: |
python -m venv venv
- name: Install dependencies
shell: bash
run: |
# we need poetry==1.3.2
# due to: https://github.com/python-poetry/poetry/issues/7611
# seen in: https://github.com/ActivityWatch/aw-watcher-window/actions/runs/5891369412/job/15978283144
pip install poetry==1.3.2
source venv/bin/activate || source venv/Scripts/activate
if [ "${{ runner.os }}" = "macOS" ]; then
MACOSX_DEPLOYMENT_TARGET=12.0 make build
else
make build
fi
- name: Check macOS deployment target
if: runner.os == 'macOS'
shell: bash
run: |
otool -l aw_watcher_window/aw-watcher-window-macos | grep -A4 'LC_BUILD_VERSION' | grep 'minos 12.0'
- name: Run tests
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
make test
- name: Package
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
if [ "${{ runner.os }}" = "macOS" ]; then
MACOSX_DEPLOYMENT_TARGET=12.0 make package
else
make package
fi
- name: Test package
shell: bash
run: |
dist/aw-watcher-window/aw-watcher-window --help
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: aw-watcher-window-${{ runner.os }}-py${{ matrix.python_version }}
path: dist/aw-watcher-window
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: 3.9
- name: Create virtualenv
shell: bash
run: |
python -m venv venv
- name: Install dependencies
shell: bash
run: |
pip install poetry
source venv/bin/activate || source venv/Scripts/activate
make build
- name: Typecheck
shell: bash
run: |
source venv/bin/activate || source venv/Scripts/activate
make typecheck

41
.github/workflows/codeql.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: "CodeQL"
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: "39 22 * * 3"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ python ]
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: +security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{ matrix.language }}"

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__
*.pyc
*.egg-info
build
dist
*.swp
.mypy_cache
aw-watcher-window-macos

373
LICENSE.txt Normal file
View File

@@ -0,0 +1,373 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at https://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

31
Makefile Normal file
View File

@@ -0,0 +1,31 @@
.PHONY: build test package clean
MACOSX_DEPLOYMENT_TARGET ?= 12.0
build:
poetry install
# if macOS, build swift
if [ "$(shell uname)" = "Darwin" ]; then \
make build-swift; \
fi
build-swift: aw_watcher_window/aw-watcher-window-macos
aw_watcher_window/aw-watcher-window-macos: aw_watcher_window/macos.swift
swiftc -target "$(shell uname -m)-apple-macosx$(MACOSX_DEPLOYMENT_TARGET)" $^ -o $@
test:
poetry run aw-watcher-window --help # Ensures that it at least starts
poetry run python -m pytest tests/
make typecheck
typecheck:
poetry run mypy aw_watcher_window/ --ignore-missing-imports
package:
pyinstaller aw-watcher-window.spec --clean --noconfirm
clean:
rm -rf build dist
rm -rf aw_watcher_window/__pycache__
rm aw_watcher_window/aw-watcher-window-macos

31
README.md Normal file
View File

@@ -0,0 +1,31 @@
aw-watcher-window
=================
Cross-platform window-Watcher for Linux (X11), macOS, Windows.
[![Build Status](https://travis-ci.org/ActivityWatch/aw-watcher-window.svg?branch=master)](https://travis-ci.org/ActivityWatch/aw-watcher-window)
## How to install
To install the pre-built application, go to https://activitywatch.net/downloads/
To build your own packaged application, run `make package`
To install the latest git version directly from github without cloning, run
`pip install git+https://github.com/ActivityWatch/aw-watcher-window.git`
To install from a cloned version, cd into the directory and run
`poetry install` to install inside an virtualenv. You can run the binary via `aw-watcher-window`.
If you want to install it system-wide it can be installed with `pip install .`, but that has the issue
that it might not get the exact version of the dependencies due to not reading the poetry.lock file.
## Usage
In order for this watcher to be available in the UI, you'll need to have a Away From Computer (afk) watcher running alongside it.
### Note to macOS users
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.

40
aw-watcher-window.spec Normal file
View File

@@ -0,0 +1,40 @@
import platform
block_cipher = None
a = Analysis(
["aw_watcher_window/__main__.py"],
pathex=[],
binaries=[("aw_watcher_window/aw-watcher-window-macos", "aw_watcher_window")] if platform.system() == "Darwin" else [],
datas=[
("aw_watcher_window/printAppStatus.jxa", "aw_watcher_window"),
],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
exclude_binaries=True,
name="aw-watcher-window",
contents_directory=".",
debug=False,
strip=False,
upx=True,
console=True,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name="aw-watcher-window",
)

View File

@@ -0,0 +1,6 @@
def main():
from .main import main as _main
return _main()
__all__ = ["main"]

View File

@@ -0,0 +1,4 @@
from aw_watcher_window import main
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,97 @@
import argparse
import tomlkit
from aw_core.config import load_config_toml
# Only end-user options belong here. load_config_toml() writes this template into
# the user's config file on first run; it comments out keys but NOT table headers,
# so anything listed here is authored into every fresh install's config.
default_config = """
[aw-watcher-window]
exclude_title = false
exclude_titles = []
poll_time = 1.0
strategy_macos = "swift"
""".strip()
# Research Edition defaults, deliberately kept out of default_config: these are
# study/dev knobs, not end-user options, and should not be persisted into the
# config of users who are not part of a study. They are still read normally when
# a user does set them (see load_config), which is how the Research Edition build
# and study participants configure them.
#
# The Research Edition release build rewrites the flag below with
# sed -i 's/^research_enabled = false$/research_enabled = true/'
# (ActivityWatch/activitywatch, .github/workflows/release.yml). Keep that line at
# column 0 and byte-identical.
research_defaults = """
research_enabled = false
""".strip()
def load_config():
config = load_config_toml("aw-watcher-window", default_config)["aw-watcher-window"]
# Research defaults fill in only what the user's config didn't set.
for key, value in tomlkit.parse(research_defaults).items():
config.setdefault(key, value)
return config
def parse_args():
config = load_config()
default_poll_time = config["poll_time"]
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."
)
parser.add_argument("--host", dest="host")
parser.add_argument("--port", dest="port")
parser.add_argument("--testing", dest="testing", action="store_true")
parser.add_argument(
"--exclude-title",
dest="exclude_title",
action="store_true",
default=default_exclude_title,
)
parser.add_argument(
"--exclude-titles",
dest="exclude_titles",
nargs='+',
default=default_exclude_titles,
help="Exclude window titles by regular expression. Can specify multiple times."
)
parser.add_argument("--verbose", dest="verbose", action="store_true")
parser.add_argument(
"--poll-time", dest="poll_time", type=float, default=default_poll_time
)
parser.add_argument(
"--strategy",
dest="strategy",
default=default_strategy_macos,
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", {}))
parsed_args.research_app_category_map = dict(config.get("research_app_category_map", {}))
return parsed_args

View File

@@ -0,0 +1,2 @@
class FatalError(Exception):
"""Exception raised when the program should exit with a fatal error."""

81
aw_watcher_window/lib.py Normal file
View File

@@ -0,0 +1,81 @@
import sys
from typing import Optional
from .exceptions import FatalError
def get_current_window_linux() -> Optional[dict]:
from . import xlib
window = xlib.get_current_window()
if window is None:
cls = "unknown"
name = "unknown"
else:
cls = xlib.get_window_class(window)
name = xlib.get_window_name(window)
return {"app": cls, "title": name}
def get_current_window_macos(strategy: str) -> Optional[dict]:
# TODO should we use unknown when the title is blank like the other platforms?
# `jxa` is the default & preferred strategy. It includes the url + incognito status
if strategy == "jxa":
from . import macos_jxa
return macos_jxa.getInfo()
elif strategy == "applescript":
from . import macos_applescript
return macos_applescript.getInfo()
else:
raise FatalError(f"invalid strategy '{strategy}'")
def get_current_window_windows() -> Optional[dict]:
from . import windows
window_handle = windows.get_active_window_handle()
# hwnd 0 means no foreground window (e.g. during UAC prompt, lock screen,
# or secure desktop). Return None so heartbeat_loop skips this poll cycle.
if not window_handle:
return None
try:
app = windows.get_app_name(window_handle)
except Exception:
# Fall back to WMI for elevated/admin processes where OpenProcess fails
try:
app = windows.get_app_name_wmi(window_handle)
except Exception:
app = None
title = windows.get_window_title(window_handle)
if app is None:
app = "unknown"
if title is None:
title = "unknown"
return {"app": app, "title": title}
def get_current_window(strategy: Optional[str] = None) -> Optional[dict]:
"""
:raises FatalError: if a fatal error occurs (e.g. unsupported platform, X server closed)
"""
if sys.platform.startswith("linux"):
return get_current_window_linux()
elif sys.platform == "darwin":
if strategy is None:
raise FatalError("macOS strategy not specified")
return get_current_window_macos(strategy)
elif sys.platform in ["win32", "cygwin"]:
return get_current_window_windows()
else:
raise FatalError(f"Unknown platform: {sys.platform}")

View File

@@ -0,0 +1,751 @@
import Cocoa
import ScriptingBridge
@objc protocol ChromeTab {
@objc optional var URL: String { get }
@objc optional var title: String { get }
}
@objc protocol ChromeWindow {
@objc optional var activeTab: ChromeTab { get }
@objc optional var mode: String { get }
}
extension SBObject: ChromeWindow, ChromeTab {}
@objc protocol ChromeProtocol {
@objc optional func windows() -> [ChromeWindow]
}
extension SBApplication: ChromeProtocol {}
// https://github.com/tingraldi/SwiftScripting/blob/4346eba0f47e806943601f5fb2fe978e2066b310/Frameworks/SafariScripting/SafariScripting/Safari.swift#L37
@objc public protocol SafariDocument {
@objc optional var name: String { get } // Its name.
@objc optional var modified: Bool { get } // Has it been modified since the last save?
@objc optional var file: URL { get } // Its location on disk, if it has one.
@objc optional var source: String { get } // The HTML source of the web page currently loaded in the document.
@objc optional var URL: String { get } // The current URL of the document.
@objc optional var text: String { get } // The text of the web page currently loaded in the document. Modifications to text aren't reflected on the web page.
@objc optional func setURL(_ URL: String!) // The current URL of the document.
}
@objc public protocol SafariTab {
@objc optional var source: String { get } // The HTML source of the web page currently loaded in the tab.
@objc optional var URL: String { get } // The current URL of the tab.
@objc optional var index: NSNumber { get } // The index of the tab, ordered left to right.
@objc optional var text: String { get } // The text of the web page currently loaded in the tab. Modifications to text aren't reflected on the web page.
@objc optional var visible: Bool { get } // Whether the tab is currently visible.
@objc optional var name: String { get } // The name of the tab.
@objc optional func setURL(_ URL: String!) // The current URL of the tab.
}
@objc public protocol SafariWindow {
@objc optional var name: String { get } // The title of the window.
@objc optional func id() -> Int // The unique identifier of the window.
@objc optional var index: Int { get } // The index of the window, ordered front to back.
@objc optional var document: SafariDocument { get } // The document whose contents are displayed in the window.
@objc optional func tabs() -> SBElementArray
@objc optional var currentTab: SafariTab { get } // The current tab.
}
extension SBObject: SafariWindow {}
@objc public protocol SafariApplication {
@objc optional func documents() -> SBElementArray
@objc optional func windows() -> [SafariWindow]
@objc optional var name: String { get } // The name of the application.
@objc optional var frontmost: Bool { get } // Is this the active application?
}
extension SBApplication: SafariApplication {}
// AW-specific structs
struct NetworkMessage: Codable, Equatable {
var app: String
var title: String?
var url: String?
}
struct Heartbeat: Codable {
var timestamp: Date
var data: NetworkMessage
}
enum HeartbeatError: Error {
case error(msg: String)
}
struct Bucket: Codable {
var client: String
var type: String
var hostname: String
}
// there's no builtin logging library on macos which has levels & hits stdout, so we build our own simple one
// there a complex open source one, but it makes it harder to compile this simple one-file swift application
let dateFormatter = DateFormatter();
func logTimestamp() -> String {
let now = Date()
dateFormatter.timeZone = TimeZone.current
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return dateFormatter.string(from: now)
}
// generate log prefix based on level
func logPrefix(_ level: String) -> String {
return "\(logTimestamp()) [aw-watcher-window-macos] [\(level)]"
}
let logLevel = ProcessInfo.processInfo.environment["LOG_LEVEL"]?.uppercased() ?? "INFO"
func debug(_ msg: String) {
if (logLevel == "DEBUG") {
print("\(logPrefix("DEBUG")) \(msg)")
fflush(stdout)
}
}
func log(_ msg: String) {
print("\(logPrefix("INFO")) \(msg)")
fflush(stdout)
}
func error(_ msg: String) {
print("\(logPrefix("ERROR")) \(msg)")
fflush(stdout)
}
// Placeholder values, set in start() from CLI arguments
var baseurl = "http://localhost:5600"
// NOTE: this differs from the hostname we get from Python, here we get `.local`, but in Python we get `.localdomain`
var clientHostname = ProcessInfo.processInfo.hostName
var clientName = "aw-watcher-window"
var bucketName = "\(clientName)_\(clientHostname)"
var excludeTitle = false
var excludeTitlePatterns: [NSRegularExpression] = []
var researchEnabled = false
var researchCategoryMap: [(pattern: String, category: String)] = []
var researchAppCategoryMap: [(app: String, category: String)] = []
let researchBrowserApps = Set([
"chrome",
"google chrome",
"google chrome canary",
"google-chrome",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium.exe",
"brave browser",
"brave",
"brave-browser",
"arc",
"arc browser",
"firefox",
"firefox developer edition",
"firefox-esr",
"safari",
"edge",
"microsoft edge",
"microsoft-edge",
"microsoft-edge-beta",
"microsoft-edge-dev",
"opera",
"vivaldi",
"vivaldi.exe",
"chrome.exe",
"brave.exe",
"firefox.exe",
"msedge.exe",
"opera.exe",
])
let main = MainThing()
var oldHeartbeat: Heartbeat?
let encoder = JSONEncoder()
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
encoder.dateEncodingStrategy = .custom({ date, encoder in
var container = encoder.singleValueContainer()
let dateString = formatter.string(from: date)
try container.encode(dateString)
})
start()
RunLoop.main.run()
func compileExcludeTitlePattern(_ pattern: String) -> NSRegularExpression {
do {
return try NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
} catch let regexError {
error("Invalid regex pattern: \(pattern) — \(regexError.localizedDescription)")
exit(1)
}
}
func parseOptionalArguments(_ arguments: ArraySlice<String>) {
var index = arguments.startIndex
while index < arguments.endIndex {
let argument = arguments[index]
if argument == "--exclude-title" {
excludeTitle = true
index = arguments.index(after: index)
continue
}
if argument == "--exclude-titles" {
let nextIndex = arguments.index(after: index)
guard nextIndex < arguments.endIndex else {
error("Missing value for --exclude-titles")
exit(1)
}
excludeTitlePatterns.append(compileExcludeTitlePattern(arguments[nextIndex]))
index = arguments.index(after: nextIndex)
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
}
if argument == "--research-app-category" {
let appIndex = arguments.index(after: index)
let categoryIndex = appIndex < arguments.endIndex ? arguments.index(after: appIndex) : arguments.endIndex
guard appIndex < arguments.endIndex, categoryIndex < arguments.endIndex else {
error("Missing app/category values for --research-app-category")
exit(1)
}
researchAppCategoryMap.append((app: arguments[appIndex], category: arguments[categoryIndex]))
index = arguments.index(after: categoryIndex)
continue
}
error("Unknown argument: \(argument)")
exit(1)
}
}
func titleShouldBeExcluded(_ title: String) -> Bool {
let range = NSRange(title.startIndex..<title.endIndex, in: title)
return excludeTitlePatterns.contains { pattern in
pattern.firstMatch(in: title, options: [], range: range) != nil
}
}
func isResearchBrowser(_ app: String) -> Bool {
return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}
// Return the category of the *longest* pattern contained in `haystack`.
// Longest-match beats map order because study maps routinely contain both a
// generic and a specific form of the same host ("google.com" vs
// "docs.google.com"); first-match-wins would file every Google Docs/Drive/
// Calendar/Meet URL under the generic entry. Ties resolve to map order.
// Twin of `_longest_match` in research_filter.py — keep the two in sync.
func longestResearchMatch(_ haystack: String) -> String? {
var bestCategory: String?
var bestLength = -1
for item in researchCategoryMap {
if item.pattern.isEmpty { continue }
// Compare by Unicode scalar count, not Character count: Swift's `count`
// measures grapheme clusters while Python's `len()` measures code points,
// which would let the two twins pick different winners for non-ASCII
// patterns of equal visual length.
let patternLength = item.pattern.unicodeScalars.count
if haystack.range(of: item.pattern, options: [.caseInsensitive]) != nil,
patternLength > bestLength
{
bestCategory = item.category
bestLength = patternLength
}
}
return bestCategory
}
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 {
if let category = longestResearchMatch(url) {
return category
}
}
if let category = longestResearchMatch(title) {
return category
}
return "excluded"
}
func classifyApp(_ app: String) -> String {
// Case-insensitive exact lookup of app name in the app category map.
// Returns the mapped category, or "Excluded" when the app is not in the map.
// Both sides are trimmed and lowercased so a configured key carrying stray
// whitespace matches identically here and in the Python path
// (research_filter.classify_app). Without trimming the configured key, a map
// entry like " Microsoft Outlook" would classify on Linux/Windows but fall
// through to "Excluded" on the macOS Swift path for the same config.
let appLower = app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
for item in researchAppCategoryMap {
if item.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == appLower {
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)
}
// Non-browser: map app to study category when a map is provided;
// otherwise keep app name and drop title (legacy behaviour).
if researchAppCategoryMap.isEmpty {
return NetworkMessage(app: data.app, title: nil, url: nil)
}
// Replace the raw app identity with its category — the app name is the
// sensitive identifier for non-browser apps, so it must not be retained.
return NetworkMessage(app: classifyApp(data.app), title: nil, url: nil)
}
func start() {
// Arguments should be:
// - url + port
// - bucket_id
// - hostname
// - client_id
let arguments = CommandLine.arguments
// 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> ...] [--research] [--research-category <pattern> <category> ...] [--research-app-category <app_name> <category> ...]")
exit(1)
}
baseurl = arguments[1]
bucketName = arguments[2]
clientHostname = arguments[3]
clientName = arguments[4]
parseOptionalArguments(arguments.dropFirst(5))
guard checkAccess() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
start()
}
return
}
createBucket()
// listen for changes in focused application
NSWorkspace.shared.notificationCenter.addObserver(
main,
selector: #selector(main.focusedAppChanged),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
main.focusedAppChanged()
// Start the polling timer
main.pollingTimer = Timer.scheduledTimer(timeInterval: 10.0, target: main, selector: #selector(main.pollActiveWindow), userInfo: nil, repeats: true)
}
// TODO might be better to have the python wrapper create this before launching the swift application
func createBucket() {
let payload = try! encoder.encode(
Bucket(client: clientName, type: "currentwindow", hostname: clientHostname))
let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)")!
Task {
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload)
guard (200...299).contains((response as! HTTPURLResponse).statusCode) else {
log("Failed to create bucket")
return
}
}
}
func sendHeartbeat(_ heartbeat: Heartbeat) {
let oldPayloadDifferent = oldHeartbeat != nil && oldHeartbeat!.data != heartbeat.data
let timeSinceLastHeartbeat = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : -1.0
// if you resize a window a ton of events (subsecond) will be fired
// we enforce a 1s minimum gap between events to avoid this
if timeSinceLastHeartbeat != -1.0 && timeSinceLastHeartbeat <= 0.5 {
debug("skipping heartbeat, last heartbeat was sent 1s ago")
return
}
// TODO running these async could cause weird state issues since the observer stuff can send a log of heartbeats
// in a short time under certain circumstances, and we don't want to send them all
Task {
if oldPayloadDifferent {
debug("sending old heartbeat for merging")
do {
// unlike the python aw-client library, we do not enforce a `commit_interval` and instead send the old event (which is not invalid)
// at the current time with a pulse value equal to the time since this event was originally sent. The aw-server will then merge
// this new event with the original event, extending the recorded time spent on this particular window/application.
let refreshedOldHeartbeat = Heartbeat(
// it is important to refresh the hearbeat using the timestamp where the user stopped working on the previous application
// more info: https://github.com/ActivityWatch/aw-watcher-window/pull/69
// we don't *think* this millisecond subtraction is necessary, but it may be:
// https://github.com/ActivityWatch/aw-watcher-window/pull/69#discussion_r987064282
timestamp: heartbeat.timestamp - 0.001,
data: oldHeartbeat!.data
)
try await sendHeartbeatSingle(refreshedOldHeartbeat, pulsetime: timeSinceLastHeartbeat + 1)
} catch {
log("Failed to send old heartbeat: \(error)")
return
}
}
do {
let since_last_seconds = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : 0
try await sendHeartbeatSingle(heartbeat, pulsetime: since_last_seconds + 1)
} catch {
log("Failed to send heartbeat: \(error)")
return
}
oldHeartbeat = heartbeat
}
}
func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws {
let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)/heartbeat?pulsetime=\(pulsetime)")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload = try! encoder.encode(heartbeat)
let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload)
guard (200...299).contains((response as! HTTPURLResponse).statusCode) else {
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 ?? "")")
}
class MainThing {
var observer: AXObserver?
var oldWindow: AXUIElement?
var pollingTimer: Timer?
// list of chrome equivalent browsers
let CHROME_BROWSERS = [
"Google Chrome",
"Google Chrome Canary",
"Chromium",
"Brave Browser",
]
// Gecko-based browsers have no scripting interface for tabs, but expose the
// current page URL on their accessibility tree's AXWebArea node
let FIREFOX_BROWSERS = [
"Firefox",
"Firefox Developer Edition",
"Firefox Nightly",
"Zen",
"Zen Browser",
"LibreWolf",
"Waterfox",
"Floorp",
]
// upper bound on accessibility elements examined per lookup, so a
// pathological tree can't stall the watcher (the web area is typically
// found within a few dozen elements)
let AX_TRAVERSAL_LIMIT = 384
// Search the window's accessibility tree breadth-first for an AXWebArea node
// and return its AXURL, the URL of the loaded page. The top-level document's
// web area sits shallow in Gecko's tree and is reached before any web areas
// of nested iframes, so the first hit is the current page.
// (there is no kAXWebAreaRole constant in HIServices; the role string is
// defined by the browsers themselves)
func geckoURL(window: AXUIElement) -> String? {
var queue: [AXUIElement] = [window]
var index = 0
while index < queue.count && index < AX_TRAVERSAL_LIMIT {
let element = queue[index]
index += 1
var roleRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef)
if roleRef as? String == "AXWebArea" {
var urlRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXURLAttribute as CFString, &urlRef)
if let url = urlRef as? NSURL {
return url.absoluteString
}
// no URL on the web area (e.g. page still loading); stop rather than
// keep searching, since a deeper hit would be an iframe's web area
return urlRef as? String
}
var childrenRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
if let children = childrenRef as? [AXUIElement] {
queue.append(contentsOf: children)
}
}
return nil
}
@objc func pollActiveWindow() {
debug("Polling active window")
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from polling")
return
}
let pid = frontmost.processIdentifier
let focusedApp = AXUIElementCreateApplication(pid)
var focusedWindow: AnyObject?
AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow)
if focusedWindow != nil {
focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement)
}
}
deinit {
pollingTimer?.invalidate()
}
func windowTitleChanged(
_ axObserver: AXObserver,
axElement: AXUIElement,
notification: CFString
) {
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from window title notification")
return
}
// calculate now before executing any scripting since that can take some time
let nowTime = Date.now
var windowTitle: AnyObject?
AXUIElementCopyAttributeValue(axElement, kAXTitleAttribute as CFString, &windowTitle)
let applicationName = frontmost.localizedName ?? frontmost.bundleIdentifier ?? ""
var data = NetworkMessage(app: applicationName, title: windowTitle as? String ?? "")
if CHROME_BROWSERS.contains(applicationName) {
debug("Chrome browser detected, extracting URL and title")
guard let bundleIdentifier = frontmost.bundleIdentifier else {
log("Failed to get bundle identifier from frontmost application, which was recognized to be Chrome")
return
}
let chromeObject: ChromeProtocol = SBApplication.init(bundleIdentifier: bundleIdentifier)!
guard let windows = chromeObject.windows,
let frontWindow = windows().first else {
log("Failed to get chrome front window")
return
}
guard let activeTab = frontWindow.activeTab else {
log("Failed to get chrome active tab")
return
}
if frontWindow.mode == "incognito" {
data = NetworkMessage(app: "", title: "")
} else {
data.url = activeTab.URL
// the tab title is more accurate and often different than the window title
// however, in some cases the binary does not have the right permissions to read
// the title properly and will return a blank string
if let tabTitle = activeTab.title {
if(tabTitle != "" && data.title != tabTitle) {
error("tab title diff: \(tabTitle), window title: \(data.title)")
data.title = tabTitle
}
}
}
} else if frontmost.localizedName == "Safari" {
debug("Safari browser detected, extracting URL and title")
guard let bundleIdentifier = frontmost.bundleIdentifier else {
log("Failed to get bundle identifier from frontmost application, which was recognized to be Safari")
return
}
let safariObject: SafariApplication = SBApplication.init(bundleIdentifier: bundleIdentifier)!
guard let windows = safariObject.windows,
let frontWindow = windows().first else {
log("Failed to get safari front window")
return
}
guard let activeTab = frontWindow.currentTab else {
log("Failed to get safari active tab")
return
}
// Safari doesn't allow incognito mode to be inspected, so we do not know if we should hide the url
data.url = activeTab.URL
// comment above applies here as well
if let tabTitle = activeTab.name {
if tabTitle != "" && data.title != tabTitle {
error("tab title diff: \(tabTitle), window title: \(data.title)")
data.title = tabTitle
}
}
} else if FIREFOX_BROWSERS.contains(applicationName) {
debug("Firefox-based browser detected, extracting URL from accessibility tree")
// note: private windows are not hidden here (unlike the Chrome incognito
// branch) — Gecko does not mark them in the accessibility tree, and their
// window titles carry a "Private Browsing" suffix for rules to match
data.url = geckoURL(window: axElement)
if data.url == nil {
// Newer Gecko builds instantiate their accessibility engine lazily and
// no longer treat plain tree walks as an assistive client, leaving the
// window's AX tree without any web content. Requesting
// AXEnhancedUserInterface (as VoiceOver does) turns the engine on; the
// call may report an error while the engine spins up, but the tree is
// populated for subsequent polls and stays on for the browser session.
let axApp = AXUIElementCreateApplication(frontmost.processIdentifier)
AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
}
}
if researchEnabled {
data = applyResearchFilter(data)
} else if excludeTitle || titleShouldBeExcluded(data.title ?? "") {
data.title = "excluded"
// the URL identifies the page at least as precisely as the title does,
// so an excluded window must not report it either
data.url = nil
}
let heartbeat = Heartbeat(timestamp: nowTime, data: data)
sendHeartbeat(heartbeat)
}
@objc func focusedWindowChanged(_ observer: AXObserver, window: AXUIElement) {
debug("Focused window changed")
if oldWindow != nil {
AXObserverRemoveNotification(observer, oldWindow!, kAXFocusedWindowChangedNotification as CFString)
}
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
AXObserverAddNotification(observer, window, kAXTitleChangedNotification as CFString, selfPtr)
windowTitleChanged(
observer, axElement: window, notification: kAXTitleChangedNotification as CFString)
oldWindow = window
}
@objc func focusedAppChanged() {
debug("Focused app changed")
if observer != nil {
CFRunLoopRemoveSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(observer!),
CFRunLoopMode.defaultMode
)
}
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from app change notification")
return
}
let pid = frontmost.processIdentifier
let focusedApp = AXUIElementCreateApplication(pid)
AXObserverCreate(
pid,
{
(
_ axObserver: AXObserver,
axElement: AXUIElement,
notification: CFString,
userData: UnsafeMutableRawPointer?
) -> Void in
guard let userData = userData else {
log("Missing userData")
return
}
let application = Unmanaged<MainThing>.fromOpaque(userData).takeUnretainedValue()
if notification == kAXFocusedWindowChangedNotification as CFString {
application.focusedWindowChanged(axObserver, window: axElement)
} else {
application.windowTitleChanged(
axObserver,
axElement: axElement,
notification: notification
)
}
}, &observer)
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
AXObserverAddNotification(observer!, focusedApp, kAXFocusedWindowChangedNotification as CFString, selfPtr)
CFRunLoopAddSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(observer!),
CFRunLoopMode.defaultMode
)
var focusedWindow: AnyObject?
AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow)
if focusedWindow != nil {
focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement)
}
}
}
// TODO I believe this is handled by the python wrapper so it isn't needed here
func checkAccess() -> Bool {
let checkOptPrompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as NSString
let options = [checkOptPrompt: true]
let accessEnabled = AXIsProcessTrustedWithOptions(options as CFDictionary?)
return accessEnabled
}

View File

@@ -0,0 +1,62 @@
from typing import Dict
from Foundation import NSAppleScript
# the applescript version of the macos strategy is kept here until the jxa
# approach is proven out in production environments
# https://github.com/ActivityWatch/aw-watcher-window/pull/52
source = """
global frontApp, frontAppName, windowTitle
set windowTitle to ""
tell application "System Events"
set frontApp to first application process whose frontmost is true
set frontAppName to name of frontApp
tell process frontAppName
try
tell (1st window whose value of attribute "AXMain" is true)
set windowTitle to value of attribute "AXTitle"
end tell
end try
end tell
end tell
return frontAppName & "
" & windowTitle
"""
script = None
def getInfo() -> Dict[str, str]:
# Cache compiled script
global script
if script is None:
script = NSAppleScript.alloc().initWithSource_(source)
# Call script
result, errorinfo = script.executeAndReturnError_(None)
if errorinfo:
raise Exception(errorinfo)
output = result.stringValue()
# Ensure there's no extra newlines in the output
assert len(output.split("\n")) == 2
app = getApp(output)
title = getTitle(output)
return {"app": app, "title": title}
def getApp(info: str) -> str:
return info.split('\n')[0]
def getTitle(info: str) -> str:
return info.split('\n')[1]
if __name__ == "__main__":
info = getInfo()
print(info)

View File

@@ -0,0 +1,23 @@
def build_swift_command(
binpath,
server_address,
bucket_id,
client_hostname,
client_name,
exclude_title=False,
exclude_titles=None,
research_category_map=None,
research_app_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])
for app_name, category in (research_app_category_map or {}).items():
command.extend(["--research-app-category", app_name, category])
return command

View File

@@ -0,0 +1,75 @@
import os
import json
import logging
from typing import Dict
logger = logging.getLogger(__name__)
script = None
def compileScript():
"""
Compiles the JXA script and caches the result.
Resources:
- https://stackoverflow.com/questions/44209057/how-can-i-run-jxa-from-swift
- https://stackoverflow.com/questions/16065162/calling-applescript-from-python-without-using-osascript-or-appscript
"""
# use a global variable to cache the compiled script for performance
global script
if script:
return script
from OSAKit import OSAScript, OSALanguage
scriptPath = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "printAppStatus.jxa"
)
with open(scriptPath) as f:
scriptContents = f.read()
# remove shebang line
if scriptContents.split("\n")[0].startswith("#"):
scriptContents = "\n".join(scriptContents.split("\n")[1:])
script = OSAScript.alloc().initWithSource_language_(
scriptContents, OSALanguage.languageForName_("JavaScript")
)
success, err = script.compileAndReturnError_(None)
# should only occur if jxa was modified incorrectly
if not success:
raise Exception(f"error compiling jxa script: {err['NSLocalizedDescription']}")
return script
def getInfo() -> Dict[str, str]:
script = compileScript()
result, err = script.executeAndReturnError_(None)
if err:
# error structure:
# {
# NSLocalizedDescription = "Error: Error: Can't get object.";
# NSLocalizedFailureReason = "Error: Error: Can't get object.";
# OSAScriptErrorBriefMessageKey = "Error: Error: Can't get object.";
# OSAScriptErrorMessageKey = "Error: Error: Can't get object.";
# OSAScriptErrorNumberKey = "-1728";
# OSAScriptErrorRangeKey = "NSRange: {0, 0}";
# }
raise Exception(f"jxa error: {err['NSLocalizedDescription']}")
return json.loads(result.stringValue())
if __name__ == "__main__":
print(getInfo())
print("Waiting 5 seconds...")
import time
time.sleep(5)
print(getInfo())

View File

@@ -0,0 +1,42 @@
import logging
from multiprocessing import Process
logger = logging.getLogger(__name__)
def background_ensure_permissions() -> None:
permission_process = Process(target=ensure_permissions, args=(()))
permission_process.start()
return
def ensure_permissions() -> None:
# noreorder
from AppKit import ( # fmt: skip
NSURL,
NSAlert,
NSAlertFirstButtonReturn,
NSWorkspace,
)
from ApplicationServices import AXIsProcessTrusted # fmt: skip
accessibility_permissions = AXIsProcessTrusted()
if not accessibility_permissions:
logger.info("No accessibility permissions, prompting user")
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."
alert = NSAlert.new()
alert.setMessageText_(title)
alert.setInformativeText_(info)
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"
)
)

225
aw_watcher_window/main.py Normal file
View File

@@ -0,0 +1,225 @@
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

View File

@@ -0,0 +1,65 @@
#!/usr/bin/osascript -l JavaScript
// adapted from:
// https://gist.github.com/EvanLovely/cb01eafb0d61515c835ecd56f6ac199a
// new to jxa?
// - https://apple-dev.groups.io/g/jxa/wiki/3202
// - interactive repl: `osascript -il JavaScript`
// - API reference: Script Editor -> File -> Open Dictionary
var seApp = Application("System Events");
var oProcess = seApp.processes.whose({frontmost: true})[0];
var appName = oProcess.displayedName();
// as of 05/01/21 incognio & url are not actively used in AW
// variables must be set to `undefined` since this script is re-run via osascript
// and the previously set values will be cached otherwise
var url = undefined, incognito = undefined, title = undefined;
// it's not possible to get the URL from firefox
// https://stackoverflow.com/questions/17846948/does-firefox-offer-applescript-support-to-get-url-of-windows
switch(appName) {
case "Safari":
// incognito is not available via safari applescript
url = Application(appName).documents[0].url();
title = Application(appName).documents[0].name();
break;
case "Google Chrome":
case "Google Chrome Canary":
case "Chromium":
case "Brave Browser":
const activeWindow = Application(appName).windows[0];
const activeTab = activeWindow.activeTab();
url = activeTab.url();
title = activeTab.name();
incognito = activeWindow.mode() === 'incognito';
break;
case "Firefox":
case "Firefox Developer Edition":
title = Application(appName).windows[0].name();
break;
default:
mainWindow = oProcess.
windows().
find(w => w.attributes.byName("AXMain").value() === true)
// in some cases, the primary window of an application may not be found
// this occurs rarely and seems to be triggered by switching to a different application
if(mainWindow) {
title = mainWindow.
attributes.
byName("AXTitle").
value()
}
}
// key names must match expected names in lib.py
JSON.stringify({
app: appName,
url,
title,
incognito
});

View File

@@ -0,0 +1,186 @@
"""
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: app name is mapped to a broad study category where
possible, otherwise replaced with 'Excluded'. Supply the mapping in
``[aw-watcher-window.research_app_category_map]``. Without a map the
previous behaviour is preserved (app name kept, title dropped).
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
behaviour falls back to title-drop.
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
# Known browser applications (lowercase, for case-insensitive matching)
BROWSER_APPS = frozenset(
{
"chrome",
"google chrome",
"google chrome canary",
"google-chrome",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium.exe",
"brave browser",
"brave",
"brave-browser",
"arc",
"arc browser",
"firefox",
"firefox developer edition",
"firefox-esr",
"safari",
"edge",
"microsoft edge",
"microsoft-edge",
"microsoft-edge-beta",
"microsoft-edge-dev",
"opera",
"vivaldi",
"vivaldi.exe",
"chrome.exe",
"brave.exe",
"firefox.exe",
"msedge.exe",
"opera.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 _longest_match(haystack: str, category_map: dict) -> Optional[str]:
"""
Return the category of the *longest* pattern contained in *haystack*.
Longest-match beats insertion order because study maps routinely contain
both a generic and a specific form of the same host: ``google.com`` →
*Search & Navigation* and ``docs.google.com`` → *Work & Productivity*.
First-match-wins would file every Google Docs/Drive/Calendar/Meet URL as
Search, since ``google.com`` is a substring of all of them.
Ties (equal-length patterns) resolve to the first one in map order.
"""
best_category: Optional[str] = None
best_len = -1
for pattern, category in category_map.items():
pattern_lower = pattern.lower()
if pattern_lower and pattern_lower in haystack and len(pattern_lower) > best_len:
best_category = category
best_len = len(pattern_lower)
return best_category
def classify_title(title: str, category_map: dict, url: str = "") -> str:
"""
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}`` — the **longest** matching
substring wins, so a specific host (``docs.google.com``) is not shadowed by
a generic one (``google.com``) that happens to be listed earlier.
Returns the category name, or ``"excluded"`` if nothing matched.
"""
if url:
category = _longest_match(url.lower(), category_map)
if category is not None:
return category
category = _longest_match(title.lower(), category_map)
if category is not None:
return category
return "excluded"
def classify_app(app: str, app_category_map: dict) -> str:
"""
Classify a non-browser application into a study category.
Performs a case-insensitive exact lookup of *app* in *app_category_map*
(``{app_name: category_name}``). Both the lookup key and the configured
keys are normalized to lowercase, so mixed-case map keys (e.g. ``"Microsoft
Outlook"``) match their lowercase application names. Returns the mapped
category, or ``"Excluded"`` when the app is not in the map.
Note: uses capital-E ``"Excluded"`` to match Matthias's original classifier
convention (``EXCLUDED = "Excluded"``), distinct from the lowercase
``"excluded"`` used for browser title mismatches.
"""
app_lower = app.strip().lower()
for map_app, category in app_category_map.items():
if map_app.strip().lower() == app_lower:
return category
return "Excluded"
def transform(
window: dict,
category_map: Optional[dict],
app_category_map: Optional[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 — URLs are stripped and incognito is preserved).
*category_map*: study-specific URL/title mapping dict, or ``None`` to no-op.
*app_category_map*: optional app-name → category mapping for non-browser
apps. When supplied, non-browser apps are replaced with their mapped
category (or ``"Excluded"`` if unmapped) instead of keeping the raw app name.
Returns the transformed window dict (never mutates the input).
"""
if category_map is None:
return window
app = window.get("app", "")
if is_browser(app):
# Browser: replace title with a study category
title = window.get("title", "")
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)
if "incognito" in window:
result["incognito"] = window["incognito"]
return result
else:
# Non-browser: map app to a study category when a map is provided,
# otherwise keep the raw app name and drop the title.
# An empty map is treated as "not provided" — so research_enabled with
# no configured app map falls back to legacy behaviour instead of
# classifying every non-browser app as "Excluded".
if app_category_map:
app_category = classify_app(app, app_category_map)
# Replace the raw app identity with its category: the app name is
# the sensitive identifier for non-browser apps (e.g. "Microsoft
# Outlook", a niche tool, a company-internal app), so it must not
# be retained. Title/URL are dropped entirely.
result = {"app": app_category}
else:
# Legacy behaviour: title dropped, app name kept as-is.
result = {"app": app}
# 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

View File

@@ -0,0 +1,89 @@
import os
import time
from typing import Optional
import win32api
import win32gui
import win32process
import wmi
def get_app_path(hwnd) -> Optional[str]:
"""Get application path given hwnd."""
path = None
_, pid = win32process.GetWindowThreadProcessId(hwnd)
process = win32api.OpenProcess(
0x0400, False, pid
) # PROCESS_QUERY_INFORMATION = 0x0400
try:
path = win32process.GetModuleFileNameEx(process, 0)
finally:
win32api.CloseHandle(process)
return path
def get_app_name(hwnd) -> Optional[str]:
"""Get application filename given hwnd."""
path = get_app_path(hwnd)
if path is None:
return None
return os.path.basename(path)
def get_window_title(hwnd):
return win32gui.GetWindowText(hwnd)
def get_active_window_handle():
hwnd = win32gui.GetForegroundWindow()
return hwnd
# WMI-version, used as fallback if win32gui/win32process/win32api fails (such as for "run as admin" processes)
c = wmi.WMI()
"""
Much of this derived from: http://stackoverflow.com/a/14973422/965332
"""
def get_app_name_wmi(hwnd) -> Optional[str]:
"""Get application filename given hwnd."""
name = None
_, pid = win32process.GetWindowThreadProcessId(hwnd)
for p in c.query("SELECT Name FROM Win32_Process WHERE ProcessId = %s" % str(pid)):
name = p.Name
break
return name
def get_app_path_wmi(hwnd) -> Optional[str]:
"""Get application path given hwnd."""
path = None
_, pid = win32process.GetWindowThreadProcessId(hwnd)
for p in c.query(
"SELECT ExecutablePath FROM Win32_Process WHERE ProcessId = %s" % str(pid)
):
path = p.ExecutablePath
break
return path
if __name__ == "__main__":
while True:
hwnd = get_active_window_handle()
print("Title:", get_window_title(hwnd))
print("App: ", get_app_name(hwnd))
print("App (wmi): ", get_app_name_wmi(hwnd))
print("Path: ", get_app_path(hwnd))
print("Path (wmi): ", get_app_path_wmi(hwnd))
time.sleep(1.0)

174
aw_watcher_window/xlib.py Normal file
View File

@@ -0,0 +1,174 @@
import logging
from typing import Optional
import Xlib
import Xlib.display
from Xlib import X
from Xlib.xobject.drawable import Window
from .exceptions import FatalError
logger = logging.getLogger(__name__)
display = Xlib.display.Display()
screen = display.screen()
NET_WM_NAME = display.intern_atom("_NET_WM_NAME")
UTF8_STRING = display.intern_atom("UTF8_STRING")
def _get_current_window_id() -> Optional[int]:
atom = display.get_atom("_NET_ACTIVE_WINDOW")
window_prop = screen.root.get_full_property(atom, X.AnyPropertyType)
if window_prop is None:
logger.warning("window_prop was None")
return None
# window_prop may contain more than one value, but it seems that it's always the first we want.
# The second has in my attempts always been 0 or rubbish.
window_id = window_prop.value[0]
return window_id if window_id != 0 else None
def _get_window(window_id: int) -> Window:
return display.create_resource_object("window", window_id)
def get_current_window() -> Optional[Window]:
"""
Returns the current window, or None if no window is active.
"""
try:
window_id = _get_current_window_id()
if window_id is None:
return None
else:
return _get_window(window_id)
except Xlib.error.ConnectionClosedError:
# when the X server closes the connection, we should exit
# note that stdio is probably closed at this point, so we can't print anything (causes OSError)
try:
logger.warning("X server closed connection, exiting")
except OSError:
pass
raise FatalError()
# Things that can lead to unknown cls/name:
# - (cls+name) Empty desktop in xfce (no window focused)
# - (name) Chrome (fixed, didn't support when WM_NAME was UTF8_STRING)
def get_window_name(window: Window) -> str:
"""After some annoying debugging I resorted to pretty much copying selfspy.
Source: https://github.com/gurgeh/selfspy/blob/8a34597f81000b3a1be12f8cde092a40604e49cf/selfspy/sniff_x.py#L165"""
try:
d = window.get_full_property(NET_WM_NAME, UTF8_STRING)
except Xlib.error.XError as e:
logger.warning(
f"Unable to get window property NET_WM_NAME, got a {type(e).__name__} exception from Xlib"
)
# I strongly suspect window.get_wm_name() will also fail and we should return "unknown" right away.
# But I don't know, so I pass the thing on, for now.
d = None
if d is None or d.format != 8:
try:
# Fallback.
r = window.get_wm_name()
if r is None:
return "unknown"
elif isinstance(r, str):
return r
else:
logger.warning(
"I don't think this case will ever happen, but not sure so leaving this message here just in case."
)
return r.decode("latin1") # WM_NAME with type=STRING.
except Xlib.error.BadWindow as e:
# I comment on the log, the number of messages that pop up is very annoying
# Also, it does not give much information about the error
# But I leave it in case someone sees it useful
# logger.warning(
# f"Unable to get window property WM_NAME, got a {type(e).__name__} exception from Xlib"
# )
return "unknown"
else:
# Fixing utf8 issue on Ubuntu (https://github.com/gurgeh/selfspy/issues/133)
# Thanks to https://github.com/gurgeh/selfspy/issues/133#issuecomment-142943681
try:
return d.value.decode("utf8")
except UnicodeError:
logger.warning(
f"Failed to decode one or more characters which will be skipped, bytes are: {d.value}"
)
if isinstance(d.value, bytes):
return d.value.decode("utf8", "ignore")
else:
return d.value.encode("utf8").decode("utf8", "ignore")
def get_window_class(window: Window) -> str:
cls = None
try:
cls = window.get_wm_class()
except Xlib.error.BadWindow:
logger.warning("Unable to get window class, got a BadWindow exception.")
# TODO: Is this needed?
# nikanar: Indeed, it seems that it is. But it would be interesting to see how often this succeeds, and if it is low, maybe fail earlier.
if not cls:
print("")
logger.warning("Code made an unclear branch")
try:
window = window.query_tree().parent
except Xlib.error.BadWindow:
# I comment on the log, the number of messages that pop up is very annoying
# Also, it does not give much information about the error
# But I leave it in case someone sees it useful
# logger.warning(
# "Unable to get window query_tree().parent, got a BadWindow exception."
# )
return "unknown"
except Xlib.error.XError as e:
logger.warning(
f"Unable to get window query_tree().parent, got a {type(e).__name__} exception from Xlib"
)
return "unknown"
if window:
return get_window_class(window)
else:
return "unknown"
cls = cls[1]
return cls
def get_window_pid(window: Window) -> str:
atom = display.get_atom("_NET_WM_PID")
pid_property = window.get_full_property(atom, X.AnyPropertyType)
if pid_property:
pid = pid_property.value[-1]
return pid
else:
# TODO: Needed?
raise Exception("pid_property was None")
if __name__ == "__main__":
from time import sleep
while True:
print("-" * 20)
window = get_current_window()
if window is None:
print("unable to get active window")
name, cls = "unknown", "unknown"
else:
cls = get_window_class(window)
name = get_window_name(window)
print("name:", name)
print("class:", cls)
sleep(1)

128
aw_watcher_window/xprop.py Normal file
View File

@@ -0,0 +1,128 @@
import sys
import subprocess
import re
import logging
from subprocess import PIPE
from typing import List
logger = logging.getLogger(__name__)
# req_version is 3.5 due to usage of subprocess.run
# It would be nice to be able to use 3.4 as well since it's still common as of May 2016
req_version = (3, 5)
cur_version = sys.version_info
if not cur_version >= req_version:
logger.error("Your Python version is too old, 3.5 or higher is required.")
exit(1)
def xprop_id(window_id) -> str:
cmd = ["xprop"]
cmd.append("-id")
cmd.append(window_id)
p = subprocess.run(cmd, stdout=PIPE)
return str(p.stdout, "utf8")
def xprop_root() -> str:
cmd = ["xprop"]
cmd.append("-root")
p = subprocess.run(cmd, stdout=PIPE)
return str(p.stdout, "utf8")
def get_active_window_id():
lines = xprop_root().split("\n")
match="_NET_ACTIVE_WINDOW(WINDOW)"
result = None
for line in lines:
if match in line:
result = line
break
wid = "0x0"
if result:
wids = re.findall("0x[0-9a-f]*", result)
if len(wids) > 0:
wid = wids[0]
return wid
def get_window_ids():
lines = xprop_root().split("\n")
client_list = next(filter(lambda x: "_NET_CLIENT_LIST(" in x, lines))
window_ids = re.findall("0x[0-9a-f]*", client_list)
return window_ids
def _extract_xprop_field(line):
return "".join(line.split("=")[1:]).strip(" \n")
def get_xprop_field(fieldname, xprop_output):
return list(map(_extract_xprop_field, re.findall(fieldname + ".*\n", xprop_output)))
def get_xprop_field_str(fieldname, xprop_output) -> str:
field = None
try:
field = get_xprop_field(fieldname, xprop_output)[0].strip('"')
except IndexError:
pass
if not field:
field = "unknown"
return field
def get_xprop_field_strlist(fieldname, xprop_output) -> List[str]:
return [s.strip('"') for s in get_xprop_field(fieldname, xprop_output)]
def get_xprop_field_int(fieldname, xprop_output) -> int:
field = None
try:
field = int(get_xprop_field(fieldname, xprop_output)[0])
except IndexError:
pass
if not field:
field = -1
return field
def get_xprop_field_class(xprop_output) -> List[str]:
classname: List[str] = []
try:
classname = [c.strip('", ') for c in get_xprop_field("WM_CLASS", xprop_output)[0].split(',')]
except IndexError:
pass
if not classname:
classname = ["unknown"]
return classname
def get_window(wid, active_window=False):
s = xprop_id(wid)
window = {
"id": wid,
"active": active_window,
"name": get_xprop_field_str("WM_NAME", s),
"class": get_xprop_field_class(s),
"desktop": get_xprop_field_int("WM_DESKTOP", s),
"command": get_xprop_field("WM_COMMAND", s),
"role": get_xprop_field_strlist("WM_WINDOW_ROLE", s),
"pid": get_xprop_field_int("WM_PID", s),
}
return window
def get_windows(wids, active_window_id=None):
return [get_window(wid, active_window=(wid == active_window_id)) for wid in wids]
if __name__ == "__main__":
from time import sleep
logging.basicConfig(level=logging.INFO)
while True:
sleep(1)
print("Active window id: " + str(get_active_window_id()))

View File

@@ -0,0 +1,14 @@
[Unit]
Description=X11 window watcher for ActivityWatch
Documentation=
After=aw-server.service
[Service]
Type=simple
ExecStart=/usr/bin/env aw-watcher-x11
Environment="DISPLAY=:0"
Restart=always
KillSignal=SIGINT
[Install]
WantedBy=default.target

1095
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[tool.poetry]
name = "aw-watcher-window"
version = "0.2.0"
description = "Window watcher for ActivityWatch"
authors = ["Erik Bjäreholt <erik@bjareho.lt>"]
license = "MPL-2.0"
[tool.poetry.scripts]
aw-watcher-window = "aw_watcher_window:main"
[tool.poetry.dependencies]
python = "^3.8"
aw-client = "^0.5.14"
pywin32 = {version = "306", platform = "win32"}
wmi = {version = "*", platform = "win32"}
pyobjc-framework-ApplicationServices = { version = "*", platform="darwin"}
pyobjc-framework-CoreText = {version = "*", platform="darwin"}
pyobjc-framework-OSAKit = {version = "*", platform="darwin"}
# locked due to https://github.com/python-xlib/python-xlib/pull/242 leading to 100% CPU stalls
# see: https://github.com/ActivityWatch/aw-watcher-window/issues/89
# also locked in aw-watcher-afk
python-xlib = {version = "0.31", platform = "linux"}
[tool.poetry.dev-dependencies]
pytest = "*"
mypy = "*"
macholib = {version = "*", platform = "darwin"} # Needed for pyinstaller
pyinstaller = {version = "*", python = "^3.8,<3.14"}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

176
tests/test_config.py Normal file
View File

@@ -0,0 +1,176 @@
import os
import pathlib
import re
import sys
import aw_core.dirs
import pytest
import tomlkit
from aw_watcher_window import config as config_module
# The Research Edition release build (release.yml) patches config.py with
# scripts/patch_research_edition_config.py before running `make test`, which
# flips research_enabled's default to true. The guards below assert the
# pristine (non-research) defaults and must not fire on that build.
RESEARCH_BUILD = os.environ.get("AW_RESEARCH_EDITION") == "true"
def _patch_config_dir(monkeypatch, tmp_path):
"""Point aw_core's config dir at tmp_path on all platforms.
XDG_CONFIG_HOME is Linux-only; appdirs ignores it on macOS/Windows.
Patching get_config_dir directly is cross-platform.
"""
def _mock(module_name=None):
base = tmp_path / "activitywatch"
result = base / module_name if module_name else base
result.mkdir(parents=True, exist_ok=True)
return str(result)
monkeypatch.setattr(aw_core.dirs, "get_config_dir", _mock)
def test_first_run_config_has_no_research_keys(tmp_path, monkeypatch):
"""Research/dev options must never be authored into a fresh user's config.
load_config_toml() writes the default_config template to disk on first run.
It comments out keys but leaves table headers uncommented, so a research
table in the template lands verbatim in every new user's config file.
Verified by symptom: run against an empty config dir and read what was written.
"""
_patch_config_dir(monkeypatch, tmp_path)
config_module.load_config()
written = next(tmp_path.rglob("aw-watcher-window.toml")).read_text()
assert "research" not in written
# The file must still be valid TOML, and parse to nothing but comments.
assert dict(tomlkit.parse(written)["aw-watcher-window"]) == {}
def test_research_options_are_read_when_user_sets_them(tmp_path, monkeypatch):
"""Absent from the template, but honoured when a Research Edition user opts in."""
_patch_config_dir(monkeypatch, tmp_path)
config_path = tmp_path / "activitywatch" / "aw-watcher-window"
config_path.mkdir(parents=True)
(config_path / "aw-watcher-window.toml").write_text(
"[aw-watcher-window]\n"
"research_enabled = true\n"
"\n"
"[aw-watcher-window.research_category_map]\n"
'youtube = "Entertainment"\n'
"\n"
"[aw-watcher-window.research_app_category_map]\n"
'"Microsoft Outlook" = "Communication"\n'
)
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])
args = config_module.parse_args()
assert args.research_enabled is True
assert args.research_category_map == {"youtube": "Entertainment"}
assert args.research_app_category_map == {"Microsoft Outlook": "Communication"}
@pytest.mark.skipif(
RESEARCH_BUILD,
reason="research edition build patches this default on purpose (release.yml)",
)
def test_research_edition_sed_target_is_intact():
"""The Research Edition release build patches this file with sed.
ActivityWatch/activitywatch .github/workflows/release.yml runs
``sed -i 's/^research_enabled = false$/research_enabled = true/'`` against
this module. That target lives in another repo, so pin it here — otherwise
reformatting research_defaults silently breaks the Research Edition build.
"""
# Check the source FILE, not the evaluated string. sed anchors on ^...$ in
# the file, so an indented ` research_enabled = false` inside the literal
# still strips to the same value — the string-level check would stay green
# while the release build broke.
source = pathlib.Path(config_module.__file__).read_text()
assert re.search(
r"^research_enabled = false$", source, re.MULTILINE
), "Research Edition sed target moved or got indented; see release.yml"
# Simulate what release.yml sed actually does: apply the replacement to the
# source FILE and confirm the patched line appears at column 0. Checking the
# runtime string (research_defaults) alone is insufficient — .strip() would
# return the same value even if the source line is indented, keeping this
# assertion green while the real sed-on-source would silently fail to match.
patched_source = re.sub(
r"^research_enabled = false$",
"research_enabled = true",
source,
flags=re.MULTILINE,
)
assert re.search(r"^research_enabled = true$", patched_source, re.MULTILINE), (
"sed patch did not produce 'research_enabled = true' in source"
)
# Also verify the patched value is structurally valid TOML with the flag set.
# The triple-quoted literal's runtime value is equivalent to the source content
# (both are stripped of surrounding whitespace), so we can reuse it here.
patched_defaults = re.sub(
r"^research_enabled = false$",
"research_enabled = true",
config_module.research_defaults,
flags=re.MULTILINE,
)
assert tomlkit.parse(patched_defaults)["research_enabled"] is True
@pytest.mark.skipif(
RESEARCH_BUILD,
reason="research edition build patches this default on purpose (release.yml)",
)
def test_parse_args_defaults_research_off_without_config(tmp_path, monkeypatch):
"""No research keys anywhere => disabled, with empty maps."""
_patch_config_dir(monkeypatch, tmp_path)
monkeypatch.setattr(sys, "argv", ["aw-watcher-window"])
args = config_module.parse_args()
assert args.research_enabled is False
assert args.research_category_map == {}
assert args.research_app_category_map == {}
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"}
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

215
tests/test_main.py Normal file
View File

@@ -0,0 +1,215 @@
import re
from types import SimpleNamespace
import pytest
import aw_watcher_window.main as main_module
from aw_watcher_window.macos_cli import build_swift_command
def test_research_mode_passes_map_to_macos_swift_strategy(monkeypatch):
commands = []
class FakeProcess:
pid = 123
def wait(self):
return None
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
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(main_module.sys, "platform", "darwin")
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"},
research_app_category_map={},
),
)
main_module.main()
assert commands[0][-4:] == ["--research", "--research-category", "youtube", "Youtube"]
def test_build_swift_command_omits_optional_filters():
command = build_swift_command(
"/tmp/aw-watcher-window-macos",
"http://localhost:5600",
"bucket",
"host.localdomain",
"aw-watcher-window",
)
assert command == [
"/tmp/aw-watcher-window-macos",
"http://localhost:5600",
"bucket",
"host.localdomain",
"aw-watcher-window",
]
def test_build_swift_command_passes_title_filters():
command = build_swift_command(
"/tmp/aw-watcher-window-macos",
"http://localhost:5600",
"bucket",
"host.localdomain",
"aw-watcher-window",
exclude_title=True,
exclude_titles=["Zoom", "Slack.*huddle"],
)
assert command == [
"/tmp/aw-watcher-window-macos",
"http://localhost:5600",
"bucket",
"host.localdomain",
"aw-watcher-window",
"--exclude-title",
"--exclude-titles",
"Zoom",
"--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",
]
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"}
@pytest.mark.parametrize(
"poll_time,expected_pulsetime",
[
(1.0, 2.0),
(2.0, 3.0),
(5.0, 7.5),
(10.0, 15.0),
],
)
def test_pulsetime_scales_with_poll_time(
poll_time: float, expected_pulsetime: float
):
assert main_module.compute_pulsetime(poll_time) == expected_pulsetime

View File

@@ -0,0 +1,423 @@
"""Tests for aw-watcher-window Research Edition filter."""
import re
import unittest
from pathlib import Path
from aw_watcher_window.research_filter import (
BROWSER_APPS,
classify_app,
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",
"Google-chrome",
"Firefox",
"Safari",
"Edge",
"Microsoft Edge",
):
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")
def test_python_and_swift_browser_aliases_match(self):
swift = (Path(__file__).parents[1] / "aw_watcher_window" / "macos.swift").read_text()
match = re.search(
r"let\s+researchBrowserApps\s*=\s*Set\s*\(\s*\[(?P<entries>.*?)\]\s*\)",
swift,
re.DOTALL,
)
self.assertIsNotNone(match)
swift_browser_apps = set(re.findall(r'"([^"]+)"', match.group("entries")))
self.assertEqual(swift_browser_apps, BROWSER_APPS)
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_longest_pattern_wins(self):
# Both "facebook" (8) and "twitter" (7) present — the longer pattern wins
result = classify_title("Facebook Twitter integration - Chrome", self.CATEGORY_MAP)
self.assertEqual(result, "Facebook")
def test_equal_length_patterns_resolve_to_map_order(self):
# "gmail" and "inbox" are both 5 chars — the earlier map entry wins
category_map = {"gmail": "Email", "inbox": "Other"}
self.assertEqual(classify_title("gmail inbox", category_map), "Email")
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 = {
"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_excludes_browser_title(self):
window = {"app": "Chrome", "title": "YouTube - Chrome"}
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"}
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_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.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
# and auth tokens; incognito flag is non-sensitive metadata and can be preserved
window = {
"app": "Chrome",
"title": "YouTube - Google Chrome",
"url": "https://youtube.com/search?q=secret",
"incognito": False,
}
result = transform(window, self.CATEGORY_MAP)
self.assertEqual(result["title"], "Youtube")
self.assertNotIn("url", result, "Browser URLs must be stripped for privacy")
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_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_original_classifier_browser_aliases_are_classified(self):
for app in ("Arc", "Arc Browser", "Vivaldi", "Vivaldi.exe", "Chromium.exe"):
with self.subTest(app=app):
window = {
"app": app,
"title": "New Tab",
"url": "https://youtube.com/watch?v=private",
}
result = transform(window, self.CATEGORY_MAP)
self.assertEqual(result, {"app": app, "title": "Youtube"})
def test_input_not_mutated(self):
window = {"app": "Chrome", "title": "YouTube - Chrome"}
original = dict(window)
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)
class TestClassifyApp(unittest.TestCase):
APP_CATEGORY_MAP = {
"microsoft outlook": "Email",
"outlook.exe": "Email",
"microsoft teams": "Work & Productivity",
"teams": "Work & Productivity",
"spotify": "Music & Audio",
"finder": "Excluded",
"steam": "Games",
}
def test_exact_match(self):
self.assertEqual(classify_app("Microsoft Outlook", self.APP_CATEGORY_MAP), "Email")
def test_case_insensitive(self):
self.assertEqual(classify_app("SPOTIFY", self.APP_CATEGORY_MAP), "Music & Audio")
self.assertEqual(classify_app("spotify", self.APP_CATEGORY_MAP), "Music & Audio")
self.assertEqual(classify_app("Spotify", self.APP_CATEGORY_MAP), "Music & Audio")
def test_exe_suffix(self):
self.assertEqual(classify_app("outlook.exe", self.APP_CATEGORY_MAP), "Email")
def test_unmapped_returns_excluded(self):
self.assertEqual(classify_app("iTerm2", self.APP_CATEGORY_MAP), "Excluded")
self.assertEqual(classify_app("Terminal", self.APP_CATEGORY_MAP), "Excluded")
self.assertEqual(classify_app("", self.APP_CATEGORY_MAP), "Excluded")
def test_leading_trailing_spaces(self):
self.assertEqual(classify_app(" Spotify ", self.APP_CATEGORY_MAP), "Music & Audio")
def test_explicit_excluded_entry(self):
# "Finder" is explicitly mapped to "Excluded" in Matthias's map
self.assertEqual(classify_app("Finder", self.APP_CATEGORY_MAP), "Excluded")
def test_empty_map_returns_excluded(self):
self.assertEqual(classify_app("Spotify", {}), "Excluded")
def test_mixed_case_map_keys(self):
# Configured keys may be mixed-case (as typed in config TOML); lookup
# must still match case-insensitively on both sides.
mixed_case_map = {
"Microsoft Outlook": "Email",
"Spotify": "Music & Audio",
}
self.assertEqual(classify_app("microsoft outlook", mixed_case_map), "Email")
self.assertEqual(classify_app("SPOTIFY", mixed_case_map), "Music & Audio")
class TestTransformWithAppMap(unittest.TestCase):
CATEGORY_MAP = {
"youtube": "Video Streaming",
"facebook": "Social Networking",
}
APP_CATEGORY_MAP = {
"microsoft outlook": "Email",
"microsoft teams": "Work & Productivity",
"spotify": "Music & Audio",
"finder": "Excluded",
}
def test_non_browser_mapped_to_category(self):
# Raw app identity is REPLACED by its category; title is dropped.
window = {"app": "Microsoft Outlook", "title": "Inbox — johndoe@example.com"}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["app"], "Email")
self.assertNotIn("title", result)
def test_non_browser_unmapped_returns_excluded(self):
# Unmapped non-browser app is anonymized to "Excluded", title dropped.
window = {"app": "iTerm2", "title": "~/projects/secret"}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["app"], "Excluded")
self.assertNotIn("title", result)
def test_non_browser_title_and_url_stripped(self):
# App replaced by category; sensitive title AND URL must not leak
window = {
"app": "Microsoft Outlook",
"title": "Private inbox",
"url": "mailbox://private@corp.example.com/INBOX",
}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["app"], "Email")
self.assertNotIn("title", result)
self.assertNotIn("url", result)
def test_non_browser_incognito_preserved(self):
window = {"app": "Spotify", "title": "Now playing: Secret Playlist", "incognito": True}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["app"], "Music & Audio")
self.assertEqual(result["incognito"], True)
def test_browser_unaffected_by_app_map(self):
# Browser classification should not change even when an app map is present
window = {"app": "Chrome", "title": "YouTube - Chrome"}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["title"], "Video Streaming")
def test_no_app_map_legacy_behaviour(self):
# Without app map, non-browser keeps raw app name and drops title (backward compat)
window = {"app": "Microsoft Outlook", "title": "Inbox"}
result = transform(window, self.CATEGORY_MAP, None)
self.assertEqual(result["app"], "Microsoft Outlook")
self.assertNotIn("title", result)
def test_empty_app_map_falls_back_to_legacy(self):
# An empty app map is treated as "not provided": research_enabled with no
# configured app map must NOT classify every non-browser app as "Excluded".
window = {"app": "Microsoft Outlook", "title": "Inbox"}
result = transform(window, self.CATEGORY_MAP, {})
self.assertEqual(result["app"], "Microsoft Outlook")
self.assertNotIn("title", result)
def test_explicit_excluded_mapped_app(self):
# "Finder" → "Excluded" (explicit map entry, not a lookup miss)
window = {"app": "Finder", "title": "/Users/jdoe/Documents"}
result = transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(result["app"], "Excluded")
self.assertNotIn("title", result)
def test_input_not_mutated_with_app_map(self):
window = {"app": "Spotify", "title": "Now playing"}
original = dict(window)
transform(window, self.CATEGORY_MAP, self.APP_CATEGORY_MAP)
self.assertEqual(window, original)
class TestSpecificHostBeatsGenericHost(unittest.TestCase):
"""Regression: a generic host must not shadow a more specific one.
Study maps list ``google.com`` well before ``docs.google.com``. Under
first-match-wins every Google Workspace URL was filed as *Search &
Navigation* instead of *Work & Productivity* — reported from the
Research Edition b4 field test.
"""
CATEGORY_MAP = {
"google.com": "Search & Navigation",
"docs.google.com": "Work & Productivity",
"drive.google.com": "Work & Productivity",
"calendar.google.com": "Work & Productivity",
"meet.google.com": "Work & Productivity",
}
def test_workspace_urls_are_work_not_search(self):
for url in (
"https://docs.google.com/document/d/abc/edit",
"https://drive.google.com/drive/my-drive",
"https://calendar.google.com/calendar/u/0/r",
"https://meet.google.com/xyz-abcd-efg",
):
with self.subTest(url=url):
self.assertEqual(
classify_title("", self.CATEGORY_MAP, url=url),
"Work & Productivity",
)
def test_plain_google_search_still_matches_generic(self):
self.assertEqual(
classify_title("", self.CATEGORY_MAP, url="https://www.google.com/search?q=aw"),
"Search & Navigation",
)
def test_specific_host_also_wins_on_title_fallback(self):
self.assertEqual(
classify_title("docs.google.com - Untitled", self.CATEGORY_MAP),
"Work & Productivity",
)
def test_empty_pattern_is_ignored(self):
# An empty map key matches everything under `in`; it must not swallow input.
category_map = {"": "Bogus", "youtube": "Youtube"}
self.assertEqual(classify_title("youtube - Chrome", category_map), "Youtube")
self.assertEqual(classify_title("unrelated", category_map), "excluded")
if __name__ == "__main__":
unittest.main()