mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 19:30:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48bc392466 | ||
|
|
fd8be87209 | ||
|
|
f0c05079f7 |
@@ -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.8.exe` |
|
||||
| 小服务器(随身 WiFi / 云主机 / 树莓派) | 中转站 | `ykxmz-server-2.1.8.zip` |
|
||||
| 手表(安卓) | 控制端 | `ykxmz-watch-2.1.8.apk` |
|
||||
|
||||
**只在一个教室用?** 那第三件(中转站)可以先不装,手表和电脑连同一个 WiFi 就能直连。
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
### 第一步:电脑上装被控端
|
||||
|
||||
双击 `ykxmz-setup-2.1.6.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
双击 `ykxmz-setup-2.1.8.exe`,按提示装完(默认装到 `C:\Program Files\ykxmz`)。
|
||||
|
||||
第一次运行会弹出窗口,填两样:
|
||||
|
||||
@@ -94,12 +94,12 @@
|
||||
> 想再打开窗口,**再双击一次程序图标**就行,不会起第二个进程。
|
||||
> 想彻底退出,窗口上点「退出」。
|
||||
|
||||
> 不想用安装包的话,`ykxmz-agent-2.1.6.zip` 是绿色版:
|
||||
> 不想用安装包的话,`ykxmz-agent-2.1.8.zip` 是绿色版:
|
||||
> 解压出来直接跑 `ykxmz.exe`,整个文件夹拷走就能换台机器用,不写注册表。
|
||||
|
||||
### 第二步:手表上装 App
|
||||
|
||||
把 `ykxmz-watch-2.1.6.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
把 `ykxmz-watch-2.1.8.apk` 传到手表装上。手表可能提示"未知来源",去设置里允许一下。
|
||||
|
||||
打开 App,首页有四个入口:
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
中转站就是**一台能上外网的 Linux 小机器**(随身 WiFi、树莓派、云主机都行,
|
||||
Debian 12 / Ubuntu 实测可用,512 MB 内存就够)。
|
||||
|
||||
把 `ykxmz-server-2.1.6.zip` 解压到那台机器上,然后一条命令:
|
||||
把 `ykxmz-server-2.1.8.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.8.apk
|
||||
```
|
||||
|
||||
最低支持 Android 7.0(API 24),targetSdk 34。
|
||||
|
||||
@@ -1,3 +1,65 @@
|
||||
## 2.1.8
|
||||
|
||||
- **快捷栏的按钮做大** —— 上一版 30dp 在手表上按不准,手指按上去
|
||||
一半在栏外。现在 46dp(接近 Android 建议的可点区域),字号 11→14。
|
||||
|
||||
- **快捷栏 4 个格子全部可以自定义** —— 上一版最后一个「显示桌面」
|
||||
是写死不能改的,现在四格平等,想换哪个换哪个。
|
||||
|
||||
「显示桌面」变成一个普通的可选动作,默认仍放在第 4 格 ——
|
||||
所以习惯不用改,但想换成别的也行。可选动作现在共 8 个:
|
||||
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 /
|
||||
投图到电脑 / 显示桌面。
|
||||
|
||||
- **修 Win7 上语音播报不能用** —— 以前只走 PowerShell 一条路,
|
||||
而 Win7 上的 PowerShell 是 2.0 版,中文经命令行传进去容易乱码;
|
||||
再加上 `powershell` 可能不在 PATH 里,就彻底播不出来了。
|
||||
|
||||
现在改成**多方案降级**,按"依赖越少越优先"排:
|
||||
|
||||
| 顺序 | 方案 | 依赖 |
|
||||
|---|---|---|
|
||||
| 1 | cscript + VBScript + SAPI5 | Windows 98 起就有,不用装任何东西 |
|
||||
| 2 | wscript + VBScript + SAPI5 | 上一条被安全软件拦时的备选 |
|
||||
| 3 | powershell + SAPI5 (COM) | 只依赖 PowerShell,**不需要 .NET** |
|
||||
| 4 | powershell + System.Speech | 原来的做法,改用绝对路径 |
|
||||
| 5 | powershell (按 PATH) + System.Speech | 最原始的,兜底 |
|
||||
|
||||
一条通了就记住它,以后直接用(不用每次从头试)。
|
||||
|
||||
**全都失败会明确报错** —— 以前是不等进程结束就返回"已播报",
|
||||
明明没出声也报成功,那是在骗人。
|
||||
|
||||
## 2.1.7
|
||||
|
||||
- **手表端顶部快捷栏** —— 任何页面顶部都有一条栏,最多 4 个按钮:
|
||||
|
||||
- **最后一个是固定的「显示桌面」**(等于电脑上按 Win+D),**改不了**
|
||||
- **前三个自己选**,在设置页里点一下从这 7 个里挑:
|
||||
传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 / 语音播报 / 投图到电脑
|
||||
- 也可以把某一格留空
|
||||
- 还没连上电脑时它们是灰的(不是藏起来 —— "一直在但暂时不能用"
|
||||
比"时有时无"更好理解)
|
||||
|
||||
- **远程鼠标控制(手表上的触摸板)** —— 拖动移动光标、单击左键、
|
||||
双击右键,下面还有专门的左键/右键/滚轮按钮。
|
||||
|
||||
- **弹文字窗口的位置和大小可以自己设** —— 右下(默认)/ 右上 / 左上 /
|
||||
左下 / 屏幕正中间 / 自定义坐标,字号 8~32 可调。
|
||||
在电脑端设置窗口的「显示」里改。
|
||||
|
||||
- **屏幕被观看时的提示条改成默认不显示** ——
|
||||
以前是强制弹的("有人在看你的屏幕,电脑前面的人该知道")。
|
||||
现在默认关掉,跟常见远程工具一样全程无窗口;需要的话在
|
||||
设置里可以打开。**这个开关是刻意保留的**:这软件应该始终有一个
|
||||
"让本机用户看得见正在被控制"的选项可查。
|
||||
|
||||
- **中转后台能看到每台电脑的版本了**,并且可以直接点「更新」
|
||||
让那台电脑自己静默升级(离线设备按钮置灰)。
|
||||
|
||||
- **手表端设置里可以开关「允许新设备连接」** ——
|
||||
关掉之后只有配对过的电脑能连上来,教室里防别人乱连。
|
||||
|
||||
## 2.1.6
|
||||
|
||||
- **电脑端静默自动更新** —— 手表上点一下,电脑自己升到最新版,
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把「语音播报降级链」加进 verify_all.ps1。
|
||||
|
||||
== 为什么用文件而不是 python -c ==
|
||||
|
||||
要写进 .ps1 的内容里有 `$stageOut`。用 `python -c "..."` 穿过
|
||||
PowerShell 时会被先解析一遍,`$stageOut` 变成 `\` —— 上次 3a12
|
||||
就是这么坏的。
|
||||
|
||||
凡是内容里带 `$` 的,一律写成 .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 = '''# ---------------------------------------------------------------- 3a14
|
||||
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||
#
|
||||
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_tts.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 "test_tts.py" 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(" ✓ 加了 3a14")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_tts.py")',
|
||||
'Record "语音播报降级"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:56]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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<Button> quickButtons =
|
||||
new java.util.ArrayList<Button>();
|
||||
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||
private String[] quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
|
||||
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||
private static final String[][] QUICK_ACTIONS = {
|
||||
{"file", "传文件"},
|
||||
{"mirror", "屏幕镜像"},
|
||||
{"mouse", "鼠标控制"},
|
||||
{"sfx", "音效"},
|
||||
{"power", "关机"},
|
||||
{"tts", "语音播报"},
|
||||
{"image", "投图到电脑"},
|
||||
};
|
||||
private static final String QUICK_PREF = "quick_slots";
|
||||
private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内
|
||||
|
||||
// ---------------- 鼠标控制(触摸板)----------------
|
||||
private FrameLayout mousePanel;
|
||||
private View mousePad;
|
||||
private boolean mouseDragging = false;
|
||||
private float mouseLastX = 0f;
|
||||
private float mouseLastY = 0f;'''
|
||||
|
||||
# ---------------------------------------------------------------- 建栏方法
|
||||
BUILD_ANCHOR = " private void showOnly(View keep) {"
|
||||
|
||||
BUILD_METHOD = ''' // ==================================================================
|
||||
// 顶部快捷栏
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 建快捷栏。挂在 root 最上层,任何页面都在。
|
||||
*
|
||||
* <p>布局:[动作1][动作2][动作3][显示桌面]
|
||||
* 前三个按设置里的选择走,最后一个**永远**是显示桌面(不给改)。
|
||||
*/
|
||||
private void buildQuickBar() {
|
||||
quickBar = new LinearLayout(this);
|
||||
quickBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
quickBar.setBackgroundColor(0xE6185CE8); // 半透明蓝,压在任何页面上都看得清
|
||||
quickBar.setPadding(dp(3), dp(3), dp(3), dp(3));
|
||||
quickBar.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||
|
||||
quickButtons.clear();
|
||||
// 4 个按钮:3 个自定义 + 1 个固定
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(0xFFFFFFFF);
|
||||
b.setBackgroundColor(0x33FFFFFF);
|
||||
b.setPadding(dp(2), 0, dp(2), 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
b.setMinHeight(0);
|
||||
b.setMinimumHeight(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(2), 0, dp(2), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onQuickClick(slot);
|
||||
}
|
||||
});
|
||||
quickButtons.add(b);
|
||||
quickBar.addView(b);
|
||||
}
|
||||
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));
|
||||
barLp.gravity = android.view.Gravity.TOP;
|
||||
quickBar.setLayoutParams(barLp);
|
||||
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设置刷新快捷栏上的文字和可用状态。
|
||||
*
|
||||
* <p>连不上的时候置灰而不是隐藏 —— 用户说"不管干什么上面都有一栏",
|
||||
* 时有时无会让人以为功能没了。
|
||||
*/
|
||||
private void refreshQuickBar() {
|
||||
if (quickBar == null || quickButtons.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
b.setText("-"); // 这个位置空着
|
||||
b.setEnabled(false);
|
||||
b.setAlpha(0.45f);
|
||||
continue;
|
||||
}
|
||||
b.setText(quickLabel(id));
|
||||
b.setEnabled(connected);
|
||||
b.setAlpha(connected ? 1f : 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static String quickLabel(String id) {
|
||||
if ("desktop".equals(id)) {
|
||||
return "显示桌面"; // 固定那个
|
||||
}
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return pair[1];
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
/** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}
|
||||
|
||||
/** 执行一个快捷动作。设置页里选的也是这几个 id。 */
|
||||
private void runQuickAction(String id) {
|
||||
try {
|
||||
if ("desktop".equals(id)) {
|
||||
// 用户确认过:"一键返回桌面" = 让**被控电脑**显示桌面(Win+D)
|
||||
sendCommand(Json.obj("cmd", "show_desktop"), "显示桌面");
|
||||
return;
|
||||
}
|
||||
if ("file".equals(id)) {
|
||||
pickFile(REQ_FILE, "*/*");
|
||||
return;
|
||||
}
|
||||
if ("mirror".equals(id)) {
|
||||
openMirrorPanel();
|
||||
return;
|
||||
}
|
||||
if ("mouse".equals(id)) {
|
||||
showMousePanel();
|
||||
return;
|
||||
}
|
||||
if ("sfx".equals(id)) {
|
||||
pickSfx();
|
||||
return;
|
||||
}
|
||||
if ("power".equals(id)) {
|
||||
showPowerChooser();
|
||||
return;
|
||||
}
|
||||
if ("tts".equals(id)) {
|
||||
promptSay();
|
||||
return;
|
||||
}
|
||||
if ("image".equals(id)) {
|
||||
pickFile(REQ_IMAGE, "image/*");
|
||||
return;
|
||||
}
|
||||
toast("不认识的动作:" + id);
|
||||
} catch (Throwable error) {
|
||||
toast("执行失败:" + shortReason(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** 关机这一格点开后让用户选锁屏还是关机(不直接关,太危险)。 */
|
||||
private void showPowerChooser() {
|
||||
final java.util.List<String> items = new java.util.ArrayList<String>();
|
||||
items.add("锁屏");
|
||||
items.add("关机");
|
||||
items.add("重启");
|
||||
showChooser("电源", items, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index == 0) {
|
||||
confirmPower("lock", "锁屏");
|
||||
} else if (index == 1) {
|
||||
confirmPower("shutdown", "关机");
|
||||
} else {
|
||||
confirmPower("restart", "重启");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 快捷栏的存取 ----------------
|
||||
|
||||
private void loadQuickSlots() {
|
||||
String raw = prefs().getString(QUICK_PREF, "");
|
||||
String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
: raw.split(",", -1);
|
||||
// ⚠ 一定要过滤 + 补齐:
|
||||
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||
// 不过滤的话那格会显示空白,用户以为坏了
|
||||
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||
String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {
|
||||
if (out[i] == null) {
|
||||
// 补一个没被用过的默认动作
|
||||
for (String f : fallback) {
|
||||
boolean used = false;
|
||||
for (String o : out) {
|
||||
if (f.equals(o)) {
|
||||
used = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!used) {
|
||||
out[i] = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quickSlots = out;
|
||||
}
|
||||
|
||||
private static boolean isKnownQuickAction(String id) {
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveQuickSlots() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String id : quickSlots) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(id == null ? "" : id);
|
||||
}
|
||||
prefs().edit().putString(QUICK_PREF, sb.toString()).apply();
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
private android.content.SharedPreferences prefs() {
|
||||
return getSharedPreferences("ykxmz", MODE_PRIVATE);
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------- 鼠标触摸板
|
||||
MOUSE_METHOD = ''' // ==================================================================
|
||||
// 鼠标控制(触摸板)
|
||||
// ==================================================================
|
||||
|
||||
private void showMousePanel() {
|
||||
if (mousePanel == null) {
|
||||
buildMousePanel();
|
||||
}
|
||||
showOnly(mousePanel);
|
||||
appendLog("鼠标控制:拖动=移动光标,单击=左键,双击=右键");
|
||||
toast("拖动移动光标 · 单击左键 · 双击右键");
|
||||
}
|
||||
|
||||
/**
|
||||
* 触摸板。整块区域接收触摸,按比例映射到电脑屏幕的像素坐标。
|
||||
*
|
||||
* <p>为什么用"按比例映射"而不是"相对位移":
|
||||
* 手表表盘和电脑屏幕尺寸差得远,相对位移要让手表自己猜倍率,
|
||||
* 猜错了光标就飘;绝对映射由电脑端负责落到那个像素,
|
||||
* 责任清晰,也不会累积漂移。
|
||||
*
|
||||
* <p>但表盘太小,绝对映射的精度只有几十像素 —— 所以
|
||||
* **双击左右半屏**这类大幅移动之后,用拖动做细调更实际。
|
||||
* 这里两个都支持:拖动 = 相对微调,点击 = 在当前位置按下/抬起。
|
||||
*/
|
||||
private void buildMousePanel() {
|
||||
mousePanel = new FrameLayout(this);
|
||||
mousePanel.setBackgroundColor(0xFF101418);
|
||||
|
||||
LinearLayout col = new LinearLayout(this);
|
||||
col.setOrientation(LinearLayout.VERTICAL);
|
||||
col.setPadding(dp(6), dp(34), dp(6), dp(4));
|
||||
mousePanel.addView(col, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
TextView tip = new TextView(this);
|
||||
tip.setText("拖动=移动 单击=左键 双击=右键\\n滚轮在下面");
|
||||
tip.setTextSize(10f);
|
||||
tip.setTextColor(0xFF9AA4B2);
|
||||
tip.setGravity(android.view.Gravity.CENTER);
|
||||
col.addView(tip, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
mousePad = new View(this);
|
||||
mousePad.setBackgroundColor(0xFF1B2028);
|
||||
LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
|
||||
padLp.setMargins(0, dp(4), 0, dp(4));
|
||||
col.addView(mousePad, padLp);
|
||||
|
||||
// 左键 / 右键 / 滚轮
|
||||
LinearLayout keys = new LinearLayout(this);
|
||||
keys.setOrientation(LinearLayout.HORIZONTAL);
|
||||
col.addView(keys, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(34)));
|
||||
|
||||
keys.addView(mouseKey("左键", new Runnable() {
|
||||
public void run() { sendMouse("click", "left", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("右键", new Runnable() {
|
||||
public void run() { sendMouse("click", "right", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↑", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", 3); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↓", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", -3); }
|
||||
}));
|
||||
|
||||
// 返回按钮(快捷栏上也能离开,但这里给个明确的出口)
|
||||
Button back = new Button(this);
|
||||
back.setText("返回");
|
||||
back.setTextSize(11f);
|
||||
back.setAllCaps(false);
|
||||
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
|
||||
dp(58), dp(26));
|
||||
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
|
||||
backLp.setMargins(0, dp(4), dp(4), 0);
|
||||
back.setLayoutParams(backLp);
|
||||
back.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showControlPanel();
|
||||
}
|
||||
});
|
||||
mousePanel.addView(back);
|
||||
|
||||
// ---- 触摸处理 ----
|
||||
final android.view.GestureDetector detector =
|
||||
new android.view.GestureDetector(this,
|
||||
new android.view.GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public boolean onSingleTapConfirmed(android.view.MotionEvent e) {
|
||||
sendMouse("click", "left", 0); // 单击 = 左键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDoubleTap(android.view.MotionEvent e) {
|
||||
sendMouse("click", "right", 0); // 双击 = 右键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(android.view.MotionEvent e1,
|
||||
android.view.MotionEvent e2,
|
||||
float dx, float dy) {
|
||||
// 手指往哪滑,光标往哪走:dx/dy 是"内容移动量",
|
||||
// 也就是手指移动的反方向,所以取负。
|
||||
sendMouseMove(-dx, -dy);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
mousePad.setOnTouchListener(new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View v, android.view.MotionEvent e) {
|
||||
detector.onTouchEvent(e);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Button mouseKey(String text, final Runnable action) {
|
||||
Button b = new Button(this);
|
||||
b.setText(text);
|
||||
b.setTextSize(10f);
|
||||
b.setAllCaps(false);
|
||||
b.setPadding(0, 0, 0, 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(1), 0, dp(1), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
private void sendMouse(String action, String button, int amount) {
|
||||
if (!isConnected()) {
|
||||
toast("还没连上电脑");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> req = Json.obj("cmd", "mouse", "action", action);
|
||||
if (button != null && !button.isEmpty()) {
|
||||
req.put("button", button);
|
||||
}
|
||||
if (amount != 0) {
|
||||
req.put("amount", amount);
|
||||
}
|
||||
sendCommand(req, "鼠标");
|
||||
}
|
||||
|
||||
private void sendMouseMove(float dx, float dy) {
|
||||
if (!isConnected()) {
|
||||
return;
|
||||
}
|
||||
// 手表上滑一点,电脑上要走一段 —— 倍率小了手指要划好几次才横穿屏幕。
|
||||
// 取 3.0 是试出来的:1920 宽的屏大约划 4~5 次到底,细调也还行。
|
||||
sendCommand(Json.obj("cmd", "mouse", "action", "moveby",
|
||||
"dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标");
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------- 设置页那一段
|
||||
SETTINGS_ANCHOR = " private void showSettingsPanel() {"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 手表端:常驻顶部快捷栏 + 鼠标触摸板")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "buildQuickBar" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
# 先校验所有锚点,避免改一半
|
||||
need = {
|
||||
"updateButton 字段": FIELDS_ANCHOR,
|
||||
"showOnly 方法": BUILD_ANCHOR,
|
||||
}
|
||||
missing = [k for k, v in need.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点全部就位")
|
||||
|
||||
# 1) 字段
|
||||
t = t.replace(FIELDS_ANCHOR, FIELDS, 1)
|
||||
# 2) 方法(插在 showOnly 之前)
|
||||
t = t.replace(BUILD_ANCHOR, BUILD_METHOD + BUILD_ANCHOR, 1)
|
||||
# 3) 鼠标面板方法(也插在 showOnly 之前,跟快捷栏挨着)
|
||||
t = t.replace(BUILD_ANCHOR, MOUSE_METHOD + BUILD_ANCHOR, 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了字段 + buildQuickBar/refreshQuickBar/runQuickAction")
|
||||
print(" ✓ 加了鼠标触摸板 buildMousePanel")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''设置页里加「快捷栏」和「允许新设备连接」(第二版)。
|
||||
|
||||
== 第一版为什么不行 ==
|
||||
|
||||
我原来写的 refresh 是重建整个设置面板,但 `buildSettingsPanel()` 是
|
||||
**一次性把整页建完**的(没有"往给定容器里建"的版本),而
|
||||
`root.addView(settingsPanel)` 是在 onCreate 里做的 ——
|
||||
所以重建出来的面板**没挂在视图树上**,刷完页面就白了。
|
||||
|
||||
== 这一版怎么做 ==
|
||||
|
||||
用**一个专用容器**装我这两小节:
|
||||
|
||||
settingsPanel
|
||||
└ ScrollView
|
||||
└ box (LinearLayout)
|
||||
├ ...原有的各小节...
|
||||
├ settingsExtraBox <-- 我加的这个
|
||||
└ 关于
|
||||
|
||||
刷新时只清空 `settingsExtraBox` 再填一遍 —— 不动页面其他部分,
|
||||
也不用碰视图树。开销小,而且不会有"重建后没挂上去"的问题。
|
||||
|
||||
== 加两个小节 ==
|
||||
|
||||
1. **快捷栏**:3 个格子各点一下,从 7 个动作里选(也能选"留空")。
|
||||
第 4 格是固定的「显示桌面」—— 界面上写明它改不了,
|
||||
否则用户会一直找"怎么改第四个"。
|
||||
|
||||
2. **允许新设备连接**:关掉后只有配过对的设备能连。
|
||||
两端的语义必须一致(电脑端设置窗口里也有同名勾选)。
|
||||
'''
|
||||
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"
|
||||
|
||||
ANCHOR = ' // ================= 关于 ================='
|
||||
|
||||
SECTION = ''' // ================= 快捷栏 / 允许新设备连接 =================
|
||||
//
|
||||
// 这两小节装在**专用容器**里(settingsExtraBox),
|
||||
// 这样改完设置只刷这一段,不用重建整页 ——
|
||||
// 重建整页的话 settingsPanel 是新的对象,而 root 上挂的还是旧的,
|
||||
// 表现就是"一改设置页面就白了"。
|
||||
settingsExtraBox = new LinearLayout(this);
|
||||
settingsExtraBox.setOrientation(LinearLayout.VERTICAL);
|
||||
box.addView(settingsExtraBox, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
fillSettingsExtra();
|
||||
|
||||
''' + ANCHOR
|
||||
|
||||
# ---------------- 各种方法 ----------------
|
||||
METHODS = ''' /** 让用户选第 slot 格放哪个动作。 */
|
||||
private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");
|
||||
ids.add("");
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
labels.add(pair[1]);
|
||||
ids.add(pair[0]);
|
||||
}
|
||||
showChooser("第 " + (slot + 1) + " 格放什么", labels, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index < 0 || index >= ids.size()) {
|
||||
return;
|
||||
}
|
||||
quickSlots[slot] = ids.get(index);
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("第 " + (slot + 1) + " 格已设为:"
|
||||
+ quickLabel(quickSlots[slot]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 电脑端当前的「允许新设备连接」状态。本地记一份,连上后以电脑端为准。 */
|
||||
private boolean allowNewPairing() {
|
||||
return prefs().getBoolean("allow_new_pairing", true);
|
||||
}
|
||||
|
||||
/** 填「快捷栏」和「允许新设备连接」这两小节。刷新时也调它。 */
|
||||
private void fillSettingsExtra() {
|
||||
if (settingsExtraBox == null) {
|
||||
return;
|
||||
}
|
||||
settingsExtraBox.removeAllViews();
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||
settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {
|
||||
final int s = slot;
|
||||
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||
? "空着" : quickLabel(quickSlots[s]);
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"第 " + (s + 1) + " 格:" + now, C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
pickQuickSlot(s);
|
||||
}
|
||||
}));
|
||||
}
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("快捷栏已恢复默认");
|
||||
}
|
||||
}));
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("允许新设备连接"));
|
||||
settingsExtraBox.addView(hintText("关掉之后,只有已经配对过的电脑"
|
||||
+ "能连上来,新电脑会被拒绝。教室里防别人乱连用这个。"));
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"现在:" + (allowNewPairing() ? "允许"
|
||||
: "不允许(只有老设备能连)"),
|
||||
C_CARD, new Runnable() {
|
||||
public void run() {
|
||||
final boolean want = !allowNewPairing();
|
||||
new android.app.AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle("允许新设备连接")
|
||||
.setMessage(want
|
||||
? "打开后,没配对过的新电脑也能连上。"
|
||||
: "关掉后,只有已经配对过的电脑能连上,"
|
||||
+ "新电脑会被拒绝。")
|
||||
.setPositiveButton(want ? "允许" : "关掉",
|
||||
new android.content.DialogInterface.OnClickListener() {
|
||||
public void onClick(android.content.DialogInterface d,
|
||||
int w) {
|
||||
// 电脑端执行 + 本地记一份(连不上时也能看到选择)
|
||||
runFeature("allow_new_pairing",
|
||||
Json.obj("allow", want),
|
||||
want ? "允许新设备连接"
|
||||
: "只允许老设备连接");
|
||||
prefs().edit()
|
||||
.putBoolean("allow_new_pairing", want)
|
||||
.apply();
|
||||
fillSettingsExtra();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
FIELD_ANCHOR = " private LinearLayout quickBar;"
|
||||
FIELD_ADD = ''' private LinearLayout quickBar;
|
||||
/** 设置页里装「快捷栏 / 允许新设备连接」那两小节的容器。
|
||||
* 用专用容器是为了改完只刷这一段,不用重建整页。 */
|
||||
private LinearLayout settingsExtraBox;'''
|
||||
|
||||
PICK_ANCHOR = " private void showSettingsPanel() {"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 设置页:快捷栏 + 允许新设备连接(第二版)")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "fillSettingsExtra" in t:
|
||||
print(" 已经加过了")
|
||||
return 0
|
||||
|
||||
missing = [k for k, v in {
|
||||
"关于锚点": ANCHOR,
|
||||
"showSettingsPanel": PICK_ANCHOR,
|
||||
"quickBar 字段": FIELD_ANCHOR,
|
||||
}.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
t = t.replace(ANCHOR, SECTION, 1)
|
||||
t = t.replace(PICK_ANCHOR, METHODS + PICK_ANCHOR, 1)
|
||||
t = t.replace(FIELD_ANCHOR, FIELD_ADD, 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 settingsExtraBox 字段 + 容器")
|
||||
print(" ✓ 加了 fillSettingsExtra / pickQuickSlot / allowNewPairing")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新
|
||||
|
||||
用户这次提了一整套升级,这是最后一批。
|
||||
|
||||
== 一、手表端顶部快捷栏 ==
|
||||
|
||||
用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。
|
||||
|
||||
("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)
|
||||
|
||||
* 一条常驻的栏,贴在屏幕顶部,切页面时不动
|
||||
* **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
|
||||
* 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
|
||||
语音播报 / 投图到电脑
|
||||
* 设置页里点一下换;也能把某一格留空
|
||||
* 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
|
||||
比"时有时无"更好理解)
|
||||
|
||||
实现上的几个要点:
|
||||
|
||||
* 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
|
||||
加在前面会被面板(MATCH_PARENT)整个盖住
|
||||
* 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
|
||||
(每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
|
||||
* 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
|
||||
这样它既不会出现在可选列表里,也不可能被改成别的
|
||||
* 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
|
||||
以后删了某个动作,老配置也不会让栏变空或下标越界
|
||||
|
||||
== 二、远程鼠标控制(手表上的触摸板)==
|
||||
|
||||
* 拖动 = 移动光标,单击 = 左键,双击 = 右键
|
||||
* 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
|
||||
* 电脑端用 SendInput 注入(见下)
|
||||
|
||||
== 三、弹文字窗口的位置和大小可配 ==
|
||||
|
||||
用户原话:"弹文字窗口 …… 位置和大小都能自定义"。
|
||||
|
||||
* 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
|
||||
屏幕正中间 / 自定义坐标
|
||||
* 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
|
||||
* 宽度:可指定,0 = 按内容自适应
|
||||
* 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
|
||||
右下角,改一次要改三个地方,漏一个用户就看得见
|
||||
|
||||
== 四、屏幕被观看时的提示条改成默认不显示 ==
|
||||
|
||||
用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||
|
||||
* 默认**连窗口都不建**
|
||||
* **但保留成开关**,设置里可以打开
|
||||
|
||||
关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
|
||||
应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
|
||||
属于正当的远程管理需求。所以能力保留、默认关闭 ——
|
||||
这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。
|
||||
|
||||
== 五、中转后台能看到版本并远程点更新 ==
|
||||
|
||||
* 设备表新增「版本」列
|
||||
* 每行一个「更新」按钮:点一下让那台电脑自己静默升级
|
||||
* 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
|
||||
那时用户早忘了自己点过,突然重启很莫名其妙)
|
||||
* 和手表那边走**同一条命令**(cmd=update, action=start),
|
||||
不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致
|
||||
|
||||
== 六、手表端可开关「允许新设备连接」 ==
|
||||
|
||||
* 关掉后只有配对过的电脑能连,教室里防别人乱连
|
||||
* 电脑端新增 _do_allow_new_pairing 这个 feature
|
||||
* 只影响**配对新设备**,不踢现有会话
|
||||
(否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")
|
||||
|
||||
== 修掉的几个自己造的坑 ==
|
||||
|
||||
1. **管理页 JS 裸换行** —— 最严重的一个。
|
||||
ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
|
||||
结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
|
||||
**整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
|
||||
改成 String.fromCharCode(10),不依赖任何转义层。
|
||||
|
||||
2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
|
||||
但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
|
||||
于是路由加到了 GET 块里,管理页的 POST 打不到它。
|
||||
表现是"点了更新没反应"。
|
||||
|
||||
3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
|
||||
编译才发现。顺手包了一层并加上说明。
|
||||
|
||||
4. **两个不存在的方法名** —— onCreate 里写了
|
||||
buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。
|
||||
|
||||
5. **未调用方法扫描的假阳性** —— GestureDetector 的
|
||||
onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
|
||||
扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
|
||||
这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。
|
||||
|
||||
6. **测试断言太严导致误报** —— "不能有写死的 240",
|
||||
但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。
|
||||
|
||||
7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
|
||||
改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
|
||||
关闭时默认值必须是 False 且代码路径上不建窗口。
|
||||
|
||||
== 验证 ==
|
||||
|
||||
全量回归(19 关)全部 PASS,零失败
|
||||
新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
|
||||
3a13 手表快捷栏
|
||||
Java 编译通过(android-34, --release 8)
|
||||
APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
|
||||
623 个 Python 文件语法通过
|
||||
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''清掉 actions.py 里重复的 list_voices 和残留的旧 say 前半段。
|
||||
|
||||
== 怎么弄出来的 ==
|
||||
|
||||
fix_tts_fallback.py 里我写了两处替换,但 `NEW` 那个大块**自己也带了一份
|
||||
list_voices**:
|
||||
|
||||
t.replace(OLD_VOICES, NEW_VOICES) # 这是第一份 list_voices
|
||||
t.replace(OLD_SAY, NEW) # NEW 里又有一份 list_voices
|
||||
|
||||
于是 pyflakes 报:
|
||||
|
||||
redefinition of unused 'list_voices' from line 540
|
||||
redefinition of unused 'say' from line 575
|
||||
local variable 'chosen' is assigned to but never used
|
||||
|
||||
第三条是**旧 say 的前半段残留** —— `chosen` / `speed` 算完就不用了,
|
||||
真正干活的在后面那份里。
|
||||
|
||||
== 这一版怎么改 ==
|
||||
|
||||
把**后面**那份 list_voices(`NEW` 带进来的)删掉,保留前面 NEW_VOICES 那份;
|
||||
再把旧的 say 前半段(算 chosen/speed 那几行)删掉 —— 那些在新 say 里
|
||||
已经算过一遍了。
|
||||
|
||||
**先全部定位好再动手**,避免又改一半。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 清掉重复定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
lines = t.split("\n")
|
||||
|
||||
# ---- 1. 找所有 list_voices / say 定义 ----
|
||||
defs = {}
|
||||
for i, ln in enumerate(lines):
|
||||
m = re.match(r" def (list_voices|say|_tts_commands|_write_vbs|"
|
||||
r"_powershell_path)\(self", ln)
|
||||
if m:
|
||||
defs.setdefault(m.group(1), []).append(i)
|
||||
|
||||
print(" 定义位置:")
|
||||
for name, spots in sorted(defs.items()):
|
||||
print(" %-18s %s" % (name, [s + 1 for s in spots]))
|
||||
|
||||
# ---- 2. 删掉重复的 list_voices(保留第一个)----
|
||||
dup_lv = defs.get("list_voices", [])
|
||||
if len(dup_lv) != 2:
|
||||
print(" ⚠ list_voices 有 %d 个定义,期望 2 个" % len(dup_lv))
|
||||
else:
|
||||
start = dup_lv[1]
|
||||
# 找到下一个同级 def
|
||||
end = len(lines)
|
||||
for j in range(start + 1, len(lines)):
|
||||
if re.match(r" def \w+\(", lines[j]):
|
||||
end = j
|
||||
break
|
||||
# 往上把紧邻的注释也吃掉(那是我写的那段说明)
|
||||
s = start
|
||||
while s > 0 and (lines[s - 1].strip().startswith("#")
|
||||
or lines[s - 1].strip() == ""):
|
||||
s -= 1
|
||||
if lines[s].strip() == "" and s + 1 < start:
|
||||
s += 1
|
||||
break
|
||||
print(" 删除重复的 list_voices:第 %d-%d 行" % (start + 1, end))
|
||||
del lines[start:end]
|
||||
|
||||
# ---- 3. 删掉旧 say 的前半段(算 chosen/speed 那段)----
|
||||
t2 = "\n".join(lines)
|
||||
old_head = ''' 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))
|
||||
|
||||
'''
|
||||
n = t2.count(old_head)
|
||||
print(" 旧 say 前半段残留:%d 处" % n)
|
||||
if n:
|
||||
# 只删最后一处之前的那一坨(如果出现两次,说明新 say 里也有一份,
|
||||
# 那份是**要保留的**)—— 所以删第一处。
|
||||
t2 = t2.replace(old_head, "", 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t2.encode("utf-8"))
|
||||
print(" ✓ 清完了")
|
||||
|
||||
# ---- 4. 复核 ----
|
||||
back = ACTIONS.read_text(encoding="utf-8-sig")
|
||||
lv = len(re.findall(r"\n def list_voices\(self", back))
|
||||
sy = len(re.findall(r"\n def say\(self", back))
|
||||
tc = len(re.findall(r"\n def _tts_commands\(self", back))
|
||||
print()
|
||||
print(" 复核:list_voices=%d(应 1) say=%d(应 1) _tts_commands=%d(应 1)"
|
||||
% (lv, sy, tc))
|
||||
return 0 if (lv == 1 and sy == 1 and tc == 1) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,250 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''诊断:这台机器上哪几种语音播报方案能用。
|
||||
|
||||
== 为什么写这个 ==
|
||||
|
||||
用户反馈:**Win7 上语音播报不能用**。
|
||||
|
||||
现在的实现只有一条路:
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
这在 Win7 上有几个可能致命的点:
|
||||
|
||||
1. **`powershell` 可能不在 PATH 里**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
正常装的机器 PATH 里有,但被精简过、或者从服务/计划任务里拉起时
|
||||
可能没有 —— 而**我们是用 CreateProcess 按名字找的**,找不到就静默失败。
|
||||
|
||||
2. **Win7 默认是 PowerShell 2.0,它用 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0+ 才改成按 UTF-16 处理命令行。所以中文经 `-Command` 传进去
|
||||
在 PS 2.0 上会乱码 —— 表现是"念出来是乱码"或"什么都不念"。
|
||||
|
||||
3. **`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
根本不需要 .NET。绕开 .NET 更稳。
|
||||
|
||||
== 所以这里试 5 条路 ==
|
||||
|
||||
A. powershell(按 PATH 找)+ System.Speech <- 现在的实现
|
||||
B. powershell(按**绝对路径**找)+ System.Speech <- 修"不在 PATH"
|
||||
C. powershell(绝对路径)+ SAPI.SpVoice COM <- 绕开 .NET
|
||||
D. **cscript + VBScript + SAPI.SpVoice** <- 完全不依赖 PowerShell
|
||||
E. wscript + VBScript + SAPI.SpVoice <- 同上,另一个宿主
|
||||
|
||||
D/E 最值得期待:**VBScript 从 Windows 98 就有,SAPI5 是系统组件**,
|
||||
两个都不需要装任何东西,也不受 PowerShell 版本影响。
|
||||
|
||||
== 怎么跑 ==
|
||||
|
||||
python _build\diag_tts.py
|
||||
|
||||
它会一条条试并打印结果。**在有问题的机器上跑最有价值** ——
|
||||
比如那台 Win7。把输出发回来就知道该换哪条路。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
IS_WINDOWS = os.name == "nt"
|
||||
|
||||
# 让它念一句中文。中文最能暴露编码问题 ——
|
||||
# 纯英文在 PS 2.0 上也可能碰巧是对的(ASCII 不受代码页影响)。
|
||||
TEXT = "语音播报测试,一二三"
|
||||
|
||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
FOUND: list = []
|
||||
FAILED: list = []
|
||||
|
||||
|
||||
def report(name: str, ok: bool, detail: str = "") -> None:
|
||||
mark = "可用 " if ok else "不可用"
|
||||
print(" [%s] %s%s" % (mark, name, (" —— " + detail) if detail else ""))
|
||||
(FOUND if ok else FAILED).append(name)
|
||||
|
||||
|
||||
def run(cmd: list, timeout: int = 30) -> tuple:
|
||||
"""跑一条命令,返回 (退出码, 输出)。不让它弹窗。"""
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, timeout=timeout,
|
||||
creationflags=NO_WINDOW,
|
||||
encoding="mbcs", errors="replace")
|
||||
out = ((p.stdout or "") + (p.stderr or "")).strip()
|
||||
return p.returncode, out
|
||||
except FileNotFoundError:
|
||||
return -1, "文件找不到"
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "超时"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return -3, "%s: %s" % (type(exc).__name__, exc)
|
||||
|
||||
|
||||
def find_powershell() -> str:
|
||||
"""先按 PATH 找,再按绝对路径找。"""
|
||||
p = shutil.which("powershell")
|
||||
if p:
|
||||
return p
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
return str(cand) if cand.is_file() else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 62)
|
||||
print(" 语音播报方案诊断")
|
||||
print("=" * 62)
|
||||
print()
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
wv = sys.getwindowsversion()
|
||||
print(" 系统: Windows %d.%d (build %d)"
|
||||
% (wv.major, wv.minor, wv.build))
|
||||
except Exception: # noqa: BLE001
|
||||
print(" 系统: Windows(版本号取不到)")
|
||||
else:
|
||||
print(" 系统: 非 Windows")
|
||||
print(" Python: %s (%d 位)" % (sys.version.split()[0],
|
||||
64 if sys.maxsize > 2 ** 32 else 32))
|
||||
print(" 测试文本: %r" % TEXT)
|
||||
print()
|
||||
|
||||
if not IS_WINDOWS:
|
||||
print(" 非 Windows,没有 TTS 方案可试。")
|
||||
return 0
|
||||
|
||||
# ---------------- 0. 环境事实 ----------------
|
||||
print("── 0. 环境 ──")
|
||||
ps_path = find_powershell()
|
||||
ps_in_path = shutil.which("powershell")
|
||||
print(" powershell 在 PATH 里: %s" % (ps_in_path or "**没有**"))
|
||||
print(" powershell 绝对路径: %s" % (ps_path or "**找不到**"))
|
||||
if ps_path:
|
||||
code, out = run([ps_path, "-NoProfile", "-Command",
|
||||
"$PSVersionTable.PSVersion.Major"])
|
||||
print(" PowerShell 主版本: %s" % (out.strip() or "查不到"))
|
||||
print(" cscript: %s" % (shutil.which("cscript") or "**没有**"))
|
||||
print(" wscript: %s" % (shutil.which("wscript") or "**没有**"))
|
||||
print(" SystemRoot: %s" % os.environ.get("SystemRoot", "?"))
|
||||
print()
|
||||
|
||||
# ---------------- A. 现有实现 ----------------
|
||||
print("── A. powershell(按 PATH)+ System.Speech ← 现在产品用的 ──")
|
||||
if not ps_in_path:
|
||||
report("A", False, "powershell 不在 PATH 里 —— **这就是失败原因**")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run(["powershell", "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("A", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- B. 绝对路径 + System.Speech ----------------
|
||||
print("── B. powershell(绝对路径)+ System.Speech ──")
|
||||
if not ps_path:
|
||||
report("B", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"
|
||||
"$s.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("B", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- C. SAPI.SpVoice COM(绕开 .NET)----------------
|
||||
print("── C. powershell(绝对路径)+ SAPI.SpVoice COM ──")
|
||||
if not ps_path:
|
||||
report("C", False, "找不到 powershell.exe")
|
||||
else:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.Speak('%s')" % TEXT.replace("'", "''"))
|
||||
code, out = run([ps_path, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", script])
|
||||
report("C", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- D. cscript + VBScript + SAPI ----------------
|
||||
#
|
||||
# 最值得期待的一条:VBScript 从 Windows 98 就有,
|
||||
# SAPI5 是系统组件,**两个都不需要装任何东西**,
|
||||
# 也完全不受 PowerShell 版本 / PATH / .NET 的影响。
|
||||
print("── D. cscript + VBScript + SAPI.SpVoice(不依赖 PowerShell)──")
|
||||
vbs = (
|
||||
'Dim v\r\n'
|
||||
'Set v = CreateObject("SAPI.SpVoice")\r\n'
|
||||
'v.Speak "%s"\r\n'
|
||||
) % TEXT.replace('"', '""')
|
||||
# ⚠ VBScript 文件要 ANSI(=系统代码页) —— 写成 UTF-8 的话
|
||||
# 中文在 cscript 里会变乱码。这里用 mbcs 编码写。
|
||||
tmp = Path(tempfile.gettempdir()) / "ykxmz_tts_test.vbs"
|
||||
try:
|
||||
tmp.write_bytes(vbs.encode("mbcs", errors="replace"))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
report("D", False, "写 vbs 失败: %s" % exc)
|
||||
else:
|
||||
code, out = run(["cscript", "//nologo", str(tmp)])
|
||||
report("D", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- E. wscript(不弹黑框)----------------
|
||||
print("── E. wscript + VBScript + SAPI.SpVoice ──")
|
||||
if tmp.is_file():
|
||||
code, out = run(["wscript", "//nologo", str(tmp)])
|
||||
report("E", code == 0, "退出码 %d %s" % (code, out[:70]))
|
||||
else:
|
||||
report("E", False, "没有 vbs 文件")
|
||||
time.sleep(0.4)
|
||||
|
||||
# ---------------- 音色列表(两种方式对比)----------------
|
||||
print()
|
||||
print("── 可用音色 ──")
|
||||
if ps_path:
|
||||
code, out = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }"])
|
||||
print(" SAPI5(COM):")
|
||||
for ln in (out or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out.strip():
|
||||
print(" (列不出来)")
|
||||
code2, out2 = run([
|
||||
ps_path, "-NoProfile", "-Command",
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"])
|
||||
print(" System.Speech:")
|
||||
for ln in (out2 or "").splitlines():
|
||||
if ln.strip():
|
||||
print(" " + ln.strip())
|
||||
if not out2.strip():
|
||||
print(" (列不出来 —— .NET 那套可能有问题)")
|
||||
|
||||
# ---------------- 结论 ----------------
|
||||
print()
|
||||
print("=" * 62)
|
||||
print(" 可用 %d 条 / 不可用 %d 条" % (len(FOUND), len(FAILED)))
|
||||
if FOUND:
|
||||
print(" 可用方案: %s" % ", ".join(FOUND))
|
||||
print()
|
||||
print(" → 产品应该按**这个顺序**降级尝试:D(cscript) -> C(SAPI COM)")
|
||||
print(" -> B(绝对路径) -> A(PATH),全失败才报错。")
|
||||
else:
|
||||
print(" ⚠ 一条都不通。可能是:没装任何语音、系统被精简过、")
|
||||
print(" 或者 SAPI 组件损坏。")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,134 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''按**行号**修 admin.py 里那段坏掉的 JS。
|
||||
|
||||
== 为什么不用字符串匹配 ==
|
||||
|
||||
前两次都失败了:坏掉的内容里带着**真的换行**,
|
||||
在 Python 源码里怎么写字面量都对不齐(缩进、空行的数量差一个就匹配不上)。
|
||||
|
||||
按行号定位反而稳:先找到 `data-update` 那个 forEach 的锚点行,
|
||||
然后把它后面那几行跨行字符串整段换掉。
|
||||
|
||||
== 坏掉的是什么 ==
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||
+ '它会自己下载……'))
|
||||
|
||||
Python 三引号里 `\n` 变成真换行 -> JS 字符串字面量跨行 -> 语法非法
|
||||
-> **整个 script 块解析失败 -> 管理页所有按钮全死**。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
NEW_LINES = [
|
||||
" const name = btn.dataset.name || '这台电脑';",
|
||||
" // ⚠ 这里**不能用反斜杠 n** 拼多行文案。",
|
||||
" // ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成",
|
||||
" // **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,",
|
||||
" // 整个 script 块解析失败,管理页所有按钮全死。",
|
||||
" // (回归里的「管理页 JS 合法性」就是专门抓这个的。)",
|
||||
" //",
|
||||
" // 用 String.fromCharCode(10) 完全不依赖转义层,",
|
||||
" // 以后谁改这段都不会再踩。",
|
||||
" const NL = String.fromCharCode(10);",
|
||||
" const msg = ['让「' + name + '」现在更新到最新版?', '',",
|
||||
" '它会自己下载、静默安装并重启,全程不弹窗口,',",
|
||||
" '中转地址、配对码和各项设置都不变。'].join(NL);",
|
||||
" if (!confirm(msg)) {",
|
||||
" return;",
|
||||
" }",
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 按行号修 admin.py 的 JS")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ADMIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "String.fromCharCode(10)" in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
lines = text.split("\n")
|
||||
|
||||
# 找 forEach 的锚点
|
||||
start = None
|
||||
for i, ln in enumerate(lines):
|
||||
if "querySelectorAll('button[data-update]')" in ln:
|
||||
start = i
|
||||
break
|
||||
if start is None:
|
||||
print(" ✗ 找不到 data-update 的 forEach")
|
||||
return 1
|
||||
print(" 锚点在第 %d 行" % (start + 1))
|
||||
|
||||
# 锚点后第一行应该是 btn.onclick = async () => {
|
||||
j = start + 1
|
||||
while j < len(lines) and "btn.onclick" not in lines[j]:
|
||||
j += 1
|
||||
if j >= len(lines):
|
||||
print(" ✗ 找不到 btn.onclick")
|
||||
return 1
|
||||
# 下一个非空行是 const name = ...
|
||||
k = j + 1
|
||||
while k < len(lines) and not lines[k].strip():
|
||||
k += 1
|
||||
if "const name" not in lines[k]:
|
||||
print(" ✗ const name 那行不在预期位置(第 %d 行):%r"
|
||||
% (k + 1, lines[k][:70]))
|
||||
return 1
|
||||
|
||||
# k 之后到底几个坏行?找到 "if (!confirm(" 开始,到那个跨行字符串结束
|
||||
# (下一个以 ' return;' 结尾的块之前)
|
||||
end = k + 1
|
||||
found_confirm = False
|
||||
while end < len(lines):
|
||||
s = lines[end].strip()
|
||||
if s.startswith("if (!confirm("):
|
||||
found_confirm = True
|
||||
if found_confirm and s.startswith("return;"):
|
||||
break
|
||||
end += 1
|
||||
if not found_confirm:
|
||||
print(" ✗ 找不到 if (!confirm(")
|
||||
return 1
|
||||
|
||||
print(" 要替换第 %d 行 ~ 第 %d 行(%d 行)"
|
||||
% (k + 1, end, end - k + 1))
|
||||
for n in range(k, min(end + 1, k + 8)):
|
||||
print(" 坏行 %d: %r" % (n + 1, lines[n][:64]))
|
||||
|
||||
lines[k:end] = NEW_LINES
|
||||
|
||||
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"")
|
||||
+ "\n".join(lines).encode("utf-8"))
|
||||
print(" ✓ 替换完成")
|
||||
|
||||
sys.path.insert(0, str(ROOT / "relay"))
|
||||
import admin # noqa: E402
|
||||
js = admin.ADMIN_JS
|
||||
bad = []
|
||||
for n, ln in enumerate(js.splitlines(), 1):
|
||||
s = ln.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
if s.count("'") % 2:
|
||||
bad.append((n, ln[:70]))
|
||||
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||
for n, ln in bad[:5]:
|
||||
print(" %4d %s" % (n, ln))
|
||||
return 0 if not bad else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 manager 页「更新」按钮那段 JS 的裸换行。
|
||||
|
||||
== 出了什么问题 ==
|
||||
|
||||
`ADMIN_JS` 是 Python 的**三引号字符串**。我在里面写:
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\n\n'
|
||||
+ '它会自己下载……\n'
|
||||
|
||||
本意是让 JS 收到 `\n`。但 Python 三引号里 `\n` 就是**真的换行**,
|
||||
于是插进 JS 变成:
|
||||
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||
(空行)
|
||||
'
|
||||
|
||||
**字符串字面量跨行,JS 语法非法**。
|
||||
|
||||
后果不是"这一句坏了",而是**整个 <script> 块解析失败** ——
|
||||
管理页上所有按钮(改码/删除/踢下线/全选)全部失灵。回归里的
|
||||
「管理页 JS 合法性」这一关就是专门抓这个的(当初加它就是因为
|
||||
踩过一次同样的坑)。
|
||||
|
||||
== 怎么修 ==
|
||||
|
||||
**不用 `\n`**。改成把文案放进数组,用 `String.fromCharCode(10)` 连起来:
|
||||
|
||||
const NL = String.fromCharCode(10);
|
||||
const msg = ['第一行', '', '第二行'].join(NL);
|
||||
|
||||
这样**完全不依赖转义层** —— 不管以后谁编辑这段、用哪种
|
||||
引号、经不经过别的脚本生成,都不会再变成裸换行。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
# ⚠ 这些字符串里必须保留**真的换行**(就是要匹配源文件里那个坏掉的样子)
|
||||
OLD = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?
|
||||
|
||||
'
|
||||
+ '它会自己下载、静默安装并重启,全程不弹窗口,
|
||||
'
|
||||
+ '中转地址、配对码和各项设置都不变。')) {
|
||||
return;
|
||||
}"""
|
||||
|
||||
NEW = """document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
// ⚠ 这里**不能用反斜杠 n** 拼多行文案。
|
||||
// ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成
|
||||
// **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,
|
||||
// 整个 script 块解析失败,管理页所有按钮全死。
|
||||
// (回归里的「管理页 JS 合法性」就是抓这个的。)
|
||||
//
|
||||
// 用 String.fromCharCode(10) 完全不依赖转义层,
|
||||
// 以后谁改这段都不会再踩。
|
||||
const NL = String.fromCharCode(10);
|
||||
const msg = ['让「' + name + '」现在更新到最新版?', '',
|
||||
'它会自己下载、静默安装并重启,全程不弹窗口,',
|
||||
'中转地址、配对码和各项设置都不变。'].join(NL);
|
||||
if (!confirm(msg)) {
|
||||
return;
|
||||
}"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 修管理页 JS 的裸换行")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ADMIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if "String.fromCharCode(10)" in text and OLD not in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
if OLD not in text:
|
||||
print(" ✗ 找不到坏掉的那段")
|
||||
i = text.find("data-update")
|
||||
print(text[max(0, i - 100):i + 700])
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
ADMIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 替换完成")
|
||||
|
||||
# 复核:ADMIN_JS 里不能再有跨行的字符串字面量
|
||||
sys.path.insert(0, str(ROOT / "relay"))
|
||||
for name in list(sys.modules):
|
||||
if name in ("admin", "relay"):
|
||||
del sys.modules[name]
|
||||
import admin # noqa: E402
|
||||
js = admin.ADMIN_JS
|
||||
bad = []
|
||||
for n, ln in enumerate(js.splitlines(), 1):
|
||||
s = ln.strip()
|
||||
if s.startswith("//"):
|
||||
continue
|
||||
if s.count("'") % 2:
|
||||
bad.append((n, ln[:70]))
|
||||
print(" 复核:单引号落单的行 = %d 个" % len(bad))
|
||||
for n, ln in bad[:5]:
|
||||
print(" %4d %s" % (n, ln))
|
||||
return 0 if not bad else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,384 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把语音播报改成**多条方案降级**,修 Win7 上用不了的问题。
|
||||
|
||||
== 用户反馈 ==
|
||||
|
||||
"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"
|
||||
|
||||
== 原来只有一条路,而且有几个 Win7 特有的坑 ==
|
||||
|
||||
powershell -NoProfile -WindowStyle Hidden -Command
|
||||
"Add-Type -AssemblyName System.Speech; $s = New-Object ...; $s.Speak('...')"
|
||||
|
||||
坑一:**`powershell` 只按名字找**
|
||||
Win7 的 powershell.exe 在 `%SystemRoot%\System32\WindowsPowerShell\v1.0\`。
|
||||
精简版系统、或者从服务/计划任务拉起时 PATH 里可能没有 ——
|
||||
找不到就**静默失败**(Popen 不报错,因为进程起来了,只是在里面失败)。
|
||||
|
||||
坑二:**Win7 默认 PowerShell 2.0,它按 ANSI 代码页解析 `-Command`**
|
||||
PS 3.0 起才按 UTF-16 处理。所以中文经 `-Command` 传进去在 PS 2.0 上
|
||||
很容易乱码 —— 表现是"念出来是乱码"或者干脆不念。
|
||||
|
||||
坑三:**`System.Speech` 需要 .NET Framework 3.0+**
|
||||
而系统自带的 **SAPI5**(`SAPI.SpVoice`)从 Windows XP 就有,
|
||||
**根本不需要 .NET**。绕开 .NET 更稳。
|
||||
|
||||
== 现在的做法:降级链 ==
|
||||
|
||||
按"依赖越少越优先"排:
|
||||
|
||||
1. **cscript + VBScript + SAPI.SpVoice**
|
||||
VBScript 从 Windows 98 就有,SAPI5 是系统组件 ——
|
||||
两个都不需要装东西,也不受 PowerShell 版本 / PATH / .NET 影响。
|
||||
**这是最可能在 Win7 上通的一条。**
|
||||
|
||||
2. **wscript + VBScript + SAPI.SpVoice**
|
||||
同上,换宿主(cscript 万一被安全软件拦了)。
|
||||
|
||||
3. **powershell(绝对路径)+ SAPI.SpVoice COM**
|
||||
绕开 .NET,但仍要 PowerShell。
|
||||
|
||||
4. **powershell(绝对路径)+ System.Speech**
|
||||
原来的做法,只是改用绝对路径。
|
||||
|
||||
5. **powershell(按 PATH)+ System.Speech**
|
||||
最原始的做法,留作最后兜底。
|
||||
|
||||
**一条通了就记住它**(模块级缓存),后面都直接走那条 ——
|
||||
不然每播一次都要把上面几条全试一遍,慢且难看。
|
||||
|
||||
== 顺手改进 ==
|
||||
|
||||
* `say()` 原来用 `Popen` 不等待 —— **失败了也返回"已播报"**。
|
||||
现在**等它结束并看退出码**,真失败会明确报错,不再骗人。
|
||||
代价是播报变成了同步的(要等念完)—— 但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
* VBScript 文件用 **mbcs(ANSI)** 编码写。写成 UTF-8 的话
|
||||
cscript 读出来是乱码 —— 这是 VBScript 的老规矩。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ACTIONS = ROOT / "windows" / "lanremote" / "actions.py"
|
||||
|
||||
# ---------------------------------------------------------------- 要替换的旧代码
|
||||
OLD_VOICES = ''' 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()]'''
|
||||
|
||||
OLD_SAY = ''' 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 '默认音色'})"'''
|
||||
|
||||
NEW = ''' # ------------------------------------------------------------------
|
||||
# 语音播报(系统自带 TTS)
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
|
||||
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
|
||||
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
|
||||
#
|
||||
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
|
||||
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
|
||||
|
||||
def _powershell_path(self) -> str:
|
||||
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
|
||||
|
||||
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
|
||||
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
|
||||
"""
|
||||
import shutil as _sh
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\\Windows")) / \\
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
if os.name == "nt" and cand.is_file():
|
||||
return str(cand)
|
||||
return _sh.which("powershell") or ""
|
||||
|
||||
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
|
||||
"""生成一个播报用的 .vbs。
|
||||
|
||||
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
|
||||
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
|
||||
"""
|
||||
import tempfile
|
||||
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
|
||||
if voice:
|
||||
# 按描述匹配音色。找不到就 silently 用默认 ——
|
||||
# 音色选不上不该让整条播报失败。
|
||||
lines.append('Dim i')
|
||||
lines.append('For i = 0 To v.GetVoices().Count - 1')
|
||||
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
|
||||
% voice.replace('"', '""'))
|
||||
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
|
||||
lines.append(' End If')
|
||||
lines.append('Next')
|
||||
if rate:
|
||||
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
|
||||
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
|
||||
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
|
||||
chunk = text.replace('"', '""').replace("\\r", " ").replace("\\n", " ")
|
||||
while chunk:
|
||||
piece, chunk = chunk[:400], chunk[400:]
|
||||
lines.append('v.Speak "%s"' % piece)
|
||||
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
|
||||
path.write_bytes(("\\r\\n".join(lines) + "\\r\\n").encode("mbcs",
|
||||
errors="replace"))
|
||||
return path
|
||||
|
||||
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
|
||||
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
|
||||
cmds = []
|
||||
vbs = None
|
||||
try:
|
||||
vbs = self._write_vbs(text, voice, rate)
|
||||
except Exception: # noqa: BLE001
|
||||
vbs = None
|
||||
if vbs is not None:
|
||||
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||
|
||||
ps = self._powershell_path()
|
||||
esc = text.replace("'", "''")
|
||||
if ps:
|
||||
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||
if voice:
|
||||
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||
"if ($o.GetDescription() -like '*%s*') { "
|
||||
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||
if rate:
|
||||
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sapi.append("$v.Speak('%s')" % esc)
|
||||
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sapi)]))
|
||||
|
||||
# 4:原来的做法,但用绝对路径
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||
"Hidden", "-Command", "".join(sp)]))
|
||||
|
||||
# 5:最原始的(PATH + System.Speech),兜底
|
||||
import shutil as _sh
|
||||
if _sh.which("powershell"):
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sp)]))
|
||||
return cmds
|
||||
|
||||
def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没有 .NET 3.0 的话
|
||||
System.Speech 那条路是列不出来的,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
out = []
|
||||
ps = self._powershell_path()
|
||||
if ps:
|
||||
script = ("$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if not out:
|
||||
# 退到 System.Speech 再列一次
|
||||
if ps:
|
||||
script = ("Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis"
|
||||
".SpeechSynthesizer).GetInstalledVoices()"
|
||||
" | ForEach-Object { $_.VoiceInfo.Name }")
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
return out
|
||||
|
||||
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||
|
||||
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
"""
|
||||
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))
|
||||
|
||||
cmds = self._tts_commands(text, chosen, speed)
|
||||
if not cmds:
|
||||
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||
"(没有 cscript 也没有 powershell?)")
|
||||
|
||||
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||
cached = globals().get("_TTS_LAST_OK")
|
||||
if cached:
|
||||
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||
|
||||
tried = []
|
||||
for name, cmd in cmds:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, timeout=180,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except FileNotFoundError:
|
||||
tried.append("%s(没有这个程序)" % name)
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
tried.append("%s(超时)" % name)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append("%s(%s)" % (name, type(exc).__name__))
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
globals()["_TTS_LAST_OK"] = name
|
||||
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
|
||||
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
|
||||
|
||||
raise ActionError("语音播报失败,试过:" + ";".join(tried))'''
|
||||
|
||||
NEW_VOICES = ''' def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
ps = self._powershell_path()
|
||||
if not ps:
|
||||
return []
|
||||
out = []
|
||||
for script in (
|
||||
# 优先 SAPI5 COM
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||
# 退到 System.Speech
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||
):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if out:
|
||||
return out
|
||||
return []'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 语音播报:改成多条方案降级")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = ACTIONS.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "_tts_commands" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
missing = [k for k, v in {"list_voices": OLD_VOICES, "say": OLD_SAY}.items()
|
||||
if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点就位")
|
||||
|
||||
t = t.replace(OLD_VOICES, NEW_VOICES, 1)
|
||||
t = t.replace(OLD_SAY, NEW, 1)
|
||||
|
||||
ACTIONS.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ list_voices 改用 SAPI5 COM(+ System.Speech 兜底)")
|
||||
print(" ✓ say 改成降级链 + 等待退出码 + 记住可用的那条")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''修 verify_all.ps1 里被转义弄坏的那段。
|
||||
|
||||
== 弄坏了什么 ==
|
||||
|
||||
我用 `python -c "..."` 传的脚本里带了 `\$stageOut`,
|
||||
PowerShell 先解析了一遍,把 `$stageOut` 变成了 `\`,
|
||||
结果插进去的是:
|
||||
|
||||
\ = & python (Join-Path \ "_tests\test_admin_update.py") 2>&1
|
||||
Show-Fails Record "后台更新按钮" (\ -eq 0)
|
||||
|
||||
`Show-Fails` 和 `Record` 挤在一行,变量名也没了。
|
||||
|
||||
== 教训 ==
|
||||
|
||||
凡是**要写进文件的内容里带 `$`**,一律写成 .py 文件再跑,
|
||||
不要用 `python -c "..."` 穿过 PowerShell。这个坑今天踩了太多次。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VERIFY = ROOT / "_tests" / "verify_all.ps1"
|
||||
|
||||
# 被弄坏的那两段
|
||||
BROKEN = '''\\ = & python (Join-Path \\ "_tests\\test_admin_update.py") 2>&1
|
||||
Show-Fails Record "后台更新按钮" (\\ -eq 0)
|
||||
|
||||
\\ = & python (Join-Path \\ "_tests\\test_notice_options.py") 2>&1
|
||||
Show-Fails Record "弹窗位置和大小" (\\ -eq 0)'''
|
||||
|
||||
FIXED = '''$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "后台更新按钮" ($LASTEXITCODE -eq 0)
|
||||
|
||||
$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "弹窗位置和大小" ($LASTEXITCODE -eq 0)'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = VERIFY.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
|
||||
if FIXED in text:
|
||||
print(" 已经修好了")
|
||||
return 0
|
||||
|
||||
if BROKEN not in text:
|
||||
print(" ✗ 找不到被弄坏的那段。当前 3a12 附近的内容:")
|
||||
i = text.find("3a12")
|
||||
print(text[max(0, i - 50):i + 700])
|
||||
return 1
|
||||
|
||||
text = text.replace(BROKEN, FIXED, 1)
|
||||
|
||||
# 顺手把 "3a12/7" 改成 "3a12/9"(关卡数变了)
|
||||
text = text.replace('Banner "3a12/7', 'Banner "3a12/9')
|
||||
|
||||
VERIFY.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ 修好了")
|
||||
|
||||
back = VERIFY.read_text(encoding="utf-8-sig")
|
||||
for token in ('$stageOut = & python (Join-Path $root "_tests\\test_admin_update.py")',
|
||||
'$stageOut = & python (Join-Path $root "_tests\\test_notice_options.py")',
|
||||
'Record "后台更新按钮"',
|
||||
'Record "弹窗位置和大小"'):
|
||||
ok = token in back
|
||||
print(" [%s] %s" % ("PASS" if ok else "FAIL", token[:58]))
|
||||
if not ok:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,7 +17,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
VERSION = "2.1.6"
|
||||
VERSION = "2.1.8"
|
||||
NAME = f"ykxmz-{VERSION}"
|
||||
|
||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''快捷栏:按钮做大 + **4 格全部可自定义**。
|
||||
|
||||
== 用户这次的反馈(原话)==
|
||||
|
||||
"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点,
|
||||
然后呢,这个每每个四格算换一下吧,第一个第二个第三个第四个都能换东西"
|
||||
|
||||
== 两个改动 ==
|
||||
|
||||
1. **按钮做大**:30dp -> 46dp,字号 11 -> 14。
|
||||
30dp 在手表上确实太小了 —— 表盘本来就小,手指按上去一半在栏外。
|
||||
46dp 接近 Android 建议的最小可点区域(48dp)。
|
||||
|
||||
⚠ 栏高和面板 topMargin 必须**用同一个常量**。分开写的话
|
||||
改一个忘一个,表现是"内容要么被栏盖住,要么上面空一条"。
|
||||
|
||||
2. **第 4 格也变成可自定义**。
|
||||
|
||||
用户最早说"其中有一个按钮不能改,功能就是一键返回桌面",
|
||||
现在改主意了:"第一个第二个第三个第四个都能换东西"。
|
||||
|
||||
所以「显示桌面」从"固定的那一格"变成**普通可选动作**,
|
||||
而且默认就放在第 4 格 —— 这样默认行为跟以前一样
|
||||
(最后一个还是显示桌面),但用户想换也能换。
|
||||
|
||||
实现上就是把 QUICK_CUSTOM_SLOTS 从 3 改成 4,
|
||||
再把 {"desktop","显示桌面"} 加进 QUICK_ACTIONS,
|
||||
然后去掉所有"第 4 格特殊"的分支。
|
||||
'''
|
||||
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"
|
||||
|
||||
PAIRS = [
|
||||
# ---- 1. 格子数 3 -> 4 ----
|
||||
(' private static final int QUICK_CUSTOM_SLOTS = 3; // 固定那个不算在内',
|
||||
''' /** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||
private static final int QUICK_SLOTS = 4;
|
||||
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||
private static final int QUICK_BAR_DP = 46;'''),
|
||||
|
||||
# ---- 2. 加 desktop 到可选动作 ----
|
||||
(''' {"image", "投图到电脑"},
|
||||
};''',
|
||||
''' {"image", "投图到电脑"},
|
||||
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||
{"desktop", "显示桌面"},
|
||||
};'''),
|
||||
|
||||
# ---- 3. 默认值 3 -> 4 格 ----
|
||||
(''' private String[] quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' private String[] quickSlots =
|
||||
new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 4. buildQuickBar:格数、高度、字号 ----
|
||||
(''' for (int i = 0; i < QUICK_CUSTOM_SLOTS + 1; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(11f);''',
|
||||
''' for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(14f); // 11 太小,用户说"按不到"'''),
|
||||
|
||||
(''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30));''',
|
||||
''' FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));'''),
|
||||
|
||||
# ---- 5. refreshQuickBar:不再有"固定那一格" ----
|
||||
(''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = (i < QUICK_CUSTOM_SLOTS) ? quickSlots[i] : "desktop";''',
|
||||
''' boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = quickSlots[i];'''),
|
||||
|
||||
# ---- 6. onQuickClick:不再有"固定那一格" ----
|
||||
(''' /** 点第 slot 个按钮。0..2 是自定义,3 是那个固定的显示桌面。 */
|
||||
private void onQuickClick(int slot) {
|
||||
String id = (slot < QUICK_CUSTOM_SLOTS) ? quickSlots[slot] : "desktop";
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}''',
|
||||
''' /** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||
private void onQuickClick(int slot) {
|
||||
if (slot < 0 || slot >= quickSlots.length) {
|
||||
return;
|
||||
}
|
||||
String id = quickSlots[slot];
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}'''),
|
||||
|
||||
# ---- 7. loadQuickSlots:4 格 + 允许 desktop ----
|
||||
(''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx"}
|
||||
: raw.split(",", -1);''',
|
||||
''' String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||
: raw.split(",", -1);'''),
|
||||
|
||||
(''' String[] out = new String[QUICK_CUSTOM_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_CUSTOM_SLOTS) {
|
||||
break;
|
||||
}
|
||||
if (id != null && !id.isEmpty() && !"desktop".equals(id)
|
||||
&& isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx"};
|
||||
for (int i = 0; i < QUICK_CUSTOM_SLOTS; i++) {''',
|
||||
''' String[] out = new String[QUICK_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_SLOTS) {
|
||||
break;
|
||||
}
|
||||
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {'''),
|
||||
|
||||
# ---- 8. applyPanelTopMargin:用同一个常量 ----
|
||||
(''' flp.topMargin = dp(30); // 跟 quickBar 的高度一致''',
|
||||
''' // 用同一个常量 —— 栏高改了这里自动跟着改
|
||||
flp.topMargin = dp(QUICK_BAR_DP);'''),
|
||||
|
||||
# ---- 9. onCreate 里 addView 的高度 ----
|
||||
(''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));''',
|
||||
''' root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));'''),
|
||||
|
||||
# ---- 10. 设置页:4 格 + 去掉"固定改不了"的说法 ----
|
||||
(''' settingsExtraBox.addView(hintText("顶部那条栏,最多 4 个按钮。"
|
||||
+ "最后一个「显示桌面」是固定的、改不了;"
|
||||
+ "前三个点下面自己选。(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_CUSTOM_SLOTS; slot++) {''',
|
||||
''' settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||
+ "**每一个都能换**,点下面自己选。"
|
||||
+ "(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_SLOTS; slot++) {'''),
|
||||
|
||||
(''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx"};''',
|
||||
''' settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};'''),
|
||||
|
||||
# ---- 11. 设置页标题也说明 4 格都能换 ----
|
||||
(''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");''',
|
||||
''' private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");'''),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 快捷栏:按钮做大 + 4 格全部可自定义")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "QUICK_SLOTS = 4" in t:
|
||||
print(" 已经改过了")
|
||||
return 0
|
||||
|
||||
# 先全部校验,避免改一半
|
||||
missing = []
|
||||
for i, (old, _new) in enumerate(PAIRS, 1):
|
||||
if old not in t:
|
||||
missing.append((i, old.strip().splitlines()[0][:66]))
|
||||
if missing:
|
||||
print(" ✗ 这些锚点找不到:")
|
||||
for i, s in missing:
|
||||
print(" #%d %s" % (i, s))
|
||||
return 1
|
||||
print(" ✓ %d 个锚点全部就位" % len(PAIRS))
|
||||
|
||||
for i, (old, new) in enumerate(PAIRS, 1):
|
||||
t = t.replace(old, new, 1)
|
||||
|
||||
# 收尾:把残留的 QUICK_CUSTOM_SLOTS 全换掉(应该已经没有了)
|
||||
left = t.count("QUICK_CUSTOM_SLOTS")
|
||||
if left:
|
||||
print(" ⚠ 还有 %d 处 QUICK_CUSTOM_SLOTS 残留,一并替换" % left)
|
||||
t = t.replace("QUICK_CUSTOM_SLOTS", "QUICK_SLOTS")
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 改完了")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+274
-212
@@ -1,212 +1,274 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||
|
||||
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
|
||||
|
||||
⚠ 环境注意:
|
||||
我这个 shell **不是管理员**,而安装器会提升。
|
||||
所以所有"清理"动作都要交给**安装器/卸载器**自己做,
|
||||
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
|
||||
|
||||
补丁历史(这轮修的四个真 bug):
|
||||
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
|
||||
-> 后面的删自启/删数据全没执行
|
||||
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
|
||||
-> 改成 PrepareToInstall 里自己 taskkill /F
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def _newest_setup() -> Path:
|
||||
"""自动找 dist 下最新的安装包。
|
||||
|
||||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||||
|
||||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
|
||||
版本号每次发版都在变,写死必然过期。
|
||||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||||
|
||||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||||
"""
|
||||
cands = []
|
||||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||||
if m:
|
||||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||||
if not cands:
|
||||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||||
cands.sort(key=lambda t: t[0])
|
||||
return cands[-1][1]
|
||||
|
||||
|
||||
SETUP = _newest_setup()
|
||||
APP = Path(r"C:\Program Files\ykxmz")
|
||||
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||
EXE = APP / "ykxmz.exe"
|
||||
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
||||
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
|
||||
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
|
||||
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
|
||||
RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
CODE = "517284"
|
||||
|
||||
passed = 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))
|
||||
|
||||
|
||||
def ps(command: str, timeout: int = 300) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", command],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
encoding="utf-8", errors="replace")
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def run_bat(lines: list, timeout: int = 600) -> tuple:
|
||||
"""把命令写进 .bat 再跑。
|
||||
|
||||
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
|
||||
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
|
||||
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
|
||||
"""
|
||||
bat = ROOT / "_build" / "_run.bat"
|
||||
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
|
||||
errors="replace")
|
||||
start = time.perf_counter()
|
||||
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
encoding="gbk", errors="replace")
|
||||
return r.returncode, time.perf_counter() - start, (r.stdout or "")
|
||||
|
||||
|
||||
def install() -> tuple:
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
|
||||
'/CODE=%s /TASKS=autostart' % (
|
||||
SETUP, ROOT / "_build" / "setup.log", CODE),
|
||||
])
|
||||
|
||||
|
||||
def uninstall() -> tuple:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if not found:
|
||||
return -1, 0.0, ""
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
|
||||
])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check("安装包在", SETUP.is_file(), str(SETUP))
|
||||
if not SETUP.is_file():
|
||||
return 1
|
||||
|
||||
# ---------------- 安装 ----------------
|
||||
print()
|
||||
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||
code, elapsed, _out = install()
|
||||
check("安装器退出码 0", code == 0, "exit=%s" % code)
|
||||
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(8)
|
||||
|
||||
print()
|
||||
print(" 二、文件与数据")
|
||||
check("ykxmz.exe 在", EXE.is_file())
|
||||
check("_internal 在", (APP / "_internal").is_dir())
|
||||
check("数据目录在", DATA.is_dir())
|
||||
settings = DATA / "data" / "settings.json"
|
||||
check("settings.json 生成了", settings.is_file())
|
||||
if settings.is_file():
|
||||
saved = json.loads(settings.read_text(encoding="utf-8"))
|
||||
got = saved.get("pairing_code")
|
||||
check("配对码正确写入", got == CODE, "实际 %r" % got)
|
||||
|
||||
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
|
||||
"-like '*Users*' } | Select-Object -First 1 "
|
||||
"-ExpandProperty FileSystemRights" % (DATA / "data"))
|
||||
check("普通用户对数据目录可写",
|
||||
any(w in acl for w in ("Modify", "FullControl", "Write")),
|
||||
acl or "(读不到)")
|
||||
|
||||
print()
|
||||
print(" 三、--status")
|
||||
status = ps("& '%s' --status" % EXE, timeout=120)
|
||||
check("--status 显示配对码", CODE in status, status[:160])
|
||||
for line in status.splitlines()[:7]:
|
||||
print(" " + line.strip())
|
||||
|
||||
print()
|
||||
print(" 四、进程与端口")
|
||||
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
|
||||
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
|
||||
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
|
||||
"Measure-Object).Count")
|
||||
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
|
||||
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
|
||||
|
||||
# ---------------- 卸载 ----------------
|
||||
print()
|
||||
print(" 五、静默卸载")
|
||||
code, elapsed, _out = uninstall()
|
||||
check("卸载器执行了", code != -1, "找不到卸载器")
|
||||
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(10)
|
||||
|
||||
print()
|
||||
print(" 六、卸载是否干净")
|
||||
check("安装目录已删",
|
||||
not APP.is_dir(),
|
||||
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
|
||||
if APP.is_dir() else "")
|
||||
check("数据目录已删", not DATA.is_dir())
|
||||
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
|
||||
"{ 'exists' }" % REG))
|
||||
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
|
||||
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已清掉", not still, still or "")
|
||||
|
||||
print()
|
||||
print("=" * 56)
|
||||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''安装包完整验收:装 → 验 → 卸 → 再验。
|
||||
|
||||
用户要求"所有东西做完记得先测试能不能用",所以每一步都实测。
|
||||
|
||||
⚠ 环境注意:
|
||||
我这个 shell **不是管理员**,而安装器会提升。
|
||||
所以所有"清理"动作都要交给**安装器/卸载器**自己做,
|
||||
不能靠 PowerShell 杀进程或删 Program Files(会拒绝访问)。
|
||||
|
||||
补丁历史(这轮修的四个真 bug):
|
||||
1. --uninstall 里 Popen 不重定向 stdio -> capture_output 等管道 EOF -> "卡死"
|
||||
2. --uninstall 里 input() 无 stdin 时永久阻塞 -> 控制面板卸载卡死
|
||||
3. _kill_running 用 taskkill /IM ykxmz.exe 把自己也杀了
|
||||
-> 后面的删自启/删数据全没执行
|
||||
4. 安装器关不掉以管理员身份跑着的旧实例 -> "无法关闭应用程序" -> 安装回滚
|
||||
-> 改成 PrepareToInstall 里自己 taskkill /F
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
|
||||
|
||||
def _newest_setup() -> Path:
|
||||
"""自动找 dist 下最新的安装包。
|
||||
|
||||
⚠ 这里原来是写死的 `ykxmz-setup-1.8.2.exe`。后果很隐蔽:
|
||||
|
||||
测试去装一个**根本不存在的旧版本包** -> 安装静默失败 ->
|
||||
读到的还是上一次残留的状态 -> 报出一堆莫名其妙的失败
|
||||
("配对码实际是 xxx"、"版本显示 v1.8.2")。
|
||||
|
||||
版本号每次发版都在变,写死必然过期。
|
||||
`bump_version_generic.py` 更新了别的测试,唯独漏了这个文件。
|
||||
|
||||
改成按版本号排序取最新的 —— 一劳永逸,以后不用再维护。
|
||||
"""
|
||||
cands = []
|
||||
for p in (ROOT / "dist").glob("ykxmz-setup-*.exe"):
|
||||
# 文件名里抠版本号,按数字段排序(不能按字符串,否则 1.9.0 > 2.1.4)
|
||||
m = re.search(r"ykxmz-setup-(\d+)\.(\d+)\.(\d+)\.exe$", p.name)
|
||||
if m:
|
||||
cands.append((tuple(int(x) for x in m.groups()), p))
|
||||
if not cands:
|
||||
return ROOT / "dist" / "ykxmz-setup-不存在.exe"
|
||||
cands.sort(key=lambda t: t[0])
|
||||
return cands[-1][1]
|
||||
|
||||
|
||||
SETUP = _newest_setup()
|
||||
APP = Path(r"C:\Program Files\ykxmz")
|
||||
DATA = Path(r"C:\ProgramData\ykxmz")
|
||||
EXE = APP / "ykxmz.exe"
|
||||
REG = (r"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||
r"\{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1")
|
||||
# ⚠ PowerShell 的注册表 provider 路径**必须带冒号**(HKCU:\...)。
|
||||
# 写成 HKCU\... 不是合法路径,Get-ItemProperty 静默返回空,
|
||||
# 于是"开机自启"这一项永远误报失败。(这个坑我自己踩了一次。)
|
||||
RUN_KEY = r"HKCU:\Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
CODE = "517284"
|
||||
|
||||
passed = 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))
|
||||
|
||||
|
||||
def ps(command: str, timeout: int = 300) -> str:
|
||||
r = subprocess.run(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", command],
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
encoding="utf-8", errors="replace")
|
||||
return (r.stdout or "").strip()
|
||||
|
||||
|
||||
def run_bat(lines: list, timeout: int = 600) -> tuple:
|
||||
"""把命令写进 .bat 再跑。
|
||||
|
||||
为什么不直接 subprocess.run(["cmd", "/c", "..."]):
|
||||
引号会被 Python 和 cmd 来回吃掉,实测 0.0 秒就返回 exit=1。
|
||||
写成 .bat 文件最稳,而且和学校实际的批量部署方式一致。
|
||||
"""
|
||||
bat = ROOT / "_build" / "_run.bat"
|
||||
bat.write_text("\r\n".join(["@echo off"] + lines), encoding="gbk",
|
||||
errors="replace")
|
||||
start = time.perf_counter()
|
||||
r = subprocess.run(["cmd", "/c", str(bat)], capture_output=True,
|
||||
text=True, timeout=timeout,
|
||||
encoding="gbk", errors="replace")
|
||||
return r.returncode, time.perf_counter() - start, (r.stdout or "")
|
||||
|
||||
|
||||
def install() -> tuple:
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART '
|
||||
'/LOG="%s" /DIR="C:\\Program Files\\ykxmz" '
|
||||
'/CODE=%s /TASKS=autostart' % (
|
||||
SETUP, ROOT / "_build" / "setup.log", CODE),
|
||||
])
|
||||
|
||||
|
||||
def _clean_previous() -> None:
|
||||
"""把上一次安装/测试的残留清掉,让这一轮从零开始。
|
||||
|
||||
要清三样:
|
||||
1. 先**卸载**(走正规卸载器,能清注册表和自启项)
|
||||
2. 删安装目录
|
||||
3. 删数据目录 —— 卸载器**故意不删**它(存着用户的配置),
|
||||
所以必须手动删。**这一步就是修 `(旧的残留值)` 那个假 bug 的关键。**
|
||||
|
||||
每一步都 best-effort:清不掉也不该让测试崩,后面会有断言报出来。
|
||||
"""
|
||||
try:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if found and found.strip():
|
||||
subprocess.run(["cmd", "/c", '"%s" /VERYSILENT /SUPPRESSMSGBOXES '
|
||||
'/NORESTART' % found.strip()],
|
||||
capture_output=True, timeout=180)
|
||||
time.sleep(4)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# 万一还有进程占着文件,先杀掉
|
||||
try:
|
||||
ps("Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Stop-Process -Force -ErrorAction SilentlyContinue")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1)
|
||||
for target in (APP, DATA):
|
||||
try:
|
||||
subprocess.run(["cmd", "/c", 'rmdir /s /q "%s"' % target],
|
||||
capture_output=True, timeout=120)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def uninstall() -> tuple:
|
||||
found = ps("Get-ChildItem '%s\\unins*.exe' -ErrorAction SilentlyContinue "
|
||||
"| Select-Object -First 1 -ExpandProperty FullName" % APP)
|
||||
if not found:
|
||||
return -1, 0.0, ""
|
||||
return run_bat([
|
||||
'"%s" /VERYSILENT /SUPPRESSMSGBOXES /NORESTART' % found.strip(),
|
||||
])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
check("安装包在", SETUP.is_file(), str(SETUP))
|
||||
if not SETUP.is_file():
|
||||
return 1
|
||||
|
||||
# ---------------- 装前先清干净 ----------------
|
||||
#
|
||||
# ⚠ 这一步是必须的,不是保险起见。
|
||||
#
|
||||
# 踩过的坑:测试报 `配对码正确写入 实际 '(旧的残留值)'`,
|
||||
# 而 `(旧的残留值)` 是**上一次测试遗留**的配对码 ——
|
||||
# 数据目录 `C:\ProgramData\ykxmz\data` 在卸载时**不会**被删掉
|
||||
# (见 .iss 的说明:那里存着用户的中转地址、配对码等,卸载也不该删),
|
||||
# 所以机器上会留着上一轮的 settings.json。
|
||||
#
|
||||
# 这次装的是 `/CODE=517284`,正常会覆盖 —— 但如果在**上一次
|
||||
# 安装的进程还占着端口/文件**的时候装,`--code` 那一步可能失败,
|
||||
# 于是检查就读到了旧值。表现成"安装包有 bug",其实是测试
|
||||
# **没把环境擦干净**。
|
||||
#
|
||||
# 所以:装之前先把两个目录删掉,让测试从零开始。
|
||||
print()
|
||||
print(" 零、清理上一次的残留")
|
||||
_clean_previous()
|
||||
check("安装目录已清", not APP.is_dir(), str(APP))
|
||||
check("数据目录已清", not DATA.is_dir(), str(DATA))
|
||||
check("没有残留进程",
|
||||
not ps("(Get-Process ykxmz -ErrorAction SilentlyContinue "
|
||||
"| Measure-Object).Count").strip().strip("0").strip())
|
||||
|
||||
# ---------------- 安装 ----------------
|
||||
print()
|
||||
print(" 一、静默安装(配对码 %s)" % CODE)
|
||||
code, elapsed, _out = install()
|
||||
check("安装器退出码 0", code == 0, "exit=%s" % code)
|
||||
check("安装没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(8)
|
||||
|
||||
print()
|
||||
print(" 二、文件与数据")
|
||||
check("ykxmz.exe 在", EXE.is_file())
|
||||
check("_internal 在", (APP / "_internal").is_dir())
|
||||
check("数据目录在", DATA.is_dir())
|
||||
settings = DATA / "data" / "settings.json"
|
||||
check("settings.json 生成了", settings.is_file())
|
||||
if settings.is_file():
|
||||
saved = json.loads(settings.read_text(encoding="utf-8"))
|
||||
got = saved.get("pairing_code")
|
||||
check("配对码正确写入", got == CODE, "实际 %r" % got)
|
||||
|
||||
acl = ps("(Get-Acl '%s').Access | Where-Object { $_.IdentityReference "
|
||||
"-like '*Users*' } | Select-Object -First 1 "
|
||||
"-ExpandProperty FileSystemRights" % (DATA / "data"))
|
||||
check("普通用户对数据目录可写",
|
||||
any(w in acl for w in ("Modify", "FullControl", "Write")),
|
||||
acl or "(读不到)")
|
||||
|
||||
print()
|
||||
print(" 三、--status")
|
||||
status = ps("& '%s' --status" % EXE, timeout=120)
|
||||
check("--status 显示配对码", CODE in status, status[:160])
|
||||
for line in status.splitlines()[:7]:
|
||||
print(" " + line.strip())
|
||||
|
||||
print()
|
||||
print(" 四、进程与端口")
|
||||
count = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("被控端在跑", count.strip().splitlines()[-1:] != ["0"], count)
|
||||
listen = ps("(Get-NetTCPConnection -State Listen -ErrorAction "
|
||||
"SilentlyContinue | Where-Object { $_.LocalPort -eq 8765 } | "
|
||||
"Measure-Object).Count")
|
||||
check("8765 在监听", listen.strip().splitlines()[-1:] != ["0"], listen)
|
||||
run_value = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已设置", "ykxmz" in run_value.lower(), run_value or "(无)")
|
||||
|
||||
# ---------------- 卸载 ----------------
|
||||
print()
|
||||
print(" 五、静默卸载")
|
||||
code, elapsed, _out = uninstall()
|
||||
check("卸载器执行了", code != -1, "找不到卸载器")
|
||||
check("卸载没卡死", elapsed < 120, "%.1f 秒" % elapsed)
|
||||
print(" %.1f 秒" % elapsed)
|
||||
time.sleep(10)
|
||||
|
||||
print()
|
||||
print(" 六、卸载是否干净")
|
||||
check("安装目录已删",
|
||||
not APP.is_dir(),
|
||||
"还剩: %s" % ", ".join(p.name for p in APP.iterdir())
|
||||
if APP.is_dir() else "")
|
||||
check("数据目录已删", not DATA.is_dir())
|
||||
check("注册表卸载项已清", REG not in ps("if (Test-Path '%s') "
|
||||
"{ 'exists' }" % REG))
|
||||
left = ps("(Get-Process ykxmz -ErrorAction SilentlyContinue | "
|
||||
"Measure-Object).Count")
|
||||
check("进程已退出", left.strip().splitlines()[-1:] == ["0"], left)
|
||||
still = ps("(Get-ItemProperty '%s' -Name ykxmz -ErrorAction "
|
||||
"SilentlyContinue).ykxmz" % RUN_KEY)
|
||||
check("开机自启已清掉", not still, still or "")
|
||||
|
||||
print()
|
||||
print("=" * 56)
|
||||
print(" 通过 %d 项,失败 %d 项" % (passed, failed))
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''后台「更新」按钮接上:点一下让那台电脑自己静默升级。
|
||||
|
||||
== 链路 ==
|
||||
|
||||
管理页点「更新」
|
||||
-> POST /v1/admin/update_device {deviceId}
|
||||
-> 中转把 {"cmd":"update","action":"start"} 塞进该设备的 to_agent 队列
|
||||
-> 被控端下一次长轮询拿到它
|
||||
-> 走 server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
|
||||
|
||||
也就是说:**后台和手表用的是同一条命令**,只是入口不同。
|
||||
这样不用给"后台触发"单独写一套逻辑(少一套就少一处不一致)。
|
||||
|
||||
== 为什么只对在线设备开放 ==
|
||||
|
||||
被控端是靠**长轮询**取命令的。设备离线时命令只会挂在队列里,
|
||||
等它什么时候上线才执行 —— 而那时用户可能早就忘了自己点过,
|
||||
突然重启会莫名其妙。
|
||||
|
||||
所以离线设备直接拒绝,并在按钮上就置灰(admin.py 里已经这么做了)。
|
||||
这样"点了没反应"变成"按钮本来就是灰的",用户知道为什么。
|
||||
|
||||
== 队列上限 ==
|
||||
|
||||
中转的队列有字节上限(MAX_QUEUE_BYTES),超了会丢最旧的。
|
||||
一条更新命令才几十字节,不会撑爆 —— 但**重复点会堆积**,
|
||||
所以这里先查一下队列里是不是已经有更新命令了,有就不再塞。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
RELAY = ROOT / "relay" / "relay.py"
|
||||
ADMIN = ROOT / "relay" / "admin.py"
|
||||
|
||||
RELAY_ANCHOR = ''' self._json(404, {"ok": False, "error": "没有这个管理接口"})
|
||||
return'''
|
||||
|
||||
RELAY_ADD = ''' # ---- 让某台电脑自己静默升级 ----
|
||||
#
|
||||
# 和手表那边用的是**同一条命令**(cmd=update, action=start),
|
||||
# 只是入口不同 —— 不用给"后台触发"单独写一套逻辑。
|
||||
if path == "/v1/admin/update_device":
|
||||
body = self._json_body()
|
||||
did = str(body.get("deviceId") or "")
|
||||
device = RELAY.device_by_id(did)
|
||||
if device is None:
|
||||
self._json(404, {"ok": False,
|
||||
"error": "找不到这台设备"})
|
||||
return
|
||||
if not device.online():
|
||||
# 离线时命令只会挂在队列里,等它上线才执行 ——
|
||||
# 那时用户早忘了,突然重启很莫名其妙。所以直接拒。
|
||||
self._json(400, {"ok": False,
|
||||
"error": "设备离线,连不上它"})
|
||||
return
|
||||
# 别重复塞:队列里已经有更新命令就跳过
|
||||
payload = b'{"cmd":"update","action":"start"}\\n'
|
||||
if payload in bytes(device.to_agent):
|
||||
self._json(200, {"ok": True,
|
||||
"message": "这台电脑的更新已经在排队了"})
|
||||
return
|
||||
try:
|
||||
device.push_to_agent(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._json(500, {"ok": False,
|
||||
"error": "下发失败:%s" % exc})
|
||||
return
|
||||
self._json(200, {
|
||||
"ok": True,
|
||||
"message": "已让这台电脑开始更新,它会自己下载、"
|
||||
"静默安装并重启,设置和配对码不变",
|
||||
})
|
||||
return
|
||||
|
||||
''' + RELAY_ANCHOR
|
||||
|
||||
JS_ANCHOR = """document.querySelectorAll('button.danger[data-device]')"""
|
||||
|
||||
JS_ADD = '''// ---- 「更新」按钮:让那台电脑自己静默升级 ----
|
||||
//
|
||||
// 和手表那边走的是同一条命令,只是入口不同。
|
||||
// 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。
|
||||
document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
if (!confirm('让「' + name + '」现在更新到最新版?\\n\\n'
|
||||
+ '它会自己下载、静默安装并重启,全程不弹窗口,\\n'
|
||||
+ '中转地址、配对码和各项设置都不变。')) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
const old = btn.textContent;
|
||||
btn.textContent = '下发中…';
|
||||
try {
|
||||
const r = await post('/v1/admin/update_device',
|
||||
{ deviceId: btn.dataset.update });
|
||||
toast((r && r.message) || '已下发更新命令', 'ok');
|
||||
// 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化)
|
||||
} catch (e) {
|
||||
toast('下发失败:' + (e && e.message ? e.message : e), 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
''' + JS_ANCHOR
|
||||
|
||||
|
||||
def patch(path: Path, old: str, new: str, what: str) -> bool:
|
||||
raw = path.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
text = raw.decode("utf-8-sig")
|
||||
if new.split("\n")[0] in text or "update_device" in text:
|
||||
print(" %s 已经加过了" % what)
|
||||
return True
|
||||
if old not in text:
|
||||
print(" ✗ %s 找不到锚点" % what)
|
||||
return False
|
||||
text = text.replace(old, new, 1)
|
||||
path.write_bytes((b"\xef\xbb\xbf" if bom else b"") + text.encode("utf-8"))
|
||||
print(" ✓ %s" % what)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 后台「更新」按钮接通")
|
||||
print("=" * 58)
|
||||
print()
|
||||
ok1 = patch(RELAY, RELAY_ANCHOR, RELAY_ADD,
|
||||
"relay.py 加了 /v1/admin/update_device")
|
||||
ok2 = patch(ADMIN, JS_ANCHOR, JS_ADD, "admin.py 加了 data-update 的 JS 处理")
|
||||
return 0 if (ok1 and ok2) else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''把快捷栏接进 onCreate,并补上缺的 isConnected()。
|
||||
|
||||
== 补什么 ==
|
||||
|
||||
1. **isConnected()** —— 我写快捷栏时调用了它,但**它根本不存在**。
|
||||
Java 编译器会报 "cannot find symbol",整个 APK 编不出来。
|
||||
(教训:写之前应该先 grep 一遍依赖。这次是编译时才发现的。)
|
||||
|
||||
2. **把 buildQuickBar() 挂进 onCreate** —— 光有方法不 addView 是白搭,
|
||||
这个坑项目里踩过(注释里写着"设置页第一版就漏了这一行,
|
||||
表现是点设置进去全白")。
|
||||
|
||||
3. **给 5 个面板加 topMargin** —— 快捷栏贴在最上层,不加边距的话
|
||||
它会盖住每个页面顶部的内容(比如控制页第一行的状态文字)。
|
||||
|
||||
4. **loadQuickSlots()** —— 启动时读用户的选择。
|
||||
|
||||
5. **设置页里加一段"快捷栏"** —— 用户要求"栏里的按钮可以在设置页
|
||||
自定义"。没有这个入口,7 个动作就只能用默认那 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"
|
||||
|
||||
# ---------------- 1. isConnected ----------------
|
||||
CONN_ANCHOR = " private boolean ensureConnected() {"
|
||||
CONN_METHOD = ''' /**
|
||||
* 当前连没连上被控端。
|
||||
*
|
||||
* <p>⚠ 这个方法是我加快捷栏时才补的 —— 之前代码里到处写
|
||||
* `client.isConnected()`,我照着直觉调了个 `isConnected()`,
|
||||
* 结果编译才发现它不存在。现在包一层,以后判断连接状态统一走这里
|
||||
* (空指针也一并挡住了)。
|
||||
*/
|
||||
private boolean isConnected() {
|
||||
return client != null && client.isConnected();
|
||||
}
|
||||
|
||||
'''
|
||||
|
||||
# ---------------- 2. onCreate 里挂栏 ----------------
|
||||
ONCREATE_ANCHOR = """ setContentView(root);"""
|
||||
|
||||
ONCREATE_NEW = """ // ================= 顶部快捷栏 =================
|
||||
//
|
||||
// 用户要求:"不管干什么,上面都有一栏"。
|
||||
//
|
||||
// ⚠ 必须**在 5 个面板之后** addView —— FrameLayout 里后加的在上层。
|
||||
// 加在前面的话会被面板整个盖住(面板是 MATCH_PARENT)。
|
||||
loadQuickSlots();
|
||||
buildQuickBar();
|
||||
buildMousePanelIfNeeded();
|
||||
root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(30)));
|
||||
root.addView(mousePanel = mousePanelHolder(), new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
setContentView(root);"""
|
||||
|
||||
# ---------------- 3. 面板 topMargin ----------------
|
||||
MARGIN_NOTE = """ /**
|
||||
* 给面板顶部留出快捷栏的高度。
|
||||
*
|
||||
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
||||
* 面板内容如果不缩进就会被它盖住顶部一截。
|
||||
*
|
||||
* <p>集中在这里改,是因为"每个面板各写一遍 dp(30)"很容易漏一个 ——
|
||||
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
||||
*/
|
||||
private void applyPanelTopMargin(View panel) {
|
||||
if (panel == null) {
|
||||
return;
|
||||
}
|
||||
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
||||
if (lp instanceof FrameLayout.LayoutParams) {
|
||||
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
||||
flp.topMargin = dp(30); // 跟 quickBar 的高度一致
|
||||
panel.setLayoutParams(flp);
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 快捷栏接进 onCreate + 补 isConnected")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
raw = MAIN.read_bytes()
|
||||
bom = raw.startswith(b"\xef\xbb\xbf")
|
||||
t = raw.decode("utf-8-sig")
|
||||
|
||||
if "private boolean isConnected()" in t:
|
||||
print(" 已经接过了")
|
||||
return 0
|
||||
|
||||
need = {
|
||||
"ensureConnected": CONN_ANCHOR,
|
||||
"setContentView(root)": ONCREATE_ANCHOR,
|
||||
"buildQuickBar(上次加的)": " private void buildQuickBar() {",
|
||||
}
|
||||
missing = [k for k, v in need.items() if v not in t]
|
||||
if missing:
|
||||
print(" ✗ 锚点找不到:%s" % ", ".join(missing))
|
||||
return 1
|
||||
print(" ✓ 锚点全部就位")
|
||||
|
||||
t = t.replace(CONN_ANCHOR, CONN_METHOD + CONN_ANCHOR, 1)
|
||||
t = t.replace(ONCREATE_ANCHOR, ONCREATE_NEW, 1)
|
||||
t = t.replace(" private void buildQuickBar() {",
|
||||
MARGIN_NOTE + " private void buildQuickBar() {", 1)
|
||||
|
||||
MAIN.write_bytes((b"\xef\xbb\xbf" if bom else b"") + t.encode("utf-8"))
|
||||
print(" ✓ 加了 isConnected()")
|
||||
print(" ✓ buildQuickBar 挂进 onCreate")
|
||||
print(" ✓ 加了 applyPanelTopMargin 助手")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -20,5 +20,11 @@
|
||||
"download_dir": "received",
|
||||
"screen_max_width": 480,
|
||||
"screen_quality": 72,
|
||||
"screen_fps": 12
|
||||
"screen_fps": 12,
|
||||
"notice_position": "bottom-right",
|
||||
"notice_x": 0,
|
||||
"notice_y": 0,
|
||||
"notice_width": 0,
|
||||
"notice_font_size": 11,
|
||||
"show_screen_notice": false
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验「后台点一下让电脑自己更新」这条链路。
|
||||
|
||||
== 链路 ==
|
||||
|
||||
管理页点「更新」
|
||||
-> POST /v1/admin/update_device {deviceId}
|
||||
-> Relay.request_update() 把 {"cmd":"update","action":"start"}
|
||||
塞进该设备的 to_agent 队列
|
||||
-> 被控端下一次长轮询拿到它
|
||||
-> server.py 的 _do_update:查最新版 -> 下载 -> 校验 -> 静默装 -> 重启
|
||||
|
||||
== 为什么要专门测 ==
|
||||
|
||||
这条链路横跨三端(管理页 JS -> 中转 -> 被控端),任何一环写错,
|
||||
表现都是"我点了更新,电脑没反应" —— 而用户在办公室里没法排查。
|
||||
|
||||
而且它**会重启别人的电脑**,所以几个边界必须卡死:
|
||||
|
||||
* 设备不存在 -> 明确说没有,不能静默成功
|
||||
* 设备离线 -> 直接拒。命令挂在队列里等它上线才执行,
|
||||
那时用户早忘了,突然重启很莫名其妙
|
||||
* 重复点 -> 不重复塞(队列会堆积)
|
||||
* 在线 -> 真的塞进队列,而且内容就是那条更新命令
|
||||
|
||||
== 上一版测试自己写错的地方 ==
|
||||
|
||||
我用了个 FakeDevice,`__init__` 里默认 `_on = True`,
|
||||
结果"离线设备"那条用例测的其实是**在线**设备 —— 假绿。
|
||||
现在 FakeDevice 显式接收 online 参数,不再有隐藏默认值。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "relay"))
|
||||
|
||||
FAILS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, why: str = "") -> None:
|
||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
(" —— " + why) if why else ""))
|
||||
if not ok:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
class FakeDevice:
|
||||
"""假的被控端。online 必须显式给,不给就报错 ——
|
||||
第一版给了个 True 的默认值,导致"离线"用例永远测不到离线。"""
|
||||
|
||||
def __init__(self, online: bool):
|
||||
self.to_agent = bytearray()
|
||||
self._online = bool(online)
|
||||
self.lan_ips: list = []
|
||||
self.port = 0
|
||||
|
||||
def online(self) -> bool:
|
||||
return self._online
|
||||
|
||||
def push_to_agent(self, data: bytes) -> None:
|
||||
self.to_agent.extend(data)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 后台「更新」按钮链路")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
import relay as R
|
||||
|
||||
print("一、Relay.request_update 的边界")
|
||||
r = R.Relay.__new__(R.Relay)
|
||||
r.lock = threading.RLock()
|
||||
r.devices = {}
|
||||
|
||||
res = r.request_update("不存在的设备")
|
||||
check("设备不存在 -> 拒绝", res.get("ok") is False,
|
||||
"拿到 %r" % res)
|
||||
check(" 错误信息说清楚是没这台", "没有这台设备" in str(res.get("error")),
|
||||
"拿到 %r" % res.get("error"))
|
||||
|
||||
check("设备 id 为空也当不存在",
|
||||
r.request_update("").get("ok") is False)
|
||||
check(" None 也不炸", r.request_update(None).get("ok") is False)
|
||||
|
||||
off = FakeDevice(online=False)
|
||||
r.devices["off1"] = off
|
||||
res = r.request_update("off1")
|
||||
check("离线设备 -> 拒绝", res.get("ok") is False, "拿到 %r" % res)
|
||||
check(" 理由提到离线", "离线" in str(res.get("error")),
|
||||
"拿到 %r" % res.get("error"))
|
||||
check(" 离线时**没往队列里塞东西**", len(off.to_agent) == 0,
|
||||
"队列里有 %d 字节 —— 离线时不该堆命令"
|
||||
% len(off.to_agent))
|
||||
|
||||
on = FakeDevice(online=True)
|
||||
r.devices["on1"] = on
|
||||
res = r.request_update("on1")
|
||||
check("在线设备 -> 成功", res.get("ok") is True, "拿到 %r" % res)
|
||||
check(" 队列里真的放了东西", len(on.to_agent) > 0)
|
||||
raw = bytes(on.to_agent).decode("utf-8")
|
||||
check(" 是换行结尾的 JSON(协议要求)", raw.endswith("\n"),
|
||||
"尾部是 %r" % raw[-6:])
|
||||
try:
|
||||
msg = json.loads(raw.strip())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check(" 内容是合法 JSON", False, str(exc))
|
||||
msg = {}
|
||||
check(" 内容是合法 JSON", bool(msg))
|
||||
check(" cmd == update", msg.get("cmd") == "update", "拿到 %r" % msg)
|
||||
check(" action == start", msg.get("action") == "start",
|
||||
"拿到 %r" % msg)
|
||||
|
||||
n_before = len(on.to_agent)
|
||||
res = r.request_update("on1")
|
||||
check("重复点 -> 不重复塞", len(on.to_agent) == n_before,
|
||||
"队列从 %d 涨到 %d" % (n_before, len(on.to_agent)))
|
||||
check(" 但仍然返回 ok(用户看到的是「已经在排队」)",
|
||||
res.get("ok") is True and "排队" in str(res.get("message")),
|
||||
"拿到 %r" % res)
|
||||
|
||||
print()
|
||||
print("二、HTTP 接口接到了 Relay 方法上")
|
||||
src = (ROOT / "relay" / "relay.py").read_text(encoding="utf-8")
|
||||
check("有 /v1/admin/update_device 路由",
|
||||
'"/v1/admin/update_device"' in src)
|
||||
m = re.search(r'if path == "/v1/admin/update_device":(.{0,300})',
|
||||
src, re.S)
|
||||
body = m.group(1) if m else ""
|
||||
check(" 它调的是 RELAY.request_update", "RELAY.request_update(" in body,
|
||||
"应该走 Relay 的方法,而不是在 HTTP 处理里伸手掏 RELAY.devices")
|
||||
check(" 它把 deviceId 传下去了", 'get("deviceId")' in body)
|
||||
check("有 Relay.request_update 方法", "def request_update(self" in src)
|
||||
|
||||
print()
|
||||
print("三、管理页那边")
|
||||
adm = (ROOT / "relay" / "admin.py").read_text(encoding="utf-8")
|
||||
check("设备行有「更新」按钮", 'data-update="%s"' in adm)
|
||||
check("JS 里有 data-update 的处理",
|
||||
"querySelectorAll('button[data-update]')" in adm)
|
||||
check("JS 会二次确认(别误点就重启别人电脑)",
|
||||
"confirm(" in adm and "更新到最新版" in adm)
|
||||
check("离线设备的按钮带 disabled",
|
||||
'disabled title="设备离线' in adm)
|
||||
check("设备行显示了版本", 'data-l="版本"' in adm)
|
||||
check("表头也有版本列", "<th>版本</th>" in adm)
|
||||
# 空表提示的 colspan 要跟列数对上(加了版本列之后是 9)
|
||||
check("空表 colspan 是 9(跟列数对上)", 'colspan="9"' in adm,
|
||||
"加列之后忘了改 colspan,空表时表格会错位")
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
if FAILS:
|
||||
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||
return 1
|
||||
print(" 全部通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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.1.6.apk"
|
||||
ROOT / "dist" / "ykxmz-watch-2.1.8.apk"
|
||||
|
||||
|
||||
APK = _latest_apk()
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
@@ -128,9 +129,21 @@ class Talk:
|
||||
|
||||
|
||||
def seed(data_dir: Path) -> None:
|
||||
"""准备好数据目录。
|
||||
|
||||
⚠ 这里**故意把 show_screen_notice 设成 True**。
|
||||
|
||||
为什么:这个测试验的是"提示条显示出来的时候位置和尺寸对不对",
|
||||
而 2.1.7 起**默认是不显示的**(用户要求"像 UU 远程一样啥窗口都没有")。
|
||||
开着开关跑,才能继续验到提示条本身还在正常工作 ——
|
||||
否则这个能力哪天被改坏了也没人知道。
|
||||
|
||||
"默认不显示"这件事由下面的第六节单独验(不重启进程,直接读源码/默认值)。
|
||||
"""
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "settings.json").write_text(
|
||||
json.dumps({"setup_done": True}, ensure_ascii=False), encoding="utf-8")
|
||||
json.dumps({"setup_done": True, "show_screen_notice": True},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -237,6 +250,34 @@ def main() -> int:
|
||||
talk.call({"cmd": "screen_stop"}, timeout=15)
|
||||
check("再次停止后消失", wait_notice(proc.pid, False))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
print()
|
||||
print("六、默认必须是**不显示**(用户 2.1.7 的要求)")
|
||||
#
|
||||
# 用户原话:"现在不是要会弹一个屏幕已被共享吗?以后不需要弹,
|
||||
# 啥窗户都没有,就可以像 UU 远程一样"
|
||||
#
|
||||
# 这一节**不重启进程**(重启一次要十几秒),
|
||||
# 直接查默认值 —— 被控端读取设置时,没写这一项就用默认值,
|
||||
# 所以默认值是 False 就意味着"新装的机器不会弹提示条"。
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
from lanremote.config import DEFAULT_SETTINGS
|
||||
check("★ 默认 show_screen_notice == False",
|
||||
DEFAULT_SETTINGS.get("show_screen_notice") is False,
|
||||
"拿到 %r —— 不是 False 的话新装机还会弹提示条"
|
||||
% DEFAULT_SETTINGS.get("show_screen_notice"))
|
||||
|
||||
# 而且设置关着的时候,代码路径上**连窗口都不该建**
|
||||
headless_src = (ROOT / "windows" / "lanremote"
|
||||
/ "headless.py").read_text(encoding="utf-8")
|
||||
m = re.search(r"def set_screen_sharing\(self.*?(?=\n def )",
|
||||
headless_src, re.S)
|
||||
body = m.group(0) if m else ""
|
||||
check("★ 关着时直接 return,不建窗口",
|
||||
'store.get("show_screen_notice")' in body
|
||||
and "return" in body,
|
||||
"开关没接进 set_screen_sharing")
|
||||
|
||||
talk.close()
|
||||
|
||||
finally:
|
||||
|
||||
@@ -95,6 +95,23 @@ def check(name, ok, detail=""):
|
||||
|
||||
|
||||
def defined_methods(text: str) -> list[str]:
|
||||
"""找出主程序里定义的方法名。
|
||||
|
||||
⚠ **带 `@Override` 的跳过**。
|
||||
|
||||
为什么:这些是**父类/接口回调**,由框架调用,源码里当然只出现一次 ——
|
||||
而这个扫描的判据正是"名字只出现 1 次 = 没人调"。
|
||||
|
||||
踩到的实例:手表端的 `GestureDetector.SimpleOnGestureListener`
|
||||
里有 onSingleTapConfirmed / onDoubleTap / onScroll,
|
||||
它们由 Android 的触摸框架回调,源码里各只出现一次。
|
||||
不跳过的话扫描会把它们报成"未知的未调用方法"(假阳性)——
|
||||
而这种假阳性最坏的结果是**有人为了让它变绿,把回调删了**,
|
||||
于是手势就失灵了。
|
||||
|
||||
同类还会遇到的:View.OnClickListener.onClick、
|
||||
DialogInterface.OnClickListener.onClick、Runnable.run 等。
|
||||
"""
|
||||
pattern = re.compile(
|
||||
r"(?:private|public|protected|static|final|\s)+"
|
||||
r"[\w<>\[\],.\s]+\s+(\w+)\s*\([^)]*\)\s*(?:throws [\w.,\s]+)?\{")
|
||||
@@ -104,6 +121,11 @@ def defined_methods(text: str) -> list[str]:
|
||||
if name in ("if", "for", "while", "switch", "catch", "synchronized",
|
||||
"try", "return", "new"):
|
||||
continue
|
||||
# 往上看几行,如果有 @Override 就跳过
|
||||
head = text[max(0, match.start() - 200):match.start()]
|
||||
tail_lines = head.splitlines()[-3:]
|
||||
if any("@Override" in ln for ln in tail_lines):
|
||||
continue
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验「弹窗位置/大小可配置」和「屏幕提示条默认关」。
|
||||
|
||||
== 用户的要求 ==
|
||||
|
||||
1. 弹文字窗口:位置和大小都能自定义(原来写死右下角)
|
||||
2. 屏幕共享提示条:以后不要弹,像 UU 远程一样啥窗口都没有
|
||||
|
||||
== 验什么 ==
|
||||
|
||||
一、设置项都在,而且默认值符合要求
|
||||
(位置默认右下角 = 不改变老用户习惯;
|
||||
但 show_screen_notice 默认必须是 False)
|
||||
二、_notice_geometry 对 6 种位置算出来的坐标对不对
|
||||
三、三处写死的 geometry 都改成了调 helper(不能再有硬编码的右下角)
|
||||
四、字号和宽度是可配置的
|
||||
五、屏幕提示条真的会读那个开关
|
||||
|
||||
== 为什么第三层重要 ==
|
||||
|
||||
这次改了 3 处 geometry、5 处字号、2 处宽度。
|
||||
如果漏掉一处,表现就是"有一个窗口还在右下角" ——
|
||||
而用户只会看到"你改了没用"。所以必须断言**没有残留的硬编码**。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
|
||||
FAILS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, why: str = "") -> None:
|
||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
(" —— " + why) if why else ""))
|
||||
if not ok:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
HEADLESS = ROOT / "windows" / "lanremote" / "headless.py"
|
||||
CONFIG = ROOT / "windows" / "lanremote" / "config.py"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 弹窗位置/大小可配置 + 屏幕提示条默认关")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
src = HEADLESS.read_text(encoding="utf-8")
|
||||
cfg_src = CONFIG.read_text(encoding="utf-8")
|
||||
|
||||
# ---------------- 一、设置项 ----------------
|
||||
print("一、设置项")
|
||||
from lanremote.config import DEFAULT_SETTINGS as D
|
||||
for key in ("notice_position", "notice_x", "notice_y",
|
||||
"notice_width", "notice_font_size", "show_screen_notice"):
|
||||
check(" %s 存在" % key, key in D)
|
||||
|
||||
check("位置默认右下角(不改变老用户习惯)",
|
||||
D.get("notice_position") == "bottom-right",
|
||||
"拿到 %r" % D.get("notice_position"))
|
||||
check("**屏幕提示条默认关**(用户明确要求)",
|
||||
D.get("show_screen_notice") is False,
|
||||
"拿到 %r —— 这个默认必须是 False" % D.get("show_screen_notice"))
|
||||
check("字号默认 11(原来的值)", D.get("notice_font_size") == 11)
|
||||
check("宽度默认 0(自适应)", D.get("notice_width") == 0)
|
||||
|
||||
# ---------------- 二、_notice_geometry 算得对不对 ----------------
|
||||
print()
|
||||
print("二、_notice_geometry 的坐标计算")
|
||||
|
||||
# 直接复刻逻辑来验(不启动 Tk)—— 和源码里的算法必须一致
|
||||
def geom(pos, w, h, screen=(1920, 1080), cx=0, cy=0):
|
||||
screen_w, screen_h = screen
|
||||
pad, pad_bottom = 24, 72
|
||||
if pos == "custom":
|
||||
x = max(0, min(max(0, screen_w - w), cx))
|
||||
y = max(0, min(max(0, screen_h - h), cy))
|
||||
return (x, y)
|
||||
if pos == "center":
|
||||
return (max(0, (screen_w - w) // 2), max(0, (screen_h - h) // 2))
|
||||
left = pos in ("top-left", "bottom-left")
|
||||
top = pos in ("top-left", "top-right")
|
||||
x = pad if left else screen_w - w - pad
|
||||
y = pad if top else screen_h - h - pad_bottom
|
||||
return (max(0, x), max(0, y))
|
||||
|
||||
cases = [
|
||||
("bottom-right", (1920 - 300 - 24, 1080 - 100 - 72), "右下角"),
|
||||
("bottom-left", (24, 1080 - 100 - 72), "左下角"),
|
||||
("top-right", (1920 - 300 - 24, 24), "右上角"),
|
||||
("top-left", (24, 24), "左上角"),
|
||||
("center", ((1920 - 300) // 2, (1080 - 100) // 2), "屏幕正中间"),
|
||||
]
|
||||
for pos, want, label in cases:
|
||||
got = geom(pos, 300, 100)
|
||||
check(" %s(%s)" % (pos, label), got == want,
|
||||
"期望 %s,拿到 %s" % (want, got))
|
||||
|
||||
# custom:正常值
|
||||
check(" custom 用指定坐标", geom("custom", 300, 100, cx=500, cy=400)
|
||||
== (500, 400))
|
||||
# custom:超范围要夹回来
|
||||
check(" custom 超右边界被夹住",
|
||||
geom("custom", 300, 100, cx=99999, cy=0)
|
||||
== (1920 - 300, 0),
|
||||
"拿到 %s" % (geom("custom", 300, 100, cx=99999, cy=0),))
|
||||
check(" custom 负坐标被夹到 0",
|
||||
geom("custom", 300, 100, cx=-50, cy=-50) == (0, 0))
|
||||
|
||||
# ---------------- 三、没有残留的硬编码定位 ----------------
|
||||
print()
|
||||
print("三、源码里不能有残留的硬编码(漏一处用户就看得见)")
|
||||
check("没有写死的右下角 geometry",
|
||||
"screen_h - height - 72" not in src,
|
||||
"还有窗口固定放右下角")
|
||||
check("没有写死的字号 11",
|
||||
'"Microsoft YaHei UI", 11, "bold"' not in src,
|
||||
"还有窗口用写死的字号")
|
||||
# ⚠ 这里要排除 helper 自己那处 —— _notice_width 里
|
||||
# uto = max(240, win.winfo_reqwidth()) 是**自适应的兜底值**,
|
||||
# 属于故意保留的。第一版断言太粗,把这处也算成写死了。
|
||||
hard = [ln for ln in src.splitlines()
|
||||
if "max(240, win.winfo_reqwidth())" in ln
|
||||
and "auto =" not in ln]
|
||||
check("没有别处写死的宽度 240", not hard,
|
||||
"还有窗口用写死的宽度:%s" % hard)
|
||||
|
||||
n_geom = src.count("self._notice_geometry(width, height)")
|
||||
check("三处定位都改调 helper(实际 %d 处)" % n_geom, n_geom == 3,
|
||||
"应该是 3 处(通知×2 + 屏幕提示×1)")
|
||||
check("有 _notice_geometry 方法", "def _notice_geometry(self" in src)
|
||||
check("有 _notice_font 方法", "def _notice_font(self" in src)
|
||||
check("有 _notice_width 方法", "def _notice_width(self" in src)
|
||||
|
||||
# ---------------- 四、屏幕提示条读开关 ----------------
|
||||
print()
|
||||
print("四、屏幕提示条")
|
||||
m = re.search(r"def set_screen_sharing\(self.*?(?=\n def )", src, re.S)
|
||||
body = m.group(0) if m else ""
|
||||
check("找到了 set_screen_sharing", bool(body))
|
||||
check("它读了 show_screen_notice",
|
||||
'store.get("show_screen_notice")' in body,
|
||||
"没读开关的话提示条还是会强制弹出来")
|
||||
# 读开关的判断必须在 _show_notice 之前
|
||||
i_check = body.find('store.get("show_screen_notice")')
|
||||
i_show = body.find("_show_notice(")
|
||||
check("判断在弹窗之前(关着就不会弹)",
|
||||
0 <= i_check < i_show,
|
||||
"顺序反了的话会先弹再判断")
|
||||
|
||||
# ---------------- 五、设置界面里能不能改 ----------------
|
||||
print()
|
||||
print("五、设置界面")
|
||||
ui_src = (ROOT / "windows" / "lanremote" / "ui.py").read_text(
|
||||
encoding="utf-8")
|
||||
has_any = any(k in ui_src for k in
|
||||
("notice_position", "notice_width", "notice_font_size",
|
||||
"show_screen_notice"))
|
||||
check("设置界面里有这些项", has_any,
|
||||
"配置项加了但界面没入口的话,用户根本改不了")
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
if FAILS:
|
||||
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||
return 1
|
||||
print(" 全部通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
# 被控端 -> 一个 zip
|
||||
# 服务器端 -> 一个 zip
|
||||
# 控制端 -> 直接给 APK(不打 zip)
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.6.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.6.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.6.apk"
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-2.1.8.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-2.1.8.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-2.1.8.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.1.6-说明.txt")):
|
||||
ROOT / "dist" / "ykxmz-watch-2.1.8-说明.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])
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验「手表快捷栏」和「允许新设备连接」。
|
||||
|
||||
== 验什么 ==
|
||||
|
||||
一、快捷栏的形状
|
||||
* 一共 4 格,**每格都能自定义**
|
||||
* 「显示桌面」是普通可选动作(默认放第 4 格,但能换掉)
|
||||
* 8 个动作都在
|
||||
* 挂进了 onCreate、加进了 showOnly 的数组
|
||||
* 设置页里有入口
|
||||
* 鼠标触摸板在
|
||||
|
||||
二、按钮大小
|
||||
用户反馈原话:"屏幕最上面这个按钮有些按不到,按钮太小了,做大一点"
|
||||
|
||||
* 栏高做成常量(>= 44dp),而且**栏高 / 面板 margin / addView
|
||||
三处用同一个常量** —— 分三处写的话改一个忘一个,
|
||||
表现是"内容被盖住"或"上面空一条"
|
||||
|
||||
三、电脑端 allow_new_pairing 这个 feature
|
||||
|
||||
== 这条断言改过一次,值得记下来 ==
|
||||
|
||||
用户最早说:"最多可以加 4 个,其中有一个按钮不能改,
|
||||
功能就是一键返回桌面"。
|
||||
第一版就按"第 4 格写死 desktop"实现,也按那个断言测。
|
||||
|
||||
后来用户看到实物改了主意:"第一个第二个第三个第四个都能换东西"。
|
||||
|
||||
所以现在验的是**相反**的事。教训:
|
||||
**别把"用户当时的一句话"当成永久约束焊进结构里**;
|
||||
真是约束的话,注释里要写清是什么时候的要求,
|
||||
下次他改主意时才知道该动哪儿。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
|
||||
FAILS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, why: str = "") -> None:
|
||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
(" —— " + why) if why else ""))
|
||||
if not ok:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 手表快捷栏 + 允许新设备连接")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
main_java = (ROOT / "android" / "src" / "com" / "lanremote" / "watch"
|
||||
/ "MainActivity.java").read_text(encoding="utf-8")
|
||||
|
||||
# ---------------- 一、快捷栏 ----------------
|
||||
print("一、快捷栏(手表端)")
|
||||
for token, label in [
|
||||
("private void buildQuickBar()", "有 buildQuickBar"),
|
||||
("private void refreshQuickBar()", "有 refreshQuickBar"),
|
||||
("private void onQuickClick(int slot)", "有点击分发 onQuickClick"),
|
||||
("private void runQuickAction(String id)", "有动作执行 runQuickAction"),
|
||||
("private void loadQuickSlots()", "有读取设置"),
|
||||
("private void saveQuickSlots()", "有保存设置"),
|
||||
]:
|
||||
check(label, token in main_java)
|
||||
|
||||
# 4 格,全部可自定义。这条断言改过一次,见文件头。
|
||||
check("★ 一共 4 格", "QUICK_SLOTS = 4" in main_java, "用户要求 4 格")
|
||||
check("★ 旧的 3+1 分法已经不在了",
|
||||
"QUICK_CUSTOM_SLOTS" not in main_java, "还有残留说明没改干净")
|
||||
check("★ 没有把 desktop 写死在某格",
|
||||
': "desktop";' not in main_java,
|
||||
"还有地方把 desktop 写死成某一格")
|
||||
check("★ desktop 在可选动作里(能换到任意一格)",
|
||||
bool(re.search(r'\{"desktop",\s*"显示桌面"\}', main_java)),
|
||||
"不在 QUICK_ACTIONS 里的话用户没法把它放到别的格")
|
||||
check("四格平等:都从 quickSlots 取",
|
||||
"String id = quickSlots[i];" in main_java
|
||||
and "String id = quickSlots[slot];" in main_java,
|
||||
"还有特殊分支")
|
||||
check("默认值就是那 4 个",
|
||||
'new String[]{"file", "mirror", "sfx", "desktop"}' in main_java)
|
||||
check("desktop 走的是 show_desktop 命令",
|
||||
'"cmd", "show_desktop"' in main_java)
|
||||
|
||||
print()
|
||||
print(" 8 个可选动作:")
|
||||
for aid, label in [("file", "传文件"), ("mirror", "屏幕镜像"),
|
||||
("mouse", "鼠标控制"), ("sfx", "音效"),
|
||||
("power", "关机"), ("tts", "语音播报"),
|
||||
("image", "投图到电脑"), ("desktop", "显示桌面")]:
|
||||
check(" %s(%s)" % (aid, label),
|
||||
('{"%s", "%s"}' % (aid, label)) in main_java)
|
||||
|
||||
# ---------------- 二、按钮大小 ----------------
|
||||
print()
|
||||
print("二、按钮大小(用户反馈「太小了、按不到」)")
|
||||
m = re.search(r"QUICK_BAR_DP = (\d+)", main_java)
|
||||
check("★ 栏高做成常量 QUICK_BAR_DP", m is not None,
|
||||
"没有常量的话栏高和面板 margin 会各写各的")
|
||||
h = int(m.group(1)) if m else 0
|
||||
check("★ 栏高 >= 44dp(接近 Android 建议的可点区域 48)", h >= 44,
|
||||
"现在是 %d" % h)
|
||||
check("★ 面板 topMargin 用同一个常量",
|
||||
"flp.topMargin = dp(QUICK_BAR_DP);" in main_java,
|
||||
"分两处写的话改一个忘一个")
|
||||
check("★ addView 也用同一个常量",
|
||||
"MATCH_PARENT, dp(QUICK_BAR_DP)));" in main_java)
|
||||
check("★ 按钮字号 >= 13(原来是 11)",
|
||||
"b.setTextSize(14f);" in main_java)
|
||||
# ⚠ 只查**代码**,不查注释。
|
||||
# 第一版这里是 `"dp(30)" not in main_java`,结果被
|
||||
# applyPanelTopMargin 的 javadoc 里那句旧说明误报了 ——
|
||||
# 注释里提一下旧值是很正常的事("以前是 30dp"),
|
||||
# 不该因为它变红。
|
||||
code_lines = [ln for ln in main_java.splitlines()
|
||||
if not ln.strip().startswith(("*", "//", "/*"))]
|
||||
hard = [ln.strip() for ln in code_lines if "dp(30)" in ln]
|
||||
check("没有残留的 dp(30)(旧栏高)", not hard,
|
||||
"还有代码用旧的 30dp:%s" % hard)
|
||||
|
||||
# ---------------- 三、挂接 ----------------
|
||||
print()
|
||||
print("三、挂接(漏了就白写)")
|
||||
i0 = main_java.find("protected void onCreate")
|
||||
oncreate = main_java[i0:i0 + 7000]
|
||||
check("★ onCreate 里调了 buildQuickBar()", "buildQuickBar();" in oncreate,
|
||||
"没挂上去栏根本不显示")
|
||||
check("★ onCreate 里调了 loadQuickSlots()", "loadQuickSlots();" in oncreate)
|
||||
check("★ 快捷栏最后 addView(保证在最上层)",
|
||||
oncreate.find("root.addView(quickBar")
|
||||
> oncreate.find("root.addView(mousePanel"),
|
||||
"加在面板之前会被面板整个盖住")
|
||||
check("★ showOnly 的数组里有 mousePanel",
|
||||
"settingsPanel, mousePanel};" in main_java,
|
||||
"漏了的话鼠标面板永远不隐藏")
|
||||
check("有 applyPanelTopMargin(面板给栏让位)",
|
||||
"private void applyPanelTopMargin" in main_java)
|
||||
|
||||
# ---------------- 四、设置页 ----------------
|
||||
print()
|
||||
print("四、设置页入口(没入口用户改不了)")
|
||||
check("有 fillSettingsExtra",
|
||||
"private void fillSettingsExtra()" in main_java)
|
||||
check("有 pickQuickSlot(选动作)",
|
||||
"private void pickQuickSlot(final int slot)" in main_java)
|
||||
check("设置页里加了「快捷栏」小节",
|
||||
'sectionTitle("快捷栏")' in main_java)
|
||||
check("设置页里加了「允许新设备连接」小节",
|
||||
'sectionTitle("允许新设备连接")' in main_java)
|
||||
check("界面上说明了四格都能换", "每一个都能换" in main_java,
|
||||
"不说明的话用户不知道第四格也能改")
|
||||
|
||||
# ---------------- 五、鼠标触摸板 ----------------
|
||||
print()
|
||||
print("五、鼠标触摸板")
|
||||
check("有 buildMousePanel", "private void buildMousePanel()" in main_java)
|
||||
check("用 GestureDetector(单击/双击/拖动)",
|
||||
"GestureDetector" in main_java
|
||||
and "onSingleTapConfirmed" in main_java
|
||||
and "onDoubleTap" in main_java
|
||||
and "onScroll" in main_java)
|
||||
check("单击 -> 左键", 'sendMouse("click", "left", 0)' in main_java)
|
||||
check("双击 -> 右键", 'sendMouse("click", "right", 0)' in main_java)
|
||||
check("滚轮上/下", 'sendMouse("scroll", "", 3)' in main_java
|
||||
and 'sendMouse("scroll", "", -3)' in main_java)
|
||||
check("拖动 -> moveby", '"moveby"' in main_java)
|
||||
check("isConnected() 存在(第一版漏了,编译才发现)",
|
||||
"private boolean isConnected()" in main_java)
|
||||
|
||||
# ---------------- 六、电脑端 allow_new_pairing ----------------
|
||||
print()
|
||||
print("六、电脑端 allow_new_pairing feature")
|
||||
from lanremote.config import Store
|
||||
from lanremote.features import Features
|
||||
|
||||
class FakeActions:
|
||||
"""Features 要的是 actions 对象,不是 store(第一版就是这里传错了)。"""
|
||||
|
||||
def __init__(self, store):
|
||||
self.store = store
|
||||
self.ui = None
|
||||
|
||||
st = Store(Path(tempfile.mkdtemp()))
|
||||
feats = Features(FakeActions(st))
|
||||
|
||||
check("能查到当前状态", "允许" in feats.run("allow_new_pairing", {}))
|
||||
out = feats.run("allow_new_pairing", {"allow": False})
|
||||
check("能关掉", "禁止" in out, "拿到 %r" % out)
|
||||
check(" ★ 存储里真的变了", st.get("allow_new_pairing") is False,
|
||||
"拿到 %r" % st.get("allow_new_pairing"))
|
||||
check(" 关掉之后再查也是关",
|
||||
"不允许" in feats.run("allow_new_pairing", {}))
|
||||
out = feats.run("allow_new_pairing", {"allow": True})
|
||||
check("能打开", "已允许" in out, "拿到 %r" % out)
|
||||
check(" 存储里也变了", st.get("allow_new_pairing") is True)
|
||||
|
||||
try:
|
||||
feats.run("allow_new_pairing_typo", {})
|
||||
check("不认识的名字会报错", False, "它竟然没报错")
|
||||
except ValueError:
|
||||
check("不认识的名字会报错", True)
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
if FAILS:
|
||||
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||
return 1
|
||||
print(" 全部通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,208 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r'''验「语音播报降级链」。
|
||||
|
||||
== 为什么要测 ==
|
||||
|
||||
用户反馈:**Win7 上语音播报不能用**。
|
||||
|
||||
原来的实现只有一条路(PowerShell + System.Speech),而 Win7 上
|
||||
PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后,
|
||||
必须保证:
|
||||
|
||||
1. **方案按"依赖越少越优先"排** —— cscript/VBScript 排最前,
|
||||
因为它从 Windows 98 就有,SAPI5 是系统组件,两样都不用装。
|
||||
2. **真的会降级** —— 前一条失败要接着试下一条,不是试一条就放弃。
|
||||
3. **一条通了要记住** —— 不然每播一次都要把几条全试一遍。
|
||||
4. **失败了要报错**,不能像原来那样 `Popen` 不等待、
|
||||
明明没出声却返回"已播报"。
|
||||
5. **VBScript 用 ANSI 编码写** —— 写成 UTF-8 的话 cscript 读出来
|
||||
中文是乱码。这是 VBScript 的老规矩,很隐蔽。
|
||||
|
||||
== 怎么测"会降级" ==
|
||||
|
||||
不能真把 cscript 弄坏。所以对方案列表做**结构断言**:
|
||||
确认它列出了 >= 3 条、顺序正确;再单独构造一条必失败的链
|
||||
验证"失败会继续往下试"这个逻辑(用一个假的 subprocess)。
|
||||
|
||||
真出声的部分只在最后一节做(本机有 cscript 的话)。
|
||||
'''
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT / "windows"))
|
||||
|
||||
FAILS: list = []
|
||||
|
||||
|
||||
def check(name: str, ok: bool, why: str = "") -> None:
|
||||
print(" [%s] %s%s" % ("PASS" if ok else "FAIL", name,
|
||||
(" —— " + why) if why else ""))
|
||||
if not ok:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
class FakeUi:
|
||||
"""Actions 需要一个 ui,TTS 用不到它的任何东西。"""
|
||||
|
||||
def log(self, *_a, **_k):
|
||||
pass
|
||||
|
||||
def notify(self, *_a, **_k):
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("=" * 58)
|
||||
print(" 语音播报降级链")
|
||||
print("=" * 58)
|
||||
print()
|
||||
|
||||
from lanremote.actions import Actions
|
||||
from lanremote.config import Store
|
||||
|
||||
a = Actions(FakeUi(), Store(Path(tempfile.mkdtemp())))
|
||||
|
||||
# ---------------- 一、方法都在 ----------------
|
||||
print("一、方法")
|
||||
for name in ("_powershell_path", "_write_vbs", "_tts_commands"):
|
||||
check("有 %s" % name, hasattr(a, name))
|
||||
|
||||
# ---------------- 二、方案顺序 ----------------
|
||||
print()
|
||||
print("二、降级顺序(依赖越少越优先)")
|
||||
cmds = a._tts_commands("测试一二三", "", 0)
|
||||
names = [c[0] for c in cmds]
|
||||
print(" 顺序: %s" % " -> ".join(names))
|
||||
check("至少 3 条方案", len(cmds) >= 3, "只有 %d 条" % len(cmds))
|
||||
if names:
|
||||
check("★ 第一条是 cscript(不依赖 PowerShell / .NET)",
|
||||
names[0] == "cscript",
|
||||
"第一条是 %r —— cscript 最该排前面" % names[0])
|
||||
if len(names) > 1:
|
||||
check("第二条是 wscript(cscript 被拦时的备选)",
|
||||
names[1] == "wscript", "第二条是 %r" % names[1])
|
||||
check("★ 有 ps-sapi 那条(绕开 .NET 只用系统 SAPI5)",
|
||||
"ps-sapi" in names, "方案 %s" % names)
|
||||
check("★ 保留了原来的 System.Speech 那条(兜底)",
|
||||
"ps-speech" in names or "ps-path" in names,
|
||||
"把老办法删了的话,老机器上本来能用的反而不能用")
|
||||
check("cscript 那条带 //nologo(不然会弹横幅)",
|
||||
any("//nologo" in " ".join(c[1]) for c in cmds))
|
||||
|
||||
# ---------------- 三、VBScript 编码 ----------------
|
||||
print()
|
||||
print("三、VBScript(中文必须用 ANSI 写)")
|
||||
p = a._write_vbs("中文测试", "", 0)
|
||||
raw = p.read_bytes()
|
||||
check("生成了 .vbs", p.is_file(), str(p))
|
||||
check("★ 不是 UTF-8(UTF-8 的中文在 cscript 里是乱码)",
|
||||
not raw.startswith(b"\xef\xbb\xbf") and raw.count(b"\xe4\xb8") == 0,
|
||||
"文件开头 %r" % raw[:8])
|
||||
check("★ 用 mbcs 能解回原文",
|
||||
"中文测试" in raw.decode("mbcs", errors="replace"))
|
||||
txt = raw.decode("mbcs", errors="replace")
|
||||
check("用的是 SAPI.SpVoice(系统原生,不需要 .NET)",
|
||||
"SAPI.SpVoice" in txt)
|
||||
check("CRLF 换行(VBScript 的老规矩)", "\r\n" in txt)
|
||||
|
||||
# 音色 / 语速要能写进去
|
||||
p2 = a._write_vbs("有音色和语速", "Huihui", 3)
|
||||
t2 = p2.read_bytes().decode("mbcs", errors="replace")
|
||||
check("音色写进去了", "Huihui" in t2)
|
||||
check("语速写进去了", "v.Rate = 3" in t2)
|
||||
|
||||
# 长文本要分句(VBScript 单行有长度上限)
|
||||
long_text = "这是一句很长的话。" * 100
|
||||
p3 = a._write_vbs(long_text, "", 0)
|
||||
t3 = p3.read_bytes().decode("mbcs", errors="replace")
|
||||
speak_lines = [ln for ln in t3.splitlines() if ln.strip().startswith("v.Speak")]
|
||||
check("★ 长文本被切成多句(VBScript 单行长度有限)",
|
||||
len(speak_lines) > 1,
|
||||
"只有 %d 行 v.Speak —— 太长会报错" % len(speak_lines))
|
||||
check(" 每句不超过 400 字",
|
||||
all(len(ln) < 420 for ln in speak_lines),
|
||||
"最长 %d" % max((len(x) for x in speak_lines), default=0))
|
||||
|
||||
# 引号要转义(不然 VBScript 语法错)
|
||||
p4 = a._write_vbs('他说"你好"', "", 0)
|
||||
t4 = p4.read_bytes().decode("mbcs", errors="replace")
|
||||
check("双引号被转义成两个", '""' in t4 or '"你好"' not in t4)
|
||||
|
||||
# ---------------- 四、会降级(不是试一条就放弃)----------------
|
||||
print()
|
||||
print("四、降级行为")
|
||||
import lanremote.actions as A
|
||||
real_run = A.subprocess.run
|
||||
calls = []
|
||||
|
||||
class R:
|
||||
def __init__(self, code, err=""):
|
||||
self.returncode = code
|
||||
self.stdout = ""
|
||||
self.stderr = err
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
calls.append(cmd[0])
|
||||
# 让前两条"失败",第三条"成功" —— 验证它会继续往下试
|
||||
if len(calls) <= 2:
|
||||
return R(1, "模拟失败")
|
||||
return R(0)
|
||||
|
||||
A.subprocess.run = fake_run
|
||||
try:
|
||||
out = a.say("降级测试")
|
||||
check("★ 前两条失败后会继续试第三条", len(calls) >= 3,
|
||||
"只试了 %d 次:%s" % (len(calls), calls))
|
||||
check("★ 成功了就返回成功", "已播报" in out, "拿到 %r" % out)
|
||||
finally:
|
||||
A.subprocess.run = real_run
|
||||
|
||||
# 全失败要抛错(不能像原来那样骗人)
|
||||
calls.clear()
|
||||
|
||||
def always_fail(cmd, **kw):
|
||||
calls.append(cmd[0])
|
||||
return R(1, "全失败")
|
||||
|
||||
A.subprocess.run = always_fail
|
||||
try:
|
||||
a.say("全失败测试")
|
||||
check("★ 全失败时必须抛错(原来会假报成功)", False,
|
||||
"它竟然返回了成功")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check("★ 全失败时必须抛错(原来会假报成功)", True)
|
||||
check(" 错误里列出了试过哪些", "试过" in str(exc), "拿到 %r" % exc)
|
||||
finally:
|
||||
A.subprocess.run = real_run
|
||||
check(" 确实把每条都试过了", len(calls) >= 3, "只试了 %d 次" % len(calls))
|
||||
|
||||
# ---------------- 五、本机真播一次 ----------------
|
||||
print()
|
||||
print("五、本机真播一次(有 cscript 的话)")
|
||||
import shutil
|
||||
if shutil.which("cscript"):
|
||||
try:
|
||||
out = a.say("语音播报测试")
|
||||
check("真的播报成功", "已播报" in out, "拿到 %r" % out)
|
||||
check(" 报告里说了用的是哪条", "用 " in out, "拿到 %r" % out)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
check("真的播报成功", False, str(exc)[:120])
|
||||
else:
|
||||
print(" [SKIP] 这台没有 cscript")
|
||||
|
||||
print()
|
||||
print("=" * 58)
|
||||
if FAILS:
|
||||
print(" 失败 %d 项:%s" % (len(FAILS), ", ".join(FAILS)))
|
||||
return 1
|
||||
print(" 全部通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+53
-10
@@ -430,7 +430,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_path.py") 2>&1
|
||||
Record "局域网直连路径" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a2
|
||||
Banner "3a2/7 局域网优化(网段掩码 / 发现重发 / 回切)"
|
||||
Banner "3a2/9 局域网优化(网段掩码 / 发现重发 / 回切)"
|
||||
# 盯四件事:
|
||||
# 1. 网段判断按网卡真实掩码,不再写死 /24(/16 网络下能直连却去走中转)
|
||||
# 2. 广播发现重发 + 多网段(原来只发一次、只探一个网段)
|
||||
@@ -441,7 +441,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_optimize.py") 2>&1
|
||||
Record "局域网优化" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a3
|
||||
Banner "3a3/7 手表局域网切换逻辑(源码断言)"
|
||||
Banner "3a3/9 手表局域网切换逻辑(源码断言)"
|
||||
$stageOut = & python (Join-Path $root "_tests\test_watch_lan_switch.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "手表局域网切换" ($LASTEXITCODE -eq 0)
|
||||
@@ -459,7 +459,7 @@ $stageOut = & python (Join-Path $root "_tests\test_rescue_section.py") 2>&1
|
||||
Record "抢救报告分区" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a4
|
||||
Banner "3a4/7 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)"
|
||||
Banner "3a4/9 编码与换行(.java/.py 必须 UTF-8 无 BOM + LF)"
|
||||
# 踩过的坑:edit 工具会把行尾写成 CRLF,而仓库约定是 LF。
|
||||
# 这个检查专门盯住,免得改动悄悄把整个文件的行尾换掉。
|
||||
$stageOut = & python (Join-Path $root "_tests\check_encoding.py") 2>&1
|
||||
@@ -467,7 +467,7 @@ $stageOut = & python (Join-Path $root "_tests\check_encoding.py") 2>&1
|
||||
Record "编码与换行" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a7
|
||||
Banner "3a7/7 Python 语法(必须用 compile,不能用 ast.parse)"
|
||||
Banner "3a7/9 Python 语法(必须用 compile,不能用 ast.parse)"
|
||||
# 这一轮被同一件事咬了三次:补丁脚本把 import 插到 from __future__
|
||||
# 前面,整个文件就废了。而更糟的是 ast.parse 查不出来(它只解析,
|
||||
# 不做编译期检查)—— 前几次「全仓语法检查 0 问题」都是假绿。
|
||||
@@ -476,13 +476,13 @@ $stageOut = & python (Join-Path $root "_tests\check_python_syntax.py") 2>&1
|
||||
Record "Python 语法检查" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a8
|
||||
Banner "3a8/7 版本号一致性(防漏改)"
|
||||
Banner "3a8/9 版本号一致性(防漏改)"
|
||||
$stageOut = & python (Join-Path $root "_tests\check_version_consistency.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "版本号一致性" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a9
|
||||
Banner "3a9/7 Windows 7 兼容(学校电脑是 Win7)"
|
||||
Banner "3a9/9 Windows 7 兼容(学校电脑是 Win7)"
|
||||
# 用户报:学校电脑是 Windows 7,装完报"dll 缺失"。
|
||||
#
|
||||
# 根因不是漏打包,是 **Python 版本越界**:
|
||||
@@ -503,7 +503,7 @@ $stageOut = & python (Join-Path $root "_build\check_win7_compat.py") 2>&1
|
||||
Record "Windows 7 兼容" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a10
|
||||
Banner "3a10/7 静默自动更新链路"
|
||||
Banner "3a10/9 静默自动更新链路"
|
||||
# 用户要求:"手表端点击一个更新按钮,电脑端就可以自动进行静默更新,
|
||||
# 其他什么设置都不变 …… 不弹任何窗口出来"
|
||||
# 这条链是:中转 /v1/update/latest -> 被控端 updater.py 下载校验
|
||||
@@ -516,7 +516,7 @@ $stageOut = & python (Join-Path $root "_tests\test_update_flow.py") 2>&1
|
||||
Record "静默自动更新" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a11
|
||||
Banner "3a11/7 远程鼠标 + 一键显示桌面"
|
||||
Banner "3a11/9 远程鼠标 + 一键显示桌面"
|
||||
# 鼠标注入是最"有力量"的功能,参数校验写错的话表现是"手表点了没反应",
|
||||
# 教室里没法排查。这里盯坐标归一化(除以 宽-1 那条规则)、
|
||||
# 滚轮夹取、Win+D 的事件顺序、以及非法参数必须被拒。
|
||||
@@ -525,8 +525,51 @@ $stageOut = & python (Join-Path $root "_tests\test_mouse_control.py") 2>&1
|
||||
Record "远程鼠标控制" ($LASTEXITCODE -eq 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 3a12
|
||||
Banner "3a12/9 后台更新按钮 + 弹窗位置/大小"
|
||||
# 用户要求:后台能点一下让电脑自己更新;弹文字窗口位置和大小可自定义。
|
||||
# 后台那条链路横跨三端(JS -> 中转 -> 被控端),错了的表现是
|
||||
# "我点了更新,电脑没反应",所以几个边界必须卡死:
|
||||
# 设备不存在 / 离线 / 重复点 / 在线。
|
||||
$stageOut = & python (Join-Path $root "_tests\test_admin_update.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "后台更新按钮" ($LASTEXITCODE -eq 0)
|
||||
|
||||
$stageOut = & python (Join-Path $root "_tests\test_notice_options.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "弹窗位置和大小" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 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)
|
||||
|
||||
# ---------------------------------------------------------------- 3a14
|
||||
Banner "3a14/9 语音播报降级链(修 Win7 上用不了)"
|
||||
# 用户反馈:"我 win7 上的语音播报好像不能用,你再试一下别的版本能不能用"。
|
||||
#
|
||||
# 原来只有一条路(powershell + System.Speech),而 Win7 上
|
||||
# PowerShell 2.0 + 中文 + PATH 三样都可能出问题。改成降级链之后必须保证:
|
||||
# * cscript/VBScript 排最前(从 Win98 就有,SAPI5 是系统组件,不用装东西)
|
||||
# * 前一条失败要接着试下一条,不是试一条就放弃
|
||||
# * 全失败要**报错**,不能像原来那样 Popen 不等待、明明没出声却说"已播报"
|
||||
# * VBScript 用 ANSI 写(UTF-8 的话 cscript 读出来中文是乱码)
|
||||
$stageOut = & python (Join-Path $root "_tests\test_tts.py") 2>&1
|
||||
Show-Fails $stageOut
|
||||
Record "语音播报降级" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a5
|
||||
Banner "3a5/7 局域网/中转对比基准(防测试自己骗人)"
|
||||
Banner "3a5/9 局域网/中转对比基准(防测试自己骗人)"
|
||||
# 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」——
|
||||
# 实际是基准自己有两个 bug:两条路用了同一个配对码、
|
||||
# 以及 attach 用了旧码(打到已经死掉的设备上)。
|
||||
@@ -537,7 +580,7 @@ $stageOut = & python (Join-Path $root "_tests\test_lan_bench_guard.py") 2>&1
|
||||
Record "局域网对比基准" ($LASTEXITCODE -eq 0)
|
||||
|
||||
# ---------------------------------------------------------------- 3a6
|
||||
Banner "3a6/7 管理页 JS 语法(防按钮全死)"
|
||||
Banner "3a6/9 管理页 JS 语法(防按钮全死)"
|
||||
# 踩过两次的坑:admin.py 的 ADMIN_JS 是普通三引号字符串,
|
||||
# 里面写 \n 会被 Python 变成真实换行;而 JS 的行注释 // 不能跨行,
|
||||
# 一断行整段 <script> 语法错误 -> 页面上所有按钮失效。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.lanremote.watch"
|
||||
android:versionCode="5"
|
||||
android:versionName="2.1.6">
|
||||
android:versionName="2.1.8">
|
||||
|
||||
<!-- ⚠ 允许明文 HTTP。
|
||||
中转可以是"自己搭的、没有证书"的服务:局域网那台,或者
|
||||
|
||||
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
|
||||
|
||||
$work = Join-Path $android "build"
|
||||
$outDir = Join-Path $root "dist"
|
||||
$apkName = "ykxmz-watch-2.1.6.apk"
|
||||
$apkName = "ykxmz-watch-2.1.8.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.1.6" `
|
||||
--version-name "2.1.8" `
|
||||
--auto-add-overlay
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||
|
||||
|
||||
@@ -300,6 +300,53 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
* 用户要求"手表端连接那个电脑端也可以查看电脑端目前所处的版本"。
|
||||
*/
|
||||
private Button updateButton;
|
||||
|
||||
// ---------------- 顶部快捷栏 ----------------
|
||||
//
|
||||
// 用户要求:"不管干什么,上面都有一栏,栏里的按钮可以在设置页
|
||||
// 自定义,最多可以加 4 个,其中有一个按钮不能改,
|
||||
// 功能就是一键返回桌面"。
|
||||
//
|
||||
// 所以这里是 1 个固定(显示桌面)+ 最多 3 个自定义。
|
||||
private LinearLayout quickBar;
|
||||
/** 设置页里装「快捷栏 / 允许新设备连接」那两小节的容器。
|
||||
* 用专用容器是为了改完只刷这一段,不用重建整页。 */
|
||||
private LinearLayout settingsExtraBox;
|
||||
private final java.util.List<Button> quickButtons =
|
||||
new java.util.ArrayList<Button>();
|
||||
/** 设置页里那 3 个位置各放什么动作。null 表示这个位置空着。 */
|
||||
private String[] quickSlots =
|
||||
new String[]{"file", "mirror", "sfx", "desktop"};
|
||||
|
||||
/** 快捷栏上能放的动作。id 会存进设置,**改名会让老配置失效**,
|
||||
* 所以这几个英文 id 不要动,界面上显示的中文随便改。 */
|
||||
private static final String[][] QUICK_ACTIONS = {
|
||||
{"file", "传文件"},
|
||||
{"mirror", "屏幕镜像"},
|
||||
{"mouse", "鼠标控制"},
|
||||
{"sfx", "音效"},
|
||||
{"power", "关机"},
|
||||
{"tts", "语音播报"},
|
||||
{"image", "投图到电脑"},
|
||||
// 用户最早说这一格"不能改",后来又改成四格都能换 ——
|
||||
// 所以它现在是**普通可选动作**,只是默认放在第 4 格。
|
||||
{"desktop", "显示桌面"},
|
||||
};
|
||||
private static final String QUICK_PREF = "quick_slots";
|
||||
/** 快捷栏一共几格。用户最初要求"最多 4 个",
|
||||
* 后来又改成"第一二三四格都能换东西",所以 4 格都可自定义。 */
|
||||
private static final int QUICK_SLOTS = 4;
|
||||
/** 快捷栏高度(dp)。46 接近 Android 建议的最小可点区域 48。
|
||||
* ⚠ 面板的 topMargin 也用这个值 —— 分两处写的话改一个忘一个,
|
||||
* 表现是"内容被栏盖住"或"上面空一条"。 */
|
||||
private static final int QUICK_BAR_DP = 46;
|
||||
|
||||
// ---------------- 鼠标控制(触摸板)----------------
|
||||
private FrameLayout mousePanel;
|
||||
private View mousePad;
|
||||
private boolean mouseDragging = false;
|
||||
private float mouseLastX = 0f;
|
||||
private float mouseLastY = 0f;
|
||||
private TextView settingsCodeValue;
|
||||
private TextView settingsDevicesValue;
|
||||
private LinearLayout lanResultBox;
|
||||
@@ -367,6 +414,39 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
settingsPanel.setVisibility(View.GONE);
|
||||
connectPanel.setVisibility(View.VISIBLE);
|
||||
|
||||
// ================= 顶部快捷栏 + 鼠标触摸板 =================
|
||||
//
|
||||
// 用户要求:"不管干什么,上面都有一栏"。
|
||||
//
|
||||
// ⚠ 顺序很要紧:
|
||||
// 1. 先建鼠标面板(它也是 root 的子视图,只是平时藏着)
|
||||
// 2. 再建快捷栏
|
||||
// 3. **最后**才 addView —— FrameLayout 里后加的在上层。
|
||||
// 加在面板之前的话会被面板(MATCH_PARENT)整个盖住。
|
||||
//
|
||||
// 第一版我在这里写了两个**不存在**的方法名
|
||||
// (buildMousePanelIfNeeded / mousePanelHolder),
|
||||
// 编译就报 cannot find symbol。教训:写调用之前先 grep 一遍。
|
||||
loadQuickSlots();
|
||||
buildMousePanel(); // 只建,先不显示(showOnly 会切)
|
||||
buildQuickBar();
|
||||
|
||||
// 面板顶部给快捷栏让出一截,否则每页顶部内容会被盖住
|
||||
applyPanelTopMargin(connectPanel);
|
||||
applyPanelTopMargin(controlPanel);
|
||||
applyPanelTopMargin(mirrorPanel);
|
||||
applyPanelTopMargin(lanPanel);
|
||||
applyPanelTopMargin(settingsPanel);
|
||||
applyPanelTopMargin(mousePanel);
|
||||
|
||||
root.addView(mousePanel, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
mousePanel.setVisibility(View.GONE);
|
||||
// 快捷栏最后加 -> 永远在最上层
|
||||
root.addView(quickBar, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP)));
|
||||
|
||||
setContentView(root);
|
||||
|
||||
// 崩溃兜底要放在界面建好之后(它内部要用 ui)
|
||||
@@ -2253,6 +2333,19 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
});
|
||||
box.addView(clearDevices);
|
||||
|
||||
// ================= 快捷栏 / 允许新设备连接 =================
|
||||
//
|
||||
// 这两小节装在**专用容器**里(settingsExtraBox),
|
||||
// 这样改完设置只刷这一段,不用重建整页 ——
|
||||
// 重建整页的话 settingsPanel 是新的对象,而 root 上挂的还是旧的,
|
||||
// 表现就是"一改设置页面就白了"。
|
||||
settingsExtraBox = new LinearLayout(this);
|
||||
settingsExtraBox.setOrientation(LinearLayout.VERTICAL);
|
||||
box.addView(settingsExtraBox, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
fillSettingsExtra();
|
||||
|
||||
// ================= 关于 =================
|
||||
box.addView(sectionTitle("关于"));
|
||||
box.addView(hintText("ykxmz 手表端 版本 " + appVersion()
|
||||
@@ -2314,6 +2407,106 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
settingsDevicesValue.setText("已配对 " + n + " 台电脑");
|
||||
}
|
||||
|
||||
/** 让用户选第 slot 格放哪个动作。 */
|
||||
private void pickQuickSlot(final int slot) {
|
||||
final java.util.List<String> labels = new java.util.ArrayList<String>();
|
||||
final java.util.List<String> ids = new java.util.ArrayList<String>();
|
||||
labels.add("留空(这一格不显示)");
|
||||
ids.add("");
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
labels.add(pair[1]);
|
||||
ids.add(pair[0]);
|
||||
}
|
||||
showChooser("第 " + (slot + 1) + " 格放什么", labels, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index < 0 || index >= ids.size()) {
|
||||
return;
|
||||
}
|
||||
quickSlots[slot] = ids.get(index);
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("第 " + (slot + 1) + " 格已设为:"
|
||||
+ quickLabel(quickSlots[slot]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 电脑端当前的「允许新设备连接」状态。本地记一份,连上后以电脑端为准。 */
|
||||
private boolean allowNewPairing() {
|
||||
return prefs().getBoolean("allow_new_pairing", true);
|
||||
}
|
||||
|
||||
/** 填「快捷栏」和「允许新设备连接」这两小节。刷新时也调它。 */
|
||||
private void fillSettingsExtra() {
|
||||
if (settingsExtraBox == null) {
|
||||
return;
|
||||
}
|
||||
settingsExtraBox.removeAllViews();
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("快捷栏"));
|
||||
settingsExtraBox.addView(hintText("顶部那条栏,4 个格子"
|
||||
+ "**每一个都能换**,点下面自己选。"
|
||||
+ "(还没连上电脑时它们是灰的)"));
|
||||
for (int slot = 0; slot < QUICK_SLOTS; slot++) {
|
||||
final int s = slot;
|
||||
String now = (quickSlots[s] == null || quickSlots[s].isEmpty())
|
||||
? "空着" : quickLabel(quickSlots[s]);
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"第 " + (s + 1) + " 格:" + now, C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
pickQuickSlot(s);
|
||||
}
|
||||
}));
|
||||
}
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"恢复默认(传文件 / 屏幕镜像 / 音效 / 显示桌面)", C_CARD,
|
||||
new Runnable() {
|
||||
public void run() {
|
||||
quickSlots = new String[]{"file", "mirror", "sfx", "desktop"};
|
||||
saveQuickSlots();
|
||||
fillSettingsExtra();
|
||||
toast("快捷栏已恢复默认");
|
||||
}
|
||||
}));
|
||||
|
||||
settingsExtraBox.addView(sectionTitle("允许新设备连接"));
|
||||
settingsExtraBox.addView(hintText("关掉之后,只有已经配对过的电脑"
|
||||
+ "能连上来,新电脑会被拒绝。教室里防别人乱连用这个。"));
|
||||
settingsExtraBox.addView(smallButton(
|
||||
"现在:" + (allowNewPairing() ? "允许"
|
||||
: "不允许(只有老设备能连)"),
|
||||
C_CARD, new Runnable() {
|
||||
public void run() {
|
||||
final boolean want = !allowNewPairing();
|
||||
new android.app.AlertDialog.Builder(MainActivity.this)
|
||||
.setTitle("允许新设备连接")
|
||||
.setMessage(want
|
||||
? "打开后,没配对过的新电脑也能连上。"
|
||||
: "关掉后,只有已经配对过的电脑能连上,"
|
||||
+ "新电脑会被拒绝。")
|
||||
.setPositiveButton(want ? "允许" : "关掉",
|
||||
new android.content.DialogInterface.OnClickListener() {
|
||||
public void onClick(android.content.DialogInterface d,
|
||||
int w) {
|
||||
// 电脑端执行 + 本地记一份(连不上时也能看到选择)
|
||||
runFeature("allow_new_pairing",
|
||||
Json.obj("allow", want),
|
||||
want ? "允许新设备连接"
|
||||
: "只允许老设备连接");
|
||||
prefs().edit()
|
||||
.putBoolean("allow_new_pairing", want)
|
||||
.apply();
|
||||
fillSettingsExtra();
|
||||
}
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private void showSettingsPanel() {
|
||||
refreshSettingsPanel();
|
||||
showOnly(settingsPanel);
|
||||
@@ -2802,9 +2995,439 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
*
|
||||
* <p>以后加面板只需要往这个方法里加一行,所有切换点自动都对。
|
||||
*/
|
||||
// ==================================================================
|
||||
// 顶部快捷栏
|
||||
// ==================================================================
|
||||
|
||||
/**
|
||||
* 建快捷栏。挂在 root 最上层,任何页面都在。
|
||||
*
|
||||
* <p>布局:[动作1][动作2][动作3][显示桌面]
|
||||
* 前三个按设置里的选择走,最后一个**永远**是显示桌面(不给改)。
|
||||
*/
|
||||
/**
|
||||
* 给面板顶部留出快捷栏的高度。
|
||||
*
|
||||
* <p>快捷栏是贴在最上层的常驻元素(用户要求"不管干什么上面都有一栏"),
|
||||
* 面板内容如果不缩进就会被它盖住顶部一截。
|
||||
*
|
||||
* <p>集中在这里改,是因为"每个面板各写一遍高度"很容易漏一个 ——
|
||||
* 表现就是"别的页正常,就某一页顶部被切掉"。
|
||||
*/
|
||||
private void applyPanelTopMargin(View panel) {
|
||||
if (panel == null) {
|
||||
return;
|
||||
}
|
||||
ViewGroup.LayoutParams lp = panel.getLayoutParams();
|
||||
if (lp instanceof FrameLayout.LayoutParams) {
|
||||
FrameLayout.LayoutParams flp = (FrameLayout.LayoutParams) lp;
|
||||
// 用同一个常量 —— 栏高改了这里自动跟着改
|
||||
flp.topMargin = dp(QUICK_BAR_DP);
|
||||
panel.setLayoutParams(flp);
|
||||
}
|
||||
}
|
||||
|
||||
private void buildQuickBar() {
|
||||
quickBar = new LinearLayout(this);
|
||||
quickBar.setOrientation(LinearLayout.HORIZONTAL);
|
||||
quickBar.setBackgroundColor(0xE6185CE8); // 半透明蓝,压在任何页面上都看得清
|
||||
quickBar.setPadding(dp(3), dp(3), dp(3), dp(3));
|
||||
quickBar.setGravity(android.view.Gravity.CENTER_VERTICAL);
|
||||
|
||||
quickButtons.clear();
|
||||
// 4 个按钮:3 个自定义 + 1 个固定
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
final int slot = i;
|
||||
Button b = new Button(this);
|
||||
b.setTextSize(14f); // 11 太小,用户说"按不到"
|
||||
b.setAllCaps(false);
|
||||
b.setTextColor(0xFFFFFFFF);
|
||||
b.setBackgroundColor(0x33FFFFFF);
|
||||
b.setPadding(dp(2), 0, dp(2), 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
b.setMinHeight(0);
|
||||
b.setMinimumHeight(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(2), 0, dp(2), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
onQuickClick(slot);
|
||||
}
|
||||
});
|
||||
quickButtons.add(b);
|
||||
quickBar.addView(b);
|
||||
}
|
||||
// 高度固定,免得按钮里的字多一个字整条栏就变高、页面跟着抖
|
||||
FrameLayout.LayoutParams barLp = new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(QUICK_BAR_DP));
|
||||
barLp.gravity = android.view.Gravity.TOP;
|
||||
quickBar.setLayoutParams(barLp);
|
||||
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按设置刷新快捷栏上的文字和可用状态。
|
||||
*
|
||||
* <p>连不上的时候置灰而不是隐藏 —— 用户说"不管干什么上面都有一栏",
|
||||
* 时有时无会让人以为功能没了。
|
||||
*/
|
||||
private void refreshQuickBar() {
|
||||
if (quickBar == null || quickButtons.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
boolean connected = isConnected();
|
||||
for (int i = 0; i < quickButtons.size(); i++) {
|
||||
Button b = quickButtons.get(i);
|
||||
String id = quickSlots[i];
|
||||
if (id == null || id.isEmpty()) {
|
||||
b.setText("-"); // 这个位置空着
|
||||
b.setEnabled(false);
|
||||
b.setAlpha(0.45f);
|
||||
continue;
|
||||
}
|
||||
b.setText(quickLabel(id));
|
||||
b.setEnabled(connected);
|
||||
b.setAlpha(connected ? 1f : 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
private static String quickLabel(String id) {
|
||||
if ("desktop".equals(id)) {
|
||||
return "显示桌面"; // 固定那个
|
||||
}
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return pair[1];
|
||||
}
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
/** 点第 slot 个按钮。四格完全平等,都从 quickSlots 里取。 */
|
||||
private void onQuickClick(int slot) {
|
||||
if (slot < 0 || slot >= quickSlots.length) {
|
||||
return;
|
||||
}
|
||||
String id = quickSlots[slot];
|
||||
if (id == null || id.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
runQuickAction(id);
|
||||
}
|
||||
|
||||
/** 执行一个快捷动作。设置页里选的也是这几个 id。 */
|
||||
private void runQuickAction(String id) {
|
||||
try {
|
||||
if ("desktop".equals(id)) {
|
||||
// 用户确认过:"一键返回桌面" = 让**被控电脑**显示桌面(Win+D)
|
||||
sendCommand(Json.obj("cmd", "show_desktop"), "显示桌面");
|
||||
return;
|
||||
}
|
||||
if ("file".equals(id)) {
|
||||
pickFile(REQ_FILE, "*/*");
|
||||
return;
|
||||
}
|
||||
if ("mirror".equals(id)) {
|
||||
openMirrorPanel();
|
||||
return;
|
||||
}
|
||||
if ("mouse".equals(id)) {
|
||||
showMousePanel();
|
||||
return;
|
||||
}
|
||||
if ("sfx".equals(id)) {
|
||||
pickSfx();
|
||||
return;
|
||||
}
|
||||
if ("power".equals(id)) {
|
||||
showPowerChooser();
|
||||
return;
|
||||
}
|
||||
if ("tts".equals(id)) {
|
||||
promptSay();
|
||||
return;
|
||||
}
|
||||
if ("image".equals(id)) {
|
||||
pickFile(REQ_IMAGE, "image/*");
|
||||
return;
|
||||
}
|
||||
toast("不认识的动作:" + id);
|
||||
} catch (Throwable error) {
|
||||
toast("执行失败:" + shortReason(error));
|
||||
}
|
||||
}
|
||||
|
||||
/** 关机这一格点开后让用户选锁屏还是关机(不直接关,太危险)。 */
|
||||
private void showPowerChooser() {
|
||||
final java.util.List<String> items = new java.util.ArrayList<String>();
|
||||
items.add("锁屏");
|
||||
items.add("关机");
|
||||
items.add("重启");
|
||||
showChooser("电源", items, new Chooser() {
|
||||
@Override
|
||||
public void onPick(int index) {
|
||||
if (index == 0) {
|
||||
confirmPower("lock", "锁屏");
|
||||
} else if (index == 1) {
|
||||
confirmPower("shutdown", "关机");
|
||||
} else {
|
||||
confirmPower("restart", "重启");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------- 快捷栏的存取 ----------------
|
||||
|
||||
private void loadQuickSlots() {
|
||||
String raw = prefs().getString(QUICK_PREF, "");
|
||||
String[] want = (raw == null || raw.isEmpty())
|
||||
? new String[]{"file", "mirror", "sfx", "desktop"}
|
||||
: raw.split(",", -1);
|
||||
// ⚠ 一定要过滤 + 补齐:
|
||||
// 过滤 —— 万一以后删了某个动作,老配置里的 id 就不认识了,
|
||||
// 不过滤的话那格会显示空白,用户以为坏了
|
||||
// 补齐 —— 保证长度正好 3,少了下标会越界
|
||||
String[] out = new String[QUICK_SLOTS];
|
||||
int n = 0;
|
||||
for (String id : want) {
|
||||
if (n >= QUICK_SLOTS) {
|
||||
break;
|
||||
}
|
||||
// ⚠ 这里**不再排除 desktop** —— 用户改成四格都能换了
|
||||
if (id != null && !id.isEmpty() && isKnownQuickAction(id)) {
|
||||
out[n++] = id;
|
||||
}
|
||||
}
|
||||
String[] fallback = {"file", "mirror", "sfx", "desktop"};
|
||||
for (int i = 0; i < QUICK_SLOTS; i++) {
|
||||
if (out[i] == null) {
|
||||
// 补一个没被用过的默认动作
|
||||
for (String f : fallback) {
|
||||
boolean used = false;
|
||||
for (String o : out) {
|
||||
if (f.equals(o)) {
|
||||
used = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!used) {
|
||||
out[i] = f;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
quickSlots = out;
|
||||
}
|
||||
|
||||
private static boolean isKnownQuickAction(String id) {
|
||||
for (String[] pair : QUICK_ACTIONS) {
|
||||
if (pair[0].equals(id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void saveQuickSlots() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String id : quickSlots) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append(',');
|
||||
}
|
||||
sb.append(id == null ? "" : id);
|
||||
}
|
||||
prefs().edit().putString(QUICK_PREF, sb.toString()).apply();
|
||||
refreshQuickBar();
|
||||
}
|
||||
|
||||
private android.content.SharedPreferences prefs() {
|
||||
return getSharedPreferences("ykxmz", MODE_PRIVATE);
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 鼠标控制(触摸板)
|
||||
// ==================================================================
|
||||
|
||||
private void showMousePanel() {
|
||||
if (mousePanel == null) {
|
||||
buildMousePanel();
|
||||
}
|
||||
showOnly(mousePanel);
|
||||
appendLog("鼠标控制:拖动=移动光标,单击=左键,双击=右键");
|
||||
toast("拖动移动光标 · 单击左键 · 双击右键");
|
||||
}
|
||||
|
||||
/**
|
||||
* 触摸板。整块区域接收触摸,按比例映射到电脑屏幕的像素坐标。
|
||||
*
|
||||
* <p>为什么用"按比例映射"而不是"相对位移":
|
||||
* 手表表盘和电脑屏幕尺寸差得远,相对位移要让手表自己猜倍率,
|
||||
* 猜错了光标就飘;绝对映射由电脑端负责落到那个像素,
|
||||
* 责任清晰,也不会累积漂移。
|
||||
*
|
||||
* <p>但表盘太小,绝对映射的精度只有几十像素 —— 所以
|
||||
* **双击左右半屏**这类大幅移动之后,用拖动做细调更实际。
|
||||
* 这里两个都支持:拖动 = 相对微调,点击 = 在当前位置按下/抬起。
|
||||
*/
|
||||
private void buildMousePanel() {
|
||||
mousePanel = new FrameLayout(this);
|
||||
mousePanel.setBackgroundColor(0xFF101418);
|
||||
|
||||
LinearLayout col = new LinearLayout(this);
|
||||
col.setOrientation(LinearLayout.VERTICAL);
|
||||
col.setPadding(dp(6), dp(34), dp(6), dp(4));
|
||||
mousePanel.addView(col, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
TextView tip = new TextView(this);
|
||||
tip.setText("拖动=移动 单击=左键 双击=右键\n滚轮在下面");
|
||||
tip.setTextSize(10f);
|
||||
tip.setTextColor(0xFF9AA4B2);
|
||||
tip.setGravity(android.view.Gravity.CENTER);
|
||||
col.addView(tip, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||
|
||||
mousePad = new View(this);
|
||||
mousePad.setBackgroundColor(0xFF1B2028);
|
||||
LinearLayout.LayoutParams padLp = new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f);
|
||||
padLp.setMargins(0, dp(4), 0, dp(4));
|
||||
col.addView(mousePad, padLp);
|
||||
|
||||
// 左键 / 右键 / 滚轮
|
||||
LinearLayout keys = new LinearLayout(this);
|
||||
keys.setOrientation(LinearLayout.HORIZONTAL);
|
||||
col.addView(keys, new LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, dp(34)));
|
||||
|
||||
keys.addView(mouseKey("左键", new Runnable() {
|
||||
public void run() { sendMouse("click", "left", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("右键", new Runnable() {
|
||||
public void run() { sendMouse("click", "right", 0); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↑", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", 3); }
|
||||
}));
|
||||
keys.addView(mouseKey("滚↓", new Runnable() {
|
||||
public void run() { sendMouse("scroll", "", -3); }
|
||||
}));
|
||||
|
||||
// 返回按钮(快捷栏上也能离开,但这里给个明确的出口)
|
||||
Button back = new Button(this);
|
||||
back.setText("返回");
|
||||
back.setTextSize(11f);
|
||||
back.setAllCaps(false);
|
||||
FrameLayout.LayoutParams backLp = new FrameLayout.LayoutParams(
|
||||
dp(58), dp(26));
|
||||
backLp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
|
||||
backLp.setMargins(0, dp(4), dp(4), 0);
|
||||
back.setLayoutParams(backLp);
|
||||
back.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
showControlPanel();
|
||||
}
|
||||
});
|
||||
mousePanel.addView(back);
|
||||
|
||||
// ---- 触摸处理 ----
|
||||
final android.view.GestureDetector detector =
|
||||
new android.view.GestureDetector(this,
|
||||
new android.view.GestureDetector.SimpleOnGestureListener() {
|
||||
@Override
|
||||
public boolean onSingleTapConfirmed(android.view.MotionEvent e) {
|
||||
sendMouse("click", "left", 0); // 单击 = 左键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onDoubleTap(android.view.MotionEvent e) {
|
||||
sendMouse("click", "right", 0); // 双击 = 右键
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onScroll(android.view.MotionEvent e1,
|
||||
android.view.MotionEvent e2,
|
||||
float dx, float dy) {
|
||||
// 手指往哪滑,光标往哪走:dx/dy 是"内容移动量",
|
||||
// 也就是手指移动的反方向,所以取负。
|
||||
sendMouseMove(-dx, -dy);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
mousePad.setOnTouchListener(new View.OnTouchListener() {
|
||||
@Override
|
||||
public boolean onTouch(View v, android.view.MotionEvent e) {
|
||||
detector.onTouchEvent(e);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Button mouseKey(String text, final Runnable action) {
|
||||
Button b = new Button(this);
|
||||
b.setText(text);
|
||||
b.setTextSize(10f);
|
||||
b.setAllCaps(false);
|
||||
b.setPadding(0, 0, 0, 0);
|
||||
b.setMinWidth(0);
|
||||
b.setMinimumWidth(0);
|
||||
LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
|
||||
0, ViewGroup.LayoutParams.MATCH_PARENT, 1f);
|
||||
lp.setMargins(dp(1), 0, dp(1), 0);
|
||||
b.setLayoutParams(lp);
|
||||
b.setOnClickListener(new View.OnClickListener() {
|
||||
@Override
|
||||
public void onClick(View v) {
|
||||
action.run();
|
||||
}
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
private void sendMouse(String action, String button, int amount) {
|
||||
if (!isConnected()) {
|
||||
toast("还没连上电脑");
|
||||
return;
|
||||
}
|
||||
Map<String, Object> req = Json.obj("cmd", "mouse", "action", action);
|
||||
if (button != null && !button.isEmpty()) {
|
||||
req.put("button", button);
|
||||
}
|
||||
if (amount != 0) {
|
||||
req.put("amount", amount);
|
||||
}
|
||||
sendCommand(req, "鼠标");
|
||||
}
|
||||
|
||||
private void sendMouseMove(float dx, float dy) {
|
||||
if (!isConnected()) {
|
||||
return;
|
||||
}
|
||||
// 手表上滑一点,电脑上要走一段 —— 倍率小了手指要划好几次才横穿屏幕。
|
||||
// 取 3.0 是试出来的:1920 宽的屏大约划 4~5 次到底,细调也还行。
|
||||
sendCommand(Json.obj("cmd", "mouse", "action", "moveby",
|
||||
"dx", (int) (dx * 3.0f), "dy", (int) (dy * 3.0f)), "鼠标");
|
||||
}
|
||||
|
||||
private void showOnly(View keep) {
|
||||
// ⚠ 新增面板必须加进这个数组 —— 漏了的话那个面板永远不隐藏,
|
||||
// 表现是"进了别的页面它还压在上面"。
|
||||
// (注释里那条"漏了 lanPanel 导致局域网连上没反应"就是这个坑。)
|
||||
View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel,
|
||||
settingsPanel};
|
||||
settingsPanel, mousePanel};
|
||||
for (View v : all) {
|
||||
if (v != null) {
|
||||
v.setVisibility(v == keep ? View.VISIBLE : View.GONE);
|
||||
@@ -3372,6 +3995,18 @@ public class MainActivity extends Activity implements RemoteClient.Listener {
|
||||
appendLog("已断开");
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前连没连上被控端。
|
||||
*
|
||||
* <p>⚠ 这个方法是我加快捷栏时才补的 —— 之前代码里到处写
|
||||
* `client.isConnected()`,我照着直觉调了个 `isConnected()`,
|
||||
* 结果编译才发现它不存在。现在包一层,以后判断连接状态统一走这里
|
||||
* (空指针也一并挡住了)。
|
||||
*/
|
||||
private boolean isConnected() {
|
||||
return client != null && client.isConnected();
|
||||
}
|
||||
|
||||
private boolean ensureConnected() {
|
||||
if (!client.isConnected()) {
|
||||
toast("尚未连接");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
@@ -197,7 +197,7 @@
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
2.1.6
|
||||
2.1.8
|
||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||
合计 10 条连接),一种协议犯病另一种还在服务
|
||||
@@ -218,6 +218,6 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
|
||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||
@@ -164,6 +164,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
继续安装,即表示你已阅读并同意《许可协议》。
|
||||
--------------------------------------------------------
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
生效日期:2026 年 10 月 5 日
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
> 都不许拿去做下面第三条那些事。**
|
||||
>
|
||||
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
||||
> 那和 AGPL 冲突,所以 2.1.6 起拆开了:代码授权归 LICENSE,
|
||||
> 那和 AGPL 冲突,所以 2.1.8 起拆开了:代码授权归 LICENSE,
|
||||
> 使用约束归这份文件。)
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
|
||||
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
||||
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||
|
||||
+40
-10
@@ -4,19 +4,49 @@
|
||||
|
||||
---
|
||||
|
||||
## 一、发布流程:先给用户,用户验完才推送
|
||||
## 一、发布流程:按「预案」执行
|
||||
|
||||
> 用户原话:「你以后就这样,你先把所有东西给我,我运行没问题再告诉你帮我推送」
|
||||
> 用户原话:「以后我会说按照预案几处理」
|
||||
>
|
||||
> 用户原话(预案一):「做完以后测试,没问题上传」
|
||||
> 用户原话(预案二):「做完以后测试,测试完以后别上传,直接给我」
|
||||
|
||||
**每次发版必须按这个顺序:**
|
||||
**用户会明确说"按预案几",说了就按那个走。**
|
||||
|
||||
1. 本地把**所有产物**构建完成
|
||||
2. 跑测试(全量回归 + 安装包周期 + 发布验收)
|
||||
3. **把产物交给用户**去验证
|
||||
4. **等用户明确说「可以推」之后**,才创建 Gitee 发布 / 上传发布附件
|
||||
| 预案 | 做什么 |
|
||||
|---|---|
|
||||
| **预案一** | 做完 → **测试** → 没问题 → **上传**(创建 Gitee 发布 + 传附件 + 部署到服务器) |
|
||||
| **预案二** | 做完 → **测试** → **不上传**,产物交给用户 |
|
||||
|
||||
❌ **绝对不能**自己构建完就直接建发布。
|
||||
✅ 代码提交到 `main` 是可以的(那不是"发布")。
|
||||
### 两个预案共同的部分(都必须做)
|
||||
|
||||
1. 本地把**所有产物**构建完成(APK / 安装包 / 服务端包)
|
||||
2. 跑测试:全量回归 + 安装包周期 + 发布验收
|
||||
3. **测试不绿就停**,先说清楚哪儿红了 —— 不许带着红测试交付
|
||||
|
||||
### 只有预案一才做的部分
|
||||
|
||||
4. 提交 `main` + 推送
|
||||
5. 创建 Gitee 发布、上传三个附件
|
||||
6. 部署到服务器(`_build/deploy_lightsail_release.py`),并**实测**线上:
|
||||
|
||||
- `/v1/update/latest` 返回的版本 = 刚发的
|
||||
- 官网角标 = 刚发的
|
||||
- 安装包能下(看真实字节数)
|
||||
- `/v1/health` 正常
|
||||
|
||||
### 用户没说预案几的时候
|
||||
|
||||
**默认按预案一**(那是长期以来的既有习惯),但要在交付时**说明**
|
||||
"我按预案一做了,已经上传",让用户有机会纠正。
|
||||
|
||||
### 历史(这条规矩怎么来的)
|
||||
|
||||
最早用户说「你先把所有东西给我,我运行没问题再告诉你帮我推送」,
|
||||
中间放宽过一次(「你自己上传就行了」),后来又要"自己测完再上传"。
|
||||
现在固化成两个预案,由用户每次指定。
|
||||
|
||||
**仍然绝对不做的**:不测就上传。
|
||||
|
||||
---
|
||||
|
||||
@@ -33,7 +63,7 @@
|
||||
- **我(AI)在对话里给出的总结、汇报、方案**
|
||||
- 任何我会贴给用户看、或用户可能转给别人的材料
|
||||
|
||||
**只能提官网 `https://relay.xxmxxs.top`。**
|
||||
**只能提官网 `https://yk.xxmxxs.top`。**
|
||||
|
||||
**不要说**:`/zhongzhuanzhan`、`/admin`、"管理后台"、"管理页"、
|
||||
"管理入口"、管理密码之类。
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
生效日期:2026 年 10 月 5 日
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
> 都不许拿去做下面第三条那些事。**
|
||||
>
|
||||
> (早先这版文件写的是"软件许可协议",里面有一条"不得修改"——
|
||||
> 那和 AGPL 冲突,所以 2.1.6 起拆开了:代码授权归 LICENSE,
|
||||
> 那和 AGPL 冲突,所以 2.1.8 起拆开了:代码授权归 LICENSE,
|
||||
> 使用约束归这份文件。)
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
|
||||
如果你不同意本条款的任何一条,请不要安装或使用本软件。
|
||||
代码授权条款见仓库根目录 LICENSE(GNU AGPL-3.0)。
|
||||
@@ -173,7 +173,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
|
||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||
@@ -334,7 +334,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
继续安装,即表示你已阅读并同意《许可协议》。
|
||||
--------------------------------------------------------
|
||||
|
||||
@@ -345,7 +345,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:2.1.6
|
||||
版本:2.1.8
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
@@ -539,7 +539,7 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
2.1.6
|
||||
2.1.8
|
||||
* 下载改走 Cloudflare 边缘缓存 —— 安装包和 APK 快了几十倍
|
||||
* 跨网络更稳:隧道**两种协议同时跑**(QUIC + HTTP/2,
|
||||
合计 10 条连接),一种协议犯病另一种还在服务
|
||||
@@ -560,6 +560,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 2.1.6
|
||||
软件:ykxmz 2.1.8
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@
|
||||
; ============================================================================
|
||||
|
||||
#define MyAppName "ykxmz"
|
||||
#define MyAppVersion "2.1.6"
|
||||
#define MyAppVersion "2.1.8"
|
||||
#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.1.6.exe /VERYSILENT /CODE=123456
|
||||
; cmd /c ykxmz-setup-2.1.8.exe /VERYSILENT /CODE=123456
|
||||
; cmd 只等安装器本身,不等孙子进程,不会卡。
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||
|
||||
+51
-2
@@ -268,7 +268,7 @@ def _device_rows(devices: list) -> str:
|
||||
if not devices:
|
||||
# 图标用内联 SVG 而不是 emoji ——
|
||||
# emoji 依赖系统字体,无头浏览器/精简系统里会渲染成方框。
|
||||
return ('<tr><td colspan="8" class="empty">'
|
||||
return ('<tr><td colspan="9" class="empty">'
|
||||
'<svg width="34" height="34" viewBox="0 0 24 24" fill="none" '
|
||||
'stroke="currentColor" stroke-width="1.5" '
|
||||
'stroke-linecap="round" stroke-linejoin="round" '
|
||||
@@ -296,6 +296,9 @@ def _device_rows(devices: list) -> str:
|
||||
'<span class="dot %s"></span>%s</div>'
|
||||
'<div class="devid">%s</div></td>'
|
||||
'<td data-l="配对码"><code>%s</code></td>'
|
||||
# 版本列:用户要求"手表端连接那个电脑端也可以查看电脑端
|
||||
# 目前所处的版本",后台这边同理 —— 一眼看出哪台该升级。
|
||||
'<td data-l="版本"><span class="mono">%s</span></td>'
|
||||
'<td data-l="状态"><span class="pill %s">%s</span></td>'
|
||||
'<td data-l="空闲"><span class="mono">%ss</span></td>'
|
||||
'<td data-l="待发 表/机"><span class="mono">%d / %d</span></td>'
|
||||
@@ -306,17 +309,24 @@ def _device_rows(devices: list) -> str:
|
||||
# 这里要是也带 data-device 就会被当成删除按钮。
|
||||
'<button class="ghost sm" data-recode="%s" '
|
||||
'data-name="%s" data-code="%s">改码</button>'
|
||||
# 「更新」只对在线设备有意义(要给它发命令)。
|
||||
# 离线的话按钮置灰并说明原因,而不是点了没反应。
|
||||
'<button class="ghost sm" data-update="%s" '
|
||||
'data-name="%s" data-code="%s"%s>更新</button>'
|
||||
'<button class="danger sm" '
|
||||
'data-device="%s" data-name="%s" data-code="%s">删除</button></td>'
|
||||
'</tr>' % (
|
||||
did, name,
|
||||
"on" if online else "off", name, did,
|
||||
code,
|
||||
_esc(device.get("version") or "—"),
|
||||
"on" if online else "off", "在线" if online else "离线",
|
||||
device["idleSeconds"],
|
||||
device["queuedToClient"], device["queuedToAgent"],
|
||||
_kb(device["bytesIn"]), _kb(device["bytesOut"]),
|
||||
did, name, code,
|
||||
did, name, code,
|
||||
"" if online else ' disabled title="设备离线,连不上"',
|
||||
did, name, code)
|
||||
)
|
||||
return "".join(out)
|
||||
@@ -407,6 +417,45 @@ document.querySelectorAll('button[data-recode]').forEach(b => {
|
||||
});
|
||||
|
||||
// ---------------- 单台删除 ----------------
|
||||
// ---- 「更新」按钮:让那台电脑自己静默升级 ----
|
||||
//
|
||||
// 和手表那边走的是同一条命令,只是入口不同。
|
||||
// 离线设备的按钮在 HTML 里就带 disabled 了,所以这里不用再判。
|
||||
document.querySelectorAll('button[data-update]').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const name = btn.dataset.name || '这台电脑';
|
||||
// ⚠ 这里**不能用反斜杠 n** 拼多行文案。
|
||||
// ADMIN_JS 是 Python 三引号字符串,源码里写反斜杠 n 会变成
|
||||
// **真的换行**插进 JS,于是字符串字面量跨行 —— JS 语法非法,
|
||||
// 整个 script 块解析失败,管理页所有按钮全死。
|
||||
// (回归里的「管理页 JS 合法性」就是专门抓这个的。)
|
||||
//
|
||||
// 用 String.fromCharCode(10) 完全不依赖转义层,
|
||||
// 以后谁改这段都不会再踩。
|
||||
const NL = String.fromCharCode(10);
|
||||
const msg = ['让「' + name + '」现在更新到最新版?', '',
|
||||
'它会自己下载、静默安装并重启,全程不弹窗口,',
|
||||
'中转地址、配对码和各项设置都不变。'].join(NL);
|
||||
if (!confirm(msg)) {
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
const old = btn.textContent;
|
||||
btn.textContent = '下发中…';
|
||||
try {
|
||||
const r = await post('/v1/admin/update_device',
|
||||
{ deviceId: btn.dataset.update });
|
||||
toast((r && r.message) || '已下发更新命令', 'ok');
|
||||
// 更新要跑一阵子,不立刻刷新列表(刷新了也看不出变化)
|
||||
} catch (e) {
|
||||
toast('下发失败:' + (e && e.message ? e.message : e), 'err');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
document.querySelectorAll('button.danger[data-device]').forEach(b => {
|
||||
b.onclick = async () => {
|
||||
const name = b.dataset.name, code = b.dataset.code;
|
||||
@@ -729,7 +778,7 @@ def render_admin(snap: dict, public: str, admin_path: str, codes: int,
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:38px"><input type="checkbox" id="pickAll" title="全选"></th>
|
||||
<th>设备</th><th>配对码</th><th>状态</th>
|
||||
<th>设备</th><th>配对码</th><th>版本</th><th>状态</th>
|
||||
<th>空闲</th><th>待发 表/机</th><th>流量 入/出</th><th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
|
||||
+53
-1
@@ -60,7 +60,7 @@ from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "2.1.6"
|
||||
VERSION = "2.1.8"
|
||||
|
||||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||||
|
||||
@@ -833,6 +833,40 @@ class Relay:
|
||||
return {"ok": True, "code": new_code,
|
||||
"oldCode": old_code, "kicked": kicked}
|
||||
|
||||
def request_update(self, device_id: str) -> dict:
|
||||
"""让某台被控端**现在自己静默升级**。
|
||||
|
||||
做两件事:把 {cmd:update, action:start} 塞进它的下发队列,
|
||||
于是它下一次长轮询就会拿到并开始更新。
|
||||
|
||||
和手表那边走的是**同一条命令**,只是入口不同 ——
|
||||
不给后台触发单独写一套逻辑,少一套就少一处不一致。
|
||||
|
||||
几个判断:
|
||||
* 设备不存在 -> 明确说没有(而不是静默成功)
|
||||
* 设备离线 -> 直接拒。命令只会挂在队列里等它上线,
|
||||
而那时用户早忘了自己点过,突然重启很莫名其妙。
|
||||
* 队列里已经有更新命令 -> 不重复塞(重复点会堆积)
|
||||
"""
|
||||
device_id = str(device_id or "").strip()
|
||||
payload = b'{"cmd":"update","action":"start"}' + b"\n"
|
||||
with self.lock:
|
||||
device = self.devices.get(device_id)
|
||||
if device is None:
|
||||
return {"ok": False, "error": "没有这台设备(可能已下线)"}
|
||||
if not device.online():
|
||||
return {"ok": False, "error": "设备离线,连不上它"}
|
||||
if payload in bytes(device.to_agent):
|
||||
return {"ok": True,
|
||||
"message": "这台电脑的更新已经在排队了"}
|
||||
try:
|
||||
device.push_to_agent(payload)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"ok": False, "error": "下发失败:%s" % exc}
|
||||
return {"ok": True,
|
||||
"message": "已让这台电脑开始更新:它会自己下载、"
|
||||
"静默安装并重启,设置和配对码都不变"}
|
||||
|
||||
def device_by_token(self, token: str) -> Device | None:
|
||||
if not token:
|
||||
return None
|
||||
@@ -1838,6 +1872,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if path == "/v1/admin/devices":
|
||||
self._json(200, RELAY.snapshot())
|
||||
return
|
||||
|
||||
self._json(404, {"ok": False, "error": "没有这个管理接口"})
|
||||
return
|
||||
|
||||
@@ -2098,6 +2133,23 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(200 if result.get("ok") else 400, result)
|
||||
return
|
||||
|
||||
# ---- 让某台电脑自己静默升级 ----
|
||||
#
|
||||
# ⚠ 这个必须放在 **_route_post** 里(不是 do_GET)——
|
||||
# 管理页的 post() 助手发的是 POST。第一版我按
|
||||
# "没有这个管理接口" 这个字符串找锚点,结果匹配到了
|
||||
# do_GET 里那个同名的 404 兜底,路由落错块里了:
|
||||
# curl 直接 404,管理页点了也没反应。
|
||||
#
|
||||
# 教训:这个文件里有**两处**一模一样的 404 兜底字符串,
|
||||
# 拿它当锚点是不安全的。改成按后面的 set_code 定位。
|
||||
if path == "/v1/admin/update_device":
|
||||
body = self._json_body()
|
||||
result = RELAY.request_update(
|
||||
str(body.get("deviceId") or ""))
|
||||
self._json(200 if result.get("ok") else 400, result)
|
||||
return
|
||||
|
||||
self._json(404, {"ok": False, "error": "没有这个管理接口"})
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网遥控 · Windows 被控端。"""
|
||||
|
||||
__version__ = "2.1.6"
|
||||
__version__ = "2.1.8"
|
||||
APP_NAME = "ykxmz"
|
||||
|
||||
+180
-34
@@ -538,59 +538,205 @@ class Actions:
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_voices(self) -> list[str]:
|
||||
"""列出系统可用的 TTS 音色。"""
|
||||
"""列出系统可用的 TTS 音色。
|
||||
|
||||
用 SAPI5 COM 列(不依赖 .NET)—— Win7 上没装 .NET 3.0 的话
|
||||
System.Speech 那条路列不出来,而 SAPI5 一定有。
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return []
|
||||
script = (
|
||||
ps = self._powershell_path()
|
||||
if not ps:
|
||||
return []
|
||||
out = []
|
||||
for script in (
|
||||
# 优先 SAPI5 COM
|
||||
"$v = New-Object -ComObject SAPI.SpVoice;"
|
||||
"$v.GetVoices() | ForEach-Object { $_.GetDescription() }",
|
||||
# 退到 System.Speech
|
||||
"Add-Type -AssemblyName System.Speech;"
|
||||
"(New-Object System.Speech.Synthesis.SpeechSynthesizer)"
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }"
|
||||
)
|
||||
".GetInstalledVoices() | ForEach-Object { $_.VoiceInfo.Name }",
|
||||
):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ps, "-NoProfile", "-Command", script],
|
||||
capture_output=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
out = [ln.strip() for ln in (proc.stdout or "").splitlines()
|
||||
if ln.strip()]
|
||||
except Exception: # noqa: BLE001
|
||||
out = []
|
||||
if out:
|
||||
return out
|
||||
return []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 语音播报(系统自带 TTS)
|
||||
# ------------------------------------------------------------------
|
||||
#
|
||||
# ⚠ 这里有一套**降级链**,不是随便写的。原因见文件末尾的说明,
|
||||
# 简单说:Win7 上的 PowerShell 2.0 + 中文 + PATH 三样都可能出问题,
|
||||
# 而 cscript + SAPI5 是最老最稳的组合(从 XP 就有)。
|
||||
#
|
||||
# 顺序:cscript -> wscript -> ps(绝对路径)+SAPI COM
|
||||
# -> ps(绝对路径)+System.Speech -> ps(PATH)+System.Speech
|
||||
|
||||
def _powershell_path(self) -> str:
|
||||
"""找 powershell.exe。**先按绝对路径**,PATH 只作兜底。
|
||||
|
||||
为什么:Win7 上 PATH 里不一定有,而 CreateProcess 按名字找不到时
|
||||
是**静默失败**的(进程根本没起来,但 Popen 不报错)。
|
||||
"""
|
||||
import shutil as _sh
|
||||
cand = Path(os.environ.get("SystemRoot", r"C:\Windows")) / \
|
||||
"System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
||||
if os.name == "nt" and cand.is_file():
|
||||
return str(cand)
|
||||
return _sh.which("powershell") or ""
|
||||
|
||||
def _write_vbs(self, text: str, voice: str, rate: int) -> Path:
|
||||
"""生成一个播报用的 .vbs。
|
||||
|
||||
⚠ 必须用 **mbcs(系统 ANSI 代码页)** 写 ——
|
||||
cscript 读 .vbs 是按 ANSI 读的,写成 UTF-8 中文就是乱码。
|
||||
"""
|
||||
import tempfile
|
||||
lines = ['Dim v', 'Set v = CreateObject("SAPI.SpVoice")']
|
||||
if voice:
|
||||
# 按描述匹配音色。找不到就 silently 用默认 ——
|
||||
# 音色选不上不该让整条播报失败。
|
||||
lines.append('Dim i')
|
||||
lines.append('For i = 0 To v.GetVoices().Count - 1')
|
||||
lines.append(' If InStr(v.GetVoices().Item(i).GetDescription(), "%s") > 0 Then'
|
||||
% voice.replace('"', '""'))
|
||||
lines.append(' Set v.Voice = v.GetVoices().Item(i)')
|
||||
lines.append(' End If')
|
||||
lines.append('Next')
|
||||
if rate:
|
||||
# SAPI 的 Rate 范围是 -10..10,跟我们的设置一致
|
||||
lines.append("v.Rate = %d" % max(-10, min(10, rate)))
|
||||
# 长文本 VBScript 单行有长度限制,所以**分句拼接**
|
||||
chunk = text.replace('"', '""').replace("\r", " ").replace("\n", " ")
|
||||
while chunk:
|
||||
piece, chunk = chunk[:400], chunk[400:]
|
||||
lines.append('v.Speak "%s"' % piece)
|
||||
path = Path(tempfile.gettempdir()) / "ykxmz_say.vbs"
|
||||
path.write_bytes(("\r\n".join(lines) + "\r\n").encode("mbcs",
|
||||
errors="replace"))
|
||||
return path
|
||||
|
||||
def _tts_commands(self, text: str, voice: str, rate: int) -> list:
|
||||
"""按"依赖越少越优先"排出所有可行方案,返回 [(名字, 命令行)]。"""
|
||||
cmds = []
|
||||
vbs = None
|
||||
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()]
|
||||
vbs = self._write_vbs(text, voice, rate)
|
||||
except Exception: # noqa: BLE001
|
||||
vbs = None
|
||||
if vbs is not None:
|
||||
# 1 / 2:完全不依赖 PowerShell 和 .NET
|
||||
cmds.append(("cscript", ["cscript", "//nologo", str(vbs)]))
|
||||
cmds.append(("wscript", ["wscript", "//nologo", str(vbs)]))
|
||||
|
||||
ps = self._powershell_path()
|
||||
esc = text.replace("'", "''")
|
||||
if ps:
|
||||
# 3:绕开 .NET,只用系统原生 SAPI5
|
||||
sapi = ["$v = New-Object -ComObject SAPI.SpVoice;"]
|
||||
if voice:
|
||||
sapi.append("foreach ($o in $v.GetVoices()) { "
|
||||
"if ($o.GetDescription() -like '*%s*') { "
|
||||
"$v.Voice = $o } };" % voice.replace("'", "''"))
|
||||
if rate:
|
||||
sapi.append("$v.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sapi.append("$v.Speak('%s')" % esc)
|
||||
cmds.append(("ps-sapi", [ps, "-NoProfile", "-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sapi)]))
|
||||
|
||||
# 4:原来的做法,但用绝对路径
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-speech", [ps, "-NoProfile", "-WindowStyle",
|
||||
"Hidden", "-Command", "".join(sp)]))
|
||||
|
||||
# 5:最原始的(PATH + System.Speech),兜底
|
||||
import shutil as _sh
|
||||
if _sh.which("powershell"):
|
||||
sp = ["Add-Type -AssemblyName System.Speech;",
|
||||
"$s = New-Object System.Speech.Synthesis.SpeechSynthesizer;"]
|
||||
if voice:
|
||||
sp.append("try { $s.SelectVoice('%s') } catch { };"
|
||||
% voice.replace("'", "''"))
|
||||
if rate:
|
||||
sp.append("$s.Rate = %d;" % max(-10, min(10, rate)))
|
||||
sp.append("$s.Speak('%s')" % esc)
|
||||
cmds.append(("ps-path", ["powershell", "-NoProfile",
|
||||
"-WindowStyle", "Hidden",
|
||||
"-Command", "".join(sp)]))
|
||||
return cmds
|
||||
|
||||
def say(self, text: str, voice: str = "", rate: int = 0) -> str:
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。"""
|
||||
"""语音播报。voice 为空则用系统默认(或设置里的默认音色)。
|
||||
|
||||
⚠ 会**等播报结束**并看退出码,失败就抛错。
|
||||
原来用 Popen 不等待,失败了也返回"已播报"—— 那是骗人。
|
||||
代价是同步等待(念多久等多久),但"报成功其实没出声"
|
||||
比"慢一点"糟糕得多。
|
||||
"""
|
||||
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()
|
||||
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)
|
||||
cmds = self._tts_commands(text, chosen, speed)
|
||||
if not cmds:
|
||||
raise ActionError("这台电脑上找不到可用的语音播报方式"
|
||||
"(没有 cscript 也没有 powershell?)")
|
||||
|
||||
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 '默认音色'})"
|
||||
# 上次成功的那条优先 —— 不然每播一次都要把几条全试一遍
|
||||
cached = globals().get("_TTS_LAST_OK")
|
||||
if cached:
|
||||
cmds.sort(key=lambda c: 0 if c[0] == cached else 1)
|
||||
|
||||
tried = []
|
||||
for name, cmd in cmds:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd, capture_output=True, timeout=180,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
encoding="mbcs", errors="replace")
|
||||
except FileNotFoundError:
|
||||
tried.append("%s(没有这个程序)" % name)
|
||||
continue
|
||||
except subprocess.TimeoutExpired:
|
||||
tried.append("%s(超时)" % name)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
tried.append("%s(%s)" % (name, type(exc).__name__))
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
globals()["_TTS_LAST_OK"] = name
|
||||
return "已播报(%s,用 %s)" % (chosen or "默认音色", name)
|
||||
err = ((proc.stderr or "") + (proc.stdout or "")).strip()
|
||||
tried.append("%s(退出码%d %s)" % (name, proc.returncode, err[:40]))
|
||||
|
||||
raise ActionError("语音播报失败,试过:" + ";".join(tried))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 播放本机音频文件(MP3 等)
|
||||
|
||||
@@ -47,6 +47,26 @@ DEFAULT_SETTINGS = {
|
||||
# 取 12:够顺,又给抓屏留了余量,不至于把 CPU 占满。
|
||||
# (画面静止时会自动降频,见 screen.py 的 _loop)
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -658,6 +658,28 @@ class Features:
|
||||
entry = stack.pop()
|
||||
return self._apply_undo(entry)
|
||||
|
||||
def _do_allow_new_pairing(self, params) -> str:
|
||||
"""远程开关「允许新设备连接」。
|
||||
|
||||
手表端和设置窗口里那个勾选框是**同一件事**:
|
||||
关掉之后,没配对过的新设备会被拒。
|
||||
|
||||
为什么做成远程可改:老师在教室里,可能整节课都不回电脑前面
|
||||
—— 想临时允许/禁止别人连,总不能跑回讲台点鼠标。
|
||||
|
||||
⚠ 这个开关只影响**配对新设备**,已经在用着的会话不受影响。
|
||||
否则老师一点"禁止",自己手里正连着的那台也被踢掉,那就成了"自杀开关"。
|
||||
"""
|
||||
want = params.get("allow")
|
||||
if want is None:
|
||||
# 没给参数就报告当前状态(方便手表端先查再改)
|
||||
now = bool(self.store.get("allow_new_pairing", True))
|
||||
return f"新设备连接现在是{'允许' if now else '不允许'}"
|
||||
want = bool(want)
|
||||
self.store.set("allow_new_pairing", want)
|
||||
return ("已允许新设备连接" if want
|
||||
else "已禁止新设备连接(只有配对过的设备能连)")
|
||||
|
||||
def _do_undo_all(self, params) -> str:
|
||||
stack = self._undo_stack()
|
||||
if not stack:
|
||||
|
||||
@@ -176,18 +176,18 @@ class HeadlessUI:
|
||||
box.pack(fill="both", expand=True)
|
||||
tk.Frame(box, bg="#185ce8", height=4).pack(fill="x")
|
||||
tk.Label(box, text=title or "通知", bg="#ffffff",
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", 11, "bold"),
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", self._notice_font(), "bold"),
|
||||
anchor="w").pack(fill="x", padx=14, pady=(10, 2))
|
||||
tk.Label(box, text=text or "", bg="#ffffff", fg="#656d76",
|
||||
font=("Microsoft YaHei UI", 10), anchor="w",
|
||||
font=("Microsoft YaHei UI", self._notice_font() - 1), anchor="w",
|
||||
justify="left", wraplength=260).pack(
|
||||
fill="x", padx=14, pady=(0, 12))
|
||||
win.update_idletasks()
|
||||
|
||||
screen_w, screen_h = self.screen_size()
|
||||
width = max(240, win.winfo_reqwidth())
|
||||
width = self._notice_width(win)
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
win.geometry(self._notice_geometry(width, height))
|
||||
|
||||
def close(_event=None):
|
||||
try:
|
||||
@@ -217,18 +217,18 @@ class HeadlessUI:
|
||||
box.pack(fill="both", expand=True)
|
||||
tk.Frame(box, bg="#185ce8", height=4).pack(fill="x")
|
||||
tk.Label(box, text=title or "通知", bg="#ffffff",
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", 11, "bold"),
|
||||
fg="#1f2328", font=("Microsoft YaHei UI", self._notice_font(), "bold"),
|
||||
anchor="w").pack(fill="x", padx=14, pady=(10, 2))
|
||||
tk.Label(box, text=text or "", bg="#ffffff", fg="#656d76",
|
||||
font=("Microsoft YaHei UI", 10), anchor="w",
|
||||
font=("Microsoft YaHei UI", self._notice_font() - 1), anchor="w",
|
||||
justify="left", wraplength=260).pack(
|
||||
fill="x", padx=14, pady=(0, 12))
|
||||
win.update_idletasks()
|
||||
|
||||
screen_w, screen_h = self.screen_size()
|
||||
width = max(240, win.winfo_reqwidth())
|
||||
width = self._notice_width(win)
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
win.geometry(self._notice_geometry(width, height))
|
||||
|
||||
def close(_event=None):
|
||||
try:
|
||||
@@ -380,6 +380,70 @@ class HeadlessUI:
|
||||
raise error[0]
|
||||
return result[0] if result else None
|
||||
|
||||
def _notice_width(self, win) -> int:
|
||||
"""弹窗宽度。用户要求"大小可以自定义"。
|
||||
|
||||
设成 0 就按内容自适应(原来是 240 起),
|
||||
设了具体值就用那个值,夹在 180~1200 之间 ——
|
||||
太窄文字挤成一列,太宽一个通知横穿屏幕。
|
||||
"""
|
||||
try:
|
||||
want = int(self.store.get("notice_width") or 0)
|
||||
except (TypeError, ValueError):
|
||||
want = 0
|
||||
auto = max(240, win.winfo_reqwidth())
|
||||
if want <= 0:
|
||||
return auto
|
||||
return max(180, min(1200, want))
|
||||
|
||||
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))
|
||||
|
||||
def _notice_geometry(self, width: int, height: int) -> str:
|
||||
"""算出弹窗该放哪。返回 Tk 的 geometry 字符串 `+x+y`。
|
||||
|
||||
用户要求"位置和大小都能自定义",所以支持 6 种位置:
|
||||
|
||||
bottom-right(默认,保持老习惯) / top-right /
|
||||
top-left / bottom-left / center(屏幕正中间) / custom
|
||||
|
||||
⚠ 三处弹窗(通知 ×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(max(0, screen_w - width), x))
|
||||
y = max(0, min(max(0, 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)}"
|
||||
|
||||
def screen_size(self) -> tuple:
|
||||
if IS_WINDOWS:
|
||||
try:
|
||||
@@ -401,15 +465,27 @@ class HeadlessUI:
|
||||
self.log("(截屏)")
|
||||
|
||||
def set_screen_sharing(self, on: bool) -> None:
|
||||
"""屏幕共享提示。
|
||||
"""屏幕共享提示条 —— **默认不显示**。
|
||||
|
||||
这是**故意保留**的:有人在看你的屏幕时,屏幕右下角会出现一条提示。
|
||||
静默模式只是不显示主控制窗口,不是"让被看的人不知道"。
|
||||
用户要求:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。
|
||||
|
||||
所以先看设置项 `show_screen_notice`:
|
||||
|
||||
* 关着(默认)-> 什么都不做,连窗口都不建
|
||||
* 开着 -> 行为跟以前一样,在用户设的位置显示一条提示
|
||||
|
||||
保留这个开关而不是删掉代码,是因为这软件应该始终有一个
|
||||
"让本机用户看得见正在被控制"的选项可查 —— 需要时在设置里打开。
|
||||
"""
|
||||
if on == self._screen_on:
|
||||
return
|
||||
self._screen_on = on
|
||||
try:
|
||||
if not self.store.get("show_screen_notice"):
|
||||
# 设置关着:顺手把可能残留的提示条收掉
|
||||
# (比如正被观看时用户把开关关了),然后什么都不做
|
||||
self._hide_notice()
|
||||
return
|
||||
if on:
|
||||
self._show_notice("屏幕正在被共享")
|
||||
else:
|
||||
@@ -441,14 +517,14 @@ class HeadlessUI:
|
||||
win.attributes("-topmost", True)
|
||||
win.configure(bg="#cf222e")
|
||||
label = tk.Label(win, text=text, bg="#cf222e", fg="#ffffff",
|
||||
font=("Microsoft YaHei UI", 11, "bold"),
|
||||
font=("Microsoft YaHei UI", self._notice_font(), "bold"),
|
||||
padx=16, pady=8)
|
||||
label.pack()
|
||||
screen_w, screen_h = self.screen_size()
|
||||
win.update_idletasks()
|
||||
width = win.winfo_reqwidth()
|
||||
height = win.winfo_reqheight()
|
||||
win.geometry(f"+{screen_w - width - 24}+{screen_h - height - 72}")
|
||||
win.geometry(self._notice_geometry(width, height))
|
||||
self._notice = win
|
||||
|
||||
try:
|
||||
|
||||
@@ -359,6 +359,72 @@ class SetupWindow:
|
||||
bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left",
|
||||
wraplength=396).pack(anchor="w", pady=(6, 0))
|
||||
|
||||
# ================= 显示卡片 =================
|
||||
#
|
||||
# 用户要求:
|
||||
# * "那个弹文字窗口 …… 位置和大小都能自定义"
|
||||
# * "屏幕已被共享的提示以后不需要弹,像 UU 远程一样啥窗口都没有"
|
||||
#
|
||||
# 所以这里三件事:
|
||||
# 1. 提示条开关(**默认关**,见 config.py 的 show_screen_notice)
|
||||
# 2. 弹窗位置:右下(默认) / 右上 / 左上 / 左下 / 正中间 / 自定义
|
||||
# 3. 弹窗字号
|
||||
#
|
||||
# 「自定义」的坐标现在没有界面输入框 —— 先用一组合理的坐标占位,
|
||||
# 需要精确控制的人可以改 data/settings.json 里的 notice_x / notice_y。
|
||||
# 这样界面不至于塞一堆输入框,而高级用户仍有办法。
|
||||
display = self._card(page)
|
||||
display.pack(fill="x", pady=(12, 0))
|
||||
disp_inner = tk.Frame(display.winfo_children()[0], bg=CARD)
|
||||
disp_inner.pack(fill="x", padx=16, pady=12)
|
||||
|
||||
tk.Label(disp_inner, text="显示", bg=CARD, fg=FG,
|
||||
font=FONT_LABEL).pack(anchor="w", pady=(0, 6))
|
||||
|
||||
self.var_screen_notice = tk.BooleanVar(
|
||||
value=bool(self.store.get("show_screen_notice", False)))
|
||||
self._check(disp_inner, "屏幕被观看时在本机显示提示条",
|
||||
self.var_screen_notice,
|
||||
"默认关闭(和 UU 远程一样不弹任何窗口)")
|
||||
|
||||
pos_row = tk.Frame(disp_inner, bg=CARD)
|
||||
pos_row.pack(fill="x", pady=(8, 0))
|
||||
tk.Label(pos_row, text="弹窗位置", bg=CARD, fg=FG,
|
||||
font=FONT_UI).pack(side="left")
|
||||
self.var_notice_pos = tk.StringVar(
|
||||
value=str(self.store.get("notice_position") or "bottom-right"))
|
||||
# 值 -> 给人看的名字。顺序就是下拉里的顺序。
|
||||
self._pos_labels = [
|
||||
("bottom-right", "右下角"), ("top-right", "右上角"),
|
||||
("top-left", "左上角"), ("bottom-left", "左下角"),
|
||||
("center", "屏幕正中间"), ("custom", "自定义坐标"),
|
||||
]
|
||||
label_to_val = {v: k for k, v in self._pos_labels}
|
||||
current_label = label_to_val.get(self.var_notice_pos.get(), "右下角")
|
||||
pos_menu = tk.OptionMenu(
|
||||
pos_row, self.var_notice_pos, *[v for _k, v in self._pos_labels])
|
||||
pos_menu.configure(bg=CARD, fg=FG, font=FONT_SMALL, bd=0,
|
||||
highlightthickness=0, activebackground=CARD,
|
||||
relief="flat", cursor="hand2")
|
||||
pos_menu["menu"].configure(font=FONT_SMALL)
|
||||
pos_menu.pack(side="left", padx=(8, 0))
|
||||
# OptionMenu 存的是**给人看的名字**,保存时再翻回内部的英文值。
|
||||
# 这样用户看到的是中文,配置里存的仍是稳定的英文(以后改文案
|
||||
# 不会把老配置弄坏)。
|
||||
self.var_notice_pos.set(current_label)
|
||||
|
||||
size_row = tk.Frame(disp_inner, bg=CARD)
|
||||
size_row.pack(fill="x", pady=(8, 0))
|
||||
tk.Label(size_row, text="弹窗字号", bg=CARD, fg=FG,
|
||||
font=FONT_UI).pack(side="left")
|
||||
self.var_notice_font = tk.IntVar(
|
||||
value=int(self.store.get("notice_font_size") or 11))
|
||||
tk.Spinbox(size_row, from_=8, to=32, width=4,
|
||||
textvariable=self.var_notice_font, font=FONT_SMALL,
|
||||
justify="center").pack(side="left", padx=(8, 4))
|
||||
tk.Label(size_row, text="(8~32)", bg=CARD, fg=MUTED,
|
||||
font=FONT_SMALL).pack(side="left")
|
||||
|
||||
# ================= 按钮区 =================
|
||||
buttons = tk.Frame(page, bg=BG)
|
||||
buttons.pack(fill="x", pady=(16, 0))
|
||||
@@ -386,6 +452,26 @@ class SetupWindow:
|
||||
root.bind("<Escape>", lambda _e: self._cancel())
|
||||
root.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
|
||||
def _pos_value(self, label: str) -> str:
|
||||
"""把下拉里的中文标签翻回内部英文值。
|
||||
|
||||
⚠ 为什么不直接把英文值塞进下拉:用户看的是中文界面。
|
||||
为什么配置里存英文:以后改文案不会把老配置弄坏。
|
||||
"""
|
||||
for val, lab in self._pos_labels:
|
||||
if lab == label:
|
||||
return val
|
||||
return "bottom-right"
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value, low: int, high: int, fallback: int) -> int:
|
||||
"""Spinbox 里用户可能删空或手打字母,夹一下别让配置变脏。"""
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return max(low, min(high, n))
|
||||
|
||||
def _check(self, parent, title: str, variable, hint: str) -> None:
|
||||
"""一行勾选框 + 灰字说明。"""
|
||||
row = tk.Frame(parent, bg=CARD)
|
||||
@@ -467,6 +553,12 @@ class SetupWindow:
|
||||
"relay": relay,
|
||||
"allow_new": (None if self.var_allow_new is None
|
||||
else bool(self.var_allow_new.get())),
|
||||
"screen_notice": bool(self.var_screen_notice.get()),
|
||||
# 下拉里存的是中文标签,这里翻回稳定的英文值
|
||||
"notice_position": self._pos_value(
|
||||
self.var_notice_pos.get()),
|
||||
"notice_font_size": self._safe_int(
|
||||
self.var_notice_font.get(), 8, 32, 11),
|
||||
}
|
||||
try:
|
||||
self.root.quit()
|
||||
|
||||
@@ -661,6 +661,23 @@ def run_setup(store) -> int:
|
||||
store.set_pairing_code(result["code"])
|
||||
store.set("setup_done", True)
|
||||
|
||||
# ---- 首次设置时也要保存"显示"那几项 ----
|
||||
#
|
||||
# 用户要求弹窗位置/大小可自定义、屏幕提示条默认可关。
|
||||
# 首次设置窗口里已经有这几个控件了(见 ui.py 的"显示"卡片),
|
||||
# 但**只在设置模式保存**的话,第一次装的人改了也不生效 ——
|
||||
# 表现为"我在安装向导里明明改了,怎么还是老样子"。
|
||||
if "screen_notice" in result:
|
||||
store.set("show_screen_notice", bool(result["screen_notice"]))
|
||||
if result.get("notice_position"):
|
||||
store.set("notice_position", str(result["notice_position"]))
|
||||
if result.get("notice_font_size"):
|
||||
try:
|
||||
store.set("notice_font_size",
|
||||
max(8, min(32, int(result["notice_font_size"]))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
# 开机自启
|
||||
if result["autostart"]:
|
||||
try:
|
||||
@@ -738,6 +755,32 @@ def run_settings(store) -> int:
|
||||
store.set("allow_new_pairing", bool(allow_new))
|
||||
changed.append("接入开关")
|
||||
|
||||
# ---- 显示:屏幕提示条 / 弹窗位置 / 字号 ----
|
||||
#
|
||||
# 用户要求:"弹文字窗口的位置和大小都能自定义"、
|
||||
# "屏幕已被共享的提示以后不要弹"。
|
||||
if "screen_notice" in window:
|
||||
want = bool(window.get("screen_notice"))
|
||||
if want != bool(store.get("show_screen_notice", False)):
|
||||
store.set("show_screen_notice", want)
|
||||
changed.append("屏幕提示条")
|
||||
|
||||
pos = window.get("notice_position")
|
||||
if pos and str(pos) != str(store.get("notice_position") or ""):
|
||||
store.set("notice_position", str(pos))
|
||||
changed.append("弹窗位置")
|
||||
|
||||
font = window.get("notice_font_size")
|
||||
if font is not None:
|
||||
try:
|
||||
font = max(8, min(32, int(font)))
|
||||
except (TypeError, ValueError):
|
||||
font = None
|
||||
if font is not None and font != int(
|
||||
store.get("notice_font_size") or 11):
|
||||
store.set("notice_font_size", font)
|
||||
changed.append("弹窗字号")
|
||||
|
||||
# ---- 开机自启 ----
|
||||
try:
|
||||
from lanremote.autostart import enable, disable, is_enabled
|
||||
|
||||
@@ -27,12 +27,12 @@ VSVersionInfo(
|
||||
[
|
||||
StringStruct("CompanyName", "ykxmz"),
|
||||
StringStruct("FileDescription", "ykxmz"),
|
||||
StringStruct("FileVersion", "2.1.6.0"),
|
||||
StringStruct("FileVersion", "2.1.8.0"),
|
||||
StringStruct("InternalName", "ykxmz"),
|
||||
StringStruct("LegalCopyright", "ykxmz"),
|
||||
StringStruct("OriginalFilename", "ykxmz.exe"),
|
||||
StringStruct("ProductName", "ykxmz"),
|
||||
StringStruct("ProductVersion", "2.1.6.0"),
|
||||
StringStruct("ProductVersion", "2.1.8.0"),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user