如何用webRTC实现远程桌面控制?(超详细记录,带源码)

先说一下项目背景:有两个web端,一个客服,一个用户。客服可以发起远程协助,用户端同意之后,可进行远程桌面操作。

就这个需求,需要准备哪些东西呢?

1.因为纯web端无法操作系统鼠标,所以需要使用electron开发一个桌面应用。

2.因为需要一个相对稳定的交互通信,所以使用webRTC来实现。(传输层除了tcp协议,还有UDP协议,与tcp不同的是,UDP建立的是长连接,速度快,但有可能会丢失数据包,所以更适合做视频,流媒体通信。WebRTC就是基于UDP协议的,所以很适合做远程桌面)

3.websocket服务(信令服务)webRTC建立连接的时候,需要通过websocket服务交换信息。(使用socket.io库)

4.两个web端(vue3)


在开始写代码之前,先梳理一下实现的步骤。

客户端:

1.客服点击按钮,通过websocket向用户端发起远程控制询问

2.收到用户同意,创建webrtc协议

3.监听鼠标键盘事件,并通过webrtc协议发送

用户端:

1.用户端收到消息,弹窗提示是否接受

2.点击接受,通过websocket向客服发送消息

3.跳转url,唤起electron应用(electron需注册协议)

electron端:

1.创建webrtc协议

2.监听鼠标键盘事件操作对应鼠标键盘

socket服务端:

1.发起/接受远程控制的消息询问

2.创建webrtc时的数据转发


socket服务端实现

实际项目的一些逻辑判断我就先不管了,只实现一个简易的demo。

当客户发送ready的时候,默认用户一定是同意的,就不弹窗了。

socket直接使用广播的形式,也不再判断房间号,用户是否匹配等问题。

javascript 复制代码
const Koa = require("koa");
const { createServer } = require("http");
const { Server } = require("socket.io");

const app = new Koa();
const httpServer = createServer(app.callback());
const io = new Server(httpServer, {
  /* options */
});

io.on("connection", (socket) => {
  console.log("connect success");
  socket.on("ready", () => {
    // console.log("ready");
    socket.broadcast.emit("ready");
  });

  socket.on("offer", (params) => {
    socket.broadcast.emit("offer", params);
  });

  socket.on("answer", (params) => {
    socket.broadcast.emit("answer", params);
  });

  socket.on("toCustomCandidate", (params) => {
    socket.broadcast.emit("toCustomCandidate", params);
  });

  socket.on("toUserCandidate", (params) => {
    socket.broadcast.emit("toUserCandidate", params);
  });

  socket.on("mousemove", (params) => {
    socket.broadcast.emit("mousemove", params);
  });

  socket.on("stream", (params) => {
    console.log("params", params);
  });
});

httpServer.listen(3000, () => {
  console.log("Server running on http://localhost:3000");
});

用户端

用户端比较简单,监听到ready事件之后,直接跳转url。

dart 复制代码
socket.on("ready", async () => {
    window.open("remote://test?age=1")
})

remote这个协议需要在electron中实现。

electron端

1.注册remote协议,以便通过url唤起

ini 复制代码
const scheme = "remote";
let isSet = false
protocol.registerSchemesAsPrivileged([
  {
    scheme: scheme,
    privileges: {
      bypassCSP: true,
    },
  },
]);

app.removeAsDefaultProtocolClient(scheme);

if (process.env.NODE_ENV === "development" && process.platform === "win32") {
    isSet = app.setAsDefaultProtocolClient(scheme, process.execPath, [
      path.resolve(process.argv[1]),
    ]);
} else {
    isSet = app.setAsDefaultProtocolClient(scheme);
}

通过url唤起之后,url上的参数获取苹果跟window略有差别。

苹果端可以通过app.on("open-url", (event, url)=> {})来获取。

在window上,只能通过process.argv中获取。

2.创建PeerConnection实例

csharp 复制代码
    // electron端
    peer.value = new PeerConnection({
      iceServers: [
        {
          urls: ["stun:stun.l.google.com:19302"],
        },
        {
          urls: ["turn:wangxiang.website:3478"],
          username: "admin",
          credential: "admin",
        },
      ],
    });
    let offer = await peer.value.createOffer();
    await peer.value.setLocalDescription(offer);
    socket.emit("offer", offer);
    socket.on("answer", async (answer) => {
        await peer.value.setRemoteDescription(answer);
    });

2个web端创建PeerConnection实例方式基本是一样的。(其中iceServers表示ICE服务器的配置,是为了确保两个对等端能够成功通信,我直接用的是网上现成的)

创建实例之后,发起方创建offer,并设置本地描述,同时通过socket发送给接收方,接收方收到offer之后同样的设置远程描述与本地描述。

csharp 复制代码
 // 客服端
  socket.on("offer", async (offer) => {
    if (peer) {
      await peer.setRemoteDescription(offer);
      let remoteAnswer = await peer.createAnswer();
      await peer.setLocalDescription(remoteAnswer);
      socket.emit("answer", {
        remoteAnswer,
        conversationId,
      });
    }
  });

