4 Commits
Author SHA1 Message Date
butubb 473dc1909d feat: 前端整体增强(应用查看UI/元素选择/步骤编辑器/拖拽/概率/分页/动作管理)
- 应用Tab:设备已装应用区块(应用名+版本+versionCode+路径)、安装弹窗设备显示 IP·型号
- 元素抓取:截图按 bounds 命中测试选最小元素,跳过全屏容器 overlay
- 步骤编辑器:输入实时同步+localStorage草稿自动保存(防丢内容)
- 拖拽修复:画布 drop 只绑定一次+stopPropagation+延迟渲染,可拖入循环块且不重复
- 步骤概率触发字段、循环按轮次/时间切换、监控页预计结束时间
- 全部列表统一搜索+分页(设备/任务/应用/分组/用户/动作)
- 自定义动作管理模块(列表/新建/编辑/删除)、步骤编辑器容器化、嵌套循环移动修复
2026-08-08 16:31:06 +08:00
butubb fcbdb0a8a1 feat: 通用步骤支持概率触发、循环按时间、预计结束时间上报
- 步骤新增 probability 参数:<100 时按百分比概率决定是否执行(实现"偶尔点赞")
- loop 支持 loop_mode=rounds/time,按时间循环跑满 loop_duration 秒
- BaseWorker._start_timer 上报 end_time(开始+max_duration),task_manager 透出
2026-08-08 16:30:54 +08:00
butubb 4f29762121 feat: 设备已装应用查看(含应用名/版本号)
- adb_helper.list_installed_apps:pm list + pm dump 取包名/versionCode/versionName/APK路径
- 应用名解析:常见应用映射 + 小APK流式pyaxmlparser解析(并行+缓存)+ 包名兜底
- web 新增 GET /api/devices/<serial>/apps
2026-08-08 16:30:49 +08:00
butubb 3a38d9a57f fix: adb 命令超时 + STF 释放失败检查,杜绝设备卡死/占用悬空
- adb 所有命令加 30s 超时:connect 到不可达地址不再无限挂起(worker 不再卡 connecting)
- STF release 检查响应状态并重试:504 等失败不再被静默吞掉,释放失败会真实上报
- release_all_mine 返回 (released, failed);web /api/release 透出失败列表
2026-08-08 16:30:42 +08:00
7 changed files with 793 additions and 173 deletions
+151 -1
View File
@@ -14,6 +14,9 @@ from core.logger import get_logger
_log = get_logger("core.adb")
_ADB_LOCK = threading.Lock()
# adb 命令超时(秒)。adb connect 到不可达主机(如 Tailscale IP 不通)会长时间挂起,
# 没有超时会让 worker 永久卡在 connecting。connect/disconnect 正常都应瞬时完成。
_ADB_TIMEOUT = 30
def _adb(*args):
@@ -21,8 +24,13 @@ def _adb(*args):
用 bytes 模式自己解码(Windows 下 subprocess text=True 会用 GBK 读 adb 输出,
遇到非 GBK 字节会崩溃)。
带 30s 超时:adb connect 到不可达地址可能挂很久,超时返回空串由调用方处理。
"""
r = subprocess.run([ADB_PATH, *args], capture_output=True)
try:
r = subprocess.run([ADB_PATH, *args], capture_output=True, timeout=_ADB_TIMEOUT)
except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
out = (r.stdout or b"") + (r.stderr or b"")
try:
return out.decode("utf-8", errors="replace").strip()
@@ -146,6 +154,148 @@ def screenshot(serial, timeout=5):
return False, str(e)
# 常见应用名映射:快速识别大/重要应用,避免拉取 APK 解析(慢)
COMMON_APPS = {
"com.ss.android.ugc.aweme": "抖音",
"com.ss.android.ugc.aweme.lite": "抖音极速版",
"com.smile.gifmaker": "快手",
"com.kuaishou.nebula": "快手极速版",
"com.tencent.mm": "微信",
"com.tencent.mobileqq": "QQ",
"com.tencent.qqlive": "腾讯视频",
"com.eg.android.AlipayGphone": "支付宝",
"com.taobao.taobao": "淘宝",
"com.tmall.wireless": "天猫",
"com.jingdong.app.mall": "京东",
"com.xunmeng.pinduoduo": "拼多多",
"com.ss.android.article.news": "今日头条",
"com.zhihu.android": "知乎",
"com.miui.home": "桌面",
"com.android.settings": "设置",
"com.android.systemui": "系统",
"com.github.uiautomator": "ATX",
"com.github.uiautomator.test": "ATX测试",
"jp.co.cyberagent.stf": "STF服务",
"moe.shizuku.privileged.api": "Shizuku",
"moe.shizuku.api": "Shizuku",
"io.tailscale.android": "Tailscale",
"com.immomo.momo": "陌陌",
"com.xingin.xhs": "小红书",
}
# 解析应用名时,超过该大小的 APK 不拉取(太慢),用映射或包名兜底
_LABEL_PARSE_MAX_BYTES = 5 * 1024 * 1024 # 5MB
# 应用名缓存:(serial, package) -> label
_APP_LABEL_CACHE = {}
def list_installed_apps(serial, timeout=40):
"""获取设备上已安装应用列表:应用名 + 包名 + versionCode + versionName + APK路径。
- `pm list packages --show-versioncode -f` 取包名/版本码/路径(快)
- `pm dump packages` 取人性化 versionName(实测 ~0.8s)
- 应用名 label:常见应用走映射;用户安装且较小(<=10MB)的未知应用流式拉 APK 用
pyaxmlparser 解析(带缓存),其余用包名兜底。
返回 (ok, apps) 或 (False, 错误消息)。
"""
if not serial:
return False, "缺少 serial"
try:
# 1. 包名 + versionCode + APK 路径
r1 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "list", "packages",
"--show-versioncode", "-f"],
capture_output=True, timeout=timeout)
apps = {}
for line in (r1.stdout or b"").decode("utf-8", errors="replace").splitlines():
line = line.strip()
m = re.match(r'package:(.+)=(\S+) versionCode:(\d+)', line)
if m:
apps[m.group(2)] = {"package": m.group(2), "path": m.group(1),
"version_code": int(m.group(3)), "version_name": "",
"label": COMMON_APPS.get(m.group(2), "")}
else:
m2 = re.match(r'package:(.+)=(\S+)', line)
if m2:
apps[m2.group(2)] = {"package": m2.group(2), "path": m2.group(1),
"version_code": 0, "version_name": "",
"label": COMMON_APPS.get(m2.group(2), "")}
# 2. 人性化版本号 versionName
try:
r2 = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "pm", "dump", "packages"],
capture_output=True, timeout=timeout)
out2 = (r2.stdout or b"").decode("utf-8", errors="replace")
for m in re.finditer(r'Package \[([^\]]+)\]', out2):
pkg = m.group(1)
if pkg not in apps:
continue
nxt = out2.find("Package [", m.end())
seg = out2[m.end():nxt if nxt != -1 else len(out2)]
vm = re.search(r'versionName=(\S+)', seg)
if vm:
apps[pkg]["version_name"] = vm.group(1)
except Exception:
pass # versionName 拿不到不阻塞
# 3. 应用名 label:用户安装且未映射的小 APK,流式拉取解析真实名
_resolve_app_labels(serial, apps, timeout)
return True, sorted(apps.values(), key=lambda a: a["package"])
except subprocess.TimeoutExpired:
return False, "查询超时"
except Exception as e:
return False, str(e)
def _resolve_app_labels(serial, apps, timeout):
"""并行解析用户安装小应用的显示名 label(带缓存)。失败/超时静默跳过。"""
import io as _io
from concurrent.futures import ThreadPoolExecutor
targets = [(pkg, a["path"]) for pkg, a in apps.items()
if a["path"].startswith("/data/") and not a["label"]
and (serial, pkg) not in _APP_LABEL_CACHE]
if not targets:
return
# 用 ls -l 一次拿所有候选 APK 大小,跳过过大的(流式拉大 APK 太慢)
sizes = {}
try:
rsz = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "ls", "-l"] + [p for _, p in targets],
capture_output=True, timeout=min(timeout, 15))
for line in (rsz.stdout or b"").decode("utf-8", errors="replace").splitlines():
parts = line.split()
# ls -l(toybox): 权限 链接 属主 属组 大小 日期 时间 路径 = 8 字段
if len(parts) >= 6:
try:
sizes[parts[-1]] = int(parts[4])
except ValueError:
pass
except Exception:
pass
small = [(pkg, path) for pkg, path in targets if sizes.get(path, 0) <= _LABEL_PARSE_MAX_BYTES]
def _fetch(pkg_path):
pkg, path = pkg_path
label = ""
try:
rp = subprocess.run(
[ADB_PATH, "-s", serial, "exec-out", "cat", path],
capture_output=True, timeout=min(timeout, 8))
from pyaxmlparser import APK as _APKParser
label = (_APKParser(_io.BytesIO(rp.stdout)).get_app_name() or "").strip()
except Exception:
label = ""
_APP_LABEL_CACHE[(serial, pkg)] = label
return pkg, label
if small:
try:
with ThreadPoolExecutor(max_workers=8) as pool:
for pkg, label in pool.map(_fetch, small):
if pkg in apps:
apps[pkg]["label"] = label
except Exception:
pass # label 解析失败不影响主流程
def identify_device(serial, duration=3):
"""让设备响铃以供识别(直连 serial,不经过 STF)。
+15 -8
View File
@@ -51,13 +51,17 @@ class STFDevice:
raise STFError(f"占用设备失败: {msg}", "conflict")
_log.info(f"[{self.serial}] STF 占用成功")
# 优先直连设备 serial(当 serial 本身就是 IP:5555 时,如 Tailscale 网络)
# 这比 STF remoteConnect 桥接更稳定,避免桥接地址认证失败
# 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
# 实测(本机 100.100.10.2,设备 100.100.10.x):
# - 本机在 tailnet 内时直连可靠且快(<1s),且只 connect、绝不 disconnect/kill-server,
# 不会影响 STF provider 共享的 adb 连接(STF 端设备保持 present/ready)。
# - STF remoteConnect 桥接在此环境反而有 adb key 认证问题(隧道显示 unauthorized,
# 即使设备已授权本机 key),直连才是可靠路径。
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接。
if ":" in self.serial:
self.remote_adb_url = self.serial
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
else:
# serial 不是 IP:port 格式,走 STF remoteConnect 桥接
self.remote_adb_url = self.stf.remote_connect(self.serial)
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
@@ -75,18 +79,18 @@ class STFDevice:
if not self.serial:
return
# 直连模式(remote_adb_url == serial):不 disconnect
# STF agent 可能共享同一个 adb 连接,disconnect 会导致 STF 设备离线后重新连接
# STF provider 共享该 IP:5555 的 adb transport,disconnect 会让 STF 误判离线并重连
if self.remote_adb_url and self.remote_adb_url != self.serial:
adb_disconnect(self.remote_adb_url)
try:
self.stf.remote_disconnect(self.serial)
except Exception:
pass
try:
self.stf.release(self.serial)
ok, msg = self.stf.release(self.serial)
if ok:
_log.info(f"[{self.serial}] 已释放")
except Exception as e:
_log.warning(f"[{self.serial}] 释放失败: {e}")
else:
_log.warning(f"[{self.serial}] STF 释放失败: {msg}")
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
@@ -242,6 +246,9 @@ class BaseWorker(threading.Thread):
def _start_timer(self):
"""子类在 run_task 开头调用,启动运行时长计时。"""
self._start_time = time.time()
if self.max_duration > 0:
# 预计结束时间(前端监控页展示),到点自动停止
_update_status(self.serial, end_time=self._start_time + self.max_duration)
def is_time_up(self):
"""是否已达最大运行时长。max_duration=0 时永远返回 False。"""
+32 -14
View File
@@ -120,27 +120,45 @@ class STFClient:
return False, str(e)
return False, "占用冲突重试耗尽"
def release(self, serial):
"""释放设备占用(含远程断开)。"""
def release(self, serial, retries=3):
"""释放设备占用。返回 (ok, msg)。
STF 释放可能因设备响应超时返回 504(如设备过载/卡死),此时设备实际未释放。
必须检查响应状态并重试,不能静默吞掉——否则占用会一直悬着。
"""
self.remote_disconnect(serial)
try:
requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
except requests.exceptions.RequestException as e:
_log.warning(f"释放 {serial} 请求失败: {e}")
last_status = None
last_body = ""
for attempt in range(1, retries + 1):
try:
r = requests.delete(f"{self.base_url}/api/v1/user/devices/{serial}",
headers=self.headers, timeout=_TIMEOUT)
if r.status_code in (200, 201, 202, 204):
return True, "released"
last_status = r.status_code
last_body = r.text[:150]
_log.warning(f"释放 {serial} 失败 HTTP {r.status_code}: {last_body} ({attempt}/{retries})")
except requests.exceptions.RequestException as e:
_log.warning(f"释放 {serial} 请求异常 ({attempt}/{retries}): {e}")
last_status = -1
last_body = str(e)
if attempt < retries:
time.sleep(2)
return False, f"STF 释放失败(HTTP {last_status}): {last_body}"
def release_all_mine(self):
"""释放当前账户占用的所有设备(清理用)。"""
released = []
"""释放当前账户占用的所有设备(清理用)。返回 (released, failed)。"""
released, failed = [], []
for d in self.list_my_devices():
serial = d["serial"]
try:
self.release(serial)
ok, msg = self.release(serial)
if ok:
released.append(serial)
_log.info(f"已释放 {serial}")
except Exception as e:
_log.error(f"释放 {serial} 失败: {e}")
return released
else:
failed.append(serial)
_log.error(f"释放 {serial} 失败: {msg}")
return released, failed
# ================== 远程 ADB 隧道 ==================
def remote_connect(self, serial):
+1
View File
@@ -735,6 +735,7 @@ class TaskManager:
"running_job": r.get("job_id", ""),
"task_job": w.get("task_job", ""),
"attempt": r.get("attempt", 0),
"end_time": w.get("end_time", 0),
})
return result
+29 -7
View File
@@ -21,6 +21,7 @@ worker 按 steps 顺序执行,支持 loop 步骤循环、停止信号、进度
group - 动作组(含 children 步骤列表,按序执行一次,可折叠复用)
"""
import random
import time
from tasks.base import BaseTask, register_task
from core.device_worker import BaseWorker, _update_status
@@ -43,7 +44,7 @@ STEP_TYPES = [
{"type": "wait", "label": "等待", "icon": "⏱",
"params": {"min": 1.0, "max": 3.0}},
{"type": "loop", "label": "循环块", "icon": "↻",
"params": {"max_iterations": 10, "children": []}},
"params": {"loop_mode": "rounds", "max_iterations": 10, "loop_duration": 600, "children": []}},
{"type": "group", "label": "动作组", "icon": "📦",
"params": {"children": []}},
]
@@ -112,6 +113,11 @@ class GenericStepsWorker(BaseWorker):
stype = step.get("type", "")
label = step.get("label", stype)
params = step.get("params", {})
# 概率触发:probability=100 必执行,<100 按百分比概率决定本次是否执行
prob = float(params.get("probability", 100))
if prob < 100 and random.random() * 100 > prob:
_log.info(f"[{self.serial}] 步骤 '{label}' 概率 {prob}% 未触发,跳过")
return
self.set_action(f"执行: {label}")
_log.info(f"[{self.serial}] 步骤: {label}({stype}) params={params}")
@@ -196,16 +202,32 @@ class GenericStepsWorker(BaseWorker):
random_sleep(min_s, max_s)
def _exec_loop(self, d, params, depth=0):
max_iter = int(params.get("max_iterations", 10))
children = params.get("children", [])
if not children:
return
for i in range(max_iter):
if self.stopped() or self.is_time_up():
mode = params.get("loop_mode", "rounds")
if mode == "time":
# 按时间循环:跑满 loop_duration 秒,同时受全局 stopped/max_duration 约束
duration = float(params.get("loop_duration", 0) or 0)
if duration <= 0:
_log.warning(f"[{self.serial}] 按时间循环未设置时长,跳过")
return
self.set_action(f"循环第 {i+1}/{max_iter} 轮")
_log.info(f"[{self.serial}] loop 第 {i+1}/{max_iter} 轮")
self._exec_steps(d, children, depth + 1)
start = time.time()
i = 0
while not self.stopped() and not self.is_time_up() and (time.time() - start) < duration:
i += 1
self.set_action(f"循环第 {i} 轮(按时间)")
_log.info(f"[{self.serial}] loop 第 {i} 轮(按时间, 已{(time.time()-start):.0f}s/{duration:.0f}s)")
self._exec_steps(d, children, depth + 1)
else:
# 按轮次循环
max_iter = int(params.get("max_iterations", 10))
for i in range(max_iter):
if self.stopped() or self.is_time_up():
return
self.set_action(f"循环第 {i+1}/{max_iter} 轮")
_log.info(f"[{self.serial}] loop 第 {i+1}/{max_iter} 轮")
self._exec_steps(d, children, depth + 1)
def _exec_group(self, d, params, depth=0):
"""动作组:按序执行 children 一次(类似 loop 但不循环)。
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -29,7 +29,7 @@ from core.task_manager import TaskManager
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
from core.models import db, init_db, User, DeviceGroup, TaskJob, CustomAction
from core.apk_manager import ApkManager
from core.adb_helper import screenshot
from core.adb_helper import screenshot, list_installed_apps
from core import uiauto_helper
from tasks import list_task_types, get_task_class
@@ -346,8 +346,8 @@ def api_stop_all():
@app.route("/api/release", methods=["POST"])
@login_required
def api_release():
released = stf.release_all_mine()
return jsonify({"ok": True, "released": released})
released, failed = stf.release_all_mine()
return jsonify({"ok": True, "released": released, "failed": failed})
@app.route("/api/device/screenshot")
@@ -370,6 +370,16 @@ def api_device_screenshot():
return jsonify({"ok": False, "error": data}), 500
@app.route("/api/devices/<serial>/apps")
@login_required
def api_device_apps(serial):
"""获取指定设备上已安装的应用列表(包名 + versionCode + versionName + 路径)。"""
ok, data = list_installed_apps(serial)
if ok:
return jsonify({"ok": True, "apps": data})
return jsonify({"ok": False, "error": data}), 500
# ================== 自定义动作(步骤打包) ==================
@app.route("/api/custom_actions")
@login_required