
简介面向需要实现远程桌面控制场景的开发者这是一套基于Java AWT、SpringBoot与WebSocket构建的跨平台私人远程桌面工具完整项目。系统覆盖鼠标键盘模拟、远程执行DOS命令、远程关机与重启等核心功能适合毕业设计参考、网络协议学习或二次开发。压缩包共662个文件大小约5.97MB包含112个JavaScript前端脚本、111个class编译文件、99个Java源码以及前端页面所需的css、png、ico、jpg等资源数据库db文件、配置文件properties和xml等一应俱全目录结构完整。已有311人学习下载。内含项目完整代码、数据库脚本及配套设计报告可据此直接运行调试梳理WebSocket通信、AWT机器人控制、SpringBoot后端服务等实现细节节省从零搭建环境的时间。1. 基于WebSocket的Java远程桌面为什么私人工具比TeamViewer更可控平时在实验室调代码最折磨的往往不是编译不过而是远程桌面工具本身不好用。人坐在A机器前却要去操作隔着一条走廊的B机器没有固定公网IP临时抓来的商业远控软件又签入一堆授权弹窗轻则卡在登录页重则被安全策略直接拦死。刚需面前我决定自己动手于是有了这套用Java写的基于WebSocket的跨平台远程桌面工具。核心路线并不复杂用java.awt.Robot做输入注入用WebSocket做双向实时通道再用一套自定义二进制帧把键鼠指令、命令回显和电源控制打包送上去。它不是为了替代商业产品而是适合私人局域网、能随时加功能、也能直接改造成毕设系统的可复现方案。2. WebSocket与协议帧为什么选Java Robot和20秒心跳2.1 为什么选Java AWT的Robot而不是JNI底层钩子远程桌面最基础的需求是把主控端的鼠标动作“翻译”成远端机器的鼠标动作。Java里最省事的模拟入口就是java.awt.Robot它不依赖特定平台APIWindows、Linux、macOS都有对应实现省掉一大票JNA/JNI适配工作。需要精确控制时配合GraphicsEnvironment.getLocalGraphicsEnvironment()拿到的GraphicsDevice可以覆盖主屏和扩展屏。写毕设实现时我建议把Robot实例按会话分配不要做成全局单例。一个被控端如果同时挂着两个控制会话两个客户端同时朝同一个Robot发mouseMove事件会互相覆盖远端鼠标像抽风一样乱跳。更常见的做法是会话打开时创建Robot会话结束主动置空引用。Robot对象本身没有close方法释放靠引用失效。下面是被控端初始化的最小写法注意代码里的注释不是为了凑数import java.awt.GraphicsDevice; import java.awt.GraphicsEnvironment; import java.awt.Robot; public class InputSimulator { private Robot robot; private int screenWidth; private int screenHeight; public InputSimulator() throws Exception { // 必须跑在有图形界面的会话里headless模式下直接抛HeadlessException GraphicsEnvironment env GraphicsEnvironment.getLocalGraphicsEnvironment(); GraphicsDevice device env.getDefaultScreenDevice(); robot new Robot(device); // 记录主屏尺寸后续坐标换算要依赖它。扩展屏的坐标可能是负数 screenWidth device.getDisplayMode().getWidth(); screenHeight device.getDisplayMode().getHeight(); } public void moveMouse(int x, int y) { robot.mouseMove(x, y); } public void click(int button) { int mask 0; if (button 1) { mask java.awt.event.InputEvent.BUTTON1_DOWN_MASK; } else if (button 2) { mask java.awt.event.InputEvent.BUTTON2_DOWN_MASK; } else { mask java.awt.event.InputEvent.BUTTON3_DOWN_MASK; } robot.mousePress(mask); robot.mouseRelease(mask); } }上面new Robot(device)传入GraphicsDevice是为了多屏环境下能把坐标绑定到具体显示设备如果只传默认屏双屏机器上的坐标很容易画错。mousePress和mouseRelease必须成对否则远端会一直认为鼠标按着不放。按钮参数我这里定的是1左键、2中键、其余为右键这个约定要和主控端协议帧保持一致后端解释错一位鼠标行为就全乱。2.2 为什么选WebSocket而不是自建TCP长连接远程控制网络层最怕两件事粘包和中间网络设备切断长连接。自建TCP长连接时要自己定义消息边界、自己做心跳、自己处理半包这些恰好是WebSocket协议层已经解决的问题。WebSocket提供二进制帧天然有长度边界服务端按帧回调自带ping/pong帧协议栈自己维护连接活性。Java这一侧SpringBoot内置的WebSocketHandler或JSR-356的ServerEndpoint都能用少量注解把端点点出来比从ServerSocket手搓要少写几百行样板代码。这里有个容易忽略的坑WebSocket的ping/pong只表示TCP层还活着不代表业务逻辑正常。很多翻车现场是TCP还通着但被控端的Robot因为锁屏或系统休眠已经失去输入权限主控端还以为一切正常。所以我在业务帧里额外加了自己的一层心跳客户端每20秒发一个Ping业务帧服务端回Pong帧连续3次60秒没有收到任何业务帧的会话服务端主动close。这样能把僵尸会话及时清掉不至于一直占着Session对象不放。Packet.class就是这个二进制协议的编解码器。帧结构设计得比较简单4字节固定魔数、1字节帧类型、4字节载荷长度后面跟着载荷本体。帧类型我做了这几类分配类型码含义载荷格式方向0x00业务心跳 ping/pong空载荷或时间戳双向0x10鼠标/键盘指令按 4.1 的紧凑结构主→被0x20命令执行回显单次最多 8KB 的 UTF-8 文本被→主0x21命令结束与退出码EXIT_CODE数字被→主0x30系统控制关机/重启命令字 ASCII 字符串主→被这里把Packet封装成ByteBuffer的小片段毕设代码里可以直接沿用import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; public class Packet { private static final byte[] MAGIC {T, D, W, S}; public static final int TYPE_HEARTBEAT 0x00; public static final int TYPE_INPUT 0x10; public static final int TYPE_SHELL 0x20; public static final int TYPE_EXIT 0x21; public static final int TYPE_POWER 0x30; public static ByteBuffer encode(int type, byte[] payload) { // 帧总长 魔数4 类型1 长度4 载荷 ByteBuffer buf ByteBuffer.allocate(9 payload.length); buf.put(MAGIC); buf.put((byte) type); buf.putInt(payload.length); buf.put(payload); buf.flip(); return buf; } public static Packet decode(byte[] frame) { ByteBuffer buf ByteBuffer.wrap(frame); byte[] magic new byte[4]; buf.get(magic); // 生产环境里这里必须校验魔数不一致直接丢弃避免垃圾帧进入处理队列 int type buf.get() 0xFF; int length buf.getInt(); byte[] payload new byte[length]; buf.get(payload); return new Packet(type, payload); } private final int type; private final byte[] payload; public Packet(int type, byte[] payload) { this.type type; this.payload payload; } public int getType() { return type; } public byte[] getPayload() { return payload; } }逻辑说明decode必须等收满一帧再调用因为WebSocket的OnMessage回调本身按帧到达不需要像TCP那样维护残留缓冲区。ByteBuffer默认大端序与putInt/getInt约定一致如果你其他语言写的对端也是大端就不会出问题。MAGIC的作用是过滤外部探活请求哪怕对端往WebSocket塞了一段JSON魔数不匹配也会被丢弃。这算不上安全机制但能有效减少解析器被脏数据打崩的概率。2.3 会话状态机鉴权、绑定与断开被控端TentacleDesktopWSS除了启动WebSocket服务还要维护一个会话状态机。状态机只有四个阶段握手校验、会话建立、控制会话中、断开清理。握手阶段要做两件事校验令牌绑定屏幕设备和输入模拟器。为什么要绑屏幕设备多屏环境下如果不把GraphicsDevice绑定进会话鼠标坐标换算根本没有基准。我手头这版代码的结构是TentacleDesktopWSS负责启动WebSocket容器并监听端口TentacleDesktopSession对应一个个客户端连接Packet负责编解码Client.class是主控端入口。DBAccess、QuerySQL、UpdateSQL这三个类是扩展用的会话记录持久化方便毕设报告里写出“操作有日志”。核心控制链路不依赖数据库刚复现阶段可以先不管它们。会话管理我常用一个ConcurrentHashMapString, TentacleDesktopSession维护sessionId - sessionsessionId取Session.getId()。每次收到控制帧先查这个Map判断会话是否已激活没激活的帧直接忽略。这样即使客户端误发了一堆指令只要没走完鉴权流程都不会对远端系统产生实际影响。3. 被控端落地从启动参数到Windows/Linux权限差异3.1 被控端启动链路先初始化Robot再监听端口被控端启动顺序很关键顺序反了会出现命令行能跑、但一连接就报AWTError的情况。我一般按三步走先校验当前是否有图形会话Windows下别是Session 0Linux下确认DISPLAY变量非空再创建Robot实例并保存主屏宽高最后启动WebSocket服务端并注册ServerEndpoint(/tentacle)。为什么Robot必须在端口监听之前创建端口先开的话客户端连上来马上下发指令Robot还没初始化完成第一个mouseMove就会抛异常会话被迫关闭。先把输入模拟器准备好客户端握手完成后拿到的就是一个能立即执行指令的对象。启动脚本上Windows和Linux写法稍有差异。Windows的锁屏控制场景下文会讲这里给Linux的启动脚本# 被控端启动Linux桌面会话 export DISPLAY:0 java -cp tentacle-desktop.jar com.tentacle.sample.TentacleDesktopWSS \ --port 8080 \ --token you-know-me参数说明--port是WebSocket监听端口局域网直连时用默认8080是为了让浏览器客户端也方便--token是简单的字符串令牌客户端通过ws://ip:8080/tentacle?tokenyou-know-me带上服务端握手时比对避免局域网内的陌生设备乱连。自己玩时token写死成test也行但毕设答辩时最好改成配置项并提示使用者修改默认值。3.2 握手与令牌校验用OnOpen挡住未授权客户端服务端这里我用JSR-356的ServerEndpoint来写SpringBoot环境中引入spring-boot-starter-websocket并把ServerEndpoint标注的类交给容器管理然后通过WebSocketConfigurer注册。握手校验写在OnOpen方法里ServerEndpoint(/tentacle) public class TentacleEndpoint { OnOpen public void onOpen(Session session, QueryParam(token) String token) throws IOException { // 对比令牌不通过直接关闭连接CloseReason会给客户端明确提示 if (!you-know-me.equals(token)) { session.close(new CloseReason( CloseReason.CloseCodes.VIOLATED_POLICY, bad token, connection refused)); return; } // 每个会话分配独立的输入模拟器避免多个客户端抢占同一Robot TentacleDesktopSession sessionCtx new TentacleDesktopSession(session); sessionCtx.setInputSimulator(new InputSimulator()); session.getUserProperties().put(sessionCtx, sessionCtx); } OnMessage public void onMessage(Session session, byte[] message) { TentacleDesktopSession sessionCtx (TentacleDesktopSession) session.getUserProperties().get(sessionCtx); if (sessionCtx null) { return; } Packet packet Packet.decode(message); // 按Packet解析出的type分发到鼠标、键盘、命令或电源控制 sessionCtx.dispatch(packet); } OnClose public void onClose(Session session) { TentacleDesktopSession sessionCtx (TentacleDesktopSession) session.getUserProperties().get(sessionCtx); if (sessionCtx ! null) { sessionCtx.release(); } } }这里有个易错点QueryParam(token)只在握手URL里携带参数时才能取到。如果客户端连接时写成ws://ip:8080/tentacletoken就是null直接走关闭分支。session.close是异步的但这里足够客户端会立刻收到关闭回调不会继续发指令。每个会话都新建InputSimulator从代码层面杜绝了多个控制端抢同一个鼠标的混乱状态代价是内存和图形设备句柄多了几个对局域网工具能接受对大规模生产环境不建议这样设计。3.3 Windows与Linux的被控端差异命令、路径和输入权限被控端最难处理的不是WebSocket而是模拟输入在不同系统上的差异化。Robot能做基本鼠标键盘模拟但关机、重启命令必须按系统区分。Windows下首选shutdown /s /f /t 0并且进程要有管理员权限Linux下是systemctl poweroff或shutdown -h now当前用户需要在power组里否则提示Access denied。我把电源控制设计成一个策略接口PowerController有两个实现Windows实现用ProcessBuilder(shutdown, /s, /f, /t, 0)Linux实现用ProcessBuilder(systemctl, poweroff)。以后要支持macOS再加一个实现类即可。链路里Http.class可以承担一部分控制指令的HTTP透传扩展核心策略不依赖它。这块的踩坑主要是权限Windows下WebSocket服务由普通用户启动时远程关机命令会弹UAC框UAC框在无人值守的会话里没人点命令就挂到超时。我后来改用schtasks创建一个临时提权任务来执行关机任务跑完立即删除才算稳下来。Linux桌面上则要注意DISPLAY环境变量——Systemd服务里跑的Java进程经常拿不到DISPLAY:0导致Robot一创建就抛HeadlessException启动脚本里必须显式导出。4. 主控端与指令下发坐标换算、命令回显和电源控制4.1 鼠标指令与坐标换算分辨率不同时鼠标漂移从哪来主控端发送鼠标指令时不能把本地面板像素直接发给被控端。本地窗口可能只有800×500显示区域远端真实屏幕是1920×1080直接发的话鼠标会像喝醉一样到不了预期位置。正确做法是先拿到远端屏幕宽高握手时被控端通过0x20帧返回SCREEN_INFO再按比例换算。在Client.class里鼠标移动事件处理大致长这样// 假设viewWidth/viewHeight是主控端显示远端屏幕的图像组件大小 double scaleX remoteScreenWidth / viewWidth; double scaleY remoteScreenHeight / viewHeight; // 换算成远端绝对坐标 int remoteX (int) (event.getX() * scaleX); int remoteY (int) (event.getY() * scaleY); // 压缩成8字节载荷4字节x 4字节y ByteBuffer buf ByteBuffer.allocate(8); buf.putInt(remoteX); buf.putInt(remoteY); client.send(Packet.encode(Packet.TYPE_INPUT, buf.array()));参数说明remoteScreenWidth/remoteScreenHeight是握手时被控端回传的GraphicsDevice.getDisplayMode()尺寸viewWidth/viewHeight是主控端显示组件的大小。只要这两对值有一对是错的远端鼠标必然漂移。我踩过的典型坑是把主控端窗口JPanel的尺寸和屏幕逻辑分辨率搞混导致换算比例始终是1:1远端是2K屏时鼠标只能点到左上角四分之一区域。排查办法是做一个“鼠标映射到中心”的功能把远端鼠标临时钉在屏幕中心偏差一眼就能看出来。4.2 远程命令回显把stdout装进帧里发回去远程命令执行是被控端最实用的功能坑也最多。我采用ProcessBuilder而不是Runtime.exec因为ProcessBuilder能显式设置redirectErrorStream(true)把stderr合并到stdout否则命令报错信息看不见。命令拆成一个参数数组传进去不要用长字符串拼路径里有空格时最明显。被控端的命令执行核心逻辑如下Process process new ProcessBuilder(cmd.exe, /c, command) .redirectErrorStream(true) .start(); // 用独立线程读stdout避免大输出时缓冲阻塞进程 try (BufferedReader reader new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { StringBuilder buffer new StringBuilder(); String line; while ((line reader.readLine()) ! null) { buffer.append(line).append(\n); // 超过4KB发一批防止网络卡顿和内存膨胀 if (buffer.length() 4096) { sendFrame(Packet.TYPE_SHELL, buffer.toString().getBytes(StandardCharsets.UTF_8)); buffer.delete(0, buffer.length()); } } if (buffer.length() 0) { sendFrame(Packet.TYPE_SHELL, buffer.toString().getBytes(StandardCharsets.UTF_8)); } } int exitCode process.waitFor(); sendFrame(Packet.TYPE_EXIT, (EXIT_CODE exitCode).getBytes(StandardCharsets.UTF_8));注意这里用cmd.exe /c是为Windows场景。如果被控端是Linux把命令改为/bin/bash -lc即可。waitFor()一定要放在读完stdout之后否则命令输出量大时管道缓冲写满进程会挂住Java端又读不出来waitFor()永远等不到结束。代码里我没用真正的独立线程而是直接在while循环里读配合4KB分帧已经能解决问题。4KB这个阈值不是标准只是自己项目里顺手如果你用SpringWebSocket可以同时调大maxTextMessageBufferSize但业务上建议主动分帧避免大消息顶爆JVM内存。4.3 关机与重启权限、延迟和防误触关机重启是远程控制里的“重武器”必须谨慎。协议里0x30系统控制帧的载荷是一个简单字符串shutdown、reboot、lock。被控端收到后不是立刻执行而是先回一个0x20确认帧“即将关机5秒后执行”同时在服务端会话里挂一个5秒延时任务。这个延时让用户看到确认消息后可以反悔。防误触的写法也不复杂示例片段如下if (Packet.TYPE_POWER packet.getType()) { String action new String(packet.getPayload(), StandardCharsets.UTF_8); if (shutdown.equals(action)) { // 先发提示再延时执行方便用户在面板上点“取消” sendText(system will shutdown in 5 seconds, send cancel to abort); sessionCtx.setPendingAction(PowerAction.SHUTDOWN, 5); } }这段描述的是时序设计而不是完整电源逻辑。真正执行电源操作时需要调用前面说的PowerController策略并且注意进程权限。整套链路到这里已经很清晰了它不是商业远控的替代品而是一个协议链路完整、可扩展的私人控制工具。毕设报告里值得强调的正是键鼠模拟、命令回显、电源控制的时序设计——这三块如果不加延迟和防误触演示时很容易“一键把测试机给关了”。5. 常见问题排查握手失败、鼠标漂移、锁屏黑屏与僵尸会话5.1 连接类WebSocket握手一直1006/1002端口也开了但连不上现象主控端连接ws://被控端IP:8080/tentacle?token...WebSocket回调一直报CloseCode 1006异常关闭或1002协议错误服务端日志里却没有明显异常堆栈。原因多数情况是被控端防火墙拦了TCP入站连接或者网络中间设备把WebSocket升级请求给拦了。第三种情况是握手URL带了多余空格导致QueryParam(token)解析失败服务端以1008主动关闭客户端却显示成1006。解决在命令行直接curl -v http://ip:8080/tentacle?tokenyou-know-me测一遍HTTP层能返回101升级状态说明端口通连普通HTTP都被重置先放行防火墙端口。其次检查Client.class构造URL时别把ws://拼错常见翻车是斜杠多写或少写。5.2 输入类远端鼠标乱飘、中文输入乱码、按键卡住现象主控端鼠标在本地图像上移动远端鼠标跑偏键盘输入英文正常输入中文变成一串问号。原因鼠标跑偏必然是坐标换算没做或算错。GraphicsDevice.getDisplayMode().getWidth()取的是逻辑分辨率系统开了125%缩放时实际桌面尺寸和逻辑尺寸不是一回事要用Toolkit.getDefaultToolkit().getScreenResolution()配合getScreenSize()做换算。中文乱码的原因是Robot.keyPress只接受键码不接受字符码用键码拼出中文基本不可能常见做法是走剪贴板传输再模拟粘贴。解决先打印远端屏幕尺寸和视觉上的桌面尺寸对比不一致就去查系统缩放。中文输入建议新增一个0x11扩展帧主控端把字符串写到本地剪贴板通过扩展帧发给被控端被控端把字符串写入系统剪贴板再模拟CtrlV。这样绕开输入法差异也不会乱码。5.3 状态类锁屏后控制失效、系统休眠后连接挂死现象被控端屏幕还亮着Robot能移动鼠标一旦锁屏或屏保开启主控端发的移动指令没有任何反应但WebSocket连接仍然正常。系统休眠唤醒后主控端心跳也收不到会话一直占着。原因Windows锁屏后进入安全桌面普通桌面会话的Robot没有权限注入输入Linux锁屏时X11的输入注入同样会被窗口管理器拦截。休眠导致网络栈暂停TCP长连接没有被及时拆除服务端还在傻等。解决锁屏控制别指望普通桌面服务要么在Windows计划任务里勾上“不管用户是否登录都要运行”并以系统权限启动要么放弃锁屏场景在被控端做定时屏幕状态探测发现锁屏就发0x22状态帧通知主控端。休眠问题则是把被控端休眠关掉或者在系统休眠前主动断开所有WebSocket会话避免僵尸会话残留。调试阶段最简单的办法Windows下powercfg /h off或用控制面板把“空闲休眠”改成从不。5.4 命令类执行dir很顺执行带空格的路径就翻车现象被控端执行dir C:\Program Files回显“系统找不到指定的路径”但本机命令行敲同样命令没问题。原因ProcessBuilder并不隐式拼接命令行字符串。把整个dir \C:\Program Files\作为一个参数传给ProcessBuilder(cmd.exe, /c, command)其实是正确做法如果图省事用Runtime.exec(cmd.exe /c dir \C:\\Program Files\)Java会按空格拆分命令路径被拦腰截断。这是新手最常见的坑。解决统一用ProcessBuilder传参数数组不要拼长字符串。如果必须用Runtime.exec自己处理引号转义特别注意Windows路径里的空格和符号。毕设代码里保留了ProcessBuilder写法就是不想让这类解析问题污染控制链路。6. 进阶技巧外网穿透与二次开发时最值得先做的两个功能6.1 让局域网工具暴露到外网用反向SSH隧道或内网穿透工具如果两台机器不在同一局域网演示时最省事的做法是被控端主动建立反向隧道把8080端口映射到一台公网服务器上。比如Linux被控端执行ssh -R 9000:localhost:8080 useryour-server主控端连接ws://your-server:9000/tentacle就能找到被控端。反向SSH的-R参数自动完成端口转发比手动配防火墙轻松太多。需要注意公网服务器SSH要开启GatewayPorts yes否则转发只监听回环地址外网还是连不上。6.2 二次开发最值得先做的扩展剪贴板同步如果要我给这套代码推荐第一个扩展功能不是画质优化而是剪贴板同步。实现思路很简单新增0x12帧类型主控端检测本地剪贴板变化时把纯文本发给被控端被控端用Toolkit.getDefaultToolkit().getSystemClipboard()写入。这个功能投入产出比很高顺手解决了中文输入、长文本复制、URL粘贴三类问题代码量只要几十行。6.3 连接后先做三件事验证屏幕尺寸、验证心跳、验证命令回显每次部署完新环境我建议按这个顺序验收第一把鼠标映射到远端屏幕中心看实际坐标是否落在中心区域第二看被控端日志有没有持续收到0x00心跳帧确认定时器都在跑第三执行一条无害命令如echo hello观察返回的EXIT_CODE是不是0。三件事全过这台机器基本能稳定用上一整天。这个教训我算是用血泪换来的有段时间我总觉得自己写的心跳逻辑没问题加到工程里就不管了直到某次演示时被控端屏保唤起连接挂死还占着端口才意识到心跳机制不只是“发个ping”而是整个会话生命周期的仲裁者。从那以后每次部署完我都强制走一遍屏幕尺寸、心跳和命令回显的验收这已经是我的习惯希望也能帮到你。本文还有配套的精品资源点击获取