# -*- 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())