From e94363eb9ca22b15299c000e858b222aaa26953c Mon Sep 17 00:00:00 2001 From: bepis Date: Thu, 23 Apr 2026 12:45:49 +1000 Subject: [PATCH] Initial commit --- .gitignore | 37 ++++++ LICENSE | 21 ++++ README.md | 134 +++++++++++++++++++++ main.py | 302 +++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 36 ++++++ requirements.txt | 1 + 6 files changed, 531 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..35f209d --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dff6960 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 BongoCatXP Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fade699 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# TapTapLoot Forwarder + +Forward keyboard input to [TapTapLoot](https://store.steampowered.com/app/your_app_id) running in Xwayland on Linux, so it can run in the background while you use other apps — similar to how BongoCat works. + +> **Based on [BongoCatXP](https://github.com/ITJesse/BongoCatXP) by ITJesse** — adapted for TapTapLoot with Xwayland support and dynamic xauth handling. + +## How it works + +TapTapLoot is launched in a rootful Xwayland instance (via a Steam launch script), which lets it run independently of your Wayland compositor. This tool uses `evdev` to read your keyboard at the device level and forwards keypresses to the TapTapLoot window via `xdotool`, without requiring the window to have focus. + +## Requirements + +- Linux (Wayland or X11) +- Python 3.7+ +- `xdotool` +- `xdotool` must be installed +- Access to `/dev/input/` devices (via `input` group) +- TapTapLoot running via the Xwayland launch script (see below) + +## Installation + +### 1. Install system dependencies + +```bash +# Arch Linux / Bazzite / SteamOS +sudo pacman -S xdotool python-pipx +# or on immutable distros (Bazzite, SteamOS): +ujust setup-decky # if needed, then use distrobox or toolbox for pipx +``` + +### 2. Install this tool + +```bash +# From this repo +pipx install git+https://gitea.example.com/yourname/TapTapLootForwarder.git + +# Or from a local clone +git clone https://gitea.example.com/yourname/TapTapLootForwarder.git +cd TapTapLootForwarder +pipx install . +``` + +### 3. Add yourself to the input group + +```bash +sudo usermod -aG input $USER +# Log out and back in for this to take effect +``` + +## Steam Launch Script + +Save the following as `~/.local/bin/taptaploot-forward.sh` and make it executable (`chmod +x`): + +```bash +#!/bin/bash +# Launches TapTapLoot in rootful Xwayland +# Steam launch option: ~/.local/bin/taptaploot-forward.sh %command% + +for i in $(seq 10 30); do + if [ ! -e "/tmp/.X11-unix/X$i" ]; then + GAME_DISPLAY=":$i" + break + fi +done + +GAME_XAUTH=$(mktemp /tmp/taptaploot-xauth.XXXXXX) +COOKIE=$(mcookie) +xauth -f "$GAME_XAUTH" add "$GAME_DISPLAY" . "$COOKIE" + +cleanup() { + kill "$XWAYLAND_PID" 2>/dev/null + wait "$XWAYLAND_PID" 2>/dev/null + rm -f "$GAME_XAUTH" +} +trap cleanup EXIT INT TERM + +XAUTHORITY="$GAME_XAUTH" Xwayland "$GAME_DISPLAY" -geometry 1920x1080 -noreset -auth "$GAME_XAUTH" & +XWAYLAND_PID=$! +sleep 1 + +DISPLAY="$GAME_DISPLAY" XAUTHORITY="$GAME_XAUTH" "$@" & +GAME_PID=$! + +wait "$GAME_PID" +``` + +Set your Steam launch option to: +``` +~/.local/bin/taptaploot-forward.sh %command% +``` + +## Usage + +1. Launch TapTapLoot via Steam (using the launch script above) +2. In a terminal, run: + +```bash +taptaploot-forwarder +``` + +Keypresses will be forwarded to the game without it needing focus. Press `Ctrl+C` to stop. + +## Bazzite / Immutable Distro Installation + +On Bazzite, Nobara, or SteamOS (which use immutable root filesystems), the recommended approach is to use a **Distrobox container**: + +```bash +# Create an Arch-based container +distrobox create --name taptaploot --image archlinux:latest +distrobox enter taptaploot + +# Inside the container: +sudo pacman -S xdotool python-pipx +sudo usermod -aG input $USER +pipx install git+https://gitea.example.com/yourname/TapTapLootForwarder.git + +# Export the command to your host +distrobox-export --bin ~/.local/bin/taptaploot-forwarder +``` + +Then run `taptaploot-forwarder` from your host terminal as normal. + +## Troubleshooting + +**Permission denied on `/dev/input/`** — make sure you've added yourself to the `input` group and logged out/in. Temporarily use `sudo taptaploot-forwarder`. + +**Window not found** — ensure TapTapLoot is running via the Xwayland launch script. The forwarder checks every 10 seconds. + +**No keyboard devices found** — permission issue, see above. + +## Credits + +- [BongoCatXP](https://github.com/ITJesse/BongoCatXP) by [ITJesse](https://github.com/ITJesse) — original implementation this is based on +- MIT License diff --git a/main.py b/main.py new file mode 100644 index 0000000..9c7adb0 --- /dev/null +++ b/main.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +import evdev +from evdev import InputDevice, ecodes +import os +import subprocess +import signal +import threading +import queue +import time +import shutil +import argparse +import glob + +__version__ = "0.1.0" + +BongoWindow = None +stop_event = threading.Event() +keyboard_stop_event = threading.Event() +key_queue = queue.Queue() +keyboard_threads = [] +keyboard_devices = [] + +TARGET_WINDOW_NAME = 'TapTapLoot' +TARGET_DISPLAY = ':10' + +def get_xauth_path(): + """Dynamically find the current taptaploot xauth file""" + matches = glob.glob('/tmp/taptaploot-xauth.*') + if matches: + return matches[0] + return None + +def get_xenv(): + """Get environment dict for xdotool calls targeting the game display""" + xauth = get_xauth_path() + env = {**os.environ, 'DISPLAY': TARGET_DISPLAY} + if xauth: + env['XAUTHORITY'] = xauth + return env + +def get_xdotool_keyname(keycode): + key_name = ecodes.KEY[keycode] + if key_name.startswith('KEY_'): + key_name = key_name[4:] + elif key_name.startswith('BTN_'): + key_name = key_name[4:] + key_name = key_name.lower() + special_keys = { + 'leftctrl': 'ctrl', + 'rightctrl': 'ctrl', + 'leftshift': 'shift', + 'rightshift': 'shift', + 'leftalt': 'alt', + 'rightalt': 'alt', + 'leftmeta': 'super', + 'rightmeta': 'super', + 'enter': 'Return', + 'esc': 'Escape', + 'backspace': 'BackSpace', + 'tab': 'Tab', + 'capslock': 'Caps_Lock', + } + return special_keys.get(key_name, key_name) + +def find_game_window(): + """Find the TapTapLoot window on the game display""" + try: + env = get_xenv() + result = subprocess.getoutput( + f"DISPLAY={TARGET_DISPLAY} XAUTHORITY={env.get('XAUTHORITY', '')} " + f"xdotool search --name '{TARGET_WINDOW_NAME}'" + ) + if not result.strip(): + return None + + for window_id in result.strip().split('\n'): + window_name = subprocess.getoutput( + f"DISPLAY={TARGET_DISPLAY} XAUTHORITY={env.get('XAUTHORITY', '')} " + f"xdotool getwindowname {window_id}" + ).strip() + if window_name == TARGET_WINDOW_NAME: + return window_id + + # No exact match, return first result + return result.strip().split('\n')[0] + except: + pass + return None + +def send_keys_to_game(keys): + """Send batched keypresses to the game window""" + if not keys or not BongoWindow: + return + + env = get_xenv() + batch_size = 4 + for i in range(0, len(keys), batch_size): + batch = keys[i:i+batch_size] + + cmd_keydown = ['xdotool', 'keydown', '--window', BongoWindow] + batch + cmd_keyup = ['xdotool', 'keyup', '--window', BongoWindow] + batch + + try: + result = subprocess.run(cmd_keydown, capture_output=True, text=True, env=env) + if result.returncode != 0: + continue + time.sleep(0.01) + subprocess.run(cmd_keyup, capture_output=True, text=True, env=env) + except Exception: + pass + +def replace_duplicate_keys(keys): + if not keys: + return [] + replacement_pool = list('abcdefghijklmnopqrstuvwxyz') + seen = set() + result = [] + for key in keys: + if key in seen: + for replacement in replacement_pool: + if replacement not in seen: + result.append(replacement) + seen.add(replacement) + break + else: + result.append(key) + else: + result.append(key) + seen.add(key) + return result + +def batch_sender(): + while not stop_event.is_set(): + time.sleep(0.2) + keys = [] + while True: + try: + key = key_queue.get_nowait() + keys.append(key) + except queue.Empty: + break + if keys: + replaced_keys = replace_duplicate_keys(keys) + send_keys_to_game(replaced_keys) + +def is_keyboard(device): + capabilities = device.capabilities() + if ecodes.EV_KEY not in capabilities: + return False + keys = capabilities[ecodes.EV_KEY] + mouse_buttons = [ + ecodes.BTN_LEFT, ecodes.BTN_RIGHT, ecodes.BTN_MIDDLE, + ecodes.BTN_SIDE, ecodes.BTN_EXTRA, ecodes.BTN_FORWARD, + ecodes.BTN_BACK, ecodes.BTN_MOUSE, + ] + for btn in mouse_buttons: + if btn in keys: + return False + if ecodes.BTN_TOUCH in keys or ecodes.BTN_TOOL_FINGER in keys: + return False + keyboard_keys = [ + ecodes.KEY_A, ecodes.KEY_B, ecodes.KEY_C, + ecodes.KEY_SPACE, ecodes.KEY_ENTER, ecodes.KEY_ESC, + ] + for key in keyboard_keys: + if key in keys: + return True + return False + +def monitor_keyboard(device): + try: + for event in device.read_loop(): + if keyboard_stop_event.is_set() or stop_event.is_set(): + break + if event.type == ecodes.EV_KEY and event.value == 1: + try: + key_name = get_xdotool_keyname(event.code) + key_queue.put(key_name) + except (KeyError, ValueError): + pass + except (OSError, IOError): + pass + +def start_keyboard_monitoring(): + global keyboard_threads, keyboard_devices + keyboard_stop_event.clear() + all_devices = [InputDevice(path) for path in evdev.list_devices()] + keyboards = [device for device in all_devices if is_keyboard(device)] + if not keyboards: + print(" [Warning] No keyboard devices found") + return False + print(f" Found {len(keyboards)} keyboard device(s)") + keyboard_devices = keyboards + keyboard_threads = [] + for kb in keyboards: + thread = threading.Thread(target=monitor_keyboard, args=(kb,), daemon=True) + thread.start() + keyboard_threads.append(thread) + return True + +def stop_keyboard_monitoring(): + global keyboard_threads, keyboard_devices + keyboard_stop_event.set() + for kb in keyboard_devices: + try: + kb.close() + except Exception: + pass + for thread in keyboard_threads: + thread.join(timeout=1.0) + keyboard_threads = [] + keyboard_devices = [] + +def window_monitor(): + global BongoWindow + last_window_state = None + + while not stop_event.is_set(): + new_window = find_game_window() + + if new_window and not last_window_state: + BongoWindow = new_window + print(f"\n✓ Connected to {TARGET_WINDOW_NAME} window (ID: {BongoWindow})") + print(" Starting keyboard monitoring...") + if start_keyboard_monitoring(): + print(" ✓ Keyboard monitoring started\n") + last_window_state = True + + elif not new_window and last_window_state: + print(f"\n⚠ {TARGET_WINDOW_NAME} window closed") + print(" Stopping keyboard monitoring...") + stop_keyboard_monitoring() + print(" ✓ Waiting for window to reappear...\n") + BongoWindow = None + last_window_state = False + + elif not new_window and last_window_state is None: + print(f"⚠ {TARGET_WINDOW_NAME} window not found") + print(" Waiting... (checks every 10s)\n") + BongoWindow = None + last_window_state = False + + elif new_window and last_window_state: + if BongoWindow != new_window: + BongoWindow = new_window + print(f"\n✓ Window ID updated (ID: {BongoWindow})\n") + + stop_event.wait(10) + +def main(): + print(f"BongoCatXP - TapTapLoot edition\n") + + def signal_handler(signum, frame): + print("\nStopping...") + stop_event.set() + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + xauth = get_xauth_path() + if xauth: + print(f"Using xauth: {xauth}") + else: + print("⚠ Warning: no taptaploot xauth file found in /tmp — is the game running?") + + print("Starting window monitor (Ctrl+C to stop)\n") + + sender_thread = threading.Thread(target=batch_sender, daemon=True) + sender_thread.start() + + monitor_thread = threading.Thread(target=window_monitor, daemon=True) + monitor_thread.start() + + try: + stop_event.wait() + finally: + print("Stopping keyboard monitoring...") + stop_keyboard_monitoring() + print("Done") + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + prog='bongocatxp', + description='BongoCat X11 Proxy - TapTapLoot edition' + ) + parser.add_argument('--version', action='version', version=f'BongoCatXP {__version__}') + args = parser.parse_args() + + if not shutil.which('xdotool'): + print("Error: xdotool not found — install with: sudo pacman -S xdotool") + exit(1) + + if not os.access('/dev/input/event0', os.R_OK): + print("No permission to read input devices.") + print("Run: sudo usermod -aG input $USER (then log out and back in)") + print(f"Or temporarily: sudo python3 {__file__}") + exit(1) + + try: + main() + except Exception as e: + print(f"\nError: {e}") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8386e0f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "taptaploot-forwarder" +version = "0.1.0" +description = "TapTapLoot X11 Forwarder - Forward keyboard input to TapTapLoot on Wayland via Xwayland" +readme = "README.md" +requires-python = ">=3.7" +license = {text = "MIT"} +authors = [ + {name = "TapTapLoot Forwarder Contributors"} +] +keywords = ["taptaploot", "x11", "keyboard", "wayland", "evdev", "xwayland"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: Utilities", +] + +dependencies = [ + "evdev>=1.6.0", +] + +[project.scripts] +taptaploot-forwarder = "main:main" + +[project.urls] +Repository = "https://gitea.example.com/yourname/TapTapLootForwarder" + +[tool.setuptools] +py-modules = ["main"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3b5570b --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +evdev==1.9.2