From 77c0c029de20d162f575abb1fd56aa5a3847d33c Mon Sep 17 00:00:00 2001 From: michael Date: Thu, 6 Aug 2026 16:28:30 +0000 Subject: [PATCH] Add rsi-clicker.py --- rsi-clicker.py | 506 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 rsi-clicker.py diff --git a/rsi-clicker.py b/rsi-clicker.py new file mode 100644 index 0000000..b27d24d --- /dev/null +++ b/rsi-clicker.py @@ -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()