Initial commit

This commit is contained in:
bepis
2026-04-23 12:45:49 +10:00
commit e94363eb9c
6 changed files with 531 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@@ -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

21
LICENSE Normal file
View File

@@ -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.

134
README.md Normal file
View File

@@ -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

302
main.py Normal file
View File

@@ -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}")

36
pyproject.toml Normal file
View File

@@ -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"]

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
evdev==1.9.2