Files
ykxmz/windows/lanremote/ui.py
T
xmz7891 b62ac865ea ykxmz 1.5.0:修复手表端 TLS 证书校验 + MTU 修复网络稳定性
被控端
- 修复 MTU 问题:夹 MSS + 路径 MTU 探测(公网可用率 48% -> 100%)
- 复用 HTTP 连接(免掉每次新建 TLS 的 30% 握手失败)
- 每分钟回头找局域网,自动切回快 400 倍的路径
- 画面心跳帧:静止时省流量,客户端仍知道流是活的
- 锁屏时给出可操作的提示

手表端
- 内置 GTS Root R4 / GlobalSign Root CA,修 SSLHandshakeException
- 连接错误翻译成人话(证书/时间/解析/超时分别提示)
- 经中转连上后用服务器报的地址刷新已配对记录
- 新增局域网连接页面、服务器延迟检测

服务器端
- 支持 chunked 请求体(修每 16 秒一次的连接错乱与大面积 502)
- 网络调优脚本 net-tune.sh(开机自动应用)
- 管理页:批量删除、网络体检、下载区
- 隧道改 HTTP/2 + 双连接

文档
- 许可协议 / 安装前必读 / 安装后须知(作者:小麦子)
2026-10-06 19:01:05 +08:00

408 lines
15 KiB
Python
Raw Permalink 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 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 = ""):
self.store = store
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 · 第一次设置")
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="远程控制 · 第一次设置", 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))
# ================= 选项卡片 =================
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,
"开机后在后台跑,不用手动开")
self.var_firewall = tk.BooleanVar(value=True)
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
self.result = {
"code": cleaned,
"autostart": bool(self.var_auto.get()),
"firewall": bool(self.var_firewall.get()),
}
try:
self.root.quit()
except tk.TclError:
pass
def _cancel(self) -> None:
self.result = None
try:
self.root.quit()
except tk.TclError:
pass