#!/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()