likes
comments
collection
share

【全栈】从0到1实现一个远程控制软件【Electron+Vue3+Vite+TS+Robotjs】项目介绍 【野百合远程

作者站长头像
站长
· 阅读数 140

项目介绍

【野百合远程控制】是我的第 2 个全栈项目

前端部分:基于 Electron 从 0 到 1 实现了一个 PC 桌面端远程控制软件。 仓库:gitee.com/adamswan/Wi…

后端部分:采用 Nodejs 实现的 websocket 服务,以简单支撑前端的业务。 仓库:gitee.com/adamswan/Wi…

【全栈】从0到1实现一个远程控制软件【Electron+Vue3+Vite+TS+Robotjs】项目介绍 【野百合远程

技术栈

前端部分:Electron、Vue3、Vite、TypeScript、Element Plus、Robotjs、Mitt、Electron-Vite CLI 等分:Electron、Vue3、Vite、TypeScript、Element Plus、Robotjs、Mitt、Electron-Vite CLI 等。安利Electron-Vite这个脚手架,比自己搭环境方便多了electron-vite.github.io/guide/getti…

后端部分:websocket服务、lodash等

野百合的使用

根据关系分为两个端,控制端,就是能控制对方电脑桌面的端;傀儡端,就是电脑桌面被控制的一方。

双方都使用同一个客户端,服务端会给每个客户端生成一个 6 位数字的控制码。

控制方输入控制码,即可打开另一个大窗口,实时显示傀儡端的电脑桌面。控制方使用自己的键鼠在大窗口内操作,傀儡端的键鼠将自动同步控制方的任何操作。

实现思路

1. 捕获傀儡端的桌面实时画面

Electron 提供的 desktopCapturer.getSources() API 能捕获傀儡端的桌面画面,并形成视频流

2. 获取傀儡端的视频流数据

浏览器提供的 navigator.mediaDevices.getDisplayMedia() API 能获取上一步得到的视频流

3. 传输傀儡端的视频流数据

使用 webRTC 技术的 P2P 技术能建立两个端之间的连接,并传输数据。

大致过程如下:

  1. 双方都 new 一个 RTCPeerConnection 对象,控制端发送一个 SDP 邀请给傀儡端,傀儡端接收到 SDP 邀请后,将其设置为自己的 remote 远端,然后再回应一个 SDP 应答,并将视频流塞进去。
  2. 控制端收到 SDP 应答,将其也设定为自己的 remote 远端,即双方互为彼此的远端,双向奔赴了。
  3. 至此,P2P 建立完成,视频传输完成。当然,这个过程还涉及了NAT穿透技术,web 做的主要事情就是使用 IceCandidate 获取双方的公网IP和端口号。

4. 控制端显示视频

事先准备一个空的 video 标签,监听 addstream 事件,将傀儡端的视频流塞入 video 标签的 srcObject 属性,即可在控制端大窗口内显示傀儡端的桌面。

5. 控制端操作傀儡端的键鼠

  1. 监听控制端的键鼠操作,将键鼠事件的数据通过 RTCPeerConnection 对象上的RTCDataChannel 对象,借助 P2P,将数据传给傀儡端。
  2. 傀儡端监听数据,通过 Electron 的 node 环境下的 robotjs 模块,将数据转成实际的键鼠操作,这样就实现了对傀儡端的键鼠控制。

前端代码

结构

controlPage.vue 启动页

<template>
    <div class="container">
        <img :src="lily" class="lily"></img>
        <h3 v-show="isShow">您的控制码: {{ localCode }}</h3>
        <h3 v-show="controlText.length !== 0">当前状态: {{ controlText }}</h3>
        <el-divider v-show="isShow" />
        <el-form v-show="isShow" ref="ruleFormRef" style="max-width: 600px" :model="ruleForm" :rules="rules"
            label-width="auto" class="demo-ruleForm" size="large" status-icon>
            <el-form-item label="控制码" prop="remoteCode">
                <el-input v-model.number="ruleForm.remoteCode" placeholder="请输入对方控制码" />
            </el-form-item>
            <el-form-item class="btn-to-right">
                <el-button type="primary" @click="submitForm(ruleFormRef)">
                    连接
                </el-button>
            </el-form-item>
        </el-form>
    </div>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ControlInfo, RuleForm } from '../Types/controlPage';
