Add rsi-clicker.py

This commit is contained in:
2026-08-06 16:28:30 +00:00
commit 77c0c029de

506
rsi-clicker.py Normal file
View File

@@ -0,0 +1,506 @@
#!/usr/bin/env python3
"""
rsi-clicker — a Wayland-native autoclicker for Linux.
Clicks are injected through /dev/uinput, entering the kernel input stack at
the same layer as your real mouse. That's why it works under Wayland and
inside Proton games, where X11-era tools (xdotool, xbindkeys, AutoKey) do
nothing.
Default binding: scroll wheel UP starts spamming left click, wheel DOWN
stops it.
Requires: python-evdev (sudo pacman -S python-evdev)
Run `rsi-clicker --doctor` first — it checks your setup and tells you
exactly what's missing.
"""
from __future__ import annotations
import argparse
import grp
import os
import random
import selectors
import signal
import sys
import time
from pathlib import Path
try:
import evdev
from evdev import InputDevice, UInput
from evdev import ecodes as e
except ImportError:
sys.exit("python-evdev is not installed.\n sudo pacman -S python-evdev\n")
HI_RES_PER_DETENT = 120 # kernel convention for REL_WHEEL_HI_RES
# --------------------------------------------------------------------------
# Device discovery
# --------------------------------------------------------------------------
def all_input_devices() -> list[InputDevice]:
devices = []
for path in sorted(evdev.list_devices()):
try:
devices.append(InputDevice(path))
except (PermissionError, OSError):
continue
return devices
def has_key(dev: InputDevice, code: int) -> bool:
return code in dev.capabilities().get(e.EV_KEY, [])
def has_rel(dev: InputDevice, code: int) -> bool:
return code in dev.capabilities().get(e.EV_REL, [])
def is_mouse(dev: InputDevice) -> bool:
"""A real pointing device: has a left button and moves in X."""
return has_key(dev, e.BTN_LEFT) and has_rel(dev, e.REL_X)
def has_wheel(dev: InputDevice) -> bool:
return has_rel(dev, e.REL_WHEEL) or has_rel(dev, e.REL_WHEEL_HI_RES)
def list_devices() -> None:
devs = all_input_devices()
if not devs:
print("No readable input devices — run `rsi-clicker --doctor`.")
return
print(f"{'PATH':<22} {'NAME':<44} NOTES")
print("-" * 96)
for dev in devs:
notes = []
if is_mouse(dev):
notes.append("MOUSE")
if has_wheel(dev):
notes.append("wheel")
if has_key(dev, e.BTN_SIDE):
notes.append("side-btns")
if has_key(dev, e.KEY_A):
notes.append("keyboard")
if not notes:
continue
print(f"{dev.path:<22} {dev.name[:42]:<44} {', '.join(notes)}")
print("\nThe device marked MOUSE + wheel is the one this will use.")
# --------------------------------------------------------------------------
# Setup checker
# --------------------------------------------------------------------------
def doctor() -> None:
ok = True
def check(label, passed, fix=None):
nonlocal ok
print(f" [{'ok' if passed else 'XX'}] {label}")
if not passed:
ok = False
if fix:
for line in fix.strip().splitlines():
print(f" {line}")
return passed
print("rsi-clicker setup check\n")
check("python-evdev installed", True)
uinput_exists = Path("/dev/uinput").exists()
check("uinput module loaded", uinput_exists, """
fix: sudo modprobe uinput
echo uinput | sudo tee /etc/modules-load.d/uinput.conf""")
if uinput_exists:
writable = os.access("/dev/uinput", os.W_OK)
check("/dev/uinput writable by you", writable, """
fix: echo 'KERNEL=="uinput", GROUP="input", MODE="0660", OPTIONS+="static_node=uinput"' \\
| sudo tee /etc/udev/rules.d/99-uinput.rules
sudo udevadm control --reload-rules && sudo udevadm trigger""")
try:
in_input = grp.getgrnam("input").gr_gid in os.getgroups()
except KeyError:
in_input = False
check("you are in the 'input' group", in_input, """
fix: sudo usermod -aG input $USER
then LOG OUT and back in — group changes need a new session""")
mice = [d for d in all_input_devices() if is_mouse(d) and has_wheel(d)]
check(f"found a mouse with a scroll wheel ({len(mice)})", bool(mice), """
fix: usually the same cause as the 'input' group check above""")
for m in mice:
print(f" {m.path} {m.name}")
print("\n" + ("All good — you're ready to run it." if ok
else "Fix the XX lines above, then re-run --doctor."))
# --------------------------------------------------------------------------
# Code name resolution
# --------------------------------------------------------------------------
def resolve_key(name: str) -> int | None:
name = (name or "").strip()
if not name:
return None
if name.isdigit():
return int(name)
upper = name.upper()
if not upper.startswith(("BTN_", "KEY_")):
upper = "KEY_" + upper
code = getattr(e, upper, None)
if code is None:
sys.exit(f"Unknown key: {name!r} — expected something like KEY_F12.")
return code
CLICK_BUTTONS = {"left": e.BTN_LEFT, "right": e.BTN_RIGHT, "middle": e.BTN_MIDDLE}
# --------------------------------------------------------------------------
# The clicker
# --------------------------------------------------------------------------
class Clicker:
def __init__(self, args):
self.args = args
self.button = CLICK_BUTTONS[args.button]
self.panic = resolve_key(args.panic)
self.interval = 1.0 / args.cps
self.jitter = args.jitter
self.hold_s = args.hold_ms / 1000.0
self.active = False
self.pressed = False
self.release_at = 0.0
self.next_click = 0.0
self.started_at = 0.0
self.click_count = 0
self.running = True
# High-res wheel accumulator, used only if the mouse lacks REL_WHEEL.
self._hires_accum = 0
self.mouse = self._find_mouse()
self.use_hires = not has_rel(self.mouse, e.REL_WHEEL)
self.extra_devices = self._find_panic_devices()
self.grabbed = False
self.ui = self._make_uinput()
if args.swallow_wheel:
try:
self.mouse.grab()
self.grabbed = True
except OSError as exc:
sys.exit(
f"Could not grab {self.mouse.path}: {exc}\n"
"Another process may already hold it."
)
self.selector = selectors.DefaultSelector()
for dev in [self.mouse, *self.extra_devices]:
self.selector.register(dev, selectors.EVENT_READ)
# -- device setup ------------------------------------------------------
def _find_mouse(self) -> InputDevice:
if self.args.device:
try:
return InputDevice(self.args.device)
except (PermissionError, OSError) as exc:
sys.exit(f"Cannot open {self.args.device}: {exc}")
candidates = [d for d in all_input_devices() if is_mouse(d) and has_wheel(d)]
if not candidates:
sys.exit(
"No mouse with a scroll wheel found.\n"
"Run `rsi-clicker --doctor` to diagnose, or `--list` to pick "
"one manually with --device."
)
if len(candidates) > 1 and self.args.verbose:
print("Multiple mice found; using the first. Others:")
for d in candidates[1:]:
print(f" {d.path} {d.name}")
return candidates[0]
def _find_panic_devices(self) -> list[InputDevice]:
if self.panic is None:
return []
return [
d for d in all_input_devices()
if d.path != self.mouse.path and has_key(d, self.panic)
]
def _make_uinput(self) -> UInput:
"""
Without --swallow-wheel we only need to emit clicks.
With it, we've taken exclusive control of the mouse, so the virtual
device has to reproduce everything the real one could do.
"""
caps = {
e.EV_KEY: [e.BTN_LEFT, e.BTN_RIGHT, e.BTN_MIDDLE],
e.EV_REL: [e.REL_X, e.REL_Y],
}
if self.args.swallow_wheel:
src = self.mouse.capabilities()
keys = set(caps[e.EV_KEY]) | set(src.get(e.EV_KEY, []))
rels = set(caps[e.EV_REL]) | set(src.get(e.EV_REL, []))
caps = {e.EV_KEY: sorted(keys), e.EV_REL: sorted(rels)}
if src.get(e.EV_MSC):
caps[e.EV_MSC] = list(src[e.EV_MSC])
return UInput(caps, name="rsi-clicker virtual pointer",
vendor=0x1209, product=0x0001, version=1)
# -- click emission ----------------------------------------------------
def _press(self):
self.ui.write(e.EV_KEY, self.button, 1)
self.ui.syn()
self.pressed = True
self.click_count += 1
def _release(self):
self.ui.write(e.EV_KEY, self.button, 0)
self.ui.syn()
self.pressed = False
def _schedule_next(self, now):
gap = self.interval
if self.jitter > 0:
gap *= 1.0 + random.uniform(-self.jitter, self.jitter)
self.next_click = now + max(gap, self.hold_s + 0.002)
def _set_active(self, state, now):
if state == self.active:
return
self.active = state
if state:
self.started_at = now
self.next_click = now
print(" ▶ clicking", flush=True)
else:
if self.pressed:
self._release()
print(f" ■ stopped ({self.click_count} clicks)", flush=True)
self.click_count = 0
# -- event handling ----------------------------------------------------
def _wheel_direction(self, ev) -> int:
"""Return +1 for up, -1 for down, 0 for 'not a full detent yet'."""
if not self.use_hires:
if ev.code == e.REL_WHEEL and ev.value != 0:
return 1 if ev.value > 0 else -1
return 0
if ev.code != e.REL_WHEEL_HI_RES:
return 0
self._hires_accum += ev.value
if abs(self._hires_accum) >= HI_RES_PER_DETENT:
direction = 1 if self._hires_accum > 0 else -1
self._hires_accum = 0
return direction
return 0
def _handle_mouse_event(self, ev, now) -> bool:
"""Returns True if the event should be forwarded (grab mode only)."""
if ev.type == e.EV_KEY and ev.code == self.panic and ev.value == 1:
self.running = False
return True
if ev.type != e.EV_REL:
return True
is_wheel = ev.code in (e.REL_WHEEL, e.REL_WHEEL_HI_RES)
if not is_wheel:
return True
direction = self._wheel_direction(ev)
if direction > 0:
self._set_active(True, now)
elif direction < 0:
self._set_active(False, now)
# In grab mode we eat wheel events so the game never sees them.
return not self.args.swallow_wheel
def _handle_extra_event(self, ev):
if (ev.type == e.EV_KEY and self.panic is not None
and ev.code == self.panic and ev.value == 1):
print("\npanic key — exiting")
self.running = False
# -- main loop ---------------------------------------------------------
def run(self):
signal.signal(signal.SIGINT, self._stop)
signal.signal(signal.SIGTERM, self._stop)
self._banner()
while self.running:
now = time.monotonic()
deadlines = []
if self.pressed:
deadlines.append(self.release_at)
elif self.active:
deadlines.append(self.next_click)
if self.active and self.args.max_duration:
deadlines.append(self.started_at + self.args.max_duration)
timeout = 0.05
if deadlines:
timeout = min(0.05, max(0.0, min(deadlines) - now))
for key, _ in self.selector.select(timeout=timeout):
dev = key.fileobj
try:
for ev in dev.read():
if dev.path == self.mouse.path:
forward = self._handle_mouse_event(ev, time.monotonic())
if self.grabbed and forward:
self.ui.write(ev.type, ev.code, ev.value)
else:
self._handle_extra_event(ev)
except BlockingIOError:
pass
except OSError:
self.selector.unregister(dev)
now = time.monotonic()
if (self.active and self.args.max_duration
and now - self.started_at >= self.args.max_duration):
print(" (auto-stop: max duration reached)")
self._set_active(False, now)
if self.pressed and now >= self.release_at:
self._release()
self._schedule_next(now)
elif self.active and not self.pressed and now >= self.next_click:
self._press()
self.release_at = now + self.hold_s
self.shutdown()
def _stop(self, *_):
self.running = False
def shutdown(self):
if self.pressed:
self._release()
if self.grabbed:
try:
self.mouse.ungrab()
except OSError:
pass
try:
self.ui.close()
except Exception:
pass
for dev in [self.mouse, *self.extra_devices]:
try:
dev.close()
except Exception:
pass
print("clean exit — mouse released")
def _banner(self):
print("rsi-clicker running\n")
print(f" wheel UP → start spamming {self.args.button} click")
print(f" wheel DOWN → stop")
print(f" rate : {self.args.cps:.1f}/sec "
f"{self.jitter * 100:.0f}% jitter, {self.args.hold_ms:.0f}ms hold)")
if self.args.swallow_wheel:
print(" wheel : SWALLOWED — scrolling won't reach any app")
else:
print(" wheel : passed through — unbind weapon-swap in-game")
if self.args.max_duration:
print(f" auto-stop : after {self.args.max_duration:.0f}s")
if self.panic is not None:
print(f" panic key : {self.args.panic}")
print(f" mouse : {self.mouse.name} ({self.mouse.path})")
print("\n Ctrl+C to quit\n")
# --------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="rsi-clicker",
description="Scroll-wheel-toggled autoclicker for Wayland.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""examples:
rsi-clicker --doctor check your setup first
rsi-clicker wheel up = go, wheel down = stop
rsi-clicker --swallow-wheel also stop the wheel reaching the game
rsi-clicker --cps 12 --hold-ms 40
""",
)
p.add_argument("--doctor", action="store_true",
help="check permissions/modules and print fixes, then exit")
p.add_argument("--list", action="store_true",
help="list input devices, then exit")
p.add_argument("--button", choices=list(CLICK_BUTTONS), default="left",
help="which button to spam (default: left)")
p.add_argument("--cps", type=float, default=11.0,
help="clicks per second (default: 11)")
p.add_argument("--jitter", type=float, default=0.12,
help="fractional timing variance 0-1 (default: 0.12)")
p.add_argument("--hold-ms", type=float, default=25.0,
help="how long each click is held, ms (default: 25); "
"raise to 40 if the game misses clicks")
p.add_argument("--swallow-wheel", action="store_true",
help="take exclusive control of the mouse and stop wheel "
"events reaching any application; everything else is "
"passed through untouched")
p.add_argument("--max-duration", type=float, default=0.0,
help="auto-stop after N seconds of continuous clicking "
"(0 = no limit)")
p.add_argument("--panic", default="KEY_F12",
help="key that instantly quits (default: KEY_F12; "
"pass '' to disable)")
p.add_argument("--device", help="explicit /dev/input/eventX for the mouse")
p.add_argument("-v", "--verbose", action="store_true")
return p
def main() -> None:
args = build_parser().parse_args()
if args.doctor:
doctor()
return
if args.list:
list_devices()
return
if not 0 < args.cps <= 100:
sys.exit("--cps must be between 0 and 100.")
if not 0.0 <= args.jitter < 1.0:
sys.exit("--jitter must be between 0 and 1.")
if not Path("/dev/uinput").exists():
sys.exit("/dev/uinput missing. Run `rsi-clicker --doctor`.")
try:
Clicker(args).run()
except PermissionError:
sys.exit("Permission denied. Run `rsi-clicker --doctor` for the fix.")
if __name__ == "__main__":
main()