Electron+WebRTC实现局域网内的远程控制软件

4,254 阅读7分钟

背景

我的工位上有一台笔记本(windows),一个显示器,一台服务器(linux)。显示器同时连接笔记本和服务器,当需要用到服务器的时候:1.使用todesk等软件进行连接;2.直接把显示器切换到服务器桌面,然后给服务器插上鼠标键盘。在这种场景下,屏幕画面的传输对我来说并不是必要的,这种场景下能不能用一套键鼠控制两个电脑呢?边做边学便有了这个小工具。记录下来,其中遇到的问题和解决方案。

最终效果

通过electron + Vue + primevue实现效果如下图所示,可以在主面板对鼠标、键盘和屏幕进行分别控制。【还是做了画面传输。】

完整代码:feiiyuu/onemouse: 🐹一个基于electron和Vue的内网远程控制工具,可以分别对鼠标、键盘和屏幕进行控制。 欢迎⭐~ Snipaste_2024-06-21_15-18-50.png

实现细节

1. 环境配置

通过electron forge官网提供的方法初始化项目。www.electronforge.io/

npm init electron-app@latest my-new-app -- --template=vite-typescript

初始化完毕之后,将Vue整合进来。 Vue 3 | Electron Forge

2. WebRTC的使用

推荐阅读文章:从0到1打造一个 WebRTC 应用 - 掘金 (juejin.cn)

要建立两个RTCPeerConnection实例对象的连接,需要通过信令服务器进行信息的转发。本文中使用websocket作为信令服务器。使用到了socketIOSocket.IO

建立连接的软件使用流程如下:

A开启服务器模式:启动socket服务器,启动socket客户端连接socket服务器。

B开启客户端模式:启动socket客户端连接A服务端的socket服务器。

要实现的效果是:被操控的电脑A启动服务端模式后,控制端B去连接服务端A。

代码实现角度:两个RTCPeerConnection建立连接的过程如下:

peerA:服务端实例。peerB客户端实例。

  • peerB创建offer并调用setLocalDescription保存到本地,然后将offer发送到信令服务器;

  • 信令服务器收到offer后转发给peerApeerA收到offer后调用setRemoteDescription将对方信息保存到本地,然后创建answer并调用setLocalDescription保存到本地,然后将answer发送到信令服务器;

  • 信令服务器收到answer转发给peerB,peerB收到后调用setRemoteDescription将对方信息保存到本地。

过程中会涉及到SDP的交换,至此完成连接过程。

peerB参考代码:由于需要使用datachannel进行数据传输,此处一并给出初始化过程。

function createClientRTC(io: Socket): RTCPeerConnection {

    const rtc = new RTCPeerConnection(config)

    rtc.onconnectionstatechange = () => {
        if (rtc.connectionState === 'disconnected') {
            e.emit('serverRTC-disconnect')
        }
    }

    rtc.onicecandidate = ({ candidate }) => {
        if (candidate) {
            io.emit(Client.CANDIDATE, candidate)
        }
    }

    io.on(Server.CANDIDATE, (args) => {
        console.log('Client accept Server candidate!');
        rtc.addIceCandidate(args)
    })

    io.on(Server.ANSWER, (args) => {
        rtc.setRemoteDescription(args)
    })

    io.on(Connection.NEW, () => {
        if (rtc.connectionState === 'connected') return
        console.log(`New client connected!`);
        rtc.createOffer().then((offer) => {
            rtc.setLocalDescription(offer)
            io.emit(Client.OFFER, offer)
        })
    })

    io.on(Server.OFFER, (args) => {
        console.log('Client accept Server Offer!');
        rtc.setRemoteDescription(args)
        rtc.createAnswer().then((answer) => {
            rtc.setLocalDescription(answer)
            io.emit(Client.ANSWER, answer)
        })
    })

    io.on('connect_error', (error) => {
        if (error.message == 'invalid token') {
            e.emit('socket-error')
        }
    })

    return rtc
}

