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>



声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。