snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on macos-latest (py-3.9) (push) Has been cancelled
Build / Test on ubuntu-24.04 (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 1m36s

This commit is contained in:
upstream-mirror
2026-09-16 00:21:02 +05:00
commit 87aaa2add8
22 changed files with 2929 additions and 0 deletions

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

@@ -0,0 +1,54 @@
name: Build
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
name: Test on ${{ matrix.os }} (py-${{ matrix.python_version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-latest, macos-latest]
python_version: ['3.9']
steps:
- uses: actions/checkout@v4
with:
submodules: 'recursive'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python_version }}
- 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
poetry install
- 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
make package
- name: Test package
shell: bash
run: |
dist/aw-watcher-afk/aw-watcher-afk --help
- name: Upload package
uses: actions/upload-artifact@v4
with:
name: aw-watcher-afk-${{ runner.os }}-py${{ matrix.python_version }}
path: dist/aw-watcher-afk

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: "42 2 * * 0"
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 @@
*.pyc
*.swp
logs
*.egg-info
build
dist
*~
.mypy_cache

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.

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
.PHONY: build test package clean
build:
poetry install
test:
poetry run aw-watcher-afk --help # Ensures that it at least starts
poetry run python -m unittest discover -s tests -p "test_afk_failures.py"
make typecheck
typecheck:
poetry run mypy aw_watcher_afk --ignore-missing-imports
package:
pyinstaller aw-watcher-afk.spec --clean --noconfirm
clean:
rm -rf build dist
rm -rf aw_watcher_afk/__pycache__

22
README.md Normal file
View File

@@ -0,0 +1,22 @@
aw-watcher-afk
==============
[![Build Status](https://github.com/ActivityWatch/aw-watcher-afk/workflows/Build/badge.svg)](https://github.com/ActivityWatch/aw-watcher-afk/actions)
Watches your keyboard and mouse activity to determine if you are AFK (away from keyboard) or not.
By default, a period of at least 3 minutes inactivity is flagged as AFK.
## 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-afk.git`
To install from a cloned version, cd into the directory and run
`poetry install` to install inside an virtualenv. 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.

55
aw-watcher-afk.spec Normal file
View File

@@ -0,0 +1,55 @@
# -*- mode: python -*-
block_cipher = None
a = Analysis(['aw_watcher_afk/__main__.py'],
pathex=[],
binaries=None,
datas=None,
hiddenimports=[
'Xlib.keysymdef.miscellany',
'Xlib.keysymdef.latin1',
'Xlib.keysymdef.latin2',
'Xlib.keysymdef.latin3',
'Xlib.keysymdef.latin4',
'Xlib.keysymdef.greek',
'Xlib.support.unix_connect',
'Xlib.ext.shape',
'Xlib.ext.xinerama',
'Xlib.ext.composite',
'Xlib.ext.randr',
'Xlib.ext.xfixes',
'Xlib.ext.security',
'Xlib.ext.xinput',
'pynput.keyboard._xorg',
'pynput.mouse._xorg',
'pynput.keyboard._win32',
'pynput.mouse._win32',
'pynput.keyboard._darwin',
'pynput.mouse._darwin',
],
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-afk',
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-afk')

View File

@@ -0,0 +1,3 @@
from .__main__ import main
__all__ = ["main"]

View File

@@ -0,0 +1,25 @@
from aw_core.log import setup_logging
from aw_watcher_afk.afk import AFKWatcher
from aw_watcher_afk.config import parse_args
def main() -> None:
args = parse_args()
# Set up logging
setup_logging(
"aw-watcher-afk",
testing=args.testing,
verbose=args.verbose,
log_stderr=True,
log_file=True,
)
# Start watcher
watcher = AFKWatcher(args, testing=args.testing)
watcher.run()
if __name__ == "__main__":
main()

136
aw_watcher_afk/afk.py Executable file
View File

@@ -0,0 +1,136 @@
import logging
import os
import platform
from datetime import datetime, timedelta, timezone
from time import sleep
from aw_client import ActivityWatchClient
from aw_core.models import Event
from .config import load_config
system = platform.system()
if system == "Windows":
# noreorder
from .windows import seconds_since_last_input # fmt: skip
elif system == "Darwin":
# noreorder
from .macos import seconds_since_last_input # fmt: skip
elif system == "Linux":
# noreorder
from .unix import seconds_since_last_input # fmt: skip
else:
raise Exception(f"Unsupported platform: {system}")
logger = logging.getLogger(__name__)
td1ms = timedelta(milliseconds=1)
class Settings:
def __init__(self, config_section, timeout=None, poll_time=None):
# Time without input before we're considering the user as AFK
self.timeout = timeout or config_section["timeout"]
# How often we should poll for input activity
self.poll_time = poll_time or config_section["poll_time"]
assert self.timeout >= self.poll_time
class AFKWatcher:
def __init__(self, args, testing=False):
# Read settings from config
self.settings = Settings(
load_config(testing), timeout=args.timeout, poll_time=args.poll_time
)
self.client = ActivityWatchClient(
"aw-watcher-afk", host=args.host, port=args.port, testing=testing
)
self.bucketname = "{}_{}".format(
self.client.client_name, self.client.client_hostname
)
# Store initial parent PID for orphan detection
self._initial_ppid = os.getppid()
def ping(self, afk: bool, timestamp: datetime, duration: float = 0):
data = {"status": "afk" if afk else "not-afk"}
e = Event(timestamp=timestamp, duration=duration, data=data)
pulsetime = self.settings.timeout + self.settings.poll_time
self.client.heartbeat(self.bucketname, e, pulsetime=pulsetime, queued=True)
def run(self):
logger.info("aw-watcher-afk started")
# Initialization
self.client.wait_for_start()
eventtype = "afkstatus"
self.client.create_bucket(self.bucketname, eventtype, queued=True)
# Start afk checking loop
with self.client:
self.heartbeat_loop()
def heartbeat_loop(self):
afk = False
while True:
try:
if system in ["Darwin", "Linux"] and os.getppid() != self._initial_ppid:
logger.info(
"afkwatcher stopped because parent process died "
f"(ppid changed from {self._initial_ppid} to {os.getppid()})"
)
break
now = datetime.now(timezone.utc)
try:
seconds_since_input = seconds_since_last_input()
except OSError:
if system != "Windows":
raise
logger.exception(
"seconds_since_last_input() failed; "
"retrying without changing AFK state"
)
sleep(self.settings.poll_time)
continue
last_input = now - timedelta(seconds=seconds_since_input)
logger.debug(f"Seconds since last input: {seconds_since_input}")
# If no longer AFK
if afk and seconds_since_input < self.settings.timeout:
logger.info("No longer AFK")
self.ping(afk, timestamp=last_input)
afk = False
# ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
self.ping(afk, timestamp=last_input + td1ms)
# If becomes AFK
elif not afk and seconds_since_input >= self.settings.timeout:
logger.info("Became AFK")
self.ping(afk, timestamp=last_input)
afk = True
# ping with timestamp+1ms with the next event (to ensure the latest event gets retrieved by get_event)
self.ping(
afk, timestamp=last_input + td1ms, duration=seconds_since_input
)
# Send a heartbeat if no state change was made
else:
if afk:
# we need the +1ms here too, to make sure we don't "miss" the last heartbeat
# (if last_input hasn't changed)
self.ping(
afk,
timestamp=last_input + td1ms,
duration=seconds_since_input,
)
else:
self.ping(afk, timestamp=last_input)
sleep(self.settings.poll_time)
except KeyboardInterrupt:
logger.info("aw-watcher-afk stopped by keyboard interrupt")
break

51
aw_watcher_afk/config.py Normal file
View File

@@ -0,0 +1,51 @@
import argparse
import sys
from aw_core.config import load_config_toml
default_config = """
[aw-watcher-afk]
timeout = 180
poll_time = 5
[aw-watcher-afk-testing]
timeout = 20
poll_time = 1
""".strip()
def load_config(testing: bool):
section = "aw-watcher-afk" + ("-testing" if testing else "")
return load_config_toml("aw-watcher-afk", default_config)[section]
def parse_args():
# get testing in a dirty way, because we need it for the config lookup
testing = "--testing" in sys.argv
config = load_config(testing)
default_poll_time = config["poll_time"]
default_timeout = config["timeout"]
parser = argparse.ArgumentParser(
description="A watcher for keyboard and mouse input to detect AFK state."
)
parser.add_argument("--host", dest="host")
parser.add_argument("--port", dest="port")
parser.add_argument(
"--testing", dest="testing", action="store_true", help="run in testing mode"
)
parser.add_argument(
"--verbose",
dest="verbose",
action="store_true",
help="run with verbose logging",
)
parser.add_argument(
"--timeout", dest="timeout", type=float, default=default_timeout
)
parser.add_argument(
"--poll-time", dest="poll_time", type=float, default=default_poll_time
)
parsed_args = parser.parse_args()
return parsed_args

295
aw_watcher_afk/listeners.py Executable file
View File

@@ -0,0 +1,295 @@
"""
Listeners for aggregated keyboard and mouse events.
This is used for AFK detection on Linux, as well as used in aw-watcher-input to track input activity in general.
NOTE: Logging usage should be commented out before committed, for performance reasons.
"""
import logging
import threading
from abc import ABCMeta, abstractmethod
from collections import defaultdict
from typing import Dict, Any, List
logger = logging.getLogger(__name__)
# logger.setLevel(logging.DEBUG)
class EventFactory(metaclass=ABCMeta):
def __init__(self) -> None:
self.new_event = threading.Event()
self._reset_data()
@abstractmethod
def _reset_data(self) -> None:
self.event_data: Dict[str, Any] = {}
def next_event(self) -> dict:
"""Returns an event and prepares the internal state so that it can start to build a new event"""
self.new_event.clear()
data = self.event_data
# self.logger.debug(f"Event: {data}")
self._reset_data()
return data
def has_new_event(self) -> bool:
return self.new_event.is_set()
class KeyboardListener(EventFactory):
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("keyboard")
self._listener = None
def start(self):
from pynput import keyboard
self._listener = keyboard.Listener(
on_press=self.on_press, on_release=self.on_release
)
self._listener.start()
def stop(self):
if self._listener is not None:
self._listener.stop()
def is_alive(self) -> bool:
return self._listener is not None and self._listener.is_alive()
def _reset_data(self):
self.event_data = {"presses": 0}
def on_press(self, key):
# self.logger.debug(f"Press: {key}")
self.event_data["presses"] += 1
self.new_event.set()
def on_release(self, key):
# Don't count releases, only clicks
# self.logger.debug(f"Release: {key}")
pass
class MouseListener(EventFactory):
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("mouse")
self.pos = None
self._listener = None
def _reset_data(self):
self.event_data = defaultdict(int)
self.event_data.update(
{"clicks": 0, "deltaX": 0, "deltaY": 0, "scrollX": 0, "scrollY": 0}
)
def start(self):
from pynput import mouse
self._listener = mouse.Listener(
on_move=self.on_move, on_click=self.on_click, on_scroll=self.on_scroll
)
self._listener.start()
def stop(self):
if self._listener is not None:
self._listener.stop()
def is_alive(self) -> bool:
return self._listener is not None and self._listener.is_alive()
def on_move(self, x, y):
newpos = (x, y)
# self.logger.debug("Moved mouse to: {},{}".format(x, y))
if not self.pos:
self.pos = newpos
delta = tuple(self.pos[i] - newpos[i] for i in range(2))
self.event_data["deltaX"] += abs(delta[0])
self.event_data["deltaY"] += abs(delta[1])
self.pos = newpos
self.new_event.set()
def on_click(self, x, y, button, down):
# self.logger.debug(f"Click: {button} at {(x, y)}")
# Only count presses, not releases
if down:
self.event_data["clicks"] += 1
self.new_event.set()
def on_scroll(self, x, y, scrollx, scrolly):
# self.logger.debug(f"Scroll: {scrollx}, {scrolly} at {(x, y)}")
self.event_data["scrollX"] += abs(scrollx)
self.event_data["scrollY"] += abs(scrolly)
self.new_event.set()
class GamepadListener(EventFactory):
"""Listens for gamepad/joystick button events via evdev (Linux only, optional).
Requires the ``evdev`` package and read access to ``/dev/input/`` device files.
On most distros users in the ``input`` group have the required access.
Only button press events are counted (not releases), so a held button does
not continuously trigger "not AFK". Analog axis events are intentionally
ignored to avoid false positives from stick drift.
"""
def __init__(self):
EventFactory.__init__(self)
self.logger = logger.getChild("gamepad")
self._threads: List[threading.Thread] = []
self._devices = [] # track open devices for cleanup in stop()
self._stop_event = threading.Event()
# True once start() has actually spawned at least one reader thread.
# Used by callers to distinguish "no gamepad ever attached" (don't
# restart) from "gamepad threads died after running" (do restart).
self._was_started = False
def _reset_data(self):
self.event_data = {"buttons": 0}
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def start(self):
try:
import evdev # noqa: F401
except ImportError:
self.logger.debug(
"evdev not installed; gamepad detection unavailable. "
"Install it with: pip install evdev"
)
return
devices = self._find_gamepads()
if not devices:
self.logger.debug("No gamepads/joysticks found in /dev/input/")
return
self.logger.info(
"Gamepad listener started for %d device(s): %s",
len(devices),
[d.name for d in devices],
)
self._stop_event.clear()
self._devices = list(devices) # keep references for cleanup
for device in devices:
t = threading.Thread(
target=self._read_events,
args=(device,),
name=f"gamepad-{device.path}",
daemon=True,
)
t.start()
self._threads.append(t)
self._was_started = True
def stop(self):
self._stop_event.set()
# Close devices to unblock threads stuck in read_loop()
# (the blocking select() call is released when the FD is closed)
for device in self._devices:
try:
device.close()
except (OSError, IOError):
pass
self._devices.clear()
# Wait briefly for threads to finish
for t in self._threads:
t.join(timeout=2.0)
self._threads.clear()
self._was_started = False
def is_alive(self) -> bool:
return any(t.is_alive() for t in self._threads)
def needs_restart(self) -> bool:
"""Return True if start() previously launched threads but any have died.
Used to distinguish a system that simply has no gamepad attached (in
which case start() returned early and no restart is needed) from one
where one or more reader threads have exited unexpectedly (e.g. device
removed or USB error). In the latter case the caller should reinitialize
the listener so all gamepads start being tracked again.
Detects partial failure (one of N gamepads removed) in addition to
total failure (all gamepads removed), so multi-gamepad setups don't
silently lose a device until the last one dies.
"""
if not self._was_started:
return False
# Restart when any reader thread has died, not just when all have.
return any(not t.is_alive() for t in self._threads)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _find_gamepads(self):
"""Return all readable /dev/input/ devices that look like gamepads."""
import evdev
gamepads = []
for path in evdev.list_devices():
try:
device = evdev.InputDevice(path)
except (OSError, PermissionError):
continue
if self._is_gamepad(device):
gamepads.append(device)
else:
# Close non-gamepad devices to avoid FD leaks
device.close()
return gamepads
@staticmethod
def _is_gamepad(device) -> bool:
"""Return True if *device* appears to be a gamepad or joystick."""
import evdev
caps = device.capabilities()
if evdev.ecodes.EV_KEY not in caps:
return False
# A subset of button codes that only appear on gamepads/joysticks
# Note: BTN_GAMEPAD == BTN_SOUTH (both 0x130) in the Linux input subsystem;
# BTN_SOUTH is kept as the more descriptive name and BTN_GAMEPAD omitted.
gamepad_btns = {
evdev.ecodes.BTN_SOUTH, # Xbox A / PS Cross (also == BTN_GAMEPAD)
evdev.ecodes.BTN_EAST, # Xbox B / PS Circle
evdev.ecodes.BTN_NORTH, # Xbox Y / PS Triangle
evdev.ecodes.BTN_WEST, # Xbox X / PS Square
evdev.ecodes.BTN_JOYSTICK, # generic joystick button
evdev.ecodes.BTN_TRIGGER, # joystick trigger
evdev.ecodes.BTN_THUMB, # joystick thumb
evdev.ecodes.BTN_TOP, # joystick top
}
device_btns = set(caps[evdev.ecodes.EV_KEY])
return bool(device_btns & gamepad_btns)
def _read_events(self, device) -> None:
"""Read button events from *device* until stop() is called."""
import evdev
try:
for event in device.read_loop():
if self._stop_event.is_set():
break
# Count button *press* events only (value == 1)
if event.type == evdev.ecodes.EV_KEY and event.value == 1:
# self.logger.debug(f"Gamepad button press: {event.code}")
self.event_data["buttons"] += 1
self.new_event.set()
except (OSError, IOError):
# Device disconnected or permission lost — stop quietly
self.logger.debug("Gamepad device %s disconnected", device.path)
finally:
# Always close the device FD on thread exit
try:
device.close()
except (OSError, IOError):
pass

14
aw_watcher_afk/macos.py Executable file
View File

@@ -0,0 +1,14 @@
from Quartz.CoreGraphics import (CGEventSourceSecondsSinceLastEventType,
kCGEventSourceStateHIDSystemState,
kCGAnyInputEventType)
def seconds_since_last_input() -> float:
return CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType)
if __name__ == "__main__":
from time import sleep
while True:
sleep(1)
print(seconds_since_last_input())

114
aw_watcher_afk/unix.py Executable file
View File

@@ -0,0 +1,114 @@
import logging
from datetime import datetime
from time import sleep
from .listeners import GamepadListener, KeyboardListener, MouseListener
class LastInputUnix:
def __init__(self):
self.logger = logging.getLogger(__name__)
# self.logger.setLevel(logging.DEBUG)
self._start_listeners()
self.last_activity = datetime.now()
def _start_listeners(self):
self.mouseListener = MouseListener()
self.mouseListener.start()
self.keyboardListener = KeyboardListener()
self.keyboardListener.start()
# Optional: gamepad support via evdev (Linux only).
# Starts silently if evdev is not installed or no gamepads are found.
self.gamepadListener = GamepadListener()
self.gamepadListener.start()
def _stop_listeners(self):
"""Stop existing listeners to avoid duplicate instances."""
if self.mouseListener.is_alive():
self.mouseListener.stop()
if self.keyboardListener.is_alive():
self.keyboardListener.stop()
if self.gamepadListener.is_alive():
self.gamepadListener.stop()
def _check_listeners(self):
"""Check if input listeners are still alive, restart if dead.
Pynput listeners can silently die when the X server restarts
(e.g. after suspend/resume, display server crash, or session switch).
Without this check, the watcher would permanently report AFK.
See: https://github.com/ActivityWatch/aw-watcher-afk/issues/27
"""
# Only restart the gamepad listener if it was actually running before
# and all its threads died (e.g. device unplugged). Without this guard,
# systems without a gamepad would trigger a restart on every poll,
# since is_alive() is always False when start() returned early.
gamepad_died = self.gamepadListener.needs_restart()
if (
not self.mouseListener.is_alive()
or not self.keyboardListener.is_alive()
or gamepad_died
):
mouse_kb_died = (
not self.mouseListener.is_alive()
or not self.keyboardListener.is_alive()
)
if gamepad_died and mouse_kb_died:
self.logger.warning(
"All input listeners died (X server restart?), reinitializing..."
)
elif gamepad_died:
self.logger.warning(
"Gamepad listener died (device removed?), reinitializing..."
)
else:
self.logger.warning(
"Input listeners died (X server restart?), reinitializing..."
)
# Stop any still-running listeners before creating new ones
# to avoid duplicate listener instances (e.g. if only one died)
self._stop_listeners()
self._start_listeners()
# Reset last_activity so we don't report a huge AFK gap
self.last_activity = datetime.now()
def seconds_since_last_input(self) -> float:
# TODO: This has a delay of however often it is called.
# Could be solved by creating a custom listener.
self._check_listeners()
now = datetime.now()
has_event = (
self.mouseListener.has_new_event()
or self.keyboardListener.has_new_event()
or self.gamepadListener.has_new_event()
)
if has_event:
self.logger.debug("New event")
self.last_activity = now
# Get/clear events
self.mouseListener.next_event()
self.keyboardListener.next_event()
self.gamepadListener.next_event()
return (now - self.last_activity).total_seconds()
_last_input_unix = None
def seconds_since_last_input():
global _last_input_unix
if _last_input_unix is None:
_last_input_unix = LastInputUnix()
return _last_input_unix.seconds_since_last_input()
if __name__ == "__main__":
while True:
sleep(1)
print(seconds_since_last_input())

54
aw_watcher_afk/windows.py Normal file
View File

@@ -0,0 +1,54 @@
import ctypes
import time
from ctypes import POINTER, Structure, c_ulonglong
from ctypes.wintypes import BOOL, DWORD, UINT
ULONGLONG = c_ulonglong
class LastInputInfo(Structure):
_fields_ = [("cbSize", UINT), ("dwTime", DWORD)]
def _getLastInputTick() -> int:
prototype = ctypes.WINFUNCTYPE(BOOL, POINTER(LastInputInfo)) # type: ignore[attr-defined]
paramflags = ((1, "lastinputinfo"),)
c_GetLastInputInfo = prototype(("GetLastInputInfo", ctypes.windll.user32), paramflags) # type: ignore
lastinput = LastInputInfo()
lastinput.cbSize = ctypes.sizeof(LastInputInfo)
if 0 == c_GetLastInputInfo(lastinput):
raise OSError("GetLastInputInfo failed")
return lastinput.dwTime
def _getTickCount64() -> int:
"""Use GetTickCount64 to avoid 32-bit overflow after ~49.7 days of uptime."""
prototype = ctypes.WINFUNCTYPE(ULONGLONG) # type: ignore[attr-defined]
paramflags = ()
c_GetTickCount64 = prototype(("GetTickCount64", ctypes.windll.kernel32), paramflags) # type: ignore
return c_GetTickCount64()
def seconds_since_last_input() -> float:
tick_count = _getTickCount64()
last_input_tick = _getLastInputTick() # 32-bit DWORD from GetLastInputInfo
# GetLastInputInfo returns a 32-bit DWORD tick count that wraps at 2^32 ms.
# GetTickCount64 returns 64-bit. To compute the difference correctly, we
# compare only the lower 32 bits when the values are close (normal case),
# and handle the wraparound when dwTime > lower 32 bits of tick_count.
tick_lower32 = tick_count & 0xFFFFFFFF
if tick_lower32 >= last_input_tick:
diff_ms = tick_lower32 - last_input_tick
else:
# 32-bit wraparound: tick_count's lower bits wrapped past dwTime
diff_ms = (0x100000000 - last_input_tick) + tick_lower32
return diff_ms / 1000
if __name__ == "__main__":
while True:
time.sleep(1)
print(seconds_since_last_input())

31
docs/transitions.dot Normal file
View File

@@ -0,0 +1,31 @@
digraph transitions {
"afk" [
label="Inactive user\n(afk)",
shape="box",
width="1.5",
style=filled,
fillcolor="1.0 .8 1.0"
];
"not-afk" [
label="Active user\n(not-afk)",
shape="box",
width="1.5",
style=filled,
fillcolor=".33 .8 1.0"
];
"sleeping" [
label="Computer asleep\n(afk)",
shape="box",
style=filled,
fillcolor=".5 .9 .9"
];
{rank=same afk "not-afk"}
"afk":e -> "not-afk":w -> "afk":e [minlen=2];
// Sleeping state
"not-afk":s -> "sleeping":n;
"afk" -> "sleeping":w;
"sleeping":n -> "not-afk":s;
}

View File

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

1075
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

35
pyproject.toml Normal file
View File

@@ -0,0 +1,35 @@
[tool.poetry]
name = "aw-watcher-afk"
version = "0.2.0"
description = "AFK watcher for ActivityWatch"
authors = ["Erik Bjäreholt <erik@bjareho.lt>"]
license = "MPL-2.0"
[tool.poetry.scripts]
aw-watcher-afk = "aw_watcher_afk:main"
[tool.poetry.dependencies]
python = "^3.8"
aw-client = "^0.5.14"
pyobjc-framework-Quartz = { version = "*", platform = "darwin" }
pynput = "*"
# 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
python-xlib = { version = "0.31", platform = "linux" }
# Optional: gamepad/joystick support via evdev (Linux only)
# Install with: pip install evdev (or: pip install aw-watcher-afk[gamepad])
evdev = { version = ">=1.4", platform = "linux", optional = true }
[tool.poetry.extras]
gamepad = ["evdev"]
[tool.poetry.dev-dependencies]
mypy = "*"
macholib = {version = "*", platform = "darwin"}
pyinstaller = {version = "^6.6", python = "^3.8,<3.14"}
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

View File

@@ -0,0 +1,82 @@
import os
import unittest
from contextlib import ExitStack
from types import SimpleNamespace
from unittest.mock import patch
from aw_watcher_afk import windows
from aw_watcher_afk.afk import AFKWatcher
class WindowsIdleQueryTests(unittest.TestCase):
def test_get_last_input_failure_raises(self):
with patch.object(
windows.ctypes, "WINFUNCTYPE", create=True
) as prototype_factory:
with patch.object(windows.ctypes, "windll", create=True):
prototype_factory.return_value.return_value.return_value = 0
with self.assertRaisesRegex(OSError, "GetLastInputInfo failed"):
windows._getLastInputTick()
class HeartbeatFailureTests(unittest.TestCase):
def make_watcher(self):
watcher = object.__new__(AFKWatcher)
watcher.settings = SimpleNamespace(timeout=10, poll_time=5)
watcher._initial_ppid = os.getppid()
return watcher
def run_loop(self, watcher, samples, system_name="Windows"):
with ExitStack() as stack:
stack.enter_context(patch("aw_watcher_afk.afk.system", system_name))
stack.enter_context(
patch(
"aw_watcher_afk.afk.seconds_since_last_input",
side_effect=samples,
)
)
ping = stack.enter_context(patch.object(watcher, "ping"))
sleep = stack.enter_context(patch("aw_watcher_afk.afk.sleep"))
log_exception = stack.enter_context(
patch("aw_watcher_afk.afk.logger.exception")
)
watcher.heartbeat_loop()
return ping, sleep, log_exception
def test_failed_sample_preserves_afk_state_and_recovers(self):
watcher = self.make_watcher()
samples = [20.0, OSError("idle API unavailable"), 20.0, KeyboardInterrupt()]
ping, sleep, log_exception = self.run_loop(watcher, samples)
self.assertEqual(
[record.args[0] for record in ping.call_args_list],
[False, True, True],
)
self.assertEqual(sleep.call_count, 3)
self.assertEqual(log_exception.call_count, 1)
def test_repeated_failures_sleep_without_emitting_heartbeats(self):
watcher = self.make_watcher()
samples = [
OSError("idle API unavailable"),
OSError("idle API unavailable"),
KeyboardInterrupt(),
]
ping, sleep, log_exception = self.run_loop(watcher, samples)
ping.assert_not_called()
self.assertEqual(sleep.call_count, 2)
self.assertEqual(log_exception.call_count, 2)
def test_non_windows_failure_reaches_supervisor(self):
watcher = self.make_watcher()
with self.assertRaisesRegex(OSError, "listener failed"):
self.run_loop(watcher, [OSError("listener failed")], system_name="Linux")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,41 @@
# Usage:
# python3 test_continuous_events.py aw-watcher-afk-testing_{hostname}
#
# Example:
# python3 test_continuous_events.py aw-watcher-afk-testing_erb-laptop-ubuntu
import sys
from datetime import timedelta
from aw_client import ActivityWatchClient
client = ActivityWatchClient("aw-watcher-afk-test", testing=True)
print(client.get_buckets())
bucket_id = sys.argv[1]
events = client.get_events(bucket_id) # For example "aw-watcher-afk-testing_erb-laptop-ubuntu"
print("\n\n")
last_event = None
wrong_events = 0
for event in sorted(events, key=lambda e: e.timestamp):
if last_event:
# The diff is the gap between the two events, should be zero
# In reality it is currently sometimes negative and almost always larger than 1s
diff = (event.timestamp - last_event.timestamp) - last_event.duration
print("{} at {}".format(event.label, event.timestamp))
print("Duration: {}".format(event.duration))
if not timedelta(seconds=1) > abs(diff):
print(" WARNING: Diff had absolute value of over 1s ({}), this might be either because of a bug or due to aw-watcher-afk not running continuously".format(diff))
wrong_events += 1
if last_event.label == event.label:
print(" WARNING: Two {} events in a row".format(event.label))
print("")
last_event = event
print("Percent of wrong events: {}".format(wrong_events / len(events)))

View File

@@ -0,0 +1,387 @@
"""Tests for listener health checking and auto-recovery."""
from unittest.mock import MagicMock, patch
from aw_watcher_afk.listeners import GamepadListener, KeyboardListener, MouseListener
def test_keyboard_listener_is_alive_before_start():
listener = KeyboardListener()
assert not listener.is_alive()
def test_mouse_listener_is_alive_before_start():
listener = MouseListener()
assert not listener.is_alive()
def test_keyboard_listener_is_alive_with_mock():
listener = KeyboardListener()
# Simulate a started listener
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = True
listener._listener = mock_pynput
assert listener.is_alive()
def test_keyboard_listener_dead_after_x_restart():
listener = KeyboardListener()
# Simulate a dead listener (X server died)
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = False
listener._listener = mock_pynput
assert not listener.is_alive()
def test_mouse_listener_is_alive_with_mock():
listener = MouseListener()
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = True
listener._listener = mock_pynput
assert listener.is_alive()
def test_mouse_listener_dead_after_x_restart():
listener = MouseListener()
mock_pynput = MagicMock()
mock_pynput.is_alive.return_value = False
listener._listener = mock_pynput
assert not listener.is_alive()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_reinitializes_dead_listeners(MockGamepad, MockMouse, MockKeyboard):
"""When listeners die (e.g. X server restart), they should be restarted."""
from aw_watcher_afk.unix import LastInputUnix
# First call: listeners are alive
mock_kb_instance = MockKeyboard.return_value
mock_mouse_instance = MockMouse.return_value
mock_gamepad_instance = MockGamepad.return_value
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = False # no gamepad connected
# No gamepad attached → needs_restart() should be False so we don't
# reinitialize every poll on gamepad-less systems.
mock_gamepad_instance.needs_restart.return_value = False
mock_kb_instance.has_new_event.return_value = False
mock_mouse_instance.has_new_event.return_value = False
mock_gamepad_instance.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
# Listeners should NOT have been restarted
assert MockKeyboard.call_count == 1
assert MockMouse.call_count == 1
# Now simulate X server death
mock_kb_instance.is_alive.return_value = False
unix.seconds_since_last_input()
# Listeners should have been restarted (new instances created)
assert MockKeyboard.call_count == 2
assert MockMouse.call_count == 2
assert MockGamepad.call_count == 2
# ---------------------------------------------------------------------------
# GamepadListener tests
# ---------------------------------------------------------------------------
def test_gamepad_listener_not_alive_before_start():
listener = GamepadListener()
assert not listener.is_alive()
def test_gamepad_listener_not_alive_without_evdev(monkeypatch):
"""GamepadListener.start() should be a no-op when evdev is not installed."""
import builtins
real_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "evdev":
raise ImportError("No module named 'evdev'")
return real_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", mock_import)
listener = GamepadListener()
listener.start()
assert not listener.is_alive()
def test_gamepad_listener_not_alive_without_devices():
"""GamepadListener.start() should be a no-op when no gamepads are found."""
listener = GamepadListener()
with patch.object(listener, "_find_gamepads", return_value=[]):
listener.start()
assert not listener.is_alive()
def test_gamepad_listener_starts_with_device():
"""GamepadListener.start() should launch a reader thread per device."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
# evdev is an optional dependency; mock the import so the test runs without it
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"),
):
listener.start()
assert len(listener._threads) == 1
listener.stop()
def test_gamepad_listener_detects_button_press():
"""Simulated button press should set has_new_event() and count the press."""
listener = GamepadListener()
assert not listener.has_new_event()
# Simulate what _read_events does on BTN_SOUTH press
listener.event_data["buttons"] += 1
listener.new_event.set()
assert listener.has_new_event()
data = listener.next_event()
assert data["buttons"] == 1
# After next_event the counter is reset
assert not listener.has_new_event()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_gamepad_event_resets_afk_timer(MockGamepad, MockMouse, MockKeyboard):
"""A gamepad button press should reset the AFK timer."""
from aw_watcher_afk.unix import LastInputUnix
mock_kb_instance = MockKeyboard.return_value
mock_mouse_instance = MockMouse.return_value
mock_gamepad_instance = MockGamepad.return_value
mock_kb_instance.is_alive.return_value = True
mock_mouse_instance.is_alive.return_value = True
mock_gamepad_instance.is_alive.return_value = True
mock_gamepad_instance.needs_restart.return_value = False
# No keyboard/mouse events, but gamepad has an event
mock_kb_instance.has_new_event.return_value = False
mock_mouse_instance.has_new_event.return_value = False
mock_gamepad_instance.has_new_event.return_value = True
unix = LastInputUnix()
seconds = unix.seconds_since_last_input()
# Timer was just reset by the gamepad event → nearly 0 seconds since last input
assert seconds < 1.0
# Event was consumed
mock_gamepad_instance.next_event.assert_called_once()
# ---------------------------------------------------------------------------
# GamepadListener.needs_restart()
# ---------------------------------------------------------------------------
def test_gamepad_needs_restart_false_before_start():
"""needs_restart() must be False before start() so we don't try to restart
a listener that was never running (e.g. on systems with no gamepad)."""
listener = GamepadListener()
assert not listener.needs_restart()
def test_gamepad_needs_restart_false_when_start_was_noop():
"""If start() returns early (no evdev / no devices), needs_restart() must
stay False to avoid an infinite restart loop on gamepad-less systems."""
listener = GamepadListener()
with patch.object(listener, "_find_gamepads", return_value=[]):
listener.start()
assert not listener.is_alive()
assert not listener.needs_restart()
def test_gamepad_needs_restart_true_when_threads_died():
"""After start() spawned threads, needs_restart() should flip to True once
all reader threads have exited (e.g. device unplugged)."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"), # returns immediately
):
listener.start()
# Wait for the (no-op) reader thread to exit
for t in listener._threads:
t.join(timeout=2.0)
assert not listener.is_alive()
assert listener.needs_restart()
def test_gamepad_needs_restart_false_after_stop():
"""After an explicit stop(), needs_restart() must be False so the caller
doesn't keep recreating a listener the user wanted shut down."""
import sys
from unittest.mock import MagicMock
listener = GamepadListener()
mock_device = MagicMock()
mock_device.path = "/dev/input/event5"
mock_device.name = "Xbox Controller"
mock_evdev = MagicMock()
with (
patch.dict(sys.modules, {"evdev": mock_evdev}),
patch.object(listener, "_find_gamepads", return_value=[mock_device]),
patch.object(listener, "_read_events"),
):
listener.start()
listener.stop()
assert not listener.needs_restart()
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_restarts_when_only_gamepad_dies(MockGamepad, MockMouse, MockKeyboard):
"""If the gamepad listener dies but mouse/keyboard are still alive, the
listeners should still be reinitialized so a reconnected gamepad starts
being tracked again (regression for the original bug)."""
from aw_watcher_afk.unix import LastInputUnix
mock_kb = MockKeyboard.return_value
mock_mouse = MockMouse.return_value
mock_gamepad = MockGamepad.return_value
mock_kb.is_alive.return_value = True
mock_mouse.is_alive.return_value = True
mock_gamepad.is_alive.return_value = True
mock_gamepad.needs_restart.return_value = False
mock_kb.has_new_event.return_value = False
mock_mouse.has_new_event.return_value = False
mock_gamepad.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
assert MockGamepad.call_count == 1 # not restarted yet
# Now simulate the gamepad dying while mouse/keyboard stay alive
mock_gamepad.is_alive.return_value = False
mock_gamepad.needs_restart.return_value = True
unix.seconds_since_last_input()
# All listeners should have been recreated
assert MockKeyboard.call_count == 2
assert MockMouse.call_count == 2
assert MockGamepad.call_count == 2
def test_gamepad_needs_restart_true_when_one_of_two_threads_dies():
"""needs_restart() must be True if any thread dies, not only when all die.
This covers multi-gamepad setups where one device is removed while another
stays alive; without this the dead device goes silently unmonitored.
Uses direct thread-state injection to avoid mock-revert races with
background threads (see lessons/tools/mock-revert-race-background-threads.md).
"""
import threading
listener = GamepadListener()
listener._was_started = True
# dead_thread: simulates a gamepad that was unplugged (thread exited)
dead_thread = threading.Thread(target=lambda: None, daemon=True)
dead_thread.start()
dead_thread.join(timeout=2.0)
assert not dead_thread.is_alive()
# live_thread: simulates a gamepad still connected (thread still running)
keep_alive = threading.Event()
live_thread = threading.Thread(target=keep_alive.wait, daemon=True)
live_thread.start()
listener._threads = [dead_thread, live_thread]
try:
# is_alive() should see the live thread; needs_restart() should fire on the dead one
assert listener.is_alive()
assert listener.needs_restart()
finally:
keep_alive.set()
live_thread.join(timeout=2.0)
@patch("aw_watcher_afk.unix.KeyboardListener")
@patch("aw_watcher_afk.unix.MouseListener")
@patch("aw_watcher_afk.unix.GamepadListener")
def test_unix_log_message_when_gamepad_and_keyboard_die_together(
MockGamepad, MockMouse, MockKeyboard, caplog
):
"""When gamepad AND mouse/keyboard die simultaneously, the log should say
'All input listeners died' (XServer restart context), not just 'Gamepad
listener died' (device-removal context).
"""
import logging
from aw_watcher_afk.unix import LastInputUnix
mock_kb = MockKeyboard.return_value
mock_mouse = MockMouse.return_value
mock_gamepad = MockGamepad.return_value
mock_kb.is_alive.return_value = True
mock_mouse.is_alive.return_value = True
mock_gamepad.is_alive.return_value = True
mock_gamepad.needs_restart.return_value = False
mock_kb.has_new_event.return_value = False
mock_mouse.has_new_event.return_value = False
mock_gamepad.has_new_event.return_value = False
unix = LastInputUnix()
unix.seconds_since_last_input()
# Simulate all listeners dying at once (XServer crash)
mock_kb.is_alive.return_value = False
mock_mouse.is_alive.return_value = False
mock_gamepad.is_alive.return_value = False
mock_gamepad.needs_restart.return_value = True
with caplog.at_level(logging.WARNING):
unix.seconds_since_last_input()
warning_messages = [r.message for r in caplog.records if r.levelno == logging.WARNING]
assert any("All input listeners died" in m for m in warning_messages), (
f"Expected 'All input listeners died' in log, got: {warning_messages}"
)
assert not any("Gamepad listener died" in m for m in warning_messages), (
f"Should not say 'Gamepad listener died' when all died: {warning_messages}"
)