diff --git a/_build/_cmsg17.txt b/_build/_cmsg17.txt new file mode 100644 index 0000000..e3ac21b --- /dev/null +++ b/_build/_cmsg17.txt @@ -0,0 +1,121 @@ +修用户报的三个问题 + 揪出我自己检查工具的盲区 + +用户报: + 1. 官网 502 + 2. 局域网连接:输入配对码提示连上了但没反应,得推出去点设备才能连 + 3. 被控端安装时要能设中转服务器;之后双击 exe 也该能改配对码 / + 开关新设备 / 中转地址 —— 但现在双击没反应 + +──────────────────────────────────────── +问题 1:502(暂态,已恢复) +──────────────────────────────────────── +你截图是 21:24:56,隧道日志显示 21:22:51 和 21:26:44 各重启了一次 +—— 502 就发生在那个空窗里。现在三个服务都 active、10 条连接、 +本机 200(3ms)、公网 200(2.6s)。 + +(重启是谁触发的还没查清,watchdog 的嫌疑最大,下一轮跟进。) + +──────────────────────────────────────── +问题 2:局域网连上后没反应(真 bug,已修) +──────────────────────────────────────── +手表上有**四个**面板,靠手工 setVisibility 切换: + + connectPanel 首页 + controlPanel 控制面板 + mirrorPanel 投屏 + lanPanel 局域网连接页 <- 后加的 + +而 showControlPanel() 只藏了前两个: + + connectPanel.setVisibility(GONE); + controlPanel.setVisibility(VISIBLE); + if (mirrorPanel != null) mirrorPanel.setVisibility(GONE); + // lanPanel 没藏! + +于是从「局域网连接」页填完码、连接成功之后: + connect() 成功 -> showControlPanel() -> 控制面板显示了 + 但 lanPanel 还盖在最上层 -> 用户看到的还是局域网页面 + +表现就是用户说的「提示连上了,但没反应,得推出去到设备列表 +点击设备才能连接」—— 推出去把 lanPanel 收掉,才看到底下。 + +**根子在于"每处切换都手写一串 setVisibility"**,加第四个面板时 +必然漏。改成统一入口: + + private void showOnly(View keep) // 藏掉所有面板,只留一个 + +6 个切换点全部走它。以后加面板只要在 showOnly 里加一行。 + +──────────────────────────────────────── +问题 3:双击 exe 没反应(功能缺失,已补) +──────────────────────────────────────── +原来是两件事叠在一起: + +**3a. 装完之后双击永远静默**(main() 最后几行) + + if not store.get("setup_done", False): + return run_setup(store) + return run_silent(store, None) # <- 什么都不弹 + +改成:没参数 + 已设置过 -> **打开设置窗口**。 +后台运行走显式 --silent(开机自启和安装包都用它,不受影响)。 + +**3b. 设置窗口里没有中转服务器** + +SetupWindow 原来只有 配对码 / 开机自启 / 放行防火墙。 +现在加了: + * 中转服务器地址(可不填;只在一个 WiFi 里用就不需要) + * 允许新设备接入(开关,只在"设置"模式给) + +并且给 SetupWindow 加了 mode: + "first" 第一次设置(防火墙 + 自启 + 转静默,老行为不变) + "settings" 已装好,只改配置 + +**3c. 顺带修了一个会静默失败的东西** + +改完发现打包出来的 exe 一跑就 ModuleNotFoundError: +No module named 'lanremote.ui' —— **PyInstaller 的 --hidden-import +里没有 ui**。它是 tkinter 那套设置窗口的实现,而静默那条路 +不需要它,静态分析就找不到。源码跑得好好的,只有 exe 会崩。 + +已加 --hidden-import "lanremote.ui",并写了 +_tests/test_double_click_window.py 针对**打包好的 exe** 验: + ✅ 进程没崩 / 窗口弹出来了 / 标题是「ykxmz · 设置」 + +──────────────────────────────────────── +额外揪出来的:我的检查工具一直在骗我 +──────────────────────────────────────── +反复被同一个问题咬了三次:补丁脚本为了加 import os / import re, +把它插到了 `from __future__` 前面,整个文件语法错。 + +而**我前面几次说的「全仓语法检查 0 个问题」是假绿**: + + ast.parse(src) -> 通过(只解析,不做编译期检查) + compile(src, ...) -> SyntaxError: from __future__ imports + must occur at the beginning of the file + +`__future__` 的位置是**编译期**检查,ast.parse 根本不管。 +所以改成用 compile(),并写成 +_tests/check_python_syntax.py(回归阶段 3a7), +它还会专门把"ast.parse 会放过、compile 会拦"的文件标出来。 + +实测:全仓 533 个文件,用 compile() 扫出 1 个坏的(ui.py), +而它正是让"双击没反应"的最后一个原因。 + +──────────────────────────────────────── +还有一个重要发现 +──────────────────────────────────────── +**你一直在跑的是 10-06 的旧 exe**: + + C:\Users\shu\Desktop\ykxmz\ykxmz.exe 10-06 17:43 <- 你双击的这个 + LanRemote\dist_exe\ykxmz\ykxmz.exe 10-07 21:5x <- 修复都在这 + +它的 silent.log 正在反复报: + [21:15:59] 连接断了,重开一次再试(The read operation timed out) + [21:16:42] 中转连接出问题:RelayError: 请求失败: ... + [21:18:34] 已登记到中转:配对码 111111 + +这就是我修的那个 126 秒长轮询问题 —— 旧版超时太短反复掉线。 +新版把客户端超时改成 180 秒了。 + +**所以这三个修复要装上新版才生效。** diff --git a/_build/add_settings_window.py b/_build/add_settings_window.py new file mode 100644 index 0000000..577a46f --- /dev/null +++ b/_build/add_settings_window.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +r'''让「双击 exe」能打开设置窗口,并能设中转服务器。 + +== 用户报的两个问题 == + +问题 1:「被控端再安装时要能设置中转服务器,这是第一次设置」 + —— SetupWindow 只有配对码/开机自启/放行防火墙,**没有中转**。 + +问题 2:「后面要想设置就要去点击 exe 去改配对码和开关新设备连接 + 还有中转服务器,现在如果再运行双击 exe 没反应,没运行也一样」 + + 看 run_agent.py 的 main() 最后几行: + + if not store.get("setup_done", False): + return run_setup(store) + # 已经设置过:直接静默,屏幕上什么都不出现 + return run_silent(store, None) + + —— 装完之后双击**永远静默**,什么窗口都不弹。 + 而且如果已经有一个实例在跑,InstanceLock 会直接 return, + 连日志都不输出。 + +== 改法 == + +1. SetupWindow 加一个 mode: + mode="first" 第一次设置(保持老行为:防火墙+自启+转静默) + mode="settings" 已装好,只改配置 + +2. 窗口加两样东西: + * 中转服务器地址(可不填;只在一个 WiFi 里用就不需要) + * 允许新设备接入(开关) + +3. main() 改成: + --setup -> settings 模式 + --silent -> 静默(开机自启/安装包用,行为不变) + 没参数 + 没设置过 -> first 模式 + 没参数 + 已经设置过 -> **settings 模式**(不再是静默) + +4. 已经在跑的时候:不再"直接 exit 什么都不显示",而是 + 提示一句"被控端正在后台运行",然后**照样打开设置窗口**。 + 保存后重启后台实例让设置生效。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +UI = ROOT / "windows" / "lanremote" / "ui.py" +RUN = ROOT / "windows" / "run_agent.py" + + +def patch(path: Path, pairs, label: str) -> bool: + text = path.read_text(encoding="utf-8") + for old, new in pairs: + if old not in text: + print(" ✗ %s:找不到锚点 %r" % (label, old[:60])) + return False + if text.count(old) != 1: + print(" ! %s:锚点出现 %d 次" % (label, text.count(old))) + text = text.replace(old, new, 1) + path.write_text(text, encoding="utf-8", newline="\n") + print(" ✓ %s" % label) + return True + + +def main() -> int: + ok = True + + # ================================================================ + # 一、ui.py:加 mode + 中转字段 + 接入开关 + # ================================================================ + print("── ui.py ──") + + ok &= patch(UI, [( + ''' def __init__(self, store, suggested: str = ""): + self.store = store + self.suggested = (suggested or store.pairing_code() + or store.new_pairing_code()) + self.result: dict | None = None''', + ''' def __init__(self, store, suggested: str = "", + mode: str = "first"): + """mode: + "first" 第一次设置(走防火墙 + 自启 + 转静默) + "settings" 已装好,只改配置(配对码/中转/接入开关) + """ + self.store = store + self.mode = mode if mode in ("first", "settings") else "first" + self.suggested = (suggested or store.pairing_code() + or store.new_pairing_code()) + self.result: dict | None = None''' + )], "SetupWindow 加 mode") + + # 标题按模式分 + ok &= patch(UI, [( + ''' self.root.title("ykxmz · 第一次设置")''', + ''' self.root.title("ykxmz · " + ( + "第一次设置" if self.mode == "first" else "设置"))''' + )], "窗口标题按模式分") + + # 副标题 + ok &= patch(UI, [( + ''' tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM, + font=FONT_SUB).pack(anchor="w")''', + ''' tk.Label(titles, + text=("远程控制 · 第一次设置" if self.mode == "first" + else "远程控制 · 设置"), + bg=BG, fg=DIM, + font=FONT_SUB).pack(anchor="w")''' + )], "副标题按模式分") + + # 在配对码卡片之后插入「中转服务器」卡片 + ok &= patch(UI, [( + ''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER, + font=FONT_SMALL, wraplength=330, + justify="left", anchor="w") + self.status.pack(fill="x", pady=(6, 0)) +''', + ''' self.status = tk.Label(inner, text="", bg=CARD, fg=DANGER, + font=FONT_SMALL, wraplength=330, + justify="left", anchor="w") + self.status.pack(fill="x", pady=(6, 0)) + + # ================= 中转服务器卡片 ================= + # 用户要求:安装时要能设中转服务器;装完之后双击 exe 也能回来改。 + # + # 为什么不填也能用:手表和电脑在同一个 WiFi 时走局域网直连, + # 根本不需要中转。只有"在办公室管教室"这种跨网络场景才要填。 + relay_card = self._card(page) + relay_card.pack(fill="x", pady=(12, 0)) + relay_inner = tk.Frame(relay_card.winfo_children()[0], bg=CARD) + relay_inner.pack(fill="x", padx=16, pady=14) + + tk.Label(relay_inner, text="中转服务器(可以不填)", bg=CARD, fg=FG, + font=FONT_LABEL).pack(anchor="w") + + self.relay_entry = tk.Entry( + relay_inner, bg="#f6f8fa", fg=FG, relief="flat", + font=FONT_SUB, insertbackground=ACCENT) + self.relay_entry.pack(fill="x", pady=(8, 0), ipady=6) + _saved_relay = str(self.store.get("relay_url", "") or "").strip() + if _saved_relay: + self.relay_entry.insert(0, _saved_relay) + + tk.Label(relay_inner, + text="手表和电脑在同一个 WiFi 时不需要填 —— 会直接走" + "局域网,几十毫秒。\\n" + "要在别的地方(比如办公室)管这台电脑时才填," + "填你自己那台中转服务器的地址。", + bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left", + wraplength=396).pack(anchor="w", pady=(8, 0)) + + self.relay_status = tk.Label(relay_inner, text="", bg=CARD, + fg=DANGER, font=FONT_SMALL, + wraplength=396, justify="left", + anchor="w") + self.relay_status.pack(fill="x", pady=(6, 0)) +''' + )], "插入中转服务器卡片") + + # 选项卡片里加「允许新设备接入」(只在 settings 模式显示) + ok &= patch(UI, [( + ''' self.var_firewall = tk.BooleanVar(value=True) + self._check(opt_inner, "放行防火墙", self.var_firewall, + "会弹一次管理员确认,然后黑窗口一闪而过")''', + ''' # 「允许新设备接入」只在设置模式给 —— + # 第一次设置时当然是允许的(不然手表连不上)。 + if self.mode == "settings": + self.var_allow_new = tk.BooleanVar( + value=bool(self.store.get("allow_new_pairing", True))) + self._check(opt_inner, "允许新设备接入", self.var_allow_new, + "关掉之后,只有已经配对过的设备能连上;" + "新设备会被拒绝") + else: + self.var_allow_new = None + + self.var_firewall = tk.BooleanVar( + value=(self.mode == "first")) + if self.mode == "first": + self._check(opt_inner, "放行防火墙", self.var_firewall, + "会弹一次管理员确认,然后黑窗口一闪而过")''' + )], "加「允许新设备接入」选项") + + # 结果里带上 relay / allow_new + ok &= patch(UI, [( + ''' self.result = { + "code": cleaned, + "autostart": bool(self.var_auto.get()), + "firewall": bool(self.var_firewall.get()), + }''', + ''' # 中转地址:允许空(空 = 只走局域网)。 + # 填了的话做一次最基本的格式检查,免得用户把域名打错还找不到原因。 + relay = self.relay_entry.get().strip() + if relay: + if not relay.startswith(("http://", "https://")): + relay = "https://" + relay + # 只留 协议://主机[:端口],去掉多余路径 + _m = re.match(r"^(https?://[^/\\s]+)", relay) + if not _m: + self._set_relay_status("中转地址看着不对,检查一下", DANGER) + return + relay = _m.group(1).rstrip("/") + self.result = { + "code": cleaned, + "autostart": bool(self.var_auto.get()), + "firewall": bool(self.var_firewall.get()), + "relay": relay, + "allow_new": (None if self.var_allow_new is None + else bool(self.var_allow_new.get())), + }''' + )], "结果带 relay / allow_new") + + # 需要一个 _set_relay_status + ok &= patch(UI, [( + ''' def _cancel(self) -> None: + self.result = None''', + ''' def _set_relay_status(self, text: str, color: str) -> None: + try: + self.relay_status.configure(text=text, fg=color) + except tk.TclError: + pass + + def _cancel(self) -> None: + self.result = None''' + )], "加 _set_relay_status") + + # ui.py 需要 re + text = UI.read_text(encoding="utf-8") + if not re.search(r"^import re\b", text, re.M): + m = re.search(r"^(?:import |from )", text, re.M) + text = text[:m.start()] + "import re\n" + text[m.start():] + UI.write_text(text, encoding="utf-8", newline="\n") + print(" ✓ ui.py 补 import re") + else: + print(" • ui.py 已有 import re") + + # ================================================================ + # 二、run_agent.py:双击打开设置窗口 + # ================================================================ + print() + print("── run_agent.py ──") + + ok &= patch(RUN, [( + ''' # 第一次运行:设置窗口 → 修防火墙 → 静默 + if not store.get("setup_done", False): + return run_setup(store) + + # 已经设置过:直接静默,屏幕上什么都不出现 + return run_silent(store, None)''', + ''' # 第一次运行:设置窗口 → 修防火墙 → 静默 + if not store.get("setup_done", False): + return run_setup(store) + + # 已经设置过、而且没给参数 —— 说明用户是**双击 exe**。 + # + # 以前这里是 `return run_silent(store, None)`:直接后台跑, + # 屏幕上什么都不出现。用户想改配对码 / 中转地址 / 接入开关时 + # 双击毫无反应,只能去翻命令行参数。 + # + # 现在双击 = 打开设置窗口。后台运行走显式 --silent + # (开机自启和安装包都用的 --silent,所以不受影响)。 + return run_settings(store)''' + )], "main() 双击改为打开设置窗口") + + # 加 run_settings 函数(放在 run_setup 之后) + if "def run_settings(" not in RUN.read_text(encoding="utf-8"): + text = RUN.read_text(encoding="utf-8") + anchor = "def main(argv=None) -> int:" + if anchor not in text: + print(" ✗ 找不到 main()") + ok = False + else: + helper = '''def run_settings(store) -> int: + """双击 exe 时打开的设置窗口。 + + 和 run_setup 的区别: + run_setup 第一次装:走防火墙 + 开机自启 + 最后转静默 + run_settings 已装好:只改配置,改完把后台实例重启一下让设置生效 + + 为什么"已经有一个实例在跑"时也要照样开窗口: + 以前 InstanceLock 拿不到就直接 return,用户双击后**什么都看不到**, + 以为程序坏了。现在开窗、改完、重启后台实例。 + """ + from lanremote.ui import SetupWindow + + running = not InstanceLock.probe_free() + + window = SetupWindow(store, mode="settings").show() + if window is None: + return 0 # 用户关掉了,什么都不改 + + changed = [] + + # ---- 配对码 ---- + if window.get("code") and window["code"] != store.pairing_code(): + try: + store.set_pairing_code(window["code"]) + changed.append("配对码") + except ValueError as exc: + print(f"设置配对码失败:{exc}") + return 2 + + # ---- 中转服务器(空 = 只走局域网)---- + relay = str(window.get("relay", "") or "").strip() + if relay != str(store.get("relay_url", "") or "").strip(): + store.set("relay_url", relay) + changed.append("中转地址") + + # ---- 允许新设备接入 ---- + allow_new = window.get("allow_new") + if allow_new is not None and bool(allow_new) != bool( + store.get("allow_new_pairing", True)): + store.set("allow_new_pairing", bool(allow_new)) + changed.append("接入开关") + + # ---- 开机自启 ---- + try: + from lanremote.autostart import enable, disable, is_enabled + want = bool(window.get("autostart")) + if want != bool(is_enabled()): + (enable if want else disable)() + changed.append("开机自启") + except Exception: # noqa: BLE001 + pass + + # ---- 让后台实例重新读配置 ---- + if changed and running: + _restart_background(store) + print("已保存:" + "、".join(changed) + "(后台实例已重启)") + elif changed: + print("已保存:" + "、".join(changed)) + else: + print("没有改动") + return 0 + + +def _restart_background(store) -> None: + """把正在跑的后台实例停掉,再用新配置起一个。 + + 为什么要重启而不是"让它自己重读": + allow_new_pairing 是启动时读进 server.pairing_enabled 的, + 中转地址也是启动时连的。文件改了但进程里还是旧值, + 用户会以为设置没生效。 + """ + import subprocess + # 只杀"没有窗口"的那个(后台实例)。taskkill 按镜像名杀会连自己一起杀, + # 所以这里用 wmic/powershell 过滤命令行里带 --silent 的。 + try: + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process -Filter \\"Name='ykxmz.exe'\\" | " + "Where-Object { $_.CommandLine -match '--silent' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, timeout=20) + except Exception: # noqa: BLE001 + pass + time.sleep(1.2) + # 用 --silent 重新起一个,完全脱离本进程 + try: + exe = sys.executable + subprocess.Popen([exe, "--silent"], + creationflags=getattr(subprocess, + "DETACHED_PROCESS", 0), + close_fds=True) + except Exception: # noqa: BLE001 + pass + + +''' + text = text.replace(anchor, helper + anchor, 1) + RUN.write_text(text, encoding="utf-8", newline="\n") + print(" ✓ 加 run_settings()") + else: + print(" • run_settings 已存在") + + # --setup 也走 settings 模式 + ok &= patch(RUN, [( + ''' # --setup:重新设置 + if args.setup: + return run_setup(store)''', + ''' # --setup:重新打开设置窗口(和双击等效) + if args.setup: + return run_settings(store)''' + )], "--setup 走 settings 模式") + + # ================================================================ + print() + import ast + bad = [] + for f in (UI, RUN): + try: + ast.parse(f.read_text(encoding="utf-8")) + except SyntaxError as exc: + bad.append("%s: 第 %s 行 %s" % (f.name, exc.lineno, exc.msg)) + if bad: + print(" ✗ 语法错:") + for b in bad: + print(" %s" % b) + return 1 + print(" ✓ 两个文件语法都通过") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/diag502b.sh b/_build/diag502b.sh new file mode 100644 index 0000000..7ebad63 --- /dev/null +++ b/_build/diag502b.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# 502 诊断 v2 +set -u + +echo " ══ 1. 三个服务 ══" +for s in lanremote-relay lanremote-tunnel lanremote-tunnel-h2; do + printf " %-24s %s\n" "$s" "$(systemctl is-active $s 2>/dev/null)" +done + +echo +echo " ══ 2. 所有 cloudflared 进程(看 protocol / metrics 端口)══" +ps -eo pid,args 2>/dev/null | grep '[c]loudflared tunnel' | while read -r line; do + echo " $line" | cut -c1-160 +done + +echo +echo " ══ 3. 两个 metrics 端口分别报几条连接 ══" +for p in 20241 20242; do + n=$(curl -s --max-time 4 "http://127.0.0.1:$p/metrics" 2>/dev/null \ + | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}') + printf " %s -> %s 条\n" "$p" "${n:-读不到}" +done + +echo +echo " ══ 4. 隧道 config.yml 的回源配置(关键:指向哪个端口)══" +cat /root/.cloudflared/config.yml 2>/dev/null | sed 's/^/ /' + +echo +echo " ══ 5. relay 实际监听端口 ══" +ss -lntp 2>/dev/null | grep -E 'python|8080|8880' | sed 's/^/ /' || echo " (没看到)" + +echo +echo " ══ 6. 本机 8080 ══" +curl -s --max-time 8 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \ + -H 'User-Agent: LanRemote/1.8.2' http://127.0.0.1:8080/v1/health + +echo +echo " ══ 7. 公网(关键)══" +curl -s --max-time 25 -o /dev/null -w ' HTTP %{http_code} %{time_total}s\n' \ + -H 'User-Agent: LanRemote/1.8.2' https://relay.xxmxxs.top/v1/health + +echo +echo " ══ 8. cloudflared 日志最近 25 行 ══" +journalctl -u lanremote-tunnel -n 25 --no-pager 2>/dev/null | tail -25 | sed 's/^/ /' diff --git a/_build/fix_future_imports.py b/_build/fix_future_imports.py new file mode 100644 index 0000000..7047015 --- /dev/null +++ b/_build/fix_future_imports.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +r'''修所有「__future__ 不在文件最前面」的文件,并加一道永久防线。 + +== 为什么反复踩 == + +我这个会话里已经**第三次**被同一件事咬: + + Python 要求 `from __future__ import ...` 必须是文件里 + **第一条语句**(docstring 之后)。而我的补丁脚本为了给文件 + 加 `import os` / `import re`,图省事写成: + + m = re.search(r"^(import |from )", text, re.M) + text = text[:m.start()] + "import os\n" + text[m.start():] + + —— 第一个 import 恰好就是 `from __future__`, + 于是新 import 被塞到它前面,整个文件语法错: + + SyntaxError: from __future__ imports must occur at the + beginning of the file + + 症状还特别隐蔽: + * 源码直接跑 -> 启动就崩 + * **打包成 exe 之后** -> 只在走到那个模块时才崩, + 用户看到的是"双击没反应" + +== 这个脚本做两件事 == + +1. 全仓扫一遍,把所有"__future__ 不在最前面"的文件修好 + (把 __future__ 提到所有 import 之前,docstring 之后) + +2. 把这条检查加进回归套件(_tests/check_future_import.py), + 以后任何脚本再犯,全量回归立刻红 —— 不会再等到打包之后。 +''' +from __future__ import annotations + +import ast +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PATTERNS = ["_tests/*.py", "_build/*.py", "relay/*.py", "windows/*.py", + "windows/lanremote/*.py", "installer/*.py", "android/*.py"] + + +def fix_file(path: Path) -> str: + """返回 'ok' / 'fixed' / 'error:...'""" + text = path.read_text(encoding="utf-8") + try: + # ⚠ 必须用 compile():ast.parse 不做 __future__ 位置检查, + # 用它探测会误判成"没问题"(这一轮就是这么被骗了好几次)。 + compile(text, str(path), "exec") + return "ok" + except SyntaxError as exc: + if "__future__" not in (exc.msg or ""): + return "ok" # 别的语法错不归这个脚本管 + + lines = text.splitlines() + # 找到 __future__ 那几行 + fut_idx = [i for i, ln in enumerate(lines) + if re.match(r"^\s*from __future__ import ", ln)] + if not fut_idx: + return "ok" + + # 把它之前的所有 import 行摘出来,插到它后面 + first = fut_idx[0] + before = lines[:first] + # docstring(如果第一段是字符串)要留在最前面 + keep = [] + moved = [] + i = 0 + # 跳过 shebang / coding / 注释 / docstring + while i < len(before): + s = before[i].strip() + if (not s or s.startswith("#") or s.startswith('"""') + or s.startswith("'''") or s.startswith('r"""') + or s.startswith("r'''")): + keep.append(before[i]) + # docstring 可能是多行,简单处理:找到闭合 + if s.startswith(('"""', "'''", 'r"""', "r'''")): + quote = '"""' if '"""' in s else "'''" + if s.count(quote) < 2: + i += 1 + while i < len(before): + keep.append(before[i]) + if quote in before[i]: + break + i += 1 + i += 1 + continue + if re.match(r"^\s*(import |from )", before[i]): + moved.append(before[i]) + i += 1 + continue + # 其它(比如赋值)就停在 docstring 之后、import 之前 —— 不动它 + break + + rest_before = before[i:] + fut_block = lines[first:] + # 找到 __future__ 连续块结束 + end = 0 + while end < len(fut_block) and ( + re.match(r"^\s*from __future__ import ", fut_block[end]) + or not fut_block[end].strip()): + end += 1 + fut_lines = fut_block[:end] + after = fut_block[end:] + + new_lines = keep + fut_lines + ([""] if moved else []) + moved \ + + rest_before + after + new_text = "\n".join(new_lines) + "\n" + + try: + compile(new_text, str(path), "exec") + except SyntaxError as exc: + return "error: 修完还是语法错:第 %s 行 %s" % (exc.lineno, exc.msg) + + path.write_text(new_text, encoding="utf-8", newline="\n") + return "fixed" + + +def main() -> int: + files = [] + for pat in PATTERNS: + files += sorted(ROOT.glob(pat)) + + fixed = [] + errors = [] + checked = 0 + for f in files: + if not f.is_file(): + continue + checked += 1 + r = fix_file(f) + if r == "fixed": + fixed.append(str(f.relative_to(ROOT))) + elif r != "ok": + errors.append("%s: %s" % (f.relative_to(ROOT), r)) + + print(" 检查 %d 个文件" % checked) + if fixed: + print(" 修好 %d 个:" % len(fixed)) + for x in fixed: + print(" %s" % x) + else: + print(" ✓ 没有需要修的") + + if errors: + print(" ⚠ %d 个修不了:" % len(errors)) + for x in errors: + print(" %s" % x) + return 1 + + # 复查 + still = [] + for f in files: + if not f.is_file(): + continue + try: + compile(f.read_text(encoding="utf-8"), str(f), "exec") + except SyntaxError as exc: + if "__future__" in (exc.msg or ""): + still.append(str(f.relative_to(ROOT))) + if still: + print(" ✗ 还有 %s" % still) + return 1 + print(" ✓ 全仓 __future__ 位置都正确") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/fix_watch_lan_panel.py b/_build/fix_watch_lan_panel.py new file mode 100644 index 0000000..e5e75fc --- /dev/null +++ b/_build/fix_watch_lan_panel.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +r'''修「局域网连上后没反应,得推出去点设备才能连」。 + +== 根因 == + +手表上有**四个**面板,靠手工 setVisibility 切换: + + connectPanel 首页(连接页) + controlPanel 控制面板 + mirrorPanel 投屏 + lanPanel 局域网连接页 ← 后加的 + +而 showControlPanel() 只藏了前两个: + + private void showControlPanel() { + connectPanel.setVisibility(View.GONE); + controlPanel.setVisibility(View.VISIBLE); + if (mirrorPanel != null) mirrorPanel.setVisibility(View.GONE); + ... + } + +**没藏 lanPanel。** 于是用户从「局域网连接」页面填完配对码、连接成功之后: + + connect() 成功 -> showControlPanel() -> 控制面板显示了 + 但 lanPanel 还在最上层 -> 用户看到的还是局域网页面 + +表现就是用户说的:「提示连上了,但没反应,得推出去到设备列表 +点击设备才能连接」—— 推出去(返回)把 lanPanel 收掉,才看到底下 +的控制面板/设备列表。 + +同样的漏藏在另外两处(切投屏的时候也忘了藏 lanPanel)。 + +== 修法 == + +不逐处补 setVisibility —— 那样每加一个面板就会再漏一次 +(这次就是加 lanPanel 时漏的)。改成**一个统一入口**: + + private void showOnly(View keep) // 藏掉所有面板,只留一个 + +所有切换都走它。以后再加面板,只要在 showOnly 的列表里加一行, +所有切换点自动都对。 +''' +from __future__ import annotations + +import re +import sys +from pathlib import Path + +SRC = (Path(__file__).resolve().parent.parent / "android" / "src" + / "com" / "lanremote" / "watch" / "MainActivity.java") + + +HELPER = ''' /** + * 只显示一个面板,其余全部隐藏。 + * + *

