背景
我的工位上有一台笔记本(windows),一个显示器,一台服务器(linux)。显示器同时连接笔记本和服务器,当需要用到服务器的时候:1.使用todesk等软件进行连接;2.直接把显示器切换到服务器桌面,然后给服务器插上鼠标键盘。在这种场景下,屏幕画面的传输对我来说并不是必要的,这种场景下能不能用一套键鼠控制两个电脑呢?边做边学便有了这个小工具。记录下来,其中遇到的问题和解决方案。
最终效果
通过electron + Vue + primevue实现效果如下图所示,可以在主面板对鼠标、键盘和屏幕进行分别控制。【还是做了画面传输。】
完整代码:feiiyuu/onemouse: 🐹一个基于electron和Vue的内网远程控制工具,可以分别对鼠标、键盘和屏幕进行控制。 欢迎⭐~
实现细节
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作为信令服务器。使用到了socketIO
Socket.IO
建立连接的软件使用流程如下:
A开启服务器模式:启动socket服务器,启动socket客户端连接socket服务器。
B开启客户端模式:启动socket客户端连接A服务端的socket服务器。
要实现的效果是:被操控的电脑A启动服务端模式后,控制端B去连接服务端A。
代码实现角度:两个RTCPeerConnection
建立连接的过程如下:
peerA
:服务端实例。peerB
客户端实例。
-
peerB
创建offer
并调用setLocalDescription
保存到本地,然后将offer
发送到信令服务器; -
信令服务器收到
offer
后转发给peerA
,peerA
收到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
}
其中,onmessage
为datachannel
收到消息后的回调。
服务端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,使得有效区域(下图蓝色部分)和窗口边框留有一定距离,便于操作。
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 Automation、nutjs.dev等,nutjs
提供了更加丰富的功能且一直都在更新维护,但由于一些原因,作者删除了npm上提供的预编译包,代码是开源的,可以自行编译。
我使用libnut-core-2.7.0
和nut.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上的keydown
和keyup
事件,通过datachannel
发送到服务端,然后服务端使用nutjs中的键盘事件即可完成操控。
8. 开机自启
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延迟,已重新配置。- 开机自启后,没有登录系统的话,似乎渲染进程无法启动,导致不能自动启动服务端。
欢迎提供宝贵方案或意见~