snapshot of https://github.com/ActivityWatch/aw-watcher-afk.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
54
.github/workflows/build.yml
vendored
Normal file
54
.github/workflows/build.yml
vendored
Normal 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
41
.github/workflows/codeql.yml
vendored
Normal 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
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
*.pyc
|
||||
*.swp
|
||||
logs
|
||||
*.egg-info
|
||||
build
|
||||
dist
|
||||
*~
|
||||
.mypy_cache
|
||||
373
LICENSE.txt
Normal file
373
LICENSE.txt
Normal 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
19
Makefile
Normal 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
22
README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
aw-watcher-afk
|
||||
==============
|
||||
|
||||
[](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
55
aw-watcher-afk.spec
Normal 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')
|
||||
3
aw_watcher_afk/__init__.py
Normal file
3
aw_watcher_afk/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .__main__ import main
|
||||
|
||||
__all__ = ["main"]
|
||||
25
aw_watcher_afk/__main__.py
Normal file
25
aw_watcher_afk/__main__.py
Normal 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
136
aw_watcher_afk/afk.py
Executable 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
51
aw_watcher_afk/config.py
Normal 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
295
aw_watcher_afk/listeners.py
Executable 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
14
aw_watcher_afk/macos.py
Executable 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
114
aw_watcher_afk/unix.py
Executable 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
54
aw_watcher_afk/windows.py
Normal 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
31
docs/transitions.dot
Normal 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;
|
||||
}
|
||||
14
misc/aw-watcher-afk.service
Normal file
14
misc/aw-watcher-afk.service
Normal 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
1075
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
pyproject.toml
Normal file
35
pyproject.toml
Normal 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"
|
||||
82
tests/test_afk_failures.py
Normal file
82
tests/test_afk_failures.py
Normal 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()
|
||||
41
tests/test_continuous_events.py
Normal file
41
tests/test_continuous_events.py
Normal 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)))
|
||||
387
tests/test_listener_health.py
Normal file
387
tests/test_listener_health.py
Normal 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}"
|
||||
)
|
||||
Reference in New Issue
Block a user