Initial commit
This commit is contained in:
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal 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
21
LICENSE
Normal 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
134
README.md
Normal 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
302
main.py
Normal 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
36
pyproject.toml
Normal 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
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
evdev==1.9.2
|
||||
Reference in New Issue
Block a user