tapir-autoclicker 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pierre Sylla
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: tapir-autoclicker
3
+ Version: 0.2.0
4
+ Summary: Small Wayland-friendly autoclicker with a Qt UI
5
+ Author: Pierre Sylla
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/PierreSylla/tapir-autoclicker
8
+ Project-URL: Issues, https://github.com/PierreSylla/tapir-autoclicker/issues
9
+ Keywords: autoclicker,wayland,hyprland,uinput
10
+ Classifier: Intended Audience :: End Users/Desktop
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Utilities
14
+ Requires-Python: >=3.12
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: PySide6>=6.5
18
+ Requires-Dist: evdev>=1.6
19
+ Requires-Dist: tomli-w>=1.0
20
+ Dynamic: license-file
21
+
22
+ # Tapir
23
+
24
+ A small autoclicker for Linux Wayland desktops, with a compact Qt window.
25
+
26
+ Clicks go through a virtual `uinput` mouse, so they work under any compositor. No X11 tools (xdotool, pynput, PyAutoGUI) are involved.
27
+
28
+ ## Features
29
+
30
+ - Interval in hours, minutes, seconds and milliseconds (10 ms minimum), with an optional random variation of ± N ms
31
+ - Left, right or middle button; single or double click
32
+ - Repeat N times or until stopped, with a click counter
33
+ - Optional countdown (0–10 s) when starting from the window or the tray, to give you time to place the cursor
34
+ - Clicks at the current cursor position and never moves the cursor
35
+ - Global start/stop hotkey: a bind managed in Hyprland, or an evdev fallback elsewhere
36
+ - Command line and Unix socket: `tapir toggle | start | stop`
37
+ - Tray icon coloured by state (grey stopped, amber countdown, green running)
38
+ - English or French interface, following the system language until you pick one
39
+ - Settings saved on every change
40
+
41
+ ## Installation
42
+
43
+ ### Arch Linux / CachyOS package
44
+
45
+ ```
46
+ cd packaging
47
+ makepkg -sif
48
+ ```
49
+
50
+ The PKGBUILD builds the **last commit** of this repository: uncommitted changes, including new files that are not committed yet, are not included. `-f` rebuilds the package even when one with the same version was built before; without it, makepkg reinstalls the old file. It installs the `tapir` command, the desktop entry and the udev rule, and pacman reloads udev for you.
51
+
52
+ ### With pipx or pip
53
+
54
+ ```
55
+ pipx install tapir-autoclicker
56
+ ```
57
+
58
+ evdev ships no prebuilt wheel, so pip compiles it: you need a C compiler, the Python headers and the kernel headers (Arch: `base-devel`; Debian and Ubuntu: `build-essential python3-dev`). The udev rule and the desktop entry are not installed this way: see Permissions.
59
+
60
+ ### From source
61
+
62
+ ```
63
+ sudo pacman -S pyside6 python-evdev python-tomli-w
64
+ python -m tapir
65
+ ```
66
+
67
+ Python 3.12 or newer. On other distributions, install PySide6, evdev and tomli-w from your package manager or with pip. From a source checkout, replace `tapir` with `python -m tapir` in the commands below.
68
+
69
+ ## Permissions
70
+
71
+ Tapir creates its virtual mouse through `/dev/uinput` and needs write access to it. Many systems already give it to the logged-in user (Steam's `60-steam-input.rules` does, for example). To check:
72
+
73
+ ```
74
+ test -w /dev/uinput && echo ok
75
+ ```
76
+
77
+ If it is not writable, install the udev rule shipped with Tapir. The Arch package does it for you; from source, run:
78
+
79
+ ```
80
+ scripts/setup-permissions.sh
81
+ ```
82
+
83
+ The script asks for confirmation, then runs:
84
+
85
+ ```
86
+ sudo install -Dm644 packaging/70-tapir-uinput.rules /etc/udev/rules.d/70-tapir-uinput.rules
87
+ sudo udevadm control --reload-rules
88
+ sudo udevadm trigger --sysname-match=uinput
89
+ ```
90
+
91
+ Without a source checkout (pip install), create the rule directly:
92
+
93
+ ```
94
+ echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/70-tapir-uinput.rules
95
+ sudo udevadm control --reload-rules
96
+ sudo udevadm trigger --sysname-match=uinput
97
+ ```
98
+
99
+ The rule uses systemd's `uaccess` tag: only the user logged in at the seat gets access, not a whole group. Its file name has to sort before `73-seat-late.rules`, where udev applies `uaccess`.
100
+
101
+ When access is missing, Tapir shows these instructions instead of starting.
102
+
103
+ On Hyprland, Tapir never reads your keyboard. The evdev fallback for other compositors needs read access to `/dev/input/event*`, usually through the `input` group. Being in that group lets every program you run read every keystroke, passwords included, so Tapir never adds you to it. Prefer your compositor's own shortcuts (see below).
104
+
105
+ ## Usage
106
+
107
+ Set the interval, button, click type and repeat mode, then press **Start**. The status line shows *Stopped*, *Starting in N s* or *Running*, and the counter shows the clicks of the current session. In "Repeat N times" mode the session stops on its own.
108
+
109
+ The list at the top left switches the interface between English and French; the change applies immediately and is saved.
110
+
111
+ The settings are locked while a session runs. When a tray is available, closing the window (the **✕** button or your compositor's close shortcut) hides it to the tray; **Quit** is in the tray menu. Without a tray, closing the window quits.
112
+
113
+ ## Global hotkey
114
+
115
+ ### Hyprland
116
+
117
+ Click **Set…** next to *Hotkey* and press the combination: a key with optional Super, Ctrl, Alt and Shift, or a mouse side button (Mouse 4 / Mouse 5). Esc cancels, Backspace removes the hotkey.
118
+
119
+ Tapir adds the bind to Hyprland itself with `hyprctl eval` (`hl.bind`, tagged with the description `tapir`). The hotkey works when Tapir's window is not focused, and the key press does not reach other applications.
120
+
121
+ - A combination that your Hyprland config already uses is refused.
122
+ - The bind is added again when Hyprland reloads its config, and removed when Tapir quits.
123
+ - Hotkey starts are immediate: the countdown only applies to the window and tray.
124
+
125
+ Tested with Hyprland 0.57 and a Lua config, where `hyprctl keyword` is not available.
126
+
127
+ ### Static bind
128
+
129
+ If you prefer a permanent bind in your own config, leave Tapir's hotkey empty and add one of these.
130
+
131
+ Lua config (`binds.lua`):
132
+
133
+ ```lua
134
+ hl.bind("SUPER + F8", hl.dsp.exec_cmd("tapir toggle"))
135
+ ```
136
+
137
+ hyprlang config (`hyprland.conf`):
138
+
139
+ ```
140
+ bind = SUPER, F8, exec, tapir toggle
141
+ ```
142
+
143
+ Do not set the same combination in both places, or each press toggles twice.
144
+
145
+ ### Other compositors
146
+
147
+ Bind `tapir toggle` in your compositor's shortcut settings.
148
+
149
+ Alternatively, when Tapir runs outside Hyprland and can read `/dev/input/event*` (see Permissions), **Set…** works the same way and Tapir listens to your input devices itself. It reads them without grabbing them, so the key press also reaches the focused application. Devices plugged in later are picked up within a couple of seconds.
150
+
151
+ ## Command line
152
+
153
+ ```
154
+ tapir open the window, or show the one already running
155
+ tapir toggle start or stop clicking
156
+ tapir start start clicking now (no countdown)
157
+ tapir stop stop clicking
158
+ ```
159
+
160
+ Commands go to the running instance through `$XDG_RUNTIME_DIR/tapir.sock`, which only your user can reach. They exit with code 1 when no instance is running. Only one instance runs at a time.
161
+
162
+ ## Configuration
163
+
164
+ Settings are saved to `~/.config/tapir/config.toml` (or `$XDG_CONFIG_HOME/tapir/config.toml`) on every change and loaded at startup. Invalid values fall back to their defaults.
165
+
166
+ ## Known limitations
167
+
168
+ - Tapir clicks wherever the cursor is; it cannot move the cursor or target a window.
169
+ - The tray is detected at startup. If Tapir starts before your bar, there is no tray icon and closing the window quits.
170
+ - On Wayland, `tapir` or **Show** may not raise a window that is already visible: compositors limit focus stealing (Hyprland: `misc:focus_on_activate`).
171
+ - The Hyprland conflict check compares key codes and key names. A bind written with a key name that Qt spells differently (`Prior` and `PgUp`, `Escape` and `Esc`) is not detected.
172
+ - The managed Hyprland bind has only been tested with a Lua config.
173
+ - When the 10 ms minimum clips the random variation, the average interval is slightly longer than the one set.
174
+ - evdev fallback: the hotkey also reaches the focused application, and a modifier held while its device is unplugged counts as held until it is pressed again.
175
+ - Two instances launched within the same few milliseconds can both start.
176
+
177
+ ## Development
178
+
179
+ ```
180
+ python -m pytest
181
+ ```
182
+
183
+ `tapir/engine.py` holds the click worker and the virtual mouse (no Qt), `hotkey.py` the hotkey model, Hyprland binds and evdev listener, `ipc.py` the socket and command line, `config.py` the settings file, `i18n.py` the French translations, and `ui/` the window and tray. A test checks that every text passed to `tr()` has a French translation with the same placeholders.
184
+
185
+ ### Releasing
186
+
187
+ Set the new version in `pyproject.toml` and `pkgver` in `packaging/PKGBUILD`, commit, then push a matching tag:
188
+
189
+ ```
190
+ git tag v0.2.0
191
+ git push origin v0.2.0
192
+ ```
193
+
194
+ The *Publish* workflow runs the tests, checks that the tag matches the version in `pyproject.toml`, builds the sdist and wheel and uploads them to PyPI through trusted publishing (no API token is stored anywhere).
195
+
196
+ ## License
197
+
198
+ MIT, see [LICENSE](https://github.com/PierreSylla/tapir-autoclicker/blob/main/LICENSE).
@@ -0,0 +1,177 @@
1
+ # Tapir
2
+
3
+ A small autoclicker for Linux Wayland desktops, with a compact Qt window.
4
+
5
+ Clicks go through a virtual `uinput` mouse, so they work under any compositor. No X11 tools (xdotool, pynput, PyAutoGUI) are involved.
6
+
7
+ ## Features
8
+
9
+ - Interval in hours, minutes, seconds and milliseconds (10 ms minimum), with an optional random variation of ± N ms
10
+ - Left, right or middle button; single or double click
11
+ - Repeat N times or until stopped, with a click counter
12
+ - Optional countdown (0–10 s) when starting from the window or the tray, to give you time to place the cursor
13
+ - Clicks at the current cursor position and never moves the cursor
14
+ - Global start/stop hotkey: a bind managed in Hyprland, or an evdev fallback elsewhere
15
+ - Command line and Unix socket: `tapir toggle | start | stop`
16
+ - Tray icon coloured by state (grey stopped, amber countdown, green running)
17
+ - English or French interface, following the system language until you pick one
18
+ - Settings saved on every change
19
+
20
+ ## Installation
21
+
22
+ ### Arch Linux / CachyOS package
23
+
24
+ ```
25
+ cd packaging
26
+ makepkg -sif
27
+ ```
28
+
29
+ The PKGBUILD builds the **last commit** of this repository: uncommitted changes, including new files that are not committed yet, are not included. `-f` rebuilds the package even when one with the same version was built before; without it, makepkg reinstalls the old file. It installs the `tapir` command, the desktop entry and the udev rule, and pacman reloads udev for you.
30
+
31
+ ### With pipx or pip
32
+
33
+ ```
34
+ pipx install tapir-autoclicker
35
+ ```
36
+
37
+ evdev ships no prebuilt wheel, so pip compiles it: you need a C compiler, the Python headers and the kernel headers (Arch: `base-devel`; Debian and Ubuntu: `build-essential python3-dev`). The udev rule and the desktop entry are not installed this way: see Permissions.
38
+
39
+ ### From source
40
+
41
+ ```
42
+ sudo pacman -S pyside6 python-evdev python-tomli-w
43
+ python -m tapir
44
+ ```
45
+
46
+ Python 3.12 or newer. On other distributions, install PySide6, evdev and tomli-w from your package manager or with pip. From a source checkout, replace `tapir` with `python -m tapir` in the commands below.
47
+
48
+ ## Permissions
49
+
50
+ Tapir creates its virtual mouse through `/dev/uinput` and needs write access to it. Many systems already give it to the logged-in user (Steam's `60-steam-input.rules` does, for example). To check:
51
+
52
+ ```
53
+ test -w /dev/uinput && echo ok
54
+ ```
55
+
56
+ If it is not writable, install the udev rule shipped with Tapir. The Arch package does it for you; from source, run:
57
+
58
+ ```
59
+ scripts/setup-permissions.sh
60
+ ```
61
+
62
+ The script asks for confirmation, then runs:
63
+
64
+ ```
65
+ sudo install -Dm644 packaging/70-tapir-uinput.rules /etc/udev/rules.d/70-tapir-uinput.rules
66
+ sudo udevadm control --reload-rules
67
+ sudo udevadm trigger --sysname-match=uinput
68
+ ```
69
+
70
+ Without a source checkout (pip install), create the rule directly:
71
+
72
+ ```
73
+ echo 'KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"' | sudo tee /etc/udev/rules.d/70-tapir-uinput.rules
74
+ sudo udevadm control --reload-rules
75
+ sudo udevadm trigger --sysname-match=uinput
76
+ ```
77
+
78
+ The rule uses systemd's `uaccess` tag: only the user logged in at the seat gets access, not a whole group. Its file name has to sort before `73-seat-late.rules`, where udev applies `uaccess`.
79
+
80
+ When access is missing, Tapir shows these instructions instead of starting.
81
+
82
+ On Hyprland, Tapir never reads your keyboard. The evdev fallback for other compositors needs read access to `/dev/input/event*`, usually through the `input` group. Being in that group lets every program you run read every keystroke, passwords included, so Tapir never adds you to it. Prefer your compositor's own shortcuts (see below).
83
+
84
+ ## Usage
85
+
86
+ Set the interval, button, click type and repeat mode, then press **Start**. The status line shows *Stopped*, *Starting in N s* or *Running*, and the counter shows the clicks of the current session. In "Repeat N times" mode the session stops on its own.
87
+
88
+ The list at the top left switches the interface between English and French; the change applies immediately and is saved.
89
+
90
+ The settings are locked while a session runs. When a tray is available, closing the window (the **✕** button or your compositor's close shortcut) hides it to the tray; **Quit** is in the tray menu. Without a tray, closing the window quits.
91
+
92
+ ## Global hotkey
93
+
94
+ ### Hyprland
95
+
96
+ Click **Set…** next to *Hotkey* and press the combination: a key with optional Super, Ctrl, Alt and Shift, or a mouse side button (Mouse 4 / Mouse 5). Esc cancels, Backspace removes the hotkey.
97
+
98
+ Tapir adds the bind to Hyprland itself with `hyprctl eval` (`hl.bind`, tagged with the description `tapir`). The hotkey works when Tapir's window is not focused, and the key press does not reach other applications.
99
+
100
+ - A combination that your Hyprland config already uses is refused.
101
+ - The bind is added again when Hyprland reloads its config, and removed when Tapir quits.
102
+ - Hotkey starts are immediate: the countdown only applies to the window and tray.
103
+
104
+ Tested with Hyprland 0.57 and a Lua config, where `hyprctl keyword` is not available.
105
+
106
+ ### Static bind
107
+
108
+ If you prefer a permanent bind in your own config, leave Tapir's hotkey empty and add one of these.
109
+
110
+ Lua config (`binds.lua`):
111
+
112
+ ```lua
113
+ hl.bind("SUPER + F8", hl.dsp.exec_cmd("tapir toggle"))
114
+ ```
115
+
116
+ hyprlang config (`hyprland.conf`):
117
+
118
+ ```
119
+ bind = SUPER, F8, exec, tapir toggle
120
+ ```
121
+
122
+ Do not set the same combination in both places, or each press toggles twice.
123
+
124
+ ### Other compositors
125
+
126
+ Bind `tapir toggle` in your compositor's shortcut settings.
127
+
128
+ Alternatively, when Tapir runs outside Hyprland and can read `/dev/input/event*` (see Permissions), **Set…** works the same way and Tapir listens to your input devices itself. It reads them without grabbing them, so the key press also reaches the focused application. Devices plugged in later are picked up within a couple of seconds.
129
+
130
+ ## Command line
131
+
132
+ ```
133
+ tapir open the window, or show the one already running
134
+ tapir toggle start or stop clicking
135
+ tapir start start clicking now (no countdown)
136
+ tapir stop stop clicking
137
+ ```
138
+
139
+ Commands go to the running instance through `$XDG_RUNTIME_DIR/tapir.sock`, which only your user can reach. They exit with code 1 when no instance is running. Only one instance runs at a time.
140
+
141
+ ## Configuration
142
+
143
+ Settings are saved to `~/.config/tapir/config.toml` (or `$XDG_CONFIG_HOME/tapir/config.toml`) on every change and loaded at startup. Invalid values fall back to their defaults.
144
+
145
+ ## Known limitations
146
+
147
+ - Tapir clicks wherever the cursor is; it cannot move the cursor or target a window.
148
+ - The tray is detected at startup. If Tapir starts before your bar, there is no tray icon and closing the window quits.
149
+ - On Wayland, `tapir` or **Show** may not raise a window that is already visible: compositors limit focus stealing (Hyprland: `misc:focus_on_activate`).
150
+ - The Hyprland conflict check compares key codes and key names. A bind written with a key name that Qt spells differently (`Prior` and `PgUp`, `Escape` and `Esc`) is not detected.
151
+ - The managed Hyprland bind has only been tested with a Lua config.
152
+ - When the 10 ms minimum clips the random variation, the average interval is slightly longer than the one set.
153
+ - evdev fallback: the hotkey also reaches the focused application, and a modifier held while its device is unplugged counts as held until it is pressed again.
154
+ - Two instances launched within the same few milliseconds can both start.
155
+
156
+ ## Development
157
+
158
+ ```
159
+ python -m pytest
160
+ ```
161
+
162
+ `tapir/engine.py` holds the click worker and the virtual mouse (no Qt), `hotkey.py` the hotkey model, Hyprland binds and evdev listener, `ipc.py` the socket and command line, `config.py` the settings file, `i18n.py` the French translations, and `ui/` the window and tray. A test checks that every text passed to `tr()` has a French translation with the same placeholders.
163
+
164
+ ### Releasing
165
+
166
+ Set the new version in `pyproject.toml` and `pkgver` in `packaging/PKGBUILD`, commit, then push a matching tag:
167
+
168
+ ```
169
+ git tag v0.2.0
170
+ git push origin v0.2.0
171
+ ```
172
+
173
+ The *Publish* workflow runs the tests, checks that the tag matches the version in `pyproject.toml`, builds the sdist and wheel and uploads them to PyPI through trusted publishing (no API token is stored anywhere).
174
+
175
+ ## License
176
+
177
+ MIT, see [LICENSE](https://github.com/PierreSylla/tapir-autoclicker/blob/main/LICENSE).
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tapir-autoclicker"
7
+ version = "0.2.0"
8
+ description = "Small Wayland-friendly autoclicker with a Qt UI"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Pierre Sylla" }]
13
+ keywords = ["autoclicker", "wayland", "hyprland", "uinput"]
14
+ classifiers = [
15
+ "Intended Audience :: End Users/Desktop",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Utilities",
19
+ ]
20
+ requires-python = ">=3.12"
21
+ dependencies = ["PySide6>=6.5", "evdev>=1.6", "tomli-w>=1.0"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/PierreSylla/tapir-autoclicker"
25
+ Issues = "https://github.com/PierreSylla/tapir-autoclicker/issues"
26
+
27
+ [project.scripts]
28
+ tapir = "tapir.ipc:main"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["tapir*"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from tapir.ipc import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,57 @@
1
+ import logging
2
+ import os
3
+ import tomllib
4
+ from dataclasses import asdict, dataclass, fields
5
+ from pathlib import Path
6
+
7
+ import tomli_w
8
+
9
+ from tapir.engine import BUTTONS
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ CONFIG_PATH = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "tapir" / "config.toml"
14
+
15
+
16
+ @dataclass
17
+ class Config:
18
+ hours: int = 0
19
+ minutes: int = 0
20
+ seconds: int = 0
21
+ milliseconds: int = 100
22
+ jitter_ms: int = 0
23
+ button: str = "left"
24
+ double_click: bool = False
25
+ repeat_forever: bool = True
26
+ repeat_count: int = 10
27
+ start_delay_s: int = 3
28
+ hotkey: str = ""
29
+ hotkey_label: str = ""
30
+ language: str = "" # empty: follow the system
31
+
32
+
33
+ def load(path: Path = CONFIG_PATH) -> Config:
34
+ try:
35
+ with path.open("rb") as f:
36
+ data = tomllib.load(f)
37
+ except FileNotFoundError:
38
+ return Config()
39
+ except (OSError, ValueError) as e:
40
+ log.warning("Ignoring unreadable config %s: %s", path, e)
41
+ return Config()
42
+ defaults = Config()
43
+ valid = {
44
+ f.name: data[f.name]
45
+ for f in fields(Config)
46
+ if type(data.get(f.name)) is type(getattr(defaults, f.name))
47
+ }
48
+ if valid.get("button") not in (None, *BUTTONS):
49
+ del valid["button"]
50
+ return Config(**valid)
51
+
52
+
53
+ def save(config: Config, path: Path = CONFIG_PATH) -> None:
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ tmp = path.with_suffix(".tmp")
56
+ tmp.write_text(tomli_w.dumps(asdict(config)), encoding="utf-8")
57
+ tmp.replace(path)
@@ -0,0 +1,135 @@
1
+ import math
2
+ import random
3
+ import threading
4
+ import time
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ from evdev import UInput, ecodes
9
+
10
+ MIN_INTERVAL_MS = 10
11
+ DOUBLE_CLICK_GAP_S = 0.04
12
+ UINPUT_NAME = "tapir-virtual-mouse"
13
+ BUTTON_CODES = {"left": ecodes.BTN_LEFT, "right": ecodes.BTN_RIGHT, "middle": ecodes.BTN_MIDDLE}
14
+ BUTTONS = tuple(BUTTON_CODES)
15
+
16
+
17
+ class State(StrEnum):
18
+ STOPPED = "stopped"
19
+ PENDING = "pending"
20
+ RUNNING = "running"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ClickSettings:
25
+ interval_ms: int
26
+ button: str = "left"
27
+ double: bool = False
28
+ repeat: int | None = None # None = until stopped
29
+ jitter_ms: int = 0
30
+
31
+
32
+ def total_ms(hours: int = 0, minutes: int = 0, seconds: int = 0, milliseconds: int = 0) -> int:
33
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000 + milliseconds
34
+
35
+
36
+ def gap_range_ms(interval_ms: int, jitter_ms: int = 0) -> tuple[int, int]:
37
+ base = max(interval_ms, MIN_INTERVAL_MS)
38
+ return max(base - jitter_ms, MIN_INTERVAL_MS), base + jitter_ms
39
+
40
+
41
+ def gap_ms(interval_ms: int, jitter_ms: int = 0) -> int:
42
+ return random.randint(*gap_range_ms(interval_ms, jitter_ms))
43
+
44
+
45
+ class UInputEmitter:
46
+ """Virtual mouse; clicks land wherever the compositor's cursor is."""
47
+
48
+ def __init__(self):
49
+ # REL axes are never used but make libinput classify the device as a pointer.
50
+ self._ui = UInput(
51
+ {ecodes.EV_KEY: list(BUTTON_CODES.values()), ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL]},
52
+ name=UINPUT_NAME,
53
+ )
54
+
55
+ def press(self, button: str) -> None:
56
+ self._ui.write(ecodes.EV_KEY, BUTTON_CODES[button], 1)
57
+ self._ui.syn()
58
+
59
+ def release(self, button: str) -> None:
60
+ self._ui.write(ecodes.EV_KEY, BUTTON_CODES[button], 0)
61
+ self._ui.syn()
62
+
63
+ def close(self) -> None:
64
+ # The kernel drops releases for buttons that are not down, so releasing everything is safe.
65
+ for code in BUTTON_CODES.values():
66
+ self._ui.write(ecodes.EV_KEY, code, 0)
67
+ self._ui.syn()
68
+ self._ui.close()
69
+
70
+
71
+ class Clicker:
72
+ """Click worker thread; callbacks run on the worker thread."""
73
+
74
+ def __init__(self, emitter, on_state=None, on_count=None):
75
+ self._emitter = emitter
76
+ self._on_state = on_state or (lambda state, countdown=0: None)
77
+ self._on_count = on_count or (lambda count: None)
78
+ self._thread: threading.Thread | None = None
79
+ self._stop = threading.Event()
80
+
81
+ @property
82
+ def active(self) -> bool:
83
+ return self._thread is not None and self._thread.is_alive()
84
+
85
+ def start(self, settings: ClickSettings, start_delay_s: float = 0) -> None:
86
+ if self.active:
87
+ return
88
+ self._stop = threading.Event()
89
+ self._thread = threading.Thread(
90
+ target=self._run, args=(settings, start_delay_s, self._stop), name="tapir-clicker", daemon=True
91
+ )
92
+ self._thread.start()
93
+
94
+ def stop(self) -> None:
95
+ self._stop.set()
96
+ if self._thread is not None:
97
+ self._thread.join()
98
+
99
+ def _run(self, settings: ClickSettings, start_delay_s: float, stop: threading.Event) -> None:
100
+ try:
101
+ if self._countdown(start_delay_s, stop):
102
+ return
103
+ self._on_state(State.RUNNING)
104
+ self._click_loop(settings, stop)
105
+ finally:
106
+ self._on_state(State.STOPPED)
107
+
108
+ def _countdown(self, seconds: float, stop: threading.Event) -> bool:
109
+ end = time.monotonic() + seconds
110
+ while (left := end - time.monotonic()) > 0:
111
+ whole = math.ceil(left)
112
+ self._on_state(State.PENDING, whole)
113
+ if stop.wait(left - whole + 1):
114
+ return True
115
+ return stop.is_set()
116
+
117
+ def _click_loop(self, settings: ClickSettings, stop: threading.Event) -> None:
118
+ count = 0
119
+ next_at = time.monotonic()
120
+ while settings.repeat is None or count < settings.repeat:
121
+ if stop.wait(max(0.0, next_at - time.monotonic())):
122
+ return
123
+ self._click(settings, stop)
124
+ count += 1
125
+ self._on_count(count)
126
+ # After a stall (suspend, overload) resume from now instead of firing a burst of late clicks.
127
+ gap = gap_ms(settings.interval_ms, settings.jitter_ms) / 1000
128
+ next_at = max(next_at + gap, time.monotonic())
129
+
130
+ def _click(self, settings: ClickSettings, stop: threading.Event) -> None:
131
+ self._emitter.press(settings.button)
132
+ self._emitter.release(settings.button)
133
+ if settings.double and not stop.wait(DOUBLE_CLICK_GAP_S):
134
+ self._emitter.press(settings.button)
135
+ self._emitter.release(settings.button)