mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 18:20:43 +00:00
按用户定的版本规矩,这一轮既有新内容又有重构,所以升第二位: 1.9.0 -> 2.0.0(最后一位清 0)。 == 一、安装时就能设中转服务器(用户要求)== 安装向导那一页现在是两个输入框:配对码 + 中转服务器。 装的时候是"第一次设置",最该一次把地址填好,不然装完还得去 双击 exe 找设置窗口,很容易漏。 只填域名就行(自动补 https://),留空 = 只走局域网。 批量静默部署同样支持: ykxmz-setup-2.0.0.exe /VERYSILENT /CODE=246810 /RELAY=relay.你的域名 为此新增了一个 --relay-url 参数: --relay 连接一次、打印状态、然后退出(人工排查用) --relay-url 只写进配置,不连接、不退出(安装流程用) 用错那个的话,安装包的命令链会在中间就 return,后面的 --silent 根本跑不到 —— 所以必须分开。 == 二、手表端新增「设置」页(用户要求)== 用户原话:"安卓端加一个设置,各种设置就在这里面调"。 以前设置散在首页(只有一个"中转设置"按钮),现在统一收进设置页: * 中转服务器(填写 / 一键清空) * 记住的配对码(显示 / 清除) * 已配对的电脑(看数量 / 清空,带二次确认) * 关于(版本号从 APK 读,不会和代码对不上) == 三、中转地址默认留空 + 一次性迁移(用户报的问题)== 用户原话:"安卓端为什么还是默认填的我的中转站,以后改成手动填"。 代码里的默认值早就是空的了,但用户看到的还是那个地址 —— 因为**老版本 1.5.0 把作者的域名硬编码成了默认值**,第一次运行就 存进了 SharedPreferences。装新 APK 不会清偏好,所以那个值一直留着。 用户其实从来没"选"过这个地址,是程序硬塞的。 所以加了 migrateLegacyRelay():存的值如果正好等于当时内置的域名, 就当成没设过、清掉。只清完全相等的,用户手填的不会动。 == 四、两个测试自身的毛病 == * test_lan_optimize 那条"没有把旧公网域名当默认值"太粗 —— 它查的是源码里任何位置,于是迁移注释里引用那行老代码就把它判红了。 改成先剥掉注释再查(断言要测的是"有没有把它当默认值")。 * test_image_modes 偶发红:它开真实 Tk 全屏窗口,在全量套件里跑 偶尔起不来(单独跑 19/19 全过)。加了有界重试。 偶发红的测试会让"全量通过"失去意义。 == 五、新增防复发断言 == _tests/test_relay_default_blank.py(15 项,回归阶段 3a3b): * 默认值必须是空串 * 除了迁移清单,源码里不许有写死的中转域名 * 迁移必须真的被调用(不是只定义) * 迁移必须是 remove,不能换成另一个硬编码值 * Windows 端的默认也得是空 理由:用户说过"这个中转站我就自己用,不对外" —— 值得用断言钉死。 == 六、顺手修的 == * release_all.py 崩在 Gitee 那一步:Gitee 的 GET /releases/tags/{tag} 在"标签存在、但还没发行版"时返回的是 **HTTP 200 + JSON null**(不是 404),于是 .get("id") 直接 AttributeError。发 1.9.0 时踩到,已改成容忍 None。 * installer/build_installer.py 里 license-combined.txt 是从 docs/ 三份文档重新生成的,所以光改生成物没用(下次构建又变回去)。 改成在 build_license() 里统一注入当前版本。 * ExE 文件属性里的版本(version_info.txt)从 1.7.1 起就一直没跟上。 全量回归 43 个阶段全部通过。
937 lines
36 KiB
Python
937 lines
36 KiB
Python
# -*- 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())
|