Files
qiming/qiming-rcoder/docker/rcoder-agent-runner/ime_server.py
2026-06-01 13:54:52 +08:00

261 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
ime_server.py - 本地输入法透传服务
接收浏览器发送的文本,使用 xdotool 输入到当前焦点窗口
工作原理:
1. 用户在浏览器中使用本地输入法(如搜狗输入法)输入中文
2. 前端通过 WebSocket 发送完整的文本到此服务
3. 此服务使用 xdotool 将文本输入到远程桌面的当前焦点窗口
端口: 6091 (可通过 IME_PORT 环境变量配置)
"""
import asyncio
import json
import subprocess
import os
import signal
import sys
from datetime import datetime
# 尝试导入 websockets如果失败则提供友好提示
try:
from websockets.server import serve
from websockets.exceptions import ConnectionClosed
except ImportError:
print("[IME] Error: websockets module not found. Install with: pip3 install websockets")
sys.exit(1)
def log(level: str, message: str):
"""格式化日志输出"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"[{timestamp}] [{level}] [IME] {message}", flush=True)
def sanitize_text(text: str) -> str:
"""
清理文本,防止命令注入和异常字符
安全检查:
1. 长度限制 (1000 字符)
2. 过滤危险控制字符 (NULL, ESC)
3. 使用 -- 参数分隔符防止注入
Args:
text: 待验证的文本
Returns:
清理后的文本
Raises:
ValueError: 如果文本包含危险内容
"""
# 1. 长度限制
if len(text) > 1000:
raise ValueError("Text too long (max 1000 chars)")
# 2. 过滤危险控制字符
dangerous_chars = ['\x00', '\x1b'] # NULL 和 ESC
if any(c in text for c in dangerous_chars):
raise ValueError("Text contains dangerous control characters")
return text
def type_text_xdotool(text: str) -> tuple[bool, str]:
"""
使用 xdotool 输入文本
Args:
text: 要输入的文本
Returns:
(success, error_message)
"""
try:
env = {**os.environ, 'DISPLAY': ':0'}
# 使用 xdotool type 输入文本
# --clearmodifiers: 清除修饰键状态(避免 Ctrl/Alt 等键干扰)
# --delay: 每个字符之间的延迟(毫秒),帮助应用程序处理
result = subprocess.run(
['xdotool', 'type', '--clearmodifiers', '--delay', '10', '--', text],
env=env,
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
return True, ""
else:
return False, result.stderr.strip()
except subprocess.TimeoutExpired:
return False, "xdotool timeout"
except FileNotFoundError:
return False, "xdotool not found"
except Exception as e:
return False, str(e)
def type_text_clipboard(text: str) -> tuple[bool, str]:
"""
备用方案: 通过剪贴板粘贴文本
当 xdotool type 对某些字符不支持时使用
Args:
text: 要输入的文本
Returns:
(success, error_message)
"""
try:
env = {**os.environ, 'DISPLAY': ':0'}
# 1. 将文本写入剪贴板
process = subprocess.Popen(
['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE,
env=env
)
process.communicate(input=text.encode('utf-8'), timeout=5)
if process.returncode != 0:
return False, "Failed to copy to clipboard"
# 2. 模拟 Ctrl+V 粘贴
result = subprocess.run(
['xdotool', 'key', '--clearmodifiers', 'ctrl+v'],
env=env,
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
return True, ""
else:
return False, result.stderr.strip()
except subprocess.TimeoutExpired:
return False, "Clipboard operation timeout"
except FileNotFoundError as e:
return False, f"Required tool not found: {e}"
except Exception as e:
return False, str(e)
async def handle_client(websocket):
"""处理来自前端的 WebSocket 连接"""
client_addr = websocket.remote_address
log("INFO", f"Client connected: {client_addr}")
try:
async for message in websocket:
try:
data = json.loads(message)
msg_type = data.get('type', '')
if msg_type == 'text':
text = data.get('text', '')
method = data.get('method', 'xdotool') # 默认使用 xdotool
if not text:
await websocket.send(json.dumps({
'status': 'error',
'message': 'Empty text'
}))
continue
# 安全验证
try:
text = sanitize_text(text)
except ValueError as e:
log("WARN", f"Text validation failed: {e}")
await websocket.send(json.dumps({
'status': 'error',
'message': str(e)
}))
continue
# 根据方法选择输入方式
if method == 'clipboard':
success, error = type_text_clipboard(text)
else:
success, error = type_text_xdotool(text)
if success:
log("INFO", f"Typed ({method}): {text[:50]}{'...' if len(text) > 50 else ''}")
await websocket.send(json.dumps({'status': 'ok'}))
else:
log("ERROR", f"Failed to type: {error}")
await websocket.send(json.dumps({
'status': 'error',
'message': error
}))
elif msg_type == 'ping':
# 心跳检测
await websocket.send(json.dumps({'type': 'pong'}))
else:
log("WARN", f"Unknown message type: {msg_type}")
await websocket.send(json.dumps({
'status': 'error',
'message': f'Unknown type: {msg_type}'
}))
except json.JSONDecodeError:
log("ERROR", f"Invalid JSON: {message[:100]}")
await websocket.send(json.dumps({
'status': 'error',
'message': 'Invalid JSON'
}))
except ConnectionClosed:
log("INFO", f"Client disconnected: {client_addr}")
except Exception as e:
log("ERROR", f"Client error: {e}")
finally:
log("INFO", f"Connection closed: {client_addr}")
async def main():
"""主函数"""
port = int(os.environ.get('IME_PORT', 6091))
host = os.environ.get('IME_HOST', '0.0.0.0')
log("INFO", f"Starting IME input server on {host}:{port}...")
log("INFO", "Protocol: WebSocket")
log("INFO", "Message format: {\"type\": \"text\", \"text\": \"要输入的文本\"}")
# 优雅关闭处理
stop = asyncio.Event()
def signal_handler():
log("INFO", "Received shutdown signal")
stop.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, signal_handler)
async with serve(handle_client, host, port):
log("INFO", f"IME server is ready, listening on ws://{host}:{port}")
await stop.wait()
log("INFO", "IME server stopped")
if __name__ == '__main__':
try:
asyncio.run(main())
except KeyboardInterrupt:
log("INFO", "Server interrupted")
except Exception as e:
log("ERROR", f"Server error: {e}")
sys.exit(1)