Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- #!/usr/bin/env python3
- """
- VTTGrabber v4.04 - Stable USB Input Edition
- - Adds scroll wheel zoom and middle-click pan for Foundry VTT
- - Atomic message IDs for reliable CDP communication
- - Retains all v4.03 functionality: strict USB filtering, modifier keys, device selection
- """
- import asyncio
- import evdev
- from evdev import ecodes, InputDevice
- import json
- import os
- import signal
- import sys
- import urllib.request
- import websockets
- from pathlib import Path
- CONFIG_FILE = Path.home() / '.vttgrabber.json'
- CDP_URL = "ws://localhost:9222/devtools/browser"
- SCREEN_WIDTH = 1920
- SCREEN_HEIGHT = 1080
- class DeviceScanner:
- """Strict USB device discovery with ASUS hardware blacklist"""
- BUILT_IN_PATTERNS = ['AT Translated', 'Lid Switch', 'Power Button', 'Sleep Button']
- def is_forbidden(self, device):
- name = device.name.lower()
- info = device.info
- if info.vendor == 0x0b05 and info.product == 0x19b6:
- return True, 'ASUS built-in hardware (Hardcoded block)'
- if 'asus' in name and 'keyboard' in name:
- return True, 'ASUS built-in hardware (Name block)'
- if any(p.lower() in name for p in self.BUILT_IN_PATTERNS):
- return True, 'System built-in'
- return False, ''
- def is_usb(self, device):
- return device.phys and 'usb' in device.phys.lower()
- def get_all_devices(self):
- devices = {'remotes': [], 'keyboards': [], 'mice': [], 'forbidden': []}
- for path in evdev.list_devices():
- try:
- dev = InputDevice(path)
- caps = dev.capabilities()
- device_info = {
- 'name': dev.name,
- 'path': path,
- 'usb_path': self._get_usb_path(path)
- }
- forbidden, reason = self.is_forbidden(dev)
- if forbidden:
- device_info['reason'] = reason
- devices['forbidden'].append(device_info)
- continue
- if not self.is_usb(dev):
- continue
- # Hardware heuristics: mice have REL_X, keyboards have >20 keys
- keys = caps.get(ecodes.EV_KEY, [])
- rel_axes = caps.get(ecodes.EV_REL, [])
- abs_axes = caps.get(ecodes.EV_ABS, [])
- is_mouse = ecodes.REL_X in rel_axes or ecodes.ABS_X in abs_axes
- is_keyboard = len(keys) > 20
- # Combo devices (wireless dongles) register as both
- if is_mouse and is_keyboard:
- devices['remotes'].append(device_info)
- devices['keyboards'].append(device_info)
- devices['mice'].append(device_info)
- elif is_mouse:
- devices['mice'].append(device_info)
- elif is_keyboard:
- devices['keyboards'].append(device_info)
- except (PermissionError, OSError):
- continue
- return devices
- def _get_usb_path(self, path):
- try:
- real = os.path.realpath(path)
- parts = real.split('/')
- for i, part in enumerate(parts):
- if 'usb' in part.lower():
- return '/'.join(parts[:i+2])
- except:
- pass
- return path
- def find_combo_devices(self, devices):
- usb_groups = {}
- for dev in devices:
- usb = dev.get('usb_path', dev['path'])
- usb_groups.setdefault(usb, []).append(dev)
- combos = []
- for usb_path, devs in usb_groups.items():
- if len(devs) > 1:
- combos.append({
- 'name': devs[0]['name'],
- 'path': devs[0]['path'],
- 'usb_path': usb_path,
- 'interfaces': devs
- })
- return combos
- def prompt_device_selection(self, devices_list, device_type, forbidden_list=None, saved_path=None):
- if not devices_list:
- print(f"\nNo USB {device_type} devices found.")
- return None
- print(f"\n--- Select USB {device_type.upper()} (Enter to skip) ---")
- for i, dev in enumerate(devices_list, 1):
- marker = " [saved]" if dev['path'] == saved_path else ""
- print(f" [{i}] {dev['name']}{marker}")
- print(f" {dev['path']}")
- if forbidden_list:
- print(f"\n Forbidden/Blocked Hardware:")
- for dev in forbidden_list:
- print(f" - {dev['name']} ({dev['reason']})")
- choice = input(f"\nEnter 1-{len(devices_list)} or press Enter to skip: ").strip()
- if choice.isdigit():
- idx = int(choice) - 1
- if 0 <= idx < len(devices_list):
- return devices_list[idx]['path']
- if saved_path:
- print(f"Using saved: {saved_path}")
- return saved_path
- return None
- class VTTGrabber:
- def __init__(self):
- self.devices = {}
- self.ws = None
- self.target_id = None
- self.running = False
- self.msg_counter = 0 # Atomic message ID counter
- self.mouse_x = SCREEN_WIDTH // 2
- self.mouse_y = SCREEN_HEIGHT // 2
- self.scroll_accumulator = 0 # For smooth scroll handling
- self.shift_pressed = False
- self.ctrl_pressed = False
- self.alt_pressed = False
- self.meta_pressed = False
- def load_saved(self):
- if CONFIG_FILE.exists():
- try:
- return json.load(open(CONFIG_FILE))
- except:
- pass
- return {}
- def save_config(self, remote, keyboard, mouse):
- try:
- json.dump({'remote': remote, 'keyboard': keyboard, 'mouse': mouse}, open(CONFIG_FILE, 'w'))
- except:
- pass
- async def select_and_grab_devices(self):
- scanner = DeviceScanner()
- devices = scanner.get_all_devices()
- saved = self.load_saved()
- all_remotes = devices['remotes'].copy()
- all_mice = devices['mice'].copy()
- combos = scanner.find_combo_devices(devices['keyboards'])
- for combo in combos:
- if combo not in all_remotes:
- all_remotes.append(combo)
- remote_path = scanner.prompt_device_selection(all_remotes, 'remote combo/trackpad', saved_path=saved.get('remote'))
- keyboard_path = scanner.prompt_device_selection(devices['keyboards'], 'keyboard', forbidden_list=devices['forbidden'], saved_path=saved.get('keyboard'))
- mouse_path = scanner.prompt_device_selection(all_mice, 'mouse/trackpad', saved_path=saved.get('mouse'))
- self.save_config(remote_path, keyboard_path, mouse_path)
- paths = {'remote': remote_path, 'keyboard': keyboard_path, 'mouse': mouse_path}
- for name, path in paths.items():
- if path:
- try:
- dev = InputDevice(path)
- dev.grab()
- self.devices[name] = dev
- print(f"Opened and grabbed {name}: {dev.name}")
- except Exception as e:
- print(f"Failed to open {name}: {e}. Run with sudo.")
- return len(self.devices) > 0
- async def find_chromium_target(self):
- try:
- req = urllib.request.Request("http://localhost:9222/json", headers={"Host": "localhost:9222"})
- with urllib.request.urlopen(req, timeout=5) as response:
- targets = json.loads(response.read().decode())
- for target in targets:
- if target["type"] == "page":
- self.target_id = target["id"]
- print(f"Found target: {target.get('title', 'Unknown')}")
- return target["webSocketDebuggerUrl"]
- except Exception as e:
- print(f"Error finding Chromium target: {e}")
- return None
- async def connect_cdp(self):
- ws_url = await self.find_chromium_target()
- if not ws_url:
- raise ConnectionError("No Chromium target found. Is Chrome running with --remote-debugging-port=9222?")
- self.ws = await websockets.connect(ws_url, ping_interval=None)
- await self.send_cdp("Input.enable", {})
- await self.send_cdp("Runtime.enable", {})
- asyncio.create_task(self._drain_ws())
- async def _drain_ws(self):
- """Background task to prevent WebSocket buffer overflow"""
- try:
- while self.running and self.ws:
- try:
- await asyncio.wait_for(self.ws.recv(), timeout=0.1)
- except asyncio.TimeoutError:
- continue
- except Exception:
- break
- except Exception:
- pass
- async def send_cdp(self, method, params):
- """Send CDP command with atomic message ID"""
- if not self.ws:
- return
- self.msg_counter += 1
- msg = {"id": self.msg_counter, "method": method, "params": params}
- try:
- await self.ws.send(json.dumps(msg))
- except Exception:
- pass
- async def inject_cursor(self):
- """Inject synthetic cursor for visibility"""
- cursor_script = """
- (function() {
- const existing = document.getElementById('vtt-cursor');
- if (existing) existing.remove();
- const c = document.createElement('div');
- c.id = 'vtt-cursor';
- c.style.cssText = `position: fixed; width: 24px; height: 24px; background: radial-gradient(circle, rgba(255,0,85,0.9) 0%, rgba(255,0,85,0.6) 40%, rgba(255,0,85,0) 70%); border: 3px solid #ff0055; border-radius: 50%; pointer-events: none; z-index: 2147483647; left: 0; top: 0; box-shadow: 0 0 10px #ff0055, 0 0 20px #ff0055; transition: transform 0.05s ease-out;`;
- document.body.appendChild(c);
- window.vttCursor = c;
- window.updateVTTCursor = function(x, y) { if (window.vttCursor) window.vttCursor.style.transform = `translate(${x}px, ${y}px)`; };
- })();
- """
- await self.send_cdp("Runtime.evaluate", {"expression": cursor_script, "awaitPromise": False})
- async def _get_modifiers(self):
- """Calculate modifier bitmask for CDP"""
- modifiers = 0
- if self.alt_pressed:
- modifiers |= 1
- if self.ctrl_pressed:
- modifiers |= 2
- if self.meta_pressed:
- modifiers |= 4
- if self.shift_pressed:
- modifiers |= 8
- return modifiers
- # =========================================================================
- # MOUSE HANDLING - Added scroll wheel and middle-click
- # =========================================================================
- async def route_mouse_device(self, device):
- """Handle mouse movement, buttons, and scroll"""
- async for event in device.async_read_loop():
- if not self.running:
- break
- # Mouse movement
- if event.type == ecodes.EV_REL:
- if event.code == ecodes.REL_X:
- self.mouse_x = max(0, min(SCREEN_WIDTH - 1, self.mouse_x + event.value))
- elif event.code == ecodes.REL_Y:
- self.mouse_y = max(0, min(SCREEN_HEIGHT - 1, self.mouse_y + event.value))
- await self.send_cdp("Input.dispatchMouseEvent", {
- "type": "mouseMoved",
- "x": self.mouse_x,
- "y": self.mouse_y,
- "modifiers": await self._get_modifiers()
- })
- # Update synthetic cursor position
- await self.send_cdp("Runtime.evaluate", {
- "expression": f"if(window.updateVTTCursor) window.updateVTTCursor({self.mouse_x},{self.mouse_y});",
- "awaitPromise": False
- })
- # Scroll wheel (NEW in v4.04)
- elif event.type == ecodes.EV_REL and event.code == ecodes.REL_WHEEL:
- self.scroll_accumulator += event.value
- if abs(self.scroll_accumulator) >= 1:
- await self.send_cdp("Input.dispatchMouseEvent", {
- "type": "mouseWheel",
- "x": self.mouse_x,
- "y": self.mouse_y,
- "deltaX": 0,
- "deltaY": int(self.scroll_accumulator * -50), # Negative for natural scroll
- "modifiers": await self._get_modifiers()
- })
- self.scroll_accumulator = 0
- # Mouse buttons (NEW: added middle click 274)
- elif event.type == ecodes.EV_KEY:
- if event.code in (272, 273, 274): # 272=Left, 273=Right, 274=Middle
- button_map = {272: "left", 273: "right", 274: "middle"}
- button = button_map[event.code]
- action = "mousePressed" if event.value == 1 else "mouseReleased"
- await self.send_cdp("Input.dispatchMouseEvent", {
- "type": action,
- "x": self.mouse_x,
- "y": self.mouse_y,
- "button": button,
- "clickCount": 1,
- "modifiers": await self._get_modifiers()
- })
- # =========================================================================
- # KEYBOARD HANDLING - Unchanged from v4.03 (proven working)
- # =========================================================================
- def _parse_dynamic_key(self, keycode):
- """Extended dynamic parser for VTT functional keys"""
- key_name = ecodes.KEY.get(keycode)
- if not key_name:
- return None, None, None
- if isinstance(key_name, list):
- key_name = key_name[0]
- base = key_name.replace('KEY_', '')
- # Standard Alphanumeric
- if len(base) == 1 and base.isalpha():
- return (base if self.shift_pressed else base.lower(), f"Key{base}", base if self.shift_pressed else base.lower())
- if len(base) == 1 and base.isdigit():
- symbols = {'1':'!', '2':'@', '3':'#', '4':'$', '5':'%', '6':'^', '7':'&', '8':'*', '9':'(', '0':')'}
- return (symbols[base] if self.shift_pressed else base, f"Digit{base}", symbols[base] if self.shift_pressed else base)
- # Expanded Functional Mapping
- specials = {
- 'SPACE': (' ', 'Space', ' '),
- 'ENTER': ('\r', 'Enter', None),
- 'KPENTER': ('\r', 'NumpadEnter', None),
- 'BACKSPACE': ('Backspace', 'Backspace', None),
- 'DELETE': ('Delete', 'Delete', None),
- 'PAGEUP': ('PageUp', 'PageUp', None),
- 'PAGEDOWN': ('PageDown', 'PageDown', None),
- 'UP': ('ArrowUp', 'ArrowUp', None),
- 'DOWN': ('ArrowDown', 'ArrowDown', None),
- 'LEFT': ('ArrowLeft', 'ArrowLeft', None),
- 'RIGHT': ('ArrowRight', 'ArrowRight', None),
- 'MINUS': ('_' if self.shift_pressed else '-', 'Minus', '_' if self.shift_pressed else '-'),
- 'EQUAL': ('+' if self.shift_pressed else '=', 'Equal', '+' if self.shift_pressed else '='),
- 'SLASH': ('?' if self.shift_pressed else '/', 'Slash', '?' if self.shift_pressed else '/'),
- 'DOT': ('>' if self.shift_pressed else '.', 'Period', '>' if self.shift_pressed else '.'),
- 'COMMA': ('<' if self.shift_pressed else ',', 'Comma', '<' if self.shift_pressed else ','),
- 'ESC': ('Escape', 'Escape', None)
- }
- return specials.get(base, (None, None, None))
- async def _send_modifier_keys(self, keycode, value):
- """Send modifier key state changes"""
- mod_map = {
- ecodes.KEY_LEFTSHIFT: ("Shift", "ShiftLeft"),
- ecodes.KEY_RIGHTSHIFT: ("Shift", "ShiftRight"),
- ecodes.KEY_LEFTCTRL: ("Control", "ControlLeft"),
- ecodes.KEY_RIGHTCTRL: ("Control", "ControlRight"),
- ecodes.KEY_LEFTALT: ("Alt", "AltLeft"),
- ecodes.KEY_RIGHTALT: ("Alt", "AltRight"),
- }
- if keycode in mod_map:
- key, code = mod_map[keycode]
- await self.send_cdp("Input.dispatchKeyEvent", {
- "type": "keyDown" if value > 0 else "keyUp",
- "key": key,
- "code": code,
- "modifiers": await self._get_modifiers()
- })
- async def route_keyboard_device(self, device):
- """Handle keyboard input with strict event type checking"""
- async for event in device.async_read_loop():
- if not self.running or event.type != ecodes.EV_KEY:
- continue
- keycode, value = event.code, event.value
- # Update modifier state
- if keycode in (ecodes.KEY_LEFTSHIFT, ecodes.KEY_RIGHTSHIFT):
- self.shift_pressed = value > 0
- await self._send_modifier_keys(keycode, value)
- continue
- elif keycode in (ecodes.KEY_LEFTCTRL, ecodes.KEY_RIGHTCTRL):
- self.ctrl_pressed = value > 0
- await self._send_modifier_keys(keycode, value)
- continue
- elif keycode in (ecodes.KEY_LEFTALT, ecodes.KEY_RIGHTALT):
- self.alt_pressed = value > 0
- await self._send_modifier_keys(keycode, value)
- continue
- # Special key handling
- if keycode == ecodes.KEY_TAB and value == 1:
- await self.send_cdp("Runtime.evaluate", {
- "expression": "const evt = new KeyboardEvent('keydown', {key: 'Tab', code: 'Tab', keyCode: 9, which: 9, bubbles: true}); (document.activeElement || document.body).dispatchEvent(evt);"
- })
- continue
- if keycode == ecodes.KEY_ESC and value == 1:
- await self.send_cdp("Runtime.evaluate", {
- "expression": "document.querySelectorAll('.window-header .close, #chat-controls .close').forEach(b => b.click()); const c = document.querySelector('canvas#board'); if(c)c.focus(); document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', code: 'Escape', keyCode: 27, which: 27, bubbles: true}));"
- })
- continue
- # Regular key handling
- display_key, code, text_char = self._parse_dynamic_key(keycode)
- if not display_key:
- continue
- if value in (1, 2): # Key down or repeat
- await self.send_cdp("Input.dispatchKeyEvent", {
- "type": "keyDown",
- "key": display_key,
- "code": code,
- "modifiers": await self._get_modifiers()
- })
- if value == 1 and text_char: # Only send char on initial press
- await self.send_cdp("Input.dispatchKeyEvent", {
- "type": "char",
- "text": text_char,
- "modifiers": await self._get_modifiers()
- })
- elif value == 0: # Key up
- await self.send_cdp("Input.dispatchKeyEvent", {
- "type": "keyUp",
- "key": display_key,
- "code": code,
- "modifiers": await self._get_modifiers()
- })
- # =========================================================================
- # MAIN
- # =========================================================================
- async def run(self):
- self.running = True
- if not await self.select_and_grab_devices():
- print("No USB devices selected. Exiting.")
- return
- print("\nConnecting to Chromium...")
- await self.connect_cdp()
- await self.inject_cursor()
- # Create tasks for all grabbed devices
- tasks = []
- for name, device in self.devices.items():
- if 'mouse' in name or 'remote' in name:
- tasks.append(asyncio.create_task(self.route_mouse_device(device)))
- if 'keyboard' in name or 'remote' in name:
- tasks.append(asyncio.create_task(self.route_keyboard_device(device)))
- await asyncio.gather(*tasks)
- def cleanup(self):
- """Release all grabbed devices"""
- self.running = False
- for name, device in self.devices.items():
- try:
- device.ungrab()
- print(f"Released {name}")
- except:
- pass
- if __name__ == "__main__":
- grabber = VTTGrabber()
- # Signal handling for clean shutdown
- def signal_handler(signum, frame):
- print(f"\nReceived signal {signum}, shutting down...")
- grabber.cleanup()
- sys.exit(0)
- signal.signal(signal.SIGINT, signal_handler)
- signal.signal(signal.SIGTERM, signal_handler)
- try:
- asyncio.run(grabber.run())
- except (KeyboardInterrupt, Exception) as e:
- print(f"Error: {e}")
- grabber.cleanup()
Advertisement