Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
441ff7c7da | ||
|
|
0b6b000b01 | ||
|
|
d3ccc4a7a8 | ||
|
|
88f4d09d4c | ||
|
|
7276cfe585 | ||
|
|
1a74372ad9 | ||
|
|
22ba9272bd | ||
|
|
ac7997176d | ||
|
|
b9fab1999d | ||
|
|
4efe17c1c9 | ||
|
|
1e39f5490a | ||
|
|
31a2ae911e | ||
|
|
90ca2c2100 | ||
|
|
2a3c748964 | ||
|
|
5d637bd4f5 | ||
|
|
81a99e007b | ||
|
|
f78955f119 | ||
|
|
27530ff5ac |
@@ -45,3 +45,6 @@ bin/adb/*
|
|||||||
# 数据备份与损坏库(运维临时文件,不入库)
|
# 数据备份与损坏库(运维临时文件,不入库)
|
||||||
data/db_backup_*/
|
data/db_backup_*/
|
||||||
data/*.corrupt_*
|
data/*.corrupt_*
|
||||||
|
data/users.db*
|
||||||
|
data/users.db-wal
|
||||||
|
data/users.db-shm
|
||||||
|
|||||||
@@ -82,3 +82,10 @@ USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037"))
|
|||||||
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
# 安全:密钥用环境变量/.env 注入,不要写死在代码或提交到 git。
|
||||||
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
TAILSCALE_API_KEY = _env("TAILSCALE_API_KEY", "") # 写入 .env,不要提交到 git
|
||||||
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
|
TAILSCALE_TAILNET = _env("TAILSCALE_TAILNET", "")
|
||||||
|
|
||||||
|
# ================== 设备自动发现 ==================
|
||||||
|
# 定时扫描网段中开放 adb 5555 的设备 → 待连接池(用户确认后才加入正式池)。
|
||||||
|
# 网段默认:局域网 + Tailscale;可在工具页设备池面板修改(存 app_meta)。
|
||||||
|
DISCOVERY_PORT = int(_env("DISCOVERY_PORT", "5555"))
|
||||||
|
DISCOVERY_SUBNETS = ["192.168.20.0/24", "100.100.10.0/24"]
|
||||||
|
DISCOVERY_INTERVAL = int(_env("DISCOVERY_INTERVAL", "60"))
|
||||||
|
|||||||
+9
-6
@@ -19,17 +19,18 @@ _ADB_LOCK = threading.Lock()
|
|||||||
_ADB_TIMEOUT = 30
|
_ADB_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
def _adb(*args):
|
def _adb(*args, timeout=_ADB_TIMEOUT):
|
||||||
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
"""执行 adb 命令,返回 (stdout+stderr) 文本。不用 shell=True,避免子进程环境问题。
|
||||||
|
|
||||||
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
|
||||||
遇到非 GBK 字节会崩溃)。
|
遇到非 GBK 字节会崩溃)。
|
||||||
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
|
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
|
||||||
|
timeout 可覆盖(扫描验证等非关键路径用短超时快速失败)。
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT)
|
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=timeout)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
|
_log.warning(f"adb {' '.join(args)} 超时 {timeout}s")
|
||||||
return ""
|
return ""
|
||||||
return _decode(r)
|
return _decode(r)
|
||||||
|
|
||||||
@@ -157,16 +158,18 @@ def _try_connect(remote_adb_url, retries):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def adb_connect_light(remote_adb_url):
|
def adb_connect_light(remote_adb_url, timeout=5):
|
||||||
"""轻量 adb connect(扫描专用):单次尝试,不重试,绝不 kill-server。
|
"""轻量 adb connect(扫描/验证专用):单次尝试,不重试,绝不 kill-server。
|
||||||
|
|
||||||
与 adb_connect 的区别:
|
与 adb_connect 的区别:
|
||||||
- 不重试:失败立即返回 False
|
- 不重试:失败立即返回 False
|
||||||
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
- 不 kill-server:避免断开其他 worker 正在使用的 ADB 连接
|
||||||
|
- 短超时(默认 5s):验证场景(设备发现)连不上的主机快速放弃,
|
||||||
|
避免每台卡 30s 拖慢整轮发现并长时间占用全局 adb 锁
|
||||||
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
用于前台 App 扫描等非关键路径,连不上就跳过,不影响正在运行的任务。
|
||||||
"""
|
"""
|
||||||
with _ADB_LOCK:
|
with _ADB_LOCK:
|
||||||
out = _adb("connect", remote_adb_url)
|
out = _adb("connect", remote_adb_url, timeout=timeout)
|
||||||
if "connected" in out.lower() and "failed" not in out.lower():
|
if "connected" in out.lower() and "failed" not in out.lower():
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""剪贴板注入公共模块(ClipInject APK 通道)。
|
||||||
|
|
||||||
|
背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard
|
||||||
|
调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。ClipInject
|
||||||
|
(com.example.clipinject,用户自研 APK)通过 am start 启动透明 Activity,
|
||||||
|
窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受后台启动限制
|
||||||
|
(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。
|
||||||
|
|
||||||
|
工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from config import ADB_PATH
|
||||||
|
from core.adb_helper import adb_connect
|
||||||
|
from core.logger import get_logger
|
||||||
|
|
||||||
|
_log = get_logger("core.clip")
|
||||||
|
|
||||||
|
_CLIP_PACKAGE = "com.example.clipinject"
|
||||||
|
_CLIP_ACTIVITY = "com.example.clipinject/.ClipActivity"
|
||||||
|
|
||||||
|
|
||||||
|
def inject_clipboard(serial, text, d=None):
|
||||||
|
"""向设备剪贴板注入文字,返回 (ok, msg)。
|
||||||
|
|
||||||
|
- d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接),
|
||||||
|
读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护)
|
||||||
|
- IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线)
|
||||||
|
- 未装 ClipInject / 读回不一致都会明确报错(不再静默失败)
|
||||||
|
"""
|
||||||
|
import uiautomator2 as u2
|
||||||
|
if ":" in serial:
|
||||||
|
try:
|
||||||
|
adb_connect(serial)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"adb 连接失败: {e}"
|
||||||
|
b64 = base64.b64encode(text.encode("utf-8")).decode()
|
||||||
|
# 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "shell", "am", "start",
|
||||||
|
"-n", _CLIP_ACTIVITY, "--es", "text_b64", b64],
|
||||||
|
capture_output=True, timeout=30)
|
||||||
|
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
|
||||||
|
if "does not exist" in out or "unable to resolve Intent" in out:
|
||||||
|
return False, f"设备未安装 ClipInject({_CLIP_PACKAGE}),请先安装"
|
||||||
|
if "Error" in out or ("Starting" not in out and r.returncode != 0):
|
||||||
|
return False, f"am start 失败: {out.strip()[:120]}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "am start 超时(adb 无响应)"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
||||||
|
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
|
||||||
|
time.sleep(0.5)
|
||||||
|
try:
|
||||||
|
if d is None:
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
d = pool.submit(u2.connect, serial).result(timeout=30)
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
|
got = pool.submit(lambda: d.clipboard).result(timeout=15)
|
||||||
|
if got == text:
|
||||||
|
return True, "已注入"
|
||||||
|
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
|
||||||
|
except Exception as e:
|
||||||
|
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
|
||||||
|
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
"""设备自动发现:扫描网段中开放 adb 5555 的设备 → 待连接池(pending)。
|
||||||
|
|
||||||
|
流程:socket 并发探测 5555(标准库,不碰 adb)→ 剔除已在设备池的 serial →
|
||||||
|
串行 adb 验证(adb_connect_light,内部全局锁)→ 用 `adb devices` 的
|
||||||
|
state=="device" 过滤(排除 unauthorized/offline,connect 输出不可信)→
|
||||||
|
写入 pending_device 表。**扫描只验证、不自动连接入池**——用户在前端确认后
|
||||||
|
才调 device_pool.add_device + adb_connect(见 web/devices_api.py)。
|
||||||
|
|
||||||
|
安全红线:绝不 kill-server / 绝不 disconnect(与全项目一致)。
|
||||||
|
|
||||||
|
配置存 app_meta KV 表(discovery_enabled/subnets/interval/port),定时线程
|
||||||
|
每轮重读,开关/周期/网段改动即时生效无需重启。
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config import (USB_ADB_HOST,
|
||||||
|
DISCOVERY_PORT, DISCOVERY_SUBNETS, DISCOVERY_INTERVAL)
|
||||||
|
from core.adb_helper import _adb, adb_connect_light
|
||||||
|
from core.logger import get_logger
|
||||||
|
from core.models import db, PendingDevice
|
||||||
|
|
||||||
|
_log = get_logger("core.disc")
|
||||||
|
|
||||||
|
# 扫描配置键(app_meta)
|
||||||
|
_K_ENABLED = "discovery_enabled"
|
||||||
|
_K_SUBNETS = "discovery_subnets"
|
||||||
|
_K_INTERVAL = "discovery_interval"
|
||||||
|
_K_PORT = "discovery_port"
|
||||||
|
|
||||||
|
# 单网段主机数上限(防误配 /8 之类),超过截断并告警
|
||||||
|
_MAX_HOSTS_PER_SUBNET = 1024
|
||||||
|
# socket 探测并发数与单 IP 超时
|
||||||
|
_PROBE_WORKERS = 64
|
||||||
|
_PROBE_TIMEOUT = 0.4
|
||||||
|
|
||||||
|
_app = None
|
||||||
|
_scan_lock = threading.Lock() # 定时/手动扫描互斥
|
||||||
|
_stop_event = threading.Event() # shutdown 用
|
||||||
|
_scanning = False # 状态快照(API/前端)
|
||||||
|
_last_scan = None # (时间串, 开放数, 可连数, 新增数)
|
||||||
|
_last_error = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx():
|
||||||
|
"""后台线程访问 db 时自行推 app context(仿 device_pool._ctx)。"""
|
||||||
|
return _app.app_context() if _app else None
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(ts=None):
|
||||||
|
return (ts or datetime.now()).strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 配置读写 ==================
|
||||||
|
def get_settings():
|
||||||
|
"""读发现配置(app_meta,缺键用 config 默认值补齐)。"""
|
||||||
|
with _ctx():
|
||||||
|
def _get(key, default):
|
||||||
|
v = db.session.execute(
|
||||||
|
db.text("SELECT value FROM app_meta WHERE key=:k"), {"k": key}).scalar()
|
||||||
|
return v if v is not None else default
|
||||||
|
try:
|
||||||
|
subnets = json.loads(_get(_K_SUBNETS, "[]")) or DISCOVERY_SUBNETS
|
||||||
|
except Exception:
|
||||||
|
subnets = DISCOVERY_SUBNETS
|
||||||
|
return {
|
||||||
|
"enabled": _get(_K_ENABLED, "1") == "1", # 默认开启
|
||||||
|
"subnets": subnets,
|
||||||
|
"interval": int(_get(_K_INTERVAL, str(DISCOVERY_INTERVAL)) or DISCOVERY_INTERVAL),
|
||||||
|
"port": int(_get(_K_PORT, str(DISCOVERY_PORT)) or DISCOVERY_PORT),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_settings(enabled=None, subnets=None, interval=None, port=None):
|
||||||
|
"""写发现配置(部分字段更新)。subnets 逐项校验 CIDR,非法返回 (False, 原因)。"""
|
||||||
|
if subnets is not None:
|
||||||
|
clean = []
|
||||||
|
for s in subnets:
|
||||||
|
s = (s or "").strip()
|
||||||
|
if not s:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(s, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
return False, f"网段格式错误: {s}"
|
||||||
|
clean.append(s)
|
||||||
|
subnets = clean
|
||||||
|
try:
|
||||||
|
if interval is not None and (int(interval) < 10 or int(interval) > 3600):
|
||||||
|
return False, "扫描周期需在 10-3600 秒之间"
|
||||||
|
if port is not None and (int(port) < 1 or int(port) > 65535):
|
||||||
|
return False, "端口不合法"
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, "参数不合法"
|
||||||
|
with _ctx():
|
||||||
|
def _put(key, value):
|
||||||
|
db.session.execute(
|
||||||
|
db.text("INSERT OR REPLACE INTO app_meta(key,value) VALUES(:k,:v)"),
|
||||||
|
{"k": key, "v": str(value)})
|
||||||
|
if enabled is not None:
|
||||||
|
_put(_K_ENABLED, "1" if enabled else "0")
|
||||||
|
if subnets is not None:
|
||||||
|
_put(_K_SUBNETS, json.dumps(subnets))
|
||||||
|
if interval is not None:
|
||||||
|
_put(_K_INTERVAL, str(int(interval)))
|
||||||
|
if port is not None:
|
||||||
|
_put(_K_PORT, str(int(port)))
|
||||||
|
db.session.commit()
|
||||||
|
return True, "已保存"
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 网段展开与端口探测 ==================
|
||||||
|
def _expand_subnets(subnets, max_hosts=_MAX_HOSTS_PER_SUBNET):
|
||||||
|
"""CIDR 列表 → IP 列表。排除 220 自身(USB_ADB_HOST)与本机 IP;
|
||||||
|
非法网段跳过记日志;单网段超过 max_hosts 截断并告警。"""
|
||||||
|
self_ips = {USB_ADB_HOST}
|
||||||
|
try: # 本机自身 IP(接口枚举,尽力而为;无 hostname -I 的平台跳过)
|
||||||
|
import subprocess
|
||||||
|
out = subprocess.run(["hostname", "-I"], capture_output=True, text=True,
|
||||||
|
timeout=3).stdout or ""
|
||||||
|
self_ips.update(p for p in out.split() if p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ips = []
|
||||||
|
for cidr in subnets:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network((cidr or "").strip(), strict=False)
|
||||||
|
except ValueError:
|
||||||
|
_log.warning(f"发现: 跳过非法网段 {cidr}")
|
||||||
|
continue
|
||||||
|
hosts = [str(h) for h in net.hosts()
|
||||||
|
if str(h) not in self_ips]
|
||||||
|
if len(hosts) > max_hosts:
|
||||||
|
_log.warning(f"发现: 网段 {cidr} 主机数 {len(hosts)} 超过上限,截断前 {max_hosts}")
|
||||||
|
hosts = hosts[:max_hosts]
|
||||||
|
ips.extend(hosts)
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_port(ip, port, timeout=_PROBE_TIMEOUT):
|
||||||
|
"""单 IP 端口探测(标准库 socket,超时/拒绝/不可达一律 False)。"""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((ip, port), timeout=timeout):
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_open(ips, port, workers=_PROBE_WORKERS):
|
||||||
|
"""并发探测,返回开放端口的主机列表。"""
|
||||||
|
if not ips:
|
||||||
|
return []
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
results = list(ex.map(lambda ip: (ip, _probe_port(ip, port)), ips))
|
||||||
|
return [ip for ip, ok in results if ok]
|
||||||
|
|
||||||
|
|
||||||
|
# ================== adb devices 解析 ==================
|
||||||
|
def _parse_adb_devices(out):
|
||||||
|
"""解析 `adb devices` 输出 → state=="device" 的 serial 集合。
|
||||||
|
|
||||||
|
只看 connect 输出不可靠(未授权设备也返回 connected),必须用 state 过滤。
|
||||||
|
"""
|
||||||
|
devices = set()
|
||||||
|
for line in (out or "").splitlines()[1:]:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2 and parts[0] and not parts[0].startswith("*"):
|
||||||
|
if parts[1] == "device":
|
||||||
|
devices.add(parts[0])
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 扫描 ==================
|
||||||
|
def scan_once(manual=False):
|
||||||
|
"""执行一轮扫描。返回 (ok, result_dict);后台线程/API 调用。"""
|
||||||
|
global _scanning, _last_scan, _last_error
|
||||||
|
if not _scan_lock.acquire(blocking=False):
|
||||||
|
return False, {"error": "扫描进行中"}
|
||||||
|
_scanning = True
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
port = settings["port"]
|
||||||
|
subnets = settings["subnets"]
|
||||||
|
if not subnets:
|
||||||
|
_last_error = "未配置扫描网段"
|
||||||
|
return True, {"found": 0, "verified": 0, "new": 0, "error": _last_error}
|
||||||
|
|
||||||
|
# 1. socket 并发探测(不碰 adb,把几百台缩小到开放端口量级)
|
||||||
|
open_ips = _probe_open(_expand_subnets(subnets), port)
|
||||||
|
candidates = {f"{ip}:{port}" for ip in open_ips}
|
||||||
|
|
||||||
|
with _ctx():
|
||||||
|
# 2. 剔除已在正式设备池的 serial
|
||||||
|
configured = set(device_pool_list_configured())
|
||||||
|
candidates -= configured
|
||||||
|
|
||||||
|
# 3. 串行 adb 验证:已连接的直接跳过(零成本),其余 adb_connect_light
|
||||||
|
already = _parse_adb_devices(_adb("devices"))
|
||||||
|
for serial in candidates - already:
|
||||||
|
adb_connect_light(serial) # 内部全局锁串行;失败无妨,下面按 state 过滤
|
||||||
|
|
||||||
|
# 4. state==device 过滤(排除 unauthorized/offline)
|
||||||
|
verified = _parse_adb_devices(_adb("devices")) & candidates
|
||||||
|
|
||||||
|
# 5. 写 pending:新 → 插入;已有 → 更新 last_seen
|
||||||
|
now = _fmt()
|
||||||
|
existing = {p.serial for p in PendingDevice.query.all()}
|
||||||
|
added = 0
|
||||||
|
for serial in verified:
|
||||||
|
source = "tailscale" if serial.split(":")[0].startswith("100.") else "lan"
|
||||||
|
if serial in existing:
|
||||||
|
PendingDevice.query.filter_by(serial=serial).update(
|
||||||
|
{"last_seen": now})
|
||||||
|
else:
|
||||||
|
db.session.add(PendingDevice(serial=serial, source=source,
|
||||||
|
first_seen=now, last_seen=now))
|
||||||
|
added += 1
|
||||||
|
db.session.commit()
|
||||||
|
_log.info(f"发现: 探测开放 {len(open_ips)} 台,可连 {len(verified)} 台,"
|
||||||
|
f"新增待连接 {added} 台")
|
||||||
|
_last_scan = (now, len(open_ips), len(verified), added)
|
||||||
|
_last_error = ""
|
||||||
|
return True, {"found": len(open_ips), "verified": len(verified), "new": added}
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"发现扫描异常: {e}")
|
||||||
|
_last_error = str(e)[:200]
|
||||||
|
return False, {"error": _last_error}
|
||||||
|
finally:
|
||||||
|
_scanning = False
|
||||||
|
_scan_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def device_pool_list_configured():
|
||||||
|
"""设备池已配置 serial(延迟 import 避免循环依赖)。"""
|
||||||
|
from core import device_pool
|
||||||
|
return device_pool.list_configured()
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 定时扫描线程 ==================
|
||||||
|
def _discovery_loop():
|
||||||
|
"""定时扫描 daemon 线程。每轮重读配置(开关/周期即时生效)。
|
||||||
|
|
||||||
|
启动先 sleep 15s 避让 web_server 预连接线程(两者都抢 adb 全局锁)。
|
||||||
|
等待用 30s 切片(_stop_event.wait),关停/改周期 ≤30s 生效。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_stop_event.wait(15)
|
||||||
|
while not _stop_event.is_set():
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings["enabled"]:
|
||||||
|
scan_once()
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"发现定时扫描异常: {e}")
|
||||||
|
wait_s = max(10, settings.get("interval", 60))
|
||||||
|
waited = 0
|
||||||
|
while waited < wait_s and not _stop_event.is_set():
|
||||||
|
_stop_event.wait(min(30, wait_s - waited))
|
||||||
|
waited += min(30, wait_s - waited)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_app(app):
|
||||||
|
"""web_server 启动时调用:绑 app + 起定时扫描 daemon 线程。"""
|
||||||
|
global _app
|
||||||
|
_app = app
|
||||||
|
_stop_event.clear()
|
||||||
|
t = threading.Thread(target=_discovery_loop, name="device-discovery", daemon=True)
|
||||||
|
t.start()
|
||||||
|
_log.info("设备自动发现线程已启动(默认 60s 扫描一次)")
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown():
|
||||||
|
"""优雅退出:置 stop_event,等待中的循环在切片边界退出。"""
|
||||||
|
_stop_event.set()
|
||||||
|
|
||||||
|
|
||||||
|
# ================== 对外查询与确认 ==================
|
||||||
|
def get_status():
|
||||||
|
"""API 状态快照:配置 + 扫描状态 + 最近一轮结果 + 待连接数量。"""
|
||||||
|
settings = get_settings()
|
||||||
|
with _ctx():
|
||||||
|
pending = PendingDevice.query.count()
|
||||||
|
return {
|
||||||
|
"enabled": settings["enabled"],
|
||||||
|
"subnets": settings["subnets"],
|
||||||
|
"interval": settings["interval"],
|
||||||
|
"port": settings["port"],
|
||||||
|
"scanning": _scanning,
|
||||||
|
"last_scan": _last_scan[0] if _last_scan else "",
|
||||||
|
"last_result": ({"found": _last_scan[1], "verified": _last_scan[2],
|
||||||
|
"new": _last_scan[3]} if _last_scan else None),
|
||||||
|
"last_error": _last_error,
|
||||||
|
"pending_count": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_pending():
|
||||||
|
"""待连接列表:只返回当前在线的设备(离线候选不可确认,不展示)。
|
||||||
|
|
||||||
|
后端直接过滤(2026-09-04):不依赖前端 JS 版本,任何客户端都拿不到
|
||||||
|
离线条目;设备恢复在线后扫描自动更新 last_seen 并重新出现在列表。
|
||||||
|
"""
|
||||||
|
online = _parse_adb_devices(_adb("devices"))
|
||||||
|
with _ctx():
|
||||||
|
rows = [p.to_dict() for p in PendingDevice.query.order_by(
|
||||||
|
PendingDevice.first_seen.desc()).all()]
|
||||||
|
return [dict(r, online=True) for r in rows if r["serial"] in online]
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_pending(serial, name=""):
|
||||||
|
"""确认连接:pending 行 → 正式设备池(add_device upsert)→ 删 pending。
|
||||||
|
|
||||||
|
返回 (ok, msg, is_new)。adb_connect + 采型号由 API 层后台线程做。
|
||||||
|
"""
|
||||||
|
with _ctx():
|
||||||
|
row = PendingDevice.query.get(serial)
|
||||||
|
if not row:
|
||||||
|
return False, "设备不在待连接列表", False
|
||||||
|
from core import device_pool
|
||||||
|
is_new = device_pool.add_device(serial, name=name or "")
|
||||||
|
PendingDevice.query.filter_by(serial=serial).delete()
|
||||||
|
db.session.commit()
|
||||||
|
return True, "已加入设备池" + ("" if is_new else "(已存在,信息已更新)"), is_new
|
||||||
|
|
||||||
|
|
||||||
|
def ignore_pending(serial):
|
||||||
|
"""忽略:删除 pending 行(下轮扫描可能再次发现)。"""
|
||||||
|
with _ctx():
|
||||||
|
row = PendingDevice.query.get(serial)
|
||||||
|
if not row:
|
||||||
|
return False, "设备不在待连接列表"
|
||||||
|
PendingDevice.query.filter_by(serial=serial).delete()
|
||||||
|
db.session.commit()
|
||||||
|
return True, "已忽略"
|
||||||
@@ -238,6 +238,21 @@ class Device(db.Model):
|
|||||||
return f"<Device {self.serial}>"
|
return f"<Device {self.serial}>"
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDevice(db.Model):
|
||||||
|
"""自动发现待连接池:扫描验证通过的设备,等待用户确认后才入正式池。
|
||||||
|
|
||||||
|
与 Device 表的区别:只代表"被扫描到、可连接",不参与任务调度。
|
||||||
|
"""
|
||||||
|
serial = db.Column(db.String(120), primary_key=True) # 如 192.168.20.5:5555
|
||||||
|
source = db.Column(db.String(20), default="") # lan / tailscale
|
||||||
|
first_seen = db.Column(db.String(20), default="") # 首次发现时间
|
||||||
|
last_seen = db.Column(db.String(20), default="") # 最近一次扫描仍可见的时间
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"serial": self.serial, "source": self.source or "",
|
||||||
|
"first_seen": self.first_seen or "", "last_seen": self.last_seen or ""}
|
||||||
|
|
||||||
|
|
||||||
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
# 版本化 schema 迁移:新增结构变更时在此追加 (版本号, 说明, SQL)
|
||||||
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
# 版本号单调递增,只执行比当前 schema_version 新的迁移。
|
||||||
SCHEMA_MIGRATIONS = [
|
SCHEMA_MIGRATIONS = [
|
||||||
@@ -252,6 +267,12 @@ SCHEMA_MIGRATIONS = [
|
|||||||
"created_at VARCHAR(20) DEFAULT '')"),
|
"created_at VARCHAR(20) DEFAULT '')"),
|
||||||
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
(3, "设备池:device 表新增 model 列(型号,在线时自动采集)",
|
||||||
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
|
"ALTER TABLE device ADD COLUMN model TEXT DEFAULT ''"),
|
||||||
|
(4, "自动发现:pending_device 待连接池表(扫描发现的设备,用户确认后才入正式池)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS pending_device ("
|
||||||
|
"serial VARCHAR(120) PRIMARY KEY,"
|
||||||
|
"source VARCHAR(20) DEFAULT '',"
|
||||||
|
"first_seen VARCHAR(20) DEFAULT '',"
|
||||||
|
"last_seen VARCHAR(20) DEFAULT '')"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+22
-6
@@ -154,12 +154,24 @@ class TaskJob:
|
|||||||
elif mode == "group":
|
elif mode == "group":
|
||||||
g = manager.groups.get(self.target.get("group_name"))
|
g = manager.groups.get(self.target.get("group_name"))
|
||||||
serials = list(g.serials) if g else []
|
serials = list(g.serials) if g else []
|
||||||
|
# 分组应引用设备池内设备:不在池内的(如手动填的旧 IP)不参与调度,
|
||||||
|
# 与 all/preempt 模式口径一致(2026-09-01)
|
||||||
|
try:
|
||||||
|
configured = set(device_pool.list_configured())
|
||||||
|
serials = [s for s in serials if s in configured]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
# all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证);
|
# all:默认返回池内在线设备(单实例互斥由 _run_with_retry 的 _running 保证);
|
||||||
# 抢占模式返回全部在线设备(含运行中,执行时抢占)
|
# 抢占模式返回全部在线设备(含运行中,执行时抢占)
|
||||||
if self.params.get("preempt"):
|
if self.params.get("preempt"):
|
||||||
try:
|
try:
|
||||||
return device_pool.list_online()
|
# 抢占模式:只抢设备池内的在线设备——
|
||||||
|
# list_online() 会包含待连接/未入池设备(如自动发现验证时连上的),
|
||||||
|
# 它们不在设备池就不该被任务调度(2026-09-01 修复)
|
||||||
|
online = set(device_pool.list_online())
|
||||||
|
configured = set(device_pool.list_configured())
|
||||||
|
return [s for s in device_pool.list_online() if s in configured]
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
@@ -337,6 +349,11 @@ class TaskManager:
|
|||||||
def __init__(self, app=None):
|
def __init__(self, app=None):
|
||||||
self.app = app # Flask app,用于 db context
|
self.app = app # Flask app,用于 db context
|
||||||
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
self.scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||||
|
if os.environ.get("DISABLE_SCHEDULER"):
|
||||||
|
# 测试/回归模式:不启动 cron 调度器,避免 test_client 加载任务后
|
||||||
|
# 按 cron 真的跑任务(干扰回归、占用设备)。add_job 仍可调用不报错。
|
||||||
|
_log.info("DISABLE_SCHEDULER=1:跳过 cron 调度器(测试/回归模式)")
|
||||||
|
else:
|
||||||
self.scheduler.start()
|
self.scheduler.start()
|
||||||
start_watchdog() # 启动心跳看门狗
|
start_watchdog() # 启动心跳看门狗
|
||||||
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
self.groups = {} # name -> DeviceGroup(内存业务对象)
|
||||||
@@ -587,14 +604,10 @@ class TaskManager:
|
|||||||
return _next_run_time(job.schedule)
|
return _next_run_time(job.schedule)
|
||||||
|
|
||||||
def run_job_now(self, job_id):
|
def run_job_now(self, job_id):
|
||||||
"""立即执行任务(手动触发)。运行窗口外拒绝启动。"""
|
"""立即执行任务(手动触发)。运行窗口只约束定时触发,手动执行不受限制。"""
|
||||||
job = self.jobs.get(job_id)
|
job = self.jobs.get(job_id)
|
||||||
if not job:
|
if not job:
|
||||||
return {"ok": False, "error": "任务不存在"}
|
return {"ok": False, "error": "任务不存在"}
|
||||||
if not _in_run_window(job.schedule):
|
|
||||||
win = job.schedule.get("window") or {}
|
|
||||||
return {"ok": False,
|
|
||||||
"error": f"当前不在运行窗口内({win.get('start', '')}-{win.get('end', '')}),任务未启动"}
|
|
||||||
# 在独立线程跑,不阻塞调用方
|
# 在独立线程跑,不阻塞调用方
|
||||||
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
t = threading.Thread(target=self._run_job, args=(job,), daemon=True)
|
||||||
t.start()
|
t.start()
|
||||||
@@ -885,4 +898,7 @@ class TaskManager:
|
|||||||
self.stop_all()
|
self.stop_all()
|
||||||
self._fg_scanner.stop()
|
self._fg_scanner.stop()
|
||||||
stop_watchdog()
|
stop_watchdog()
|
||||||
|
try:
|
||||||
self.scheduler.shutdown(wait=False)
|
self.scheduler.shutdown(wait=False)
|
||||||
|
except Exception:
|
||||||
|
pass # DISABLE_SCHEDULER 模式下 scheduler 未 start,shutdown 会抛异常
|
||||||
|
|||||||
Binary file not shown.
@@ -21,6 +21,10 @@ import signal
|
|||||||
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
|
# 项目根加入 sys.path(脚本在 scripts/ 下运行,保证可 import web_server/config)
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# 测试模式:禁用 cron 调度器(否则 test_client 会按 DB 里的任务 cron 真跑任务,
|
||||||
|
# 干扰回归/占用设备——实测 ocr关键字任务每 30 分钟触发导致回归超时)
|
||||||
|
os.environ["DISABLE_SCHEDULER"] = "1"
|
||||||
|
|
||||||
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
|
# 单接口超时保护:adb 相关接口可能慢(连接/查询设备),给足时间
|
||||||
signal.alarm(300)
|
signal.alarm(300)
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,34 @@ fi
|
|||||||
pip uninstall -y -q opencv-python 2>/dev/null || true
|
pip uninstall -y -q opencv-python 2>/dev/null || true
|
||||||
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
|
pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "opencv-python-headless>=4.8,<5" # 5.x wheel 无 cv2 模块,锁定 4.x
|
||||||
|
|
||||||
|
# pip 24.0 解压 abi3 wheel 偶发丢根文件(4.14.0.94 实测 site-packages/cv2 只剩子目录,
|
||||||
|
# cv2.abi3.so / __init__.py 缺失 → import cv2 成空壳,OCR 全挂)→ 空壳检测 + wheel 手动解压兜底(幂等)
|
||||||
|
python3 - <<'PY'
|
||||||
|
import glob, os, shutil, site, subprocess, sys, zipfile
|
||||||
|
|
||||||
|
def cv2_ok():
|
||||||
|
try:
|
||||||
|
import cv2
|
||||||
|
return hasattr(cv2, "__version__") and cv2.__file__ is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if cv2_ok():
|
||||||
|
sys.exit(0)
|
||||||
|
print("cv2 空壳,wheel 手动解压修复...")
|
||||||
|
r = subprocess.run(["pip", "download", "--no-deps", "-q",
|
||||||
|
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||||
|
"-d", "/tmp/ocv", "opencv-python-headless>=4.8,<5"], capture_output=True)
|
||||||
|
if r.returncode != 0:
|
||||||
|
sys.exit(1)
|
||||||
|
w = sorted(glob.glob("/tmp/ocv/opencv_python_headless*.whl"))[-1]
|
||||||
|
with zipfile.ZipFile(w) as z:
|
||||||
|
z.extractall("/tmp/ocv")
|
||||||
|
dst = os.path.join(site.getsitepackages()[0], "cv2")
|
||||||
|
shutil.rmtree(dst, ignore_errors=True)
|
||||||
|
shutil.copytree("/tmp/ocv/cv2", dst)
|
||||||
|
import cv2
|
||||||
|
print(f"cv2 {cv2.__version__} 手动解压修复完成")
|
||||||
|
PY
|
||||||
|
|
||||||
exec python -u web_server.py
|
exec python -u web_server.py
|
||||||
|
|||||||
+11
-2
@@ -94,11 +94,11 @@ function showTab(name){
|
|||||||
// 停止所有定时器
|
// 停止所有定时器
|
||||||
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
if(_monitorTimer){clearInterval(_monitorTimer);_monitorTimer=null;}
|
||||||
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
if(_logTimer){clearInterval(_logTimer);_logTimer=null;}
|
||||||
|
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||||||
// 按需加载数据
|
// 按需加载数据
|
||||||
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
if(name==='monitor'){loadMonitor();_monitorTimer=setInterval(loadMonitor,5000);}
|
||||||
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
if(name==='tasks'){showSubTab('tasks',_activeSubs.tasks);loadTasks();loadCustomActions();}
|
||||||
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
if(name==='tools'){showSubTab('tools',_activeSubs.tools);loadToolsDevices();loadAdbDevices();loadTailscaleDevices();loadApks();}
|
||||||
if(name==='groups')loadGroups();
|
|
||||||
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
if(name==='logs'){loadLogs();if(document.getElementById('log-auto').checked)_logTimer=setInterval(loadLogs,3000);}
|
||||||
if(name==='users')loadUsers();
|
if(name==='users')loadUsers();
|
||||||
}
|
}
|
||||||
@@ -106,13 +106,22 @@ function showTab(name){
|
|||||||
// ================== 页内子分栏(任务/工具 通用) ==================
|
// ================== 页内子分栏(任务/工具 通用) ==================
|
||||||
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
// 每个带子分栏的 Tab 记住上次选中的子分栏,切走再切回来保持原位
|
||||||
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
let _activeSubs = {tasks: 'plan', tools: 'clipboard'};
|
||||||
|
let _discoveryTimer = null; // 设备自动发现 10s 轮询(仅 devpool 子分栏激活时)
|
||||||
|
|
||||||
function showSubTab(tabId, name){
|
function showSubTab(tabId, name){
|
||||||
_activeSubs[tabId] = name;
|
_activeSubs[tabId] = name;
|
||||||
const tab = document.getElementById('tab-'+tabId);
|
const tab = document.getElementById('tab-'+tabId);
|
||||||
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
tab.querySelectorAll('.sub-tab').forEach(b=>b.classList.toggle('active', b.dataset.sub===name));
|
||||||
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
tab.querySelectorAll('.sub-panel').forEach(p=>p.classList.toggle('active', p.id===tabId+'-sub-'+name));
|
||||||
if(name==='devpool' && typeof loadDevPool==='function') loadDevPool();
|
if(name==='groups' && typeof loadGroups==='function') loadGroups();
|
||||||
|
if(name==='devpool' && typeof loadDevPool==='function'){
|
||||||
|
loadDevPool();
|
||||||
|
if(typeof loadDiscovery==='function'){
|
||||||
|
loadDiscovery();
|
||||||
|
if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||||||
|
_discoveryTimer=setInterval(loadDiscovery,10000); // 10s 轮询:扫完自动出新结果
|
||||||
|
}
|
||||||
|
} else if(_discoveryTimer){clearInterval(_discoveryTimer);_discoveryTimer=null;}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ================== 模态框 ==================
|
// ================== 模态框 ==================
|
||||||
|
|||||||
@@ -1082,7 +1082,7 @@ async function saveTask(jobId){
|
|||||||
cron:buildCronFromFreq(),
|
cron:buildCronFromFreq(),
|
||||||
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
|
stop_cron:(parseInt(st[1])||0)+' '+(parseInt(st[0])||0)+' * * *'};
|
||||||
}
|
}
|
||||||
// 运行窗口:窗口外(定时触发 + 手动执行)不启动
|
// 运行窗口:只约束定时触发(窗口外定时不启动),手动执行不受限制
|
||||||
if(document.getElementById('f-window_enable').checked){
|
if(document.getElementById('f-window_enable').checked){
|
||||||
schedule.window={
|
schedule.window={
|
||||||
start:document.getElementById('f-window_start').value||'09:00',
|
start:document.getElementById('f-window_start').value||'09:00',
|
||||||
|
|||||||
@@ -307,8 +307,14 @@ function renderDevices(){
|
|||||||
if(_sig===_devicesSig)return;
|
if(_sig===_devicesSig)return;
|
||||||
_devicesSig=_sig;
|
_devicesSig=_sig;
|
||||||
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
|
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
|
||||||
|
// 「只看在线」勾选时过滤离线设备(默认勾选)
|
||||||
|
const onlyOnline=document.getElementById('monitor-filter-online')
|
||||||
|
? document.getElementById('monitor-filter-online').checked : true;
|
||||||
setListPager('devices',{
|
setListPager('devices',{
|
||||||
data:()=>_statusData.devices||[],
|
data:()=>{
|
||||||
|
const all=_statusData.devices||[];
|
||||||
|
return onlyOnline? all.filter(d=>d.present) : all;
|
||||||
|
},
|
||||||
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
filter:(dev,kw)=>((dev.serial||'').toLowerCase().includes(kw)||(dev.model||'').toLowerCase().includes(kw)||(dev.foreground_app||'').toLowerCase().includes(kw)||(dev.task_job||'').toLowerCase().includes(kw)||(dev.current_action||'').toLowerCase().includes(kw)),
|
||||||
sortValue:_sortValue,
|
sortValue:_sortValue,
|
||||||
render:renderDeviceRow,
|
render:renderDeviceRow,
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function openTaskModal(jobId){
|
|||||||
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
|
'<div class="form-group"><label>允许运行·开始</label><input type="time" class="form-control" id="f-window_start" value="'+esc(winStart)+'"></div>'+
|
||||||
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
|
'<div class="form-group"><label>允许运行·结束</label><input type="time" class="form-control" id="f-window_end" value="'+esc(winEnd)+'"></div>'+
|
||||||
'</div>'+
|
'</div>'+
|
||||||
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口外任务不会启动(定时触发和手动执行都受约束)。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
|
'<div class="help" id="f-window_help" style="display:'+(win?'block':'none')+'">窗口只约束定时触发:窗口外定时不会启动,手动执行不受限制。跨午夜示例:21:00-09:00 = 晚 9 点到次日早 9 点允许运行</div>'+
|
||||||
'<div class="form-row">'+
|
'<div class="form-row">'+
|
||||||
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
'<div class="form-group"><label>重试次数</label><input type="number" class="form-control" id="f-retry_attempts" value="'+j.retry.max_attempts+'" min="1" max="10"></div>'+
|
||||||
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
'<div class="form-group"><label>重试间隔(秒)</label><input type="number" class="form-control" id="f-retry_delay" value="'+j.retry.delay+'" min="10" max="3600"></div>'+
|
||||||
|
|||||||
+120
-3
@@ -21,7 +21,9 @@ async function loadToolsDevices(force){
|
|||||||
const list = await _fetchAdbDevices(force);
|
const list = await _fetchAdbDevices(force);
|
||||||
const status = document.getElementById('clip-status');
|
const status = document.getElementById('clip-status');
|
||||||
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
|
if(list === null){status.textContent='加载失败';showToast('加载设备失败','error');return;}
|
||||||
_clipDevices = list;
|
// 剪贴板注入只对在线设备有意义:过滤掉设备池未连接(pool)/离线条目;
|
||||||
|
// adb 终端面板保留全部(重连设备需要),它用自己的渲染逻辑不受影响
|
||||||
|
_clipDevices = list.filter(d=>d.state==='device');
|
||||||
const wrap = document.getElementById('clip-devices');
|
const wrap = document.getElementById('clip-devices');
|
||||||
if(!_clipDevices.length){
|
if(!_clipDevices.length){
|
||||||
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
|
wrap.innerHTML='<div class="empty" style="padding:20px">无可用设备(本地 adb 或设备池均无)</div>';
|
||||||
@@ -39,11 +41,119 @@ async function loadToolsDevices(force){
|
|||||||
status.textContent = '共 '+_clipDevices.length+' 台设备';
|
status.textContent = '共 '+_clipDevices.length+' 台设备';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================== 设备自动发现(扫描 → 待连接池 → 确认) ==================
|
||||||
|
function loadDiscovery(){
|
||||||
|
apiGet('/api/devices/discovery').then(r=>{
|
||||||
|
if(!r||!r.ok)return;
|
||||||
|
const status=document.getElementById('discovery-status');
|
||||||
|
const enabled=document.getElementById('discovery-enabled');
|
||||||
|
const interval=document.getElementById('discovery-interval');
|
||||||
|
const subnets=document.getElementById('discovery-subnets');
|
||||||
|
if(!enabled)return; // 面板未渲染(离开子分栏)
|
||||||
|
enabled.checked=!!r.enabled;
|
||||||
|
interval.value=r.interval||60;
|
||||||
|
subnets.value=(r.subnets||[]).join(', ');
|
||||||
|
// 状态行
|
||||||
|
if(r.scanning){status.textContent='扫描中...';}
|
||||||
|
else if(r.last_scan){
|
||||||
|
let txt='上次扫描 '+esc(r.last_scan)+' · 开放 '+r.last_result.found+' · 可连 '
|
||||||
|
+(r.last_result.verified||0)+' · 新增 '+(r.last_result.new||0);
|
||||||
|
if(r.last_error)txt+=' · ⚠ '+esc(r.last_error);
|
||||||
|
status.textContent=txt;
|
||||||
|
}else{status.textContent='尚未扫描';}
|
||||||
|
// 待连接表格:只显示在线设备(离线候选不可确认连接,隐藏;恢复在线后自动出现)
|
||||||
|
const rows=(r.pending||[]).filter(x=>x.online);
|
||||||
|
const tb=document.getElementById('tb-discovery');
|
||||||
|
if(!tb)return;
|
||||||
|
if(!rows.length){
|
||||||
|
tb.innerHTML='<tr><td colspan="6" class="empty">待连接池为空(扫描到的设备会出现在这里)</td></tr>';
|
||||||
|
}else{
|
||||||
|
tb.innerHTML=rows.map(x=>{
|
||||||
|
const src=x.source==='tailscale'
|
||||||
|
?'<span class="label label-info">Tailscale</span>'
|
||||||
|
:'<span class="label label-warning">局域网</span>';
|
||||||
|
const onl=x.online
|
||||||
|
?'<span class="label label-success">在线</span>'
|
||||||
|
:'<span class="label label-warning">离线</span>';
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(x.serial)+'</td>'
|
||||||
|
+'<td>'+src+'</td><td>'+esc(x.first_seen)+'</td><td>'+esc(x.last_seen)+'</td><td>'+onl+'</td>'
|
||||||
|
+'<td><button class="btn btn-primary btn-xs" data-serial="'+esc(x.serial)+'" onclick="confirmDiscoveryDev(this.dataset.serial)">确认添加</button> '
|
||||||
|
+'<button class="btn btn-xs" data-serial="'+esc(x.serial)+'" onclick="ignoreDiscoveryDev(this.dataset.serial)">忽略</button></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleDiscovery(checked){
|
||||||
|
apiPost('/api/devices/discovery/settings',{enabled:checked}).then(r=>{
|
||||||
|
if(r&&r.ok)showToast('定时扫描已'+(checked?'开启':'关闭'),'success');
|
||||||
|
else showToast('设置失败: '+((r&&r.error)||''),'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function saveDiscoverySettings(){
|
||||||
|
const subnets=(document.getElementById('discovery-subnets').value||'')
|
||||||
|
.split(',').map(s=>s.trim()).filter(Boolean);
|
||||||
|
const interval=parseInt(document.getElementById('discovery-interval').value)||60;
|
||||||
|
apiPost('/api/devices/discovery/settings',{subnets,interval}).then(r=>{
|
||||||
|
if(r&&r.ok)showToast('设置已保存','success');
|
||||||
|
else showToast('保存失败: '+((r&&r.error)||''),'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function runDiscoveryScan(){
|
||||||
|
apiPost('/api/devices/discovery/scan',{}).then(r=>{
|
||||||
|
if(r&&r.ok){showToast(r.msg||'扫描已启动','success');setTimeout(loadDiscovery,3000);}
|
||||||
|
else showToast('扫描失败: '+((r&&r.error)||''),'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function confirmDiscoveryDev(serial){
|
||||||
|
const name=prompt('备注名(可选,留空直接添加)','');
|
||||||
|
if(name===null)return;
|
||||||
|
apiPost('/api/devices/discovery/confirm',{serial,name}).then(r=>{
|
||||||
|
if(r&&r.ok){
|
||||||
|
showToast('已加入设备池'+(r.is_new?'':'(已存在,已更新)'),'success');
|
||||||
|
loadDevPool();loadDiscovery();
|
||||||
|
}else showToast('确认失败: '+((r&&r.error)||''),'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ignoreDiscoveryDev(serial){
|
||||||
|
if(!confirm('忽略 '+serial+'?下轮扫描可能再次发现'))return;
|
||||||
|
apiPost('/api/devices/discovery/ignore',{serial}).then(r=>{
|
||||||
|
if(r&&r.ok){showToast('已忽略','success');loadDiscovery();}
|
||||||
|
else showToast('操作失败: '+((r&&r.error)||''),'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function toggleClipDevice(cb){
|
function toggleClipDevice(cb){
|
||||||
if(cb.checked)_clipSelected.add(cb.value);
|
if(cb.checked)_clipSelected.add(cb.value);
|
||||||
else _clipSelected.delete(cb.value);
|
else _clipSelected.delete(cb.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 注入剪贴板(ClipInject APK 通道,Android 10+ 可用):
|
||||||
|
// am start 透明 Activity 写入 + u2 读回比对验证;结果逐台渲染 + toast 汇总
|
||||||
|
function injectClipboard(){
|
||||||
|
const text = document.getElementById('clip-text').value;
|
||||||
|
const serials = [..._clipSelected];
|
||||||
|
if(!serials.length){showToast('请先勾选目标设备','error');return;}
|
||||||
|
if(!text.trim()){showToast('请输入注入内容','error');return;}
|
||||||
|
const btn = Array.from(document.querySelectorAll('#tools-sub-clipboard .btn-primary'))
|
||||||
|
.find(b=>b.textContent.includes('注入剪贴板'));
|
||||||
|
if(btn){btn.disabled = true; const old = btn.textContent; btn.textContent = '注入中...'; btn._old = old;}
|
||||||
|
apiPost('/api/tools/clipboard/set', {serials, text}).then(r=>{
|
||||||
|
if(!r){showToast('请求失败','error');return;}
|
||||||
|
const rows = Object.entries(r.results||{}).map(([s,v])=>{
|
||||||
|
const ok = !!v.ok;
|
||||||
|
return '<tr><td style="font-family:monospace">'+esc(s)+'</td>'
|
||||||
|
+'<td><span class="label '+(ok?'label-success':'label-danger')+'">'
|
||||||
|
+(ok?'成功':'失败')+'</span> '+esc(v.msg||'')+'</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
document.getElementById('clip-result').innerHTML = rows;
|
||||||
|
document.getElementById('clip-result-wrap').style.display='block';
|
||||||
|
const okc = r.ok_count||0, fail = r.fail_count||0;
|
||||||
|
if(fail>0) showToast(okc+' 台成功,'+fail+' 台失败','error');
|
||||||
|
else showToast(okc+' 台设备注入成功','success');
|
||||||
|
}).catch(e=>{showToast('注入异常: '+e,'error');})
|
||||||
|
.finally(()=>{ if(btn){btn.disabled=false; btn.textContent=btn._old;} });
|
||||||
|
}
|
||||||
|
|
||||||
// ================== 工具:应用版本管理 ==================
|
// ================== 工具:应用版本管理 ==================
|
||||||
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
|
let _appVerRows = []; // [{serial, installed, version_name, version_code, error}]
|
||||||
|
|
||||||
@@ -285,9 +395,16 @@ async function loadDevPool(){
|
|||||||
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
|
tb.innerHTML='<tr><td colspan="7" class="empty">加载中...</td></tr>';
|
||||||
const r=await apiGet('/api/devices/pool');
|
const r=await apiGet('/api/devices/pool');
|
||||||
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
|
if(!r||!r.ok){tb.innerHTML='<tr><td colspan="7" class="empty">加载失败</td></tr>';return;}
|
||||||
const rows=r.devices||[];
|
let rows=r.devices||[];
|
||||||
|
// 在线优先排序;「只看在线」勾选时过滤掉离线(IP 换了/长期离线的旧条目不再碍眼)
|
||||||
|
const onlyOnline=document.getElementById('devpool-filter-online')
|
||||||
|
? document.getElementById('devpool-filter-online').checked : false;
|
||||||
|
rows.sort((a,b)=>(b.online?1:0)-(a.online?1:0));
|
||||||
|
if(onlyOnline)rows=rows.filter(x=>x.online);
|
||||||
const onlineCnt=rows.filter(x=>x.online).length;
|
const onlineCnt=rows.filter(x=>x.online).length;
|
||||||
status.textContent='共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
|
status.textContent=onlyOnline
|
||||||
|
? '在线 '+rows.length+' 台(共 '+(r.devices||[]).length+' 台配置)'
|
||||||
|
: '共 '+rows.length+' 台,在线 '+onlineCnt+' 台';
|
||||||
tb.innerHTML=rows.map(d=>{
|
tb.innerHTML=rows.map(d=>{
|
||||||
const st= d.enabled
|
const st= d.enabled
|
||||||
? '<span class="label label-success">启用</span>'
|
? '<span class="label label-success">启用</span>'
|
||||||
|
|||||||
+21
-10
@@ -478,26 +478,37 @@ class GenericStepsWorker(BaseWorker):
|
|||||||
return found # 命中结果,"测试此步骤"用
|
return found # 命中结果,"测试此步骤"用
|
||||||
|
|
||||||
def _exec_clipboard(self, d, params, depth=0):
|
def _exec_clipboard(self, d, params, depth=0):
|
||||||
"""剪贴板注入:写入指定内容到剪贴板,可选立即粘贴到当前聚焦的输入框。
|
"""剪贴板注入(ClipInject 通道,Android 10+ 可用),可选立即粘贴。
|
||||||
|
|
||||||
批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2……
|
批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2……
|
||||||
粘贴模式复用 u2 send_keys(内部 setClipboard + pasteClipboard,与"输入文字"同机制)。
|
粘贴模式:ClipInject 写入剪贴板后调 atx-agent pasteClipboard 粘贴到当前
|
||||||
|
焦点输入框;动作失败时兜底 u2 set_text 直接设置聚焦输入框文本(不依赖
|
||||||
|
剪贴板,与"输入文字"兜底同机制)。
|
||||||
"""
|
"""
|
||||||
text = str(params.get("text", "") or "")
|
text = str(params.get("text", "") or "")
|
||||||
if not text:
|
if not text:
|
||||||
_log.warning(f"[{self.serial}] clipboard 内容为空,跳过")
|
_log.warning(f"[{self.serial}] clipboard 内容为空,跳过")
|
||||||
return False
|
return False
|
||||||
|
from core.clipboard_helper import inject_clipboard
|
||||||
|
ok, msg = inject_clipboard(self.serial, text, d=d)
|
||||||
|
if not ok:
|
||||||
|
_log.warning(f"[{self.serial}] 剪贴板注入失败: {msg}")
|
||||||
|
return False
|
||||||
|
_log.info(f"[{self.serial}] 剪贴板注入: {text[:60]}({msg})")
|
||||||
|
if not params.get("paste"):
|
||||||
|
return True
|
||||||
try:
|
try:
|
||||||
if params.get("paste"):
|
d.jsonrpc.pasteClipboard()
|
||||||
d.send_keys(text) # setClipboard + pasteClipboard
|
_log.info(f"[{self.serial}] 已粘贴: {text[:60]}")
|
||||||
_log.info(f"[{self.serial}] 剪贴板注入并粘贴: {text[:60]}")
|
|
||||||
else:
|
|
||||||
d.set_clipboard(text)
|
|
||||||
_log.info(f"[{self.serial}] 剪贴板注入(不粘贴): {text[:60]}")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_log.warning(f"[{self.serial}] 剪贴板注入异常: {e}")
|
try:
|
||||||
return False
|
d(focused=True).set_text(text)
|
||||||
|
_log.info(f"[{self.serial}] 粘贴兜底 set_text: {text[:60]}")
|
||||||
|
return True
|
||||||
|
except Exception as e2:
|
||||||
|
_log.warning(f"[{self.serial}] 粘贴失败: {e} / {e2}")
|
||||||
|
return True # 剪贴板已写入,粘贴失败不致命
|
||||||
|
|
||||||
def _exec_wait(self, d, params, depth=0):
|
def _exec_wait(self, d, params, depth=0):
|
||||||
min_s = float(params.get("min", 1.0))
|
min_s = float(params.get("min", 1.0))
|
||||||
|
|||||||
@@ -313,7 +313,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
|
<button class="tab active" data-tab="monitor" onclick="showTab('monitor')">监控</button>
|
||||||
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
|
<button class="tab" data-tab="tasks" onclick="showTab('tasks')">任务</button>
|
||||||
<button class="tab" data-tab="groups" onclick="showTab('groups')">分组</button>
|
|
||||||
<button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
|
<button class="tab" data-tab="logs" onclick="showTab('logs')" data-perm="logs">日志</button>
|
||||||
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
|
<button class="tab" data-tab="users" onclick="showTab('users')" data-perm="admin">用户</button>
|
||||||
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
|
<button class="tab" data-tab="tools" onclick="showTab('tools')" data-perm="admin">工具</button>
|
||||||
@@ -343,6 +342,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
|
<button class="btn" onclick="screenAllDevices('on')" data-perm="devices" title="所有在线设备亮屏并解锁">一键亮屏</button>
|
||||||
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
|
<button class="btn" onclick="screenAllDevices('off')" data-perm="devices" title="所有在线设备息屏(运行中任务会中断)">一键息屏</button>
|
||||||
<button class="btn" onclick="loadMonitor()">刷新</button>
|
<button class="btn" onclick="loadMonitor()">刷新</button>
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
||||||
|
<input type="checkbox" id="monitor-filter-online" checked onchange="loadMonitor()"> 只看在线
|
||||||
|
</label>
|
||||||
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
|
<input class="form-control list-search" id="search-devices" placeholder="搜索设备/型号/App/任务...">
|
||||||
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
|
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,23 +422,6 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ========== Tab 3: 分组 ========== -->
|
<!-- ========== Tab 3: 分组 ========== -->
|
||||||
<div id="tab-groups" class="tab-panel">
|
|
||||||
<div class="page-title">设备分组管理</div>
|
|
||||||
<div class="page-sub">管理设备分组,用于任务按分组执行</div>
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
|
|
||||||
<button class="btn" onclick="loadGroups()">刷新</button>
|
|
||||||
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
|
|
||||||
</div>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr>
|
|
||||||
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="tb-groups"></tbody>
|
|
||||||
</table>
|
|
||||||
<div class="list-pager" id="pager-groups"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ========== Tab 4: 日志 ========== -->
|
<!-- ========== Tab 4: 日志 ========== -->
|
||||||
<div id="tab-logs" class="tab-panel">
|
<div id="tab-logs" class="tab-panel">
|
||||||
<div class="page-title">日志查看</div>
|
<div class="page-title">日志查看</div>
|
||||||
@@ -494,6 +479,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
|
<button class="sub-tab" data-sub="appver" onclick="showSubTab('tools','appver')">应用版本管理</button>
|
||||||
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
<button class="sub-tab" data-sub="devapps" onclick="showSubTab('tools','devapps')">设备已装应用</button>
|
||||||
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
|
<button class="sub-tab" data-sub="devpool" onclick="showSubTab('tools','devpool')">设备池管理</button>
|
||||||
|
<button class="sub-tab" data-sub="groups" onclick="showSubTab('tools','groups')">设备分组</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 子分栏 1:剪贴板注入 -->
|
<!-- 子分栏 1:剪贴板注入 -->
|
||||||
@@ -513,7 +499,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
|
<button class="btn btn-primary" onclick="injectClipboard()">注入剪贴板</button>
|
||||||
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
|
<span class="hint">注入后需在目标 App 的输入框长按选择"粘贴"</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="help">实现:u2 setClipboard(jsonrpc 直连 atx-agent),实测支持中文、引号、换行;USB 设备首次连接会自动推送 atx-agent。</div>
|
<div class="help">实现:ClipInject APK 透明 Activity 写入剪贴板(Android 10+ 可用,u2 setClipboard 在 Android 12/13 会被系统丢弃);设备需先安装 com.example.clipinject,注入后自动读回比对验证。</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:280px">
|
<div style="flex:1;min-width:280px">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -649,6 +635,28 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
|
|
||||||
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
|
<!-- 子分栏 6:设备池管理(本地 SQLite 清单,任务调度的数据源) -->
|
||||||
<div class="sub-panel" id="tools-sub-devpool">
|
<div class="sub-panel" id="tools-sub-devpool">
|
||||||
|
<!-- 自动发现:扫描网段 → 待连接池,确认后才加入正式池 -->
|
||||||
|
<div style="border-bottom:1px solid var(--border);margin-bottom:10px;padding-bottom:10px">
|
||||||
|
<div class="toolbar" style="flex-wrap:wrap">
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:4px">
|
||||||
|
<input type="checkbox" id="discovery-enabled" onchange="toggleDiscovery(this.checked)"> 定时扫描
|
||||||
|
</label>
|
||||||
|
<span class="text-muted" style="margin-left:8px">周期</span>
|
||||||
|
<input type="number" id="discovery-interval" style="width:70px" min="10">
|
||||||
|
<span class="text-muted">秒 · 网段</span>
|
||||||
|
<input type="text" id="discovery-subnets" class="form-control" style="width:300px;font-family:monospace"
|
||||||
|
placeholder="192.168.20.0/24, 100.100.10.0/24" title="逗号分隔的 CIDR 网段">
|
||||||
|
<button class="btn" onclick="saveDiscoverySettings()">保存设置</button>
|
||||||
|
<button class="btn btn-primary" onclick="runDiscoveryScan()">立即扫描</button>
|
||||||
|
<span id="discovery-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||||
|
<thead><tr><th>serial</th><th>来源</th><th>首次发现</th><th>最近发现</th><th>在线</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="tb-discovery"><tr><td colspan="6" class="empty">尚未扫描</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="help">扫描局域网/Tailscale 网段中开放 adb 5555 的设备进入<b>待连接池</b>;
|
||||||
|
<b>确认后才加入正式设备池</b>(不自动连接、不参与任务调度)。未授权设备(手机未接受 RSA 弹窗)不会出现。</div>
|
||||||
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
|
<input type="text" id="devpool-serial" class="form-control" style="width:220px;font-family:monospace"
|
||||||
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
|
placeholder="serial,如 100.100.10.20:5555" onkeydown="if(event.key==='Enter')addPoolDev()">
|
||||||
@@ -658,6 +666,9 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
|
<button class="btn" onclick="reconnectPoolDevices()" title="并发 adb connect 池内全部设备">一键重连</button>
|
||||||
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
|
<button class="btn" onclick="refreshPoolModels()" title="采集池内在线设备的型号(getprop)">采集型号</button>
|
||||||
<button class="btn" onclick="loadDevPool()">刷新</button>
|
<button class="btn" onclick="loadDevPool()">刷新</button>
|
||||||
|
<label style="display:inline-flex;align-items:center;gap:4px;margin:0 6px">
|
||||||
|
<input type="checkbox" id="devpool-filter-online" checked onchange="loadDevPool()"> 只看在线
|
||||||
|
</label>
|
||||||
<span id="devpool-status" class="text-muted"></span>
|
<span id="devpool-status" class="text-muted"></span>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-hover table-sm" style="margin-top:8px">
|
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||||
@@ -673,6 +684,24 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 子分栏:设备分组(原顶级 tab,移到工具页) -->
|
||||||
|
<div class="sub-panel" id="tools-sub-groups">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn btn-primary" onclick="openGroupModal()" data-perm="tasks">新建分组</button>
|
||||||
|
<button class="btn" onclick="loadGroups()">刷新</button>
|
||||||
|
<input class="form-control list-search" id="search-groups" placeholder="搜索分组名/描述...">
|
||||||
|
<span id="groups-status" class="text-muted"></span>
|
||||||
|
</div>
|
||||||
|
<table class="table table-hover table-sm" style="margin-top:8px">
|
||||||
|
<thead><tr>
|
||||||
|
<th data-sort="name" onclick="_lpSort('groups','name')">分组名</th><th>描述</th><th data-sort="count" onclick="_lpSort('groups','count')">设备数</th><th>设备列表</th><th>操作</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody id="tb-groups"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="list-pager" id="pager-groups"></div>
|
||||||
|
<div class="help">管理设备分组,用于任务按分组执行。分组内的设备需先在<b>设备池管理</b>中确认(不在池内的设备不参与调度)。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /.content -->
|
</div><!-- /.content -->
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ main{position:relative;z-index:2;height:calc(100vh - 88px);overflow-y:auto;paddi
|
|||||||
<header>
|
<header>
|
||||||
<div class="title">设备监控大屏</div>
|
<div class="title">设备监控大屏</div>
|
||||||
<div class="stats" id="wall-stats"></div>
|
<div class="stats" id="wall-stats"></div>
|
||||||
|
<label class="wall-btn" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer">
|
||||||
|
<input type="checkbox" id="wall-filter-online" checked onchange="refreshStatus()"> 只看在线
|
||||||
|
</label>
|
||||||
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
|
<button class="wall-btn" id="btn-wake-all" onclick="wakeAll()">💡 一键亮屏</button>
|
||||||
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
|
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
|
||||||
</header>
|
</header>
|
||||||
@@ -203,11 +206,18 @@ async function refreshStatus(){
|
|||||||
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]));
|
JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.device_name,d.model]));
|
||||||
_devices=devs;
|
_devices=devs;
|
||||||
renderStats(devs);
|
renderStats(devs);
|
||||||
if(changed)renderGrid(devs);
|
if(changed)renderGrid(visibleDevs(devs));
|
||||||
// 操作弹窗打开时同步刷新其标题状态
|
// 操作弹窗打开时同步刷新其标题状态
|
||||||
if(_ctrlSerial)updateCtrlHead();
|
if(_ctrlSerial)updateCtrlHead();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「只看在线」勾选时过滤离线设备(默认勾选)
|
||||||
|
function visibleDevs(devs){
|
||||||
|
const onlyOnline=document.getElementById('wall-filter-online')
|
||||||
|
? document.getElementById('wall-filter-online').checked : true;
|
||||||
|
return onlyOnline? devs.filter(d=>d.present) : devs;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStats(devs){
|
function renderStats(devs){
|
||||||
const online=devs.filter(d=>d.present).length;
|
const online=devs.filter(d=>d.present).length;
|
||||||
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
|
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
|
||||||
@@ -391,14 +401,18 @@ function updateCtrlHead(){
|
|||||||
document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)');
|
document.getElementById('ctrl-sub').textContent=_ctrlSerial+(d.present?'':'(离线)');
|
||||||
}
|
}
|
||||||
// 坐标映射:显示尺寸 -> 设备原生分辨率
|
// 坐标映射:显示尺寸 -> 设备原生分辨率
|
||||||
|
// 图片 object-fit:contain 时实际绘制区域小于盒子(居中留黑边),
|
||||||
|
// 必须按绘制区域换算,否则黑边/偏移导致点击错位
|
||||||
function ctrlCoords(ev){
|
function ctrlCoords(ev){
|
||||||
const img=document.getElementById('ctrl-img');
|
const img=document.getElementById('ctrl-img');
|
||||||
if(!img.naturalWidth)return[null,null];
|
if(!img.naturalWidth||!img.naturalHeight)return[null,null];
|
||||||
const r=img.getBoundingClientRect();
|
const r=img.getBoundingClientRect();
|
||||||
return[
|
const scale=Math.min(r.width/img.naturalWidth,r.height/img.naturalHeight);
|
||||||
Math.round((ev.clientX-r.left)/r.width*img.naturalWidth),
|
const drawW=img.naturalWidth*scale, drawH=img.naturalHeight*scale;
|
||||||
Math.round((ev.clientY-r.top)/r.height*img.naturalHeight),
|
const ox=(r.width-drawW)/2, oy=(r.height-drawH)/2; // contain 偏移
|
||||||
];
|
const px=ev.clientX-r.left, py=ev.clientY-r.top;
|
||||||
|
if(px<ox||px>ox+drawW||py<oy||py>oy+drawH)return[null,null]; // 黑边点击忽略
|
||||||
|
return[Math.round((px-ox)/scale),Math.round((py-oy)/scale)];
|
||||||
}
|
}
|
||||||
function ctrlTap(x,y){
|
function ctrlTap(x,y){
|
||||||
if(x==null||!x)return;
|
if(x==null||!x)return;
|
||||||
|
|||||||
+85
-1
@@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|||||||
|
|
||||||
from flask import Blueprint, jsonify, request
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
from core import device_pool
|
from core import device_pool, device_discovery
|
||||||
from core.adb_helper import adb_connect
|
from core.adb_helper import adb_connect
|
||||||
from core.logger import get_logger
|
from core.logger import get_logger
|
||||||
from web.auth import perm_required, PERM_DEVICES
|
from web.auth import perm_required, PERM_DEVICES
|
||||||
@@ -113,6 +113,90 @@ def api_devices_pool_refresh_models():
|
|||||||
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
|
return jsonify({"ok": True, "msg": "型号采集已启动(后台并发,约 10 秒)"})
|
||||||
|
|
||||||
|
|
||||||
|
# ================== API:设备自动发现(扫描 → 待连接池 → 确认) ==================
|
||||||
|
# 扫描只把设备放进待连接池(pending),用户确认后才加入正式设备池并连接。
|
||||||
|
@bp.route("/api/devices/discovery", methods=["GET"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_devices_discovery():
|
||||||
|
"""发现状态 + 待连接列表(前端 10s 轮询一次请求)。"""
|
||||||
|
try:
|
||||||
|
status = device_discovery.get_status()
|
||||||
|
pending = device_discovery.list_pending()
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"discovery 状态获取失败: {e}")
|
||||||
|
return jsonify({"ok": False, "error": str(e)[:120]}), 503
|
||||||
|
status["pending"] = pending
|
||||||
|
return jsonify({"ok": True, **status})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/devices/discovery/scan", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_devices_discovery_scan():
|
||||||
|
"""手动触发一轮扫描(后台执行,约 5-30 秒)。"""
|
||||||
|
ok, result = device_discovery.scan_once(manual=True)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": result.get("error", "扫描失败")}), 409
|
||||||
|
if "error" in result:
|
||||||
|
return jsonify({"ok": False, "error": result["error"]}), 400
|
||||||
|
return jsonify({"ok": True, "msg": "扫描已启动(后台执行,约 5-30 秒)",
|
||||||
|
"result": result})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/devices/discovery/confirm", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_devices_discovery_confirm():
|
||||||
|
"""确认连接:待连接 → 正式设备池 + 后台 adb connect + 采型号。"""
|
||||||
|
data = request.json or {}
|
||||||
|
serial = (data.get("serial") or "").strip()
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
ok, msg, is_new = device_discovery.confirm_pending(
|
||||||
|
serial, name=(data.get("name") or "").strip())
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": msg}), 404
|
||||||
|
if ":" in serial:
|
||||||
|
def _connect():
|
||||||
|
try:
|
||||||
|
adb_connect(serial)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
device_pool.refresh_model(serial)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
threading.Thread(target=_connect, daemon=True).start()
|
||||||
|
_log.info(f"设备池管理: 发现确认 {serial}")
|
||||||
|
return jsonify({"ok": True, "msg": msg, "is_new": is_new})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/devices/discovery/ignore", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_devices_discovery_ignore():
|
||||||
|
"""忽略:从待连接池删除(下轮扫描可能再次发现)。"""
|
||||||
|
serial = (request.json or {}).get("serial", "").strip()
|
||||||
|
if not serial:
|
||||||
|
return jsonify({"ok": False, "error": "缺少 serial"}), 400
|
||||||
|
ok, msg = device_discovery.ignore_pending(serial)
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": msg}), 404
|
||||||
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/devices/discovery/settings", methods=["POST"])
|
||||||
|
@perm_required(PERM_DEVICES)
|
||||||
|
def api_devices_discovery_settings():
|
||||||
|
"""保存发现配置(部分字段更新):{enabled?, subnets?, interval?, port?}。"""
|
||||||
|
data = request.json or {}
|
||||||
|
ok, msg = device_discovery.save_settings(
|
||||||
|
enabled=data.get("enabled"),
|
||||||
|
subnets=data.get("subnets"),
|
||||||
|
interval=data.get("interval"),
|
||||||
|
port=data.get("port"))
|
||||||
|
if not ok:
|
||||||
|
return jsonify({"ok": False, "error": msg}), 400
|
||||||
|
return jsonify({"ok": True, "msg": msg})
|
||||||
|
|
||||||
|
|
||||||
# ================== API:Tailscale 管理(仅管理员) ==================
|
# ================== API:Tailscale 管理(仅管理员) ==================
|
||||||
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
# 通过 Tailscale 官方 API v2 管理 tailnet 设备(列表/改名/授权/密钥不过期/删除/生成 auth key)。
|
||||||
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
# 设备 IP 由 tailnet 自动分配,API 无法修改,列表只读展示。
|
||||||
|
|||||||
+55
-4
@@ -416,10 +416,34 @@ def api_screen_stream():
|
|||||||
mimetype="multipart/x-mixed-replace; boundary=frame")
|
mimetype="multipart/x-mixed-replace; boundary=frame")
|
||||||
|
|
||||||
|
|
||||||
|
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
|
||||||
|
|
||||||
|
|
||||||
def _screen_get_device(serial):
|
def _screen_get_device(serial):
|
||||||
"""远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。"""
|
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
|
||||||
|
|
||||||
|
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
|
||||||
|
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
|
||||||
|
由调用方把异常转 503。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
import uiautomator2 as u2
|
import uiautomator2 as u2
|
||||||
return u2.connect(serial)
|
now = time.time()
|
||||||
|
cached = _screen_u2_cache.get(serial)
|
||||||
|
if cached and now - cached[0] < 60:
|
||||||
|
return cached[1]
|
||||||
|
d = u2.connect(serial)
|
||||||
|
_screen_u2_cache[serial] = (now, d)
|
||||||
|
if len(_screen_u2_cache) > 30: # 惰性清理过期项
|
||||||
|
for k in list(_screen_u2_cache):
|
||||||
|
if now - _screen_u2_cache[k][0] >= 60:
|
||||||
|
del _screen_u2_cache[k]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_invalidate(serial):
|
||||||
|
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
|
||||||
|
_screen_u2_cache.pop(serial, None)
|
||||||
|
|
||||||
@bp.route("/api/screen/thumb")
|
@bp.route("/api/screen/thumb")
|
||||||
@perm_required(PERM_DEVICES)
|
@perm_required(PERM_DEVICES)
|
||||||
@@ -464,6 +488,7 @@ def api_screen_tap():
|
|||||||
_screen_get_device(serial).click(int(x), int(y))
|
_screen_get_device(serial).click(int(x), int(y))
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_screen_invalidate(serial)
|
||||||
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
|
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
|
||||||
|
|
||||||
@bp.route("/api/screen/swipe", methods=["POST"])
|
@bp.route("/api/screen/swipe", methods=["POST"])
|
||||||
@@ -480,6 +505,7 @@ def api_screen_swipe():
|
|||||||
duration=float(data.get("duration", 0.2)))
|
duration=float(data.get("duration", 0.2)))
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_screen_invalidate(serial)
|
||||||
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
|
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
|
||||||
|
|
||||||
|
|
||||||
@@ -498,6 +524,7 @@ def api_screen_key():
|
|||||||
_screen_get_device(serial).press(key)
|
_screen_get_device(serial).press(key)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_screen_invalidate(serial)
|
||||||
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
|
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
|
||||||
|
|
||||||
@bp.route("/api/screen/text", methods=["POST"])
|
@bp.route("/api/screen/text", methods=["POST"])
|
||||||
@@ -528,8 +555,32 @@ def _current_focus_pkg(serial):
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
_screen_u2_cache = {} # serial -> (ts, device):大屏高频触控复用连接(60s TTL)
|
||||||
|
|
||||||
|
|
||||||
def _screen_get_device(serial):
|
def _screen_get_device(serial):
|
||||||
"""远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。"""
|
"""远程看屏用的 u2 连接(60s 缓存复用,避免每次 tap/swipe 都重建连接)。
|
||||||
|
|
||||||
|
大屏高频操作(点击/滑动/按键)每次 u2.connect 有 1-3s 握手开销(atx-agent
|
||||||
|
探测),体验卡顿。缓存 60s 内复用;连接失败(设备离线/重启)时清缓存重建,
|
||||||
|
由调用方把异常转 503。
|
||||||
|
"""
|
||||||
|
import time
|
||||||
import uiautomator2 as u2
|
import uiautomator2 as u2
|
||||||
return u2.connect(serial)
|
now = time.time()
|
||||||
|
cached = _screen_u2_cache.get(serial)
|
||||||
|
if cached and now - cached[0] < 60:
|
||||||
|
return cached[1]
|
||||||
|
d = u2.connect(serial)
|
||||||
|
_screen_u2_cache[serial] = (now, d)
|
||||||
|
if len(_screen_u2_cache) > 30: # 惰性清理过期项
|
||||||
|
for k in list(_screen_u2_cache):
|
||||||
|
if now - _screen_u2_cache[k][0] >= 60:
|
||||||
|
del _screen_u2_cache[k]
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _screen_invalidate(serial):
|
||||||
|
"""u2 调用失败时清掉缓存(设备离线/atx-agent 重启),下次请求重新连接。"""
|
||||||
|
_screen_u2_cache.pop(serial, None)
|
||||||
|
|
||||||
|
|||||||
+39
-31
@@ -94,29 +94,9 @@ def api_tools_clipboard_set():
|
|||||||
|
|
||||||
|
|
||||||
def _set_device_clipboard(serial, text):
|
def _set_device_clipboard(serial, text):
|
||||||
"""向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。
|
"""向单台设备注入剪贴板文字(ClipInject 通道,见 core.clipboard_helper)。"""
|
||||||
|
from core.clipboard_helper import inject_clipboard
|
||||||
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
|
return inject_clipboard(serial, text)
|
||||||
- USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent
|
|
||||||
- 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求
|
|
||||||
"""
|
|
||||||
import uiautomator2 as u2
|
|
||||||
if ":" in serial:
|
|
||||||
try:
|
|
||||||
adb_connect(serial)
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"adb 连接失败: {e}"
|
|
||||||
d = None
|
|
||||||
try:
|
|
||||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
||||||
d = pool.submit(u2.connect, serial).result(timeout=30)
|
|
||||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
||||||
pool.submit(d.set_clipboard, text).result(timeout=15)
|
|
||||||
return True, "已注入"
|
|
||||||
except FuturesTimeout:
|
|
||||||
return False, "连接/注入超时(atx-agent 可能无响应)"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
|
||||||
|
|
||||||
|
|
||||||
def _app_ver_on_device(serial, pkg):
|
def _app_ver_on_device(serial, pkg):
|
||||||
@@ -186,29 +166,57 @@ def _blocked_adb_cmd(cmd):
|
|||||||
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
return any(p in low for p in _ADB_BLOCKED_PATTERNS)
|
||||||
|
|
||||||
def _set_device_clipboard(serial, text):
|
def _set_device_clipboard(serial, text):
|
||||||
"""向单台设备注入剪贴板文字(u2 jsonrpc.setClipboard)。
|
"""向单台设备注入剪贴板文字(ClipInject APK:透明 Activity 聚焦后写入)。
|
||||||
|
|
||||||
|
Android 10+ 禁止后台(atx-agent/shell)写剪贴板,u2.set_clipboard 在
|
||||||
|
Android 12/13 上调用"成功"但内容被系统静默丢弃。ClipInject
|
||||||
|
(com.example.clipinject)通过 am start 启动透明 Activity,窗口聚焦后
|
||||||
|
setPrimaryClip,shell 启动 Activity 不受后台启动限制(广播方式在 MIUI
|
||||||
|
会被 Background activity start 拦截,必须用 am start)。
|
||||||
|
|
||||||
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
|
- IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线)
|
||||||
- USB 设备(无冒号):u2 按 serial 直连,首次会自动推送 atx-agent
|
- 设备未装 ClipInject:am start 返回 unable to resolve Intent,明确报错
|
||||||
- 连接/注入都带超时保护,避免 atx-agent 无响应时挂住请求
|
- 注入后 u2 读回比对验证(后台读剪贴板实测可用),不一致也明确报错
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import time
|
||||||
import uiautomator2 as u2
|
import uiautomator2 as u2
|
||||||
if ":" in serial:
|
if ":" in serial:
|
||||||
try:
|
try:
|
||||||
adb_connect(serial)
|
adb_connect(serial)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"adb 连接失败: {e}"
|
return False, f"adb 连接失败: {e}"
|
||||||
d = None
|
b64 = base64.b64encode(text.encode("utf-8")).decode()
|
||||||
|
# 1. am start 透明 Activity 写剪贴板(shell 身份启动,不受后台启动限制)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
[ADB_PATH, "-s", serial, "shell", "am", "start",
|
||||||
|
"-n", "com.example.clipinject/.ClipActivity",
|
||||||
|
"--es", "text_b64", b64],
|
||||||
|
capture_output=True, timeout=30)
|
||||||
|
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
|
||||||
|
if "does not exist" in out or "unable to resolve Intent" in out:
|
||||||
|
return False, ("设备未安装 ClipInject(com.example.clipinject),"
|
||||||
|
f"请先安装: {out.strip()[:100]}")
|
||||||
|
if "Error" in out or ("Starting" not in out and r.returncode != 0):
|
||||||
|
return False, f"am start 失败: {out.strip()[:120]}"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "am start 超时(adb 无响应)"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
||||||
|
# 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用)
|
||||||
|
time.sleep(0.5)
|
||||||
try:
|
try:
|
||||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
d = pool.submit(u2.connect, serial).result(timeout=30)
|
d = pool.submit(u2.connect, serial).result(timeout=30)
|
||||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||||
pool.submit(d.set_clipboard, text).result(timeout=15)
|
got = pool.submit(lambda: d.clipboard).result(timeout=15)
|
||||||
|
if got == text:
|
||||||
return True, "已注入"
|
return True, "已注入"
|
||||||
except FuturesTimeout:
|
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"
|
||||||
return False, "连接/注入超时(atx-agent 可能无响应)"
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f"{type(e).__name__}: {str(e)[:120]}"
|
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
|
||||||
|
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
|
||||||
|
|
||||||
def _app_ver_on_device(serial, pkg):
|
def _app_ver_on_device(serial, pkg):
|
||||||
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
|
"""单设备包版本查询:pm path 检查安装 → dumpsys 取 versionName/versionCode。
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import atexit
|
|||||||
import secrets
|
import secrets
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
@@ -44,6 +45,9 @@ init_db(app)
|
|||||||
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
# 设备池(本地清单 + adb 在线状态):任务调度的数据源
|
||||||
from core import device_pool
|
from core import device_pool
|
||||||
device_pool.init_app(app)
|
device_pool.init_app(app)
|
||||||
|
# 设备自动发现:定时扫描网段 → 待连接池(确认后才入池)
|
||||||
|
from core import device_discovery
|
||||||
|
device_discovery.init_app(app)
|
||||||
mgr = TaskManager(app=app)
|
mgr = TaskManager(app=app)
|
||||||
apk_mgr = ApkManager(app=app)
|
apk_mgr = ApkManager(app=app)
|
||||||
|
|
||||||
@@ -147,6 +151,33 @@ def _stop_uiauto():
|
|||||||
_uiauto_proc = None
|
_uiauto_proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def _preconnect_pool_devices():
|
||||||
|
"""容器/服务重启后 adb server 重启,设备全部掉线,逐个重连要几分钟;
|
||||||
|
启动时后台并发 connect 设备池里的网络设备(IP:5555),把恢复时间降到秒级。
|
||||||
|
|
||||||
|
USB 设备无需 connect(adb server 自动重枚举),只连网络设备。
|
||||||
|
预连接幂等:已连接的设备返回 already connected,无副作用。
|
||||||
|
"""
|
||||||
|
def _run():
|
||||||
|
try:
|
||||||
|
from core import device_pool
|
||||||
|
from core.adb_helper import _adb
|
||||||
|
serials = [s for s in device_pool.list_configured() if ":" in s]
|
||||||
|
if not serials:
|
||||||
|
return
|
||||||
|
_log.info(f"预连接设备池 {len(serials)} 台网络设备(重启后加速恢复)...")
|
||||||
|
import concurrent.futures
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as ex:
|
||||||
|
list(ex.map(lambda s: _adb("connect", s), serials))
|
||||||
|
out = _adb("devices")
|
||||||
|
online = sum(1 for ln in (out or "").splitlines()[1:]
|
||||||
|
if len(ln.split()) >= 2 and ln.split()[1] == "device")
|
||||||
|
_log.info(f"设备池预连接完成: {online} 台在线")
|
||||||
|
except Exception as e:
|
||||||
|
_log.warning(f"设备池预连接失败: {e}")
|
||||||
|
threading.Thread(target=_run, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _run_server(host, port):
|
def _run_server(host, port):
|
||||||
"""启动 Flask,自动处理端口冲突和权限问题。
|
"""启动 Flask,自动处理端口冲突和权限问题。
|
||||||
|
|
||||||
@@ -185,9 +216,12 @@ if __name__ == "__main__":
|
|||||||
from config import WEB_HOST, WEB_PORT
|
from config import WEB_HOST, WEB_PORT
|
||||||
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
# 自动启动 uiautodev 服务(元素抓取功能依赖,端口 20242)
|
||||||
_ensure_uiauto_running()
|
_ensure_uiauto_running()
|
||||||
|
# 预连接设备池:adb server 重启后设备全掉线,后台并发重连加速恢复
|
||||||
|
_preconnect_pool_devices()
|
||||||
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
|
||||||
try:
|
try:
|
||||||
_run_server(WEB_HOST, WEB_PORT)
|
_run_server(WEB_HOST, WEB_PORT)
|
||||||
finally:
|
finally:
|
||||||
mgr.shutdown()
|
mgr.shutdown()
|
||||||
|
device_discovery.shutdown()
|
||||||
_stop_uiauto()
|
_stop_uiauto()
|
||||||
|
|||||||
Reference in New Issue
Block a user