mirror of
https://gitee.com/xmz7891/ykxmz.git
synced 2026-10-18 17:10:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b86eeb349b | ||
|
|
0a4ec4f0c4 | ||
|
|
878b7311e9 |
@@ -59,3 +59,8 @@ desktop.ini
|
||||
|
||||
# ---- 测试收发的临时数据 ----
|
||||
/_tests/received/
|
||||
|
||||
# 令牌(绝对不要进仓库)
|
||||
/_build/.gitee_token
|
||||
*.token
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
## 1.7.0
|
||||
|
||||
- 电脑端改成 **Windows 安装包**(Inno Setup),双击安装,不再需要解压 zip
|
||||
- 安装向导里填 6 位配对码,装完手表直接连,不用再跑一次设置
|
||||
- 安装包支持静默批量部署:`ykxmz-setup-1.7.0.exe /VERYSILENT /CODE=123456`
|
||||
- 三份说明文档合并进安装向导,必须接受才能继续
|
||||
- 修配置分裂问题:管理员装的配对码,普通用户账号看不到(数据目录改成按安装位置判断)
|
||||
- 修装到 Program Files 后报「拒绝访问」:ProgramData 数据目录自动授权
|
||||
- 修安装卡死:`--code` 命令处理完不返回
|
||||
- 中转管理页加上网站图标
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""给被控端加 --code / --name,供安装包调用。
|
||||
|
||||
为什么需要:
|
||||
安装向导里用户填了 6 位配对码,安装程序必须把它传给被控端。
|
||||
但命令行原来只有 --new-code(随机换一个),**没有"设成指定值"** ——
|
||||
所以用户填的码会被忽略,装完还得再跑一次设置窗口。
|
||||
|
||||
加了之后安装程序可以这样一条命令搞定:
|
||||
ykxmz.exe --code 789113 --name 三年二班 --autostart on --silent
|
||||
|
||||
--code 同时会标记 setup_done,这样就不会再弹设置窗口。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RUN = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\run_agent.py"))
|
||||
|
||||
# ---------- 1. 参数声明 ----------
|
||||
|
||||
OLD_ARG = ''' parser.add_argument("--new-code", action="store_true", help="换一组配对码")'''
|
||||
|
||||
NEW_ARG = ''' 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 的计算机名)")'''
|
||||
|
||||
# ---------- 2. 处理逻辑 ----------
|
||||
|
||||
OLD_USE = ''' if args.reset_devices:
|
||||
store.devices = {}
|
||||
store.save_devices()
|
||||
if args.new_code or not store.pairing_code():
|
||||
store.new_pairing_code()'''
|
||||
|
||||
NEW_USE = ''' 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()'''
|
||||
|
||||
# ---------- 3. 帮助文本 ----------
|
||||
|
||||
OLD_HELP = ''' --new-code 换一组配对码'''
|
||||
|
||||
NEW_HELP = ''' --new-code 换一组配对码
|
||||
--code 123456 把配对码设成指定值(安装包用)
|
||||
--name "三年二班" 设置电脑在手表上显示的名字'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = RUN.read_text(encoding="utf-8")
|
||||
ok = True
|
||||
|
||||
if '"--code"' in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
|
||||
for old, new, label in ((OLD_ARG, NEW_ARG, "参数声明"),
|
||||
(OLD_USE, NEW_USE, "处理逻辑"),
|
||||
(OLD_HELP, NEW_HELP, "帮助文本")):
|
||||
if old not in text:
|
||||
print(f" [失败] {label} 锚点没找到")
|
||||
ok = False
|
||||
continue
|
||||
text = text.replace(old, new, 1)
|
||||
print(f" {label}")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
RUN.write_text(text, encoding="utf-8", newline="\n")
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" run_agent.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""给中转管理页加 favicon(用项目的 app.ico)。
|
||||
|
||||
用户提过:"那个中转管理端那个,网页图标没有啊,图标就是我之前给你那个图标"
|
||||
|
||||
做法:
|
||||
1. 把 windows/app.ico 拷一份到 relay/favicon.ico(跟 relay.py 同目录)
|
||||
2. relay.py 加一条 GET /favicon.ico 路由直接给这个文件
|
||||
3. 管理页 <head> 里加 <link rel="icon" ...>
|
||||
|
||||
为什么不做成 base64 内嵌:
|
||||
ico 有 147KB,内嵌到 relay.py 里会让源码从 78KB 涨到 270KB,
|
||||
而且每次改图标都要动代码。放成独立文件干净得多。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
RELAY = ROOT / "relay" / "relay.py"
|
||||
ICON_SRC = ROOT / "windows" / "app.ico"
|
||||
ICON_DST = ROOT / "relay" / "favicon.ico"
|
||||
|
||||
# ---------- 1. 路由 ----------
|
||||
|
||||
ANCHOR_ROUTE = ''' # ---- 成品下载(挂在管理页上给人下 APK / 整包)----'''
|
||||
|
||||
NEW_ROUTE = ''' # ---- 网站图标 ----
|
||||
if path in ("/favicon.ico", "/apple-touch-icon.png"):
|
||||
icon = Path(__file__).resolve().parent / "favicon.ico"
|
||||
if icon.is_file():
|
||||
try:
|
||||
blob = icon.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/x-icon")
|
||||
self.send_header("Content-Length", str(len(blob)))
|
||||
# 图标不常变,让浏览器缓存一天
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(blob)
|
||||
return
|
||||
# 没有图标文件就 404,别让请求打到后面的逻辑
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
''' + ANCHOR_ROUTE
|
||||
|
||||
# ---------- 2. 页面里声明 ----------
|
||||
|
||||
ANCHOR_HEAD = '''<meta name=viewport content="width=device-width,initial-scale=1">'''
|
||||
|
||||
NEW_HEAD = ('''<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/favicon.ico">''')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
|
||||
# ---- 图标文件 ----
|
||||
if not ICON_SRC.is_file():
|
||||
print(f" [失败] 找不到 {ICON_SRC}")
|
||||
return 1
|
||||
shutil.copy2(ICON_SRC, ICON_DST)
|
||||
print(f" 拷贝图标 -> relay/favicon.ico({ICON_DST.stat().st_size} 字节)")
|
||||
|
||||
text = RELAY.read_text(encoding="utf-8")
|
||||
|
||||
if "favicon.ico" in text:
|
||||
print(" relay.py 里已经有 favicon 路由")
|
||||
else:
|
||||
for old, new, label in ((ANCHOR_ROUTE, NEW_ROUTE, "favicon 路由"),
|
||||
(ANCHOR_HEAD, NEW_HEAD, "页面里的 link 声明")):
|
||||
if old not in text:
|
||||
print(f" [失败] {label} —— 锚点没找到")
|
||||
ok = False
|
||||
continue
|
||||
text = text.replace(old, new, 1)
|
||||
print(f" {label}")
|
||||
|
||||
if ok:
|
||||
RELAY.write_text(text, encoding="utf-8", newline="\n")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
try:
|
||||
ast.parse(RELAY.read_text(encoding="utf-8"))
|
||||
print(" relay.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""数据目录自动回退:写不进程序目录就转 ProgramData。
|
||||
|
||||
背景:
|
||||
原来的 data_dir() 就是 app_dir()/data —— 数据存在 exe 旁边。
|
||||
绿色版没问题,但**做成安装包装到 Program Files 之后**,
|
||||
普通用户对那个目录没有写权限,配置会存不下去。
|
||||
|
||||
用户选的做法:
|
||||
"能写 exe 旁边就写,不能就转 ProgramData"
|
||||
|
||||
这样两种情况都对:
|
||||
* 绿色版 / 装在 D 盘 / 便携 U 盘 -> 还是存在旁边,拔走就带走配置
|
||||
* 装在 Program Files -> 自动转 C:\ProgramData\ykxmz,
|
||||
所有用户共用一份(配对码不会因为换 Windows 账号而丢)
|
||||
|
||||
顺带加一个 YKXMZ_DATA 环境变量,测试和特殊部署可以强制指定。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\lanremote\config.py"))
|
||||
|
||||
OLD = '''def app_dir() -> Path:
|
||||
"""程序所在目录(兼容 PyInstaller 冻结环境)。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
return app_dir() / "data"'''
|
||||
|
||||
NEW = '''def app_dir() -> Path:
|
||||
"""程序所在目录(兼容 PyInstaller 冻结环境)。"""
|
||||
if getattr(sys, "frozen", False):
|
||||
return Path(sys.executable).resolve().parent
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# 数据目录只算一次,之后缓存 —— 每个命令都去试写一次太浪费
|
||||
_DATA_DIR_CACHE: Path | None = None
|
||||
|
||||
|
||||
def _can_write(folder: Path) -> bool:
|
||||
"""这个目录能不能写(不存在就先建)。"""
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
probe = folder / ".write-test"
|
||||
probe.write_text("x", encoding="utf-8")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""配置 / 令牌 / 接收文件的存放目录。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 YKXMZ_DATA(测试和特殊部署用)
|
||||
2. 程序目录下的 data/ —— 绿色版、便携 U 盘,拔走就带走配置
|
||||
3. %PROGRAMDATA%\\ykxmz\\data —— 装到 Program Files 时用这个
|
||||
|
||||
为什么要有第 3 条:
|
||||
安装包默认装到 C:\\Program Files\\ykxmz,
|
||||
普通用户对那里**没有写权限**(Windows 的硬规矩,不是 bug)。
|
||||
不转走的话,配对码和令牌会存不下去,用户会莫名其妙连不上。
|
||||
ProgramData 是所有用户可写的公共位置,换 Windows 账号也不丢配置。
|
||||
"""
|
||||
global _DATA_DIR_CACHE
|
||||
if _DATA_DIR_CACHE is not None:
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
override = os.environ.get("YKXMZ_DATA")
|
||||
if override:
|
||||
_DATA_DIR_CACHE = Path(override)
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
beside = app_dir() / "data"
|
||||
if _can_write(beside):
|
||||
_DATA_DIR_CACHE = beside
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
try:
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
# 兜底:连 ProgramData 都写不了(极罕见),退回用户目录
|
||||
common = (Path(os.environ.get("LOCALAPPDATA")
|
||||
or Path.home()) / "ykxmz" / "data")
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = CONFIG.read_text(encoding="utf-8")
|
||||
|
||||
if "YKXMZ_DATA" in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
if OLD not in text:
|
||||
print(" [失败] 锚点没找到")
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
|
||||
# 确认 os 已经 import 了
|
||||
if "\nimport os" not in text:
|
||||
text = text.replace("import sys\n", "import os\nimport sys\n", 1)
|
||||
print(" 补了 import os")
|
||||
|
||||
CONFIG.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" data_dir 加了自动回退(ProgramData)")
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" config.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+1
-1
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
|
||||
import paramiko
|
||||
|
||||
HOST = "192.168.10.105"
|
||||
HOST = "192.168.2.156"
|
||||
USER = "root"
|
||||
PASSWORD = "1313144"
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""诊断:发现端口 8766 到底有没有被 agent 绑上。
|
||||
|
||||
test_selfcheck 广播到 127.0.0.1:8766 收到 WSAECONNRESET(ICMP 端口不可达),
|
||||
说明**没有进程在监听 8766**。但测试里 agent 明明起来了(8841 能连)。
|
||||
|
||||
查清楚是:
|
||||
a) agent 绑 8766 失败(被占 / 权限)
|
||||
b) agent 压根没去绑
|
||||
c) 绑上了但没回包
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
PORT = 8851
|
||||
DISCOVERY = 8766
|
||||
|
||||
|
||||
def port_holder(port: int, udp: bool) -> list[str]:
|
||||
out: list[str] = []
|
||||
if udp:
|
||||
cmd = ("Get-NetUDPEndpoint -LocalPort %d -ErrorAction SilentlyContinue | "
|
||||
"ForEach-Object { \"$($_.LocalAddress):$($_.LocalPort) PID $($_.OwningProcess)\" }"
|
||||
% port)
|
||||
else:
|
||||
cmd = ("Get-NetTCPConnection -LocalPort %d -ErrorAction SilentlyContinue | "
|
||||
"ForEach-Object { \"$($_.LocalAddress):$($_.LocalPort) PID $($_.OwningProcess)\" }"
|
||||
% port)
|
||||
proc = subprocess.run(["powershell", "-NoProfile", "-Command", cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
if line.strip():
|
||||
out.append(line.strip())
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# 清干净(注意别把自己杀了 —— $PID 在 PowerShell 里是 PowerShell 的,
|
||||
# 所以这里按命令行特征挑,不用 PID 比较)
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
|
||||
"Where-Object { $_.CommandLine -match 'run_agent' -or "
|
||||
"$_.CommandLine -match 'test_' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
|
||||
"-ErrorAction SilentlyContinue }"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
time.sleep(2)
|
||||
|
||||
print(" 起之前,8766 有没有人占:")
|
||||
holders = port_holder(DISCOVERY, udp=True)
|
||||
print(f" {holders if holders else '空闲'}")
|
||||
|
||||
data_dir = ROOT / "windows" / "data"
|
||||
if data_dir.exists():
|
||||
shutil.rmtree(data_dir, ignore_errors=True)
|
||||
|
||||
env = dict(os.environ)
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
print()
|
||||
print(f" 起 agent(TCP {PORT})…")
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "run_agent.py", "--port", str(PORT),
|
||||
"--new-code", "--add-device", "10"],
|
||||
cwd=str(ROOT / "windows"), env=env,
|
||||
stdout=open(ROOT / "_build" / "agent_diag.log", "wb"),
|
||||
stderr=subprocess.STDOUT)
|
||||
|
||||
try:
|
||||
# 等 TCP 就绪
|
||||
ready = False
|
||||
for _ in range(50):
|
||||
try:
|
||||
s = socket.create_connection(("127.0.0.1", PORT), timeout=1)
|
||||
s.close()
|
||||
ready = True
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.4)
|
||||
print(f" TCP {PORT} 就绪: {ready}")
|
||||
|
||||
time.sleep(1.5)
|
||||
|
||||
print()
|
||||
print(f" 起之后,8766 有没有人占:")
|
||||
holders = port_holder(DISCOVERY, udp=True)
|
||||
print(f" {holders if holders else '还是空闲 —— 说明 agent 没绑上!'}")
|
||||
|
||||
print()
|
||||
print(" 直接探测 8766:")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.settimeout(3)
|
||||
import json
|
||||
request = json.dumps({"lanremote": "LANREMOTE_SELFCHECK",
|
||||
"proto": 1}).encode("utf-8")
|
||||
try:
|
||||
sock.sendto(request, ("127.0.0.1", DISCOVERY))
|
||||
data, addr = sock.recvfrom(2048)
|
||||
print(f" ✓ 收到应答: {data[:160]!r}")
|
||||
except ConnectionResetError as exc:
|
||||
print(f" ✗ WSAECONNRESET —— 端口不可达,没人监听({exc})")
|
||||
except socket.timeout:
|
||||
print(" ✗ 超时 —— 有人监听但没回")
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
print()
|
||||
print(" agent 日志尾部:")
|
||||
log = ROOT / "_build" / "agent_diag.log"
|
||||
if log.is_file():
|
||||
text = log.read_text(encoding="utf-8", errors="replace")
|
||||
for line in text.splitlines()[-14:]:
|
||||
print(f" {line}")
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# 最终验收:下载区 + 服务状态
|
||||
set -u
|
||||
|
||||
BASE=http://127.0.0.1:8080
|
||||
PUB=https://relay.xxmxxs.top
|
||||
UA='LanRemote/1.4.0'
|
||||
KEY=$(cat /root/lanremote-relay/data/admin_key.txt 2>/dev/null)
|
||||
|
||||
echo "===== 1. 管理页下载区 ====="
|
||||
page=$(curl -s --max-time 10 -H "X-Admin: $KEY" "$BASE/" 2>/dev/null)
|
||||
for n in 'id=dlPanel' '下载最新版' '/v1/downloads'; do
|
||||
if echo "$page" | grep -q "$n"; then echo " ✓ $n"; else echo " ✗ $n"; fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "===== 2. 下载区文件列表 ====="
|
||||
curl -s --max-time 15 "$BASE/v1/downloads" | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
files = d.get("files", [])
|
||||
pick = None
|
||||
for f in files:
|
||||
if "agent" in f["name"].lower():
|
||||
pick = f
|
||||
break
|
||||
print(" 会挑:", pick["name"] if pick else "(无)")
|
||||
for f in files:
|
||||
print(" %-32s %6.2f MB" % (f["name"], f["size"] / 1024 / 1024))
|
||||
'
|
||||
|
||||
echo
|
||||
echo "===== 3. 公网下载三个文件 ====="
|
||||
for f in ykxmz-watch-1.6.2.apk ykxmz-server-1.6.2.zip; do
|
||||
info=$(curl -s --max-time 120 -o /dev/null \
|
||||
-w '%{http_code} %{size_download}' \
|
||||
-H "User-Agent: $UA" "$PUB/download/$f" 2>/dev/null)
|
||||
printf " %-30s %s\n" "$f" "$info"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "===== 4. 服务状态 ====="
|
||||
for s in lanremote-nettune.service lanremote-relay lanremote-tunnel \
|
||||
lanremote-watchdog.timer; do
|
||||
printf " %-30s %-9s %s\n" "$s" \
|
||||
"$(systemctl is-active "$s" 2>/dev/null)" \
|
||||
"$(systemctl is-enabled "$s" 2>/dev/null)"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "===== 5. 网络调优(重启后还在吗)====="
|
||||
echo " tcp_mtu_probing = $(cat /proc/sys/net/ipv4/tcp_mtu_probing)"
|
||||
echo " MSS 规则 $(iptables -t mangle -L -n 2>/dev/null | grep -ci mss) 条"
|
||||
echo " 隧道连接数 = $(curl -s --max-time 5 http://127.0.0.1:20241/metrics 2>/dev/null | grep '^cloudflared_tunnel_ha_connections' | awk '{print $2}')"
|
||||
echo " 中转版本 = $(curl -s --max-time 8 $BASE/v1/health | python3 -c 'import json,sys; print(json.load(sys.stdin).get("version"))' 2>/dev/null)"
|
||||
|
||||
echo
|
||||
echo "===== 6. 可用率(20 次)====="
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
c=$(timeout 12 curl -4 -s -o /dev/null -w '%{http_code}' \
|
||||
-H "User-Agent: $UA" "$PUB/v1/health" 2>/dev/null)
|
||||
[ "$c" = "200" ] && ok=$((ok+1))
|
||||
sleep 1
|
||||
done
|
||||
echo " 公网 $ok/20 = $((ok * 5))%"
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""修:--code / --name 处理完不退出,害得安装程序卡死。
|
||||
|
||||
现象:
|
||||
装完之后配对码没写进去,而且安装程序在 CurStepChanged 里
|
||||
用 ewWaitUntilTerminated 等 ykxmz.exe --code ... --silent,
|
||||
**永远等不到** —— 因为 --silent 是常驻守护进程,不会返回。
|
||||
|
||||
根因:
|
||||
main() 里 --code 那段处理完之后没有 return,
|
||||
继续往下走到 run_silent(),于是变成"启动一个常驻服务"。
|
||||
|
||||
改法:
|
||||
--code / --name 做成**一次性命令**:写完配置、打印、立刻退出。
|
||||
想同时启动服务就显式加 --silent(那时才走守护进程)。
|
||||
|
||||
这样安装程序的分工就干净了:
|
||||
CurStepChanged ykxmz.exe --code 123456 <- 秒退,只写配置
|
||||
[Run] postinstall ykxmz.exe --silent <- 启动,nowait 不等
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
RUN = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\run_agent.py"))
|
||||
|
||||
OLD = ''' if args.code is not None or args.name:
|
||||
store = Store(data_dir())
|
||||
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])
|
||||
print(f"电脑名已设为 {store.get('name')}")
|
||||
|
||||
if args.autostart:'''
|
||||
|
||||
NEW = ''' if args.code is not None or args.name:
|
||||
store = Store(data_dir())
|
||||
wrote = False
|
||||
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:'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = RUN.read_text(encoding="utf-8")
|
||||
if "只写了配置就结束" in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
if OLD not in text:
|
||||
print(" [失败] 锚点没找到")
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
RUN.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" --code / --name 改成一次性命令(写完就退出)")
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" run_agent.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""修:按"能不能写"判断数据目录导致安装版配置分裂。
|
||||
|
||||
现象:
|
||||
安装时(管理员)把 settings.json 写到了
|
||||
C:\Program Files\ykxmz\data\settings.json
|
||||
之后普通用户运行时,_can_write() 对那个目录返回 False,
|
||||
于是转到
|
||||
C:\ProgramData\ykxmz\data
|
||||
目录是空的 —— 安装时设的配对码普通用户根本看不到。
|
||||
|
||||
根因:
|
||||
用"当前用户能不能写"来决定数据目录,这个判断**不稳定**:
|
||||
同一个程序,管理员跑和普通用户跑会得出不同结论,
|
||||
结果就是配置分裂成两份,谁也看不见谁。
|
||||
|
||||
改法:
|
||||
改成**确定性**判断 —— 看程序装在哪,不看当前是谁在跑:
|
||||
* 装在 Program Files 下 -> 安装版,数据一律放 ProgramData
|
||||
* 其它位置(绿色版/D盘/U盘)-> 能写 exe 旁边就写旁边
|
||||
这样管理员和普通用户看到的是同一份配置。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\lanremote\config.py"))
|
||||
|
||||
# ---------- 1. 加"是不是安装版"的判断 ----------
|
||||
|
||||
OLD_MARK = '''def data_dir() -> Path:'''
|
||||
|
||||
NEW_MARK = '''def _is_installed_copy() -> bool:
|
||||
"""程序是不是装在 Program Files 下(也就是"安装版")。
|
||||
|
||||
这个判断只跟**程序装在哪**有关,跟"当前是谁在跑"无关 ——
|
||||
这正是关键:数据目录必须对所有用户是同一个,
|
||||
否则管理员装的配置普通用户看不见(实测踩过)。
|
||||
"""
|
||||
try:
|
||||
here = str(app_dir()).lower().replace("/", "\\\\")
|
||||
except OSError:
|
||||
return False
|
||||
for name in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
|
||||
base = os.environ.get(name)
|
||||
if not base:
|
||||
continue
|
||||
prefix = str(base).lower().replace("/", "\\\\").rstrip("\\\\")
|
||||
if here == prefix or here.startswith(prefix + "\\\\"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def data_dir() -> Path:'''
|
||||
|
||||
# ---------- 2. 用它 ----------
|
||||
|
||||
OLD_PICK = ''' beside = app_dir() / "data"
|
||||
if _can_write(beside):
|
||||
_DATA_DIR_CACHE = beside
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# ProgramData:所有用户共用一份配置(换 Windows 账号也不丢)
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
if _can_write(common):
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE'''
|
||||
|
||||
NEW_PICK = ''' # ── 安装版(装在 Program Files)一律用 ProgramData ──
|
||||
# 不能按"我能不能写 exe 旁边"来判断:
|
||||
# 安装时是管理员跑的,写得进去;之后普通用户跑就写不进去,
|
||||
# 于是悄悄换了目录 —— 安装时设的配对码就找不着了(实测踩过)。
|
||||
# 按"装在哪"判断,管理员和普通用户看到的就是同一份配置。
|
||||
in_program_files = _is_installed_copy()
|
||||
|
||||
beside = app_dir() / "data"
|
||||
if not in_program_files and _can_write(beside):
|
||||
_DATA_DIR_CACHE = beside
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# ProgramData:所有用户共用一份配置(换 Windows 账号也不丢)
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
if _can_write(common):
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = CONFIG.read_text(encoding="utf-8")
|
||||
ok = True
|
||||
|
||||
if "_is_installed_copy" in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
|
||||
for old, new, label in ((OLD_MARK, NEW_MARK, "加 _is_installed_copy"),
|
||||
(OLD_PICK, NEW_PICK, "数据目录改成确定性判断")):
|
||||
if old not in text:
|
||||
print(f" [失败] {label} —— 锚点没找到")
|
||||
ok = False
|
||||
continue
|
||||
text = text.replace(old, new, 1)
|
||||
print(f" {label}")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
CONFIG.write_text(text, encoding="utf-8", newline="\n")
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" config.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,114 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""修:中转连接尝试阻塞了局域网发现。
|
||||
|
||||
现象(agent 日志):
|
||||
[20:31:17] 没配过中转地址,用内置默认值:192.168.10.105:8080;https://...
|
||||
[20:31:25] http://192.168.10.105:8080 不通,试下一个 <- 等了 8 秒
|
||||
[20:31:27] https://relay.xxmxxs.top 不通,试下一个 <- 又 2 秒
|
||||
[20:31:27] 自动发现已开启(UDP 8766) <- 10 秒后才绑上
|
||||
|
||||
server.start() 原来的顺序:
|
||||
1. 绑 TCP
|
||||
2. enable_relay() <- 挨个试中转地址,每个都要等超时(这里 10 秒)
|
||||
3. 起 accept 线程
|
||||
4. 起 DiscoveryResponder
|
||||
|
||||
问题:**局域网发现被中转拖住了**。
|
||||
电脑和手表在同一个 WiFi 时,本来应该"广播一下就找到",
|
||||
结果因为中转连不上,要等 10 几秒手表才搜得到电脑。
|
||||
中转地址越烂、候选越多,等得越久。
|
||||
|
||||
影响:
|
||||
* 用户体验上表现为"刚开电脑时手表搜不到,过一会儿才行"
|
||||
* 自动化测试也栽在这(广播时发现服务还没起)
|
||||
|
||||
改法:把局域网发现**提到 enable_relay() 之前**。
|
||||
局域网是本机能力,不该依赖任何网络外部条件。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SERVER = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\lanremote\server.py"))
|
||||
|
||||
OLD = ''' # 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
|
||||
try:
|
||||
self.enable_relay()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"启用中转失败:{exc}")
|
||||
|
||||
self._accept_thread = threading.Thread(target=self._accept_loop,
|
||||
name="accept", daemon=True)
|
||||
self._accept_thread.start()
|
||||
self._clip_thread = threading.Thread(target=self._clip_loop,
|
||||
name="clip", daemon=True)
|
||||
self._clip_thread.start()
|
||||
|
||||
# 启动局域网自动发现,让手表不用输 IP
|
||||
self._discovery = DiscoveryResponder(self.store, self.port)
|
||||
# 手表点「连不上?」会广播请求,这里就把网络自检窗口弹出来
|
||||
self._discovery.on_selfcheck = self._on_selfcheck_request
|
||||
if self._discovery.start():
|
||||
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
|
||||
else:
|
||||
self._log(f"自动发现未开启:{self._discovery.last_error}")'''
|
||||
|
||||
NEW = ''' # ── 先起局域网发现 ──
|
||||
#
|
||||
# 顺序很重要:enable_relay() 会挨个试中转地址,
|
||||
# 每个候选都要等到超时才算"不通",地址越烂等得越久(实测 10 秒+)。
|
||||
# 原来这一步排在发现服务前面,导致:
|
||||
# 电脑刚开机的十几秒里,手表广播搜不到它。
|
||||
# 局域网发现是纯本机能力,不该被"连不连得上外网"拖住,
|
||||
# 所以挪到前面,开机即可被搜到。
|
||||
self._discovery = DiscoveryResponder(self.store, self.port)
|
||||
# 手表点「连不上?」会广播请求,这里就把网络自检结论算出来
|
||||
self._discovery.on_selfcheck = self._on_selfcheck_request
|
||||
if self._discovery.start():
|
||||
self._log(f"自动发现已开启(UDP {DISCOVERY_PORT})")
|
||||
else:
|
||||
self._log(f"自动发现未开启:{self._discovery.last_error}")
|
||||
|
||||
# ── 再起收发线程 ──
|
||||
self._accept_thread = threading.Thread(target=self._accept_loop,
|
||||
name="accept", daemon=True)
|
||||
self._accept_thread.start()
|
||||
self._clip_thread = threading.Thread(target=self._clip_loop,
|
||||
name="clip", daemon=True)
|
||||
self._clip_thread.start()
|
||||
|
||||
# ── 最后才连中转(会阻塞,所以放最后)──
|
||||
# 配了中转地址就同时启用跨网络通道(和局域网监听并存,不冲突)
|
||||
try:
|
||||
self.enable_relay()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log(f"启用中转失败:{exc}")'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = SERVER.read_text(encoding="utf-8")
|
||||
if "先起局域网发现" in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
if OLD not in text:
|
||||
print(" [失败] 锚点没找到")
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
SERVER.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" 已把局域网发现提到 enable_relay() 之前")
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" server.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,207 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""修 ProgramData 权限问题(用户装完报 WinError 5 拒绝访问)。
|
||||
|
||||
现象:
|
||||
PermissionError: [WinError 5] 拒绝访问。
|
||||
'C:\ProgramData\_ykxmz_test\data\settings.json.tmp' ->
|
||||
'C:\ProgramData\_ykxmz_test\data\settings.json'
|
||||
|
||||
原因(Windows 的老规矩,不是 bug):
|
||||
安装包是**管理员**跑的,它在 ProgramData 下建的 settings.json
|
||||
属主是管理员。之后普通用户跑被控端时:
|
||||
* 能**新建**文件(ProgramData 默认允许 Users 新建)
|
||||
* 但**不能覆盖/改名**别人建的文件(os.replace 要删目标)
|
||||
于是"写 .tmp -> 原子替换"这一步就炸了。
|
||||
|
||||
而我原来的 _can_write() 只测了"能不能建文件",没有测**替换**,
|
||||
所以误判成可写,跑到一半才报错。
|
||||
|
||||
三层修:
|
||||
① _write() 替换失败就降级:直接写目标文件 -> 再不行去掉只读位
|
||||
② _can_write() 改成测完整周期(建 -> 改名 -> 删)
|
||||
③ 目录选择:ProgramData 不行就退到 LOCALAPPDATA(每人一份,永远可写)
|
||||
④ 安装包给数据目录授权 users-modify(治本)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\windows"
|
||||
r"\lanremote\config.py"))
|
||||
|
||||
# ---------------- ① 写入降级 ----------------
|
||||
|
||||
OLD_WRITE = ''' @staticmethod
|
||||
def _write(path: Path, data) -> None:
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)'''
|
||||
|
||||
NEW_WRITE = ''' @staticmethod
|
||||
def _write(path: Path, data) -> None:
|
||||
"""写 JSON。优先"临时文件 + 原子替换",失败就降级。
|
||||
|
||||
为什么要降级:
|
||||
装到 Program Files / ProgramData 时,配置是**管理员**建的那一份,
|
||||
属主是管理员。普通用户跑的时候:
|
||||
能新建文件,但不能覆盖/改名别人建的文件。
|
||||
于是 os.replace 报 WinError 5。
|
||||
这时候退成"直接写目标文件" —— 牺牲原子性,换能存下配置。
|
||||
"""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
# 1) 正常路径:临时文件 + 原子替换
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
os.replace(tmp, path)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2) 降级:直接覆盖目标文件
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
Store._cleanup(tmp)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3) 再降级:目标被设成只读就摘掉只读位再写
|
||||
try:
|
||||
os.chmod(path, 0o666)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
Store._cleanup(tmp)
|
||||
return
|
||||
except OSError:
|
||||
Store._cleanup(tmp)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _cleanup(tmp: Path) -> None:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass'''
|
||||
|
||||
# ---------------- ② 可写检测测完整周期 ----------------
|
||||
|
||||
OLD_CANWRITE = '''def _can_write(folder: Path) -> bool:
|
||||
"""这个目录能不能写(不存在就先建)。"""
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
probe = folder / ".write-test"
|
||||
probe.write_text("x", encoding="utf-8")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False'''
|
||||
|
||||
NEW_CANWRITE = '''def _can_write(folder: Path) -> bool:
|
||||
"""这个目录能不能**真正**用来存配置。
|
||||
|
||||
注意不能只测"能新建文件" —— 那只过一半。
|
||||
实际写配置用的是"临时文件 + 原子替换"(os.replace),
|
||||
它需要**覆盖已存在文件**的权限。
|
||||
装到 ProgramData 时,管理员建的那份普通用户改不了,
|
||||
只测新建会误判成可写,然后跑起来才报 WinError 5。
|
||||
所以这里把整个周期都跑一遍:建 -> 改名覆盖 -> 删。
|
||||
"""
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
probe = folder / ".write-test"
|
||||
probe.write_text("x", encoding="utf-8")
|
||||
|
||||
# 关键:模拟真实写入的"替换"这一步
|
||||
target = folder / ".write-test-target"
|
||||
try:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
probe.write_text("a", encoding="utf-8")
|
||||
os.replace(probe, target) # <- 真正的门槛在这
|
||||
target.write_text("b", encoding="utf-8") # 覆盖已存在的
|
||||
target.unlink()
|
||||
except OSError:
|
||||
# 建得了新文件但替换不了 -> 不能当数据目录
|
||||
try:
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
except OSError:
|
||||
return False'''
|
||||
|
||||
# ---------------- ③ 目录选择加 LOCALAPPDATA 兜底 ----------------
|
||||
|
||||
OLD_DIR = ''' base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
try:
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
# 兜底:连 ProgramData 都写不了(极罕见),退回用户目录
|
||||
common = (Path(os.environ.get("LOCALAPPDATA")
|
||||
or Path.home()) / "ykxmz" / "data")
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE'''
|
||||
|
||||
NEW_DIR = ''' # ProgramData:所有用户共用一份配置(换 Windows 账号也不丢)
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
if _can_write(common):
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# 兜底:ProgramData 写不了(ACL 没配好 / 权限被收紧)就退到用户目录。
|
||||
# 每人一份配置,但**永远能写** —— 总比跑起来报错强。
|
||||
local = (Path(os.environ.get("LOCALAPPDATA")
|
||||
or Path.home()) / "ykxmz" / "data")
|
||||
local.mkdir(parents=True, exist_ok=True)
|
||||
_DATA_DIR_CACHE = local
|
||||
return _DATA_DIR_CACHE'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = CONFIG.read_text(encoding="utf-8")
|
||||
ok = True
|
||||
|
||||
if "_cleanup" in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
|
||||
for old, new, label in ((OLD_WRITE, NEW_WRITE, "写入降级"),
|
||||
(OLD_CANWRITE, NEW_CANWRITE, "可写检测测完整周期"),
|
||||
(OLD_DIR, NEW_DIR, "LOCALAPPDATA 兜底")):
|
||||
if old not in text:
|
||||
print(f" [失败] {label} —— 锚点没找到")
|
||||
ok = False
|
||||
continue
|
||||
text = text.replace(old, new, 1)
|
||||
print(f" {label}")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
CONFIG.write_text(text, encoding="utf-8", newline="\n")
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" config.py 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""改 Gitee 仓库的简介 / 主页,并核对发行版。
|
||||
|
||||
用法(令牌只走命令行,不落盘):
|
||||
python _build/gitee_meta.py <令牌>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
OWNER, REPO = "xmz7891", "ykxmz"
|
||||
API = f"https://gitee.com/api/v5/repos/{OWNER}/{REPO}"
|
||||
|
||||
DESCRIPTION = (
|
||||
"教室电脑手表遥控 · 老师用手表管教室电脑:放课件、投图、"
|
||||
"屏蔽游戏网站、下课关机。支持局域网直连与跨网络中继"
|
||||
)
|
||||
HOMEPAGE = "https://relay.xxmxxs.top"
|
||||
|
||||
|
||||
def call(url, payload=None, method="GET", timeout=60):
|
||||
data = None
|
||||
headers = {"User-Agent": "ykxmz-meta"}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json;charset=UTF-8"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8", "replace")
|
||||
return resp.status, (json.loads(body) if body.strip() else {})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(" 用法: python _build/gitee_meta.py <令牌>")
|
||||
return 1
|
||||
token = sys.argv[1]
|
||||
|
||||
print("===== 1. 改仓库简介 =====")
|
||||
try:
|
||||
status, reply = call(
|
||||
API,
|
||||
{"access_token": token, "name": REPO,
|
||||
"description": DESCRIPTION, "homepage": HOMEPAGE},
|
||||
method="PATCH")
|
||||
if status in (200, 201):
|
||||
print(f" ✓ 简介已更新")
|
||||
print(f" {reply.get('description', '')}")
|
||||
print(f" 主页: {reply.get('homepage', '')}")
|
||||
else:
|
||||
print(f" ✗ HTTP {status}: {reply}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:300]
|
||||
print(f" ✗ HTTP {exc.code}: {detail}")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print("===== 2. 核对发行版 =====")
|
||||
try:
|
||||
_s, releases = call(f"{API}/releases?access_token={token}&per_page=5")
|
||||
if not isinstance(releases, list):
|
||||
print(f" 读不到发行版列表: {releases}")
|
||||
for rel in releases[:3]:
|
||||
print(f" {rel.get('tag_name')} {rel.get('name')}")
|
||||
for asset in rel.get("assets", []):
|
||||
size = asset.get("size", 0) / 1024 / 1024
|
||||
print(f" {asset.get('browser_download_url', '')}"
|
||||
.replace("https://gitee.com", ""))
|
||||
print(f" 大小 {size:.2f} MB "
|
||||
f"下载次数 {asset.get('download_count', 0)}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" 查发行版失败: {exc}")
|
||||
|
||||
print()
|
||||
print(f" 仓库: https://gitee.com/{OWNER}/{REPO}")
|
||||
print(f" 发行版: https://gitee.com/{OWNER}/{REPO}/releases")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""在 Gitee 上创建发行版(Release)并上传三个文件。
|
||||
|
||||
用法:
|
||||
python _build/gitee_release.py <令牌> [版本号]
|
||||
|
||||
令牌只从命令行参数读,**不写进任何文件、不进仓库**。
|
||||
|
||||
Gitee 的两个接口:
|
||||
建发行版 POST /api/v5/repos/{owner}/{repo}/releases
|
||||
传附件 POST /api/v5/repos/{owner}/{repo}/releases/{id}/attach_files
|
||||
|
||||
SSH 密钥只能推代码,建发行版必须用这个令牌。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
OWNER = "xmz7891"
|
||||
REPO = "ykxmz"
|
||||
API = f"https://gitee.com/api/v5/repos/{OWNER}/{REPO}"
|
||||
|
||||
|
||||
def api(url: str, payload: dict | None = None, method: str = "GET",
|
||||
timeout: int = 60):
|
||||
data = None
|
||||
headers = {"User-Agent": "ykxmz-release"}
|
||||
if payload is not None:
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json;charset=UTF-8"
|
||||
request = urllib.request.Request(url, data=data, headers=headers,
|
||||
method=method)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
body = response.read().decode("utf-8", "replace")
|
||||
return response.status, (json.loads(body) if body.strip() else {})
|
||||
|
||||
|
||||
def upload_file(url: str, path: Path, token: str, timeout: int = 600):
|
||||
"""multipart 上传单个文件。"""
|
||||
boundary = "----ykxmz" + uuid.uuid4().hex
|
||||
content_type = (mimetypes.guess_type(path.name)[0]
|
||||
or "application/octet-stream")
|
||||
head = (
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="access_token"\r\n\r\n'
|
||||
f"{token}\r\n"
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; '
|
||||
f'filename="{path.name}"\r\n'
|
||||
f"Content-Type: {content_type}\r\n\r\n"
|
||||
).encode("utf-8")
|
||||
tail = f"\r\n--{boundary}--\r\n".encode("utf-8")
|
||||
body = head + path.read_bytes() + tail
|
||||
|
||||
request = urllib.request.Request(
|
||||
url, data=body, method="POST",
|
||||
headers={
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
"Content-Length": str(len(body)),
|
||||
"User-Agent": "ykxmz-release",
|
||||
})
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
text = response.read().decode("utf-8", "replace")
|
||||
return response.status, (json.loads(text) if text.strip() else {})
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(" 用法: python _build/gitee_release.py <令牌> [版本号]")
|
||||
return 1
|
||||
token = sys.argv[1]
|
||||
version = sys.argv[2] if len(sys.argv) > 2 else "1.6.0"
|
||||
tag = f"v{version}"
|
||||
|
||||
# 更新内容从哪来:
|
||||
# 1) 命令行第三个参数(直接给一段话)
|
||||
# 2) _build/CHANGELOG.md 里最新一节
|
||||
changelog = sys.argv[3] if len(sys.argv) > 3 else ''
|
||||
if not changelog:
|
||||
note = ROOT / "_build" / "CHANGELOG.md"
|
||||
if note.is_file():
|
||||
text = note.read_text(encoding="utf-8")
|
||||
blocks = [b.strip() for b in text.split(chr(10) + "## ") if b.strip()]
|
||||
for b in blocks:
|
||||
if b.startswith(version) or b.startswith("v" + version):
|
||||
changelog = b
|
||||
break
|
||||
if not changelog and blocks:
|
||||
changelog = blocks[0]
|
||||
if not changelog:
|
||||
changelog = f"版本 {version}"
|
||||
|
||||
# 电脑端给**安装包**(不是 zip 了,用户要求);
|
||||
# 服务器端给 zip;手表端直接给 APK。
|
||||
files = [
|
||||
ROOT / "dist" / f"ykxmz-setup-{version}.exe",
|
||||
ROOT / "dist" / f"ykxmz-server-{version}.zip",
|
||||
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
|
||||
]
|
||||
for path in files:
|
||||
if not path.is_file():
|
||||
print(f" [失败] 找不到 {path}")
|
||||
return 1
|
||||
|
||||
print(f" 仓库: {OWNER}/{REPO}")
|
||||
print(f" 标签: {tag}")
|
||||
print()
|
||||
|
||||
# 发行版说明**只放更新内容**(用户要求)。
|
||||
# 不放下载表格、不放用法说明 —— 那些在 README 里,
|
||||
# 这里堆一堆反而看不清这版改了什么。
|
||||
body = f"""## 更新内容
|
||||
|
||||
{changelog}
|
||||
"""
|
||||
|
||||
print(" 建发行版…")
|
||||
try:
|
||||
status, reply = api(
|
||||
f"{API}/releases",
|
||||
{"access_token": token, "tag_name": tag, "name": f"ykxmz {version}",
|
||||
"body": body, "target_commitish": "main", "prerelease": False},
|
||||
method="POST")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:400]
|
||||
print(f" [失败] HTTP {exc.code}: {detail}")
|
||||
return 1
|
||||
|
||||
if status not in (200, 201):
|
||||
print(f" [失败] HTTP {status}: {reply}")
|
||||
return 1
|
||||
|
||||
release_id = reply.get("id")
|
||||
print(f" ✓ 发行版已建,id={release_id}")
|
||||
print(f" {reply.get('html_url', '')}")
|
||||
|
||||
# 如果标签已经存在(重复发布),走"找已有发行版再传附件"的路
|
||||
if not release_id:
|
||||
print(" [提示] 没拿到 id,尝试按标签查已有的发行版…")
|
||||
try:
|
||||
_s, existing = api(f"{API}/releases/tags/{tag}?access_token={token}")
|
||||
release_id = existing.get("id")
|
||||
print(f" 已有发行版 id={release_id}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" [失败] 查不到: {exc}")
|
||||
return 1
|
||||
|
||||
# ---- 2. 传附件 ----
|
||||
print()
|
||||
print(" 上传文件:")
|
||||
attach = f"{API}/releases/{release_id}/attach_files"
|
||||
for path in files:
|
||||
size_mb = path.stat().st_size / 1024 / 1024
|
||||
try:
|
||||
status, reply = upload_file(attach, path, token)
|
||||
if status in (200, 201):
|
||||
print(f" ✓ {path.name} ({size_mb:.2f} MB)")
|
||||
else:
|
||||
print(f" ✗ {path.name}: HTTP {status} {reply}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode("utf-8", "replace")[:200]
|
||||
print(f" ✗ {path.name}: HTTP {exc.code} {detail}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" ✗ {path.name}: {exc}")
|
||||
|
||||
print()
|
||||
print(f" 完成:https://gitee.com/{OWNER}/{REPO}/releases/tag/{tag}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""服务器端打包时带上 favicon.ico。
|
||||
|
||||
package_remote.py 原来只收 relay 目录下的 .py / .sh,
|
||||
favicon.ico 会被漏掉 —— 结果装到新设备上管理页又没图标了。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PKG = (Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote\_build"
|
||||
r"\package_remote.py"))
|
||||
|
||||
OLD = ''' wanted = sorted(
|
||||
[f for f in RELAY_SRC.iterdir()
|
||||
if f.suffix in (".py", ".sh") and f.is_file()],
|
||||
key=lambda f: f.name)'''
|
||||
|
||||
NEW = ''' # 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
|
||||
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
|
||||
wanted = sorted(
|
||||
[f for f in RELAY_SRC.iterdir()
|
||||
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
|
||||
key=lambda f: f.name)'''
|
||||
|
||||
|
||||
def main() -> int:
|
||||
text = PKG.read_text(encoding="utf-8")
|
||||
if '.sh", ".ico"' in text:
|
||||
print(" 已经改过")
|
||||
return 0
|
||||
if OLD not in text:
|
||||
print(" [失败] 锚点没找到")
|
||||
return 1
|
||||
|
||||
text = text.replace(OLD, NEW, 1)
|
||||
PKG.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" 服务器端打包现在会带上 .ico")
|
||||
|
||||
try:
|
||||
ast.parse(text)
|
||||
print(" 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -17,7 +17,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
VERSION = "1.6.0"
|
||||
VERSION = "1.7.0"
|
||||
NAME = f"ykxmz-{VERSION}"
|
||||
|
||||
AGENT_SRC = ROOT / "dist_exe" / "ykxmz"
|
||||
@@ -482,9 +482,11 @@ def main() -> int:
|
||||
server_dir.mkdir(parents=True)
|
||||
# 文件清单写死容易漏 —— watchdog.sh / net-tune.sh 都漏过一次。
|
||||
# 改成:relay 目录里所有 .py / .sh 都带上。
|
||||
# 后缀白名单:.py / .sh 是服务本体,.ico 是管理页图标。
|
||||
# 漏掉 .ico 的话,装到新设备上管理页又会变成没有图标的默认样式。
|
||||
wanted = sorted(
|
||||
[f for f in RELAY_SRC.iterdir()
|
||||
if f.suffix in (".py", ".sh") and f.is_file()],
|
||||
if f.suffix in (".py", ".sh", ".ico") and f.is_file()],
|
||||
key=lambda f: f.name)
|
||||
for src in wanted:
|
||||
shutil.copy2(src, server_dir / src.name)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""一条命令完成发布。
|
||||
|
||||
用法:
|
||||
python _build/release.py 1.7.0 # 升版本 + 构建 + 打包 + 推送 + 建发行版
|
||||
python _build/release.py 1.7.0 --no-readme # 明说这版没改 README
|
||||
|
||||
它干这些事:
|
||||
1. 升版本号(三端 + 文档 + 测试断言)
|
||||
2. 重建 EXE 和 APK
|
||||
3. 打三个成品
|
||||
4. 跑一遍关键测试
|
||||
5. git 提交 + 推送
|
||||
6. 在 Gitee 建发行版,把三个文件传上去
|
||||
7. 覆盖仓库简介(只在第一次需要,重复设成一样也无害)
|
||||
|
||||
令牌从 _build/.gitee_token 读(那个文件在 .gitignore 里,不会进仓库)。
|
||||
第一次用:把令牌写进去就行。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
TOKEN_FILE = ROOT / "_build" / ".gitee_token"
|
||||
GIT = r"C:\Program Files\Git\bin\git.exe"
|
||||
GIT_SSH = r"C:\Program Files\Git\usr\bin\ssh.exe"
|
||||
|
||||
|
||||
def sh(args, cwd=ROOT, check=False, timeout=1800, env=None):
|
||||
merged = dict(os.environ)
|
||||
if env:
|
||||
merged.update(env)
|
||||
proc = subprocess.run(args, cwd=cwd, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=timeout,
|
||||
env=merged)
|
||||
if check and proc.returncode != 0:
|
||||
raise RuntimeError(f"{args[0]} 失败:{proc.stdout}\n{proc.stderr}")
|
||||
return proc
|
||||
|
||||
|
||||
def step(n, title):
|
||||
print()
|
||||
print(f" {'=' * 56}")
|
||||
print(f" {n}. {title}")
|
||||
print(f" {'=' * 56}")
|
||||
|
||||
|
||||
def read_current_version() -> str:
|
||||
text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if line.startswith("VERSION = "):
|
||||
return line.split('"')[1]
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print(" 用法: python _build/release.py <新版本号>")
|
||||
print(f" 当前版本: {read_current_version()}")
|
||||
return 1
|
||||
|
||||
new_version = sys.argv[1]
|
||||
old_version = read_current_version()
|
||||
note_readme = "--no-readme" in sys.argv
|
||||
|
||||
if not TOKEN_FILE.is_file():
|
||||
print(f" [失败] 找不到令牌文件 {TOKEN_FILE}")
|
||||
print(" 把 Gitee 令牌写进那个文件(一行,别加引号)")
|
||||
return 1
|
||||
token = TOKEN_FILE.read_text(encoding="utf-8").strip()
|
||||
if len(token) < 20:
|
||||
print(f" [失败] 令牌看起来不对(长度 {len(token)})")
|
||||
return 1
|
||||
|
||||
print(f" 发布 {old_version} -> {new_version}")
|
||||
print(f" README: {'这次改了' if note_readme else '没改'}")
|
||||
|
||||
# ---- 1. 升版本 ----
|
||||
step(1, "升版本号")
|
||||
proc = sh([sys.executable, "_build/bump_version_generic.py",
|
||||
old_version, new_version])
|
||||
print(proc.stdout[-1200:])
|
||||
if proc.returncode != 0:
|
||||
print(proc.stderr[-500:])
|
||||
return 1
|
||||
|
||||
# ---- 2. 重建 ----
|
||||
step(2, "重建 EXE 和 APK")
|
||||
sh([GIT, "--version"]) # 探活,无关紧要
|
||||
for args in (["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", "android\\build_apk.ps1"],
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-File", "windows\\build_exe.ps1"]):
|
||||
proc = sh(args, timeout=2400)
|
||||
tail = (proc.stdout or "").strip().splitlines()[-2:]
|
||||
for line in tail:
|
||||
print(f" {line}")
|
||||
if proc.returncode != 0:
|
||||
print(f" [失败] {args[-1]}")
|
||||
print((proc.stderr or "")[-600:])
|
||||
return 1
|
||||
|
||||
# ---- 3. 打成品 ----
|
||||
step(3, "打成品(安装包 + 服务器 zip + APK)")
|
||||
proc = sh([sys.executable, "_build/package_remote.py"])
|
||||
print(proc.stdout[-1200:])
|
||||
if proc.returncode != 0:
|
||||
return 1
|
||||
|
||||
# 电脑端对外发的是**安装包**,不是 zip
|
||||
proc = sh([sys.executable, "installer/build_installer.py"], timeout=1800)
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
if "安装包" in line or "失败" in line:
|
||||
print(f" {line.strip()}")
|
||||
if proc.returncode != 0:
|
||||
print((proc.stdout or "")[-1200:])
|
||||
return 1
|
||||
|
||||
# ---- 4. 关键测试 ----
|
||||
step(4, "跑关键测试")
|
||||
for test in ("audit_encoding.py", "test_net_tune.py", "test_chunked_body.py",
|
||||
"test_admin_js.py"):
|
||||
proc = sh([sys.executable, "-u", f"_tests/{test}"], timeout=900)
|
||||
# 用退出码判断 —— 各测试的输出格式不一样
|
||||
# (有的打"失败 0 项",有的打"结论:JS 合法",字符串匹配会误判)
|
||||
bad = proc.returncode != 0
|
||||
print(f" {test:28} {'✗' if bad else '✓'}")
|
||||
if bad:
|
||||
print((proc.stdout or "")[-800:])
|
||||
print((proc.stderr or "")[-400:])
|
||||
return 1
|
||||
# 打包测试要起真的中转 + agent,前面刚跑过几个测试,
|
||||
# 偶尔会撞上端口/隧道还没恢复 —— 重试一次就好
|
||||
ok_pkg = False
|
||||
for attempt in range(3):
|
||||
proc = sh([sys.executable, "-u", "_tests/test_package_remote.py"],
|
||||
timeout=1800)
|
||||
out = proc.stdout or ""
|
||||
last = [ln for ln in out.splitlines() if "通过" in ln or "失败" in ln]
|
||||
ok_pkg = proc.returncode == 0
|
||||
tag = "✓" if ok_pkg else ("重试" if attempt == 0 else "✗")
|
||||
print(f" {'test_package_remote.py':28} {tag} "
|
||||
f"{last[-1] if last else ''}")
|
||||
if ok_pkg:
|
||||
break
|
||||
print(" (失败,清一下残留再试)")
|
||||
sh(["powershell", "-NoProfile", "-Command",
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
|
||||
"Where-Object { $_.CommandLine -match 'run_agent' -or "
|
||||
"$_.CommandLine -match '_tests' -or "
|
||||
"$_.CommandLine -match 'relay.py' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
|
||||
"-ErrorAction SilentlyContinue }"], timeout=120)
|
||||
time.sleep(8)
|
||||
if not ok_pkg:
|
||||
return 1
|
||||
|
||||
# ---- 5. 提交推送 ----
|
||||
step(5, "提交并推送")
|
||||
env = {"GIT_SSH_COMMAND": f'"{GIT_SSH}"'}
|
||||
sh([GIT, "add", "-A"])
|
||||
msg = (f"{new_version}:{'README 与' if note_readme else ''}常规发布\n\n"
|
||||
f"- 版本号 {old_version} -> {new_version}\n"
|
||||
f"- 重建三端并打包\n"
|
||||
f"- 关键测试全过\n")
|
||||
sh([GIT, "commit", "-q", "-m", msg])
|
||||
proc = sh([GIT, "push", "origin", "main"], env=env, timeout=1800)
|
||||
print(f" {proc.stdout.strip()[-300:]}")
|
||||
if proc.returncode != 0:
|
||||
print(proc.stderr[-600:])
|
||||
return 1
|
||||
|
||||
# ---- 6. 发行版 ----
|
||||
step(6, "建 Gitee 发行版")
|
||||
proc = sh([sys.executable, "_build/gitee_release.py", token, new_version],
|
||||
timeout=2400)
|
||||
print(proc.stdout[-1200:])
|
||||
if proc.returncode != 0:
|
||||
print(proc.stderr[-600:])
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(f" ✓ {new_version} 发布完成")
|
||||
print(f" https://gitee.com/xmz7891/ykxmz/releases/tag/v{new_version}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""让被控端记住上次连通的中转地址。
|
||||
|
||||
问题:
|
||||
设备 IP 会变(DHCP),内置的默认地址写死了就过期。
|
||||
start() 每次都要把候选地址挨个探一遍,每个等到超时 ——
|
||||
实测 2 个候选要 10 秒,这 10 秒里局域网发现都还没起来。
|
||||
|
||||
改法:
|
||||
1. 挑中的地址存进 store(relay_last_good)
|
||||
2. 下次启动把它排到候选列表**最前面**,通常一次就通,启动瞬间完成
|
||||
3. 地址失效时会自动往下试,试通了再存新的 —— 自愈
|
||||
4. 内置默认地址更新成当前设备地址(192.168.2.156)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
RELAY_PY = ROOT / "windows" / "lanremote" / "relay.py"
|
||||
SERVER_PY = ROOT / "windows" / "lanremote" / "server.py"
|
||||
|
||||
# ---------- 1. 记住并优先使用上次通的地址 ----------
|
||||
|
||||
OLD_PICK = ''' self.candidates = candidates
|
||||
# 先探一遍,挑第一个通的
|
||||
chosen = self._pick_candidate(candidates)
|
||||
self.url = chosen
|
||||
if len(candidates) > 1:
|
||||
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")'''
|
||||
|
||||
NEW_PICK = ''' # 上次连通的地址排到最前面。
|
||||
# 设备 IP 会变(DHCP),但大概率还是上次那个;
|
||||
# 排前面能省掉"挨个探到超时"的那几秒(实测 2 个候选要 10 秒,
|
||||
# 而这段时间局域网发现都还没起来,手表搜不到电脑)。
|
||||
try:
|
||||
last_good = str(self.store.get("relay_last_good") or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
last_good = ""
|
||||
if last_good:
|
||||
normalized = last_good.rstrip("/")
|
||||
if normalized in candidates:
|
||||
candidates.remove(normalized)
|
||||
candidates.insert(0, normalized)
|
||||
|
||||
self.candidates = candidates
|
||||
# 先探一遍,挑第一个通的
|
||||
chosen = self._pick_candidate(candidates)
|
||||
self.url = chosen
|
||||
if len(candidates) > 1:
|
||||
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
|
||||
|
||||
# 记下来,下次优先试它
|
||||
if chosen:
|
||||
try:
|
||||
if str(self.store.get("relay_last_good") or "") != chosen:
|
||||
self.store.set("relay_last_good", chosen)
|
||||
except Exception: # noqa: BLE001
|
||||
pass'''
|
||||
|
||||
# ---------- 2. 更新内置默认地址 ----------
|
||||
|
||||
OLD_DEFAULT = 'DEFAULT_RELAY_URL = "192.168.10.105:8080;https://relay.xxmxxs.top"'
|
||||
NEW_DEFAULT = ('# 内置的中转地址:局域网优先,连不上自动切公网隧道。\n'
|
||||
'# 第一个是随身 WiFi 的固定地址(写死是因为它常在同一网段、'
|
||||
'直连最快);\n'
|
||||
'# 它变了也没关系 —— 连上过的地址会被记住并排到最前面。\n'
|
||||
'DEFAULT_RELAY_URL = "192.168.2.156:8080;'
|
||||
'https://relay.xxmxxs.top"')
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
|
||||
text = RELAY_PY.read_text(encoding="utf-8")
|
||||
if "relay_last_good" in text:
|
||||
print(" relay.py: 已经改过")
|
||||
elif OLD_PICK in text:
|
||||
text = text.replace(OLD_PICK, NEW_PICK, 1)
|
||||
RELAY_PY.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(" relay.py: 加上「记住上次通的地址」")
|
||||
else:
|
||||
print(" [失败] relay.py 锚点没找到")
|
||||
ok = False
|
||||
|
||||
stext = SERVER_PY.read_text(encoding="utf-8")
|
||||
if "192.168.2.156" in stext:
|
||||
print(" server.py: 默认地址已经是新的")
|
||||
elif OLD_DEFAULT in stext:
|
||||
stext = stext.replace(OLD_DEFAULT, NEW_DEFAULT, 1)
|
||||
SERVER_PY.write_text(stext, encoding="utf-8", newline="\n")
|
||||
print(" server.py: 默认地址改成 192.168.2.156")
|
||||
else:
|
||||
print(" [提示] server.py 里没找到那行默认地址(可能已改)")
|
||||
|
||||
if not ok:
|
||||
return 1
|
||||
|
||||
for path in (RELAY_PY, SERVER_PY):
|
||||
try:
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
print(f" {path.name} 语法 OK")
|
||||
except SyntaxError as exc:
|
||||
print(f" {path.name} 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""把"电脑端发 zip"改成"电脑端发安装包 exe"。
|
||||
|
||||
用户要求:发行版里电脑端要给**安装包**,不是 zip。
|
||||
|
||||
改两个地方:
|
||||
_build/gitee_release.py 上传列表里 agent zip -> setup exe
|
||||
_build/release.py 打包之后多跑一步编译安装包
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
GITEE = ROOT / "_build" / "gitee_release.py"
|
||||
RELEASE = ROOT / "_build" / "release.py"
|
||||
|
||||
# ---------- 1. gitee_release.py 的上传列表 ----------
|
||||
|
||||
G_OLD = ''' files = [
|
||||
ROOT / "dist" / f"ykxmz-agent-{version}.zip",
|
||||
ROOT / "dist" / f"ykxmz-server-{version}.zip",
|
||||
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
|
||||
]'''
|
||||
|
||||
G_NEW = ''' # 电脑端给**安装包**(不是 zip 了,用户要求);
|
||||
# 服务器端给 zip;手表端直接给 APK。
|
||||
files = [
|
||||
ROOT / "dist" / f"ykxmz-setup-{version}.exe",
|
||||
ROOT / "dist" / f"ykxmz-server-{version}.zip",
|
||||
ROOT / "dist" / f"ykxmz-watch-{version}.apk",
|
||||
]'''
|
||||
|
||||
# ---------- 2. release.py 多编译一步安装包 ----------
|
||||
|
||||
R_OLD = ''' # ---- 3. 打包 ----
|
||||
step(3, "打三个成品")
|
||||
proc = sh([sys.executable, "_build/package_remote.py"])
|
||||
print(proc.stdout[-1400:])
|
||||
if proc.returncode != 0:
|
||||
return 1'''
|
||||
|
||||
R_NEW = ''' # ---- 3. 打成品 ----
|
||||
step(3, "打成品(安装包 + 服务器 zip + APK)")
|
||||
proc = sh([sys.executable, "_build/package_remote.py"])
|
||||
print(proc.stdout[-1200:])
|
||||
if proc.returncode != 0:
|
||||
return 1
|
||||
|
||||
# 电脑端对外发的是**安装包**,不是 zip
|
||||
proc = sh([sys.executable, "installer/build_installer.py"], timeout=1800)
|
||||
for line in (proc.stdout or "").splitlines():
|
||||
if "安装包" in line or "失败" in line:
|
||||
print(f" {line.strip()}")
|
||||
if proc.returncode != 0:
|
||||
print((proc.stdout or "")[-1200:])
|
||||
return 1'''
|
||||
|
||||
R_OLD2 = ''' step(3, "打三个成品")'''
|
||||
R_NEW2 = ''' step(3, "打成品(安装包 + 服务器 zip + APK)")'''
|
||||
|
||||
|
||||
def patch(path: Path, pairs, label: str) -> bool:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
changed = False
|
||||
for old, new in pairs:
|
||||
if new.strip() and new in text:
|
||||
continue
|
||||
if old not in text:
|
||||
print(f" [提示] {path.name}: 找不到「{label}」的锚点(可能已改)")
|
||||
continue
|
||||
text = text.replace(old, new, 1)
|
||||
changed = True
|
||||
if changed:
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
print(f" {path.name}: 已更新")
|
||||
else:
|
||||
print(f" {path.name}: 无需改动")
|
||||
try:
|
||||
ast.parse(path.read_text(encoding="utf-8"))
|
||||
except SyntaxError as exc:
|
||||
print(f" [失败] {path.name} 语法错误: 行 {exc.lineno}: {exc.msg}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
if not patch(GITEE, [(G_OLD, G_NEW)], "上传列表"):
|
||||
ok = False
|
||||
if not patch(RELEASE, [(R_OLD, R_NEW)], "打包步骤"):
|
||||
ok = False
|
||||
|
||||
print()
|
||||
print(" 复核:")
|
||||
g = GITEE.read_text(encoding="utf-8")
|
||||
r = RELEASE.read_text(encoding="utf-8")
|
||||
print(f" gitee_release.py 用 setup exe: "
|
||||
f"{'✓' if 'ykxmz-setup-{version}.exe' in g else '✗'}")
|
||||
print(f" release.py 会编译安装包: "
|
||||
f"{'✓' if 'build_installer.py' in r else '✗'}")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""设备回来了:把成品传到下载区,并部署最新 relay。
|
||||
|
||||
版本号从 _build/package_remote.py 里读,不用手改。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
sys.path.insert(0, str(ROOT / "_build"))
|
||||
|
||||
from devssh import run, upload # noqa: E402
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8")
|
||||
match = re.search(r'VERSION\s*=\s*"([^"]+)"', text)
|
||||
return match.group(1) if match else "0.0.0"
|
||||
|
||||
|
||||
VER = current_version()
|
||||
REMOTE_DIR = "/root/lanremote-relay/downloads"
|
||||
|
||||
UPLOADS = [
|
||||
(f"dist/ykxmz-agent-{VER}.zip", f"{REMOTE_DIR}/ykxmz-agent-{VER}.zip"),
|
||||
(f"dist/ykxmz-server-{VER}.zip", f"{REMOTE_DIR}/ykxmz-server-{VER}.zip"),
|
||||
(f"dist/ykxmz-watch-{VER}.apk", f"{REMOTE_DIR}/ykxmz-watch-{VER}.apk"),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print("===== 1. 清掉旧版本 =====")
|
||||
_c, out, _e = run(f"ls {REMOTE_DIR}/ 2>/dev/null", timeout=30)
|
||||
for line in (out or "").splitlines():
|
||||
name = line.strip()
|
||||
if name and VER not in name:
|
||||
run(f"rm -f '{REMOTE_DIR}/{name}'", timeout=30)
|
||||
print(f" 删除 {name}")
|
||||
|
||||
print()
|
||||
print(f"===== 2. 上传 {VER} =====")
|
||||
for local, remote in UPLOADS:
|
||||
src = ROOT / local
|
||||
if not src.is_file():
|
||||
print(f" [失败] {local} 不存在")
|
||||
return 1
|
||||
upload(str(src), remote, 0o644)
|
||||
print(f" ✓ {src.name} ({src.stat().st_size / 1024 / 1024:.2f} MB)")
|
||||
|
||||
print()
|
||||
print("===== 3. 部署最新 relay.py / net-tune.sh =====")
|
||||
for local, remote in (("relay/relay.py", "/root/lanremote-relay/relay.py"),
|
||||
("relay/net-tune.sh",
|
||||
"/root/lanremote-relay/net-tune.sh"),
|
||||
("relay/watchdog.sh",
|
||||
"/root/lanremote-relay/watchdog.sh")):
|
||||
upload(str(ROOT / local), remote, 0o755)
|
||||
print(f" ✓ {Path(local).name}")
|
||||
run("systemctl restart lanremote-relay", timeout=90)
|
||||
print(" 已重启中转服务")
|
||||
|
||||
print()
|
||||
print("===== 4. 确认 =====")
|
||||
_c, out, _e = run(
|
||||
"sleep 4; echo ' 版本: '$(curl -s --max-time 8 "
|
||||
"http://127.0.0.1:8080/v1/health | head -c 100); "
|
||||
f"echo; ls -la {REMOTE_DIR}/ | tail -5", timeout=90)
|
||||
print(out)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,84 @@
|
||||
# 全新安装验证:清干净 -> 装到默认路径 -> 跑一遍 -> 检查权限
|
||||
# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File _build\test_install_fresh.ps1
|
||||
|
||||
$ErrorActionPreference = "Continue"
|
||||
$root = "C:\Users\shu\Desktop\deepseekagent\LanRemote"
|
||||
$setup = Join-Path $root "dist\ykxmz-setup-1.6.2.exe"
|
||||
$appId = "{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}_is1"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== 1. 彻底清理上一次的安装 =====" -ForegroundColor Cyan
|
||||
Get-Process ykxmz, unins000 -ErrorAction SilentlyContinue |
|
||||
Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
foreach ($dir in @("C:\Program Files\ykxmz", "C:\ProgramData\_ykxmz_test",
|
||||
"C:\ProgramData\ykxmz")) {
|
||||
if (Test-Path "$dir\unins000.exe") {
|
||||
Start-Process "$dir\unins000.exe" `
|
||||
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES" -Wait `
|
||||
-ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
Remove-Item $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
# 删掉 Inno 记住的目录,否则下次还会装到老地方
|
||||
foreach ($hive in @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
|
||||
"HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall")) {
|
||||
Remove-Item "$hive\$appId" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Host " 清理完成"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== 2. 全新安装(不带 /DIR,看默认去哪)=====" -ForegroundColor Cyan
|
||||
$log = Join-Path $root "_build\install_fresh.log"
|
||||
Remove-Item $log -Force -ErrorAction SilentlyContinue
|
||||
$proc = Start-Process -FilePath $setup `
|
||||
-ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART",
|
||||
"/LOG=$log", "/CODE=654321" -PassThru -Wait
|
||||
Write-Host " 安装 exit=$($proc.ExitCode)"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== 3. 安装位置 =====" -ForegroundColor Cyan
|
||||
$exe = "C:\Program Files\ykxmz\ykxmz.exe"
|
||||
Write-Host " $exe : $(Test-Path $exe)"
|
||||
Write-Host " _internal : $((Get-ChildItem 'C:\Program Files\ykxmz\_internal' -ErrorAction SilentlyContinue | Measure-Object).Count) 项"
|
||||
Write-Host " docs : $((Get-ChildItem 'C:\Program Files\ykxmz\docs' -ErrorAction SilentlyContinue | Measure-Object).Count) 个"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== 4. 数据目录权限 =====" -ForegroundColor Cyan
|
||||
$dataDir = "C:\ProgramData\ykxmz\data"
|
||||
if (Test-Path $dataDir) {
|
||||
Write-Host " $dataDir 存在"
|
||||
(Get-Acl $dataDir).Access |
|
||||
Where-Object { $_.IdentityReference -match "Users|用户" } |
|
||||
ForEach-Object { Write-Host " $($_.IdentityReference) : $($_.FileSystemRights)" }
|
||||
} else {
|
||||
Write-Host " $dataDir 不存在(还没运行过,正常)"
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "===== 5. 跑起来(这一步以前报 WinError 5)=====" -ForegroundColor Cyan
|
||||
if (Test-Path $exe) {
|
||||
& $exe --status 2>&1 |
|
||||
Select-String -Pattern "配对码|数据目录|开机自启" |
|
||||
ForEach-Object { Write-Host " $($_.Line.Trim())" }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " 写一次配置(模拟实际运行):"
|
||||
$out = & $exe --name "三年二班" 2>&1
|
||||
$out | ForEach-Object { Write-Host " $_" }
|
||||
Write-Host " exit=$LASTEXITCODE"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " 再看状态:"
|
||||
& $exe --status 2>&1 |
|
||||
Select-String -Pattern "配对码|数据目录" |
|
||||
ForEach-Object { Write-Host " $($_.Line.Trim())" }
|
||||
} else {
|
||||
Write-Host " ✗ 找不到 exe,安装失败了" -ForegroundColor Red
|
||||
if (Test-Path $log) {
|
||||
Get-Content $log -Encoding UTF8 | Select-Object -Last 15 |
|
||||
ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""验证「记住上次通的地址」是否让启动变快。
|
||||
|
||||
第一次启动:候选里没有记忆,要挨个探(默认地址现在是 192.168.2.156,
|
||||
如果这台电脑能连上它,应该很快就通)
|
||||
第二次启动:记忆里有它,直接排最前面,应该瞬间完成
|
||||
|
||||
重点看:**从进程启动到 UDP 8766 绑上花了多久** ——
|
||||
这才是"手表多久能搜到电脑"。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
DISCOVERY = 8766
|
||||
|
||||
|
||||
def clean() -> None:
|
||||
subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command",
|
||||
"Get-CimInstance Win32_Process -Filter \"Name='python.exe'\" | "
|
||||
"Where-Object { $_.CommandLine -match 'run_agent' } | "
|
||||
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force "
|
||||
"-ErrorAction SilentlyContinue }"],
|
||||
capture_output=True, text=True, timeout=60)
|
||||
time.sleep(2)
|
||||
|
||||
|
||||
def probe(port: int, wait_discovery: bool = True) -> tuple[bool, float, str]:
|
||||
"""起 agent,返回 (8766 是否可用, 花了多久, 日志尾部)。"""
|
||||
t0 = time.time()
|
||||
env = dict(os.environ)
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "run_agent.py", "--port", str(port),
|
||||
"--new-code", "--add-device", "10"],
|
||||
cwd=str(ROOT / "windows"), env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
deadline = time.time() + 40
|
||||
got = False
|
||||
while time.time() < deadline:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.settimeout(1.5)
|
||||
try:
|
||||
s.sendto(json.dumps({"lanremote": "LANREMOTE_SELFCHECK",
|
||||
"proto": 1}).encode(), ("127.0.0.1",
|
||||
DISCOVERY))
|
||||
data, _ = s.recvfrom(2048)
|
||||
if b"LANREMOTE_ACK" in data:
|
||||
got = True
|
||||
break
|
||||
except (socket.timeout, ConnectionResetError, OSError):
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
time.sleep(0.3)
|
||||
elapsed = time.time() - t0
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
time.sleep(1.5)
|
||||
|
||||
log = ROOT / "windows" / "data" / "silent.log"
|
||||
tail = ""
|
||||
if log.is_file():
|
||||
tail = log.read_text(encoding="utf-8", errors="replace")
|
||||
return got, elapsed, tail
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(" 清干净…")
|
||||
clean()
|
||||
|
||||
print()
|
||||
print(" ===== 第一次(先把记忆清掉)=====")
|
||||
data = ROOT / "windows" / "data"
|
||||
shutil.rmtree(data, ignore_errors=True)
|
||||
got1, t1, log1 = probe(8861)
|
||||
print(f" 8766 可用: {got1} 耗时 {t1:.1f} 秒")
|
||||
rel_lines = [ln for ln in log1.splitlines() if "选用" in ln or "中转已启用" in ln]
|
||||
for ln in rel_lines:
|
||||
print(f" {ln.strip()}")
|
||||
|
||||
print()
|
||||
print(" ===== 第二次(有记忆了)=====")
|
||||
# 保留 data 里的 settings.json(记忆就存在这里)
|
||||
got2, t2, log2 = probe(8862)
|
||||
print(f" 8766 可用: {got2} 耗时 {t2:.1f} 秒")
|
||||
rel_lines = [ln for ln in log2.splitlines() if "选用" in ln or "中转已启用" in ln]
|
||||
for ln in rel_lines:
|
||||
print(f" {ln.strip()}")
|
||||
|
||||
print()
|
||||
print(" ===== 结果 =====")
|
||||
print(f" 第一次 {t1:.1f} 秒 -> 第二次 {t2:.1f} 秒")
|
||||
if t2 < t1:
|
||||
print(f" ✓ 快了 {t1 - t2:.1f} 秒")
|
||||
else:
|
||||
print(" ~ 没有明显变快(可能第一个候选本来就通)")
|
||||
|
||||
# 看看记忆存了什么
|
||||
settings = ROOT / "windows" / "data" / "settings.json"
|
||||
if settings.is_file():
|
||||
try:
|
||||
obj = json.loads(settings.read_text(encoding="utf-8"))
|
||||
print(f" 记忆的中转地址: {obj.get('relay_last_good')}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" 读 settings 失败: {exc}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""把整个项目打成压缩包,交给用户。
|
||||
|
||||
包含:全部源码(三端 + 安装包脚本 + 测试 + 构建脚本)+ 三份文档
|
||||
+ 已构建好的成品(安装包 / APK / 两个 zip)
|
||||
|
||||
排除:
|
||||
* .git 版本库(几十 MB,而且推上去过)
|
||||
* dist_exe 40MB PyInstaller 产物,一条命令能重建
|
||||
* _build/pyinstaller_work 同上
|
||||
* __pycache__ / *.pyc
|
||||
* _tests/out_* / _tests/received 测试临时数据
|
||||
* _build/jc* 编译中间产物
|
||||
* **_build/.gitee_token** ← Gitee 令牌,**绝对不能进包**
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
OUT = ROOT / "dist" / "ykxmz-项目完整源码.zip"
|
||||
|
||||
# 目录级排除(按相对路径前缀)
|
||||
EXCLUDE_DIRS = (
|
||||
".git",
|
||||
"dist_exe",
|
||||
"_build/pyinstaller_work",
|
||||
"_build/jc_all",
|
||||
"_build/jc_tls",
|
||||
"_build/jc_tls2",
|
||||
"_build/jc_v15",
|
||||
"_build/out_java",
|
||||
"_build/out_app",
|
||||
"_tests/out_java",
|
||||
"_tests/out_app",
|
||||
"_tests/received",
|
||||
"_tests/_stubdata",
|
||||
"_tests/shots",
|
||||
"__pycache__",
|
||||
"android/build",
|
||||
"android/gen",
|
||||
"android/bin",
|
||||
"windows/data",
|
||||
)
|
||||
|
||||
# 文件名级排除
|
||||
EXCLUDE_NAMES = {
|
||||
".gitee_token",
|
||||
"Thumbs.db",
|
||||
"desktop.ini",
|
||||
".DS_Store",
|
||||
}
|
||||
|
||||
# 后缀级排除
|
||||
EXCLUDE_SUFFIXES = (
|
||||
".pyc", ".pyo", ".pyd",
|
||||
".log", ".tmp", ".toc", ".pyz", ".pkg",
|
||||
".bak", ".orig",
|
||||
)
|
||||
|
||||
# 要打进去的顶层条目(只打这些,别的一律不要)
|
||||
INCLUDE_TOP = (
|
||||
"windows", "android", "relay", "installer", "docs", "tools",
|
||||
"_tests", "_build",
|
||||
"README.md", "LICENSE.md", ".gitignore",
|
||||
"diag", "dist",
|
||||
)
|
||||
|
||||
# dist 里只带上成品,别把中间目录带进去
|
||||
DIST_KEEP = (".exe", ".apk", ".zip", ".idsig", ".txt")
|
||||
|
||||
|
||||
def excluded(rel: str, path: Path) -> bool:
|
||||
rel_posix = rel.replace("\\", "/")
|
||||
for prefix in EXCLUDE_DIRS:
|
||||
if rel_posix == prefix or rel_posix.startswith(prefix + "/"):
|
||||
return True
|
||||
if path.name in EXCLUDE_NAMES:
|
||||
return True
|
||||
if path.suffix.lower() in EXCLUDE_SUFFIXES:
|
||||
return True
|
||||
# dist 目录只留成品
|
||||
if rel_posix.startswith("dist/"):
|
||||
if path.is_file() and not path.name.lower().endswith(DIST_KEEP):
|
||||
return True
|
||||
if rel_posix == "dist" and path.is_dir():
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if OUT.exists():
|
||||
OUT.unlink()
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
count = 0
|
||||
total = 0
|
||||
skipped_token = False
|
||||
|
||||
with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED,
|
||||
compresslevel=6) as archive:
|
||||
for top in INCLUDE_TOP:
|
||||
root = ROOT / top
|
||||
if not root.exists():
|
||||
print(f" [跳过] 没有 {top}")
|
||||
continue
|
||||
|
||||
if root.is_file():
|
||||
archive.write(root, f"ykxmz/{top}")
|
||||
count += 1
|
||||
total += root.stat().st_size
|
||||
continue
|
||||
|
||||
for path in sorted(root.rglob("*")):
|
||||
rel = str(path.relative_to(ROOT))
|
||||
if excluded(rel, path):
|
||||
if path.name == ".gitee_token":
|
||||
skipped_token = True
|
||||
continue
|
||||
if path.is_dir():
|
||||
continue
|
||||
archive.write(path, f"ykxmz/{rel.replace(chr(92), '/')}")
|
||||
count += 1
|
||||
total += path.stat().st_size
|
||||
|
||||
size = OUT.stat().st_size / 1024 / 1024
|
||||
print(f" ✓ {OUT.name}")
|
||||
print(f" {count} 个文件,原始 {total / 1024 / 1024:.1f} MB,"
|
||||
f"压缩后 {size:.1f} MB")
|
||||
print(f" Gitee 令牌已排除: {skipped_token or '(本来就没扫到)'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 1.7 MiB |
@@ -23,7 +23,7 @@ def _latest_apk() -> Path:
|
||||
(ROOT / "dist").glob("*watch*.apk"),
|
||||
key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return candidates[0] if candidates else \
|
||||
ROOT / "dist" / "ykxmz-watch-1.6.0.apk"
|
||||
ROOT / "dist" / "ykxmz-watch-1.7.0.apk"
|
||||
|
||||
|
||||
APK = _latest_apk()
|
||||
|
||||
@@ -26,9 +26,9 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
# 被控端 -> 一个 zip
|
||||
# 服务器端 -> 一个 zip
|
||||
# 控制端 -> 直接给 APK(不打 zip)
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.6.0.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.6.0.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.6.0.apk"
|
||||
AGENT_ZIP = ROOT / "dist" / "ykxmz-agent-1.7.0.zip"
|
||||
SERVER_ZIP = ROOT / "dist" / "ykxmz-server-1.7.0.zip"
|
||||
WATCH_APK = ROOT / "dist" / "ykxmz-watch-1.7.0.apk"
|
||||
TOOLCHAIN = Path(r"C:\Users\shu\Desktop\deepseekagent\starklink\toolchain")
|
||||
JAVAC = TOOLCHAIN / "jdk17" / "bin" / "javac.exe"
|
||||
JAVA = TOOLCHAIN / "jdk17" / "bin" / "java.exe"
|
||||
@@ -89,7 +89,7 @@ def main() -> int:
|
||||
for label, path in (("被控端", agent_dir / "README-agent.txt"),
|
||||
("服务器端", relay_dir / "README-server.txt"),
|
||||
("控制端(旁边那份)",
|
||||
ROOT / "dist" / "ykxmz-watch-1.6.0-说明.txt")):
|
||||
ROOT / "dist" / "ykxmz-watch-1.7.0-说明.txt")):
|
||||
raw = path.read_bytes() if path.is_file() else b""
|
||||
check(f"{label}说明有 UTF-8 BOM",
|
||||
raw[:3] == b"\xef\xbb\xbf", raw[:3])
|
||||
|
||||
@@ -402,7 +402,6 @@ New-Item -ItemType Directory -Force -Path $out | Out-Null
|
||||
(Join-Path $root "android\src\com\lanremote\watch\DiscoveryCore.java") `
|
||||
(Join-Path $root "android\src\com\lanremote\watch\Transport.java") `
|
||||
(Join-Path $root "android\src\com\lanremote\watch\RelayTransport.java") `
|
||||
# TlsCompat:内置根证书,修老手表连中转时的 SSLHandshakeException
|
||||
(Join-Path $root "android\src\com\lanremote\watch\TlsCompat.java") `
|
||||
(Join-Path $root "android\src\com\lanremote\watch\RemoteClient.java") `
|
||||
(Join-Path $root "_tests\java\JavaClientTest.java") 2>&1 | Out-Null
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.lanremote.watch"
|
||||
android:versionCode="5"
|
||||
android:versionName="1.6.0">
|
||||
android:versionName="1.7.0">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
@@ -27,7 +27,7 @@ $d8 = Join-Path $bt "d8.bat"
|
||||
|
||||
$work = Join-Path $android "build"
|
||||
$outDir = Join-Path $root "dist"
|
||||
$apkName = "ykxmz-watch-1.6.0.apk"
|
||||
$apkName = "ykxmz-watch-1.7.0.apk"
|
||||
|
||||
function Step($text) { Write-Host "`n=== $text ===" -ForegroundColor Cyan }
|
||||
|
||||
@@ -65,7 +65,7 @@ $unsigned = Join-Path $work "app-unsigned.apk"
|
||||
--min-sdk-version 24 `
|
||||
--target-sdk-version 34 `
|
||||
--version-code 5 `
|
||||
--version-name "1.6.0" `
|
||||
--version-name "1.7.0" `
|
||||
--auto-add-overlay
|
||||
if ($LASTEXITCODE -ne 0) { throw "aapt2 link 失败" }
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:1.6.0
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
@@ -197,7 +197,7 @@
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
1.6.0
|
||||
1.7.0
|
||||
* 跨网络支持:Cloudflare 具名隧道,固定域名
|
||||
* 中转服务器:单设备单配对码、管理页可删设备
|
||||
* 手表端:自动判断局域网/中转并无缝切换
|
||||
@@ -209,6 +209,6 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.6.0
|
||||
软件:ykxmz 1.7.0
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:1.6.0
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
|
||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||
@@ -152,6 +152,6 @@ ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.6.0
|
||||
软件:ykxmz 1.7.0
|
||||
继续安装,即表示你已阅读并同意《许可协议》。
|
||||
--------------------------------------------------------
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:1.6.0
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
生效日期:2026 年 10 月 5 日
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.6.0
|
||||
软件:ykxmz 1.7.0
|
||||
|
||||
如果你不同意本协议的任何一条,请不要安装或使用本软件。
|
||||
--------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
r"""编译 Inno Setup 安装包。
|
||||
|
||||
做三件事:
|
||||
1. 把三份文档合并成 license-combined.txt(Inno 的 LicenseFile 只认一个文件)
|
||||
2. 把 .iss 里的版本号同步成当前版本
|
||||
3. 调 ISCC.exe 编译
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(r"C:\Users\shu\Desktop\deepseekagent\LanRemote")
|
||||
INSTALLER = ROOT / "installer"
|
||||
ISS = INSTALLER / "ykxmz.iss"
|
||||
COMBINED = INSTALLER / "license-combined.txt"
|
||||
ISCC = Path(r"C:\Program Files (x86)\Inno Setup 6\ISCC.exe")
|
||||
|
||||
DOCS = ("LICENSE.txt", "BEFORE-INSTALL.txt", "AFTER-INSTALL.txt")
|
||||
|
||||
|
||||
def current_version() -> str:
|
||||
text = (ROOT / "_build" / "package_remote.py").read_text(encoding="utf-8")
|
||||
match = re.search(r'VERSION\s*=\s*"([^"]+)"', text)
|
||||
return match.group(1) if match else "0.0.0"
|
||||
|
||||
|
||||
def build_license() -> None:
|
||||
"""三份文档拼成一份,给 Inno 的许可页用。
|
||||
|
||||
每份之间加分隔线,免得读起来像一份文档。
|
||||
"""
|
||||
parts = []
|
||||
for index, name in enumerate(DOCS):
|
||||
path = ROOT / "docs" / name
|
||||
if not path.is_file():
|
||||
print(f" [警告] 缺少 {name}")
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8-sig").strip()
|
||||
if index:
|
||||
parts.append("")
|
||||
parts.append("=" * 70)
|
||||
parts.append("")
|
||||
parts.append(text)
|
||||
body = "\n".join(parts) + "\n"
|
||||
|
||||
# 装许可页的正文要 UTF-8 **带 BOM**,否则 Inno 在中文系统上会显示乱码
|
||||
COMBINED.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8"))
|
||||
print(f" 合并三份文档 -> {COMBINED.name}({len(body)} 字符)")
|
||||
|
||||
|
||||
def sync_version(version: str) -> None:
|
||||
text = ISS.read_text(encoding="utf-8")
|
||||
new = re.sub(r'#define MyAppVersion "[^"]*"',
|
||||
f'#define MyAppVersion "{version}"', text)
|
||||
if new != text:
|
||||
ISS.write_text(new, encoding="utf-8", newline="\r\n")
|
||||
print(f" .iss 版本号 -> {version}")
|
||||
else:
|
||||
print(f" .iss 版本号已经是 {version}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not ISCC.is_file():
|
||||
print(f" [失败] 找不到 Inno Setup: {ISCC}")
|
||||
return 1
|
||||
|
||||
version = current_version()
|
||||
print(f" 编译 ykxmz 安装包 v{version}")
|
||||
print()
|
||||
|
||||
build_license()
|
||||
sync_version(version)
|
||||
|
||||
agent = ROOT / "dist_exe" / "ykxmz" / "ykxmz.exe"
|
||||
if not agent.is_file():
|
||||
print(f" [失败] 被控端还没构建:{agent}")
|
||||
print(" 先跑 windows\\build_exe.ps1")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print(" 调 ISCC 编译…")
|
||||
proc = subprocess.run([str(ISCC), str(ISS)],
|
||||
capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=1800)
|
||||
out = (proc.stdout or "") + (proc.stderr or "")
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line and ("Error" in line or "error" in line
|
||||
or "Successful" in line or "compil" in line.lower()
|
||||
or "warning" in line.lower()):
|
||||
print(f" {line}")
|
||||
|
||||
if proc.returncode != 0:
|
||||
print(f" [失败] ISCC 退出码 {proc.returncode}")
|
||||
print(out[-1500:])
|
||||
return 1
|
||||
|
||||
exe = ROOT / "dist" / f"ykxmz-setup-{version}.exe"
|
||||
if not exe.is_file():
|
||||
print(f" [失败] 没生成 {exe}")
|
||||
return 1
|
||||
size = exe.stat().st_size / 1024 / 1024
|
||||
print()
|
||||
print(f" ✓ 安装包: {exe.name} {size:.2f} MB")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,533 @@
|
||||
========================================================
|
||||
ykxmz 软件许可协议
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz(远程管理工具)
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
生效日期:2026 年 10 月 5 日
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第一条 协议的范围
|
||||
--------------------------------------------------------
|
||||
|
||||
本协议是你(以下称"使用者")与作者小麦子之间就 ykxmz 软件
|
||||
(含被控端程序、中转服务器程序、手表端应用及相关文档,
|
||||
以下统称"本软件")达成的许可使用协议。
|
||||
|
||||
下载、安装、复制或以任何方式使用本软件,即表示你已阅读、
|
||||
理解并同意本协议全部条款。如不同意,请不要安装或使用。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第二条 授权范围
|
||||
--------------------------------------------------------
|
||||
|
||||
在遵守本协议的前提下,作者授予你一项免费的、非独占的、
|
||||
不可转让的使用许可,你可以:
|
||||
|
||||
1. 在**你自己拥有或已获得明确授权**的设备上安装和使用本软件;
|
||||
2. 复制并分发本软件的完整副本(须保持文件完整、不得修改、
|
||||
不得去除本协议与作者署名);
|
||||
3. 在教室、机房、家庭等场景中,用于管理**你负责管理**的设备。
|
||||
|
||||
本软件免费使用,作者不收取任何费用。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第三条 【重要】使用红线
|
||||
--------------------------------------------------------
|
||||
|
||||
本软件是一套远程控制工具,被控端可以被远程操作屏幕、文件、
|
||||
音量、电源等。正因为能力较强,以下行为**严格禁止**:
|
||||
|
||||
1. **禁止**在未经设备所有者或管理者明确同意的情况下,
|
||||
在他人的电脑、手机、平板上安装被控端;
|
||||
|
||||
2. **禁止**用于偷窥、跟踪、监视他人(包括配偶、家人、
|
||||
同事、同学、下属)的隐私;
|
||||
|
||||
3. **禁止**用于窃取账号密码、聊天记录、照片、文件等
|
||||
任何个人信息;
|
||||
|
||||
4. **禁止**将本软件伪装成系统组件、隐藏其存在、
|
||||
故意使设备使用者无法发现或无法卸载;
|
||||
|
||||
5. **禁止**用于任何违反所在地法律法规的活动。
|
||||
|
||||
**一句话:只能管你自己的设备,或者别人明确同意你管的设备。**
|
||||
|
||||
违反上述任何一条,本协议立即终止,且由此产生的全部法律
|
||||
责任由使用者自行承担,与作者无关。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第四条 使用者应尽的通知义务
|
||||
--------------------------------------------------------
|
||||
|
||||
如果你在被控端所在的设备上使用本软件,你有义务确保:
|
||||
|
||||
1. 该设备的使用者知道这台设备可以被远程管理;
|
||||
2. 该设备的使用者知道如何查看、关闭或卸载本软件;
|
||||
3. 被控端保持"连接时可见提示"功能开启(默认开启)。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第五条 隐私说明
|
||||
--------------------------------------------------------
|
||||
|
||||
本软件的设计原则是**数据不经过作者的服务器**:
|
||||
|
||||
1. 被控端与手表/控制端之间的所有数据(画面、文件、剪贴板、
|
||||
语音等)通过你自己部署的中转服务器或局域网直连传输,
|
||||
作者**不会**收到、存储或分析任何内容;
|
||||
|
||||
2. 中转服务器只转发加密连接中的字节流,不解析内容;
|
||||
它记录的信息仅限于:设备名称、配对码、在线状态、
|
||||
流量大小、连接时间——用于你在管理页上查看自己的设备;
|
||||
|
||||
3. 中转服务器部署在**你自己的设备**上,数据存在你自己的
|
||||
硬盘里,作者无法访问;
|
||||
|
||||
4. 本软件不包含任何统计上报、行为采集、广告推送代码。
|
||||
|
||||
关于本软件需要使用的权限(网络访问、屏幕读取、文件读写等),
|
||||
详见《安装前必读》。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第六条 无担保声明
|
||||
--------------------------------------------------------
|
||||
|
||||
本软件按"现状"提供,作者不对以下事项作出任何明示或默示的
|
||||
担保,包括但不限于:
|
||||
|
||||
1. 软件无错误、不中断、能满足你的特定用途;
|
||||
2. 与你的操作系统、杀毒软件、网络环境完全兼容;
|
||||
3. 因网络运营商限制、Cloudflare 服务调整、系统更新等
|
||||
外部原因导致的服务不可用。
|
||||
|
||||
**使用本软件的风险由你自行承担。**
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第七条 责任限制
|
||||
--------------------------------------------------------
|
||||
|
||||
在适用法律允许的最大范围内,作者不对因使用或无法使用本软件
|
||||
而产生的任何直接、间接、偶然、特殊或后果性损失负责,包括
|
||||
但不限于数据丢失、业务中断、设备损坏、利润损失。
|
||||
|
||||
若适用法律不允许完全排除责任,作者承担的责任总额不超过
|
||||
你为获得本软件所支付的费用(本软件免费,故为零)。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第八条 协议终止
|
||||
--------------------------------------------------------
|
||||
|
||||
出现下列情形之一,本协议自动终止:
|
||||
|
||||
1. 你违反本协议任何条款;
|
||||
2. 你将本软件用于第三条约定的禁止用途。
|
||||
|
||||
协议终止后,你应当立即停止使用并卸载本软件。第二条授予的
|
||||
分发许可同时失效。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
第九条 其他
|
||||
--------------------------------------------------------
|
||||
|
||||
1. 本协议适用中华人民共和国法律;
|
||||
2. 因本协议产生的争议,双方应友好协商解决;协商不成的,
|
||||
提交作者所在地有管辖权的人民法院;
|
||||
3. 作者保留在后续版本中调整本协议的权利,调整后的协议
|
||||
随新版本一同发布;继续使用新版本即视为接受新协议;
|
||||
4. 本协议部分条款无效,不影响其余条款的效力。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.7.0
|
||||
|
||||
如果你不同意本协议的任何一条,请不要安装或使用本软件。
|
||||
--------------------------------------------------------
|
||||
|
||||
======================================================================
|
||||
|
||||
========================================================
|
||||
ykxmz 安装前必读(前信息文件)
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
|
||||
请你**在安装之前**花三分钟读完这一页。里面有几条很要紧的
|
||||
提醒,尤其是第三节和第五节。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
一、这是什么
|
||||
--------------------------------------------------------
|
||||
|
||||
ykxmz 是一套自己搭建的远程管理工具,由三部分组成:
|
||||
|
||||
1-agent(被控端) 装在你**要管理**的电脑上(Windows)
|
||||
2-server(服务器端)装在一台能上外网的 Linux 小机器上
|
||||
(随身 WiFi、树莓派、VPS 都行)
|
||||
3-watch(控制端) 装在你的安卓手表上
|
||||
|
||||
装好之后,你在手表上就能看到并操作那台电脑:屏幕、文件、
|
||||
音量、电源、剪贴板……共两百多个功能。
|
||||
|
||||
**关键点:所有数据都走你自己搭的那台服务器,
|
||||
作者碰不到你的任何内容。** 详见《许可协议》第五条。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
二、装机前对照检查
|
||||
--------------------------------------------------------
|
||||
|
||||
请先确认:
|
||||
|
||||
[ ] 这台电脑是**你自己的**,或者设备主人明确同意你远程管理
|
||||
[ ] 电脑是 Windows 10 / 11(64 位)
|
||||
[ ] 手表是安卓 9.0(API 28)或更高版本
|
||||
[ ] 服务器端那台机器是 Linux(Debian 12 实测通过),
|
||||
能上外网,内存 512MB 够用
|
||||
[ ] 你有一个 Cloudflare 账号和一个域名(跨网络才需要)
|
||||
|
||||
**不需要**装 Python、不需要 root、不需要 ADB。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
三、【重要】三条必须知道的事
|
||||
--------------------------------------------------------
|
||||
|
||||
① 被控端默认是"静默"运行的
|
||||
|
||||
装完之后**没有窗口、没有托盘图标、没有终端**,
|
||||
任务管理器里能看到一个叫 ykxmz.exe 的进程,
|
||||
开机自启项里也显示 ykxmz。
|
||||
|
||||
这么做是为了不打扰用电脑的人,**不是**为了藏起来。
|
||||
如果你要在这台电脑上装给别人用,你有义务告诉对方:
|
||||
|
||||
怎么查看(任务管理器 -> 详细信息 -> ykxmz.exe)
|
||||
怎么关闭(任务管理器 -> 结束任务)
|
||||
怎么卸载(ykxmz.exe --uninstall)
|
||||
|
||||
② 配对码是唯一的口令,请设一个别人猜不到的
|
||||
|
||||
配对码是 6 位数字,**它就是你电脑的钥匙**。
|
||||
不要用 123456、000000、生日、手机号后六位这类。
|
||||
|
||||
一个配对码同时只能绑定一台电脑。如果别人先用了你想要的码,
|
||||
后装的这台会被拒绝,并在屏幕上弹出提示:
|
||||
「该配对码已被使用,请换一个配对码」
|
||||
换个没人用的码就行。
|
||||
|
||||
③ 连接时屏幕上有提示
|
||||
|
||||
只要手表或控制端连上来,被控端会在屏幕右下角弹一个提示。
|
||||
这是**故意保留**的功能,请不要关掉它——
|
||||
它保证了设备使用者知道"现在有人在看这台电脑"。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
四、安装顺序
|
||||
--------------------------------------------------------
|
||||
|
||||
按这个顺序来,别跳:
|
||||
|
||||
第 1 步 架服务器端(只做一次)
|
||||
把 2-server 整个目录拷到随身 WiFi 上,执行:
|
||||
bash install.sh
|
||||
它会装好中转服务 + Cloudflare 隧道,并打印一个
|
||||
公网地址。
|
||||
|
||||
第 2 步 装被控端(每台要管理的电脑做一次)
|
||||
双击 ykxmz.exe -> 输 6 位配对码 -> 勾上开机自启和
|
||||
放行防火墙 -> 确定。会弹一次管理员确认。
|
||||
|
||||
第 3 步 装控制端
|
||||
把 3-watch 里的 APK 装到手表上。装完打开就能用,
|
||||
地址已经内置好了。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
五、会改你系统的哪些东西
|
||||
--------------------------------------------------------
|
||||
|
||||
安装和使用过程中,被控端会做这些改动,**请你知情**:
|
||||
|
||||
1. 写一条开机自启项
|
||||
位置:HKEY_CURRENT_USER\...\CurrentVersion\Run
|
||||
名称:ykxmz
|
||||
内容:"...\ykxmz.exe" --silent
|
||||
|
||||
2. 添加 Windows 防火墙放行规则
|
||||
规则名:LAN Remote TCP / LAN Remote UDP / LAN Remote App TCP
|
||||
/ LAN Remote App UDP
|
||||
作用:允许 8765(TCP)和 8766(UDP)端口通信
|
||||
|
||||
3. 在程序目录下建一个 data 子目录
|
||||
里面存:配对码、已配对设备、设置
|
||||
卸载时会一并删除
|
||||
|
||||
4. 说明:部分功能(关机、改注册表、隐藏任务栏等)
|
||||
需要管理员权限,会弹 UAC 确认,**不会**偷偷提权。
|
||||
|
||||
**以上全部可撤销**:卸载时自启项和防火墙规则会一起清掉。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
六、杀毒软件可能报警
|
||||
--------------------------------------------------------
|
||||
|
||||
本软件没有购买代码签名证书(那是要花钱的),
|
||||
加上它确实具备"远程控制 + 开机自启"这类能力,
|
||||
所以**部分杀毒软件可能会报可疑**。
|
||||
|
||||
这是误报。你可以:
|
||||
1. 在杀毒软件里把 ykxmz 整个目录加入信任/排除项;
|
||||
2. 或者先看完《许可协议》,确认用途正当后再决定是否放行。
|
||||
|
||||
**作者不会、也没有办法让本软件绕过杀毒软件。**
|
||||
如果你需要"绝对不被杀软发现",那本软件不适合你。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
七、接下来看什么
|
||||
--------------------------------------------------------
|
||||
|
||||
装完之后请看《安装后须知》(AFTER-INSTALL.txt),
|
||||
里面有:怎么确认它在正常运行、怎么换配对码、
|
||||
怎么彻底卸载、连不上怎么排查。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.7.0
|
||||
继续安装,即表示你已阅读并同意《许可协议》。
|
||||
--------------------------------------------------------
|
||||
|
||||
======================================================================
|
||||
|
||||
========================================================
|
||||
ykxmz 安装后须知(后信息文件)
|
||||
========================================================
|
||||
|
||||
软件名称:ykxmz
|
||||
版本:1.7.0
|
||||
作者:小麦子
|
||||
|
||||
装好之后看这一页:怎么确认它在跑、怎么改设置、
|
||||
怎么彻底卸载、出问题怎么查。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
一、确认它真的在运行
|
||||
--------------------------------------------------------
|
||||
|
||||
被控端装好后**看不到任何窗口**,这是正常的。想确认它在跑:
|
||||
|
||||
方法一(最直观)
|
||||
Ctrl+Shift+Esc 打开任务管理器
|
||||
-> "详细信息"标签 -> 找 ykxmz.exe
|
||||
有,就是在跑
|
||||
|
||||
方法二(看端口)
|
||||
命令行执行: netstat -ano | findstr 8765
|
||||
有 LISTENING,就是正常
|
||||
|
||||
方法三(问程序自己)
|
||||
命令行执行: ykxmz.exe --status
|
||||
会打印:配对码、端口、中转连接状态、已配对设备数
|
||||
|
||||
方法四(从手表看)
|
||||
手表上点"自动连接",能连上就说明在跑
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
二、常用操作
|
||||
--------------------------------------------------------
|
||||
|
||||
重新打开设置窗口
|
||||
ykxmz.exe --setup
|
||||
|
||||
换一个配对码
|
||||
在设置窗口里改,或者:
|
||||
ykxmz.exe --new-code (随机生成一个)
|
||||
换完之后,手表那边要用新码重连
|
||||
|
||||
看看当前状态
|
||||
ykxmz.exe --status
|
||||
|
||||
临时允许新设备配对(默认关着)
|
||||
ykxmz.exe --add-device 10 (开放 10 分钟)
|
||||
过了时间自动关回去
|
||||
|
||||
关掉/打开开机自启
|
||||
ykxmz.exe --autostart off
|
||||
ykxmz.exe --autostart on
|
||||
|
||||
网络自检(连不上时先跑这个)
|
||||
双击 wlzj.bat
|
||||
或 ykxmz.exe --netcheck 90
|
||||
|
||||
修复防火墙(要管理员)
|
||||
右键 xffhq.bat -> 以管理员身份运行
|
||||
或 ykxmz.exe --fix-firewall
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
三、彻底卸载
|
||||
--------------------------------------------------------
|
||||
|
||||
一条命令清干净:
|
||||
|
||||
ykxmz.exe --uninstall
|
||||
|
||||
它会依次:
|
||||
1. 停掉正在跑的进程
|
||||
2. 删掉开机自启项
|
||||
3. 删掉数据目录(配对码、已配对手表全没了)
|
||||
4. 安排删除程序目录本身(退出后几秒自动删)
|
||||
|
||||
**想保留数据**(比如以后还要用,只是换个位置装):
|
||||
先手动把 data 目录复制出来,再卸载。
|
||||
|
||||
**手动卸载也可以**(如果程序已经删了):
|
||||
1. 任务管理器结束 ykxmz.exe
|
||||
2. 删除注册表项
|
||||
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
|
||||
下的 ykxmz
|
||||
3. 删除整个程序目录
|
||||
4. (可选)删除防火墙规则:netsh advfirewall firewall
|
||||
delete rule name="LAN Remote TCP"
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
四、连不上怎么查
|
||||
--------------------------------------------------------
|
||||
|
||||
按顺序排查,绝大多数问题在前三步就解决了:
|
||||
|
||||
第 1 步 被控端在跑吗?
|
||||
任务管理器里有没有 ykxmz.exe
|
||||
没有 -> ykxmz.exe --setup
|
||||
|
||||
第 2 步 防火墙放行了吗?
|
||||
右键 xffhq.bat -> 以管理员身份运行
|
||||
|
||||
第 3 步 网络通不通?
|
||||
双击 wlzj.bat 做自检
|
||||
它会生成一份《网络自检结果.txt》
|
||||
|
||||
第 4 步 跨网络的话,中转通不通?
|
||||
浏览器打开 http://<随身WiFi的IP>:8080/url
|
||||
看它有没有返回公网地址
|
||||
|
||||
第 5 步 手表上点"连不上?点这里诊断"
|
||||
|
||||
常见现象对照:
|
||||
|
||||
"连接失败,配对码错误"
|
||||
-> 手表上的码和电脑上的不一样,对一下
|
||||
|
||||
"该配对码已被使用,请换一个配对码"
|
||||
-> 这个码被另一台电脑占了。换一个,
|
||||
或者去中转管理页把占用它的那台设备删掉
|
||||
|
||||
"设备不在线"
|
||||
-> 电脑上的程序没跑,或者网络断了
|
||||
|
||||
"连不上中转服务器"
|
||||
-> 随身 WiFi 那台机器上的中转没起来,
|
||||
或者隧道挂了。SSH 上去执行 lanremote-status 看看
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
五、服务器端(随身 WiFi)日常维护
|
||||
--------------------------------------------------------
|
||||
|
||||
三个服务都是开机自启,正常情况下不用管:
|
||||
|
||||
lanremote-relay 中转服务
|
||||
lanremote-tunnel Cloudflare 隧道
|
||||
lanremote-watchdog.timer 每 30 秒检查一次隧道连接
|
||||
|
||||
想一眼看清状态:
|
||||
lanremote-status
|
||||
|
||||
常用命令:
|
||||
lanremote-url 打印当前公网地址
|
||||
lanremote-key 打印管理页钥匙
|
||||
systemctl restart lanremote-tunnel 手动换个地址
|
||||
journalctl -u lanremote-relay -f 看实时日志
|
||||
|
||||
管理页:
|
||||
浏览器打开 <公网地址>,用管理密码登录(默认 120830)
|
||||
能看:设备列表、在线状态、配对码、流量、会话数
|
||||
能做:删除设备(连配对码一起清)、踢会话、查看当前地址
|
||||
|
||||
忘了公网地址?
|
||||
打开 http://<随身WiFi的IP>:8080/url —— 直接返回当前地址,
|
||||
不用登录。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
六、已知限制
|
||||
--------------------------------------------------------
|
||||
|
||||
1. 一条隧道同时只服务一台手表
|
||||
新连上来的会顶掉旧的,旧的那台会提示"已被另一台手表接管"。
|
||||
|
||||
2. 跨网络时的延迟
|
||||
经 Cloudflare 隧道走一圈,一来一回约 0.6 秒;
|
||||
局域网直连只有几十毫秒。**程序和手表会自动判断**:
|
||||
发现两边在同一个网络就切直连,断了自动切回中转。
|
||||
|
||||
3. 网络环境的影响
|
||||
如果随身 WiFi 用的 4G 卡对 UDP 有限制,隧道可能偶尔抖。
|
||||
程序里有看门狗会自动重启隧道,通常 1 分钟内恢复。
|
||||
|
||||
4. 杀毒软件误报
|
||||
见《安装前必读》第六节。作者不提供绕过杀软的功能。
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
七、出问题时怎么反馈
|
||||
--------------------------------------------------------
|
||||
|
||||
请带上这几样,作者才能定位:
|
||||
|
||||
1. ykxmz.exe --status 的输出
|
||||
2. wlzj.bat 生成的《网络自检结果.txt》
|
||||
3. 手表上的诊断结果(手表首页"连不上?点这里诊断")
|
||||
4. 问题什么时候开始的、之前做过什么
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
八、版本记录
|
||||
--------------------------------------------------------
|
||||
|
||||
1.7.0
|
||||
* 跨网络支持:Cloudflare 具名隧道,固定域名
|
||||
* 中转服务器:单设备单配对码、管理页可删设备
|
||||
* 手表端:自动判断局域网/中转并无缝切换
|
||||
* 被控端:所有开关和画面功能支持回退/撤销
|
||||
* 配对码被占用时弹通知提示
|
||||
* 新增 --uninstall 一键卸载
|
||||
* 针对 512MB 内存的小机器做了优化
|
||||
|
||||
|
||||
--------------------------------------------------------
|
||||
作者:小麦子
|
||||
软件:ykxmz 1.7.0
|
||||
许可条款见《许可协议》(LICENSE.txt)
|
||||
--------------------------------------------------------
|
||||
@@ -0,0 +1,255 @@
|
||||
; ============================================================================
|
||||
; ykxmz 教室电脑手表遥控 · 被控端安装包
|
||||
;
|
||||
; 用 Inno Setup 6 编译:
|
||||
; "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" installer\ykxmz.iss
|
||||
;
|
||||
; ── 设计说明 ──
|
||||
;
|
||||
; 尽量用 Inno 自带的机制,不手搓:
|
||||
; * 许可协议页 -> LicenseFile(三份文档合并成一份,点"我接受"才能继续)
|
||||
; * 开机自启 -> [Tasks](标准勾选框)
|
||||
; * 装完启动 -> [Run] postinstall(标准勾选框)
|
||||
; * 安装目录 -> DisableDirPage=no(让用户自己选,默认 Program Files)
|
||||
; * 卸载 -> [UninstallRun] 调被控端自己的 --uninstall
|
||||
; 只有"填 6 位配对码"这一页 Inno 没有现成的,用标准的
|
||||
; CreateInputQueryPage —— 那是 Inno 的官方 API,不算自己造轮子。
|
||||
;
|
||||
; ── 用户确定的设置 ──
|
||||
; 安装位置 默认 C:\Program Files\ykxmz,安装时可改
|
||||
; 数据目录 能写 exe 旁就写,不能就转 ProgramData(程序内部处理)
|
||||
; 三份文档 放安装包里,统一接受才能下一步
|
||||
; 配对码 安装向导里填 6 位
|
||||
; 快捷方式 不建开始菜单、不建桌面
|
||||
; 开机自启 默认勾上
|
||||
; 装完启动 立即静默启动
|
||||
; 卸载 一并删掉,什么都不留
|
||||
; ============================================================================
|
||||
|
||||
#define MyAppName "ykxmz 教室电脑手表遥控"
|
||||
#define MyAppVersion "1.7.0"
|
||||
#define MyAppPublisher "小麦子"
|
||||
#define MyAppExeName "ykxmz.exe"
|
||||
|
||||
[Setup]
|
||||
AppId={{8F3C1D42-7A5E-4B91-9C6D-2E8F4A1B7C30}
|
||||
AppName={#MyAppName}
|
||||
AppVersion={#MyAppVersion}
|
||||
AppVerName={#MyAppName} {#MyAppVersion}
|
||||
AppPublisher={#MyAppPublisher}
|
||||
VersionInfoVersion={#MyAppVersion}
|
||||
|
||||
; 默认装到 Program Files\ykxmz,但安装向导里有一页让用户自己改
|
||||
DefaultDirName={autopf}\ykxmz
|
||||
DisableDirPage=no
|
||||
|
||||
; 三份文档合并成一份许可页:看完点「我接受」才能继续
|
||||
LicenseFile=license-combined.txt
|
||||
|
||||
; 不建开始菜单组(用户明确不要)
|
||||
DisableProgramGroupPage=yes
|
||||
AllowNoIcons=yes
|
||||
|
||||
; 装到 Program Files 和改防火墙都要管理员
|
||||
PrivilegesRequired=admin
|
||||
ArchitecturesAllowed=x64compatible
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
|
||||
OutputDir=..\dist
|
||||
OutputBaseFilename=ykxmz-setup-{#MyAppVersion}
|
||||
SetupIconFile=..\windows\app.ico
|
||||
UninstallDisplayIcon={app}\{#MyAppExeName}
|
||||
UninstallDisplayName={#MyAppName}
|
||||
|
||||
Compression=lzma2/max
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
ShowLanguageDialog=no
|
||||
DisableWelcomePage=no
|
||||
|
||||
[Languages]
|
||||
Name: "chinese"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
; 用户要的:默认勾上开机自启(取消勾选也允许)
|
||||
Name: "autostart"; Description: "开机自动启动(推荐给教室电脑)"; \
|
||||
GroupDescription: "启动选项:"; Flags: checkedonce
|
||||
|
||||
[Files]
|
||||
; 被控端整个目录(ykxmz.exe + _internal)
|
||||
Source: "..\dist_exe\ykxmz\*"; DestDir: "{app}"; \
|
||||
Flags: recursesubdirs createallsubdirs ignoreversion
|
||||
|
||||
; 三份文档也放一份到安装目录,装完还能翻
|
||||
Source: "..\docs\LICENSE.txt"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||
Source: "..\docs\BEFORE-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||
Source: "..\docs\AFTER-INSTALL.txt"; DestDir: "{app}\docs"; Flags: ignoreversion
|
||||
|
||||
[Dirs]
|
||||
; ⚠ 这一节很关键 —— 不配的话装完一运行就报 WinError 5 拒绝访问。
|
||||
;
|
||||
; 被控端的配置存在 %ProgramData%\ykxmz\data 下(exe 旁边写不进去时的回退)。
|
||||
; 安装包是管理员跑的,它建的目录默认只有管理员能改;
|
||||
; 之后普通用户(老师的账号)跑被控端时:
|
||||
; 能新建文件,但覆盖不了管理员建的那份 settings.json
|
||||
; 结果就是"装完打开报权限错误"。
|
||||
;
|
||||
; Permissions: users-modify 把所有用户的写权限打开,才是真的能用。
|
||||
; 注意末尾不带反斜杠,否则 Inno 会给它单独建一层同名子目录。
|
||||
Name: "{commonappdata}\ykxmz"; Permissions: users-modify
|
||||
Name: "{commonappdata}\ykxmz\data"; Permissions: users-modify
|
||||
|
||||
[InstallDelete]
|
||||
; 早期版本把配置写在程序目录下的 data\ 里。
|
||||
; 现在数据统一放 ProgramData(见 config.py 的 data_dir),
|
||||
; 这里把旧的那份清掉 —— 留着的话,两份配置并存,
|
||||
; 以后排查问题会被误导("我明明改了配对码怎么没变")。
|
||||
Type: filesandordirs; Name: "{app}\data"
|
||||
|
||||
[Run]
|
||||
; 装完立即静默启动(标准 postinstall 勾选框,默认勾上)
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--silent"; \
|
||||
Description: "立即启动(后台运行,不显示窗口)"; \
|
||||
Flags: nowait postinstall skipifsilent
|
||||
|
||||
; 让用户看一眼装完须知
|
||||
Filename: "{app}\docs\AFTER-INSTALL.txt"; \
|
||||
Description: "打开「安装后须知」"; \
|
||||
Flags: postinstall shellexec unchecked skipifsilent
|
||||
|
||||
[UninstallRun]
|
||||
; 卸载前先让被控端自己收尾:停进程、删开机自启、清数据目录。
|
||||
; 这样"卸载后什么都不留"才是真的干净。
|
||||
Filename: "{app}\{#MyAppExeName}"; Parameters: "--uninstall"; \
|
||||
Flags: runhidden waituntilterminated; RunOnceId: "StopAgent"
|
||||
|
||||
[UninstallDelete]
|
||||
; 被控端会自己删数据目录,这里兜底再清一遍安装目录里的残留
|
||||
Type: filesandordirs; Name: "{app}\data"
|
||||
Type: filesandordirs; Name: "{app}\docs"
|
||||
Type: filesandordirs; Name: "{app}\_internal"
|
||||
|
||||
[Code]
|
||||
var
|
||||
CodePage: TInputQueryWizardPage;
|
||||
ForcedCode: String;
|
||||
|
||||
{ 把字符串里的非数字滤掉。 }
|
||||
function OnlyDigits(const raw: String): String;
|
||||
var
|
||||
i: Integer;
|
||||
ch: Char;
|
||||
begin
|
||||
Result := '';
|
||||
for i := 1 to Length(raw) do
|
||||
begin
|
||||
ch := raw[i];
|
||||
if (ch >= '0') and (ch <= '9') then
|
||||
Result := Result + ch;
|
||||
end;
|
||||
end;
|
||||
|
||||
{ 配对码那一页。Inno 没有现成的输入页,用官方的 CreateInputQueryPage。 }
|
||||
procedure InitializeWizard();
|
||||
begin
|
||||
{ 静默部署可以带 /CODE=123456 直接把码传进来(电教批量装用这个)。
|
||||
注意:Inno 的 [Code] 段连**注释里的花括号**都会当常量去解析,
|
||||
所以这里不能直接写 param 那种写法,否则编译器报语法错误。 }
|
||||
ForcedCode := OnlyDigits(ExpandConstant('{param:CODE|}'));
|
||||
|
||||
CodePage := CreateInputQueryPage(wpSelectTasks,
|
||||
'配对码',
|
||||
'给这台电脑定一个 6 位配对码',
|
||||
'手表上要输这一串数字才能连上这台电脑。自己定个好记的,' +
|
||||
'比如 789113。装完之后也可以在被控端窗口里改。');
|
||||
CodePage.Add('6 位配对码:', False);
|
||||
if ForcedCode <> '' then
|
||||
CodePage.Values[0] := ForcedCode;
|
||||
end;
|
||||
|
||||
{ 校验:必须是 6 位数字。
|
||||
注意 —— **静默模式下这一页不会显示**,值是空的,
|
||||
这时候绝不能拦(否则 /VERYSILENT 安装必然失败,我第一版就栽在这)。 }
|
||||
function NextButtonClick(CurPageID: Integer): Boolean;
|
||||
var
|
||||
raw: String;
|
||||
digits: String;
|
||||
begin
|
||||
Result := True;
|
||||
if CurPageID <> CodePage.ID then
|
||||
Exit;
|
||||
if WizardSilent then
|
||||
Exit;
|
||||
|
||||
raw := CodePage.Values[0];
|
||||
digits := OnlyDigits(raw);
|
||||
if (Length(digits) <> 6) and (raw <> '') then
|
||||
begin
|
||||
MsgBox('配对码要 6 位数字。' + #13#10 + #13#10 +
|
||||
'你现在填的是:' + raw + #13#10 +
|
||||
'(过滤掉非数字后剩 ' + IntToStr(Length(digits)) + ' 位)' + #13#10 + #13#10 +
|
||||
'重新填一下,或者直接点「取消」退出安装。',
|
||||
mbError, MB_OK);
|
||||
Result := False;
|
||||
Exit;
|
||||
end;
|
||||
{ 规范化,后面 [Run] 阶段用得到 }
|
||||
CodePage.Values[0] := digits;
|
||||
end;
|
||||
|
||||
{ 最终用的配对码:命令行 /CODE 优先,其次是向导里填的。
|
||||
两个都没有就返回空串 —— 那就跳过 --code,让被控端自己生成一个。 }
|
||||
function EffectiveCode(): String;
|
||||
begin
|
||||
Result := ForcedCode;
|
||||
if Result = '' then
|
||||
Result := OnlyDigits(CodePage.Values[0]);
|
||||
if Length(Result) <> 6 then
|
||||
Result := '';
|
||||
end;
|
||||
|
||||
{ 把向导里收集的东西交给被控端。
|
||||
注意这里**不能带 --silent** —— 那是常驻守护进程,永远不返回,
|
||||
用 ewWaitUntilTerminated 等它会把安装程序挂死(第一版就栽在这)。
|
||||
写配置和启动服务分成两次调用:
|
||||
--code / --autostart 一次性命令,秒退
|
||||
--silent 常驻,交给 [Run] 段用 nowait 启动 }
|
||||
procedure CurStepChanged(CurStep: TSetupStep);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
Params: String;
|
||||
Code: String;
|
||||
begin
|
||||
if CurStep <> ssPostInstall then
|
||||
Exit;
|
||||
|
||||
Code := EffectiveCode();
|
||||
if Code <> '' then
|
||||
begin
|
||||
Params := '--code ' + Code;
|
||||
if not Exec(ExpandConstant('{app}\{#MyAppExeName}'), Params,
|
||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated,
|
||||
ResultCode) then
|
||||
begin
|
||||
if not WizardSilent then
|
||||
MsgBox('配对码写入失败(错误码 ' + IntToStr(ResultCode) + ')。' + #13#10 +
|
||||
'装完之后双击程序图标,重新设一次也行。',
|
||||
mbInformation, MB_OK);
|
||||
end;
|
||||
end;
|
||||
|
||||
{ 开机自启:单独调一次,也是一次性命令 }
|
||||
if WizardIsTaskSelected('autostart') then
|
||||
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--autostart on',
|
||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
|
||||
{ 卸载前先停掉正在跑的进程,不然文件删不干净。 }
|
||||
function InitializeUninstall(): Boolean;
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Result := True;
|
||||
Exec(ExpandConstant('{app}\{#MyAppExeName}'), '--uninstall',
|
||||
ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
+26
-1
@@ -58,7 +58,7 @@ from collections import deque
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "1.6.0"
|
||||
VERSION = "1.7.0"
|
||||
|
||||
# ---------------- 参数(都能用环境变量覆盖)----------------
|
||||
|
||||
@@ -778,6 +778,8 @@ def _login_page(message: str = "") -> bytes:
|
||||
notice = ('<p class="bad">' + message + '</p>') if message else ''
|
||||
html = """<!doctype html><html lang=zh><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1">
|
||||
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||
<link rel="apple-touch-icon" href="/favicon.ico">
|
||||
<title>中转服务器 · 登录</title>
|
||||
<style>
|
||||
body{font:14px/1.6 -apple-system,'Microsoft YaHei',sans-serif;
|
||||
@@ -994,6 +996,29 @@ class Handler(BaseHTTPRequestHandler):
|
||||
})
|
||||
return
|
||||
|
||||
# ---- 网站图标 ----
|
||||
if path in ("/favicon.ico", "/apple-touch-icon.png"):
|
||||
icon = Path(__file__).resolve().parent / "favicon.ico"
|
||||
if icon.is_file():
|
||||
try:
|
||||
blob = icon.read_bytes()
|
||||
except OSError:
|
||||
blob = b""
|
||||
if blob:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "image/x-icon")
|
||||
self.send_header("Content-Length", str(len(blob)))
|
||||
# 图标不常变,让浏览器缓存一天
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.end_headers()
|
||||
self.wfile.write(blob)
|
||||
return
|
||||
# 没有图标文件就 404,别让请求打到后面的逻辑
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# ---- 成品下载(挂在管理页上给人下 APK / 整包)----
|
||||
if path == "/v1/downloads":
|
||||
self._json(200, {"ok": True, "files": _list_downloads()})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""局域网遥控 · Windows 被控端。"""
|
||||
|
||||
__version__ = "1.6.0"
|
||||
__version__ = "1.7.0"
|
||||
APP_NAME = "局域网遥控 被控端"
|
||||
|
||||
+159
-4
@@ -105,10 +105,57 @@ class Store:
|
||||
|
||||
@staticmethod
|
||||
def _write(path: Path, data) -> None:
|
||||
"""写 JSON。优先"临时文件 + 原子替换",失败就降级。
|
||||
|
||||
为什么要降级:
|
||||
装到 Program Files / ProgramData 时,配置是**管理员**建的那一份,
|
||||
属主是管理员。普通用户跑的时候:
|
||||
能新建文件,但不能覆盖/改名别人建的文件。
|
||||
于是 os.replace 报 WinError 5。
|
||||
这时候退成"直接写目标文件" —— 牺牲原子性,换能存下配置。
|
||||
"""
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, path)
|
||||
payload = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
|
||||
# 1) 正常路径:临时文件 + 原子替换
|
||||
try:
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
os.replace(tmp, path)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2) 降级:直接覆盖目标文件
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
Store._cleanup(tmp)
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3) 再降级:目标被设成只读就摘掉只读位再写
|
||||
try:
|
||||
os.chmod(path, 0o666)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(payload)
|
||||
Store._cleanup(tmp)
|
||||
return
|
||||
except OSError:
|
||||
Store._cleanup(tmp)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _cleanup(tmp: Path) -> None:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
with self._lock:
|
||||
@@ -217,5 +264,113 @@ def app_dir() -> Path:
|
||||
return Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# 数据目录只算一次,之后缓存 —— 每个命令都去试写一次太浪费
|
||||
_DATA_DIR_CACHE: Path | None = None
|
||||
|
||||
|
||||
def _can_write(folder: Path) -> bool:
|
||||
"""这个目录能不能**真正**用来存配置。
|
||||
|
||||
注意不能只测"能新建文件" —— 那只过一半。
|
||||
实际写配置用的是"临时文件 + 原子替换"(os.replace),
|
||||
它需要**覆盖已存在文件**的权限。
|
||||
装到 ProgramData 时,管理员建的那份普通用户改不了,
|
||||
只测新建会误判成可写,然后跑起来才报 WinError 5。
|
||||
所以这里把整个周期都跑一遍:建 -> 改名覆盖 -> 删。
|
||||
"""
|
||||
try:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
probe = folder / ".write-test"
|
||||
probe.write_text("x", encoding="utf-8")
|
||||
|
||||
# 关键:模拟真实写入的"替换"这一步
|
||||
target = folder / ".write-test-target"
|
||||
try:
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
probe.write_text("a", encoding="utf-8")
|
||||
os.replace(probe, target) # <- 真正的门槛在这
|
||||
target.write_text("b", encoding="utf-8") # 覆盖已存在的
|
||||
target.unlink()
|
||||
except OSError:
|
||||
# 建得了新文件但替换不了 -> 不能当数据目录
|
||||
try:
|
||||
probe.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_installed_copy() -> bool:
|
||||
"""程序是不是装在 Program Files 下(也就是"安装版")。
|
||||
|
||||
这个判断只跟**程序装在哪**有关,跟"当前是谁在跑"无关 ——
|
||||
这正是关键:数据目录必须对所有用户是同一个,
|
||||
否则管理员装的配置普通用户看不见(实测踩过)。
|
||||
"""
|
||||
try:
|
||||
here = str(app_dir()).lower().replace("/", "\\")
|
||||
except OSError:
|
||||
return False
|
||||
for name in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
|
||||
base = os.environ.get(name)
|
||||
if not base:
|
||||
continue
|
||||
prefix = str(base).lower().replace("/", "\\").rstrip("\\")
|
||||
if here == prefix or here.startswith(prefix + "\\"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
return app_dir() / "data"
|
||||
"""配置 / 令牌 / 接收文件的存放目录。
|
||||
|
||||
优先级:
|
||||
1. 环境变量 YKXMZ_DATA(测试和特殊部署用)
|
||||
2. 程序目录下的 data/ —— 绿色版、便携 U 盘,拔走就带走配置
|
||||
3. %PROGRAMDATA%\ykxmz\data —— 装到 Program Files 时用这个
|
||||
|
||||
为什么要有第 3 条:
|
||||
安装包默认装到 C:\Program Files\ykxmz,
|
||||
普通用户对那里**没有写权限**(Windows 的硬规矩,不是 bug)。
|
||||
不转走的话,配对码和令牌会存不下去,用户会莫名其妙连不上。
|
||||
ProgramData 是所有用户可写的公共位置,换 Windows 账号也不丢配置。
|
||||
"""
|
||||
global _DATA_DIR_CACHE
|
||||
if _DATA_DIR_CACHE is not None:
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
override = os.environ.get("YKXMZ_DATA")
|
||||
if override:
|
||||
_DATA_DIR_CACHE = Path(override)
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# ── 安装版(装在 Program Files)一律用 ProgramData ──
|
||||
# 不能按"我能不能写 exe 旁边"来判断:
|
||||
# 安装时是管理员跑的,写得进去;之后普通用户跑就写不进去,
|
||||
# 于是悄悄换了目录 —— 安装时设的配对码就找不着了(实测踩过)。
|
||||
# 按"装在哪"判断,管理员和普通用户看到的就是同一份配置。
|
||||
in_program_files = _is_installed_copy()
|
||||
|
||||
beside = app_dir() / "data"
|
||||
if not in_program_files and _can_write(beside):
|
||||
_DATA_DIR_CACHE = beside
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# ProgramData:所有用户共用一份配置(换 Windows 账号也不丢)
|
||||
base = os.environ.get("PROGRAMDATA") or r"C:\ProgramData"
|
||||
common = Path(base) / "ykxmz" / "data"
|
||||
if _can_write(common):
|
||||
_DATA_DIR_CACHE = common
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
# 兜底:ProgramData 写不了(ACL 没配好 / 权限被收紧)就退到用户目录。
|
||||
# 每人一份配置,但**永远能写** —— 总比跑起来报错强。
|
||||
local = (Path(os.environ.get("LOCALAPPDATA")
|
||||
or Path.home()) / "ykxmz" / "data")
|
||||
local.mkdir(parents=True, exist_ok=True)
|
||||
_DATA_DIR_CACHE = local
|
||||
return _DATA_DIR_CACHE
|
||||
|
||||
@@ -253,6 +253,20 @@ class RelayLink:
|
||||
self._log("没配置中转地址,跳过")
|
||||
return False
|
||||
|
||||
# 上次连通的地址排到最前面。
|
||||
# 设备 IP 会变(DHCP),但大概率还是上次那个;
|
||||
# 排前面能省掉"挨个探到超时"的那几秒(实测 2 个候选要 10 秒,
|
||||
# 而这段时间局域网发现都还没起来,手表搜不到电脑)。
|
||||
try:
|
||||
last_good = str(self.store.get("relay_last_good") or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
last_good = ""
|
||||
if last_good:
|
||||
normalized = last_good.rstrip("/")
|
||||
if normalized in candidates:
|
||||
candidates.remove(normalized)
|
||||
candidates.insert(0, normalized)
|
||||
|
||||
self.candidates = candidates
|
||||
# 先探一遍,挑第一个通的
|
||||
chosen = self._pick_candidate(candidates)
|
||||
@@ -260,6 +274,14 @@ class RelayLink:
|
||||
if len(candidates) > 1:
|
||||
self._log(f"中转地址候选 {len(candidates)} 个,选用 {chosen}")
|
||||
|
||||
# 记下来,下次优先试它
|
||||
if chosen:
|
||||
try:
|
||||
if str(self.store.get("relay_last_good") or "") != chosen:
|
||||
self.store.set("relay_last_good", chosen)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
self.device_id = self._ensure_device_id()
|
||||
self.code = str(self.store.pairing_code() or "")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user