mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 14:50:45 +00:00
被控端 - 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%) - 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败) - 每分钟回头找局域网,自动切回快 400 倍的路径 - 画面心跳帧:静止时省流量,客户端仍知道流是活的 - 锁屏时给出可操作的提示 手表端 - 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException - 连接错误翻译成人话(证书/时间/解析/超时分别提示) - 经中转连上后用服务器报的地址刷新已配对记录 - 新增局域网连接页面、服务器延迟检测 服务器端 - 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502) - 网络调优脚本 net-tune.sh(开机自动应用) - 管理页:批量删除、网络体检、下载区 - 隧道改 HTTP/2 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
343 lines
12 KiB
Java
343 lines
12 KiB
Java
package com.lanremote.watch;
|
|
|
|
import java.io.BufferedReader;
|
|
import java.io.IOException;
|
|
import java.io.InputStreamReader;
|
|
import java.io.OutputStream;
|
|
import java.net.InetSocketAddress;
|
|
import java.net.Socket;
|
|
import java.net.SocketTimeoutException;
|
|
import java.nio.charset.Charset;
|
|
import java.util.Arrays;
|
|
import java.util.Map;
|
|
import java.util.concurrent.BlockingQueue;
|
|
import java.util.concurrent.LinkedBlockingQueue;
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
/**
|
|
* 被控端协议客户端(纯 Java,无 Android 依赖)。
|
|
*
|
|
* 之所以保持纯 Java:可以把它编译到桌面 JVM,直接连真实的 Python 被控端做联调,
|
|
* 而不是只靠"看起来应该没问题"。
|
|
*/
|
|
public class RemoteClient {
|
|
|
|
public static final Charset UTF8 = Charset.forName("UTF-8");
|
|
public static final int CHUNK_SIZE = 256 * 1024;
|
|
public static final int DEFAULT_PORT = 8765;
|
|
|
|
/** 事件与断开回调。注意:回调发生在读取线程,UI 层需自行切主线程。 */
|
|
public interface Listener {
|
|
void onEvent(String event, Map<String, Object> message);
|
|
|
|
void onDisconnected(String reason);
|
|
}
|
|
|
|
/**
|
|
* 传输层。局域网直连时是 SocketTransport,跨网络时是 RelayTransport。
|
|
* 抽出来之后协议逻辑完全不用管数据是走 TCP 还是走中转的 HTTPS。
|
|
*/
|
|
private Transport transport;
|
|
private BufferedReader reader;
|
|
private OutputStream writer;
|
|
private Thread readerThread;
|
|
/** 单行上限,和服务端的 MAX_LINE_BYTES 对齐 */
|
|
private static final int MAX_LINE_CHARS = 8 * 1024 * 1024;
|
|
|
|
private volatile boolean running;
|
|
|
|
private final BlockingQueue<Map<String, Object>> replies =
|
|
new LinkedBlockingQueue<Map<String, Object>>();
|
|
private final Object sendLock = new Object();
|
|
|
|
private Listener listener;
|
|
private String serverName = "";
|
|
private String deviceId = "";
|
|
private String deviceName = "手表";
|
|
|
|
// ------------------------------------------------------------------
|
|
|
|
public boolean isConnected() {
|
|
return running && transport != null && !transport.isClosed();
|
|
}
|
|
|
|
/** 当前传输方式的描述,界面上用来显示"直连"还是"经中转"。 */
|
|
public String transportName() {
|
|
if (transport == null) {
|
|
return "";
|
|
}
|
|
return transport instanceof RelayTransport ? "中转" : "直连";
|
|
}
|
|
|
|
public RelayTransport relayTransport() {
|
|
return transport instanceof RelayTransport
|
|
? (RelayTransport) transport : null;
|
|
}
|
|
|
|
public String getServerName() {
|
|
return serverName;
|
|
}
|
|
|
|
public void setDevice(String deviceId, String deviceName) {
|
|
this.deviceId = deviceId == null ? "" : deviceId;
|
|
this.deviceName = deviceName == null ? "手表" : deviceName;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 连接与握手
|
|
// ------------------------------------------------------------------
|
|
|
|
/**
|
|
* 连接并完成握手。
|
|
*
|
|
* @param token 为空则用配对码配对;非空则直接令牌登录(下次打开免配对码)
|
|
* @return 服务端握手回包,含 ok / token / serverName
|
|
*/
|
|
public Map<String, Object> connect(String host, int port, String code, String token,
|
|
Listener listener, int timeoutMs) throws IOException {
|
|
close();
|
|
this.listener = listener;
|
|
this.running = true;
|
|
this.replies.clear();
|
|
|
|
Socket sock = new Socket();
|
|
sock.connect(new InetSocketAddress(host, port), timeoutMs);
|
|
sock.setSoTimeout(0);
|
|
sock.setTcpNoDelay(true);
|
|
return startTransport(new Transport.SocketTransport(sock), code, token,
|
|
listener, timeoutMs);
|
|
}
|
|
|
|
/**
|
|
* 经中转服务器连接(跨网络)。
|
|
*
|
|
* 手表没法直连电脑(两边都在 NAT 后面),所以两边都主动连出去,
|
|
* 由中转按配对码把两条连接对上。中转只转发字节、不认识协议内容,
|
|
* 所以电脑那边的配对和令牌校验照样生效。
|
|
*/
|
|
/** 上次经中转接入时,被控端报的局域网 IP 和端口。 */
|
|
private java.util.List<String> relayLanIps = new java.util.ArrayList<String>();
|
|
private int relayLanPort;
|
|
|
|
public java.util.List<String> relayLanIps() {
|
|
return relayLanIps;
|
|
}
|
|
|
|
public int relayLanPort() {
|
|
return relayLanPort;
|
|
}
|
|
|
|
public Map<String, Object> connectViaRelay(String baseUrl, String code,
|
|
String token, Listener listener,
|
|
int timeoutMs) throws IOException {
|
|
close();
|
|
relayLanIps = new java.util.ArrayList<String>();
|
|
relayLanPort = 0;
|
|
RelayTransport relay = new RelayTransport(baseUrl, code, deviceName);
|
|
if (!relay.attach()) {
|
|
String reason = relay.getLastError();
|
|
if (reason == null || reason.isEmpty()) {
|
|
reason = "连不上中转服务器";
|
|
}
|
|
relay.close();
|
|
// 和局域网那条路保持一致:握手被拒要回 ok=false,不要抛异常。
|
|
// 抛异常的话界面只能显示"连不上",分不清是配对码错了、
|
|
// 设备不在线、还是中转服务器连不上 —— 这三者的处理方式完全不同。
|
|
this.serverName = "";
|
|
return Json.obj("ok", false, "error", reason);
|
|
}
|
|
Map<String, Object> reply =
|
|
startTransport(relay, code, token, listener, timeoutMs);
|
|
if (Json.bool(reply, "ok", false)) {
|
|
// 留给界面判断:要不要切成局域网直连
|
|
relayLanIps = relay.getLanIps();
|
|
relayLanPort = relay.getLanPort();
|
|
}
|
|
return reply;
|
|
}
|
|
|
|
/** 握手 + 起读取线程。局域网和中转共用这一段。 */
|
|
private Map<String, Object> startTransport(Transport channel, String code,
|
|
String token, Listener listener,
|
|
int timeoutMs) throws IOException {
|
|
this.listener = listener;
|
|
this.running = true;
|
|
this.replies.clear();
|
|
this.transport = channel;
|
|
this.reader = new BufferedReader(
|
|
new InputStreamReader(channel.input(), UTF8), 8192);
|
|
this.writer = channel.output();
|
|
|
|
Thread thread = new Thread(new Runnable() {
|
|
@Override
|
|
public void run() {
|
|
readLoop();
|
|
}
|
|
}, "lanremote-reader");
|
|
thread.setDaemon(true);
|
|
thread.start();
|
|
this.readerThread = thread;
|
|
|
|
Map<String, Object> handshake;
|
|
if (token != null && token.length() > 0) {
|
|
handshake = Json.obj("cmd", "auth", "deviceId", deviceId,
|
|
"deviceName", deviceName, "token", token);
|
|
} else {
|
|
handshake = Json.obj("cmd", "pair", "deviceId", deviceId,
|
|
"deviceName", deviceName, "code", code == null ? "" : code.trim());
|
|
}
|
|
|
|
Map<String, Object> reply;
|
|
try {
|
|
reply = call(handshake, timeoutMs);
|
|
} catch (IOException failed) {
|
|
// 握手失败必须关掉,否则 socket 和读取线程泄漏,
|
|
// 而且 isConnected() 会一直是 true,界面以为连上了。
|
|
close();
|
|
throw failed;
|
|
}
|
|
if (Json.bool(reply, "ok", false)) {
|
|
this.serverName = Json.str(reply, "serverName", "");
|
|
} else {
|
|
close();
|
|
}
|
|
return reply;
|
|
}
|
|
|
|
public void close() {
|
|
running = false;
|
|
try {
|
|
if (transport != null) {
|
|
transport.close();
|
|
}
|
|
} catch (Exception ignored) {
|
|
// 关闭时的异常无需处理
|
|
}
|
|
transport = null;
|
|
reader = null;
|
|
writer = null;
|
|
replies.clear();
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 收发
|
|
// ------------------------------------------------------------------
|
|
|
|
private void readLoop() {
|
|
try {
|
|
// close() 会把 reader 置 null,而 running 判断和 readLine 之间有竞态。
|
|
// 先取到局部变量并判空,避免 NPE —— 读取线程里的未捕获 NPE 会杀掉整个进程。
|
|
BufferedReader localReader = this.reader;
|
|
if (localReader == null) {
|
|
return;
|
|
}
|
|
String line;
|
|
while (running && (line = localReader.readLine()) != null) {
|
|
if (line.length() > MAX_LINE_CHARS) {
|
|
continue; // 对端发超长单行,直接丢弃防止占满内存
|
|
}
|
|
if (line.trim().isEmpty()) {
|
|
continue;
|
|
}
|
|
Map<String, Object> message;
|
|
try {
|
|
message = Json.parseObject(line);
|
|
} catch (Exception malformed) {
|
|
continue; // 单条报文损坏不影响整条连接
|
|
}
|
|
if (message.containsKey("event")) {
|
|
if (listener != null) {
|
|
listener.onEvent(String.valueOf(message.get("event")), message);
|
|
}
|
|
} else {
|
|
replies.offer(message);
|
|
}
|
|
}
|
|
} catch (IOException ignored) {
|
|
// 连接被关闭或网络中断,走下面的统一处理
|
|
} finally {
|
|
boolean notify = running;
|
|
running = false;
|
|
if (notify && listener != null) {
|
|
listener.onDisconnected("连接已断开");
|
|
}
|
|
}
|
|
}
|
|
|
|
private void send(Map<String, Object> message) throws IOException {
|
|
OutputStream out = writer;
|
|
if (out == null) {
|
|
throw new IOException("尚未连接");
|
|
}
|
|
byte[] payload = (Json.write(message) + "\n").getBytes(UTF8);
|
|
synchronized (sendLock) {
|
|
out.write(payload);
|
|
out.flush();
|
|
}
|
|
}
|
|
|
|
/** 发一条命令并等待应答。 */
|
|
public Map<String, Object> call(Map<String, Object> message, long timeoutMs)
|
|
throws IOException {
|
|
if (!isConnected()) {
|
|
throw new IOException("尚未连接");
|
|
}
|
|
send(message);
|
|
return awaitReply(timeoutMs);
|
|
}
|
|
|
|
private Map<String, Object> awaitReply(long timeoutMs) throws IOException {
|
|
try {
|
|
Map<String, Object> reply = replies.poll(timeoutMs, TimeUnit.MILLISECONDS);
|
|
if (reply == null) {
|
|
throw new SocketTimeoutException("等待服务端响应超时");
|
|
}
|
|
return reply;
|
|
} catch (InterruptedException interrupted) {
|
|
Thread.currentThread().interrupt();
|
|
throw new IOException("等待响应被中断");
|
|
}
|
|
}
|
|
|
|
public Map<String, Object> sendString(String cmd, String key, String value, long timeoutMs)
|
|
throws IOException {
|
|
Map<String, Object> message = Json.obj("cmd", cmd);
|
|
message.put(key, value);
|
|
return call(message, timeoutMs);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 分块传输(图片 / 音频 / 文件)
|
|
// ------------------------------------------------------------------
|
|
|
|
public Map<String, Object> sendChunks(String cmd, String name, byte[] blob, long timeoutMs)
|
|
throws IOException {
|
|
int total = blob.length == 0 ? 1 : (blob.length + CHUNK_SIZE - 1) / CHUNK_SIZE;
|
|
for (int index = 0; index < total; index++) {
|
|
int from = index * CHUNK_SIZE;
|
|
int to = Math.min(blob.length, from + CHUNK_SIZE);
|
|
byte[] piece = blob.length == 0 ? new byte[0] : Arrays.copyOfRange(blob, from, to);
|
|
Map<String, Object> message = Json.obj(
|
|
"cmd", cmd,
|
|
"name", name,
|
|
"seq", index,
|
|
"last", index == total - 1,
|
|
"data", B64.encode(piece));
|
|
send(message);
|
|
}
|
|
return awaitReply(timeoutMs);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 便捷封装
|
|
// ------------------------------------------------------------------
|
|
|
|
public boolean ping(long timeoutMs) {
|
|
try {
|
|
return Json.bool(call(Json.obj("cmd", "ping"), timeoutMs), "pong", false);
|
|
} catch (IOException e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|