525 lines
18 KiB
Python
525 lines
18 KiB
Python
#!/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 getpass
|
|
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""")
|
|
|
|
# Two distinct failures here, with completely different fixes:
|
|
# (a) not in the group at all -> usermod
|
|
# (b) in the group, but this login -> log out / newgrp
|
|
# session predates the change
|
|
user = getpass.getuser()
|
|
try:
|
|
group = grp.getgrnam("input")
|
|
in_db = user in group.gr_mem
|
|
in_session = group.gr_gid in os.getgroups()
|
|
except KeyError:
|
|
in_db = in_session = False
|
|
|
|
if in_session:
|
|
check("you are in the 'input' group", True)
|
|
elif in_db:
|
|
check("'input' group active in this session", False, """
|
|
You ARE in the group, but this login session started before that
|
|
happened, so your processes never got it.
|
|
|
|
fix: log out and back in (or reboot)
|
|
to test right now without logging out: newgrp input""")
|
|
else:
|
|
check("you are in the 'input' group", False, """
|
|
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() |