3.添加监听Candidate

csharp 复制代码
  //electron端
  socket.on("toUserCandidate", (candidate) => {
    console.log("toUserCandidate");
    peer.addIceCandidate(candidate);
  });
  
   peer.onicecandidate = (event) => {
      console.log("localPc:", event.candidate, event);
      if (event.candidate) {
        socket.emit("toStaffCandidate", {
          candidate: event.candidate,
          ...params,
        });
      }
    };
typescript 复制代码
  // 客服端
  peer.onicecandidate = (e) => {
    if (e.candidate) {
      socket.emit("toUserCandidate", {
        candidate: e.candidate,
        conversationId,
      });
    }
  };
  socket.on("toStaffCandidate", async (candidate) => {
    await peer.addIceCandidate(candidate);
  });
  // 远程桌面的视频流
  peer.onaddstream = (e: any) => {
    console.log("onaddstream");
    try {
      setVideo(e.stream);
    } catch (ex) {}
  };

4. 处理鼠标键盘事件

javascript 复制代码
    // preload.js
    channel.onmessage = (e) => {
      var eventData = JSON.parse(e.data);
      if (eventData.type === "scroll") {
        ipcRenderer.send("scroll", { x: eventData.x, y: eventData.y });
      } else if (eventData.type === "mousemove") {
        ipcRenderer.send("mousemove", { x: eventData.x, y: eventData.y });
      } else if (eventData.type === "keydown") {
        ipcRenderer.send("keydown", { key: eventData.key });
      } else if (eventData.type === "mousedown") {
        ipcRenderer.send("mousedown", { key: eventData.key });
      } else if (eventData.type === "mouseup") {
        ipcRenderer.send("mouseup", { key: eventData.key });
      } else if (eventData.type === "copy") {
        ipcRenderer.send("copy", { key: eventData.key });
      } else if (eventData.type === "paste") {
        ipcRenderer.send("paste", { key: eventData.key });
      }
    };
    
    // main.js
     ipcMain.on("keydown", (e, { key }) => {
        try {
          robot.keyTap(key);
        } catch (error) {
          log.warn("keydown error", error);
        }
      });

      ipcMain.on("copy", (e, { key }) => {
        robot.keyTap("c", ["control"]);
      });

      ipcMain.on("paste", (e, { key }) => {
        robot.keyTap("v", ["control"]);
      });

      ipcMain.on("mousedown", (e, { key }) => {
        robot.mouseToggle("down");
      });

      ipcMain.on("mouseup", (e, { key }) => {
        robot.mouseToggle("up");
      });

      ipcMain.on("mousemove", (e, { x, y }) => {
        robot.moveMouse(x * (screenWidth / 1280), y * (screenHeight / 720));
      });

鼠标事件的处理用的是robotjs这个库,我安装的时候有点问题,所以用的是@jitsi/robotjs。

这个库在window上打包一切正常,但是在mac上打包未生效。

客户端

客户端的RTCPeerConnection实例跟electron端创建方式是一样的,区别也在介绍electron时说明了。

鼠标事件以及键盘事件,有些特殊的按键以及功能需要做兼容,比如换行,回车按键以及复制粘贴功能。

javascript 复制代码
import { Socket } from "socket.io-client";
import { throttle } from "./help";
const PeerConnection =
  window.RTCPeerConnection ||
  // @ts-ignore
  window.mozRTCPeerConnection ||
  // @ts-ignore
  window.webkitRTCPeerConnection;
const PEERCONFIG = {
  iceServers: [
    {
      urls: ["stun:stun1.l.google.com:19302"],
    },
    {
      urls: ["turn:wangxiang.website:3478"],
      username: "admin",
      credential: "admin",
    },
  ],
};

let peer: RTCPeerConnection;
let isCom = false;
export function initPeer(socket: Socket, conversationId: string) {
  // 1.创建
  peer = new PeerConnection(PEERCONFIG);
  // 2.监听通道数据
  peer.ondatachannel = function (event) {
    const channel = event.channel;
    channel.onopen = function () {
      console.log("onopen");
      eventListen(channel);
    };
    channel.onmessage = function (event) {
      console.log("onmessage");
      console.log(event.data);
    };
  };
  peer.onconnectionstatechange = () => {
    if (peer.connectionState === "disconnected") {
      socket.emit("remoteClose", {
        conversationId,
      });
    }
  };
  // 3.监听offer
  socket.on("offer", async (offer) => {
    console.log("offer");
    if (peer) {
      await peer.setRemoteDescription(offer);
      let remoteAnswer = await peer.createAnswer();
      await peer.setLocalDescription(remoteAnswer);
      socket.emit("answer", {
        remoteAnswer,
        conversationId,
      });
    }
  });
  // 4.监听Candidate
  socket.on("toStaffCandidate", async (candidate) => {
    await peer.addIceCandidate(candidate);
  });
  // 5.监听stream
  // @ts-ignore
  peer.onaddstream = (e: any) => {
    console.log("onaddstream");
    try {
      setVideo(e.stream);
    } catch (ex) {}
  };

  peer.onicecandidate = (e) => {
    if (e.candidate) {
      socket.emit("toUserCandidate", {
        candidate: e.candidate,
        conversationId,
      });
    }
  };

  window.addEventListener("remoteClose", () => {
    socket.emit("remoteClose", {
      conversationId,
    });
  });

  const tip = document.getElementById("remote-tip");
  tip!.style.display = "block";
  const vid2 = document.getElementById("remote-control");
  // @ts-ignore
  vid2.srcObject = null;
}

