Files
ykxmz/windows/lanremote/instance.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

160 lines
5.2 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 时应该能**找回窗口**,
而不是又起一个进程(那样端口会冲突,用户会以为坏了)。
做法:主实例占住一个本机回环端口;后启动的实例连上去发个 "show" 就退出。
端口可以用环境变量 YKXMZ_LOCK_PORT 覆盖:
测试需要在"用户自己的实例正在跑"的同时再起一个被控端。
不加这个口子的话,测试实例一启动就看到锁被占,直接退出
(日志里只有一句"已有实例在运行,退出"),
表现成"测试怎么都不登记到中转",很难查。
默认值不变,正常运行完全不受影响。
"""
from __future__ import annotations
import os
import socket
import threading
CONTROL_PORT = 8767
_CMD_SHOW = b"SHOW"
def control_port() -> int:
"""单实例锁用的端口(允许用环境变量覆盖,主要给测试用)。"""
raw = str(os.environ.get("YKXMZ_LOCK_PORT") or "").strip()
if raw:
try:
value = int(raw)
if 1 <= value <= 65535:
return value
except ValueError:
pass
return CONTROL_PORT
class InstanceLock:
def __init__(self, port: int | None = None):
self.port = control_port() if port is None else int(port)
self._sock: socket.socket | None = None
self._thread: threading.Thread | None = None
self._running = False
self.on_show = None # 收到"显示窗口"请求时的回调
@property
def is_primary(self) -> bool:
return self._sock is not None
def try_claim(self) -> bool:
"""尝试成为主实例。失败说明已有实例在运行。"""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 关键:Windows 上 SO_REUSEADDR 会允许第二个进程绑同一个端口,
# 导致单实例判定失效。Windows 必须用 SO_EXCLUSIVEADDRUSE。
if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
try:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
except OSError:
pass
else:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(("127.0.0.1", self.port))
sock.listen(4)
sock.settimeout(1.0)
except OSError:
try:
sock.close()
except Exception:
pass
return False
self._sock = sock
self._running = True
self._thread = threading.Thread(target=self._loop, name="instance",
daemon=True)
self._thread.start()
return True
def _loop(self) -> None:
while self._running:
try:
conn, _addr = self._sock.accept()
except socket.timeout:
continue
except OSError:
break
try:
conn.settimeout(2.0)
data = conn.recv(32)
if data.strip() == _CMD_SHOW and self.on_show:
try:
self.on_show()
except Exception:
pass
conn.sendall(b"OK")
except OSError:
pass
finally:
try:
conn.close()
except OSError:
pass
def release(self) -> None:
self._running = False
if self._thread is not None and self._thread.is_alive():
self._thread.join(timeout=2.0)
self._thread = None
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
self._sock = None
def is_running(port: int | None = None, timeout: float = 0.6) -> bool:
"""现在有没有别的实例占着单实例锁。
只是"探一下",不会让对方做任何事(发的是空包,不是 SHOW)。
为什么单独要这个函数:
"双击 exe 开设置窗口"这条路上要知道后台是不是在跑 ——
在跑的话,保存完得让后台重启才会生效;
不在跑的话直接存就行。原来没有这个能力,
我第一版顺手写了个 InstanceLock.probe_free(),
而那个方法根本不存在,一调就 AttributeError。
"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
try:
sock.connect(("127.0.0.1", control_port() if port is None else int(port)))
return True
except OSError:
return False
finally:
try:
sock.close()
except OSError:
pass
def signal_show(port: int | None = None, timeout: float = 2.0) -> bool:
"""通知已在运行的实例把窗口显示出来。"""
if port is None:
port = control_port()
try:
sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
sock.sendall(_CMD_SHOW)
try:
sock.recv(16)
except OSError:
pass
sock.close()
return True
except OSError:
return False