diff --git a/README.md b/README.md index fbe23b7..b53fd26 100644 --- a/README.md +++ b/README.md @@ -1,655 +1,455 @@ -# 局域网遥控 · LAN Remote - -用手表(安卓)遥控自己的电脑。同一个 WiFi 下即可,不经过任何服务器。 - -``` -┌─────────────────┐ 局域网 TCP ┌──────────────────┐ -│ 手表控制端 APK │ ─────────────────────────► │ Windows 被控端 │ -│ 490×602 大按钮 │ ◄───────────────────────── │ 可见托盘 + 配对码 │ -└─────────────────┘ └──────────────────┘ -``` - ---- - -## 一、先说清楚这个工具「不是什么」 - -你最初的描述里,有几条我**明确没有做**,因为它们属于恶意软件特征: - -| 你提的要求 | 为什么不做 | -|---|---| -| 注册成系统更新组件、**杀毒软件杀不掉** | 这是免杀对抗。正常远控工具(TeamViewer、向日葵、AnyDesk)从不做这个。 | -| 托盘不显示、**任务管理器里看不到** | 这是对电脑使用者隐藏自身。 | -| 伪装成普通系统文件常驻 | 持久化 + 伪装。 | - -**原因很简单**:如果被控的是你自己的电脑,你不需要隐藏——你会希望它可见、能管理、能一键断开。而"杀软杀不掉、任务管理器看不到"只在一种场景下有意义:**用电脑的人不知道你在控制它**。 - -所以这一版是:**被控端全程可见、可随时断开、权限可随时关闭**。它是个正经工具,不是木马。 - ---- - -## 二、快速开始(3 步) - -### 第 1 步:电脑端 - -把 `LanRemote-Agent-Windows.zip` 解压到任意目录,双击 `LanRemote-Agent.exe`。 - -程序会弹出窗口,**窗口中间有一组 6 位配对码**,例如: - -``` - 在手表上输入这组配对码 - 4 5 4 5 3 4 -``` - -窗口里同时能看到: -- 当前连接状态 -- 防火墙是否放行(没放行会显示橙色提示 + 「一键修复」按钮) -- 「打开接收目录」「退出」 - -**关掉窗口 = 程序继续在后台跑**(没有托盘图标)。 -想再打开窗口,**再双击一次 EXE** 就行,不会重复起进程。 -想彻底退出,在窗口上点「退出」。 - -### 第 2 步:手表端 - -安装 `LanRemote-Watch-1.3.0.apk`,打开后点那个大按钮 **「自动搜索电脑」**。 - -App 会往局域网广播一个探测包,被控端收到后回自己的名字和端口, -**所以手表端不用输 IP、不用管网段、也不用管电脑上装了几个虚拟网卡**。 - -搜到之后点一下那台电脑,输入被控端窗口上的 6 位数字,就连上了。 - -### 第 3 步:之后就不用再输了 - -配对成功后令牌保存在手表上,**下次打开 App 会自动搜到并直接连**, -连数字都不用再输。 - -多台电脑就配对多次,列表里点哪个连哪个。长按可以删除配对。 - -> 如果搜不到,展开「手动输入地址」可以自己填 IP 和端口 —— 但绝大多数情况 -> 下搜不到都不是 IP 的问题,而是**防火墙没放行**(见下面第五节)。 - ---- - -### 工具箱(唯一入口) - -被控端目录里有个 **`工具箱.bat`**,双击它就能看到所有功能: - -``` -============================================================ - 局域网遥控 . 工具箱 版本 1.3.0 -============================================================ - - 1. 启动被控端 日常使用就点这个 - 2. 一键修复防火墙 连不上时先试这个(需要管理员) - 3. 网络自检 还连不上就用它排查 - 4. 查看使用说明 - 5. 查看运行日志 - 6. 打开接收文件夹 - 7. 查看当前防火墙状态 - - 0. 退出 -``` - -排查连接问题的推荐顺序:**1 → 2 → 3**。 - -| 选项 | 作用 | -|---|---| -| **1 启动被控端** | 确认程序在跑(任务管理器里能看到 `LanRemote-Agent.exe`) | -| **2 一键修复防火墙** | 弹一次 UAC 点「是」。删掉「阻止」规则 + 补一条**程序级**放行规则。后者才是治本 | -| **3 网络自检** | 跑起来后到手表上点「测一下」,看电脑窗口有没有 `>>> 收到 TCP 连接 <<<` | -| **7 查看防火墙状态** | 随时确认四条规则在不在、有没有阻止规则 | - -网络自检的两种结果: - -- **收到连接** → 包能到电脑,问题在本机(程序没跑 / 端口被占) -- **一个包都没收到** → 包没到,多半是路由器开了「AP 隔离」 - ---- - -## 三、功能 - -手表上全部是 58~64dp 的大按钮,两列排布,整页可滚动。 - -| 分类 | 功能 | -|---|---| -| **媒体** | 播放/暂停、停止、上一曲、下一曲、音量±、静音 | -| **显示** | 投图到电脑(全屏显示,按 Esc 或点一下关闭) | -| **屏幕** | **实时屏幕镜像**(默认 4 fps,可调帧率/画质/分辨率)、**一键截屏**并保存到手表 | -| **音频** | 放音到电脑(用系统 MCI 播放,不弹播放器窗口) | -| **文件** | 发送文件到电脑 / 接收电脑上的文件 | -| **剪贴板** | 手表文字 → 电脑剪贴板;电脑剪贴板 → 手表(自动复制) | -| **语音** | 让电脑语音播报指定文字(系统自带 TTS) | -| **通知** | 在电脑托盘弹通知 | -| **电源** | 关机、重启、锁屏、睡眠、取消关机(二次确认) | -| **其他** | 自定义命令(回显输出和退出码)、查看电脑系统信息 | -| **被动** | 电脑上复制东西时,手表会收到推送提示 | - ---- - -## 四、安全设计 - -这是重点,请看完。 - -1. **首次连接必须配对**:电脑端生成 6 位随机码,只在窗口上显示,手表输入正确才能连。 -2. **令牌制**:配对成功后发一个 32 位随机令牌,之后靠令牌免配对码登录。 -3. **未鉴权拒绝一切命令**:没通过握手就发命令,服务端直接拒绝。 -4. **权限开关实时生效**:窗口上可以随时关掉"自定义命令"、"电源操作"、"屏幕镜像",关掉后远程立刻执行不了。 -5. **无托盘但仍有可见性**:按需求去掉了托盘图标;只要有设备连着, - 屏幕右上角就会出现一条「已连接」提示条,可一键断开。 -6. **屏幕共享有强制提示**(见下)。 -7. **不写注册表**:所有数据放在程序目录下的 `data/`,绿色便携。 -8. **只在局域网**:不联外网、不上传任何数据。想更安全就在防火墙里只放行内网段。 - -### 屏幕镜像的强制可见提示 - -只要有人在看你的屏幕,被控端**屏幕右上角就会出现一条红色提示条**: - -``` -┌──────────────────────────────────────────────────────────┐ -│ ● 屏幕正在被共享 1 台设备正在观看 · 4.0 fps [停止共享] │ -└──────────────────────────────────────────────────────────┘ -``` - -这条提示的规则: - -- **一定有**:开始推流就出现,没有开关能关掉它。界面里也没有"隐藏提示"这类选项。 -- **本机可一键掐断**:点提示条上的「停止共享」,立刻断开所有观看,并通知手表。 -- **截屏也会闪一下**:单次截屏虽然短暂,同样会闪 2.5 秒提示。 -- **无人观看自动停**:所有手表都断开后,抓屏线程立即结束,提示条消失,不留后台耗电。 -- **关掉权限立刻断**:把窗口上的「允许屏幕镜像 / 截屏」取消勾选,正在进行的共享立刻被掐断。 - -这是本工具和"屏幕监视软件"的分界线:**被控端永远知道、永远能停。** - -> ⚠️ 自定义命令 = 远程执行任意命令。这是你自己电脑上你自己开的功能, -> 但请务必:**只在你信任的局域网里用**,用完可以在窗口里关掉它。 - -### 安卓端权限 - -手表 App **在第一次启动时一次性把需要的权限全要了**,之后选图片、选音频、发文件都不再弹窗: - -| 权限 | 用途 | -|---|---| -| `INTERNET` / `ACCESS_NETWORK_STATE` / `ACCESS_WIFI_STATE` | 连局域网 | -| `READ_MEDIA_IMAGES` / `READ_MEDIA_AUDIO` / `READ_MEDIA_VIDEO`(Android 13+) | 列出手表上的媒体文件 | -| `READ_EXTERNAL_STORAGE`(Android 12 及以下,`maxSdkVersion=32`) | 同上 | - -有权限时用**内置选择器**(带缩略图,直接在 App 里列文件);万一权限被拒,会自动回退到系统选择器, -所以功能不会因为拒权限而不可用。 - ---- - -## 五、连不上?先看这里 - -**实测中「连不上」最常见的原因只有一个:Windows 防火墙没放行。** - -家用 WiFi 一般会被 Windows 归类成「公用网络(Public)」, -而公用网络的默认入站动作是**阻止**。如果没有显式放行规则, -手表的连接会被**静默丢包** —— 表现就是「配对码明明对,但一直转圈连不上」。 - -真机上验证过: - -``` -Get-NetFirewallRule -Direction Inbound -Action Allow -> 0 条规则 -Get-NetConnectionProfile -InterfaceAlias WLAN -> Public -``` - -**解决办法**:被控端窗口会自动检测并显示一条橙色提示 - -``` -防火墙未放行,手表连不上。 [一键修复] -``` - -点「一键修复」,同意一次管理员请求即可(放行需要管理员权限,这一步躲不掉)。 -程序也会在目录下生成 `放行防火墙.bat`,可以右键「以管理员身份运行」。 - -手动执行等价于: - -```powershell -netsh advfirewall firewall add rule name="LAN Remote TCP" dir=in action=allow protocol=TCP localport=8765 profile=any -netsh advfirewall firewall add rule name="LAN Remote UDP" dir=in action=allow protocol=UDP localport=8766 profile=any -``` - -**如果防火墙已放行还是连不上**,依次检查: - -1. 手表和电脑是不是**同一个 WiFi**(不是手机热点、不是客人网络) -2. 路由器有没有开 **「AP 隔离」**(开了的话同一 WiFi 的设备也互相不通) -3. 电脑上是不是有 VPN / 加速器 / 虚拟网卡在抢占路由 - (程序会优先选真实网卡地址;本机装 Tailscale 时 `hostname` 会解析到 - 169.254 链路地址,程序已按网段优先级排除了这种情况) -4. 端口被别的程序占用(程序会提示「端口被占用」) - ---- - -## 六、连不上时:用「设备诊断」 - -**诊断已经合并进手表主程序了**,不需要再装单独的 App。 - -手表上:**主界面 ->「设备诊断」** -> 自动开始检测。 - -它会自动: - -1. **采集环境信息** —— 厂商/型号/安卓版本/API、屏幕像素与 density、 - 屏幕 dp 尺寸、字体缩放、是否被识别为手表、各权限的授予状态 -2. **采集网络参数** —— 连接类型、WiFi 名称/信号、本机所有 IPv4 地址与 - 前缀长度、DHCP 网关/掩码/DNS -3. **真的跑一次局域网自动发现**(UDP 广播 4 秒),列出找到的电脑 -4. **对找到的每台电脑做真实 TCP 连接测试**,报告成功/失败与耗时 -5. 测完**自动复制到剪贴板**,直接到聊天窗口粘贴发给开发者即可 - -如果一台都没搜到,可以在诊断界面手动填电脑 IP 点「测一下」—— -它会分别做 **UDP 单播探测** 和 **TCP 连接**,能直接区分问题在哪: - -| 结果 | 说明 | -|---|---| -| 单播有应答 + TCP 成功 | 网络是通的,问题在广播(路由器多播过滤 / AP 隔离) | -| 单播有应答 + TCP 失败 | 广播被拦,且**电脑防火墙拦了 TCP 8765** → 点被控端的「一键修复」 | -| 单播无应答 + TCP 失败 | 根本没通(不同网段 / 电脑没运行 / 防火墙全拦) | - -**它刻意不收集什么:** - -- IMEI、序列号、手机号、运营商 -- 联系人、短信、通话记录 -- 相册、文件列表、剪贴板内容 -- 位置坐标(定位权限只用于读取 WiFi 名称,不读经纬度) -- 账号、已安装应用列表 - -报告内容全部显示在屏幕上,复制由使用者主动触发。 - -诊断与主程序在**同一个 APK** 里,共用同一份自动发现实现 -(`DiscoveryCore`),所以不会出现「诊断说能通、主程序连不上」的假象。 - -底部还有一行「手动测电脑 IP」+「测一下」按钮——广播被拦时, -它会分别做 UDP 单播和 TCP 连接,直接告诉你问题在哪一段。 - ---- - -## 六点五、电脑端全部图形化(不依赖任何 .bat) - -被控端窗口底部一行短按钮: - -``` -[ 接收 ] [ 自检 ] [ 说明 ] [ 设置 ] [ 退出 ] -``` - -| 按钮 | 作用 | -|---|---| -| **接收** | 打开收到的文件目录 | -| **自检** | 图形化网络自检(见下) | -| **说明** | 窗口里直接看使用说明 | -| **设置** | 投图标题 / 播报音色 / 语速 / 音乐目录 | -| **退出** | 真正结束进程并释放端口 | - -### 图形化自检 - -**不再需要点任何 .bat。** 自检直接读被控端自己的计数器, -所以被控端在跑的时候也能诊断(旧的控制台版本要抢端口,跑不了): - -``` -● 正常 一切正常,手表应该能连上。 - ---- 本机网络 --- - 192.168.2.64 [默认路由] - >>> 手表上填这个 IP:192.168.2.64 - WiFi: 1602 - ---- 监听 --- - 控制端口 TCP 8765: 监听中 - 发现端口 UDP 8766: 监听中 - ---- 收到过多少(关键)--- - 自动发现请求: 12 个 - 自检请求: 1 个 - TCP 连接: 3 次 - 有数字说明手表的包能到达这台电脑 - ---- 防火墙 --- - 端口放行 TCP: 是 - 程序级放行: 是 (LanRemote-Agent.exe) - 阻止规则: 0 条(针对本程序) - ---- 结论 --- - [OK] 一切正常,手表应该能连上。 -``` - -窗口里还有「复制」和「一键修复防火墙」按钮。 - -**手表上点「连不上?」**,这台电脑会自动把自检窗口弹出来 -(走 UDP,所以 TCP 被防火墙拦掉时也能到达)。 - ---- - -## 六点六、手表端:分页 + 37 个功能 - -**连接后**的界面是 6 个分页标签,每页最多 7 个按钮 —— 一屏放得下,**完全不用滚动**。 - -| 分页 | 功能 | -|---|---| -| **媒体** | 播放/暂停、停止、上一曲、下一曲、音量 +、音量 −、静音 | -| **屏幕** | 屏幕镜像、截屏、投图到电脑、放音到电脑、关显示器、设为壁纸 | -| **文件** | 发送文件、接收文件、剪贴板→电脑、取电脑剪贴板、剪贴板历史、打开文件夹 | -| **语音** | 语音播报、音色、朗读剪贴板、电脑音乐、弹通知 | -| **电脑** | 快捷命令、打开网址、进程管理、性能、磁盘、电量 | -| **系统** | 锁屏、睡眠、关机、重启、取消定时关机、定时关机、设备诊断 | - -几个值得说的: - -- **电脑音乐** —— 列出电脑音乐目录里的音频,选一个直接在电脑上播 -- **音色** —— 列出电脑上所有 TTS 音色(中文 HuiHui / 英文 Zira 等),选一个 -- **进程管理** —— 按内存排序列出电脑进程,可以结束 -- **剪贴板历史** —— 电脑端保留最近 20 条剪贴板,手表上点一条就取回来 -- **设为壁纸** —— 把刚投过去的那张图设成电脑桌面壁纸 -- **快捷命令** —— 任务管理器 / 资源管理器 / CMD / 计算器 / 记事本 / 画图 / 控制面板 / 设备管理器 -- **定时关机** —— 15 / 30 / 60 / 120 分钟后关机,可取消 - -**安全边界**(都有测试): - -- 打开网址只允许 `http` / `https`,拒绝 `file://` 之类 -- 播放本机音频只允许音乐目录内的文件 -- 快捷命令和文件夹都是白名单,不接受任意路径 -- 结束进程必须由用户在手表上确认 - ---- - -## 关于杀毒软件误报 - -### 先说结论 - -**这个工具没有做任何免杀。** 它会被某些杀软启发式报毒,这是真的, -我不打算靠混淆/加壳/加密去绕,因为那正是恶意软件的做法 —— -绕过去之后,你也分不清自己装的是不是真有毒。 - -真正能解决误报的路只有一条:**代码签名**(要买证书,见下)。 - -### 为什么会被盯上 - -| 特征 | 为什么杀软敏感 | 这是谁的需求 | -|---|---|---| -| PyInstaller 打包 | 打包器的引导代码是启发式的重灾区 | 你要「多文件 exe,双击就跑」 | -| 写注册表 Run 键 | 自启动是持久化的典型手法 | 你要「开机自启」 | -| 无窗口后台常驻 | 隐藏进程是木马的常见形态 | 你要「全程静默」 | -| 监听端口 + 收指令 | 远控和被控端在行为上无法区分 | 这就是远控工具 | -| 提权改防火墙 | 改防火墙规则经常被拦 | 你要「黑终端一闪修防火墙」 | - -**这五条全是你明确要求的。** 它们组合在一起,行为特征上和远控木马是一回事 —— -区别在于:**配对码是你自己设的、认证是真的、你不会拿它去连别人的电脑。** - -### 我做了哪些合法的降误报 - -1. **`--onedir` 而不是 `--onefile`** - onefile 每次启动都往临时目录解压自己,这是最容易被判毒的行为之一。 - onedir 不解压,启动也更快。 - -2. **`--noupx`** - 加壳压缩是杀软重点关注对象,明确关掉。 - -3. **完整的版本信息** - EXE 里写入了公司名、产品名、文件描述、版本、版权。 - 未签名二进制有正经的版本资源,比光秃秃的少一点可疑度, - 也方便你在杀软里按「厂商 + 产品」加例外。 - -4. **不做任何偷偷摸摸的事** - 没有自我复制、没有进程注入、没有内存加载、没有关闭杀软、 - 没有偷偷加防火墙例外。自启写注册表是**你勾了勾选框**才做的。 - -### 你遇到误报时怎么办 - -**优先做成例外,不要关杀软。** - -被控端目录整个加进排除项即可(就是你放 `LanRemote-Agent.exe` 那个文件夹): - -``` -Windows 安全中心 → 病毒和威胁防护 → 管理设置 - → 排除项 → 添加或删除排除项 → 添加文件夹 - → 选 LanRemote-Agent 那个目录 -``` - -加**文件夹**而不是单个 exe —— 因为 `_internal\` 里的 dll/pyd 也可能被单独报。 - -### 真正根治:代码签名 - -有了签名,SmartScreen 和大部分杀软就不会再拦。需要: - -1. 买一张代码签名证书(OV 约 ¥1000-2000/年,EV 更贵但立刻获得 SmartScreen 信誉) -2. 用 `signtool` 给 `LanRemote-Agent.exe` 和 `_internal` 里的 dll 签名 -3. 加时间戳(否则证书过期后签名失效) - -买了证书之后告诉我,我把签名步骤加进构建脚本。 - -### 我查到的情况(本机) - -这台机器上 Windows Defender 的**按需扫描是坏的**: - -``` -MpSmartConfigInitialize() failed (80070002) -``` - -Defender 的 PowerShell 模块(`Get-MpComputerStatus` 等)也不存在, -所以**我没法在这台机器上给你一个权威的"报不报毒"结论**。 -我不会假装测过。 - -想自己确认的话,把 EXE 传到 VirusTotal 看一下 —— 那是最快的办法。 - ---- - -## 七、后台运行方式 - -按需求设计成**没有托盘图标**: - -- 双击 EXE → 弹出一个小窗口,上面一串 6 位配对码 -- **关掉窗口 → 程序继续在后台跑**,仍然占着 8765 / 8766 端口 -- 任务管理器里能看到 `LanRemote-Agent.exe` -- **再双击一次 EXE → 把已经在跑的那个窗口叫回来**,不会重复起进程 - (这里用 `SO_EXCLUSIVEADDRUSE`:Windows 上 `SO_REUSEADDR` 会允许 - 第二个进程绑同一端口,导致单实例判定失效 —— 实测踩到过) -- 想彻底退出:窗口上点「退出」 -- 运行记录写在 `data\运行日志.txt` - -**保留了一条可见性**:只要有设备连着,屏幕右上角会出现一条小提示条 -(`● 已连接:<设备名>`,蓝色,带「断开」按钮);屏幕共享时它变红, -并带「停止共享」。没有托盘图标的前提下,这是让「有人正在控制这台电脑」 -不被完全隐形的最低限度做法。空闲时它完全不显示。 - ---- - -## 八、常见问题 - -**Q:手表连不上?** -A:先看被控端窗口上有没有橙色「防火墙未放行」提示,有就点「一键修复」。 -  没有的话,装附带的「遥控诊断」跑一次,把结果发给开发者即可定位。 - -**Q:提示"电脑拒绝连接"?** -A:被控端程序没在运行(任务管理器里找 `LanRemote-Agent.exe`),或者端口被改了。 - -**Q:提示"配对码错误"?** -A:配对码会变——点了"换一组"或者被控端重启过,就用窗口上最新的那组。 - -**Q:下次打开还要输配对码?** -A:不该要。如果还要,说明上次配对没成功保存。 - -**Q:托盘图标在哪?** -A:按需求**没有托盘图标**。关窗后程序继续后台运行;再双击一次 EXE 就能找回窗口。 - -**Q:投图之后电脑上窗口关不掉?** -A:点一下图片,或者按 Esc。也可以把设置里的 `image_timeout` 改成秒数自动关闭。 - -**Q:手表屏幕显示不全?** -A:界面是按 490×602 自适应做的:宽度小于 200dp 时自动切成单列并加大按钮。 -  别的分辨率改 `MainActivity.java` 里 `adaptGridToScreen()` 的阈值即可。 - ---- - -## 九、项目结构 - -``` -LanRemote/ -├── windows/ # 被控端(Python) -│ ├── lanremote/ -│ │ ├── protocol.py # 协议常量 + 分块编解码 -│ │ ├── config.py # 数据持久化(设备令牌、设置) -│ │ ├── actions.py # 投图/放音/媒体/剪贴板/电源/命令/TTS -│ │ ├── screen.py # 屏幕捕获 + 帧流(Pillow,JPEG) -│ │ ├── discovery.py # UDP 广播应答(手表不用输 IP) -│ │ ├── firewall.py # 防火墙检测 + 一键提权放行 -│ │ ├── instance.py # 单实例锁(再次双击找回窗口) -│ │ ├── server.py # TCP 服务 + 配对鉴权 + 命令分发 + 帧广播 -│ │ └── ui.py # 小窗口 + 状态提示条(无托盘) -│ ├── run_agent.py # 启动入口 -│ └── build_exe.ps1 # PyInstaller --onedir 打包 -│ -├── android/ # 手表控制端(纯 Java,无第三方依赖) -│ ├── src/com/lanremote/watch/ -│ │ ├── Json.java # 自写 JSON(桌面 JVM 也能跑,便于联调) -│ │ ├── B64.java # 自写 Base64(兼容老 Android) -│ │ ├── RemoteClient.java # 协议客户端(纯 Java) -│ │ ├── DiscoveryCore.java # 自动发现核心(纯 Java,可桌面联调) -│ │ ├── Discovery.java # 安卓包装(MulticastLock) -│ │ ├── DeviceStore.java # 设备令牌持久化 -│ │ ├── MediaPicker.java # 内置媒体选择器(免系统弹窗) -│ │ └── MainActivity.java # 490×602 自适应大按钮 + 镜像面板 -│ ├── AndroidManifest.xml -│ ├── res/ # 图标 + 字符串 -│ └── build_apk.ps1 # aapt2+javac+d8+apksigner 手工构建 -│ -├── diag/ # 早期独立诊断 App 的源码(已合并进主程序,不再单独出包) -│ ├── src/com/lanremote/diag/ -│ │ └── DiagActivity.java # 环境采集 + 真实连通测试 + 一键复制 -│ ├── AndroidManifest.xml -│ ├── res/ # 图标 + 字符串 -│ └── build_diag_apk.ps1 # 复用主程序的 Json/DiscoveryCore 一起编译 -│ -├── _tests/ # 全部测试 -│ ├── test_agent_protocol.py # 被控端协议回归(69 项) -│ ├── check_layout.py # 490×602 布局数学核算(22 项) -│ ├── test_indicator_visible.py# 屏幕共享强制提示(真实界面,9 项) -│ ├── test_simple_ui.py # 小窗口/无托盘/后台常驻(真实界面,12 项) -│ ├── java/JavaClientTest.java # 跨语言联调(48 项) -│ ├── run_stub_server.py # 无界面桩服务端 -│ └── verify_all.ps1 # 一键全量验证(7 个阶段) -│ -└── _build/ # 构建辅助 -``` - ---- - -## 十、协议(想自己扩展可以看) - -TCP 长连接,每条消息一行 UTF-8 JSON(`\n` 结尾),二进制用 Base64 分块(256KB/块)。 - -**握手** - -| 方向 | 消息 | -|---|---| -| 手表→电脑 | `{"cmd":"pair","code":"454534","deviceId":"watch-xxx","deviceName":"Watch"}` | -| 电脑→手表 | `{"ok":true,"token":"<32位>","serverName":"DESKTOP-XXX"}` | -| 手表→电脑 | `{"cmd":"auth","deviceId":"...","token":"..."}` | - -**命令**(需鉴权) - -| cmd | 参数 | 说明 | -|---|---|---| -| `ping` | - | 心跳 | -| `info` | - | 系统信息 | -| `media` | `action` | playpause/stop/prev/next/volup/voldown/mute | -| `image` | `name,seq,last,data` | 投图(分块) | -| `audio` | `name,seq,last,data` | 放音(分块) | -| `clip_set` / `clip_get` | `text` / - | 剪贴板 | -| `say` | `text` | 语音播报 | -| `notify` | `title,text` | 通知 | -| `power` | `action` | shutdown/restart/lock/sleep/abort | -| `run` | `command` | 自定义命令 | -| `file_push` | `name,seq,last,data` | 上传文件 | -| `file_list` | - | 列出接收目录 | -| `file_pull` | `path` | 下载文件(分块回传) | -| `screen_start` | `fps,maxWidth,quality` | 开始实时屏幕镜像(1~15 fps) | -| `screen_stop` | - | 停止屏幕镜像 | -| `screenshot` | `maxWidth,quality` | 单次截屏,回包里直接带 JPEG(base64) | - -**自动发现**(UDP 8766,让手表不用输 IP) - -| 方向 | 报文 | -|---|---| -| 手表→广播 | `{"lanremote":"LANREMOTE_DISCOVER","proto":1}` | -| 电脑→单播 | `{"lanremote":"LANREMOTE_REPLY","proto":1,"name":"DESKTOP-X","port":8765,"version":"1.1.0"}` | - -手表拿到回复后就知道电脑的 IP 和端口,直接连 TCP。与在哪个网段、 -电脑有几张网卡都无关。 - -**事件推送**(电脑→手表,异步) - -| event | 说明 | -|---|---| -| `clip` | 电脑剪贴板变化 | -| `file` | `file_pull` 的分块数据 | -| `frame` | 屏幕镜像的一帧(JPEG base64,带 `w`/`h`/`size`/`ts`) | -| `screen_stopped` | 被控端本机掐断了共享 | -| `status` | 状态消息 | - -**注意三个容易踩的坑**(都是实测踩过的): -1. 分块上传时,**中间块不回包**,只在最后一块回一次。否则回复洪泛会导致后续命令消息错位。 -2. `file_pull` 分块之后还会有一条最终回执,客户端必须读走,不然会污染下一条命令。 -3. 镜像帧是**高频事件**,客户端读取线程必须把 `frame` 当事件异步处理, - 绝不能让 `frame` 进入请求-应答队列,否则命令会全部超时。 - ---- - -## 十一、测试情况 - -跑 `_tests\verify_all.ps1` 一键全量验证: - -| 项目 | 内容 | 结果 | -|---|---|---| -| Python 被控端协议回归 | 配对/鉴权/媒体/剪贴板/投图/放音/文件/命令/电源/屏幕镜像/截屏/多设备 | **69/69 PASS** | -| 手表布局适配核算 | 490×602 在 density 1.0~3.0 下的按钮尺寸与文字容量 | **22/22 PASS** | -| 屏幕共享强制提示 | 真实 tkinter 界面 + Win32 `EnumWindows` 枚举窗口 | **9/9 PASS** | -| 小窗口与后台常驻 | 真实界面 + Win32:窗口尺寸、关窗后进程存活、双启动找回窗口、无托盘 | **12/12 PASS** | -| Java 协议层编译 | javac 目标 Java 8 | **PASS** | -| Java ↔ 桩服务端 | 跨语言联调(含帧流、截屏、**UDP 自动发现**) | **48/48 PASS** | -| Java ↔ 打包后 EXE | 端到端跑真交付物 | **48/48 PASS** | -| 交付 zip 解压重跑 | 解压到全新目录后运行 EXE 再跑一遍 | **PASS** | - -**测试中发现并修掉的真实 bug:** - -1. **分块上传回复洪泛** —— 服务端给每个分块都回一次 ack,客户端只读一次,导致后续所有命令消息错位(`run` 收到的是 `file_pull` 的回包)。改为只在最后一块回包。 -2. **同名文件拉回旧版本** —— 同名文件第二次上传会存成 `name_1.bin`,但 `file_pull("name.bin")` 会拉到**上一轮的旧文件**。改为按名字匹配取最新的一份。(靠三方 SHA-256 比对才定位出来:长度完全一致,只有内容不同。) -3. **损坏图片被当成成功** —— 实测发现传随机字节时 PIL 打开失败并返回错误;确认这是正确行为并加了回归用例。 -4. **自定义命令的控制台噪音** —— `shutdown /a` 的报错会打到 stderr,改成全静默。 -5. **镜像帧污染命令队列** —— 帧是高频事件,必须走事件通道;一旦混入请求-应答队列,所有命令都会超时。已在客户端读取线程里分流。 - -**屏幕相关的安全属性,是被单独验证过的:** -`_tests/test_indicator_visible.py` 启动**真实界面**(不是桩),开始共享后用 Win32 `EnumWindows` -在系统窗口列表里查找提示条,确认:开始共享时出现 → 停止后消失 → 再次共享再出现 → -客户端断开后自动消失。这条属性不靠"看代码相信",靠枚举窗口实证。 - -**未能验证的部分(如实说明):** -- **没有真机跑 APK**。这台机器上没接安卓设备,也没装模拟器。APK 的验证做到了:构建成功、签名校验通过、`aapt dump badging` 结构正确、dex 里类齐全、协议层在桌面 JVM 上连真实服务端 35/35 通过。**但界面在真实手表上的渲染效果没实测过。** -- 装到手表上如果布局有问题,改 `adaptGridToScreen()` 的阈值即可。 - ---- - -## 十二、自己重新构建 - -```powershell -# 被控端 → 多文件 EXE -powershell -ExecutionPolicy Bypass -File windows\build_exe.ps1 - -# 手表端 → APK -powershell -ExecutionPolicy Bypass -File android\build_apk.ps1 - -# 全量验证 -powershell -ExecutionPolicy Bypass -File _tests\verify_all.ps1 -``` - -APK 用的是工作区里现成的工具链(`starklink\toolchain`:JDK 17 + Android SDK 34 + build-tools 34), -走 `aapt2 → javac → d8 → zipalign → apksigner` 手工链,**不需要 Gradle**。 - ---- - -## 十三、法律与责任 - -本工具是**你自己的设备之间**的局域网遥控工具。 - -- 只用于你拥有或已获授权控制的电脑。 -- 在他人电脑上安装并隐藏运行,属于违法行为(《刑法》285/286 条)。 -- 本工具不提供任何隐藏、免杀、伪装能力,就是为了让它不能被用于那种场景。 - -请合法使用。 +# ykxmz · 教室电脑手表遥控 + +老师在讲台上抬一下手腕,就能把教室里那台电脑管了。 + +放课件、放音频、投个图、看看谁在打游戏、下课关机 —— 不用走回讲台去点鼠标。 + +> 名字来自"遥控手表"的拼音首字母,读作"遥控拇指"也行。 + +--- + +## 先看这个:你是哪一类人 + +这份文档不短,但不用全看。挑自己那一节就行。 + +| 你是谁 | 从这里开始 | +|---|---| +| **老师** | [三步装好](#三步装好) → [上课时怎么用](#上课时怎么用) | +| **电教 / 学校信息中心** | [一个学校怎么铺开](#一个学校怎么铺开) → [批量管教室](#批量管教室) | +| **开发者** | [自己编译](#自己编译) → [通信协议](#通信协议) | +| **普通用户**(家里自己用) | [三步装好](#三步装好) → [连不上怎么办](#连不上怎么办) | + +--- + +## 这东西是干嘛的 + +一句话:**用手表遥控电脑。** + +为什么用手表而不是手机?因为老师上课时手上是空的,手机在兜里,掏手机的动作比抬手大得多。手表抬一下就行,还不打断讲课。 + +它不只能放 PPT。教室里那些事它基本都管得着: + +- 课件全屏投到教室大屏 +- 放听力音频(电脑音箱比手表响多了) +- 看看学生机是不是在打游戏,直接把游戏进程结了 +- 屏蔽游戏网站,下课一键恢复 +- 下课关机、锁屏 +- 喊一声"上课了"让电脑播音(TTS) + +电脑那边不用装 Python,一个 EXE 双击就跑,绿色免安装,不留注册表。 + +--- + +## 三件套 + +一套完整的部署是三个程序,装在三个地方: + +| 装在哪 | 是什么 | 文件 | +|---|---|---| +| 教室电脑(Windows) | 被控端 | `ykxmz-agent-1.5.0.zip` | +| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-1.5.0.zip` | +| 手表(安卓) | 控制端 | `ykxmz-watch-1.5.0.apk` | + +**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 + +**要在办公室管教室?** 那必须装中转站(见 [跨网络](#跨网络在办公室管教室))。 + +--- + +## 三步装好 + +### 第一步:电脑上装被控端 + +解压 `ykxmz-agent-1.5.0.zip`,双击里面的 `ykxmz.exe`。 + +弹出的窗口里填两样: + +- **6 位数字** —— 自己定,好记就行,比如 `789113` +- 两个勾:`设置开机自启`、`放行防火墙`(放行那个要过一次管理员确认) + +点确定,屏幕会黑闪一下(在加防火墙规则),然后窗口消失,程序转到后台跑。 + +> **窗口关了程序还在跑**,这是故意的 —— 上课时讲台上不该有个窗口杵着。 +> 想再打开窗口,**再双击一次 EXE** 就行,不会起第二个进程。 +> 想彻底退出,窗口上点「退出」。 + +### 第二步:手表上装 App + +把 `ykxmz-watch-1.5.0.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 + +打开 App,首页有四个入口: + +``` +┌──────────────────────────┐ +│ 遥控手表 │ +│ 和电脑连同一个 WiFi,不用输 IP │ +├──────────────────────────┤ +│ 中转设置(跨网络用) │ ← 办公室管教室才需要 +│ 局域网连接(同一个 WiFi) │ ← 教室里用这个 +│ 输入配对码连接 │ ← 跨网络连的时候用 +├──────────────────────────┤ +│ 服务器:还没测 检查服务器 │ +└──────────────────────────┘ +│ 已配对的电脑(点一下直连) │ +│ DESKTOP-XXXX │ +└──────────────────────────┘ +``` + +**教室里**(手表和电脑连同一个 WiFi):点「局域网连接」→「自动搜索电脑」→ 点搜到的电脑 → 输第一步那 6 位数字。 + +**不用输 IP。** App 往局域网广播一个包,电脑收到会自报家门,所以电脑上装了几块虚拟网卡、IP 是多少都不用管。 + +### 第三步:以后不用再输了 + +配对一次之后令牌存在手表里,**下次打开 App 自动就连上**,数字都不用再输。 + +多台电脑就多配对几次,列表里点哪个连哪个。长按删除配对。 + +--- + +## 上课时怎么用 + +连上之后手表上是一排标签页,左右滑动切换。下面挑上课最常用的讲。 + +### 教室 + +这一页是给上课准备的。 + +| 按钮 | 干什么 | +|---|---| +| **屏蔽游戏网站** | 改 hosts,把常见游戏站点指到本地。学生机打开就是空白页 | +| **结束游戏进程** | 扫一遍常见游戏进程名,找到就结束掉 | +| **看有哪些限制** | 列出当前开了哪些限制,别自己忘了 | +| **一键恢复全部** | 把这节课开的所有限制撤掉,**下课必点** | + +屏蔽是改 hosts 文件,不是装驱动,所以: + +- 学生自己删掉 hosts 里那几行就绕过去了 —— 它挡的是"随手点开",不是防作弊 +- 换个游戏站就得重新加,这个列表是写死的常见站点 +- 重启电脑有的会保留、有的不会,取决于你们学校的还原卡 + +### 屏幕 + +- **实时屏幕镜像** —— 手表上看教室里那台电脑在显示什么。默认 4 fps,在手表上太小看不清,主要是"看一眼有没有人乱动" +- **一键截屏** —— 存到手表相册 + +> ⚠️ **屏幕只要被看,电脑右上角就有一条红色提示条,关不掉。** +> 这是刻意做的:教室里那台电脑是公家的,谁在看必须让人知道。 +> 电脑前面的人随时可以点提示条上的「停止共享」把你掐断。 + +### 媒体 / 音频 + +放课件自带的音频,或者直接放电脑上的音乐。音量、静音、上一曲下一曲都在这一页。 + +**放音到电脑**走的是系统自带的播放器,不会弹出一个播放器窗口盖住课件。 + +### 投影 + +**投图到电脑** —— 手表上选一张图,全屏显示在教室大屏上。点一下或者按 Esc 关掉。 + +考试时贴个"考试中,请勿喧哗"的图,比在黑板写快。 + +### 系统 + +关机、重启、锁屏、睡眠,都有二次确认。**定时关机**可以设"30 分钟后关",下课忘了也不怕。 + +另外这一页最上面有三个按钮: + +- **回退上一步** —— 刚才手滑点错了,撤销 +- **回退全部** —— 把能撤的都撤了 +- **看能回退什么** —— 列出还能撤哪些 + +能撤的是各种开关和画面。**关机和删文件这类撤不了,也不会出现在列表里**,免得你以为按了就能回来。 + +### 其他几页 + +- **文件** —— 往电脑传文件、从电脑拿文件(课件来回倒腾用) +- **语音** —— 让电脑念一段字。"上课了,请回到座位"这种 +- **剪贴板** —— 手表上的字发到电脑剪贴板,或者把电脑复制的字拿到手表 +- **通知** —— 在电脑上弹个提示 +- **效率** —— 待办清单 +- **娱乐** —— 掷骰子、抽奖、抛硬币(课堂互动随机点名能用) + +--- + +## 跨网络:在办公室管教室 + +教室的电脑和你在的办公室不是一个网,直连不可能。这时候要装**中转站**。 + +原理说白了就是:两边都主动连到那台小服务器上,服务器按配对码把两条线接起来。 + +``` + 手表 ──┐ ┌── 教室电脑 + ├── 中转站(公网) ──┤ + 办公室 ──┘ └── 你手机 +``` + +**中转站只是水管子,它不认识里面流的是什么。** 配对、令牌校验、权限判断全在电脑那边做。所以就算中转机被人拿走了,也进不了你的电脑。 + +### 装中转站 + +把 `ykxmz-server-1.5.0.zip` 解压到那台小机器上(随身 WiFi、树莓派、云主机都行,Debian/Ubuntu 系统),然后: + +```bash +sudo bash install.sh +``` + +装完会打印一个公网地址,长这样: + +``` +https://relay.你的域名 +``` + +**记住这个地址**,电脑和手表都要填它。忘了就打开 `http://<小机器的IP>:8080/url` 查。 + +### 告诉电脑中转地址 + +在电脑上(cmd 或 PowerShell): + +``` +ykxmz.exe --relay "192.168.1.50:8080;https://relay.你的域名" +``` + +两个地址用分号隔开,**按顺序试,第一个通的就用**。这样在教室里走局域网(快、稳),出了学校自动切隧道。 + +### 告诉手表中转地址 + +手表首页点「中转设置」,填上面同一个地址。 + +然后回家首页点「输入配对码连接」,填电脑上那 6 位数字。 + +### 中转站怎么管 + +浏览器打开中转站的公网地址,用管理密码登录(默认 `120830`,装完后强烈建议改)。 + +能看:哪些电脑在线、配对码是什么、有多少会话、内存用了多少。 +能做:踢掉某个会话、删掉某个配对码绑定、跑个网络体检。 +下载区:最新版的三个文件都挂在上面,点一下就能下。 + +--- + +## 一个学校怎么铺开 + +如果是一个学校几十间教室,上面那套手工装会很累。这一节给电教。 + +### 被控端 + +`ykxmz.exe` 是绿色单文件,**整个文件夹拷过去就能用**。批量装有两种办法: + +**办法一:机房同传 / 还原卡** + +把文件夹放进母盘镜像,开机自启那条在程序里勾上就行。注意 `data/` 目录里存着这台机器的配对码和令牌,**母盘里别带上别的机器的 `data/`**,否则一堆机器共用一个码。 + +**办法二:脚本推** + +```powershell +# 把文件夹拷到目标机,然后静默设置配对码和自启 +Copy-Item -Recurse "\\服务器\share\ykxmz" "C:\ykxmz" +Start-Process "C:\ykxmz\ykxmz.exe" -ArgumentList ` + '--code 789113', '--relay 192.168.1.50:8080;https://relay.你的域名', '--auto-start', '--silent' +``` + +`--silent` 不弹窗口,适合批量。**静默模式下配对码一定要用参数指定**,不然每台机器随机生成一个,你也不知道是多少。 + +### 配一次码,服务器就记住了 + +这是这套东西比较省心的一点:**一台电脑第一次设的配对码,服务器会记住。** + +以后同一台电脑再报别的码(重装、误操作、母盘没清干净),服务器会**自动改回原来那个**。所以你不会因为手抖把一台机器的码搞乱,手表永远认得出它。 + +反过来,**一个码不能同时属于两台电脑**,服务器会拒绝后来那个。这也是为什么母盘里不能带别人的 `data/`。 + +### 网络规划 + +- 中转站放在学校出口能上外网的地方,**带宽不用大**,它是控制通道不是视频通道 +- 走局域网时是手表直连电脑,**完全不经过中转站**,所以一个班几十只手表也不挤服务器 +- 教室之间如果是隔离的 VLAN,那手表和电脑得在同一个 VLAN 里,或者干脆都走中转 + +### 权限怎么收 + +被控端窗口上有几个权限开关,**随时关,关了远程立刻执行不了**: + +- 允许自定义命令 +- 允许电源操作 +- 允许屏幕镜像 / 截屏 + +给班主任的电脑可以把"自定义命令"和"电源操作"关掉,只留日常那几个按钮。 + +--- + +## 连不上怎么办 + +按这个顺序排查,九成问题在第二步。 + +### 第一步:程序在跑吗 + +电脑任务管理器里找 `ykxmz.exe`。没有就双击 EXE 启动。 + +### 第二步:防火墙放行了吗 + +被控端窗口上如果显示橙色提示"防火墙未放行",点旁边的**一键修复**,过一次管理员确认。 + +或者用目录里的 `工具箱.bat` → 选 `2`。 + +> 这一条是**最常见的坑**。很多学校的电脑装了第三方安全软件,它会把新程序的入站连接全挡了。 +> 一键修复会加一条**程序级**的放行规则(不是端口级),这个才管用。 + +### 第三步:还是不行就跑网络自检 + +`工具箱.bat` → 选 `3`,然后到手表上点「检查服务器」。 + +看电脑窗口: + +- **出现 `>>> 收到 TCP 连接 <<<`** → 包到电脑了,问题在电脑本机(程序没跑 / 端口被占) +- **一个包都没收到** → 包没到电脑,八成是路由器开了**「AP 隔离」**(也叫客户端隔离),去路由器后台关掉 + +### 手表上会显示具体原因 + +App 把错误翻译成人话了,看到哪句就照做: + +| 手表上显示 | 什么意思 | +|---|---| +| 证书校验失败 | 手表系统太旧,或者网络被人劫持 | +| 证书时间不对 | **把手表的日期时间调成自动获取**,这个很常见 | +| 连接超时 | 网络慢,或者被防火墙挡了 | +| 电脑拒绝连接 | 电脑上 ykxmz 没开 | +| 域名解析不了 | 手表没联网,检查 WiFi | +| 连不上中转 | 中转站挂了,或者地址填错了 | + +首页那个「检查服务器」能测出到中转的延迟,快/一般/慢会用不同颜色标出来。 + +--- + +## 安全 + +教室里那台电脑是公家的东西,所以这套工具的安全设计偏"让人知道"而不是"藏起来"。 + +**每次连接都要配对。** 电脑上生成 6 位数字,只在窗口上显示,手表输对了才连得上。 + +**配对之后发令牌。** 32 位随机串存在手表上,之后靠它登录,不用每次输数字。 + +**没通过握手,一条命令都不执行。** 服务端直接拒。 + +**屏幕被看,电脑上一定有提示条。** 关不掉,没有开关。电脑前面的人随时能掐断。 + +**权限开关实时生效。** 窗口上关掉哪一项,远程立刻做不了那一项。 + +**不写注册表。** 数据全在程序目录下的 `data/` 里,拔了硬盘拷走就能换台机器跑。 + +**不在后台偷偷上传。** 局域网模式下完全不联外网。跨网络模式只连你自己那台中转站。 + +> **这个工具不藏自己。** 没有"任务管理器里看不见",没有"免杀对抗",没有伪装成系统进程。 +> 被控端全程可见、可随时断开、权限可随时关掉。 +> 因为管的是教室的电脑,藏起来只会让老师不放心。 + +--- + +## 自己编译 + +给开发者。三端都是零第三方依赖,不用 npm、不用 Gradle 拉一堆东西。 + +### 被控端(Windows) + +Python 3.11 + tkinter + Pillow。打包用 PyInstaller。 + +```powershell +windows\build_exe.ps1 +# 产物:dist_exe\ykxmz\ykxmz.exe +``` + +### 控制端(安卓手表) + +纯 Java,没有 Gradle,直接命令行编: + +```powershell +android\build_apk.ps1 +# 流程:aapt2 → javac → d8 → zipalign → apksigner +# 产物:dist\ykxmz-watch-1.5.0.apk +``` + +最低支持 Android 7.0(API 24),targetSdk 34。 + +### 服务器端 + +Python 3 标准库,没有第三方包。 + +```bash +bash relay/install.sh +``` + +### 跑测试 + +```powershell +_tests\verify_all.ps1 +``` + +会跑编码审计、协议回归、打包验证、Java 编译、端到端,最后打一张汇总表。 + +### 打包三个成品 + +```powershell +python _build\package_remote.py +``` + +出三个文件:`ykxmz-agent-*.zip`、`ykxmz-server-*.zip`、`ykxmz-watch-*.apk`。 + +--- + +## 通信协议 + +给要改代码或者做二次开发的人。 + +传输是 **TCP 上的换行分隔 UTF-8 JSON**,一行一个消息。 + +```json +{"cmd": "handshake", "code": "789113", "deviceId": "..."} +{"ok": true, "token": "32位随机串", "serverName": "DESKTOP-XXXX"} +``` + +二进制数据(图片、文件、音频)走 **Base64** 分块传输,单次会话上限 256 MB。 + +跨网络时同样的消息套在 HTTP 长轮询里,中转站按配对码把两端字节对接,**它不解析内容**。 + +Java 和 Python 两端都实现了同一套协议,`_tests/java/` 下有互相打通的测试。 + +--- + +## 常见问题 + +**问:手表能连多台电脑吗?** +能。配对多台,列表里点哪个连哪个。 + +**问:一台电脑能被几只手表连?** +可以多只,屏幕镜像会显示"几台设备正在观看"。教室场景一般一只就够。 + +**问:换台电脑要重新配对吗?** +要。配对码跟着电脑走,不是跟着手表走。 + +**问:电脑重装系统了,配对码会变吗?** +不会。只要 `data/` 目录还在,服务器就认原来那个码。母盘部署时注意别把 `data/` 一起发出去。 + +**问:走中转会不会很慢?** +实测局域网 3 毫秒左右,公网中转 1~3 秒(取决于你那台小机器的线路)。所以**同一个 WiFi 一定走局域网**,程序会优先选它。 + +**问:能在手机上用吗?** +协议是通的,但界面是按手表的小屏幕设计的(大按钮、两列、可滚动),手机上会显得按钮特别大。要做手机版得重写布局。 + +**问:收费吗?** +看 [LICENSE.txt](docs/LICENSE.txt)。里面有使用红线,装之前看一眼。 + +--- + +## 出问题了 + +先看 [连不上怎么办](#连不上怎么办)。 + +还不行的话,把这几样发过来,能定位得快很多: + +1. 手表上显示的那句错误(App 现在是中文的,直接念出来就行) +2. 电脑上 `工具箱.bat` → 选 `5` 的日志内容 +3. 中转站的公网地址(不用给密码) + +--- + +作者:小麦子 +许可见 [LICENSE.txt](docs/LICENSE.txt) diff --git a/_build/bump_version_generic.py b/_build/bump_version_generic.py new file mode 100644 index 0000000..5a2332e --- /dev/null +++ b/_build/bump_version_generic.py @@ -0,0 +1,148 @@ +# -*- coding: utf-8 -*- +r"""通用版本号升级(1.5.0 -> 1.6.0)。 + +只改**用户能看到版本号的地方**,不动那些当数据用的 "1.4.0/1.5.0": + * HTTP User-Agent "LanRemote/1.4.0" —— 协议标识,不是产品版本, + 而且 20 多个测试在断言它,动它要同步改一大片 + * _tests 里当测试数据用的版本串 + * _build 下的一次性脚本(历史记录) + +改这些: + android/AndroidManifest.xml versionCode / versionName + android/build_apk.ps1 APK 文件名 + manifest 参数 + _build/package_remote.py 三个产物名 + windows/version_info.txt EXE 的文件版本 + windows/lanremote/__init__.py __version__ + docs/*.txt 三份文档 + relay/relay.py VERSION + _tests/test_package_remote.py 断言成品名 + _tests/test_apk_contents.py 断言 APK 名 +""" +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +OLD = sys.argv[1] if len(sys.argv) > 1 else "1.5.0" +NEW = sys.argv[2] if len(sys.argv) > 2 else "1.6.0" + +OLD_CODE = str(int(OLD.split(".")[0]) * 100 + int(OLD.split(".")[1]) + 1) +NEW_CODE = str(int(NEW.split(".")[0]) * 100 + int(NEW.split(".")[1]) + 1) + + +def swap(path: Path, pairs, note: str) -> bool: + if not path.is_file(): + print(f" [跳过] {path.name} 不存在") + return False + raw = path.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + original = text + for old, new in pairs: + text = text.replace(old, new) + if text == original: + print(f" [跳过] {path.name}: 没有 {OLD}") + return False + path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + text.replace("\r\n", "\n").encode("utf-8")) + print(f" {path.name}: {note}") + return True + + +def main() -> int: + print(f" 版本号 {OLD} -> {NEW}(versionCode {OLD_CODE} -> {NEW_CODE})") + print() + + changed = 0 + + # ---- Android manifest ---- + manifest = ROOT / "android" / "AndroidManifest.xml" + if swap(manifest, + [(f'android:versionCode="{OLD_CODE}"', + f'android:versionCode="{NEW_CODE}"'), + (f'android:versionName="{OLD}"', + f'android:versionName="{NEW}"')], + f"versionCode={NEW_CODE} versionName={NEW}"): + changed += 1 + + # ---- APK 构建脚本 ---- + apk_ps1 = ROOT / "android" / "build_apk.ps1" + if swap(apk_ps1, + [(f"ykxmz-watch-{OLD}.apk", f"ykxmz-watch-{NEW}.apk"), + (f"--version-code {OLD_CODE}", f"--version-code {NEW_CODE}"), + (f'--version-name "{OLD}"', f'--version-name "{NEW}"')], + "APK 名 + 版本"): + changed += 1 + + # ---- 打包脚本 ---- + if swap(ROOT / "_build" / "package_remote.py", + [(f'VERSION = "{OLD}"', f'VERSION = "{NEW}"')], + f"VERSION={NEW}"): + changed += 1 + + # ---- Windows EXE 版本信息 ---- + if swap(ROOT / "windows" / "version_info.txt", + [(f'"{OLD}.0"', f'"{NEW}.0"')], + f"EXE 文件版本 {NEW}.0"): + changed += 1 + + # ---- __init__ ---- + if swap(ROOT / "windows" / "lanremote" / "__init__.py", + [(f'"{OLD}"', f'"{NEW}"')], + f"__version__={NEW}"): + changed += 1 + + # ---- 中转 VERSION ---- + if swap(ROOT / "relay" / "relay.py", + [(f'VERSION = "{OLD}"', f'VERSION = "{NEW}"')], + f"VERSION={NEW}"): + changed += 1 + + # ---- 三份文档 ---- + for path in sorted((ROOT / "docs").glob("*.txt")): + raw = path.read_bytes() + had_bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + new_text = text.replace(OLD, NEW) + if new_text != text: + path.write_bytes((b"\xef\xbb\xbf" if had_bom else b"") + + new_text.replace("\r\n", "\n").encode("utf-8")) + print(f" docs/{path.name}") + changed += 1 + + # ---- 会断言成品名的测试 ---- + for name in ("test_package_remote.py", "test_apk_contents.py"): + path = ROOT / "_tests" / name + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8") + new_text = (text.replace(f"ykxmz-agent-{OLD}", f"ykxmz-agent-{NEW}") + .replace(f"ykxmz-server-{OLD}", f"ykxmz-server-{NEW}") + .replace(f"ykxmz-watch-{OLD}", f"ykxmz-watch-{NEW}")) + if new_text != text: + path.write_text(new_text, encoding="utf-8", newline="\n") + print(f" _tests/{name}") + changed += 1 + + print() + print(f" 共改了 {changed} 处") + print(f" 注意:HTTP UA 仍然是 LanRemote/1.4.0(协议标识,不是产品版本)") + + # 复核 + print() + print(" 复核:") + checks = [ + (manifest, f'versionName="{NEW}"'), + (apk_ps1, f"ykxmz-watch-{NEW}.apk"), + (ROOT / "_build" / "package_remote.py", f'VERSION = "{NEW}"'), + ] + for path, needle in checks: + ok = needle in path.read_text(encoding="utf-8-sig") + print(f" {path.name}: {'✓' if ok else '✗'} {needle}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/package_remote.py b/_build/package_remote.py index cbb05d6..c2b3095 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 = "1.5.0" +VERSION = "1.6.0" NAME = f"ykxmz-{VERSION}" AGENT_SRC = ROOT / "dist_exe" / "ykxmz" diff --git a/_build/push_160.ps1 b/_build/push_160.ps1 new file mode 100644 index 0000000..cd135e5 --- /dev/null +++ b/_build/push_160.ps1 @@ -0,0 +1,54 @@ +# 提交并推送 1.6.0 +$ErrorActionPreference = "Continue" +$git = "C:\Program Files\Git\bin\git.exe" +$root = "C:\Users\shu\Desktop\deepseekagent\LanRemote" +Set-Location $root + +Write-Host "===== 1. 暂存 =====" -ForegroundColor Cyan +& $git add -A 2>&1 | Out-Null +$files = @(& $git -c core.quotepath=false diff --cached --name-only | + Where-Object { $_ -and $_.Trim() -ne "" }) +Write-Host " 改动: $($files.Count) 个" +$files | Select-Object -First 20 | ForEach-Object { Write-Host " $_" } + +Write-Host "" +Write-Host "===== 2. 确认没有大文件 =====" -ForegroundColor Cyan +$big = foreach ($f in $files) { + $p = Join-Path $root ($f -replace '/','\') + if (Test-Path -LiteralPath $p -PathType Leaf) { + $len = (Get-Item -LiteralPath $p).Length + if ($len -gt 500KB) { [pscustomobject]@{ N = $f; MB = [math]::Round($len/1MB,2) } } + } +} +if ($big) { $big | ForEach-Object { Write-Host " ⚠ $($_.MB) MB $($_.N)" -ForegroundColor Yellow } } +else { Write-Host " ✓ 没有超过 500KB 的" -ForegroundColor Green } + +Write-Host "" +Write-Host "===== 3. 提交 =====" -ForegroundColor Cyan +$msg = @" +1.6.0:重写 README,面向老师 / 电教 / 开发者 / 普通用户 + +README +- 原来的 README 是开发者视角(在解释"我为什么没做某功能"), + 而且通篇 AI 腔。这次按用户视角重写,讲怎么用。 +- 开头就分流:老师 / 电教 / 开发者 / 普通用户,各看各的 +- 定位改成"教室电脑手表遥控",主场景是老师上课管教室那台电脑 +- 加了电教专用一节:机房同传部署、脚本批量推、网络规划、权限收口 +- 加了开发者一节:三端怎么编译、怎么跑测试、怎么打包 +- 加了通信协议一节 +- 常见问题、排错顺序、错误对照表都补全 + +版本号 +- 1.5.0 -> 1.6.0(versionCode 107) +- 顺手写了通用的版本升级脚本 _build/bump_version_generic.py, + 以后升版本一条命令就行,不用手改十几个文件 +"@ +& $git commit -q -m $msg 2>&1 | Out-Null +Write-Host " exit=$LASTEXITCODE" +& $git log --oneline 2>&1 | Select-Object -First 2 | ForEach-Object { Write-Host " $_" } + +Write-Host "" +Write-Host "===== 4. 推送 =====" -ForegroundColor Cyan +$env:GIT_SSH_COMMAND = "`"C:\Program Files\Git\usr\bin\ssh.exe`"" +& $git push origin main 2>&1 | Select-Object -Last 5 | ForEach-Object { Write-Host " $_" } +Write-Host " exit=$LASTEXITCODE" diff --git a/_build/upload_160.py b/_build/upload_160.py new file mode 100644 index 0000000..0b04ab0 --- /dev/null +++ b/_build/upload_160.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +r"""上传 1.6.0 到中转站下载区,并清掉旧版本。""" +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote") +sys.path.insert(0, str(ROOT / "_build")) + +from devssh import run, upload # noqa: E402 + +VER = "1.6.0" +REMOTE_DIR = "/root/lanremote-relay/downloads" + +UPLOADS = [ + (f"dist/ykxmz-agent-{VER}.zip", + f"{REMOTE_DIR}/ykxmz-agent-{VER}.zip"), + (f"dist/ykxmz-server-{VER}.zip", + f"{REMOTE_DIR}/ykxmz-server-{VER}.zip"), + (f"dist/ykxmz-watch-{VER}.apk", + f"{REMOTE_DIR}/ykxmz-watch-{VER}.apk"), +] + + +def main() -> int: + # 旧版本清掉,页面上只留最新的一份 + print(" 清掉旧版本:") + _c, out, _e = run(f"ls {REMOTE_DIR}/ 2>/dev/null", timeout=30) + for line in (out or "").splitlines(): + name = line.strip() + if name and VER not in name: + run(f"rm -f '{REMOTE_DIR}/{name}'", timeout=30) + print(f" 删除 {name}") + + print() + print(" 上传 1.6.0:") + for local, remote in UPLOADS: + src = ROOT / local + if not src.is_file(): + print(f" [失败] {local} 不存在") + return 1 + upload(str(src), remote, 0o644) + print(f" ✓ {src.name} ({src.stat().st_size / 1024 / 1024:.2f} MB)") + + print() + _c, out, _e = run(f"ls -la {REMOTE_DIR}/ | tail -6", timeout=30) + print(out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_apk_contents.py b/_tests/test_apk_contents.py index 3138bc0..9b5d679 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-1.5.0.apk" + ROOT / "dist" / "ykxmz-watch-1.6.0.apk" APK = _latest_apk() diff --git a/_tests/test_package_remote.py b/_tests/test_package_remote.py index 7e54c4a..457932c 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-1.5.0.zip" -SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.5.0.zip" -WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.5.0.apk" +AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.6.0.zip" +SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.6.0.zip" +WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.6.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-1.5.0-说明.txt")): + ROOT / "dist" / "ykxmz-watch-1.6.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/android/AndroidManifest.xml b/android/AndroidManifest.xml index 7ad7576..680c7ec 100644 --- a/android/AndroidManifest.xml +++ b/android/AndroidManifest.xml @@ -2,7 +2,7 @@ + android:versionName="1.6.0"> diff --git a/android/build_apk.ps1 b/android/build_apk.ps1 index 12bf3b5..ec5c855 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-1.5.0.apk" +$apkName = "ykxmz-watch-1.6.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 "1.5.0" ` + --version-name "1.6.0" ` --auto-add-overlay if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" } diff --git a/docs/AFTER-INSTALL.txt b/docs/AFTER-INSTALL.txt index e73ef10..f8ac74a 100644 --- a/docs/AFTER-INSTALL.txt +++ b/docs/AFTER-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.5.0 +版本:1.6.0 作者:小麦子 装好之后看这一页:怎么确认它在跑、怎么改设置、 @@ -197,7 +197,7 @@ 八、版本记录 -------------------------------------------------------- - 1.5.0 + 1.6.0 * 跨网络支持:Cloudflare 具名隧道,固定域名 * 中转服务器:单设备单配对码、管理页可删设备 * 手表端:自动判断局域网/中转并无缝切换 @@ -209,6 +209,6 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.5.0 + 软件:ykxmz 1.6.0 许可条款见《许可协议》(LICENSE.txt) -------------------------------------------------------- diff --git a/docs/BEFORE-INSTALL.txt b/docs/BEFORE-INSTALL.txt index 99b90b1..dc970dd 100644 --- a/docs/BEFORE-INSTALL.txt +++ b/docs/BEFORE-INSTALL.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz -版本:1.5.0 +版本:1.6.0 作者:小麦子 请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的 @@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成: -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.5.0 + 软件:ykxmz 1.6.0 继续安装,即表示你已阅读并同意《许可协议》。 -------------------------------------------------------- diff --git a/docs/LICENSE.txt b/docs/LICENSE.txt index 697ef61..121ad7e 100644 --- a/docs/LICENSE.txt +++ b/docs/LICENSE.txt @@ -3,7 +3,7 @@ ======================================================== 软件名称:ykxmz(远程管理工具) -版本:1.5.0 +版本:1.6.0 作者:小麦子 生效日期:2026 年 10 月 5 日 @@ -150,7 +150,7 @@ -------------------------------------------------------- 作者:小麦子 - 软件:ykxmz 1.5.0 + 软件:ykxmz 1.6.0 如果你不同意本协议的任何一条,请不要安装或使用本软件。 -------------------------------------------------------- diff --git a/relay/relay.py b/relay/relay.py index c2f9baa..c6c4eed 100644 --- a/relay/relay.py +++ b/relay/relay.py @@ -58,7 +58,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path -VERSION = "1.5.0" +VERSION = "1.6.0" # ---------------- 参数(都能用环境变量覆盖)---------------- diff --git a/windows/lanremote/__init__.py b/windows/lanremote/__init__.py index df6f185..61d38e9 100644 --- a/windows/lanremote/__init__.py +++ b/windows/lanremote/__init__.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- """局域网遥控 · Windows 被控端。""" -__version__ = "1.5.0" +__version__ = "1.6.0" APP_NAME = "局域网遥控 被控端" diff --git a/windows/version_info.txt b/windows/version_info.txt index 572014f..e1560a3 100644 --- a/windows/version_info.txt +++ b/windows/version_info.txt @@ -27,12 +27,12 @@ VSVersionInfo( [ StringStruct("CompanyName", "ykxmz"), StringStruct("FileDescription", "ykxmz"), - StringStruct("FileVersion", "1.5.0.0"), + StringStruct("FileVersion", "1.6.0.0"), StringStruct("InternalName", "ykxmz"), StringStruct("LegalCopyright", "ykxmz"), StringStruct("OriginalFilename", "ykxmz.exe"), StringStruct("ProductName", "ykxmz"), - StringStruct("ProductVersion", "1.5.0.0"), + StringStruct("ProductVersion", "1.6.0.0"), ], ) ]