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版本
    安装Visual Studio C++编译工具: 下载Visual Studio 2022 Community版
    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 文件

{
  "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">
    <div class="controls">
      <button @click="clearScreen" class="button">
        网速:{{ netSpeed + 'ms' }} / FPS:{{ fps }}
      </button>
      <!-- 输入框用于输入虚拟机的IP地址 -->
      <input type="text" v-model="vmIp" placeholder="Enter VM IP" class="input" />
      <!-- 按钮用于连接到虚拟机 -->
      <button @click="connectVm" class="button">连接到虚拟机</button>
      <!-- 输入框用于设置屏幕捕获间隔 -->
      <input
        type="number"
        v-model="captureInterval"
        placeholder="Set Capture Interval (ms)"
        class="input"
      />
      <!-- 按钮用于设置捕获间隔 -->
      <button @click="setCaptureInterval" class="button">设置捕获间隔</button>
      <!-- 按钮用于发起屏幕捕获请求 -->
      <button @click="captureScreen" class="button">捕获屏幕</button>
    </div>
    <!-- 显示连接状态的信息 -->
    <div v-if="connectionStatus" class="status">{{ connectionMessage }}</div>
    <!-- 显示错误信息 -->
    <div v-if="errorMessage" class="error">{{ errorMessage }}</div>
    <!-- 
      画布用于显示虚拟机的屏幕
      鼠标按下事件
      鼠标释放事件
      鼠标移动事件
      鼠标滚轮事件
      键盘按键事件
    -->
    <canvas
      ref="screen"
      id="screen"
      width="1280"
      height="720"
      @mousedown.prevent="handleMouseDown"
      @mouseup.prevent="handleMouseUp"
      @click.prevent="handleClick"
      @contextmenu.prevent="handleRightClick"
      @mousemove.prevent="handleMouseMove"
      @dblclick.prevent="handleDoubleClick"
      @wheel.prevent="handleMouseWheel"
      @keydown.prevent="handleKeyDown"
      @keyup.prevent="handleKeyUp"
      @focus.prevent="handleFocus"
      @blur.prevent="handleBlur"
      tabindex="0"
      class="screen"
    ></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">
.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>

前后端分离是一种开发模式,它将前端和后端的开发分离,前端主要负责用户界面的展示和交互,后端主要负责数据的处理和存储。Vue2、Node.js和MySQL可以结合使用来实现前后端分离。 首先,我们可以使用Vue2作为前端框架,通过它来开发用户界面。Vue2提供了一套响应式的数据绑定和组件化的架构,使得前端开发更加高效和灵活。我们可以使用Vue的官方脚手架工具vue-cli来快速搭建项目的基础结构。 其次,Node.js可以用作后端技术,作为一个基于事件驱动的服务器端JavaScript运行环境,它提供了丰富的模块和工具,使得后端开发更加便捷。我们可以使用Express框架来构建Node.js的后端应用,通过定义路由和处理请求,与前端进行数据的交互。 最后,MySQL是一个开源的关系型数据库管理系统,它可以存储和管理数据。我们可以使用Node.js的mysql库来连接和操作MySQL数据库,通过编写SQL语句来实现数据的增删改查。 在实际开发中,前端通过Ajax或者Axios等工具向后端发送请求,后端接收请求后,通过与MySQL数据库的交互来获取或处理数据,并将结果返回给前端。前端通过Vue2的数据绑定和渲染机制,将后端返回的数据展示在用户界面上。 通过Vue2、Node.js和MySQL的组合,我们可以实现一个完整的前后端分离的应用程序。Vue2提供了优秀的用户界面,Node.js作为后端技术提供了强大的功能和灵活性,MySQL作为数据库管理系统提供了数据的存储和管理。这样的开发模式可以提高开发效率和代码的维护性,同时也能够实现更好的用户体验和性能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值