mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 21:50:44 +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 + 双连接 文档 - 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
722 lines
33 KiB
Java
722 lines
33 KiB
Java
import com.lanremote.watch.B64;
|
||
import com.lanremote.watch.Json;
|
||
import com.lanremote.watch.RemoteClient;
|
||
|
||
import java.util.ArrayList;
|
||
import java.util.Arrays;
|
||
import java.util.List;
|
||
import java.util.Map;
|
||
import java.util.Random;
|
||
|
||
/**
|
||
* 跨语言联调测试:桌面 JVM 上的 Java 协议层 <-> 真实 Python 被控端。
|
||
*
|
||
* 目的:证明安卓端用的这套代码不是"看起来应该能用",
|
||
* 而是真的能跟被控端完成配对、鉴权、传文件、执行命令。
|
||
*/
|
||
public class JavaClientTest {
|
||
|
||
private static int passed = 0;
|
||
private static int failed = 0;
|
||
private static final List<String> failures = new ArrayList<String>();
|
||
|
||
private static void check(String name, boolean condition) {
|
||
check(name, condition, "");
|
||
}
|
||
|
||
private static void check(String name, boolean condition, String detail) {
|
||
if (condition) {
|
||
passed++;
|
||
System.out.println(" [PASS] " + name);
|
||
} else {
|
||
failed++;
|
||
failures.add(name);
|
||
System.out.println(" [FAIL] " + name + " " + detail);
|
||
}
|
||
}
|
||
|
||
private static void section(String title) {
|
||
System.out.println("====================================================================");
|
||
System.out.println(title);
|
||
}
|
||
|
||
public static void main(String[] args) throws Exception {
|
||
String host = args.length > 0 ? args[0] : "127.0.0.1";
|
||
int port = args.length > 1 ? Integer.parseInt(args[1]) : 8799;
|
||
String code = args.length > 2 ? args[2] : "246813";
|
||
boolean realAgent = args.length > 3 && "real".equals(args[3]);
|
||
final String dumpPath = args.length > 4 ? args[4] : null;
|
||
String deviceId = "java-test-device";
|
||
String deviceName = "JVM测试端";
|
||
|
||
// ---------------- 1. 纯 Java 编解码 ----------------
|
||
section("1. JSON 编解码");
|
||
Map<String, Object> sample = Json.obj("a", 1, "b", "中文\"引号\\反斜杠", "c", true);
|
||
String encoded = Json.write(sample);
|
||
Map<String, Object> decoded = Json.parseObject(encoded);
|
||
check("整数往返", Json.integer(decoded, "a", -1) == 1);
|
||
check("中文与转义往返",
|
||
"中文\"引号\\反斜杠".equals(Json.str(decoded, "b", "")),
|
||
Json.str(decoded, "b", ""));
|
||
check("布尔往返", Json.bool(decoded, "c", false));
|
||
|
||
String tricky = "{\"a\":[1,2.5,-3e2,true,false,null],\"b\":{\"c\":\"\\u4e2d\"}}";
|
||
Map<String, Object> trickyParsed = Json.parseObject(tricky);
|
||
check("嵌套数组解析", Json.list(trickyParsed, "a").size() == 6);
|
||
check("\\u 转义解析",
|
||
"中".equals(Json.str(Json.asMap(trickyParsed.get("b")), "c", "")));
|
||
|
||
boolean threw = false;
|
||
try {
|
||
Json.parseObject("{bad json");
|
||
} catch (RuntimeException e) {
|
||
threw = true;
|
||
}
|
||
check("非法 JSON 抛异常", threw);
|
||
|
||
// ---------------- 2. Base64 ----------------
|
||
section("2. Base64 编解码");
|
||
Random random = new Random(42);
|
||
boolean b64ok = true;
|
||
for (int size : new int[]{0, 1, 2, 3, 4, 5, 100, 1023, 1024, 65537}) {
|
||
byte[] blob = new byte[size];
|
||
random.nextBytes(blob);
|
||
byte[] back = B64.decode(B64.encode(blob));
|
||
if (!Arrays.equals(blob, back)) {
|
||
b64ok = false;
|
||
System.out.println(" 长度 " + size + " 不一致");
|
||
}
|
||
}
|
||
check("各种长度往返一致(含空、非 3 倍数、跨块)", b64ok);
|
||
|
||
// ---------------- 3. 错误配对码 ----------------
|
||
section("3. 配对码校验");
|
||
RemoteClient bad = new RemoteClient();
|
||
bad.setDevice(deviceId, deviceName);
|
||
Map<String, Object> wrong = bad.connect(host, port, "000000", null,
|
||
new SilentListener(), 8000);
|
||
check("错误配对码被拒绝", !Json.bool(wrong, "ok", true),
|
||
Json.str(wrong, "error", ""));
|
||
bad.close();
|
||
|
||
// ---------------- 4. 正确配对 ----------------
|
||
section("4. 配对与令牌");
|
||
RemoteClient client = new RemoteClient();
|
||
client.setDevice(deviceId, deviceName);
|
||
Map<String, Object> paired = client.connect(host, port, code, null,
|
||
new SilentListener(), 8000);
|
||
check("正确配对码通过", Json.bool(paired, "ok", false),
|
||
Json.str(paired, "error", ""));
|
||
String token = Json.str(paired, "token", "");
|
||
check("拿到设备令牌", token.length() == 32, "token=" + token);
|
||
check("拿到服务端名称", Json.str(paired, "serverName", "").length() > 0);
|
||
client.close();
|
||
|
||
// ---------------- 5. 令牌免配对 ----------------
|
||
section("5. 令牌免配对重连(模拟下次打开 App)");
|
||
RemoteClient badToken = new RemoteClient();
|
||
badToken.setDevice(deviceId, deviceName);
|
||
Map<String, Object> rejected = badToken.connect(host, port, null, "deadbeef",
|
||
new SilentListener(), 8000);
|
||
check("无效令牌被拒绝", !Json.bool(rejected, "ok", true));
|
||
check("提示需要重新配对", Json.bool(rejected, "needPair", false));
|
||
badToken.close();
|
||
|
||
FileAccumulator accumulator = new FileAccumulator();
|
||
RemoteClient session = new RemoteClient();
|
||
session.setDevice(deviceId, deviceName);
|
||
Map<String, Object> authed = session.connect(host, port, null, token,
|
||
accumulator, 8000);
|
||
check("有效令牌直接登录", Json.bool(authed, "ok", false),
|
||
Json.str(authed, "error", ""));
|
||
|
||
// ---------------- 6. 基础命令 ----------------
|
||
section("6. 基础命令");
|
||
check("ping", session.ping(5000));
|
||
Map<String, Object> info = session.call(Json.obj("cmd", "info"), 8000);
|
||
Map<String, Object> infoBody = Json.asMap(info.get("info"));
|
||
check("info 返回系统信息", infoBody != null && infoBody.get("os") != null,
|
||
String.valueOf(info));
|
||
|
||
check("媒体:停止",
|
||
Json.bool(session.call(Json.obj("cmd", "media", "action", "stop"), 8000),
|
||
"ok", false));
|
||
check("媒体:非法动作被拒",
|
||
!Json.bool(session.call(Json.obj("cmd", "media", "action", "explode"), 8000),
|
||
"ok", false));
|
||
|
||
// ---------------- 7. 剪贴板 / 播报 / 通知 ----------------
|
||
section("7. 剪贴板 / 播报 / 通知");
|
||
check("clip_set",
|
||
Json.bool(session.call(Json.obj("cmd", "clip_set", "text", "来自Java的文本"),
|
||
8000), "ok", false));
|
||
Map<String, Object> clip = session.call(Json.obj("cmd", "clip_get"), 8000);
|
||
check("clip_get 取回一致", "来自Java的文本".equals(Json.str(clip, "text", "")),
|
||
Json.str(clip, "text", ""));
|
||
check("语音播报",
|
||
Json.bool(session.call(Json.obj("cmd", "say", "text", "Java 联调测试"), 15000),
|
||
"ok", false));
|
||
check("发送通知",
|
||
Json.bool(session.call(Json.obj("cmd", "notify", "title", "来自JVM",
|
||
"text", "联调"), 8000), "ok", false));
|
||
|
||
// ---------------- 8. 投图 / 放音 ----------------
|
||
section("8. 投图 / 放音(分块传输真实素材)");
|
||
// 真实 PNG:被控端会真的用 PIL 打开并全屏显示
|
||
byte[] image = B64.decode(TEST_PNG_B64);
|
||
Map<String, Object> imageReply =
|
||
session.sendChunks("image", "java_photo.png", image, 25000);
|
||
check("图片上传并全屏显示成功",
|
||
Json.bool(imageReply, "ok", false)
|
||
&& Json.integer(imageReply, "size", -1) == image.length,
|
||
String.valueOf(imageReply));
|
||
|
||
// 真实 WAV:被控端会用系统 MCI 播放
|
||
byte[] audio = makeWav(1, 8000);
|
||
Map<String, Object> audioReply =
|
||
session.sendChunks("audio", "java_sound.wav", audio, 25000);
|
||
check("音频上传并播放成功",
|
||
Json.bool(audioReply, "ok", false)
|
||
&& Json.integer(audioReply, "size", -1) == audio.length,
|
||
String.valueOf(audioReply));
|
||
|
||
// 反例:损坏数据必须被明确拒绝,而不是假装成功。
|
||
// 只有在连真实被控端时才有意义——桩模式不做真实图片解码。
|
||
byte[] garbage = new byte[1024];
|
||
random.nextBytes(garbage);
|
||
Map<String, Object> garbageReply =
|
||
session.sendChunks("image", "broken.png", garbage, 25000);
|
||
if (realAgent) {
|
||
check("损坏图片被正确拒绝(不会假装成功)",
|
||
!Json.bool(garbageReply, "ok", true), String.valueOf(garbageReply));
|
||
} else {
|
||
check("桩模式跳过损坏图片校验", true);
|
||
}
|
||
|
||
// ---------------- 9. 文件互传 ----------------
|
||
section("9. 文件上传 / 列表 / 回传");
|
||
byte[] payload = new byte[300 * 1024 + 7]; // 跨越多个分块
|
||
for (int i = 0; i < payload.length; i++) {
|
||
payload[i] = (byte) ((i * 31 + 7) & 0xFF); // 确定性内容,便于三方比对
|
||
}
|
||
System.out.println(" 发送方 sha256=" + sha256(payload)
|
||
+ " len=" + payload.length);
|
||
Map<String, Object> push =
|
||
session.sendChunks("file_push", "java_payload.bin", payload, 20000);
|
||
check("大文件分块上传成功",
|
||
Json.bool(push, "ok", false)
|
||
&& Json.integer(push, "size", -1) == payload.length,
|
||
String.valueOf(push));
|
||
|
||
Map<String, Object> listing = session.call(Json.obj("cmd", "file_list"), 8000);
|
||
boolean found = false;
|
||
for (Object item : Json.list(listing, "files")) {
|
||
Map<String, Object> file = Json.asMap(item);
|
||
if (file != null && "java_payload.bin".equals(Json.str(file, "name", ""))) {
|
||
found = true;
|
||
}
|
||
}
|
||
check("file_list 能看到刚上传的文件", found, String.valueOf(listing));
|
||
|
||
accumulator.reset();
|
||
session.call(Json.obj("cmd", "file_pull", "path", "java_payload.bin"), 20000);
|
||
boolean sizeOk = accumulator.size() == payload.length;
|
||
boolean readyOk = accumulator.ready();
|
||
byte[] pulled = accumulator.data();
|
||
boolean contentOk = Arrays.equals(pulled, payload);
|
||
System.out.println(" 接收方 sha256=" + sha256(pulled)
|
||
+ " len=" + pulled.length);
|
||
if (!contentOk && pulled.length == payload.length) {
|
||
int diffs = 0;
|
||
int firstDiff = -1;
|
||
for (int i = 0; i < payload.length; i++) {
|
||
if (pulled[i] != payload[i]) {
|
||
if (firstDiff < 0) {
|
||
firstDiff = i;
|
||
}
|
||
diffs++;
|
||
}
|
||
}
|
||
System.out.println(" 差异字节数 " + diffs + " / " + payload.length
|
||
+ ",首个 @" + firstDiff);
|
||
System.out.println(" 期望前32: " + hex(payload, 32));
|
||
System.out.println(" 实际前32: " + hex(pulled, 32));
|
||
if (firstDiff >= 0) {
|
||
int from = Math.max(0, firstDiff - 4);
|
||
System.out.println(" 期望@" + from + ": " + hex(slice(payload, from, 24), 24));
|
||
System.out.println(" 实际@" + from + ": " + hex(slice(pulled, from, 24), 24));
|
||
}
|
||
}
|
||
if (dumpPath != null) {
|
||
try {
|
||
java.io.FileOutputStream fos = new java.io.FileOutputStream(dumpPath);
|
||
fos.write(pulled);
|
||
fos.close();
|
||
System.out.println(" 已把接收内容写入 " + dumpPath);
|
||
} catch (Exception e) {
|
||
System.out.println(" 写 dump 失败: " + e);
|
||
}
|
||
}
|
||
check("file_pull 回传字节数一致", sizeOk, "got " + accumulator.size());
|
||
check("file_pull 收到结束标记", readyOk, "last 标志未到达");
|
||
check("file_pull 回传内容一致", contentOk,
|
||
"size=" + accumulator.size() + " ready=" + readyOk);
|
||
|
||
// ---------------- 10. 自定义命令 ----------------
|
||
section("10. 自定义命令");
|
||
Map<String, Object> run = session.call(
|
||
Json.obj("cmd", "run", "command", "echo java-bridge-ok"), 20000);
|
||
check("run 成功", Json.bool(run, "ok", false), String.valueOf(run));
|
||
check("run 输出正确",
|
||
Json.str(run, "stdout", "").contains("java-bridge-ok"),
|
||
Json.str(run, "stdout", ""));
|
||
check("run 退出码为 0", Json.integer(run, "code", -1) == 0);
|
||
|
||
// ---------------- 11. 事件通道 ----------------
|
||
section("11. 服务端主动事件");
|
||
// 注意:客户端自己 clip_set 的内容不会被回推(避免回声),
|
||
// 所以这里要从"电脑本地"改剪贴板,才能真正触发推送通道。
|
||
//
|
||
// 但剪贴板可能被机器上别的程序占着(输入法等),那时
|
||
// Set-Clipboard 会直接失败。这属于环境问题,不是被控端的毛病,
|
||
// 所以检测到就跳过,不要给出假失败。
|
||
if (!clipboardUsable()) {
|
||
System.out.println(" (剪贴板被其它程序占用,跳过这一项)");
|
||
check("剪贴板推送(环境不支持,跳过)", true, "skipped");
|
||
} else {
|
||
triggerLocalClipboard();
|
||
Map<String, Object> clipEvent = waitForEvent(accumulator, "clip", 20000);
|
||
check("收到电脑本地剪贴板变化推送", clipEvent != null,
|
||
clipEvent == null ? "超时未收到" : "ok");
|
||
}
|
||
|
||
// ---------------- 13. 屏幕镜像 / 截屏 ----------------
|
||
// ---------------- 12. 功能页 / 语音页 ----------------
|
||
section("12. 功能页 / 语音页(用户报连不上的那几个)");
|
||
try {
|
||
long t0 = System.currentTimeMillis();
|
||
Map<String, Object> featListReply =
|
||
session.call(Json.obj("cmd", "feature_list"), 25000);
|
||
long cost = System.currentTimeMillis() - t0;
|
||
java.util.List<Object> features = Json.list(featListReply, "features");
|
||
check("feature_list 返回 ok", Json.bool(featListReply, "ok", false),
|
||
Json.str(featListReply, "error", "(无 error 字段)")
|
||
+ " 耗时 " + cost + "ms");
|
||
check("feature_list 功能数 > 100", features.size() > 100,
|
||
features.size() + " 个,耗时 " + cost + "ms");
|
||
if (!features.isEmpty() && features.get(0) instanceof Map) {
|
||
@SuppressWarnings("unchecked")
|
||
Map<String, Object> first = (Map<String, Object>) features.get(0);
|
||
check("功能项带 name/label/group",
|
||
Json.str(first, "name", "").length() > 0
|
||
&& Json.str(first, "label", "").length() > 0
|
||
&& Json.str(first, "group", "").length() > 0,
|
||
String.valueOf(first));
|
||
}
|
||
|
||
long t1 = System.currentTimeMillis();
|
||
Map<String, Object> voiceListReply =
|
||
session.call(Json.obj("cmd", "voice_list"), 25000);
|
||
java.util.List<Object> voices = Json.list(voiceListReply, "voices");
|
||
check("voice_list 返回 ok", Json.bool(voiceListReply, "ok", false),
|
||
Json.str(voiceListReply, "error", "") + " 耗时 "
|
||
+ (System.currentTimeMillis() - t1) + "ms");
|
||
check("voice_list 有音色", voices.size() > 0,
|
||
voices.size() + " 个");
|
||
|
||
long t2 = System.currentTimeMillis();
|
||
Map<String, Object> audioListReply =
|
||
session.call(Json.obj("cmd", "audio_list"), 25000);
|
||
check("audio_list 返回 ok", Json.bool(audioListReply, "ok", false),
|
||
Json.str(audioListReply, "error", "") + " 耗时 "
|
||
+ (System.currentTimeMillis() - t2) + "ms");
|
||
|
||
long t3 = System.currentTimeMillis();
|
||
Map<String, Object> sayTestReply =
|
||
session.call(Json.obj("cmd", "say", "text", "测试"), 25000);
|
||
check("say 返回 ok", Json.bool(sayTestReply, "ok", false),
|
||
Json.str(sayTestReply, "error", "") + " 耗时 "
|
||
+ (System.currentTimeMillis() - t3) + "ms");
|
||
|
||
long t4 = System.currentTimeMillis();
|
||
Map<String, Object> featRunReply = session.call(
|
||
Json.obj("cmd", "feature_run", "name", "world_clock",
|
||
"params", Json.obj()), 25000);
|
||
check("feature_run 返回 ok", Json.bool(featRunReply, "ok", false),
|
||
Json.str(featRunReply, "error", "") + " 耗时 "
|
||
+ (System.currentTimeMillis() - t4) + "ms");
|
||
} catch (Exception e) {
|
||
check("功能页/语音页命令能调用", false, String.valueOf(e));
|
||
}
|
||
|
||
section("13. 屏幕镜像 / 截屏");
|
||
Map<String, Object> shot = session.call(Json.obj("cmd", "screenshot",
|
||
"maxWidth", 640, "quality", 70), 40000);
|
||
check("截屏返回成功", Json.bool(shot, "ok", false), brief(shot));
|
||
byte[] shotJpeg = B64.decode(Json.str(shot, "data", ""));
|
||
check("截屏是合法 JPEG", isJpeg(shotJpeg), shotJpeg.length + " 字节");
|
||
check("截屏尺寸有效",
|
||
Json.integer(shot, "w", 0) > 0 && Json.integer(shot, "h", 0) > 0,
|
||
Json.integer(shot, "w", 0) + "x" + Json.integer(shot, "h", 0));
|
||
check("截屏遵守 maxWidth", Json.integer(shot, "w", 9999) <= 640,
|
||
String.valueOf(Json.integer(shot, "w", 0)));
|
||
|
||
accumulator.resetFrames();
|
||
Map<String, Object> screenStart = session.call(Json.obj("cmd", "screen_start",
|
||
"fps", 4, "maxWidth", 320, "quality", 50), 25000);
|
||
check("开始屏幕镜像", Json.bool(screenStart, "ok", false), brief(screenStart));
|
||
|
||
long frameDeadline = System.currentTimeMillis() + 20000;
|
||
while (System.currentTimeMillis() < frameDeadline && accumulator.frames() < 3) {
|
||
Thread.sleep(150);
|
||
}
|
||
check("收到至少 3 帧镜像", accumulator.frames() >= 3,
|
||
"实际收到 " + accumulator.frames() + " 帧");
|
||
byte[] lastFrame = accumulator.lastFrame();
|
||
check("镜像帧是合法 JPEG", isJpeg(lastFrame),
|
||
lastFrame == null ? "null" : lastFrame.length + " 字节");
|
||
check("镜像帧遵守 maxWidth", accumulator.lastFrameWidth() <= 320,
|
||
String.valueOf(accumulator.lastFrameWidth()));
|
||
|
||
Map<String, Object> screenStop = session.call(Json.obj("cmd", "screen_stop"), 15000);
|
||
check("停止屏幕镜像", Json.bool(screenStop, "ok", false), brief(screenStop));
|
||
|
||
// ---------------- 14. 局域网自动发现 ----------------
|
||
section("14. 局域网自动发现(手表不用输 IP)");
|
||
|
||
// 定向广播地址计算 —— 这是真机上暴露出来的 bug:
|
||
// 原来写死猜 192.168.255.255,对 /24 根本不是本网段广播地址
|
||
check("定向广播 192.168.10.104/24 -> 192.168.10.255",
|
||
"192.168.10.255".equals(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.10.104", 24)),
|
||
String.valueOf(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.10.104", 24)));
|
||
check("定向广播 10.0.0.5/8 -> 10.255.255.255",
|
||
"10.255.255.255".equals(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("10.0.0.5", 8)),
|
||
String.valueOf(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("10.0.0.5", 8)));
|
||
check("定向广播 172.16.3.7/16 -> 172.16.255.255",
|
||
"172.16.255.255".equals(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("172.16.3.7", 16)),
|
||
String.valueOf(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("172.16.3.7", 16)));
|
||
check("定向广播 192.168.1.1/32 -> 自身",
|
||
"192.168.1.1".equals(
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("192.168.1.1", 32)));
|
||
check("IP 与整数互转往返",
|
||
"192.168.10.104".equals(com.lanremote.watch.DiscoveryCore.longToIp(
|
||
com.lanremote.watch.DiscoveryCore.ipToLong("192.168.10.104"))));
|
||
|
||
// 子网扫描的地址列表(广播被拦时的兜底手段)
|
||
List<String> subnet = com.lanremote.watch.DiscoveryCore.subnetHosts(
|
||
"192.168.10.104", 24, 1024);
|
||
check("子网主机数正确 (/24 -> 254)", subnet.size() == 254, "得到 " + subnet.size());
|
||
if (subnet.size() == 254) {
|
||
check("子网首尾地址正确",
|
||
"192.168.10.1".equals(subnet.get(0))
|
||
&& "192.168.10.254".equals(subnet.get(253)),
|
||
subnet.get(0) + " .. " + subnet.get(253));
|
||
}
|
||
|
||
List<com.lanremote.watch.DiscoveryCore.Found> discovered =
|
||
com.lanremote.watch.DiscoveryCore.search(4000,
|
||
com.lanremote.watch.DiscoveryCore.directedBroadcast("127.0.0.1", 8));
|
||
check("广播搜索到被控端", !discovered.isEmpty(), "找到 " + discovered.size() + " 台");
|
||
com.lanremote.watch.DiscoveryCore.Found target = null;
|
||
for (com.lanremote.watch.DiscoveryCore.Found item : discovered) {
|
||
System.out.println(" 发现: " + item);
|
||
if (target == null) {
|
||
target = item;
|
||
}
|
||
}
|
||
// 优先挑端口匹配的那台(同一台电脑上可能还跑着别的实例)
|
||
for (com.lanremote.watch.DiscoveryCore.Found item : discovered) {
|
||
if (item.port == port) {
|
||
target = item;
|
||
break;
|
||
}
|
||
}
|
||
if (target != null) {
|
||
check("广播搜索结果里包含本次被控端(端口 " + port + ")",
|
||
target.port == port, "拿到的是端口 " + target.port);
|
||
check("发现的电脑名非空", target.name != null && target.name.length() > 0,
|
||
String.valueOf(target.name));
|
||
check("按发现结果确实能建立 TCP 连接",
|
||
com.lanremote.watch.DiscoveryCore.ping(target.host, target.port, 3000));
|
||
|
||
// 单播探测:诊断工具在"广播被拦"时用它判断到底是广播问题还是根本不通
|
||
com.lanremote.watch.DiscoveryCore.Found direct =
|
||
com.lanremote.watch.DiscoveryCore.probeHost("127.0.0.1", 3000);
|
||
check("单播探测指定 IP 有应答(诊断工具的兜底路径)",
|
||
direct != null,
|
||
direct == null ? "无应答" : "应答端口 " + direct.port);
|
||
}
|
||
|
||
// ---------------- 15. 断开处理 ----------------
|
||
section("14. 断开处理");
|
||
session.close();
|
||
Thread.sleep(500);
|
||
check("关闭后 isConnected 为 false", !session.isConnected());
|
||
|
||
System.out.println();
|
||
System.out.println("====================================================================");
|
||
System.out.println("通过 " + passed + " 项,失败 " + failed + " 项");
|
||
if (!failures.isEmpty()) {
|
||
System.out.println("失败项:");
|
||
for (String name : failures) {
|
||
System.out.println(" - " + name);
|
||
}
|
||
}
|
||
System.exit(failed == 0 ? 0 : 1);
|
||
}
|
||
|
||
private static Map<String, Object> waitForEvent(FileAccumulator acc, String eventName,
|
||
long timeoutMs) throws Exception {
|
||
long deadline = System.currentTimeMillis() + timeoutMs;
|
||
while (System.currentTimeMillis() < deadline) {
|
||
Map<String, Object> found = acc.pollEvent(eventName);
|
||
if (found != null) {
|
||
return found;
|
||
}
|
||
Thread.sleep(150);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 用 PowerShell 改本机剪贴板,模拟"用户在电脑上自己复制了东西"。 */
|
||
/** 剪贴板现在能不能用(没被别的程序占着)。 */
|
||
private static boolean clipboardUsable() {
|
||
try {
|
||
Process p = Runtime.getRuntime().exec(new String[]{
|
||
"powershell", "-NoProfile", "-Command",
|
||
"try { Set-Clipboard -Value ('probe-' + [guid]::NewGuid()); "
|
||
+ "'ok' } catch { 'locked' }"});
|
||
java.io.BufferedReader reader = new java.io.BufferedReader(
|
||
new java.io.InputStreamReader(p.getInputStream(), "UTF-8"));
|
||
String line;
|
||
while ((line = reader.readLine()) != null) {
|
||
if (line.trim().equalsIgnoreCase("ok")) {
|
||
return true;
|
||
}
|
||
if (line.trim().equalsIgnoreCase("locked")) {
|
||
return false;
|
||
}
|
||
}
|
||
p.waitFor();
|
||
return false;
|
||
} catch (Exception e) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
private static void triggerLocalClipboard() {
|
||
try {
|
||
Runtime.getRuntime().exec(new String[]{
|
||
"powershell", "-NoProfile", "-Command",
|
||
"Set-Clipboard -Value 'java-local-clip-" + System.nanoTime() + "'"});
|
||
} catch (Exception ignored) {
|
||
// 环境不支持时忽略,靠轮询依然可能收到事件
|
||
}
|
||
}
|
||
|
||
/** 生成一个真实可播放的 WAV(16bit 单声道正弦波)。 */
|
||
private static byte[] makeWav(int seconds, int sampleRate) {
|
||
int samples = sampleRate * seconds;
|
||
int dataSize = samples * 2;
|
||
java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
|
||
try {
|
||
out.write("RIFF".getBytes("US-ASCII"));
|
||
out.write(intLE(36 + dataSize));
|
||
out.write("WAVE".getBytes("US-ASCII"));
|
||
out.write("fmt ".getBytes("US-ASCII"));
|
||
out.write(intLE(16));
|
||
out.write(shortLE((short) 1)); // PCM
|
||
out.write(shortLE((short) 1)); // 单声道
|
||
out.write(intLE(sampleRate));
|
||
out.write(intLE(sampleRate * 2)); // 字节率
|
||
out.write(shortLE((short) 2)); // block align
|
||
out.write(shortLE((short) 16)); // 位深
|
||
out.write("data".getBytes("US-ASCII"));
|
||
out.write(intLE(dataSize));
|
||
for (int i = 0; i < samples; i++) {
|
||
double wave = Math.sin(2 * Math.PI * 440 * i / sampleRate);
|
||
out.write(shortLE((short) (3000 * wave)));
|
||
}
|
||
} catch (Exception ignored) {
|
||
// 内存写入不会失败
|
||
}
|
||
return out.toByteArray();
|
||
}
|
||
|
||
private static boolean isJpeg(byte[] data) {
|
||
return data != null && data.length > 4
|
||
&& (data[0] & 0xFF) == 0xFF && (data[1] & 0xFF) == 0xD8
|
||
&& (data[data.length - 2] & 0xFF) == 0xFF
|
||
&& (data[data.length - 1] & 0xFF) == 0xD9;
|
||
}
|
||
|
||
/** 把回包压成一行,便于失败时看原因。 */
|
||
private static String brief(Map<String, Object> message) {
|
||
if (message == null) {
|
||
return "null";
|
||
}
|
||
String text = String.valueOf(message);
|
||
if (text.length() > 160) {
|
||
// 截屏回包里有大段 base64,打印时裁掉
|
||
text = text.replaceAll("\"data\":\"[^\"]{40,}\"", "\"data\":\"<省略>\"");
|
||
}
|
||
return text.length() > 200 ? text.substring(0, 200) + "…" : text;
|
||
}
|
||
|
||
private static String hex(byte[] data, int count) {
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < Math.min(count, data.length); i++) {
|
||
sb.append(String.format("%02x ", data[i] & 0xFF));
|
||
}
|
||
return sb.toString().trim();
|
||
}
|
||
|
||
private static byte[] slice(byte[] data, int from, int count) {
|
||
int to = Math.min(data.length, from + count);
|
||
return Arrays.copyOfRange(data, from, Math.max(from, to));
|
||
}
|
||
|
||
/** 计算 SHA-256 前 16 位十六进制,用于三方比对。 */
|
||
private static String sha256(byte[] data) { try {
|
||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||
byte[] hash = digest.digest(data);
|
||
StringBuilder sb = new StringBuilder();
|
||
for (int i = 0; i < 8; i++) {
|
||
sb.append(String.format("%02x", hash[i]));
|
||
}
|
||
return sb.toString();
|
||
} catch (Exception e) {
|
||
return "n/a";
|
||
}
|
||
}
|
||
|
||
private static byte[] intLE(int value) { return new byte[]{
|
||
(byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF),
|
||
(byte) ((value >> 16) & 0xFF), (byte) ((value >> 24) & 0xFF)};
|
||
}
|
||
|
||
private static byte[] shortLE(short value) {
|
||
return new byte[]{(byte) (value & 0xFF), (byte) ((value >> 8) & 0xFF)};
|
||
}
|
||
|
||
/** 一张真实的 64x48 PNG。 */
|
||
private static final String TEST_PNG_B64 =
|
||
"iVBORw0KGgoAAAANSUhEUgAAAEAAAAAwCAIAAAAuKetIAAAAkUlEQVR4nO3ZsQ3CUBAE0fGIOqAM"
|
||
+ "WoMKaM2U8TuhApwgYc+XX3rJSbvR3XJ9rJRJnMRJnMRJnMRJ3OXbYLzuHMzt+Z4wAZm1QtvB/dPY"
|
||
+ "LHM+AYmTOImTOImTOImTOImTOImTOImTOImTOImTOImTOImTOImTOJn+tDiOd6aeKgGJW85H984k"
|
||
+ "TuIkTuLce4FffQD4RwrAdUul6AAAAABJRU5ErkJggg==";
|
||
|
||
/** 收集 file 事件分块、屏幕镜像帧,同时缓存其他事件。 */
|
||
static class FileAccumulator implements RemoteClient.Listener {
|
||
private final java.io.ByteArrayOutputStream buffer = new java.io.ByteArrayOutputStream();
|
||
private final java.util.concurrent.ConcurrentLinkedQueue<Map<String, Object>> events =
|
||
new java.util.concurrent.ConcurrentLinkedQueue<Map<String, Object>>();
|
||
private volatile boolean complete = false;
|
||
|
||
private volatile int frameCount = 0;
|
||
private volatile byte[] lastFrame = null;
|
||
private volatile int lastFrameWidth = 0;
|
||
private volatile int lastFrameHeight = 0;
|
||
|
||
void reset() {
|
||
synchronized (buffer) {
|
||
buffer.reset();
|
||
complete = false;
|
||
}
|
||
}
|
||
|
||
void resetFrames() {
|
||
frameCount = 0;
|
||
lastFrame = null;
|
||
lastFrameWidth = 0;
|
||
lastFrameHeight = 0;
|
||
}
|
||
|
||
int frames() {
|
||
return frameCount;
|
||
}
|
||
|
||
byte[] lastFrame() {
|
||
return lastFrame;
|
||
}
|
||
|
||
int lastFrameWidth() {
|
||
return lastFrameWidth;
|
||
}
|
||
|
||
int lastFrameHeight() {
|
||
return lastFrameHeight;
|
||
}
|
||
|
||
int size() {
|
||
synchronized (buffer) {
|
||
return buffer.size();
|
||
}
|
||
}
|
||
|
||
boolean ready() {
|
||
return complete;
|
||
}
|
||
|
||
byte[] data() {
|
||
synchronized (buffer) {
|
||
return buffer.toByteArray();
|
||
}
|
||
}
|
||
|
||
Map<String, Object> pollEvent(String name) {
|
||
for (Map<String, Object> event : events) {
|
||
if (name.equals(Json.str(event, "event", ""))) {
|
||
events.remove(event);
|
||
return event;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@Override
|
||
public void onEvent(String event, Map<String, Object> message) {
|
||
if ("frame".equals(event)) {
|
||
byte[] jpeg = B64.decode(Json.str(message, "data", ""));
|
||
frameCount++;
|
||
lastFrame = jpeg;
|
||
lastFrameWidth = Json.integer(message, "w", 0);
|
||
lastFrameHeight = Json.integer(message, "h", 0);
|
||
return;
|
||
}
|
||
if ("file".equals(event)) {
|
||
byte[] chunk = B64.decode(Json.str(message, "data", ""));
|
||
synchronized (buffer) {
|
||
buffer.write(chunk, 0, chunk.length);
|
||
if (Json.bool(message, "last", false)) {
|
||
complete = true;
|
||
}
|
||
}
|
||
} else {
|
||
events.add(message);
|
||
}
|
||
}
|
||
|
||
@Override
|
||
public void onDisconnected(String reason) {
|
||
System.out.println(" (连接断开: " + reason + ")");
|
||
}
|
||
}
|
||
|
||
static class SilentListener implements RemoteClient.Listener {
|
||
@Override
|
||
public void onEvent(String event, Map<String, Object> message) {
|
||
}
|
||
|
||
@Override
|
||
public void onDisconnected(String reason) {
|
||
}
|
||
}
|
||
}
|