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

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

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

1. 环境配置

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

 1npm 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进行数据传输,此处一并给出初始化过程。

 1function createClientRTC(io: Socket): RTCPeerConnection {
 2
 3    const rtc = new RTCPeerConnection(config)
 4
 5    rtc.onconnectionstatechange = () => {
 6        if (rtc.connectionState === 'disconnected') {
 7            e.emit('serverRTC-disconnect')
 8        }
 9    }
10
11    rtc.onicecandidate = ({ candidate }) => {
12        if (candidate) {
13            io.emit(Client.CANDIDATE, candidate)
14        }
15    }
16
17    io.on(Server.CANDIDATE, (args) => {
18        console.log('Client accept Server candidate!');
19        rtc.addIceCandidate(args)
20    })
21
22    io.on(Server.ANSWER, (args) => {
23        rtc.setRemoteDescription(args)
24    })
25
26    io.on(Connection.NEW, () => {
27        if (rtc.connectionState === 'connected') return
28        console.log(`New client connected!`);
29        rtc.createOffer().then((offer) => {
30            rtc.setLocalDescription(offer)
31            io.emit(Client.OFFER, offer)
32        })
33    })
34
35    io.on(Server.OFFER, (args) => {
36        console.log('Client accept Server Offer!');
37        rtc.setRemoteDescription(args)
38        rtc.createAnswer().then((answer) => {
39            rtc.setLocalDescription(answer)
40            io.emit(Client.ANSWER, answer)
41        })
42    })
43
44    io.on('connect_error', (error) => {
45        if (error.message == 'invalid token') {
46            e.emit('socket-error')
47        }
48    })
49
50    return rtc
51}
52
53function ClientConnect(host_name: string, port: number, password: number) {
54    const io = CreateIOClient(host_name, port, password)
55    const rtc = createClientRTC(io)
56    console.log('Client Create RTC!');
57    const dataChannel = rtc.createDataChannel('mouse', {
58        ordered: true,
59    })
60    return { channel: dataChannel, peer: rtc }
61}

peerA参考代码:

 1function createServerRTC(io: Socket, onmessage: (event: MessageEvent) => void): RTCPeerConnection {
 2    const rtc = new RTCPeerConnection(config)
 3
 4    rtc.onconnectionstatechange = () => {
 5        if (rtc.connectionState === 'disconnected') {
 6            io.removeAllListeners(Client.ANSWER)
 7            io.removeAllListeners(Client.OFFER)
 8            io.removeAllListeners(Client.CANDIDATE)
 9            const store = usePeerStore()
10            store.updateServerPeer(undefined)
11            rtc.close()
12        }
13    }
14
15    rtc.ondatachannel = (event) => {
16        const channel = event.channel
17        channel.onmessage = onmessage
18    }
19
20    rtc.onicecandidate = ({ candidate }) => {
21        if (candidate) {
22            io.emit(Server.CANDIDATE, candidate)
23        }
24    }
25
26    io.on(Client.CANDIDATE, (args) => {
27        console.log('Server accept client candidate!');
28        rtc.addIceCandidate(args)
29    })
30
31    io.on(Client.OFFER, (args) => {
32        console.log('Server accept client offer!');
33        rtc.setRemoteDescription(args)
34        rtc.createAnswer().then((answer) => {
35            rtc.setLocalDescription(answer)
36            io.emit(Server.ANSWER, answer)
37        })
38    })
39
40    return rtc
41}

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

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

 1 io.on('connection', (socket) => {
 2        io.emit(Connection.NEW) 
 3        
 4        socket.on(Client.OFFER, (args) => {
 5            socket.broadcast.emit(Client.OFFER, args)
 6        })
 7
 8        socket.on(Client.ANSWER, (args) => {
 9            socket.broadcast.emit(Client.ANSWER, args)
10        })
11
12        socket.on(Client.CANDIDATE, (args) => {
13            socket.broadcast.emit(Client.CANDIDATE, args)
14        })
15
16        socket.on(Server.ANSWER, (args) => {
17            socket.broadcast.emit(Server.ANSWER, args)
18        })
19
20        socket.on(Server.OFFER, (args) => {
21            socket.broadcast.emit(Server.OFFER, args)
22        })
23
24        socket.on(Server.CANDIDATE, (args) => {
25            socket.broadcast.emit(Server.CANDIDATE, args)
26        })
27    })

以上,完成了WebRTC的连接。

3. 控制面板的创建

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

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

 1function createFullWindow(mainWin: BrowserWindow) {
 2    const win = new BrowserWindow({
 3        width: 800,
 4        height: 600,
 5        icon:'images/logo.png',
 6        webPreferences: {
 7            preload: path.join(__dirname, 'preload.js'),
 8        },
 9    })
10    const routerPath = `#/control`
11
12    if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
13        win.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL + routerPath);
14    } else {
15        win.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`), {
16            hash: routerPath  
17        });
18    }
19    if (process.env.NODE_ENV === 'development'){
20        win.webContents.openDevTools();
21    } 
22    return win.id
23}

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

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

  • 通过调用electron中的screen模块可以获取鼠标的位置。
  • 通过调用窗口的getContentBounds方法,可以获取窗口内容区域的位置和高度以及宽度。 参考代码:
 1import { screen } from 'electron'
 2
 3const Offset = 5
 4const yTopOffset = 10
 5function isValidArea(cx: number, cy: number, winx: number, winy: number, width: number, height: number) {
 6    if ((cx < (winx + width - Offset) && cx > winx + Offset) && (cy < (winy + height - Offset) && cy > winy + yTopOffset))
 7        return true
 8    return false
 9}
10
11const viceWindow = BrowserWindow.fromId(win)
12const { x: cx, y: cy } = screen.getCursorScreenPoint()
13const { x: winx, y: winy, width, height } = viceWindow.getContentBounds()
14if (!isValidArea(cx, cy, winx, winy, width, height)) return
15const xnum = (cx - winx - Offset) / (width - 2 * Offset)
16const 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方法解决,参考代码:

 1rtc.onnegotiationneeded = () => {
 2        rtc.createOffer().then((offer) => {
 3            rtc.setLocalDescription(offer)
 4            io.emit(ServerVideo.OFFER, offer)
 5        })
 6    }

6. 服务端鼠标事件控制

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

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

 1npm i @scanood/nut-js

移动鼠标的参考代码:

 1import { mouse } from '@scanood/nut-js'
 2
 3async function mouseAction(data: MouseData) {
 4    const { x, y } = data 
 5    const { width, height } = screen.getPrimaryDisplay().size
 6    const point = new Point(x * width, y * height)
 7    await mouse.move([point])
 8}

7. 键盘输入

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

8. 开机自启

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

 1import autoLaunch from "auto-launch";
 2
 3function AutoLaunch(app, start) {
 4  const laucher = new autoLaunch({
 5    name: app.getName(),
 6    path: app.getPath("exe"),
 7  });
 8  if (start) laucher.enable();
 9  else laucher.disable();
10}
11
12export { AutoLaunch };
  • 键盘输入有延迟nutjs默认300ms延迟,已重新配置。
  • 开机自启后,没有登录系统的话,似乎渲染进程无法启动,导致不能自动启动服务端。

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

个人笔记记录 2021 ~ 2025