0


VUE3 + Node + nestjs 实现 web远程桌面(windows版)

效果:

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版本

  1. 地址:

Python Releases for Windows | Python.org

  1. 安装Visual Studio C++编译工具: 下载Visual Studio 2022 Community
  2. 地址:

下载 Visual Studio Tools - 免费安装 Windows、Mac、Linux

  1. 1:安装 “使用C++的桌面开发”
  2. 2:安装 MSVC v143 - VS 2022 C++ x64/x86 生成工具(或更高版本)
  3. 3:安装 Windows 10 SDK(或你的目标平台对应的SDK

2、安装node:

  1. node版本 20.14.0

3、安装node-gyp:

  1. npm install -g node-gyp

一、创建屏幕捕获的 node 应用,用于捕获系统桌面的应用(部署在虚拟机系统)

1、创建node项目

  1. npm init -y
  2. npm install ws sharp robotjs

2、安装 nodemon

  1. npm install --save-dev nodemon

3、文件目录:

  1. node_modules
  2. main.js
  3. package.json

4、修改 package.json 文件

  1. {
  2. "name": "desktop",
  3. "version": "1.0.0",
  4. "description": "",
  5. "main": "main.js",
  6. "scripts": {
  7. "start": "nodemon main.js"
  8. },
  9. "build": {
  10. "asar": true,
  11. "asarUnpack": [
  12. "**/node_modules/sharp/**/*",
  13. "**/node_modules/@img/**/*"
  14. ],
  15. "directories": {
  16. "output": "dist"
  17. },
  18. "files": [
  19. "**/*"
  20. ]
  21. },
  22. "keywords": [],
  23. "author": "",
  24. "license": "ISC",
  25. "dependencies": {
  26. "robotjs": "^0.6.0",
  27. "sharp": "^0.33.4",
  28. "ws": "^8.17.0"
  29. },
  30. "devDependencies": {
  31. "nodemon": "^3.1.1"
  32. }
  33. }

5、main.js 应用服务

  1. const http = require("http");
  2. const WebSocketServer = require("ws").Server;
  3. const robot = require("robotjs");
  4. const sharp = require("sharp");
  5. const util = require("util");
  6. const exec = util.promisify(require("child_process").exec);
  7. // 设置并发处理的数量,sharp 库可以同时处理 4 个任务,根据 CPU 的核心数设置
  8. sharp.concurrency(4);
  9. // 设置缓存
  10. // memory:缓存的最大内存使用量,单位是 MB。默认值是 50。这个数字应该根据你的服务器的可用内存来设置。如果你的服务器有足够的内存,你可以尝试增加这个数字,看看是否可以提高性能。
  11. // files:缓存的最大文件数量。默认值是 20。这个数字应该根据你的应用程序的需求来设置。如果你的应用程序需要处理大量的文件,那么你可以尝试增加这个数字,看看是否可以提高性能。
  12. // items:缓存的最大项目数量。默认值是 100。这个数字应该根据你的应用程序的需求来设置。如果你的应用程序需要处理大量的项目,那么你可以尝试增加这个数字,看看是否可以提高性能。
  13. sharp.cache({ memory: 1024, files: 100, items: 200 });
  14. // 启用 SIMD 指令,SIMD 指令可以让 CPU 同时处理多个数据,从而提高性能。然而,不是所有的 CPU 都支持 SIMD 指令
  15. sharp.simd(true);
  16. // 存储所有连接的客户端
  17. let clients = new Map();
  18. // 设置捕获间隔为50帧每秒
  19. let captureInterval = 1000 / 50;
  20. // 创建 HTTP 服务器,用于简单的健康检查
  21. const server = http.createServer((req, res) => {
  22. res.statusCode = 200;
  23. res.setHeader("Content-Type", "text/plain");
  24. res.end("虚拟机 Node.js 应用服务启动成功!");
  25. });
  26. // 在 HTTP 服务器中增加错误处理
  27. server.on("error", (error) => {
  28. console.error("虚拟机 Node.js 应用服务启动失败:", error);
  29. });
  30. // 创建 WebSocket 服务器,用于处理来自中转服务器的请求
  31. const wss = new WebSocketServer({ server });
  32. // 设置中转服务器的端口号
  33. const PORT = 9527;
  34. // 启动中转服务器
  35. server.listen(PORT, () => {
  36. console.log(`Server running at http://localhost:${PORT}/`);
  37. });
  38. // 当有客户端连接时的处理逻辑,ws是中转服务器的socket连接
  39. wss.on("connection", function connection(ws) {
  40. console.log("虚拟机:中转服务连接成功");
  41. // 生成一个随机的wsId
  42. const wsId = Math.random().toString(36).substring(2, 9);
  43. // 将wsId设置为ws的id
  44. ws.id = wsId;
  45. // 将wsId和ws连接存储到clients集合中
  46. clients.set(wsId, ws);
  47. // 执行一次屏幕捕获
  48. captureScreen(ws);
  49. // // 测试:每隔 1S 执行一次,总共执行 2 次
  50. // let count = 0;
  51. // let timer = null;
  52. // timer = setInterval(() => {
  53. // count++;
  54. // if (count > 20000) {
  55. // clearInterval(timer);
  56. // return;
  57. // }
  58. // captureScreen(ws);
  59. // }, 30);
  60. // 接收到中转服务器消息时的处理逻辑
  61. ws.on("message", function incoming(message) {
  62. try {
  63. // 尝试解析接收到的消息为 JSON 对象
  64. const command = JSON.parse(message);
  65. // 根据不同的动作行为执行不同的操作
  66. handleCommand(command, ws);
  67. } catch (error) {
  68. // 处理消息解析或处理中的错误
  69. console.error("虚拟机:处理消息解析或处理中的错误:", error);
  70. // 向客户端发送错误信息
  71. ws.send(JSON.stringify({ type: "error", message: "虚拟机:命令格式错误" }));
  72. // 关闭与中转服务器的连接
  73. ws.close();
  74. }
  75. });
  76. // 客户端断开连接时的处理逻辑
  77. ws.on("close", () => {
  78. clients.delete(wsId);
  79. // 清空客户端的图像数据块历史和发送数量
  80. clientsDataMap.delete(wsId);
  81. console.log("虚拟机:中转服务断开连接");
  82. });
  83. // WebSocket 连接错误处理
  84. ws.on("error", (error) => {
  85. console.error("虚拟机:中转服务连接失败:", error);
  86. });
  87. });
  88. // 存储每个客户端的每张图像分割的历史数据块和要已发送的数据块
  89. const clientsDataMap = new Map();
  90. // 最多保留当前客户端3次图像分割的数据块历史记录
  91. const MAX_HISTORY_COUNT = 3;
  92. // 删除当前客户端最旧的图像分割的历史数据块,传入的 clientData 是当前客户端保留的数据
  93. function pruneOldHistory(clientData) {
  94. // 获取当前客户端所有图像分割的历史数据块
  95. const keys = Array.from(clientData.chunksHistory.keys());
  96. // console.log("--------------------虚拟机:当前客户端保留的历史图像", keys);
  97. // 如果图像的标识数量大于最大历史记录数量
  98. if (keys.length > MAX_HISTORY_COUNT) {
  99. // 获取最旧的图像标识
  100. const oldestKey = keys.sort((a, b) => a - b)[0];
  101. // 根据图像标识删除这个图像对应的数据块数组
  102. clientData.chunksHistory.delete(oldestKey);
  103. }
  104. }
  105. // 提取计算时间差的函数
  106. function calculateDuration(startTime) {
  107. const endTime = Date.now();
  108. return endTime - startTime;
  109. }
  110. // 计算像素的哈希值
  111. function hashPixel(r, g, b, a) {
  112. return r + g * 256 + b * 256 * 256 + a * 256 * 256 * 256;
  113. }
  114. // 提取计算差异像素数量的函数,threshold:阈值,也就是像素每个通道的差值
  115. function calculateDiffPixels(rgbaData, previousChunk, threshold) {
  116. // 记录差异像素数量
  117. let numDiffPixels = 0;
  118. // 设置像素跳过的步长,每隔10个像素进行一次比对,我们就不需要比较所有的像素,因为在图像中,相邻的像素通常是相似的
  119. // 一般设置在 2 ~ 10 之间,大了会导致图像质量降低,小了会导致图像处理时间变长
  120. const PIXEL_SKIP = 12;
  121. // 遍历当前图像块的每一个像素
  122. for (let i = 0; i < rgbaData.length; i += 4 * PIXEL_SKIP) {
  123. // 计算当前像素与上一次图像块对应像素的差值
  124. const rDiff = Math.abs(rgbaData[i] - previousChunk.data[i]);
  125. const gDiff = Math.abs(rgbaData[i + 1] - previousChunk.data[i + 1]);
  126. const bDiff = Math.abs(rgbaData[i + 2] - previousChunk.data[i + 2]);
  127. const aDiff = Math.abs(rgbaData[i + 3] - previousChunk.data[i + 3]);
  128. // 如果有任何一个通道的差值大于阈值,就认为有差异
  129. if ((rDiff | gDiff | bDiff | aDiff) > threshold) {
  130. numDiffPixels++;
  131. // 如果有差异,就停止循环
  132. if (numDiffPixels > 0) {
  133. return numDiffPixels;
  134. }
  135. }
  136. // // 计算当前像素与上一次图像块对应像素的哈希值
  137. // // 哈希比对是基于整个像素值的,而不是基于每个通道的差值,所以这里的阈值 (threshold) 没有被用到。
  138. // const currentHash = hashPixel(rgbaData[i], rgbaData[i + 1], rgbaData[i + 2], rgbaData[i + 3]);
  139. // const previousHash = hashPixel(previousChunk.data[i], previousChunk.data[i + 1], previousChunk.data[i + 2], previousChunk.data[i + 3]);
  140. // // 如果哈希值不同,就认为有差异
  141. // if (currentHash !== previousHash) {
  142. // numDiffPixels++;
  143. // // 如果有差异,就停止循环
  144. // if (numDiffPixels > 0) {
  145. // return numDiffPixels;
  146. // }
  147. // }
  148. }
  149. return numDiffPixels;
  150. }
  151. // 捕获屏幕
  152. const captureScreen = async (data) => {
  153. const wsId = data.id;
  154. // 根据 wsId 获取对应的 WebSocket 连接
  155. const ws = clients.get(wsId);
  156. // 如果 ws 不存在,则返回错误信息
  157. if (!ws) {
  158. console.error("虚拟机:中转服务断开连接");
  159. return;
  160. }
  161. try {
  162. // 记录当前图像处理的开始时间
  163. const startTime = Date.now();
  164. console.log("--------------------虚拟机:开始时间", startTime, "--------------------");
  165. // 生成一个随机字符串代表这个图像的标识
  166. const imageId = Math.random().toString(36).substring(2, 9);
  167. // 剪裁图像的y坐标
  168. let startY = 0;
  169. // 初始化 或 获取客户端数据
  170. let clientData = clientsDataMap.get(wsId) || {
  171. // 存储每个客户端每个图像的历史数据块,按 imageId 分类
  172. chunksHistory: new Map(),
  173. // 存储每个客户端的上一次处理的图像 imageId
  174. lastImageId: null,
  175. // 存储每个客户端的当前图像需要发送的数据块,按 imageId 分类
  176. needToSend: new Map(),
  177. // 根据 imageID 存储每个图像的开始时间
  178. imageStartTime: new Map(),
  179. // 存储当前图像每个数据块的Promise
  180. chunkPromises: new Map(),
  181. };
  182. // 如果当前图像的历史数据块不存在,就创建一个空数组
  183. if (!clientData.chunksHistory.has(imageId)) clientData.chunksHistory.set(imageId, []);
  184. // 获取客户端上一次图像的历史数据块
  185. const previousChunks =
  186. clientData.lastImageId && clientData.chunksHistory.get(clientData.lastImageId) ? clientData.chunksHistory.get(clientData.lastImageId) : [];
  187. // 更新最新的 imageId 为上次图像ID
  188. clientData.lastImageId = imageId;
  189. // 如果当前图像的需要发送的数据块不存在,就创建一个空数组用于存储要发送的数据块
  190. if (!clientData.needToSend.has(imageId)) clientData.needToSend.set(imageId, []);
  191. // 设置当前图像的开始时间
  192. clientData.imageStartTime.set(imageId, startTime);
  193. // 如果当前图像的数据块Promise不存在,就创建一个空数组用于存储数据块的Promise
  194. if (!clientData.chunkPromises.has(imageId)) clientData.chunkPromises.set(imageId, []);
  195. // 计算时间差
  196. const duration1 = calculateDuration(clientData.imageStartTime.get(imageId));
  197. console.log("--------------------虚拟机:一张图片的处理时间1", duration1);
  198. // 使用robot截屏
  199. // 获取屏幕的大小
  200. // const screenSize = robot.getScreenSize();
  201. // 进行屏幕截图,返回的是一个对象,包含了截图的未编码的像素数据和图像的宽度和高度
  202. const bitmap = robot.screen.capture(0, 0);
  203. // 屏幕实际大小
  204. const width = bitmap.width; // screenSize.width;
  205. const height = bitmap.height; // screenSize.height;
  206. // 屏幕需要的宽度和高度
  207. const actualWidth = 1280;
  208. const actualHeight = 720;
  209. // 宽度和高度的缩放比例
  210. const scaleWidth = actualWidth / width;
  211. const scaleHeight = actualHeight / height;
  212. // 把图像以高度进行分割,这是每块最大高度
  213. // 需要自行调整块大小:如果块太小,会增加管理的复杂性和通信开销。如果块太大,可能会影响响应性。实验不同的块大小,找到最佳平衡点。
  214. const maxChunkHeight = height / 8; // 720/10
  215. // 计算需要将图片分割成多少个数据块
  216. const totalChunks = Math.ceil(height / maxChunkHeight);
  217. // 计算时间差
  218. const duration2 = calculateDuration(clientData.imageStartTime.get(imageId));
  219. console.log("--------------------虚拟机:一张图片的处理时间2", duration2);
  220. // 处理当前图像块的函数,传入当前图像块的索引、高度、y坐标、对应当前图像块位置的上一次图像块
  221. const handleChunk = async (chunkIndex, chunkHeight, startY, previousChunk) => {
  222. return new Promise(async (resolve, reject) => {
  223. // 从 rgbaFullImageBuffer 中剪裁当前图像块
  224. const rgbaData = bitmap.image.subarray(startY * width * 4, (startY + chunkHeight) * width * 4);
  225. // 创建当前图像块对象
  226. const currentChunk = { data: rgbaData, width: width, height: chunkHeight };
  227. // 将当前图像块的像素数据添加到客户端数据对应的图像历史记录中
  228. clientData.chunksHistory.get(imageId)[chunkIndex] = currentChunk;
  229. // 初始化是否发送当前图像块
  230. let sendChunk = false;
  231. // 如果上一次图像块存在,就计算差异像素数量
  232. if (previousChunk) {
  233. // 记录差异像素数量
  234. const numDiffPixels = calculateDiffPixels(rgbaData, previousChunk, 10);
  235. // 如果有差异,就发送当前图像块
  236. if (numDiffPixels > 0) {
  237. sendChunk = true;
  238. }
  239. // console.log("--------------------虚拟机:差异像素数量", numDiffPixels, "图像块索引", chunkIndex);
  240. } else {
  241. // 如果没有上一次图像块,就发送当前图像块
  242. sendChunk = true;
  243. }
  244. // 如果需要发送当前图像块
  245. if (sendChunk) {
  246. // 使用 Buffer.from() 方法来创建一个新的 Buffer,相当于拷贝
  247. const newData = Buffer.from(rgbaData);
  248. // 将BGRA格式的图像数据转换为RGBA格式
  249. for (let i = 0; i < newData.length; i += 4) {
  250. const b = newData[i];
  251. const r = newData[i + 2];
  252. newData[i] = r;
  253. newData[i + 2] = b;
  254. }
  255. // 方式一:使用 sharp 库的流式接口处理图像数据
  256. // 创建一个 sharp 实例,用于处理图像数据
  257. sharp(newData, {
  258. // 设置图像处理的原始数据格式,指定图像的宽度、高度和颜色通道数。
  259. // bitmap.image 图像数据是原始的未编码的像素数据,而不是已编码的图像文件(如 JPEG 或 PNG 文件)。
  260. // sharp 库需要知道这些信息,以便正确地解析和处理图像数据。不加这个会报错。
  261. raw: {
  262. width: currentChunk.width, // 图像的宽度
  263. height: currentChunk.height, // 图像的高度
  264. channels: 4, // 图像数据的通道数,这里是4,代表RGBA三个颜色通道
  265. },
  266. })
  267. //width 和 height 是新的图像大小,fit: "fill" 表示如果新的宽度和高度与原图不成比例,那么图像将被拉伸以填充新的大小。
  268. .resize({
  269. width: Math.round(currentChunk.width * scaleWidth),
  270. height: Math.round(currentChunk.height * scaleHeight),
  271. fit: "fill",
  272. })
  273. // 将图像转换为 jpeg 格式
  274. .jpeg({
  275. quality: 40, // 图像质量,1-100,100 是最高质量
  276. chromaSubsampling: "4:2:0", // 使用 4:2:0 色度抽样可以显著减少图像数据量,提高处理速度。
  277. trellisQuantisation: true, // 启用 trellis 量化可以提高 JPEG 编码的效率。
  278. overshootDeringing: true, // 启用 overshoot deringing 可以减少环绕效应,提高图像质量。
  279. optimiseScans: true, // 启用 progressive (interlace) 扫描优化可以提高图像加载性能。
  280. optimiseCoding: true, // 启用 Huffman 编码优化可以减少图像文件大小。
  281. })
  282. // 将最终的图像数据转换为一个 Node.js Buffer,以便后续可以进行进一步的处理或传输。
  283. .toBuffer()
  284. .then((buffer) => {
  285. // 将最终的图像数据转换为一个 base64 字符串
  286. const base64String = buffer.toString("base64");
  287. // 将 Node.js Buffer 转换为 ArrayBuffer
  288. // const uint8Array = new Uint8Array(webpBuffer);
  289. // 将当前图像块添加到客户端数据需要发送的图像块数组中
  290. clientData.needToSend.get(imageId).push({
  291. chunkIndex, // 当前图像块的索引,因为图像块是按高度进行分割的,所以需要索引计算这个图像块在当前图像中的位置
  292. chunkHeight, // 当前图像块的高度
  293. base64String, // 当前图像块的base64字符串
  294. });
  295. resolve();
  296. })
  297. .catch((err) => {
  298. // 处理错误...
  299. console.log("错误:", err);
  300. });
  301. } else {
  302. resolve();
  303. }
  304. // resolve();
  305. });
  306. };
  307. // 获取当前图像的所有图像块的Promise
  308. const promises = clientData.chunkPromises.get(imageId);
  309. for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
  310. // 如果当前数据块是最后一个数据块,就将数据块高度设置为剩余高度,否则设置为最大高度(图像分割的平均高度)
  311. let chunkHeight = chunkIndex === totalChunks - 1 ? height - startY : maxChunkHeight;
  312. promises.push(handleChunk(chunkIndex, chunkHeight, startY, previousChunks[chunkIndex] || null));
  313. startY += chunkHeight;
  314. }
  315. // 等待当前图像的所有图像块处理完成
  316. await Promise.all(promises);
  317. // 计算时间差
  318. const duration3 = calculateDuration(clientData.imageStartTime.get(imageId));
  319. console.log("--------------------虚拟机:一张图片的处理时间3", duration3);
  320. // 当前图像需要发送的数据块数组
  321. const chunks = clientData.needToSend.get(imageId);
  322. // console.log("--------------------虚拟机:当前图像需要发送的数据块数量", chunks.length);
  323. // 如果有发送的数据块,就发送所有需要发送的数据块
  324. if (chunks.length > 0) {
  325. // 发送当前图像的所有数据块
  326. chunks.forEach((data, index) => {
  327. ws.send(
  328. JSON.stringify({
  329. type: "screenUpdate",
  330. data: {
  331. imageId,
  332. chunk: data.base64String,
  333. overallWidth: width, // 屏幕实际宽度
  334. overallHeight: height, // 屏幕实际高度
  335. chunkWidth: Math.round(width * scaleWidth), // 图像块的宽度
  336. chunkHeight: Math.round(data.chunkHeight * scaleHeight), // 图像块的高度
  337. chunkIndex: data.chunkIndex, // 当前图像块的索引
  338. chunksCount: chunks.length, // 已发送的数据块数量
  339. totalChunks, // 数据块的总数
  340. isLastChunk: index == chunks.length - 1, // 是否最后一个数据块
  341. startTime: clientData.imageStartTime.get(imageId), // 图像处理的开始时间
  342. },
  343. })
  344. );
  345. });
  346. } else {
  347. // console.log("--------------------虚拟机:没有需要发送的数据块");
  348. ws.send(
  349. JSON.stringify({
  350. type: "screenUpdate",
  351. data: {
  352. chunk: null,
  353. overallWidth: width, // 屏幕实际宽度
  354. overallHeight: height, // 屏幕实际高度
  355. },
  356. })
  357. );
  358. }
  359. // 计算时间差
  360. const duration4 = calculateDuration(clientData.imageStartTime.get(imageId));
  361. console.log("--------------------虚拟机:一张图片的处理时间4", duration4);
  362. // 如果当前图像的需要发送的数据块存在,清除当前图像需要发送的数据块
  363. if (clientData.needToSend.has(imageId)) clientData.needToSend.delete(imageId);
  364. // 清除当前图像开始时间
  365. clientData.imageStartTime.delete(imageId);
  366. // 清除当前图像的数据块Promise
  367. clientData.chunkPromises.delete(imageId);
  368. // 删除过时的图像数据块历史记录
  369. pruneOldHistory(clientData);
  370. // 将本次图像处理的历史数据块更新到客户端数据
  371. clientsDataMap.set(wsId, clientData);
  372. } catch (error) {
  373. // 向客户端发送错误信息
  374. ws.send(JSON.stringify({ type: "error", message: "虚拟机:屏幕捕获失败" }));
  375. console.error("虚拟机:屏幕捕获失败:", error);
  376. }
  377. };
  378. // 根据不同的动作行为执行不同的操作
  379. function handleCommand(command, ws) {
  380. // 根据命令类型执行相应的操作
  381. switch (command.type) {
  382. case "mouseAction":
  383. // 处理鼠标动作
  384. handleMouse(command);
  385. break;
  386. case "keyboardAction":
  387. // 处理键盘动作
  388. handleKeyboard(command);
  389. break;
  390. case "capture":
  391. // 捕获屏幕并发送给中转服务器
  392. captureScreen(ws);
  393. break;
  394. case "setCaptureInterval":
  395. // 设置屏幕捕获间隔
  396. setCaptureInterval(command.interval, ws);
  397. break;
  398. // 处理屏幕更新,当前端完成屏幕更新后,等待一段时间后再次进行捕获屏幕的操作
  399. case "screenUpdateComplete":
  400. // 使用 setTimeout 控制捕获间隔
  401. // setTimeout(() => captureScreen(ws), captureInterval);
  402. captureScreen(ws);
  403. break;
  404. default:
  405. console.error("虚拟机:未知命令类型:", command.type);
  406. }
  407. }
  408. // 设置屏幕捕获间隔
  409. function setCaptureInterval(interval, ws) {
  410. captureInterval = interval;
  411. captureScreen(ws);
  412. }
  413. function handleMouse(command) {
  414. console.log("虚拟机:处理鼠标动作", command.action);
  415. try {
  416. switch (command.action) {
  417. case "move":
  418. // 执行鼠标移动操作
  419. robot.moveMouse(command.x, command.y);
  420. break;
  421. case "mouseDown":
  422. // 先移动鼠标到指定坐标
  423. robot.moveMouseSmooth(command.x, command.y);
  424. // 执行鼠标按下操作,传入按键和修饰键,修饰键就是鼠标的左键、右键、中键
  425. robot.mouseToggle("down", command.button);
  426. break;
  427. case "mouseUp":
  428. // 先移动鼠标到指定坐标
  429. robot.moveMouseSmooth(command.x, command.y);
  430. // 执行鼠标抬起操作,传入按键和修饰键,修饰键就是鼠标的左键、右键、中键
  431. robot.mouseToggle("up", command.button);
  432. break;
  433. case "click":
  434. // 先移动鼠标到指定坐标
  435. robot.moveMouseSmooth(command.x, command.y);
  436. // 执行鼠标点击操作,第二个参数为是否双击
  437. robot.mouseClick(command.button, command.dblclick);
  438. break;
  439. case "dblclick":
  440. // 先移动鼠标到指定坐标
  441. robot.moveMouseSmooth(command.x, command.y);
  442. // 执行鼠标双击操作,第二个参数为是否双击
  443. robot.mouseClick(command.button, true);
  444. break;
  445. case "rightClick":
  446. // 先移动鼠标到指定坐标
  447. robot.moveMouseSmooth(command.x, command.y);
  448. // 执行鼠标右键点击操作,第二个参数为是否双击
  449. robot.mouseClick(command.button, command.dblclick);
  450. break;
  451. case "scroll":
  452. // 执行鼠标滚动操作
  453. // // 方法一:使用 robotjs 模拟鼠标滚动(无效)
  454. // robot.scrollMouse(command.dx, command.dy);
  455. // // 方法二:使用 nircmd 模拟鼠标滚动(无效),下载地址:https://www.nirsoft.net/utils/nircmd.html
  456. // const dx = command.dx !== 0 ? command.dx : 0;
  457. // const dy = command.dy !== 0 ? command.dy : 0;
  458. // const direction = dy > 0 ? "down" : "up";
  459. // const amount = Math.abs(dy);
  460. // exec(`nircmd.exe sendmouse wheel ${direction} ${amount}`, (error, stdout, stderr) => {
  461. // if (error) {
  462. // console.error(`执行 nircmd 命令时出错: ${error.message}`);
  463. // return;
  464. // }
  465. // if (stderr) {
  466. // console.error(`nircmd 错误输出: ${stderr}`);
  467. // return;
  468. // }
  469. // console.log(`nircmd 输出: ${stdout}`);
  470. // });
  471. break;
  472. default:
  473. console.error("未知鼠标动作:", command.action);
  474. }
  475. } catch (error) {
  476. console.error("虚拟机:处理鼠标动作时出错:", error);
  477. }
  478. }
  479. const keyMap = {
  480. // 功能键
  481. Backspace: "backspace",
  482. Tab: "tab",
  483. Enter: "enter",
  484. Shift: "shift",
  485. Control: "control",
  486. Alt: "alt",
  487. Meta: "command",
  488. Pause: "pause",
  489. CapsLock: "capslock",
  490. Escape: "escape",
  491. Space: "space",
  492. PageUp: "pageup",
  493. PageDown: "pagedown",
  494. End: "end",
  495. Home: "home",
  496. ArrowLeft: "left",
  497. ArrowUp: "up",
  498. ArrowRight: "right",
  499. ArrowDown: "down",
  500. PrintScreen: "printscreen",
  501. Insert: "insert",
  502. Delete: "delete",
  503. ContextMenu: "contextmenu",
  504. NumLock: "numlock",
  505. ScrollLock: "scrolllock",
  506. " ": "space",
  507. };
  508. function handleKeyboard(command) {
  509. try {
  510. const { action, key, modifiers } = command;
  511. // 将键名转换为 robotjs 支持的键名
  512. const robotKey = keyMap[key] || key;
  513. // modifierKeys是一个数组,用于存储按下的修饰键(如Shift、Ctrl、Alt、Command等)
  514. const modifierKeys = [];
  515. // 如果shift被按下,就将"shift"添加到modifierKeys数组中
  516. if (modifiers.shift) modifierKeys.push("shift");
  517. // 如果ctrl被按下,就将"control"添加到modifierKeys数组中
  518. if (modifiers.ctrl) modifierKeys.push("control");
  519. // 如果alt被按下,就将"alt"添加到modifierKeys数组中
  520. if (modifiers.alt) modifierKeys.push("alt");
  521. // 如果meta被按下,就将"command"添加到modifierKeys数组中
  522. if (modifiers.meta) modifierKeys.push("command");
  523. // 值为"press"时,表示有一个键盘按下事件发生。
  524. if (action === "press") {
  525. // 如果有修饰键被按下
  526. if (modifierKeys.length > 0) {
  527. // 模拟按下指定的键和修饰键。key是被按下的键,modifierKeys是被按下的修饰键。
  528. robot.keyTap(robotKey, modifierKeys);
  529. } else {
  530. // 如果没有修饰键被按下,就模拟按下指定的键。
  531. robot.keyTap(robotKey);
  532. }
  533. } else {
  534. console.error("虚拟机:未知键盘动作:", action);
  535. }
  536. } catch (error) {
  537. console.error("虚拟机:处理键盘动作时出错:", error);
  538. }
  539. }