function ClientConnect(host_name: string, port: number, password: number) {
    const io = CreateIOClient(host_name, port, password)
    const rtc = createClientRTC(io)
    console.log('Client Create RTC!');
    const dataChannel = rtc.createDataChannel('mouse', {
        ordered: true,
    })
    return { channel: dataChannel, peer: rtc }
}

peerA参考代码:

function createServerRTC(io: Socket, onmessage: (event: MessageEvent) => void): RTCPeerConnection {
    const rtc = new RTCPeerConnection(config)

    rtc.onconnectionstatechange = () => {
        if (rtc.connectionState === 'disconnected') {
            io.removeAllListeners(Client.ANSWER)
            io.removeAllListeners(Client.OFFER)
            io.removeAllListeners(Client.CANDIDATE)
            const store = usePeerStore()
            store.updateServerPeer(undefined)
            rtc.close()
        }
    }

    rtc.ondatachannel = (event) => {
        const channel = event.channel
        channel.onmessage = onmessage
    }

    rtc.onicecandidate = ({ candidate }) => {
        if (candidate) {
            io.emit(Server.CANDIDATE, candidate)
        }
    }

    io.on(Client.CANDIDATE, (args) => {
        console.log('Server accept client candidate!');
        rtc.addIceCandidate(args)
    })

    io.on(Client.OFFER, (args) => {
        console.log('Server accept client offer!');
        rtc.setRemoteDescription(args)
        rtc.createAnswer().then((answer) => {
            rtc.setLocalDescription(answer)
            io.emit(Server.ANSWER, answer)
        })
    })

    return rtc
}

其中,onmessagedatachannel收到消息后的回调。

服务端socket职责就是转发收到的内容,参考代码:

 io.on('connection', (socket) => {
        io.emit(Connection.NEW) // 告诉客户端发起RTC连接
        
        socket.on(Client.OFFER, (args) => {
            socket.broadcast.emit(Client.OFFER, args)
        })

        socket.on(Client.ANSWER, (args) => {
            socket.broadcast.emit(Client.ANSWER, args)
        })

        socket.on(Client.CANDIDATE, (args) => {
            socket.broadcast.emit(Client.CANDIDATE, args)
        })

        socket.on(Server.ANSWER, (args) => {
            socket.broadcast.emit(Server.ANSWER, args)
        })

        socket.on(Server.OFFER, (args) => {
            socket.broadcast.emit(Server.OFFER, args)
        })

        socket.on(Server.CANDIDATE, (args) => {
            socket.broadcast.emit(Server.CANDIDATE, args)
        })
    })

以上,完成了WebRTC的连接。

3. 控制面板的创建

在WebRTC连接成功后,会触发datachannel的onopen事件,可以在此时进行控制面板的创建。值得注意的是:onopen事件是在渲染进程中触发的,控制面板的窗口创建是在主进程进行,这里需要用到electron中进程间通信的方法。进程间通信 | Electron (electronjs.org)

由于使用了Vue框架,只需要进行router的配置,为控制面板分配一个路由即可。 创建窗口的参考代码:

function createFullWindow(mainWin: BrowserWindow) {
    const win = new BrowserWindow({
        width: 800,
        height: 600,
        icon:'images/logo.png',
        webPreferences: {
            preload: path.join(__dirname, 'preload.js'),
        },
    })
    const routerPath = `#/control`

    if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
        win.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL + routerPath);
    } else {
        win.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), {
            hash: routerPath  // 注意!!应当使用hash进行路由跳转
        });
    }
    if (process.env.NODE_ENV === 'development'){
        win.webContents.openDevTools();
    } 
    return win.id
}

4. 鼠标坐标的采集和发送

这里以控制面板窗口作为参照,采集当前鼠标位置在控制面板中的相对位置,计算出x,y两个方向的比例,然后映照到服务端的桌面中。Electron中的坐标位置是以左上角为(0,0)计算的。

  • 通过调用electron中的screen模块可以获取鼠标的位置。
  • 通过调用窗口的getContentBounds方法,可以获取窗口内容区域的位置和高度以及宽度。 参考代码:
import { screen } from 'electron'

