首页/文章/electron

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

2024-09-13
18797 分钟
...

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

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

完整代码:Scanood/onemouse: 🐹一个基于electron和WebRTC的内网远程控制软件。 (github.com) 欢迎⭐~

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) 
        
        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  
        });
    }
    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,使得有效区域(下图蓝色部分)和窗口边框留有一定距离,便于操作。

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延迟,已重新配置。
  • 开机自启后,没有登录系统的话,似乎渲染进程无法启动,导致不能自动启动服务端。

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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
electron2025-03-05
不依赖第三方库自己实现 Electron 的主与渲染进程的国际化功能
正文 直接贴出代码: 主进程实现 首先需要在 文件中获取当前系统的语言,然后初始化国际化相关: 文件: 文件 然后在主进程中就可以通过 来实现国际化了。 渲染进程的实现 App 在启动的时候渲染进程已经执行了国际化的初始化,所以在主进程已经保存了一份当前语言的 信息,所以渲染进程就不需要进程这一步,直接从主进程获取即可。 主进程进程添加一个 事件,供渲染进程获取国际化相关数据: 首先我们要在 preload 中提前将国际化相关的信息写入...
学习
electron2025-03-05
应用多开,限制只启动一个应用,防止多个实例
通过app.requestSingleInstanceLock来控制应用的多开,返回值为boolean。 此方法的返回值表示你的应用程序实例是否成功取得了锁。如果它取得锁失败,你可以假设另一个应用实例已经取得了锁并且仍旧在运行,并立即退出。 即:如果当前进程是应用程序的主要实例,则此方法返回true,同时你的应用会继续运行。如果当它返回false,如果你的程序没有取得锁,它应该立刻退出,并且将参数发送给那个已经取到锁的进程。 在macO...
学习
electron2024-09-23
Electron实现文件缓存背景
背景 基于Electron研发的一款IM企业通信桌面端应用,会存在非常多文件、图片、视频类型回话消息。在Electron应用(桌面客户端软件)中,快速加载并显示图片是提升用户体验的关键。然而,传统的图片加载方式往往存在加载速度慢、资源占用高等问题,影响了用户的使用体验。 解决的问题 1. 支持自定义配置存储的磁盘位置 2. 支持长期存储 3. 支持自定义存储大小 4. 支持自定义存储类型(如图片、视频、文件,或者更细致化到MIME) 5...
学习
electron2024-09-23
我的 Electron 客户端也可以全量和增量更新了
前言 本文主要介绍 客户端应用的自动更新,包括全量和增量这两种方式。 全量更新: 运行新的安装包,安装目录所有资源覆盖式更新。 增量更新: 只更新修改的部分,通常是渲染进程和主进程文件。 本文并没有拿真实项目来举例子,而是起一个 新的项目 从 0 到 1 来实现自动更新。 如果已有的项目需要支持该功能,借鉴本文的主要步骤即可。 前置说明: 1. 由于业务场景的限制,本文介绍的更新仅支持 操作系统,其余操作系统未作兼容处理。 2. 更新流...
学习
electron2024-09-20
我的 Electron 客户端被第三方页面入侵了
问题描述 公司有个内部项目是用 来开发的,有个功能需要像浏览器一样加载第三方站点。 本来一切安好,但是某天打开某个站点的链接,导致 整个客户端直接变成了该站点的页面 。 这一看就是该站点做了特殊的处理,经排查网页源码后,果然发现了有这么一句代码。 翻译一下就是:如果当前窗口不是顶级窗口的话,将当前窗口设置为顶级窗口。 奇怪的是两者不是 跨域 了吗,为什么 还可以影响顶级窗口。 先说一下我当时的一些解决办法: 1. 用 替换 2. 给 添...
学习
electron2024-09-11
如何将 Electron 项目上架
前言 是一个开源框架,它允许开发者使用 技术( 、 和 )来构建跨平台的桌面应用程序。 应用程序可以运行在 、 和 上,为用户提供了一种统一的方式来开发和维护软件。 本文将探讨如何将 Electron 构建的桌面应用程序上架到 中。 创建证书 进入苹果开发者平台 进入证书列表: 创建证书(Certificates) 用于对需要上架应用进行签名 创建 和 两个证书。 证书用于在 网站上注册的计算机上签署用于开发和测试的应用程序。 注册方法...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录