# -*- coding: utf-8 -*- """被控端启动入口。 双击 EXE 的完整流程: 第一次运行 弹出一个窗口「第一次使用」→ 输入 6 位配对码 可勾选:设置开机自启 / 顺便放行防火墙 ↓ 点确定 黑终端一闪(提权跑一键修复防火墙,会先弹一次 UAC) ↓ 窗口消失,程序转入后台静默运行 —— 屏幕上再没有任何东西 之后每次开机 直接后台静默运行,一个窗口都没有 手表 输那 6 位数字就连上 要重新设置:ykxmz.exe --setup 命令行参数: --setup 重新打开设置窗口 --silent 直接静默运行(开机自启用的就是这个) --add-device [分钟] 静默运行 + 临时允许新设备配对 --autostart on|off 开关开机自启 --status 打印状态后退出 --port 9000 指定端口 --new-code 换一组配对码 --code 123456 把配对码设成指定值(安装包用) --name "三年二班" 设置电脑在手表上显示的名字 --reset-devices 清空已配对设备 """ from __future__ import annotations import argparse import os import sys from pathlib import Path from lanremote import APP_NAME, __version__ from lanremote.config import Store, app_dir, data_dir from lanremote.instance import InstanceLock def parse_args(argv=None): parser = argparse.ArgumentParser( prog="lanremote-agent", description=f"{APP_NAME} v{__version__}") parser.add_argument("--port", type=int, help="监听端口(默认 8765)") parser.add_argument("--new-code", action="store_true", help="换一组配对码") parser.add_argument("--code", default=None, metavar="6位数字", help="把配对码设成指定值(必须是 6 位数字)。" "安装包用这个把用户在安装向导里填的码写进去。") parser.add_argument("--name", default=None, metavar="名字", help="设置这台电脑在手表上显示的名字" "(默认用 Windows 的计算机名)") parser.add_argument("--reset-devices", action="store_true", help="清空已配对设备") parser.add_argument("--silent", action="store_true", help="全静默运行:没有任何窗口,也没有终端") parser.add_argument("--add-device", nargs="?", type=int, const=10, default=None, metavar="分钟", help="静默运行,但临时允许新设备配对(默认 10 分钟)") parser.add_argument("--autostart", choices=["on", "off"], help="开关开机自启") parser.add_argument("--status", action="store_true", help="打印状态后退出") parser.add_argument("--setup", action="store_true", help="重新打开设置窗口") parser.add_argument("--netcheck", nargs="?", type=int, const=90, default=None, metavar="秒", help="网络自检(自开控制台,不需要装 Python)") parser.add_argument("--fix-firewall", action="store_true", help="提权修复防火墙") parser.add_argument("--relay-url", default=None, metavar="地址", help="只把中转地址写进配置,不连接、不退出(安装包用)") parser.add_argument("--relay", default=None, metavar="地址", help="设置中转服务器地址(跨网络用)。" "可用分号分隔多个,按顺序尝试,例如:" "http://192.168.1.10:8080;https://aaa.bbb.com" " —— 局域网优先,断了自动切隧道。" "传空字符串关闭(默认就是空,只走局域网)") parser.add_argument("--relay-status", action="store_true", help="查看中转连接状态") parser.add_argument("--uninstall", action="store_true", help="卸载:停进程、删开机自启、删数据目录") parser.add_argument("--keep-dir", action="store_true", help="配合 --uninstall:不安排自删程序目录。" "安装包的卸载器调用时用这个 —— " "Inno 自己会删 {app},两边一起删会抢文件") parser.add_argument("--quiet", action="store_true", help="配合 --uninstall:完全不碰控制台。" "安装包的卸载器(runhidden)**必须**用这个," "否则 AllocConsole/input 会阻塞导致卸载卡死") return parser.parse_args(argv) # ====================================================================== # 命令行小工具 # ====================================================================== def cli_autostart(action: str) -> int: from lanremote.autostart import enable, disable, status try: print(enable() if action == "on" else disable()) except ValueError as exc: print(f"失败: {exc}") return 1 info = status() print(f"当前: {'已开启' if info['enabled'] else '未开启'}") return 0 def cli_uninstall(keep_dir: bool = False, quiet: bool = False) -> int: """卸载。 keep_dir=True 不安排自删程序目录(安装包卸载器调用时用) quiet=True 完全不碰控制台(安装包卸载器调用时**必须**用) ⚠ quiet 为什么必须: 安装包的卸载器用 runhidden 调这个命令,进程没有控制台。 这时候 attach_console() 里的 AllocConsole() + open("CONIN$") 会**永久阻塞** —— 现象就是"从控制面板卸载卡住不结束"。 (实测:卡住的进程只有 1 个线程、0.16 秒 CPU,明显在启动早期就停了。) 另外 input() 也不能调:没有 stdin 时它同样阻塞。 所以 quiet 模式下把结果写进日志文件,人要看就去看日志。 """ from lanremote.uninstall import run as do_uninstall if not quiet: attach_console() try: report = do_uninstall(keep_dir=keep_dir) except Exception as exc: # noqa: BLE001 report = f"卸载出错:{exc}" code = 1 else: code = 0 if quiet: # 没有控制台,把结果落到文件,排查时能看。 # # ⚠ 别写进数据目录!do_uninstall() 会把它整个删掉, # 写到那里等于白写(实测:日志文件从来没出现过)。 # 放 %TEMP% 最稳妥。 try: import tempfile log = Path(tempfile.gettempdir()) / "ykxmz-uninstall.log" log.write_text(report, encoding="utf-8") except Exception: # noqa: BLE001 pass return code print(report) print() # 只有真有人在看的时候才等回车 if sys.stdin is not None and sys.stdin.isatty(): try: input("按回车关闭…") except (EOFError, KeyboardInterrupt): pass return code def attach_console() -> None: """给 --windowed 程序自己开一个控制台窗口。 打包成 --windowed 后进程没有控制台,直接 print 什么都看不到。 AllocConsole 可以临时开一个,并把 stdout/stderr 接过去。 ⚠ 调用方要小心:**在"隐藏窗口 + 没有控制台"的场景下这个函数会挂死**。 安装包的卸载器是用 runhidden 调 --uninstall 的, AllocConsole() 之后的 open("CONIN$") 会一直阻塞, 表现就是"从控制面板卸载永远卡住不结束" (实测:进程 1 个线程、0.16 秒 CPU,卡在启动早期)。 所以那条路径必须传 --quiet 跳过整个函数。 """ if os.name != "nt": return try: import ctypes # 从 .bat 双击启动时已经有控制台了,再 AllocConsole 会失败并且输出丢失 if ctypes.windll.kernel32.GetConsoleWindow(): return ctypes.windll.kernel32.AllocConsole() sys.stdout = open("CONOUT$", "w", encoding="utf-8", buffering=1) sys.stderr = open("CONOUT$", "w", encoding="utf-8", buffering=1) try: sys.stdin = open("CONIN$", "r", encoding="utf-8") except OSError: pass except Exception: # noqa: BLE001 pass def wait_key() -> None: try: print() input("按回车键关闭这个窗口…") except Exception: # noqa: BLE001 pass class _Tee: """把输出同时送到控制台和文件。""" def __init__(self, *streams): self.streams = streams def write(self, text): for stream in self.streams: try: stream.write(text) stream.flush() except Exception: # noqa: BLE001 pass def flush(self): for stream in self.streams: try: stream.flush() except Exception: # noqa: BLE001 pass def cli_netcheck(seconds: int) -> int: """网络自检:监听端口,看手表的包到底有没有到这台电脑。 EXE 是 --windowed 打包的,本来没有控制台。这里自己开一个, 同时把结果写一份到「网络自检结果.txt」—— 控制台万一没显示出来(被杀软拦、权限受限等), 用户还能从文件里看到结论。wlzj.bat 跑完会自动打开这个文件。 """ attach_console() original = sys.stdout path = app_dir() / "网络自检结果.txt" handle = None try: handle = open(path, "w", encoding="utf-8") except OSError: handle = None if handle is not None: sys.stdout = _Tee(original, handle) code = 1 try: from lanremote import netcheck code = netcheck.run_check(int(seconds or 90)) except Exception as exc: # noqa: BLE001 print(f"自检运行失败: {exc}") finally: if handle is not None: try: sys.stdout = original handle.close() except Exception: # noqa: BLE001 pass try: print() print(f"结果已保存到:{path}") except Exception: # noqa: BLE001 pass wait_key() return code def cli_fix_firewall() -> int: """提权修复防火墙(会弹一次 UAC,然后黑终端一闪)。""" store = Store(data_dir()) ok, detail = fix_firewall_with_flash(store) attach_console() print(("已修复:" if ok else "未完成:") + str(detail)) wait_key() return 0 if ok else 1 def normalize_relay_url(raw: str) -> str: """把用户填的中转地址规范化。 规则(和安装向导、设置窗口保持**完全一致**): * 去掉首尾空白 * 没有 http:// / https:// 就补 https:// (用户基本只会填个域名,逼他打协议头没意义) * 去掉末尾斜杠(拼接 "/v1/health" 时不会变成双斜杠) * 空字符串 = 关掉中转,只走局域网 ⚠ 为什么抽成函数:原来这段逻辑散在 cli_relay 里,安装向导 再写一份就容易两边不一致。现在统一走这里。 """ url = (raw or "").strip() if not url: return "" # 先去掉末尾斜杠,再补协议头(顺序反了会把 "https://" 的斜杠也削掉) url = url.rstrip("/") if not url.startswith(("http://", "https://")): url = "https://" + url return url def cli_relay(url: str) -> int: """设置中转地址。传空字符串就关掉。""" store = Store(data_dir()) url = normalize_relay_url(url) store.set("relay_url", url) attach_console() if not url: print("已关闭中转(以后只在局域网内可用)") wait_key() return 0 print(f"中转地址已设为:{url}") print() print("正在测试能不能连上…") try: from lanremote.relay import RelayLink link = RelayLink(store) # start() 的 url 是必填参数 —— 之前这里没传, # 被 try 兜住后打成"✗ 测试失败:missing 1 required positional # argument: 'url'",用户以为是自己地址填错了。 if not link.start(url): print(" 启动失败") wait_key() return 1 # 等它登记成功 import time as _t for _ in range(30): _t.sleep(1) status = link.status() if status.get("online"): print(f" ✓ 已连上中转(设备 {status.get('deviceId')}," f"配对码 {status.get('code')})") link.stop() wait_key() return 0 if status.get("error"): print(f" … {status['error']}") print(f" ✗ 30 秒内没能连上 最后错误:{link.status().get('error')}") link.stop() except Exception as exc: # noqa: BLE001 print(f" ✗ 测试失败:{exc}") wait_key() return 1 def cli_relay_status() -> int: """看现在的中转状态。""" from lanremote.relay import RelayLink store = Store(data_dir()) # 不给默认值:中转地址由用户自己填(--relay 或设置界面)。 # 以前这里兜底了一个写死的地址,结果是「我明明没填,它却去连一个 # 我根本不认识的服务器」,而且那个地址早过期了。 url = str(store.get("relay_url") or "") attach_console() if not url: print("没有配置中转(只在局域网内可用)") print("要跨网络请运行:ykxmz.exe --relay https://你的中转地址") wait_key() return 0 # start() 的 url 是必填参数。之前这里调 link.start() 不传参, # 而且不在任何 try 里 —— 直接抛 TypeError 给用户看一堆堆栈。 try: link = RelayLink(store) link.start(url) except Exception as exc: # noqa: BLE001 print("启动中转链路失败:%s" % exc) wait_key() return 1 print(f"中转地址:{url}") print("正在测…") import time as _t for _ in range(30): _t.sleep(1) status = link.status() if status.get("online"): print(f" ✓ 在线") print(f" 设备标识 {status.get('deviceId')}") print(f" 配对码 {status.get('code')}") print(f" 本次已服务 {status.get('sessions')} 个手表会话") link.stop() wait_key() return 0 print(f" ✗ 不在线 最后错误:{link.status().get('error')}") link.stop() wait_key() return 1 def cli_status() -> int: from lanremote.autostart import status as auto_status store = Store(data_dir()) auto = auto_status() print(f"{APP_NAME} v{__version__}") print(f" 数据目录: {data_dir()}") print(f" 监听端口: {store.get('port', 8765)}") print(f" 配对码: {store.pairing_code() or '(还没生成)'}") print(f" 已配设备: {len(store.device_list())} 台") print(f" 允许新配对: {'是' if store.get('allow_new_pairing', True) else '否'}") print(f" 开机自启: {'已开启' if auto['enabled'] else '未开启'}") if auto["enabled"]: print(f" 命令行: {auto['command']}") return 0 # 设完配对码之后留多久给手表来配对 SETUP_PAIR_MINUTES = 60 # 一台设备都没配对过时,自动开多久的配对窗口(自愈用) FIRST_PAIR_MINUTES = 120 # ====================================================================== # 黑终端一闪:提权修防火墙 # ====================================================================== def fix_firewall_with_flash(store) -> tuple[bool, str]: """提权执行防火墙修复。 会先弹一次 UAC;确认之后黑色终端窗口一闪,script 跑完自动关掉。 这就是设置流程里那个"闪过的黑窗口"。 """ from lanremote import firewall from lanremote.discovery import DISCOVERY_PORT tcp_port = int(store.get("port", 8765) or 8765) udp_port = int(DISCOVERY_PORT) try: return firewall.apply_elevated(tcp_port, udp_port) except Exception as exc: # noqa: BLE001 return False, f"防火墙修复失败: {exc}" # ====================================================================== # 静默运行 # ====================================================================== def run_silent(store, allow_pair_minutes: int | None) -> int: """全静默运行:没有窗口,没有终端,只在后台监听。""" import time from lanremote.actions import Actions from lanremote.headless import HeadlessUI from lanremote.server import RemoteServer log_file = data_dir() / "silent.log" # 静默模式下默认**不接受新配对**(防止局域网里任何人接进来)。 # 需要加设备时用 --add-device 临时开一个时间窗。 # # 自愈:如果一台设备都还没配对过,就必须把配对打开 —— # 否则用户设完码会永远连不上(报"被控端已关闭新设备配对")。 # 配对码仍然是门槛,所以不影响安全;第一台配对成功后自动关闭。 if not store.device_list(): allow_pair_minutes = max(allow_pair_minutes or 0, FIRST_PAIR_MINUTES) store.set("close_pair_after_first", True) if allow_pair_minutes: store.set("allow_new_pairing", True) deadline = time.time() + max(1, allow_pair_minutes) * 60 else: store.set("allow_new_pairing", False) deadline = 0.0 ui = HeadlessUI(store, log_file=log_file) ui.log(f"{APP_NAME} v{__version__} 静默启动") if not store.get("setup_done", False): ui.log("注意:还没有设置过配对码。请先运行:ykxmz.exe --setup") ui.log(f"当前临时配对码是 {store.pairing_code()}") lock = InstanceLock() if not lock.try_claim(): ui.log("已有实例在运行,退出") return 0 server = RemoteServer(store, None, ui=ui) server.actions = Actions(ui, store) from lanremote.features import Features server.features = Features(server.actions) server.pairing_enabled = bool(store.get("allow_new_pairing", True)) ui.server = server try: server.start() except (OSError, OverflowError, ValueError) as exc: ui.log(f"启动失败:{exc}") lock.release() return 1 code = store.pairing_code() if allow_pair_minutes and code: ui.log(f"配对窗口已开 {allow_pair_minutes} 分钟,配对码 {code}") ui.log(f"正在监听 TCP {server.port} / UDP 8766") import threading def watchdog(): time.sleep(1.0) while not ui._stopping: time.sleep(5.0) if allow_pair_minutes and deadline and time.time() > deadline: store.set("allow_new_pairing", False) server.pairing_enabled = False ui.log("配对窗口已关闭") threading.Thread(target=watchdog, daemon=True, name="pair-window").start() try: ui.run_forever() except KeyboardInterrupt: pass finally: try: server.stop() except Exception: # noqa: BLE001 pass ui.stop() lock.release() # 必须用 os._exit 结束。 # 界面线程拥有 Tk 解释器,正常走解释器退出会报 # "Tcl_AsyncDelete: async handler deleted by the wrong thread", # 而且进程退出码会变成 -2147483645(崩溃码)。 # os._exit 跳过解释器收尾,干净退出。 sys.stdout.flush() sys.stderr.flush() os._exit(0) # ====================================================================== # 首次设置 # ====================================================================== def check_code_available(store, code: str): """这个配对码能用吗? 能用(或没法判断)返回 None;被别的电脑占了返回一段说明文字。 检查走中转的只读接口 /v1/code/check —— 不注册,所以不会误占。 连不上中转时返回 None(放行):不能因为服务器连不上就装不了, 真有问题等转静默之后还会再提示一次。 """ if not code or len(code) != 6 or not code.isdigit(): return "配对码必须是 6 位数字" target = str(store.get("relay_url") or "").strip() if not target: # 没配过就用内置默认值(定义在 server.py) from lanremote.server import DEFAULT_RELAY_URL target = DEFAULT_RELAY_URL if not target.strip(): return None # 没配中转,没法检查 try: from lanremote.relay import RelayLink link = RelayLink(store, log=lambda _m: None) # start() 会去连中转并登记 —— 但我们只用它的 candidate 解析, # 不用它登记(登记会占用配对码)。 link.candidates = [] for piece in target.replace(",", ";").split(";"): piece = piece.strip().rstrip("/") if not piece: continue if not piece.startswith(("http://", "https://")): from lanremote.relay import _guess_scheme piece = _guess_scheme(piece) + piece link.candidates.append(piece) link.url = link.candidates[0] if link.candidates else "" reply = link._check_code(code) except Exception: # noqa: BLE001 return None # 检查失败就当没问题 if not reply.get("ok"): return None # 服务器没回应,放行 if not reply.get("taken"): return None # 没人用,可以用 where = reply.get("deviceName") or "另一台电脑" return ( f"配对码 {code} 已经被「{where}」占用了。\n\n" f"一个配对码同时只能给一台电脑用。\n" f"请换一个别的 6 位数字。\n\n" f"(想继续用这个码的话,可以去中转管理页把占用它的那台设备删掉)" ) def _tell_firewall_problem(detail: str) -> None: """防火墙没放行成功,说明为什么这次没生效。""" message = ( "防火墙没有放行成功,所以这次**没有**把配对码交给服务器。\n\n" f"原因:{detail or '未知'}\n\n" "该怎么办:\n" " 1. 右键点 ykxmz.exe →「以管理员身份运行」,重新走一遍设置\n" " 2. 或者手动放行:控制面板 → Windows Defender 防火墙 →\n" " 允许应用通过防火墙 → 找到 ykxmz → 勾上「专用网络」\n\n" "放行之后再打开 ykxmz.exe 就行了。" ) try: import tkinter as tk from tkinter import messagebox root = tk.Tk() root.withdraw() root.attributes("-topmost", True) messagebox.showerror("还没生效:防火墙没放行", message) root.destroy() return except Exception: # noqa: BLE001 pass print("=" * 56) print("防火墙没放行,配对码未提交到服务器") print(message) print("=" * 56) def _tell_code_problem(message: str) -> None: """把配对码的问题告诉用户。有界面就弹窗,没有就打印。""" try: import tkinter as tk from tkinter import messagebox root = tk.Tk() root.withdraw() root.attributes("-topmost", True) messagebox.showerror("配对码不能用", message) root.destroy() return except Exception: # noqa: BLE001 pass print("=" * 56) print("配对码不能用") print(message) print("=" * 56) def run_setup(store) -> int: """弹设置窗口 → 存码 → 黑终端一闪修防火墙 → 转静默。""" from lanremote.autostart import enable, is_enabled from lanremote.ui import SetupWindow # 先检查配对码能不能用,**通过了才去改防火墙**。 # # 踩过的坑:以前是先存码、开自启、修防火墙,最后才转静默—— # 而"码被别的电脑占了"这件事要等转静默之后才发现。 # 那时防火墙已经改完了,用户在设置窗口里也没机会换码。 result = None while True: window = SetupWindow(store).show() if window is None: return 0 # 用户关掉了窗口(放弃安装) code = window.get("code", "") problem = check_code_available(store, code) if problem is None: result = window break # 被占了:说清楚,让他回去换一个 _tell_code_problem(problem) store.set_pairing_code(result["code"]) store.set("setup_done", True) # 开机自启 if result["autostart"]: try: if not is_enabled(): enable() except Exception: # noqa: BLE001 pass # 黑终端一闪:修防火墙 firewall_ok = True firewall_note = "" if result["firewall"]: firewall_ok, firewall_note = fix_firewall_with_flash(store) # 放行防火墙之前,配对码**不加入服务器**。 # # 为什么:注册(= 配对码挂到服务器上)如果发生在防火墙之前, # 那么防火墙没放行成功时,服务器上会留着一个谁也连不上的码, # 而且它会把码占住,用户重装都换不了。 # 所以这里卡一道:防火墙没成功就不注册。 if not firewall_ok: _tell_firewall_problem(firewall_note) return 3 # 关键:设完码必须开一个配对窗口。 # 之前这里传 None(= 不开窗口 → allow_new_pairing=False), # 结果用户刚设完码,被控端立刻就把门锁上,手表第一次配对必然失败, # 报"被控端已关闭新设备配对"。 # 第一台设备配对成功后服务端会自动把窗口关上。 store.set("close_pair_after_first", True) 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 import time # ← 必须在这里 import: # run_agent.py 模块级没有 import time(原来只有 run_silent 里 # 局部 import 了一个),我加这个函数时直接用了 time.sleep, # pyflakes 立刻报 undefined name 'time'。 # 这也说明 pyflakes 那一关是有用的。 # 只杀"没有窗口"的那个(后台实例)。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] except Exception: pass args = parse_args(argv) # --code / --name / --relay-url 要**最先**处理 —— 它们会改配置, # 而 --status / --autostart 这些是"看一眼就退出"的命令。 # 安装包的调用链是: # ykxmz.exe --code 789113 --name 三年二班 --relay-url https://... --autostart on --silent # 如果先把 --autostart 处理掉就 return 了,配对码根本写不进去。 if (args.code is not None or args.name or args.relay_url is not None): store = Store(data_dir()) wrote = False # ---- 中转服务器(安装向导那一页填的)---- # ⚠ 和 --relay 的区别很重要: # --relay 连接一次、打印状态、然后 return # (人工排查用,**不能**和 --code 连用, # 因为它会提前退出,后面的 --silent 就不跑了) # --relay-url 只写进配置,不连接、不退出 # (安装包用:要和 --code/--autostart/--silent # 串在一条命令里跑完) if args.relay_url is not None: url = normalize_relay_url(str(args.relay_url)) store.set("relay_url", url) print(f"中转服务器已设为 {url or '(空 —— 只走局域网,不跨网络)'}") wrote = True if args.code is not None: digits = "".join(ch for ch in str(args.code) if ch.isdigit()) if len(digits) != 6: print(f"配对码要 6 位数字,收到的是 {args.code!r}") return 2 try: store.set_pairing_code(digits) except ValueError as exc: print(f"设置配对码失败:{exc}") return 2 # 装完就算"设置好了",不要再弹设置窗口 store.set("setup_done", True) store.set("allow_new_pairing", True) print(f"配对码已设为 {digits}") wrote = True if args.name: store.set("name", str(args.name).strip()[:32]) print(f"电脑名已设为 {store.get('name')}") wrote = True # 只写了配置就结束 —— 这是一次性命令。 # 不加这个 return 的话会继续走到 run_silent(), # 变成常驻进程永不返回,调用方(安装程序)会一直等下去。 # 想顺便启动服务,显式加 --silent。 if wrote and not (args.silent or args.add_device is not None or args.setup): return 0 if args.autostart: return cli_autostart(args.autostart) if args.status: return cli_status() # 这两个是给 .bat 调的:EXE 自带功能,不需要系统装 Python if args.netcheck is not None: return cli_netcheck(args.netcheck) if args.fix_firewall: return cli_fix_firewall() if args.relay is not None: return cli_relay(args.relay) if args.relay_status: return cli_relay_status() if args.uninstall: return cli_uninstall(keep_dir=bool(args.keep_dir), quiet=bool(args.quiet)) store = Store(data_dir()) if args.port: if not (1 <= int(args.port) <= 65535): print(f"端口 {args.port} 不合法,应在 1-65535 之间") return 2 store.set("port", int(args.port)) if args.reset_devices: store.devices = {} store.save_devices() # --code:把配对码设成安装向导里填的那个。 # 严格要 6 位数字 —— 和设置窗口里的规则一致, # 免得用户装了之后发现手表上少输一位连不上。 if args.code is not None: digits = "".join(ch for ch in str(args.code) if ch.isdigit()) if len(digits) != 6: print(f"配对码要 6 位数字,收到的是 {args.code!r}") return 2 try: store.set_pairing_code(digits) except ValueError as exc: print(f"设置配对码失败:{exc}") return 2 # 装完就是"设置好了",不要再弹设置窗口 store.set("setup_done", True) store.set("allow_new_pairing", True) print(f"配对码已设为 {digits}") if args.name: store.set("name", str(args.name).strip()[:32]) if args.new_code or not store.pairing_code(): store.new_pairing_code() # 显式要求静默 / 临时开配对窗口 if args.silent or args.add_device is not None: return run_silent(store, args.add_device) # --setup:重新打开设置窗口(和双击等效) if args.setup: return run_settings(store) # 第一次运行:设置窗口 → 修防火墙 → 静默 if not store.get("setup_done", False): return run_setup(store) # 已经设置过、而且没给参数 —— 说明用户是**双击 exe**。 # # 以前这里是 `return run_silent(store, None)`:直接后台跑, # 屏幕上什么都不出现。用户想改配对码 / 中转地址 / 接入开关时 # 双击毫无反应,只能去翻命令行参数。 # # 现在双击 = 打开设置窗口。后台运行走显式 --silent # (开机自启和安装包都用的 --silent,所以不受影响)。 return run_settings(store) if __name__ == "__main__": sys.exit(main())