Files
ykxmz/windows/run_agent.py
T
xmz7891 8880e7033d 修「经中转几次后永久哑掉」——「按一下没反应」的真因
== 现象 ==

用户报:
  * 局域网能搜到电脑,IP/端口/配对码都对,连上后**没有任何反应**
  * 走中转「已配对」,但点按钮没反应

局域网那一半已经修过(preferLan)。这次抓到的是**经中转**那一半,
而且比预想的严重得多。

== 证据链(插桩复现出来的)==

被控端日志里直接抛了未捕获异常,线程当场死:

    Exception in thread client-relay:
      server.py:407  session.send(reply)
      relay.py:166   self._link.send_to_client(data)
      relay.py:357   self._post("/v1/agent/send", ...)
      relay.py:766   raise RelayError(f"请求失败: {last_error}")
    lanremote.relay.RelayError: 请求失败: Idle

之后的状态:
  * client-relay 线程没了 —— 电脑再也不往手表回任何东西
  * relay-link 线程还活着、还在长轮询 —— 中转那边看着「设备在线」
  * **手表看到的就是「连上了,但按一下毫无反应」**

写入仓库的测试 _tests/test_relay_local.py 一直是这个形状:
前面几项 PASS、跑到经中转发命令就卡死。之前没人深挖。

== 根因 1:一条 HTTP/1.1 连接被两个线程同时用 ==

_post() 里锁只包住「取连接」那一段:

    with self._http_lock:
        connection = self._http      # 锁到这里就放了
    try:
        connection.request("POST", ...)   # 锁外发请求

于是两个线程会同时用同一条 connection:
  * relay-link 线程跑 /v1/agent/poll —— 长轮询,一条请求挂 180 秒
  * client-relay 线程同时 POST /v1/agent/send

HTTP/1.1 一条连接同一时刻只能有一个在途请求,第二个线程一进来
就撞上 http.client 状态机,抛 CannotSendRequest("Idle")。

_post 的「重试一次」也救不了:它 _drop_http() 之后,另一个线程
手里还攥着那条旧连接对象在发,照样坏。

修法:
  * **发命令用独立连接**(_post 加 dedicated 参数)。
    长轮询那条常年被占着,发命令不该去挤它,各走各的。
  * 两条连接各自一把锁**锁住整个「发请求 + 读响应」周期**,
    不再只锁取连接那一下。
  * 普通请求若发现缓存连接正忙(长轮询在途),临时另开一条用完即关。
  * _drop_http(dedicated=) 区分清哪条;finally 里一定归还占用
    (否则那条连接会被永久标记为忙)。

== 根因 2:回包失败会打死整个会话线程 ==

server.py 的 _client_loop 里:

    if reply is not None:
        session.send(reply)      # <- 不在任何 try 里
    ...
    except OSError:              # <- 外层只捕获 OSError
        pass

而中转链路上 send 抛的是 RelayError,直接冒出去把线程打死。
**死一次就永久哑**,代价太大。

修法:session.send() 单独兜住所有异常,失败就断开这个会话
(让手表重连),绝不让线程消失。外层也补一个兜底 except。

== 修复效果 ==

    修复前:pair OK -> ping OK -> ping 卡死 -> 线程崩溃,永久哑
    修复后:pair 21ms -> ping 82ms -> ping 4ms -> ping 6ms,零异常

_tests/test_relay_local.py:**之前完全卡死 -> 现在 21/21 全过**

这个测试之前一直是红的,说明「35 个阶段的回归全绿」这件事
本身就不成立过 —— 某个阶段卡住时看起来像环境问题而不是缺陷。

== 同批修掉验证阶段找到的其它问题 ==

1. **TTL 清理 + 关掉接入开关 = 设备永久锁死**(严重)
   register() 用 `device_id in self.devices` 判断老设备,但超时清理
   把设备从 devices 和 codes 都删了。电脑关机过夜(必然 > 6 小时)
   第二天开机就会被自己的开关挡住,而且**重启也救不回来**
   (清 codes 时没落盘,重启后又当孤儿清一遍,自我维系)。
   管理员在页面上还看不到这台设备,不知道该放开谁。
   修法:状态文件里单独存 knownDevices 设备历史,超时清理绝不动它;
   register 改查这份历史;清理后补 _save();delete_device 才真正忘记。

2. **管理页 JS 语法错误 -> 所有按钮失效**(严重)
   admin.py 里 ADMIN_JS 是普通三引号字符串,我在 Python 源码里写的
   `\n` 被变成真实换行,拼进 JS 就是字符串里的裸换行 ->
   整段 <script> 不执行 -> 改码/删除/批量删除/全选全变幽灵按键。
   元凶是我加改码功能那次提交。改成 `\\n`。