const Offset = 5
const yTopOffset = 10
function isValidArea(cx: number, cy: number, winx: number, winy: number, width: number, height: number) {
    if ((cx < (winx + width - Offset) && cx > winx + Offset) && (cy < (winy + height - Offset) && cy > winy + yTopOffset))
        return true
    return false
}

const viceWindow = BrowserWindow.fromId(win)
const { x: cx, y: cy } = screen.getCursorScreenPoint()
const { x: winx, y: winy, width, height } = viceWindow.getContentBounds()
if (!isValidArea(cx, cy, winx, winy, width, height)) return
const xnum = (cx - winx - Offset) / (width - 2 * Offset)
const ynum = (cy - winy - yTopOffset) / (height - Offset - yTopOffset)

这里设置了offset,使得有效区域(下图蓝色部分)和窗口边框留有一定距离,便于操作。

image.png

5. 桌面画面的采集和传输

以上建立的WebRTC连接中,客户端的RTCPeerConnection是在主界面的渲染进程中创建,想要在控制面板窗口中播放桌面端画面无法直接使用已建立的RTCPeerConnection连接。这里通过创建新的RTCPeerConnection实例并进行连接,以此传输桌面画面。流程如下:

  • datachannel连接后,客户端控制面板打开,创建socket客户端并连接信令服务器
  • 客户端开启“屏幕”选项,通过datachannel发送消息到服务端告知其采集桌面画面。
  • 服务端渲染进程调用主进程方法,获取桌面视频流id,并创建socket客户端连接信令服务器
  • 服务端创建RTCPeerConnection实例,向信令服务器发送offer等信息,与客户端控制面板中的RTCPeerConnection实例建立连接。(连接过程同上)
  • 服务端通过视频流id采集得到视频流,调用RTCPeerConnection实例的addTrack将视频流添加到轨道。
  • 客户端RTCPeerConnection通过ontrack方法接收视频流并使用<video>标签播放。

这里有一点要注意:在已经建立好的连接上添加视频流后,需要renegotiate

javascript - WebRTC PeerConnection addTrack after connection established - Stack Overflow

这里通过onnegotiationneeded方法解决,参考代码:

rtc.onnegotiationneeded = () => {
        rtc.createOffer().then((offer) => {
            rtc.setLocalDescription(offer)
            io.emit(ServerVideo.OFFER, offer)
        })
    }

6. 服务端鼠标事件控制

要完成通过node控制鼠标移动的操作,可以使用的库有:RobotJS - Node.js Desktop Automationnutjs.dev等,nutjs提供了更加丰富的功能且一直都在更新维护,但由于一些原因,作者删除了npm上提供的预编译包,代码是开源的,可以自行编译。

我使用libnut-core-2.7.0nut.js-4.2.0进行了编译,可以通过以下命令安装:

npm i @scanood/nut-js

移动鼠标的参考代码:

import { mouse } from '@scanood/nut-js'

async function mouseAction(data: MouseData) {
    const { x, y } = data // 客户端发送的鼠标位置比例
    const { width, height } = screen.getPrimaryDisplay().size
    const point = new Point(x * width, y * height)
    await mouse.move([point])
}

7. 键盘输入

键盘的输入和鼠标逻辑上相同,只需要监听DOM上的keydownkeyup事件,通过datachannel发送到服务端,然后服务端使用nutjs中的键盘事件即可完成操控。

8. 开机自启

Teamwork/node-auto-launch: Launch applications or executables at login (Mac, Windows, and Linux) (github.com) 参考代码:

import autoLaunch from "auto-launch";

function AutoLaunch(app, start) {
  const laucher = new autoLaunch({
    name: app.getName(),
    path: app.getPath("exe"),
  });
  if (start) laucher.enable();
  else laucher.disable();
}

export { AutoLaunch };

不足

  • 键盘输入有延迟nutjs默认300ms延迟,已重新配置。
  • 开机自启后,没有登录系统的话,似乎渲染进程无法启动,导致不能自动启动服务端。

欢迎提供宝贵方案或意见~