import lily from '../assets/lily.jpg?url'

const ruleFormRef = ref<FormInstance>()

const ruleForm = reactive<RuleForm>({
    // 远程控制码
    remoteCode: ''
})

// 本地控制码
const localCode = ref<number>(88888)
const setLocalCode = async (code: Promise<any>) => {
    let res = await code
    localCode.value = res
}

// 控制后的文字提示
const controlText = ref<string>('')
const setControlText = (status: string) => {
    controlText.value = status
}
const isShow = computed(() => {
    if (controlText.value.length === 0) {
        return true
    }
    return false
})

const checkRemoteCode = (rule: any, value: any, callback: any) => {
    if (!value) {
        return callback(new Error('不能为空'))
    }

    if (Number.isInteger(value) === false) {
        callback(new Error('只能为数字'))
    }
    if (value.toString().length !== 6) {
        callback(new Error('长度只能6位'))
    }
    callback()
}
const rules = reactive<FormRules<RuleForm>>({
    remoteCode: [{ validator: checkRemoteCode, trigger: 'change' }],
})

// 开始控制
const startControl = (code: number | string) => {
    // 触发预加载脚本的自定义函数
    (window as any).myAPI.startControl(code)
}

const submitForm = async (formEl: FormInstance | undefined) => {
    if (!formEl) return
    await formEl.validate((valid, fields) => {
        if (valid) {
            startControl(ruleForm.remoteCode)
        } else {
            console.log('error submit!', fields)
        }
    })
}

const login = async () => {
    // 让主进程去登录,获取本地状态码
    const code = (window as any).myAPI.doLogin()
    // 设置本地状态码
    setLocalCode(code)
}

const wiredFun = () => {
    // 监听傀儡端被控制
    (window as any).myAPI.pupeIsControled()
        .then((remote: number) => {
            setControlText(`被${remote}远程控制中...`)
        })
}

// 监听主进程发的控制消息
(window as any).myAPI.controlStateChange().then((obj: ControlInfo) => {
    const { type, name } = obj
    if (type === 1) { // 在控制别人
        setControlText(`正在远程控制${name}`)
    }
})

onMounted(() => {
    login()
    wiredFun()
})
</script>

<style scoped lang="less">
.container {
    overflow: hidden;
    height: 100%;
    padding-top: 30px;
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
    background-color: rgba(244, 244, 244);
    .btn-to-right {
        :deep(.el-form-item__content) {
            justify-content: end;
        }

    }
    .lily {
        width: 200px;
        height: 200px;
    }
}
</style>

启动窗口,启动页的html

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>野百合远程控制</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

第二窗口,显示视频的html

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>野百合远程控制</title>
  <style>
    * {
      margin: 0;
    }
    #screen-video {
      width: 100%;
      height: 100%;
      object-fit: fill;
    }
  </style>
</head>

<body>
  <video id="screen-video"></video>
  <script type="module" src="/src/main.ts"></script>
</body>

</html>

main.ts 主进程

import { app, BrowserWindow, ipcMain, desktopCapturer, session } from 'electron'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { getDeskRealTimeVideoStream } from './getRealTime.ts'
import './robotToControlUser.ts'
import { autoLogin, sendDataToControl, listenToBeControl, forwardInfo } from './websocket.ts'
import { NumOrStr } from './lily'

const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))

// The built directory structure
//
// ├─┬─┬ dist
// │ │ └── index.html
// │ │
// │ ├─┬ dist-electron
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, '..')

// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')

process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST

// 初始窗口
let win: BrowserWindow | null
// 用于 webRTC 的新窗口
let newWin: BrowserWindow

function createWindow() {
  win = new BrowserWindow({
    width: 410,
    height: 530,
    autoHideMenuBar: true,
    resizable: false,
    icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
    webPreferences: {
      preload: path.join(__dirname, 'preload.mjs'),
    },
  })

  // Test active push message to Renderer-process.
  win.webContents.on('did-finish-load', () => {
    win?.webContents.send('main-process-message', (new Date).toLocaleString())
  })

  if (VITE_DEV_SERVER_URL) {
    win.loadURL(VITE_DEV_SERVER_URL)
    // win.webContents.openDevTools() //自动打开控制台
  } else {
    // win.loadFile('dist/index.html')
    win.loadFile(path.join(RENDERER_DIST, 'index.html'))
  }

  // 监听窗口关闭事件  
  win.on('close', () => {
    // 初始窗口也一并关闭
    newWin?.close()
    app.quit()
  });
}

