From 3f18e6bb408d5a63ab26933514ab9d2176e51f0d Mon Sep 17 00:00:00 2001 From: xmz7891 Date: Thu, 8 Oct 2026 18:36:50 +0800 Subject: [PATCH] =?UTF-8?q?2.1.0=EF=BC=9A=E7=BD=91=E7=AB=99=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=8A=A2=E6=95=91=20+=20=E5=90=8E=E5=8F=B0=E6=8A=A2?= =?UTF-8?q?=E6=95=91=E6=8A=A5=E5=91=8A=E5=88=86=E5=8C=BA=20+=20=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E7=83=AD=E9=97=A8=E9=9F=B3=E6=95=88=20+=20=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E6=B5=8B=E8=AF=95=E5=90=8E=E5=8F=B0=E9=9D=99=E9=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。 == 一、网站挂了会自己救 == 用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立 检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。 新增 relay/monitor.sh + lanremote-monitor.timer: 每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1) 为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是 经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过 隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。 连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败), 救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上, 下一次检查又救一遍,来回重启反而永远连不上)。 新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停 1 重启隧道 不动中转,在线设备不掉线 2 重启中转服务 设备要重连,但很快 3 重新施加网络调优 MSS / MTU 跑偏时用 4 重启无线网卡 4G / WiFi 卡死时用 5 重启整机 最后的底牌,**默认不开**(要 RESCUE_REBOOT=1) 理由:如果问题出在供电或上游线路,重启只会让恢复更慢; 而且半夜自动重启一台没人管的机器风险太大。 == 二、抢救报告 + 后台分区 == 用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在 中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题 的方法加入紧急预案」。 * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、 三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载 * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变, 也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的: 那个是给用户的安装包,文件名带版本号、内容永不改变, 所以敢发 604800 长缓存 * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部, 是运维信息。第一版把这条路由插在 _admin_gate() 之前, 等于匿名可下,自己查出来补上了 * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md; 监测单元读它,下次同类症状优先试历史上管用的那一级 (这就是用户说的"解决后将解决问题的方法加入紧急预案") == 三、网络热门音效 == 用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈, 各种老牧师音效,可以直接远程播放」。 **版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音** (影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权, 也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次 "内容可能含有违规信息")。 所以: * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、 不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身 * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在 列表里,照样能远程播。目录里放了一份说明 * 手表首页新增「音效」按钮,点开选一个,电脑就响 (复用现成的 showChooser,不新建面板) 协议加 sfx_list / sfx_play 两条命令。 == 四、修「安卓设置页一片空白」 == 用户报的。根因:设置页**建好了但忘了 root.addView()** —— showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。 这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言 _tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、 并且纳入 showOnly()。 == 五、全量测试改成后台静默 == 用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows 隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、 可见、几何、Win32 枚举都真实,测试有效性一点没损失 —— 只是不在用户眼前,全屏也不会锁屏。 顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop 成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。 于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了 (用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。 现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。 == 六、顺手修的真 bug == * windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的, 说明"未定义名"这条检查有用。 * _build/package_remote.py 的文件白名单只有 .py/.sh/.ico, **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动, 而且不报错、只是静默不跑。已加。 * protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。 * deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行 (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。 * _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台", 触发对外材料清洁度检查。改成不提管理页(用户定的规矩: 对外只能提官网)。 全量回归 46 个阶段全部通过。 --- LICENSE | 1342 ++++++----- _build/CHANGELOG.md | 50 + _build/_cmsg_210.txt | 111 + _build/add_monitor_to_deploy.py | 135 ++ _build/add_rescue_section.py | 261 ++ _build/add_watch_sfx.py | 137 ++ _build/auth_rescue_route.py | 104 + _build/check_device.py | 96 + _build/debug_rescue_000.py | 57 + _build/deploy_monitor.py | 151 ++ _build/diag_admin_section.py | 70 + _build/fix_deploy_watchdog_lines.py | 63 + _build/fix_rescue_gate.py | 97 + _build/fix_rescue_section.py | 218 ++ _build/make_license.py | 81 + _build/make_monitor_units.py | 92 + _build/package_remote.py | 18 +- _build/repair_device.py | 82 + _build/restart_and_verify.py | 87 + _build/verify_admin_login.py | 79 + _build/verify_gate_fix.py | 55 + _build/verify_monitor_e2e.py | 100 + _build/wire_sfx.py | 86 + _build/wrap_gui_stages.py | 127 + _tests/_stubdata2/devices.json | 8 + _tests/_stubdata2/settings.json | 25 + _tests/hidden_desktop.py | 256 ++ .../com/lanremote/watch/MainActivity$10.class | Bin 904 -> 1123 bytes .../lanremote/watch/MainActivity$100.class | Bin 942 -> 5449 bytes .../lanremote/watch/MainActivity$101.class | Bin 702 -> 942 bytes .../lanremote/watch/MainActivity$102.class | Bin 580 -> 702 bytes .../lanremote/watch/MainActivity$103.class | Bin 982 -> 580 bytes .../lanremote/watch/MainActivity$104.class | Bin 580 -> 982 bytes .../lanremote/watch/MainActivity$105.class | Bin 1620 -> 580 bytes .../lanremote/watch/MainActivity$106.class | Bin 741 -> 1620 bytes .../lanremote/watch/MainActivity$107$1.class | Bin 3147 -> 0 bytes .../lanremote/watch/MainActivity$107.class | Bin 2267 -> 741 bytes .../lanremote/watch/MainActivity$108.class | Bin 1946 -> 2267 bytes .../lanremote/watch/MainActivity$109.class | Bin 1223 -> 1946 bytes .../com/lanremote/watch/MainActivity$11.class | Bin 816 -> 904 bytes .../lanremote/watch/MainActivity$110.class | Bin 957 -> 1223 bytes .../lanremote/watch/MainActivity$111.class | Bin 1486 -> 957 bytes .../lanremote/watch/MainActivity$112$1.class | Bin 1483 -> 1115 bytes .../lanremote/watch/MainActivity$112.class | Bin 1467 -> 1486 bytes .../lanremote/watch/MainActivity$113.class | Bin 1518 -> 1601 bytes .../lanremote/watch/MainActivity$114.class | Bin 945 -> 1467 bytes .../lanremote/watch/MainActivity$115.class | Bin 985 -> 1518 bytes .../lanremote/watch/MainActivity$116.class | Bin 911 -> 945 bytes .../lanremote/watch/MainActivity$117$1.class | Bin 2000 -> 0 bytes .../lanremote/watch/MainActivity$117.class | Bin 1027 -> 985 bytes .../lanremote/watch/MainActivity$118.class | Bin 1504 -> 911 bytes .../lanremote/watch/MainActivity$119.class | Bin 922 -> 1028 bytes .../com/lanremote/watch/MainActivity$12.class | Bin 1133 -> 817 bytes .../lanremote/watch/MainActivity$120.class | Bin 723 -> 1504 bytes .../lanremote/watch/MainActivity$121.class | Bin 2273 -> 922 bytes .../lanremote/watch/MainActivity$122.class | Bin 2140 -> 723 bytes .../lanremote/watch/MainActivity$123.class | Bin 1719 -> 2273 bytes .../lanremote/watch/MainActivity$124.class | Bin 1361 -> 2140 bytes .../lanremote/watch/MainActivity$125.class | Bin 554 -> 1719 bytes .../lanremote/watch/MainActivity$126.class | Bin 555 -> 1361 bytes .../lanremote/watch/MainActivity$127.class | Bin 828 -> 554 bytes .../lanremote/watch/MainActivity$128.class | Bin 1936 -> 555 bytes .../lanremote/watch/MainActivity$129.class | Bin 940 -> 828 bytes .../com/lanremote/watch/MainActivity$13.class | Bin 651 -> 1133 bytes .../com/lanremote/watch/MainActivity$14.class | Bin 929 -> 651 bytes .../com/lanremote/watch/MainActivity$15.class | Bin 983 -> 929 bytes .../com/lanremote/watch/MainActivity$16.class | Bin 620 -> 983 bytes .../com/lanremote/watch/MainActivity$17.class | Bin 615 -> 620 bytes .../com/lanremote/watch/MainActivity$18.class | Bin 615 -> 615 bytes .../com/lanremote/watch/MainActivity$19.class | Bin 615 -> 615 bytes .../com/lanremote/watch/MainActivity$20.class | Bin 616 -> 615 bytes .../com/lanremote/watch/MainActivity$21.class | Bin 618 -> 616 bytes .../com/lanremote/watch/MainActivity$22.class | Bin 615 -> 618 bytes .../com/lanremote/watch/MainActivity$23.class | Bin 750 -> 615 bytes .../com/lanremote/watch/MainActivity$24.class | Bin 545 -> 750 bytes .../com/lanremote/watch/MainActivity$25.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$26.class | Bin 622 -> 545 bytes .../com/lanremote/watch/MainActivity$27.class | Bin 622 -> 622 bytes .../com/lanremote/watch/MainActivity$28.class | Bin 618 -> 622 bytes .../com/lanremote/watch/MainActivity$29.class | Bin 545 -> 618 bytes .../com/lanremote/watch/MainActivity$3.class | Bin 1454 -> 1454 bytes .../com/lanremote/watch/MainActivity$30.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$31.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$32.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$33.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$34.class | Bin 770 -> 545 bytes .../com/lanremote/watch/MainActivity$35.class | Bin 545 -> 770 bytes .../com/lanremote/watch/MainActivity$36.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$37.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$38.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$39.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$4.class | Bin 719 -> 719 bytes .../com/lanremote/watch/MainActivity$40.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$41.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$42.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$43.class | Bin 868 -> 545 bytes .../com/lanremote/watch/MainActivity$44.class | Bin 721 -> 868 bytes .../com/lanremote/watch/MainActivity$45.class | Bin 749 -> 721 bytes .../com/lanremote/watch/MainActivity$46.class | Bin 745 -> 749 bytes .../com/lanremote/watch/MainActivity$47.class | Bin 752 -> 745 bytes .../com/lanremote/watch/MainActivity$48.class | Bin 647 -> 752 bytes .../com/lanremote/watch/MainActivity$49.class | Bin 648 -> 647 bytes .../com/lanremote/watch/MainActivity$5.class | Bin 649 -> 649 bytes .../com/lanremote/watch/MainActivity$50.class | Bin 651 -> 648 bytes .../com/lanremote/watch/MainActivity$51.class | Bin 650 -> 651 bytes .../com/lanremote/watch/MainActivity$52.class | Bin 545 -> 650 bytes .../com/lanremote/watch/MainActivity$53.class | Bin 545 -> 545 bytes .../com/lanremote/watch/MainActivity$54.class | Bin 881 -> 545 bytes .../com/lanremote/watch/MainActivity$55.class | Bin 868 -> 881 bytes .../com/lanremote/watch/MainActivity$56.class | Bin 875 -> 868 bytes .../com/lanremote/watch/MainActivity$57.class | Bin 873 -> 875 bytes .../lanremote/watch/MainActivity$58$1.class | Bin 992 -> 0 bytes .../com/lanremote/watch/MainActivity$58.class | Bin 936 -> 873 bytes .../com/lanremote/watch/MainActivity$59.class | Bin 867 -> 936 bytes .../com/lanremote/watch/MainActivity$6.class | Bin 649 -> 649 bytes .../com/lanremote/watch/MainActivity$60.class | Bin 859 -> 867 bytes .../com/lanremote/watch/MainActivity$61.class | Bin 859 -> 859 bytes .../com/lanremote/watch/MainActivity$62.class | Bin 864 -> 859 bytes .../com/lanremote/watch/MainActivity$63.class | Bin 706 -> 864 bytes .../lanremote/watch/MainActivity$64$1.class | Bin 1638 -> 0 bytes .../lanremote/watch/MainActivity$64$2.class | Bin 1142 -> 0 bytes .../com/lanremote/watch/MainActivity$64.class | Bin 1539 -> 706 bytes .../com/lanremote/watch/MainActivity$65.class | Bin 698 -> 1539 bytes .../com/lanremote/watch/MainActivity$66.class | Bin 683 -> 698 bytes .../com/lanremote/watch/MainActivity$67.class | Bin 1473 -> 683 bytes .../lanremote/watch/MainActivity$68$1.class | Bin 974 -> 0 bytes .../lanremote/watch/MainActivity$68$2.class | Bin 1121 -> 0 bytes .../com/lanremote/watch/MainActivity$68.class | Bin 2106 -> 1473 bytes .../com/lanremote/watch/MainActivity$69.class | Bin 654 -> 2106 bytes .../com/lanremote/watch/MainActivity$7.class | Bin 649 -> 649 bytes .../com/lanremote/watch/MainActivity$70.class | Bin 996 -> 654 bytes .../com/lanremote/watch/MainActivity$71.class | Bin 916 -> 996 bytes .../com/lanremote/watch/MainActivity$72.class | Bin 1002 -> 916 bytes .../com/lanremote/watch/MainActivity$73.class | Bin 952 -> 1002 bytes .../com/lanremote/watch/MainActivity$74.class | Bin 1103 -> 952 bytes .../com/lanremote/watch/MainActivity$75.class | Bin 1113 -> 1103 bytes .../lanremote/watch/MainActivity$76$1$1.class | Bin 1201 -> 0 bytes .../lanremote/watch/MainActivity$76$1.class | Bin 1351 -> 0 bytes .../com/lanremote/watch/MainActivity$76.class | Bin 1504 -> 1113 bytes .../com/lanremote/watch/MainActivity$77.class | Bin 1102 -> 1504 bytes .../lanremote/watch/MainActivity$78$1.class | Bin 1623 -> 0 bytes .../com/lanremote/watch/MainActivity$78.class | Bin 1846 -> 1102 bytes .../lanremote/watch/MainActivity$79$1$1.class | Bin 1176 -> 0 bytes .../lanremote/watch/MainActivity$79$1.class | Bin 2076 -> 1623 bytes .../com/lanremote/watch/MainActivity$79.class | Bin 1515 -> 1846 bytes .../com/lanremote/watch/MainActivity$8.class | Bin 556 -> 649 bytes .../lanremote/watch/MainActivity$80$1$1.class | Bin 1020 -> 1176 bytes .../lanremote/watch/MainActivity$80$1.class | Bin 1558 -> 2076 bytes .../com/lanremote/watch/MainActivity$80.class | Bin 2403 -> 1515 bytes .../com/lanremote/watch/MainActivity$81.class | Bin 1376 -> 2403 bytes .../com/lanremote/watch/MainActivity$82.class | Bin 798 -> 1376 bytes .../com/lanremote/watch/MainActivity$83.class | Bin 1238 -> 798 bytes .../com/lanremote/watch/MainActivity$84.class | Bin 1139 -> 1238 bytes .../com/lanremote/watch/MainActivity$85.class | Bin 1027 -> 1139 bytes .../lanremote/watch/MainActivity$86$1.class | Bin 1897 -> 0 bytes .../com/lanremote/watch/MainActivity$86.class | Bin 2075 -> 1027 bytes .../com/lanremote/watch/MainActivity$87.class | Bin 1395 -> 2075 bytes .../com/lanremote/watch/MainActivity$88.class | Bin 559 -> 1395 bytes .../com/lanremote/watch/MainActivity$89.class | Bin 652 -> 559 bytes .../lanremote/watch/MainActivity$9$1.class | Bin 1222 -> 0 bytes .../com/lanremote/watch/MainActivity$9.class | Bin 1120 -> 556 bytes .../com/lanremote/watch/MainActivity$90.class | Bin 832 -> 652 bytes .../com/lanremote/watch/MainActivity$91.class | Bin 1217 -> 832 bytes .../lanremote/watch/MainActivity$92$1.class | Bin 1180 -> 0 bytes .../com/lanremote/watch/MainActivity$92.class | Bin 1468 -> 1217 bytes .../com/lanremote/watch/MainActivity$93.class | Bin 554 -> 1468 bytes .../com/lanremote/watch/MainActivity$94.class | Bin 647 -> 554 bytes .../com/lanremote/watch/MainActivity$95.class | Bin 647 -> 647 bytes .../com/lanremote/watch/MainActivity$96.class | Bin 1310 -> 647 bytes .../com/lanremote/watch/MainActivity$97.class | Bin 2276 -> 1310 bytes .../com/lanremote/watch/MainActivity$98.class | Bin 1033 -> 2276 bytes .../com/lanremote/watch/MainActivity$99.class | Bin 5444 -> 1033 bytes .../lanremote/watch/MainActivity$Act.class | Bin 458 -> 458 bytes .../lanremote/watch/MainActivity$TabDef.class | Bin 634 -> 634 bytes .../com/lanremote/watch/MainActivity.class | Bin 81460 -> 81920 bytes _tests/test_apk_contents.py | 2 +- _tests/test_package_remote.py | 8 +- _tests/test_rescue_section.py | 188 ++ _tests/test_sfx.py | 163 ++ _tests/test_watch_panels_wired.py | 112 + _tests/verify_all.ps1 | 78 +- android/AndroidManifest.xml | 2 +- android/build_apk.ps1 | 4 +- .../src/com/lanremote/watch/MainActivity.java | 65 + docs/AFTER-INSTALL.txt | 6 +- docs/BEFORE-INSTALL.txt | 4 +- docs/LICENSE.txt | 65 +- installer/license-combined.txt | 75 +- installer/ykxmz.iss | 4 +- relay/admin.py | 49 +- relay/deploy.sh | 77 + relay/lanremote-monitor.service | 35 + relay/lanremote-monitor.timer | 20 + relay/monitor.sh | 148 ++ relay/relay.py | 139 +- relay/rescue.sh | 358 +++ relay/setup-monitor.sh | 107 + windows/lanremote/__init__.py | 2 +- windows/lanremote/actions.py | 2146 +++++++++-------- windows/lanremote/protocol.py | 360 +-- windows/lanremote/server.py | 18 + windows/lanremote/sfx.py | 414 ++++ windows/version_info.txt | 4 +- 203 files changed, 6645 insertions(+), 1996 deletions(-) create mode 100644 _build/_cmsg_210.txt create mode 100644 _build/add_monitor_to_deploy.py create mode 100644 _build/add_rescue_section.py create mode 100644 _build/add_watch_sfx.py create mode 100644 _build/auth_rescue_route.py create mode 100644 _build/check_device.py create mode 100644 _build/debug_rescue_000.py create mode 100644 _build/deploy_monitor.py create mode 100644 _build/diag_admin_section.py create mode 100644 _build/fix_deploy_watchdog_lines.py create mode 100644 _build/fix_rescue_gate.py create mode 100644 _build/fix_rescue_section.py create mode 100644 _build/make_license.py create mode 100644 _build/make_monitor_units.py create mode 100644 _build/repair_device.py create mode 100644 _build/restart_and_verify.py create mode 100644 _build/verify_admin_login.py create mode 100644 _build/verify_gate_fix.py create mode 100644 _build/verify_monitor_e2e.py create mode 100644 _build/wire_sfx.py create mode 100644 _build/wrap_gui_stages.py create mode 100644 _tests/_stubdata2/devices.json create mode 100644 _tests/_stubdata2/settings.json create mode 100644 _tests/hidden_desktop.py delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$107$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$117$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$58$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$64$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$64$2.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$68$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$68$2.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$76$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$78$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$79$1$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$86$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$9$1.class delete mode 100644 _tests/out_app/com/lanremote/watch/MainActivity$92$1.class create mode 100644 _tests/test_rescue_section.py create mode 100644 _tests/test_sfx.py create mode 100644 _tests/test_watch_panels_wired.py create mode 100644 relay/lanremote-monitor.service create mode 100644 relay/lanremote-monitor.timer create mode 100644 relay/monitor.sh create mode 100644 relay/rescue.sh create mode 100644 relay/setup-monitor.sh create mode 100644 windows/lanremote/sfx.py diff --git a/LICENSE b/LICENSE index ada1a81..ef423e5 100644 --- a/LICENSE +++ b/LICENSE @@ -1,661 +1,681 @@ - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU Affero General Public License is a free, copyleft license for -software and other kinds of works, specifically designed to ensure -cooperation with the community in the case of network server software. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -our General Public Licenses are intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - Developers that use our General Public Licenses protect your rights -with two steps: (1) assert copyright on the software, and (2) offer -you this License which gives you legal permission to copy, distribute -and/or modify the software. - - A secondary benefit of defending all users' freedom is that -improvements made in alternate versions of the program, if they -receive widespread use, become available for other developers to -incorporate. Many developers of free software are heartened and -encouraged by the resulting cooperation. However, in the case of -software used on network servers, this result may fail to come about. -The GNU General Public License permits making a modified version and -letting the public access it on a server without ever releasing its -source code to the public. - - The GNU Affero General Public License is designed specifically to -ensure that, in such cases, the modified source code becomes available -to the community. It requires the operator of a network server to -provide the source code of the modified version running there to the -users of that server. Therefore, public use of a modified version, on -a publicly accessible server, gives the public access to the source -code of the modified version. - - An older license, called the Affero General Public License and -published by Affero, was designed to accomplish similar goals. This is -a different license, not a version of the Affero GPL, but Affero has -released a new version of the Affero GPL which permits relicensing under -this license. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU Affero General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Remote Network Interaction; Use with the GNU General Public License. - - Notwithstanding any other provision of this License, if you modify the -Program, your modified version must prominently offer all users -interacting with it remotely through a computer network (if your version -supports such interaction) an opportunity to receive the Corresponding -Source of your version by providing access to the Corresponding Source -from a network server at no charge, through some standard or customary -means of facilitating copying of software. This Corresponding Source -shall include the Corresponding Source for any work covered by version 3 -of the GNU General Public License that is incorporated pursuant to the -following paragraph. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the work with which it is combined will remain governed by version -3 of the GNU General Public License. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU Affero General Public License from time to time. Such new versions -will be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU Affero General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU Affero General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU Affero General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If your software can interact with users remotely through a computer -network, you should also make sure that it provides a way for users to -get its source. For example, if your program is a web application, its -interface could display a "Source" link that leads users to an archive -of the code. There are many ways you could offer source, and different -solutions will be better for different programs; see section 13 for the -specific requirements. - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU AGPL, see -. +ykxmz —— 教室多媒体辅助工具 +Copyright (C) 2026 小麦子 + +本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的 +GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。 + +本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或 +特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。 + +你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本 +(见本文件下半部分)。如果没有,见 。 + +-------------------------------------------------------------------------- +⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》): + 本软件是一套远程控制工具,禁止用于未经同意的他人设备、 + 禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**, + 和本文件(约束**代码**的授权)互不冲突。 +-------------------------------------------------------------------------- + + + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 2d4880e..ee9b06e 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -1,3 +1,53 @@ +## 2.1.0 + +- **网站挂了会自己救**:中转服务器上常驻一个监测单元,每 60 秒从**公网** + 查一次(不是查本机 —— 本机通不代表用户能打开)。连续 3 次不通就按阶梯 + 自动抢救: + + | 级 | 动作 | 代价 | + |---|---|---| + | 1 | 重启隧道 | 不动中转,在线设备不掉线 | + | 2 | 重启中转服务 | 设备要重连,但很快 | + | 3 | 重新施加网络调优 | MSS / MTU 跑偏时用 | + | 4 | 重启无线网卡 | 4G / WiFi 卡死时用 | + | 5 | 重启整机 | 最后的底牌,**默认不开** | + + 每做完一级立刻复验,好了就停。救完 10 分钟内不再动手(防抖 —— + 否则预案刚重启完隧道还没连上,下一次检查又救一遍, + 来回重启反而永远连不上)。 + +- **网站出问题会自动留下诊断报告**:万一五级都没救回来,服务端会把现场 + (内存、进程、隧道连接数、路由和 MTU、三份日志尾部、内核异常) + 整理成一份 txt 留在服务端,需要排查时直接取来看就行。 + 只保留最近 30 份,不会把设备塞满。 + + 每次自动抢救成功,也会把「第几级奏效」记进知识库, + 下次同类问题优先试历史上管用的那一级。 + +- **网络热门音效**:手表上新增「音效」按钮,点一下电脑就响。 + 内置 8 个(冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / 警报 / 鼓点 / 弹簧)。 + + > 说明一下**版权**:网上那些「冰冰冰」「啊哈哈哈」是有版权的真实录音, + > 不能打包进安装包。所以内置的是**程序合成**的版本(正弦/方波现场算), + > 名字对着感觉来,但不是那些录音本身。 + > 想要一模一样的一手素材,把 mp3/wav 丢进电脑的 `data\sfx\` 目录, + > 点「刷新」就出现在列表里,照样能远程播。目录里放了一份说明。 + +- **修「安卓设置页一片空白」**:设置页建好了但**忘了挂到窗口上** + (少了 `root.addView`),`showOnly()` 让它可见也没用 —— 它根本不在 + 视图树里。已修,并加了断言盯着「每个面板都必须挂到 root、 + 并且纳入 showOnly()」—— 这类漏挂编译不报错、只在运行时表现为空白。 + +- **全量测试改成后台静默**:会开窗口的 6 个阶段(含开**全屏**那个) + 现在跑在独立的 Windows 隐藏桌面上 —— 窗口是**真的**(全屏、可见、 + 几何、Win32 枚举都真实),但屏幕上什么都看不到,也不会锁你的屏。 + + 顺带修了一个一直误判的检测:判断「锁屏」时只看 `OpenInputDesktop` + 成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。 + 所以截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了。 + 改成看输入桌面的**名字**。现在锁屏也能跑全量:该 SKIP 的 SKIP, + 该跑的照跑。 + ## 2.0.0 - **安装时就能设中转服务器**:安装向导那一页现在是两个输入框 —— diff --git a/_build/_cmsg_210.txt b/_build/_cmsg_210.txt new file mode 100644 index 0000000..502f019 --- /dev/null +++ b/_build/_cmsg_210.txt @@ -0,0 +1,111 @@ +2.1.0:网站自动抢救 + 后台抢救报告分区 + 网络热门音效 + 全量测试后台静默 + +按用户定的规矩,这一轮有新功能,升第二位:2.0.0 -> 2.1.0。 + +== 一、网站挂了会自己救 == + +用户原话:「网站还是容易挂,建议继续增加隧道稳定性,并在后台设立 +检测单元,实时检测网站状态,一旦检测到网站挂了,立即启动紧急预案」。 + +新增 relay/monitor.sh + lanremote-monitor.timer: + 每 60 秒从**公网**查一次 /v1/health(不是查 127.0.0.1) + + 为什么不查本机:本机 200 只说明中转进程活着,用户真正关心的是 + 经过 Cloudflare 边缘 + 隧道 + 本机三段之后能不能打开。实测过 + 隧道两条实例都掉了、本机 curl 照样 200,只有从公网测才发现 502。 + + 连续 3 次不通才动手(这条 4G 线路本来就有 2~4% 偶发失败), + 救完 10 分钟内不再动手(否则预案刚重启完隧道还没连上, + 下一次检查又救一遍,来回重启反而永远连不上)。 + +新增 relay/rescue.sh:五级阶梯,每级做完立刻复验,好了就停 + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,**默认不开**(要 RESCUE_REBOOT=1) + + 理由:如果问题出在供电或上游线路,重启只会让恢复更慢; + 而且半夜自动重启一台没人管的机器风险太大。 + +== 二、抢救报告 + 后台分区 == + +用户原话:「如果紧急预案未能解决,则将问题打包成 .txt,然后在 +中转站设置里面加一个分区专门放还有下载这个 txt。解决后将解决问题 +的方法加入紧急预案」。 + + * 五级都没救回来 -> 把现场(内存/进程/隧道连接数/路由和 MTU、 + 三份日志尾部、内核异常)整理成 rescue_reports/<时间戳>.txt + * 后台新增「抢救报告」分区,列出报告(带结论摘要),点一下下载 + * 下载走单独的 /rescue/<名字>,**不发缓存头**(内容会变, + 也不该让 Cloudflare 边缘留着)。和 /download/ 分开是有意的: + 那个是给用户的安装包,文件名带版本号、内容永不改变, + 所以敢发 604800 长缓存 + * **要登录才能下** —— 报告里有内网 IP、进程表、日志尾部, + 是运维信息。第一版把这条路由插在 _admin_gate() 之前, + 等于匿名可下,自己查出来补上了 + * 每次自动抢救成功,把「第几级奏效」写进 rescue_knowledge.md; + 监测单元读它,下次同类症状优先试历史上管用的那一级 + (这就是用户说的"解决后将解决问题的方法加入紧急预案") + +== 三、网络热门音效 == + +用户原话:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈, +各种老牧师音效,可以直接远程播放」。 + +**版权说明**:网上那些「冰冰冰」「啊哈哈哈」是**有版权的真实录音** +(影视片段 / 别人的作品),不能下载打包进安装包 —— 那既侵权, +也会让这个项目在 Gitee 那边直接踩线(这个仓库已经被判过一次 +"内容可能含有违规信息")。 + +所以: + * 内置 8 个**程序合成**的音效(正弦/方波现场算,零版权风险、 + 不占安装包体积):冰冰冰 / 啊哈哈哈 / 老牧师 / 叮 / 当当当 / + 警报 / 鼓点 / 弹簧。名字对着感觉来,但不是那些录音本身 + * 做「自己放进来」的目录:mp3/wav 丢进 data/sfx/ 就自动出现在 + 列表里,照样能远程播。目录里放了一份说明 + * 手表首页新增「音效」按钮,点开选一个,电脑就响 + (复用现成的 showChooser,不新建面板) + +协议加 sfx_list / sfx_play 两条命令。 + +== 四、修「安卓设置页一片空白」 == + +用户报的。根因:设置页**建好了但忘了 root.addView()** —— +showOnly() 让它 VISIBLE 也没用,它根本不在视图树里。 + +这类"漏挂"编译完全不报,只在运行时表现为空白。已加断言 +_tests/test_watch_panels_wired.py 盯着:每个面板都必须挂到 root、 +并且纳入 showOnly()。 + +== 五、全量测试改成后台静默 == + +用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 + +会开窗口的 6 个阶段(含开**全屏**那个)现在跑在独立的 Windows +隐藏桌面上(_tests/hidden_desktop.py)。窗口是**真的** —— 全屏、 +可见、几何、Win32 枚举都真实,测试有效性一点没损失 —— +只是不在用户眼前,全屏也不会锁屏。 + +顺带修了一个一直在骗人的检测:判断"锁屏"时只看 OpenInputDesktop +成不成功是不对的 —— **锁屏时它同样成功**(那时输入桌面是 Winlogon)。 +于是截屏类测试在锁屏时被记成 FAIL 而不是 SKIP,把真问题淹掉了 +(用户电脑锁了一夜,我就是被这个误导过)。改成看输入桌面的**名字**。 +现在锁屏也能跑全量:该 SKIP 的 SKIP,该跑的照跑。 + +== 六、顺手修的真 bug == + +* windows/lanremote/server.py 的 sfx_play 分支用了不存在的变量名 body + (应该是 msg)-> **一调就 NameError**。pyflakes 那关抓出来的, + 说明"未定义名"这条检查有用。 +* _build/package_remote.py 的文件白名单只有 .py/.sh/.ico, + **.service/.timer 收不进服务端 zip** -> 新机器上监测根本不启动, + 而且不报错、只是静默不跑。已加。 +* protocol.py 被我补丁插入了 LF 行到 CRLF 文件里 -> 行尾混用,编码审计红。 +* deploy.sh 的 watchdog 块被我改锚点时吃掉了 start/say 两行 + (bash -n 通过、只是少两条执行语句)—— 改完读回来核对才发现。 +* _build/CHANGELOG.md 的 2.1.0 说明里写了"管理后台", + 触发对外材料清洁度检查。改成不提管理页(用户定的规矩: + 对外只能提官网)。 + +全量回归 46 个阶段全部通过。 diff --git a/_build/add_monitor_to_deploy.py b/_build/add_monitor_to_deploy.py new file mode 100644 index 0000000..281c5ba --- /dev/null +++ b/_build/add_monitor_to_deploy.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +r'''让 deploy.sh 在装新机器时把「监测单元」也装上。 + +== 为什么必须做这一步 == + +监测单元是我新加的:monitor.sh 每 60 秒查一次公网, +挂了就叫 rescue.sh 抢救。但如果 deploy.sh 不认识它, +**新装的机器上监测根本不会启动** —— 而且不会报任何错, +只是静默地不跑。等网站真挂了才发现"预案没触发",那就晚了。 + +部署脚本里 watchdog 已经是个现成的样板(同样是 timer + oneshot), +照着加一份即可。 + +== 顺便 == + +deploy.sh 里本来就有"包里有才装"的写法(`if [ -f ... ]`), +这里也沿用 —— 老的服务端 zip(没有 monitor.sh)重跑部署时 +不会因为找不到文件而报错。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEPLOY = ROOT / "relay" / "deploy.sh" + +ANCHOR = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true''' + +NEW = ''' systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + fi + + # ---- 网站监测单元(如果包里有)---- + # + # 和看门狗的区别很重要: + # 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好" + # 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开" + # + # 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, + # 看门狗也可能觉得没事;只有从公网测才发现 502。 + if [ -f "$RELAY_DIR/monitor.sh" ]; then + mkdir -p "$RELAY_DIR/rescue_reports" + chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true + if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +KEOF + fi + + # 包里直接带了单元文件就用包里的(内容随版本更新); + # 没有就现场生成一份等价的 —— 兼容老 zip。 + if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then + cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.service" </dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF' +[Unit] +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +EOF + fi + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true + systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true + say " 网站监测单元已启用(每 60 秒查一次公网)"''' + + +def main() -> int: + raw = DEPLOY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "lanremote-monitor.timer" in text: + print(" · deploy.sh 已经加过了") + return 0 + if ANCHOR not in text: + print(" ✗ 找不到锚点") + return 1 + if text.count(ANCHOR) != 1: + print(" ! 锚点出现 %d 次" % text.count(ANCHOR)) + text = text.replace(ANCHOR, NEW, 1) + + # 原来 watchdog 那个 if 块的 fi 会被我们的 NEW 提前收掉, + # 所以要把后面多余的那个 fi 去掉一个。先看看形状。 + import re + DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + + text.encode("utf-8")) + print(" ✓ deploy.sh 加了监测单元的安装") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_rescue_section.py b/_build/add_rescue_section.py new file mode 100644 index 0000000..af0730f --- /dev/null +++ b/_build/add_rescue_section.py @@ -0,0 +1,261 @@ +# -*- coding: utf-8 -*- +r'''后台加一个「抢救报告」分区。 + +用户要求: + 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区 + 专门放还有下载这个 txt」 + +rescue.sh 抢救失败时会往 relay/rescue_reports/ 写一份诊断报告 .txt。 +这里让后台能: + * 列出这些报告(时间、大小、一句话结论) + * 点一下下载看内容 + +== 为什么不复用 /download/ == + +/download/ 是**给用户**的(安装包、APK),带 cf 长缓存 +(max-age=604800, immutable)——因为文件名带版本号、内容永不改变。 + +抢救报告正相反:内容会变、是给管理员看的、不该被边缘缓存。 +所以单独开一条 /rescue/<名字>,发 no-store。 +''' +from __future__ import annotations + +import html +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" +ADMIN = ROOT / "relay" / "admin.py" + + +def patch(path: Path, pairs, label: str): + raw = path.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + results = [] + for old, new, what in pairs: + if old not in text: + results.append(("✗", what, "找不到锚点")) + continue + if text.count(old) != 1: + results.append(("!", what, "锚点 %d 处" % text.count(old))) + text = text.replace(old, new, 1) + results.append(("✓", what, "")) + print(" ── %s ──" % label) + for m, w, why in results: + print(" %s %s%s" % (m, w, (" —— " + why) if why else "")) + if any(m == "✗" for m, _w, _y in results): + return False + path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + text.encode("utf-8")) + return True + + +# =========================================================================== +# relay.py +# =========================================================================== + +RELAY_LIST = ''' +# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。 +# 和 downloads/ 分开:那个是给用户的安装包(带长缓存), +# 这个是给管理员看的诊断(必须不缓存)。 +RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports" +RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满) + + +def _safe_rescue_name(name: str) -> str: + """校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。""" + name = (name or "").strip() + if not name or len(name) > 80: + return "" + if "/" in name or "\\\\" in name or ".." in name: + return "" + if not name.endswith(".txt"): + return "" + for ch in name: + if ch.isalnum() or ch in "._-": + continue + return "" + return name + + +def _list_rescue_reports() -> list: + """列出抢救报告(按时间倒序)。 + + 顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示, + 不用一个个点进去看。 + """ + items = [] + try: + if not RESCUE_DIR.is_dir(): + return items + for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True): + if not entry.is_file(): + continue + safe = _safe_rescue_name(entry.name) + if not safe: + continue + stat = entry.stat() + summary = "" + try: + with entry.open("r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1200) + for line in head.splitlines(): + s = line.strip() + if s.startswith("判断:"): + summary = s.replace("判断:", "").strip() + break + if not summary: + for line in head.splitlines(): + s = line.strip() + if s.startswith("公网 ") and "->" in s: + summary = s + break + except OSError: + pass + items.append({ + "name": entry.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "summary": summary[:120], + }) + except OSError: + return items + items.sort(key=lambda i: i["mtime"], reverse=True) + return items[:RESCUE_KEEP] + +''' + +ADMIN_CALL = ''' admin_path=ADMIN_PATH,''' + +ADMIN_CALL_NEW = ''' admin_path=ADMIN_PATH, + rescue_fn=_list_rescue_reports,''' + +# 下载路由(GET):插在 /download/ 之前 +RESCUE_ROUTE_ANCHOR = ''' if path.startswith("/download/"): + wanted = urllib.parse.unquote(path[len("/download/"):]) + safe = _safe_download_name(wanted)''' + +RESCUE_ROUTE = ''' if path.startswith("/rescue/"): + # 抢救报告下载。单独一条路由,**不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + +# =========================================================================== +# admin.py +# =========================================================================== + +ADMIN_SIG = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, + downloads_fn) -> str:''' + +ADMIN_SIG_NEW = '''def render_admin(snap: dict, public: str, admin_path: str, codes: int, + downloads_fn, rescue_fn=None) -> str:''' + +ADMIN_CARD_ANCHOR = '''
+

下载区

+
官网下载按钮指向这里的文件。
+
%(dl)s
+
+ ''' + +ADMIN_CARD = '''
+

下载区

+
官网下载按钮指向这里的文件。
+
%(dl)s
+
+
+

抢救报告

+
网站挂了、自动预案也没救回来时,机器会把现场 + 打包成一份 txt 放在这里。点开看看是哪一段断了。
+
%(rescue)s
+
+ ''' + + +def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。照 dl_rows 的风格(页面里直接渲染)。""" + if not items: + return ('
还没有报告 —— 说明网站一直没出过' + '「自动救不回来」的问题。
') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = html.escape(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + +def main() -> int: + ok = True + + ok &= patch(RELAY, [ + ("DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"", + "DOWNLOADS_DIR = Path(__file__).resolve().parent / \"downloads\"\n" + + RELAY_LIST, "加 rescue_reports 列表函数"), + (RESCUE_ROUTE_ANCHOR, RESCUE_ROUTE + RESCUE_ROUTE_ANCHOR, + "加 /rescue/ 下载路由(no-store)"), + (ADMIN_CALL, ADMIN_CALL_NEW, "把 rescue_fn 传给后台页"), + ], "relay.py") + + ok &= patch(ADMIN, [ + (ADMIN_SIG, ADMIN_SIG_NEW, "render_admin 加 rescue_fn 参数"), + (ADMIN_CARD_ANCHOR, ADMIN_CARD, "后台加「抢救报告」卡片"), + # 算报告列表(照 dl_rows 的写法) + (''' return """''', + ''' # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉) + try: + rescue_items = rescue_fn() if rescue_fn else [] + except Exception: # noqa: BLE001 + rescue_items = [] + rescue_rows = _rescue_rows(rescue_items) + + return """''', + "算抢救报告列表"), + ('"dl": dl_rows,', '"dl": dl_rows,\n "rescue": rescue_rows,', + "把报告列表塞进模板"), + ], "admin.py") + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_watch_sfx.py b/_build/add_watch_sfx.py new file mode 100644 index 0000000..ebb54d3 --- /dev/null +++ b/_build/add_watch_sfx.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +r'''手表端加「音效」—— 用现成的 showChooser(),不新建面板。 + +== 为什么改方案 == + +第一版我给音效页写了完整的新面板(FrameLayout + ScrollView + 动态列表), +后来发现 MainActivity 里**已经有 showChooser(title, labels, Chooser)** +这个现成的选择器 —— pickVoice() 就是这么选 TTS 音色的。 + +用选择器的话: + * 不用新建面板 -> 不用碰 root.addView / showOnly(那两个地方 + 刚因为"漏挂"出过一次全白的事故) + * 代码量少一半,出错面小得多 + * 而且和"选音色"的操作手感一致,用户不用学两套 + +所以改成:首页加一个「音效」按钮 -> 弹出音效列表 -> 点哪个放哪个。 + +== 服务端配合 == + +服务端返回的是 {"sfx":[{"name":...},...]} 这种嵌套结构, +而手表的 stringList(reply, key) 只认扁平的字符串数组。 +audio_list 早就这么做了(同时返回 files / names / paths), +这里跟着加一个 "names",手表直接用。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch" + / "MainActivity.java") + +# ---------------------------------------------------------------- 方法 +METHOD_ANCHOR = " /** 选择电脑上的 TTS 音色。 */" + +METHOD_NEW = ''' /** 让电脑放一个音效(网络热门音效那类)。 + * + * 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明: + * pickVoice() 已经是这个套路,复用省事也更不容易出错。 + */ + private void pickSfx() { + appendLog("正在获取音效…"); + io.execute(new Runnable() { + @Override + public void run() { + try { + final Map reply = + client.call(Json.obj("cmd", "sfx_list"), 15000); + // 服务端同时给了扁平的 names(和 audio_list 一个约定) + final List names = stringList(reply, "names"); + final String dir = Json.str(reply, "dir", ""); + ui.post(new Runnable() { + @Override + public void run() { + if (names.isEmpty()) { + toast("电脑上还没有音效(或没连上)"); + appendLog("音效列表是空的。" + + (dir.isEmpty() ? "" : "素材目录:" + dir)); + return; + } + showChooser("音效(点一下电脑就响)", names, + new Chooser() { + @Override + public void onPick(int index) { + String one = names.get(index); + sendCommand(Json.obj("cmd", "sfx_play", + "name", one), "音效 " + one); + } + }); + } + }); + } catch (final IOException e) { + postError("获取音效", e); + } + } + }); + } + +''' + METHOD_ANCHOR + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + out: list = [] + + def patch(old: str, new: str, what: str) -> None: + nonlocal text + if old not in text: + out.append(("✗", what, "找不到锚点")) + return + if text.count(old) != 1: + out.append(("!", what, "锚点 %d 处" % text.count(old))) + text = text.replace(old, new, 1) + out.append(("✓", what, "")) + + # 1) 插方法 + patch(METHOD_ANCHOR, METHOD_NEW, "插入 pickSfx()") + + # 2) 首页加按钮(放在「音效」该在的位置:设置按钮之后) + patch(''' box.addView(sfxButton);''', ''' box.addView(sfxButton);''', + "(占位,见下)") if False else None + + patch(''' box.addView(relayButton);''', + ''' box.addView(relayButton); + + // 音效:点一下电脑就响。用户要求"可以直接远程播放"。 + Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14); + LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); + sfxParams.setMargins(0, 0, 0, dp(8)); + sfxButton.setLayoutParams(sfxParams); + sfxButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + pickSfx(); + } + }); + box.addView(sfxButton);''', + "首页加「音效」按钮") + + print(" ── 补丁结果 ──") + for mark, what, why in out: + print(" %s %s%s" % (mark, what, (" —— " + why) if why else "")) + if any(m == "✗" for m, _w, _y in out): + print() + print(" 有锚点没匹配,没有写回") + return 1 + + SRC.write_text(text, encoding="utf-8", newline="\n") + print() + print(" 已写回 MainActivity.java") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/auth_rescue_route.py b/_build/auth_rescue_route.py new file mode 100644 index 0000000..bc5e888 --- /dev/null +++ b/_build/auth_rescue_route.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +r'''给 /rescue/ 加上管理鉴权。 + +== 问题 == + +我把 /rescue/ 路由插在 do_GET 的第 1531 行,而管理页的登录校验 +(self._admin_gate())在第 1723 行 —— **路由在鉴权之前**。 + +也就是说:任何人只要知道 + https://relay.xxmxxs.top/rescue/20261008-173000.txt +就能直接下载诊断报告。那里面有什么: + + * 内网 IP(192.168.2.x)、主机名 + * 进程列表、内存占用 + * 中转和隧道的日志尾部 + * MTU / 路由 / iptables 规则 + +这些是**运维信息**,不该公开。对比一下:/download/ 是给用户的 +安装包,公开是对的;/rescue/ 是给自己看的,必须登录。 + +(同样的问题 /shot/ 也有 —— 那个是屏幕共享截图,本来就该拦。 + 这次先修 /rescue/,顺手看一眼 /shot/。) + +== 改法 == + +在 /rescue/ 分支开头加一句 self._admin_gate(), +和 /v1/admin/* 那些接口一样。do_GET 和 do_HEAD 都要加。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" + +# do_GET 里的(有 body 的那份) +GET_OLD = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +GET_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + # + # ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、 + # MTU/路由/iptables —— 是运维信息,不是给所有人看的。 + # 第一版把这条路由插在 _admin_gate() **之前**, + # 等于匿名可下,实测踩到。 + if not self._admin_gate(): + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +# do_HEAD 里的(只有 headers) +HEAD_OLD = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + # 同样要登录(见 do_GET 那段注释)。 + if not self._admin_gate(): + return no() + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + + +def main() -> int: + raw = RELAY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + changed = [] + for old, new, what in ((GET_OLD, GET_NEW, "do_GET"), + (HEAD_OLD, HEAD_NEW, "do_HEAD")): + if new.split("\n")[0] in text and "_admin_gate" in text.split(old)[0][-600:]: + changed.append(("·", what, "好像已经加过了")) + continue + if old not in text: + changed.append(("✗", what, "找不到锚点")) + continue + text = text.replace(old, new, 1) + changed.append(("✓", what, "已加鉴权")) + + print(" ── 结果 ──") + for m, w, why in changed: + print(" %s %-10s %s" % (m, w, why)) + + if any(m == "✗" for m, _w, _y in changed): + return 1 + + RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" 已写回 relay.py") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/check_device.py b/_build/check_device.py new file mode 100644 index 0000000..321e39a --- /dev/null +++ b/_build/check_device.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +r'''看一眼中转服务器的完整状态。 + +为什么写成文件:这一轮我用 `python -c @"..."@` 传长命令失败了好几次 —— +PowerShell 的双引号 heredoc 会做变量插值、还会把 `$(...)` 当子表达式 +在本机执行(systemctl / iptables 跑到 Windows 上了)。凡是超过两行 +的远端命令,一律写成文件跑。 + +用法: + python _build/check_device.py +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +echo "════ 服务 ════" +for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 lanremote-nettune; do + printf ' %-24s %s\n' "$s" "$(systemctl is-active $s 2>&1)" +done +printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer 2>&1)" +printf ' %-24s %s\n' "watchdog.timer" "$(systemctl is-active lanremote-watchdog.timer 2>&1)" + +echo +echo "════ 网络 ════" +printf ' %-24s %s\n' "wlan0 MTU" "$(cat /sys/class/net/wlan0/mtu 2>/dev/null)" +printf ' %-24s %s\n' "MSS 规则" "$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条" +printf ' %-24s %s\n' "tcp_mtu_probing" "$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)" +printf ' %-24s %s\n' "默认路由" "$(ip route show default 2>/dev/null | head -1)" + +echo +echo "════ 内存 / 运行时长 ════" +printf ' %-24s %s\n' "运行时长" "$(uptime -p 2>/dev/null)" +free -m 2>/dev/null | sed -n '2p' | awk '{printf " %-24s %s/%s MB\n", "内存 used/total", $3, $2}' + +echo +echo "════ 隧道连接数 ════" +for p in 20241 20242; do + n=$(curl -s --max-time 5 "http://127.0.0.1:$p/metrics" 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1) + printf ' %-24s %s\n' "指标端口 $p" "${n:-拿不到}" +done + +echo +echo "════ 公网自测 ════" +printf ' %-24s %s\n' "公网 /v1/health" "$(curl -s -o /dev/null -w '%{http_code} (%{time_total}s)' --max-time 15 https://relay.xxmxxs.top/v1/health 2>/dev/null)" +printf ' %-24s %s\n' "本机 /v1/health" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/v1/health 2>/dev/null)" +printf ' %-24s %s\n' "公网首页" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://relay.xxmxxs.top/ 2>/dev/null)" +printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 http://127.0.0.1:8080/rescue/none.txt 2>/dev/null)" + +echo +echo "════ 监测单元状态 ════" +cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' || echo " (还没有状态文件)" +echo " ── monitor.log 尾部 ──" +tail -5 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' || echo " (还没有日志)" + +echo +echo "════ 抢救报告 ════" +n=$(ls $RELAY/rescue_reports 2>/dev/null | wc -l) +echo " 共 $n 份" +ls -la $RELAY/rescue_reports/ 2>/dev/null | tail -n +4 | awk '{printf " %s %s\n", $6" "$7" "$8, $9}' + +echo +echo "════ 知识库 ════" +if [ -f $RELAY/rescue_knowledge.md ]; then + echo " 行数:$(wc -l < $RELAY/rescue_knowledge.md)" + tail -6 $RELAY/rescue_knowledge.md | sed 's/^/ /' +else + echo " (没有)" +fi + +echo +echo "════ 定时器下次触发 ════" +systemctl list-timers lanremote-monitor.timer lanremote-watchdog.timer --no-pager 2>/dev/null | head -4 | sed 's/^/ /' + +echo +echo "════ 后台页有没有「抢救报告」分区 ════" +printf ' 匹配次数: %s\n' "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告')" +''' + + +def main() -> int: + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/debug_rescue_000.py b/_build/debug_rescue_000.py new file mode 100644 index 0000000..c23cb93 --- /dev/null +++ b/_build/debug_rescue_000.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- +r'''查 /rescue/ 为什么返回 HTTP 000。 + +000 是 curl 的"没拿到任何响应"。但同一个端口上 /v1/health 是好的、 +目录穿越请求也能拿到 404 —— 说明不是端口不通,是**特定路径**出问题。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 造一份报告 ════" +printf '测试报告\n\n 判断:测试结论\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +ls -la $RELAY/rescue_reports/ | sed 's/^/ /' + +echo +echo "════ 对照:几个路径的返回 ════" +for p in /v1/health /rescue/19700101-000000-demo.txt /rescue/nonexistent.txt /rescue/; do + printf ' %-42s -> %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 "$B$p")" +done + +echo +echo "════ 详细看 /rescue/ 那次请求 ════" +curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | head -25 | sed 's/^/ /' + +echo +echo "════ relay 日志尾部(有没有 Traceback)════" +journalctl -u lanremote-relay --no-pager -n 30 2>/dev/null | tail -30 | sed 's/^/ /' + +echo +echo "════ 后台密码文件在不在 ════" +ls -la $RELAY/data/admin_password.txt 2>&1 | sed 's/^/ /' +echo " (长度 $(wc -c < $RELAY/data/admin_password.txt 2>/dev/null || echo 0))" + +echo +echo "════ 清理 ════" +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/deploy_monitor.py b/_build/deploy_monitor.py new file mode 100644 index 0000000..3468f3e --- /dev/null +++ b/_build/deploy_monitor.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +r'''把「网站监测 + 紧急预案 + 抢救报告分区」部署到中转服务器。 + +部署的东西: + relay.py / admin.py 后台多一个「抢救报告」分区 + /rescue/ 下载 + monitor.sh / rescue.sh 监测单元 + 紧急预案 + lanremote-monitor.service/.timer 每 60 秒拉一次 monitor.sh + lanremote-nettune.service 顺手修掉设备上那份过期的(见下) + +== 顺手修的设备问题 == + +设备上那份 lanremote-nettune.service 是**很早以前的版本**,有两个毛病: + + 1. ExecStart 里写了 `iptables ... || iptables ...` + systemd 的 ExecStart **不支持 `||`** —— 它把 `||` 当成一个参数, + iptables 报 `Bad argument '||'`,服务直接 exit 2 失败。 + + 2. 里面有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己 + 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。 + **两条 ExecStart 顺序是 net-tune.sh 先、1400 后**,所以最终是 1400 —— + 也就是说这份过期配置一直在主动损害稳定性。 + +本地 relay/deploy.sh 生成的是对的(单入口 -> deploy-tune.sh)。 +这里直接按那个正确形状重写设备上的 unit。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import upload, run # noqa: E402 + +RELAY_DIR = "/root/lanremote-relay" +UNIT_DIR = "/etc/systemd/system" + +FILES = [ + "relay.py", "admin.py", "landing.py", "showcase.py", + "monitor.sh", "rescue.sh", "net-tune.sh", "watchdog.sh", + "deploy-tune.sh", +] + +NETTUNE_UNIT = '''[Unit] +Description=ykxmz 网络调优(MSS 钳制 + 路径 MTU 探测) +After=network-online.target +Wants=network-online.target +Before=lanremote-relay.service lanremote-tunnel.service lanremote-tunnel-h2.service + +[Service] +Type=oneshot +RemainAfterExit=yes +# ⚠ 只跑 net-tune.sh 这一个入口 —— MSS / 接口 MTU / 路径探测的唯一出处。 +# +# 设备上原来那份 unit 有两条毛病: +# * ExecStart 里写了 `iptables ... || iptables ...` +# systemd 的 ExecStart **不支持 `||`**,它把 `||` 当参数传给 +# iptables,报 `Bad argument '||'`,服务直接 exit 2 失败。 +# * 还有一句 `ip link set wlan0 mtu 1400`,而 net-tune.sh 自己 +# 实测过"降到 1400 反而掉到 50%"并把网卡设回 1500。 +# 两条 ExecStart 是 net-tune.sh 先、1400 后,所以最终停在 1400 —— +# 这份过期配置一直在主动损害稳定性。 +# +# (仓库里的 deploy.sh 生成的是 `ExecStart=/bin/bash deploy-tune.sh` +# 那种形状,但设备上并没有 deploy-tune.sh,所以这里直接用 +# net-tune.sh,和设备的实际情况对齐。 +# deploy-tune.sh 也一并传上去了,以后想切回单入口随时可以。) +ExecStart=/bin/bash /root/lanremote-relay/net-tune.sh + +[Install] +WantedBy=multi-user.target +''' + + +def main() -> int: + print("=" * 62) + print(" 部署 监测单元 + 紧急预案 + 抢救报告分区") + print("=" * 62) + + print() + print(" ── 1. 上传文件 ──") + for name in FILES: + local = ROOT / "relay" / name + if not local.is_file(): + print(" [跳过] %s 不存在" % name) + continue + upload(str(local), "%s/%s" % (RELAY_DIR, name)) + print(" ✓ %s" % name) + + for unit in ("lanremote-monitor.service", "lanremote-monitor.timer"): + local = ROOT / "relay" / unit + upload(str(local), "%s/%s" % (UNIT_DIR, unit)) + print(" ✓ %s -> %s" % (unit, UNIT_DIR)) + + print() + print(" ── 2. 修掉过期的 nettune 服务(MTU 打架 + ExecStart 里的 ||)──") + import tempfile + tmp = Path(tempfile.gettempdir()) / "lanremote-nettune.service" + tmp.write_text(NETTUNE_UNIT, encoding="utf-8", newline="\n") + upload(str(tmp), "%s/lanremote-nettune.service" % UNIT_DIR) + print(" ✓ 已替换") + + print() + print(" ── 3. 语法检查 ──") + out = run( + "cd %s && for f in relay.py admin.py landing.py showcase.py; do " + "python3 -c \"import ast,sys; ast.parse(open(sys.argv[1],encoding='utf-8').read())\" $f " + "|| exit 1; done && echo PY_OK; " + "bash -n monitor.sh && bash -n rescue.sh && echo SH_OK; " + "systemd-analyze verify %s/lanremote-monitor.service 2>&1 | head -3 || true" + % (RELAY_DIR, UNIT_DIR), timeout=120) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 4. 建目录 + 知识库 + 重载并启用 ──") + out = run( + "mkdir -p %s/rescue_reports && chmod +x %s/monitor.sh %s/rescue.sh && " + "[ -f %s/rescue_knowledge.md ] || printf '# 紧急预案知识库\\n\\n" + '每次自动抢救成功,这里会记一条"第几级奏效"。\\n' + '以后遇到同类症状,监测单元会优先试历史上奏效的那一级。\\n" > %s/rescue_knowledge.md; ' + "systemctl daemon-reload && " + "systemctl restart lanremote-nettune.service; sleep 3; " + "systemctl enable --now lanremote-monitor.timer 2>&1 | tail -1; " + "sleep 2; systemctl is-active lanremote-monitor.timer" + % (RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR, RELAY_DIR), timeout=180) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" ── 5. 复核 ──") + out = run( + "echo 'nettune: '$(systemctl is-active lanremote-nettune.service); " + "echo 'monitor.timer: '$(systemctl is-active lanremote-monitor.timer); " + "echo 'MTU: '$(cat /sys/class/net/wlan0/mtu); " + "echo 'MSS 规则: '$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss)' 条'; " + "echo '报告目录: '$(ls %s/rescue_reports 2>/dev/null | wc -l)' 份'; " + "echo '后台分区: '$(curl -s -o /dev/null -w '%%{http_code}' http://127.0.0.1:8080/rescue/nonexistent.txt)" + % RELAY_DIR, timeout=120) + for ln in out[1].strip().splitlines(): + print(" " + ln) + + print() + print(" 提示:监测单元默认每 60 秒查一次公网,连续 3 次不通才动手;") + print(" 抢救完 10 分钟内不再动手(防抖,避免来回重启)。") + print(" 日志在 %s/monitor.log 和 %s/rescue.log。" % (RELAY_DIR, RELAY_DIR)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/diag_admin_section.py b/_build/diag_admin_section.py new file mode 100644 index 0000000..c54bfdb --- /dev/null +++ b/_build/diag_admin_section.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +r'''查后台「抢救报告」分区为什么不显示。 + +可能原因: + 1. 后台页要登录,curl 直接拿是登录页(那 0 次是正常的,得带 cookie) + 2. relay.py 没真正重新加载(旧进程还在) + 3. 模板参数没传进去 + +一条条排除。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay + +echo "════ 1. 直接 curl 后台页,看返回的是什么 ════" +curl -s --max-time 8 -D- http://127.0.0.1:8080/zhongzhuanzhan -o /tmp/adm.html 2>&1 | head -8 +echo " --- 页面大小: $(wc -c < /tmp/adm.html) 字节 ---" +echo " --- 前 3 行 ---" +head -3 /tmp/adm.html | sed 's/^/ /' +echo " --- 含关键词统计 ---" +for k in 登录 密码 password 抢救报告 下载区 设备; do + printf ' %-12s %s\n' "$k" "$(grep -c "$k" /tmp/adm.html 2>/dev/null)" +done + +echo +echo "════ 2. relay.py 里到底有没有 rescue_fn ════" +grep -n 'rescue_fn' $RELAY/relay.py | head -5 | sed 's/^/ /' +echo " admin.py 里 _rescue_rows:" +grep -n 'def _rescue_rows\|def render_admin' $RELAY/admin.py | head -4 | sed 's/^/ /' + +echo +echo "════ 3. 进程是什么时候起的(比文件新还是旧)════" +ps -o pid,lstart,cmd -p $(systemctl show -p MainPID --value lanremote-relay) 2>/dev/null | sed 's/^/ /' +echo " relay.py 修改时间: $(stat -c '%y' $RELAY/relay.py)" +echo " admin.py 修改时间: $(stat -c '%y' $RELAY/admin.py)" + +echo +echo "════ 4. 无登录直接调 render_admin 看看(绕过 HTTP)════" +cd $RELAY && python3 -c " +import admin +html = admin.render_admin({'version':'2.0.0','uptimeSeconds':1,'devices':[],'allowNew':True}, + 'https://x','/zhongzhuanzhan',0, lambda: [], + rescue_fn=lambda: [{'name':'20261008-120000.txt','size':1234, + 'mtime':1791400000, + 'summary':'测试摘要'}] ) +print(' 渲染长度:', len(html)) +print(' 含抢救报告:', '抢救报告' in html) +print(' 含测试摘要:', '测试摘要' in html) +print(' 含下载链接:', '/rescue/20261008-120000.txt' in html) +" 2>&1 | sed 's/^/ /' +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_deploy_watchdog_lines.py b/_build/fix_deploy_watchdog_lines.py new file mode 100644 index 0000000..49def3c --- /dev/null +++ b/_build/fix_deploy_watchdog_lines.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +r'''修 deploy.sh 里 watchdog 块被我吃掉的两行。 + +== 出什么事了 == + +我用 heredoc(python -c @"..."@)改 _build/add_monitor_to_deploy.py 的锚点, +PowerShell 把里面的引号和 $ 打散了,脚本没改成;而**上一次** +(锚点写错的那次)已经把 deploy.sh 改了 —— 那次用的锚点是 + + systemctl enable lanremote-watchdog.timer ... || true + +它把后面两行(start timer + say 状态)连带 `fi` 一起卷进了替换, +结果这两行就没了: + + systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true + say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" + +bash -n 仍然通过(语法没错,只是少了两行执行语句), +所以光靠语法检查发现不了 —— 这就是为什么改完还要**读回来核对**。 + +这个脚本把这两行补回去。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEPLOY = ROOT / "relay" / "deploy.sh" + +OLD = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + fi + + # ---- 网站监测单元(如果包里有)----""" + +NEW = """ systemctl enable lanremote-watchdog.timer >/dev/null 2>&1 || true + systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true + say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" + fi + + # ---- 网站监测单元(如果包里有)----""" + + +def main() -> int: + raw = DEPLOY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "systemctl start lanremote-watchdog.timer" in text: + print(" · 那两行已经在了") + return 0 + if OLD not in text: + print(" ✗ 找不到锚点,需要人工看一眼") + return 1 + text = text.replace(OLD, NEW, 1) + DEPLOY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + + text.encode("utf-8")) + print(" ✓ 补回了 start timer 和 say 那两行") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_rescue_gate.py b/_build/fix_rescue_gate.py new file mode 100644 index 0000000..8282b69 --- /dev/null +++ b/_build/fix_rescue_gate.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +r'''修 /rescue/ 鉴权失败时"不发响应"导致的挂起。 + +== 现象 == + +加上鉴权之后,不带登录请求 /rescue/xxx.txt 会**挂到超时**: + + > GET /rescue/19700101-000000-demo.txt HTTP/1.1 + * Operation timed out after 8001 milliseconds with 0 bytes received + +curl 报 HTTP 000。 + +== 根因 == + +_admin_gate() **只返回布尔值,不负责发响应** —— 它是个判断函数。 +所以每个调用点都必须自己在 false 时把响应发出去。 + +看其它调用点,全都是这么写的: + + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + +而我加的那两处写成了: + + if not self._admin_gate(): + return <- do_GET:一个字节都没发 + ... + if not self._admin_gate(): + return no() <- do_HEAD:no() 默认 404,状态码也不对 + +裸 return 在 BaseHTTPRequestHandler 里就是"什么都不做" —— +连接保持着、没有响应头、客户端一直等。表现成"网站卡住", +比报 401 严重得多。 + +== 改法 == + + do_GET -> self._json(401, ...) 然后 return + do_HEAD -> return no(401) (no() 本来就支持传状态码) +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" + +GET_OLD = ''' if not self._admin_gate(): + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +GET_NEW = ''' # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** —— + # 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler + # 里等于"什么都不做":连接保持着、没有响应头, + # 客户端一直等到超时(curl 报 HTTP 000)。 + # 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。 + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_OLD = ''' if not self._admin_gate(): + return no() + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + +HEAD_NEW = ''' # 同上:必须自己发响应,而且状态码要是 401 不是 404 + # (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。 + if not self._admin_gate(): + return no(401) + wanted = urllib.parse.unquote(path[len("/rescue/"):])''' + + +def main() -> int: + raw = RELAY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + ok = True + for old, new, what in ((GET_OLD, GET_NEW, "do_GET"), + (HEAD_OLD, HEAD_NEW, "do_HEAD")): + if old not in text: + print(" ✗ %s: 找不到锚点" % what) + ok = False + continue + text = text.replace(old, new, 1) + print(" ✓ %s: 鉴权失败时现在会发响应" % what) + + if not ok: + return 1 + RELAY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" 已写回 relay.py") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_rescue_section.py b/_build/fix_rescue_section.py new file mode 100644 index 0000000..a56de65 --- /dev/null +++ b/_build/fix_rescue_section.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- +r'''补上 add_rescue_section.py 漏掉/插错的三处。 + +前一个补丁的问题: + 1. _rescue_rows() 只在补丁脚本里定义,**没插进 admin.py** —— + 后台一渲染就 NameError(页面直接 500) + 2. /rescue/ 路由被插进了 **do_HEAD**,而且用了 `self.wfile.write(body)` —— + HEAD 本来就不该有 body。而且 do_GET 那边一条都没加, + 等于浏览器点下载根本走不到。 + (锚点 "/download/" 出现两次:do_HEAD 和 do_GET 各一份, + replace(...,1) 只命中了前面那个。这就是那个 "! 锚点 2 处" 警告。) + +这个脚本按 do_HEAD 自己的 ok()/no() 风格把两处都写对。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY = ROOT / "relay" / "relay.py" +ADMIN = ROOT / "relay" / "admin.py" + + +def load(p: Path): + raw = p.read_bytes() + return raw, raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig") + + +def save(p: Path, text: str, bom: bool): + p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + + +# =========================================================================== +# 1. do_HEAD:换成 ok()/no() 风格(不能有 body) +# =========================================================================== +BAD_HEAD = ''' if path.startswith("/rescue/"): + # 抢救报告下载。单独一条路由,**不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + +GOOD_HEAD = ''' if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + return no() + target = RESCUE_DIR / safe + if not target.is_file(): + return no() + quoted = urllib.parse.quote(safe) + headers = {"Content-Disposition": + "attachment; filename*=UTF-8''%s" % quoted, + "Cache-Control": "no-store"} + return ok("text/plain; charset=utf-8", target.stat().st_size, + headers) + +''' + +# =========================================================================== +# 2. do_GET:加同一条路由(这里才真正回 body) +# =========================================================================== +GET_ANCHOR = ''' if path.startswith("/download/"): + wanted = urllib.parse.unquote(path[len("/download/"):]) + safe = _safe_download_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return''' + +GET_NEW = ''' if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + +''' + GET_ANCHOR + +# =========================================================================== +# 3. admin.py:把 _rescue_rows 真正定义进去 +# =========================================================================== +RESCUE_FN = '''def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。 + + 照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 —— + 这台机器只有 460MB 内存,多个接口就多份开销,而且后台页 + 本来就只是一次性的展示。 + + 没有报告时给一句正面的话("一直没出过问题"), + 而不是干巴巴的"暂无数据"—— 没报告是好事。 + """ + if not items: + return ('
还没有报告 —— 说明网站一直没出过' + '「自动救不回来」的问题。
') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = _esc(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + +def render_admin(''' + + +def main() -> int: + ok = True + + # ---- relay.py ---- + raw, bom, text = load(RELAY) + if BAD_HEAD in text: + text = text.replace(BAD_HEAD, GOOD_HEAD, 1) + print(" ✓ do_HEAD 里的 /rescue/ 换成 ok()/no() 风格(不再写 body)") + elif GOOD_HEAD in text: + print(" · do_HEAD 已经是对的") + else: + print(" ✗ do_HEAD 里找不到那段 /rescue/") + ok = False + + if 'if path.startswith("/rescue/")' in text.split("def do_GET")[1]: + print(" · do_GET 里已经有了") + else: + if GET_ANCHOR in text: + # 只替换 do_GET 里那一处(do_HEAD 那处已经在上面被换掉了, + # 所以现在全文只剩一处) + n = text.count(GET_ANCHOR) + if n != 1: + print(" ! do_GET 锚点还有 %d 处" % n) + text = text.replace(GET_ANCHOR, GET_NEW, 1) + print(" ✓ do_GET 加了 /rescue/ 路由(真正回 body 的那条)") + else: + print(" ✗ do_GET 里找不到 /download/ 锚点") + ok = False + save(RELAY, text, bom) + + # ---- admin.py ---- + raw, bom, text = load(ADMIN) + if "def _rescue_rows(" in text: + print(" · admin.py 里已经定义了 _rescue_rows") + else: + if "def render_admin(" not in text: + print(" ✗ admin.py 里找不到 render_admin") + ok = False + else: + text = text.replace("def render_admin(", RESCUE_FN, 1) + save(ADMIN, text, bom) + print(" ✓ admin.py 里补上了 _rescue_rows 的定义") + ok = ok and True + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/make_license.py b/_build/make_license.py new file mode 100644 index 0000000..667f467 --- /dev/null +++ b/_build/make_license.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +r'''放根目录的 LICENSE(AGPL-3.0)。 + +== 为什么要有这个文件 == + +Gitee 对一个公开仓库会检查"有没有公认的开源协议",没有就弹许可证向导, +而且远控类工具**没有协议**在合规视角里比有协议更容易被盯上 +(这个仓库已经被判过一次"内容可能含有违规信息")。 + +原来仓库里只有 docs/LICENSE.txt —— 那是**自定义协议**, +里面写着"复制并分发完整副本(须保持文件完整、**不得修改**)", +这**不符合 OSI 开源定义**(开源必须允许修改),所以 Gitee 不认。 +(Gitee 上这个仓库的 license 字段实测就是空的。) + +== 为什么选 AGPL-3.0(用户拍板)== + + * OSI 认可、Gitee 认,公开仓库不会再被提示 + * 强 copyleft:别人改了必须以同样协议开源,**不能拿去做闭源商用** —— + 最接近作者原本"免费给人用、但不许别人改名变成自己的"的想法 + * 含"网络服务"条款:把它做成在线服务对外提供也必须开源, + 正好匹配这个项目"中转服务器"的形态 + (GPL 就没有这一条) + +== 两份文件的分工(不冲突)== + + LICENSE 管**代码**:可以改、可以再发布,但衍生作品必须同样开源 + docs/LICENSE.txt 管**使用行为**:禁止偷窥、禁止私自装到别人设备、 + 禁止伪装隐藏等红线 + 免责声明 + +一个管"代码怎么用",一个管"行为不能做什么"。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = Path(__import__("os").environ.get("TEMP", "/tmp")) / "agpl.txt" +DST = ROOT / "LICENSE" + +HEADER = """ykxmz —— 教室多媒体辅助工具 +Copyright (C) 2026 小麦子 + +本程序是自由软件:你可以再发布和/或修改它,依据自由软件基金会发布的 +GNU Affero 通用公共许可证(第 3 版或你选择的任何更新版本)的条款。 + +本程序的发布是希望它有用,但**没有任何保证**;甚至没有适销性或 +特定用途适用性的默示保证。详见 GNU Affero 通用公共许可证。 + +你应该已经随本程序收到了一份 GNU Affero 通用公共许可证的副本 +(见本文件下半部分)。如果没有,见 。 + +-------------------------------------------------------------------------- +⚠ 使用限制请看 docs/LICENSE.txt(《使用条款与免责声明》): + 本软件是一套远程控制工具,禁止用于未经同意的他人设备、 + 禁止用于偷窥监视、禁止伪装隐藏。那份文件约束的是**使用行为**, + 和本文件(约束**代码**的授权)互不冲突。 +-------------------------------------------------------------------------- + + +""" + + +def main() -> int: + if not SRC.is_file(): + print(" ✗ 找不到 AGPL 原文:%s" % SRC) + print(" (先跑一遍取原文的那步)") + return 1 + body = SRC.read_text(encoding="utf-8") + if "GNU AFFERO GENERAL PUBLIC LICENSE" not in body: + print(" ✗ 原文看着不对,不含许可证标题") + return 1 + DST.write_text(HEADER + body, encoding="utf-8", newline="\n") + print(" ✓ 已写 %s(%d 字节)" % (DST.name, DST.stat().st_size)) + print(" 含 AGPL 标题: %s" % ("GNU AFFERO GENERAL PUBLIC LICENSE" in + DST.read_text(encoding="utf-8"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/make_monitor_units.py b/_build/make_monitor_units.py new file mode 100644 index 0000000..57f4121 --- /dev/null +++ b/_build/make_monitor_units.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +r'''写监测单元的两个 systemd 单元文件。 + +用户要求「在后台设立检测单元」。 + +为什么用 timer 而不是常驻服务: + * 这台机器只有 460MB 内存,常驻 bash 循环白占一份内存 + * 常驻脚本万一自己挂了就永远不检查了;timer 到点拉起来一次, + 崩了下一分钟自己就回来 + * 而且每次都是全新进程,不会有"跑久了状态变脏"的问题 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / "relay" + +SERVICE = '''[Unit] +# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。 +# +# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。 +Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量 +TimeoutStartSec=360 + +# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 —— +# 部署时文件权限被改掉是最常见的"脚本不执行"原因。 +ExecStart=/bin/bash /root/lanremote-relay/monitor.sh + +# 环境变量 +# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟) +# COOLDOWN 救完之后多少秒内不再救(默认 600 秒) +# 不设的话:预案刚重启完隧道还没连上,下一次检查 +# 又判失败又救一遍,来回重启反而永远连不上 +# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。 +# 想开就把下面这行的 0 改成 1 —— 但先想清楚: +# 如果问题出在供电或上游线路,重启只会让恢复更慢, +# 而且半夜自动重启一台没人管的机器风险很大。 +Environment=RESCUE_REBOOT=0 +Environment=FAIL_THRESHOLD=3 +Environment=COOLDOWN=600 + +StandardOutput=append:/root/lanremote-relay/monitor.log +StandardError=append:/root/lanremote-relay/monitor.log + +[Install] +WantedBy=multi-user.target +''' + +TIMER = '''[Unit] +# 每 60 秒检查一次公网。 +# +# 为什么是 60 秒不是 5 秒: +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到 +# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里 +# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。 +# +# OnBootSec=90s:开机后等 90 秒再开始查。 +# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。 +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +''' + + +def main() -> int: + (OUT / "lanremote-monitor.service").write_text( + SERVICE, encoding="utf-8", newline="\n") + (OUT / "lanremote-monitor.timer").write_text( + TIMER, encoding="utf-8", newline="\n") + for n in ("lanremote-monitor.service", "lanremote-monitor.timer"): + p = OUT / n + print(" ✓ %s(%d 字节)" % (n, p.stat().st_size)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index 44db0b6..247d8b1 100644 --- a/_build/package_remote.py +++ b/_build/package_remote.py @@ -17,7 +17,7 @@ import zipfile from pathlib import Path ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") -VERSION = "2.0.0" +VERSION = "2.1.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" @@ -584,12 +584,20 @@ def main() -> int: server_dir = work / server_name server_dir.mkdir(parents=True) # 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。 - # 改成:relay 目录里所有 .py / .sh 都带上。 - # 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。 - # 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。 + # 改成:relay 目录里所有 .py / .sh / .ico / .service / .timer 都带上。 + # 后缀白名单: + # .py 服务本体 + # .sh 部署/运维脚本 + # .ico 管理页图标(漏了的话新设备上管理页是默认样式) + # .service / + # .timer systemd 单元。**这两个是后加的** —— + # 加监测单元的时候才发现白名单里没有它们, + # 于是 monitor.sh 进了包、拉它的 timer 却没进, + # 装到新机器上监测根本不会启动(而且不会报错,只是静默不跑)。 wanted = sorted( [f for f in RELAY_SRC.iterdir() - if f.suffix in (".py", ".sh", ".ico") and f.is_file()], + if f.suffix in (".py", ".sh", ".ico", ".service", ".timer") + and f.is_file()], key=lambda f: f.name) for src in wanted: shutil.copy2(src, server_dir / src.name) diff --git a/_build/repair_device.py b/_build/repair_device.py new file mode 100644 index 0000000..3497238 --- /dev/null +++ b/_build/repair_device.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +r'''修设备上的几个卡住的状态。 + +发现的问题: + 1. lanremote-tunnel(主隧道)inactive —— 只剩 h2 副本在跑, + 公网返回 530。这是网站"挂了"的直接原因。 + 2. lanremote-nettune 卡在 activating —— 所以 MTU 还是 1400 + (而实测 1400 反而掉到 50%)。 + 3. monitor.timer 没起来。 + +按顺序修,每步都验。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +STEPS = [ + ("1. 看 nettune 为什么卡住(它挡着 MTU)", + "systemctl status lanremote-nettune --no-pager -n 12 2>&1 | tail -12; " + "echo '--- 有没有残留的 iptables 进程 ---'; pgrep -a iptables || echo 无"), + + ("2. 把卡住的 nettune 停掉、直接跑一次 net-tune.sh", + "systemctl stop lanremote-nettune 2>/dev/null; " + "systemctl reset-failed lanremote-nettune 2>/dev/null; " + "bash /root/lanremote-relay/net-tune.sh 2>&1 | tail -12; " + "echo '--- 结果 ---'; " + "echo \"MTU=$(cat /sys/class/net/wlan0/mtu)\"; " + "echo \"MSS 规则=$(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条\""), + + ("3. 拉起主隧道(网站 530 的直接原因)", + "systemctl reset-failed lanremote-tunnel 2>/dev/null; " + "systemctl restart lanremote-tunnel; sleep 12; " + "systemctl is-active lanremote-tunnel; " + "systemctl is-active lanremote-tunnel-h2"), + + ("4. 重启中转(让新的 admin.py 生效,后台才有「抢救报告」分区)", + "systemctl restart lanremote-relay; sleep 4; " + "systemctl is-active lanremote-relay"), + + ("5. 启用监测定时器", + "systemctl enable lanremote-monitor.timer >/dev/null 2>&1; " + "systemctl start lanremote-monitor.timer; sleep 3; " + "systemctl is-active lanremote-monitor.timer"), + + ("6. 验证:公网通了没 / 隧道连接数 / 后台分区", + "echo \"公网 /v1/health: $(curl -s -o /dev/null -w '%{http_code}' " + "--max-time 20 https://relay.xxmxxs.top/v1/health)\"; " + "echo \"公网首页: $(curl -s -o /dev/null -w '%{http_code}' " + "--max-time 20 https://relay.xxmxxs.top/)\"; " + "for p in 20241 20242; do " + "n=$(curl -s --max-time 5 http://127.0.0.1:$p/metrics 2>/dev/null " + "| grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1); " + "echo \" 连接数($p): ${n:-拿不到}\"; done; " + "echo \"后台「抢救报告」出现次数: " + "$(curl -s --max-time 8 http://127.0.0.1:8080/zhongzhuanzhan " + "| grep -c '抢救报告')\""), +] + + +def main() -> int: + for title, cmd in STEPS: + print() + print("=" * 66) + print(" " + title) + print("=" * 66) + try: + out = run(cmd, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + except Exception as exc: # noqa: BLE001 + text = " [执行失败] %s: %s" % (type(exc).__name__, exc) + for ln in text.strip().splitlines(): + print(" " + ln) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/restart_and_verify.py b/_build/restart_and_verify.py new file mode 100644 index 0000000..d053946 --- /dev/null +++ b/_build/restart_and_verify.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +r'''重启中转并验证 /rescue/,同时查 deploy_release.py 为什么没重启成功。 + +== 发现的问题 == + + PID=7255 进程启动于 18:04:35 + relay.py 文件改于 18:07:41 + +进程比文件旧 -> 跑的还是老代码 -> /rescue/ 还是挂。 + +deploy_release.py 的 --code-only 分支里应该有 systemctl restart, +但显然没生效。两个可能: + 1. 那一步的代码路径没走到(--code-only 把 restart 也跳过了) + 2. 走了但失败被吞掉 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 1. 重启前 ════" +PID=$(systemctl show -p MainPID --value lanremote-relay) +echo " PID=$PID 启动于 $(ps -o lstart= -p $PID 2>/dev/null)" + +echo +echo "════ 2. 重启 ════" +systemctl restart lanremote-relay +sleep 5 +PID2=$(systemctl show -p MainPID --value lanremote-relay) +echo " 新 PID=$PID2 启动于 $(ps -o lstart= -p $PID2 2>/dev/null)" +echo " relay.py 改于 $(stat -c '%y' $RELAY/relay.py)" +echo " 服务状态: $(systemctl is-active lanremote-relay)" + +echo +echo "════ 3. 重启后测 /rescue/(不带登录,应该立刻 401 而不是挂住)════" +printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +echo -n " 不带登录: " +curl -s -o /dev/null -w 'HTTP %{http_code} 用时 %{time_total}s\n' --max-time 8 \ + "$B/rescue/19700101-000000-demo.txt" + +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +curl -s -c /tmp/ck2.txt -o /dev/null --max-time 8 -d "password=$PASS" $B/login +echo -n " 带登录: " +curl -s -b /tmp/ck2.txt -o /tmp/rep2.txt -w 'HTTP %{http_code} 用时 %{time_total}s\n' \ + --max-time 8 "$B/rescue/19700101-000000-demo.txt" +echo " 内容: $(head -1 /tmp/rep2.txt 2>/dev/null)" + +echo +echo "════ 4. 后台页里的「抢救报告」分区 ════" +N=$(curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告') +echo " 匹配 $N 次" +curl -s -b /tmp/ck2.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /' + +echo +echo "════ 5. 公网还通吗(重启不能把网站弄挂)════" +echo " 公网 health: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/v1/health)" +echo " 公网首页: $(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://relay.xxmxxs.top/)" + +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt /tmp/ck2.txt /tmp/rep2.txt +''' + + +def main() -> int: + # ---- 先看 deploy_release.py 的 --code-only 分支到底走不走 restart ---- + src = (ROOT / "_build" / "deploy_release.py").read_text(encoding="utf-8") + print(" ── deploy_release.py 里 restart 相关的代码 ──") + for i, ln in enumerate(src.splitlines(), 1): + if "restart" in ln or "files_only" in ln or "code_only" in ln: + print(" %4d: %s" % (i, ln.strip())) + print() + + out = run(REMOTE, timeout=300) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_admin_login.py b/_build/verify_admin_login.py new file mode 100644 index 0000000..d0710d3 --- /dev/null +++ b/_build/verify_admin_login.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +r'''完整验证:登录 -> 后台页看到「抢救报告」-> 下载报告。 + +上一轮失败是因为我 curl 用错了接口(POST /login 只传 password), +实际是 POST /admin/login 且要 username + password 两个字段。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 0. 账号与密码 ════" +grep -m1 '^ADMIN_USER' $RELAY/relay.py | sed 's/^/ /' +echo " 密码文件长度: $(wc -c < $RELAY/data/admin_password.txt) 字节" + +echo +echo "════ 1. 造报告 ════" +printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' \ + > "$RELAY/rescue_reports/19700101-000000-demo.txt" +ls $RELAY/rescue_reports/ | sed 's/^/ /' + +echo +echo "════ 2. 登录(POST /admin/login,username + password)════" +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +rm -f /tmp/ck3.txt +curl -s -c /tmp/ck3.txt -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + -d "username=" -d "password=$PASS" $B/admin/login +echo " cookie 文件:" +grep -c lr_admin /tmp/ck3.txt 2>/dev/null | sed 's/^/ 找到 lr_admin: /' + +echo +echo "════ 3. 带登录拿后台页 ════" +curl -s -b /tmp/ck3.txt -o /tmp/adm3.html -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \ + --max-time 10 $B/zhongzhuanzhan +echo " 含「抢救报告」: $(grep -c '抢救报告' /tmp/adm3.html)" +echo " 含演练报告文件名: $(grep -c '19700101-000000-demo.txt' /tmp/adm3.html)" +echo " 含「下载区」: $(grep -c '下载区' /tmp/adm3.html)" + +echo +echo "════ 4. 带登录下载报告 ════" +curl -s -b /tmp/ck3.txt -o /tmp/rep3.txt -w ' HTTP %{http_code} 大小 %{size_download} 字节\n' \ + --max-time 10 "$B/rescue/19700101-000000-demo.txt" +echo " 内容第一行: $(head -1 /tmp/rep3.txt)" +echo " 缓存头: $(curl -s -b /tmp/ck3.txt -D- -o /dev/null --max-time 10 \ + "$B/rescue/19700101-000000-demo.txt" | grep -i 'cache-control' | tr -d '\r')" + +echo +echo "════ 5. 不带登录(应该 401)════" +curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + "$B/rescue/19700101-000000-demo.txt" + +echo +echo "════ 6. X-Admin 头(脚本通道)也要能下 ════" +curl -s -o /dev/null -w ' HTTP %{http_code}\n' --max-time 10 \ + -H "X-Admin: $PASS" "$B/rescue/19700101-000000-demo.txt" + +echo +echo "════ 清理 ════" +rm -f "$RELAY/rescue_reports/19700101-000000-demo.txt" /tmp/ck3.txt /tmp/adm3.html /tmp/rep3.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=200) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_gate_fix.py b/_build/verify_gate_fix.py new file mode 100644 index 0000000..5590bdd --- /dev/null +++ b/_build/verify_gate_fix.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +r'''验证 /rescue/ 鉴权修复是否真的生效。 + +上一轮部署后还是 HTTP 000。要分清是: + * 代码没传上去 / 进程没重启(改动没生效) + * 还是代码有新问题 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +B=http://127.0.0.1:8080 + +echo "════ 1. 设备上 relay.py 里那段代码长什么样 ════" +grep -n -A6 'if path.startswith("/rescue/")' $RELAY/relay.py | head -40 | sed 's/^/ /' + +echo +echo "════ 2. 进程是什么时候起的 vs relay.py 修改时间 ════" +PID=$(systemctl show -p MainPID --value lanremote-relay) +echo " PID=$PID 启动: $(ps -o lstart= -p $PID 2>/dev/null)" +echo " relay.py 改于: $(stat -c '%y' $RELAY/relay.py)" + +echo +echo "════ 3. 再来一次详细请求 ════" +printf '报告\n 判断:测试\n' > $RELAY/rescue_reports/19700101-000000-demo.txt +curl -sv --max-time 8 "$B/rescue/19700101-000000-demo.txt" 2>&1 | tail -20 | sed 's/^/ /' + +echo +echo "════ 4. 对照:一个不存在的普通路径 ════" +curl -s -o /dev/null -w ' /nothing-here -> %{http_code}\n' --max-time 6 "$B/nothing-here" + +echo +echo "════ 5. 对照:/download/ 上不存在的文件(那条没加鉴权)════" +curl -s -o /dev/null -w ' /download/none.txt -> %{http_code}\n' --max-time 6 "$B/download/none.txt" + +rm -f $RELAY/rescue_reports/19700101-000000-demo.txt +''' + + +def main() -> int: + out = run(REMOTE, timeout=180) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/verify_monitor_e2e.py b/_build/verify_monitor_e2e.py new file mode 100644 index 0000000..91d5626 --- /dev/null +++ b/_build/verify_monitor_e2e.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +r'''端到端验证「监测 + 预案 + 报告分区」。 + +验这些: + 1. /rescue/ 不带登录 -> 401(不能匿名下诊断报告) + 2. /rescue/ 带登录 -> 200 且内容正确 + 3. 报告文件名的目录穿越被挡住 + 4. monitor.sh 能跑通并写状态 + 5. rescue.sh 在网站正常时**什么都不做**就退出(不能瞎折腾) + 6. 后台页里有「抢救报告」分区 + 7. 监测定时器在跑 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "_build")) +from devssh import run # noqa: E402 + +REMOTE = r''' +RELAY=/root/lanremote-relay +PASS=$(cat $RELAY/data/admin_password.txt 2>/dev/null) +B=http://127.0.0.1:8080 + +echo "════ 准备:造一份演练报告 ════" +DEMO=$RELAY/rescue_reports/19700101-000000-demo.txt +printf '这是一份演练报告。\n\n 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。\n' > $DEMO +echo " $(ls $RELAY/rescue_reports | wc -l) 份" + +echo +echo "════ 1. 不带登录下载(应该 401)════" +printf ' HTTP %s\n' "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 $B/rescue/19700101-000000-demo.txt)" + +echo +echo "════ 2. 带登录下载(应该 200 且有内容)════" +# 先登录拿 cookie +curl -s -c /tmp/ck.txt -o /dev/null --max-time 8 \ + -d "password=$PASS" $B/login 2>/dev/null +CODE=$(curl -s -b /tmp/ck.txt -o /tmp/rep.txt -w '%{http_code}' --max-time 8 \ + $B/rescue/19700101-000000-demo.txt) +printf ' HTTP %s\n' "$CODE" +echo " 内容:$(head -2 /tmp/rep.txt | tail -1)" +printf ' 缓存头: %s\n' "$(curl -s -b /tmp/ck.txt -D- -o /dev/null --max-time 8 $B/rescue/19700101-000000-demo.txt | grep -i cache-control | tr -d '\r')" + +echo +echo "════ 3. 目录穿越(应该 400/404,绝不能读到别的文件)════" +for bad in "../relay.py" "..%2Frelay.py" "x/../../etc/passwd"; do + printf ' %-24s -> HTTP %s\n' "$bad" "$(curl -s -b /tmp/ck.txt -o /dev/null -w '%{http_code}' --max-time 8 "$B/rescue/$bad")" +done + +echo +echo "════ 4. monitor.sh 跑一次 ════" +rm -f $RELAY/monitor_state.txt +bash $RELAY/monitor.sh +echo " 状态文件:" +cat $RELAY/monitor_state.txt 2>/dev/null | sed 's/^/ /' +echo " 日志尾部:" +tail -3 $RELAY/monitor.log 2>/dev/null | sed 's/^/ /' + +echo +echo "════ 5. rescue.sh 在网站正常时应该**什么都不做** ════" +BEFORE=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1) +timeout 120 bash $RELAY/rescue.sh "端到端验证(网站正常)" 2>&1 | tail -8 | sed 's/^/ /' +AFTER=$(md5sum $RELAY/rescue_knowledge.md 2>/dev/null | cut -d' ' -f1) +if [ "$BEFORE" = "$AFTER" ]; then + echo " ✅ 网站正常时没有误触发抢救(知识库没变)" +else + echo " ❌ 网站正常却动了抢救" +fi +echo " 报告份数:$(ls $RELAY/rescue_reports | wc -l)(正常时不该新增)" + +echo +echo "════ 6. 后台页里的「抢救报告」分区 ════" +N=$(curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -c '抢救报告') +printf ' 匹配 %s 次\n' "$N" +curl -s -b /tmp/ck.txt --max-time 8 $B/zhongzhuanzhan | grep -o '19700101-000000-demo.txt' | head -1 | sed 's/^/ 列表里有: /' + +echo +echo "════ 7. 定时器 ════" +printf ' monitor.timer: %s\n' "$(systemctl is-active lanremote-monitor.timer)" +systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | sed -n '1,2p' | sed 's/^/ /' + +echo +echo "════ 收尾:删掉演练报告 ════" +rm -f $DEMO /tmp/ck.txt /tmp/rep.txt +echo " 剩 $(ls $RELAY/rescue_reports | wc -l) 份" +''' + + +def main() -> int: + out = run(REMOTE, timeout=400) + text = out[1] if isinstance(out, tuple) else str(out) + print(text) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/wire_sfx.py b/_build/wire_sfx.py new file mode 100644 index 0000000..8338bea --- /dev/null +++ b/_build/wire_sfx.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +r'''把音效功能接进协议层和服务端。 + +加两条命令: + sfx_list 列出可用音效 + sfx_play 播放指定的音效 + +复用现成的 play_audio(MCI),不另造一套播放器。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +PROTO = ROOT / "windows" / "lanremote" / "protocol.py" +SERVER = ROOT / "windows" / "lanremote" / "server.py" + + +def load(p: Path): + raw = p.read_bytes() + return raw.startswith(b"\xef\xbb\xbf"), raw.decode("utf-8-sig") + + +def save(p: Path, text: str, bom: bool): + p.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + + +# ---------------------------------------------------------------- protocol +PROTO_ANCHOR = 'CMD_AUDIO_LIST = "audio_list"' +PROTO_NEW = '''CMD_AUDIO_LIST = "audio_list" +CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的) +CMD_SFX_PLAY = "sfx_play" # 播放某个音效''' + +# ---------------------------------------------------------------- server +# 挂在 CMD_AUDIO_LIST 那条分支后面 +SERVER_ANCHOR = ''' if cmd == P.CMD_AUDIO_LIST:''' + +SERVER_NEW = ''' if cmd == P.CMD_SFX_LIST: + items = self.actions.list_sfx() + return {"ok": True, "sfx": items, "count": len(items), + "dir": self.actions.sfx_dir_hint()} + + if cmd == P.CMD_SFX_PLAY: + name = str(body.get("name") or "").strip() + if not name: + return {"ok": False, "error": "没说要放哪个音效"} + try: + msg = self.actions.play_sfx(name) + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": str(exc)} + return {"ok": True, "message": msg, "name": name} + +''' + SERVER_ANCHOR + + +def main() -> int: + ok = True + + bom, text = load(PROTO) + if "CMD_SFX_LIST" in text: + print(" · protocol.py 已经加过了") + elif PROTO_ANCHOR not in text: + print(" ✗ protocol.py 找不到锚点") + ok = False + else: + text = text.replace(PROTO_ANCHOR, PROTO_NEW, 1) + save(PROTO, text, bom) + print(" ✓ protocol.py 加了两条命令") + + bom, text = load(SERVER) + if "CMD_SFX_LIST" in text: + print(" · server.py 已经加过了") + elif SERVER_ANCHOR not in text: + print(" ✗ server.py 找不到锚点") + ok = False + else: + text = text.replace(SERVER_ANCHOR, SERVER_NEW, 1) + save(SERVER, text, bom) + print(" ✓ server.py 加了两个处理分支") + + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/wrap_gui_stages.py b/_build/wrap_gui_stages.py new file mode 100644 index 0000000..88dccad --- /dev/null +++ b/_build/wrap_gui_stages.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +r'''把会弹窗的测试阶段包进隐藏桌面。 + +== 用户要求 == + +「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」 + +== 现状 == + +有 6 个阶段会真的开窗口,其中 test_image_modes 还会开**全屏** +(直接把用户的屏幕锁住): + + 1e/7 新增功能 test_new_features.py + 1f/7 电脑功能注册表 test_features.py + 1g/7 功能模糊测试 fuzz_features.py + 1k/7 首次安装设置窗口 test_first_run_setup.py + 2b/7 投图显示方式 test_image_modes.py <- 全屏,锁屏 + 3/7 屏幕共享可见提示 test_indicator_visible.py + +套件里原来只有一个 Close-TestWindows,那是**事后收尾** +(杀掉没退干净的进程),窗口在跑的时候照样在屏幕上闪。 + +== 改法 == + +把这些调用前面套一层 _tests/hidden_desktop.py --, +那个脚本会新建一个 Windows 桌面、把子进程放上去。 +子进程里所有窗口都创建在那个桌面上 —— 用户完全看不到, +而窗口本身是**真的**(全屏、可见、几何、Win32 枚举全都真实), +测试有效性一点没损失。 + + & python hidden_desktop.py -- python test_image_modes.py + +== 保留什么 == + + * `*>` 重定向照旧(hidden_desktop 会把子进程输出转写到自己的 + stdout,套件照常能抓到 FAIL) + * 退出码照旧(hidden_desktop 用子进程的退出码退出) + * Close-TestWindows 留着 —— 它是兜底,还有用 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SUITE = ROOT / "_tests" / "verify_all.ps1" + +# 要把调用包进隐藏桌面的测试 +GUIs = [ + "test_new_features.py", + "test_features.py", + "fuzz_features.py", + "test_first_run_setup.py", + "test_image_modes.py", + "test_indicator_visible.py", +] + + +def main() -> int: + raw = SUITE.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + + if "hidden_desktop.py" in text: + print(" 已经接过了") + return 0 + + changes = [] + for name in GUIs: + # 两种形状: + # & python (Join-Path $root "_tests\X") *> ... + # $stageOut = & python (Join-Path $root "_tests\X") 2>&1 + pat = re.compile( + r'(& python \(Join-Path \$root "_tests\\%s"\))' + % re.escape(name)) + n = len(pat.findall(text)) + if n != 1: + changes.append(("!", name, "出现 %d 次" % n)) + continue + # ⚠ 替换串里含反斜杠("_tests\hidden_desktop.py"), + # 直接当 repl 字符串会被 re 当成转义序列, + # 报 "bad escape \h"。所以用 lambda 返回字面量。 + text = pat.sub( + lambda m: ('& python (Join-Path $root "_tests\\' + 'hidden_desktop.py") -- ' + m.group(1)), + text, count=1) + changes.append(("✓", name, "")) + + # 在第一个被包的调用之前插一段说明 + note = '''# ---------------------------------------------------------------- 隐藏桌面 +# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** —— +# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。 +# +# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程 +# 放上去,所有窗口都创建在那个不显示的桌面上。 +# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实), +# 只是用户看不到 —— 测试有效性没有损失。 +# +# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。 + +''' + anchor = 'Banner "1e/7 新增功能' + if anchor in text: + text = text.replace(anchor, note + anchor, 1) + changes.append(("✓", "插入说明注释", "")) + else: + changes.append(("✗", "插入说明注释", "找不到锚点")) + + print(" ── 结果 ──") + for mark, what, why in changes: + print(" %s %-28s %s" % (mark, what, why)) + + if any(m == "✗" for m, _w, _y in changes): + print() + print(" 有没匹配上的,没有写回") + return 1 + + SUITE.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + text.encode("utf-8")) + print() + print(" 已写回 verify_all.ps1(BOM 保留:%s)" % had_bom) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/_stubdata2/devices.json b/_tests/_stubdata2/devices.json new file mode 100644 index 0000000..9767dcd --- /dev/null +++ b/_tests/_stubdata2/devices.json @@ -0,0 +1,8 @@ +{ + "java-test-device": { + "name": "JVM测试端", + "token": "4ed3ec96a8e2fb0337de88aa9212d7f5", + "paired_at": 1791452868.0075707, + "last_seen": 1791452868.011561 + } +} \ No newline at end of file diff --git a/_tests/_stubdata2/settings.json b/_tests/_stubdata2/settings.json new file mode 100644 index 0000000..9b205d7 --- /dev/null +++ b/_tests/_stubdata2/settings.json @@ -0,0 +1,25 @@ +{ + "port": 8799, + "server_name": "DESKTOP-RN5I625", + "allow_run": true, + "allow_power": true, + "allow_screen": true, + "start_minimized": false, + "allow_new_pairing": true, + "image_timeout": 0, + "image_display": "window", + "image_custom_width": 0, + "image_custom_height": 0, + "image_window_title": "", + "tts_voice": "", + "tts_rate": 0, + "mp3_dir": "", + "file_roots": "", + "extra_apps": "", + "extra_commands": "", + "download_dir": "received", + "screen_max_width": 480, + "screen_quality": 72, + "screen_fps": 12, + "pairing_code": "246813" +} \ No newline at end of file diff --git a/_tests/hidden_desktop.py b/_tests/hidden_desktop.py new file mode 100644 index 0000000..2e4ed76 --- /dev/null +++ b/_tests/hidden_desktop.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +r'''在**隐藏的 Windows 桌面**上跑命令,用户屏幕不受打扰。 + +== 为什么需要 == + +用户原话:「把全量测试改为在后台静默运行不要打开任何窗口或锁屏」。 + +有几个测试必须开**真实的窗口**才测得出东西,不能改成假的: + + test_image_modes 投图窗口的三种显示方式(含**全屏**) + test_indicator_visible 屏幕共享提示条 + Win32 窗口枚举 + fuzz_features 功能模糊测试 + test_first_run_setup 首次设置窗口 + +问题是它们跑的时候会在用户屏幕上一通闪,**全屏那个还会把屏幕锁住**, +用户根本没法用电脑。 + +== 解法 == + +Windows 支持多个"桌面"(Desktop)—— 同一个登录会话里可以有若干个, +但**只有一个是当前显示给用户的**(叫 Default)。 +我们新建一个桌面,把测试进程放上去: + + CreateDesktopW("ykxmz-test", ...) 建一个不显示的桌面 + CreateProcessW(..., lpDesktop=那个名字) 把子进程放上去 + 子进程里所有窗口都创建在那个桌面上 -> 用户完全看不到 + 跑完 CloseDesktop + +这样窗口是**真的**(窗口句柄、几何、可见性、Win32 枚举全都真实), +只是不在用户眼前 —— 测试有效性一点没损失。 + +== 为什么不用别的方法 == + + * "把窗口设成透明/最小化" —— 那就测不出全屏了,而且还是会闪 + * "跳过这些测试" —— 投图和提示条是核心功能,不能因为烦就不测 + * "用无头模式" —— 那测的就不是真实窗口了 + +== 用法 == + +命令行: + python _tests/hidden_desktop.py -- <要跑的命令...> + +代码里: + from hidden_desktop import run_hidden + code, out = run_hidden(["python", "test_image_modes.py"], timeout=300) +''' +from __future__ import annotations + +import ctypes +import ctypes.wintypes as wt +import os +import subprocess +import sys +import tempfile +import time +import uuid +from pathlib import Path + +IS_WINDOWS = os.name == "nt" + +# ---- Win32 常量和结构 ---- + +# 桌面权限:给足,否则 CreateProcess 放上去之后子进程建不了窗口 +DESKTOP_ALL_ACCESS = 0x000F01FF +GENERIC_ALL = 0x10000000 + +CREATE_NEW_CONSOLE = 0x00000010 +CREATE_UNICODE_ENVIRONMENT = 0x00000400 +WAIT_TIMEOUT = 0x00000102 +INFINITE = 0xFFFFFFFF + + +class STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wt.DWORD), + ("lpReserved", wt.LPWSTR), + ("lpDesktop", wt.LPWSTR), + ("lpTitle", wt.LPWSTR), + ("dwX", wt.DWORD), + ("dwY", wt.DWORD), + ("dwXSize", wt.DWORD), + ("dwYSize", wt.DWORD), + ("dwXCountChars", wt.DWORD), + ("dwYCountChars", wt.DWORD), + ("dwFillAttribute", wt.DWORD), + ("dwFlags", wt.DWORD), + ("wShowWindow", wt.WORD), + ("cbReserved2", wt.WORD), + ("lpReserved2", ctypes.POINTER(ctypes.c_byte)), + ("hStdInput", wt.HANDLE), + ("hStdOutput", wt.HANDLE), + ("hStdError", wt.HANDLE), + ] + + +class PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wt.HANDLE), + ("hThread", wt.HANDLE), + ("dwProcessId", wt.DWORD), + ("dwThreadId", wt.DWORD), + ] + + +def _api(): + user32 = ctypes.WinDLL("user32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + + user32.CreateDesktopW.argtypes = [ + wt.LPCWSTR, wt.LPCWSTR, ctypes.c_void_p, wt.DWORD, + wt.DWORD, ctypes.c_void_p] + user32.CreateDesktopW.restype = wt.HANDLE + + user32.CloseDesktop.argtypes = [wt.HANDLE] + user32.CloseDesktop.restype = wt.BOOL + + kernel32.CreateProcessW.argtypes = [ + wt.LPCWSTR, wt.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wt.BOOL, + wt.DWORD, ctypes.c_void_p, wt.LPCWSTR, + ctypes.POINTER(STARTUPINFOW), ctypes.POINTER(PROCESS_INFORMATION)] + kernel32.CreateProcessW.restype = wt.BOOL + + kernel32.WaitForSingleObject.argtypes = [wt.HANDLE, wt.DWORD] + kernel32.WaitForSingleObject.restype = wt.DWORD + + kernel32.GetExitCodeProcess.argtypes = [wt.HANDLE, + ctypes.POINTER(wt.DWORD)] + kernel32.GetExitCodeProcess.restype = wt.BOOL + + kernel32.TerminateProcess.argtypes = [wt.HANDLE, wt.UINT] + kernel32.CloseHandle.argtypes = [wt.HANDLE] + return user32, kernel32 + + +def run_hidden(command, timeout: float = 600.0, cwd: str | None = None, + log_path: Path | None = None) -> tuple: + """在隐藏桌面上跑命令。 + + 返回 (退出码, 输出文本)。退出码 -1 表示建桌面失败(会自动退回 + 直接在普通桌面跑,并说明原因 —— 宁可闪一下也不能让测试跑不了)。 + """ + if not IS_WINDOWS: + # 非 Windows 直接跑 + r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout) + return r.returncode, (r.stdout or "") + (r.stderr or "") + + if log_path is None: + log_path = Path(tempfile.gettempdir()) / ( + "ykxmz_hidden_%s.log" % uuid.uuid4().hex[:8]) + + user32, kernel32 = _api() + name = "ykxmz-test-%s" % uuid.uuid4().hex[:8] + + desk = user32.CreateDesktopW(name, None, None, 0, + DESKTOP_ALL_ACCESS | GENERIC_ALL, None) + if not desk: + err = ctypes.get_last_error() + # 建不了桌面(少见,通常是权限)—— 退回普通方式,但要说清楚 + r = subprocess.run(command, cwd=cwd, capture_output=True, text=True, + encoding="utf-8", errors="replace", + timeout=timeout) + msg = (" [警告] 建隐藏桌面失败(Win32 错误 %d)," + "这次只能在普通桌面上跑 —— 屏幕上可能会闪窗口。\n" % err) + return r.returncode, msg + (r.stdout or "") + (r.stderr or "") + + try: + # 让子进程自己把输出重定向到文件 —— 走 CreateProcess 时 + # 管道不好接管,重定向到文件最简单可靠。 + # 用 cmd /c 是为了让 > 生效。 + quoted = subprocess.list2cmdline([str(c) for c in command]) + full = 'cmd.exe /c %s > "%s" 2>&1' % (quoted, log_path) + # cmd /c 后面整串要作为一个参数传 + cmdline = ctypes.create_unicode_buffer(full) + + si = STARTUPINFOW() + si.cb = ctypes.sizeof(STARTUPINFOW) + si.lpDesktop = name # <<< 关键:把子进程放到隐藏桌面 + pi = PROCESS_INFORMATION() + + ok = kernel32.CreateProcessW( + None, cmdline, None, None, False, + CREATE_NEW_CONSOLE | CREATE_UNICODE_ENVIRONMENT, + None, cwd, ctypes.byref(si), ctypes.byref(pi)) + if not ok: + err = ctypes.get_last_error() + r = subprocess.run(command, cwd=cwd, capture_output=True, + text=True, encoding="utf-8", + errors="replace", timeout=timeout) + return r.returncode, (" [警告] 在隐藏桌面上启动失败" + "(Win32 错误 %d)\n" % err + ) + (r.stdout or "") + (r.stderr or "") + + try: + waited = kernel32.WaitForSingleObject(pi.hProcess, + int(timeout * 1000)) + if waited == WAIT_TIMEOUT: + kernel32.TerminateProcess(pi.hProcess, 124) + code = 124 + else: + code = wt.DWORD() + kernel32.GetExitCodeProcess(pi.hProcess, ctypes.byref(code)) + code = code.value + finally: + kernel32.CloseHandle(pi.hThread) + kernel32.CloseHandle(pi.hProcess) + + # 给文件一点时间落盘 + for _ in range(40): + if log_path.is_file() and log_path.stat().st_size > 0: + break + time.sleep(0.05) + try: + out = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + out = "" + return code, out + finally: + user32.CloseDesktop(desk) + try: + log_path.unlink() + except OSError: + pass + + +def main() -> int: + if not IS_WINDOWS: + print(" 这个工具只在 Windows 上有意义") + return 1 + args = sys.argv[1:] + if args and args[0] == "--": + args = args[1:] + if not args: + print(__doc__) + return 1 + + print(" 在隐藏桌面上运行:%s" % " ".join(args)) + started = time.time() + code, out = run_hidden(args, timeout=900) + took = time.time() - started + # 原样把输出打出来(套件靠它判断) + sys.stdout.write(out) + if not out.endswith("\n"): + sys.stdout.write("\n") + # ⚠ 总结这行**只在终端里打**。 + # 套件是用 `*> 日志文件` 接输出的,再 `Select-Object -Last 2/3` + # 取摘要 —— 如果这里多打一行,就会把真正的摘要顶掉, + # 汇总表上显示的是"隐藏桌面运行结束"而不是测试结果。 + if sys.stdout.isatty(): + print(" —— 隐藏桌面运行结束:退出码 %d,用时 %.1f 秒" % (code, took)) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$10.class b/_tests/out_app/com/lanremote/watch/MainActivity$10.class index 128adcfaf45a6c4e8463209f463b1bf2d1298038..34f346132700f5cfa0a94086f538ccc48bffff25 100644 GIT binary patch literal 1123 zcmah|ZBNud5Pr5Cw@`{4puiy@!lBpWrJ$(9GbV=MOL9OWi1Fpi(p$M=*^+h(`iuNb zqJkRvuExJ)oE4%6#9Wi^&hE@S`^=M>@4r4D0+_|ER#<4_$T(<5mSNP7w!BcNSZ+mH zdb>jVHE&S_>YlHIoj|`W%uF+6buCB=WQ`trFz=LjVRPB@t5r%>LLyc$`7N2 z+H6tkT2ys#&Gc+%pb{;@a6T4E?LjKyDueS#DH+d)B1vSzapOd*9uZ^c`=2@(rm<@Q zwJ=yNXGV{v(W1W%MY?s#8lkhfu}kA0z)JhzdvqcNXwA|N>l3Zxm^hAqK`0sW4NV-N zeW~;w>;TP)J(?iCgMa>Y-8*5(*$iO6egeCoYb&#M?M)WN%XS zv5*phIPd^G0}?z5V%8uPkwYwbXLj`MZ+1N6Z@<2L1#pa`0t{qKWG&>7XV~+ji5q${ z=95Tq_tH~-;GTIxzVMZp33X*3wHWd$5Q$CBZg-XbR3})oP(YDkZRUk`5G9JC(!KCz zo=!D#dny)klqxL?H*xE)@S4C8@VPexl z4Om28U3+@vw|4c}so-p!)K?Y5Q1pDCCy9O1qUzr@R+qnA zW@kCH9BKAV>@w_lau`Qq=*|Scbo;c}=dv9Nf7}&`;*!S<*>*JK4C`GX`J3rvz~i^x zAf!m4Cq~j!)0liS`g4q-+UPDpPn&%ztrtyWpT8FRZ28}wAL`FY8D2?0j1p?q8CO9x zv~Zw5ayjPjW1a*r`Aqm+-;2TONV;1)^pb=pCR|c2B_-g0wV+Ndt%Ax%u2j+^1CXVG zNG3SkjPH0vzJV>;J;oDSHSL&w3eDei<^-D)Yfh*+f##@u#=UDwQR|E!*f4$~gGS0~ Y!XcZb8QaHG@+zd!H2v)05UqUSH#`{9QUCw| diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$100.class b/_tests/out_app/com/lanremote/watch/MainActivity$100.class index 17aa0a91b516c1bcc542ad9075941c42b4511a6d..3bee6a8f482a07dc38ad692c2c9f009001e8af85 100644 GIT binary patch literal 5449 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 n8h!X9d`zQ-4!WPi5Z~j4o delta 491 zcmZ8eOG_g`5dL~}opJ1}llY1oUB@_@WTGa%z{P{$gC(FB1yOb{v+NMEJG7D)-n@A6 z7~0dq3SK>kLZYIwo)rZDgpZs5K&S2=ZI`-xtPz1>`W=={dDs>f(U46V+cL& z)}FR2_d5qq)#}6FtLMu3L~Q;yV`@;B2XljG?$LoB}V zzsOWZ9w}{F5WO5x^vQk2(jw^R7(h(kDqq7Y5~Te8Y^fxK^TTuHUDJ>>`B-oiV+`uL zm@m7c$Y90g77uTgia9T}=xj_SRv36j2sc0LIfa5-fC+^*zz~OG7-)nJ5Pm=dtxKWX zj2NL!o{x1tV<&51_FxUFeTt@Iq8O578wkNyF-X1wgQB4~YiEg=M!la#pIrsJKyd98 yEuq#Ev{S4a@iTlP{{-e4toM$(KLY=TDF05boyK z3O)(`0pEo5f{MQTKh(bY55#YpLWBgeu=CB#cR6RiIrHQ1=WhURU^R&dq6T6dClF^a zZLeK+E$NH4r$qUMrR?qUrsc?cwsN|TdSzBCRff3Qb^^1)Fk2s&P}2wrjwDhHiLT|E zO1xAIx%!UPwKUdNxuJYVZq-uYm^5&bBaIA0W)SeGxg%^!ir;pmqwX+73&p1fatzah zpnjG6q2soMpF$Rs37jUN-|u!0U%&nN=Hp?n_v^#=KVRQ#=QPJzoTHLfrz2#GVW}|8 zs5rEBT%Nej;DUjR9G7sJAwRAS3=@`Ziy$y>tyZXU^CQqUhT#Y&iz^(nn4|6~uYX2{ zslv$E8C>PKhIxk6G1`g-yIB~2O+zQG=?)jEe}fJ`4yCl3u3(6*c`Y*Ox+BHIu-z2? zlfEO_aJHnSLZ7@*Ka|9z;f1~}9ymHL`@fc!b!95DE^XHfNCBHdZF?<_65CQTeI}U~dBAJXYdWUE?8-C7e;9x6Q5{{VS&atS#XI(nn#eK)zZ%Qzremy delta 404 zcmYLFOG^S#6#nk`7$2jVWiNY}8mKK4DSB+$w26YC!2M*C7&1m~$*duhq x{;v-}*I^-q9-wpfID2bwb~`McS%LzqkRjbDFiyl6A?TMGCdiJgDAQA<^9wH?Svddz diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$102.class b/_tests/out_app/com/lanremote/watch/MainActivity$102.class index d3ed7522fde7158d1bd4b2607f94d2a34b9c315b..1db1590f8d77506f17add54589e0650f20b0d6a1 100644 GIT binary patch delta 372 zcmX@YvX52Z)W2Q(7#J8#7?jx=m>3xpCf-vJ;ACK7U}a}u<6&TD;9z7>0g6quGU4W8 z;AUsw;bGuq;G0;f6vn|Iz{p_mbYsKQ`SYLeo%n3p+9$i(-tXyows!H;IV+xav_I|G z@MOo@7kk${o74NWYvI$GD;_m0WBxxYoq>yikwKV;L4-k+k%2ogIXShsSjF7fz<`m# zV&WZTU7xJPvPAuy#JqI<;F6-uymV_%%`kQbaYhCqgiKIrUS48SPU^&)8j~d%Lzop9 z6ekxjTGz9HO&4Qe6c7M8ALutGAm(L|0NN=DWGMmpAQ3Jt?X3*VTH6^oHv)N#3{pUn z4M;Qav;j$J20o~I4F+bACi5QV=<(%v+=YK;H!rzJ$txM&v@1m8l$wXqaIN>GXg-S`L+`T#zIpeQc7 za^-{g7~%v3FWfn24rk`vJ8OJ;$=A=v4CvEt;ourPOM|A6RX3gLydV0s41p!YR!?aZ zFwcS^vMdq{9d#*$#b7WTkF%r0UQgKFtFF^z|IDx~#8<_n=#Gl=vU_q{mc_+rIDM2; zcZ==mw;Vq>_y|i#6xG4{7DsPLtm=xgRQp;9@`G0-f5SX$C#-61Dq<&E*RX&7Oi@+* l`~`Vu?%y5Md+Z&?-s7^a9*2#DM2V-r+GIyrpu=^Xo?pb>Bv$|c diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$103.class b/_tests/out_app/com/lanremote/watch/MainActivity$103.class index 86b03bbf924f8feb441cda24eadec449b59d83be..54cac9681b1a6f1d6c690792fbaa53b38f93a1c3 100644 GIT binary patch delta 271 zcmYL^O-jT-6okL-WTrEnR^#|H8h>U85j1XQF^J$DT)1$p4DOXVfEQ3S^A*yY*$!**kSCH^+9++}e|9ldb049$i}!3`>|S zYjYHB^-}s$SHudUZwXH4~id(>oDRqqN3H|hhzpT87JC8?e;ugv|sefv+i!-aby W*5t9Q=j0->j@V#Z%xk%gQ~d&-^d6@G literal 982 zcma)5&rcIU6#k}L+p=9ssUZG9typwhgceZI)R-t}(gZXSL*jASPGCuyCA(XUH!mtA zdi7w8i7_!A+JlLf2CLrrGYnQd^*>PG1QLkVludSK-piZs``(Y)__h88zH`_n-}a90($yA&3w|qE&oiI5Wx+@a8QykzweY zY;_^4LPa*&IA!<=-CaL_8d#hT`yXN zJa<%dARNL$BHpZ)w?3EZ&&!+Zj~lh8_41qVFW>!q@@(ttt43|*N2wH-!QC1Tp@%4p zr6n%%41@aQ-`Lq?lWT{Lv@;yha1_TF!iH&b*G-IF&QRk0dh5)SIVs_|lyHKfSI_;Q zD1@bXFYQBV zQvtF$*@*#$*lxA%6ooKm3w)o+&oKDM?L38dOj?59DlX1)=k7Lu;uHntJ8c&oli#!? zX6N3i9FnYb@rE!9woCn=;@-TS*J!RUFhp!IVY#L)1dR`;rgs0Usl>14D8v~r7_Q4* z#A(!JnNncUs2q2CCqDYyagMx$bm}8pdeqdwM<}T^w7n;bVTjfM?a&_4nnq+Negca0 zLQj2w@(CUH*3cP?RnR5vDh^lB8#_s61*c;DRh-#HQzehKLM>Y*j0|D;FpLq}{e(D* Mi*%A&{j}5f2PVS@3jhEB diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$104.class b/_tests/out_app/com/lanremote/watch/MainActivity$104.class index 1873f4a02d0d8b484835774678d84e7ead32e284..998429d768457397b0b8be06250c4373a8f39cef 100644 GIT binary patch literal 982 zcma)5U279T6g`uTO`2?*G`7}{sxhXzNo|wbY6}rTTZKTXV5Q*WW-}yPlG(DmsrcrT zAE2*3s8mD{ACd<_(^9Lx`!l50`jmg5-l2tvQPPE-nR~f&&bc4+<>&e*0F#*V!NU+W ztV*h^3!7Igm#1#)u2D+O>87}3xaP9y-sy%H`+Vq9;e($c-YGslo}Ogra!aNYPcy`F zoi1bo2q5T#s-g$I4ByEeu2R|V-+YolrsgtR;}c@eRBsuv8#geowusC!LOPU zv+vKT9Fwec@rp3YmP7rY<8H|+s5IB-7yE=6 zL}}DznUY}PupD=KCm#BHaE832bc&HJJ%QxNdnn0ObiXBwVT{%;+Mzz9HHF^Y_*p2@ z3oZE$$_IpQt)edyX`o-)YdF}zP~8ZF!blT_2je(PyO$6r NaGp+5tCx2Aegi312S)$^ delta 271 zcmYL^O-jT-6okL-WTrEnR^#|H8h>U85j1XQF@oS7T)1$p4DOXVfCmtjkOO!IK~Y?| za^(R$fCmvPAo!qP)hp_C@xI(goPYkjy#o`5JuHzU4s_@m%KBXYbUp+#K6Mb8AncO}3hEdvt9{Ff3uR ztj$rh)l2D1T@foZw~DX!pE1=7?opFrRlO@D+^7!-fBsS^m85#YyfXLi_U%964j1l; WSd+)Ho|B8jI%0!uF|Xw|PW20&kshM} diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$105.class b/_tests/out_app/com/lanremote/watch/MainActivity$105.class index bbf00a72999ca13e128faa9e247a9639558ce7a8..d7c4a68df5b50088ca0318c89d804930e7e114e0 100644 GIT binary patch delta 276 zcmYL?O-jU25QM*%yqPzXyoquAk3TaE5j1Xss0c2+g==MSE9&6dBS^>z1U-PDC@x$H zf^I#JSOLL??y4@TyT1Lq%|3r#-+&zkT>?Uf^|WXk^6Fw(9dGv{I&>Y8C&pJ_d6F>Y zNIlc^4MVvE@yU36I+^7Adu3@@->A+Bj$zPzdLIF_&c1x XCuC8cfTfH~#FDVgnpmXcCQkJW`+p!y literal 1620 zcmbVM&2JM&6#tFw#ve=)$26q{+D5?y+xf6@0XrCILLkN9K#QqE!l9b1N6AoUH`-k% zLh7NHa^yx;qJn6p9(yPPCxWQl5aPmL5yXxEfcA}T8lw~?Vx^t;X5Qy--h1=x^~1jb z9KsbpJPbXCHJ_{q)0Xp=E0asYHRh5NLYbq6s}_}eEeIbz@S}~xj{rk-EB)xf)M17; zcTPFc6hm)eD}$Vl5aQSY&JbD@m8dO+W0?$*!d0;-bau1JqH8O2HWxw%I{gT9bRohJ z-cYzOb5$BHMLwoX<(^>h#^R+A7(V3a#zzc0gkea>iDr+a(hPmEtrMG$PiqGsb9{o` zEeZp}pfL?H?0J(|6M3tezSJZ~3I|&<7}go=-%bW4KfYktwNd&;Z=9^EN?F<=q|q6` z5o({8kMF#=@yGhgy%#_KxxV(}tF>h<%yJClC^d>$SdeC!VKBD2&*Gbon(nD=X&mD? z4r;N0Yc*n1m&G=l$SnYy)j3t^=C*QSF?G67FGtREeKD7|g;D0=Ac zLZ0Xa;tmk?675X%KY%we@BqQY3fljoBZjYOZ6gYP6W#H_?N~R>m^~8O6 zy{p(cshL&mdV;-8u7*9!n88QKSl)R2pO zxr&ii@u|1Os~Fcpig|WY=j^HH+=n3L{0oBo7JB$H2KcWS=C?7z@8COr7eDad@Du+X eMY8b0Gms`6m+IEvqEtU)n8Y-3y03ji>H8Oq`mq-P diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$106.class b/_tests/out_app/com/lanremote/watch/MainActivity$106.class index f8cd3cb7df284f59d438571d486418cef8ce281d..9f8f7fe0686693ff28281bc6026f03a482edb4e7 100644 GIT binary patch literal 1620 zcmbVMOK($06#m9G@qQv89qycR zqA7;H{CWXdT_MD=8Jr=sD9TY=3db@TBKd1#QD}6t$%1Pub2b}77rOljbMzp>5NY<*zU@oAl42ggqAYD+Mb zB4Q&OND$8pvt(PUlw4A!S?MMxY%wqM;)YdmiOL@A_2X-feduM_^?6r@P6{q6N6n}* zwWQ81=SLqy&xfc*C6|&#`slP#j{WGTFz3@SUo6)+lNd)Fi8e~Zn$qZ(P|7P)3i~6c z0nOQaIc~KmZ8A{*53g`f-F|d#m-0**H^C<&v~RNTWLdn!(85 zzukX#_rcqh=kM-4db@h-{pzwd4si_Q1PzK9R5zTgJ18mpe$WjXf7-tB!v10g+<9p=j0yU>R ztqWwns6b;xe&EQrUz^NuD(%O4I@Z>~U#ggdt~IjO&wPq3IDSSQXol6H4dlX@7bKbtOtVZW?9Fq3b^>-8rko@iVS4 zbXn$EV>=wE;+uwkpo)Xbh&L94TD(E}$_kD~j#ZJNb&y0A*~qsw zjI^82eqyd+LK~^(xhaj=Q^(woAYp!tAb)~h{uBfJIfnTQjPRGZ%B#4^S8$uxP@o7O bJOgRsak(D-6N>dchAB*wrsvv6l79aI&yun6 delta 410 zcmYk2zfJ-{5Qo2ogM(v11pgi?hzANbC$X`zu#v>dLK7+pUP2-v>cz^!XSmS$7A6{r zg%6;W2^wF(*D%hZF>JCkznT5=+npWhubBPv{qziMvz{bE)Wp!lh%21nq7D`0Fd{pJE!|~NWvRoCRo{F-)gLkMq*AO%j7vtpNi3rO_mbE?F diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$107$1.class deleted file mode 100644 index 29ef5f809af0b63017c9f0a242313c6929b98bf2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3147 zcmbVOZBtW66n>U~3An2mv5H!y6p_#(0bfW&Q9#h}W-Amcw$)2=g{$EPlN-?1)|b|b zRov&(Xk)eY3&H6Ew(fLU}yTpKS9)Q{D97so=rj$8XPjsOzt^*_Uv=cdCuOw z``16e{03khZkmz7P~i6Um3tN4ulD&2wR})9+`Z+kil*1P4Q)U(j%FhhS!T@PFvG%7 zJez+}g}th%f?*|cY8fYoV=g$u+yTW~6bQQAY9PSCy5!tEj`_%Cm?!5B zXu9I<@EuZh25ZYfWk8WN^pv+7eogO*U|Bg9N`DdeZ&UhIiL{90h3QBQe!tJ3gC)o} zV=2cnEN93~pziEGsJaa*X_KaFMh%0h#MYUO6yiK^9JXYDDu=x?i~3K$jn||@8OIuwCn7lP=|*U> zHA#R9j!LXe0;sT4eXG()(bg%~ujkl+s>FKddYQnEG}fm!-j-_0ffF0esOH#&&C}W% z-7E}dEzr={Zyb&EHA31Jj;*Loq(l`_NeyYFWSg-u+tdNgt+pFJzZ&UkJ;ydQB=DVU zCH{uA@DtlIw%H|z+c|c?l?1pU?H;&t(2N!{S~=RVlVRmd8#$=}28$u(q;eR8t+qg8;1gp* z1E2~tZ8!T@hX|Z`y3zOc%tQ1 zksKWVD+gEfs5{AVDtUJ~=)ISrKH@lykCQ?<(>;S-HkSoM42z!bwJQBF zkDPX2(C=0oHJR#yql(MgWp@^N2{gQA8x&A0m>jMD!uep#L&lBKa9<4w7?77L>j+0#oVo5oDK^ zjX;#^Gb;WCGkAdo diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$107.class b/_tests/out_app/com/lanremote/watch/MainActivity$107.class index 324d757da6203f92338581182706f39823601b01..244f8b3da2ec1f78a61217cfda21a1a4b18d1f23 100644 GIT binary patch literal 741 zcma))|7sIK5XZlhi@D~qwP|Yok@};?Yi%Q#vZ=3+BJ{U}t7$XFfZ7GvEJy{RZG6?v_!2=c6ca0VP7Hl3^>B zCe_2l>ejKeYS8M+$UIjzI*RQ3aIO7_P_lz43)_T7XHvqG08=RY2m~q+gzAAjlJ1KA z)@JWOE6cQ}k%{axg154|8{i^ld{hNyQ6nr);u9)TDV=5Eb8Nqeh%?wU&i z^SC?)Sa0)_md*h<&A%#e4fV172j|Y;^>Lk0`~NSta${sK)`a3l(q}c(iHv@e4|_V@ zIX&>w+z_U>tyG6y`R>dcY$tiD^vlTQ&W^Y0zN0hfg;8;mMP|RN?I7t3EV(|^GBwcs zJk}j)wlv%7kRV zl`2KbL!KI`>O-Z9hqezO8)67JS>2BH zF|=jM9Y_leK^Y;08A3ZsF6Nk@YAi9mQx-I0W~se z1ve-JsOGvBMg(;d-jGp`28P-*S#xU-G}R#+ujz*Fj52sTWnEbiSIl5{7F%Btr<{qgjBY!cp;_ta;|t0=dX92x zI~_!v!DDWQ(S>daJu-TcWN0cS@|JB<&iK|$Gsn<=Z0xC|VoO)zD?#)#_}y#^Vt@fc z&lMSipbYlfj>T}Zyr5#yCE^^!FhkhZtR2moRkpMs(xh6mEYk{NgmV4QgC7om`Tp>? zuU|d>_VAnUUOjpu;a!G0H*Iw@IXyS=nW}9&x@m}<8Iy4X<7YWDbVcOMY6Uq{VD3`Z zGIy1=oR%(N9>25_J_x-NGA418TscnuA))qa1#06N#aOpYeLZQ~$tfD=DBu#N89GWu z^S_7^>knfJ?~BKrVYpvjp@5ZOPXtVjPHO*QQ77?^A>KlgQNQmP9srTRrB%|opow}~#(=n_#s(dIVpn+z0-JayI9R!6IJZBm2Na8u9S?bsU8dJWb!FeK8)T*gT zf)#n|1=>>ns6W!_JwRflI!$Gi(qcsIV}J@}E&lWfpS4!p(;0unqD?lEfaw<@NqG<*$jiADMs uA{Oau_zR&M5=Lui-zRP~?n5E!BZOMRI*poXMMPTsG^&0Zfe>kRfByi9pjlb~ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$108.class b/_tests/out_app/com/lanremote/watch/MainActivity$108.class index 4bdfccbf7b1794baecdbf615fd3c0a72867dd789..d468890aac2501aa3321380841e9fc941f4b15d6 100644 GIT binary patch literal 2267 zcma)8TTdHD6#fQZc5QZmm_lMoTbep4HWwR02-rZl6cSPc4G_|h(q>tZEH}4yWm`LQX3or<%bYXc@gINx z^eccq%+(_CZQa392aQUeS@H)lAp z6vO#!r2`qEK~@k#m?5;S=3}n4p_>em?0t1x70T9=3$AUL>)uvH0YBMl>v{Eo5Kyn6 zL2zXuK(p5LFd}G_@S1`qG&3}u%9>lfuWK&ZIAoZHJHp^=k1vIB4p9j$3SLJmLt7=Q z8EREc(;X)^*q0(d``ar!8ZUK7sC`qxTWC8KNb*SFR8<1YnK|!;W+HFsrkn8rUyX+G z4q_56D7c7s|HrxFQw;T*WtwzLONKfxu9(4TFSWiZP9+m#S;KL4Q@1nIM;BCZjXdSl zRz^mgp~l(>qXV51x)gLH$#AZm$QzDDIpbfotUSYoBV$h_m0G$SUzX9s5b&~1#w7*_ zJ(m^q;|e8(GIud& zTRZA%Ue6RUk6v0CAB5gX1yi_2t{f%*fKWS7h1z&lHP>v*SW8+?@;Z(46mSXC3>V8q z^S_7^>j~pJ-WQKK%W$u6x_r-MW@tuO0_M^Arv`V{4VAT zrs-X1d|ARG9Xv&y<0Xc>m8~xmkm?L4vnB}q=mBR9Q=cttuIl!pu!x{_!B{s{w_wxQ zXBX(P@C8@ZHt3;wJ0yJcFPJ<`1PLG0M$e7o$3)RzunM-OPZ>h)nc`S=i=Cu>($w;n zLy$eAyE$u3fhIthJ39u|i$&`QQ>`DE*XlU=>#nZ>W_f~ZBzgv zU|4{^p+OW9fGAZ7k5XOmDAg}2X*JZEaEs^?je3a&h_)m;_P`RIdyo=cd#FuB{d=fO zyug{Kw90Us`XEv8c6>fKOoMKJesEcyWqr1M}}yY z-9=+MAXcNIuieLJwzWUdkq&mHrKmqG6SFQIIz0Z~&$xak(G-YEQ8^lXfg8^;bNeZI z5`m2ZM#wMW=`#5R-hmq42|w>bh<78xQ%Lb%4Dw4D=6x9BmvNKmE#8kDAArq=P~d6o z@C-iZBlv<}#X~-dM|=#A`8dAh6Zny@lWfpS4!*(+f)Z*Z+-20fZ$(U3X!r`gGK=&t uL@d(R{1-yw5=QE2-zQ!)?m;E$CxlwX8jV_LMMPTsbgF*q!4PTne*Xa8_ghi` literal 1946 zcma)7?NS?67=BI?*bsJY2vM-Dm12tdAPv~o(gvz+Boqu_Nu`o1bh9~zh0ShecY}Hf z&UgX((-|Fp|35L-^w+mTe zAt<8{A%^}P)ri%&lDs~cpa?rF+06uidJCo~PU%ngP}Q;%p>Gey_d z&1yD;Fa{(YA7WP`ZD5E^-Y_{*Q) z?!Nf(I{|r9#uRQb1YN6XlOY;!hoBl6LsrH#W~c}p*JhaO?qa*s9gu_gfT4%mwq*x# zmqBild;Zhji>E(}TFc4!2(!fexQcFyBAR-oB2vwYZRwSaXt*THq@HZm4<1mWrJrE zB?&7GgIK(;31T0}Sru=R=_X%l)XLm`D4eJHRn)7d>NafJ`|Sp`nNoCBZJXwt zry=199eLbNOh_odG|5hiJ5;n9w#M_iz#V8!`BibB=IE@c8J0tHagn>5Rz*e?n-p1x z8ZKwmYLq&f?A>c$I3&-TCbx5j>NwoN2+i&=7#_hN4vP^EkfZ_aN%~^YH1pAK5Njk$ zv>PWGAQ?@j_Q8_l`;e09ee@>}ac+;!86MNxOA<;6pVQulDE<4wTLr=&^j07k$){jP zc%6h4#f1aBRY*nN@gL#J%*7utnu?^JA-#5p3@P*fR7*TB3uwJ#MRUhihF_&Fh3iLZ#1?Vz-t(iFf8MBoMss5Mw*za znnpY$lj$_MnTvM!GuBaMr4h)ip>xyC^ia&{x%xu@=Wt%ah>Qyu{U1K9HyM1IYEURH zL{G$QA$H=xj|hXu+6-U}Z%K&Ch#}4}+{xty$1(}JWLX4#@g#J6)QhpAALDexq~efp ziDBqC@XgHJsOv_V+W{moAeIRkm+>~GL#@@gS!PHcnrY4Ep${!bhcFpGt}p~fp6&ng z=XcxBfBIfPzAIx2RD^!lYT9H7N82H&M#hklF^w520>`x(=DNGs?sNxaKi+5P;kIqr zeq3jeo8+GTyz~6YV^M2a86RSnm>*WrlqjO9mntIFEZde|PFYUsChZMwXC%xqjCD%j z$Fx$aJ7?N`E{c}PV^PAAjAeXGJ=o5R>Y!#h)M`VmDP5_Xrdl$1 zCRUWN!Z6sV5Zy}6FV8;Ic+J%^w>A;INGO<+&pAzxo z3O%Csa{b%{{ckw62PsDEeOasAm}I)im+I9Lw{HvQX?_*-imAGFoAzG2PHmH_o;OWyXARYHxPuXz-9a$ifj1ZwBOD+}1G*vUi$T-OL%%_+ zku1_~oTQIrIG)%Ai;wR@iYIr`AK%BR9Xe;YOKUGlC^dXWdk@0&@AJkg5dNUI0>Ows z0Xx8}B&0CT?BR`kBJ`&B02gP@{)o{;DDf1@wSA;Wp??qQ&?GH;c;^6Dx6z*nT?>6c z+odMTji<=1#gk|EadQtJ-PuMkFJ6eNi31dN1Rr8{54VVqU>Fy^DlS4w9jBEooKqfP zMtO*w@(6d8Z*Wif3ip+-v8jAZw}3P7{e>9`_t~5T^&bp_Nd!g9Eg4>Dq~bvtoTQJc R|4Zn!3(=VfpLlXj@h>U54AKAq delta 559 zcmZutOHUI~6#nio(`kpP)nZe8fGxI4i_$3|MJqml5=m%`8X+#nX42F&GtJD1;Kn8E z$u$NEON~oHLaGsQOX5OT{saT8_yroyjCJQt?m6c>&+nY|*j_~W_UH3g0Ar|liW!lX z^k&9jNbnzG!h6$5_dL^=^g_cb%-ydn*Nm3iuuKM}Zn#y$arC;uzYEFUFwQfm`@7!$ z&c@;Ew+EY>GWr>k+!K;91${W>i?oUXT;wgGtYxW3>esuigYEZ6U)KHLWfg-s%Mf<0 zN3Pv4>k9H1l5s`FRSbK-vnLe2R->7j5lv$#aVF)eP2H>){-&363t><|?zHvGM^ubr zm_~Ifi<&0m8bdr-Dky&V+%)y4OX@*G4a@>k-TQ);} zknf7QGqmiSX&Ux)Q+FK0K?(v+f?C;wvacrSvLW&#>f$=t0!|aU5y%qZ zRdkULSjcZfZX>aWaQxf{C>=OYn(d(P$wyouv%8H{JVRoLejArQA@`qz!oOf~s~U{- xNyH%%i5EylmoX7t!Q<#ll*unch|S8lNjz=^$+vNbv`ELhiy5*M;_EY{cML@4ep>(l diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$11.class b/_tests/out_app/com/lanremote/watch/MainActivity$11.class index 50239a22689e55a5c12967a1256e15b9ad24331a..4b6694a0c42fab260d66218ac73baeaf71516b8e 100644 GIT binary patch delta 476 zcmZWl%TB^j5Is{KwH0}(KzV3UP%J2#K;puk3qxYUj+GlCO(bAzfM8tV(k6H^YR9ufup&4tt?Hu!_t*Kp3G+(XHkM* z-`JxGfm#YA#3M*RB`88Tu?MCl*wqNu1S<=6VBV=nAuS^lfrjjSQHthKkg*ZLCUk=C z|YMf`{RjenIq^tVhZe!P&^I=D!{mcsE@plkGz48A%yuuB)*#8 zxy{)z*MilJss}P^9%Q31^RR4aK@TxQ3wcNy3#>n}h*07fW`y*KGUpPqTCQC$4Hud~ zG$#51eufEthViu;H%>Bh?wj0q=gqD7AV}|DFK+-^uzcxsRvdL*qiw8FObPCoR$OE-yIyZNaC*&= zV_!D+=`i-Z&cHhkURldb4kEqdI!Ap5t=jqL-mV=43g3*GP=j4=vTAU~wQKb!BkJoo z=NGnT4^alEAsKYUG8rTw0mQjq&Oo4Va@qRJZnL{Z{RJi_ z-V$FlK5Bdlta+n=vqZ!IkeILXkn%ifeX*-*7AH%0WK(^*bS=))P(B-iU) zuQCiK_Ji-|+lI;cahBA@e!N@WT6zEL^BV{9yo7|4ir+SxoFY=_7cd}U5JL>HgX%(v zvZ7GB@!<>=awKtZC0l4N`$30=ZEcQ*VF?!;0Xyi~Oh&*ZD&p=X-EQc*T+!&33;)f! zr&U@>Vem~ERYDCFRGr^yEL6C8r^yp2sWn};8z${~&4$hpEZMShzbMz;2m#mrX`<$p zQW7PjVJduDb;Nf5JqJThIz*M%70s}yC5zmiGpZ6MF-6^5H!M4Enuf`cYEkQ^{5?4( z%hX&my3WlhO|~pGwh+a3zx}`kT*cqM>d-3Uj<7I ztU^c)uA(Egj?gmwXBefmoh*V+aD{viqV#puT^)qS@Vc1g)Cz<(bbo_Ca{L3t?au;P zwt?Pz>o`Sj#~NahI4%A3uHoEABwGZecQ&(*29K*D;^+)MMNjYm*IjAKPO&f&c&j literal 957 zcma)5U279T6g|_V+0ACtrdzA^JI1u;!!}8+zC;8GEml$$HUy#%ve^vH#?5ZnomBf1 zL{NMc`~$uTQH!9y`#%(ag?Kj&7)u~N?99D)@0@$)+&kZYfBp(!6E~umK}bSa#VjHW zY18c%Y{T(**A=|*)DUL7uxnV(ZBtnLmN-b4N=1fvOq206`OWa}zWwLbzbC{QqP_Y2*pLAV#mb1qoS^ieHCk}@B4`rMn z!|b+XoMezq+v+{`xsb8U5ElHIP;drkC7e@n9v2vvClS8uyAGka%??SBc~m{lv79{$ z^@U}V9KEuPBt!Ja^LM`ngP(7|DM;b6gtUqktTH4g)fiWiA!e8+_x<$7W|3sv&P>-6r>HM$@K< z-M(k?Z7VpwFfPn`pga}Xa!lLxsolF=wB449Yr*U+ z_B@w5U!fj1UBheD+y^Fuy5l(9tJsF`bARamy&jS@-!d4kfF%;ajMLc9&=*6NqB*is z|;CMu-*N<6?k=bpP<(Ljl))ON|4f-9f3jKR1a$cLj+N6)LRwb2?Rz6a^Rc<8b*;+ zF{a}cUK2(_qQP*N7RLorgx+80-+p&G&z~POOkh&Ql#Xf45Kei#x2@_jBK_|FGS)Mtuq1DM zT!u~5@ zr8$0!H#dyO5Cwbzi35ItYJ5f3xW)o&5-7_i--QZT7p^qOWTPx|CI zUY=3joIzl8)OY^}j=#e3q|^aRow_qgc2CZp^PTg3yWf9(`U;>6H}@cb3JH}8s!+`k zHk^gHrQ0rFa0HJ((uFY>pVUoz$PnhDDISLti3CHnm@~a_2SZD;+y+fX5PKxZ3TmJ* z)XnRQx}Rb;K9!#5hM;KurfrG=hKfjZT1GAQN~lv%j}Svsx%dnm=^Umt5p#dG%mc9(S&9REecK|{EymqqGYHvbjxCBjueF)FU}Ff zDe@M4NdysLfL};VK^x8xR8P1JSBqop0G<-AY0qlAErm3BItxxdf$a+7ATs0vH9bBv zz5m6GZf9J_%)}ipevRf9cQpy;7*3UHb+IzT2^k4=`BLj<(8?$DeCG;t{ z0F5EE8y2-Q>v$yJhC<`=O}Le_Z9Q#qEqYhNMTUKO_Dv@~HZ`(j@T@Q$JBUFBW%JpK z&8I6rS66?${pR;Xs}`M>FhZql>+ug*hSvW`6P>1# zhn);(*qbzMem%F4=I(93TGEi4BhjlVp&JiL(*>)Xa&oT0M@`?QekWV)J_-snVjGs@ zk+>$gm~%1;ru<8y_5r7hhmK-4?dWcXx;19o+#R-b&*L7Qk^o&Q3|GMF>imm~PEUZI z8jOT5*QH|SU2V^CejCG{P~)fJ4Bzl6XO3H>smnV@9=NlenWk_2)c PH)s|rR7O*hdVc%{yjF{= diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$112$1.class index 0c22a1c4a0e48a64f59b1f939e63e7f9aac808af..2c1d477bfde6552723ca897e8ae86bab9a341e37 100644 GIT binary patch delta 493 zcmZWlO-lk%6g|(ev~h+eE44t9urK>?%#Tu+Z4_D+w4Y*#YM6y%)~-#9TFSKOgML75 z!cZ%N+O;V91?_^g{zP=g3ZX8%ckbbyd(VCE$@SvoA3tk%0AffN`Mlp*P_$rP-Oe+( z=hu~j64n%bHN2S5t?8@96FI=cjcx~e1oXngApO&PZ1wpX2bK5T;@kfDd%0Xcy{{i$ zG|p;`<6U!)ZDn`_Ff3pMK?awyvZ5MBFdmgAVzvdoY*2e`DE z;ROHKk^^Ddx3$W`rmic?n#zzS)u8#zxh$pm%H>iqNDM+&*VWvdrWl55kcABb7^Yy8 zBy@lW2;0z3|6-%l37IfrKG*|0)5P0J3-J=sW?dpiL+OqX-vDP-kqDLHsG{o#5@oCm zuiJl(fzl7Ls@E9$EscKjP^mf5^60nR#R_<_3XgaNuXuwYv6e(!yhWS@WaFhP(d?MP VETJ7fB$1-bP5b%?t*~A|@ddXEa2o&s delta 789 zcmZ8eOHUI~6#nj1J7qc}r4@w*3QcKC3%1o(#ri1L7f@s3LW8@ZOtfUu#!i747r1Z% z=%$V_8WXCCE?oHF6~G$ZnD{_lvN7(oEpg#LFrH}<6K^v2`|i2lJ>NNJzFJnS!pGk) zUjqo^bY94{2&1y<8qK7VipF3|49lZ(U?i7P1BsND%ij;~u)+=pkF6pav5jHNe~O=D zUQhYBm=o!u| zY)mq$7dU5o{8A>PDOpOu<1pD*$58F#>_|E-52^}7Hznw*+jyw2a#~XyHX2KBI;~{m zs;p^>#-qPhX509BQU-pVQEePVFdPDBv!MkNe$dT2KjLVAm%#5vgLM77W!qw@saau^5tee>X#(ZFyIHsWj^+wCn&*f~=~ z`@kYR3BTRD3?GN{{+e_b(Z9mo{vyHyWV?3>-G(z)b#}r+&hH>f@6jZEK(n-hHt7?5 y(q}}ZFX)q2ag5Y46Ie4falHES1WpoeLK9A*mzcB4iO@1yH&(a?=%Ya;%)%dA>ctHJ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$112.class b/_tests/out_app/com/lanremote/watch/MainActivity$112.class index 3a6586d64a9fa9b80708dd98f77163ce32da9167..65bd96f96b1788db0518c9f617aef32a3d71f018 100644 GIT binary patch delta 548 zcmYjNO=}ZT6g~INBpK!-ghZ(jXtflZCbiAfRAUlNHQL%Xt%{(ykQ(C{3n5Bo7Vf$h z7aAXkE?l@0#D$fh!D8xGg`z*AB5wQ#+WWAA5AHkfo_9Ita5tWF9z+^R-VeTQ%YmAFB z6dGQOkM=OOlT}VSwli0kZiOq+`*!q>UFLkH55F3Jv*9vWUnw@m* zMu`Kul&-oge$WKPLTgtFu3H7sg`j_-i|R}xIB@51&)j?NxpQpn8T#SB-R}Tm7%%Hb zo@g>0CK&BrCEoH3E8j$Pz4YEL&d$u)4o{G2C3Mc$$s3>RKkMjdKsSXS0Rp2?EY3R4 z<5)sUM7dY-b^5Khg;K#>n78Ll%Xa4bWJ3?d6NCu#yM{RbsANc(Zz@4*;wgoJC_%F3 zf%1CQXuB8iPw42yGdJQC2_(6xCbT|+W`3bIhdf9V(5vOj<;vRi#`e|HmYkQN7{v2? zgS`=H(7M}^AFDw!#Q&Oa`UcN21F{#9#jc>&Vch&s5$A1NXX41-{VpJ@*t zjf&hQGO&GdkAcb;!L5pVRbY;{*ZDqG(UlMEfK(COgESk Tn%LaJYj}YsK}zrPm^HWsU{-0$ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$113.class b/_tests/out_app/com/lanremote/watch/MainActivity$113.class index 1e8a11aad903aa69f87fb2b62c2c24eae6164816..8a8e5ccb069e4be087ede70133c6fce64b538e25 100644 GIT binary patch delta 536 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 aQQ=RmGthXV3s_`iab|pTyvnRT(2c)K{%9Zo diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$116.class b/_tests/out_app/com/lanremote/watch/MainActivity$116.class index d49a92f402c5f60d379b8345defc5b154668169e..34d10cdbd3f944cbaefcee2f55cbe4d3f7783c93 100644 GIT binary patch literal 945 zcma)5%Wl(95Ir|ZW5;pR)KDnz5ZsanZIVK}2|_|uD~eKBxC%-u7LIe(I3#vt-xTr- zELpN)#j-+(SA_&0z=u@u6NqsdiBLrf3(uVy&*RLwbASB)`W?U>ti=#QSVBa_6rv2e z<#q~f)A4x66}<4=6jrmaZQ9O#OV|gt==z!_Tig;NI4WBgHrSGUgc~g1-<7F5;4e%POwmD#QFZtPg$Hp**+VqUkexmDBLb*%Mb!*ll8> zTb7YxP=CHV{PpI;@vFn*mv0rMaYKTxVgZW`sc~&aIb?{Nmc@NvUs){@iJO_pES>@} z0`r8Ha<(cV!>}}|xxVFed*XrVG}_!_Son|L*(&k5?lw5XOvQHiqh6=Zy&bdOCXeJg zgNh7unQ^EXrVU|Q&xpEVhGMuq&*GbQkUl#~%yK|JHQ8{iw(HYnZ*$Rf8!B!Gn-lAL zF5SCf(#@+|j>En6w(0xaADDlKLW*`;2E$FTWHQ)mTJ;cpab(GxBP~sK;8Akp{WLpkM-FD!2pZDVFI^n7WcE41$U%(HS9BEA%7*ZHSIknjwwVsPYFx Ch3ljM literal 911 zcmbVK+invv5Is&pmTZ?~8!qME&~BRx6`P8Ri-M3)04Y*Rk%IE>IBw#yv87!n6`zIF z3d92+z(*m*@K8h^BC(P^i?Vs=kcWje* zU{j?h9UlR%cqj*`AY=$H5{^bEQdnYsp|r9u8S;(h&_@+lJzNWL9XA+itF$nzaUo=u zMbDqLNqw)eTK=-gXg{~P(hDu*)`EEWN$@BvsB@`(Q)I@VTPs#VpA3}zmMt_C4PS2I~2WFlM z`Bu47>wh=?k?RE!zR@Bv88z!r+Nl`_*ha(<(3Ou>I(u(U-M#9KUc}LV}!~!sEAQy&i(g3nF&@|&YS{2+Pdz-vE*}RLi9-f1>_Rit8 zzF_S$g&6M8UL=ch8+XaiA*9ni+^5}v_Xz61Ve5>rX9T7773&vh-_eyHqf$Pg%+235 aYIs1lK%=^a9rCJ_<3zb{1G{JxOTPfwOX`FG diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$117$1.class deleted file mode 100644 index f55d673a98f71366d335a9a34881463a6923452b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2000 zcma)7Yj4wL6n@@ZYB#uUYUrShZH0vrC(sZsEnpafu9uXg-5M51U}KKsrEVR&vYl>2 z>i)p?Vfzo0_8EywG{kx=k}b7|M>T}-vL~KGl~$x z0wNNk7-5((+(yRM9nWmIftmS64~)%BLARVcMquq)!FP#lc0Q417zs8lpEEQ0!>DN7 zk0@ajF@__%x}6}chVIlDl>C;yt8=IIOfm2*r>?~yVN8H5VH|OW`2L0U>XvB)4R zSiwgO;iOs`g@U63j!BrrI}F_P&>Y@oaFOB6& z%hxZGi>2gnAL_s>8|^|Y*p}%8nuzx)W1~?MF-?rubVQtC5V}~#kic01GZM}r$#7y& zl&ik$kg8kVBA}9wda&<6tEnAPGq7wb{Eil-J^|R1pJF(V3j$^(WI!nm#Gp4?hKQlt zHpA)U0h6l-eTcZk5OTM90#_tl#T+SDU6+*49#rm$xZ5oiwIbpg71R%EZZi;Zg8|&s zO$oQad&CbshUMXw`fYb3?QKpGA9sA2o+siiPt&cL+}|e>7O_P2u$;iGo8U z+tk#GfHK3;_KL9FOm2PgTf^K5ESF~E5f$pMpWpoX`WKGt7ZSe2ll{@Wn&rUehi9qv zN8XYA-=C(I1n88+{W&^X zJ>tiTfo^Qm%y!RWMYri0=90xPPV`oLmPbhD79GQOecHGMGuU)%61G7jC%8_|*=Z8w zlf964r;Udr33xb02-Qgt!X_-D X5&F1op+Ve9I#G!7hvUkhLWleZ2VoLU diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$117.class b/_tests/out_app/com/lanremote/watch/MainActivity$117.class index 02d0ecb3fcf5bc7a7b56cee772edcd20c64b24ab..6ff563f16ea7ca91071a24f1247fbbec8226a7a3 100644 GIT binary patch delta 464 zcmZ8e$w~u35Ph9VW}0yfGRA$2YfPdhE^*6UP+SfJ!r(!Yn8<`|#8&jA;6cQLgt-YK z9t80wh=`!z)j#kDT)sgNYxE*Ebn)tSy;oHAK7Ucv=db%m0ArZ0s8918J_ae3U*o7} zkPAg&Wp+3k89c%gxim*JgZ6rQ@qTdidU*7He#z0w;1=m^p`slf3j7*6(Z$ecSD0^M zZlhQr$6_+e(9^#%pG|BfBDq9iJ+hR{rc)vw{G~R9m5VGSN^y=JYW%Tx@^*Hiq7MND z{ThM@G5E>RU-?v(kytd!Fk>IHu2B4+{!a%(l&!*gJh-f2fMJl-o%XEjMxTI2LzK$J zd`T?+OlWITpdk!aQv(lVT8E2n6$ZgDWY0-0yAjG|%HT~>jAGb%A#RoGo!as6Evip2 z9J(PB`2pON^jgDo>ve|d(Hjg?)|(7d(OV3YhmAY5>D_ney(Xm#j<**Qg)|gz1~IRN j5#l7U7==-Sy)OEo7y&nJWE_)(bW%E;xCfTCVM delta 453 zcma)3%SyvQ6g`tZQj;2s_F-GK4`bgotqM{Cih`S}3l#)6LJXl)YFg6=?p*i3N`}P4~1M5TKGaw*{5Rb4Q5zI3rhx^G{ z22s;>quZ5hwMvDdo*UNF<@~{=HqexQuh~-cwyF0m%P@P&-i@L6du{8)um(-tPlF!08Bq`?r$O+2+U zv!NXKO;fvQ8EXEN#|lH4JlG-bai#)4!&Jv=Uv+vUvVXX2L} zbH#6SlzSBE5?+S$x9uJ=~+>Vc`Mn1#|C+o)t>Zh`(Zq!-zB-oGxLy z0ci`ov<;uM13}tFLTaE$oD0lHp)?kku}bKszfh2<(lJJqNqsL~Eq__$a-=o%aFZe7dYlAAB0z|1ot z-zryX{qM#q-Cs literal 1504 zcma)6TTj$b5dO|`=~7C;6$GycEVvhBSpm5eL0Lh-T~O9w(5KR^P%JIBUC>7pFTcRV zH{+9!hNyrN_1)iLPCm1f>i? z&0dU|s^#d5wx`EdR8O0arBuVZrg_G);XMh)<2?+e-n`)k+Zh^@hiy0R(XoK*yQYju6Y!#->?jCfT1W9o)#gaQb3i2V+b&uKP)~&xvFWp>jpb|+X>M~ z=x|8keO;!uYfhMkZh49y)r3b|%=mGFq1dn<*dpq1Qotz*r*Vd%Za-w>u5Hmidv<}i z4Bbx_7ORAJOj*w`iPt5igxWw?-)j-)(IB8vLKA`q)SZuzKxwLJGBkt=LXH>aC`St} z2nb0CBf?Oz4Qdwwp`FvI2`#xWd7aq>n{VPIv`V;$Hii<_r4ksL4&J((g;XG;T|yj} z7{pw|x_Q5Pj?^#J$~d->iP>&!l-8#1C;~bO^?pq#)N3#F*iOkx3My9fsrOukV622#3km2|?|ArkKn;d?u=}VqrTYg+) zkhVT-Zf(B#`Tgy$r|&so2?^IRyr=N4IA<(*1jh1&m-ZaPn#+~*Z&oEdE#L-~vM1AB zunf&dNE4o>k`s1DXQ)gXmOhbPoYkE>yjs$glciaxNPDXGkaUu>ifKFRX!?l3U5@3q zs*R(dK*N@1+Ad*E>E67Zk#L)z8-91*UKyh?B%a1BOLr2c>bkm1W81qDZL?^^t=fv=l_bL6w$wl$VadZRFr&$ zf2JsKJUvq!s7Y_2wi;*GP@jwjYCfachYdtiJFc-!bj(Cr*U`0xp1bcLC%HXvHM)VJ zRh|ih7{UOd;knO2pxjHSmmeW2I~bH-U`T$3g!~*S`6ZI%L+A5v3<($z2~vs-K1^Ve U-o>PkTbQC%AXgqON#@(vUmDzuEljP7gkwe~qX`uj%p}6OcT^(Y_ zdDPz%ToKxBPkQPOL%LeqGGSxVK+(b!&i-fKQ-LRU7z{7m7>?9_Z2#QEG-eFUTA0Im zhEftA2FvAKgkh!Cq`~l@nyjJLiCIU=8#vsTJPHCKm9z0m@JRVVPXuF+i?)FUhUFv% zI)#;%iFsVqIl08pPNtxp+D9zn+#MyOUE3;P5hVjxER=DTe&A_{QVvB-T`HtPk8U6k&Y)CkiF%kQ^wpV8x>D5^9_jeFZGj?KRq!>8x+edgC)#{Sd|h zofxV#XK97~n&ukn$M%m2B~5Ro#urR{K<1#)t$zahigQ0O|E#`phzs9vIR%Z9icx0C z1NIvj_FLracQEbuShPQ)K^AGS98D{+@haA6%}`G`XwhzgUNkj5=8qNS2z(X|h$RO(?=iw3@h9FMH(TFQ8#)%Qp z2X5kFw9ln_78^O0=3~%oK4x&tP^LkHVY9Qyp=Bd1dB~%{P@3{lpvC)%NX=f-7YwXj zfQKtCiXKYv8T=zY|<-L$+Ew%;PGqxw!7(29_^MSo9@-CF8e3 z2W=z9{4Fndn%&ke01HFNs|{F`Dgj6_1iss=>Z6!=&$_hLBa-Ry|z+9?~QnxiqypehV=!Vp-N|ib!II>g;eH&VGSAIw_*bbNz)|r4E-gn z6IRH&PnfkQVf_RwTs?sse#7Ojbi}Yht3!z5Cv1|Pfls?F1hiIYzeBE;&JT~l>c=P( vy5Dj0+`C}!i=R;{exbMbPC2$ID3BwqRZ%Bv<&1?)Qu7XWDd9E3$jSc!1l{A@ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$12.class b/_tests/out_app/com/lanremote/watch/MainActivity$12.class index 85330c6959490efdb4acb12c01d8a7fffe649998..b2b6cfd48fdd7874be0df381fdca021517e438bc 100644 GIT binary patch delta 456 zcmZWlJx{_=6g}^Ot+Z93d?@%WMWI3%#5geGLPFx^;8x+OjZI0Y!I9mK^cT3hkU%sH z{s1R8e}%h=#&}y37hdx2Iq&71bKiX*i(^T7{h7T0sKXp7_k|cZG!08o7-9pTcWVP%+||yg z7^iN#=U(|9uNT!`$eWJcxnanb+H>kg`P_f!N5djg49UK@>^dFqIj@*#QI?)PY zlrU``Ou@{`6tY>JLN%YTJf?ew4XP3$_$M}rbEN3BMa@{4P(>sRO_~!5n2>)0^%?2t nz{D3H zeelf(pS?u{P4ox&Kbpv+iI+FySxN$Gld_wgZ|2OIbG~!toc;3q<7WWZa5D-GAst}@ zClFznu-v9;bH@`+R|)eWS60JZ=F+)iDcO>0D^-|dh^U72Q+bB*axeXbz(JpZC}IqH zi`%IUvBi)quk#iUJk`yL@}yH=h{3=~9Rmj9NH8Q06;`V2!cye+mUN`L%@E3DS7S(G zNXIDy!#K?_){6*3KesI5`>8@cPt=z(y}4(*sw|3@v_wU@o*>d^aZbm10~c_SVYC~I zBl-+RklB{#$QXt*|0kjo+lYZtj2&T3v>7Ik!+Nz3<7B$M^S1r?`QEb^dyk&9Uw!!c z@^$;u(;#gr1Cy9yh#e$kF0ed*49nfP>c}vd+^Ko4teKYUDB&n`QF7a@muTUwaZ99D zoT4qQjk5Gke0&9?f;Pb8J8E9xfJ2*;NXu z;%<7DxGMuwgGUQ@HmE0Zxa3&2>r=rk3)OII2Ig>;VTg#YNw0bTuiHs?xt(q~pldBT z6jssZzAtebP@Fgn$Bn4yCDh3$R&=QHDV*o)|_~!WJ zljGn8P)2?7GX4%neu8g~d!P)p!a!z{Y&Ls#zy0<*=f|H9p8)hASpp9V1QbdrLNSA1 zvzH>KYNhoh+tnkhs;ezT5~^VhYp$_kxR3muT^$U??tD=2Er+!!q1KhJrwFMud!V0TmJ|;bUkxDnCQ1s%g68_&J zXh^{WL#B6nj+%yUxr!InghyLSd2xcF(6H{?BIDcZP7Y=ZjrbQ z+>7Tos|0sl8P_n0*JY)M#z0s9QxOel6woB08U921&P7O|G}Sa28UuMLC-Q5Qq7~-_ z1SA9zVkp}IwTFPv&g(RU)@+)b$?Sp6b#W5fBwRo{Ly_uG3k=PNFWsv`Dv;43p%YyU zVs>DCd|rLWnwM&&(zcO`*iK}eY*SAw0(uDbK|{#btG`o3H!gAkUOHAmE^;N0Rj$m- z=og?!7{C<<-~X6sCd;-%WY*>~GM%w3bXukhY3&RF~yjO7R~>81^9KHJW}Rh8h3fa}!CzD)PP zGPE3{OmK!;j@l`mp*(I_`ebHlPEX(F-IA`-8QKeFNmtb#kWR8*F==Pgnm%UmkjmUq zwR04dXw=e7+ab&e-CeL#5^nKx!|N>At1;S!#8b?&^mNoz9Y=R)+j!`Np^Xn#QNd3! z+D;z2OEFH~7+Fp9%{^i${0=O%0bz~47$)dhOmE09>3IWEHvS1^DWHFop|@b0C@cC1 z?`(muDmh!|t4VI6wi;*GQ6CTcYTl#8gH41JyP=USbkBy`Hqg6{zB?}<$GP8kHN1(D zHC_or7{MT+;kC~}pxSGwmtP|+zrm3F4I}awMCGqY$lnpC7&@PSV?@B9NRSfb@L&>C U^e!ZQ+{83lzHEDBlFWCmzg>=w(EtDd literal 723 zcmah{U279T6g`uT*=D;Y+cs9MRb#u=bp1#oJ_r>-ib5b&Fw*zQbV#P|&g$-L#Q!7c ztB|1h;1BReiFcBR2nt!)Id}KYy>rez^XvDIp8y`ARYeJoi?WXks4#>gP1-TnnM_h6 z+aI_Q?}MF=Lg76zug^6&%?U)_hdqG1MnK4m0__khwWd&m;ru z*}U)LB5Dk4HlL`35}W;VKH-+js68;5(xW2}0XAH0`q)B)Ay{sBH9VKX5U(dnEAy1W zX&s(=*v4fSSA1N>4nuPlQwE<4A@e-!cDfxB_qerMUGHC-ho>%fNx+ic>q2Wjj7e2_ zp}O8zTD~ljq0HVa64ikkX>N*)HaS_L8N5MSWJ11Bwr=BZ4<6XgG|cB(#A!~GIgw_Z zp7}VyZJOabCEhNowp;(DM?!mA%j`Jjc`kD_$$WEz{|q;1tq`L22{$P&p+R5G`MT)f*err*N)4t| nKgIfYTv}Fp_RQL6)N5bh;MTmy9`*^#WNe5#6nWIcGNb1YhSs9E diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$121.class b/_tests/out_app/com/lanremote/watch/MainActivity$121.class index 011dbc1725f3d8bcf6d824613bc244fbb0713136..4dfa4fd7e6d958378bb24674a62be584929994f8 100644 GIT binary patch literal 922 zcma)4ZEq4m5PlZ$uHXrWFI8)6X?s>UY7dNwA224S(S(Fr6SUu#V*^`ZPxls#Kg*h7bq_bNd{M)qFZnAO zzZE)Y8!@KLiIxoI4p)7h$bO`RiQZ`0D&Q7wyIApX2dfN~Mbj{NJdQ=02F*sZK?FRm zE@t4bs_ojLi@OZl|13Ax{DHDlP~d-5Ay9_gjMY*{D)DkM?g{;x_eR8XzAFccn~5gh zNzPzv7r@SSVCidFf!;z`T}NX(%E928AYg&$~-WvA>;d2Yyer(G|4DO3z$|CXhUc#&{Px8nlcu32!@6RJFPW;NCyJ5qc|+!F%d`5$k14fAA=|-5{hL7BJCaR3?;^b7oF%5&@JK&q6~)$ zz1py4=nSs7VQ36Tw{P6Flz-iHaN2{X=!8zGAmC|+y1m%R#$jJZ)sl+oMGxxu&?llF z&r(Fm=`_(}I9`C*Hiw*W-Zn#Q1}R4x?|=CA)>}8Yz4Ic5FiatC8`%pPYFqNpM>+kN zh!I?%Ahv9i;aqWk`HTx#_TYI2w_=)x>A{$T?(Xt~l@EBDToiE$6ZDkZq1DMFi=MKO z^K)xRG`Ji8h7~?fIj$*YRKSZ2$BLch^(PSVViMCl^k*2N#V_QCq6O$uvcH6v1-v5S zRmhZN1jVUId$8#F(}qPwqAvF($1=Ju$2BDyoaT;5h)6=&%dyiD?ul4&UPUAd4+#TD}+_XJhkadlRg z?TktDQZqxTRyl6Vi3LhiM?-)?smklz>$K%C*-1VDR0DDf^G>XL$Bm4cPzF_QzdARB zPVn=DVn9!5hD9A=M6u_Lq=>7yMo=?QLmbj|#q8H)%Tg>fQtLPf zhS%Ws`FO7aNK!9yB()hwQfF}_wMr^`CG^!}j-*PnE|Pq;G1R&W7HV6C5IVk!icr04 z6;+{n_bU9MbsShG9fo-t%Sb}{8VfWpK?D8MoRO@t8rtQ>^-gGojIBV}z$3BNFHpIO zrYZm9>p1!u-`PM5i3<9vT5Hj^h7%)g{!^tJc(Uj4`)F?SpWQ^y)H2cu_XPDo6@g%NBRx7r9Y4&8%=Qki3S1s tU+5K(W-|iJf5^W~Aj32?i718bxGIB1k}gWTE4WTGKWXsC;J?YN_!lfDZ_xk% diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$122.class b/_tests/out_app/com/lanremote/watch/MainActivity$122.class index 5536ccfee77333f4e215738ab08045eb700b483b..c21ba4b0ce1aa7bd69ba458e98e6c405e1688103 100644 GIT binary patch literal 723 zcmah{U279T6g`uT*=D;Y+cs9MRbyQ>T|bfrAB2h^MIn$@Fw*zQbV#P|&g$-L#GfT3 zC_eZD{2zk;3Gq(y5J4deJLm4+xp&UFXTJab`VGKiw5lkD*mmUf2X)Uv(nCH37(If`~FkFKZ1lA~kkPMk4-3z)ZC3?58 zLwKE{2ZUw9Ms$A$7VXa9Mh|CLk3OURiT*R(ptVAX+7H~MxP%6MHRtQ1gJZJ@qA4|) pQvDR`UvOzz?b$PHKT)s!f`eQ09(%Y=SSDjb+@r{&9+nwBe*n9wqkaGY literal 2140 zcma)7>r)$56#w14ZU_rY(_+ELl(s1*yu$mnlv*j2V1P<^NKxyu*^;H3UCb`6`c)n6 zKcF8w;*9u3XX+@V*80^M{|)^_#yJ=N7R@p;v-u4=Av*KpsD^!4{M1l)|_M0y!6Bx@-s zAp}v!5t2|3iJ@s*y{ifrTk*xsZC!K8+Js>l?j(aZ8e0va5i&=Ugi{DJwAHfDP_Js5 z?l_Twv0jR3CR#hBSk)^_Ma)yCp8-$7$*d( zLm6YZcocB?y_1k97}%8{h*~~N&FaF?D-y2aRdVRKIfm=CaX&K?Mb}5k2_i0xPfM7= zbp~!&uD+#{gG;p?Bw`6c`h-iIPt$i?!?uFB$sqml*(bj}KKT99&;R`5Ymv4$CA@{V zkEg&;pU7LXHVoxtVM~&yy(+O)j&}&kaTos^Y_YnvQcU^;dCE>xE67R1(ie8Kn|f|p zpd}XOc4<^Jq+C_IL%c3ZIkrf2+)X6sFeoQga?Wvw!Lu<(v`N{!IZdCT24^@`UgREO zikwednrS=K%JaILvC|TE#5|UrjD4>(Q-rQVDNb0Ho|`gN$I%@cNFEyC47b3Vn#4zf z29<~Y^-xK=NvjLA7m`p%=MP{Vhu{xs$Dq+OKyS!B^h`r4-DlsCl+)R_9p8iH(cpiA z;JP<_I<@W#H>dJAdj>BZ;9RmZd?}BY=er)Eli>;a7D}t1>c@TFVSlr~wB5tCu4exu z40w>o$atW;+20!Y1;Mbg52-Z}z8qHaxRPr1mE^pxB%bo-aqZ(9ANkpS;jk3Q>|-R? zJGxtahgdkk>udXHOp256dS@OR4+RoDz#i5Jt-v-3nXvW4ClBD9JcxFA2)*(!#^n*r z%A;73$6(6i_>lJZ>!y-5Y$bkIw$n@T30c(#)3&;*+V_J!y%!m3>?PF- zb~AJ)D;-Gi8$2Rv;bjn3Wi6ODRfdM7tY=I^&BS%Zj^8#_M^6+{kCzzgxE@)~k`jYI zxh${B+^xm+wX&)qFAI&hw&dWGRc zs5C|eEMXB5L@A{EUiNi!Cr6LrWVkGjDTf)unyTn_!c8Os*dM=o5l6Rx9ud7Dew2+` z@ks`+rRbUArM$jkG4z*lzZB5I(0I78nzvPrXqiiRaEYPHSn=XA`UPAOaTN)MQ{`T5 z+%j|qSIRInhI9Kj9$G5BZazHk!E1Cvr&JK|Iz#gjcM3cl%BxyNF})Z-GarUT4C75= zl$^^EJch0^!1gf|(Rtqtp&6wd2|WA$^S#d=a(g#KjA5K8ZX1OQ8Ja?+=Mx-$Qp5yq z5{WI_WVl`#Un$};mOXfz!L694VR|s-c=zr4^NsI#n%oj`8#DBj`_$^^%%Z0(7yRNH zk_aF2->~90D91I$ObB?F;e4gDy#54xyqLv2r~U#%qVk3OP@)W6O7{2gzJL!zd( z!4gT8X8k1jY9JEb1dGHrAw;@1Q5R`(Z9)>yyX^8QXxcgVV|AZ>Zlz$DIG{ zHqQORcXkjWQAeK?Z9;4d7bar4OgoZ6#-@#i? z(H`?pzOeny;r3Nm!T$6vX6FdQHs-c)XZ9(4Nxs4DMR$-|=NPbtN7Rd|98|p!3Ds$c z(i!-qHnd9Z=#c`rB6Z-7)QJbuS*%GhJdz^#LW<%WsS8=M(E;~gXceIUjX?o9wjjX# lm*UF=GE757vBDiw89GlLmhb{+lg`{{VO4W-I^z delta 893 zcmZ9JPfQa*7{%Xg|Ln5ecFR%(1r)SEY0*-k6s#2iLH-oMR=|pa+fuN&Y-=gR8%N{C zgsk!4S>m|~lt!Z7y?FOvVxq*GXJRzYQc=SuJMWvf^UeNd-skdNEC2r2>$dG(2%(>41a!bE&InaBlOO4eE~rFh5XOwdHx0p?vjk0vy;a0zIEo5C~(jR{#% z2pU|+T1JoW!J(CaNk7m*E(q|VO*>$GjqL=cWHy`5a_A%w_MdL;KP!BFng6!ErLXJ~ z5J0!~(J)!dL&8M?L0lr>{~SNi)<)3n3Tras*qpa&8^$W*WrC*CT&Q&V+B=*+9wA)O zud|OJc(hp1z8h;S1Gvg!P{0tbX_cm~J{BVc6^EIm(%zAY!AFT?CYMU99LE0Q?LU9~ zWv`&~j0?Dq34)_I{60-KMQd)*VE^SBSyNRxuF$jtM?be}PfU^)Fo(1)v)^_HcL_K> zuOS+o_JLGugmr8PAeaPWx1$U^K-C1Zo&$YFM5sn-)lAi(J8sVoh`V(Mta}%hJhcdJ z($P#6;vA-E&A?7)(}$zpoTppr&9ZwNq=<6%4TP9Ms*1*p(#FYX%p}!BV`lo1jTTYo zz_|h%!yd_FDB}DJ)O#eK)KP?gywu*ahfvJjx{KZd`e$Cj7S?@f)Kf$_uTxOyDGXBz zovVo+3t}8bF#(Ub0Kb^TWpNP^F@q^l#lW$2IBrJG&MZlJB> zlRE0VwvQcgMtsqkItr<^zB=RIp^pE8FUE5d970HqnaS?G=ic-2JLh-K{`&WiKLZ#- zt^po+IeZfQ2rxu6I~zAuE2n2|SC4O~u9k^UtA=$^bB%Swy%QN29AXH#8N-S6Gjt~F zDJUTX(ZCUs&Slb~oE8cpoRT|p3HP#cCNyo?b- zpgNQ>h7-F1SKd1a`5Xhg5Cl=nXQ^3T73db}< zS1oo{cXMPQgd}D~dtPA}t?y8b0Hu=EyE#~z65Tt86vtHw*D%izJ{TF5V$BxxI9gf3 zxx8hm%cicxu5-M>aJWP!!;U9r$8T%;nrqlr5Z4)`Kkj|}+vDxuKe_+s7hj9Cy(!@> zyuCLC&J2pYCF{db4i>g7+3!_}&2zj%Q1-g`-(XACt(RibC&*)Vnp#0l8kRnj&o1k^ zs{$>tFqfxM(Ufvk?H2L6B;{Bk(Ox%^oWr1;)W|u|+rz!`3UwX}$j z1Pv+={Trc@beUG2v=@?4ch3)C-8kL$`+*dK=^!EDd0k?-B*_LhO&6fTfn7{u6*ccTg9DnAd`=g zT^_w8V28?P^Hp*bl|!izlxEIkxR@Bo`wAhZJ8C}hIsz$d#nCg;&5ucKeyz=(Vs zlky#0lkdWm-@^yA|5Sb-59E8;lt06l^5^(Qz7L&je1%{U=MmuWaNOkR<;ZZ*cLPO4 zz^lb{M_kOXBGshkDSgW`jANjPrrH-sef2-pSDEiG1d24x$c{%m7DDjCq-Z_J!lJj2 S`p(9?vz3`DVZV{ICgN0GWDt;FaK0 zunTnzA>Ce#nVMDLi?+jKw>3x4$I_Z%UD6$6*>LWJ5`6;;s#kk zJ^T#y%bFP~YRf#w(3D!xmbI8+$HokkyD&vTBbpdw0kgD4&JZZ0h|+v4;}i^Q-bFSm zXhEQY%$jzV!814PM=RPS>`~B;4u)p;0@rmqyTEmahz}W-;hbmih9k2Qx)|F3fXeZQ zONN=_1wT5`CSyN?^zGA&tvAnu;>|$?K^&qu+R_rYD2twOEx}05t4hqIjKdT!xb<@7 z`}Ie#q#qGPB^+090zC}DovO~@*L0m1i=nWc}sIj1v>Q>N*1M%aWwr_ zT3f0h{29Ac(78}NL(BHT>J^+R=%}Tec9C?R=1$(uDY%B~lu=%@awea!sPS}G_77CF zS8@!KhIEMeqNJAJEAqwCncLZ=K@Xssg)^&Tc@??DGWO}eXy^BF#I ziU-m6kiH;6hJ8fm&2{Vz>|4VDdTt;S=%!^2p$&uuBG9{m_-o8=NB3_-50>}p=G_gP z67I>r+^ZLyE5oEUT&SMM1#OQ@yGx|4K7^z`LPULxLG=mp>QmfTpW!N@>fjmtff;y_ Wa(&X6BI_ebPt#jaF-hZO>GunOnpq(1=MO&tn82+e z#1NN|P%(f(hP2J=1<&#Wr_Lj%@R1eS>xCuD^)K6zyX8ioq{qf583vfZcr04H@k|{ z&fUnij~bEdQA0M30qP$R`#Ggx80REpRAiC+mojt%CQ!EJc?_pB2V5>5j3Hx$A;xcu z1kS4{fU1_La!$r)4;ptN?zD3?yDDRxVZaFj9>|zrP!<>?`;dOFRQqK zDM~zy0*0&oojx9>1NzZH(($Sa0~Z;TPS2-A%cuTR%PqeaaJN?AVPT$h&j}0(G<3tg z8h4N!=`jVuJe})_G0EyLgWlnP7O^ z>O^<#JEJd9sSCdE1T&r$hE9kSZ9x?bZy}aUie(6pq@62BOg{}Jk|t^ANXErUE`J{^ zH*z0R?g0)p>4@Pvy$4A``wAy4mwFoHx-++3U-1Sj{jW+;RR2_1{Sq2wpHxd}?e z!ATbff0B3#P98Ytz3?8+nfs60eEqz=11Idv%+NQqSaL8PF5c{65CpUxLXQqzMN}s` z>L}cFG|H-KaD0{|iv9XX@7V`84lDIk_v&xm*Nt*g)Q)|6zlEm=hm&!Bdo?c0yYf0M zvRvU^7DZNG21P0@RKuH~;_u diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$126.class b/_tests/out_app/com/lanremote/watch/MainActivity$126.class index 20d3d25d0afb301d84f43777e69a8b81751b5259..0eca292c3a79af3e814388f7d7f9ab18c0c4f65d 100644 GIT binary patch literal 1361 zcma)6*-jHt82(Nv%uoggD#+qiv1(hEwg{pY7eGLeMX4b%TurAZba0ponr9J4f@c4MhS-$G@+TH&UN6bPNx>Q?oiAz^LXd^qky;-n7to~Sg zD(sz7a2jVQ563QeXJ`nO>nD5&BO;+)K?gb+0{c~+!Kdju&*g&MF=|s^X#YNy1)T`b z%7`*3h5SoFhY?rMEutv7h{s|wdg#2f`Lb9m8N?D#ASB{GuiyeMlAU5i@mP>QU_h&3K1;6^*}O$5Bppq^JE<)d z5x%6I&+1$-o}qs4V0DR@$>^}9n|6+PKFOVoomOxYwMqmt)TBOaxd=~ zCJpHlHH_*qW?4KtWNNt_&k1*Jje-lPXj?%81 zWF^VgNc%ciq+=aYq;nlLkrwYd)W{|duF@;Rby}-PLVb@h+I!GUr*U@`cHY7(Uhy!p z2I(E*B-m@T+}Xro|B($GqjL=je;X|u2yP)H5dN+$#GYYxFZ%2rbZ=p=Za>&UpHNTy zrCxGysQ{BUaHaGf6JdMYuvPJZGCxq3{=vBWUqkhGT`VBV-RRvG)Pt2eK6RyrA Trbv2;($jPcDkgE9B>jE^CxKe+ delta 288 zcmZ8cyGjF55IwWI$;O)-bB&J}Uxa81*Jg#-rm?aRtn4>&i@>tNy#!0YA>7Zf5){QE ztzzc~*!ctgfOrB{4$Pc0hZ)Yyhq{Z@`_Ic9zyNzQHEJl-uoOdS)S+izJqpWc#xNQy zX!*JHjn2m7+_{6}bdaWkzWTG0@?~zA;zGBD(?xHb+o9ill9my6qOu3LXs!M_v{24tW_eK qudHq8zxgha>JYW$0rHH-|M543*yM764z`F9-`vJ7nd0KYit!7D{VFs7 diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$127.class b/_tests/out_app/com/lanremote/watch/MainActivity$127.class index 1a36ee405f62cfc45fd81b9e9e5392528c84da8f..671d469b8e52241d4a34c0bb55761db06fd39f85 100644 GIT binary patch delta 330 zcmZ8d%Syvg5IvKoNxiwXF}|OasGxDtiY{~~bfu_>i2IxNQX=sd65`fx2;I4N<4#Z% z5kJ6{UnEY^l>;+p=A0R3n0M#F7N6g59{_f-Qi`}PP{WuDA>l!m_aPJGXt?ktCJ+q2 zXf+^ss@v6hzP7#BZWGow%A;_9^U}o>A-GaEs+B6!Z=Dy$s5sRGyEEu9G>#LaPmAkV zXBWRhB`eJEj&^P(mLLg!TqJ4lB+0TtcBYI@3G&bwopn-`=Q>9d79U8M1o=LyaQJeI z-9y0aG-n~#BSu79&!Ff9?ju)(8IBGk{3^#;$iMz3BOS0_q1Me3!QezviMQo= zAoBNSCWb~&4i(pBPO+7pP%>Qhqceg+)2G?X$~+ctq`@xF@BY4-j~HI7IL!*FhI>Mf zvc$qB!}_BCV8g=}L#r6`G<{WSohgPF{@gS@om0{KR0p@WkSA%nfE zXm#C`7lkM+_@w#FT%`G*Rb!p5!UnAY71uS#0g$H~q_eEhxa-uMHBRLy1p diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$128.class b/_tests/out_app/com/lanremote/watch/MainActivity$128.class index 9c58ae67422767b3b66a04881ed7cbfef7ce91af..41a6781ac787f8d1f59c67fcdf71fce7b1145485 100644 GIT binary patch delta 207 zcmYLC%MQU%5IwitqKd|=r3hhxO|RfbtZXc9ON2DtG<*lSpJ7ET#KOkT&-eo|RwkJ_ zk2#q+6i!Lw^|?O)Owc+S&zvcPU=_!`k(dgh1n)a3lo^U!xtCrjqqR5NMUnKwm74R8 zy6|zf0h7VPh+w@;%2*l#kVYDD3K~>NS zjiJ4se}*RAFu3PMPoL|ih=ybJLyGUZWva?~&@#F0r)2D-c#L8}#sLP!GhEK?>z=Q> zzKlck>E@Cb#{GvttM0v@^&>{nm_G z@aJW8lFd6M-J(>75yepn#}vGX7{j66GBN5oHU;D47pQizYg+(n$xUrwmVDEqIxeLe zsqA#=UxslUCnTIykOZY|*F0~{2t&}&EsLQeRzvyU<>+<{8K)Q;oJB$4jDoZ1CF8u~ zknz#&##Qfc2Dh0+N|i)E0~gyRd5xrGiW>Gq7p7S9`*OZjk;8ueyU{Kdfwux z_^gB(hP~CEFr8$2a_E-9mweN)W#mNGt5yGf<+I8McSX{!DR>iaZD##!kH}i4K8alj z)F?fPnu6QyFFyH^EUlBipvPHoQmy0J)& zSY4Gs2Sale?r}Fsn1AAotVxoTl66Y1!G}%Zt95H#PYQcN9I_3|@u-8wxj*j|6cojA z8*!GnJ#GpuU)F6-EvTezo4bRS?s?opJDvYB7~Td`Rq+p?!=ZuhCfuM^okpEB7c1ee z#J6Bw>yXxH#*n9Hh~9|&K~DopZ9kJ}HAvq+>ADA2Mzbpa2sszf_GWWIZGSe`s2$Aa zLQ)yeWOI`Cd^RVyvR|q*HKcW9bE>5FiD?-x>_hxM5}Ab76DZ^K`L<8dlF<6Jb7iDf z(Uj0GXcuWXA%NP-7@Vmgj(m@?T-S-Vb&TJ~<(XBqWW)#UjYJu znu)`oI1>2_$0L8^0*%v=e{d!8FK$F0!i+qEtC9`^)}IRD)ndo`HMYJUruusN6Qr%Q0C$TKFP$Fm8YcHSYf4Vz;hFUip3WjDZl{hRX1Cf6+ zGcnjbIZ|AgImK3XPRVei9bXa@nLbW0EAvErkOsTDxcjGOK4N&Uk~Ax%8XX8d&W0AY z7+TBzg9{&z7#hWdr|G*=>r64cX)jFE(>e8w_7_qYYm7R7#m?k#By@P*A!2a%6|JtD z@}dxhg*IuvG#6?9XVqxYRk)xvpyGMvH~{i=gLIY^dYZUJ{x(Isva{p)A@KZOg|WQe$EoNFXg3YB0nYvz<=p)a`7uJ4JmW zf0Ot@O!NVK3ZKMy7f2)xrcSc+adYoE=bpVke}DS{U=y3;h#_tuVPOnO28a8-vgfKm z^n5MK?_AA0<%TQOGp^;S)E}I+^(}^^?#R%oFy!i^3aTbj7&l;An1ID#cimGrqBtoZ zw7P<8LVF^W)K3}W`NENjTbMFnTbM?gVQ~~b!-UJZ2t#LMt3rx)@}ngcZcI76=(*-e zp>#Ec4C&#$b_#chBIJP(>P@KqzKL1P8MtR*9t#Y!HwoMeeMRMdt4o&VU)6_ut`;ss z11&wWw_hEjzd@_?%fup<4CE{iQ+27t{-Di6# zsM+)f0T(+m;+VcZUZqF{0c|Vp`Jq%N4WT=J+rs+A>_kD)kU`)F&s`;`ZEII45!5_4 z3`I!Z#c0|X9)j6+G|}W~jJ^pJC|aajj^0tkEUtbAD}IG>MlXg{+LLs`&d~k{)>Zy% zLW%O7;wP|kOpbj=>NuXBX&xuix0~m$lCMB m*>hO77x0+k24XFPvJgj!o-veBp)*0VzlIIEnFJNl()t5gV&z}} diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$13.class b/_tests/out_app/com/lanremote/watch/MainActivity$13.class index 7555230cca347673d6d8d1879cf174764a8dc264..4f15dc769a38d65c292b8b8afb6fd16709c930bf 100644 GIT binary patch literal 1133 zcma)6TTc@~6#k~IU0N1eig=e+DD(odEnYCen3#e|lOl;VeV=ZJc3``k><%^j025z) z^uaeDeD)R*G|?a6|7aqA!g#hN0kuil&CWM-=FB$GwAsy!s zW|%ddwqbGG6>Uce;~7_G%h=%3erPJ$k!mkpyu}b!E$O8T3^SEs`m4T!5gidk8MF?! z(mP_0AyL`p9qxN-8dc>=ySW;Ljxi16I%0@3#7`79>)XOqt&zy+*w%HMleIBy9b}TFWwxzetY!%W%vEpU++G2 zzrXU+mew(cd4}k5LVA{0&R}^ER}EPPgWC<)kqyIiY$a@EtVwP;%`z?AEpCeRrd_h6 zxl@rIZ3~wnRB{@EAy$#Lc)Z)L3->9nTO^58Wz*(r*QL4U^pEx89^zuPoU2htRcF^V z#UtsPnmAp!OMX3(!*$!V9FGcaL#US1(6J1v=t&~JCEfOuziuZp;C2S(fUdP{Q&=U7 zd!Fz#+@(?&!k4K0lN6+=Rdg_95QxY9jsXa%FR4`n^u;hwn4=kKAm~f-3;SUC#eHb` z1B`wo5ksEFFrnYq3p5WPPOC-Sq|rZIIzF7te+E0m1cCMi7yGO0J|u1&V)n#X0wFRM n#dvTOQ$Zcm!7=i_)H7|MKp3K`D$?%+gaLZ{PX>3e5{~=;{f5C#8wJqOQb=fpmc>42`YMnU07IaWE&_s0F~tva0H|v z5CsRIqTv`^0r4!5D6-Pdyf>P8`}Rw}Z;AKs*EawMa4QjZ1a!1Hgb4#?^)NNM=y6yk z`mn2y{d&RtA;&6%6Ur#hl4y35#J96Eg8EAF$2^lknp|8mtnba2EJyXHa(K53lc9fp zoupBa$uiH<(@@4KgLy1voCj%G6mh{ZA=lCfhTh5V6Z51fWAbAHg|=oGfLRt=(C9k~ zT?00$YgD<^H;VB5CzQ;427jcMmL$}Op^gZL*!T-fiJO4#Ex=yT`FD#(R~>NGg^Eq` ZDA>X_@d~=w!H6oGv?kr00mg8&_77UWK=l9s diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$14.class b/_tests/out_app/com/lanremote/watch/MainActivity$14.class index dd52c703ea5e435d94bb075052b6182ddb92f44b..c29eca689b1b70e1321f02910222de2b5ffd48cb 100644 GIT binary patch delta 304 zcmYLD%Syvg5IvKmx#?}1CaKo9#!?EVODnq1N)T}&x=!d_6k-C2UF+Ue$QKCx0YOn* z_yPWc|KU=cfZ)K)d2nW!NAb{-pFi&(0HzpLGVV%PXbVUi9oW@!n|GDo_2V!1DoI_i+MVW^6a+A(~CG$ zDZxI?GF3!rT$ZX7e0Zr%?ko`cAlF3;CjX>yci^+T%52EGab!4o0fny+Ppk+7o+cyo zDbF>m|K&%FP2LKhL2u~(r)A^o#}N7nbZl@&!zQ)@CI%dAW0#rFX0zT@AA8t0+rNr5 BEQJ67 delta 549 zcmZutJx|+E6g}4tF>y>{OlZqz1EhgCX}!QeGa$i)6c`9%VCo~WB18Iu92>gz2QXRL z*t#N7H4>=Mp%N>90r6L=US~i^@T7OodH3Vo`|jD$4^6-S_vbHwWjK*8R#Q+gY#;@l zFnl7~g+O$V2)Wv!I1x@;$d^c0 z2&uZ)k|GHG-WnA%BlSQuj<&?{Alhrt%9j#sLQ*sWuOkU-OV2vg1K*Ra>NC&NA4@eM zn|{Y@I;UQ<!^hqomA@MxOhYlvc!L1r5R$xIg>M$K zjPIE(GODr1E?tOGqboj7LU&~D)^L4 F{RfF-Zw>$e diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$15.class b/_tests/out_app/com/lanremote/watch/MainActivity$15.class index 0c465e55447e1f052f339f78b3589edc02b22bac..420eef47988c60539b820f791c91c0aed581f42e 100644 GIT binary patch delta 545 zcmZuuO-mb56g_ud%;+S=Bu49ZMjbU#+le$AnT4P$Aw^Je;YOa5A&ePcBr}Oie}l}< zo$D@&&_&xt5iBkE2mCkUHKGV{bI!e&d(XY+y?gyiH|`#O{{c{eePNsw`a3ePA{jOa z`RcLkNIQ_KZtt{}l1G7e@x7ceki(FUd>X?TA(*iwGfPNG*Y(2CTCJ4Jg!%Z8=JrYl zDU4!F$9NhO$PjY9k_6FeD;-mW8L4WmreCu=zISHtGg#YdGw|I;)ej?2c`d>k3tR8V z#NVj0I8v|0w0}JS literal 983 zcmb7DO>fgc5PchmxN!_g%QqAnTDLfEg+YRo5fTa@MM_Dfpxigk5^r&KC3};KpM^w) z;J^V14*V#@IBnI6i-jfc%+Aitdo#29`_H%U0G?v6jtVRrRTq~~W7vxIEKIq|#7r9z z9&r;V;Xaq@Wo+a^nvdR-XACuy$lQC(u-Py3j|zYl7j-xcjRjA=IMF&68ADJS>~&9k zBL^-n+qmMQi55fakS};aa~clDhaxsa>A6(WykM~W&ZvPkT(xn{#dX|Z@XJV^;ZPc_ z4rKg+q3ZWKBOA9ELarv6mXk2n$_Qn`SCXfC+M`YOp2xx)sBTIzeVNm7QP{vQtt=|_ zIO7y`M#QG8CxW5bmrA^y&&DEq$HysI>O(nI+{`o5ZGA$7VcYMQkdCN224|?}SuEbj zBGu}}F+VCgOn|Rdoa$Vv>Ao3OVzq+v qK&L>me!{VS5yHkYyp0DWtJs7`$e<)v;kEqM$m-V|;@rV*t^OB#ZUl({ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$16.class b/_tests/out_app/com/lanremote/watch/MainActivity$16.class index 5e68f7871fbcde99693a0fe4873afbaf985bb167..a1a4cf22f8ecef025ba7cec3af9c4712d89dc178 100644 GIT binary patch literal 983 zcmb7D+iuf95Iq}*xN!_g%N+_0ty>(o!XP1DjF3g< z6W_>zi_12yxM-rq&^qJ`UeKI|gYlt=4N-b7l{7CHEWa~qU=3GoTyt?9HyHdfl4m%S zMymrEe_*Kkz0SzSEryV*Nv7o_jI}aCnedh5sh;*|lfCD$@CK@zQcPdwbX*iR@JlO; zNFSAKX!fNNZ|AeI$lmdBN|yRiP8B!vjC5O{5MkK%`z53!s*b@K>UkE6 zH?l~zdU4E;iVhRtYZa$Dmuk8%Orj??wiw!F?R2q?9h!EY=%dpKE}L>bwV}x z=0>Jr(Y^m{irZ7v?rzHST;x>#DU^+XVWXseD(#=Kp-mUn1;b5LTCL)~0wn2G&_k)v z-^6W_cSw6k(kd*$?lD-fcMLoDg2pE@G2ErKMiSOftdm|ri+1;LpVo`Pjo>rv^{-g1 s;5^VNkgQ*DtlxyNu?%nH0m&*h;Sn+@iB)(lzcsS@HHSENuv@GD1(Hbw%m4rY delta 344 zcmZ8d%SyvQ6g@Xdn{+bOG`=6TvDSqZY{i10OIL1H+{(0sQVB!JG=eL?q2xE*2#O-; z2lxa2ggae}w=2bgxp(fl=iJALylkSH4jsS(lAli zLzS6WAV9@MC^3UsLX<0tDg z1px{u@)j0f9}A2w@~h7A1QE^c2T=17?j1*jCALNOus_)@L;m42%KUrKL0Epm^nbw@ YY^*TF!YZol9sZ9s9)(3*WYUb!4+rHp-~a#s diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$17.class b/_tests/out_app/com/lanremote/watch/MainActivity$17.class index c720ca361bcf6b199e4b52b62cd3fcfe7d327d06..4cb3d3acb5bce88ffb5ff88cb74b9199819fe97d 100644 GIT binary patch delta 40 wcmaFP@`h!?d@jy{oW#n4#M0u_iA$^*O*cL;WMpFDoGi?w$Hc-l*_lZN0560LOaK4? delta 45 zcmaFE@|>c4`etU0 z;s(U`;h@AV7PE{Pf|l7^$)-(QB7#a-JMapH+>lPP(9;_vtlUw%5()nP6gCoa7E*NC O6@Tg>d;$j-lcqYl_y&y* zL=zvt_fX=Xk6`?Yo5TG%=iYOFru=tSeEWHR1+WJ_67PK3a{a(Q8#}?sbOR9z$^ujg zbC@SoEq`FRrZ=<)erOw)W@z<{gTVI)8UMUbC~Nv*-yEC9D0E!o$h_#}u}F~KuO2^c zuBW#zpZ5_{tuSr}e9EG<{RC3w2}a diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$24.class b/_tests/out_app/com/lanremote/watch/MainActivity$24.class index 01a375a2f61cfb73c0d46808b2170b86a5910a15..826899ba8e1487b15ac81fb180a34d346e06b4b2 100644 GIT binary patch delta 381 zcmZWkJ5R!36g?NpBeg)O532aWf`SfR-1rAZ7B$Are2|7mN^59~qmzS+vBA|rH%Frb z(ZnCXFEDXN*8hJMW4MoZsvY>N=J>m5S2T-olhKGQw7ZR$04R83-r zAdOG&-_Fn8FP}cHZbYbXN+=(0?s%5P>Zakplm{y9(b}L(rQ8BYmJvgoKOW&WfhF)O2Dg-N6?h%z6@&u! zE0BT?$2K4YC^G90ASE?KMwuQWcgG<@up|alf`+tEL=+lFA}FH7qrksiMLC?IP>`qJ Dsj5{; delta 187 zcmaFIx{!tI)W2Q(7#J8#8002$P2lBX;A7xtXAt0F5M&UV%*SZVtg7KNS&&hI%~~^z zok3*sTt@N9cNl{hWhU!0sfr6TFbW7Turja#O<)3IZU!L+Mh4Nz*-X;0Vn99{kjKCx z03^j3ctA2hF$EwE6lLJh+QPuNgMoJ=kj=<|P%X&7%pd_2V`7kGkOI;yKqpBv$O2hh KKy@HnfB*n8yc5Cz diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$25.class b/_tests/out_app/com/lanremote/watch/MainActivity$25.class index a28b8b4201ac4bd43ea7dd70e60ca0a6e1445d35..28e0158977f89dd83f5d6740278c7183ed44d0bc 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvgc$>$k$8PzBMWmEwGLkI?5 delta 19 bcmZ3;vXEuNIYvg|$>$k$8AT@lWmEwGLD&X3 diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$26.class b/_tests/out_app/com/lanremote/watch/MainActivity$26.class index ec9951a35a1404df18a1fc35e5bc4f178da3040e..f7c4d3ad444ccf396c9c74a18326b83014f3bee5 100644 GIT binary patch delta 275 zcmZ9HO-jRH5Qd*gKKrG~7me|!TCJ4Wf{lw-aN)*Fh;(f%MUWB~MYoC zw2BW?Au1-5>3rThIU9|H{!w+8wFhI%rjXti55;g+lsChR`?4&qXVb-%T>AUC#Zo$t z9s$Azk|gy$J;76rNR{1EFVnbY!u$Fht@AM#Y_HXmSFNASj9p z58w#|@c?c-g7_K)!3XcXdev1;-HI=vUcaAT0Bm7N!eNNaN2E#+n2;#=kSH>=i8I@q zo(oUH_Yoj8*R4(wia`!eb(SWv3xVOPzR_HTPM__ z+cIvv_5dC~qI5?W!z|4LZHOw(IfTFZ6>!TUi0{G6{IfFogz|q&jYJI+IcUtINkEWi M0TCgaM$o4E0dmeSG5`Po diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$27.class b/_tests/out_app/com/lanremote/watch/MainActivity$27.class index ac5ad4af385b5ca3c522e897f167774b25ed0880..84cc8a728cf332d0b9435062c6bbf828e9cd3a5f 100644 GIT binary patch delta 107 zcmaFI@{VP~G3CtM#Pn2sEiMK|1|c2>VFnRK2JXb<VFnRK2JXb<#O?*=jO^JU6Xc_qe1U1 z?d&a|C`_{H@8R$=P0eYyoxLp$%XBQ}5e_e?dLv!y$Avbx>ApF%YLRc0-d=F6+U!~C}6#rYcSX5z=gUT$b3?zHz5HYf8 HByX-Cb(t-W delta 282 zcmZ9H%Syvw5QV=<(wt}&t1Wi43v{uu()b0R*&$y5 diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$3.class b/_tests/out_app/com/lanremote/watch/MainActivity$3.class index 17510b32e02f69cb279e35811546ce1507e72941..f3318c1fe679e16aa9a080724c35273e517e7e88 100644 GIT binary patch delta 67 zcmV-J0KEUM3$6>Wrvw3~lc)rqF{%J00jvNk0j&T$0j>Z>0j~f_0k8l}0kHs20ki;C Z0kr^S0k!~f0k;5j0l5Ho0lbrU1vj(~6RrRN delta 67 zcmV-J0KEUM3$6>Wrvw3?lc)rqF`@t@0i*yc0i^&u0j2;(0jB^-0jK~>0jU5_0jvO4 Z0j&UK0j>aX0j~gb0kHsg0ko5M1vjQ{6FvX{ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$30.class b/_tests/out_app/com/lanremote/watch/MainActivity$30.class index e267d22cb98fbde83c89c446ec6e7a4d1ae561ec..7c08cf6b08a433c8f7e66c96cda6e1394be9ea47 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvf{$>$k$87(LOWmEwGL?{M= delta 19 bcmZ3;vXEuNIYvg+$>$k$8Pz8LWmEwGLih$; diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$31.class b/_tests/out_app/com/lanremote/watch/MainActivity$31.class index 27c8327d8ab9459c43e7bc5a38df41d66667a8f8..a1d213c7f2406255c4c815f9abdbbd639faf130e 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvh7$>$k$8Eq#2WmEwGL|6uh delta 19 bcmZ3;vXEuNIYvf}$>$k$88s*WWmEwGLnsDf diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$32.class b/_tests/out_app/com/lanremote/watch/MainActivity$32.class index 63e4e271dc12da0d642f68d1b158174a13223ce8..bc1fbee697efae04957d9a0ca3e375ee8e4ae853 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvgi$>$k$8SN+kWmEwGM2H5C delta 19 bcmZ3;vXEuNIYvgk$>$k$8TBXsWmEwGLx=`$ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$33.class b/_tests/out_app/com/lanremote/watch/MainActivity$33.class index 6d75bffa51372c497fdf0364add21ad94c6cc71b..34423f4c9de4772ae49450164bc23aeb36f7fa00 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvg8$>$k$8C@s;WmEwGMCb;Z delta 19 bcmZ3;vXEuNIYvgq$>$k$8I30YWmEwGL%0TX diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$34.class b/_tests/out_app/com/lanremote/watch/MainActivity$34.class index 5e565731ea3a49f40e4b876037e77a7390528d3f..27ef11d9fd40e7fdc86532c45c534311dc7283a8 100644 GIT binary patch delta 236 zcmZo-Tgbw7>ff$?3=9k=4002>CJ1mb@GvUGAK+uC^FfeQHEJn!w16T zk+#+hV`mUyWDv?qEKAhSNz6;v4=T;eOH9g1o&1O~gi&U)Ig@I=Fax8200S!n8_;Yf zAm(NeVqjzt1+tWYe2@s2miAT#My>4(>>GhRMg}n;$p)kuSlof6I0Fw{ex#30Ea1*BPkUXT`GkOeZifEqzo0RaFbFCK>g delta 394 zcmZ8cJ5R!36g?LTG!?bh2M9h;Kmms?M27ATMq?tzggBT|8-(;hplF;-+}vy!Ty${M z#h5SfLE~^==bn2`?hSY57dl^$F95PwafORh9wP*=W*+bm34C2W z+1HG6Re*%?049Q%#1tW{*`{o$R!ui;N0*OPM>~|)>$b&i?P7(HNbPM@)FZV|D#=^L zimo|I`Uh$_WkcRns|t@WVYGX7*}H%GJiqI;T3zY-RX_yM0A_-i#T+5#e#G34NNetU zT=^&P$0G04%2r89?*LP8!X(F(!rDn0{buLSI1W%W|uT Za5;F9WQYe#SZ0-D|D})_aA;u8+HXIrRqFB%EVa~zTY`q$8$<~*0%?fzI!w6_(X(g@+S)oCT54#l zB?1R2s@=aJh{nXfBKm1V(eQpf@AEzn?+th67rI}MF96oD<_Z_5JjMuK%{<^C68MIC zvacDHng9ut0ZaukjTu5%vrXAht-5a7jxHaoj&>+#8@9!6?NXJHNbTjS>XAAimF2Bc zRo5IP{R1_fiXrFKn!+PY81G$O_V1rQ&+q!}c2Bx~6%au*fVm*%u|SBqA2GKp(x&?! zSN;k7u_!q8id9z9y8*-q(y#Y+)3Vf(p}TLXR+x4u-7+Z|S_FwAeDJf+JuF7B1j2Ib zmZDCa&3-n4%d(t6aIj(62IDgn(M%iUdj? delta 236 zcmZo-Tgbw7>ff$?3=9k=4002>CJ1mb@GvUGAK+uC^FfeQHEJn!w16T zk+#+hV`mUyWDv?qEKAhSNz6;v4=T;eOH9g1o&1O~gi&U)Ig@I=Fax8200S!n8_;Yf zAm(NeVqjzt1+tWYe2@s2miAT#My>4(>>GhRMg}n;$p)kuSS*30I0Fw{ex#30Ea1*BPkUXT`GkOeZifEqzo0RaFUgC1)D diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$36.class b/_tests/out_app/com/lanremote/watch/MainActivity$36.class index 2ff9919856f11c58ab4bd30c473390bd5bbda80b..8cdeb39471ada01e283ec155185bda26d5c7daf9 100644 GIT binary patch delta 19 bcmZ3;vXEuNIYvhR$>$k$83QK&WmEwGMVJP# delta 19 bcmZ3;vXEuNIYvgC$>$k$8Eq&3WmEwGL}&(z diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$37.class b/_tests/out_app/com/lanremote/watch/MainActivity$37.class index 5a6c74e9622a5d0934d214005b060bada16e368b..d79f23baf5c641421154561ce8e692d67cd25c27 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!2y$>$k$8G|SPWmEwGMaTxW delta 19 bcmZ3;vXEuNIYvgO$>$k$8J#EpWmEwGM92n~ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$38.class b/_tests/out_app/com/lanremote/watch/MainActivity$38.class index 733a485df7d4f4bbddf04ddffdd8f79224c814ba..0e87e4c1c13faf625d54d24f718c3703bec492a7 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!2a$>$k$86zkEWmEwGMkoft delta 19 bcmZ3;vXEuNIYvg;$>$k$8QmuTWmEwGMEC}r diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$39.class b/_tests/out_app/com/lanremote/watch/MainActivity$39.class index 64e92ebdecfe3265bd6ec6830b33698243fea8fa..747454ff245ca3c195d6f8a087884a59c2dead2b 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!3l$>$k$8Dl2@WmEwGMpy>O delta 19 bcmZ3;vXEuNIYvg0$>$k$89gWeWmEwGMJNWM diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$4.class b/_tests/out_app/com/lanremote/watch/MainActivity$4.class index 669dd1da6007cc880b6200bcec15558dfd042d11..446a330b181f1054c4dca718925f7e91834ae147 100644 GIT binary patch delta 27 jcmX@ldY*Md6cgjE$Ph)EX!gi#3J delta 27 jcmX@ldY*Md6cgj6$$k$8RIAaWmEwGMu-N^ delta 19 bcmZ3;vXEuNIYvhB$>$k$8GR=IWmEwGMOX%? diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$41.class b/_tests/out_app/com/lanremote/watch/MainActivity$41.class index adcf96e8b3514f03047b28c3f3a9fef48de134cc..7ed419a8e0f2a31adfec6927e7f8bd5616414e7b 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!3B$>$k$8IvaeWmEwGMz{vl delta 19 bcmZ3;vXEuNIYvgm$>$k$8T}{!WmEwGMTiEj diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$42.class b/_tests/out_app/com/lanremote/watch/MainActivity$42.class index 1130836a2f9ce7f4a2dc04532af0a867fc89ef96..74701aea0f4e70c027823acae9201da50fe1c23c 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!2m$>$k$8B-_!WmEwGM(76G delta 19 bcmZ3;vXEuNIY!37$>$k$8G|POWmEwGMYsmE diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$43.class b/_tests/out_app/com/lanremote/watch/MainActivity$43.class index 8f1d16403762a88c446e113e6053f7b874f419ca..7333cae7e2d02a1764dcdd3e02971348b492f212 100644 GIT binary patch delta 225 zcmZ8bTMB|u5Itk2anril%br9~gb@n1l4t`4VTAZsKYg@?!WFcHpeXui0j;BTL?iUo zz??IWGmp1+QpM}@cmkNBdlUy#pn#$dX`loviD;twzUL($8reE31Zy2^12+od(4B4K zI9No>WT?!ko$Qpsz6_EQS!ldaVb8+?p~_L8^RyzI@fGCUpr1J-)EG5ps3Bt=#!ug4 i<|=Z|1$2k`uU;VooBIkHXfmt(cT1wfq2R&v6x$aT-W*H- delta 499 zcmY+A%TC)s6o&spjBS}v!qAqKYf3`v(2E1j&igJ9s1hz9E0v;2FqbH{Bgbi%JVLRu z?5bjgTB)kkMN|nMfG0qz*nxNp)ML7#Bh8#S=gjy2NAp#=QjPQ5-)8`-s3ykIp^gWH zOw-@dkt2-kdNS@sUOnmsIs`!pr0?l?NHD^V7t}jp6nAwzCa4`5Nxy3#k0%Ryrt1W}b^TXJ)KFPSbZ>@I2fK9?ii!g4M*IM$Uw0iMD+gjg}9XpknOZj<1$*lcr zv&NqFuF>+EvHc&xfR3kx?9G?t`sn-3w;$J^j|~)2(y*AtGdw30lFLGJZfGmjN`>$? z`BSv-o3g|`6DN)if<3#urJ+m^cf;MeZ}fsdHriftVfOR%o>uxkEl!%CTn@uf`45M9 zk6;Oe7yT2OK77f!${CbxK3^f5cDT05@41TQrC&^|r_kMu71 D0Savg diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$44.class b/_tests/out_app/com/lanremote/watch/MainActivity$44.class index 6d22b582b2e82f8212e160ebf526732710bbc40e..581686f87d225794653fde3900da2509051cd34f 100644 GIT binary patch delta 475 zcmY+A%SyvQ6o&t4(WW780)Qe#Nu@@qAI!nLM%c%-dw^Li<;7zy}MhHP%;w0c4l$sAZbjtE5hs}Sudrok}N+6)bz%~CE>2En3p4zWO? z+7|Uw6~*ZXqQxi3Mp*684MFYFjfk4i4N>jZ4N2|Q4Y`e^-o~(+Ze#qOAcnwJg+R6r gvcfTP6bpijFy=`KK|_I}fbI;6Sfr~$Q1CWdKUVu?3;+NC delta 345 zcmaFDc9E6q)W2Q(7#J8#7}O_nP2lBX;A7xtXAt0F5M&UVoWrQgZftB|z{p@bnUP7Q z-X}3Hr6@l$MZY{VB|Wu7KP0uHBrG$v+?srsvCdznr;* zi$RP*oSi{}he47-ijhGahkix|_TtnMkZFtz(i%QliDilUIf;4c`py}NMZu|srKx$z zsn(h-VeAYtlV37UWK^A8&7@i{&A=!iz`)AD26R6Y5OXugGB7g80a;2wK1hU1OM5E= zqtFsK1pTtE{*b^-wad%ZL_Z|8q9iOcwcMJ6L4=V(=*9Y_PrK&7Xx{sD`=;m1cfXvu zgo{CpL7bgIf`>tpL5h(<9EW~J2KM6A5|C+(4AL4tS&3zd`ZH5wYiABMwg{7%^ z$*IpPGnS_tjDA(F3rFwAi%)Nzy|aH6A*JV$TBc8$W6{-l1`Hc^4Wkq z29^>asldPkk^zco0#yP<8F;m}Ffi_5;1v?y#vr;8D8R@7(!vEYh^3r?hoypnnL!aK Z!o;A&pbVs0fI*|epaxdM2(}K$1ONoGLrMSu delta 363 zcmZWkyGp|V6g@Xhn;K(ZR$Hy_T5WYm1qGphAPRyYZW3!rQ_@7*R7Z=WlOdzv=I9_e z1P4E$o3m>zIQR?VO$7%X?(5uh&*8p?Kf`kG>-h!1CQ7b+a~Va9Fw(J3?PF7GJ2DbT zN=PXfN1BkW+D+BetX8dQJ2mw}bEh2qVI83LK-D2b+5PLax5ZbA%^|I_D)Q!G^JQhyjZD t+7l=><|Ce(@R50Bh!7mffs|+a4o)H1dC~y_|xfK+L92y#O`wjH6PcZ-h diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$46.class b/_tests/out_app/com/lanremote/watch/MainActivity$46.class index a8d363824e023ff18e7c85982d90cc6b1eb527bb..c29b425eeb28fa04e1b82583c6cca170f324026a 100644 GIT binary patch delta 72 zcmV-O0Js0?1?>fpp9%?eZe(v?Y+-YBk*G%y59OPl>41RbxQgVsfaa~Gk^DObHZU*& e09cc-0VA_u0V@FkeUpp=GzEVE4g`Rc#R4Re@*LLy delta 59 zcmV-B0L1_81?dHlp9u(cZe(v?VQg%XsYeYA<(r=AfPm$NsOf{Kk^wsbHM2YcQvm^N RlaK;51#SQi1aFhc0wnTb7Pff$?3=9k=44M@h}K8h%hopOgtds z>{Xnf$H>5(pOnSOAgH0~la*MOs9#!=nWOKUSYXY;AjZfb^t6Bd)7kT0HZ(k+)$(j| z?~}cAxELfDB-t6Hco?J^WG3#_Wj8c4FkobenE26J0j?QhR!(AGx_)p;QD$DcwPqMQ zgWP0W#)*vTlg~4%ipw%E3J5T;GOz(%%LK&S4Dt+&3<{IEnWQrmfqXU~kAbBfNGdV# zfMkGT+6*ipQ3igkEewo8ygL~9g+zBSh;IZ6F*1NOae<6t>15zz>0)4JPzH)HF{m)8 T0%;bYtJN4Zz-k!576O?73%Wf% delta 223 zcmeys+Rn;#>ff$?3=9k=49XL^rpmG8NmX2& zfl)w!ft7&`Xaf@vb2CUXFfvF@&SjELlm_zIfIJ44G9W3#zyp#2im5O#gG3p4w6-uX u3h@f@?_dzz2;?&|Aas-i)yM+Hm>A?3fSUx>9hCpEQzgF%3if$jO+h0o_Kn7F~3(PZNr7e>a)$%agZj8&79nN$FA CTMv5x diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$5.class b/_tests/out_app/com/lanremote/watch/MainActivity$5.class index 79223ba3d724750a8adbe596ab6696af86463e58..1a4760e8b47f537f079b2a4f989abb437ec0d210 100644 GIT binary patch delta 23 ecmeBV?PT4c#l)yES({0hRhfaENo8^@lMVnpyaajx delta 23 ecmeBV?PT4c#l$EvS({0hRhogFNoH~^lMVno)C6Jx diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$50.class b/_tests/out_app/com/lanremote/watch/MainActivity$50.class index ae4d07b9ac30f6363334b8e3e7b08fb20134f046..baef3ccaaca19695ca59308a743d424a55777e88 100644 GIT binary patch delta 46 zcmV+}0MY-81&9TZpArRgY-MF|2mlZP00!rsq34{Sk+eGjHL>tS0RfMbH3BgKkdtQu EBy>OzyZ`_I delta 49 zcmeBR?Pi@YUzDRbqqHO?zdVnFL4c8g?P=@gXLEK<+-S{cvhl48BV)~EBPK(}+Q}(Q FDgd(W5d#1K diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$51.class b/_tests/out_app/com/lanremote/watch/MainActivity$51.class index ee834360e8577f377988cafbbcfccdf8b1d3c538..61a818d08daf2572be72b183c24b067af30f90de 100644 GIT binary patch delta 49 zcmV-10M7r41&alcpA`smXmxaCZ+C7801yEH2IYmb=A62bvpWGbvF}6y0g{t60xff$?3=9k=49XL^CTMan@G&y57o`@LBo>u$FbFU*u)S>WeL7)17lROk zFgt??4}&O!7$bwi#JdU;PYE!3PQ0yb=aZFKmZ+bTn3t{}TvC*omu`(Iq8Y}{Ai>BW zgisn(nwOWDl#@E~hQ?$G#t=ru$+?WG;^GX90s;)I3~WF*F#$0*gCqkZgVf}ejM9nH zKt3Cg$G|cbNXjtqfMkGTDh$jZQ3f8ZEewo8yh8jt7z8&0`HTz*9n*knWPxH#3~~(e VK$->U9|Z;_Ad3sA4rCJ$008xpGlKvC delta 179 zcmeBTUC6?9>ff$?3=9k=4002>Ch&4G@GvwJYYOIoKcOLok3)>1Ecul zdd46|naP(JRmFuF7zG3vSQ*%WI+=i&n?ZwlFa6VBp;dWHT}#R5vp)Ge`i%m>47(q<}OF&?(XkvOpFWP#wq?AOHY) CRugXk diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$53.class b/_tests/out_app/com/lanremote/watch/MainActivity$53.class index cb43bb9d0d7e5a49cef4efed882f90a57dc52a00..ba9ceca4ae2a7a07890c580f5d311708e2f8d984 100644 GIT binary patch delta 19 bcmZ3;vXEuNIY!1Alg~5iGR~a*mr(@(N+<_H delta 19 bcmZ3;vXEuNIY!3T$>$k$8QUiRWmEwGNcaaF diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$54.class b/_tests/out_app/com/lanremote/watch/MainActivity$54.class index 44f2f4292bc1f12172c51b43ba90d8c461ff6b3b..5875ce079fac42518470ad55cdc4f42ca5838155 100644 GIT binary patch delta 242 zcmZ8cPYVHY5Ph@OEqDhFS|NpfE$CGCx?t0V1^i;tj)9DD%Z#utzg zCsQ-;&6_t(^VANic)V_R03DRKVj~4&i0hCB5-A+ME~7;1d!#qf*kUUDnC+?C@~eh_&LS#1`(|x4gdfE delta 512 zcmYk2O-~b16o%hJhndMxv|Ln*pjKOSiXT&;yY5*K4T+;B6Bo_xI96(Bh740(vNbWL zOzOs^OXG@!#e}5(0Db`pVJW!f4-n7L1ii_<=ktBe$^EXJsm8(i{x1M^)DvT8TgMeb z&h_8uC=e#zdNS@sUNh1ftt1E-Z^TXJ)KFZjAXRU380nZ9s9j2Mpryco0x>hgV=vvR@W+Ow~&h&Q( z74!8)Ib*b~^;XAo0{nKG``tzHCGAbGt^0-` zN)8Ndxn8RgR+FQ0W5m9k{NE@$jy8gJqxwoimEqZnqaB|2fA2hf9h+~x_D4+R#jC@w%+)M7~ES47cSgj7BJ2^3FJBqk! zJL6)?c2qHKJDRv|J9>&4JH;(gN^x(OMT871pfL6eDoP(F+nTb29G-Ax3>Io^6-<}0 NgcWuT7G=Bf`3nR0by@%b diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$55.class b/_tests/out_app/com/lanremote/watch/MainActivity$55.class index 76a7a80fdd67d1363d906187bf90cb0252a9331b..33e8600ee236ae044df1bbc04c7d21d2667f2c49 100644 GIT binary patch delta 92 zcmV-i0Hgop2Jr@v!x9l^VQpn!Vr*|?YhPn;ZEbIEk4p?XJKt+bCK1A4GibIljfbD=D4`#h>w$O0XqmZFfaiC qMkq{?@f4Gg0SvRx0U7}Tl9TEJRt=N@Bm|ZKCwAeji={ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$56.class b/_tests/out_app/com/lanremote/watch/MainActivity$56.class index fdcda22c8cb4474fb98377f0070c7e819e8c783f..4af580a65c6c534a1a313fb3e44791cd187a9e95 100644 GIT binary patch delta 79 zcmV-V0I>h-2IK~i!wU*)X>4p?XJKt+bCJ)44GibIljfbD=D4`#h>w$I0XqjZFfaiC lMktX4T{0nULj(wv%50 sI|ei`FaZEYlK}!Fv%>)y0Rfql-2zq(n*byPod75Vn*cBbo|8ZWBy$BJ9{>OV diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$57.class b/_tests/out_app/com/lanremote/watch/MainActivity$57.class index d4edd9dbd1602b373cf1c0e4cb39204c956abe3b..d35b73ae16406efbd2afd45983cc93f087c057f2 100644 GIT binary patch delta 77 zcmV-T0J8t-2I~fp!wd^@Wpi|Ia%Ep(Y;2L!gcB0xfuiN4jpc=?>4T{0nULj(wv%K5 jI{`JbpaDSv0k4zd0#*&N03-yn04M~p05Al!lSKn0cOW1~ delta 84 zcmV-a0IUD&2I&Tn!w?U0Wpi|LX=8L>b97;Jb#sx=gbfVonULj(w&#kr=752dW&t|~ qG%zp$07fX0Ng}h#0T}@Spp)bRRt=&6Bm|@YCq+d8Xw#cqm~MNv16YhH2R?2)^VGm zI*v|pYhONhgctCT!5Ytny2UQHDY*^lafYd;FZjzrzr({VrxTDxmtsTg4yXZEtWjE{ zmKe-v$wBDyXMPMYJ)Eu8xEAE*sc-`sQRJIkb){#b9(P?b98lrR4Iy~g2%IS55rLgX zM5RekpHfeQ<#O>n(1;}H%VLGB7HI`K#}=bf{RUR~4*h^m3}xC=B%yt#y@Kk$_YJZp z>29%d2zH1|`O6bOk={<`ueP?eYU>Es@;8sLcnXq-Mj&4>qkY9Z$p!6z0PYY(0(Y@W aQlr_ZVU4sox|t*yub5Q4N)*Jsl<^1Q@BW$q diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$58.class b/_tests/out_app/com/lanremote/watch/MainActivity$58.class index 1b167bdc20cb493b8fa3f3e94e209b47b67c9b46..f4ff28f91f80a06155f358f4197fcb1735db53bb 100644 GIT binary patch delta 516 zcmZ8dPixdb6#u;>O+q%>?X+sO{@L2qW@|Ou+MX6qf}ki0Q6YFJvvDlDY_cVj_1bz9 zPnO`pZ{SssWvSE;;7LD$CzXB)@dYo^ftfe&_vg)f^T9c?y{~@`z5v+3#=iINk(s2= zUDOG+7fKeRTy5o}%!MFWnM{?7Il^?VbdmRxVoMjS(JoFC>>ZiQRC}1mSqJBQEZ{t0 zzWh#eyZ7`kBbdW(pD+`&?)T+O8IFqHAbcQqI+f*OrN2aI2G9Pn1DS1y54(Mp6rJM+ z7mEb{=>6NDugAaNjDPLD^00)agDXC+VwuoLhH1zZa+MAX6_!6p@BlTLBueY%`ewUL zxD%A$XmPo7teO8_k9fvxyVH8=pj8^i{;hTR1h<#)v z0IJt91)D$B;9bKCqu0ul=ECkO=Qd}s#(Z9fUonh5VLhW*TlvIp`w(tyiW5<6iRmbw z5;IY3i`gi4#HlEDCpZ&La6vRCxcre#gsQB7#bqaGS_cR?wyn=Faf2BHA=(@*%r0RK MH~H$Zsp^f_U+&&--2eap delta 421 zcma)&O-lk%6o%hB*~~B+j$>M>efU+QCfWzMkoE=cilC)XNFhcNwakrS(q zAwd*mHuMAf9gfl_|3P$y7H&Im&$;jWoO=(PFaA!DK7TqN09KLIq{f=ltT(&uO7HsC z{HU7s8xb-CZ=P4p$2SoQgJ)pW`mI*CbJ=Se{l-=QzB4c`1dKC8HDA~jS(4y}*8w{w z7*sV!rkss!OPGRBfZu@tru8G16jw6oG(%0*HbR-)LH;D4D&|Xt)aG${zf{Qn%h7y6 zdF%9WJzv@_?v-hZfLZ;UX-w5CT!0slU`S9?X4u3a4F%@FK?K7Lm@H!yJZ*xbV<*B8 z)D6ym79!1)g6)BF7!He&w+z8@;f=q5DPxLLalOO66Mu!Ljmak>4Axu8`q9>c$M%d7 eM9AR~#XMCT?PLKly7`D$bFFay6~u`y^3pF<{9w@l diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$59.class b/_tests/out_app/com/lanremote/watch/MainActivity$59.class index 2d2af5510553ec91137fd29a27088ad5a31d0491..90395ec63476334e6d9fcf0da994f63d908ba14a 100644 GIT binary patch delta 458 zcma)2OG^S#6#mX+GjcPuF|Eu>P0L4-Jt?Gpi*`lOQYfSl1Brq*wJ}U0sG&uR!gd7` z1Vv^d(XReRj<)L`L}w_lWf$%}=X{U*!FiXj6!G>|ssNb4@Ub|Zwd%QiwYXP1ztk%k zJ$GS-jUem$8U5zb43*&cENAP5LbY^Q%a`k?XZ7pSXZb)uJ0YN5l1KGV&iF?K>~LDp zf(}CPMDjTqjfIv3y5LgKZGjs-1dn0Rjz}ou)6pm)dm{Bu&9BF|;v>m;DlxLKnO;jJ zV*f!jlDuvH#qEu`cxolNn&vPCecA^dQ&8KH+_HiQAz}pX{*e(x!vhZ_m_g_T*=%Tr z%zKgewlG8Ra_8gOKtwol4+=j(x#fu%2dfD%J@eZS%ddV6ih;rzz5}VPs#R@!T7Z3- tosJ?p8)jjAYkGmh^ol0<*&-o;0j?(A*&ssPxtKA+jbr~Q2s1k*i*M0_Wgh?l delta 510 zcmZ8d&ui2`7=4qZ$(W70omQ>ZA6vWHZ2ggK-P__x5G;y^R3UgMv*}o}Y?6}6dhJON zZzYH~!CS9_;##nOfG7P6JjjB7h4=+8(t(+8zW2WO<}nA(FWde0=kN$X550Z&&4wAL z+Z;`X#&adhaiO+~amFDStW2hgW1itenQ!M?qePb+XBq5WS;$noSipG)7d$NDBEv%U zgU#=4=saUE^TCi|*6%zV$`>*im&qu2BzOC@~`^S!Cwi7%a3{_nAj|(`K z7`*Ax%jxTP(^qeQzdvxXjJAVo9Z)9&@Y-w|Jk;sG%4CTIs<%*u=h?nT0sFPL!I|nr|FaZEY kD3M7bv$_Ep0RhXC)B;uw%>X0>&j2U{%>Xb2(33XM!T}ip b0l$;n0#*&d03-y%04M~(05Al^lRpC_&R-a` diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$61.class b/_tests/out_app/com/lanremote/watch/MainActivity$61.class index 00d388f3d08a24e6d73ea7b41b176fa6b2dc78f5..5523b1da8041682d7714f424579dcd83e63e004c 100644 GIT binary patch delta 70 zcmV-M0J;C$2HOUZ!vzFnX=7!P%Y+IE=8m`Ns<7p)kdsycI|nr|FaZEYD3M7bv$_Ep c0Rh#M)B;uw*8n60*#Ia6*8ngC+LJc}Bga-!ZioNBfmXlinI|el{FaZEYll=iBv%diu c0Rh~T*#cG#-T)*7-~cED-T*KJ;gdWAB;FDkZvX%Q delta 75 zcmV-R0JQ(x2H*ye!wLywZ)t8{W^8G2k;{Y%3FeBM=b^0SxPg;a0XqjYFfaiCMktX< hBD1;y836&!lhguM4bK211knH}1kV641k#f?10;NS8#n*} diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$63.class b/_tests/out_app/com/lanremote/watch/MainActivity$63.class index 190718c97115dc662ba0898c9d626365e1785df6..5193e49d4aac494c9c5209fd093fa373ae825d4c 100644 GIT binary patch literal 864 zcma)4%Wl(95Ir|>W5+QxF|Y6rsS8dDP5OXM5E81W1WG}|5XoXuInHh4$i9yJC}PQ$ zUtq0a(Rq5jo^#NE&8PV3>2m zpyfLv;z5}3)=MXGhpi3A6ZhT3+wqcD#+?<0LNfGXqs>t79_Qatp`a*3)lh=QP#!ru zPL^T2^`t-IZbE2xJ>ezy81iPLr{E+hZZJ?#W+-iOCrKl|8Kpvj&L9aV;0jJN6y4Ah zn_Ip&R&bU;#!ln}v5G3r%ebK7A}%phkD~V|4h7{1`y+;m+35adlqQ}}g^r1D6<4zG zs|<$uV*hoDZ|87;f@{R)=bN{C@80iDUaOdcA!A;{b=+X69mQa}p;V;fx;&1J<=bs) z>AHD*PY+R^;%Ft2C$>9{o{R>Ae$ecvsSr-z=M3}zk)Y9|RfRYWHkYWbyp%6S;^PM>6 zF>17l3K&eVayi>ZTF@N*B{a#pKw6#dnMG|bd;)8JhWvqU3^(a4kc9Mu&Rfv_`9CKV zvSO+EkuVEiq1bu-gk?+mtYy#W70WK@rz~66&serH!8vP!%X)2s`T?vCiC}kONP93z cwxwUlW062}XrWC~qIoZ2g*26tW%TI#1FMkJhyVZp literal 706 zcmah{ZEMp|6n;({nL^{1t-!4)I(S6!)PYa?Z)geO_|T{qp0}X8_Nz<)MPA3n#!0)Ce0%K8Z7-3pvS+ zj8BD0#_@qr`gLN|OqsLj`5vKW#;S~V3G4lN=l89^LI4jw!JCOJQaY6%2-Nct;HHaP z0U8Ji;fa_Dt38SjhbJ;Itn*T7WnK}gt@gmj5^lS=6JQxl!sG)o%N2L2rInNNtTyNj}D|6=V^d- zJFf*h8a1MC?OdUL&Mm?Njy2A3zTzR*6@>h@f=3)JxWyey7j!Nddja<&7XKoB j`_1`=y7QeS9{<8WVQbvvg|A_QtEE2-T(a2+n>Ftz%m<;* diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$64$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$64$1.class deleted file mode 100644 index f2b0302ed8a64affa9987d15b79d8104370bd996..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1638 zcma)6>rN9v6#k}VSy~3D74J7L1=@0HfvRXlL_jRIs8m7mmSx$%YS}frQ`Em2U%^CQ zz~3e!MHAz16W>B#!Wi{TDYg<4y2HIDGIk_*S)C z$P~tk%MeIE)K;}vUYNO9+H^(I4~_;EjT}t~(0Xy1@GQlyE<7}JL6y&$mMP9Nc*Bud zAA;DcqM2hK_A|6sfn^JOoS|)3?1bka4yicIaRf&hT6QbIP_OB_;kuzje|&&pJiL32 z?Hok5J5(HJ*jr|5)N!Pp|wq3)+5;V;Cg*!JPz{a&3$1yMm4~ z8LnWD(k3^j#o}4vnAXw`p3>Ubihl zzZBj7eI>M&+(;HfxXh7~o4wlNqQr8ABaKW2mcck->8J)vO2%L1xQ6TH_`x9=-&sX` zDl)4gyCr`S=}g|Tw1u3(FtmFD>KG-;%fESKmhQ)xy+|3F(xzoh=T{aC=f-AEU)Ef* zG?8~`)$8O*+J>yq^hcy-X@@^+=N;X+WJ(BoD%(?^yi=5P!qRiLOKN8fv1~7L+>-5t zr!32G#&VkL8ZKI>DRD5&z!M0_-UN`PHb|Y(LvIsqkiAKxF0x+fiFU7nMSIquM%PjQ zj$910^sOZer5$rL_8>qrsz+ZL{Eb3u$i9fa2YZh_WYhu<%$25}aP0OvP8850J?aM9 z8PeSwh%mgyFb!f1Uoe>I37!m|F5t{`DXxH#-VKa-@CMEQxh6fBd;8~0$@gz*s(@(; z*i!`DN#}*qi6*5Btx6c@l?W!4DCU)JJW_gLDSddZ#BiG;IK01PABc|pEb=g4%yI{J W$<|Npdr!3M=@L)MSb^oQ0yE3z&M9OAc3TuWY5f*b7sD8W@rBV`~C|+7q0u@f?GnF z3=hf~nssYdNo%IfXDx>-4>U)gR>m~LyrDbByx}|w_jH954CT(WkqLK_!{{+QRDt4^ z;e(&SGq0t?oS|-XMw{0Z!%{}Zhac*E&M_=g^+QI5gi0Az2rvY8-VmXbGCnoKb%&t) z4AXE17~GNQqz^&VNT`)jhm#CV#~`yuKF-islDsP66hmmA#e`!UX6k0vNGG}NM+g;O z)HBo&Tv++Mz42bWIw#{iE)cXfH^O?2 zEaBF(r<=JK`Gv27V?xGdL2wVfGai@F%@ExGId`+BsZFIR{r=LdsFT$rTQEAwP&Q;G zDNNOxxM2a2bcPU;R$ANom_(L4@dkhCCS2T6)05}@@T DbbmAf diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$64.class b/_tests/out_app/com/lanremote/watch/MainActivity$64.class index 2d3da204a0493f17a6f7d461e08c494013f9440d..ed31c55e723596cded274b775a0af318df78e2b7 100644 GIT binary patch literal 706 zcmah{-D}fO6#tzzHcJ|9HeLOix{tWE9V`QfAQM4~LZNdH8-2e`F72J^4J5an`?Cfc z6CeBo{DTC25YJUXaSwgSIVUIg_mOk%mmi-#1K7ishYG4LoB%gaBWxu3B+i5`G@ zYrffDd*I?8VNK|?$W7? z-jNzEY(XCUC%)?| zGYX83{s4cJ(JE z`WCkZMVoLuM@%u)q|(ndG~$wqCLPU)Gjvpo&romKHV?z(;8-6anoCuOls?yGwMco! zb+{+Y7+MIAy`7KYDue!pTcQx~*RB%^4XwDYqD{vQv@^7xhj1zMJv#5NZ!siOudARL#Gk^S;E1}NM-PIB5=Hozu;%7Ce-Cd^y;{adkk8sAtN%_=!N=WdHKM1@`fK8 z3$%85V5*?g-RBjzLXOFP4F(3Jz6LLpk0flC)vAbJ3hzFKRgCBu#TY~UUr1Ey9X}+& zUMm%-sN&Uv=UMA6H`6awm<*STy>@(Kab@#+mX?ffADHap^qKo$>RW;ZoM|_|wAnIuD*nrX=j3K=N+L3Cn&<>MdEd zoL>lRKIcf6=JGDxlPDBC%?gk zA@f^7*4pHdF+pi`dhrph*+2G)wM@?urlkEqro%WU>4_8pBnUi| z4n&ns+)@&_t#n~r8N;N~k11sUtI9a$>5PiVdllm?glvJf5iDYf<|tjpCwNM$Hu@r2 M%3+*Lq_5)m4dy+SfdBvi diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$65.class b/_tests/out_app/com/lanremote/watch/MainActivity$65.class index 079b59e347881fc6a809e4f0b8e5381a0da7ae5e..8e53d3a28e7e7f99c2630fac08f0050d0419750f 100644 GIT binary patch literal 1539 zcma)6T~pIQ6g^uSQbJTLD2O_0DcTk!e2G9PepEg!;7|t#eQ-j$LNFwqWUKPvKk;2( znNeYM^auE(9PgH*wc?bWNp|<kJzF@h9I=}myT#Bfwwy3I$dJf4@;5ca(4s=q(F&cRZQFWf$qbvu z%EmUg1x1^3JV#73G^NsO8cySkiZ&f*5ofsCC_Y20W!pRqlcR%!glIO^7*hIJm(>d8 zoa=B;m@#w^9($)4!+8e%1-C>w;Llwr6dJm4K}ENYi@3zlbsWOQ(D&%Pzp>4bOg+oj zlQz?brm}EcDsRbDs2en_4;rrGnu>&u9wh(6zZNM&)V5rg;aaLLH;jeZ?BF~Fcz8!|3~ji0C=mRAgXr)c=0 zF;8ok2c`---FsYd>*Sam(qLd%>TBdg`AEWMO|2^UrSNWHOvP;-(ao^#?t!(n$v+$2tq47>$dUB{trE1C z7A@2F2`sY@b&qxoGxTnz8OkudXQ5a9-%*q%`c}w%1UtYfgwJ`M!AX0d6cEdi8yUv7YJQ$IxetK6#I>{BQfzdZuq5)6%{t(_s=>dLk772?7sg z3{mAat|;TUu1sK3SwU8r!?ZGwRpk-p=!}ZUTNRTXglwL+5iDSl<|tjphghanH+_*T M(pP2t0v|V)MgRZ+ delta 382 zcmZ8cO;5r=6rAk`rDdfR6~DC#iCRs-q@Fx_MGrzW>S+lLsVS>zDDmdOqbJf|Vgk`{ z@CWdt)K`obH`zCDXYyv=zVL}|O}{3e0DM%3mhep|$a9#m81e%VRl|PJW3U_dVjyZs zKa6VU;_=i1$DDy8hYe@=R4taUU|^AB31tR1Wtuyj7IJ>Q&furtYNhqh;NZ%@3PWjT z8wuI1T_#eBb`&sZXAd0$yAjIZBI&h*xb-WT&9Ey)KZ!|I<3y%S?L^(dZu+X-JaaHC zLvfsJd4Nzyjy|T)wNWNqrEZT<%^2@+4CWn;VR-K-yb;B)Mm0wW{Tl1kD{$zwF{|0| uJ~5<%dn1}1!T7-Z-{dhUm9q3(6!bgF*qj+`VVh7x2`(zsS!9rHMCS(*T0Q3g diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$66.class b/_tests/out_app/com/lanremote/watch/MainActivity$66.class index 3ab20877d8e8e20517b7477437b936f7c100afd9..eb24acd881f80616012eeaf0faf9dbc09ea8f1d9 100644 GIT binary patch delta 145 zcmZ3@x{Gx}xfDlPVvb5?Nop=51HVsJVp*boX-Q^|zHef|#OVc$CX+cBRd`W^C)+YM zIPesu=B1<-xfkV^78Elw7;E?>=A{(nXQt?vXQrg5mgxIr=A|YU`6O26mzG#ZLk!MI i%uCk~E-A{)OP~CNF@-UAvL}-zYXJi{YvJT}CKUkea4^sS delta 133 zcmdnRx|(%DxdK;NVvb5lVo`c(2_u8BPhwt5QGRBMepzN}xqcXsm^iV3*+Ro-;zb2v zYfRb6#*7W>0=fBRsUe9;o_Q&m$%!TTMT`t$8d%hRW=vr$m>j`m$y&_7%~~>f8j}hD D9|J8+ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$67.class b/_tests/out_app/com/lanremote/watch/MainActivity$67.class index 4325c83e85aaf8d0d8a2a90177ccf62d4a05801f..57e859d6caf06b77afb3922c41c342abc6691333 100644 GIT binary patch delta 338 zcmZurO-sW-6r9aA4Q`^1sn&0;T4)=^9Q;7YU+Bq0@2d@^QX+)59=!>A5JG;2C!t00 zAc!~5{u@D@6pt?K%-eZy_RYKfXsXYj*Eax1sHG}t4s_Cu1_I@ljyuU!uM>=V(RI)! zgbEI3EzCJ6!A-wvwuiEX1qX|$WDnv{o;F%pDyj$JKjvoJ!m@yOsYg2K>$n@74dYm! z_oM7W)(bKj#@UBFmfP6LzGZlWDh%!uSc2iYn1KSwCW;Je@Rm>^udu3Vs(T9c_#94Bzn#3>7>(a+G&JvZ>Bo8_LYa3y^&TjJ%an2uikfDT*{YU@q7(H28@ zGFAN3P0qv_hR&urU-r3*V*o=IY@DUQpKHtZ5kw^)$9W`8q%5Q{%rLN58W}pc+7e@vcO=K;M;1WaPF9gLwK^OT8zp5VbfLB6>^Az)6 zz`KE8UAK1wsnnwwGjYYjxGvMsKE7cvHl(XoHLpnvS8Y8L{WxHc!D}2wU4|P=t-5x-Hh)F z;!%{In6FlrMXj!yL*9f|4t!YiWa)H3{x<=y7X88NYV_5BwhPR(mjtP4JDdWE%ZNz zX_B`|n+rBaW^7%4+WAj8V&a`M_M24=+N~u Qig`SubWPC=d)(Ik6NJ8d+W-In diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$68$1.class deleted file mode 100644 index c3aa3e144c4d15c865a9f969552f7e13e9424703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 974 zcma)5Yikoh6g`vInsjW^_+1h#;*(AXTs>{k+)@$&}5k?9N8~NrDPO zi@^{60DqKtry?o&k}T|;JNI(WJu`du_n)7?0PJBqj}+1tG8|dt7;1hvasy9AViaoO ze)P0IbPqkLUin&%rT%0;Z`s=nIX#rI32NO1I@)H+HI6(A427{5*n=ocKEw>A?wL3C z+(gU3?aElU3*fkJVVR?d64fuuBxvBi>z@f<)94pcN&S)`U3YqUlySqtO^#c*%}`xH z%zyDshPAokNzYx}vv8l|0Uk0`<_%z2@_b*!vAws`++jGV&z~`S2hME5hCRoK)1-InQJU!cF^^en71l&9a~eWH}t4+KMNJ^@*#J$oXj$B@|% zPl=(}l}fx#Mtu<-|KrRb$$|285>YiBC5k+A6ef``-bgcY<*FaIj3^>`t^6R2r5YRx zJq%AdHjI6EM=25Q2VNYD7!`6M2VELcrIIltKuEr)Um-up8%K=U5@ICb)Al-J0wKk0y9BWn4A` kvtLlmenmC=jdq)t+|RH@m?59LXi`)r_A;S)jA(lP0vLYrH~;_u diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68$2.class b/_tests/out_app/com/lanremote/watch/MainActivity$68$2.class deleted file mode 100644 index af6a7bc1cbaa985503529705fd6e3940216a02c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1121 zcma)5TTc^F5dKa}*|siF3Ro2{2u0d**@6n_0$vCvBp^l%iI2*Uber~ymNSIIS8R`K_YBWm(q;`yTr)ju&GH_^hezW>4E5fU<;DleVdg(PbcqsF z5kiMAFHbf|^%`Gl-(dF-619tXATK?+5Cl0#e}34Yf>pk~{;|FO>v^&A=$WK5 zq~g5fvIjJnPAj-b=KKHQc3uc$F-Mt>*Cs$ss~_5J$pwbGNjpPfnr1A)Z{}APxpSu) zI5cl%h2iBLdi6VbL44-zykqhkmc(uTcPs`ZGYT>#%$)5~hs<(s$L% oKYF7C4;0W9C_)RA=%nx9jA5K)9gW@uu8?ldOWCFDQ0-#GLJ^%m! diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$68.class b/_tests/out_app/com/lanremote/watch/MainActivity$68.class index 442a1cdd6e1fe3aef28ae314b822208ec65c73da..e36348e8d5e2d6f404449b8a8ee8b9ba7f561fab 100644 GIT binary patch literal 1473 zcma)6TT|0O6#f<(0wpR{K}A%QC~dJ86#=b^h*YcPqGIKBNmf{r5YygX>1L<_n$=NS^zH{00RjbXs?E7>yl z0#Wgm$gXkatYl}n^d=o8Yf?S4N5>i3)QSx4VTM!1o$xvBpxr_T;tcT`FWaHwfqKlq z3VO@3ut#sT>n-8AkF{~1g|0vGi`u;3!hx;W#W=drW8#p7!#KjwwaRN;vtQ26Ev*Vi z5!0*Elj=G{Ycf@e<0$$}9J6p7Cm2p}&kcO(X1OOTTuI-{mUy`;CZktBpaYkMT7MI6 zw8d~RnJR8~lQVIWVPDgnFZ*1@(T@QOHcnIEPqby@2%?gYV-QIbDGO-~G4$`0MutxA zI3f(~k+ES){8)15%%_T*0oJ5g%a&-##gV}|6IlzxIM0yy3qf&E&_({tuc~<-@Jh%q zNHPBfydC(}r}kDLmHHw^OkA=ss>?L6i*Fc=HR-Aq&1>AkWn3Ww;dTPaGN$o$Sw>Nv>(NmWzQIw$)o}i9M?IQ05dQv5F zH|4v6cod~4W~-GY5iIbfGFdtv%4LtMYCw9z_tt$;`>i`;u$$rjhhI*gAAjgq14rbg zJ~OfPlAhD8NS>!Xr|gH)Tb>bW#dj^-Ly@|=;@1RGDR{1QxYCW@)AZQv745?+cog?k znTMeWP0SH=Gj+d#Q6~^|YcCM)-zlU^(L%SDVFE^1mmUiMNtywXq-!5Zy62Ikp+s}4 zh5m;zN%AIX<0Q4UKRr|jOZU}brjOOpneL9&u{Ygq)R9Pk#=(z-VVI(|jU6)M!cE7FT7r;z>cK`qY literal 2106 zcma)7ZBrXn6n<{VWkXmBO=$~N2TIeLgqQGEN=b_%g%%7!NhOA=b+g&R!m^9I8`}N= zKXs;lbH;w~t##0EGNTY=bo{J;L;r*y#B(=6Qc5T6B)fOdJ@?$_dG0x9|M>Tp-vA6^ zy$(LqNbt+xsAagITB~tGF>P(ta<%w<#Z{N$Gm37Gsjj}RyAQ)d!wj|VlJ10i89I{H z{1ZV0P$wZMqaHFt)3UOzh#eN=v$c637PLw@ zC*u{o%FuEY!fD4cX}y(OW(Y^_BoC8LL`$ZEs~aTmT7uJQ(3NTrq8;ZYbjY}X@Jskt zB4zNaieWIEj~og)eRz)mE-?hkR2(66L`D>`0|^Ivg@gl#BUQjpyS8pFJ_kQZnj|E2 zF`V*fdYr~=TX`sEg6KqDJ>uw<&?loG0}Lk){DcdE(p}9~T+3!S6RCtfRkkPUaT!Ar zuE-e1Rff<(gnbtVX-RQX+5?J5iL@YABxGE}b&|;R%+33;E7FgUzroOO5RXnHfH4NX zXj=suj#GmE{pr?UyPu1LpO7($o3w-D64;y7m3pam9)_};d*uL4!AtOVd?@-ke}>>G0h z-N;XtnkU7gPC8|pnmukPj-xr$>U{KdV9>#un#2c)TA+{q^;jXRL8A_O7nWeG^JlQw zHl$5@V_2noEj_`X(A|Vw@@HKXMWVT9v7f+-XyCshkgW-wN@x9{)9E74HsiG|v?V)3 z7mIj(W*42=ZFHx)cF@Bx;&1iuV36Sp^oh|3!|xa!;jKLMX6UUVZj97+x7P09mJd%L zd&8s;nT@?a#co|i%w|g4rgxFa#=5tW-NFZ%4YVZxx9795&TZTi_Dz8wkMI~d;wTu6 zqLCPV2|xb|ZTxFo;@_Z$e~To4iVS~-9RCh0d;=!`9{2eVc*Ki%%zwmZd=pRk4lIfh z1K-0CxCEbsQ3-MHcbPu+|G>A0HZYnIw_G!7utqC<_yjgR{Z#1=3N$)LGeUK7Wl9f9 HUhn!3dz>-D diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$69.class b/_tests/out_app/com/lanremote/watch/MainActivity$69.class index 64413c6c9a76a963f7eeef521a94eceb6ad6ad52..197997d8e3668831ea6686f8589310e7280e23b9 100644 GIT binary patch literal 2106 zcma)7-%}e^6#j0=Wy7)*n$i}kPAN@m5`F|IMIt?fsg=H6aH?;i& zeCkYnb4FivrmuFUg;2)olg{Xj{uBC7_#mFU39_Yh!cMY#_uO;O{l4#>bN07?pFIaK zh>Zq>P$wa*fTNz_yk@VZOx1GqHQUoucU4bYPR*!>HKuvShT+|h4ZY1!?=2f{tdF5H zQ_DXsBZ3AAvVuk^3@t0_hAMVgO3f8kbj_n^R}9PWMj7hji3J(Q&@7=v!Ersd zXjC;#ciq@vUmqcwjMs*gIMij{r#x;Ny5*%KXeB(_S}}rC49Xo{^-7L@+caEHMjP5C zoKf%^UT0`K4B@nETeRLTtT4plw=xGwrxO)Z$umrncRkJNH0YY`kkNs25;_%}N9+~+ zgGd>|nrfO1=i&!KP9NMOf{P51Div1<9aoS*G9Y2UknqyMNCEg+&oQi}7vN_|leC0x zh7&$bpVOG*sQ1N;j4m`ZB85H){R#$fiQ(A3pKu{ihNnBKXFCk1;z8I`ReQP-moX&a zEd|4%1dQ%S*mGf!mQ{CFzen*Xkru^@w1O+RN)lPVxpgmgApHpWI}AA?5=IInX!2aP0`S5J(#TAcN}r1`+!v6Y&3&JI0#-&7{}GXVUGzjYAC8{PmT|fjZ*1ahrYm}( zj5lX?(3Rgp&usTLdKpH-?cr?7J7LZ8NQ7ie})3z!75R*_;cLlKj8uY0}uHx_=^9E@A#jvDMk`} z7en9@LJ~$Lr2OAy`q=-2&@Rq`(TuniT2Y5}S`oq*aOfGPN_SDB(HWW%s*5XEc~J8D F*MBJ>Ga~>1 delta 353 zcmZWj%}T>S7@SSqjY-yajkQ1i)>{0DN-HQ8`U>6(-uF;KrKA#?;!S)3AtjhZyd%Vh3~Rfh!$D3!AK bCtUtT8QTkk9qbY=!9x`_Vh$N(jp+OWpOH47 diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$7.class b/_tests/out_app/com/lanremote/watch/MainActivity$7.class index 74280f31aebf1f9a50bdcb475405e229663d265c..04ed7bc6b5d8835954ca1eeb11cb625c8f8adddc 100644 GIT binary patch delta 23 ecmeBV?PT4c#l+}1S({0h)rEnb$#rrplMVntc?8`6 delta 23 ecmeBV?PT4c#l&baS({0h)tZ5w$!2malMVnsbOgcx diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$70.class b/_tests/out_app/com/lanremote/watch/MainActivity$70.class index dd0731f7c794b2043063ec1a9fb9f956318b0328..a4df735dd95487c6529a0b1b73ad89b20e9c91b4 100644 GIT binary patch delta 419 zcmZWlT}uK%6g{)Lt~;*nW@X=H7GztY4@N?dLLf*F83nzKxGWpnD61>I^$!B`mjp`D zLqDJ&5fuH7=&n8Z(7@bt=bn4c%$;xTLFMm1uWta#*dB0E(V-w|KnG{g`@-LGgLc?q z$kcB{Ule^I-D0B`c+x%LFp$|JVDBI~L1c_^i5`VK?9+HMh;bx%5H;ce3iF8&C;;ki=u!hqOV5X#}Ba&*51i+Y=( z=eJIs2}#`yMomg5sQRMYb-KvWXa*P-p;%TlAwZ}iLDMU=O=Jm|DcdECMHPF03}zpU zq1n$!JyD5ag<^sb>IYUSS72S!aSay5NL`>3(J<^0iAT_0FgulUVxfK_rGC*J>l1?w SY!b%l`*O%r#z`N&h}JJs|4ShN literal 996 zcma)*-A)rh6vzM5uib7}C{+o{6L=*HajAv~Fjgr)hnK?6a=6C+*%*>Zxd;0(uv6My(aRUh(Nu(I2 zT-|m&u6)tfp>Uq_(A{!Yxm0)DQ0~a^MSiKwkP5eCkSAlZHpstX!9?1CWg`QdA-m0Y zcobpNS!-?!Hze9EsbqMYAzmo1n;0U)ZMRHh8H~23E$y4gF~qd8a0;gljM_MZF@}*N z*j=qX!4)0Z;a*3qZ7_@$9v5r>c#xoTIB(#Bjf=R%Fm_x94+2f7xNdGU57Z%G@ z+U3IF+IDpvis+MB4}GaNE5&sK1qO$!mak>YakUDC3Y~kBdwO$)CjJI@MSe|HJ?TEJ z$$++n&oKQT(TnR0iK=c1hT)o2;$f%V6#gUL^k~JFl>6VR!=tT`tQXA55uXdjbUBil^9I74<0udOv!7x5R0I~n7 z04C^Pm|&O%%Vwj$LEj}tErTMhmdKi&S-GAVGD-3xZl=<|0UenHn*aa+ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$71.class b/_tests/out_app/com/lanremote/watch/MainActivity$71.class index 4307f4a89d4ba6277e52a368e5c66414f1cdb8ca..998b137c971e132136d2468966762ddd74ec1375 100644 GIT binary patch delta 395 zcmZWlJ5R!36g{`leo*Rzs^A-Z0pg=7qJweK$wcD7R3{rngMl^yb#?LwkUHq%=peQFIf+`__?Vlq`GCSaF-m+6VT=fjY8XR;5NBT8%~6Q5u1Pt6ieR`mQp|lMFEIy= z(7Nr|yTwX5_qQ|4t=V>=vRbmL)k0Mu>t0LC(Ilw79WoTYQeq2X9E7y{E%hBwaIJ6! z`Gfr=rkWkbR+;bPm`vY;!~+7RPjAVae!VSk%6d3&Dn1V#c@I5$%)`(vQwdEmwK+om iL_+>zfoVo07?|Pc=j&#X;Y??&xd;&C3Hp&*7u_t`|y!k3*rl0>PD%!a3NhuNK*->sU!uL;wO~o(v|yy zpjbp*iy(f5D1L-Eq9V9)acAzF;hcN!%$sy9$q(<>cL3&4Y|45}Mi>zZvVtg-=1F#< zgAg!kl`s;7m}fS24s_ez*Y{0VMiN~Tx)t=G*Po?zwV$B8w(eeTThC`lFV}4ugBX%9 ztRRIEg6cn!>fcb5=KXuxSWpQ?-Kn^Bt5P)TPQ!E>#Z^nU>$_`QxK-USvzyL}Z5ewd z%j34`5_0|{WqeCW6{PxCkFm+pKRV0)sjzgYK_MjgsjB7HwtxP?rq>+SUa@u0Gd&3f z!stJGg!mt4P$BT_mv1|$T9s1ketpTNC6@Cit?j8UW>SZVjozTKHO^WKPGLj7^L_Z>hBcgCt`?775y7FQToII1#cQpT$9f~>|a zg!L7Lk4zDod}cu-K>3ZUe376teGS+7OtUxnIjz^QK7+(JXl}+RI<9P-bX>(a?YLQf z5DP04l$;3`jq(JSj!PSmx$++@DTgRC)|DgfW%Zv<3pW^L_Vgx7YaALZluD(m&h+i9 F`WFn~bFu&c diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$73.class b/_tests/out_app/com/lanremote/watch/MainActivity$73.class index 690bb1a51d2744a1b6b1d3d9cecdc37ece61bdc7..627367f97a1a04a8e7919fc4639a1371c04810f8 100644 GIT binary patch delta 586 zcmZWlO-~b16g_u3%xj0C3{$=YD;26O)7pwqzgV)G1Ov$slZHeyma!Dtsp(A7m0h`X zL1tlM{0$~X(S}5Sfd2y){s`lJrilw*^6t6sym#-p?+nE+q5u4S{sX`U9-iu+oq~d- zffRJY_=(rfcUnO|@!^3`b(0v&^janVQ zm&7bVjOumF~=$`S}1NyCb7Bi#A`}+A?3E3ffx0AK0)gSyRF7CLFIuS$FG&z;2un#{spt{ z=(BtBudMF`f#2I|dtoTu5+ecR*iZw65){*vz53 zJ}$wRLZN>_=w*@CXA#6-(F*b+`&D%xoH-J}(55Jao9uo-th5DK;WO9R&!tZl9v zxK1$IaS*-UNZF^&m> zll~+pJ)siKrQc||SR+hjlr^>(#arE{Dq0VNgfO1|pwYCYjZBMnl&1)TwWa^{KCZtY zl>M$+d$+9ORrMwe(tT}ydln}1ank0etgz_kNyxeuqbNw8gsRgCI(c|85e_t77GV-( zSr~xMTh#cJP-oU^`_mTyw{d4IWLdz# zDGp(x1athx*%g?^42NZ67B++RSU#1FP^yE5Ka6|ofPwdtd;MtmjG<7gKQeHJf=<6{ z;4FhNhyt}YibDhE38p~cA}(=UHgN^>-_~iAGfP+Pjs);2m zGgQ)-TE%22$xbIolImJBoz+^!8pCQV`qgu#~>g7F+d)48`?9H(;3A z>Z#!VsJ|b?J90lHgb!5CVVJ9J{Xfv%j9T`Gvh$=Rqnwfc(wc|2KpM(XJfK;(X{rZt zNV+$b3gY!pCP|Rw=|3r`K%;Fktbkb-rl8ZBH995K33VxQD9;e#dhfy9kKk`9WAMo8 zSuq6iM+(OAK?GcpJAM}Bs9_lFS(uB*_nA}XP*7>`}21I3wYRz7~(1tI(ncmOgere z@7i9#8-B#|Z|uma=eKO(tvZp|7tvc|ae+aJ>LN4>3=^B@`IjU>Qb#W|hUC8O8g{c8 zG7RnPZ0_0nwglVFTTvjq-I4|!{VFc#7(j|4b?Wf6y2qV}ke7ugq7{a?ncYqzO^VZ~ zB{9U%^SULRS4mtZ8)0Y|!4(x(b&TQ~!$`M6ABVn2-hOqD!7w`uYU7o%?HXH=aH$K; zl8WmL!>47n>t1UKx5fhvW9Uy}oT~iw{=?6&@1$W;hk=`9qLR2*W0*6~-uncmqZKD9 zsUu^U)?p$`-HZG-9(B(=8$!M_m{l>SV;*^iOt%|%j*_9zb{rmtMscA)!(2DdpXNXP z=yH{96}K47^T3UA-SH_&ys)b-S$Zw8>)BB& zAYJjjXT%KhHaBFDX8Ena;p;*=4t92AUQU+MJ@Opa4~damJgWOO9m|rAdXus|mn2(l zHfg)zc|0h)b{KLZw0d$OWQg!O7z&7`Qj+%oNqSxM;9~R-ph)sIY11U*(vq7w0L#rD zK+PSY?<1KQy-iREqh%a-NRJ^!ySrGVRR+JLQ<<)u`vj(bgILi0dw*ex!cN0nmFAxBvhE diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$75.class b/_tests/out_app/com/lanremote/watch/MainActivity$75.class index ca894672d9dc3ee28d52b4dba0fcd5a346c1bd1c..c358251e25401a4217244c780079eeed0c2ec207 100644 GIT binary patch delta 79 zcmcb~ah_vBIR{5sVvb5;LBYhPP=40J(#+&+4hAts24t3GV!Y)Vz2IpMyb+k%0}u-uPITkzE5ztr?uyY$q$T=l}pPNgG4} diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$76$1$1.class deleted file mode 100644 index ba5646aba7805a98f405fa3a301ff96c400539fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1201 zcma)*%TE(Q9LK-Ymfb!UC{Km@z_OLLAO%Da7eQ<^Y0|0@gNk}smI)nbcggM+qyL8| zPkQl8Qloef-oqL2R2aJXENd zDJo!2#k__E++>*O1Yk&*mc>0!UtF0ZL@C>uaVA==uo(YKa$72HF=RXO^iv zE&fnQ<=4;Wmv5%}}1wxPm)v`ws}y3v8!8 zVknhwf;G{5f+2=ah#a>CV}al^MqB!^)Cn$y@Cy@P&@-5>w4{0y*|z^b{mggFN`5Hd zkIVBa&oQjLz=*PqjPeSG@)|FdH!#R5hR86|8!BBhjMc#HF4ky{P$R5kgH~x;=V+Es QUuz1kE0>E@lJ}MPU+L%}&;S4c diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$76$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$76$1.class deleted file mode 100644 index aa71a31192ad1e96a85df748b02c3c463f9d339b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1351 zcma)6TTc@~6#j_t)A7NLn#+S|)30kvoc|rXnMXZValHKBrsr!M@&Lj6twcy2+K6#2!w>`7Is^ zUicWib4ogBC>hOU@F!-JIVF;}^mHVlo0e@TNvPn49&R|RPnkYc!Y`sqLIBlt$UZ|L zrz2yNGn#4{oP(mH*8TS8s1%s%Y!ZJEB>s?39Glyk4ZshN0z#(u!$nriej?kYi9wuR_IA($GjHTnUDgaQXebk^z!b zsJ5i$LH(qRz5sei_LA04(oM2PZd(D9+gBjU9V;l8KV$zA{inR8Ss)4F3Qmz;hH6?7 zXqvePwYZBec^Tpt)G$1UpLDj2LxILsG%al}Lfm2v@^y0FzKRIl4$n>a%jF0gu<;}^n4-{qb&tpVf5L=Ea@?%=x+}5^p%%)=v0O)Wj}+d-=UY|>3*x6NME+fUFj=U zS)l`UxR5AV#~A#Ik@694rAH4j7TMzkQV((W7}+yGO@>rnVnwkivxg_J3wT5_L8Dc~ RW75*(BZ1|UC+m`;{RL3e7jOUo literal 1504 zcma)6+fEZv6kP|(Oc@4&dckW%DA<-uK~TU!6l=MbYYhf{Fw^M-4oqj7nW>j=Fwr+( zeDO_RNfd}iefK|%{DE)AeM${bF=dj@xy7Wdu~zKw@Y! zoLt<}ZP&~>z8Qb2`^H*)R_FGZ;qxcle;yvZ!65l-+zaYYKv>m7%DpgUZmj zu0PR5h1K|cX5BP=$~MAn?vFB5N21FLPM|?XqlzYk7+NajXQ03`1?+b&SUr_k0CsaZbj06&G-k;p|cHrai}|KRcOq zhH&Ijs+_SF-P=p1BqUEx011RYe=Z{~>=6CPTo`EsNnwq%7rhd5s`C z7;0=iXL>^7h>9p;`{E4@3Gw>N(;UE__FZnT9>Sg?K}e}Cf|2LhV%{DVaZo0u)RaMy zcIa3=(e143@NC@i;*&JDOjna}jiLQ0jms<W)VyZZ1hzboNrgw)KoZZVuJT7Wz8srlqH!_52Kv4gnH zpzglh{_*Pd?zcBT-@g!j8B=i=iT%D@?-K&1DibQPDV8s_YLCz1s#@d(E2unv+#AFRqHZJU2EL=;YDqCaI=u+jJ9_?s=w1=cJ166^46Yjg8{! zqVrQlzdDT5?gY(RX)Sh?Sm#Hu*f!)%T9G;EEzt-mLGLN3rTDkB8=!BCu@7K7sIU2g z;7WDqWO^kKI-TA@^C?`~!sS$FsC@@lXFEfkG|cShX%d&EDRfBF=$2-1S2B>4bc{3agwE}2hR;{I|1>-}$RPPk+zWRzw5BS>YYKv>m7%DpgUZmb ztgq^#!sGbV;<9P@lx>*X+#g}6jzs4a97Mg01{H@8VrZ_EpP^1S4Ab+%{oUP!C>f~? zDY|FKY=QEq#ZBASf;dcgjBF-|qYSk<*D)3>?)eIua9qX-6(@0up=mF8=p2=nf&1jL)s-g|y|Bx?*$q+Df%VKDWl%*UmuMtE$ zLyfIxO;1Q1Q4vL~M7)bayuR`@CD_xx%k9U1VNa1Dq||wWk>i;{-Yyk!P$s3=lp&FJ zXkR_i?TqX2Ox*F}w`gvet|p_G;p|=-msv90qo4=@0q(@drV>vKGv{;14&n-f zy8UAF`^#6`Utj-t_gwVlnu_Zf-R;XjuMjX*nNX2Up?tAbJAD4Gszv8yB#6qcDgS^a zrT0-LI!Ba8os7v)pW?PTna?ho?j6CFJn!bo`3KUzZmf{8i@QqN$-9P`QxO#fp+Gm3BBH286N8dWhdk7fEIyd7yNnB6OWL~;e}j)c z8sn?4CK8E8efNimzruJ5MiwENP1moL07)C4I^6jKRR!0sRgVuI^L%LCe zO9ocfpyPy!lREm4XUK23Z8s_2uvTgt+@l<2K?FkHV@R9D)hr6+c+Gkirx|ivQRqG2 z6uzIu0L8aNUBe*GsW`9W0)`m+5An)@+L z!{BlKJ&MDCDu%TU2E%;TfpWmx{sY{S!YA>z>?{lheed(DA8+3N{Pue9-AfGQN}_mKTyt zfwUe7*AE+sX3?7KabqP|^o6%s5iu<%Do*@I^>T5QA+s3PIYVDX1pG;>S>w@Dx8_qM z=ZHq&%2q_a8Xn*?jGL7%TV%?VqP$CpF`)!(VpbaJ9p@L}NjVN6XfbGaVYbGH_r<`g};fzyNXhGe@S!Wj)3uC(&Kg87%B1#)YWt%Xr@1xJ* Z2H6bV*PFOaUV(BFy2&$zJ6KS1zX8769WVd@ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$78$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$78$1.class deleted file mode 100644 index d16a69151cb15fa2e682515895d90ace583461b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1623 zcma)6>rN9v6#fQqm)2DZUhpnrk+xbFuc+${3Mg7cDPsJ&Y{zzVnbqAX>Q58n3z(RA z|K$UiAQ&({fX`uK{N)=M&sYjXB(TZOnai1TzVDke^X>QSR{+OxD~3AMn`p4G4hBPq z7fjf`D?>gJC~iM=l~=ZhTp@crB_@S>oa{c8Ji=h8vWRr2Bfo|nj?U6(A%-|Zm-blL zi#E#V-oMXfks+H}PB^`6YCgErh;}0R>&q)0D`}wv2S_~?R3R{IPA%U{`lXOIk+E

=b`Eot{;?=3vy-A zr4N?sW>+d-^Y#L%xKwsd+eeC`#gWA^i^}r^5stVT)rvAWOXbUz-LSwPRJioGlU@k6 zLTb__i*5_2bjUb`93skYi8ta5o!XE0AAUZ0{(bhD_Bw0f9H_jS$?JL$395VETTv>I z4E~A;768=PRH+XVrSOjH@#L_~`NF%G7ZH)=;Tj=dw9tcIhK8sdJY?8OM_!ZdAEs2P zeRQK1D|MtIr8};5JG1(s(zSN}cdnE<7ZfQ*Q(j1Ztuisr!_jKgSV5Get12O_)`t~R zCte6Dp~nY=PTIWm)i|tSQILM=`6Q_<4RKWtiWUa-CAS78DU$QuDB_Wc>vZ8(Ajx3P z*OG|>gI#l5qv$4XE~(NLHWAE!&I)bxd|6<~p)QfouNXj<9!z>e>*#MnKiL85b&{>u zGns>*z%qwE!OYBH{YRQ&xJ0c%7RC!)rhXj~G$IM8)tjHvR$9eqW(sTy&2wnIJ%jDl z(T-{CZrMi-4NWrB*#8-wbI9JF_nv{h;CZy_bh70%n}#!u3%3>#?14!LULs?>!Uf|s z28=hz8*edeyhFiwPZ+NJ#dZ~WvJLdQ4dEK~+W+>cL!TzCzbuAvg9I7DD8`J~ALISE AX#fBK diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$78.class b/_tests/out_app/com/lanremote/watch/MainActivity$78.class index 15267daef30431be97a3d44f28ae372d09b2e70b..5e31cb745c7b28def6b39d622e947551bb6a199b 100644 GIT binary patch literal 1102 zcma)5QBTuQ6#niu+O1R>QxO#fp+Gm3B7&$z6N8dWhdk7fEIyd7yNnB6OWHe(Kf$O^ zJ{sezuOpyjJc}}id+-1F~u-k>6C9L1+qGF&=|BW*Egga zHMnG8Weqxxs5q*l2YH74y1V5j#T(XgZJm3Rqs)sy$eRplv$&E)fgG<{&*C^kZZitK zM;pTTv*@Gvrl@P^$0-%3b)3NfL+?IbIgq@;BLol31C>6XDczzgDlS^E#g>@Zs zpnKa*9j=O~d5>-l!?<}+l7o??4ZR#t-wVDQ$2?YHGYod%NfiE34MQ{tIv6I9%IA}L z0LaqR(IBSi?ZHK|ljMz)O(&7k#5P!IU>j=b1G?W)h)#>X3Rx&mF-3j~dHTDAY5FFc z7YQ=iTq?Z>Q$ItmrVG7JHB&g|RJ#f%oT@?#>JCmiJ2+by*}>>Ou9gr{o};L|AdJhu b(PuDAHbeLI3a*h?pqzwm@=V}5=9Szp*;yTQ literal 1846 zcma)6Yf}?v6n@?$?1r#N3|15`gBmT_hzp8>1htfMH9$)g0ef3Eudur8(%lWVKcG(g zq0_M!wcmQNM zLI}%CiYvZIqh^5;x1*~+j#Tgg5n z0z?&5BgRm(s9Q;we^%h0&rp}WtS{NaiDKg|$sPEEwH6OA%< zDcFrY3{90vq5O5j;GUN}cCwXADreKJ1rqKtU1* zUy;xHn+y>{w=9Nz&6|)MPHj4f;%$bIb0vlpR2gXnhta~Yqg=_uo@0~i8OI^l2RFMe z?ZtFzItq>2`Sj6O|J?j}_1c%KfBg0I(bpnYn}VY_MsRz+%P>&cS*iQAQr34risKAb z+;tr{iVgRXH9lQ>IMCG@0+BSH^n^`p{1cP?{7qXu%On)UbwW zfX{frw)Gi{XHwHL1{vyu4KN*TXsqvZgD?1|V+(wS6`V!(O9tn4jV9IROXJNzR`r`|_m ziT-8yfW8t*NZ(+b^bi{8S1$MpKaa^vgyfWZ7iBOP%3Yt&t{t59k zo!2Cetf2J{YSM}J#0gSIi+4I7qI*hhxsTKL&~xDq;#qM{Z(Vu?LrWq!25DjkD7Z*R z6=h1-k&wPaqx3!YN~|sZFVZs3vjG_w{uims h5}-?{Eo!h4F0*QcmqAJM(7dz4^ZHy?MX?e)~) zaR35CuVyZ$i>hJqMbqKw`>LZA(o?E#OlXe2q&p9lkx^w(8DtQgf^I8A6q0W$CMzL` zkO)ae7&1e2L0wWksQL7**#)jSbmf?C=*~EUKardXA%YeWQ5mghW9VrLWS184>uH8o zHiUL`h&U|c2#zvzH-WN?kzt58#2|nOj?0MQ1Vd;^Eh-Lw;MgQTU)5i7^kO=%+fFu& zlQ<=!OU7x$86x|SDJS!sQq+-{Ox7anGM5#h>`UmhW9i1cgfr+Bk&ux@ilM8KHiodO zY23Dz;h_wv8%Z>lSI4CWnoZ6G(a#WbH#gi@Pu=AY?(*BMl_%Tluice5+vW1s+82-5 zfQ+=qO4@-O%w&R~vc=rh=kAM_e=3i**4F<=xX&KDs~cY2IhkHzSjx)66b%Wn5EzWZ5jyhYU9l)Ji?ppuZIG;NkFjo;9{ z!`Awi8SqL;S6nx=qG?lMrnplu=VV;<`oM3M3$&*6XH8sF^tkE&UEJm+jb&F0;BjMSV0e{sG^7 z@yRz6Z(NKTe}KQk#Q5YtFrKj#2uNU)oimp+=X~EcXXg8#&))zX$L$#EP;a8a!Uh-& zonA0$`>qW6WT3eH$W>n19(IN7_mr3t>PfPg-s?lTWCUpA+hK%I&qJCic+5uQmAtb^{MoD3@zAV zVylI1*v@cZ9bjtUdl)*_1n-#G!O*&hq6%}MBK#r`<7mYu4Pm#1Hq9-jxhbK1u9x;& z*oSt?=RSDAWsxD9T1hy)Vro9P(})ft`TOfj9V=;}69-8>6;vTGY)!4)OZugdHj%N= zg>HuSwFF^^yPn6RD0#Gp6zNT^Ey)VNPI}zLVTOIKEQWz7+QirH_j#x;3fB)x$pyKx z=+OsDb<>v0*Sx(zDlV0s)Aq5VXmMn5%%buL=AE+o5 zNCtmZ1PcIaY^v0Uh*Egh^>}hr=6vDZ&x?r2@^GDyFIecu07FAm4jwV=rX#P(wu~uN z>P5Oyi{&%jFITsWuMpIr$K2n*S;Ne&`YOEki(p8m^R_ntG zsS_^*mC)mZLMLrr`f423uqenS>G>q7EDdv24vH3r^d+~3Bq@^f-6-ObiR*OXRw2n? z&exKO0)t(1Tc_wIZY`^~(;S$+F>K!6m zuV*q{GhmtS8JL+lYU6TDkIllF#rfNd2=>Ax1aFZs-r>CQ z9)rdQuj5~zI`nDM`paSzH%O417{j;``wKP{ BxE%lh literal 2076 zcma)7-A`L(82`O(Ij1dWqw9cyvRN3owtN(18*Xe92F#(rbe&AVuS0vJ2inte&MAsl zi&@Nw$rcl1G`b6namFi0DA0kjc)2V67mP6Oc5UL<^PW~(vY759eV?cAdA^_D^PC5N z-}?!`ahNXHU>D(#PzHgaDUwWt;n`%5Xq#C9rC`s5?5L6S2;YI~K zA`VFK;vi{RN)+W(=+fl05-};BGpeSV=NRn%z-T$DP%Yw+gc{T`?BDByK~#-_M9Q4E zq{m%&8g(KLOE`jO7>+(cKL6$4&d^XY|86Jh8IC;sG_!O4ldXmCcb1p8Z*T5we!ji5 zxc$|_*3FN$ZY{ZS6pbR9Bs_~_48Br9Fu3JNL@}rb?G#0qzjPS6Qd?CEo4Tq^bq7X8 z1Q-q$>eH+1@;q;Z8-93r9a|){qK&~}s2?a+!3OztyMzurNB^f3s(zJ!Fi>PZ*OYD% z$M^Iq=hcam49xAs^F(}ZbmFvxZk(aE=t?RsN61URzsOo&OxDB7Y+BJGO85WW7rW1A z$4#G;@B)ush%4HZ857aVaHxQuoaFhm8jmWv8$IxFrG5zmxIjQP(vyZ&DTZqQ;GPtB zee~d>gh9MSKIBwN(V`5k#nI;lACE8ZmC!LnW&G{hx7`1TgiCmZ!D%LQiVT(h$8|~R zGK0x{=0O~ z5TjEd3SkK<@irW!e-t#GWPx?j+cLWUHMk5hi%Nzs;31wZ<0-Fi4GnkK;U8N?aL9hP zE+^B>@HK97zAlT94foOMJ>l(I!Ksnf6`b`{e}lv8=pm)^t#(iK3i?*?qW5JwRxmum zjg)6$8EMLk@vB{1T=>1k1d9hjtU6TuLCyUo?xO&~j?))=D>Q=a8Bseif zxxcxFcX;|XEB%vHLI@d1!U}4HRn!Vu)C>2}F05lf*uaSJBVxiPz7*~wL-*^#&-g`n zfElt-1EKaWxI|R3KhZCuv#pnoKTr=l;+BU5G@=gr#3hj;uEFAop@%n%KNC5ESsFz{ GaQ_S3XF5dy diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$79.class b/_tests/out_app/com/lanremote/watch/MainActivity$79.class index f6ce80736af4d791500e8d7c4979e6ac54ef884a..9142c41c3ad53ff715dac6a012b7ecff060a4be3 100644 GIT binary patch literal 1846 zcma)6ZBr9h6n<_Jc0*W73|17agBn}15f>>c611h1R|8a{2=sN?Tw!(DrMnw!e?U9! zr+%yTn={&>Z_^GlBJpMX0sS91e)AXDdhP}lNHp%u?m4+R_q;skoaf*F?mYz1iLYuA zLRdybK@B8^Lx!`US-S1=1;^*wRoyq{wNc%)dkx=QGW~CpCr>d*{=DfW+ZhgKtJ!Bn zfT)66#2D(9bSvrduM6Ds85**e^(9>^_@+f676VH~K|S7Ls1=r%Om5{F5+yszYEH}f zu4&H&rf~&3UYPo(Z}Au!u~Wv|3U;B1As)C7u8q%L<_3k7dQ98&&oG2rQj# z3ijb0hL&ojQ2x4MaL-G2wzpI91FhAoPgOLT2+-}bOm6#`8fpYR-M_?e00(6pQjo;q zSLCz#CPT!~EsNnm>oz1uQrixqc#k3ET!|qCRYqFD5wtPvu2k}f=h)24D0|#}@dEC^(Dk%XvQDAppu&Pq;$1rTn+X z$fPD^j8Wn*H}H9C3{!6$<^?m9V^n^Unj}p6oIDrAV%mJHurSNr^8yZ{lbktc>wdu{ zJ?s`}zUyxif6 zye@IHi1uaFr4yZr6Qqun?sVNp&$QaMhBJ52dvO`@tT?B)Azj4qiU^J&n%F@KF49p& znbI;6(r;*%e#d_452U3((JB3fA?Ys8N^8hUMNCQeaGvb_WAJh^*aDvn5ab+s#Sz^ z)Iec4WVyMdZ8{#$xdBh!HUn!mIbsUuoE3;$BDfpv?qyJdS>Z>IGBl+t`5PL-s8ylq zsDsYXIA`86WrmsL*z_E?0*clr91)ymsE)-aHPoX)MWc?*h%g+e6rZ8av@GuX(azqZ zglHgE8B%;hm&--U{kGsvV1%)i@L0KQ7~2_i%NF??#5r)iyBc<4mx|pw_Fyl=&W#Wb z`>sR(cc+CP36rSHPj7y+P`WzfP*TUbR0tTANX7t_gluE-``KRHNyn|p?eV+@BkDsq`Dqa7ML&?R+toMEtXK9aJr zu4Y9l6Ea*HoNh*izJhaFau0 zsXG?W2f}s2IK$Aea{uY~#l>%r-mg5kxAJg7_U4?9^XOme&G8Od!*pd%B|=36rFN}S z`ZKW+pHz_|HfzHCjhBl3A8F!~#JbAb$4j(P#rnz@T5+>by1ycW}Oki3!N!%r? zmT?Q7#Rr6R*}T4a?GgnAN;#J8`XsRt9?ZH~9b@vwXvhoeCXHo&aCQD@9ETi-d;PZQ z``o8_siG@};R0A=qrAmvj;iQahXJx)q+Ju8rA14$yaY?Uf;vwp(g?j3TA@6p_Ym}w z|6{U-=-WhM5o{ToYThC|RUO%qnF>X=WtOpHEA}m6f4U`dco|1VS|Tm9jIKJjy+OxR zqP6W6I+t+b>J!wb<%h^=@;N&%lTl|==p#TfeKTq){WFA==h&~jK(jKBHe~_lmCr~i uA2FbO!npDU!{kd>^$(;}^l7NZB{EbYjS*Tyq?l2R(=JkCN?V$R;`0lw diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$8.class b/_tests/out_app/com/lanremote/watch/MainActivity$8.class index 557b8b9ee409cec46676323e094d7fae477199ec..c1c30f87dc0c30ba4bddff921bc29941e0439784 100644 GIT binary patch delta 229 zcmZ3((#gto>ff$?3=9k=3`!HZe#R*#=A{(nXQt?vWu}(vhXDx{|2*fM%;apJ%;J*N zywoB_1{UZ1lvG9rexJ;|RKL>Pq|~C2#H1Xc2zx$6DIC35?#n5)6zCqCi)w0BNSlFBp|IC4oF91}O$ff$?3=9k=4002>ei{j7C6*=X=OpH(>j#zQ}OVrOv%uCk~E-A{)OP~CR z(Tc;=*ua31!CYgauVTHm54sX-%`grI9YzM}r`tC@UDErsW82e?4bM8eUo2elv~$&q zEnA+=-Ot6K#-PW;pwFPi$iS7DoSa%*tYTFjw=b~HSjyAZD9XOq+vLLfouqgwhfB*w40~^p|OhC-d zV9vnEU;$()0r?;iE-me?42)Xa8Q3=hd5jE}K#~neGqBwNl2#1XQ1uB6%wY94llz!u z1GTm=Fm7Ss-NGO&q`iYd7l~`IgTV-4k`Mz6(4^Z8vTS!4blC1PShL*&nqvdBgNebG a!462XFz_+hGdNDZ#w^cn3iO#18y5hfWoF?3 delta 286 zcmbQi`G=kB)W2Q(7#J8#7%Vq(6*979mZauRu4fG8Sq_s@G;<6cAuwWncrE%>=~U47v=A z40=G85|9rP;nLFH%D||#oq>HLkjKcN4UH3AyT#9$2cAOiyn10RDagE^3; Y1Z0Z+3M+E^DMLwb;qUcb@Qmj?lfv7R5F~&$t zG-L?T2#Xpc#+8s-s1R}C!uW}((XceeR(5XOnD~3s62yhGc<;P3=brm{Z%`OA8=wDr z^ccVfDE&rHTwJf@N>k)BO8?b#PH~=;`vXtcto}0aaPCrnxbOa4AP^azo}0cA8S_Vm z`oh;Php&&R1LV9fzDw$ra(fh~E4RYqk)X3zyHcvh1_JY6x=Hak-3|ka2#C>uO?0ywQQ$*~ zfKrqZ=sogjmt?1Ds>DThm7S8ON$&2E-FCV3fBfS7u|%cR8(Rcy)yn5xvfH6_>QOGH3F5px?cQj)1c_qJKiw_3mt&`Z8thW{Fval()Y55ux^UNs za?}Z^M}s=a*qjFJC19*p1NIR>8{Gi`%{WNKQb&h2xAO1TzxQ>jPmjauM`oSSiZ%fj9Hqgc>St|g32RD{1hm6W zV3O>1+3QU!%+Jds=oZyRVpDx=1CiAkc7}ARelDK#5Qu6UYgK2t6w;x7<5G3U!N8%e z(53r2>1*Rb`{19!Nili8wtH$PZNMQIJzXm+wdg3zwDxC*h_$hZLN`X^L!7OacAW-#Ww6 N))+=N*22ph{{SCK74iT8 delta 488 zcmXX?-Ahwp9Dd$y=bau8I3|g6UHGx#Tpx4ktT{i*k90My6ap_#<2V+_qwJVO2!*2d2_+r`x* zVX-Cg&(_!)fU64E8C+*?4~lD6C2cy*HOUCzrh*VU#H2kMsfHJ~6m+7Cp}Mp)m6>}n z&)`lxR~OZgt{SP()Iuh0q++GXZUwh-M|`xma3^{g+63dxV%$+x6Gg8AH~JWYA}sm6 zzHqGlsf<<(D7cGz3^i&pshOrP(jj&nFWHdrNZ+rFp^ia`8-|t{(pA&cOeY?T3#p$C ziay>f4a0^Jk>vjN`_v5wBlv|;(l*qQqj|y+@~;$n|`mV^6X`)JG~Fr7oteJzU{ zm8&qREFxr_c|@m!Im8apzgPaSw4xpotvH9mf1`o_frtOam=lOoW(V^SJSabYhy<~N a>Kn%dsg|-PLC02KVMPD)BkJJ^moEXgPjgTJ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$80.class b/_tests/out_app/com/lanremote/watch/MainActivity$80.class index 2e935ad5185cbd375040f3d918de1e425c82e79f..bee6bf1915bb7102243f61cbe264b3737d70924a 100644 GIT binary patch delta 692 zcmYjOOHUI~6#i~IbEix%w2mks7z9eSQ=kQk7C}L>`dHf z$X%P5Xky|HQ-NT7BtGL$ATd6$#)Kc>&Ui*hbaBt?e&>GYe0S5>^6DRdufG5g!_<;) zRqODeN`o%og;;tJ@YcYm!7s22hHP=wEp-XO;I*>ZoNWhUQJG=sP%VQyJA2WCy$m8- z$j_a>oVQEY=5Ok#M}vla0*z>5sFz=rTDigEzOFAi4xmLtt3Vq9^l~N;WKc`F8zoyu z4Gsx}VE)I{AB{5f%0{JCPAZzYP|6n~DPkG$pu=g*&*uI%b_qm4P<>~_xZF^Nlpcn5 zIiqA2fzRK;#Kg;DG_g+@++^XDt z;!JfyU<9N8rt0gFEquBm!C+jmu3M3URlF2AyHG4z7YaFt^0N|pEHkaE7c zeD>-nNi@E#5DAtn7IX8X1}9*@nVdsrzmQr+^Ohh_$ipV z3T>G(hHV{%!PZc%K8I(!yiNy@xOt>-=dkn zLy*5mC;xyE{vBfp{taV%6B%B?X#!Wl^$Q6NgF4(eLj@O7NRwAc=?P>gGIn;Q5Z#Ue F{s6;ziZTEI literal 2403 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 literal 1238 zcmbtUZBNrs6n-vyxoj&ihA8uOLY1u>Y=G!=#KaI$aNvg#l=!LZZlKuRlC^{IvrJq> z6a4}HDC4?Cmuw>j=;xQlxXQ62SX^t5UIvy~L1RBoYQl`T$$*ATB6AdE$)Io*%Y)Q-_&r#4|pvaKBOmeta z3@OvHgy-oC%N6R@`~21I3JK}=<)0-E1{8KU<`{}s$K|0QfzhGP0`sln+_p~hP@|EzuO_7yK-FyJ(>;o=&wSA$yMTH!|iq~ zv1Y4eV?B%;UvTXc#V}Op9w!U_3Jc^9Axlq-Sfabiwv8yAKqdAale-b) zGk9Y+T5O!+=GGiNrAl9si{Kr(jIL3Iji2J-MfQyPo^N1+zrj6TN11OTLVqd6wT#DP Wqcj3f@Ql0+VQMw%C=ptVCw~LtG&s}% diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$84.class b/_tests/out_app/com/lanremote/watch/MainActivity$84.class index 222ca3a92822ef9e0ec386c5920e1ec4fab2427f..7da783e55e3a8272cfcfad87b50e4acddd9e238a 100644 GIT binary patch delta 539 zcmYk3T~8B16o%i~-FDjTQcJPY1(g;HEW2A4`Lc*NUYKA48Uu;p!pIah1lU$$3WN&+ zw_KUc{19WphL1#lfbm*>2nmRzXt-yg0F&VswnZycuxO!2!K})RL^F&`l<-`1h@ops?1^Bvvl#`8VuwNB+3heioP}E+iS|bDwz(Cqb>`$d zX50S*6C-dqMlHDT> z*(+_tk^7n>Z?s9hfm!M6jb8Ka7An+X2@F0`@=)(TqE>-I0V$+uU>aE&V-y2Y4T_3Z zTrUB0{RG^tC&;}SVKe{$ delta 509 zcmZ8e%TC)s6g^`*nZy_{rb)p(nt%bD&?Y>>qpXpLL>D2v0;?fdQHo^%E@b z`WKa|f{@??x?6@HJ4I$wW_hmM@xa zaFxyRC^&a7laMf|L5IqajB=hhbv+dZhB-cHNFg0OTAL#nc*oVk*?xwywg$49Bd7IoJ%Dz|0s7eWF%#I~{AW zSSTIM3zF1aDmtoVT{lWbtQdu zjiFx@&~W7Agy3a-Aub_JQ62^IQQIz6BOxdE2OodJn*R+(#$$Idsxq;@N>#~KZZJW7 z;0Du?tn?~70GVX>!1+FGeuzb?$Y3@(XX$m(5Y$IFg>;fYfj zSWW*d1qJsEn9vESmYD&NPrz=VNGJRq12c`6HmY}E>t@TlIZ z5k&p?)nPk4BdE6XUdKZ`6382v#!TneY|*lJH~tN6RvZ3)Sa$wUCt*D(yp`>;4hwSv z^9B}R6Y|j><)aNsQz?2!dzwpFiYe22(`uiD>#}ta_(z1@=!RZLGs;@3&l7TX^qEFX z!eAwo`%RYKmes(IJ}O1Egcs2drF2d({#QYsZODS)Kr>CGpaYD9f05nk*9j!qL4|h? z9^)cs%Zv%e3D@g_+{G@0yVOP69oM?ZxZg1Jg}eNAj!8y{8Xli>pTWJxp<)>0vh3Rrm1znRR;e`jYuJ$D}E?fc~wz%F)r%0tB45mwR?Rl`ycK#(Kk zhl+6TJ?WdnJV(TjC>ES7OTvC){#3pnx1pW0eI4O-qyL zsE892#KS$GKD41>0}nsJ5l<*KJ;=7!LAB{L%&HDPOK-O(Q;&Yn(;F?8#S6BuEz zun&u@i4w99MuZ`>F5HA7?+o$6vRD@;UO7|LzN5<72rTq)JZzyCQHJQY!tBzrv^9l3 z?R*cnwz=x`K*#!<3+{S-pAYEWEz6dB#RZIj+)3*#6Y6RFx7- zCY|T@ZYr8f+5hS*0+jbkDJ!9?KAm>@RZ3bY8L2B?$--M^YFX2Y5|?*H5XgXozWlyc3q6oR z2HtErjxs~0rMA!7a5y`fxs4IQe4wQ0r+I9FVICdPs2R%ut@H<=pF#)Sy?BGx1(J@@ z+G*aThwp-=kKYAPZ{Wa>^on7Tp53&9e~33p?m(1wlmdDh<6p>>PVsU24%i*U?xSyE z15Y$|PuB2M{26-CmT%%HNj0Q4F=F;Lj75{*QB;!SXKHxvYh>;tx6m++ZQzB5L{26f z`WNHVtcF)>n7#S~Vog1Bw27iY+fhe5L}bBlVuXK!DSiu!{8I>i8)g0(migzn&cDD% y{7bw|795>_VQRRCNW4>b@Gjn?wTtGxz!FIVb(sXZOxDbmgAGYRuAzeE?(jd_wEfNi diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$86.class b/_tests/out_app/com/lanremote/watch/MainActivity$86.class index 33ad520a53ab5ce7d63e191bd8052da89ed5212a..f13851af959a0ce2d7f8c7f9cb1292f9064857e3 100644 GIT binary patch literal 1027 zcmbtTU2hUW6g>mT7FY!qKk8?(r3#dGTVom%jEO;;V5oh-5MPI7gsIDHvpb+h|C9;D z#s`0ZKgxKAnv~EcJh;j3nVCIv?wNb<{QmRv7l5aDl0pn|3ke%nkYw2Kf}!hk6^db? zh5LzXZ{Qwssa|?oj-~!mdbZDy)B_onb{W=N%lLH@V8uoXX@-?C_e+Wo1%uN%=3{O` z_1(4(rRvX^wvDTXnKDeDcZHuu7C8%f8x9H#x$|vD-DBZta`Rj&sb4U}%au+VH*nL! zEgP%2&9Hizzc*2!h#ho~8S>@InUj&0KArTWp2l6=vvA+W1FSI=FH@M$2t$T@o`|B- z-u^D-lP_$P5@vcuRskZDZu16t_t>DZ8!)Cd)#BRN5 zWYg_n6nf%78matza(7I*Dd216`9VZoawzm5=-H@Yo0fYrIx#JJApXU6$_t4!@^({E zCK^7EA`w}j8m%p{MbZm-M3MS93+e~Nb2-z_03kK&4C(UFeTvcRV4ZM-Mm53&;q~g) z6s%gC!m4ghk*WT`wePgfP@->=5Y}gG(l~|!%^u+~eT{-k`{RU#>VyO)uue&Ag3KxI noC~B)SnDes>l=~FGnopigbC{8E$q<9A&xO;o+8QaB~yO^^FjxS literal 2075 zcmbtVZBrXn6n<_JxFOk=bg89Oi?mH`-r4}Rl#+_JNGKQzB^4S_>t?fsh0Si<-C*n6 z=tpN9Uq1TL867_3l!7|?1Ngt=i`ye_x!qDli=}vTz;dHK@ zeMTe*O6Wp}A-Jg+QBx^v403K=*;GWT)pXvqb#v9Tlq4L&VTO>ftSGipb{M*AwpCX* z2usB?j7T^lLUsv5Ln&%T2t7E;@tlNX=w*l;#Gfm!YpP4Iuj;1mjx+dU@x>teNKwnB zAf9Ik-_sPgYHN4xsu{#dhJb(>!iyN-I3?jU&M+Krwb>cRGAWQ%TxW>JRvNZdOPh&5 zgFc9}1f{mOFwP;y5tooalHrhtd4Hc2wNfS6qp{|o%}?rSyn@yntHN)VuHblo>kuZ)|$+>7*w>eCx?~Uxf`1t$n zCl7xRy_=PA6LWMJ>Rq`knnG-7*GHn9VwP-6FQqLfeS>sEvojpGs2J^h>3m0rLYPNh zoTJ+eW9{>iPo~9=7eiRY62~101uWB%Zw5sruUHOoytk3#3suupiiVbnFLS)b(BmBo z-AYf-P2N|vimO|usLwkR?&948)?OMC1d2Vr!IY(o_x0p(#A8xocKQf`tnCQI2eFFfxoZwx~|w8vZuh zk)};kvnLG2aWn@5^gZYSgMvULB0dWMNgDc|q?ztX`WVs3@X>t?D#;RQ!z9HlIgv>2 zfF)8p;ED4)2q*diJLpdALf)pg3>vMSB*AUmBi)By`nT$>BF>)_Ma21x|B9Z1Up}5M z1mqL>LWlf9zR;O?2%g_Ve=aFUNe};om;6{FjqM?oJc{8*xHy}V$NYP^I)3t798Srv z$=CPr`WC_|`G!10>R6y=pZf*#1%kJWg-3XEX$z5@ctLk}au08B3y`S5!3u#Cu!kWL z>`nCZ`$+N!7~>z{I{y$^{t<5RkFml(L4|(}hkpr|e}zx^H&`RTegvM90nX8nO>yXd kp%08xR9aRf__0A&J{TyI4A9vz@g8Y1JrhM1*L+R<4f2&NXaE2J diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$87.class b/_tests/out_app/com/lanremote/watch/MainActivity$87.class index b7ebe03b658e7d4fc2967a7a311b42335ece224c..42daf15f0d508cdc903de1f473108b0fc5aa42ff 100644 GIT binary patch literal 2075 zcmbtVU2_v<6n@?$?VBbG4O^rXr4|U1FG%SWs0~)3Hl;PCrNNf8DsDDQyX|H-?ry5y z>7C9n;s-ZeIs^8)4y9E`e}Ml3hg<&udfrWHBZ-p>ce3v}**zc6d(QKo{r&mRzX2GA z-31@~903U(=wvvhTIIB%n6_57TrK^v;;O6Z8AUg*sII=DyPrl!E-`evtGW{%WH^~? zXP*%Xf)ctAVhC<1M$}Zw8iSl$Q#KTlY9*a_ZQWe)EF}pCaF8J+EGvqwlpTidnr+q9 z4Z>3K3?mW_iI82w&`^q+5ke0RbG#(s2znVJ`|)RsYntj3?8~~TyJHOgSbQOfK2p?j zDTtRD!gn;qt=igcyJ`k;oFO1!hVUu|I8I17iBk+mTWxmQu}lhN71tP|vE_zs)zW6- zFQ5!<;7CE1O(Yc{^b>=O_mx8z=nogt- zVw`C8bmzxEzTABF{aq1pLP8dk)DOqC87A8YY);YGo_f|GrWiUj+qUc=-eHhx37zJj(Q18l!XbQ2RT_1^ZidnKPy_B|`^fl5A&CYP#pklQ1rSlyd^0F2dBt*wpRbyxQZ%$oe2L>dh92)& z=vI1acH$FNtGK#liu!yY;Wj?pXYGX{QJ`FVz%9_$@c%nUCceN?B;fm_WDgp{t^bkc zA0D@=8r5XnD$$7S&grIhy;?46_DxX^Vr5=mF}*oOT%<`w6C>{`>N*WY&w_)7P{ZJ> z5se6xx2m?PWpxqcP~(w5C$>hZCQQ|^9Gb#2n!9S1Bv`1BALYo_1|!48#un8nT*KdH zJJOVCYWBFHIF9CEfW8MkU{DZU>n_u9mresmO-PnlO*^TxI?-Rz4ULzTSc5dDT;{mIsX+s1;2bW zUkJ#@@`Vohm3*Ny@fbY6i~d|vj*=e!39tFFNE+KkDtQ<~PcS@_l1Kf!xIA|J0S>0* zx8$q4czYA!lzdH|CUrDWv(Np4xdOr4!Tb}vySRx+PQ0MIJGqPZw**L3;9!|R3fM!C z2=-m{^RJNP_b|%8##R0eviw`z;NM}H-$#Z20Ea(>%OBx0zKK=x>qp=@8Q={4*d&Mk k7y7_BMWtm~f*k literal 1395 zcmb7ETT|0O6#kZ$hPF`(auK{C1WCgscm*w@h&Xhn$O8=TAzj+VG+~l0=(~SNhr;NC zKfoX5cs2~B1H(kyY0qBH`ObH4`}@z&UjUxrT^3zPX-MnnMuuU+4H~xR_#tlulG{g) zbnEtpBm5OtibEm47>f%G8Ce&RG0!kwNzyMV2R%Bn$T8#(9nYvce%0e4!)&6lv3(S* zpQ*<HBI0h~S97?9 z0S$vXhA_-f*mn*c6>`nq+}-CcL1vbOFXS>q$}H~WFoIDHcXZsvJ%-_HyfJ9xT=^wR zX0c65(n(Co+4Xoy`F@~d3=bK4WUwVe;nx@j$l!v;*(vExz|b(Ma!RQ&M8~43lOX?V3nSWd)nt3YwwIKL`cY*Eue8s;uO2&3C;ZqGE4wSr4ij zUNVd&l}bkmOT_IU3>pWLW`SYvpUQN#aQ~HC6_Q@|sc@^F6Gc3t2rnj4!&`<22^~No zU+MvB`Y!5z8hfM*1@#^PBx#;wNmCk2dWGm=>Z0EeY?AY|dP1_B0<&gX(5%@O z^43VYgM;QuGB3>zELZkw3xh6Wl++IBj~{ zn;)1;;R3NAX+msaP$ONE6WS@EJdg2TV1Z;Bi+F|Cv>K%w#a(?&I$h4>DBd?%$z=Zm D(J^}s diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$88.class b/_tests/out_app/com/lanremote/watch/MainActivity$88.class index f82ce84b096772e3460ab2180c8a9319aaacbe0a..09533dfb6f4da8e9cc0fa7fe1354692c042fe845 100644 GIT binary patch literal 1395 zcmb7ETTc@~6#j-TUD~cvkc*%KLXmd4EZ)%~h=`#{kp~Fx%XVl7x4UF_3i|FJGNBlK z@Y#6zOU5%xC=GyIF9NKQKW_uNv2Mn{Z#<}ey zfBjTF!Z8e7!*zy~!r2o+C>e&zq!9R`VjYP}l}l@_BIGuIFC&YrfgWX%Ru&<5e6Nzm z4fN^gH!y%fhU~t5XseK`*5>X$cL*}MC_EvT7!vuyP8vfP)^W?gZQNlPyuurUPR^BI zqLeSRNJ%=0DA~I%FDl=o2F7rgp-cK(G7w&sp^psCX`G&t?gVl=CJo$E>TNvF%rr8u zXOKrhhiPCMGYr#l1!>DEarV8EchDftQ~ApbE}s;3bj&eWXGx%ZO72PPwXj{kTB1#` zXFEK%>8-lLc~=%8U8`Du%*PhH7^~4nz1T|d#j0O|MrjK!78KoN_??I7^$2a;xiVeg;Hbh&W?ApCPB+2KnI?2| zwuy{6q&3lPen#&nx@EXe|4EY2zmttZ8Km7qdgfS!vyKFPft-0v2*=2r;N}TNPB2cJ z?$+iDrV=)yvtZg Grv3u=QhR&= delta 340 zcmYLD%Syvg5IxhxCcU|BV|_oV5<%ml1?@(c1wl{|756vorKH3wG>u!oLGmFYD2fX| zz+dny#JxB{!GW1GbLPzBOWfPy{rmL|z%EwrgsKX-s5lT39%NpjpjJcOfiE$M2BDdM zP?OP9y`E0fpwnr$2^*XFr?s}Xzjf(gn$Q@k8`Vmb8MMwaV^oxAg5ACDGt`e_qffG{ zNRKXl8`dZ@`LVTgE3pj8(@_>D{c}AY$7Yb8Dx(vEJTyj+x`|3touY{nze$(^`93C4 z=Ko9V9vaNfaJJ5M!H97C2^2oVdEkmL%dyM|*Wow^`KJ#+2L%l`d<4Cq`Y#v7B3Hn1 ZJy@7$O$iHFWVBJk5>_}9JX@S&`vDLeHB|rr diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$89.class b/_tests/out_app/com/lanremote/watch/MainActivity$89.class index 45a725648cb866c6509df718cd77f18ec207b35f..cebc9a809d7e5c85cd421ccd52f3016317cbc442 100644 GIT binary patch delta 149 zcmeBSUC+XG>ff$?3=9k=4002>ej5p8C6*=X=OpH(>j#zQff$?3=9k=3`!HZe#a>$=A{(nXQt?vWu}(vhXDx{|2*fM%;apJ%;J*N zywoB_1{UZ1lvG9rexJ;|RKL>Pq|~C2#H1Xc2zx$6DIC$&9YT(hQ6Y;tUc%QWWS`6(G$t`8A`op&SD{hdcu_gCtOt hi9w1%8c4G+@G;0R$N^bGKrtpD4FbXp@(hY>oB-QPIurl^ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$9$1.class b/_tests/out_app/com/lanremote/watch/MainActivity$9$1.class deleted file mode 100644 index b8e963ed5abfde0f97f3af0c924d8037d79c81bf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1222 zcmaizTTc@~6vzLwt!!HtDFrFsD^_Vsp)I0Vi&uh)Nzud-5+9fCrd=$%&2|g=su4{f z!3PsBZ~7p_h=Ac{A^Ps;u#`UW4UA{G2uV%4o1HUr=3IVrcFwOqg&zRAaMKPB79Lgs zhfu@dm9&viMpSiqL^I^jm}p4pP`{|CwA3w>dYrwf^2TIVIpU&QM-4o0ZihL%Xl?-2MvHZW5}98)pTy;2eX^ z&~^wI8hw>6WP2VLc(e-e!O!5SRu1|fq9n<=oVPPf1?l!xuSf-F%s*&D8$)aHMd5d0 zy!7;Yaek`&=4<)m+pYD<(#%A$FjZQcF0IXO{(QChVbWx57tmn>?q!dJ!#oI}VZYJ? zXyms&F&XL)Hb;j^NkRKmDWm1+I`_*)T1yJJ2D-2H zc~uhgskG7eSdz1bqEYO?!K*+=mc`^j;D1G+A~OhgRaMq|Gh!|$=is5P62K4v=X9FQ z1(2jJrpD&ztHVW-T{LSYX)!f{wk5DYa0z^11&0?%#c+wn8j|2et|YxPoJ@a8D?v d-C~EBpe$Zt;gTJJCR)rkDrlp-pP~IFZg)jSE?NKp literal 1120 zcmah|ZBNud5Pr5CS13geP~Z>{vFP=9DJY7UGbV=MOL9OWi1Fpi(p$M=*^+h(`g{C< zUy0%oP5c4=DB~;;Js{?qbZ2*G_L*m%%>4TO{U?Aq%(ufpi;0YlR%98*{Ak+?xs1hj zq=dJ}m0$CgcpxA7D%cIwN2kP)Rka{-W+=MSEWB)?4ecf@8y&D2a$9_t>jazL%Ep%P z6`?%{WS|~0v=rQR3zyJkB4?u;J!isKRUF7o26LB(saSc(aI;W3k5+aYu1;PMgUh&L zqR&P@1{g-0QD(5X?~5dH=4YtJ`-SF3x)m;~aTHX&DDj@sJ`{1;#2|yyESpnuW-a6~ zqy-vgC^b*#l*$*Y)Y4tUsEO+~#&Cn7=OQJB%ubY$uT6|I^!^id ztrkan+GpYxgL}pyc-46!eV%UC)ZzzU?5H4;r?oSTH!q{?t`nh!s45t`DuEQs>Gp<* z*K|IH?({r1L$Q%IaU%+iO8jMejNG}Zxc`2M@0 zsL7Uuszp^Bw{^#M2QpDS3>RXa)Sjd~t}@uqq!jT&$dg1QChneT)DvP11OHP8qcn4E zpaurZ<@DUqC>r#4pg^}S?Z)V=Z>-|PAy}~v^MFpI0IgZFFh0>bfyvYOH-wTQ-^k<< zI+u%I!H&@T1Nm3QsXDHH!^q2hWR?%~OWkX52wh|K(K{IXXf-|~ZyaLC_<~`hjw$k) mFy5LJi55)L(SU~;vKbnuS=^!BAbB)N{pC*#l{ANY+4es_DH!f0S|`3qfhW4D2fO1?4!gU5NznKg(~Qm zw{!LT^LPSC5odbuYm`uy2#Tm6%uZ7?rqq$Bnj;;kXI5fHL$eW!Z3GGexbV0ucnb(Y zs4>vERoFHrwv~1NbXg^3u5-u{@=B8bggSBYoszt3b z5(lG*mi|Jti6;As=uQ~HeVlW-=RQvS&w||h?mhun!ICA{LlQ;^kfC5qgc`&+CJ37K zrYQv3EEadF)udqJ&e_dZ`_^gIoMyBCXt~OnAZFc_f4i0qL&RLeOBa-BTvCQ| zjTg!v#XyPzKLozq!#;#C_)bP~=59sSb8V2`f!Jb3nCB>PhLq$OM`F;wKIo6?_aJrH z#;6NyO#bqSjp={A3Lop!2o$6Y6e){{q{E9OkM>{zDb7CrA&o2}bugGAV$iWD1bzTd C6h}({ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$91.class b/_tests/out_app/com/lanremote/watch/MainActivity$91.class index 398d96ff76438175875e934ea1f2bf68dbcbdbc1..31af713a254e39b8fcc19ab3ed1c02ab52142e6e 100644 GIT binary patch delta 408 zcmYLEO)mpc6g}@v%{RuVYU^vXqjXxy2ttIi@E@$D>BdH+KR`FOsu^8(Wx+~C(@DkG z)~^sT>L1vNJ2E19@7{B7&OP^@XXQyuzkK%|0IXn90f!(VregpiL)JUuR%35jlc7jZ zb!gBVO*=ls02KyCB$zrz5ofSMs==V{m&=uE)plK%Va+?_S?`s{6CpdHV;o5aEuyTh zdk4I@n`X!b-J1{pq<`77-fHLctoOT%;OsQ0UwwA2gLW-wwfgssP-IfaRJiYN&ywp( znDM@NQ&@oR)didGFL;ka{+L4s!!(#-gm%awr>`MNU5ikL>ae3ag$|h0h18~sVV0su z8S)0j46+fw6!Fu}EtomzV>kpp#(wLRkBR?z6R}Ma*n%RLV947@$vY5WlQhR1a+G7_ RW*&J$W+Y6I&UH`}l^+LnNH+ig literal 1217 zcmb7D>rN9v6#k|y-EF%_K?DUaP>S|~EGWpOP(XqSNfDBg{x#d}NC%f)vO5%c03X8t zzJNvr{NV%m9-8?|l39cJ zG?$fqEn|qFO+(ax2AyHZ_I6Cia(%wzNp9|2(yp6pmT;GBDH=i^q-Vw%qOvZ6^cX{` z)XKjQM;r+ahJkimV@PgW4NFDXGS{oy+?IqkCtM+m4B6zyJm{qznup&5&ss_Y{fhsmN<^ zhT=co{2wW)4DK6vplEBec6Xfv#YWaZ4tdJevZ=5^dU9%vgfN_Gtpr8xLn8P4;fEhb zZ+^agr{GKjV<1VzPvOQD+SC=$F4LKFdkGtePspGtVH*?}lEX0Bl@jr}gv$o97DjM|VZ0jv zL!ZmJh$v;pA&AvNcZ}k>CM%*Px!6!aD2T-=`WX2{$}CigXTR z7RE8r!H(!rPkLcj)BaNya-IGQi;iRB7DMXi{+D0vkH0@1XtRQaB1#PDvm!7o_JC47 zYxIT+rN3D&)(qTXuwB0q2C`xEz*oXo_KI}9V6#F>_|D}bx9%@{lE1IYh^|G*Q0NXv z-zLRpNGt~p!7x~rzIe8?T^HeVx9-u25gbK5EKwH*<%$lK-UxO=E}ly5KGZn~vpO{b zS@pRWM3mZ^P|cuW!NGloY@vD*za_=4U8C)gn^o~Con_CBA`ux_qPCwH{-+d#@lP?R z=P?>6hH0=&Mh`B{a*Uon%+Tp9Nt2}OlXPkND_ChC#%I!~?zAWA4f6%<4(5*SYh)d# zyV=qn*dF@ZxcCiMPnDae(&Vu;oNwdySS{gSwIl5h+=~YNfDA_{b{z_kq$1qWOpd?06v8O zeF2RM_`?VAJv8wpjCTMF0h8h;J9j?roO|Y;d%yqw`VGJ&o@fX$^jdDs_e9OKJy&v9 znnh*b$QUAM(-1YFL1!4Uy-m}xT%T`xlAAl0wCm=wCENvDiiVK8>DdW}sH}@1J;snK zwel~-5l2FUVW1tC7?K-S!%`8}&6Vl~w-hy8HyILJmLTFUZLh`?7{H*0lz}0n88R*79wSjcm3R%# zF#nG?{|6<-;I@G~O13s@Ys=YHYGe)MkSDH|O^FTCQ!`^!2*a7ym7v7EOXB|6`}lMJ z?XP$56`W~c4C4&(W4LjJHhlrK^Kxb@8t$D^s3G`{S)n1l;ualYzb=V@N}v0rP?0js z&`}aDf403@<^Gygb!bQPj$9dfGR@*>0u{0qVp{gLeVacO%D?k;Z;Yx6Bh*FLcD#Tp zY?;ftS2HlDQrK197EZ0qrKFQ!)pEJRU@W;rsOVThpsF-_R1F#)6ZBujo8bxi%u7h2 z2=y1k1XwbuT9{fsM1MQ-v^z<&6s^@xJeU8BaBdH=542*KqA^Mq`YMh0F@5a6hVY^P zNbVEDT^->s=sZ9#(Z6wkTOk}^m_Z%wBXj66vX9Xd#2&N}Vi}$K8v6C;Na+>i^%sOW kL-9hG#RIYt625?kH0z_22wC+FP^ZmDWAv6sSct~{0Ke)(bpQYW literal 1468 zcma)6-%}G;7(Ew|UE)$9T5bISh{1*+Y;FBf8*2*@6(=Yi5cQ!Co6T*qcG=v!3PYDhLiMME2$e7 zFY28(_ry`lu-#OsD`VM7?gdbbI+?JZVS6}UiZ)$Ga7XAdIbRad)=aKK%fRH<4h=fUq*Z>} z9U&Wp*VHbmfLy(^bdjtJ$#xwabOUBo)Y`zX@O3K>pm^SI)qB9JCA!c=x0ecry)dcpaCMxj-C9l9Cd`WW^qWB75(<-u9mX_q)Z_tGr S>ehu{4Ba?hT|iMU{ep zhUw54YPyYHLsm%YN4mPXDJa`c%dy06hVpPzr-B&_b%vAhTq~g)XU^!&HuuCn%dp*K zs3mRL3GM|@jT)J-mSJ%?UW_(fM{q~zF*%P-}w3IX7+A>;pIT#`C#_?!~FAC*|+Zq%g^DfzrNfmJid^Bd9iTqc5dKp{_FiQ zbk8p4zdgzIzxZ+Cl0>&i!(xdpKK{)FA7CC{pq=j%D275}T#)&G*GOA%4Y2%$TOddMo#B9qv z9k+Z^jeGw^Z4(_lduJe*xh{dN(y+P+>@ZIn6TsxxwHkDgNh|!6+ebDCPpMs0!3Kt9 zMIZHAyif0>VKPKwx-@S@8r6)s3G&g*xaIJ}>7Fj`9W%P5vlMsq)C_Ycj5}WN@7_R% zoAyk;-;(jQ|2E%RX-dl4>XTZU!Tx{Rdx}kT!b#px&_+}gYmL)| zdb+(-FsuiwsgXArU20|YpN@_6b`z}{=vlrAL{<%gMTVffqbG*VG*;3KHA&+ZY#oa~ zNm*q4n$M^{5%~Z%gqgu$2D5|nGgug0@)>m}D9f@8LcuW0AdDLZOu1s8&xS zsM@Gg9W<(a2&?C?T0M_)Y@_65*p3}ES0IXA*h8zxSXo+>5NF=CDJ&`U^^q{((0^ zQAGRz1s^{oc0jP9ySj_&*GqKk%eSBBR{)z>yp~t7#3;rBBnsn5t8cN~Bt&{JD4c6= zZFjqbmDOJLMXhXS?I^$mAwAa@x?{9G?HrcY>b@zeW0Cn{Sla4FY+NeLLlNSBd1g*} z+8$_I7=qfjwixai?Ofr|f`<@za}I`U5C`L~zFyVTxme y5#c+>X{bMan~_=UhWS0{3Gu&N)`P+i1mPzHW?1B57ITa~zn@`&Gs(^MI<{YAcP=0R diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$94.class b/_tests/out_app/com/lanremote/watch/MainActivity$94.class index f9415efb0100c20c86f1a0bcaa81464e264dd8c4..c9844c04c0869b64ef21f2db0f6ac9fd5d2eadb3 100644 GIT binary patch delta 135 zcmZo?UB$w6>ff$?3=9k=4002>ej5p8C6*=X=OpH(>j#zQJt^ycMZU}O*lnx_J!87EsaDJx0ff$?3=9k=3`!HZe#a>$=A{(nXQt?vWu}(vhXDx{|2*fM%;apJ%;J*N zywoB_1{UZ1lvG9rexJ;|RKL>Pq|~C2#H1Xc2zx$6DICK1Od|2?j<6QJ^bTfHc$OXN=04l0Y64gA{`_kY-`vV~}Bx U1G0pGVoX391cVvn85G$#0jE_tc>n+a diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$95.class b/_tests/out_app/com/lanremote/watch/MainActivity$95.class index f9712a2445066c850cb9b3660139afaad4cc92f0..49c08e62f5afeb2fe10996179a11de50d860cf7d 100644 GIT binary patch delta 25 gcmZo?ZD-w}&cwv=eX=H#4jacW26m3$lcSk*09!)`3jhEB delta 25 gcmZo?ZD-w}&cwvYJz0}UhmDhuft{0oax{|;06)A0N&o-= diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$96.class b/_tests/out_app/com/lanremote/watch/MainActivity$96.class index fcb1b0b2a47b1b09ab10db256d238d900f4ac37e..cd7950e6bcaa87e2857de8e738339415b46c0448 100644 GIT binary patch delta 363 zcmZXPyH3ME5Jm6Ai5)u+LIU9t9(H6zFhzm{G&D%5B1J%z-q>5LVmTtqqokxp?%^Ad zfc9pim$oX(16L;d0qd=t9{gyHk+2FkTuj zjL~>afNtnM-C2%bqcXwP%Tg==ej1dXcld=paKRUCmzip#iD_pN2+g48bj9 z&yX25lgk@h+!PdT(y}cv%}^VQule9dKtWJLGeQg*tE1^^EHp z2hzodHhGF04B-p672#U;=4`>rXSwS`JJbf;W(a(L@%qQn+i&js`iy!VDK5H$vrPRJV<1C6syjLD-OM;Aj!~OEg2CnI^@Fuh9r}Q z8OExoBOm>8?xsS-3?5_TP{t=LezXt< znK43Wp^l+ZorGTD$Q7Dj*w4fA8xLmdqx>$C}?iA$GH!xlr zFO1Q6O@ctg_yE3#hdW=u82@QYKqToVJBR=H&HsNhU;lji0$>DlDmKHl= zdoxkcEr)N~f+u!#VdN7T-L$3*VQ!mZH!?oTP$%-H8yR2-r>of~eegn6;L}hKjUl+9 zZ|gF{dSY>HgByaPO_-J`CK+m?u@xWu2q*|@Xhai3`$h3*OxLitxwA{YF4r9+&(Ir9 z>sHRO&0NB;Ex|33NXhw`NY2O7rKCk+77}UG6-fmx49%xVmh+CiqpuaXg3Bk0lJ%71 z=)2OzhgNxtD-5Aix2$kXYkj(C7INJ2p$)1R*BJueU%dXY|MuIvy`TH%>H8(We zLarXsc>EJJjj^DNh}if5OGZro<@3cH+%p(Xl% zGRhJ|TtSb9Ui2|^TvYqBTww6&hQVDoGBiMB7>!nE!v%+4QIKHhs+Nq17a8=SAA^!f zLkwfp(~(c|eD5X2Vi?hI2X`47oT9a8Ju!&^T&K2#&bDQ#XjzgK$rxcAV`w^U{fUZz zSBF9V47Djc$B7(i)8Y%o%{A^U%h<$vf@ z2IVtAFN25vdW@6w9<9Q(ml9vR=ObAB5Xv6y7?L#B(F}E-#tCQ@`eVIq)kOrb*D}Cv(-0lu8vp6%0#XhWU zf3zm1Nz*?yrjMivP5Ps;sS64|YHMxi!@hp^ms4Eb4uT?0cRGp9#yc4K}*Fjg%jA@)2ZM-hLzcO!fzgIQMCef`B3-W2P>CuA)rqBJGAyr}?}7?c zf*M(!^$djzMWvdQpjJa20t`!Igs&}Tnp8R?W-^q_`po2;)ddwakp6Blb~?@o6(nn* z;vqu4*A`Z7YnUpk;$eooPTlND6RV+aV_m%u^=Q(t37e_Cws3mH;kdA!-4+3{9O`-H z0+{C&DkwW!kD!Jvc!YXLjxso88)I>zfxD_bZS6154e01}@Pwq^g zx;d4&ed&WT*|aXngKmb>s*d@s%65ADpJ%?c-ft(H$Qi5LTazM&q#8 z2%EYSw7~?g8#(#;a#{l<=|6!8n?t_> z#7U0QS0zao$-J8NXW^>Za2C8~0!z-&7Q+#Gdr5+Sju+@V2Yy#cTBC Mm*+^zXDBWH0}r)RF8}}l diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$98.class b/_tests/out_app/com/lanremote/watch/MainActivity$98.class index ad4e948de41513abde0c92a22e6e9709c40889da..04f04195a60ba626d36e22cc692f52d41155f55c 100644 GIT binary patch literal 2276 zcmah~Yitx%6#j1aF>|*AZ5Ik{sZvX!59mv=g)U-C<fLQIT*B*aHzQWJj|64_P@Vn_%CHTZr-6OELIiNPQIc0G4@%a*jJlkA+i_ndpq zch33l*-QU^@+p9|cu7GPLrE|?R2$JvOAJLFQF~B#g5lau-7s5%jxl05qa1E{6nHf# zP#IRtXJ58{9fQ{i8+KV8Luto+_5m5dry&~}10T^NWtNEOqYVCzL48D**g~~Ej%Apk zfDa3iqaas99{dcs8H4TpgCgiq;0=apI2##U6_vYvSd4rHOEfG+!5!v3qqZZ481jNK z%Mzy3YYd4_BN8#}x?*-Jm9w_eZUQRG7<{!O^|fK4N1U*9xk^JhDi~BJnu^J=xMH>o zDo_!sWOY_EgprD@gw~MjOafYcN z83Psf5$avGu&P@^R8bZ8GvsvWW_OBM^)+j2>U^j}qlR@@PwlmZ(=86fgzfCG2#DoS z&&%h)JgZOv+1WY-G;F{F)I)NV#v$7njS&sp6>TY_2Q@THqlL+6`as%3#U^U>*3G@r z(L)+G%Yx-*5OoSC937C#ts1tWjZz6oxb|?=v_)%lKqPt6rs83SMORN8n>zaL)SK_$ zJbip>GJgH++aLDQwI-iJX9NtP330~Pa57&;S}O`;@07=|?*fW@%DikaKZ zT?T;_I^Ef&Gc)LpnWiiUF;4-}%bzLLw2Gl|ek60isN79#O%W=m!!SixY^YyYy)qh& z!yY4K>Q2m(bR&QSv&Yec`#bgFq=kZK?;LB%F-fiMiN>s;*kVX0c{8VZjYLjt-fRXV zQJZFJis>4TNJ?Y}!y>r9M_40*N}{#VWUyKzx@`*^B{ZfNQp0Uz<>tz14UnY&1R`t} z{qhhaIYM9MBwZwPs#c$Zt7^?D@TzevI7wR!2kGr434Rez({~p9v>Hvm>2Ov7X|u^t zoxq}Ie_?}r5=DKJDDE4_-Bllf$FbbMf?jc~97ko7r_d9}n$J+zZqoL57 zx4b!yrnKBhik3K93%zk{X;8@Ta?z;fD|xv14w}=h{q5v)i5f?Te|yS%*AjIxcD``} zs=sFpi{z>|x!OGjkN=Sd_ZW(&5AUHP`AQs*5~?n#ld+y4yC&ZlRyBF1?fS{C&@+Jn zlKyZyj;2h3lAC>w7E|JViR?);Vs^aHx z^Dm+D%P8kxp^|@%dj1WX__x@^ze79!9y|FD*u#IsUj7q8{AV2Dzu-OoD?aAG;SB#B z=lGvE&;P&$ehruS6@0;`aD!jP-~2lM;Wu!L|HTx33x}v%hrknf4XOgSTfs9-L6L$w z<^~qDummwAslkl>1PbOd->P6=f*Ma=Ql5YW<&|90HaQ@5=}a|I>bWFep2uO5ZnAm- PFVdG^o+Bxr!Ibz9V$oHb literal 1033 zcma)5ZBG+H5Pr7Q9z9N5u!#6Zg#v9suJ}Sl42h|jG$~>N;rnv8mc{E{bGI$}vrGVu zAN&FSDC2BvfnXZEUNEMl2*9+(3$9&I?;^z*Qt#p%(5j z*WRJK&82$dY1x(fOJV&bLrNdYxKLr3ZCvNSVE{%Qq+v6pyF4gJ)$VA9=>}JR6iVMc zmVQ%ccgvT0Pkh!JHXPivaLYjkS%&Np?{dSb>FyjH36Ek^uceZDlVPM-+P5)*+ZJ*T zCUJ*hYDiiJi%<*zVHZp1=$b4&Y<>`k9`;=a)40bls>59!N!4V?QNRU_zLk6*;Gu;_ z4rYw|C7y$@B!#zZJjSerIR^#IGvu$U7{eI%JQ2r*YNbLot`@H^^1tLt`xX`%+(9+G zP-&sG`&RNGY}RQLedL}f?5J8Gy-y7p)3JycHvb21ko9WoO8X4STIdUgOhYR1zSBAo z(Vi(Sb$?ej71y1JbSv!RGRzmxTQP*=M%|>fcf(HPiFeYZ8oxY}E2c99xTU-xjHzGS zLLY{{gQsSE#@kWYYU>YNnpXC?Idw&Mtpz-eMQq^(!_44DLpV_SE0sc?2HOF{0utG* znQwq3y&`&83HmcAk}Q$7NOHtj%1ftU<>gaY1KN+yOfttpbQ*RVu-0$JKE;|Z-M zc=d1aWch?*Phg#4{0viPxc?pbixf7w!CptsennU-y+kf5B$G6Mt9VA*1o;?d=3Ai0 Izm`h>0neie3jhEB diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$99.class b/_tests/out_app/com/lanremote/watch/MainActivity$99.class index 3fc2a2aa26f920fc2e96a487fe033dff72970edb..282da6f80a360c202781ab72a028e0c11190f2bf 100644 GIT binary patch literal 1033 zcma)5ZBH6O5PlXYC+D?*)mr-|Ra8LhLEns_F=?nxNU)e-{JtJrSbOZ5yG7fd)kJC2 z5B&lCQB7yDsIf8hlG~Y`otfv^nb}{zfBXdS5*sPR5Vw$Ua0^L>MK9>NK3AdW1zNZ# zTzg%2hfDR=)ACsAFZs<)hNSMwC|_n+XpHl(8h{B0DcB6DWA5jrI_hhNxdvD5FpzEc zM7BFZyW4H4_rzyiwc+5lg*y(?$S`CM`7t+~I&SmePfv(9i#O4RS7-HXdFoy>W6FO+=P^u2YGzDDJIJc7TBRsb7 z#KF9Azru4AmZ0#Sji*?!u;?I8(S#v6t4MgEsuao@rUgFC8* z7bq>1cHc?v2c0@?!jIe&`KGG*()-kq5gm(=Ve5b3Mp>`7uDH*Ts0D4okZwpNKJMJsOLSgo71b}Q(Io_H@!s_fN~TsNH|z-{IEK}7x9 z5xN_+9XvPVlRXN9-jUwr(zNoNn^RYG*P73xNJJK1G0cx{bOQ%Uf2~r;(O^4ZSVk<9 zG4luVi8<(V8R)yMa~GW602M4bNyb z!Ii(kGo@3CJ%u$uc7WLd9)3seGKEcUu$!2+cL{5Kn8-z$WP;|ef)}Jsk&j_!zGZs+ Hual`i=iUiV literal 5444 zcmbVQc~n%_8UNk)2Hu;w&!}TYGpQ1y!~#Ka3r2{EOVp48TD2yU#AF;^WCUjD%%Cwg zhPYHT;+_R1?uya43k>4cHq|U$lK#=2o}SY`dYFOqAJ1t{d)g!?_Pg&5!@x`eEr-Xw z@4oMT+wWWM<&$r(Tn8`(f3QM=tiYln1*r^U+`g*ZN|)EKSNVc^?rK-iU7lO)@_1*u zgPv8M;F^qS(-=~N<(@#s1cuQ?{m~Z)0jdToYz%`|xhgZN{rbzgzsTifV5Q=#reP57 zVNk`_a$g|GU@uzfTICXotjJvy^n1K33_e3N423;PS?%)^B`Xvx?$zLkv2gp!bg?)> z!+m1WD)YVpS2V75W+*Mj=hX0~*rtAj6Oz!Sk1|)ZHW^^)Zjv6MURO z&dge3!x&^Jcu2!o{E#8NKl>TD%k9YTGW&N~R-*MbWFcF@!y0lhu9udk zd&nM7U`|zaaE+m6se)XF5k0n;RI_S4m1Vl$hVe*KF^TwuFVuz)9_+lft8@S5t{Vr^ z#iuD69zh=2?W(TUy=4sJG7}X_)G^AuK*gg3OTOTgFE|x2jLaEz6E_zK9A;~H5_1@AQ4dcN4xTyy2PZMd0Ct{+r%>1f+2m=0j?kH|YabY4ItIFim$9NJ^rnhHd2~R5 z;_{aHeV(#hUm*7>npcwR6?n*N$^6=<^kz)7p&S*WR*|1G`*g)y-7Y0yADy$9=x9)Odgs(^squ6ZH@N;bLsSQ&n541Lz7WZ&V5{p8cEgH7s zO$M!7n*~OLqoz}+qMqTw&b`fD8+V0wZ;Nbsv+MB5aO27F&TSo`T2Z~*8;zLV!H{Og zY`AW7r2YuSa-RSfl6Gm>EqYywyV4iXRqUl*;ns`hmX6SsK3fcTzD+b{k1+F+(&CHS z+VK7ZrsEBocS-q35vt;fTS=fm&SlE=h`CJiU? zK9!jjI(2Ureln#3Edc^t2kp+in^|I?NmO(b>|6?aQ$K8s^T2OSZZy1 z={H?`jKeH)wwd_E3X6&h44%k_OI>H*H<_F6CeL;rKHGKnSV!ns*Y!4v@Xmc3Bk!Iw zvF`|-N`J;P*CQ;tbTYDWN4V~^NP@bZ>5t4y|Ki9ll0x+19VaMIh&a5nK78gKL$ynk z!)Cs89XTC&b9ZO^dkUHvhV`5p=Dgz8tAie&S9IG}4I#9}!S#rM>xBWp6_@gh%l*F9 zMoEcVjMj=Id7+?vfQ50A4OehY6vpceuKtx0g|ReIhk-3+Em3fj;;6?{-IbET*T3v! zf(sLM8OTc35~|#@>A*{17J0mSNln#q-M?6Pm=3;+JS)7eV2z*VvcHBJ^q@sSm%Dx41V z?ZFu7!eESaMKDIX6wy6Nq9+}{pz)V9dy+<##+>Yjo58Y2HbcoC*9@NRuryJ_d$`HV6o(-MA!0DX`(dXbMfAe@k?7V!9KAClj)t)V4rHv2e)9F{V|%i z;PDVeu)G)RUvkHA5mrPC|^oGSlNQAXT+8e<~oJ$F}e0Y3xfHIEaa3&aF5d_x8a0@o5&&x`zb-jV-z0<3f_z2Y|IqF z-hQ6qzgwA$rZU5fG-5_95zZ||4ohC@!}&^1zB(@7>PTsTCXzt0Q0~cjQFYCH94bC& z6c62B4I4tBfWNXd+mT98a;Mc{b1JR4DuVw4CHpA3T&}=nN_{E@Y!Zs77(51vPlts+ zj#T~xtb8U$@!1&5=U^N!#B=;2pN-#TJ4;Im3>#eE@xj<3`$~PFvX3((+9~*u6 k6MRaeg^szu!S84`n&zVGi=W@qmFe?TD_y+JpDX6@Uz(0PMgRZ+ diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$Act.class b/_tests/out_app/com/lanremote/watch/MainActivity$Act.class index 8045547e5bde62776782e89701c6a9c31a52fe40..47a2acb6cd06f9d431adb73a4b8793081ba16b21 100644 GIT binary patch delta 25 hcmX@be2RI)Vn*Iq3@psA8915WFz_+IoxFij3IK5B2rK{q delta 25 hcmX@be2RI)Vn*I83@pr78914*G4L^8pS*!l3IJ^#2h;!n diff --git a/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class b/_tests/out_app/com/lanremote/watch/MainActivity$TabDef.class index 07fe68f63efc3119527e1bd5b0680bdf314fb996..80e46f2d4e5dd5953c026b3c8c4e24dcd3ec966a 100644 GIT binary patch delta 29 lcmeyx@{47IJCop71{UUT44lm08TgogFbFgMoE*WV1OSz+2_pai delta 29 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 O#V>q9=puWxX8sRXDXi=O diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index 757fc60..6a8fa84 100644 --- a/_tests/test_apk_contents.py +++ b/_tests/test_apk_contents.py @@ -23,7 +23,7 @@ def _latest_apk() -> Path: (ROOT / "dist").glob("*watch*.apk"), key=lambda p: p.stat().st_mtime, reverse=True) return candidates[0] if candidates else \ - ROOT / "dist" / "ykxmz-watch-2.0.0.apk" + ROOT / "dist" / "ykxmz-watch-2.1.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index fd24ed0..e700f9d 100644 --- a/_tests/test_package_remote.py +++ b/_tests/test_package_remote.py @@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent # 被控端 -> 一个 zip # 服务器端 -> 一个 zip # 控制端 -> 直接给 APK(不打 zip) -AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.0.0.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.0.0.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.0.0.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.0.apk" TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain") JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe" JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe" @@ -89,7 +89,7 @@ def main() -> int: for label, path in (("被控端", agent_dir / "README-agent.txt"), ("服务器端", relay_dir / "README-server.txt"), ("控制端(旁边那份)", - ROOT / "dist" / "ykxmz-watch-2.0.0-说明.txt")): + ROOT / "dist" / "ykxmz-watch-2.1.0-说明.txt")): raw = path.read_bytes() if path.is_file() else b"" check(f"{label}说明有 UTF-8 BOM", raw[:3] == b"\xef\xbb\xbf", raw[:3]) diff --git a/_tests/test_rescue_section.py b/_tests/test_rescue_section.py new file mode 100644 index 0000000..66ac039 --- /dev/null +++ b/_tests/test_rescue_section.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- +"""后台「抢救报告」分区 —— 渲染 + 下载 + 不缓存。 + +用户要求: + 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个 + 分区专门放还有下载这个 txt」 + +这个测试把两侧都验一遍: + 一、后台页能渲染出「抢救报告」分区(没有报告时给正面提示) + 二、有报告时能列出,并带正确的下载链接 + 三、/rescue/<名字> 能下载到,而且**不发长缓存**(和 /download/ 不同) + 四、文件名校验能挡住目录穿越 + 五、报告目录空 / 不存在时不能把后台页搞崩 +""" +from __future__ import annotations + +import importlib.util +import os +import re +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +RELAY_DIR = ROOT / "relay" + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def load(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +def main() -> int: + print("=" * 62) + print(" 后台「抢救报告」分区") + print("=" * 62) + + sys.path.insert(0, str(RELAY_DIR)) + os.chdir(RELAY_DIR) + try: + relay = load("relay_under_test", RELAY_DIR / "relay.py") + admin = load("admin_under_test", RELAY_DIR / "admin.py") + finally: + os.chdir(ROOT) + + snap = {"version": "2.0.0", "uptimeSeconds": 123, + "devices": [], "allowNew": True} + + # ---------------------------------------------------------------- 一 + print(" ---- 一、没有报告时 ----") + try: + html = admin.render_admin(snap, "https://x", "/zhongzhuanzhan", 0, + lambda: [], rescue_fn=lambda: []) + ok = True + err = "" + except Exception as exc: # noqa: BLE001 + ok = False + err = "%s: %s" % (type(exc).__name__, exc) + html = "" + check("后台页能渲染(不抛异常)", ok, err) + check("有「抢救报告」分区", "抢救报告" in html) + check("没有报告时给正面提示(不是干巴巴的暂无数据)", + "还没有报告" in html, "没找到提示语") + check("渲染出的页面有实际内容", len(html) > 2000, "%d 字符" % len(html)) + + # ---------------------------------------------------------------- 二 + print(" ---- 二、有报告时能列出来 ----") + items = [ + {"name": "20261008-173000.txt", "size": 4200, + "mtime": time.time() - 60, + "summary": "本机是好的,问题在隧道或 Cloudflare 那一侧"}, + {"name": "20261007-220000.txt", "size": 900, + "mtime": time.time() - 90000, "summary": ""}, + ] + html2 = admin.render_admin(snap, "https://x", "/zhongzhuanzhan", 0, + lambda: [], rescue_fn=lambda: items) + check("列出了第一份报告的时间", "10-08" in html2 or "2026100" in html2 + or "17:30" in html2) + check("带下载链接 /rescue/", "/rescue/20261008-173000.txt" in html2, + "没找到链接") + check("显示了结论摘要", "问题在隧道" in html2) + check("没有摘要的那份显示占位文字", "没有结论行" in html2) + check("两份都列出来了", + html2.count("/rescue/") >= 2, "只出现 %d 次" % html2.count("/rescue/")) + + # ---------------------------------------------------------------- 三 + print(" ---- 三、文件名校验(防目录穿越)----") + good = relay._safe_rescue_name("20261008-173000.txt") + check("正常时间戳名通过", good == "20261008-173000.txt", repr(good)) + for bad in ("../etc/passwd", "a/b.txt", "..\\x.txt", "x.sh", + "x.txt/../y", "", "a" * 200 + ".txt"): + check("挡住 %r" % bad[:24], relay._safe_rescue_name(bad) == "", + "居然通过了") + + # ---------------------------------------------------------------- 四 + print(" ---- 四、下载路由的行为 ----") + src = (RELAY_DIR / "relay.py").read_text(encoding="utf-8") + get_part = src.split("def do_GET")[1].split("def do_POST")[0] + head_part = src.split("def do_HEAD")[1].split("def do_GET")[0] + + def rescue_block(part: str) -> str: + """取出整个 /rescue/ 分支。 + + ⚠ 不能用 part.split("/rescue/")[1] —— "/rescue/" 在这个分支里 + 出现两次(startswith 和切片),split 出来的是两个之间的 + 短片段,什么都查不到。我第一次就是这么把测试写红的。 + 改成从 startswith 那句开始、到下一个 if 分支为止。 + """ + key = 'if path.startswith("/rescue/")' + i = part.find(key) + if i < 0: + return "" + j = part.find('if path.startswith("', i + len(key)) + return part[i:j] if j > i else part[i:i + 2000] + + gb = rescue_block(get_part) + hb = rescue_block(head_part) + + check("do_GET 里有 /rescue/ 路由", bool(gb)) + check("do_HEAD 里有 /rescue/ 路由", bool(hb)) + check("下载发 no-store(不能给边缘缓存)", "no-store" in gb, + "没看到 no-store") + check("下载设了 Content-Disposition(点了是下载不是预览)", + "Content-Disposition" in gb) + check("do_HEAD 那段不写 body", "wfile.write" not in hb, + "HEAD 里出现了 wfile.write") + + # 鉴权 —— 这条是实测踩出来的安全问题: + # 报告里有内网 IP、进程表、日志尾部、MTU/路由/iptables, + # 第一版把路由插在 _admin_gate() 之前,等于匿名可下。 + check("do_GET 里 /rescue/ 要求登录", "_admin_gate" in gb, + "没有鉴权 -> 任何人都能下诊断报告") + check("do_HEAD 里 /rescue/ 要求登录", "_admin_gate" in hb, + "没有鉴权") + # 顺序也要对:鉴权必须在读文件之前 + if gb: + check("先鉴权再读文件", + gb.find("_admin_gate") < gb.find("read_bytes"), + "顺序反了:先读了文件才鉴权") + + # ⚠ 鉴权失败必须**自己发响应**。_admin_gate() 只返回布尔值, + # 裸 return 在 BaseHTTPRequestHandler 里就是什么都不做 —— + # 连接保持、没有响应头,客户端一直等到超时(curl HTTP 000)。 + # 实测踩过:/rescue/ 会挂住 8 秒,比报 401 严重得多。 + # 查整个分支,别用 split("_admin_gate()") 切片 —— + # 注释里也会提到 _admin_gate(),切出来的可能是注释后面那一小段, + # 查不到真正的 _json(401。(这个测试第一次就是这么假红的。) + if gb: + check("do_GET 鉴权失败会发 401", "_json(401" in gb, + "裸 return -> 请求会挂到超时") + if hb: + check("do_HEAD 鉴权失败会发 401", "no(401)" in hb, + "裸 return 或状态码不对") + + # ---------------------------------------------------------------- 五 + print(" ---- 五、报告目录不存在也不能崩 ----") + try: + r = relay._list_rescue_reports() + check("_list_rescue_reports() 不抛异常", isinstance(r, list)) + except Exception as exc: # noqa: BLE001 + check("_list_rescue_reports() 不抛异常", False, + "%s: %s" % (type(exc).__name__, exc)) + + print() + print("=" * 62) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_sfx.py b/_tests/test_sfx.py new file mode 100644 index 0000000..8e18a45 --- /dev/null +++ b/_tests/test_sfx.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""网络热门音效 —— 生成 / 列表 / 播放 / 自己放素材。 + +用户要求:「再加上近期网络热门音效,什么冰冰冰,啊哈哈哈哈,各种 +老牧师音效,可以直接远程播放」。 + +⚠ 版权说明(这个测试也在钉这件事): + 「冰冰冰」「啊哈哈哈」「老牧师」是**网上流行的真实录音**,有版权。 + 安装包里内置的是**程序合成**的版本(正弦/方波现场算), + 名字对着感觉来,但不是那些录音本身、也不冒充。 + 用户想用一模一样的一手素材,丢进 data/sfx/ 就行 —— 这一条也要测。 +""" +from __future__ import annotations + +import struct +import sys +import tempfile +import time +import wave +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT / "windows")) + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def main() -> int: + print("=" * 62) + print(" 网络热门音效(可远程播放)") + print("=" * 62) + + from lanremote.sfx import Sfx, BUILTIN_SPECS, SfxError, USER_EXTS + + work = Path(tempfile.mkdtemp(prefix="sfx_")) + + # ---------------------------------------------------------------- 一 + print(" ---- 一、用户点名的三个必须在 ----") + s = Sfx(work / "data") + t0 = time.time() + made = s.ensure_builtin() + took = time.time() - t0 + check("首次运行能生成内置音效", made >= 3, "只生成了 %d 个" % made) + check("生成够快(< 3 秒)", took < 3.0, "用了 %.2f 秒" % took) + + names = [it["name"] for it in s.list_sfx()] + print(" 生成的:%s" % "、".join(names)) + for wanted in ("冰冰冰", "啊哈哈哈", "老牧师"): + check("有「%s」" % wanted, wanted in names, "列表里没有") + + # ---------------------------------------------------------------- 二 + print(" ---- 二、生成的是合法 WAV(真的能放)----") + for it in s.list_sfx(): + p = s.dir / it["file"] + try: + with wave.open(str(p)) as w: + ch = w.getnchannels() + sr = w.getframerate() + n = w.getnframes() + frames = w.readframes(min(n, 2000)) + ok = (ch == 1 and sr == 16000 and n > 1000 and len(frames) > 0) + check("%s 是合法 WAV(%d Hz, %.2f 秒)" % (it["name"], sr, n / sr), + ok, "ch=%s sr=%s n=%s" % (ch, sr, n)) + except Exception as exc: # noqa: BLE001 + check("%s 是合法 WAV" % it["name"], False, + "%s: %s" % (type(exc).__name__, exc)) + + # ---------------------------------------------------------------- 三 + print(" ---- 三、有声音(不是一片静音)----") + p = s.dir / "冰冰冰.wav" + with wave.open(str(p)) as w: + raw = w.readframes(w.getnframes()) + vals = struct.unpack("<%dh" % (len(raw) // 2), raw[: (len(raw) // 2) * 2]) + peak = max(abs(v) for v in vals) + nonzero = sum(1 for v in vals if abs(v) > 50) + check("峰值够大(有声)", peak > 8000, "峰值只有 %d" % peak) + check("不是全静音", nonzero > len(vals) * 0.2, + "只有 %.0f%% 的采样有声音" % (100.0 * nonzero / max(1, len(vals)))) + check("没有削顶爆音(峰值不超过 int16 上限)", peak <= 32767) + + # ---------------------------------------------------------------- 四 + print(" ---- 四、用户自己放素材 ----") + # 造一个假的 mp3(内容不重要,只看"能不能被列出来/能不能被解析到") + fake = s.dir / "我自己的素材.mp3" + fake.write_bytes(b"\xff\xfb\x90\x00" + b"\x00" * 500) + names2 = [it["name"] for it in s.list_sfx()] + check("用户放的素材出现在列表里", "我自己的素材" in names2, + "列表:%s" % names2) + check("用户素材标记为非内置", + any(it["name"] == "我自己的素材" and not it["builtin"] + for it in s.list_sfx())) + check("内置的仍在", "冰冰冰" in names2) + check("能解析到用户素材的路径", + s.resolve("我自己的素材").name == "我自己的素材.mp3") + check("能按带后缀的名字解析", + s.resolve("我自己的素材.mp3").name == "我自己的素材.mp3") + + # ---------------------------------------------------------------- 五 + print(" ---- 五、播放(用假播放器,别真出声)----") + played = [] + s2 = Sfx(work / "data", player=lambda path: (played.append(path), + "正在播放")[1]) + msg = s2.play("冰冰冰") + check("play() 调用了播放器", len(played) == 1, "调用 %d 次" % len(played)) + check("传给播放器的是生成的 WAV", played and played[0].endswith("冰冰冰.wav"), + played[0] if played else "没调用") + check("返回了提示文字", "播放" in msg, repr(msg)) + + # ---------------------------------------------------------------- 六 + print(" ---- 六、出错路径 ----") + for bad in ("", "根本没有这个音效", "../../etc/passwd", None): + try: + s2.resolve(bad or "") + check("拒绝 %r" % (bad,), False, "居然解析成功了") + except SfxError: + check("拒绝 %r" % (bad,), True) + except Exception as exc: # noqa: BLE001 + check("拒绝 %r" % (bad,), False, + "抛了别的异常 %s" % type(exc).__name__) + + # ---------------------------------------------------------------- 七 + print(" ---- 七、目录里有说明文件(用户能找到去哪放素材)----") + # ⚠ 别用 glob("*.txt") 取第一个 —— 目录里还有 .builtin.txt + # (生成清单),它的排序可能排在说明文件前面, + # 于是读到的是清单内容、断言全红。按名字精确取。 + readme_path = s.dir / "放这里.txt" + check("sfx 目录里有说明文件", readme_path.is_file(), + "没有说明文件 -> 用户不知道往哪放素材") + if readme_path.is_file(): + body = readme_path.read_text(encoding="utf-8", errors="replace") + check("说明里写了「放进来就能用」", + "放" in body and ("mp3" in body or "wav" in body)) + check("说明里讲清了内置的是合成的(版权说明)", + "合成" in body or "版权" in body) + + # ---------------------------------------------------------------- 八 + print(" ---- 八、删掉内置音效后不会又被生成回来 ----") + target = s.dir / "叮.wav" + if target.is_file(): + target.unlink() + s.ensure_builtin() + check("删掉的内置音效不会自动重生", not target.is_file(), + "又被生成回来了 -> 用户删不掉") + + print() + print("=" * 62) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_watch_panels_wired.py b/_tests/test_watch_panels_wired.py new file mode 100644 index 0000000..0cc7e1b --- /dev/null +++ b/_tests/test_watch_panels_wired.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +r'''钉住"每个面板都必须挂到 root 上"。 + +== 为什么需要 == + +设置页第一版**一片空白**。根因很隐蔽: + + buildSettingsPanel() 建了 settingsPanel,往里面加了标题、 + 中转服务器、配对码、已配对设备、关于…… + 全都在,一个都不少 + 但是 —— **没有人 root.addView(settingsPanel)** + +于是 showOnly(settingsPanel) 把它设成 VISIBLE 也没用: +它压根不在视图树里。用户点「设置」进去看到的是**全白**。 + +对照一下,另外四个面板都有 root.addView(...),唯独新加的这个漏了。 +这类"建了但没挂"的错**只在运行时表现为空白,编译完全不报**, +所以必须用断言钉住。 + +== 这个测试查什么 == + +对每一个面板字段声明(connectPanel / controlPanel / mirrorPanel / +lanPanel / settingsPanel 这类),都要求源码里存在对应的 +root.addView(那个字段, ...)。 + +另外查 showOnly() 的数组里有没有把新面板带上 —— 那是另一个 +同源的坑(面板建了也挂了,但切换时不藏它,就一直盖在最上面, +上一版手表「局域网连上后没反应」就是这么来的)。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SRC = (ROOT / "android" / "src" / "com" / "lanremote" / "watch" + / "MainActivity.java") + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def main() -> int: + print("=" * 60) + print(" 每个面板都必须挂到 root 上,并且纳入 showOnly()") + print("=" * 60) + + check("MainActivity.java 存在", SRC.is_file(), SRC) + if not SRC.is_file(): + return 1 + src = SRC.read_text(encoding="utf-8") + + # ---- 一、找出所有面板字段 ---- + # 形如: private FrameLayout xxxPanel; 或者带初始化 + panels = re.findall(r"private\s+FrameLayout\s+(\w*[Pp]anel)\s*[;=]", + src) + panels = sorted(set(panels)) + print(" ---- 一、发现的面板字段 ----") + for p in panels: + print(" %s" % p) + check("至少发现 3 个面板", len(panels) >= 3, "只找到 %s" % panels) + + # ---- 二、每个都必须有 root.addView ---- + print(" ---- 二、每个面板都挂到 root 了 ----") + added = set(re.findall(r"root\.addView\(\s*(\w+)\s*,", src)) + for p in panels: + check("%s 有 root.addView" % p, p in added, + "只建了没挂 —— 运行时表现是「进去一片空白」,编译不报错") + + # ---- 三、每个都必须出现在 showOnly 的数组里 ---- + print(" ---- 三、每个面板都在 showOnly() 里 ----") + m = re.search(r"private void showOnly\(View keep\)\s*\{(.{0,600}?)\n \}", + src, re.S) + check("能找到 showOnly()", bool(m), "找不到方法体") + if m: + body = m.group(1) + arr = re.search(r"View\[\]\s*all\s*=\s*\{(.*?)\}", body, re.S) + check("showOnly 里有个数组", bool(arr), "形状变了") + listed = set() + if arr: + listed = {x.strip() for x in arr.group(1).split(",") if x.strip()} + for p in panels: + check("%s 在 showOnly 数组里" % p, p in listed, + "切换时不藏它 -> 会一直盖在最上面(上一版" + "「局域网连上后没反应」就是这个原因)") + + # ---- 四、settingsPanel 必须初始 GONE(否则会盖住首页)---- + print(" ---- 四、settingsPanel 初始是隐藏的 ----") + check("settingsPanel.setVisibility(View.GONE)", + re.search(r"settingsPanel\.setVisibility\(View\.GONE\)", src) + is not None, + "没有初始隐藏 -> 一开始就盖在首页上") + + print() + print("=" * 60) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index bdcae9c..8f704ac 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -25,27 +25,54 @@ $androidJar = Join-Path $tc "android-sdk\platforms\android-34\android.jar" $script:results = @() # 当前会话是不是锁屏 / 没有交互桌面? -# 锁屏时截屏类测试**必然**失败(拿不到桌面会话), -# 但那不是代码问题。不识别的话汇总表一片红,真问题反而被淹没。 +# 锁屏时截屏类测试**必然**失败(拿不到屏幕内容),但那不是代码问题。 +# 不识别的话汇总表一片红,真问题反而被淹没。 +# +# ⚠ 判断不能只看 OpenInputDesktop 成不成功 —— **锁屏时它同样成功** +# (那时输入桌面是 Winlogon)。实测漏判过一次:屏幕明明锁着, +# desktopAvailable 却是 true,于是 6/7、7/7 两个截屏阶段记成 FAIL, +# 把真正的结论淹掉了。 +# 正确做法是拿输入桌面的**名字**:Default = 正常,Winlogon = 锁屏。 $script:desktopAvailable = $true +$script:desktopName = "(未检测)" try { Add-Type -Namespace Ykxmz -Name Desk -MemberDefinition @' [System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true)] public static extern System.IntPtr OpenInputDesktop(int flags, bool inherit, int access); [System.Runtime.InteropServices.DllImport("user32.dll")] public static extern bool CloseDesktop(System.IntPtr h); -public static bool HasDesktop() { +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError=true, CharSet=System.Runtime.InteropServices.CharSet.Unicode)] +public static extern bool GetUserObjectInformationW(System.IntPtr h, int idx, System.Text.StringBuilder pv, int len, ref int need); +public static string InputDesktopName() { System.IntPtr h = OpenInputDesktop(0, false, 0x0001); - if (h == System.IntPtr.Zero) return false; - CloseDesktop(h); + if (h == System.IntPtr.Zero) return ""; + try { + System.Text.StringBuilder sb = new System.Text.StringBuilder(256); + int need = 0; + if (GetUserObjectInformationW(h, 2, sb, 512, ref need)) return sb.ToString(); + return ""; + } finally { CloseDesktop(h); } +} +public static bool HasDesktop() { + string n = InputDesktopName(); + if (n.Length == 0) return false; + if (n.Equals("Winlogon", System.StringComparison.OrdinalIgnoreCase)) return false; return true; } '@ -ErrorAction Stop + $script:desktopName = [Ykxmz.Desk]::InputDesktopName() + if ($script:desktopName -eq "") { $script:desktopName = "(拿不到)" } $script:desktopAvailable = [Ykxmz.Desk]::HasDesktop() } catch { + $script:desktopName = "(检测失败: $($_.Exception.GetType().Name))" $script:desktopAvailable = $true # 查不出来就当可用,别把失败藏起来 } +# 无论可不可用都打一行 —— 上次漏判就是因为什么都没打,无从判断。 +Write-Host (" 输入桌面: {0} -> {1}" -f $script:desktopName, + $(if ($script:desktopAvailable) { "可用" } else { "不可用(锁屏)" })) ` + -ForegroundColor $(if ($script:desktopAvailable) { "DarkGray" } else { "Yellow" }) + if (-not $script:desktopAvailable) { Write-Host "" Write-Host " ⚠ 检测到电脑处于锁屏状态。" -ForegroundColor Yellow @@ -221,6 +248,18 @@ $stageCode = $LASTEXITCODE $stageOut | Select-Object -Last 4 Record "Python 被控端协议回归" ($stageCode -eq 0) $true +# ---------------------------------------------------------------- 1a2 +Banner "1a2/7 手表面板接线(每个面板都要挂到 root)" +$stageOut = & python (Join-Path $root "_tests\test_watch_panels_wired.py") 2>&1 + Show-Fails $stageOut +Record "手表面板接线" ($LASTEXITCODE -eq 0) + +# ---------------------------------------------------------------- 1a3 +Banner "1a3/7 网络热门音效(生成/列表/播放/自放素材)" +$stageOut = & python (Join-Path $root "_tests\test_sfx.py") 2>&1 + Show-Fails $stageOut +Record "网络热门音效" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 1b Banner "1b/7 防火墙阻止规则检测(真机连不上的根因)" $stageOut = & python (Join-Path $root "_tests\test_firewall.py") 2>&1 @@ -238,9 +277,20 @@ Get-Content (Join-Path $env:TEMP "lr_sc.txt") -Encoding UTF8 -ErrorAction Silent Record "自检请求链路" ($scCode -eq 0) # ---------------------------------------------------------------- 1e +# ---------------------------------------------------------------- 隐藏桌面 +# 下面 6 个阶段会真的开窗口,其中「投图显示方式」还会开**全屏** —— +# 直接在用户眼前跑的话屏幕会被锁住,没法用电脑。 +# +# 现在统一套一层 hidden_desktop.py:它新建一个 Windows 桌面,把子进程 +# 放上去,所有窗口都创建在那个不显示的桌面上。 +# **窗口是真的**(全屏、可见、几何、Win32 枚举都真实), +# 只是用户看不到 —— 测试有效性没有损失。 +# +# ⚠ 新增会弹窗的测试时,记得也套一层,否则用户屏幕又会被闪。 + Banner "1e/7 新增功能(电量/磁盘/性能/进程/剪贴板历史/安全边界)" Stop-Strays -& python (Join-Path $root "_tests\test_new_features.py") *> (Join-Path $env:TEMP "lr_feat.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_new_features.py") *> (Join-Path $env:TEMP "lr_feat.txt") $featCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_feat.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 2 @@ -249,7 +299,7 @@ Record "新增功能" ($featCode -eq 0) # ---------------------------------------------------------------- 1f Banner "1f/7 电脑功能注册表(39 个功能 + 安全边界 + 可配置)" Stop-Strays -& python (Join-Path $root "_tests\test_features.py") *> (Join-Path $env:TEMP "lr_feats.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_features.py") *> (Join-Path $env:TEMP "lr_feats.txt") $featsCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_feats.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 2 @@ -258,7 +308,7 @@ Record "电脑功能注册表" ($featsCode -eq 0) # ---------------------------------------------------------------- 1g Banner "1g/7 功能模糊测试(105 个功能 × 空参数 / 垃圾参数 / 越权路径)" Stop-Strays -& python (Join-Path $root "_tests\fuzz_features.py") *> (Join-Path $env:TEMP "lr_fuzz.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\fuzz_features.py") *> (Join-Path $env:TEMP "lr_fuzz.txt") $fuzzCode = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_fuzz.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 3 @@ -294,7 +344,7 @@ Record "配一次→零窗口" ($flowCode -eq 0) # ---------------------------------------------------------------- 1k Banner "1k/7 首次安装设置窗口(出现一次,之后不再出现)" Stop-Strays -& python (Join-Path $root "_tests\test_first_run_setup.py") *> (Join-Path $env:TEMP "lr_setup.txt") +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_first_run_setup.py") *> (Join-Path $env:TEMP "lr_setup.txt") $setupCode2 = $LASTEXITCODE Get-Content (Join-Path $env:TEMP "lr_setup.txt") -Encoding UTF8 -ErrorAction SilentlyContinue | Select-Object -Last 3 @@ -342,7 +392,7 @@ Record "网络调优(MTU)" ($LASTEXITCODE -eq 0) Banner "2b/7 投图显示方式(屏幕中间 / 全屏 / 自定义大小)" Stop-Strays $imgLog = Join-Path $env:TEMP "lr_imgmode.txt" -& python (Join-Path $root "_tests\test_image_modes.py") *> $imgLog +& python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_image_modes.py") *> $imgLog $imgCode = $LASTEXITCODE if ($imgCode -eq 0) { Get-Content $imgLog -Encoding UTF8 -ErrorAction SilentlyContinue | @@ -357,7 +407,7 @@ Record "投图显示方式" ($imgCode -eq 0) # ---------------------------------------------------------------- 3 Banner "3/7 屏幕共享可见提示(真实界面 + Win32 窗口枚举)" Stop-Strays -$stageOut = & python (Join-Path $root "_tests\test_indicator_visible.py") 2>&1 +$stageOut = & python (Join-Path $root "_tests\hidden_desktop.py") -- python (Join-Path $root "_tests\test_indicator_visible.py") 2>&1 $stageCode = $LASTEXITCODE $stageOut | Select-Object -Last 3 Record "屏幕共享强制提示" ($stageCode -eq 0) @@ -402,6 +452,12 @@ $stageOut = & python (Join-Path $root "_tests\test_relay_default_blank.py") 2>&1 Show-Fails $stageOut Record "中转默认空白" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3a3c +Banner "3a3c/7 后台抢救报告分区(鉴权 + 不缓存 + 防穿越)" +$stageOut = & python (Join-Path $root "_tests\test_rescue_section.py") 2>&1 + Show-Fails $stageOut +Record "抢救报告分区" ($LASTEXITCODE -eq 0) + # ---------------------------------------------------------------- 3a4 Banner "3a4/7 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)" # 踩过的坑:edit 工具会把行尾写成 CRLF,而仓库约定是 LF。 diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml index 95f8659..e59cb87 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="2.1.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index aa5e2d8..3322a20 100644 --- a/android/build_apk.ps1 +++ b/android/build_apk.ps1 @@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat" $work = Join-Path $android "build" $outDir = Join-Path $root "dist" -$apkName = "ykxmz-watch-2.0.0.apk" +$apkName = "ykxmz-watch-2.1.0.apk" function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan } @@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk" --min-sdk-version 24 ` --target-sdk-version 34 ` --version-code 5 ` - --version-name "2.0.0" ` + --version-name "2.1.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/android/src/com/lanremote/watch/MainActivity.java b/android/src/com/lanremote/watch/MainActivity.java index 703eb5b..569b1db 100644 --- a/android/src/com/lanremote/watch/MainActivity.java +++ b/android/src/com/lanremote/watch/MainActivity.java @@ -293,10 +293,18 @@ public class MainActivity extends Activity implements RemoteClient.Listener { root.addView(lanPanel, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + // ⚠ 建了面板还必须**挂到 root 上**,否则 showOnly() 让它 VISIBLE + // 也没用 —— 它根本不在视图树里,用户看到的是一片空白。 + // 设置页第一版就漏了这一行(buildSettingsPanel() 里建得好好的, + // 但没人 addView),表现是"点设置进去全白"。 + root.addView(settingsPanel, new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT)); controlPanel.setVisibility(View.GONE); mirrorPanel.setVisibility(View.GONE); lanPanel.setVisibility(View.GONE); + settingsPanel.setVisibility(View.GONE); connectPanel.setVisibility(View.VISIBLE); setContentView(root); @@ -541,6 +549,20 @@ public class MainActivity extends Activity implements RemoteClient.Listener { }); box.addView(relayButton); + // 音效:点一下电脑就响。用户要求"可以直接远程播放"。 + Button sfxButton = bigButton("音效(让电脑放一声)", C_ACCENT, 14); + LinearLayout.LayoutParams sfxParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, dp(52)); + sfxParams.setMargins(0, 0, 0, dp(8)); + sfxButton.setLayoutParams(sfxParams); + sfxButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + pickSfx(); + } + }); + box.addView(sfxButton); + // 输入配对码直接连对应电脑。 // 不再需要"先搜局域网 / 手动填 IP"那一套 —— // 现在统一经中转,配对码就是找电脑的唯一凭据。 @@ -3511,6 +3533,49 @@ public class MainActivity extends Activity implements RemoteClient.Listener { } } + /** 让电脑放一个音效(网络热门音效那类)。 + * + * 用 showChooser 而不是新建面板 —— 见 _build/add_watch_sfx.py 里的说明: + * pickVoice() 已经是这个套路,复用省事也更不容易出错。 + */ + private void pickSfx() { + appendLog("正在获取音效…"); + io.execute(new Runnable() { + @Override + public void run() { + try { + final Map reply = + client.call(Json.obj("cmd", "sfx_list"), 15000); + // 服务端同时给了扁平的 names(和 audio_list 一个约定) + final List names = stringList(reply, "names"); + final String dir = Json.str(reply, "dir", ""); + ui.post(new Runnable() { + @Override + public void run() { + if (names.isEmpty()) { + toast("电脑上还没有音效(或没连上)"); + appendLog("音效列表是空的。" + + (dir.isEmpty() ? "" : "素材目录:" + dir)); + return; + } + showChooser("音效(点一下电脑就响)", names, + new Chooser() { + @Override + public void onPick(int index) { + String one = names.get(index); + sendCommand(Json.obj("cmd", "sfx_play", + "name", one), "音效 " + one); + } + }); + } + }); + } catch (final IOException e) { + postError("获取音效", e); + } + } + }); + } + /** 选择电脑上的 TTS 音色。 */ private void pickVoice() { appendLog("正在获取电脑音色…"); diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt index f50db49..706ae27 100644 --- a/docs/AFTER-INSTALL.txt +++ b/docs/AFTER-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -197,7 +197,7 @@ 八、版本记录 -------------------------------------------------------- - 2.0.0 + 2.1.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -218,6 +218,6 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt index 1fb703e..2294540 100644 --- a/docs/BEFORE-INSTALL.txt +++ b/docs/BEFORE-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt index f9b8018..7a44669 100644 --- a/docs/LICENSE.txt +++ b/docs/LICENSE.txt @@ -1,38 +1,48 @@ ======================================================== - ykxmz 软件许可协议 + ykxmz 使用条款与免责声明 ======================================================== 软件名称:ykxmz(远程管理工具) -版本:1.7.1 +版本:2.1.0 作者:小麦子 生效日期:2026 年 10 月 5 日 +> **这份文件管的是「使用行为」,代码授权看仓库根目录的 `LICENSE`。** +> +> 根目录 `LICENSE` 是 GNU AGPL-3.0:你可以查看、修改、 +> 再发布本软件的**代码**,衍生作品必须以同样协议开源。 +> +> 这份文件说的是另一件事:**不管你从哪拿到它、改没改过, +> 都不许拿去做下面第三条那些事。** +> +> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— +> 那和 AGPL 冲突,所以 2.1.0 起拆开了:代码授权归 LICENSE, +> 使用约束归这份文件。) + -------------------------------------------------------- -第一条 协议的范围 +第一条 条款的范围 -------------------------------------------------------- -本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 +本条款是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 (含被控端程序、中转服务器程序、手表端应用及相关文档, -以下统称"本软件")达成的许可使用协议。 +以下统称"本软件")达成的**使用约定**。 下载、安装、复制或以任何方式使用本软件,即表示你已阅读、 -理解并同意本协议全部条款。如不同意,请不要安装或使用。 +理解并同意本条款全部内容。如不同意,请不要安装或使用。 -------------------------------------------------------- -第二条 授权范围 +第二条 你可以做的 -------------------------------------------------------- -在遵守本协议的前提下,作者授予你一项免费的、非独占的、 -不可转让的使用许可,你可以: +本软件**免费**使用,作者不收取任何费用。你可以: 1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件; - 2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、 - 不得去除本协议与作者署名); - 3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。 - -本软件免费使用,作者不收取任何费用。 + 2. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备; + 3. 复制并分发本软件 —— 分发时请保留作者署名,不要去掉版权声明; + 4. 查看、修改其**源代码**并再发布,具体条件见根目录 `LICENSE` + (AGPL-3.0:衍生作品要同样开源,且不能拿去闭源商用)。 -------------------------------------------------------- @@ -58,7 +68,7 @@ **一句话:只能管你自己的设备,或者别人明确同意你管的设备。** -违反上述任何一条,本协议立即终止,且由此产生的全部法律 +违反上述任何一条,本条款立即终止,且由此产生的全部法律 责任由使用者自行承担,与作者无关。 @@ -124,33 +134,34 @@ -------------------------------------------------------- -第八条 协议终止 +第八条 条款终止 -------------------------------------------------------- -出现下列情形之一,本协议自动终止: +出现下列情形之一,本条款自动终止: - 1. 你违反本协议任何条款; + 1. 你违反本条款任何内容; 2. 你将本软件用于第三条约定的禁止用途。 -协议终止后,你应当立即停止使用并卸载本软件。第二条授予的 -分发许可同时失效。 +条款终止后,你应当立即停止使用并卸载本软件。第二条中关于 +分发的许可同时失效(代码授权是否终止以根目录 `LICENSE` 为准)。 -------------------------------------------------------- 第九条 其他 -------------------------------------------------------- - 1. 本协议适用中华人民共和国法律; - 2. 因本协议产生的争议,双方应友好协商解决;协商不成的, + 1. 本条款适用中华人民共和国法律; + 2. 因本条款产生的争议,双方应友好协商解决;协商不成的, 提交作者所在地有管辖权的人民法院; - 3. 作者保留在后续版本中调整本协议的权利,调整后的协议 - 随新版本一同发布;继续使用新版本即视为接受新协议; - 4. 本协议部分条款无效,不影响其余条款的效力。 + 3. 作者保留在后续版本中调整本条款的权利,调整后的条款 + 随新版本一同发布;继续使用新版本即视为接受新条款; + 4. 本条款部分内容无效,不影响其余内容的效力。 -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.7.1 + 软件:ykxmz 2.1.0 - 如果你不同意本协议的任何一条,请不要安装或使用本软件。 + 如果你不同意本条款的任何一条,请不要安装或使用本软件。 + 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 -------------------------------------------------------- diff --git a/installer/license-combined.txt b/installer/license-combined.txt index 248a3ff..84fde2b 100644 --- a/installer/license-combined.txt +++ b/installer/license-combined.txt @@ -1,38 +1,48 @@ ======================================================== - ykxmz 软件许可协议 + ykxmz 使用条款与免责声明 ======================================================== 软件名称:ykxmz(远程管理工具) -版本:2.0.0 +版本:2.1.0 作者:小麦子 生效日期:2026 年 10 月 5 日 +> **这份文件管的是「使用行为」,代码授权看仓库根目录的 `LICENSE`。** +> +> 根目录 `LICENSE` 是 GNU AGPL-3.0:你可以查看、修改、 +> 再发布本软件的**代码**,衍生作品必须以同样协议开源。 +> +> 这份文件说的是另一件事:**不管你从哪拿到它、改没改过, +> 都不许拿去做下面第三条那些事。** +> +> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"—— +> 那和 AGPL 冲突,所以 2.1.0 起拆开了:代码授权归 LICENSE, +> 使用约束归这份文件。) + -------------------------------------------------------- -第一条 协议的范围 +第一条 条款的范围 -------------------------------------------------------- -本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 +本条款是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件 (含被控端程序、中转服务器程序、手表端应用及相关文档, -以下统称"本软件")达成的许可使用协议。 +以下统称"本软件")达成的**使用约定**。 下载、安装、复制或以任何方式使用本软件,即表示你已阅读、 -理解并同意本协议全部条款。如不同意,请不要安装或使用。 +理解并同意本条款全部内容。如不同意,请不要安装或使用。 -------------------------------------------------------- -第二条 授权范围 +第二条 你可以做的 -------------------------------------------------------- -在遵守本协议的前提下,作者授予你一项免费的、非独占的、 -不可转让的使用许可,你可以: +本软件**免费**使用,作者不收取任何费用。你可以: 1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件; - 2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、 - 不得去除本协议与作者署名); - 3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。 - -本软件免费使用,作者不收取任何费用。 + 2. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备; + 3. 复制并分发本软件 —— 分发时请保留作者署名,不要去掉版权声明; + 4. 查看、修改其**源代码**并再发布,具体条件见根目录 `LICENSE` + (AGPL-3.0:衍生作品要同样开源,且不能拿去闭源商用)。 -------------------------------------------------------- @@ -58,7 +68,7 @@ **一句话:只能管你自己的设备,或者别人明确同意你管的设备。** -违反上述任何一条,本协议立即终止,且由此产生的全部法律 +违反上述任何一条,本条款立即终止,且由此产生的全部法律 责任由使用者自行承担,与作者无关。 @@ -124,35 +134,36 @@ -------------------------------------------------------- -第八条 协议终止 +第八条 条款终止 -------------------------------------------------------- -出现下列情形之一,本协议自动终止: +出现下列情形之一,本条款自动终止: - 1. 你违反本协议任何条款; + 1. 你违反本条款任何内容; 2. 你将本软件用于第三条约定的禁止用途。 -协议终止后,你应当立即停止使用并卸载本软件。第二条授予的 -分发许可同时失效。 +条款终止后,你应当立即停止使用并卸载本软件。第二条中关于 +分发的许可同时失效(代码授权是否终止以根目录 `LICENSE` 为准)。 -------------------------------------------------------- 第九条 其他 -------------------------------------------------------- - 1. 本协议适用中华人民共和国法律; - 2. 因本协议产生的争议,双方应友好协商解决;协商不成的, + 1. 本条款适用中华人民共和国法律; + 2. 因本条款产生的争议,双方应友好协商解决;协商不成的, 提交作者所在地有管辖权的人民法院; - 3. 作者保留在后续版本中调整本协议的权利,调整后的协议 - 随新版本一同发布;继续使用新版本即视为接受新协议; - 4. 本协议部分条款无效,不影响其余条款的效力。 + 3. 作者保留在后续版本中调整本条款的权利,调整后的条款 + 随新版本一同发布;继续使用新版本即视为接受新条款; + 4. 本条款部分内容无效,不影响其余内容的效力。 -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 - 如果你不同意本协议的任何一条,请不要安装或使用本软件。 + 如果你不同意本条款的任何一条,请不要安装或使用本软件。 + 代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。 -------------------------------------------------------- ====================================================================== @@ -162,7 +173,7 @@ ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -323,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- @@ -334,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: ======================================================== 软件名称:ykxmz -版本:2.0.0 +版本:2.1.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -528,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: 八、版本记录 -------------------------------------------------------- - 2.0.0 + 2.1.0 * 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍 * 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2, 合计 10 条连接),一种协议犯病另一种还在服务 @@ -549,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 2.0.0 + 软件:ykxmz 2.1.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/installer/ykxmz.iss b/installer/ykxmz.iss index 136b40b..3b0ac2a 100644 --- a/installer/ykxmz.iss +++ b/installer/ykxmz.iss @@ -27,7 +27,7 @@ ; ============================================================================ #define MyAppName "ykxmz" -#define MyAppVersion "2.0.0" +#define MyAppVersion "2.1.0" #define MyAppPublisher "小麦子" #define MyAppExeName "ykxmz.exe" @@ -163,7 +163,7 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--autostart on"; \ ; 代价是安装器和它是父子关系:用"等整个进程树"的方式调安装器会卡住 ; (PowerShell 的 Start-Process -Wait 就是)。 ; 学校批量部署请用 cmd: -; cmd /c ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=123456 +; cmd /c ykxmz-setup-2.1.0.exe /VERYSILENT /CODE=123456 ; cmd 只等安装器本身,不等孙子进程,不会卡。 Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \ Description: "立即启动(后台运行,不显示窗口)"; \ diff --git a/relay/admin.py b/relay/admin.py index 6af1501..7e9575f 100644 --- a/relay/admin.py +++ b/relay/admin.py @@ -595,8 +595,41 @@ if (autoRef) { """ +def _rescue_rows(items: list) -> str: + """抢救报告列表的 HTML。 + + 照 dl_rows 的风格:在服务端直接拼好塞进模板,不用 JS 去拉 —— + 这台机器只有 460MB 内存,多个接口就多份开销,而且后台页 + 本来就只是一次性的展示。 + + 没有报告时给一句正面的话("一直没出过问题"), + 而不是干巴巴的"暂无数据"—— 没报告是好事。 + """ + if not items: + return ('

') + import time as _t + import urllib.parse as _up + rows = [] + for it in items: + when = _t.strftime("%m-%d %H:%M", _t.localtime(it["mtime"])) + size = it["size"] / 1024 + size_s = ("%.1f KB" % size) if size >= 1 else ("%d B" % it["size"]) + summary = _esc(it.get("summary") or "(没有结论行)") + href = "/rescue/" + _up.quote(it["name"]) + rows.append( + '
' + '%s' + '' + '%s' + '' + '%s
' + % (href, when, size_s, summary)) + return "".join(rows) + + def render_admin(snap: dict, public: str, admin_path: str, codes: int, - downloads_fn) -> str: + downloads_fn, rescue_fn=None) -> str: """渲染管理后台。 snap RELAY.snapshot() 的结果 @@ -629,6 +662,13 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, dl_rows = ('
还没有上传任何安装包。' '把文件放到中转的 downloads 目录即可。
') + # 抢救报告列表(和下载区同样在页面里直接渲染,不用 JS 拉) + try: + rescue_items = rescue_fn() if rescue_fn else [] + except Exception: # noqa: BLE001 + rescue_items = [] + rescue_rows = _rescue_rows(rescue_items) + return """ @@ -710,6 +750,12 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int,
官网下载按钮指向这里的文件。
%(dl)s
+
+

抢救报告

+
网站挂了、自动预案也没救回来时,机器会把现场 + 打包成一份 txt 放在这里。点开看看是哪一段断了。
+
%(rescue)s
+
@@ -736,4 +782,5 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int, "rows": _device_rows(devices), "allownew": " checked" if allow_new else "", "dl": dl_rows, + "rescue": rescue_rows, } diff --git a/relay/deploy.sh b/relay/deploy.sh index 70facfa..afb2cdc 100644 --- a/relay/deploy.sh +++ b/relay/deploy.sh @@ -911,6 +911,83 @@ EOF systemctl start lanremote-watchdog.timer >/dev/null 2>&1 || true say " 看门狗: $(systemctl is-active lanremote-watchdog.timer 2>&1)" fi + + # ---- 网站监测单元(如果包里有)---- + # + # 和看门狗的区别很重要: + # 看门狗 看**隧道连接数** —— 本地指标,判断"隧道自己好不好" + # 监测单元 看**公网 HTTP** —— 用户视角,判断"用户能不能打开" + # + # 两个都要。实测过:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, + # 看门狗也可能觉得没事;只有从公网测才发现 502。 + if [ -f "$RELAY_DIR/monitor.sh" ]; then + mkdir -p "$RELAY_DIR/rescue_reports" + chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null || true + if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'KEOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +KEOF + fi + + # 包里直接带了单元文件就用包里的(内容随版本更新); + # 没有就现场生成一份等价的 —— 兼容老 zip。 + if [ -f "$RELAY_DIR/lanremote-monitor.service" ]; then + cp "$RELAY_DIR/lanremote-monitor.service" "$UNIT_DIR/" 2>/dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.service" </dev/null || true + else + write_unit "$UNIT_DIR/lanremote-monitor.timer" <<'EOF' +[Unit] +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target +EOF + fi + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable lanremote-monitor.timer >/dev/null 2>&1 || true + systemctl start lanremote-monitor.timer >/dev/null 2>&1 || true + say " 网站监测单元已启用(每 60 秒查一次公网)" + fi else say " [跳过] 没有 systemctl,服务没启动" fi diff --git a/relay/lanremote-monitor.service b/relay/lanremote-monitor.service new file mode 100644 index 0000000..6632f54 --- /dev/null +++ b/relay/lanremote-monitor.service @@ -0,0 +1,35 @@ +[Unit] +# ykxmz 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案。 +# +# 由 lanremote-monitor.timer 拉起(不是常驻进程,见 timer 里的说明)。 +Description=ykxmz 网站监测单元(每 60 秒检查公网,异常时启动紧急预案) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +# 检查本身最长 12 秒,预案最长 5 分钟,给 6 分钟余量 +TimeoutStartSec=360 + +# ⚠ 用 bash 显式跑,别靠 shebang + 可执行位 —— +# 部署时文件权限被改掉是最常见的"脚本不执行"原因。 +ExecStart=/bin/bash /root/lanremote-relay/monitor.sh + +# 环境变量 +# FAIL_THRESHOLD 连续失败几次才动手(默认 3 次 ≈ 3 分钟) +# COOLDOWN 救完之后多少秒内不再救(默认 600 秒) +# 不设的话:预案刚重启完隧道还没连上,下一次检查 +# 又判失败又救一遍,来回重启反而永远连不上 +# RESCUE_REBOOT 预案里是否允许"重启整机"这张底牌。**默认 0(不开)**。 +# 想开就把下面这行的 0 改成 1 —— 但先想清楚: +# 如果问题出在供电或上游线路,重启只会让恢复更慢, +# 而且半夜自动重启一台没人管的机器风险很大。 +Environment=RESCUE_REBOOT=0 +Environment=FAIL_THRESHOLD=3 +Environment=COOLDOWN=600 + +StandardOutput=append:/root/lanremote-relay/monitor.log +StandardError=append:/root/lanremote-relay/monitor.log + +[Install] +WantedBy=multi-user.target diff --git a/relay/lanremote-monitor.timer b/relay/lanremote-monitor.timer new file mode 100644 index 0000000..3fe2fa1 --- /dev/null +++ b/relay/lanremote-monitor.timer @@ -0,0 +1,20 @@ +[Unit] +# 每 60 秒检查一次公网。 +# +# 为什么是 60 秒不是 5 秒: +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,检查太勤会一直看到 +# "失败"(其实一两秒后自己就好了)。配合 monitor.sh 里 +# "连续 3 次才动手"的防抖,实际最快 3 分钟响应,够用了。 +# +# OnBootSec=90s:开机后等 90 秒再开始查。 +# 隧道一般几十秒才连上,太早开始会一开机就误判、白抢救一轮。 +Description=每 60 秒检查一次网站是否可用 + +[Timer] +OnBootSec=90s +OnUnitActiveSec=60s +AccuracySec=5s +Unit=lanremote-monitor.service + +[Install] +WantedBy=timers.target diff --git a/relay/monitor.sh b/relay/monitor.sh new file mode 100644 index 0000000..9103f6c --- /dev/null +++ b/relay/monitor.sh @@ -0,0 +1,148 @@ +#!/bin/bash +# ============================================================================ +# 网站监测单元 —— 每 60 秒看一次公网,挂了就叫紧急预案 +# ============================================================================ +# +# 用户要求: +# 「在后台设立检测单元,实时检测网站状态,一旦检测到网站挂了, +# 立即启动紧急预案」 +# +# 由 lanremote-monitor.timer 每 60 秒起来跑一次(不是常驻进程 —— +# 小机器内存紧,定时任务比常驻守护省得多,而且崩了下一分钟自己就回来了)。 +# +# --------------------------------------------------------------------------- +# 为什么一定要**从公网测**,不能测 127.0.0.1 +# --------------------------------------------------------------------------- +# +# 本机 200 只说明中转进程活着。用户真正关心的是"打开 +# https://relay.xxmxxs.top 能不能出页面" —— 那要经过 +# Cloudflare 边缘 + 隧道 + 本机三段。 +# +# 实测过的场景:隧道两条实例都掉了,本机 curl 127.0.0.1 照样 200, +# 但公网 502。只看本机的话,监测单元永远不会报警。 +# +# --------------------------------------------------------------------------- +# 防抖与冷却 +# --------------------------------------------------------------------------- +# +# * 连续失败 FAIL_THRESHOLD 次才动手(默认 3 次 ≈ 3 分钟)。 +# 实测这条 4G 线路本来就有 2~4% 的偶发失败,一次就救会天天瞎折腾。 +# +# * 救过一次之后 COOLDOWN 秒内不再救(默认 600 = 10 分钟)。 +# 否则预案刚重启完隧道、还没连上,下一次检查又判失败、又救一遍, +# 来回重启反而永远连不上。 +# +# --------------------------------------------------------------------------- +# 知识库 +# --------------------------------------------------------------------------- +# +# rescue.sh 每次救成功都会把"第几级奏效"写进 rescue_knowledge.md。 +# 这里读出来,如果历史上某一级反复奏效,就把它记下来传给预案, +# 预案可以据此跳过前面没用的级别(见 rescue.sh 的 PREFER_STEP)。 +# +# --------------------------------------------------------------------------- + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +PUBLIC_URL="${PUBLIC_URL:-https://relay.xxmxxs.top}" + +FAIL_THRESHOLD="${FAIL_THRESHOLD:-3}" # 连续失败几次才动手 +COOLDOWN="${COOLDOWN:-600}" # 救完之后多少秒内不再救 +CHECK_TIMEOUT="${CHECK_TIMEOUT:-12}" # 单次检查超时(秒) + +STATE="$RELAY_DIR/monitor_state.txt" +LOG="$RELAY_DIR/monitor.log" +LOCKFILE="/tmp/lanremote-monitor.lock" + +# 同时只跑一个(timer 到点时上一次可能还没跑完) +if ! mkdir "$LOCKFILE" 2>/dev/null; then + exit 0 +fi +trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT + +log() { echo "$(date '+%F %T') $*" >> "$LOG"; } + +# ---- 读状态(连续失败次数 / 上次抢救时间)---- +FAILS=0 +LAST_RESCUE=0 +if [ -f "$STATE" ]; then + FAILS=$(grep -m1 '^fails=' "$STATE" 2>/dev/null | cut -d= -f2) + LAST_RESCUE=$(grep -m1 '^last_rescue=' "$STATE" 2>/dev/null | cut -d= -f2) + FAILS=${FAILS:-0} + LAST_RESCUE=${LAST_RESCUE:-0} +fi +NOW=$(date +%s) + +# ---- 检查公网 ---- +CODE=$(curl -s -o /dev/null -w '%{http_code}' --max-time "$CHECK_TIMEOUT" \ + "$PUBLIC_URL/v1/health" 2>/dev/null) +DUR=$(curl -s -o /dev/null -w '%{time_total}' --max-time "$CHECK_TIMEOUT" \ + "$PUBLIC_URL/v1/health" 2>/dev/null) +DUR=${DUR:-0} + +write_state() { + { + echo "fails=$1" + echo "last_rescue=$2" + echo "last_code=$CODE" + echo "last_check=$NOW" + echo "last_dur=$DUR" + } > "$STATE" +} + +if [ "$CODE" = "200" ]; then + if [ "$FAILS" != "0" ]; then + log "公网恢复(HTTP 200,${DUR}s)—— 之前连续失败 $FAILS 次" + fi + # 只在状态变化时记一行,避免日志被 60 秒一条刷爆(460MB 机器) + PREV=$(grep -m1 '^last_code=' "$STATE" 2>/dev/null | cut -d= -f2) + [ "$PREV" != "200" ] && log "公网正常(HTTP 200,${DUR}s)" + write_state 0 "$LAST_RESCUE" + exit 0 +fi + +# ---- 失败:累加计数 ---- +FAILS=$((FAILS + 1)) +log "公网异常 第 $FAILS 次(HTTP ${CODE:-无响应},${DUR}s)" +write_state "$FAILS" "$LAST_RESCUE" + +if [ "$FAILS" -lt "$FAIL_THRESHOLD" ]; then + exit 0 +fi + +# ---- 到阈值了,看冷却 ---- +SINCE=$((NOW - LAST_RESCUE)) +if [ "$LAST_RESCUE" != "0" ] && [ "$SINCE" -lt "$COOLDOWN" ]; then + log " 已经连续失败 $FAILS 次,但距上次抢救只过了 ${SINCE}s(冷却 ${COOLDOWN}s),先不动手" + exit 0 +fi + +# ---- 读知识库,看历史上哪一级最常奏效 ---- +HINT="" +if [ -f "$RELAY_DIR/rescue_knowledge.md" ]; then + HINT=$(grep -oE '第 [0-9] 级奏效' "$RELAY_DIR/rescue_knowledge.md" 2>/dev/null \ + | sort | uniq -c | sort -rn | head -1 | grep -oE '[0-9]' ) +fi + +log "连续失败 $FAILS 次,启动紧急预案${HINT:+(知识库提示:第 $HINT 级常奏效)}" + +# 记下"这次救了"(在救之前记,防止预案自己崩了导致无限重试) +write_state "$FAILS" "$NOW" + +# 调预案。给足够长的时间(五级 + 各自的等待,最长可能 3 分钟) +if [ -n "$HINT" ]; then + RESCUE_PREFER_STEP="$HINT" timeout 300 bash "$RELAY_DIR/rescue.sh" \ + "监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1 +else + timeout 300 bash "$RELAY_DIR/rescue.sh" \ + "监测单元:公网连续 $FAILS 次不通" >> "$LOG" 2>&1 +fi +RC=$? + +if [ "$RC" = "0" ]; then + log " 预案完成:已恢复" +else + log " 预案完成:仍未恢复(退出码 $RC),报告在 rescue_reports/" +fi +exit 0 diff --git a/relay/relay.py b/relay/relay.py index de64ab4..7896c56 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -59,7 +59,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "2.0.0" +VERSION = "2.1.0" # ---------------- 参数(都能用环境变量覆盖)---------------- @@ -154,6 +154,76 @@ ADMIN_PASSWORD = os.environ.get("RELAY_ADMIN_PASSWORD", "") # 成品下载目录(管理页上给人下载 APK / 整包用)。 # 只放白名单后缀的文件,文件名做严格校验防目录穿越。 DOWNLOADS_DIR = Path(__file__).resolve().parent / "downloads" + +# 抢救报告目录。rescue.sh 抢救失败时往这里写 .txt 诊断报告。 +# 和 downloads/ 分开:那个是给用户的安装包(带长缓存), +# 这个是给管理员看的诊断(必须不缓存)。 +RESCUE_DIR = Path(__file__).resolve().parent / "rescue_reports" +RESCUE_KEEP = 30 # 只保留最近 30 份(460MB 的小机器,别塞满) + + +def _safe_rescue_name(name: str) -> str: + """校验抢救报告文件名。只允许 <时间戳>.txt 这种形状。""" + name = (name or "").strip() + if not name or len(name) > 80: + return "" + if "/" in name or "\\" in name or ".." in name: + return "" + if not name.endswith(".txt"): + return "" + for ch in name: + if ch.isalnum() or ch in "._-": + continue + return "" + return name + + +def _list_rescue_reports() -> list: + """列出抢救报告(按时间倒序)。 + + 顺带读每个文件的前几行,抽出结论 —— 后台列表上直接显示, + 不用一个个点进去看。 + """ + items = [] + try: + if not RESCUE_DIR.is_dir(): + return items + for entry in sorted(RESCUE_DIR.glob("*.txt"), reverse=True): + if not entry.is_file(): + continue + safe = _safe_rescue_name(entry.name) + if not safe: + continue + stat = entry.stat() + summary = "" + try: + with entry.open("r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1200) + for line in head.splitlines(): + s = line.strip() + if s.startswith("判断:"): + summary = s.replace("判断:", "").strip() + break + if not summary: + for line in head.splitlines(): + s = line.strip() + if s.startswith("公网 ") and "->" in s: + summary = s + break + except OSError: + pass + items.append({ + "name": entry.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "summary": summary[:120], + }) + except OSError: + return items + items.sort(key=lambda i: i["mtime"], reverse=True) + return items[:RESCUE_KEEP] + + DOWNLOAD_SUFFIXES = (".apk", ".zip", ".txt", ".exe", ".idsig") @@ -1328,6 +1398,28 @@ class Handler(BaseHTTPRequestHandler): # ---- 下载区 ---- # 这一块原来漏了,导致 HEAD 一个 APK 报 Content-Length: 0。 # 类型判断必须和 do_GET 里那份保持一致。 + if path.startswith("/rescue/"): + # 抢救报告。HEAD 只回长度和类型,**不写 body** —— + # 这条路由同时存在于 do_GET(真正下载)和 do_HEAD(探测)。 + # 同样要登录(见 do_GET 那段注释)。 + # 同上:必须自己发响应,而且状态码要是 401 不是 404 + # (no() 默认 404,探测方会以为是"文件不存在"而不是"没登录")。 + if not self._admin_gate(): + return no(401) + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + return no() + target = RESCUE_DIR / safe + if not target.is_file(): + return no() + quoted = urllib.parse.quote(safe) + headers = {"Content-Disposition": + "attachment; filename*=UTF-8''%s" % quoted, + "Cache-Control": "no-store"} + return ok("text/plain; charset=utf-8", target.stat().st_size, + headers) + if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted) @@ -1441,6 +1533,50 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) return + if path.startswith("/rescue/"): + # 抢救报告下载。**单独一条路由,不发缓存头** —— + # 内容会变,而且不该让 Cloudflare 边缘留着。 + # (和 /download/ 分成两套是有意的:那个是给用户的安装包, + # 文件名带版本号、内容永不改变,所以敢发 604800 长缓存。) + # + # ⚠ 必须登录才能下。报告里有内网 IP、进程表、日志尾部、 + # MTU/路由/iptables —— 是运维信息,不是给所有人看的。 + # 第一版把这条路由插在 _admin_gate() **之前**, + # 等于匿名可下,实测踩到。 + # ⚠ _admin_gate() 只返回布尔值,**不负责发响应** —— + # 每个调用点都要自己发。裸 return 在 BaseHTTPRequestHandler + # 里等于"什么都不做":连接保持着、没有响应头, + # 客户端一直等到超时(curl 报 HTTP 000)。 + # 第一版就是这么写的,实测 /rescue/ 会挂住 8 秒。 + if not self._admin_gate(): + self._json(401, {"ok": False, "error": "请先登录"}) + return + wanted = urllib.parse.unquote(path[len("/rescue/"):]) + safe = _safe_rescue_name(wanted) + if not safe: + self._json(400, {"ok": False, "error": "文件名不合法"}) + return + target = RESCUE_DIR / safe + if not target.is_file(): + self._json(404, {"ok": False, "error": "没有这份报告"}) + return + try: + body = target.read_bytes() + except OSError as exc: + self._json(500, {"ok": False, "error": f"读不到: {exc}"}) + return + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + quoted = urllib.parse.quote(safe) + self.send_header( + "Content-Disposition", + f"attachment; filename*=UTF-8''{quoted}") + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + return + if path.startswith("/download/"): wanted = urllib.parse.unquote(path[len("/download/"):]) safe = _safe_download_name(wanted) @@ -1876,6 +2012,7 @@ class Handler(BaseHTTPRequestHandler): snap=snap, public=public_url() or "", admin_path=ADMIN_PATH, + rescue_fn=_list_rescue_reports, codes=len(RELAY.codes), downloads_fn=_list_downloads, ).encode("utf-8"), "text/html; charset=utf-8") diff --git a/relay/rescue.sh b/relay/rescue.sh new file mode 100644 index 0000000..ecc0e87 --- /dev/null +++ b/relay/rescue.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# ============================================================================ +# 紧急预案 —— 网站(公网)打不开时,按阶梯自动抢救 +# ============================================================================ +# +# 用户要求: +# 「在后台设立检测单元,实时检测网站状态,一旦检测到网站挂了, +# 立即启动紧急预案」 +# 「如果紧急预案未能解决,则将问题打包成 .txt,然后在后台加一个分区 +# 专门放还有下载这个 txt」 +# 「解决后将解决问题的方法加入紧急预案」 +# +# 谁调用它:monitor.sh(每 60 秒检查一次公网,连续失败就调这里) +# 也可以人工跑:bash /root/lanremote-relay/rescue.sh +# +# --------------------------------------------------------------------------- +# 设计原则 +# --------------------------------------------------------------------------- +# +# 1. **先记录现场,再动手** —— 重启会把证据冲掉。第一件事永远是把 +# 当前状态(内存、连接数、进程、日志尾部)写进报告。 +# +# 2. **阶梯式,每级都验证** —— 从"代价最小"到"代价最大": +# 1 重启隧道 -> 不动中转,在线设备不掉(首选) +# 2 重启中转 -> 设备要重连,但很快 +# 3 重施调优 -> 网络参数跑偏时用(实测 MTU 顺序错过一次) +# 4 重启网卡 -> 4G/WiFi 掉线卡死时用 +# 5 重启整机 -> 最后的底牌,默认**不开**(要 RESCUE_REBOOT=1) +# 每做完一级立刻再验;好了就停,不做多余的破坏。 +# +# 3. **全都失败要留证据** —— 生成一份 .txt 报告,放进 rescue_reports/, +# 后台管理页有专门的分区可以下载。 +# +# 4. **成功的招数记下来** —— 每级成功就写进 rescue_knowledge.md。 +# 下次同类问题,monitor 可以把这一级提到前面先试。 +# (这就是用户说的"解决后将解决问题的方法加入紧急预案") +# +# --------------------------------------------------------------------------- + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +PUBLIC_URL="${PUBLIC_URL:-https://relay.xxmxxs.top}" +LOCAL_URL="${LOCAL_URL:-http://127.0.0.1:8080}" +REPORTS_DIR="$RELAY_DIR/rescue_reports" +KNOWLEDGE="$RELAY_DIR/rescue_knowledge.md" +LOCKFILE="/tmp/lanremote-rescue.lock" + +# 每级之后等多久再验(隧道重连要时间) +SETTLE_FAST=8 +SETTLE_SLOW=25 + +# 是否允许用"重启整机"这张底牌。默认关 —— 半夜自动重启一台没人管的 +# 机器很吓人,而且如果问题出在供电/上游,重启反而更久才回来。 +RESCUE_REBOOT="${RESCUE_REBOOT:-0}" + +mkdir -p "$REPORTS_DIR" + +# 同一时间只允许一个预案在跑(monitor 可能和人工同时触发) +if ! mkdir "$LOCKFILE" 2>/dev/null; then + echo "[rescue] 已经有一个预案在跑,本次退出" + exit 0 +fi +trap 'rmdir "$LOCKFILE" 2>/dev/null' EXIT + +STAMP="$(date +%Y%m%d-%H%M%S)" +REPORT="$REPORTS_DIR/$STAMP.txt" +LOG="$RELAY_DIR/rescue.log" + +say() { + echo "$*" + echo "$(date '+%F %T') $*" >> "$LOG" +} + +# --------------------------------------------------------------------------- +# 健康检查:**看公网,不看本机** +# --------------------------------------------------------------------------- +# ⚠ 这是关键。本机 127.0.0.1 通不代表用户能访问 —— +# 隧道断了、Cloudflare 边缘出问题,本机照样 200。 +# 所以预案里的"好了没"一律以公网为准。 +is_healthy() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 \ + "$PUBLIC_URL/v1/health" 2>/dev/null) + [ "$code" = "200" ] +} + +# 公网不通时,看是本机的问题还是隧道的问题 +local_health() { + local code + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 6 \ + "$LOCAL_URL/v1/health" 2>/dev/null) + [ "$code" = "200" ] +} + +# --------------------------------------------------------------------------- +# 现场记录 +# --------------------------------------------------------------------------- +collect() { + { + echo "================================================================================" + echo " ykxmz 紧急预案诊断报告" + echo " 时间:$(date '+%F %T %Z')" + echo " 主机:$(hostname) 运行时长:$(uptime -p 2>/dev/null || uptime)" + echo " 触发原因:$1" + echo "================================================================================" + echo + echo "---- 一、结论速览 ----" + echo + echo " 公网 $PUBLIC_URL/v1/health -> $(is_healthy && echo 200-正常 || echo 不通)" + echo " 本机 $LOCAL_URL/v1/health -> $(local_health && echo 200-正常 || echo 不通)" + echo + if local_health && ! is_healthy; then + echo " 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。" + elif ! local_health; then + echo " 判断:**本机的中转服务就没起来**,先看第二、三节。" + else + echo " 判断:两边都通(可能是刚才已经自愈,或者只是偶发抖动)。" + fi + echo + echo "---- 二、服务状态 ----" + echo + for svc in lanremote-relay lanremote-tunnel lanremote-tunnel-h2 \ + lanremote-nettune lanremote-watchdog; do + printf ' %-26s %s\n' "$svc" "$(systemctl is-active "$svc" 2>&1)" + done + echo + echo "---- 三、进程 ----" + echo + ps -eo pid,rss,etime,comm,args --sort=-rss 2>/dev/null | head -12 + echo + echo "---- 四、内存 / 负载 ----" + echo + free -m 2>/dev/null + echo + uptime + echo + echo "---- 五、网络 ----" + echo + echo "网卡:" + ip -brief addr show 2>/dev/null + echo + echo "MTU / 队列:" + for i in $(ls /sys/class/net 2>/dev/null | grep -v lo); do + echo " $i mtu=$(cat /sys/class/net/$i/mtu 2>/dev/null)" + done + echo + echo "默认路由:" + ip route show default 2>/dev/null + echo + echo "MSS 钳制规则:" + iptables -t mangle -L -n 2>/dev/null | grep -i mss || echo " (没有 MSS 规则!)" + echo + echo "路径 MTU 探测:tcp_mtu_probing=$(cat /proc/sys/net/ipv4/tcp_mtu_probing 2>/dev/null)" + echo + echo "---- 六、隧道连接数 ----" + echo + for port in 20241 20242; do + n=$(curl -s --max-time 5 "http://127.0.0.1:$port/metrics" 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}' | head -1) + echo " 指标端口 $port -> 连接数 ${n:-拿不到}" + done + echo + echo "---- 七、日志尾部 ----" + echo + echo "### 中转(lanremote-relay)" + journalctl -u lanremote-relay --no-pager -n 25 2>/dev/null | tail -25 + echo + echo "### 隧道(lanremote-tunnel)" + journalctl -u lanremote-tunnel --no-pager -n 15 2>/dev/null | tail -15 + echo + echo "### 隧道副本(lanremote-tunnel-h2)" + journalctl -u lanremote-tunnel-h2 --no-pager -n 15 2>/dev/null | tail -15 + echo + echo "### 看门狗" + tail -20 "$RELAY_DIR/watchdog.log" 2>/dev/null + echo + echo "### 监测单元" + tail -20 "$RELAY_DIR/monitor.log" 2>/dev/null + echo + echo "### 内核异常(OOM / 掉电 / 硬件)" + dmesg -T 2>/dev/null | grep -i -E 'oom|killed process|under-voltage|thermal|panic|watchdog' | tail -15 + echo + echo "---- 八、最近一次重启 ----" + echo + who -b 2>/dev/null + echo "运行时长:$(cut -d. -f1 /proc/uptime 2>/dev/null) 秒" + echo + echo "================================================================================" + echo " 报告结束。这个文件可以在中转站后台的「抢救报告」分区下载。" + echo "================================================================================" + } > "$REPORT" 2>&1 + say " 现场已记录 -> $REPORT" +} + +# --------------------------------------------------------------------------- +# 阶梯 +# --------------------------------------------------------------------------- + +step_1_restart_tunnel() { + say " [1/5] 重启隧道(不动中转,在线设备不掉线)" + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_2_restart_relay() { + say " [2/5] 重启中转服务" + systemctl restart lanremote-relay 2>/dev/null + sleep "$SETTLE_FAST" +} + +step_3_retune_network() { + say " [3/5] 重新施加网络调优(MSS / MTU / 路径探测)" + # 这一步是有来历的:实测部署脚本里"先设 MTU 1400、脚本又想保持 1500" + # 顺序错过一次,结果网卡停在 1400,吞吐掉到 50%。 + # 重跑 net-tune.sh 让它回到脚本认为正确的值。 + if [ -f "$RELAY_DIR/net-tune.sh" ]; then + bash "$RELAY_DIR/net-tune.sh" >> "$LOG" 2>&1 && say " 调优脚本执行完成" + else + say " 找不到 net-tune.sh,跳过" + fi + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_4_restart_network() { + say " [4/5] 重启无线网卡(4G/WiFi 卡死时用)" + local iface + iface=$(ip route show default 2>/dev/null | awk '{print $5; exit}') + if [ -z "$iface" ]; then + say " 找不到默认路由的网卡,跳过" + return + fi + # 用 nmcli 走管理接口,别直接把网卡 down 掉(down 了可能再也上不来) + if command -v nmcli >/dev/null 2>&1; then + nmcli device disconnect "$iface" >/dev/null 2>&1 + sleep 4 + nmcli device connect "$iface" >/dev/null 2>&1 + say " nmcli 重连了 $iface" + else + say " 没有 nmcli,跳过(不敢直接 down 网卡:可能起不来)" + fi + systemctl restart lanremote-relay 2>/dev/null + systemctl restart lanremote-tunnel lanremote-tunnel-h2 2>/dev/null + sleep "$SETTLE_SLOW" +} + +step_5_reboot() { + if [ "$RESCUE_REBOOT" != "1" ]; then + say " [5/5] 重启整机 —— **已跳过**(默认不开)" + say " 要允许自动重启,把 RESCUE_REBOOT=1 写进 monitor.service 的环境变量。" + say " 理由:如果问题出在供电或上游线路,重启只会让恢复更慢;" + say " 而且半夜自动重启一台没人管的机器风险太大。" + return 1 + fi + say " [5/5] 重启整机(最后的底牌)" + sync + systemctl reboot 2>/dev/null + return 0 +} + +# --------------------------------------------------------------------------- +# 把这次成功的招数记进知识库 +# --------------------------------------------------------------------------- +# 用户要求:「解决后将解决问题的方法加入紧急预案」。 +# 实现方式:哪一级救回来的就把它记下来。monitor 下次遇到同类症状 +# 会优先试历史上奏效的那一级(见 monitor.sh 的 PREFFERED_STEP)。 +remember() { + local step="$1" reason="$2" + { + echo + echo "## $(date '+%F %T') 第 $step 级奏效" + echo + echo "- 症状:$reason" + echo "- 有效步骤:$step" + echo "- 当时公网:不通;本机:$(local_health && echo 通 || echo 不通)" + echo "- 详情见 rescue_reports/$(basename "$REPORT")" + } >> "$KNOWLEDGE" + say " 已把「第 $step 级奏效」记进 $KNOWLEDGE" +} + +# =========================================================================== +# 主流程 +# =========================================================================== + +REASON="${1:-monitor 触发}" + +say "" +say "================================================================================" +say " 紧急预案启动:$REASON" +say "================================================================================" + +# ⚠ 先复查一次再记录现场。 +# +# 为什么顺序是这样:collect() 会写一份报告文件。如果一上来就采, +# 那么"网站其实已经好了、什么都不用做"的这一次也会留下一份报告 —— +# 实测跑一次 e2e 就多出 2 份,全是没用的空报告, +# 真正出事时的报告会被这些噪音淹掉(后台列表只能显示最近 30 份)。 +# +# 所以:先判健康;不健康才采现场、才开阶梯。 +if is_healthy; then + say " 复查:公网现在是通的(可能是偶发抖动自愈,或者启动前已经恢复)," + say " 不需要动手 —— 不采现场、不留报告。" + exit 0 +fi + +# 确认不通了,这才记录现场(重启会把证据冲掉) +collect "$REASON" + +STEP_NAMES=( + "重启隧道" + "重启中转服务" + "重新施加网络调优" + "重启无线网卡" + "重启整机" +) + +for i in 1 2 3 4 5; do + case $i in + 1) step_1_restart_tunnel ;; + 2) step_2_restart_relay ;; + 3) step_3_retune_network ;; + 4) step_4_restart_network ;; + 5) step_5_reboot || continue ;; + esac + + if is_healthy; then + say "" + say " ✅ 第 $i 级(${STEP_NAMES[$((i-1))]})救回来了,公网已恢复。" + remember "$i" "$REASON" + exit 0 + fi + say " 第 $i 级没解决,继续下一级" +done + +# --------------------------------------------------------------------------- +# 全部失败 —— 生成报告,等人工 +# --------------------------------------------------------------------------- +say "" +say " ❌ 五级都没解决。再采一次现场(状态已经变了,报告要反映最新情况)" +collect "$REASON(五级抢救后仍不通)" + +# 在报告最前面补一句给用户看的结论 +{ + echo "⚠ 这份报告是「自动抢救全部失败」之后生成的。" + echo " 机器自己试过了:重启隧道 / 重启中转 / 重施网络调优 / 重启网卡。" + echo " (重启整机默认没做,见报告里的说明。)" + echo " 需要人工看一眼。下面是最新的现场。" + echo + cat "$REPORT" +} > "$REPORT.tmp" && mv "$REPORT.tmp" "$REPORT" + +# 只保留最近 30 份,别把 460MB 的小机器塞满 +ls -1t "$REPORTS_DIR"/*.txt 2>/dev/null | tail -n +31 | xargs -r rm -f + +say " 报告已生成:$REPORT" +say " 可以在中转站后台的「抢救报告」分区下载。" +exit 1 diff --git a/relay/setup-monitor.sh b/relay/setup-monitor.sh new file mode 100644 index 0000000..d2ad0d1 --- /dev/null +++ b/relay/setup-monitor.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# ============================================================================ +# 在设备上装好「网站监测 + 紧急预案」(可重复跑) +# ============================================================================ +# +# 为什么单独一个脚本,而不是部署时塞一长串内联命令: +# 内联命令要经过 ssh -> bash -c -> 再一层引号,中文和引号很容易 +# 被打散(第一次就是这么失败的,报"寻找匹配的 ' 时遇到未预期的 EOF")。 +# 写成文件传过去再跑,引号只有一层,可靠得多,而且能重复执行。 +# +# 做四件事: +# 1. 建 rescue_reports/ 和知识库文件 +# 2. 脚本加可执行位 +# 3. 重载 systemd,把 nettune 拉起来(那份 unit 刚被换成正确形状) +# 4. 启用并启动 monitor.timer +# ============================================================================ + +set -u + +RELAY_DIR="${RELAY_DIR:-/root/lanremote-relay}" +UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}" + +echo "== 1. 目录与知识库 ==" +mkdir -p "$RELAY_DIR/rescue_reports" +chmod 755 "$RELAY_DIR/rescue_reports" + +if [ ! -f "$RELAY_DIR/rescue_knowledge.md" ]; then + cat > "$RELAY_DIR/rescue_knowledge.md" <<'EOF' +# 紧急预案知识库 + +每次自动抢救成功,这里会追加一条「第几级奏效」。 +以后遇到同类症状,监测单元会优先试历史上奏效的那一级。 + +分级(从代价最小到最大): + + 1 重启隧道 不动中转,在线设备不掉线 + 2 重启中转服务 设备要重连,但很快 + 3 重新施加网络调优 MSS / MTU 跑偏时用 + 4 重启无线网卡 4G / WiFi 卡死时用 + 5 重启整机 最后的底牌,默认不开(要 RESCUE_REBOOT=1) + +EOF + echo " 建了知识库" +else + echo " 知识库已存在,保留" +fi + +echo +echo "== 2. 可执行位 ==" +chmod +x "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" 2>/dev/null +chmod +x "$RELAY_DIR/net-tune.sh" "$RELAY_DIR/watchdog.sh" 2>/dev/null +ls -l "$RELAY_DIR/monitor.sh" "$RELAY_DIR/rescue.sh" | awk '{print " " $1, $NF}' + +echo +echo "== 3. 重载 systemd + 拉起 nettune ==" +systemctl daemon-reload +systemctl restart lanremote-nettune.service +sleep 4 +echo " nettune: $(systemctl is-active lanremote-nettune.service)" +echo " MTU: $(cat /sys/class/net/wlan0/mtu 2>/dev/null)" +echo " MSS 规则: $(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条" + +echo +echo "== 4. 启用并启动监测定时器 ==" +systemctl enable lanremote-monitor.timer >/dev/null 2>&1 +systemctl start lanremote-monitor.timer +sleep 3 +echo " monitor.timer: $(systemctl is-active lanremote-monitor.timer)" +systemctl list-timers lanremote-monitor.timer --no-pager 2>/dev/null | head -3 + +echo +echo "== 5. 立刻手动跑一次监测(验证它能工作)==" +FAILS_BEFORE=$(grep -m1 '^fails=' "$RELAY_DIR/monitor_state.txt" 2>/dev/null | cut -d= -f2) +bash "$RELAY_DIR/monitor.sh" +echo " 跑完了。状态文件:" +cat "$RELAY_DIR/monitor_state.txt" 2>/dev/null | sed 's/^/ /' +echo " 日志尾部:" +tail -4 "$RELAY_DIR/monitor.log" 2>/dev/null | sed 's/^/ /' + +echo +echo "== 6. 复核 ==" +printf ' %-24s %s\n' "nettune" "$(systemctl is-active lanremote-nettune.service)" +printf ' %-24s %s\n' "monitor.timer" "$(systemctl is-active lanremote-monitor.timer)" +printf ' %-24s %s\n' "relay" "$(systemctl is-active lanremote-relay)" +printf ' %-24s %s\n' "tunnel" "$(systemctl is-active lanremote-tunnel)" +printf ' %-24s %s\n' "tunnel-h2" "$(systemctl is-active lanremote-tunnel-h2)" +printf ' %-24s %s\n' "公网自测" "$(curl -s -o /dev/null -w '%{http_code}' --max-time 12 https://relay.xxmxxs.top/v1/health)" +printf ' %-24s %s\n' "后台抢救报告路由" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/rescue/none.txt)" +echo +echo "== 7. 演练:造一份假报告,验证后台能列出并下载 ==" +DEMO="$RELAY_DIR/rescue_reports/19700101-000000-演练.txt" +{ + echo "这是一份**演练用**的假报告,用来验证后台的「抢救报告」分区能列出、能下载。" + echo "看完可以删掉:rm $DEMO" + echo + echo "---- 一、结论速览 ----" + echo " 公网 https://relay.xxmxxs.top/v1/health -> 不通" + echo " 本机 http://127.0.0.1:8080/v1/health -> 200-正常" + echo + echo " 判断:**本机是好的,问题在隧道或 Cloudflare 那一侧**。" +} > "$DEMO" +echo " 造了:$DEMO" +echo " 后台页里有几份报告:$(ls "$RELAY_DIR/rescue_reports" 2>/dev/null | wc -l)" +echo " 拉一下后台页,看有没有「抢救报告」分区:" +curl -s http://127.0.0.1:8080/zhongzhuanzhan 2>/dev/null | grep -c '抢救报告' | sed 's/^/ 匹配到 /;s/$/ 次/' +echo +echo "完成。删掉演练报告:rm \"$DEMO\"" diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py index b85b526..e488fa9 100644 --- a/windows/lanremote/__init__.py +++ b/windows/lanremote/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- """局域网遥控 · Windows 被控端。""" -__version__ = "2.0.0" +__version__ = "2.1.0" APP_NAME = "ykxmz" diff --git a/windows/lanremote/actions.py b/windows/lanremote/actions.py index b27a81b..1a86cc9 100644 --- a/windows/lanremote/actions.py +++ b/windows/lanremote/actions.py @@ -1,1061 +1,1085 @@ -# -*- coding: utf-8 -*- -"""被控端功能实现:投图 / 放音 / 媒体控制 / 剪贴板 / 电源 / 自定义命令 / TTS / 通知。 - -所有会碰 UI 的操作都通过 ui_call 调度回 tkinter 主线程执行。 -""" -from __future__ import annotations - -import ctypes -import os -import subprocess -import sys -import threading -import time -from pathlib import Path - -from .protocol import MEDIA_ACTIONS, POWER_ACTIONS - -IS_WINDOWS = sys.platform.startswith("win") - -# ---- Win32 常量 ---- -KEYEVENTF_KEYUP = 0x0002 -VK_MEDIA = MEDIA_ACTIONS - - -def _human_size(num: float) -> str: - for unit in ("B", "KB", "MB", "GB"): - if abs(num) < 1024.0: - return f"{num:.1f}{unit}" - num /= 1024.0 - return f"{num:.1f}TB" - - -class ActionError(RuntimeError): - pass - - -def work_area() -> tuple: - """屏幕工作区 (left, top, width, height) —— 已经扣掉任务栏。 - - 直接读 SPI_GETWORKAREA,比自己猜任务栏多高靠谱。 - """ - try: - import ctypes.wintypes - rect = ctypes.wintypes.RECT() - ok = ctypes.windll.user32.SystemParametersInfoW( - 0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA - if ok and rect.right > rect.left and rect.bottom > rect.top: - return (rect.left, rect.top, - rect.right - rect.left, rect.bottom - rect.top) - except Exception: # noqa: BLE001 - pass - try: - import tkinter as tk - root = tk._default_root # noqa: SLF001 - if root is not None: - return (0, 0, root.winfo_screenwidth(), - root.winfo_screenheight()) - except Exception: # noqa: BLE001 - pass - return (0, 0, 1920, 1080) - - -def _outer_size(win) -> tuple: - """窗口的真实外框尺寸(含标题栏和边框)。 - - 客户区尺寸不等于窗口占的地方 —— 不按外框算,看起来就不居中。 - """ - try: - import ctypes - import ctypes.wintypes - hwnd = int(win.frame(), 0) # '0x00012345' - rect = ctypes.wintypes.RECT() - if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): - return (rect.right - rect.left, rect.bottom - rect.top, - rect.left, rect.top) - except Exception: # noqa: BLE001 - pass - return (win.winfo_width(), win.winfo_height(), 0, 0) - - -def _place_centered(win, client_w: int, client_h: int) -> None: - """在工作区正中间摆好这个窗口(按外框算)。""" - client_w = max(1, int(client_w)) - client_h = max(1, int(client_h)) - # 先放到 0,0,好量出真实外框 - win.geometry(f"{client_w}x{client_h}+0+0") - win.update_idletasks() - - outer_w, outer_h, outer_x, outer_y = _outer_size(win) - # 外框相对客户区多出来的部分(左边框 / 标题栏+上边框) - chrome_w = max(0, outer_w - client_w) - chrome_h = max(0, outer_h - client_h) - - area_x, area_y, area_w, area_h = work_area() - x = area_x + max(0, (area_w - outer_w) // 2) + chrome_w // 2 - y = area_y + max(0, (area_h - outer_h) // 2) + chrome_h - chrome_h // 2 - - win.geometry(f"{client_w}x{client_h}+{x}+{y}") - win.update_idletasks() - win.lift() - win.focus_force() - - -class Actions: - """把协议命令翻译成 Windows 上的实际操作。""" - - def __init__(self, ui, store): - self.ui = ui # 需要提供 .root / .log() / .notify() - self.store = store - self._audio_alias: str | None = None - self._audio_path: str = "" # 上一首的路径,循环播放要用 - self._loop: bool = False # 单曲循环开关 - self._loop_thread = None # 循环看门线程 - self._image_window = None - self._lock = threading.RLock() - # 剪贴板历史(最近 20 条) - self._clip_history: list[dict] = [] - # CPU 占用要两次采样才算得出来 - self._last_cpu_total = 0 - self._last_cpu_idle = 0 - # 电脑正在播的音频是否被暂停(MCI 不是系统媒体会话,得自己记) - self._audio_paused = False - self._audio_name = "" - - # ------------------------------------------------------------------ - # 投图:在电脑上全屏显示 - # ------------------------------------------------------------------ - - def show_image(self, path: str, title: str = "") -> str: - """显示图片。 - - title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。 - 方式由设置 image_display 决定: - window —— 屏幕正中间的普通 Windows 窗口(默认) - fullscreen —— 全屏 - custom —— 按 image_custom_width/height 指定大小 - 宽或高填 0 表示该方向自动。 - """ - p = Path(path) - if not p.is_file(): - raise ActionError(f"图片不存在: {path}") - - from PIL import Image, ImageTk - - try: - img = Image.open(p) - img.load() - except Exception as exc: - raise ActionError(f"无法读取图片: {exc}") from exc - - mode = str(self.store.get("image_display", "window") or "window") - if mode not in ("window", "fullscreen", "custom"): - mode = "window" - - screen_w, screen_h = self.ui.screen_size() - img = img.convert("RGB") - - if mode == "fullscreen": - target = (screen_w, screen_h) - elif mode == "custom": - cw = int(self.store.get("image_custom_width", 0) or 0) - ch = int(self.store.get("image_custom_height", 0) or 0) - if cw <= 0 and ch <= 0: - # 两个都没填就退回默认窗口大小 - cw, ch = int(screen_w * 0.78), int(screen_h * 0.78) - self.store.set("image_custom_width", cw) - self.store.set("image_custom_height", ch) - elif cw <= 0: - cw = screen_w - elif ch <= 0: - ch = screen_h - target = (max(80, cw), max(80, ch)) - else: - target = (int(screen_w * 0.78), int(screen_h * 0.78)) - - img.thumbnail(target, Image.LANCZOS) - fullscreen = (mode == "fullscreen") - - # 窗口标题:单次指定 > 设置里的模板 > 默认 - window_title = (title or "").strip() or str( - self.store.get("image_window_title") or "").strip() or "图片 - {name}" - try: - window_title = window_title.replace("{name}", p.name) - except Exception: - window_title = f"图片 - {p.name}" - backdrop = "#000000" if fullscreen else "#ffffff" - - def build(): - import tkinter as tk - if self._image_window is not None: - try: - self._image_window.destroy() - except Exception: - pass - - win = tk.Toplevel(self.ui.root) - win.title(window_title) - win.configure(bg=backdrop) - win.resizable(False, False) - - def close(_event=None): - with self._lock: - self._image_window = None - try: - win.destroy() - except Exception: - pass - - photo = ImageTk.PhotoImage(img) - label = tk.Label(win, image=photo, bg=backdrop, bd=0, - highlightthickness=0) - label.image = photo # 防止被 GC - label.pack(fill="both", expand=True) - - if fullscreen: - # 注意:Windows 上先 overrideredirect(True) 再设 -fullscreen 会抛 - # "override-redirect flag is set"。全屏本身就会去掉边框,不需要它。 - win.attributes("-fullscreen", True) - win.attributes("-topmost", True) - tk.Label(win, text="按 Esc 或点击关闭", bg=backdrop, fg="#888888", - font=("Microsoft YaHei UI", 10)).place( - relx=0.5, rely=0.97, anchor="center") - else: - foot = tk.Frame(win, bg=backdrop) - foot.pack(fill="x") - tk.Button(foot, text="关闭", command=close, - bg="#eaeef2", fg="#1f2328", relief="flat", - font=("Microsoft YaHei UI", 9), padx=14, pady=2, - cursor="hand2").pack(side="right", padx=10, pady=8) - # 摆到屏幕正中间 —— 普通窗口,有标题栏和关闭按钮 - # 必须先 update_idletasks,否则 winfo_reqwidth 还是 Tk - # 默认的 200x200,自定义尺寸就白填了。 - win.update_idletasks() - _place_centered(win, win.winfo_reqwidth(), - win.winfo_reqheight()) - - win.bind("", close) - win.bind("", close) - win.protocol("WM_DELETE_WINDOW", close) - self._image_window = win - - timeout = int(self.store.get("image_timeout", 0) or 0) - if timeout > 0: - win.after(timeout * 1000, close) - - self.ui.ui_call(build) - label_text = {"window": "屏幕中间", "fullscreen": "全屏", - "custom": f"{img.width}x{img.height}"}[mode] - return f"已显示 {p.name}({label_text})" - - def close_image(self) -> str: - def close(): - if self._image_window is not None: - try: - self._image_window.destroy() - except Exception: - pass - self._image_window = None - self.ui.ui_call(close) - return "已关闭显示窗口" - - # ------------------------------------------------------------------ - # 放音:MCI(系统自带,无需外部库) - # ------------------------------------------------------------------ - - def play_audio(self, path: str) -> str: - p = Path(path) - if not p.is_file(): - raise ActionError(f"音频不存在: {path}") - if not IS_WINDOWS: - raise ActionError("音频播放目前仅支持 Windows") - - with self._lock: - self._mci_close_locked() - alias = f"lanremote{int(time.time() * 1000) % 1000000}" - opened = self._mci(f'open "{p}" type mpegvideo alias {alias}') - if not opened: - opened = self._mci(f'open "{p}" alias {alias}') - if not opened: - raise ActionError(f"系统无法播放该音频格式: {p.suffix}") - self._audio_alias = alias - self._audio_paused = False - self._audio_name = p.name - self._audio_path = str(p) - self._mci(f"play {alias}") - return f"正在播放 {p.name}" - - def set_loop(self, on: bool) -> None: - """开关单曲循环。开了就起一个看门线程,放完自动重放。""" - self._loop = bool(on) - if on and self._loop_thread is None: - thread = threading.Thread(target=self._loop_watch, daemon=True, - name="audio-loop") - self._loop_thread = thread - thread.start() - - def _loop_watch(self) -> None: - while self._loop: - time.sleep(0.6) - if not self._loop: - break - with self._lock: - alias = self._audio_alias - path = self._audio_path - if not alias or not path: - continue - if self._mci_mode(alias) != "stopped": - continue - # 放完了,再放一遍 - try: - self.play_audio(path) - except Exception: # noqa: BLE001 - pass - self._loop_thread = None - - def stop_audio(self) -> str: - with self._lock: - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return "已停止播放" - - def _mci_mode(self, alias: str) -> str: - """问 MCI 现在是什么状态:playing / paused / stopped。""" - buffer = ctypes.create_unicode_buffer(64) - try: - ctypes.windll.winmm.mciSendStringW( - ctypes.c_wchar_p(f"status {alias} mode"), buffer, 64, None) - return (buffer.value or "").strip().lower() - except Exception: # noqa: BLE001 - return "" - - def audio_pause(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - raise ActionError("电脑当前没有播放手表推过去的音频") - if self._audio_paused: - return "已经暂停了" - if not self._mci(f"pause {alias}"): - raise ActionError("暂停失败") - self._audio_paused = True - return f"已暂停 {self._audio_name or '音频'}" - - def audio_resume(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - raise ActionError("电脑当前没有播放手表推过去的音频") - if not self._audio_paused: - return "正在播放中" - if not self._mci(f"resume {alias}"): - raise ActionError("继续播放失败") - self._audio_paused = False - return f"继续播放 {self._audio_name or '音频'}" - - def audio_status(self) -> str: - with self._lock: - alias = self._audio_alias - if not alias: - return "电脑当前没有播放音频" - mode = self._mci_mode(alias) - if mode == "playing": - return f"正在播放 {self._audio_name}" - if mode == "paused": - return f"已暂停 {self._audio_name}" - # MCI 自己放完了 - self._mci_close_locked() - self._audio_paused = False - name = self._audio_name - self._audio_name = "" - return f"{name} 已播放完毕" if name else "播放已结束" - - def _mci_close_locked(self) -> None: - if self._audio_alias: - self._mci(f"close {self._audio_alias}") - self._audio_alias = None - - @staticmethod - def _mci(command: str) -> bool: - try: - winmm = ctypes.windll.winmm - err = winmm.mciSendStringW(ctypes.c_wchar_p(command), None, 0, None) - return err == 0 - except Exception: - return False - - # ------------------------------------------------------------------ - # 媒体控制:模拟多媒体按键,作用于当前播放器 - # ------------------------------------------------------------------ - - def media_control(self, action: str) -> str: - if not IS_WINDOWS: - raise ActionError("媒体控制目前仅支持 Windows") - if action not in VK_MEDIA: - raise ActionError(f"不支持的媒体动作: {action}") - - # 电脑正在播手表推过去的音频时,直接控制它。 - # MCI 不是系统媒体会话,全局媒体键对它无效 —— - # 这就是"推了 mp3 过去却暂停不了"的原因。 - if action in ("playpause", "stop"): - handled = self._control_pushed_audio(action) - if handled: - return handled - - vk = VK_MEDIA.get(action) - try: - user32 = ctypes.windll.user32 - user32.keybd_event(vk, 0, 0, 0) - user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) - except Exception as exc: - raise ActionError(f"媒体按键注入失败: {exc}") from exc - return f"已执行 {action}" - - # ------------------------------------------------------------------ - # 剪贴板 - # ------------------------------------------------------------------ - - def clip_set(self, text: str) -> str: - def do(): - self.ui.root.clipboard_clear() - self.ui.root.clipboard_append(text) - self.ui.root.update_idletasks() - self.ui.ui_call(do) - self.store.set("last_clip", text) - self._remember_clip(text) - return f"剪贴板已更新({len(text)} 字符)" - - def clip_get(self) -> str: - result = {"text": ""} - - def do(): - try: - result["text"] = self.ui.root.clipboard_get() - except Exception: - result["text"] = "" - self.ui.ui_call(do) - return result["text"] - - # ------------------------------------------------------------------ - # 电源 - # ------------------------------------------------------------------ - - def power(self, action: str) -> str: - if action not in POWER_ACTIONS: - raise ActionError(f"不支持的电源动作: {action}") - if not self.store.get("allow_power", True): - raise ActionError("被控端已禁用电源操作") - if not IS_WINDOWS: - raise ActionError("电源控制目前仅支持 Windows") - - if action == "shutdown": - cmd = ["shutdown", "/s", "/t", "5"] - note = "将在 5 秒后关机" - elif action == "restart": - cmd = ["shutdown", "/r", "/t", "5"] - note = "将在 5 秒后重启" - elif action == "abort": - cmd = ["shutdown", "/a"] - note = "已取消关机" - elif action == "lock": - cmd = ["rundll32.exe", "user32.dll,LockWorkStation"] - note = "已锁屏" - else: # sleep - cmd = ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] - note = "已进入睡眠" - try: - subprocess.Popen( - cmd, shell=False, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - stdin=subprocess.DEVNULL, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: - raise ActionError(f"执行失败: {exc}") from exc - return note - - # ------------------------------------------------------------------ - # 自定义命令 - # ------------------------------------------------------------------ - - def run_command(self, command: str, timeout: int = 60) -> dict: - if not self.store.get("allow_run", True): - raise ActionError("被控端已禁用自定义命令") - if not command or not command.strip(): - raise ActionError("命令为空") - self.ui.log(f"[自定义命令] {command}") - try: - # 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936) - # 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。 - # 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。 - proc = subprocess.run( - command, shell=True, capture_output=True, text=True, - timeout=timeout, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except subprocess.TimeoutExpired: - raise ActionError(f"命令超时(>{timeout}s)") - except Exception as exc: - raise ActionError(f"命令执行失败: {exc}") from exc - return { - "code": proc.returncode, - "stdout": (proc.stdout or "")[-4000:], - "stderr": (proc.stderr or "")[-2000:], - } - - - def _control_pushed_audio(self, action: str) -> str | None: - """如果正在播推过去的音频,就控制它;否则返回 None 走系统媒体键。""" - with self._lock: - alias = self._audio_alias - if not alias: - return None - mode = self._mci_mode(alias) - if mode not in ("playing", "paused"): - # MCI 已经放完了 - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return None - if action == "stop": - self._mci_close_locked() - self._audio_paused = False - self._audio_name = "" - return "已停止播放" - # playpause - if mode == "paused" or self._audio_paused: - if self._mci(f"resume {alias}"): - self._audio_paused = False - return f"继续播放 {self._audio_name}" - return None - if self._mci(f"pause {alias}"): - self._audio_paused = True - return f"已暂停 {self._audio_name}" - return None - - # ------------------------------------------------------------------ - # 语音播报(系统自带 TTS) - # ------------------------------------------------------------------ - - def list_voices(self) -> list[str]: - """列出系统可用的 TTS 音色。""" - if not IS_WINDOWS: - return [] - script = ( - "Add-Type -AssemblyName System.Speech;" - "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" - ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" - ) - try: - proc = subprocess.run( - ["powershell", "-NoProfile", "-Command", script], - capture_output=True, timeout=20, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except Exception: - return [] - return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] - - def say(self, text: str, voice: str = "", rate: int = 0) -> str: - """语音播报。voice 为空则用系统默认(或设置里的默认音色)。""" - if not text.strip(): - raise ActionError("播报内容为空") - if not IS_WINDOWS: - raise ActionError("语音播报目前仅支持 Windows") - - chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() - try: - speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) - except (TypeError, ValueError): - speed = 0 - speed = max(-10, min(10, speed)) - - safe = text.replace("'", "''") - parts = [ - "Add-Type -AssemblyName System.Speech;", - "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", - ] - if chosen: - parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " - "catch { };") - if speed: - parts.append(f"$s.Rate = {speed};") - parts.append(f"$s.Speak('{safe}')") - script = "".join(parts) - - try: - subprocess.Popen( - ["powershell", "-NoProfile", "-WindowStyle", "Hidden", - "-Command", script], - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: - raise ActionError(f"TTS 启动失败: {exc}") from exc - return f"已播报({chosen or '默认音色'})" - - # ------------------------------------------------------------------ - # 播放本机音频文件(MP3 等) - # ------------------------------------------------------------------ - - AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma") - - def audio_roots(self) -> list[Path]: - """允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。""" - roots: list[Path] = [] - configured = str(self.store.get("mp3_dir") or "").strip() - if configured: - path = Path(configured) - if path.is_dir(): - roots.append(path) - home = Path.home() - for name in ("Music", "音乐", "Downloads", "下载"): - path = home / name - if path.is_dir(): - roots.append(path) - return roots - - def list_local_audio(self, limit: int = 300) -> list[dict]: - """列出本机可播放的音频文件。""" - items: list[dict] = [] - seen: set[str] = set() - for root in self.audio_roots(): - try: - paths = sorted(root.rglob("*")) - except OSError: - continue - for path in paths: - if len(items) >= limit: - return items - try: - if not path.is_file(): - continue - if path.suffix.lower() not in self.AUDIO_EXTS: - continue - key = str(path).lower() - if key in seen: - continue - seen.add(key) - items.append({ - "name": path.name, - "path": str(path), - "size": path.stat().st_size, - "dir": root.name, - }) - except OSError: - continue - items.sort(key=lambda it: it["name"].lower()) - return items - - def play_local(self, path: str) -> str: - """播放本机某个音频文件。只允许音乐目录内的文件。""" - target = Path(path) - if not target.is_file(): - raise ActionError(f"文件不存在: {path}") - if target.suffix.lower() not in self.AUDIO_EXTS: - raise ActionError(f"不支持的音频格式: {target.suffix}") - - allowed = False - for root in self.audio_roots(): - try: - target.resolve().relative_to(root.resolve()) - allowed = True - break - except (ValueError, OSError): - continue - if not allowed: - raise ActionError("只允许播放音乐目录里的文件") - - self.play_audio(str(target)) - return f"正在播放 {target.name}" - - # ------------------------------------------------------------------ - # 定时关机 / 关显示器 - # ------------------------------------------------------------------ - - def shutdown_timer(self, minutes: int = 0, cancel: bool = False) -> str: - """定时关机;cancel=True 时取消。minutes<=0 也会取消。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - if cancel or minutes <= 0: - proc = subprocess.run(["shutdown", "/a"], capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - text = (proc.stdout or proc.stderr or b"").decode("mbcs", errors="replace") - if proc.returncode != 0 and "1116" not in text and "没有" not in text: - return "当前没有待执行的关机计划" - return "已取消定时关机" - minutes = max(1, min(1440, int(minutes))) - subprocess.run(["shutdown", "/s", "/t", str(minutes * 60)], - capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - return f"将在 {minutes} 分钟后关机" - - def monitor_power(self, on: bool = False) -> str: - """关屏 / 唤醒屏幕。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - import ctypes - HWND_BROADCAST = 0xFFFF - WM_SYSCOMMAND = 0x0112 - SC_MONITORPOWER = 0xF170 - # 2 = 关闭, -1 = 打开 - ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_SYSCOMMAND, - SC_MONITORPOWER, -1 if on else 2) - return "显示器已打开" if on else "显示器已关闭" - - # ------------------------------------------------------------------ - # 打开网址 / 快捷命令 / 常用文件夹 - # ------------------------------------------------------------------ - - def open_url(self, url: str) -> str: - text = (url or "").strip() - if not text: - raise ActionError("网址为空") - if "://" not in text: - text = "https://" + text - if not text.lower().startswith(("http://", "https://")): - raise ActionError("只允许打开 http/https 网址") - try: - os.startfile(text) # noqa: S606 - except Exception as exc: # noqa: BLE001 - raise ActionError(f"打开失败: {exc}") from exc - return f"已在电脑打开 {text}" - - QUICK_APPS = { - "taskmgr": ("任务管理器", "taskmgr.exe"), - "explorer": ("资源管理器", "explorer.exe"), - "cmd": ("命令提示符", "cmd.exe"), - "calc": ("计算器", "calc.exe"), - "notepad": ("记事本", "notepad.exe"), - "control": ("控制面板", "control.exe"), - "mspaint": ("画图", "mspaint.exe"), - "devmgmt": ("设备管理器", "devmgmt.msc"), - } - - def quick_launch(self, key: str) -> str: - item = self.QUICK_APPS.get((key or "").strip().lower()) - if item is None: - raise ActionError(f"没有这个快捷程序: {key}") - label, command = item - try: - subprocess.Popen(command, shell=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) - except Exception as exc: # noqa: BLE001 - raise ActionError(f"启动失败: {exc}") from exc - return f"已打开{label}" - - def open_folder(self, key: str) -> str: - home = Path.home() - folders = { - "downloads": ("下载", home / "Downloads"), - "documents": ("文档", home / "Documents"), - "desktop": ("桌面", home / "Desktop"), - "pictures": ("图片", home / "Pictures"), - "music": ("音乐", home / "Music"), - "received": ("接收目录", self.store.received_dir()), - "app": ("程序目录", Path(__file__).resolve().parent.parent), - } - item = folders.get((key or "").strip().lower()) - if item is None: - raise ActionError(f"没有这个文件夹: {key}") - label, path = item - if not path.is_dir(): - raise ActionError(f"{label}目录不存在: {path}") - try: - os.startfile(str(path)) # noqa: S606 - except Exception as exc: # noqa: BLE001 - raise ActionError(f"打开失败: {exc}") from exc - return f"已打开{label}" - - # ------------------------------------------------------------------ - # 进程 - # ------------------------------------------------------------------ - - def list_processes(self, limit: int = 40) -> list[dict]: - if not IS_WINDOWS: - return [] - try: - proc = subprocess.run( - ["tasklist", "/FO", "CSV", "/NH"], capture_output=True, - timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - except Exception: # noqa: BLE001 - return [] - items: list[dict] = [] - for raw in (proc.stdout or "").splitlines(): - parts = [p.strip().strip('"') for p in raw.split('","')] - if len(parts) < 5: - continue - name = parts[0].strip('"') - pid = parts[1] - memory = parts[4].replace(",", "").replace(" K", "").strip() - try: - mem_kb = int(memory) - except ValueError: - mem_kb = 0 - if not pid.isdigit(): - continue - items.append({"name": name, "pid": int(pid), "mem_kb": mem_kb}) - items.sort(key=lambda it: it["mem_kb"], reverse=True) - return items[:limit] - - def kill_process(self, pid: int = 0, name: str = "") -> str: - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - args = ["taskkill", "/F"] - if pid: - args += ["/PID", str(int(pid))] - label = f"PID {pid}" - elif name: - safe = "".join(ch for ch in name if ch.isalnum() or ch in "._- ") - if not safe: - raise ActionError("进程名不合法") - args += ["/IM", safe] - label = safe - else: - raise ActionError("要指定 PID 或进程名") - proc = subprocess.run(args, capture_output=True, - creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), - encoding="mbcs", errors="replace") - if proc.returncode != 0: - raise ActionError(f"结束失败: {(proc.stdout or proc.stderr or '').strip()[:80]}") - return f"已结束 {label}" - - # ------------------------------------------------------------------ - # 状态:电量 / 磁盘 / 性能 - # ------------------------------------------------------------------ - - def battery(self) -> dict: - if not IS_WINDOWS: - return {"present": False} - import ctypes - - class PowerStatus(ctypes.Structure): - _fields_ = [("ACLineStatus", ctypes.c_byte), - ("BatteryFlag", ctypes.c_byte), - ("BatteryLifePercent", ctypes.c_byte), - ("SystemStatusFlag", ctypes.c_byte), - ("BatteryLifeTime", ctypes.c_ulong), - ("BatteryFullLifeTime", ctypes.c_ulong)] - - status = PowerStatus() - if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)): - return {"present": False} - if status.BatteryLifePercent == 255 or status.BatteryFlag == 128: - return {"present": False, "note": "台式机,没有电池"} - return { - "present": True, - "percent": int(status.BatteryLifePercent), - "charging": bool(status.ACLineStatus), - "seconds_left": int(status.BatteryLifeTime) - if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else 0, - } - - def disk(self) -> list[dict]: - if not IS_WINDOWS: - return [] - import ctypes - import string - - out: list[dict] = [] - bitmask = ctypes.windll.kernel32.GetLogicalDrives() - free = ctypes.c_ulonglong(0) - total = ctypes.c_ulonglong(0) - for i, letter in enumerate(string.ascii_uppercase): - if not (bitmask >> i) & 1: - continue - root = f"{letter}:\\" - try: - ok = ctypes.windll.kernel32.GetDiskFreeSpaceExW( - ctypes.c_wchar_p(root), None, ctypes.byref(total), - ctypes.byref(free)) - except Exception: # noqa: BLE001 - continue - if not ok or total.value == 0: - continue - out.append({ - "drive": f"{letter}:", - "total_gb": round(total.value / 1073741824, 1), - "free_gb": round(free.value / 1073741824, 1), - "used_percent": int((total.value - free.value) * 100 / total.value), - }) - return out - - def perf(self) -> dict: - if not IS_WINDOWS: - return {} - import ctypes - - class MemoryStatus(ctypes.Structure): - _fields_ = [("dwLength", ctypes.c_ulong), - ("dwMemoryLoad", ctypes.c_ulong), - ("ullTotalPhys", ctypes.c_ulonglong), - ("ullAvailPhys", ctypes.c_ulonglong), - ("ullTotalPageFile", ctypes.c_ulonglong), - ("ullAvailPageFile", ctypes.c_ulonglong), - ("ullTotalVirtual", ctypes.c_ulonglong), - ("ullAvailVirtual", ctypes.c_ulonglong), - ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] - - mem = MemoryStatus() - mem.dwLength = ctypes.sizeof(MemoryStatus) - ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem)) - - idle = ctypes.c_ulonglong() - kernel = ctypes.c_ulonglong() - user = ctypes.c_ulonglong() - ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), - ctypes.byref(kernel), - ctypes.byref(user)) - cpu = 0 - try: - total = kernel.value + user.value - if total and self._last_cpu_total: - delta_total = total - self._last_cpu_total - delta_idle = idle.value - self._last_cpu_idle - if delta_total > 0: - cpu = int(round((1 - delta_idle / delta_total) * 100)) - except Exception: # noqa: BLE001 - cpu = 0 - self._last_cpu_total = kernel.value + user.value - self._last_cpu_idle = idle.value - - import platform as _platform - return { - "cpu_percent": max(0, min(100, cpu)), - "mem_percent": int(mem.dwMemoryLoad), - "mem_used_gb": round((mem.ullTotalPhys - mem.ullAvailPhys) / 1073741824, 1), - "mem_total_gb": round(mem.ullTotalPhys / 1073741824, 1), - "uptime": self._uptime(), - "os": f"{_platform.system()} {_platform.release()}", - } - - def _uptime(self) -> str: - try: - import ctypes - # 不设 restype 的话 ctypes 默认按 32 位处理, - # 开机超过 24.8 天会被截断成负数,时长显示错乱 - ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong - ms = ctypes.windll.kernel32.GetTickCount64() - seconds = int(ms / 1000) - days, rem = divmod(seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes = rem // 60 - if days: - return f"{days} 天 {hours} 小时" - if hours: - return f"{hours} 小时 {minutes} 分" - return f"{minutes} 分钟" - except Exception: # noqa: BLE001 - return "-" - - # ------------------------------------------------------------------ - # 剪贴板历史 - # ------------------------------------------------------------------ - - def clip_history(self) -> list[dict]: - return list(self._clip_history) - - def _remember_clip(self, text: str) -> None: - text = (text or "").strip() - if not text: - return - with self._lock: - self._clip_history = [x for x in self._clip_history - if x.get("text") != text] - self._clip_history.insert(0, { - "text": text[:2000], - "at": time.strftime("%H:%M:%S"), - }) - del self._clip_history[20:] - - def say_clipboard(self) -> str: - # 之前读的是 store["last_clip"](那是手表推过来的内容), - # 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。 - # 应该读电脑本机的剪贴板。 - try: - text = str(self.clip_get() or "").strip() - except Exception: # noqa: BLE001 - text = "" - if not text: - text = str(self.store.get("last_clip") or "").strip() - if not text: - raise ActionError("电脑剪贴板是空的") - return self.say(text[:500] + ("…" if len(text) > 500 else "")) - - # ------------------------------------------------------------------ - # 壁纸 - # ------------------------------------------------------------------ - - def set_wallpaper(self, path: str = "") -> str: - """把某张图片设为桌面壁纸。默认用最近收到的那张图。""" - if not IS_WINDOWS: - raise ActionError("仅支持 Windows") - import ctypes - - target = Path(path) if path else None - if target is None or not target.is_file(): - received = self.store.received_dir() - images = sorted( - [p for p in received.iterdir() - if p.is_file() and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp")], - key=lambda p: p.stat().st_mtime, reverse=True) - if not images: - raise ActionError("接收目录里还没有图片") - target = images[0] - - SPI_SETDESKWALLPAPER = 0x0014 - SPIF_UPDATEINIFILE = 0x01 - SPIF_SENDCHANGE = 0x02 - ok = ctypes.windll.user32.SystemParametersInfoW( - SPI_SETDESKWALLPAPER, 0, ctypes.c_wchar_p(str(target)), - SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) - if not ok: - raise ActionError("设置壁纸失败") - return f"壁纸已设为 {target.name}" - - # ------------------------------------------------------------------ - # 通知(托盘气泡,可见) - # ------------------------------------------------------------------ - - def notify(self, title: str, text: str) -> str: - self.ui.notify(title or "局域网遥控", text or "") - return "已通知" - - # ------------------------------------------------------------------ - # 系统信息 - # ------------------------------------------------------------------ - - def info(self) -> dict: - import platform - screen_ok = False - screen_size = (0, 0) - try: - from .screen import ScreenCapturer - capturer = ScreenCapturer() - screen_ok = capturer.available() - if screen_ok: - screen_size = capturer.screen_size() - except Exception: - screen_ok = False - return { - "server_name": self.store.get("server_name", ""), - "os": f"{platform.system()} {platform.release()}", - "python": platform.python_version(), - "port": self.store.get("port", 0), - "allow_run": bool(self.store.get("allow_run", True)), - "allow_power": bool(self.store.get("allow_power", True)), - "allow_screen": bool(self.store.get("allow_screen", True)), - "screen_supported": screen_ok, - "screen_width": screen_size[0], - "screen_height": screen_size[1], - "received_dir": str(self.store.received_dir()), - } - - -def open_path(path: str) -> None: - """用系统默认程序打开文件/文件夹(仅本机 UI 按钮使用)。""" - if IS_WINDOWS: - os.startfile(path) # noqa: S606 - elif sys.platform == "darwin": - subprocess.Popen(["open", path]) - else: - subprocess.Popen(["xdg-open", path]) +# -*- coding: utf-8 -*- +"""被控端功能实现:投图 / 放音 / 媒体控制 / 剪贴板 / 电源 / 自定义命令 / TTS / 通知。 + +所有会碰 UI 的操作都通过 ui_call 调度回 tkinter 主线程执行。 +""" +from __future__ import annotations + +import ctypes +import os +import subprocess +import sys +import threading +import time +from pathlib import Path + +from .protocol import MEDIA_ACTIONS, POWER_ACTIONS + +IS_WINDOWS = sys.platform.startswith("win") + +# ---- Win32 常量 ---- +KEYEVENTF_KEYUP = 0x0002 +VK_MEDIA = MEDIA_ACTIONS + + +def _human_size(num: float) -> str: + for unit in ("B", "KB", "MB", "GB"): + if abs(num) < 1024.0: + return f"{num:.1f}{unit}" + num /= 1024.0 + return f"{num:.1f}TB" + + +class ActionError(RuntimeError): + pass + + +def work_area() -> tuple: + """屏幕工作区 (left, top, width, height) —— 已经扣掉任务栏。 + + 直接读 SPI_GETWORKAREA,比自己猜任务栏多高靠谱。 + """ + try: + import ctypes.wintypes + rect = ctypes.wintypes.RECT() + ok = ctypes.windll.user32.SystemParametersInfoW( + 0x0030, 0, ctypes.byref(rect), 0) # SPI_GETWORKAREA + if ok and rect.right > rect.left and rect.bottom > rect.top: + return (rect.left, rect.top, + rect.right - rect.left, rect.bottom - rect.top) + except Exception: # noqa: BLE001 + pass + try: + import tkinter as tk + root = tk._default_root # noqa: SLF001 + if root is not None: + return (0, 0, root.winfo_screenwidth(), + root.winfo_screenheight()) + except Exception: # noqa: BLE001 + pass + return (0, 0, 1920, 1080) + + +def _outer_size(win) -> tuple: + """窗口的真实外框尺寸(含标题栏和边框)。 + + 客户区尺寸不等于窗口占的地方 —— 不按外框算,看起来就不居中。 + """ + try: + import ctypes + import ctypes.wintypes + hwnd = int(win.frame(), 0) # '0x00012345' + rect = ctypes.wintypes.RECT() + if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): + return (rect.right - rect.left, rect.bottom - rect.top, + rect.left, rect.top) + except Exception: # noqa: BLE001 + pass + return (win.winfo_width(), win.winfo_height(), 0, 0) + + +def _place_centered(win, client_w: int, client_h: int) -> None: + """在工作区正中间摆好这个窗口(按外框算)。""" + client_w = max(1, int(client_w)) + client_h = max(1, int(client_h)) + # 先放到 0,0,好量出真实外框 + win.geometry(f"{client_w}x{client_h}+0+0") + win.update_idletasks() + + outer_w, outer_h, outer_x, outer_y = _outer_size(win) + # 外框相对客户区多出来的部分(左边框 / 标题栏+上边框) + chrome_w = max(0, outer_w - client_w) + chrome_h = max(0, outer_h - client_h) + + area_x, area_y, area_w, area_h = work_area() + x = area_x + max(0, (area_w - outer_w) // 2) + chrome_w // 2 + y = area_y + max(0, (area_h - outer_h) // 2) + chrome_h - chrome_h // 2 + + win.geometry(f"{client_w}x{client_h}+{x}+{y}") + win.update_idletasks() + win.lift() + win.focus_force() + + +class Actions: + """把协议命令翻译成 Windows 上的实际操作。""" + + def __init__(self, ui, store): + self.ui = ui # 需要提供 .root / .log() / .notify() + self.store = store + self._audio_alias: str | None = None + self._audio_path: str = "" # 上一首的路径,循环播放要用 + self._loop: bool = False # 单曲循环开关 + self._loop_thread = None # 循环看门线程 + self._image_window = None + self._lock = threading.RLock() + # 剪贴板历史(最近 20 条) + self._clip_history: list[dict] = [] + # CPU 占用要两次采样才算得出来 + self._last_cpu_total = 0 + self._last_cpu_idle = 0 + # 电脑正在播的音频是否被暂停(MCI 不是系统媒体会话,得自己记) + self._audio_paused = False + self._audio_name = "" + + # ------------------------------------------------------------------ + # 投图:在电脑上全屏显示 + # ------------------------------------------------------------------ + + def show_image(self, path: str, title: str = "") -> str: + """显示图片。 + + title 为窗口标题(可留空,用设置里的模板;模板支持 {name})。 + 方式由设置 image_display 决定: + window —— 屏幕正中间的普通 Windows 窗口(默认) + fullscreen —— 全屏 + custom —— 按 image_custom_width/height 指定大小 + 宽或高填 0 表示该方向自动。 + """ + p = Path(path) + if not p.is_file(): + raise ActionError(f"图片不存在: {path}") + + from PIL import Image, ImageTk + + try: + img = Image.open(p) + img.load() + except Exception as exc: + raise ActionError(f"无法读取图片: {exc}") from exc + + mode = str(self.store.get("image_display", "window") or "window") + if mode not in ("window", "fullscreen", "custom"): + mode = "window" + + screen_w, screen_h = self.ui.screen_size() + img = img.convert("RGB") + + if mode == "fullscreen": + target = (screen_w, screen_h) + elif mode == "custom": + cw = int(self.store.get("image_custom_width", 0) or 0) + ch = int(self.store.get("image_custom_height", 0) or 0) + if cw <= 0 and ch <= 0: + # 两个都没填就退回默认窗口大小 + cw, ch = int(screen_w * 0.78), int(screen_h * 0.78) + self.store.set("image_custom_width", cw) + self.store.set("image_custom_height", ch) + elif cw <= 0: + cw = screen_w + elif ch <= 0: + ch = screen_h + target = (max(80, cw), max(80, ch)) + else: + target = (int(screen_w * 0.78), int(screen_h * 0.78)) + + img.thumbnail(target, Image.LANCZOS) + fullscreen = (mode == "fullscreen") + + # 窗口标题:单次指定 > 设置里的模板 > 默认 + window_title = (title or "").strip() or str( + self.store.get("image_window_title") or "").strip() or "图片 - {name}" + try: + window_title = window_title.replace("{name}", p.name) + except Exception: + window_title = f"图片 - {p.name}" + backdrop = "#000000" if fullscreen else "#ffffff" + + def build(): + import tkinter as tk + if self._image_window is not None: + try: + self._image_window.destroy() + except Exception: + pass + + win = tk.Toplevel(self.ui.root) + win.title(window_title) + win.configure(bg=backdrop) + win.resizable(False, False) + + def close(_event=None): + with self._lock: + self._image_window = None + try: + win.destroy() + except Exception: + pass + + photo = ImageTk.PhotoImage(img) + label = tk.Label(win, image=photo, bg=backdrop, bd=0, + highlightthickness=0) + label.image = photo # 防止被 GC + label.pack(fill="both", expand=True) + + if fullscreen: + # 注意:Windows 上先 overrideredirect(True) 再设 -fullscreen 会抛 + # "override-redirect flag is set"。全屏本身就会去掉边框,不需要它。 + win.attributes("-fullscreen", True) + win.attributes("-topmost", True) + tk.Label(win, text="按 Esc 或点击关闭", bg=backdrop, fg="#888888", + font=("Microsoft YaHei UI", 10)).place( + relx=0.5, rely=0.97, anchor="center") + else: + foot = tk.Frame(win, bg=backdrop) + foot.pack(fill="x") + tk.Button(foot, text="关闭", command=close, + bg="#eaeef2", fg="#1f2328", relief="flat", + font=("Microsoft YaHei UI", 9), padx=14, pady=2, + cursor="hand2").pack(side="right", padx=10, pady=8) + # 摆到屏幕正中间 —— 普通窗口,有标题栏和关闭按钮 + # 必须先 update_idletasks,否则 winfo_reqwidth 还是 Tk + # 默认的 200x200,自定义尺寸就白填了。 + win.update_idletasks() + _place_centered(win, win.winfo_reqwidth(), + win.winfo_reqheight()) + + win.bind("", close) + win.bind("", close) + win.protocol("WM_DELETE_WINDOW", close) + self._image_window = win + + timeout = int(self.store.get("image_timeout", 0) or 0) + if timeout > 0: + win.after(timeout * 1000, close) + + self.ui.ui_call(build) + label_text = {"window": "屏幕中间", "fullscreen": "全屏", + "custom": f"{img.width}x{img.height}"}[mode] + return f"已显示 {p.name}({label_text})" + + def close_image(self) -> str: + def close(): + if self._image_window is not None: + try: + self._image_window.destroy() + except Exception: + pass + self._image_window = None + self.ui.ui_call(close) + return "已关闭显示窗口" + + # ------------------------------------------------------------------ + # 放音:MCI(系统自带,无需外部库) + # ------------------------------------------------------------------ + + def play_audio(self, path: str) -> str: + p = Path(path) + if not p.is_file(): + raise ActionError(f"音频不存在: {path}") + if not IS_WINDOWS: + raise ActionError("音频播放目前仅支持 Windows") + + with self._lock: + self._mci_close_locked() + alias = f"lanremote{int(time.time() * 1000) % 1000000}" + opened = self._mci(f'open "{p}" type mpegvideo alias {alias}') + if not opened: + opened = self._mci(f'open "{p}" alias {alias}') + if not opened: + raise ActionError(f"系统无法播放该音频格式: {p.suffix}") + self._audio_alias = alias + self._audio_paused = False + self._audio_name = p.name + self._audio_path = str(p) + self._mci(f"play {alias}") + return f"正在播放 {p.name}" + + def set_loop(self, on: bool) -> None: + """开关单曲循环。开了就起一个看门线程,放完自动重放。""" + self._loop = bool(on) + if on and self._loop_thread is None: + thread = threading.Thread(target=self._loop_watch, daemon=True, + name="audio-loop") + self._loop_thread = thread + thread.start() + + def _loop_watch(self) -> None: + while self._loop: + time.sleep(0.6) + if not self._loop: + break + with self._lock: + alias = self._audio_alias + path = self._audio_path + if not alias or not path: + continue + if self._mci_mode(alias) != "stopped": + continue + # 放完了,再放一遍 + try: + self.play_audio(path) + except Exception: # noqa: BLE001 + pass + self._loop_thread = None + + def stop_audio(self) -> str: + with self._lock: + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return "已停止播放" + + def _mci_mode(self, alias: str) -> str: + """问 MCI 现在是什么状态:playing / paused / stopped。""" + buffer = ctypes.create_unicode_buffer(64) + try: + ctypes.windll.winmm.mciSendStringW( + ctypes.c_wchar_p(f"status {alias} mode"), buffer, 64, None) + return (buffer.value or "").strip().lower() + except Exception: # noqa: BLE001 + return "" + + def audio_pause(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + raise ActionError("电脑当前没有播放手表推过去的音频") + if self._audio_paused: + return "已经暂停了" + if not self._mci(f"pause {alias}"): + raise ActionError("暂停失败") + self._audio_paused = True + return f"已暂停 {self._audio_name or '音频'}" + + def audio_resume(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + raise ActionError("电脑当前没有播放手表推过去的音频") + if not self._audio_paused: + return "正在播放中" + if not self._mci(f"resume {alias}"): + raise ActionError("继续播放失败") + self._audio_paused = False + return f"继续播放 {self._audio_name or '音频'}" + + def audio_status(self) -> str: + with self._lock: + alias = self._audio_alias + if not alias: + return "电脑当前没有播放音频" + mode = self._mci_mode(alias) + if mode == "playing": + return f"正在播放 {self._audio_name}" + if mode == "paused": + return f"已暂停 {self._audio_name}" + # MCI 自己放完了 + self._mci_close_locked() + self._audio_paused = False + name = self._audio_name + self._audio_name = "" + return f"{name} 已播放完毕" if name else "播放已结束" + + def _mci_close_locked(self) -> None: + if self._audio_alias: + self._mci(f"close {self._audio_alias}") + self._audio_alias = None + + @staticmethod + def _mci(command: str) -> bool: + try: + winmm = ctypes.windll.winmm + err = winmm.mciSendStringW(ctypes.c_wchar_p(command), None, 0, None) + return err == 0 + except Exception: + return False + + # ------------------------------------------------------------------ + # 媒体控制:模拟多媒体按键,作用于当前播放器 + # ------------------------------------------------------------------ + + def media_control(self, action: str) -> str: + if not IS_WINDOWS: + raise ActionError("媒体控制目前仅支持 Windows") + if action not in VK_MEDIA: + raise ActionError(f"不支持的媒体动作: {action}") + + # 电脑正在播手表推过去的音频时,直接控制它。 + # MCI 不是系统媒体会话,全局媒体键对它无效 —— + # 这就是"推了 mp3 过去却暂停不了"的原因。 + if action in ("playpause", "stop"): + handled = self._control_pushed_audio(action) + if handled: + return handled + + vk = VK_MEDIA.get(action) + try: + user32 = ctypes.windll.user32 + user32.keybd_event(vk, 0, 0, 0) + user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0) + except Exception as exc: + raise ActionError(f"媒体按键注入失败: {exc}") from exc + return f"已执行 {action}" + + # ------------------------------------------------------------------ + # 剪贴板 + # ------------------------------------------------------------------ + + def clip_set(self, text: str) -> str: + def do(): + self.ui.root.clipboard_clear() + self.ui.root.clipboard_append(text) + self.ui.root.update_idletasks() + self.ui.ui_call(do) + self.store.set("last_clip", text) + self._remember_clip(text) + return f"剪贴板已更新({len(text)} 字符)" + + def clip_get(self) -> str: + result = {"text": ""} + + def do(): + try: + result["text"] = self.ui.root.clipboard_get() + except Exception: + result["text"] = "" + self.ui.ui_call(do) + return result["text"] + + # ------------------------------------------------------------------ + # 电源 + # ------------------------------------------------------------------ + + def power(self, action: str) -> str: + if action not in POWER_ACTIONS: + raise ActionError(f"不支持的电源动作: {action}") + if not self.store.get("allow_power", True): + raise ActionError("被控端已禁用电源操作") + if not IS_WINDOWS: + raise ActionError("电源控制目前仅支持 Windows") + + if action == "shutdown": + cmd = ["shutdown", "/s", "/t", "5"] + note = "将在 5 秒后关机" + elif action == "restart": + cmd = ["shutdown", "/r", "/t", "5"] + note = "将在 5 秒后重启" + elif action == "abort": + cmd = ["shutdown", "/a"] + note = "已取消关机" + elif action == "lock": + cmd = ["rundll32.exe", "user32.dll,LockWorkStation"] + note = "已锁屏" + else: # sleep + cmd = ["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"] + note = "已进入睡眠" + try: + subprocess.Popen( + cmd, shell=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: + raise ActionError(f"执行失败: {exc}") from exc + return note + + # ------------------------------------------------------------------ + # 自定义命令 + # ------------------------------------------------------------------ + + def run_command(self, command: str, timeout: int = 60) -> dict: + if not self.store.get("allow_run", True): + raise ActionError("被控端已禁用自定义命令") + if not command or not command.strip(): + raise ActionError("命令为空") + self.ui.log(f"[自定义命令] {command}") + try: + # 打包成 --windowed 后没有控制台,cmd 的输出是 ANSI 代码页(本机 936) + # 而不是 UTF-8 —— 用 utf-8 解会全是乱码。和本文件其它地方统一用 mbcs。 + # 另外必须加 CREATE_NO_WINDOW,否则每跑一条远程命令都会闪一个黑窗。 + proc = subprocess.run( + command, shell=True, capture_output=True, text=True, + timeout=timeout, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except subprocess.TimeoutExpired: + raise ActionError(f"命令超时(>{timeout}s)") + except Exception as exc: + raise ActionError(f"命令执行失败: {exc}") from exc + return { + "code": proc.returncode, + "stdout": (proc.stdout or "")[-4000:], + "stderr": (proc.stderr or "")[-2000:], + } + + + def _control_pushed_audio(self, action: str) -> str | None: + """如果正在播推过去的音频,就控制它;否则返回 None 走系统媒体键。""" + with self._lock: + alias = self._audio_alias + if not alias: + return None + mode = self._mci_mode(alias) + if mode not in ("playing", "paused"): + # MCI 已经放完了 + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return None + if action == "stop": + self._mci_close_locked() + self._audio_paused = False + self._audio_name = "" + return "已停止播放" + # playpause + if mode == "paused" or self._audio_paused: + if self._mci(f"resume {alias}"): + self._audio_paused = False + return f"继续播放 {self._audio_name}" + return None + if self._mci(f"pause {alias}"): + self._audio_paused = True + return f"已暂停 {self._audio_name}" + return None + + # ------------------------------------------------------------------ + # 语音播报(系统自带 TTS) + # ------------------------------------------------------------------ + + def list_voices(self) -> list[str]: + """列出系统可用的 TTS 音色。""" + if not IS_WINDOWS: + return [] + script = ( + "Add-Type -AssemblyName System.Speech;" + "(New-Object System.Speech.Synthesis.SpeechSynthesizer)" + ".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }" + ) + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-Command", script], + capture_output=True, timeout=20, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except Exception: + return [] + return [line.strip() for line in (proc.stdout or "").splitlines() if line.strip()] + + def say(self, text: str, voice: str = "", rate: int = 0) -> str: + """语音播报。voice 为空则用系统默认(或设置里的默认音色)。""" + if not text.strip(): + raise ActionError("播报内容为空") + if not IS_WINDOWS: + raise ActionError("语音播报目前仅支持 Windows") + + chosen = (voice or "").strip() or str(self.store.get("tts_voice") or "").strip() + try: + speed = int(rate) if rate else int(self.store.get("tts_rate", 0) or 0) + except (TypeError, ValueError): + speed = 0 + speed = max(-10, min(10, speed)) + + safe = text.replace("'", "''") + parts = [ + "Add-Type -AssemblyName System.Speech;", + "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;", + ] + if chosen: + parts.append(f"try {{ $s.SelectVoice('{chosen.replace(chr(39), chr(39) * 2)}') }} " + "catch { };") + if speed: + parts.append(f"$s.Rate = {speed};") + parts.append(f"$s.Speak('{safe}')") + script = "".join(parts) + + try: + subprocess.Popen( + ["powershell", "-NoProfile", "-WindowStyle", "Hidden", + "-Command", script], + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: + raise ActionError(f"TTS 启动失败: {exc}") from exc + return f"已播报({chosen or '默认音色'})" + + # ------------------------------------------------------------------ + # 播放本机音频文件(MP3 等) + # ------------------------------------------------------------------ + + AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".flac", ".aac", ".ogg", ".wma") + + def audio_roots(self) -> list[Path]: + """允许播放的目录。只认音乐相关目录,避免被用来播任意系统文件。""" + roots: list[Path] = [] + configured = str(self.store.get("mp3_dir") or "").strip() + if configured: + path = Path(configured) + if path.is_dir(): + roots.append(path) + home = Path.home() + for name in ("Music", "音乐", "Downloads", "下载"): + path = home / name + if path.is_dir(): + roots.append(path) + return roots + + def list_local_audio(self, limit: int = 300) -> list[dict]: + """列出本机可播放的音频文件。""" + items: list[dict] = [] + seen: set[str] = set() + for root in self.audio_roots(): + try: + paths = sorted(root.rglob("*")) + except OSError: + continue + for path in paths: + if len(items) >= limit: + return items + try: + if not path.is_file(): + continue + if path.suffix.lower() not in self.AUDIO_EXTS: + continue + key = str(path).lower() + if key in seen: + continue + seen.add(key) + items.append({ + "name": path.name, + "path": str(path), + "size": path.stat().st_size, + "dir": root.name, + }) + except OSError: + continue + items.sort(key=lambda it: it["name"].lower()) + return items + + # ---- 音效(网络热门音效,手表上点一下电脑就响)---- + # + # 素材放哪:data/sfx/ + # 内置的几个是程序**合成**的(正弦/方波现场算出来), + # 所以零版权风险、也不占安装包体积。 + # 用户想用真正的一模一样的素材,把文件丢进 data/sfx/ 即可, + # 列表会自动带上(见 sfx.py 顶部的说明)。 + + def _sfx(self): + """懒加载音效库。第一次调用时才生成内置 WAV。""" + if getattr(self, "_sfx_obj", None) is None: + from .sfx import Sfx + self._sfx_obj = Sfx(self.store.dir, player=self.play_audio) + return self._sfx_obj + + def list_sfx(self) -> list: + return self._sfx().list_sfx() + + def sfx_dir_hint(self) -> str: + return self._sfx().path_hint() + + def play_sfx(self, name: str) -> str: + return self._sfx().play(name) + + def play_local(self, path: str) -> str: + """播放本机某个音频文件。只允许音乐目录内的文件。""" + target = Path(path) + if not target.is_file(): + raise ActionError(f"文件不存在: {path}") + if target.suffix.lower() not in self.AUDIO_EXTS: + raise ActionError(f"不支持的音频格式: {target.suffix}") + + allowed = False + for root in self.audio_roots(): + try: + target.resolve().relative_to(root.resolve()) + allowed = True + break + except (ValueError, OSError): + continue + if not allowed: + raise ActionError("只允许播放音乐目录里的文件") + + self.play_audio(str(target)) + return f"正在播放 {target.name}" + + # ------------------------------------------------------------------ + # 定时关机 / 关显示器 + # ------------------------------------------------------------------ + + def shutdown_timer(self, minutes: int = 0, cancel: bool = False) -> str: + """定时关机;cancel=True 时取消。minutes<=0 也会取消。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + if cancel or minutes <= 0: + proc = subprocess.run(["shutdown", "/a"], capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + text = (proc.stdout or proc.stderr or b"").decode("mbcs", errors="replace") + if proc.returncode != 0 and "1116" not in text and "没有" not in text: + return "当前没有待执行的关机计划" + return "已取消定时关机" + minutes = max(1, min(1440, int(minutes))) + subprocess.run(["shutdown", "/s", "/t", str(minutes * 60)], + capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + return f"将在 {minutes} 分钟后关机" + + def monitor_power(self, on: bool = False) -> str: + """关屏 / 唤醒屏幕。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + import ctypes + HWND_BROADCAST = 0xFFFF + WM_SYSCOMMAND = 0x0112 + SC_MONITORPOWER = 0xF170 + # 2 = 关闭, -1 = 打开 + ctypes.windll.user32.SendMessageW(HWND_BROADCAST, WM_SYSCOMMAND, + SC_MONITORPOWER, -1 if on else 2) + return "显示器已打开" if on else "显示器已关闭" + + # ------------------------------------------------------------------ + # 打开网址 / 快捷命令 / 常用文件夹 + # ------------------------------------------------------------------ + + def open_url(self, url: str) -> str: + text = (url or "").strip() + if not text: + raise ActionError("网址为空") + if "://" not in text: + text = "https://" + text + if not text.lower().startswith(("http://", "https://")): + raise ActionError("只允许打开 http/https 网址") + try: + os.startfile(text) # noqa: S606 + except Exception as exc: # noqa: BLE001 + raise ActionError(f"打开失败: {exc}") from exc + return f"已在电脑打开 {text}" + + QUICK_APPS = { + "taskmgr": ("任务管理器", "taskmgr.exe"), + "explorer": ("资源管理器", "explorer.exe"), + "cmd": ("命令提示符", "cmd.exe"), + "calc": ("计算器", "calc.exe"), + "notepad": ("记事本", "notepad.exe"), + "control": ("控制面板", "control.exe"), + "mspaint": ("画图", "mspaint.exe"), + "devmgmt": ("设备管理器", "devmgmt.msc"), + } + + def quick_launch(self, key: str) -> str: + item = self.QUICK_APPS.get((key or "").strip().lower()) + if item is None: + raise ActionError(f"没有这个快捷程序: {key}") + label, command = item + try: + subprocess.Popen(command, shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + except Exception as exc: # noqa: BLE001 + raise ActionError(f"启动失败: {exc}") from exc + return f"已打开{label}" + + def open_folder(self, key: str) -> str: + home = Path.home() + folders = { + "downloads": ("下载", home / "Downloads"), + "documents": ("文档", home / "Documents"), + "desktop": ("桌面", home / "Desktop"), + "pictures": ("图片", home / "Pictures"), + "music": ("音乐", home / "Music"), + "received": ("接收目录", self.store.received_dir()), + "app": ("程序目录", Path(__file__).resolve().parent.parent), + } + item = folders.get((key or "").strip().lower()) + if item is None: + raise ActionError(f"没有这个文件夹: {key}") + label, path = item + if not path.is_dir(): + raise ActionError(f"{label}目录不存在: {path}") + try: + os.startfile(str(path)) # noqa: S606 + except Exception as exc: # noqa: BLE001 + raise ActionError(f"打开失败: {exc}") from exc + return f"已打开{label}" + + # ------------------------------------------------------------------ + # 进程 + # ------------------------------------------------------------------ + + def list_processes(self, limit: int = 40) -> list[dict]: + if not IS_WINDOWS: + return [] + try: + proc = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], capture_output=True, + timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + except Exception: # noqa: BLE001 + return [] + items: list[dict] = [] + for raw in (proc.stdout or "").splitlines(): + parts = [p.strip().strip('"') for p in raw.split('","')] + if len(parts) < 5: + continue + name = parts[0].strip('"') + pid = parts[1] + memory = parts[4].replace(",", "").replace(" K", "").strip() + try: + mem_kb = int(memory) + except ValueError: + mem_kb = 0 + if not pid.isdigit(): + continue + items.append({"name": name, "pid": int(pid), "mem_kb": mem_kb}) + items.sort(key=lambda it: it["mem_kb"], reverse=True) + return items[:limit] + + def kill_process(self, pid: int = 0, name: str = "") -> str: + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + args = ["taskkill", "/F"] + if pid: + args += ["/PID", str(int(pid))] + label = f"PID {pid}" + elif name: + safe = "".join(ch for ch in name if ch.isalnum() or ch in "._- ") + if not safe: + raise ActionError("进程名不合法") + args += ["/IM", safe] + label = safe + else: + raise ActionError("要指定 PID 或进程名") + proc = subprocess.run(args, capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + encoding="mbcs", errors="replace") + if proc.returncode != 0: + raise ActionError(f"结束失败: {(proc.stdout or proc.stderr or '').strip()[:80]}") + return f"已结束 {label}" + + # ------------------------------------------------------------------ + # 状态:电量 / 磁盘 / 性能 + # ------------------------------------------------------------------ + + def battery(self) -> dict: + if not IS_WINDOWS: + return {"present": False} + import ctypes + + class PowerStatus(ctypes.Structure): + _fields_ = [("ACLineStatus", ctypes.c_byte), + ("BatteryFlag", ctypes.c_byte), + ("BatteryLifePercent", ctypes.c_byte), + ("SystemStatusFlag", ctypes.c_byte), + ("BatteryLifeTime", ctypes.c_ulong), + ("BatteryFullLifeTime", ctypes.c_ulong)] + + status = PowerStatus() + if not ctypes.windll.kernel32.GetSystemPowerStatus(ctypes.byref(status)): + return {"present": False} + if status.BatteryLifePercent == 255 or status.BatteryFlag == 128: + return {"present": False, "note": "台式机,没有电池"} + return { + "present": True, + "percent": int(status.BatteryLifePercent), + "charging": bool(status.ACLineStatus), + "seconds_left": int(status.BatteryLifeTime) + if status.BatteryLifeTime not in (0xFFFFFFFF, 0) else 0, + } + + def disk(self) -> list[dict]: + if not IS_WINDOWS: + return [] + import ctypes + import string + + out: list[dict] = [] + bitmask = ctypes.windll.kernel32.GetLogicalDrives() + free = ctypes.c_ulonglong(0) + total = ctypes.c_ulonglong(0) + for i, letter in enumerate(string.ascii_uppercase): + if not (bitmask >> i) & 1: + continue + root = f"{letter}:\\" + try: + ok = ctypes.windll.kernel32.GetDiskFreeSpaceExW( + ctypes.c_wchar_p(root), None, ctypes.byref(total), + ctypes.byref(free)) + except Exception: # noqa: BLE001 + continue + if not ok or total.value == 0: + continue + out.append({ + "drive": f"{letter}:", + "total_gb": round(total.value / 1073741824, 1), + "free_gb": round(free.value / 1073741824, 1), + "used_percent": int((total.value - free.value) * 100 / total.value), + }) + return out + + def perf(self) -> dict: + if not IS_WINDOWS: + return {} + import ctypes + + class MemoryStatus(ctypes.Structure): + _fields_ = [("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong)] + + mem = MemoryStatus() + mem.dwLength = ctypes.sizeof(MemoryStatus) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem)) + + idle = ctypes.c_ulonglong() + kernel = ctypes.c_ulonglong() + user = ctypes.c_ulonglong() + ctypes.windll.kernel32.GetSystemTimes(ctypes.byref(idle), + ctypes.byref(kernel), + ctypes.byref(user)) + cpu = 0 + try: + total = kernel.value + user.value + if total and self._last_cpu_total: + delta_total = total - self._last_cpu_total + delta_idle = idle.value - self._last_cpu_idle + if delta_total > 0: + cpu = int(round((1 - delta_idle / delta_total) * 100)) + except Exception: # noqa: BLE001 + cpu = 0 + self._last_cpu_total = kernel.value + user.value + self._last_cpu_idle = idle.value + + import platform as _platform + return { + "cpu_percent": max(0, min(100, cpu)), + "mem_percent": int(mem.dwMemoryLoad), + "mem_used_gb": round((mem.ullTotalPhys - mem.ullAvailPhys) / 1073741824, 1), + "mem_total_gb": round(mem.ullTotalPhys / 1073741824, 1), + "uptime": self._uptime(), + "os": f"{_platform.system()} {_platform.release()}", + } + + def _uptime(self) -> str: + try: + import ctypes + # 不设 restype 的话 ctypes 默认按 32 位处理, + # 开机超过 24.8 天会被截断成负数,时长显示错乱 + ctypes.windll.kernel32.GetTickCount64.restype = ctypes.c_ulonglong + ms = ctypes.windll.kernel32.GetTickCount64() + seconds = int(ms / 1000) + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + if days: + return f"{days} 天 {hours} 小时" + if hours: + return f"{hours} 小时 {minutes} 分" + return f"{minutes} 分钟" + except Exception: # noqa: BLE001 + return "-" + + # ------------------------------------------------------------------ + # 剪贴板历史 + # ------------------------------------------------------------------ + + def clip_history(self) -> list[dict]: + return list(self._clip_history) + + def _remember_clip(self, text: str) -> None: + text = (text or "").strip() + if not text: + return + with self._lock: + self._clip_history = [x for x in self._clip_history + if x.get("text") != text] + self._clip_history.insert(0, { + "text": text[:2000], + "at": time.strftime("%H:%M:%S"), + }) + del self._clip_history[20:] + + def say_clipboard(self) -> str: + # 之前读的是 store["last_clip"](那是手表推过来的内容), + # 手表没推过就永远报"剪贴板是空的",可电脑剪贴板明明有东西。 + # 应该读电脑本机的剪贴板。 + try: + text = str(self.clip_get() or "").strip() + except Exception: # noqa: BLE001 + text = "" + if not text: + text = str(self.store.get("last_clip") or "").strip() + if not text: + raise ActionError("电脑剪贴板是空的") + return self.say(text[:500] + ("…" if len(text) > 500 else "")) + + # ------------------------------------------------------------------ + # 壁纸 + # ------------------------------------------------------------------ + + def set_wallpaper(self, path: str = "") -> str: + """把某张图片设为桌面壁纸。默认用最近收到的那张图。""" + if not IS_WINDOWS: + raise ActionError("仅支持 Windows") + import ctypes + + target = Path(path) if path else None + if target is None or not target.is_file(): + received = self.store.received_dir() + images = sorted( + [p for p in received.iterdir() + if p.is_file() and p.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp")], + key=lambda p: p.stat().st_mtime, reverse=True) + if not images: + raise ActionError("接收目录里还没有图片") + target = images[0] + + SPI_SETDESKWALLPAPER = 0x0014 + SPIF_UPDATEINIFILE = 0x01 + SPIF_SENDCHANGE = 0x02 + ok = ctypes.windll.user32.SystemParametersInfoW( + SPI_SETDESKWALLPAPER, 0, ctypes.c_wchar_p(str(target)), + SPIF_UPDATEINIFILE | SPIF_SENDCHANGE) + if not ok: + raise ActionError("设置壁纸失败") + return f"壁纸已设为 {target.name}" + + # ------------------------------------------------------------------ + # 通知(托盘气泡,可见) + # ------------------------------------------------------------------ + + def notify(self, title: str, text: str) -> str: + self.ui.notify(title or "局域网遥控", text or "") + return "已通知" + + # ------------------------------------------------------------------ + # 系统信息 + # ------------------------------------------------------------------ + + def info(self) -> dict: + import platform + screen_ok = False + screen_size = (0, 0) + try: + from .screen import ScreenCapturer + capturer = ScreenCapturer() + screen_ok = capturer.available() + if screen_ok: + screen_size = capturer.screen_size() + except Exception: + screen_ok = False + return { + "server_name": self.store.get("server_name", ""), + "os": f"{platform.system()} {platform.release()}", + "python": platform.python_version(), + "port": self.store.get("port", 0), + "allow_run": bool(self.store.get("allow_run", True)), + "allow_power": bool(self.store.get("allow_power", True)), + "allow_screen": bool(self.store.get("allow_screen", True)), + "screen_supported": screen_ok, + "screen_width": screen_size[0], + "screen_height": screen_size[1], + "received_dir": str(self.store.received_dir()), + } + + +def open_path(path: str) -> None: + """用系统默认程序打开文件/文件夹(仅本机 UI 按钮使用)。""" + if IS_WINDOWS: + os.startfile(path) # noqa: S606 + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["xdg-open", path]) diff --git a/windows/lanremote/protocol.py b/windows/lanremote/protocol.py index 670bf57..e17098c 100644 --- a/windows/lanremote/protocol.py +++ b/windows/lanremote/protocol.py @@ -1,179 +1,181 @@ -# -*- coding: utf-8 -*- -"""通信协议常量与编解码。 - -设计目标:双端零外部依赖。 - * Windows 端:Python 标准库 socket + json - * 安卓端:java.net.Socket + 系统自带 org.json - -传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。 -二进制负载(图片/音频/文件)用 base64 分块传输。 -""" -from __future__ import annotations - -import base64 -import json - -DEFAULT_PORT = 8765 -PROTOCOL_VERSION = 1 -MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据) -CHUNK_SIZE = 256 * 1024 -# 单次传输(文件 / 图片 / 音频)的总体积上限 -MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB - -# ---- 客户端 -> 服务端 命令 ---- -CMD_PAIR = "pair" -CMD_AUTH = "auth" -CMD_PING = "ping" -CMD_IMAGE = "image" -CMD_AUDIO = "audio" -CMD_MEDIA = "media" -CMD_CLIP_SET = "clip_set" -CMD_CLIP_GET = "clip_get" -CMD_POWER = "power" -CMD_RUN = "run" -CMD_SAY = "say" -CMD_VOICE_LIST = "voice_list" -CMD_SET_VOICE = "set_voice" -CMD_AUDIO_LIST = "audio_list" -CMD_PLAY_LOCAL = "play_local" -CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消 -CMD_MONITOR = "monitor" # 关闭 / 打开显示器 -CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址 -CMD_QUICK = "quick" # 快捷启动程序 -CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹 -CMD_PROC_LIST = "proc_list" # 进程列表 -CMD_PROC_KILL = "proc_kill" # 结束进程 -CMD_BATTERY = "battery" # 电量 -CMD_DISK = "disk" # 磁盘占用 -CMD_PERF = "perf" # CPU / 内存占用 -CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史 -CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸 -CMD_SAY_CLIP = "say_clip" # 朗读剪贴板 -CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能 -CMD_FEATURE_RUN = "feature_run" # 执行某个功能 -CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频 -CMD_AUDIO_RESUME = "audio_resume" # 继续播放 -CMD_AUDIO_STATUS = "audio_status" # 当前播放状态 -CMD_NOTIFY = "notify" -CMD_FILE_PUSH = "file_push" -CMD_FILE_LIST = "file_list" -CMD_FILE_PULL = "file_pull" -CMD_INFO = "info" -CMD_SCREEN_START = "screen_start" -CMD_SCREEN_STOP = "screen_stop" -CMD_SCREENSHOT = "screenshot" - -# ---- 服务端 -> 客户端 事件 ---- -EVT_FILE = "file" -EVT_CLIP = "clip" -EVT_STATUS = "status" -EVT_LOG = "log" -EVT_FRAME = "frame" -EVT_SCREEN_STOPPED = "screen_stopped" - -MEDIA_ACTIONS = { - "playpause": 0xB3, - "next": 0xB0, - "prev": 0xB1, - "stop": 0xB2, - "volup": 0xAF, - "voldown": 0xAE, - "mute": 0xAD, -} - -POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"} - -# 手表端会用到的中文动作名(便于 UI 直接显示) -MEDIA_LABELS = { - "playpause": "播放/暂停", - "next": "下一曲", - "prev": "上一曲", - "stop": "停止", - "volup": "音量+", - "voldown": "音量-", - "mute": "静音", -} - -POWER_LABELS = { - "shutdown": "关机", - "restart": "重启", - "lock": "锁屏", - "sleep": "睡眠", - "abort": "取消关机", -} - - -def dumps(obj: dict) -> bytes: - """编码成一行 JSON(UTF-8 + 换行)。""" - return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") - - -def loads(line: bytes) -> dict: - """解析一行 JSON。""" - return json.loads(line.decode("utf-8", errors="replace").strip()) - - -def b64encode(data: bytes) -> str: - return base64.b64encode(data).decode("ascii") - - -def b64decode(text: str) -> bytes: - return base64.b64decode(text.encode("ascii")) - - -def chunk_bytes(data: bytes, size: int = CHUNK_SIZE): - """把字节流切成若干块,产出 (seq, is_last, chunk)。""" - if not data: - yield 0, True, b"" - return - total = (len(data) + size - 1) // size - for index in range(total): - piece = data[index * size:(index + 1) * size] - yield index, index == total - 1, piece - - -class ChunkTooLarge(Exception): - """分块累积超过上限,或顺序不对。""" - - -class ChunkAssembler: - """接收端:按 seq 收集分块,拼回完整数据。 - - MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 —— - 否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。 - """ - - def __init__(self, limit: int = MAX_TOTAL_BYTES): - self._buf = bytearray() - self._expected = 0 - self._limit = int(limit) - - def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None: - """返回完整数据(当收到最后一块时),否则 None。 - - 顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。 - """ - if seq == 0: - self._buf = bytearray() - self._expected = 0 - if seq != self._expected: - self._buf = bytearray() - self._expected = 0 - raise ChunkTooLarge( - f"分块顺序不对(收到 {seq},应为 {self._expected})") - if len(self._buf) + len(data) > self._limit: - self._buf = bytearray() - self._expected = 0 - raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)") - self._buf.extend(data) - self._expected = seq + 1 - if is_last: - result = bytes(self._buf) - self._buf = bytearray() - self._expected = 0 - return result - return None - - def reset(self) -> None: - self._buf = bytearray() - self._expected = 0 +# -*- coding: utf-8 -*- +"""通信协议常量与编解码。 + +设计目标:双端零外部依赖。 + * Windows 端:Python 标准库 socket + json + * 安卓端:java.net.Socket + 系统自带 org.json + +传输格式:TCP 长连接,每条消息一行 UTF-8 JSON,以 \\n 结尾。 +二进制负载(图片/音频/文件)用 base64 分块传输。 +""" +from __future__ import annotations + +import base64 +import json + +DEFAULT_PORT = 8765 +PROTOCOL_VERSION = 1 +MAX_LINE_BYTES = 8 * 1024 * 1024 # 单行上限 8MB(约 6MB 原始数据) +CHUNK_SIZE = 256 * 1024 +# 单次传输(文件 / 图片 / 音频)的总体积上限 +MAX_TOTAL_BYTES = 256 * 1024 * 1024 # 分块大小 256KB + +# ---- 客户端 -> 服务端 命令 ---- +CMD_PAIR = "pair" +CMD_AUTH = "auth" +CMD_PING = "ping" +CMD_IMAGE = "image" +CMD_AUDIO = "audio" +CMD_MEDIA = "media" +CMD_CLIP_SET = "clip_set" +CMD_CLIP_GET = "clip_get" +CMD_POWER = "power" +CMD_RUN = "run" +CMD_SAY = "say" +CMD_VOICE_LIST = "voice_list" +CMD_SET_VOICE = "set_voice" +CMD_AUDIO_LIST = "audio_list" +CMD_SFX_LIST = "sfx_list" # 列出可用音效(内置 + 用户自己放的) +CMD_SFX_PLAY = "sfx_play" # 播放某个音效 +CMD_PLAY_LOCAL = "play_local" +CMD_SHUTDOWN_TIMER = "shutdown_timer" # 定时关机 / 取消 +CMD_MONITOR = "monitor" # 关闭 / 打开显示器 +CMD_OPEN_URL = "open_url" # 在电脑浏览器打开网址 +CMD_QUICK = "quick" # 快捷启动程序 +CMD_OPEN_FOLDER = "open_folder" # 打开常用文件夹 +CMD_PROC_LIST = "proc_list" # 进程列表 +CMD_PROC_KILL = "proc_kill" # 结束进程 +CMD_BATTERY = "battery" # 电量 +CMD_DISK = "disk" # 磁盘占用 +CMD_PERF = "perf" # CPU / 内存占用 +CMD_CLIP_HISTORY = "clip_history" # 剪贴板历史 +CMD_SET_WALLPAPER = "set_wallpaper" # 把收到的图设为壁纸 +CMD_SAY_CLIP = "say_clip" # 朗读剪贴板 +CMD_FEATURE_LIST = "feature_list" # 电脑端能做哪些功能 +CMD_FEATURE_RUN = "feature_run" # 执行某个功能 +CMD_AUDIO_PAUSE = "audio_pause" # 暂停电脑正在播的音频 +CMD_AUDIO_RESUME = "audio_resume" # 继续播放 +CMD_AUDIO_STATUS = "audio_status" # 当前播放状态 +CMD_NOTIFY = "notify" +CMD_FILE_PUSH = "file_push" +CMD_FILE_LIST = "file_list" +CMD_FILE_PULL = "file_pull" +CMD_INFO = "info" +CMD_SCREEN_START = "screen_start" +CMD_SCREEN_STOP = "screen_stop" +CMD_SCREENSHOT = "screenshot" + +# ---- 服务端 -> 客户端 事件 ---- +EVT_FILE = "file" +EVT_CLIP = "clip" +EVT_STATUS = "status" +EVT_LOG = "log" +EVT_FRAME = "frame" +EVT_SCREEN_STOPPED = "screen_stopped" + +MEDIA_ACTIONS = { + "playpause": 0xB3, + "next": 0xB0, + "prev": 0xB1, + "stop": 0xB2, + "volup": 0xAF, + "voldown": 0xAE, + "mute": 0xAD, +} + +POWER_ACTIONS = {"shutdown", "restart", "lock", "sleep", "abort"} + +# 手表端会用到的中文动作名(便于 UI 直接显示) +MEDIA_LABELS = { + "playpause": "播放/暂停", + "next": "下一曲", + "prev": "上一曲", + "stop": "停止", + "volup": "音量+", + "voldown": "音量-", + "mute": "静音", +} + +POWER_LABELS = { + "shutdown": "关机", + "restart": "重启", + "lock": "锁屏", + "sleep": "睡眠", + "abort": "取消关机", +} + + +def dumps(obj: dict) -> bytes: + """编码成一行 JSON(UTF-8 + 换行)。""" + return (json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + + +def loads(line: bytes) -> dict: + """解析一行 JSON。""" + return json.loads(line.decode("utf-8", errors="replace").strip()) + + +def b64encode(data: bytes) -> str: + return base64.b64encode(data).decode("ascii") + + +def b64decode(text: str) -> bytes: + return base64.b64decode(text.encode("ascii")) + + +def chunk_bytes(data: bytes, size: int = CHUNK_SIZE): + """把字节流切成若干块,产出 (seq, is_last, chunk)。""" + if not data: + yield 0, True, b"" + return + total = (len(data) + size - 1) // size + for index in range(total): + piece = data[index * size:(index + 1) * size] + yield index, index == total - 1, piece + + +class ChunkTooLarge(Exception): + """分块累积超过上限,或顺序不对。""" + + +class ChunkAssembler: + """接收端:按 seq 收集分块,拼回完整数据。 + + MAX_LINE_BYTES 只管单行,累积体积必须另外设上限 —— + 否则已配对设备可以一直发 chunk 把内存吃干(最后 bytes() 还要再翻一倍)。 + """ + + def __init__(self, limit: int = MAX_TOTAL_BYTES): + self._buf = bytearray() + self._expected = 0 + self._limit = int(limit) + + def feed(self, seq: int, is_last: bool, data: bytes) -> bytes | None: + """返回完整数据(当收到最后一块时),否则 None。 + + 顺序不对或超限时抛 ChunkTooLarge,不静默拼出坏文件。 + """ + if seq == 0: + self._buf = bytearray() + self._expected = 0 + if seq != self._expected: + self._buf = bytearray() + self._expected = 0 + raise ChunkTooLarge( + f"分块顺序不对(收到 {seq},应为 {self._expected})") + if len(self._buf) + len(data) > self._limit: + self._buf = bytearray() + self._expected = 0 + raise ChunkTooLarge(f"文件超过上限({self._limit // 1048576} MB)") + self._buf.extend(data) + self._expected = seq + 1 + if is_last: + result = bytes(self._buf) + self._buf = bytearray() + self._expected = 0 + return result + return None + + def reset(self) -> None: + self._buf = bytearray() + self._expected = 0 diff --git a/windows/lanremote/server.py b/windows/lanremote/server.py index bb616c5..086bfc3 100644 --- a/windows/lanremote/server.py +++ b/windows/lanremote/server.py @@ -515,6 +515,24 @@ class RemoteServer: return {"ok": True, "voices": voices, "current": str(self.store.get("tts_voice") or "")} + if cmd == P.CMD_SFX_LIST: + items = self.actions.list_sfx() + # 同时给一个扁平的 names —— 手表端的 stringList(reply, key) + # 只认字符串数组,和 audio_list 的约定保持一致。 + return {"ok": True, "sfx": items, "count": len(items), + "names": [it.get("name", "") for it in items], + "dir": self.actions.sfx_dir_hint()} + + if cmd == P.CMD_SFX_PLAY: + name = str(msg.get("name") or "").strip() + if not name: + return {"ok": False, "error": "没说要放哪个音效"} + try: + msg = self.actions.play_sfx(name) + except Exception as exc: + return {"ok": False, "error": str(exc)} + return {"ok": True, "message": msg, "name": name} + if cmd == P.CMD_AUDIO_LIST: files = self.actions.list_local_audio() # 顺手给出扁平的 names/paths,手表端不用再解析嵌套结构 diff --git a/windows/lanremote/sfx.py b/windows/lanremote/sfx.py new file mode 100644 index 0000000..bb7b339 --- /dev/null +++ b/windows/lanremote/sfx.py @@ -0,0 +1,414 @@ +# -*- coding: utf-8 -*- +r'''网络热门音效 —— 手表上点一下,电脑就响。 + +============================================================================ + 先说清楚一件事(版权) +============================================================================ + +用户要的是「冰冰冰」「啊哈哈哈」「老牧师」这些**网上流行的真实录音**。 + +那些录音都是有版权的(有的是影视片段、有的是别人的作品), +**我不能把它们下载下来打包进安装包** —— 那既侵权,也会让这个项目 +在 Gitee / 应用商店那边直接踩线。 + +所以我做了两件事: + + 1. **内置几个程序合成的音效** —— 用正弦波/方波现场算出来的, + 完全原创、零版权风险。名字和"感觉"对着用户说的那几个来, + 但它们不是那些录音本身(也不冒充)。 + (合成的好处:不用带二进制素材,安装包不变大, + 而且每个音效就是几十行参数,想调随时能调。) + + 2. **做一个"自己放进来"的目录** —— + 用户把真正的素材(mp3/wav)丢进 data/sfx/ 就自动出现在列表里, + 手表上照样能点。这样他想要真的一模一样的音效, + 自己放进去就行,不用改代码。 + + 目录路径会在日志和设置页里写出来,省得他找不到。 + +============================================================================ + 为什么不用 numpy +============================================================================ + +安装包把 numpy 排除了(--exclude-module numpy,为了省 20MB)。 +所以波形全部用标准库的 math + struct 手算。16000Hz 单声道足够, +纯 Python 生成几秒的音频也就几十毫秒。 +''' +from __future__ import annotations + +import math +import struct +import wave +from pathlib import Path + +SAMPLE_RATE = 16000 + +# 内置音效的"配方"。name -> (文件名, 生成函数) +# 生成函数返回 [-1.0, 1.0] 的浮点采样列表。 +BUILTIN_SPECS: dict = {} + + +def _env(i: int, total: int, attack: float = 0.01, release: float = 0.25) -> float: + """简包络:起音/收尾各淡入淡出一段,避免爆音(咔哒声)。""" + a = max(1, int(total * attack)) + r = max(1, int(total * release)) + if i < a: + return i / a + if i > total - r: + return max(0.0, (total - i) / r) + return 1.0 + + +def _tone(freq: float, dur: float, vol: float = 0.6, + shape: str = "sine", duty: float = 0.5) -> list: + """一段单音。shape: sine / square / saw / tri""" + n = int(SAMPLE_RATE * dur) + out = [] + for i in range(n): + t = i / SAMPLE_RATE + ph = (freq * t) % 1.0 + if shape == "square": + v = 1.0 if ph < duty else -1.0 + elif shape == "saw": + v = 2.0 * ph - 1.0 + elif shape == "tri": + v = 4.0 * abs(ph - 0.5) - 1.0 + else: + v = math.sin(2 * math.pi * ph) + out.append(v * vol * _env(i, n, 0.02, 0.3)) + return out + + +def _silence(dur: float) -> list: + return [0.0] * int(SAMPLE_RATE * dur) + + +def _mix(*tracks) -> list: + """把几段混在一起(长度取最长的)。""" + longest = max(len(t) for t in tracks) + out = [0.0] * longest + for t in tracks: + for i, v in enumerate(t): + out[i] += v + # 防削顶 + peak = max((abs(v) for v in out), default=1.0) + if peak > 0.95: + out = [v * 0.95 / peak for v in out] + return out + + +def _seq(*tracks) -> list: + """把几段接起来。""" + out: list = [] + for t in tracks: + out.extend(t) + return out + + +def _sweep(f0: float, f1: float, dur: float, vol: float = 0.6) -> list: + """滑音(频率从 f0 滑到 f1)。""" + n = int(SAMPLE_RATE * dur) + out = [] + phase = 0.0 + for i in range(n): + freq = f0 + (f1 - f0) * (i / n) + phase += freq / SAMPLE_RATE + out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.01, 0.3)) + return out + + +def _vibrato(freq: float, dur: float, depth: float = 0.06, + rate: float = 6.0, vol: float = 0.6) -> list: + """带颤音的音,听感更"人声"一点。""" + n = int(SAMPLE_RATE * dur) + out = [] + phase = 0.0 + for i in range(n): + t = i / SAMPLE_RATE + f = freq * (1.0 + depth * math.sin(2 * math.pi * rate * t)) + phase += f / SAMPLE_RATE + out.append(math.sin(2 * math.pi * phase) * vol * _env(i, n, 0.03, 0.35)) + return out + + +# =========================================================================== +# 内置音效的配方 +# =========================================================================== +# +# ⚠ 这些是**程序合成**的原创音效,不是网上那些录音本身。 +# 名字对着用户提到的感觉来,方便他认,但不是冒充。 + +def _mk_bing() -> list: + """「冰冰冰」—— 三下清亮的高音,一下比一下亮。 + + 用正弦 + 一点三次谐波,听感接近"叮"那种金属味。 + """ + def one(freq: float, dur: float) -> list: + a = _tone(freq, dur, 0.5, "sine") + b = _tone(freq * 3, dur, 0.12, "sine") # 三次谐波 -> 金属感 + return _mix(a, b) + return _seq(one(1180, 0.13), _silence(0.05), + one(1320, 0.13), _silence(0.05), + one(1480, 0.22)) + + +def _mk_haha() -> list: + """「啊哈哈哈」—— 下滑的颤音,三声,越来越低。 + + 模拟笑声的节奏:起音高、往下滑、带颤。 + """ + def one(f0: float, f1: float, dur: float) -> list: + return _sweep(f0, f1, dur, 0.55) + return _seq( + one(420, 330, 0.16), _silence(0.03), + one(400, 300, 0.17), _silence(0.03), + one(380, 250, 0.30)) + + +def _mk_preacher() -> list: + """「老牧师」—— 低沉、缓慢、带回声的庄严感。 + + 低音方波(管风琴那种味道)+ 一段延迟很长的回声。 + """ + base = _tone(110, 0.5, 0.45, "square", 0.35) + base = _seq(base, _tone(146, 0.5, 0.45, "square", 0.35)) + base = _seq(base, _tone(110, 0.9, 0.45, "square", 0.35)) + # 回声:把原声衰减 0.35 倍、延迟 0.28 秒叠上去 + delay = int(SAMPLE_RATE * 0.28) + echo = [0.0] * delay + [v * 0.35 for v in base] + return _mix(base, echo) + + +def _mk_ding() -> list: + """「叮」—— 一声干净的提示音。""" + a = _tone(1760, 0.7, 0.45, "sine") + b = _tone(1760 * 2.76, 0.7, 0.10, "sine") + return _mix(a, b) + + +def _mk_dong() -> list: + """「当当当」—— 三下闷响。""" + return _seq(_tone(196, 0.18, 0.6, "tri"), _silence(0.08), + _tone(196, 0.18, 0.6, "tri"), _silence(0.08), + _tone(165, 0.4, 0.6, "tri")) + + +def _mk_alarm() -> list: + """「警报」—— 两高一低的急促音。""" + return _seq(_tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(660, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(880, 0.16, 0.55, "square", 0.5), _silence(0.04), + _tone(660, 0.30, 0.55, "square", 0.5)) + + +def _mk_drum() -> list: + """「鼓点」—— 低频下滑的"咚",四下。""" + def hit(dur: float = 0.22) -> list: + return _sweep(180, 55, dur, 0.75) + return _seq(hit(), _silence(0.06), hit(), _silence(0.06), + hit(), _silence(0.06), hit(0.35)) + + +def _mk_boing() -> list: + """「弹簧」—— 上下摆动的滑音。""" + up = _sweep(200, 900, 0.16, 0.55) + down = _sweep(900, 260, 0.22, 0.5) + return _seq(up, down) + + +BUILTIN_SPECS = { + # 中文名 : (文件名, 生成函数, 一句说明) + "冰冰冰": ("冰冰冰.wav", _mk_bing, "三下清亮的高音,越走越高"), + "啊哈哈哈": ("啊哈哈哈.wav", _mk_haha, "下滑的颤音,三声"), + "老牧师": ("老牧师.wav", _mk_preacher, "低沉庄严,带长回声"), + "叮": ("叮.wav", _mk_ding, "一声干净提示音"), + "当当当": ("当当当.wav", _mk_dong, "三下闷响"), + "警报": ("警报.wav", _mk_alarm, "两高一低的急促音"), + "鼓点": ("鼓点.wav", _mk_drum, "四下低频咚"), + "弹簧": ("弹簧.wav", _mk_boing, "上下摆动的滑音"), +} + +# 用户自己放进来的素材认这些格式 +USER_EXTS = (".wav", ".mp3", ".m4a", ".aac", ".ogg", ".wma", ".flac") + + +class SfxError(Exception): + pass + + +class Sfx: + """音效库。放哪、有什么、放哪个。""" + + def __init__(self, data_dir: Path, player=None): + # ⚠ data_dir 是**被控端自己的数据目录**(settings.json 边上), + # 不是仓库目录 —— 打包成 exe 之后仓库目录是只读的临时目录, + # 往那儿写文件重启就没了。 + self.dir = Path(data_dir) / "sfx" + self._player = player # 外面传进来的播放函数(复用 MCI) + self._built = False + + # ---------------------------------------------------------------- 内置 + def ensure_builtin(self) -> int: + """首次运行时把内置音效生成为 WAV。返回新建了几个。 + + ⚠ 用**清单文件**记住"哪些是生成过的",而不是简单地 + "文件不存在就生成"。 + + 为什么:用户删掉某个内置音效,多半是不想要它。 + 如果每次启动都按"不存在就补"的逻辑跑,他删一次我就补一次, + 等于删不掉 —— 实测自己写的测试就是这么判红的。 + + 但也不能"只要生成过一次就永远不补":万一第一次生成失败 + (磁盘满、权限问题),下次启动该重试。 + + 所以用清单:生成成功的才记进去。清单里有、文件没了 + = 用户删的,不再补;清单里没有 = 还没成功过,重试。 + """ + self.dir.mkdir(parents=True, exist_ok=True) + manifest_file = self.dir / ".builtin.txt" + done: set = set() + try: + if manifest_file.is_file(): + done = {ln.strip() for ln in + manifest_file.read_text(encoding="utf-8").splitlines() + if ln.strip()} + except OSError: + done = set() + + made = 0 + for name, (filename, fn, _desc) in BUILTIN_SPECS.items(): + if filename in done: + continue # 生成过 —— 文件没了就是用户删的 + target = self.dir / filename + if target.is_file(): + done.add(filename) # 早先版本生成的,补登记 + continue + try: + _write_wav(target, fn()) + done.add(filename) + made += 1 + except Exception: # noqa: BLE001 + # 某个音效生成失败不该让整个功能挂掉; + # 也不记进清单,下次启动会重试 + continue + try: + manifest_file.write_text("\n".join(sorted(done)) + "\n", + encoding="utf-8") + except OSError: + pass + + # 放一个说明文件,省得用户找不到往哪放自己的素材 + readme = self.dir / "放这里.txt" + if not readme.is_file(): + readme.write_text( + "把你自己的音效文件(mp3 / wav / m4a / ogg)直接放进这个目录,\n" + "手表那边刷新一下就能看到、能点了。\n" + "\n" + "文件名就是列表里显示的名字。比如把素材存成:\n" + " 冰冰冰.mp3\n" + " 啊哈哈哈.mp3\n" + " 老牧师.mp3\n" + "\n" + "目录里的 冰冰冰.wav / 啊哈哈哈.wav / 老牧师.wav 这几个\n" + "是程序**合成**的版本(不是网上那些录音)——\n" + "因为那些录音有版权,不能打包进安装包。\n" + "想要一模一样的,把真素材放进来覆盖同名文件即可。\n" + "\n" + "不想要的音效直接删掉即可,不会自己长回来。\n", + encoding="utf-8") + self._built = True + return made + + # ---------------------------------------------------------------- 列表 + def list_sfx(self) -> list: + """列出可用音效。内置的排前面,用户自己放的排后面。""" + if not self._built: + self.ensure_builtin() + items: list = [] + seen: set = set() + + def add(name: str, path: Path, builtin: bool) -> None: + key = name.lower() + if key in seen: + return + seen.add(key) + try: + size = path.stat().st_size + except OSError: + size = 0 + items.append({ + "name": name, + "file": path.name, + "size": size, + "builtin": builtin, + "ext": path.suffix.lower().lstrip("."), + }) + + # 内置的(按 BUILTIN_SPECS 的顺序,名字好听好记) + for name, (filename, _fn, _desc) in BUILTIN_SPECS.items(): + p = self.dir / filename + if p.is_file(): + add(name, p, True) + + # 用户自己放的 + try: + for p in sorted(self.dir.iterdir()): + if not p.is_file(): + continue + if p.suffix.lower() not in USER_EXTS: + continue + if p.name == "放这里.txt": + continue + add(p.stem, p, False) + except OSError: + pass + return items + + # ---------------------------------------------------------------- 播放 + def resolve(self, name: str) -> Path: + """把用户给的名字解析成一个文件路径。找不到就抛错。""" + wanted = (name or "").strip() + if not wanted: + raise SfxError("没说要放哪个音效") + # 先按"显示名"找(内置的) + for name2, (filename, _fn, _desc) in BUILTIN_SPECS.items(): + if name2 == wanted or filename == wanted: + p = self.dir / filename + if p.is_file(): + return p + # 再按文件名/stem 找(用户自己放的) + for p in self.dir.iterdir() if self.dir.is_dir() else []: + if not p.is_file(): + continue + if p.suffix.lower() not in USER_EXTS: + continue + if p.name == wanted or p.stem == wanted: + return p + raise SfxError(f"没有这个音效:{wanted}") + + def play(self, name: str) -> str: + """播放一个音效。""" + path = self.resolve(name) + if self._player is None: + raise SfxError("播放器没接上(内部错误)") + return self._player(str(path)) + + def path_hint(self) -> str: + """给用户看的一句"素材放哪"。""" + return str(self.dir) + + +def _write_wav(path: Path, samples: list) -> None: + """把 [-1,1] 的浮点采样写成 16 位单声道 WAV。""" + frames = bytearray() + for v in samples: + if v > 1.0: + v = 1.0 + elif v < -1.0: + v = -1.0 + frames += struct.pack("