VUE3 + Node + nestjs 实现 web远程桌面(windows版)
CSDN 2024-07-03 08:33:02 阅读 85
效果:
1、远程的windwos系统分辨率最好设置1280*720,这样可以保证交互速度是最快的
2、这个项目适合内网部署,服务器需要具备万兆光口。这样可以保证服务器到客户端的通信速度,如果是应用到云服务,那就需要考虑把图片上传到云存储,让云端管理图片资源。
前言:
1、远程桌面其实技术上并没有什么难度。复杂的地方主要在于性能优化,本身基于node做远程桌面其实就很有挑战性,我也是开发了好几天,换了好几种写法和各种插件库,才能达到现在的交互流畅度。当然跟RDP或VNC的远程桌面交互还是差很多。
2、交互上我只实现了鼠标操作和部分键盘操作,模拟滚轮操作有问题,我也不知道怎么解决,如果有大佬知道欢迎留言。
3、在截取屏幕这块只有robotjs这种基于node的库是最快的,我已经测试了很多库了,比如 electron,screenshot-desktop,包括使用exec操作系统命令调用nircmd截屏,其实都不如robotjs快。
4、在处理图像这块我也测试了很多插件库。只有sharp是速度最快的。像jimp,PNG这些都无法做到100ms以内,opencv这个库涉及到很多底层的实现,所以就不做考虑了。
5、桌面屏幕的图像是以分块的形式进行处理和渲染的,这样可以保证在交互的过程中只处理改变的某个部分,在传输的过程中我没有使用zlib进行压缩,因为经过我测试,如果在前端进行解压,会降低渲染性能,而且我这个项目主要在内网用,所以也就放弃压缩数据相关的优化方案了。
6、在后端和前端的图像处理部分都使用了Promise进行异步操作,之前其实是用web Worker写的,但是我发现web Worker针对后端来说性能不如Promise,在前端来说没有差别,所以我就统一使用Promise
7、前端渲染用的canvas,在渲染性能上做了一些优化方案。包括双缓冲,离屏,位图等技术
8、如果有大佬有更好的优化方案,不依赖其他语言的实现,我倒是很愿意一起交流一下。
9、现在还有很多交互操作需要完善,这个文章我会持续更新
1、安装依赖的环境:安装Python:下载Python 3.10.11版本
地址:
Python Releases for Windows | Python.org
安装Visual Studio C++编译工具: 下载Visual Studio 2022 Community版
地址:
下载 Visual Studio Tools - 免费安装 Windows、Mac、Linux
1:安装 “使用C++的桌面开发”
2:安装 MSVC v143 - VS 2022 C++ x64/x86 生成工具(或更高版本)
3:安装 Windows 10 SDK(或你的目标平台对应的SDK)
2、安装node:
node版本 20.14.0
3、安装node-gyp:
npm install -g node-gyp
一、创建屏幕捕获的 node 应用,用于捕获系统桌面的应用(部署在虚拟机系统)
1、创建node项目
npm init -y
npm install ws sharp robotjs
2、安装 nodemon
npm install --save-dev nodemon
3、文件目录:
node_modules
main.js
package.json
4、修改 package.json 文件
<code>{
"name": "desktop",
"version": "1.0.0",
"description": "",
"main": "main.js",
"scripts": {
"start": "nodemon main.js"
},
"build": {
"asar": true,
"asarUnpack": [
"**/node_modules/sharp/**/*",
"**/node_modules/@img/**/*"
],
"directories": {
"output": "dist"
},
"files": [
"**/*"
]
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"robotjs": "^0.6.0",
"sharp": "^0.33.4",
"ws": "^8.17.0"
},
"devDependencies": {
"nodemon": "^3.1.1"
}
}
5、main.js 应用服务
const http = require("http");
const WebSocketServer = require("ws").Server;
const robot = require("robotjs");
const sharp = require("sharp");
const util = require("util");
const exec = util.promisify(require("child_process").exec);
// 设置并发处理的数量,sharp 库可以同时处理 4 个任务,根据 CPU 的核心数设置
sharp.concurrency(4);
// 设置缓存
// memory:缓存的最大内存使用量,单位是 MB。默认值是 50。这个数字应该根据你的服务器的可用内存来设置。如果你的服务器有足够的内存,你可以尝试增加这个数字,看看是否可以提高性能。
// files:缓存的最大文件数量。默认值是 20。这个数字应该根据你的应用程序的需求来设置。如果你的应用程序需要处理大量的文件,那么你可以尝试增加这个数字,看看是否可以提高性能。
// items:缓存的最大项目数量。默认值是 100。这个数字应该根据你的应用程序的需求来设置。如果你的应用程序需要处理大量的项目,那么你可以尝试增加这个数字,看看是否可以提高性能。
sharp.cache({ memory: 1024, files: 100, items: 200 });
// 启用 SIMD 指令,SIMD 指令可以让 CPU 同时处理多个数据,从而提高性能。然而,不是所有的 CPU 都支持 SIMD 指令
sharp.simd(true);
// 存储所有连接的客户端
let clients = new Map();
// 设置捕获间隔为50帧每秒
let captureInterval = 1000 / 50;
// 创建 HTTP 服务器,用于简单的健康检查
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader("Content-Type", "text/plain");
res.end("虚拟机 Node.js 应用服务启动成功!");
});
// 在 HTTP 服务器中增加错误处理
server.on("error", (error) => {
console.error("虚拟机 Node.js 应用服务启动失败:", error);
});
// 创建 WebSocket 服务器,用于处理来自中转服务器的请求
const wss = new WebSocketServer({ server });
// 设置中转服务器的端口号
const PORT = 9527;
// 启动中转服务器
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}/`);
});
// 当有客户端连接时的处理逻辑,ws是中转服务器的socket连接
wss.on("connection", function connection(ws) {
console.log("虚拟机:中转服务连接成功");
// 生成一个随机的wsId
const wsId = Math.random().toString(36).substring(2, 9);
// 将wsId设置为ws的id
ws.id = wsId;
// 将wsId和ws连接存储到clients集合中
clients.set(wsId, ws);
// 执行一次屏幕捕获
captureScreen(ws);
// // 测试:每隔 1S 执行一次,总共执行 2 次
// let count = 0;
// let timer = null;
// timer = setInterval(() => {
// count++;
// if (count > 20000) {
// clearInterval(timer);
// return;
// }
// captureScreen(ws);
// }, 30);
// 接收到中转服务器消息时的处理逻辑
ws.on("message", function incoming(message) {
try {
// 尝试解析接收到的消息为 JSON 对象
const command = JSON.parse(message);
// 根据不同的动作行为执行不同的操作
handleCommand(command, ws);
} catch (error) {
// 处理消息解析或处理中的错误
console.error("虚拟机:处理消息解析或处理中的错误:", error);
// 向客户端发送错误信息
ws.send(JSON.stringify({ type: "error", message: "虚拟机:命令格式错误" }));
// 关闭与中转服务器的连接
ws.close();
}
});
// 客户端断开连接时的处理逻辑
ws.on("close", () => {
clients.delete(wsId);
// 清空客户端的图像数据块历史和发送数量
clientsDataMap.delete(wsId);
console.log("虚拟机:中转服务断开连接");
});
// WebSocket 连接错误处理
ws.on("error", (error) => {
console.error("虚拟机:中转服务连接失败:", error);
});
});
// 存储每个客户端的每张图像分割的历史数据块和要已发送的数据块
const clientsDataMap = new Map();
// 最多保留当前客户端3次图像分割的数据块历史记录
const MAX_HISTORY_COUNT = 3;
// 删除当前客户端最旧的图像分割的历史数据块,传入的 clientData 是当前客户端保留的数据
function pruneOldHistory(clientData) {
// 获取当前客户端所有图像分割的历史数据块
const keys = Array.from(clientData.chunksHistory.keys());
// console.log("--------------------虚拟机:当前客户端保留的历史图像", keys);
// 如果图像的标识数量大于最大历史记录数量
if (keys.length > MAX_HISTORY_COUNT) {
// 获取最旧的图像标识
const oldestKey = keys.sort((a, b) => a - b)[0];
// 根据图像标识删除这个图像对应的数据块数组
clientData.chunksHistory.delete(oldestKey);
}
}
// 提取计算时间差的函数
function calculateDuration(startTime) {
const endTime = Date.now();
return endTime - startTime;
}
// 计算像素的哈希值
function hashPixel(r, g, b, a) {
return r + g * 256 + b * 256 * 256 + a * 256 * 256 * 256;
}
// 提取计算差异像素数量的函数,threshold:阈值,也就是像素每个通道的差值
function calculateDiffPixels(rgbaData, previousChunk, threshold) {
// 记录差异像素数量
let numDiffPixels = 0;
// 设置像素跳过的步长,每隔10个像素进行一次比对,我们就不需要比较所有的像素,因为在图像中,相邻的像素通常是相似的
// 一般设置在 2 ~ 10 之间,大了会导致图像质量降低,小了会导致图像处理时间变长
const PIXEL_SKIP = 12;
// 遍历当前图像块的每一个像素
for (let i = 0; i < rgbaData.length; i += 4 * PIXEL_SKIP) {
// 计算当前像素与上一次图像块对应像素的差值
const rDiff = Math.abs(rgbaData[i] - previousChunk.data[i]);
const gDiff = Math.abs(rgbaData[i + 1] - previousChunk.data[i + 1]);
const bDiff = Math.abs(rgbaData[i + 2] - previousChunk.data[i + 2]);
const aDiff = Math.abs(rgbaData[i + 3] - previousChunk.data[i + 3]);
// 如果有任何一个通道的差值大于阈值,就认为有差异
if ((rDiff | gDiff | bDiff | aDiff) > threshold) {
numDiffPixels++;
// 如果有差异,就停止循环
if (numDiffPixels > 0) {
return numDiffPixels;
}
}
// // 计算当前像素与上一次图像块对应像素的哈希值
// // 哈希比对是基于整个像素值的,而不是基于每个通道的差值,所以这里的阈值 (threshold) 没有被用到。
// const currentHash = hashPixel(rgbaData[i], rgbaData[i + 1], rgbaData[i + 2], rgbaData[i + 3]);
// const previousHash = hashPixel(previousChunk.data[i], previousChunk.data[i + 1], previousChunk.data[i + 2], previousChunk.data[i + 3]);
// // 如果哈希值不同,就认为有差异
// if (currentHash !== previousHash) {
// numDiffPixels++;
// // 如果有差异,就停止循环
// if (numDiffPixels > 0) {
// return numDiffPixels;
// }
// }
}
return numDiffPixels;
}
// 捕获屏幕
const captureScreen = async (data) => {
const wsId = data.id;
// 根据 wsId 获取对应的 WebSocket 连接
const ws = clients.get(wsId);
// 如果 ws 不存在,则返回错误信息
if (!ws) {
console.error("虚拟机:中转服务断开连接");
return;
}
try {
// 记录当前图像处理的开始时间
const startTime = Date.now();
console.log("--------------------虚拟机:开始时间", startTime, "--------------------");
// 生成一个随机字符串代表这个图像的标识
const imageId = Math.random().toString(36).substring(2, 9);
// 剪裁图像的y坐标
let startY = 0;
// 初始化 或 获取客户端数据
let clientData = clientsDataMap.get(wsId) || {
// 存储每个客户端每个图像的历史数据块,按 imageId 分类
chunksHistory: new Map(),
// 存储每个客户端的上一次处理的图像 imageId
lastImageId: null,
// 存储每个客户端的当前图像需要发送的数据块,按 imageId 分类
needToSend: new Map(),
// 根据 imageID 存储每个图像的开始时间
imageStartTime: new Map(),
// 存储当前图像每个数据块的Promise
chunkPromises: new Map(),
};
// 如果当前图像的历史数据块不存在,就创建一个空数组
if (!clientData.chunksHistory.has(imageId)) clientData.chunksHistory.set(imageId, []);
// 获取客户端上一次图像的历史数据块
const previousChunks =
clientData.lastImageId && clientData.chunksHistory.get(clientData.lastImageId) ? clientData.chunksHistory.get(clientData.lastImageId) : [];
// 更新最新的 imageId 为上次图像ID
clientData.lastImageId = imageId;
// 如果当前图像的需要发送的数据块不存在,就创建一个空数组用于存储要发送的数据块
if (!clientData.needToSend.has(imageId)) clientData.needToSend.set(imageId, []);
// 设置当前图像的开始时间
clientData.imageStartTime.set(imageId, startTime);
// 如果当前图像的数据块Promise不存在,就创建一个空数组用于存储数据块的Promise
if (!clientData.chunkPromises.has(imageId)) clientData.chunkPromises.set(imageId, []);
// 计算时间差
const duration1 = calculateDuration(clientData.imageStartTime.get(imageId));
console.log("--------------------虚拟机:一张图片的处理时间1", duration1);
// 使用robot截屏
// 获取屏幕的大小
// const screenSize = robot.getScreenSize();
// 进行屏幕截图,返回的是一个对象,包含了截图的未编码的像素数据和图像的宽度和高度
const bitmap = robot.screen.capture(0, 0);
// 屏幕实际大小
const width = bitmap.width; // screenSize.width;
const height = bitmap.height; // screenSize.height;
// 屏幕需要的宽度和高度
const actualWidth = 1280;
const actualHeight = 720;
// 宽度和高度的缩放比例
const scaleWidth = actualWidth / width;
const scaleHeight = actualHeight / height;
// 把图像以高度进行分割,这是每块最大高度
// 需要自行调整块大小:如果块太小,会增加管理的复杂性和通信开销。如果块太大,可能会影响响应性。实验不同的块大小,找到最佳平衡点。
const maxChunkHeight = height / 8; // 720/10
// 计算需要将图片分割成多少个数据块
const totalChunks = Math.ceil(height / maxChunkHeight);
// 计算时间差
const duration2 = calculateDuration(clientData.imageStartTime.get(imageId));
console.log("--------------------虚拟机:一张图片的处理时间2", duration2);
// 处理当前图像块的函数,传入当前图像块的索引、高度、y坐标、对应当前图像块位置的上一次图像块
const handleChunk = async (chunkIndex, chunkHeight, startY, previousChunk) => {
return new Promise(async (resolve, reject) => {
// 从 rgbaFullImageBuffer 中剪裁当前图像块
const rgbaData = bitmap.image.subarray(startY * width * 4, (startY + chunkHeight) * width * 4);
// 创建当前图像块对象
const currentChunk = { data: rgbaData, width: width, height: chunkHeight };
// 将当前图像块的像素数据添加到客户端数据对应的图像历史记录中
clientData.chunksHistory.get(imageId)[chunkIndex] = currentChunk;
// 初始化是否发送当前图像块
let sendChunk = false;
// 如果上一次图像块存在,就计算差异像素数量
if (previousChunk) {
// 记录差异像素数量
const numDiffPixels = calculateDiffPixels(rgbaData, previousChunk, 10);
// 如果有差异,就发送当前图像块
if (numDiffPixels > 0) {
sendChunk = true;
}
// console.log("--------------------虚拟机:差异像素数量", numDiffPixels, "图像块索引", chunkIndex);
} else {
// 如果没有上一次图像块,就发送当前图像块
sendChunk = true;
}
// 如果需要发送当前图像块
if (sendChunk) {
// 使用 Buffer.from() 方法来创建一个新的 Buffer,相当于拷贝
const newData = Buffer.from(rgbaData);
// 将BGRA格式的图像数据转换为RGBA格式
for (let i = 0; i < newData.length; i += 4) {
const b = newData[i];
const r = newData[i + 2];
newData[i] = r;
newData[i + 2] = b;
}
// 方式一:使用 sharp 库的流式接口处理图像数据
// 创建一个 sharp 实例,用于处理图像数据
sharp(newData, {
// 设置图像处理的原始数据格式,指定图像的宽度、高度和颜色通道数。
// bitmap.image 图像数据是原始的未编码的像素数据,而不是已编码的图像文件(如 JPEG 或 PNG 文件)。
// sharp 库需要知道这些信息,以便正确地解析和处理图像数据。不加这个会报错。
raw: {
width: currentChunk.width, // 图像的宽度
height: currentChunk.height, // 图像的高度
channels: 4, // 图像数据的通道数,这里是4,代表RGBA三个颜色通道
},
})
//width 和 height 是新的图像大小,fit: "fill" 表示如果新的宽度和高度与原图不成比例,那么图像将被拉伸以填充新的大小。
.resize({
width: Math.round(currentChunk.width * scaleWidth),
height: Math.round(currentChunk.height * scaleHeight),
fit: "fill",
})
// 将图像转换为 jpeg 格式
.jpeg({
quality: 40, // 图像质量,1-100,100 是最高质量
chromaSubsampling: "4:2:0", // 使用 4:2:0 色度抽样可以显著减少图像数据量,提高处理速度。
trellisQuantisation: true, // 启用 trellis 量化可以提高 JPEG 编码的效率。
overshootDeringing: true, // 启用 overshoot deringing 可以减少环绕效应,提高图像质量。
optimiseScans: true, // 启用 progressive (interlace) 扫描优化可以提高图像加载性能。
optimiseCoding: true, // 启用 Huffman 编码优化可以减少图像文件大小。
})
// 将最终的图像数据转换为一个 Node.js Buffer,以便后续可以进行进一步的处理或传输。
.toBuffer()
.then((buffer) => {
// 将最终的图像数据转换为一个 base64 字符串
const base64String = buffer.toString("base64");
// 将 Node.js Buffer 转换为 ArrayBuffer
// const uint8Array = new Uint8Array(webpBuffer);
// 将当前图像块添加到客户端数据需要发送的图像块数组中
clientData.needToSend.get(imageId).push({
chunkIndex, // 当前图像块的索引,因为图像块是按高度进行分割的,所以需要索引计算这个图像块在当前图像中的位置
chunkHeight, // 当前图像块的高度
base64String, // 当前图像块的base64字符串
});
resolve();
})
.catch((err) => {
// 处理错误...
console.log("错误:", err);
});
} else {
resolve();
}
// resolve();
});
};
// 获取当前图像的所有图像块的Promise
const promises = clientData.chunkPromises.get(imageId);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
// 如果当前数据块是最后一个数据块,就将数据块高度设置为剩余高度,否则设置为最大高度(图像分割的平均高度)
let chunkHeight = chunkIndex === totalChunks - 1 ? height - startY : maxChunkHeight;
promises.push(handleChunk(chunkIndex, chunkHeight, startY, previousChunks[chunkIndex] || null));
startY += chunkHeight;
}
// 等待当前图像的所有图像块处理完成
await Promise.all(promises);
// 计算时间差
const duration3 = calculateDuration(clientData.imageStartTime.get(imageId));
console.log("--------------------虚拟机:一张图片的处理时间3", duration3);
// 当前图像需要发送的数据块数组
const chunks = clientData.needToSend.get(imageId);
// console.log("--------------------虚拟机:当前图像需要发送的数据块数量", chunks.length);
// 如果有发送的数据块,就发送所有需要发送的数据块
if (chunks.length > 0) {
// 发送当前图像的所有数据块
chunks.forEach((data, index) => {
ws.send(
JSON.stringify({
type: "screenUpdate",
data: {
imageId,
chunk: data.base64String,
overallWidth: width, // 屏幕实际宽度
overallHeight: height, // 屏幕实际高度
chunkWidth: Math.round(width * scaleWidth), // 图像块的宽度
chunkHeight: Math.round(data.chunkHeight * scaleHeight), // 图像块的高度
chunkIndex: data.chunkIndex, // 当前图像块的索引
chunksCount: chunks.length, // 已发送的数据块数量
totalChunks, // 数据块的总数
isLastChunk: index == chunks.length - 1, // 是否最后一个数据块
startTime: clientData.imageStartTime.get(imageId), // 图像处理的开始时间
},
})
);
});
} else {
// console.log("--------------------虚拟机:没有需要发送的数据块");
ws.send(
JSON.stringify({
type: "screenUpdate",
data: {
chunk: null,
overallWidth: width, // 屏幕实际宽度
overallHeight: height, // 屏幕实际高度
},
})
);
}
// 计算时间差
const duration4 = calculateDuration(clientData.imageStartTime.get(imageId));
console.log("--------------------虚拟机:一张图片的处理时间4", duration4);
// 如果当前图像的需要发送的数据块存在,清除当前图像需要发送的数据块
if (clientData.needToSend.has(imageId)) clientData.needToSend.delete(imageId);
// 清除当前图像开始时间
clientData.imageStartTime.delete(imageId);
// 清除当前图像的数据块Promise
clientData.chunkPromises.delete(imageId);
// 删除过时的图像数据块历史记录
pruneOldHistory(clientData);
// 将本次图像处理的历史数据块更新到客户端数据
clientsDataMap.set(wsId, clientData);
} catch (error) {
// 向客户端发送错误信息
ws.send(JSON.stringify({ type: "error", message: "虚拟机:屏幕捕获失败" }));
console.error("虚拟机:屏幕捕获失败:", error);
}
};
// 根据不同的动作行为执行不同的操作
function handleCommand(command, ws) {
// 根据命令类型执行相应的操作
switch (command.type) {
case "mouseAction":
// 处理鼠标动作
handleMouse(command);
break;
case "keyboardAction":
// 处理键盘动作
handleKeyboard(command);
break;
case "capture":
// 捕获屏幕并发送给中转服务器
captureScreen(ws);
break;
case "setCaptureInterval":
// 设置屏幕捕获间隔
setCaptureInterval(command.interval, ws);
break;
// 处理屏幕更新,当前端完成屏幕更新后,等待一段时间后再次进行捕获屏幕的操作
case "screenUpdateComplete":
// 使用 setTimeout 控制捕获间隔
// setTimeout(() => captureScreen(ws), captureInterval);
captureScreen(ws);
break;
default:
console.error("虚拟机:未知命令类型:", command.type);
}
}
// 设置屏幕捕获间隔
function setCaptureInterval(interval, ws) {
captureInterval = interval;
captureScreen(ws);
}
function handleMouse(command) {
console.log("虚拟机:处理鼠标动作", command.action);
try {
switch (command.action) {
case "move":
// 执行鼠标移动操作
robot.moveMouse(command.x, command.y);
break;
case "mouseDown":
// 先移动鼠标到指定坐标
robot.moveMouseSmooth(command.x, command.y);
// 执行鼠标按下操作,传入按键和修饰键,修饰键就是鼠标的左键、右键、中键
robot.mouseToggle("down", command.button);
break;
case "mouseUp":
// 先移动鼠标到指定坐标
robot.moveMouseSmooth(command.x, command.y);
// 执行鼠标抬起操作,传入按键和修饰键,修饰键就是鼠标的左键、右键、中键
robot.mouseToggle("up", command.button);
break;
case "click":
// 先移动鼠标到指定坐标
robot.moveMouseSmooth(command.x, command.y);
// 执行鼠标点击操作,第二个参数为是否双击
robot.mouseClick(command.button, command.dblclick);
break;
case "dblclick":
// 先移动鼠标到指定坐标
robot.moveMouseSmooth(command.x, command.y);
// 执行鼠标双击操作,第二个参数为是否双击
robot.mouseClick(command.button, true);
break;
case "rightClick":
// 先移动鼠标到指定坐标
robot.moveMouseSmooth(command.x, command.y);
// 执行鼠标右键点击操作,第二个参数为是否双击
robot.mouseClick(command.button, command.dblclick);
break;
case "scroll":
// 执行鼠标滚动操作
// // 方法一:使用 robotjs 模拟鼠标滚动(无效)
// robot.scrollMouse(command.dx, command.dy);
// // 方法二:使用 nircmd 模拟鼠标滚动(无效),下载地址:https://www.nirsoft.net/utils/nircmd.html
// const dx = command.dx !== 0 ? command.dx : 0;
// const dy = command.dy !== 0 ? command.dy : 0;
// const direction = dy > 0 ? "down" : "up";
// const amount = Math.abs(dy);
// exec(`nircmd.exe sendmouse wheel ${direction} ${amount}`, (error, stdout, stderr) => {
// if (error) {
// console.error(`执行 nircmd 命令时出错: ${error.message}`);
// return;
// }
// if (stderr) {
// console.error(`nircmd 错误输出: ${stderr}`);
// return;
// }
// console.log(`nircmd 输出: ${stdout}`);
// });
break;
default:
console.error("未知鼠标动作:", command.action);
}
} catch (error) {
console.error("虚拟机:处理鼠标动作时出错:", error);
}
}
const keyMap = {
// 功能键
Backspace: "backspace",
Tab: "tab",
Enter: "enter",
Shift: "shift",
Control: "control",
Alt: "alt",
Meta: "command",
Pause: "pause",
CapsLock: "capslock",
Escape: "escape",
Space: "space",
PageUp: "pageup",
PageDown: "pagedown",
End: "end",
Home: "home",
ArrowLeft: "left",
ArrowUp: "up",
ArrowRight: "right",
ArrowDown: "down",
PrintScreen: "printscreen",
Insert: "insert",
Delete: "delete",
ContextMenu: "contextmenu",
NumLock: "numlock",
ScrollLock: "scrolllock",
" ": "space",
};
function handleKeyboard(command) {
try {
const { action, key, modifiers } = command;
// 将键名转换为 robotjs 支持的键名
const robotKey = keyMap[key] || key;
// modifierKeys是一个数组,用于存储按下的修饰键(如Shift、Ctrl、Alt、Command等)
const modifierKeys = [];
// 如果shift被按下,就将"shift"添加到modifierKeys数组中
if (modifiers.shift) modifierKeys.push("shift");
// 如果ctrl被按下,就将"control"添加到modifierKeys数组中
if (modifiers.ctrl) modifierKeys.push("control");
// 如果alt被按下,就将"alt"添加到modifierKeys数组中
if (modifiers.alt) modifierKeys.push("alt");
// 如果meta被按下,就将"command"添加到modifierKeys数组中
if (modifiers.meta) modifierKeys.push("command");
// 值为"press"时,表示有一个键盘按下事件发生。
if (action === "press") {
// 如果有修饰键被按下
if (modifierKeys.length > 0) {
// 模拟按下指定的键和修饰键。key是被按下的键,modifierKeys是被按下的修饰键。
robot.keyTap(robotKey, modifierKeys);
} else {
// 如果没有修饰键被按下,就模拟按下指定的键。
robot.keyTap(robotKey);
}
} else {
console.error("虚拟机:未知键盘动作:", action);
}
} catch (error) {
console.error("虚拟机:处理键盘动作时出错:", error);
}
}
二、中转服务(基于nest),部署在服务器,这个服务也可以不需要。
1、安装需要的依赖:
npm install ws @nestjs/websockets @nestjs/platform-socket.io
2、创建用于远程桌面调度的中转服务模块:
nest g module remoteDesktop
nest g service remoteDesktop
nest g gateway remoteDesktop --no-spec
3、remote-desktop.gateway.ts:
import {
WebSocketGateway,
SubscribeMessage,
MessageBody,
ConnectedSocket,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import * as WebSocket from 'ws';
// 使用WebSocketGateway装饰器定义一个WebSocket网关,监听8113端口
@WebSocketGateway(8114, {
// 允许跨域
cors: {
origin: '*', // 允许所有来源
},
// 定义命名空间
namespace: 'desktop', // 默认是 /,如果设置成 /desktop,那么客户端连接的时候,就需要使用 ws://localhost:8113/desktop 这种形式
})
export class RemoteDesktopGateway {
// 创建 WebSocket 服务器实例
@WebSocketServer() server: Server;
// 存储所有虚拟机连接的映射表
private vmConnections: Map<string, WebSocket> = new Map();
// WebSocket 服务器初始化完成后的回调函数
afterInit(server: Server) {
// 打印服务器初始化完成的日志
console.log('中转服务器初始化完成');
}
// 当客户端连接时触发的处理函数
handleConnection(clientSocket: Socket) {
// 打印客户端连接的日志
console.log(`客户端连接成功: ${clientSocket.id}`);
}
// 当客户端断开连接时触发的处理函数
handleDisconnect(clientSocket: Socket) {
// 打印客户端断开连接的日志
console.log(`客户端断开连接: ${clientSocket.id}`);
// 遍历所有虚拟机连接,检查已经断开的连接,并删除对应的虚拟机连接
this.vmConnections.forEach((vmSocket, ip) => {
// 检查与虚拟机的 WebSocket 连接是否已关闭
if (vmSocket.readyState === WebSocket.CLOSED) {
// 从映射表中删除已断开的虚拟机连接
this.vmConnections.delete(ip);
// 打印虚拟机断开连接的日志
console.log(`虚拟机断开连接: ${ip}`);
}
});
}
// 处理前端请求连接虚拟机的消息
@SubscribeMessage('connectToVm')
async handleConnectVm(
@MessageBody() data: { ip: string },
@ConnectedSocket() clientSocket: Socket,
) {
// 检查是否已经存在该虚拟机的连接
let vmSocket = this.vmConnections.get(data.ip);
if (!vmSocket) {
// 创建新的 WebSocket 连接到虚拟机
vmSocket = new WebSocket(`ws://${data.ip}:9527`);
// 绑定虚拟机 WebSocket 事件处理程序,传入虚拟机的socket连接,ip,客户端socket连接
this.setupVmSocketEvents(vmSocket, data.ip, clientSocket);
// 向客户端发送虚拟机连接中状态
clientSocket.emit('vmConnectionStatus', {
message: '虚拟机连接中',
ip: data.ip,
});
} else {
// 移除之前绑定的事件处理程序
vmSocket.removeAllListeners();
// 重新绑定事件处理程序
this.setupVmSocketEvents(vmSocket, data.ip, clientSocket);
// 如果已存在连接,直接发送虚拟机已连接的状态到客户端
clientSocket.emit('vmConnectionStatus', {
message: '虚拟机连接已存在',
ip: data.ip,
});
}
}
// 设置虚拟机 WebSocket 事件处理程序
setupVmSocketEvents(vmSocket: WebSocket, ip: string, clientSocket: Socket) {
// 当虚拟机的 WebSocket 连接打开时
vmSocket.on('open', () => {
// 打印虚拟机连接成功的日志
console.log(`虚拟机连接成功: ${ip}`);
// 将虚拟机连接添加到映射表
this.vmConnections.set(ip, vmSocket);
// 发送虚拟机连接状态到客户端
clientSocket.emit('vmConnectionStatus', {
message: '虚拟机连接成功',
ip: ip,
});
});
// 监听虚拟机发送的消息
vmSocket.on('message', (message) => {
// 解析接收到的消息
const msg = JSON.parse(message);
// 如果消息类型是屏幕更新
if (msg.type === 'screenUpdate') {
// 将屏幕更新消息转发给客户端,携带图片相关数据
this.server.to(clientSocket.id).emit('screenUpdate', msg.data);
}
// 如果消息类型是错误
if (msg.type === 'error') {
// 向客户端发送错误信息
this.server
.to(clientSocket.id)
.emit('vmError', { ip: ip, message: msg.message });
}
});
// 当虚拟机的 WebSocket 连接关闭时
vmSocket.on('close', (code, reason) => {
// 从映射表中删除已断开的连接
this.vmConnections.delete(ip);
// 打印虚拟机断开连接的日志
console.log(
`close:虚拟机 ${ip} 断开连接, code: ${code}, reason: ${reason}`,
);
// 向客户端发送虚拟机断开连接的状态
clientSocket.emit('vmConnectionStatus', {
message: '虚拟机断开连接',
ip: ip,
});
});
// 当虚拟机的 WebSocket 连接出现错误时
vmSocket.on('error', (error) => {
// 打印 WebSocket 连接错误的日志
console.error(`虚拟机 ${ip} WebSocket 连接错误:`, error);
// 向客户端发送错误信息
clientSocket.emit('vmError', { ip: ip, message: error.message });
// 关闭虚拟机连接,会触发虚拟机应用 WebSocket 连接的 close 事件 和 当前中转服务 vmSocket 的 close 事件
vmSocket.close();
});
}
// 订阅前端发送的虚拟机动作请求
@SubscribeMessage('vmAction')
handleVmAction(
@MessageBody() data: { ip: string; action: any },
@ConnectedSocket() clientSocket: Socket,
) {
// 从连接映射中获取对应IP的虚拟机WebSocket连接
const vmSocket = this.vmConnections.get(data.ip);
// 检查WebSocket连接是否打开
if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
// 发送动作数据到虚拟机
vmSocket.send(JSON.stringify(data.action));
}
}
// 订阅前端发送的已经完成屏幕更新请求
@SubscribeMessage('screenUpdateComplete')
handleScreenUpdateComplete(
@MessageBody() data: { ip: string },
@ConnectedSocket() clientSocket: Socket,
) {
// 从连接映射中获取对应IP的虚拟机WebSocket连接
const vmSocket = this.vmConnections.get(data.ip);
// 检查WebSocket连接是否打开
if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
// 向虚拟机发送已经完成屏幕更新的请求,让虚拟机等待一段时间后再次进行屏幕捕获
vmSocket.send(JSON.stringify({ type: 'screenUpdateComplete' }));
}
}
// 订阅前端发送的捕获屏幕请求
@SubscribeMessage('capture')
handleCapture(
@MessageBody() data: { ip: string },
@ConnectedSocket() clientSocket: Socket,
) {
// 从连接映射中获取对应IP的虚拟机WebSocket连接
const vmSocket = this.vmConnections.get(data.ip);
// 检查WebSocket连接是否打开
if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
// 发送捕获屏幕的请求到虚拟机
vmSocket.send(JSON.stringify({ type: 'capture' }));
}
}
// 订阅前端发送的设置捕获间隔请求
@SubscribeMessage('setCaptureInterval')
handleSetCaptureInterval(
@MessageBody() data: { ip: string; interval: number },
@ConnectedSocket() clientSocket: Socket,
) {
// 从连接映射中获取对应IP的虚拟机WebSocket连接
const vmSocket = this.vmConnections.get(data.ip);
// 检查WebSocket连接是否打开
if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
// 发送设置捕获间隔的请求到虚拟机
vmSocket.send(
JSON.stringify({ type: 'setCaptureInterval', interval: data.interval }),
);
}
}
// 订阅前端发送的断开虚拟机连接请求
@SubscribeMessage('disconnectFromVm')
handleDisconnectFromVm(
@MessageBody() data: { ip: string },
@ConnectedSocket() clientSocket: Socket,
) {
const vmSocket = this.vmConnections.get(data.ip);
if (vmSocket) {
// 关闭虚拟机连接,会触发虚拟机应用 WebSocket 连接的 close 事件 和 当前中转服务 vmSocket 的 close 事件
vmSocket.close();
// 从映射表中删除已断开的连接
this.vmConnections.delete(data.ip);
// 打印虚拟机断开连接的日志
console.log(`disconnectFromVm:虚拟机 ${data.ip} 断开连接`);
}
}
}
三、前端
1、安装依赖:
npm install socket.io-client
2、index.vue:
<template>
<div class="container">code>
<div class="controls">code>
<button @click="clearScreen" class="button">code>
网速:{ { netSpeed + 'ms' }} / FPS:{ { fps }}
</button>
<!-- 输入框用于输入虚拟机的IP地址 -->
<input type="text" v-model="vmIp" placeholder="Enter VM IP" class="input" />code>
<!-- 按钮用于连接到虚拟机 -->
<button @click="connectVm" class="button">连接到虚拟机</button>code>
<!-- 输入框用于设置屏幕捕获间隔 -->
<input
type="number"code>
v-model="captureInterval"code>
placeholder="Set Capture Interval (ms)"code>
class="input"code>
/>
<!-- 按钮用于设置捕获间隔 -->
<button @click="setCaptureInterval" class="button">设置捕获间隔</button>code>
<!-- 按钮用于发起屏幕捕获请求 -->
<button @click="captureScreen" class="button">捕获屏幕</button>code>
</div>
<!-- 显示连接状态的信息 -->
<div v-if="connectionStatus" class="status">{ { connectionMessage }}</div>code>
<!-- 显示错误信息 -->
<div v-if="errorMessage" class="error">{ { errorMessage }}</div>code>
<!--
画布用于显示虚拟机的屏幕
鼠标按下事件
鼠标释放事件
鼠标移动事件
鼠标滚轮事件
键盘按键事件
-->
<canvas
ref="screen"code>
id="screen"code>
width="1280"code>
height="720"code>
@mousedown.prevent="handleMouseDown"code>
@mouseup.prevent="handleMouseUp"code>
@click.prevent="handleClick"code>
@contextmenu.prevent="handleRightClick"code>
@mousemove.prevent="handleMouseMove"code>
@dblclick.prevent="handleDoubleClick"code>
@wheel.prevent="handleMouseWheel"code>
@keydown.prevent="handleKeyDown"code>
@keyup.prevent="handleKeyUp"code>
@focus.prevent="handleFocus"code>
@blur.prevent="handleBlur"code>
tabindex="0"code>
class="screen"code>
></canvas>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { io } from 'socket.io-client'
import { useEventListener } from '@vueuse/core'
// 虚拟机IP地址
const vmIp = ref('')
// 屏幕捕获间隔,默认500毫秒
const captureInterval = ref(1000 / 50)
// 鼠标按下的状态
const isMouseDown = ref(false)
// 鼠标抬起的状态
const isMouseUp = ref(false)
// WebSocket是否连接的状态
const isConnected = ref(false)
// 错误信息
const errorMessage = ref('')
// 连接状态
const connectionStatus = ref(false)
// 连接状态信息
const connectionMessage = ref('')
// 网速
const netSpeed = ref(0)
// FPS
const fps = ref(0)
// 鼠标按下事件的定时器
let mouseDownTimeout
// 画布的DOM引用
const screen = ref(null)
const canvas = ref(null)
const context = ref(null)
// 缩放比例
const scaleX = ref(1)
const scaleY = ref(1)
// 创建socket连接
// http://localhost:8114/desktop
// http://172.16.250.122:8114/desktop
const socket = io('http://localhost:8114/desktop', {
autoConnect: false // 禁止自动连接
})
const imagesData = ref({})
// 用于存储每个图像的数据块信息
function initImageData(imageId) {
if (!imagesData.value[imageId]) {
imagesData.value[imageId] = {
receivedChunks: 0, // 接收到的数据包数量
totalChunks: 0, // 总数据包数量
renderCount: 0, // 渲染完成的数据包数量
chunksCount: 0, // 已发送的数据块数量
isLastChunk: false, // 是否最后一个数据块
startTime: 0 // 开始时间
}
}
}
onMounted(() => {
// 明确调用 connect 方法连接服务器
setTimeout(() => {
socket.connect()
}, 1000)
// 创建后台缓冲画布,在后台处理图像的渲染,然后再将处理后的图像渲染到前台画布上。提高渲染的平滑性
const backCanvas = document.createElement('canvas')
backCanvas.width = 1280
backCanvas.height = 720
const backContext = backCanvas.getContext('2d')
// 获取画布引用
canvas.value = screen.value
// 获取画布上下文
context.value = canvas.value.getContext('2d')
// 设置画布焦点
canvas.value.focus()
const imageWorker = (data) => {
return new Promise(async (resolve, reject) => {
// 从事件中解构出所需的数据
const { imageId, chunk, canvasWidth, canvasHeight, chunkWidth, chunkHeight, chunkIndex } =
data
// 确保接收到的数据包含必要的属性
if (chunk && canvasWidth && canvasHeight) {
// 创建一个离屏画布,离屏画布是一种可以在主线程之外的工作线程中使用的画布,可以避免阻塞主线程,提高页面的响应性
const offscreenCanvas = new OffscreenCanvas(chunkWidth, chunkHeight)
// 获取离屏画布的2D渲染上下文
const offscreenContext = offscreenCanvas.getContext('2d')
// 解码 Base64 编码的字符串
const compressedBuffer = Uint8Array.from(atob(chunk), (c) => c.charCodeAt(0))
// 解压缩数据
// const decompressedBuffer = pako.inflate(compressedBuffer);
// 使用 fflate 的 unzlibSync 方法解压缩数据
// const decompressedBuffer = unzlibSync(compressedBuffer)
// 如果后端传输的是Uint8Array格式的字符串的话
// const uint8Array = new Uint8Array(Object.values(chunk))
// 将接收到的图像数据转换为Blob对象
const blob = new Blob([compressedBuffer], { type: 'image/jpeg' })
// 创建图像位图。图像位图是一种可以直接用于drawImage方法的图像数据格式,使用它可以避免额外的图像解码步骤,提高渲染性能。
const imageBitmap = await createImageBitmap(blob)
// 计算图像数据块在画布上的位置
const cols = Math.ceil(canvasWidth / chunkWidth)
const x = (chunkIndex % cols) * chunkWidth
const y = Math.floor(chunkIndex / cols) * chunkHeight
// 清除画布上的所有内容,为新图像做准备
// context.clearRect(x, y, chunkWidth, chunkHeight)
// 在画布上绘制接收到的图像块数据,传入位图
offscreenContext.drawImage(imageBitmap, 0, 0, chunkWidth, chunkHeight)
// 转换为ImageBitmap,以便传输,ImageBitmap 对象可以在不同的上下文中使用,包括在 Worker 线程中,而且它的绘制性能通常比直接使用 Image 或 Canvas 对象更好。
const transferredImageBitmap = offscreenCanvas.transferToImageBitmap()
// 渲染到后台缓冲画布
backContext.drawImage(transferredImageBitmap, x, y)
// 关闭ImageBitmap,关闭有助于释放内存
transferredImageBitmap.close()
// 渲染完成一个数据块
imagesData.value[imageId].renderCount++
// 图像渲染完成的数据包数量 == 后端发送的数据包总量,表示一张图像的所有数据块都已经渲染完成
if (imagesData.value[imageId].renderCount === imagesData.value[imageId].chunksCount) {
// 将后台缓冲画布的内容复制到前台显示画布
// 使用requestAnimationFrame来确保图像渲染在适当的时机
requestAnimationFrame(() => {
// 清除画布上的所有内容,为新图像做准备
// context.clearRect(x, y, chunkWidth, chunkHeight)
// 在画布上绘制接收到的图像块
context.value.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 将后台缓冲画布的内容复制到前台显示画布
context.value.drawImage(backCanvas, 0, 0)
// 计算总渲染时间
const endTime = Date.now()
const totalTime = endTime - imagesData.value[imageId].startTime
console.log(`${imagesData.value[imageId].startTime}:总渲染时间: ${totalTime}ms`)
// FPS
fps.value = Math.round(1000 / (totalTime - netSpeed.value))
// 删除当前图像数据
delete imagesData.value[imageId]
// 通知服务器屏幕更新已完成,可以继续让服务器发送新的屏幕图像数据包
// socket.emit('screenUpdateComplete', { ip: vmIp.value })
resolve()
})
}
} else {
// 如果接收到的数据不完整,打印错误信息
console.error('接收到的数据包不完整', event.data)
}
})
}
// 监听 screenUpdateChunk 事件
socket.on('screenUpdate', (data) => {
// 从事件数据中解构出imageBitmap
/**
* chunk: 图像某个数据块
* overallWidth: 图像整体宽度
* overallHeight: 图像整体高度
* chunkWidth: 数据块宽度
* chunkHeight: 数据块高度
* chunkIndex: 数据块索引
* chunksCount: 已发送的数据块数量
* totalChunks: 总数据块数量
* isLastChunk: 是否最当前图像最后一个数据块
* startTime: 开始时间
*/
const {
imageId,
chunk,
overallWidth,
overallHeight,
chunkWidth,
chunkHeight,
chunkIndex,
chunksCount,
totalChunks,
isLastChunk,
startTime
} = data
// 计算缩放比例,以保持图像的纵横比不变,这是画布与虚拟机屏幕的缩放比例,主要用于交互操作
scaleX.value = canvas.value.width / overallWidth
scaleY.value = canvas.value.height / overallHeight
// 确保所有属性都存在
if (chunk && overallWidth && overallHeight && chunkWidth && chunkHeight) {
// 初始化当前图像数据的缓存
initImageData(imageId)
// 更新这个 startTime 对应的图像数据块信息
imagesData.value[imageId].receivedChunks++ // 接收到的数据块数量
imagesData.value[imageId].totalChunks = totalChunks // 总数据块数量
imagesData.value[imageId].chunksCount = chunksCount // 后端已发送的数据块数量
imagesData.value[imageId].isLastChunk = isLastChunk // 是否是最后一个数据块
imagesData.value[imageId].startTime = startTime // 后端当前图像处理的开始时间
// 解码并渲染当前数据包
try {
// 方式二:使用Promise
imageWorker({
chunk, // 这是一个图像的其中一部分数据块base64编码的二进制数据
canvasWidth: canvas.value.width, // 画布宽度
canvasHeight: canvas.value.height, // 画布高度
chunkWidth, // 图像数据块宽度
chunkHeight, // 图像数据块高度
chunkIndex, // 图像数据块索引
imageId // 图像ID
// chunkWidth: chunkWidth * scaleX.value, // 缩放后的数据块宽度,如果后端图片尺寸与前端画布尺寸不一致,需要通过缩放比例进行转换
// chunkHeight: chunkHeight * scaleY.value, // 缩放后的数据块高度,如果后端图片尺寸与前端画布尺寸不一致,需要通过缩放比例进行转换
})
// 如果当前数据块是最后一个数据块,则通知服务器继续发送下一个图像数据
if (imagesData.value[imageId].isLastChunk) {
// 通知服务器屏幕更新已完成
socket.emit('screenUpdateComplete', { ip: vmIp.value })
}
// 检查是否接收到所有数据包,当前图像接收到的数据包的数量 == 当前图像已发送的数据包数量
if (imagesData.value[imageId].receivedChunks === imagesData.value[imageId].chunksCount) {
const endTime = Date.now()
const totalTime = endTime - imagesData.value[imageId].startTime // 计算总时间
console.log(
`${imagesData.value[imageId].startTime}:一个图像所有数据包传输时间: ${totalTime}ms`
)
// 网速
netSpeed.value = totalTime
}
} catch (error) {
console.error('Error decoding or inflating chunk:', error)
}
}
// 如果当前数据块不存在就继续让服务器发送下一个图像数据
if (!chunk) {
// 通知服务器继续发送下一个图像数据
socket.emit('screenUpdateComplete', { ip: vmIp.value })
}
})
// 监听虚拟机连接状态事件,显示连接状态信息
socket.on('vmConnectionStatus', (status) => {
connectionStatus.value = true
connectionMessage.value = `VM ${status.ip} is ${status.message}`
console.log(`VM ${status.ip} is ${status.message}`)
})
// 监听虚拟机错误事件,显示错误消息
socket.on('vmError', (error) => {
errorMessage.value = `Error with VM ${error.ip}: ${error.message}`
// 向服务器发送重新捕获屏幕的请求
socket.emit('screenUpdateComplete', { ip: vmIp.value })
})
// 监听连接事件,更新连接状态
socket.on('connect', () => {
isConnected.value = true
})
// 监听断开连接事件,更新连接状态和显示错误消息
socket.on('disconnect', () => {
isConnected.value = false
errorMessage.value = 'Disconnected from server'
})
})
// 页面刷新或关闭时断开连接
useEventListener(window, 'beforeunload', (event) => {
// 在这里执行你需要的操作
console.log('页面即将刷新或关闭')
socket.emit('disconnectFromVm', { ip: vmIp.value })
socket.disconnect()
// 如果你需要阻止页面关闭,可以使用以下代码
event.preventDefault()
event.returnValue = ''
})
// 定义一个函数用于连接到虚拟机
const connectVm = () => {
// 使用WebSocket发送连接请求到指定的虚拟机IP
socket.emit('connectToVm', { ip: vmIp.value })
}
// 定义一个函数用于设置屏幕捕获的时间间隔
const setCaptureInterval = () => {
// 发送设置捕获间隔的请求,间隔时间从captureInterval的值中获取并转换为整数
socket.emit('setCaptureInterval', {
ip: vmIp.value,
interval: parseInt(captureInterval.value, 10)
})
}
// 定义一个函数用于发送屏幕捕获请求
const captureScreen = () => {
// 使用WebSocket发送捕获屏幕的请求到指定的虚拟机IP
socket.emit('capture', { ip: vmIp.value })
}
// 定义一个函数处理鼠标按下事件
const handleMouseDown = (event) => {
// console.log("鼠标按下", event);
// 如果鼠标按下状态为true,则直接返回
if (isMouseDown.value) {
return
}
// 每次鼠标按下都需要重置鼠标抬起状态
isMouseUp.value = false
// 因为鼠标按下再抬起会导致触发鼠标点击,所以需要延迟300毫秒再执行按下操作,这样可以分辨出是点击还是按下
mouseDownTimeout = setTimeout(() => {
// 设置鼠标按下的状态为true
isMouseDown.value = true
// 发送鼠标点击动作
sendMouseAction('mouseAction', event, 'mouseDown')
}, 300)
}
// 定义一个函数处理鼠标抬起事件
const handleMouseUp = (event) => {
// console.log("鼠标释放", event);
// 如果鼠标按下定时器存在,则清除定时器
if (mouseDownTimeout) {
clearTimeout(mouseDownTimeout)
}
// 如果鼠标按下状态为true,则发送鼠标抬起动作
if (isMouseDown.value) {
// 设置鼠标按下的状态为false
isMouseDown.value = false
// 设置鼠标抬起状态为true
isMouseUp.value = true
// 延迟300毫秒,因为鼠标抬起会导致自动触发鼠标点击
setTimeout(() => {
isMouseUp.value = false
}, 300)
if (event.button == 0) {
// 发送鼠标抬起动作
sendMouseAction('mouseAction', event, 'mouseUp')
} else if (event.button == 2) {
// 如果鼠标按下并且是右键抬起,则当做右键点击处理
sendMouseAction('mouseAction', event, 'rightClick')
}
}
}
// 定义一个函数处理鼠标单机事件
const handleClick = useDebounceFn((event) => {
// 设置画布焦点
canvas.value.focus()
// console.log("鼠标点击", event);
// 如果鼠标抬起状态为false,则发送鼠标点击动作
if (!isMouseUp.value) {
sendMouseAction('mouseAction', event, 'click')
}
}, 200)
// 定义一个函数处理鼠标右键点击事件
const handleRightClick = useDebounceFn((event) => {
// console.log("鼠标右键点击", event);
// 如果鼠标抬起状态为false,则发送鼠标点击动作
if (!isMouseUp.value) {
sendMouseAction('mouseAction', event, 'rightClick')
}
}, 200)
// 定义一个函数处理鼠标移动事件
const handleMouseMove = useDebounceFn((event) => {
// 发送鼠标移动动作
sendMouseAction('mouseAction', event, 'move')
}, captureInterval.value)
// 定义一个函数处理鼠标双击事件
const handleDoubleClick = useDebounceFn((event) => {
// console.log("鼠标双击", event);
// sendMouseAction("mouseAction", event, "dblclick");
}, 200)
// 定义一个函数发送鼠标动作
const sendMouseAction = (type, event, action) => {
// 获取画布的位置信息,用于计算鼠标在画布上的相对位置
const rect = screen.value.getBoundingClientRect()
// 计算鼠标在画布上的相对位置,通过缩放比例进行转换
const x = (event.clientX - rect.left) / scaleX.value
const y = (event.clientY - rect.top) / scaleY.value
const buttonMap = ['left', 'middle', 'right']
const button = buttonMap[event.button] || 'left' // 默认使用 "left" 按钮
// console.log('鼠标位置', action, x, y, button, event.detail)
// 发送鼠标动作,包括动作类型、位置、按钮信息和是否双击
socket.emit('vmAction', {
ip: vmIp.value,
action: {
type: type, // 动作类型
action, // 鼠标动作
x: x, // 鼠标位置
y: y, // 鼠标位置
button, // 鼠标修饰键
dblclick: event.detail == 2 // 是否双击
}
})
}
// 定义一个函数处理鼠标滚轮事件
const handleMouseWheel = useDebounceFn((event) => {
console.log('鼠标滚动', event)
// 发送鼠标滚动动作,包括滚动的水平和垂直距离
socket.emit('vmAction', {
ip: vmIp.value,
action: {
type: 'mouseAction',
action: 'scroll',
dx: event.deltaX,
dy: event.deltaY
}
})
}, 200)
// 定义一个函数处理键盘按下事件
const handleKeyDown = (event) => {}
// 记录最后一个被抬起的键和时间
let lastKeyUp = { key: null, time: 0 }
// 定义一个函数处理键盘抬起事件
const handleKeyUp = (event) => {
// 获取修饰键信息,比如shift、ctrl、alt、meta等
const modifiers = {
shift: event.shiftKey,
ctrl: event.ctrlKey,
alt: event.altKey,
meta: event.metaKey
}
const action = {
type: 'keyboardAction',
// robotjs库不支持单独的键盘抬起事件,所以将所有的键盘抬起事件都当作键盘按下事件来处理
action: 'press',
// key是键盘按下的值,例如'a'、'1'、'ArrowUp'等
key: event.key,
// 修饰键信息
modifiers: modifiers
}
// 如果抬起的键是字母键
if (
!(
event.key === 'Shift' ||
event.key === 'Control' ||
event.key === 'Alt' ||
event.key === 'Meta'
)
) {
console.log('组合键或字母键', action)
socket.emit('vmAction', {
ip: vmIp.value,
action
})
// 更新最后一个被抬起的键和时间
lastKeyUp = { key: event.key, time: Date.now() }
}
// 在同时抬起修饰键和字母键时,如果先抬起修饰键,那么需要等待一段时间获取字母键抬起的信息
setTimeout(() => {
// 如果抬起的键是修饰键
if (
event.key === 'Shift' ||
event.key === 'Control' ||
event.key === 'Alt' ||
event.key === 'Meta'
) {
console.log('修饰符抬起', Date.now() - lastKeyUp.time)
// 如果抬起修饰键的时间与抬起字母键的时间差大于500毫秒,那么就发送修饰键的信息
if (Date.now() - lastKeyUp.time > 500) {
console.log('修饰键', action)
socket.emit('vmAction', {
ip: vmIp.value,
action
})
}
}
}, 200)
}
// 定义一个函数处理画布获得焦点事件
const handleFocus = () => {
console.log('画布获得焦点')
}
// 定义一个函数处理画布失去焦点事件
const handleBlur = () => {
console.log('画布失去焦点')
}
</script>
<style scoped lang="scss">code>
.container {
display: flex;
flex-direction: column;
align-items: center;
height: 100vh;
background-color: #f0f0f0;
padding: 20px;
box-sizing: border-box;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.input {
margin: 5px 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
width: 150px;
}
.button {
margin: 5px 0;
padding: 10px 10px;
border: none;
border-radius: 4px;
background-color: #007bff;
color: white;
cursor: pointer;
width: 185px;
}
.button:hover {
background-color: #0056b3;
}
.status {
color: green;
margin-bottom: 10px;
}
.error {
color: red;
margin-bottom: 10px;
}
.screen {
border: 1px solid #ccc;
background-color: white;
}
</style>
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。