function createNEWWindow() {
  newWin = new BrowserWindow({
    width: 1500,
    height: 888,
    autoHideMenuBar: true,
    x: 0,
    y: 0,
    webPreferences: {
      preload: path.join(__dirname, 'preload.mjs'),
      nodeIntegration: true,
      webSecurity: false,
      // contextIsolation: false,
    }
  })
}

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
    win = null
  }
})

app.on('activate', () => {
  // On OS X it's common to re-create a window in the app when the
  // dock icon is clicked and there are no other windows open.
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

app.whenReady().then(createWindow)


// 1、处理登录
handleLogin()
function handleLogin() {
  ipcMain.handle('login', async () => {
    // 先返回一个假数据
    const { code } = await autoLogin('login', null) as any
    return code
  })
}

// 2、监听控制端发起的控制行为
linstenToControl()
function linstenToControl() {
  ipcMain.on('control', async (event, code) => {
    await sendDataToControl('control', { 'remote': code })
    controlSuccess(1, code)
  })
}

// 3、当控制成功时
async function controlSuccess(type: number, name: number) {
  // 通知渲染进程控制成功了
  win?.webContents.send('controlStateChange', { type, name })
  // 新建窗口
  createNEWWindow()
  if (newWin) {
    // 捕获傀儡端的实时视频流
    getDeskRealTimeVideoStream(desktopCapturer, session)

    //! 坑: loadFile 方法通常用于加载本地文件系统中的 HTML 文件,而不是从开发服务器(如 Vite 开发服务器)加载。如果你的 HTML 文件是通过 Vite 打包或服务的,你应该使用 loadURL 方法并指向 Vite 开发服务器的 URL
    newWin.loadURL('http://localhost:5173/new-win-controled.html');

    // newWin.webContents.openDevTools(); // 自动打开F12  

    // 监听窗口关闭事件  
    newWin.on('close', () => {
      // 初始窗口也一并关闭
      win?.close()
      app.quit()
      win = null
    });
  }
}

// 4、告知傀儡端,它被控制了
tellPupeIsControled()
async function tellPupeIsControled() {
  const res: any = await listenToBeControl() // 开启监听
  win?.webContents.send('pupeIsControled', res.remote)
}

// 5、监听转发事件
listenForward()
function listenForward() {
  ipcMain.on('forward', (e, type, oData) => {
    forwardInfo(type, oData)
  })
}

// 小窗
export function mainToRender(channel: string, data: string) {
  win?.webContents.send(channel, data)
}

// 大窗
export function mainBigWinToRender(channel: string, data: string) {
  newWin?.webContents.send(channel, data)
}

getRealTime.ts 捕获傀儡端视频流

export const getDeskRealTimeVideoStream = (desktopCapturer: any, session: any) => {
    session.defaultSession.setDisplayMediaRequestHandler((request: any, callback: any) => {
        // 捕获电脑桌面
        desktopCapturer.getSources({ types: ['screen'] })
          .then((sources: any) => {
            // Grant access to the first screen found.
            callback({ video: sources[0], audio: 'loopback' })
          })
      })
}

websocket.ts 和服务端通信

import WebSocket from 'ws';
import mitt from 'mitt' // 发布订阅
import { mainBigWinToRender, mainToRender } from './main'
import { ipcMain } from 'electron';
import { Mitter, MsgObj } from './lily';

const ws: WebSocket = new WebSocket('ws://127.0.0.1:8088')

export const mitter = mitt() as Mitter


// 监听连接成功的事件
ws.on('open', () => {
    console.log('connect success')
})

// 监听 message 事件
ws.on('message', (message: string) => {
    let res = {} as MsgObj
    try {
        res = JSON.parse(message)
    } catch (err) {
        console.log('err', err)
    }
    // 向外抛出数据
    mitter.emit(res.action, res.data)
})

// 发数据
export function sendDataWithJSON(type: string, oData: object | null) {
    let sendData = {
        action: type
    } as MsgObj
    if (oData !== null) {
        sendData.data = oData
    }
    ws.send(JSON.stringify(sendData))
}

// 自动登录
export function autoLogin(type: string, oData: object | null) {
    return new Promise((resolve) => {
        sendDataWithJSON(type, oData)
        mitter.on('login-success', (data: object) => {
            resolve(data)
        })
    })
}

// 控制端发消息 要求控制对象
export function sendDataToControl(type: string, oData: object | null) {
    return new Promise((resolve) => {
        sendDataWithJSON(type, oData)
        mitter.on('control-success', (data: object) => {
            resolve(data)
        })
    })
}

// 傀儡端监听被控制的事件,切换页面文字
export function listenToBeControl() {
    return new Promise((resolve) => {
        mitter.on('controlled-by', (data: object) => {
            resolve(data)
        })
    })
}

// 转发消息
export function forwardInfo(type: string, oData: object) {
    return new Promise((resolve) => {
        sendDataWithJSON(type, oData)
        // 监听 forward 事件
        mitter.on('forward', (data: object) => {
            resolve(data)
        })
    })
}


ipcMain.on('pcOfferSendToWS', (e, offer) => {
    let oData = {
        action: 'pcoffer',
        data: {
            pcoffer: offer
        }
    }
    // 发出去
    ws.send(JSON.stringify(oData))
    // 监听回来的数据
    mitter.on('pcoffer-for-createAnswer', (data: MsgObj) => {
        // 主进程 -> 渲染进程
        mainToRender('gen-answer', data.res)
    })
})

ipcMain.on('send-answer', (e, answer) => {
    let oData = {
        action: 'answer',
        data: {
            answer: answer
        }
    }
    // 发出去
    ws.send(JSON.stringify(oData))
    mitter.on('answer-for-set-remote', (data: MsgObj) => {
        // 主进程 -> 渲染进程
        mainBigWinToRender('set-remote', data.res)
    })
})

ipcMain.on('send-candidate-to-small-win', (e, candidate) => {
    let oData = {
        action: 'candidate',
        data: {
            candidate: candidate
        }
    }
    // 发出去
    ws.send(JSON.stringify(oData))
    mitter.on('for-pupe-addIce', (data: any) => {
        // 主进程 -> 渲染进程
        mainToRender('set-addIce', data.res)
    })
})

robotToControlUser.ts 将键鼠事件转换为真实操作

import { ipcMain } from "electron"
import { createRequire } from 'node:module'
import { Keyboard, MouseupData } from "./lily"
const require = createRequire(import.meta.url)
const robot = require('robotjs')
const vkey = require('vkey')

// 控制傀儡端的鼠标操作
export const handleMouse = (data: MouseupData) => {
    let { clientX, clientY, screen, video } = data
    // 根据比例关系,转换为在傀儡端桌面真实的鼠标坐标
    let x = clientX * screen.width / video.width
    let y = clientY * screen.height / video.height
    // 移动鼠标到坐标位置
    robot.moveMouse(x, y)
    // 点击鼠标
    robot.mouseClick()
}

// 控制傀儡端的键盘操作
export const handleKey = (data: Keyboard) => {
    // 处理组合键
    const modifiers = [] // 收集组合键
    if (data.meta) {
        modifiers.push('meta')
    }
    if (data.shift) {
        modifiers.push('shift')
    }
    if (data.alt) {
        modifiers.push('alt')
    }
    if (data.ctrl) {
        modifiers.push('ctrl')
    }
    // 转换为真实按键名
    let key = vkey[data.keyCode].toLowerCase()
    if (key[0] !== '<') { // 过滤 <shift> 键
        // 按下按键
        robot.keyTap(key, modifiers)
    }
}

ipcMain.on('autoOperateMouse', (e, data) => {
    handleMouse(data)
})

ipcMain.on('autoOperateKeyboard', (e, data) => {
    console.log('autoOperateKeyboard', data)
    handleKey(data)
})

preload.ts 渲染进程

import { ipcRenderer, contextBridge } from 'electron'
import { listenToKey, listentoMouse } from './peer-control'

// --------- Expose some API to the Renderer process ---------
// 在这里向 window 上添加自定义的属性、方法
contextBridge.exposeInMainWorld('ipcRenderer', {
  on(...args: Parameters<typeof ipcRenderer.on>) {
    const [channel, listener] = args
    return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
  },
  off(...args: Parameters<typeof ipcRenderer.off>) {
    const [channel, ...omit] = args
    return ipcRenderer.off(channel, ...omit)
  },
  send(...args: Parameters<typeof ipcRenderer.send>) {
    const [channel, ...omit] = args
    return ipcRenderer.send(channel, ...omit)
  },
  invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
    const [channel, ...omit] = args
    return ipcRenderer.invoke(channel, ...omit)
  },

  // You can expose other APTs you need here.
  // ...

})

const myAPI = {
  // 登录
  doLogin: async () => {
    let res = await ipcRenderer.invoke('login')
    return res
  },
  // 控制状态发生变化
  controlStateChange: () => {
    return new Promise((resolve) => {
      ipcRenderer.on('controlStateChange', (event, data) => {
        resolve(data)
      })
    })
  },
  // 渲染进程向主进程单向通信,告知主进程,控制开始
  startControl: (code: string) => {
    ipcRenderer.send('control', code)
  },
  pupeIsControled: (event: any, remote: number) => {
    return new Promise((resolve) => {
      ipcRenderer.on('pupeIsControled', (event, remote) => {
        resolve(remote)
      })
    })
  }
}

contextBridge.exposeInMainWorld('myAPI', myAPI)

if (document.getElementById('screen-video')) {
  listenToKey() // 监听控制端键盘
  listentoMouse()// 监听控制端鼠标
}

peer-control.ts 建立P2P连接并传输视频与键鼠事件数据

import { ipcRenderer } from 'electron'
import mitte from 'mitt'
import { MouseupData } from './lily'

const mitter2 = mitte() // 发布订阅

const video: HTMLVideoElement = document.getElementById('screen-video') as HTMLVideoElement

export const getVideoStream = () => {
    return new Promise((resolve) => {
        navigator.mediaDevices.getDisplayMedia({
            audio: false,
            video: {
                width: { max: window.screen.width },
                height: { max: window.screen.height }
            }
        })
            .then(stream => {
                // 将视频流传出去
                resolve(stream)
            })
            .catch(e => console.log(e))
    })
}

// 1、创建控制端的 SDP 邀请
const pc: any = new window.RTCPeerConnection({})

// 创建控制端的 RTCDataChannel
const dataChannel: RTCDataChannel = pc.createDataChannel('robotchannel', { reliable: false })
dataChannel.onopen = () => {// 监听数据通道打开
    // 监听鼠标事件
    mitter2.on('mouseup', (obj) => {
        // 通过 dataChannel 发给傀儡端
        dataChannel.send(JSON.stringify(obj))
    })
    // 监听键盘事件
    mitter2.on('keydown', (obj) => {
        // 通过 dataChannel 发给傀儡端
        dataChannel.send(JSON.stringify(obj))
    })
}

dataChannel.onmessage = (event: MessageEvent<any>) => {
    console.log('dataChannel onmessage', event)
}
dataChannel.onerror = (event: Event) => {
    console.log('dataChannel onerror', event)
}

// webRTC NAT穿透:ICE, 交互式连接创建
let bFlag = true
pc.onicecandidate = function (e: RTCPeerConnectionIceEvent) {
    if (e.candidate && bFlag === true) {
        console.log('candidate', video, JSON.stringify(e.candidate))
        ipcRenderer.send('send-candidate-to-small-win', JSON.stringify(e.candidate))
        bFlag = false
    }
}

let candidateForControl: RTCIceCandidateInit[] = []

export async function addIceCandidateForControl(candidate: RTCIceCandidateInit) {
    if (candidate) {
        candidateForControl.push(candidate)
    }
    if (pc.remoteDescription && pc.remoteDescription.type) {
        for (let i = 0; i < candidateForControl.length; i++) {
            await pc.addIceCandidate(new RTCIceCandidate(candidateForControl[i]))
        }
        candidateForControl = []
    }
}

// 2、将 SDP 邀请发出去
const createOffer = async () => {
    const offer = await pc.createOffer({
        offerToReceiveAudio: false,
        offerToReceiveVideo: true
    })
    await pc.setLocalDescription(offer)
    console.log('pc offer', JSON.stringify(offer))
    return pc.localDescription
}
createOffer().then(async (offer) => {
    if (video !== null) { // 大窗口
        ipcRenderer.send('pcOfferSendToWS', JSON.stringify(offer))
    }

})

// 监听 addstream 事件,显示视频
pc.onaddstream = (e: any) => {
    console.log('add-stream', e.stream)
    // 给 video 标签设定视频流地址
    video.srcObject = e.stream
    // 元数据加载完成后播放
    video.onloadedmetadata = (e) => video.play()
}


// 傀儡端
const pc2: any = new window.RTCPeerConnection({})
// 监听通道
pc2.ondatachannel = (e: any) => {
    // 监听datachannel消息
    e.channel.onmessage = (event: any) => {
        let { type, widthAndHeight, data } = JSON.parse(event.data)
        if (type === 'mouse') { // 将数据传给robotjs, 让它自动控制鼠标
            data.screen = {
                width: widthAndHeight.windowWidth,
                height: widthAndHeight.windowHeight
            }
            ipcRenderer.send('autoOperateMouse', data)
        } else if (type === 'keyboard') {
            ipcRenderer.send('autoOperateKeyboard', data)
        }
    }
}

pc2.onicecandidate = function (e: any) {// webRTC NAT穿透:ICE, 交互式连接创建
    if (e.candidate) {
        ipcRenderer.send('forward', 'puppet-candidate', JSON.stringify(e.candidate))
    }
}

let count = 0
ipcRenderer.on('set-addIce', (e, candidate) => {
    if (!video) { // 小窗
        count++
        if (count === 3) {
            setTimeout(() => {
                addIceCandidateForPupe(JSON.parse(candidate))
            }, 1000)
        }

    }
})

let candidateForPupe: any = []
export async function addIceCandidateForPupe(candidate: any) {
    if (candidate) {
        candidateForPupe.push(candidate)
    }
    // if (pc2.remoteDescription && pc2.remoteDescription.type) {
    for (let i = 0; i < candidateForPupe.length; i++) {
        await pc2.addIceCandidate(new RTCIceCandidate(candidateForPupe[i]))
    }
    candidateForPupe = []
    // }
}


// 3、傀儡端收到 SDP 邀请,做 3 件事:
// 1) 将 SDP 设置为自己的 remote
// 2) 也创建一个 RTCPeerConnection 连接,并将视频流放进去
// 3)向控制端发送一个 SDP 应答
ipcRenderer.on('gen-answer', async (e, offer: string) => {
    if (!document.getElementById('screen-video')) {// 小窗
        let answer = await createAnswer(JSON.parse(offer))
        ipcRenderer.send('send-answer', JSON.stringify(answer))
    }
})


export async function createAnswer(offer: string) {
    let screenStream = await getVideoStream() // 获取视频流
    pc2.addStream(screenStream)
    await pc2.setRemoteDescription(offer)
    await pc2.setLocalDescription(await pc2.createAnswer())
    return pc2.localDescription
}


// 将傀儡端响应的 SDP 应答设置为控制端的 remote
export const setRemote = async (answer: string) => {
    await pc.setRemoteDescription(answer)
}

ipcRenderer.on('set-remote', (event, answer) => {
    if (video) {
        setRemote(JSON.parse(answer))
    }
})

// 监听控制端的键盘
export const listenToKey = () => {
    window.addEventListener('keydown', (e) => {
        let data = {
            keyCode: e.keyCode,
            shift: e.shiftKey,
            meta: e.metaKey,
            control: e.ctrlKey,
            alt: e.altKey
        }
        // 抛出键盘事件
        mitter2.emit('keydown', {
            type: "keyboard",
            data
        })
    })
}

// 监听控制端的鼠标
export const listentoMouse = () => {
    window.addEventListener('mouseup', (e) => {
        let data: Partial<MouseupData> = {}
        data.clientX = e.clientX
        data.clientY = e.clientY
        data.video = { // 获取视频区域的真实宽高
            width: video.getBoundingClientRect().width,
            height: video.getBoundingClientRect().height
        }
        // 抛出鼠标事件
        mitter2.emit('mouseup', {
            type: 'mouse',
            widthAndHeight: {
                windowWidth: window.screen.width,
                windowHeight: window.screen.height
            },
            data
        })
    })
}

后端代码

只有一个文件 websocketServer.js

const WebSocket = require('ws')
const { random } = require('lodash')

// 创建 WebSocket 服务
const wss = new WebSocket.Server({
    port: 8088
})

// 建立 6 位随机数与 ws 实例的映射
// 客户端、傀儡端都会登录,所以会产生两个 Map 成员
const map = new Map()

wss.on('connection', function connection(ws, request) { // 监听 connection 事件
    // 新增方法:发送格式化的数据
    ws.sendDataWithJSON = (type, oData) => {
        ws.send(JSON.stringify({
            action: type,
            data: oData
        }))
    }

    // 新增方法:处理错误请求数据
    ws.sendError = (oData) => {
        ws.send(JSON.stringify({
            action: 'error',
            data: oData
        }))
    }

    // 生成 6 位随机数
    const randomNum = random(100000, 999999, false)
    console.log('6 位随机数', randomNum)

    map.set(randomNum, ws)

    // 监听 message 事件
    listenMsg(randomNum, ws)

    // 监听 close 事件
    listenClose(randomNum, ws)

    // 超时自动断开
    whenTimeout(ws)
})

let pcofferTemp
let answerTemp
let candidateTemp

function listenMsg(randomNum, instance) {
    instance.on('message', function (message) {
        // console.log('服务端收到消息:', message.toString('utf8'))

        let parsedMessage = {
            // action: '操作类型',
            // data: '负载'
        }

        try {
            parsedMessage = JSON.parse(message.toString('utf8'))
        } catch (errInfo) {
            instance.sendError(errInfo)
            return console.log('无效消息:', errInfo)
        }

        const { action, data } = parsedMessage

        if (action === 'login') { //! 处理登录
            instance.sendDataWithJSON('login-success', { code: randomNum })
        } else if (action === 'control') { //! 处理控制
            // 控制端输入傀儡端的 code,作为 remote 字段的值发送给ws服务,
            // 表示要控制code值为该值的傀儡端
            const remote = Number(data.remote)

            if (map.has(remote)) { // 如果要控制的用户存在
                // 通知控制端,控制成功
                instance.sendDataWithJSON('control-success', { 'remote': remote })

                //! 建立两个ws实例的联动关系
                // 获取傀儡端的 ws 实例
                let remoteWS = map.get(remote)
                // 将傀儡端的 ws 实例的 sendDataWithJSON 方法设置为控制端的 sendRemote 方法
                instance.sendRemote = remoteWS.sendDataWithJSON
                // 将控制端的 ws 实例的 sendDataWithJSON 方法设置为傀儡端的 sendRemote 方法
                remoteWS.sendRemote = instance.sendDataWithJSON

                // 通知傀儡端,它被控制了
                instance.sendRemote('controlled-by', { 'remote': randomNum })
            } else {
                instance.sendError('用户不存在')
            }
        } else if (action === 'forward') { //! 处理转发
            console.error('data.event', data.event)
            instance.sendRemote(data.event, data.data)
        } else if (action === 'pcoffer') {
            console.error('pcoffer', data.pcoffer)
            pcofferTemp = data.pcoffer
            instance.sendDataWithJSON('pcoffer-for-createAnswer', { res: pcofferTemp })
        } else if (action === 'answer') {
            console.error('answer', data.answer)
            answerTemp = data.answer
            instance.sendDataWithJSON('answer-for-set-remote', { res: answerTemp })
        } else if (action === 'candidate') {
            console.log('candidate', data.candidate)
            candidateTemp = data.candidate
            instance.sendDataWithJSON('for-pupe-addIce', { res: candidateTemp })
        }
    })
}

function listenClose(randomNum, instance) {
    instance.on('close', () => {
        pcofferTemp = ''
        answerTemp = ''
        candidateTemp = ''
        map.delete(randomNum)
        delete instance.sendRemote
        clearTimeout(instance._closeTimeout)
    })
}

function whenTimeout(instance) {
    instance._closeTimeout = setTimeout(() => {
        instance.terminate()
    }, 600000);
}
转载自:https://juejin.cn/post/7413356439474995215
评论
请登录