二、中转服务(基于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:

  1. import {
  2. WebSocketGateway,
  3. SubscribeMessage,
  4. MessageBody,
  5. ConnectedSocket,
  6. WebSocketServer,
  7. } from '@nestjs/websockets';
  8. import { Server, Socket } from 'socket.io';
  9. import * as WebSocket from 'ws';
  10. // 使用WebSocketGateway装饰器定义一个WebSocket网关,监听8113端口
  11. @WebSocketGateway(8114, {
  12. // 允许跨域
  13. cors: {
  14. origin: '*', // 允许所有来源
  15. },
  16. // 定义命名空间
  17. namespace: 'desktop', // 默认是 /,如果设置成 /desktop,那么客户端连接的时候,就需要使用 ws://localhost:8113/desktop 这种形式
  18. })
  19. export class RemoteDesktopGateway {
  20. // 创建 WebSocket 服务器实例
  21. @WebSocketServer() server: Server;
  22. // 存储所有虚拟机连接的映射表
  23. private vmConnections: Map<string, WebSocket> = new Map();
  24. // WebSocket 服务器初始化完成后的回调函数
  25. afterInit(server: Server) {
  26. // 打印服务器初始化完成的日志
  27. console.log('中转服务器初始化完成');
  28. }
  29. // 当客户端连接时触发的处理函数
  30. handleConnection(clientSocket: Socket) {
  31. // 打印客户端连接的日志
  32. console.log(`客户端连接成功: ${clientSocket.id}`);
  33. }
  34. // 当客户端断开连接时触发的处理函数
  35. handleDisconnect(clientSocket: Socket) {
  36. // 打印客户端断开连接的日志
  37. console.log(`客户端断开连接: ${clientSocket.id}`);
  38. // 遍历所有虚拟机连接,检查已经断开的连接,并删除对应的虚拟机连接
  39. this.vmConnections.forEach((vmSocket, ip) => {
  40. // 检查与虚拟机的 WebSocket 连接是否已关闭
  41. if (vmSocket.readyState === WebSocket.CLOSED) {
  42. // 从映射表中删除已断开的虚拟机连接
  43. this.vmConnections.delete(ip);
  44. // 打印虚拟机断开连接的日志
  45. console.log(`虚拟机断开连接: ${ip}`);
  46. }
  47. });
  48. }
  49. // 处理前端请求连接虚拟机的消息
  50. @SubscribeMessage('connectToVm')
  51. async handleConnectVm(
  52. @MessageBody() data: { ip: string },
  53. @ConnectedSocket() clientSocket: Socket,
  54. ) {
  55. // 检查是否已经存在该虚拟机的连接
  56. let vmSocket = this.vmConnections.get(data.ip);
  57. if (!vmSocket) {
  58. // 创建新的 WebSocket 连接到虚拟机
  59. vmSocket = new WebSocket(`ws://${data.ip}:9527`);
  60. // 绑定虚拟机 WebSocket 事件处理程序,传入虚拟机的socket连接,ip,客户端socket连接
  61. this.setupVmSocketEvents(vmSocket, data.ip, clientSocket);
  62. // 向客户端发送虚拟机连接中状态
  63. clientSocket.emit('vmConnectionStatus', {
  64. message: '虚拟机连接中',
  65. ip: data.ip,
  66. });
  67. } else {
  68. // 移除之前绑定的事件处理程序
  69. vmSocket.removeAllListeners();
  70. // 重新绑定事件处理程序
  71. this.setupVmSocketEvents(vmSocket, data.ip, clientSocket);
  72. // 如果已存在连接,直接发送虚拟机已连接的状态到客户端
  73. clientSocket.emit('vmConnectionStatus', {
  74. message: '虚拟机连接已存在',
  75. ip: data.ip,
  76. });
  77. }
  78. }
  79. // 设置虚拟机 WebSocket 事件处理程序
  80. setupVmSocketEvents(vmSocket: WebSocket, ip: string, clientSocket: Socket) {
  81. // 当虚拟机的 WebSocket 连接打开时
  82. vmSocket.on('open', () => {
  83. // 打印虚拟机连接成功的日志
  84. console.log(`虚拟机连接成功: ${ip}`);
  85. // 将虚拟机连接添加到映射表
  86. this.vmConnections.set(ip, vmSocket);
  87. // 发送虚拟机连接状态到客户端
  88. clientSocket.emit('vmConnectionStatus', {
  89. message: '虚拟机连接成功',
  90. ip: ip,
  91. });
  92. });
  93. // 监听虚拟机发送的消息
  94. vmSocket.on('message', (message) => {
  95. // 解析接收到的消息
  96. const msg = JSON.parse(message);
  97. // 如果消息类型是屏幕更新
  98. if (msg.type === 'screenUpdate') {
  99. // 将屏幕更新消息转发给客户端,携带图片相关数据
  100. this.server.to(clientSocket.id).emit('screenUpdate', msg.data);
  101. }
  102. // 如果消息类型是错误
  103. if (msg.type === 'error') {
  104. // 向客户端发送错误信息
  105. this.server
  106. .to(clientSocket.id)
  107. .emit('vmError', { ip: ip, message: msg.message });
  108. }
  109. });
  110. // 当虚拟机的 WebSocket 连接关闭时
  111. vmSocket.on('close', (code, reason) => {
  112. // 从映射表中删除已断开的连接
  113. this.vmConnections.delete(ip);
  114. // 打印虚拟机断开连接的日志
  115. console.log(
  116. `close:虚拟机 ${ip} 断开连接, code: ${code}, reason: ${reason}`,
  117. );
  118. // 向客户端发送虚拟机断开连接的状态
  119. clientSocket.emit('vmConnectionStatus', {
  120. message: '虚拟机断开连接',
  121. ip: ip,
  122. });
  123. });
  124. // 当虚拟机的 WebSocket 连接出现错误时
  125. vmSocket.on('error', (error) => {
  126. // 打印 WebSocket 连接错误的日志
  127. console.error(`虚拟机 ${ip} WebSocket 连接错误:`, error);
  128. // 向客户端发送错误信息
  129. clientSocket.emit('vmError', { ip: ip, message: error.message });
  130. // 关闭虚拟机连接,会触发虚拟机应用 WebSocket 连接的 close 事件 和 当前中转服务 vmSocket 的 close 事件
  131. vmSocket.close();
  132. });
  133. }
  134. // 订阅前端发送的虚拟机动作请求
  135. @SubscribeMessage('vmAction')
  136. handleVmAction(
  137. @MessageBody() data: { ip: string; action: any },
  138. @ConnectedSocket() clientSocket: Socket,
  139. ) {
  140. // 从连接映射中获取对应IP的虚拟机WebSocket连接
  141. const vmSocket = this.vmConnections.get(data.ip);
  142. // 检查WebSocket连接是否打开
  143. if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
  144. // 发送动作数据到虚拟机
  145. vmSocket.send(JSON.stringify(data.action));
  146. }
  147. }
  148. // 订阅前端发送的已经完成屏幕更新请求
  149. @SubscribeMessage('screenUpdateComplete')
  150. handleScreenUpdateComplete(
  151. @MessageBody() data: { ip: string },
  152. @ConnectedSocket() clientSocket: Socket,
  153. ) {
  154. // 从连接映射中获取对应IP的虚拟机WebSocket连接
  155. const vmSocket = this.vmConnections.get(data.ip);
  156. // 检查WebSocket连接是否打开
  157. if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
  158. // 向虚拟机发送已经完成屏幕更新的请求,让虚拟机等待一段时间后再次进行屏幕捕获
  159. vmSocket.send(JSON.stringify({ type: 'screenUpdateComplete' }));
  160. }
  161. }
  162. // 订阅前端发送的捕获屏幕请求
  163. @SubscribeMessage('capture')
  164. handleCapture(
  165. @MessageBody() data: { ip: string },
  166. @ConnectedSocket() clientSocket: Socket,
  167. ) {
  168. // 从连接映射中获取对应IP的虚拟机WebSocket连接
  169. const vmSocket = this.vmConnections.get(data.ip);
  170. // 检查WebSocket连接是否打开
  171. if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
  172. // 发送捕获屏幕的请求到虚拟机
  173. vmSocket.send(JSON.stringify({ type: 'capture' }));
  174. }
  175. }
  176. // 订阅前端发送的设置捕获间隔请求
  177. @SubscribeMessage('setCaptureInterval')
  178. handleSetCaptureInterval(
  179. @MessageBody() data: { ip: string; interval: number },
  180. @ConnectedSocket() clientSocket: Socket,
  181. ) {
  182. // 从连接映射中获取对应IP的虚拟机WebSocket连接
  183. const vmSocket = this.vmConnections.get(data.ip);
  184. // 检查WebSocket连接是否打开
  185. if (vmSocket && vmSocket.readyState === WebSocket.OPEN) {
  186. // 发送设置捕获间隔的请求到虚拟机
  187. vmSocket.send(
  188. JSON.stringify({ type: 'setCaptureInterval', interval: data.interval }),
  189. );
  190. }
  191. }
  192. // 订阅前端发送的断开虚拟机连接请求
  193. @SubscribeMessage('disconnectFromVm')
  194. handleDisconnectFromVm(
  195. @MessageBody() data: { ip: string },
  196. @ConnectedSocket() clientSocket: Socket,
  197. ) {
  198. const vmSocket = this.vmConnections.get(data.ip);
  199. if (vmSocket) {
  200. // 关闭虚拟机连接,会触发虚拟机应用 WebSocket 连接的 close 事件 和 当前中转服务 vmSocket 的 close 事件
  201. vmSocket.close();
  202. // 从映射表中删除已断开的连接
  203. this.vmConnections.delete(data.ip);
  204. // 打印虚拟机断开连接的日志
  205. console.log(`disconnectFromVm:虚拟机 ${data.ip} 断开连接`);
  206. }
  207. }
  208. }

