diff --git a/README.md b/README.md index 26fe05b..36305d9 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ | 装在哪 | 是什么 | 文件 | |---|---|---| -| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.6.exe` | -| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.6.zip` | -| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.6.apk` | +| 教室电脑(Windows) | 被控端 | `ykxmz-setup-2.1.7.exe` | +| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.7.zip` | +| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.7.apk` | **只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。 @@ -81,7 +81,7 @@ ### 第一步:电脑上装被控端 -双击 `ykxmz-setup-2.1.6.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 +双击 `ykxmz-setup-2.1.7.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。 第一次运行会弹出窗口,填两样: @@ -94,12 +94,12 @@ > 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。 > 想彻底退出,窗口上点「退出」。 -> 不想用安装包的话,`ykxmz-agent-2.1.6.zip` 是绿色版: +> 不想用安装包的话,`ykxmz-agent-2.1.7.zip` 是绿色版: > 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。 ### 第二步:手表上装 App -把 `ykxmz-watch-2.1.6.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 +把 `ykxmz-watch-2.1.7.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。 打开 App,首页有四个入口: @@ -223,7 +223,7 @@ 中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行, Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。 -把 `ykxmz-server-2.1.6.zip` 解压到那台机器上,然后一条命令: +把 `ykxmz-server-2.1.7.zip` 解压到那台机器上,然后一条命令: ```bash bash deploy.sh @@ -456,7 +456,7 @@ windows\build_exe.ps1 ```powershell android\build_apk.ps1 # 流程:aapt2 → javac → d8 → zipalign → apksigner -# 产物:dist\ykxmz-watch-2.1.6.apk +# 产物:dist\ykxmz-watch-2.1.7.apk ``` 最低支持 Android 7.0(API 24),targetSdk 34。 diff --git a/_build/CHANGELOG.md b/_build/CHANGELOG.md index 90d5cfa..27f2457 100644 --- a/_build/CHANGELOG.md +++ b/_build/CHANGELOG.md @@ -1,3 +1,33 @@ +## 2.1.7 + +- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮: + + - **最后一个是固定的「显示桌面」**(等于电脑上按 Win+D),**改不了** + - **前三个自己选**,在设置页里点一下从这 7 个里挑: + 传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 / 投图到电脑 + - 也可以把某一格留空 + - 还没连上电脑时它们是灰的(不是藏起来 —— "一直在但暂时不能用" + 比"时有时无"更好理解) + +- **远程鼠标控制(手表上的触摸板)** —— 拖动移动光标、单击左键、 + 双击右键,下面还有专门的左键/右键/滚轮按钮。 + +- **弹文字窗口的位置和大小可以自己设** —— 右下(默认)/ 右上 / 左上 / + 左下 / 屏幕正中间 / 自定义坐标,字号 8~32 可调。 + 在电脑端设置窗口的「显示」里改。 + +- **屏幕被观看时的提示条改成默认不显示** —— + 以前是强制弹的("有人在看你的屏幕,电脑前面的人该知道")。 + 现在默认关掉,跟常见远程工具一样全程无窗口;需要的话在 + 设置里可以打开。**这个开关是刻意保留的**:这软件应该始终有一个 + "让本机用户看得见正在被控制"的选项可查。 + +- **中转后台能看到每台电脑的版本了**,并且可以直接点「更新」 + 让那台电脑自己静默升级(离线设备按钮置灰)。 + +- **手表端设置里可以开关「允许新设备连接」** —— + 关掉之后只有配对过的电脑能连上来,教室里防别人乱连。 + ## 2.1.6 - **电脑端静默自动更新** —— 手表上点一下,电脑自己升到最新版, diff --git a/_build/add_notice_options.py b/_build/add_notice_options.py new file mode 100644 index 0000000..cb2cf7a --- /dev/null +++ b/_build/add_notice_options.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +r'''弹文字窗口「位置 + 大小」可配置,屏幕共享提示条默认关掉。 + +== 用户的要求 == + +"那个弹文字窗口,不要(现在)在电脑的右下角,(要能放在)屏幕正中间, + 还可以自定义窗口大小" + +"现在不是要会弹一个屏幕已被共享吗?以后不需要弹,啥窗户都没有, + 就可以像 uu 远程一样" + +== 现状(查清楚了)== + +headless.py 里有三个窗口定位点,**全在右下角**: + + 第 190 行 notify() 里的通知窗口 ┐ 就是用户说的"弹文字窗口" + 第 231 行 notify()(代码重复了一份)┘ 6 秒自动关 + 第 451 行 _show_notice() 屏幕共享提示条 + +三处都写死了 `+{screen_w - width - 24}+{screen_h - height - 72}`。 + +== 改法 == + +1. **加一个统一的定位函数 `_notice_geometry(width, height)`** + 支持 6 种位置:右下 / 右上 / 左上 / 左下 / 正中间 / 自定义坐标。 + 三处都改调它 —— 这样以后再调位置只用改一个地方。 + +2. **加设置项**(config.py): + notice_position 默认 bottom-right(保持原样,不改变老用户习惯) + notice_x / notice_y position=custom 时用 + notice_width 0 = 按内容自适应(原来就是这样) + notice_font_size 11 = 原来的字号 + show_screen_notice **默认 False** —— 屏幕被看时不再弹提示条 + +3. **`set_screen_sharing` 尊重 `show_screen_notice`** + 关着就什么都不做(连窗口都不建)。 + +== 关于"去掉屏幕提示条"这件事 == + +原来那条是**故意加**的:有人在看屏幕时,电脑前面的人应该知道。 +用户现在要求去掉,理由是"像 UU 远程一样不要任何窗口"。 + +这是**用户自己的电脑、自己的教室**,属于正当的远程管理需求 +(极域电子教室、联想网络控制工具这类校园软件也都是无提示的)。 +但我在 README 和设置界面里都保留了这个选项的描述 —— +**能力还在,只是默认关**,这样万一以后有争议,能证明这软件有可见开关。 + +== 顺带发现的一个问题 == + +`notify()` 里那段建窗口的代码**被复制了两份**(第 166-201 和 206-250), +两份几乎一模一样。这次不动它(不属于本次需求,动它风险大), +但下划线标注一下,以后重构时知道。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CONFIG = ROOT / "windows" / "lanremote" / "config.py" +HEADLESS = ROOT / "windows" / "lanremote" / "headless.py" + +CONFIG_ADD = ''' "screen_fps": 12, + # ---- 弹窗外观(用户要求"位置和大小都能自定义")---- + # + # 位置:bottom-right / top-right / top-left / bottom-left / center / custom + # 默认保持右下角 —— 不改变老用户的使用习惯。 + "notice_position": "bottom-right", + "notice_x": 0, # position=custom 时的横坐标(像素) + "notice_y": 0, # position=custom 时的纵坐标(像素) + "notice_width": 0, # 0 = 按内容自适应(240 起) + "notice_font_size": 11, # 标题字号;正文自动小 1 号 + # 屏幕被观看时是否在本机弹提示条。 + # + # ⚠ 原来这个提示条是**强制显示**的("有人在看你的屏幕, + # 电脑前面的人必须知道")。用户要求去掉,理由是"像 UU 远程一样 + # 不要任何窗口"。 + # + # 这是用户自己的电脑,属于正当的远程管理需求 + # (极域电子教室这类校园软件也都是无提示的)。 + # 所以能力保留、**默认关闭** —— 需要的话在设置里打开, + # 这样这软件始终有一个"可见性开关"可查。 + "show_screen_notice": False, +}''' + +GEOMETRY_HELPER = ''' + def _notice_geometry(self, width: int, height: int) -> str: + """算出弹窗该放哪。返回 Tk 的 geometry 字符串 `+x+y`。 + + 用户要求"位置和大小都能自定义",所以这里支持 6 种位置。 + ⚠ 三处弹窗(通知 ×2、屏幕提示 ×1)都调这个函数 —— + 以前三处各写死一遍右下角,改一次要改三个地方。 + """ + screen_w, screen_h = self.screen_size() + pos = str(self.store.get("notice_position") or "bottom-right") + pad = 24 # 离屏幕边缘留一点空 + pad_bottom = 72 # 底下多留些,避开任务栏 + + if pos == "custom": + try: + x = int(self.store.get("notice_x") or 0) + y = int(self.store.get("notice_y") or 0) + except (TypeError, ValueError): + x = y = 0 + # 夹到屏幕内,免得设了个离谱的值窗口就找不到了 + x = max(0, min(screen_w - width, x)) + y = max(0, min(screen_h - height, y)) + return f"+{x}+{y}" + + if pos == "center": + return (f"+{max(0, (screen_w - width) // 2)}" + f"+{max(0, (screen_h - height) // 2)}") + + # 四个角 + left = pos in ("top-left", "bottom-left") + top = pos in ("top-left", "top-right") + x = pad if left else screen_w - width - pad + y = pad if top else screen_h - height - pad_bottom + return f"+{max(0, x)}+{max(0, y)}" + +''' + +SCREEN_SHARING_OLD = ''' def set_screen_sharing(self, on: bool) -> None: + """屏幕共享提示。 + + 这是**故意保留**的:有人在看你的屏幕时,屏幕右下角会出现一条提示。 + 静默模式只是不显示主控制窗口,不是"让被看的人不知道"。 + """ + try: + if on: + self._show_notice("屏幕正在被共享") + else: + self._hide_notice() + except Exception as exc: # noqa: BLE001 + self.log(f"共享提示失败: {exc}")''' + +SCREEN_SHARING_NEW = ''' def set_screen_sharing(self, on: bool) -> None: + """屏幕共享提示条 —— **默认不显示**。 + + 用户要求:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。 + + 所以这里先看设置项 `show_screen_notice`: + * 关着(默认)-> 什么都不做,连窗口都不建 + * 开着 -> 行为跟以前一样,右下角(或用户设的位置)一条红条 + + 保留这个开关而不是删掉代码,是因为这软件应该始终有一个 + "让本机用户看得见正在被控制"的选项可查 —— + 需要的时候在设置里打开就行。 + """ + try: + if not self.store.get("show_screen_notice"): + # 设置关着:确保之前残留的提示条也收掉(比如用户 + # 正在被看的时候把开关关了),然后什么都不做 + if not on: + self._hide_notice() + return + if on: + self._show_notice("屏幕正在被共享") + else: + self._hide_notice() + except Exception as exc: # noqa: BLE001 + self.log(f"共享提示失败: {exc}")''' + + +def patch_file(path: Path, pairs: list, what: str) -> bool: + """先校验所有锚点,再一次性替换。""" + raw = path.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + missing = [name for name, old, _new in pairs if old not in text] + if missing: + print(" ✗ %s 这些锚点找不到:%s" % (what, ", ".join(missing))) + return False + for name, old, new in pairs: + text = text.replace(old, new, 1) + path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" ✓ %s(%d 处)" % (what, len(pairs))) + return True + + +def main() -> int: + print("=" * 58) + print(" 弹窗位置/大小可配置 + 屏幕提示条默认关") + print("=" * 58) + print() + + # ---- config.py ---- + cfg_raw = CONFIG.read_bytes() + cfg_bom = cfg_raw.startswith(b"\xef\xbb\xbf") + cfg = cfg_raw.decode("utf-8-sig") + if "notice_position" in cfg: + print(" config.py 已经加过了") + else: + anchor = ' "screen_fps": 12,\n}' + if anchor not in cfg: + print(" ✗ config.py 找不到 screen_fps 结尾锚点") + return 1 + cfg = cfg.replace(anchor, CONFIG_ADD, 1) + CONFIG.write_bytes((b"\xef\xbb\xbf" if cfg_bom else b"") + + cfg.encode("utf-8")) + print(" ✓ config.py 加了 6 个设置项") + + # ---- headless.py ---- + html_raw = HEADLESS.read_bytes() + print() + ok = patch_file(HEADLESS, [ + ("_notice_geometry 方法", " def screen_size(self)", + GEOMETRY_HELPER.lstrip("\n") + " def screen_size(self)"), + ("set_screen_sharing 尊重设置项", SCREEN_SHARING_OLD, SCREEN_SHARING_NEW), + ], "headless.py 主体") + + if not ok: + return 1 + + # ---- 三处写死的 geometry 改成调 helper ---- + raw = HEADLESS.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + old_line = ('win.geometry(f"+{screen_w - width - 24}' + '+{screen_h - height - 72}")') + n = text.count(old_line) + if n == 0: + print(" ⚠ 没找到写死的 geometry 行(可能已改)") + else: + text = text.replace(old_line, "win.geometry(self._notice_geometry(width, height))") + HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" ✓ 替换了 %d 处写死的右下角定位" % n) + + # ---- 字号可配置 ---- + raw = HEADLESS.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + n2 = 0 + for old_font, new_font in [ + ('font=("Microsoft YaHei UI", 11, "bold")', + 'font=("Microsoft YaHei UI", self._notice_font(), "bold")'), + ('font=("Microsoft YaHei UI", 10)', + 'font=("Microsoft YaHei UI", self._notice_font() - 1)'), + ]: + c = text.count(old_font) + if c: + text = text.replace(old_font, new_font) + n2 += c + if n2: + HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" ✓ 替换了 %d 处写死的字号" % n2) + + # ---- 加 _notice_font 方法 ---- + raw = HEADLESS.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + text = raw.decode("utf-8-sig") + if "_notice_font" in text and "def _notice_font" not in text: + helper = ''' + def _notice_font(self) -> int: + """弹窗字号。用户要求"大小可以自定义",所以从设置里读。 + + 夹在 8~32 之间:太小看不清,太大一个通知就把屏幕占满了。 + """ + try: + size = int(self.store.get("notice_font_size") or 11) + except (TypeError, ValueError): + size = 11 + return max(8, min(32, size)) + +''' + text = text.replace(" def _notice_geometry(self, width: int, height: int) -> str:", + helper.lstrip("\n") + " def _notice_geometry(self, width: int, height: int) -> str:", 1) + HEADLESS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8")) + print(" ✓ 加了 _notice_font 方法") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_quickbar_stage.py b/_build/add_quickbar_stage.py new file mode 100644 index 0000000..dae4a3d --- /dev/null +++ b/_build/add_quickbar_stage.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +r'''把快捷栏那一关加进 verify_all.ps1。 + +== 为什么用文件而不是 python -c == + +要写进 .ps1 的内容里有 `$stageOut`、`$(...)`。 +用 `python -c "..."` 穿过 PowerShell 时,**PowerShell 会先解析一遍**, +把 `$stageOut` 变成 `\` —— 上次 3a12 就是这么坏的 +(插进去变成 `\ = & python (Join-Path \ "..."`,语法直接错)。 + +凡是内容里带 `$` 的,一律写成 .py 文件再跑。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +VERIFY = ROOT / "_tests" / "verify_all.ps1" + +ANCHOR = "# ---------------------------------------------------------------- 3a5" + +STAGE = '''# ---------------------------------------------------------------- 3a13 +Banner "3a13/9 手表快捷栏 + 允许新设备连接" +# 用户要求:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页 +# 自定义,最多可以加 4 个,其中有一个按钮不能改, +# 功能就是一键返回桌面"。 +# +# 四个地方最容易漏,漏了都不会报错、只会让人困惑: +# 1. buildQuickBar 没挂进 onCreate -> 栏根本不显示 +# 2. 没加进 showOnly 的数组 -> 面板永远压在上面 +# 3. 第 4 格做成了可配置 -> 用户找半天"显示桌面"没了 +# 4. 设置页没有入口 -> 说是能自定义其实改不了 +$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py") 2>&1 + Show-Fails $stageOut +Record "手表快捷栏" ($LASTEXITCODE -eq 0) + +''' + + +def main() -> int: + raw = VERIFY.read_bytes() + bom = raw.startswith(b"\xef\xbb\xbf") + t = raw.decode("utf-8-sig") + + if "手表快捷栏" in t and "test_quickbar" in t: + print(" 已经加过了") + return 0 + if ANCHOR not in t: + print(" ✗ 找不到锚点") + return 1 + + t = t.replace(ANCHOR, STAGE + ANCHOR, 1) + VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8")) + print(" ✓ 加了 3a13") + + back = VERIFY.read_text(encoding="utf-8-sig") + for token in ('$stageOut = & python (Join-Path $root "_tests\\test_quickbar.py")', + 'Record "手表快捷栏"'): + ok = token in back + print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56])) + if not ok: + return 1 + + # 顺手把那些 "3aN/7" 的分母改成 9(关卡总数变了) + import re + n = len(re.findall(r'Banner "3a\d+/7 ', back)) + back = re.sub(r'(Banner "3a\d+)/7 ', r'\1/9 ', back) + VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + back.encode("utf-8")) + print(" ✓ 把 %d 个 3aN/7 的分母改成 /9" % n) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/add_watch_quickbar.py b/_build/add_watch_quickbar.py new file mode 100644 index 0000000..848120d --- /dev/null +++ b/_build/add_watch_quickbar.py @@ -0,0 +1,556 @@ +# -*- coding: utf-8 -*- +r'''手表端:常驻顶部快捷栏。 + +== 用户的要求(原话)== + +"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页自定义, + 最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面" + +("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。) + +== 做什么 == + +1. **一条常驻的快捷栏**:贴在屏幕顶部,任何页面都在 + (控制页 / 镜像页 / 设置页 / 局域网页面板切换时不动) + +2. **最多 4 个按钮**: + * **1 个固定**:显示桌面(Win+D)—— 不给改,这是用户明确要求的 + * **最多 3 个自定义**:从 7 个动作里挑 + +3. **7 个可选动作**(都有现成调用,见下面的 QUICK_ACTIONS): + file 传文件到电脑 + mirror 屏幕镜像 + mouse 鼠标控制(本补丁配套的触摸板) + sfx 音效 + power 关机 / 锁屏 + tts 语音播报 + image 投图到电脑 + +4. **设置页里能改**:勾选哪 3 个动作。 + +== 为什么贴在 FrameLayout 最上层 == + +现有架构是:一个 FrameLayout `root`,5 个面板都 MATCH_PARENT 叠在里面, +靠 `showOnly()` 切可见性。 + +快捷栏也加到 `root`,**在 5 个面板之后 addView** —— 这样它在最上层: + * 切页面时它不受影响(不用每个页面各建一份) + * 面板填满整个屏幕也不会盖住它 + +代价:面板内容会被栏盖住一截,所以给每个面板加了 topMargin +(见 PATCH_PANEL_MARGIN)。 + +== 为什么栏在没连上时也显示 == + +用户说"不管干什么上面都有一栏",那就**一直在**。 +没连上的时候按钮置灰(不是隐藏)—— "一直在但暂时不能用" +比"时有时无"更好理解,也不会让用户以为功能没了。 + +== 存储 == + +用 SharedPreferences(和 App 其他设置一致),键 `quick_slots`, +存逗号分隔的动作 id。默认给 file,mirror,sfx。 +读取时会**过滤掉不认识的 id**(比如以后删了某个动作), +不够 3 个就按默认顺序补齐 —— 这样老配置永远不会让栏变空。 +''' +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MAIN = ROOT / "android" / "src" / "com" / "lanremote" / "watch" / "MainActivity.java" + +# ---------------------------------------------------------------- 字段 +FIELDS_ANCHOR = " private Button updateButton;" + +FIELDS = ''' private Button updateButton; + + // ---------------- 顶部快捷栏 ---------------- + // + // 用户要求:"不管干什么,上面都有一栏,栏里的按钮可以在设置页 + // 自定义,最多可以加 4 个,其中有一个按钮不能改, + // 功能就是一键返回桌面"。 + // + // 所以这里是 1 个固定(显示桌面)+ 最多 3 个自定义。 + private LinearLayout quickBar; + private final java.util.List