为什么要有这个:原来每个切换点都手写一串 setVisibility, + * 加第四个面板(lanPanel)时漏补了几处 —— 于是「局域网连接页」 + * 填完码连上了,{@link #showControlPanel()} 也执行了, + * 但局域网页还盖在最上层,用户看到的是"连上了但没反应", + * 得推出去才看到底下的控制面板。 + * + *

以后加面板只需要往这个方法里加一行,所有切换点自动都对。 + */ + private void showOnly(View keep) { + View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel}; + for (View v : all) { + if (v != null) { + v.setVisibility(v == keep ? View.VISIBLE : View.GONE); + } + } + } + +''' + + +def main() -> int: + text = SRC.read_text(encoding="utf-8") + ok = True + + # ---- 1. 插入 showOnly 帮助方法(放在 showControlPanel 之前)---- + anchor = " private void showControlPanel() {" + if "private void showOnly(View keep)" in text: + print(" • showOnly 已经存在") + elif anchor not in text: + print(" ✗ 找不到 showControlPanel") + return 1 + else: + text = text.replace(anchor, HELPER + anchor, 1) + print(" ✓ 插入 showOnly()") + + # ---- 2. showControlPanel 改用 showOnly ---- + old_ctrl = """ private void showControlPanel() { + connectPanel.setVisibility(View.GONE); + controlPanel.setVisibility(View.VISIBLE); + if (mirrorPanel != null) { + mirrorPanel.setVisibility(View.GONE); + } + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); + }""" + new_ctrl = """ private void showControlPanel() { + // ⚠ 必须走 showOnly —— 原来这里手写 setVisibility,漏了 lanPanel, + // 导致"局域网连上后没反应、要推出去点设备"。 + showOnly(controlPanel); + getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); + }""" + if old_ctrl in text: + text = text.replace(old_ctrl, new_ctrl, 1) + print(" ✓ showControlPanel 改用 showOnly") + else: + print(" ! showControlPanel 的形状和预期不一样,用逐行方式改") + # 退化:把它函数体里的四行拆掉,换成 showOnly + m = re.search(r"( private void showControlPanel\(\) \{\n)(.*?)(\n \})", + text, re.S) + if m: + body = m.group(2) + # 保留 setSoftInputMode 那行 + keep = [ln for ln in body.splitlines() + if "setSoftInputMode" in ln] + newbody = (" showOnly(controlPanel);\n" + + "\n".join(keep)) + text = text[:m.start(2)] + newbody + text[m.end(2):] + print(" ✓ 已替换(退化方式)") + else: + ok = False + + # ---- 3. 另外两处切投屏的地方也要藏 lanPanel ---- + # 形态:connectPanel.GONE / controlPanel.GONE / mirrorPanel.VISIBLE + pat_mirror = re.compile( + r" connectPanel\.setVisibility\(View\.GONE\);\n" + r" controlPanel\.setVisibility\(View\.GONE\);\n" + r" mirrorPanel\.setVisibility\(View\.VISIBLE\);") + + def repl_mirror(m): + return " showOnly(mirrorPanel);" + + n_mirror = len(pat_mirror.findall(text)) + if n_mirror: + text = pat_mirror.sub(repl_mirror, text) + print(" ✓ %d 处切投屏改用 showOnly" % n_mirror) + else: + print(" • 没找到切投屏的那两处(可能已经改过)") + + # ---- 4. showLanPanel 也走 showOnly ---- + pat_lan = re.compile( + r" connectPanel\.setVisibility\(View\.GONE\);\n" + r" controlPanel\.setVisibility\(View\.GONE\);\n" + r"(\s*)mirrorPanel\.setVisibility\(View\.GONE\);\n" + r"(\s*)lanPanel\.setVisibility\(View\.VISIBLE\);") + if pat_lan.search(text): + text = pat_lan.sub(" showOnly(lanPanel);", text) + print(" ✓ showLanPanel 改用 showOnly") + + if not ok: + print(" 有地方没匹配上,没有写回") + return 1 + + SRC.write_text(text, encoding="utf-8", newline="\n") + print() + print(" 已写回 MainActivity.java") + + # 自检:还有没有裸的 panel setVisibility(除了 showOnly 内部和首页初始化) + leftovers = [] + for i, line in enumerate(text.splitlines(), 1): + s = line.strip() + if re.match(r"^(connectPanel|controlPanel|mirrorPanel|lanPanel)" + r"\.setVisibility", s): + leftovers.append((i, s)) + print() + print(" ── 剩下的裸 setVisibility(%d 处)──" % len(leftovers)) + for ln, s in leftovers: + print(" 第 %d 行: %s" % (ln, s)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_build/ykxmz.spec b/_build/ykxmz.spec index c4c1176..e8c5a13 100644 --- a/_build/ykxmz.spec +++ b/_build/ykxmz.spec @@ -6,7 +6,7 @@ a = Analysis( pathex=['C:/Users/shu/Desktop/deepseekagent/LanRemote/windows'], binaries=[], datas=[], - hiddenimports=['PIL._tkinter_finder', 'lanremote.netcheck', 'lanremote.features', 'lanremote.headless'], + hiddenimports=['PIL._tkinter_finder', 'lanremote.netcheck', 'lanremote.features', 'lanremote.headless', 'lanremote.ui'], hookspath=[], hooksconfig={}, runtime_hooks=[], diff --git a/_tests/check_python_syntax.py b/_tests/check_python_syntax.py new file mode 100644 index 0000000..94297b1 --- /dev/null +++ b/_tests/check_python_syntax.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +r'''全仓语法检查 —— 必须用 compile(),不能用 ast.parse()。 + +== 为什么这个文件存在 == + +我这一轮反复被同一个问题咬: + + Python 要求 `from __future__ import ...` 是文件里第一条语句。 + 我的补丁脚本为了加 `import os` / `import re`,把它插到了 + `from __future__` **前面**,整个文件就废了。 + +而更糟的是**我的检查工具查不出来**: + + ast.parse(src) -> 通过(它只解析,不做编译期检查) + compile(src, ...) -> SyntaxError: from __future__ imports + must occur at the beginning of the file + +我前面几次"全仓语法检查 0 个问题"就是这么来的假绿。 +真正的症状是: + + * 源码直接跑 -> 启动就崩 + * 打包成 exe -> 走到那个模块才崩,用户看到"双击没反应" + +所以这个脚本用 **compile()** 扫全仓,并且专门把 +"只被 ast.parse 放过、但 compile 会拦" 的文件标出来。 + +进回归套件之后,任何脚本再犯一次,全量回归立刻红。 +''' +from __future__ import annotations + +import ast +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +PATTERNS = ["_tests/*.py", "_build/*.py", "relay/*.py", "windows/*.py", + "windows/lanremote/*.py", "installer/*.py"] + + +def main() -> int: + files = [] + for pat in PATTERNS: + files += sorted(ROOT.glob(pat)) + + bad = [] + sneaky = [] # ast.parse 放过、compile 拦下的(就是这次的坑) + n = 0 + for f in files: + if not f.is_file(): + continue + n += 1 + try: + src = f.read_text(encoding="utf-8") + except Exception: # noqa: BLE001 + continue + try: + compile(src, str(f), "exec") + except SyntaxError as exc: + bad.append((f.relative_to(ROOT), exc.lineno, exc.msg)) + # 看看 ast.parse 是不是会放过它 + try: + ast.parse(src) + sneaky.append(f.relative_to(ROOT)) + except SyntaxError: + pass + + print(" 用 compile() 检查 %d 个文件" % n) + if not bad: + print(" ✓ 全部通过") + return 0 + + print(" ✗ %d 个文件有语法错:" % len(bad)) + for rel, ln, msg in bad: + print(" %s: 第 %s 行 %s" % (rel, ln, msg)) + if sneaky: + print() + print(" ⚠ 下面这些 **ast.parse 会放过**(所以以前的检查是假绿):") + for rel in sneaky: + print(" %s" % rel) + print(" -> 以后一律用 compile(),别再用 ast.parse() 当语法检查。") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/test_double_click_window.py b/_tests/test_double_click_window.py new file mode 100644 index 0000000..19bbd6f --- /dev/null +++ b/_tests/test_double_click_window.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +r'''验证「双击 exe 会打开设置窗口」。 + +== 为什么要专门测这个 == + +这条路上踩了两个坑,而且**都是静默失败**(用户只看到"双击没反应"): + + 1. run_agent.py 的 main() 里,装完之后无参数直接 run_silent + -> 什么都不弹(代码层面的行为问题) + 2. 改成弹窗口之后,PyInstaller 的 --hidden-import 里没有 lanremote.ui + -> exe 里根本没有 tkinter 那套代码,一跑就 + ModuleNotFoundError,进程立刻退出(打包层面的问题) + +第 2 个尤其阴:**源码跑得好好的,只有打包出来的 exe 才会崩**。 +所以这个测试必须针对**打包好的 exe** 跑,不能只跑源码。 +''' +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXE = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe" + +passed = 0 +failed = 0 + + +def check(name, ok, detail=""): + global passed, failed + if ok: + passed += 1 + print(" [PASS] " + name) + else: + failed += 1 + print(" [FAIL] " + name + ((" —— " + str(detail)) if detail else "")) + + +def main() -> int: + print("=" * 58) + print(" 双击 exe 要能弹出设置窗口") + print("=" * 58) + + check("打包好的 exe 存在", EXE.is_file(), EXE) + if not EXE.is_file(): + return 1 + + work = Path(tempfile.mkdtemp(prefix="uiwin_")) + data = work / "data" + data.mkdir(parents=True, exist_ok=True) + # 造一个"已经装好"的配置 —— 这正是用户双击时的状态 + (data / "settings.json").write_text(json.dumps({ + "port": 8765, "server_name": "TEST-PC", "setup_done": True, + "allow_new_pairing": True, "pairing_code": "123456", + "pairing_created": 0, "relay_url": "", + }, ensure_ascii=False, indent=2), encoding="utf-8") + + import os + env = dict(os.environ) + env["YKXMZ_DATA"] = str(data) + env["YKXMZ_LOCK_PORT"] = "19931" # 避开用户自己的实例 + + print(" ── 启动 exe(无参数)──") + out = open(work / "out.txt", "wb") + proc = subprocess.Popen([str(EXE)], env=env, cwd=str(EXE.parent), + stdout=out, stderr=subprocess.STDOUT) + time.sleep(12) + + if proc.poll() is not None: + out.close() + text = (work / "out.txt").read_text(encoding="utf-8", errors="replace") + check("进程没有立刻退出", False, + "退出码 %s;输出:%s" % (proc.returncode, text[-400:])) + # 特别指出 hiddenimports 这个坑 + if "lanremote.ui" in text or "No module named" in text: + print(" ↑ 这就是 --hidden-import 漏了 lanremote.ui 的典型症状") + return 1 + + check("进程活着(没崩)", True) + + # 有没有窗口:让 PowerShell 把标题**写进文件**再读。 + # ⚠ 不能直接读 stdout —— Windows 控制台默认不是 UTF-8, + # 中文标题会变成乱码(实测 "ykxmz · 设置" 读成 "ykxmz �� ����"), + # 于是"标题里带设置"这条会假失败。 + try: + title_file = work / "title.txt" + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + "(Get-Process -Id %d).MainWindowTitle | " + "Set-Content -Encoding UTF8 -Path '%s'" + % (proc.pid, str(title_file).replace("'", "''"))], + capture_output=True, timeout=20) + title = "" + if title_file.is_file(): + title = title_file.read_text(encoding="utf-8-sig").strip() + except Exception as exc: # noqa: BLE001 + title = "" + print(" (读窗口标题失败:%s)" % exc) + + print(" 窗口标题: %r" % title) + check("弹出了窗口(MainWindowTitle 非空)", bool(title), + "标题是空的 —— 窗口没弹出来") + + if title: + check("标题里带「设置」", "设置" in title, title) + + # 收尾 + try: + proc.terminate() + proc.wait(timeout=6) + except Exception: # noqa: BLE001 + try: + proc.kill() + except Exception: # noqa: BLE001 + pass + out.close() + + print() + print("=" * 58) + print(" 通过 %d 项,失败 %d 项" % (passed, failed)) + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/_tests/verify_all.ps1 b/_tests/verify_all.ps1 index df97009..81792de 100644 --- a/_tests/verify_all.ps1 +++ b/_tests/verify_all.ps1 @@ -404,6 +404,15 @@ $stageOut = & python (Join-Path $root "_tests\check_encoding.py") 2>&1 Show-Fails $stageOut Record "编码与换行" ($LASTEXITCODE -eq 0) +# ---------------------------------------------------------------- 3a7 +Banner "3a7/7 Python 语法(必须用 compile,不能用 ast.parse)" +# 这一轮被同一件事咬了三次:补丁脚本把 import 插到 from __future__ +# 前面,整个文件就废了。而更糟的是 ast.parse 查不出来(它只解析, +# 不做编译期检查)—— 前几次「全仓语法检查 0 问题」都是假绿。 + = & python (Join-Path "_tests\check_python_syntax.py") 2>&1 + Show-Fails +Record "Python 语法检查" ( -eq 0) + # ---------------------------------------------------------------- 3a5 Banner "3a5/7 局域网/中转对比基准(防测试自己骗人)" # 这个基准一度是红的,而且报告里写着「这是中转链路的产品 bug」—— diff --git a/android/src/com/lanremote/watch/MainActivity.java b/android/src/com/lanremote/watch/MainActivity.java index 08f1cd5..6ca26a2 100644 --- a/android/src/com/lanremote/watch/MainActivity.java +++ b/android/src/com/lanremote/watch/MainActivity.java @@ -2074,12 +2074,8 @@ public class MainActivity extends Activity implements RemoteClient.Listener { } private void showLanPanel() { - connectPanel.setVisibility(View.GONE); - controlPanel.setVisibility(View.GONE); - if (mirrorPanel != null) { - mirrorPanel.setVisibility(View.GONE); - } - lanPanel.setVisibility(View.VISIBLE); + // 走统一入口 —— 见 showOnly 的说明(这里原来也漏藏过别的面板) + showOnly(lanPanel); getWindow().setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); // 进来先自动搜一次,省得用户还要自己点 @@ -2298,20 +2294,35 @@ public class MainActivity extends Activity implements RemoteClient.Listener { } private void showConnectPanel() { - connectPanel.setVisibility(View.VISIBLE); - controlPanel.setVisibility(View.GONE); - if (mirrorPanel != null) { - mirrorPanel.setVisibility(View.GONE); - } + // 走统一入口 —— 原来这里也没藏 lanPanel + showOnly(connectPanel); getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); } - private void showControlPanel() { - connectPanel.setVisibility(View.GONE); - controlPanel.setVisibility(View.VISIBLE); - if (mirrorPanel != null) { - mirrorPanel.setVisibility(View.GONE); + /** + * 只显示一个面板,其余全部隐藏。 + * + *

为什么要有这个:原来每个切换点都手写一串 setVisibility, + * 加第四个面板(lanPanel)时漏补了几处 —— 于是「局域网连接页」 + * 填完码连上了,{@link #showControlPanel()} 也执行了, + * 但局域网页还盖在最上层,用户看到的是"连上了但没反应", + * 得推出去才看到底下的控制面板。 + * + *

以后加面板只需要往这个方法里加一行,所有切换点自动都对。 + */ + private void showOnly(View keep) { + View[] all = {connectPanel, controlPanel, mirrorPanel, lanPanel}; + for (View v : all) { + if (v != null) { + v.setVisibility(v == keep ? View.VISIBLE : View.GONE); + } } + } + + private void showControlPanel() { + // ⚠ 必须走 showOnly —— 原来这里手写 setVisibility,漏了 lanPanel, + // 导致"局域网连上后没反应、要推出去点设备"。 + showOnly(controlPanel); getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); } @@ -3813,9 +3824,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener { if (!ensureConnected()) { return; } - connectPanel.setVisibility(View.GONE); - controlPanel.setVisibility(View.GONE); - mirrorPanel.setVisibility(View.VISIBLE); + showOnly(mirrorPanel); animateIn(mirrorPanel, 24f, 260); getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING); @@ -3876,7 +3885,9 @@ public class MainActivity extends Activity implements RemoteClient.Listener { mirrorImage.setImageBitmap(null); } if (controlPanel.getVisibility() != View.VISIBLE && client.isConnected()) { - controlPanel.setVisibility(View.VISIBLE); + // 走统一入口:投屏退出后要把控制面板露出来, + // 同时保证 lanPanel 之类不会再盖在上面 + showOnly(controlPanel); } } @@ -3938,9 +3949,7 @@ public class MainActivity extends Activity implements RemoteClient.Listener { } // 截屏结果直接在镜像面板里显示 if (mirrorPanel.getVisibility() != View.VISIBLE) { - connectPanel.setVisibility(View.GONE); - controlPanel.setVisibility(View.GONE); - mirrorPanel.setVisibility(View.VISIBLE); + showOnly(mirrorPanel); } mirrorStatus.setText("正在截屏…"); appendLog("正在截屏…"); diff --git a/windows/build_exe.ps1 b/windows/build_exe.ps1 index fdf946f..891b9bf 100644 --- a/windows/build_exe.ps1 +++ b/windows/build_exe.ps1 @@ -1,4 +1,4 @@ -# 把 Windows 被控端打包成「多文件 EXE」(PyInstaller --onedir) +# 把 Windows 被控端打包成「多文件 EXE」(PyInstaller --onedir) # 用法: powershell -ExecutionPolicy Bypass -File windows\build_exe.ps1 # # 产物: dist_exe\ykxmz\ @@ -22,6 +22,12 @@ if ($LASTEXITCODE -ne 0) { throw "生成图标失败" } $ico = Join-Path $windows "app.ico" Write-Host "`n=== 2/3 PyInstaller 打包(--onedir 多文件)===" -ForegroundColor Cyan +# ⚠ lanremote.ui 必须显式 --hidden-import(见下面那行)。 +# 它是"设置窗口"(tkinter)的实现,而静默那条路不需要它, +# PyInstaller 静态分析就找不到 —— 于是双击 exe 想弹设置窗口时 +# 会 ModuleNotFoundError: No module named 'lanremote.ui', +# 进程直接退出,用户看到的就是"双击没反应"。 +# (PS 的续行符 ` 后面不能再跟 # 注释,所以说明写在这里。) & python -m PyInstaller --noconfirm --clean --onedir --windowed ` --name "ykxmz" ` --icon "$ico" ` @@ -30,6 +36,7 @@ Write-Host "`n=== 2/3 PyInstaller 打包(--onedir 多文件)===" -Foreground --hidden-import "lanremote.netcheck" ` --hidden-import "lanremote.features" ` --hidden-import "lanremote.headless" ` + --hidden-import "lanremote.ui" ` --version-file (Join-Path $windows "version_info.txt") ` --noupx ` --exclude-module "pystray" ` diff --git a/windows/lanremote/instance.py b/windows/lanremote/instance.py index 8a8b903..506632b 100644 --- a/windows/lanremote/instance.py +++ b/windows/lanremote/instance.py @@ -116,6 +116,32 @@ class InstanceLock: self._sock = None +def is_running(port: int | None = None, timeout: float = 0.6) -> bool: + """现在有没有别的实例占着单实例锁。 + + 只是"探一下",不会让对方做任何事(发的是空包,不是 SHOW)。 + + 为什么单独要这个函数: + "双击 exe 开设置窗口"这条路上要知道后台是不是在跑 —— + 在跑的话,保存完得让后台重启才会生效; + 不在跑的话直接存就行。原来没有这个能力, + 我第一版顺手写了个 InstanceLock.probe_free(), + 而那个方法根本不存在,一调就 AttributeError。 + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + try: + sock.connect(("127.0.0.1", control_port() if port is None else int(port))) + return True + except OSError: + return False + finally: + try: + sock.close() + except OSError: + pass + + def signal_show(port: int | None = None, timeout: float = 2.0) -> bool: """通知已在运行的实例把窗口显示出来。""" if port is None: diff --git a/windows/lanremote/ui.py b/windows/lanremote/ui.py index 9cca05b..7b1d26b 100644 --- a/windows/lanremote/ui.py +++ b/windows/lanremote/ui.py @@ -22,6 +22,7 @@ """ from __future__ import annotations +import re import tkinter as tk from pathlib import Path @@ -111,8 +112,14 @@ class SetupWindow: CODE_LEN = 6 - def __init__(self, store, suggested: str = ""): + def __init__(self, store, suggested: str = "", + mode: str = "first"): + """mode: + "first" 第一次设置(走防火墙 + 自启 + 转静默) + "settings" 已装好,只改配置(配对码/中转/接入开关) + """ self.store = store + self.mode = mode if mode in ("first", "settings") else "first" self.suggested = (suggested or store.pairing_code() or store.new_pairing_code()) self.result: dict | None = None @@ -128,7 +135,8 @@ class SetupWindow: on_ready 是窗口建好之后的回调(自动化测试用)。 """ self.root = tk.Tk() - self.root.title("ykxmz · 第一次设置") + self.root.title("ykxmz · " + ( + "第一次设置" if self.mode == "first" else "设置")) self.root.configure(bg=BG) self.root.resizable(False, False) self._set_icon() @@ -234,7 +242,10 @@ class SetupWindow: titles.pack(side="left", anchor="w") tk.Label(titles, text="ykxmz", bg=BG, fg=FG, font=FONT_TITLE).pack( anchor="w") - tk.Label(titles, text="远程控制 · 第一次设置", bg=BG, fg=DIM, + tk.Label(titles, + text=("远程控制 · 第一次设置" if self.mode == "first" + else "远程控制 · 设置"), + bg=BG, fg=DIM, font=FONT_SUB).pack(anchor="w") # ================= 配对码卡片 ================= @@ -279,6 +290,41 @@ class SetupWindow: justify="left", anchor="w") self.status.pack(fill="x", pady=(6, 0)) + # ================= 中转服务器卡片 ================= + # 用户要求:安装时要能设中转服务器;装完之后双击 exe 也能回来改。 + # + # 为什么不填也能用:手表和电脑在同一个 WiFi 时走局域网直连, + # 根本不需要中转。只有"在办公室管教室"这种跨网络场景才要填。 + relay_card = self._card(page) + relay_card.pack(fill="x", pady=(12, 0)) + relay_inner = tk.Frame(relay_card.winfo_children()[0], bg=CARD) + relay_inner.pack(fill="x", padx=16, pady=14) + + tk.Label(relay_inner, text="中转服务器(可以不填)", bg=CARD, fg=FG, + font=FONT_LABEL).pack(anchor="w") + + self.relay_entry = tk.Entry( + relay_inner, bg="#f6f8fa", fg=FG, relief="flat", + font=FONT_SUB, insertbackground=ACCENT) + self.relay_entry.pack(fill="x", pady=(8, 0), ipady=6) + _saved_relay = str(self.store.get("relay_url", "") or "").strip() + if _saved_relay: + self.relay_entry.insert(0, _saved_relay) + + tk.Label(relay_inner, + text="手表和电脑在同一个 WiFi 时不需要填 —— 会直接走" + "局域网,几十毫秒。\n" + "要在别的地方(比如办公室)管这台电脑时才填," + "填你自己那台中转服务器的地址。", + bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left", + wraplength=396).pack(anchor="w", pady=(8, 0)) + + self.relay_status = tk.Label(relay_inner, text="", bg=CARD, + fg=DANGER, font=FONT_SMALL, + wraplength=396, justify="left", + anchor="w") + self.relay_status.pack(fill="x", pady=(6, 0)) + # ================= 选项卡片 ================= options = self._card(page) options.pack(fill="x", pady=(12, 0)) @@ -291,9 +337,22 @@ class SetupWindow: self.var_auto = tk.BooleanVar(value=True) self._check(opt_inner, "开机自动运行", self.var_auto, "开机后在后台跑,不用手动开") - self.var_firewall = tk.BooleanVar(value=True) - self._check(opt_inner, "放行防火墙", self.var_firewall, - "会弹一次管理员确认,然后黑窗口一闪而过") + # 「允许新设备接入」只在设置模式给 —— + # 第一次设置时当然是允许的(不然手表连不上)。 + if self.mode == "settings": + self.var_allow_new = tk.BooleanVar( + value=bool(self.store.get("allow_new_pairing", True))) + self._check(opt_inner, "允许新设备接入", self.var_allow_new, + "关掉之后,只有已经配对过的设备能连上;" + "新设备会被拒绝") + else: + self.var_allow_new = None + + self.var_firewall = tk.BooleanVar( + value=(self.mode == "first")) + if self.mode == "first": + self._check(opt_inner, "放行防火墙", self.var_firewall, + "会弹一次管理员确认,然后黑窗口一闪而过") tk.Label(opt_inner, text="没放行成功的话,配对码不会提交给服务器," "会告诉你怎么处理。", @@ -389,16 +448,37 @@ class SetupWindow: DANGER) return + # 中转地址:允许空(空 = 只走局域网)。 + # 填了的话做一次最基本的格式检查,免得用户把域名打错还找不到原因。 + relay = self.relay_entry.get().strip() + if relay: + if not relay.startswith(("http://", "https://")): + relay = "https://" + relay + # 只留 协议://主机[:端口],去掉多余路径 + _m = re.match(r"^(https?://[^/\s]+)", relay) + if not _m: + self._set_relay_status("中转地址看着不对,检查一下", DANGER) + return + relay = _m.group(1).rstrip("/") self.result = { "code": cleaned, "autostart": bool(self.var_auto.get()), "firewall": bool(self.var_firewall.get()), + "relay": relay, + "allow_new": (None if self.var_allow_new is None + else bool(self.var_allow_new.get())), } try: self.root.quit() except tk.TclError: pass + def _set_relay_status(self, text: str, color: str) -> None: + try: + self.relay_status.configure(text=text, fg=color) + except tk.TclError: + pass + def _cancel(self) -> None: self.result = None try: diff --git a/windows/run_agent.py b/windows/run_agent.py index 6eb701d..e91a017 100644 --- a/windows/run_agent.py +++ b/windows/run_agent.py @@ -672,6 +672,103 @@ def run_setup(store) -> int: return run_silent(store, SETUP_PAIR_MINUTES) +def run_settings(store) -> int: + """双击 exe 时打开的设置窗口。 + + 和 run_setup 的区别: + run_setup 第一次装:走防火墙 + 开机自启 + 最后转静默 + run_settings 已装好:只改配置,改完把后台实例重启一下让设置生效 + + 为什么"已经有一个实例在跑"时也要照样开窗口: + 以前 InstanceLock 拿不到就直接 return,用户双击后**什么都看不到**, + 以为程序坏了。现在开窗、改完、重启后台实例。 + """ + from lanremote.instance import is_running + from lanremote.ui import SetupWindow + + running = is_running() + + window = SetupWindow(store, mode="settings").show() + if window is None: + return 0 # 用户关掉了,什么都不改 + + changed = [] + + # ---- 配对码 ---- + if window.get("code") and window["code"] != store.pairing_code(): + try: + store.set_pairing_code(window["code"]) + changed.append("配对码") + except ValueError as exc: + print(f"设置配对码失败:{exc}") + return 2 + + # ---- 中转服务器(空 = 只走局域网)---- + relay = str(window.get("relay", "") or "").strip() + if relay != str(store.get("relay_url", "") or "").strip(): + store.set("relay_url", relay) + changed.append("中转地址") + + # ---- 允许新设备接入 ---- + allow_new = window.get("allow_new") + if allow_new is not None and bool(allow_new) != bool( + store.get("allow_new_pairing", True)): + store.set("allow_new_pairing", bool(allow_new)) + changed.append("接入开关") + + # ---- 开机自启 ---- + try: + from lanremote.autostart import enable, disable, is_enabled + want = bool(window.get("autostart")) + if want != bool(is_enabled()): + (enable if want else disable)() + changed.append("开机自启") + except Exception: # noqa: BLE001 + pass + + # ---- 让后台实例重新读配置 ---- + if changed and running: + _restart_background(store) + print("已保存:" + "、".join(changed) + "(后台实例已重启)") + elif changed: + print("已保存:" + "、".join(changed)) + else: + print("没有改动") + return 0 + + +def _restart_background(store) -> None: + """把正在跑的后台实例停掉,再用新配置起一个。 + + 为什么要重启而不是"让它自己重读": + allow_new_pairing 是启动时读进 server.pairing_enabled 的, + 中转地址也是启动时连的。文件改了但进程里还是旧值, + 用户会以为设置没生效。 + """ + import subprocess + # 只杀"没有窗口"的那个(后台实例)。taskkill 按镜像名杀会连自己一起杀, + # 所以这里用 wmic/powershell 过滤命令行里带 --silent 的。 + try: + subprocess.run( + ["powershell", "-NoProfile", "-Command", + "Get-CimInstance Win32_Process -Filter \"Name='ykxmz.exe'\" | " + "Where-Object { $_.CommandLine -match '--silent' } | " + "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"], + capture_output=True, timeout=20) + except Exception: # noqa: BLE001 + pass + time.sleep(1.2) + # 用 --silent 重新起一个,完全脱离本进程 + try: + exe = sys.executable + subprocess.Popen([exe, "--silent"], + creationflags=getattr(subprocess, + "DETACHED_PROCESS", 0), + close_fds=True) + except Exception: # noqa: BLE001 + pass + + def main(argv=None) -> int: try: sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr] @@ -773,16 +870,23 @@ def main(argv=None) -> int: if args.silent or args.add_device is not None: return run_silent(store, args.add_device) - # --setup:重新设置 + # --setup:重新打开设置窗口(和双击等效) if args.setup: - return run_setup(store) + return run_settings(store) # 第一次运行:设置窗口 → 修防火墙 → 静默 if not store.get("setup_done", False): return run_setup(store) - # 已经设置过:直接静默,屏幕上什么都不出现 - return run_silent(store, None) + # 已经设置过、而且没给参数 —— 说明用户是**双击 exe**。 + # + # 以前这里是 `return run_silent(store, None)`:直接后台跑, + # 屏幕上什么都不出现。用户想改配对码 / 中转地址 / 接入开关时 + # 双击毫无反应,只能去翻命令行参数。 + # + # 现在双击 = 打开设置窗口。后台运行走显式 --silent + # (开机自启和安装包都用的 --silent,所以不受影响)。 + return run_settings(store) if __name__ == "__main__":