Files
ykxmz/windows/lanremote/ui.py
T
xmz7891 b76014709e 修用户报的三个问题 + 揪出我自己检查工具的盲区
用户报:
  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 秒了。

**所以这三个修复要装上新版才生效。**
2026-10-07 21:38:22 +08:00

488 lines
19 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 位配对码 + 勾「开机自启」「放行防火墙」
→ 点确定 → 先检查这个码在服务器上有没有被占
被占就弹框让你换,换好了才继续
→ 黑终端一闪(放行防火墙)
→ 防火墙没放行成功的话,**不把码交给服务器**,并说明原因
→ 之后永远后台静默,屏幕上什么都没有
→ 手表输这 6 位数字就连上了
界面重做的要点:
* 用作者给的图标(原来是代码画的一个蓝方块)
* 分区卡片:配对码单独一块,选项单独一块,不再是一堆裸控件
* 配对码框大字号、居中、字距拉开,一眼能看清
* 状态提示带颜色(红=错误,黄=提醒,绿=通过)
* 配对码统一成 **6 位数字**(中转就是要这个格式,
以前允许 6-16 位字母数字,用户填 10 位会在检查那步卡死)
"""
from __future__ import annotations
import re
import tkinter as tk
from pathlib import Path
# ----------------------------------------------------------------------
# 配色(浅色,和手表端一致)
# ----------------------------------------------------------------------
BG = "#eef1f6"
CARD = "#ffffff"
FG = "#1f2328"
ACCENT = "#185ce8"
ACCENT_DARK = "#0f47bd"
OK = "#1a7f37"
WARN = "#9a6700"
DANGER = "#cf222e"
DIM = "#656d76"
MUTED = "#8c959f"
BORDER = "#d8dee4"
FONT_UI = ("Microsoft YaHei UI", 10)
FONT_TITLE = ("Microsoft YaHei UI", 17, "bold")
FONT_SUB = ("Microsoft YaHei UI", 9)
FONT_SMALL = ("Microsoft YaHei UI", 9)
FONT_LABEL = ("Microsoft YaHei UI", 10, "bold")
FONT_CODE = ("Consolas", 26, "bold")
FONT_BTN = ("Microsoft YaHei UI", 10, "bold")
# 图标源图(作者给的)
ICON_SOURCE = Path(__file__).resolve().parent.parent.parent / "_build" / "icon_source.png"
def make_icon_image(size: int = 64):
"""取图标。优先用作者给的那张图,读不到再退回代码画的那个。"""
try:
from PIL import Image
if ICON_SOURCE.is_file():
img = Image.open(ICON_SOURCE).convert("RGBA")
width, height = img.size
side = min(width, height)
left = (width - side) // 2
top = (height - side) // 2
img = img.crop((left, top, left + side, top + side))
return img.resize((size, size), Image.LANCZOS)
except Exception: # noqa: BLE001
pass
return _drawn_icon(size)
ICON_BLUE = (24, 92, 232, 255)
def _drawn_icon(size: int = 64):
"""兜底:代码画一个蓝底手表。"""
from PIL import Image, ImageDraw
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
margin = max(1, size // 16)
draw.rounded_rectangle([margin, margin, size - margin, size - margin],
radius=max(2, size // 5), fill=ICON_BLUE)
cx = size // 2
box_w = int(size * 0.36)
box_h = int(size * 0.30)
top = int(size * 0.35)
strap_w = int(size * 0.16)
draw.rounded_rectangle([cx - strap_w // 2, top - int(size * 0.10),
cx + strap_w // 2, top],
radius=max(1, size // 32), fill=(255, 255, 255, 255))
draw.rounded_rectangle([cx - strap_w // 2, top + box_h,
cx + strap_w // 2, top + box_h + int(size * 0.10)],
radius=max(1, size // 32), fill=(255, 255, 255, 255))
draw.rounded_rectangle([cx - box_w // 2, top, cx + box_w // 2, top + box_h],
radius=max(2, size // 16), fill=(255, 255, 255, 255))
return img
class SetupWindow:
"""首次使用的设置窗口。
用法:
result = SetupWindow(store).show()
if result is None: # 用户关掉了
...
result["code"] # 用户设的配对码
result["autostart"] # 要不要开机自启
result["firewall"] # 要不要顺手放行防火墙
"""
CODE_LEN = 6
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
self.root: tk.Tk | None = None
self._after_ids: list = []
self._icon = None
# ------------------------------------------------------------------
def show(self, on_ready=None) -> dict | None:
"""弹出窗口并阻塞,直到用户点确定或关掉。
on_ready 是窗口建好之后的回调(自动化测试用)。
"""
self.root = tk.Tk()
self.root.title("ykxmz · " + (
"第一次设置" if self.mode == "first" else "设置"))
self.root.configure(bg=BG)
self.root.resizable(False, False)
self._set_icon()
self._build()
self.root.update_idletasks()
if on_ready is not None:
def _fire():
if self._alive():
on_ready(self)
self._after_ids.append(self.root.after(300, _fire))
self._center()
self.root.lift()
self.root.attributes("-topmost", True)
def _drop_topmost():
if self._alive():
try:
self.root.attributes("-topmost", False)
except tk.TclError:
pass
self._after_ids.append(self.root.after(700, _drop_topmost))
self.root.mainloop()
for after_id in self._after_ids:
try:
self.root.after_cancel(after_id)
except Exception: # noqa: BLE001
pass
self._after_ids = []
try:
self.root.destroy()
except tk.TclError:
pass
self.root = None
return self.result
def _alive(self) -> bool:
"""窗口还在吗。after 回调必须在销毁后自己退出,否则会报
invalid command name。"""
try:
return self.root is not None and bool(self.root.winfo_exists())
except tk.TclError:
return False
def _set_icon(self) -> None:
try:
from PIL import ImageTk
self._icon = ImageTk.PhotoImage(make_icon_image(64))
self.root.iconphoto(True, self._icon)
except Exception: # noqa: BLE001
pass
def _center(self) -> None:
width = self.root.winfo_reqwidth()
height = self.root.winfo_reqheight()
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
x = max(0, (screen_w - width) // 2)
y = max(0, (screen_h - height) // 3)
self.root.geometry(f"{width}x{height}+{x}+{y}")
# ------------------------------------------------------------------
def _card(self, parent) -> tk.Frame:
"""一个白底圆角感的卡片(tkinter 没有圆角,用边框色模拟)。
返回的是**外层** frame —— 调用处要 pack 它。
踩过的坑:一开始返回 inner,而 inner 的父控件是 outer,
于是 inner.pack() 挂回 outer,outer 自己从没被 pack,
结果卡片完全不显示(窗口高度只剩 199px)。
"""
outer = tk.Frame(parent, bg=BORDER)
inner = tk.Frame(outer, bg=CARD)
inner.pack(fill="both", expand=True, padx=1, pady=1)
return outer
def _build(self) -> None:
root = self.root
# ---- 顶部强调条 ----
tk.Frame(root, bg=ACCENT, height=5).pack(fill="x")
page = tk.Frame(root, bg=BG)
page.pack(fill="both", expand=True, padx=20, pady=18)
# ================= 标题区 =================
header = tk.Frame(page, bg=BG)
header.pack(fill="x")
try:
from PIL import ImageTk
badge = ImageTk.PhotoImage(make_icon_image(46))
icon_label = tk.Label(header, image=badge, bg=BG, bd=0)
icon_label.image = badge # 留引用
icon_label.pack(side="left", padx=(0, 12))
except Exception: # noqa: BLE001
pass
titles = tk.Frame(header, bg=BG)
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=("远程控制 · 第一次设置" if self.mode == "first"
else "远程控制 · 设置"),
bg=BG, fg=DIM,
font=FONT_SUB).pack(anchor="w")
# ================= 配对码卡片 =================
card = self._card(page)
card.pack(fill="x", pady=(16, 0))
inner = tk.Frame(card.winfo_children()[0], bg=CARD)
inner.pack(fill="x", padx=16, pady=14)
tk.Label(inner, text="配对码", bg=CARD, fg=FG, font=FONT_LABEL).pack(
anchor="w")
code_row = tk.Frame(inner, bg=CARD)
code_row.pack(fill="x", pady=(8, 0))
code_box = tk.Frame(code_row, bg="#f6f8fa",
highlightthickness=1,
highlightbackground=BORDER,
highlightcolor=ACCENT)
code_box.pack(side="left")
self.entry = tk.Entry(
code_box, bg="#f6f8fa", fg=ACCENT_DARK, relief="flat",
font=FONT_CODE, width=7, justify="center",
insertbackground=ACCENT)
self.entry.insert(0, self.suggested)
self.entry.pack(padx=10, pady=6)
self.entry.select_range(0, "end")
self.entry.focus_set()
# 只允许输入数字,且最多 6 位
self.entry.bind("<KeyRelease>", self._on_code_key)
self._flat_button(code_row, "换一个", self._reroll).pack(
side="left", padx=(10, 0))
tk.Label(inner, text="6 位数字。手表上输这 6 位就能连上这台电脑。",
bg=CARD, fg=MUTED, font=FONT_SMALL).pack(
anchor="w", pady=(8, 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))
# ================= 选项卡片 =================
options = self._card(page)
options.pack(fill="x", pady=(12, 0))
opt_inner = tk.Frame(options.winfo_children()[0], bg=CARD)
opt_inner.pack(fill="x", padx=16, pady=12)
tk.Label(opt_inner, text="选项", bg=CARD, fg=FG,
font=FONT_LABEL).pack(anchor="w", pady=(0, 6))
self.var_auto = tk.BooleanVar(value=True)
self._check(opt_inner, "开机自动运行", self.var_auto,
"开机后在后台跑,不用手动开")
# 「允许新设备接入」只在设置模式给 ——
# 第一次设置时当然是允许的(不然手表连不上)。
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="没放行成功的话,配对码不会提交给服务器,"
"会告诉你怎么处理。",
bg=CARD, fg=MUTED, font=FONT_SMALL, justify="left",
wraplength=396).pack(anchor="w", pady=(6, 0))
# ================= 按钮区 =================
buttons = tk.Frame(page, bg=BG)
buttons.pack(fill="x", pady=(16, 0))
primary = tk.Button(
buttons, text="确定,开始使用", command=self._confirm,
relief="flat", bd=0, font=FONT_BTN, cursor="hand2",
bg=ACCENT, fg="#ffffff",
activebackground=ACCENT_DARK, activeforeground="#ffffff",
padx=22, pady=10)
primary.pack(side="left")
tk.Button(
buttons, text="取消", command=self._cancel,
relief="flat", bd=0, font=FONT_UI, cursor="hand2",
bg=BG, fg=DIM, activebackground="#e2e6ec", activeforeground=FG,
padx=14, pady=10).pack(side="left", padx=(8, 0))
tk.Label(page, text="设完之后程序就在后台跑,不会再出现任何窗口。"
"需要重设就跑 ykxmz.exe --setup",
bg=BG, fg=MUTED, font=FONT_SMALL).pack(anchor="w",
pady=(12, 0))
root.bind("<Return>", lambda _e: self._confirm())
root.bind("<Escape>", lambda _e: self._cancel())
root.protocol("WM_DELETE_WINDOW", self._cancel)
def _check(self, parent, title: str, variable, hint: str) -> None:
"""一行勾选框 + 灰字说明。"""
row = tk.Frame(parent, bg=CARD)
row.pack(fill="x", pady=(2, 0))
tk.Checkbutton(row, text=title, variable=variable, bg=CARD, fg=FG,
activebackground=CARD, activeforeground=FG,
selectcolor="#ffffff", font=FONT_UI, bd=0,
highlightthickness=0,
cursor="hand2").pack(side="left")
tk.Label(row, text=hint, bg=CARD, fg=MUTED,
font=FONT_SMALL).pack(side="left", padx=(8, 0))
def _flat_button(self, parent, text, command, primary: bool = False):
button = tk.Button(
parent, text=text, command=command, relief="flat", bd=0,
font=FONT_UI if not primary else FONT_BTN, cursor="hand2",
bg="#eaeef2" if not primary else ACCENT,
fg=FG if not primary else "#ffffff",
activebackground="#dfe3e8" if not primary else ACCENT_DARK,
activeforeground=FG if not primary else "#ffffff",
padx=12, pady=6)
return button
# ------------------------------------------------------------------
def _on_code_key(self, _event=None) -> None:
"""输入时过滤:只留数字,最多 6 位。"""
raw = self.entry.get()
cleaned = "".join(ch for ch in raw if ch.isdigit())[:self.CODE_LEN]
if cleaned != raw:
position = self.entry.index("insert")
self.entry.delete(0, "end")
self.entry.insert(0, cleaned)
self.entry.icursor(min(position, len(cleaned)))
if len(cleaned) == self.CODE_LEN:
self._set_status("", MUTED)
def _set_status(self, text: str, color: str) -> None:
try:
self.status.configure(text=text, fg=color)
except tk.TclError:
pass
def _reroll(self) -> None:
self.entry.delete(0, "end")
self.entry.insert(0, self.store.new_pairing_code())
self._set_status("", MUTED)
self.entry.focus_set()
def _confirm(self) -> None:
typed = self.entry.get().strip()
cleaned = "".join(ch for ch in typed if ch.isdigit())
if not cleaned:
self._set_status("请填一个 6 位数字", DANGER)
return
if len(cleaned) != self.CODE_LEN:
self._set_status(
f"配对码要正好 {self.CODE_LEN} 位数字,现在是 {len(cleaned)} 位",
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:
self.root.quit()
except tk.TclError:
pass