三、前端

1、安装依赖:

npm install socket.io-client

2、index.vue:

  1. <template>
  2. <div class="container">
  3. <div class="controls">
  4. <button @click="clearScreen" class="button">
  5. 网速:{{ netSpeed + 'ms' }} / FPS:{{ fps }}
  6. </button>
  7. <!-- 输入框用于输入虚拟机的IP地址 -->
  8. <input type="text" v-model="vmIp" placeholder="Enter VM IP" class="input" />
  9. <!-- 按钮用于连接到虚拟机 -->
  10. <button @click="connectVm" class="button">连接到虚拟机</button>
  11. <!-- 输入框用于设置屏幕捕获间隔 -->
  12. <input
  13. type="number"
  14. v-model="captureInterval"
  15. placeholder="Set Capture Interval (ms)"
  16. class="input"
  17. />
  18. <!-- 按钮用于设置捕获间隔 -->
  19. <button @click="setCaptureInterval" class="button">设置捕获间隔</button>
  20. <!-- 按钮用于发起屏幕捕获请求 -->
  21. <button @click="captureScreen" class="button">捕获屏幕</button>
  22. </div>
  23. <!-- 显示连接状态的信息 -->
  24. <div v-if="connectionStatus" class="status">{{ connectionMessage }}</div>
  25. <!-- 显示错误信息 -->
  26. <div v-if="errorMessage" class="error">{{ errorMessage }}</div>
  27. <!--
  28. 画布用于显示虚拟机的屏幕
  29. 鼠标按下事件
  30. 鼠标释放事件
  31. 鼠标移动事件
  32. 鼠标滚轮事件
  33. 键盘按键事件
  34. -->
  35. <canvas
  36. ref="screen"
  37. id="screen"
  38. width="1280"
  39. height="720"
  40. @mousedown.prevent="handleMouseDown"
  41. @mouseup.prevent="handleMouseUp"
  42. @click.prevent="handleClick"
  43. @contextmenu.prevent="handleRightClick"
  44. @mousemove.prevent="handleMouseMove"
  45. @dblclick.prevent="handleDoubleClick"
  46. @wheel.prevent="handleMouseWheel"
  47. @keydown.prevent="handleKeyDown"
  48. @keyup.prevent="handleKeyUp"
  49. @focus.prevent="handleFocus"
  50. @blur.prevent="handleBlur"
  51. tabindex="0"
  52. class="screen"
  53. ></canvas>
  54. </div>
  55. </template>
  56. <script setup>
  57. import { ref, onMounted } from 'vue'
  58. import { io } from 'socket.io-client'
  59. import { useEventListener } from '@vueuse/core'
  60. // 虚拟机IP地址
  61. const vmIp = ref('')
  62. // 屏幕捕获间隔,默认500毫秒
  63. const captureInterval = ref(1000 / 50)
  64. // 鼠标按下的状态
  65. const isMouseDown = ref(false)
  66. // 鼠标抬起的状态
  67. const isMouseUp = ref(false)
  68. // WebSocket是否连接的状态
  69. const isConnected = ref(false)
  70. // 错误信息
  71. const errorMessage = ref('')
  72. // 连接状态
  73. const connectionStatus = ref(false)
  74. // 连接状态信息
  75. const connectionMessage = ref('')
  76. // 网速
  77. const netSpeed = ref(0)
  78. // FPS
  79. const fps = ref(0)
  80. // 鼠标按下事件的定时器
  81. let mouseDownTimeout
  82. // 画布的DOM引用
  83. const screen = ref(null)
  84. const canvas = ref(null)
  85. const context = ref(null)
  86. // 缩放比例
  87. const scaleX = ref(1)
  88. const scaleY = ref(1)
  89. // 创建socket连接
  90. // http://localhost:8114/desktop
  91. // http://172.16.250.122:8114/desktop
  92. const socket = io('http://localhost:8114/desktop', {
  93. autoConnect: false // 禁止自动连接
  94. })
  95. const imagesData = ref({})
  96. // 用于存储每个图像的数据块信息
  97. function initImageData(imageId) {
  98. if (!imagesData.value[imageId]) {
  99. imagesData.value[imageId] = {
  100. receivedChunks: 0, // 接收到的数据包数量
  101. totalChunks: 0, // 总数据包数量
  102. renderCount: 0, // 渲染完成的数据包数量
  103. chunksCount: 0, // 已发送的数据块数量
  104. isLastChunk: false, // 是否最后一个数据块
  105. startTime: 0 // 开始时间
  106. }
  107. }
  108. }
  109. onMounted(() => {
  110. // 明确调用 connect 方法连接服务器
  111. setTimeout(() => {
  112. socket.connect()
  113. }, 1000)
  114. // 创建后台缓冲画布,在后台处理图像的渲染,然后再将处理后的图像渲染到前台画布上。提高渲染的平滑性
  115. const backCanvas = document.createElement('canvas')
  116. backCanvas.width = 1280
  117. backCanvas.height = 720
  118. const backContext = backCanvas.getContext('2d')
  119. // 获取画布引用
  120. canvas.value = screen.value
  121. // 获取画布上下文
  122. context.value = canvas.value.getContext('2d')
  123. // 设置画布焦点
  124. canvas.value.focus()
  125. const imageWorker = (data) => {
  126. return new Promise(async (resolve, reject) => {
  127. // 从事件中解构出所需的数据
  128. const { imageId, chunk, canvasWidth, canvasHeight, chunkWidth, chunkHeight, chunkIndex } =
  129. data
  130. // 确保接收到的数据包含必要的属性
  131. if (chunk && canvasWidth && canvasHeight) {
  132. // 创建一个离屏画布,离屏画布是一种可以在主线程之外的工作线程中使用的画布,可以避免阻塞主线程,提高页面的响应性
  133. const offscreenCanvas = new OffscreenCanvas(chunkWidth, chunkHeight)
  134. // 获取离屏画布的2D渲染上下文
  135. const offscreenContext = offscreenCanvas.getContext('2d')
  136. // 解码 Base64 编码的字符串
  137. const compressedBuffer = Uint8Array.from(atob(chunk), (c) => c.charCodeAt(0))
  138. // 解压缩数据
  139. // const decompressedBuffer = pako.inflate(compressedBuffer);
  140. // 使用 fflate 的 unzlibSync 方法解压缩数据
  141. // const decompressedBuffer = unzlibSync(compressedBuffer)
  142. // 如果后端传输的是Uint8Array格式的字符串的话
  143. // const uint8Array = new Uint8Array(Object.values(chunk))
  144. // 将接收到的图像数据转换为Blob对象
  145. const blob = new Blob([compressedBuffer], { type: 'image/jpeg' })
  146. // 创建图像位图。图像位图是一种可以直接用于drawImage方法的图像数据格式,使用它可以避免额外的图像解码步骤,提高渲染性能。
  147. const imageBitmap = await createImageBitmap(blob)
  148. // 计算图像数据块在画布上的位置
  149. const cols = Math.ceil(canvasWidth / chunkWidth)
  150. const x = (chunkIndex % cols) * chunkWidth
  151. const y = Math.floor(chunkIndex / cols) * chunkHeight
  152. // 清除画布上的所有内容,为新图像做准备
  153. // context.clearRect(x, y, chunkWidth, chunkHeight)
  154. // 在画布上绘制接收到的图像块数据,传入位图
  155. offscreenContext.drawImage(imageBitmap, 0, 0, chunkWidth, chunkHeight)
  156. // 转换为ImageBitmap,以便传输,ImageBitmap 对象可以在不同的上下文中使用,包括在 Worker 线程中,而且它的绘制性能通常比直接使用 Image 或 Canvas 对象更好。
  157. const transferredImageBitmap = offscreenCanvas.transferToImageBitmap()
  158. // 渲染到后台缓冲画布
  159. backContext.drawImage(transferredImageBitmap, x, y)
  160. // 关闭ImageBitmap,关闭有助于释放内存
  161. transferredImageBitmap.close()
  162. // 渲染完成一个数据块
  163. imagesData.value[imageId].renderCount++
  164. // 图像渲染完成的数据包数量 == 后端发送的数据包总量,表示一张图像的所有数据块都已经渲染完成
  165. if (imagesData.value[imageId].renderCount === imagesData.value[imageId].chunksCount) {
  166. // 将后台缓冲画布的内容复制到前台显示画布
  167. // 使用requestAnimationFrame来确保图像渲染在适当的时机
  168. requestAnimationFrame(() => {
  169. // 清除画布上的所有内容,为新图像做准备
  170. // context.clearRect(x, y, chunkWidth, chunkHeight)
  171. // 在画布上绘制接收到的图像块
  172. context.value.clearRect(0, 0, canvas.value.width, canvas.value.height)
  173. // 将后台缓冲画布的内容复制到前台显示画布
  174. context.value.drawImage(backCanvas, 0, 0)
  175. // 计算总渲染时间
  176. const endTime = Date.now()
  177. const totalTime = endTime - imagesData.value[imageId].startTime
  178. console.log(`${imagesData.value[imageId].startTime}:总渲染时间: ${totalTime}ms`)
  179. // FPS
  180. fps.value = Math.round(1000 / (totalTime - netSpeed.value))
  181. // 删除当前图像数据
  182. delete imagesData.value[imageId]
  183. // 通知服务器屏幕更新已完成,可以继续让服务器发送新的屏幕图像数据包
  184. // socket.emit('screenUpdateComplete', { ip: vmIp.value })
  185. resolve()
  186. })
  187. }
  188. } else {
  189. // 如果接收到的数据不完整,打印错误信息
  190. console.error('接收到的数据包不完整', event.data)
  191. }
  192. })
  193. }
  194. // 监听 screenUpdateChunk 事件
  195. socket.on('screenUpdate', (data) => {
  196. // 从事件数据中解构出imageBitmap
  197. /**
  198. * chunk: 图像某个数据块
  199. * overallWidth: 图像整体宽度
  200. * overallHeight: 图像整体高度
  201. * chunkWidth: 数据块宽度
  202. * chunkHeight: 数据块高度
  203. * chunkIndex: 数据块索引
  204. * chunksCount: 已发送的数据块数量
  205. * totalChunks: 总数据块数量
  206. * isLastChunk: 是否最当前图像最后一个数据块
  207. * startTime: 开始时间
  208. */
  209. const {
  210. imageId,
  211. chunk,
  212. overallWidth,
  213. overallHeight,
  214. chunkWidth,
  215. chunkHeight,
  216. chunkIndex,
  217. chunksCount,
  218. totalChunks,
  219. isLastChunk,
  220. startTime
  221. } = data
  222. // 计算缩放比例,以保持图像的纵横比不变,这是画布与虚拟机屏幕的缩放比例,主要用于交互操作
  223. scaleX.value = canvas.value.width / overallWidth
  224. scaleY.value = canvas.value.height / overallHeight
  225. // 确保所有属性都存在
  226. if (chunk && overallWidth && overallHeight && chunkWidth && chunkHeight) {
  227. // 初始化当前图像数据的缓存
  228. initImageData(imageId)
  229. // 更新这个 startTime 对应的图像数据块信息
  230. imagesData.value[imageId].receivedChunks++ // 接收到的数据块数量
  231. imagesData.value[imageId].totalChunks = totalChunks // 总数据块数量
  232. imagesData.value[imageId].chunksCount = chunksCount // 后端已发送的数据块数量
  233. imagesData.value[imageId].isLastChunk = isLastChunk // 是否是最后一个数据块
  234. imagesData.value[imageId].startTime = startTime // 后端当前图像处理的开始时间
  235. // 解码并渲染当前数据包
  236. try {
  237. // 方式二:使用Promise
  238. imageWorker({
  239. chunk, // 这是一个图像的其中一部分数据块base64编码的二进制数据
  240. canvasWidth: canvas.value.width, // 画布宽度
  241. canvasHeight: canvas.value.height, // 画布高度
  242. chunkWidth, // 图像数据块宽度
  243. chunkHeight, // 图像数据块高度
  244. chunkIndex, // 图像数据块索引
  245. imageId // 图像ID
  246. // chunkWidth: chunkWidth * scaleX.value, // 缩放后的数据块宽度,如果后端图片尺寸与前端画布尺寸不一致,需要通过缩放比例进行转换
  247. // chunkHeight: chunkHeight * scaleY.value, // 缩放后的数据块高度,如果后端图片尺寸与前端画布尺寸不一致,需要通过缩放比例进行转换
  248. })
  249. // 如果当前数据块是最后一个数据块,则通知服务器继续发送下一个图像数据
  250. if (imagesData.value[imageId].isLastChunk) {
  251. // 通知服务器屏幕更新已完成
  252. socket.emit('screenUpdateComplete', { ip: vmIp.value })
  253. }
  254. // 检查是否接收到所有数据包,当前图像接收到的数据包的数量 == 当前图像已发送的数据包数量
  255. if (imagesData.value[imageId].receivedChunks === imagesData.value[imageId].chunksCount) {
  256. const endTime = Date.now()
  257. const totalTime = endTime - imagesData.value[imageId].startTime // 计算总时间
  258. console.log(
  259. `${imagesData.value[imageId].startTime}:一个图像所有数据包传输时间: ${totalTime}ms`
  260. )
  261. // 网速
  262. netSpeed.value = totalTime
  263. }
  264. } catch (error) {
  265. console.error('Error decoding or inflating chunk:', error)
  266. }
  267. }
  268. // 如果当前数据块不存在就继续让服务器发送下一个图像数据
  269. if (!chunk) {
  270. // 通知服务器继续发送下一个图像数据
  271. socket.emit('screenUpdateComplete', { ip: vmIp.value })
  272. }
  273. })
  274. // 监听虚拟机连接状态事件,显示连接状态信息
  275. socket.on('vmConnectionStatus', (status) => {
  276. connectionStatus.value = true
  277. connectionMessage.value = `VM ${status.ip} is ${status.message}`
  278. console.log(`VM ${status.ip} is ${status.message}`)
  279. })
  280. // 监听虚拟机错误事件,显示错误消息
  281. socket.on('vmError', (error) => {
  282. errorMessage.value = `Error with VM ${error.ip}: ${error.message}`
  283. // 向服务器发送重新捕获屏幕的请求
  284. socket.emit('screenUpdateComplete', { ip: vmIp.value })
  285. })
  286. // 监听连接事件,更新连接状态
  287. socket.on('connect', () => {
  288. isConnected.value = true
  289. })
  290. // 监听断开连接事件,更新连接状态和显示错误消息
  291. socket.on('disconnect', () => {
  292. isConnected.value = false
  293. errorMessage.value = 'Disconnected from server'
  294. })
  295. })
  296. // 页面刷新或关闭时断开连接
  297. useEventListener(window, 'beforeunload', (event) => {
  298. // 在这里执行你需要的操作
  299. console.log('页面即将刷新或关闭')
  300. socket.emit('disconnectFromVm', { ip: vmIp.value })
  301. socket.disconnect()
  302. // 如果你需要阻止页面关闭,可以使用以下代码
  303. event.preventDefault()
  304. event.returnValue = ''
  305. })
  306. // 定义一个函数用于连接到虚拟机
  307. const connectVm = () => {
  308. // 使用WebSocket发送连接请求到指定的虚拟机IP
  309. socket.emit('connectToVm', { ip: vmIp.value })
  310. }
  311. // 定义一个函数用于设置屏幕捕获的时间间隔
  312. const setCaptureInterval = () => {
  313. // 发送设置捕获间隔的请求,间隔时间从captureInterval的值中获取并转换为整数
  314. socket.emit('setCaptureInterval', {
  315. ip: vmIp.value,
  316. interval: parseInt(captureInterval.value, 10)
  317. })
  318. }
  319. // 定义一个函数用于发送屏幕捕获请求
  320. const captureScreen = () => {
  321. // 使用WebSocket发送捕获屏幕的请求到指定的虚拟机IP
  322. socket.emit('capture', { ip: vmIp.value })
  323. }
  324. // 定义一个函数处理鼠标按下事件
  325. const handleMouseDown = (event) => {
  326. // console.log("鼠标按下", event);
  327. // 如果鼠标按下状态为true,则直接返回
  328. if (isMouseDown.value) {
  329. return
  330. }
  331. // 每次鼠标按下都需要重置鼠标抬起状态
  332. isMouseUp.value = false
  333. // 因为鼠标按下再抬起会导致触发鼠标点击,所以需要延迟300毫秒再执行按下操作,这样可以分辨出是点击还是按下
  334. mouseDownTimeout = setTimeout(() => {
  335. // 设置鼠标按下的状态为true
  336. isMouseDown.value = true
  337. // 发送鼠标点击动作
  338. sendMouseAction('mouseAction', event, 'mouseDown')
  339. }, 300)
  340. }
  341. // 定义一个函数处理鼠标抬起事件
  342. const handleMouseUp = (event) => {
  343. // console.log("鼠标释放", event);
  344. // 如果鼠标按下定时器存在,则清除定时器
  345. if (mouseDownTimeout) {
  346. clearTimeout(mouseDownTimeout)
  347. }
  348. // 如果鼠标按下状态为true,则发送鼠标抬起动作
  349. if (isMouseDown.value) {
  350. // 设置鼠标按下的状态为false
  351. isMouseDown.value = false
  352. // 设置鼠标抬起状态为true
  353. isMouseUp.value = true
  354. // 延迟300毫秒,因为鼠标抬起会导致自动触发鼠标点击
  355. setTimeout(() => {
  356. isMouseUp.value = false
  357. }, 300)
  358. if (event.button == 0) {
  359. // 发送鼠标抬起动作
  360. sendMouseAction('mouseAction', event, 'mouseUp')
  361. } else if (event.button == 2) {
  362. // 如果鼠标按下并且是右键抬起,则当做右键点击处理
  363. sendMouseAction('mouseAction', event, 'rightClick')
  364. }
  365. }
  366. }
  367. // 定义一个函数处理鼠标单机事件
  368. const handleClick = useDebounceFn((event) => {
  369. // 设置画布焦点
  370. canvas.value.focus()
  371. // console.log("鼠标点击", event);
  372. // 如果鼠标抬起状态为false,则发送鼠标点击动作
  373. if (!isMouseUp.value) {
  374. sendMouseAction('mouseAction', event, 'click')
  375. }
  376. }, 200)
  377. // 定义一个函数处理鼠标右键点击事件
  378. const handleRightClick = useDebounceFn((event) => {
  379. // console.log("鼠标右键点击", event);
  380. // 如果鼠标抬起状态为false,则发送鼠标点击动作
  381. if (!isMouseUp.value) {
  382. sendMouseAction('mouseAction', event, 'rightClick')
  383. }
  384. }, 200)
  385. // 定义一个函数处理鼠标移动事件
  386. const handleMouseMove = useDebounceFn((event) => {
  387. // 发送鼠标移动动作
  388. sendMouseAction('mouseAction', event, 'move')
  389. }, captureInterval.value)
  390. // 定义一个函数处理鼠标双击事件
  391. const handleDoubleClick = useDebounceFn((event) => {
  392. // console.log("鼠标双击", event);
  393. // sendMouseAction("mouseAction", event, "dblclick");
  394. }, 200)
  395. // 定义一个函数发送鼠标动作
  396. const sendMouseAction = (type, event, action) => {
  397. // 获取画布的位置信息,用于计算鼠标在画布上的相对位置
  398. const rect = screen.value.getBoundingClientRect()
  399. // 计算鼠标在画布上的相对位置,通过缩放比例进行转换
  400. const x = (event.clientX - rect.left) / scaleX.value
  401. const y = (event.clientY - rect.top) / scaleY.value
  402. const buttonMap = ['left', 'middle', 'right']
  403. const button = buttonMap[event.button] || 'left' // 默认使用 "left" 按钮
  404. // console.log('鼠标位置', action, x, y, button, event.detail)
  405. // 发送鼠标动作,包括动作类型、位置、按钮信息和是否双击
  406. socket.emit('vmAction', {
  407. ip: vmIp.value,
  408. action: {
  409. type: type, // 动作类型
  410. action, // 鼠标动作
  411. x: x, // 鼠标位置
  412. y: y, // 鼠标位置
  413. button, // 鼠标修饰键
  414. dblclick: event.detail == 2 // 是否双击
  415. }
  416. })
  417. }
  418. // 定义一个函数处理鼠标滚轮事件
  419. const handleMouseWheel = useDebounceFn((event) => {
  420. console.log('鼠标滚动', event)
  421. // 发送鼠标滚动动作,包括滚动的水平和垂直距离
  422. socket.emit('vmAction', {
  423. ip: vmIp.value,
  424. action: {
  425. type: 'mouseAction',
  426. action: 'scroll',
  427. dx: event.deltaX,
  428. dy: event.deltaY
  429. }
  430. })
  431. }, 200)
  432. // 定义一个函数处理键盘按下事件
  433. const handleKeyDown = (event) => {}
  434. // 记录最后一个被抬起的键和时间
  435. let lastKeyUp = { key: null, time: 0 }
  436. // 定义一个函数处理键盘抬起事件
  437. const handleKeyUp = (event) => {
  438. // 获取修饰键信息,比如shift、ctrl、alt、meta等
  439. const modifiers = {
  440. shift: event.shiftKey,
  441. ctrl: event.ctrlKey,
  442. alt: event.altKey,
  443. meta: event.metaKey
  444. }
  445. const action = {
  446. type: 'keyboardAction',
  447. // robotjs库不支持单独的键盘抬起事件,所以将所有的键盘抬起事件都当作键盘按下事件来处理
  448. action: 'press',
  449. // key是键盘按下的值,例如'a'、'1'、'ArrowUp'等
  450. key: event.key,
  451. // 修饰键信息
  452. modifiers: modifiers
  453. }
  454. // 如果抬起的键是字母键
  455. if (
  456. !(
  457. event.key === 'Shift' ||
  458. event.key === 'Control' ||
  459. event.key === 'Alt' ||
  460. event.key === 'Meta'
  461. )
  462. ) {
  463. console.log('组合键或字母键', action)
  464. socket.emit('vmAction', {
  465. ip: vmIp.value,
  466. action
  467. })
  468. // 更新最后一个被抬起的键和时间
  469. lastKeyUp = { key: event.key, time: Date.now() }
  470. }
  471. // 在同时抬起修饰键和字母键时,如果先抬起修饰键,那么需要等待一段时间获取字母键抬起的信息
  472. setTimeout(() => {
  473. // 如果抬起的键是修饰键
  474. if (
  475. event.key === 'Shift' ||
  476. event.key === 'Control' ||
  477. event.key === 'Alt' ||
  478. event.key === 'Meta'
  479. ) {
  480. console.log('修饰符抬起', Date.now() - lastKeyUp.time)
  481. // 如果抬起修饰键的时间与抬起字母键的时间差大于500毫秒,那么就发送修饰键的信息
  482. if (Date.now() - lastKeyUp.time > 500) {
  483. console.log('修饰键', action)
  484. socket.emit('vmAction', {
  485. ip: vmIp.value,
  486. action
  487. })
  488. }
  489. }
  490. }, 200)
  491. }
  492. // 定义一个函数处理画布获得焦点事件
  493. const handleFocus = () => {
  494. console.log('画布获得焦点')
  495. }
  496. // 定义一个函数处理画布失去焦点事件
  497. const handleBlur = () => {
  498. console.log('画布失去焦点')
  499. }
  500. </script>
  501. <style scoped lang="scss">
  502. .container {
  503. display: flex;
  504. flex-direction: column;
  505. align-items: center;
  506. height: 100vh;
  507. background-color: #f0f0f0;
  508. padding: 20px;
  509. box-sizing: border-box;
  510. }
  511. .controls {
  512. display: flex;
  513. align-items: center;
  514. gap: 10px;
  515. margin-bottom: 20px;
  516. }
  517. .input {
  518. margin: 5px 0;
  519. padding: 10px;
  520. border: 1px solid #ccc;
  521. border-radius: 4px;
  522. width: 150px;
  523. }
  524. .button {
  525. margin: 5px 0;
  526. padding: 10px 10px;
  527. border: none;
  528. border-radius: 4px;
  529. background-color: #007bff;
  530. color: white;
  531. cursor: pointer;
  532. width: 185px;
  533. }
  534. .button:hover {
  535. background-color: #0056b3;
  536. }
  537. .status {
  538. color: green;
  539. margin-bottom: 10px;
  540. }
  541. .error {
  542. color: red;
  543. margin-bottom: 10px;
  544. }
  545. .screen {
  546. border: 1px solid #ccc;
  547. background-color: white;
  548. }
  549. </style>
标签: vue.js node.js windows

本文转载自: https://blog.csdn.net/m0_60188097/article/details/139705724
版权归原作者 焚木灵 所有, 如有侵权,请联系我们删除。

“VUE3 + Node + nestjs 实现 web远程桌面(windows版)”的评论:

还没有评论