316 lines
9.2 KiB
JavaScript
316 lines
9.2 KiB
JavaScript
/**
|
||
* ime_passthrough.js - 本地输入法透传前端脚本
|
||
*
|
||
* 使用方法:
|
||
* 1. 将此文件放到 noVNC 目录中
|
||
* 2. 在 vnc.html 的 </body> 前添加:
|
||
* <script src="ime_passthrough.js"></script>
|
||
*
|
||
* 工作原理:
|
||
* 1. 创建一个隐藏的输入框,用于捕获用户的输入法输入
|
||
* 2. 当用户点击 VNC 画面时,焦点切换到隐藏输入框
|
||
* 3. 用户使用本地输入法(如搜狗输入法)输入中文
|
||
* 4. 输入完成后,通过 WebSocket 发送到 IME 服务器
|
||
* 5. 服务器使用 xdotool 将文本输入到远程桌面
|
||
*/
|
||
|
||
(function () {
|
||
'use strict';
|
||
|
||
// 配置
|
||
const CONFIG = {
|
||
// IME 服务端口(与 ime_server.py 中的端口一致)
|
||
IME_PORT: 6091,
|
||
// 重连间隔(毫秒)
|
||
RECONNECT_INTERVAL: 3000,
|
||
// 心跳间隔(毫秒)
|
||
HEARTBEAT_INTERVAL: 30000,
|
||
// 是否启用调试日志
|
||
DEBUG: false
|
||
};
|
||
|
||
// 日志函数
|
||
function log(level, message) {
|
||
if (level === 'DEBUG' && !CONFIG.DEBUG) return;
|
||
const timestamp = new Date().toISOString();
|
||
console.log(`[${timestamp}] [${level}] [IME] ${message}`);
|
||
}
|
||
|
||
// 状态变量
|
||
let imeWebSocket = null;
|
||
let imeInput = null;
|
||
let isComposing = false;
|
||
let reconnectTimer = null;
|
||
let heartbeatTimer = null;
|
||
|
||
/**
|
||
* 创建隐藏的输入框
|
||
*/
|
||
function createImeInput() {
|
||
if (imeInput) return imeInput;
|
||
|
||
imeInput = document.createElement('textarea');
|
||
imeInput.id = 'ime-capture';
|
||
imeInput.setAttribute('autocomplete', 'off');
|
||
imeInput.setAttribute('autocorrect', 'off');
|
||
imeInput.setAttribute('autocapitalize', 'off');
|
||
imeInput.setAttribute('spellcheck', 'false');
|
||
imeInput.style.cssText = `
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: 50%;
|
||
width: 1px;
|
||
height: 1px;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
z-index: -1;
|
||
`;
|
||
|
||
document.body.appendChild(imeInput);
|
||
|
||
// 监听输入法事件
|
||
imeInput.addEventListener('compositionstart', onCompositionStart);
|
||
imeInput.addEventListener('compositionend', onCompositionEnd);
|
||
imeInput.addEventListener('input', onInput);
|
||
imeInput.addEventListener('keydown', onKeyDown);
|
||
|
||
log('INFO', 'IME input element created');
|
||
return imeInput;
|
||
}
|
||
|
||
/**
|
||
* 连接到 IME WebSocket 服务
|
||
*/
|
||
function connectWebSocket() {
|
||
if (imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
|
||
// 构建 WebSocket URL(使用当前页面的主机名)
|
||
const wsUrl = `ws://${location.hostname}:${CONFIG.IME_PORT}`;
|
||
log('INFO', `Connecting to IME server: ${wsUrl}`);
|
||
|
||
try {
|
||
imeWebSocket = new WebSocket(wsUrl);
|
||
|
||
imeWebSocket.onopen = function () {
|
||
log('INFO', 'Connected to IME server');
|
||
clearReconnectTimer();
|
||
startHeartbeat();
|
||
};
|
||
|
||
imeWebSocket.onclose = function (event) {
|
||
log('INFO', `Disconnected from IME server (code: ${event.code})`);
|
||
stopHeartbeat();
|
||
scheduleReconnect();
|
||
};
|
||
|
||
imeWebSocket.onerror = function (error) {
|
||
log('ERROR', 'WebSocket error');
|
||
// onclose 会自动触发
|
||
};
|
||
|
||
imeWebSocket.onmessage = function (event) {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
if (data.status === 'ok') {
|
||
log('DEBUG', 'Text sent successfully');
|
||
} else if (data.status === 'error') {
|
||
log('ERROR', `Server error: ${data.message}`);
|
||
} else if (data.type === 'pong') {
|
||
log('DEBUG', 'Heartbeat pong received');
|
||
}
|
||
} catch (e) {
|
||
log('ERROR', `Failed to parse response: ${e}`);
|
||
}
|
||
};
|
||
} catch (e) {
|
||
log('ERROR', `Failed to connect: ${e}`);
|
||
scheduleReconnect();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送文本到 IME 服务器
|
||
*/
|
||
function sendText(text, method = 'xdotool') {
|
||
if (!text) return;
|
||
|
||
if (!imeWebSocket || imeWebSocket.readyState !== WebSocket.OPEN) {
|
||
log('WARN', 'WebSocket not connected, text not sent');
|
||
return;
|
||
}
|
||
|
||
const message = JSON.stringify({
|
||
type: 'text',
|
||
text: text,
|
||
method: method
|
||
});
|
||
|
||
try {
|
||
imeWebSocket.send(message);
|
||
log('DEBUG', `Sent text: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`);
|
||
} catch (e) {
|
||
log('ERROR', `Failed to send text: ${e}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 输入法开始组合
|
||
*/
|
||
function onCompositionStart(e) {
|
||
isComposing = true;
|
||
log('DEBUG', 'Composition started');
|
||
}
|
||
|
||
/**
|
||
* 输入法完成组合
|
||
*/
|
||
function onCompositionEnd(e) {
|
||
isComposing = false;
|
||
const text = e.data;
|
||
|
||
if (text) {
|
||
log('DEBUG', `Composition ended: ${text}`);
|
||
sendText(text);
|
||
}
|
||
|
||
// 清空输入框
|
||
setTimeout(() => {
|
||
if (imeInput) imeInput.value = '';
|
||
}, 10);
|
||
}
|
||
|
||
/**
|
||
* 普通输入(非输入法)
|
||
*/
|
||
function onInput(e) {
|
||
// 如果正在使用输入法组合,不处理
|
||
if (isComposing) return;
|
||
|
||
const text = imeInput.value;
|
||
if (text) {
|
||
log('DEBUG', `Direct input: ${text}`);
|
||
sendText(text);
|
||
imeInput.value = '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 键盘按下事件
|
||
* 用于处理特殊键(如回车、退格等)
|
||
*/
|
||
function onKeyDown(e) {
|
||
// 如果正在使用输入法组合,让输入法处理
|
||
if (isComposing) return;
|
||
|
||
// 这些键由 VNC 处理,不需要 IME 透传
|
||
// 用户可以点击 VNC 画面来让 VNC 处理键盘
|
||
}
|
||
|
||
/**
|
||
* 定时重连
|
||
*/
|
||
function scheduleReconnect() {
|
||
if (reconnectTimer) return;
|
||
|
||
log('INFO', `Will reconnect in ${CONFIG.RECONNECT_INTERVAL}ms`);
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connectWebSocket();
|
||
}, CONFIG.RECONNECT_INTERVAL);
|
||
}
|
||
|
||
function clearReconnectTimer() {
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 心跳保活
|
||
*/
|
||
function startHeartbeat() {
|
||
stopHeartbeat();
|
||
heartbeatTimer = setInterval(() => {
|
||
if (imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN) {
|
||
try {
|
||
imeWebSocket.send(JSON.stringify({ type: 'ping' }));
|
||
log('DEBUG', 'Heartbeat ping sent');
|
||
} catch (e) {
|
||
log('ERROR', `Heartbeat failed: ${e}`);
|
||
}
|
||
}
|
||
}, CONFIG.HEARTBEAT_INTERVAL);
|
||
}
|
||
|
||
function stopHeartbeat() {
|
||
if (heartbeatTimer) {
|
||
clearInterval(heartbeatTimer);
|
||
heartbeatTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化 IME 透传功能
|
||
*/
|
||
function init() {
|
||
log('INFO', 'Initializing IME passthrough...');
|
||
|
||
// 创建隐藏输入框
|
||
createImeInput();
|
||
|
||
// 连接 WebSocket
|
||
connectWebSocket();
|
||
|
||
// 监听 VNC 画面点击,切换焦点到隐藏输入框
|
||
// 需要找到 noVNC 的 screen 元素
|
||
const setupClickHandler = () => {
|
||
// noVNC 可能使用不同的元素 ID
|
||
const vncScreen = document.getElementById('screen') ||
|
||
document.querySelector('canvas') ||
|
||
document.querySelector('.noVNC_canvas');
|
||
|
||
if (vncScreen) {
|
||
vncScreen.addEventListener('click', () => {
|
||
if (imeInput) {
|
||
imeInput.focus();
|
||
log('DEBUG', 'IME input focused');
|
||
}
|
||
});
|
||
log('INFO', 'VNC screen click handler attached');
|
||
} else {
|
||
log('WARN', 'VNC screen element not found, will retry');
|
||
setTimeout(setupClickHandler, 1000);
|
||
}
|
||
};
|
||
|
||
// 等待 DOM 完全加载
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', setupClickHandler);
|
||
} else {
|
||
setupClickHandler();
|
||
}
|
||
|
||
log('INFO', 'IME passthrough initialized');
|
||
log('INFO', 'Usage: Click on VNC screen, then type using your local input method');
|
||
}
|
||
|
||
// 页面加载后初始化
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
|
||
// 导出到全局(方便调试)
|
||
window.IMEPassthrough = {
|
||
sendText: sendText,
|
||
connect: connectWebSocket,
|
||
isConnected: () => imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN,
|
||
setDebug: (enabled) => { CONFIG.DEBUG = enabled; }
|
||
};
|
||
|
||
})();
|