function eventListen(channel: RTCDataChannel) {
  const vid2 = document.getElementById("remote-control");
  vid2!.addEventListener("wheel", (event: any) => {
    channel.send(
      JSON.stringify({
        x: 0,
        y: event.wheelDelta,
        type: "scroll",
      }),
    );
  });

  vid2!.addEventListener("mousedown", (event) => {
    channel.send(
      JSON.stringify({
        x: event.offsetX,
        y: event.offsetY,
        type: "mousedown",
      }),
    );
  });

  vid2!.addEventListener("mouseup", (event) => {
    channel.send(
      JSON.stringify({
        x: event.offsetX,
        y: event.offsetY,
        type: "mouseup",
      }),
    );
  });

  const moveEvent = throttle((event) => {
    channel.send(
      JSON.stringify({
        x: event.offsetX,
        y: event.offsetY,
        type: "mousemove",
      }),
    );
  }, 50);

  vid2!.addEventListener("mousemove", (event) => {
    moveEvent(event);
  });

  document.addEventListener("keydown", (event) => {
    if (isCom && event.key === "c") {
      console.log("copy");
      channel.send(
        JSON.stringify({
          key: "",
          type: "copy",
        }),
      );
      return;
    }

    if (isCom && event.key === "v") {
      console.log("paste");
      channel.send(
        JSON.stringify({
          key: "",
          type: "paste",
        }),
      );
      return;
    }
    channel.send(
      JSON.stringify({
        key: transKey(event.key),
        type: "keydown",
      }),
    );
  });

  document.addEventListener("keyup", (event) => {
    isCom = false;
  });
}

function transKey(key: string) {
  if (key === "Backspace") {
    return "backspace";
  } else if (key === "ArrowLeft") {
    return "left";
  } else if (key === "ArrowUp") {
    return "up";
  } else if (key === "ArrowRight") {
    return "right";
  } else if (key === "ArrowDown") {
    return "down";
  } else if (key === "Meta") {
    isCom = true;
    return "command";
  } else if (key === "Control") {
    isCom = true;
    return "control";
  } else if (key === "Enter") {
    return "\r";
  } else if (key === "Shift") {
    return "shift";
  }
  return key;
}

function setVideo(stream: RTCTrackEvent["streams"]) {
  const vid2 = document.getElementById("remote-control");
  const tip = document.getElementById("remote-tip");
  tip!.style.display = "none";
  // @ts-ignore
  vid2.srcObject = stream;
  // @ts-ignore
  vid2.onloadedmetadata = function () {
    // @ts-ignore
    vid2.play();
  };
}

这个远程控制功能还是蛮复杂的,主要是用到的端太多了。

我在正式开始做这个功能之前,也是先写了测试demo,等demo流程都走通之后,才开始实现功能+逻辑。因为实际项目中,还有房间号,客服和用户是否匹配,是否重复远程,是否结束等各种逻辑判断。

demo地址 github.com/yeshaojun/w... (这个是没有electron端,两个web端实现桌面共享,但无法同步鼠标操作)

electron github.com/yeshaojun/w... (客服端,跟socket服务用上个demo的,用户端用electron)

如无法实现或访问,可留言。

相关推荐
圣光SG18 小时前
Java类与对象及面向对象基础核心详细笔记
java·前端·数据库
Jinuss18 小时前
源码分析之React中的useImperativeHandle
开发语言·前端·javascript
ZC跨境爬虫18 小时前
CSS核心知识点与定位实战全解析(结合Playwright爬虫案例)
前端·css·爬虫
Jinuss18 小时前
源码分析之React中的forwardRef解读
前端·javascript·react.js
mengsi5518 小时前
Antigravity IDE 在浏览器上 verify 成功但本地 IDE 没反应 “开启Tun依然无济于事” —— 解决方案
前端·ide·chrome·antigravity
Можно18 小时前
pages.json 和 manifest.json 有什么作用?uni-app 核心配置文件详解
前端·小程序·uni-app
hzhsec18 小时前
钓鱼邮件分析与排查
服务器·前端·安全·web安全·钓鱼邮件
#做一个清醒的人19 小时前
Electron 保活方案:用子进程彻底解决原生插件崩溃问题
前端·electron·node.js
四千岁19 小时前
Obsidian + jsDelivr + PicGo = 免费无限图床:一键上传,全平台粘贴即发
前端·程序员·github