3. **--relay / --relay-status 崩溃**(严重)
   RelayLink.start(url) 的 url 是必填参数,我把默认地址改空白时
   run_agent.py 两处调用没跟着改。一处被 try 吞成「测试失败:missing
   1 required positional argument」(用户以为地址填错了),
   一处直接报栈。两处都补上 url。

4. 非法 deviceId(控制字符 / 路径分隔符)现在被拒(白名单:
   字母数字 + - _ . :)

5. enable_relay() 第二次调用不再谎报成功(加 RelayLink.running 属性,
   返回值反映真实运行状态;不再用「对象建过没有」判断)

6. snapshot() 清孤儿码后补 _save(),磁盘和内存不再脱节

== 局域网全面优化(task-2,另一个智能体完成)==

4 个真问题:
  * **网段判断写死 /24** —— 在 /16 网络上 10.8.1.5 和 10.8.2.5
    被判成不同网络,明明能直连却去走中转还费流量。
    改成按网卡真实掩码比较。
  * **广播只发一个包** —— 丢一个就整轮搜不到。改成 5 个时间点重发。
  * **只探一个网段** —— 多网卡手表搜不到另一网段的电脑。改成全探。
  * **走中转之后再也回不到局域网** —— 出门再回家永远挂中转。
    新增定期回切(先便宜地 TCP ping,通了才动真格,
    不破坏正在工作的中转连接)。

实测:局域网握手 2ms、50/50 = 100%、中位 0ms。
(跨网络那半之前拿不到数字,就是被上面那个线程崩溃挡住的 ——
  现已修复。)

顺带:instance.py 单实例锁端口加 YKXMZ_LOCK_PORT 覆盖(默认不变)。
用户自己那台被控端正跑在这台机器上占着 8767/TCP + 8766/UDP,
是这次排查里好几个「测试怎么都不通」的根因。

== 服务端一键部署脚本(task-1,另一个智能体完成)==

relay/deploy.sh + relay/deploy-tune.sh,随服务端 zip 发布:
  * 自动检查并安装 cloudflared
  * 部署程序、装 4 个 systemd 服务、网络调优、配隧道、自检打公网地址
  * **不传 --hostname 且没有 TUNNEL_TOKEN 时明确提示要填**,
    不会静默用一个写死的域名(用户明确说过这个中转站是他自己用的,
    不对外开放,绝不能把私人域名烧成默认值)
  * 重跑 = 升级,已有数据/配对码/管理密码/凭据都不动

顺带修了发布包一直缺 site_shots/ 的老 bug
(package_remote.py 用 iterdir()+is_file() 收文件,目录进不去,
新机器装完官网截图全是 404)。

== 默认中转地址改成空白 ==

用户要求:软件默认不带任何中转地址,由用户自己填。
四处硬编码全部清空(server.py / MainActivity.java / run_agent.py /
relay.py 的注释和文档示例),全仓已无非注释的硬编码地址。

== 文档同步(task-5,另一个智能体完成)==

README / BEFORE-INSTALL / AFTER-INSTALL / 打包模板:
  * 新增「软件里不带任何中转地址」告示,讲清跨网络要自己部署
  * 修正 Android 版本(写的是 9.0/API28,实际 API 24 / Android 7.0)
  * 删掉「地址已经内置好了」(现在是错的)
  * AGENT_README 原来把作者自己的中转地址当成「内置地址」发给用户,已重写
  * RELAY_README 原来写「两条隧道同时跑会互相争抢」,
    和真机/CHANGELOG 矛盾;按实测改成「两种协议同时在跑(故意的)」
2026-10-07 17:59:39 +08:00

790 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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", 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 cli_relay(url: str) -> int:
"""设置中转地址。传空字符串就关掉。"""
store = Store(data_dir())
url = (url or "").strip().rstrip("/")
store.set("relay_url", url)
attach_console()
if not url:
print("已关闭中转(以后只在局域网内可用)")
wait_key()
return 0
if not url.startswith(("http://", "https://")):
url = "https://" + url
store.set("relay_url", url)
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 main(argv=None) -> int:
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
except Exception:
pass
args = parse_args(argv)
# --code / --name 要**最先**处理 —— 它们会改配置,
# 而 --status / --autostart 这些是"看一眼就退出"的命令。
# 安装包的调用链是:
# ykxmz.exe --code 789113 --name 三年二班 --autostart on --silent
# 如果先把 --autostart 处理掉就 return 了,配对码根本写不进去。
if args.code is not None or args.name:
store = Store(data_dir())
wrote = False
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_setup(store)
# 第一次运行:设置窗口 → 修防火墙 → 静默
if not store.get("setup_done", False):
return run_setup(store)
# 已经设置过:直接静默,屏幕上什么都不出现
return run_silent(store, None)
if __name__ == "__main__":
sys.exit(main())