Files
ykxmz/windows/lanremote/ui.py
T
xmz7891 f0c05079f7 2.1.7:手表快捷栏 + 鼠标触摸板 + 弹窗可配 + 后台远程更新
用户这次提了一整套升级,这是最后一批。

== 一、手表端顶部快捷栏 ==

用户原话:"手表端不管干什么,上面都有一栏,栏里的按钮可以在设置页
自定义,最多可以加 4 个,其中有一个按钮不能改,功能就是一键返回桌面"。

("返回桌面"经确认 = **让被控电脑显示桌面**,即 Win+D。)

  * 一条常驻的栏,贴在屏幕顶部,切页面时不动
  * **4 个格子**:3 个可自定义 + **1 个固定的「显示桌面」(改不了)**
  * 7 个可选动作:传文件 / 屏幕镜像 / 鼠标控制 / 音效 / 关机 /
    语音播报 / 投图到电脑
  * 设置页里点一下换;也能把某一格留空
  * 没连上电脑时按钮置灰(不是藏起来 —— "一直在但暂时不能用"
    比"时有时无"更好理解)

实现上的几个要点:

  * 快捷栏 addView 在 5 个面板**之后** —— FrameLayout 里后加的在上层,
    加在前面会被面板(MATCH_PARENT)整个盖住
  * 面板加了 topMargin 给栏让位,集中在 applyPanelTopMargin 里改
    (每个面板各写一遍很容易漏一个,表现是"就某一页顶部被切掉")
  * 第 4 格在代码里**写死** desktop,而且 QUICK_ACTIONS 里**没有**它 ——
    这样它既不会出现在可选列表里,也不可能被改成别的
  * 存储用 SharedPreferences。读取时**过滤不认识的 id + 补齐到 3 个**:
    以后删了某个动作,老配置也不会让栏变空或下标越界

== 二、远程鼠标控制(手表上的触摸板)==

  * 拖动 = 移动光标,单击 = 左键,双击 = 右键
  * 下面还有专门的 左键 / 右键 / 滚轮上 / 滚轮下 按钮
  * 电脑端用 SendInput 注入(见下)

== 三、弹文字窗口的位置和大小可配 ==

用户原话:"弹文字窗口 …… 位置和大小都能自定义"。

  * 位置:右下(默认,保持老习惯)/ 右上 / 左上 / 左下 /
    屏幕正中间 / 自定义坐标
  * 字号:8~32(夹住,免得设成 200 一个通知占满屏幕)
  * 宽度:可指定,0 = 按内容自适应
  * 三处窗口定位统一到一个 _notice_geometry() —— 以前三处各写死一遍
    右下角,改一次要改三个地方,漏一个用户就看得见

== 四、屏幕被观看时的提示条改成默认不显示 ==

用户原话:"以后不需要弹,啥窗户都没有,就可以像 UU 远程一样"。

  * 默认**连窗口都不建**
  * **但保留成开关**,设置里可以打开

  关于这条:那条提示原来是**故意加**的(有人在看屏幕时,电脑前面的人
  应该知道)。用户要求去掉,这是他自己的电脑、自己的教室,
  属于正当的远程管理需求。所以能力保留、默认关闭 ——
  这软件应该始终有一个"让本机用户看得见正在被控制"的选项可查。

== 五、中转后台能看到版本并远程点更新 ==

  * 设备表新增「版本」列
  * 每行一个「更新」按钮:点一下让那台电脑自己静默升级
  * 离线设备的按钮**置灰并说明原因**(命令只会挂在队列里等它上线,
    那时用户早忘了自己点过,突然重启很莫名其妙)
  * 和手表那边走**同一条命令**(cmd=update, action=start),
    不给"后台触发"单独写一套逻辑 —— 少一套就少一处不一致

== 六、手表端可开关「允许新设备连接」 ==

  * 关掉后只有配对过的电脑能连,教室里防别人乱连
  * 电脑端新增 _do_allow_new_pairing 这个 feature
  * 只影响**配对新设备**,不踢现有会话
    (否则老师一点"禁止",自己正连着的那台也被踢掉,成了"自杀开关")

== 修掉的几个自己造的坑 ==

1. **管理页 JS 裸换行** —— 最严重的一个。
   ADMIN_JS 是 Python 三引号字符串,我在里面写 `\n` 拼多行文案,
   结果是**真的换行**插进 JS,字符串字面量跨行 -> 语法非法 ->
   **整个 script 块解析失败 -> 管理页所有按钮全死**(改码/删除/踢下线/全选)。
   改成 String.fromCharCode(10),不依赖任何转义层。

2. **路由落进了 do_GET** —— 我拿 "没有这个管理接口" 当锚点,
   但那个字符串在文件里有**两处**(do_GET 和 _route_post 各一个),
   于是路由加到了 GET 块里,管理页的 POST 打不到它。
   表现是"点了更新没反应"。

3. **isConnected() 根本不存在** —— 写快捷栏时凭直觉调了个方法名,
   编译才发现。顺手包了一层并加上说明。

4. **两个不存在的方法名** —— onCreate 里写了
   buildMousePanelIfNeeded / mousePanelHolder,编译报 cannot find symbol。

5. **未调用方法扫描的假阳性** —— GestureDetector 的
   onSingleTapConfirmed / onDoubleTap / onScroll 是框架回调,
   扫描把"名字只出现 1 次"当成死代码。改成**跳过 @Override 的方法**。
   这种假阳性最坏的结果是有人为了让它变绿把回调删了,手势就失灵了。

