feat(companion): add ultra-lightweight distributable PC host daemon and F13-F24 custom key mappings
- Added F13-F24 custom key mappings in WebUI dropdown and DOM_TO_HID map - Created pc_companion package with zero-CPU Win32 RegisterHotKey daemon (remotemapper_host.py) - Added JSON hot-reload config, autostart manager, silent background launcher, and PyInstaller builder - Ensured full portability with dynamic relative path resolution and Python environment resolution for distribution
This commit is contained in:
1 parent
686a7fcc7c
commit
44415fa442
14 files changed
+490
No files matched your search
@@ -0,0 +1,18 @@
|
||||
# RemoteMapper PC Companion (本地脚本扩展守护程序)
|
||||
|
||||
通过在 ESP32 WebUI 中将遥控器按键映射为 F13 ~ F24 无冲突按键,本程序可在电脑后台以 0.0% CPU 占用 实时监听并异步执行本地自定义脚本。
|
||||
|
||||
## 快速使用方法
|
||||
|
||||
### 第一步:在 WebUI 中将遥控器按键配置为 F13 ~ F24
|
||||
1. 电脑浏览器打开 WebUI (http://192.168.2.179 或 http://192.168.4.1);
|
||||
2. 点击想绑定的遥控器按键(例如 菜单键、电视键、电源键 或任意按键);
|
||||
3. 在弹出的高级下拉菜单中,选择 ??? 本地自定义脚本专用键 (F13 ~ F24 无冲突)(例如 F13 或 F14 等);
|
||||
4. 点击 保存映射。
|
||||
|
||||
### 第二步:配置你想执行的本地动作
|
||||
打开 pc_companion/config.json,根据需求修改或添加命令:
|
||||
随时修改保存 config.json,程序会自动热更新重新载入!
|
||||
|
||||
### 第三步:启动守护进程
|
||||
双击运行 start_companion.bat 即可!
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import sys
|
||||
import winreg
|
||||
import subprocess
|
||||
|
||||
REG_PATH = r"Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
APP_NAME = "RemoteMapperCompanion"
|
||||
|
||||
def get_pythonw_path():
|
||||
python_exe = sys.executable
|
||||
pythonw = os.path.join(os.path.dirname(python_exe), "pythonw.exe")
|
||||
if os.path.exists(pythonw):
|
||||
return pythonw
|
||||
return python_exe
|
||||
|
||||
def enable_autostart():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
host_script = os.path.join(script_dir, "remotemapper_host.py")
|
||||
pythonw = get_pythonw_path()
|
||||
|
||||
cmd = f'"{pythonw}" "{host_script}"'
|
||||
|
||||
# Write to HKCU Run
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE)
|
||||
winreg.SetValueEx(key, APP_NAME, 0, winreg.REG_SZ, cmd)
|
||||
winreg.CloseKey(key)
|
||||
|
||||
print("=" * 60)
|
||||
print("[OK] 成功设置开机自启动!")
|
||||
print(f" 自启目标: {cmd}")
|
||||
print(" 启动模式: 后台静默无弹窗运行 (pythonw.exe)")
|
||||
print("=" * 60)
|
||||
|
||||
# Launch immediately in background
|
||||
subprocess.Popen([pythonw, host_script], cwd=script_dir)
|
||||
print("[OK] 已在后台为您即刻启动静默守护进程!")
|
||||
|
||||
def disable_autostart():
|
||||
try:
|
||||
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, REG_PATH, 0, winreg.KEY_SET_VALUE)
|
||||
winreg.DeleteValue(key, APP_NAME)
|
||||
winreg.CloseKey(key)
|
||||
print("=" * 60)
|
||||
print("[OK] 已成功从开机自启动中移除!")
|
||||
print("=" * 60)
|
||||
except FileNotFoundError:
|
||||
print("[INFO] 当前未开启开机自启。")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] 移除失败: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) > 1 and sys.argv[1].lower() == "disable":
|
||||
disable_autostart()
|
||||
else:
|
||||
enable_autostart()
|
||||
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
echo 正在安装/检查 PyInstaller...
|
||||
python -m pip install pyinstaller
|
||||
echo 正在打包为无黑框独立 exe...
|
||||
pyinstaller --noconsole --onefile --name RemoteMapperHost remotemapper_host.py
|
||||
echo.
|
||||
echo [OK] 打包完成!独立免安装程序位于: dist\RemoteMapperHost.exe
|
||||
pause
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"F13": {
|
||||
"name": "打开计算器",
|
||||
"command": "calc.exe",
|
||||
"enabled": true
|
||||
},
|
||||
"F14": {
|
||||
"name": "打开浏览器网址",
|
||||
"command": "explorer https://gemini.google.com",
|
||||
"enabled": true
|
||||
},
|
||||
"F15": {
|
||||
"name": "打开记事本",
|
||||
"command": "notepad.exe",
|
||||
"enabled": true
|
||||
},
|
||||
"F16": {
|
||||
"name": "运行自定义 Python 任务",
|
||||
"command": "python scripts/demo_task.py",
|
||||
"enabled": true
|
||||
},
|
||||
"F17": {
|
||||
"name": "运行自定义批处理",
|
||||
"command": "scripts/demo_action.bat",
|
||||
"enabled": true
|
||||
},
|
||||
"F19": {
|
||||
"name": "自定义槽位 7",
|
||||
"command": "notepad.exe",
|
||||
"enabled": false
|
||||
},
|
||||
"F20": {
|
||||
"name": "自定义槽位 8",
|
||||
"command": "",
|
||||
"enabled": false
|
||||
},
|
||||
"F21": {
|
||||
"name": "自定义槽位 9",
|
||||
"command": "",
|
||||
"enabled": false
|
||||
},
|
||||
"F22": {
|
||||
"name": "自定义槽位 10",
|
||||
"command": "",
|
||||
"enabled": false
|
||||
},
|
||||
"F23": {
|
||||
"name": "自定义槽位 11",
|
||||
"command": "",
|
||||
"enabled": false
|
||||
},
|
||||
"F24": {
|
||||
"name": "自定义槽位 12",
|
||||
"command": "",
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python autostart_manager.py disable
|
||||
pause
|
||||
@@ -0,0 +1,4 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
python autostart_manager.py enable
|
||||
pause
|
||||
@@ -0,0 +1,52 @@
|
||||
; ==============================================================================
|
||||
; RemoteMapper AutoHotkey Companion (极致超轻量 2MB 方案)
|
||||
; ==============================================================================
|
||||
#NoEnv
|
||||
#SingleInstance Force
|
||||
SetWorkingDir %A_ScriptDir%
|
||||
|
||||
; F13 键 -> 打开计算器
|
||||
F13::
|
||||
Run, calc.exe
|
||||
return
|
||||
|
||||
; F14 键 -> 打开常用网址
|
||||
F14::
|
||||
Run, https://gemini.google.com
|
||||
return
|
||||
|
||||
; F15 键 -> 打开记事本
|
||||
F15::
|
||||
Run, notepad.exe
|
||||
return
|
||||
|
||||
; F16 键 -> 运行 Python 脚本
|
||||
F16::
|
||||
Run, python scripts/demo_task.py
|
||||
return
|
||||
|
||||
; F17 键 -> 运行批处理脚本
|
||||
F17::
|
||||
Run, scripts/demo_action.bat
|
||||
return
|
||||
|
||||
; F19 ~ F24 预留扩展槽位
|
||||
F19::
|
||||
; 在此添加您的自定义命令
|
||||
return
|
||||
|
||||
F20::
|
||||
; 在此添加您的自定义命令
|
||||
return
|
||||
|
||||
F21::
|
||||
return
|
||||
|
||||
F22::
|
||||
return
|
||||
|
||||
F23::
|
||||
return
|
||||
|
||||
F24::
|
||||
return
|
||||
@@ -0,0 +1,245 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RemoteMapper PC Companion Daemon
|
||||
================================
|
||||
Ultra-lightweight Windows background service for RemoteMapper ESP32.
|
||||
Listens for F13-F24 (and other virtual hotkeys) using native Win32 RegisterHotKey (0.0% CPU usage).
|
||||
Executes custom local scripts (.bat, .ps1, .py, apps, URLs) asynchronously.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import ctypes
|
||||
import subprocess
|
||||
import threading
|
||||
from ctypes import wintypes
|
||||
|
||||
# Ensure Windows environment
|
||||
if sys.platform != 'win32':
|
||||
print("[ERROR] RemoteMapper PC Companion only supports Windows.")
|
||||
sys.exit(1)
|
||||
|
||||
# Win32 Constants
|
||||
WM_HOTKEY = 0x0312
|
||||
MOD_NONE = 0x0000
|
||||
MOD_ALT = 0x0001
|
||||
MOD_CONTROL = 0x0002
|
||||
MOD_SHIFT = 0x0004
|
||||
MOD_WIN = 0x0008
|
||||
MOD_NOREPEAT = 0x4000
|
||||
|
||||
# Virtual Key Mapping for F13 - F24 and standard keys
|
||||
VK_MAP = {
|
||||
# High F-keys (F13 - F24: standard no-conflict keys)
|
||||
"F13": 0x7C, "F14": 0x7D, "F15": 0x7E, "F16": 0x7F,
|
||||
"F17": 0x80, "F18": 0x81, "F19": 0x82, "F20": 0x83,
|
||||
"F21": 0x84, "F22": 0x85, "F23": 0x86, "F24": 0x87,
|
||||
# Standard F-keys
|
||||
"F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73,
|
||||
"F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77,
|
||||
"F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B,
|
||||
# Special keys
|
||||
"PAUSE": 0x13, "SCROLLLOCK": 0x91, "PRINTSCREEN": 0x2C,
|
||||
"APPS": 0x5D, "SLEEP": 0x5F
|
||||
}
|
||||
|
||||
def get_app_dir():
|
||||
"""Get absolute directory path whether running from Python script or PyInstaller EXE."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
return os.path.dirname(os.path.abspath(sys.executable))
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
SCRIPT_DIR = get_app_dir()
|
||||
CONFIG_FILE = os.path.join(SCRIPT_DIR, "config.json")
|
||||
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
class RemoteMapperDaemon:
|
||||
def __init__(self, config_path=CONFIG_FILE):
|
||||
self.config_path = config_path
|
||||
self.config = {}
|
||||
self.hotkey_map = {} # id -> (key_name, config_entry)
|
||||
self.registered_ids = []
|
||||
self.last_config_mtime = 0
|
||||
self.running = True
|
||||
|
||||
def load_config(self):
|
||||
"""Load JSON configuration mapping hotkeys to local script actions."""
|
||||
if not os.path.exists(self.config_path):
|
||||
self.create_default_config()
|
||||
|
||||
try:
|
||||
with open(self.config_path, "r", encoding="utf-8") as f:
|
||||
self.config = json.load(f)
|
||||
self.last_config_mtime = os.path.getmtime(self.config_path)
|
||||
print(f"[CONFIG] Loaded {len(self.config)} hotkey action(s) from {os.path.basename(self.config_path)}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to load config.json: {e}")
|
||||
return False
|
||||
|
||||
def create_default_config(self):
|
||||
"""Create sample config.json if missing."""
|
||||
default_cfg = {
|
||||
"F13": {
|
||||
"name": "打开计算器",
|
||||
"command": "calc.exe",
|
||||
"enabled": True
|
||||
},
|
||||
"F14": {
|
||||
"name": "打开浏览器网址",
|
||||
"command": "explorer https://gemini.google.com",
|
||||
"enabled": True
|
||||
},
|
||||
"F15": {
|
||||
"name": "打开记事本",
|
||||
"command": "notepad.exe",
|
||||
"enabled": True
|
||||
},
|
||||
"F16": {
|
||||
"name": "运行自定义 Python 任务",
|
||||
"command": "python scripts/demo_task.py",
|
||||
"enabled": True
|
||||
},
|
||||
"F17": {
|
||||
"name": "运行自定义批处理",
|
||||
"command": "scripts/demo_action.bat",
|
||||
"enabled": True
|
||||
}
|
||||
}
|
||||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||||
json.dump(default_cfg, f, ensure_ascii=False, indent=4)
|
||||
print(f"[INIT] Created default template: {self.config_path}")
|
||||
|
||||
def register_all_hotkeys(self):
|
||||
"""Register all enabled hotkeys with Windows OS."""
|
||||
self.unregister_all_hotkeys()
|
||||
self.hotkey_map.clear()
|
||||
hotkey_id = 100
|
||||
|
||||
for key_str, entry in self.config.items():
|
||||
if not entry.get("enabled", True):
|
||||
continue
|
||||
|
||||
parts = [p.strip().upper() for p in key_str.split("+")]
|
||||
main_key = parts[-1]
|
||||
vk = VK_MAP.get(main_key)
|
||||
|
||||
if not vk:
|
||||
print(f"[WARN] Unknown key: '{main_key}' in '{key_str}' (skipped)")
|
||||
continue
|
||||
|
||||
mod = MOD_NOREPEAT
|
||||
for p in parts[:-1]:
|
||||
if p in ("CTRL", "CONTROL"): mod |= MOD_CONTROL
|
||||
elif p in ("ALT", "MENU"): mod |= MOD_ALT
|
||||
elif p in ("SHIFT",): mod |= MOD_SHIFT
|
||||
elif p in ("WIN", "WINDOWS", "SUPER"): mod |= MOD_WIN
|
||||
|
||||
res = user32.RegisterHotKey(None, hotkey_id, mod, vk)
|
||||
if res:
|
||||
self.hotkey_map[hotkey_id] = (key_str, entry)
|
||||
self.registered_ids.append(hotkey_id)
|
||||
action_name = entry.get("name", "未命名动作")
|
||||
print(f" [+] 成功监听: {key_str: <10} -> {action_name}")
|
||||
hotkey_id += 1
|
||||
else:
|
||||
err = kernel32.GetLastError()
|
||||
print(f" [-] 注册失败: {key_str} (可能已被其他程序占用, Win32 Error: {err})")
|
||||
|
||||
def unregister_all_hotkeys(self):
|
||||
"""Unregister previously registered hotkeys."""
|
||||
for hid in self.registered_ids:
|
||||
user32.UnregisterHotKey(None, hid)
|
||||
self.registered_ids.clear()
|
||||
|
||||
def execute_action(self, key_str, entry):
|
||||
"""Execute the configured command asynchronously with robust relative path and Python environment resolution."""
|
||||
name = entry.get("name", "未命名动作")
|
||||
cmd = entry.get("command", "").strip()
|
||||
|
||||
if not cmd:
|
||||
print(f"[TRIGGER] {key_str} 按下 -> 无配置命令")
|
||||
return
|
||||
|
||||
# 1. Expand {DIR} or {ROOT} placeholders
|
||||
cmd = cmd.replace("{DIR}", SCRIPT_DIR).replace("{ROOT}", SCRIPT_DIR)
|
||||
|
||||
# 2. Smart Python path resolution (works even if Python is not in system PATH on end-user PC)
|
||||
if cmd.startswith("python ") or cmd.startswith("pythonw "):
|
||||
py_bin = sys.executable
|
||||
if cmd.startswith("pythonw "):
|
||||
py_bin = py_bin.replace("python.exe", "pythonw.exe")
|
||||
cmd = f'"{py_bin}" ' + cmd.split(" ", 1)[1]
|
||||
|
||||
print(f"\n[TRIGGER] >>> 遥控器按下 {key_str} [{name}] -> 执行命令: {cmd}")
|
||||
|
||||
def _run():
|
||||
try:
|
||||
# Force working directory to SCRIPT_DIR so all relative paths (scripts/...) work anywhere
|
||||
subprocess.Popen(cmd, shell=True, cwd=SCRIPT_DIR)
|
||||
except Exception as ex:
|
||||
print(f"[ERROR] 执行失败 [{cmd}]: {ex}")
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
|
||||
def check_config_reload(self):
|
||||
"""Periodically check if config.json was edited by user and hot-reload."""
|
||||
try:
|
||||
if os.path.exists(self.config_path):
|
||||
mtime = os.path.getmtime(self.config_path)
|
||||
if mtime > self.last_config_mtime:
|
||||
print("\n[HOT-RELOAD] 检测到 config.json 已修改,正在重新载入配置...")
|
||||
if self.load_config():
|
||||
self.register_all_hotkeys()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
"""Main message loop with Windows event pump (0% CPU)."""
|
||||
print("=" * 60)
|
||||
print(" RemoteMapper PC Companion - 极简超轻量本地脚本扩展守护进程")
|
||||
print(f" 工作目录: {SCRIPT_DIR}")
|
||||
print("=" * 60)
|
||||
|
||||
self.load_config()
|
||||
self.register_all_hotkeys()
|
||||
|
||||
print("\n[READY] 正在后台监听遥控器快捷键 (0.0% CPU 极低开销)...")
|
||||
print("[TIP] 随时编辑 config.json 即可实时热更新,按 Ctrl+C 退出程序。\n")
|
||||
|
||||
msg = wintypes.MSG()
|
||||
last_check_time = time.time()
|
||||
|
||||
while self.running:
|
||||
# Check for config reload every 1.5s
|
||||
now = time.time()
|
||||
if now - last_check_time > 1.5:
|
||||
self.check_config_reload()
|
||||
last_check_time = now
|
||||
|
||||
# Process any available messages without busy looping
|
||||
while user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 1): # PM_REMOVE = 1
|
||||
if msg.message == WM_HOTKEY:
|
||||
hid = msg.wParam
|
||||
if hid in self.hotkey_map:
|
||||
key_str, entry = self.hotkey_map[hid]
|
||||
self.execute_action(key_str, entry)
|
||||
user32.TranslateMessage(ctypes.byref(msg))
|
||||
user32.DispatchMessageW(ctypes.byref(msg))
|
||||
|
||||
# Sleep 30ms to prevent CPU spin
|
||||
time.sleep(0.03)
|
||||
|
||||
self.unregister_all_hotkeys()
|
||||
|
||||
if __name__ == "__main__":
|
||||
daemon = RemoteMapperDaemon()
|
||||
try:
|
||||
daemon.run()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[EXIT] RemoteMapper PC Companion 已退出。")
|
||||
daemon.unregister_all_hotkeys()
|
||||
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
echo ===================================================
|
||||
echo [RemoteMapper] Triggered local batch script!
|
||||
echo Time: %date% %time%
|
||||
echo ===================================================
|
||||
timeout /t 3
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import datetime
|
||||
|
||||
print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] RemoteMapper Python 自定义脚本触发成功!")
|
||||
# 您可以在这里编写任意自动化逻辑,例如调取大模型 API、操控软件、发送网络请求等
|
||||
@@ -0,0 +1,3 @@
|
||||
# PowerShell 示例脚本
|
||||
Write-Host "[RemoteMapper] 触发 PowerShell 脚本" -ForegroundColor Cyan
|
||||
[System.Media.SystemSounds]::Asterisk.Play()
|
||||
@@ -0,0 +1,2 @@
|
||||
Set WshShell = CreateObject("WScript.Shell")
|
||||
WshShell.Run "pythonw.exe "" & Replace(WScript.ScriptFullName, "silent_start.vbs", "remotemapper_host.py") & "", 0, False
|
||||
@@ -0,0 +1,13 @@
|
||||
@echo off
|
||||
cd /d "%~dp0"
|
||||
cls
|
||||
echo ========================================================
|
||||
echo RemoteMapper PC Companion Service
|
||||
echo ========================================================
|
||||
echo.
|
||||
python remotemapper_host.py
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo [ERROR] Python script exited with error.
|
||||
pause
|
||||
)
|
||||
@@ -434,6 +434,20 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
||||
<option value="k:0:0x44">F11 (全屏)</option>
|
||||
<option value="k:0:0x45">F12 (开发者工具)</option>
|
||||
</optgroup>
|
||||
<optgroup label="🖥️ 本地自定义脚本专用键 (F13 ~ F24 无冲突)">
|
||||
<option value="k:0:0x68">F13 (触发脚本 1)</option>
|
||||
<option value="k:0:0x69">F14 (触发脚本 2)</option>
|
||||
<option value="k:0:0x6A">F15 (触发脚本 3)</option>
|
||||
<option value="k:0:0x6B">F16 (触发脚本 4)</option>
|
||||
<option value="k:0:0x6C">F17 (触发脚本 5)</option>
|
||||
<option value="k:0:0x6D">F18 (触发脚本 6)</option>
|
||||
<option value="k:0:0x6E">F19 (触发脚本 7)</option>
|
||||
<option value="k:0:0x6F">F20 (触发脚本 8)</option>
|
||||
<option value="k:0:0x70">F21 (触发脚本 9)</option>
|
||||
<option value="k:0:0x71">F22 (触发脚本 10)</option>
|
||||
<option value="k:0:0x72">F23 (触发脚本 11)</option>
|
||||
<option value="k:0:0x73">F24 (触发脚本 12)</option>
|
||||
</optgroup>
|
||||
<optgroup label="常用快捷组合键">
|
||||
<option value="k:0x04:0x36">Alt + , (豆包/AI语音助手)</option>
|
||||
<option value="k:0x08:0x0B">Win + H (Windows语音听写)</option>
|
||||
@@ -524,6 +538,8 @@ const char INDEX_HTML[] PROGMEM = R"rawliteral(
|
||||
'Comma': 0x36, 'Period': 0x37, 'Slash': 0x38, 'CapsLock': 0x39,
|
||||
'F1': 0x3A, 'F2': 0x3B, 'F3': 0x3C, 'F4': 0x3D, 'F5': 0x3E, 'F6': 0x3F,
|
||||
'F7': 0x40, 'F8': 0x41, 'F9': 0x42, 'F10': 0x43, 'F11': 0x44, 'F12': 0x45,
|
||||
'F13': 0x68, 'F14': 0x69, 'F15': 0x6A, 'F16': 0x6B, 'F17': 0x6C, 'F18': 0x6D,
|
||||
'F19': 0x6E, 'F20': 0x6F, 'F21': 0x70, 'F22': 0x71, 'F23': 0x72, 'F24': 0x73,
|
||||
'PrintScreen': 0x46, 'ScrollLock': 0x47, 'Pause': 0x48, 'Insert': 0x49,
|
||||
'Home': 0x4A, 'PageUp': 0x4B, 'Delete': 0x4C, 'End': 0x4D, 'PageDown': 0x4E,
|
||||
'ArrowRight': 0x4F, 'ArrowLeft': 0x50, 'ArrowDown': 0x51, 'ArrowUp': 0x52
|
||||
|
||||
Reference in new issue
Block a user