6. **测试断言太严导致误报** —— "不能有写死的 240",
   但 helper 里 `auto = max(240, ...)` 是自适应的兜底,故意的。

7. **屏幕共享提示那个测试断言的是旧行为** —— 没有简单删掉,
   改成验两个方向:开关打开时提示条正常(位置/尺寸/出现/消失),
   关闭时默认值必须是 False 且代码路径上不建窗口。

== 验证 ==

    全量回归(19 关)全部 PASS,零失败
    新增关卡:3a10 静默更新 / 3a11 鼠标控制 / 3a12 后台更新+弹窗 /
              3a13 手表快捷栏
    Java 编译通过(android-34, --release 8)
    APK / 安装包 / 服务端包 全部重建,Win7 兼容校验通过
    623 个 Python 文件语法通过
2026-10-09 22:55:28 +08:00

580 lines
24 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))
# ================= 显示卡片 =================
#
# 用户要求:
# * "那个弹文字窗口 …… 位置和大小都能自定义"
# * "屏幕已被共享的提示以后不需要弹,像 UU 远程一样啥窗口都没有"
#
# 所以这里三件事:
# 1. 提示条开关(**默认关**,见 config.py 的 show_screen_notice)
# 2. 弹窗位置:右下(默认) / 右上 / 左上 / 左下 / 正中间 / 自定义
# 3. 弹窗字号
#
# 「自定义」的坐标现在没有界面输入框 —— 先用一组合理的坐标占位,
# 需要精确控制的人可以改 data/settings.json 里的 notice_x / notice_y。
# 这样界面不至于塞一堆输入框,而高级用户仍有办法。
display = self._card(page)
display.pack(fill="x", pady=(12, 0))
disp_inner = tk.Frame(display.winfo_children()[0], bg=CARD)
disp_inner.pack(fill="x", padx=16, pady=12)
tk.Label(disp_inner, text="显示", bg=CARD, fg=FG,
font=FONT_LABEL).pack(anchor="w", pady=(0, 6))
self.var_screen_notice = tk.BooleanVar(
value=bool(self.store.get("show_screen_notice", False)))
self._check(disp_inner, "屏幕被观看时在本机显示提示条",
self.var_screen_notice,
"默认关闭(和 UU 远程一样不弹任何窗口)")
pos_row = tk.Frame(disp_inner, bg=CARD)
pos_row.pack(fill="x", pady=(8, 0))
tk.Label(pos_row, text="弹窗位置", bg=CARD, fg=FG,
font=FONT_UI).pack(side="left")
self.var_notice_pos = tk.StringVar(
value=str(self.store.get("notice_position") or "bottom-right"))
# 值 -> 给人看的名字。顺序就是下拉里的顺序。
self._pos_labels = [
("bottom-right", "右下角"), ("top-right", "右上角"),
("top-left", "左上角"), ("bottom-left", "左下角"),
("center", "屏幕正中间"), ("custom", "自定义坐标"),
]
label_to_val = {v: k for k, v in self._pos_labels}
current_label = label_to_val.get(self.var_notice_pos.get(), "右下角")
pos_menu = tk.OptionMenu(
pos_row, self.var_notice_pos, *[v for _k, v in self._pos_labels])
pos_menu.configure(bg=CARD, fg=FG, font=FONT_SMALL, bd=0,
highlightthickness=0, activebackground=CARD,
relief="flat", cursor="hand2")
pos_menu["menu"].configure(font=FONT_SMALL)
pos_menu.pack(side="left", padx=(8, 0))
# OptionMenu 存的是**给人看的名字**,保存时再翻回内部的英文值。
# 这样用户看到的是中文,配置里存的仍是稳定的英文(以后改文案
# 不会把老配置弄坏)。
self.var_notice_pos.set(current_label)
size_row = tk.Frame(disp_inner, bg=CARD)
size_row.pack(fill="x", pady=(8, 0))
tk.Label(size_row, text="弹窗字号", bg=CARD, fg=FG,
font=FONT_UI).pack(side="left")
self.var_notice_font = tk.IntVar(
value=int(self.store.get("notice_font_size") or 11))
tk.Spinbox(size_row, from_=8, to=32, width=4,
textvariable=self.var_notice_font, font=FONT_SMALL,
justify="center").pack(side="left", padx=(8, 4))
tk.Label(size_row, text="(8~32)", bg=CARD, fg=MUTED,
font=FONT_SMALL).pack(side="left")
# ================= 按钮区 =================
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 _pos_value(self, label: str) -> str:
"""把下拉里的中文标签翻回内部英文值。
⚠ 为什么不直接把英文值塞进下拉:用户看的是中文界面。
为什么配置里存英文:以后改文案不会把老配置弄坏。
"""
for val, lab in self._pos_labels:
if lab == label:
return val
return "bottom-right"
@staticmethod
def _safe_int(value, low: int, high: int, fallback: int) -> int:
"""Spinbox 里用户可能删空或手打字母,夹一下别让配置变脏。"""
try:
n = int(value)
except (TypeError, ValueError):
return fallback
return max(low, min(high, n))
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())),
"screen_notice": bool(self.var_screen_notice.get()),
# 下拉里存的是中文标签,这里翻回稳定的英文值
"notice_position": self._pos_value(
self.var_notice_pos.get()),
"notice_font_size": self._safe_int(
self.var_notice_font.get(), 8, 32, 11),
}
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