阶段2: USB 远程 adb server 打通(220 host 网络 5037 全接口监听,无需改 220)+ 自建远程看屏(MJPEG 流 + u2 触控 tap/swipe/key/text,ws-scrcpy 不在 npm 改自建)

This commit is contained in:
2026-08-17 16:50:12 +08:00
parent 5354fe6379
commit 645938b305
10 changed files with 466 additions and 50 deletions
+7
View File
@@ -80,6 +80,13 @@ STF_DOCKER_CONTAINER = _env("STF_DOCKER_CONTAINER", "")
STF_ADB_CONTAINER = _env("STF_ADB_CONTAINER", "")
STF_SCRIPT_PATH = _env("STF_SCRIPT_PATH", "")
# ================== USB 设备远程 adb server(阶段 2) ==================
# USB 设备插在 220(STF 主机)上,本机 adb 看不到;220 的 adb 容器是 host 网络
# 模式,5037 已监听所有网卡(含 Tailscale),平台用 adb -H/-P 直接驱动。
# 平台自身 USB(serial 无冒号且在本机 adb 里)不受影响,仍走本地。
USB_ADB_HOST = _env("USB_ADB_HOST", "100.100.10.1") # 220 的 Tailscale IP
USB_ADB_PORT = int(_env("USB_ADB_PORT", "5037"))
# ================== Tailscale 管理(工具页) ==================
# 调用官方 API v2 管理 tailnet 设备。配置从 .env 读取:
# TAILSCALE_API_KEY:后台 → Settings → API Access Tokens 生成(或 OAuth client_id:secret)
+55
View File
@@ -31,6 +31,26 @@ def _adb(*args):
except subprocess.TimeoutExpired:
_log.warning(f"adb {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
return _decode(r)
def _adb_remote(host, port, *args):
"""在远程 adb server(如 220)上执行 adb 命令,返回文本。
USB 设备插在 220,本机 adb server 看不到,必须 -H/-P 指向 220 的 adb server
(host 网络模式,5037 全接口监听,Tailscale 可达)。超时语义同 _adb。
"""
try:
r = subprocess.run([ADB_PATH, "-H", host, "-P", str(port), *args],
capture_output=True, timeout=_ADB_TIMEOUT)
except subprocess.TimeoutExpired:
_log.warning(f"adb -H {host} -P {port} {' '.join(args)} 超时 {_ADB_TIMEOUT}s")
return ""
return _decode(r)
def _decode(r):
"""subprocess 结果解码(Windows GBK 兼容)。"""
out = (r.stdout or b"") + (r.stderr or b"")
try:
return out.decode("utf-8", errors="replace").strip()
@@ -77,6 +97,41 @@ def get_foreground_app(remote_adb_url, timeout=3):
return None
def get_foreground_app_remote(host, port, serial, timeout=3):
"""远程 adb server(220)上设备的前台 App(USB 设备用)。
与 get_foreground_app 同逻辑,只是 adb 指向远程 server(-H/-P)。
"""
if not serial:
return None
try:
r = subprocess.run(
[ADB_PATH, "-H", host, "-P", str(port), "-s", serial, "shell",
"dumpsys window | grep mCurrentFocus"],
capture_output=True, timeout=timeout
)
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace").strip()
if not out:
return None
low = out.lower()
if "device offline" in low or "device not found" in low or "error:" in low:
return None
for line in out.split("\n"):
if "mCurrentFocus" not in line:
continue
m = re.search(r'\bu0\s+(\S+)', line)
if m:
token = m.group(1).rstrip("}")
if "/" in token:
return token.split("/")[0]
return token or None
return None
except subprocess.TimeoutExpired:
return None
except Exception:
return None
def adb_connect(remote_adb_url, retries=5):
"""adb connect(带重试)。返回 True/False。
+21 -2
View File
@@ -17,7 +17,8 @@ import time
from sqlalchemy import text
from core.adb_helper import _adb
from config import USB_ADB_HOST, USB_ADB_PORT
from core.adb_helper import _adb, _adb_remote
from core.logger import get_logger
from core.models import db, Device
@@ -61,13 +62,31 @@ def list_devices():
# ================== 在线状态 ==================
def list_online():
"""本机 adb devices 中 state=device 的设备(实时)。"""
"""本机 adb 中 state=device 的设备 + 池内 USB 设备(经 220 远程 adb server)。
池内无 USB 设备(serial 无冒号)时不查远程 server,零额外开销。
"""
out = _adb("devices") or ""
serials = []
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and parts[1] == "device":
serials.append(parts[0])
# 池内有 USB 设备时,补查远程 adb server(220,host 网络 5037 全接口监听)
try:
usb = [s for s in list_configured() if ":" not in s]
except Exception:
usb = []
if usb:
try:
out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or ""
for line in out.splitlines()[1:]:
parts = line.split()
if len(parts) >= 2 and parts[0] and parts[1] == "device":
if parts[0] not in serials:
serials.append(parts[0])
except Exception:
pass
return serials
+57 -32
View File
@@ -1,9 +1,9 @@
"""设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。
STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放)
BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop
STFDevice — 单设备生命周期:acquire(直连/远程 server)→ release
BaseWorker — 通用 worker 基类:封装设备生命周期 + u2 连接 + 状态上报 + stop
+ 生命周期钩子(setup/teardown/on_error) + 心跳上报
子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常
子类只需实现 run_task(d),可选实现钩子,不用管 adb/状态/异常
_WORKERS — 全局状态注册表,供 web_server 读取实时状态
_watchdog — 心跳看门狗线程,检测 worker 卡死
@@ -16,10 +16,11 @@ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeou
import uiautomator2 as u2
from config import USB_ADB_HOST, USB_ADB_PORT
from core.logger import get_logger
from core.stf_client import DeviceOfflineError, STFError
from core import device_pool
from .adb_helper import adb_connect, adb_disconnect
from .adb_helper import adb_connect, _adb_remote
_log = get_logger("core.worker")
@@ -35,15 +36,15 @@ _U2_INFO_TIMEOUT = 10
class STFDevice:
"""单设备生命周期:acquire(直连)→ release(无操作)。
阶段 1:已摘除 STF occupy/release——单实例互斥由 TaskManager._running 保证,
设备健康由 device_pool(本地 adb 在线状态)判断。保留 STF 引用仅为
非 IP:port(USB 序列号)设备的 remoteConnect 桥接,阶段 2 换成 220 adb server。
阶段 2:已摘除 STF occupy/release 与 remoteConnect 桥接——
单实例互斥由 TaskManager._running 保证;非 IP:port(USB 序列号)设备
通过 220 的远程 adb server(-H/-P)驱动,不再依赖 STF。
"""
def __init__(self, stf_client=None, serial=None):
self.stf = stf_client # 仅 USB 桥接用(阶段 2 移除)
def __init__(self, serial=None):
self.serial = serial
self.remote_adb_url = None
self.remote_adb_url = None # IP:5555 设备:设备地址
self.usb_server = None # USB 设备:远程 adb server 地址(host:port)
def acquire(self, serial=None):
self.serial = serial or self.serial or self._pick_free()
@@ -51,18 +52,21 @@ class STFDevice:
# 直连优先:serial 本身是 IP:5555(如 Tailscale 网络设备)时直接 adb connect。
# 只 connect、绝不 disconnect/kill-server(红线),不影响 STF provider 的连接。
# 仅当 serial 不是 IP:port(如 USB 序列号)时才走 STF 桥接(阶段 2 替换)。
if ":" in self.serial:
self.remote_adb_url = self.serial
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
if not adb_connect(self.remote_adb_url):
raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline")
else:
if self.stf is None:
raise STFError(f"USB 设备 {self.serial} 暂不支持(未启用 STF 桥接)", "offline")
self.remote_adb_url = self.stf.remote_connect(self.serial)
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
if not adb_connect(self.remote_adb_url):
raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline")
# USB 设备:插在 220 上,本机 adb 看不到,经远程 adb server 驱动。
# 无需 adb connect——设备已在远程 server 上,直接校验可见性。
self.usb_server = f"{USB_ADB_HOST}:{USB_ADB_PORT}"
_log.info(f"[{self.serial}] USB 设备,远程 adb server: {self.usb_server}")
out = _adb_remote(USB_ADB_HOST, USB_ADB_PORT, "devices") or ""
seen = {line.split()[0] for line in out.splitlines()[1:]
if len(line.split()) >= 2}
if self.serial not in seen:
raise STFError(f"USB 设备 {self.serial} 不在远程 adb server", "offline")
time.sleep(2)
def _pick_free(self):
@@ -72,15 +76,8 @@ class STFDevice:
return free[0]
def release(self):
if not self.serial:
return
# 直连模式(remote_adb_url == serial):不 disconnect(红线,STF 共享 transport)
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
# 直连模式:不 disconnect(红线,STF 共享 transport);USB:无连接可断
pass
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
@@ -349,15 +346,20 @@ class BaseWorker(threading.Thread):
# ================== 基类主循环(不要重写) ==================
def run(self):
"""基类主循环:不要重写。子类实现 run_task + 可选钩子。"""
device = STFDevice(self.stf, serial=self.serial)
device = STFDevice(serial=self.serial)
_update_status(self.serial, status="connecting", stf_occupied=False,
last_error="", remote_adb_url="", model="",
last_error="", remote_adb_url="", usb_server="", model="",
current_action="")
try:
device.acquire()
remote = device.remote_adb_url
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护
self.d = self._u2_connect_with_timeout(remote)
usb_server = device.usb_server
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护;
# USB 设备(无 IP:5555)经远程 adb server 建 u2 连接
if usb_server:
self.d = self._u2_connect_usb_with_timeout(usb_server)
else:
self.d = self._u2_connect_with_timeout(remote)
if self.d is None:
raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline")
_log.info(f"[{self.serial}] u2 连接成功")
@@ -369,7 +371,8 @@ class BaseWorker(threading.Thread):
except Exception:
pass
_update_status(self.serial, status="running", stf_occupied=True,
remote_adb_url=remote, model=model)
remote_adb_url=remote or "", usb_server=usb_server or "",
model=model)
# setup 钩子
try:
@@ -435,6 +438,28 @@ class BaseWorker(threading.Thread):
except FuturesTimeout:
_log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s")
return None
def _u2_connect_usb_with_timeout(self, server):
"""USB 设备:经远程 adb server(220)建 u2 连接,带超时保护。
u2 3.7 底层 adbutils 支持远程 adb server:AdbClient(host, port).device(serial),
再交给 u2.connect(AdbDevice)(与本地连接同语义,无需 adb connect)。
"""
host, _, port = server.partition(":")
with ThreadPoolExecutor(max_workers=1) as pool:
def _connect():
from adbutils import AdbClient
dev = AdbClient(host=host, port=int(port)).device(self.serial)
return u2.connect(dev)
fut = pool.submit(_connect)
try:
return fut.result(timeout=_U2_CONNECT_TIMEOUT)
except FuturesTimeout:
_log.error(f"[{self.serial}] u2 远程连接超时 {_U2_CONNECT_TIMEOUT}s")
return None
except Exception as e:
_log.error(f"[{self.serial}] u2 远程连接失败: {e}")
return None
except Exception as e:
_log.error(f"[{self.serial}] u2.connect 异常: {e}")
return None
+37 -9
View File
@@ -32,7 +32,7 @@ from core.logger import get_logger
from core.models import db, DeviceGroup as GroupRow, TaskJob as JobRow
from core import device_pool
from .stf_client import STFClient, DeviceOfflineError
from .adb_helper import get_foreground_app, adb_connect_light, adb_disconnect
from .adb_helper import get_foreground_app, get_foreground_app_remote
from .device_worker import (
get_all_worker_status, _update_status, _remove_worker,
_WORKERS, _WORKERS_LOCK,
@@ -260,22 +260,26 @@ class _ForegroundScanner:
worker_status = {w["serial"]: w for w in get_all_worker_status()}
# 分类设备
have_conn = {} # serial -> remote_adb_url(worker 运行中,已有 adb 连接)
have_conn = {} # serial -> remote_adb_url(IP:5555 运行中设备)
usb_conn = {} # serial -> usb_server(USB 运行中设备,经远程 adb server)
free_serials = [] # 空闲设备
for serial in online:
w = worker_status.get(serial, {})
url = w.get("remote_adb_url")
if url and w.get("status") in ("running", "connecting"):
# 1. 设备正在执行任务,已有 adb 连接,直接查询
have_conn[serial] = url
if w.get("status") not in ("running", "connecting"):
free_serials.append(serial)
elif w.get("remote_adb_url"):
# 1. IP:5555 设备正在执行任务,已有 adb 连接,直接查询
have_conn[serial] = w["remote_adb_url"]
elif w.get("usb_server"):
# 1b. USB 设备运行中:经远程 adb server 查询
usb_conn[serial] = w["usb_server"]
else:
# 2. 空闲设备
free_serials.append(serial)
results = {}
_log.info("前台 App 扫描分类: 运行中=%d, 空闲=%d",
len(have_conn), len(free_serials))
_log.info("前台 App 扫描分类: 运行中=%d, USB运行中=%d, 空闲=%d",
len(have_conn), len(usb_conn), len(free_serials))
# 1. worker 运行中设备:用已有 remote_adb_url 查询(并发 10)
if have_conn:
@@ -289,6 +293,18 @@ class _ForegroundScanner:
except Exception:
results[s] = None
# 1b. USB 运行中设备:经远程 adb server 查询(并发 5)
if usb_conn:
with ThreadPoolExecutor(max_workers=min(5, len(usb_conn))) as pool:
futures = {pool.submit(self._scan_usb_conn, s): s
for s, server in usb_conn.items()}
for fut in as_completed(futures, timeout=15):
s = futures[fut]
try:
results[s] = fut.result()
except Exception:
results[s] = None
# 2. 空闲设备:不打扰,直接返回"空闲"
for s in free_serials:
results[s] = "空闲"
@@ -303,6 +319,18 @@ class _ForegroundScanner:
self._scanning.clear()
_log.info("前台 App 扫描完成: %d 台设备", len(results))
def _scan_usb_conn(self, serial):
"""USB 运行中设备:经远程 adb server(220)查询前台 App。"""
try:
with _WORKERS_LOCK:
server = _WORKERS.get(serial, {}).get("usb_server", "")
if not server:
return None
host, _, port = server.partition(":")
return get_foreground_app_remote(host, int(port), serial)
except Exception:
return None
# ================== 任务管理器 ==================
class TaskManager:
+7 -7
View File
@@ -13,13 +13,13 @@ STF 当前仅提供:occupy/release 互斥、present+ready 健康信号、设
1. **设备清单放本地 SQLite**(data/users.db 新增 devices 表),220 的 connect_devices.sh
退役(其"每 5 分钟补连"职责由平台定时器 + SSH 兜底接管,或直接依赖 adb 重试)
2. **USB 有线设备保留支持**——当前舰队 0 台 USB(全 IP:5555),但能力要保留。
方案:220 的 adb 容器发布 5037 端口(改 docker 运行参数,唯一动 220 的地方),
平台通过 Tailscale 用 `adb -H <220> -P 5037` 和 `adbutils.Adb(host=220)`(u2 底层)
驱动远端 USB 设备;本机 USB 设备照旧走本地 adb。已验证 uiautomator2 3.7.0 底层
adbutils 支持远程 adb server
3. **网页远程看屏**——引入 **ws-scrcpy**(Node 服务,WebRTC 低延迟流 + 触控),
与平台同机部署、内嵌管理员页面 iframe;本机 adb 即数据源,无需 STF。
备选:自建 minicap JPEG 流 + u2 注入(2-3 天),或保留 STF 容器只做看屏(不推荐,依赖没摘干净)
实施中发现 **220 的 adb 容器是 host 网络模式、5037 已监听所有网卡(含 Tailscale
100.100.10.1),无需改动 220 任何配置**。平台用 `adb -H 100.100.10.1 -P 5037`
和 `adbutils.AdbClient(host=..., port=5037)`(u2 底层)驱动远端 USB 设备;
本机 USB 设备照旧走本地 adb。已验证(阶段 2 完成)
3. **网页远程看屏**——原计划 ws-scrcpy **不在 npm 分发**(仅 GitHub,国内下载不可靠),
改**自建**:MJPEG 流(u2/atx-agent minicap 截图,~5fps,实测 0.19s/帧)+ u2 触控
(tap/swipe/key/text),管理页新子分栏,零新依赖(阶段 2 完成)
---
+1
View File
@@ -110,6 +110,7 @@ function showSubTab(tabId, name){
const tab = document.getElementById('tab-'+tabId);
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));
if(name==='screen' && typeof loadScreenDevices==='function') loadScreenDevices();
}
// ================== 模态框 ==================
+121
View File
@@ -425,3 +425,124 @@ function copyTsKey(){
if(s)s.textContent='已复制';
}
// ================== Tab: 工具 - 远程看屏(MJPEG 流 + u2 触控) ==================
let _screenViewOn = false;
async function loadScreenDevices(force){
const sel=document.getElementById('screen-device');
if(!sel)return;
const list=await _fetchAdbDevices(force);
if(list===null)return;
const cur=sel.value;
const stateTxt={device:'在线',offline:'离线',pool:'设备池(未连接)',stf_not_ready:'STF未就绪'};
sel.innerHTML=list.map(d=>
'<option value="'+esc(d.serial)+'">'+esc(d.serial)+' ('+(stateTxt[d.state]||d.state)+')</option>'
).join('');
if(cur)sel.value=cur;
}
function _screenSerial(){
const sel=document.getElementById('screen-device');
return sel?sel.value:'';
}
function startScreenView(){
const serial=_screenSerial();
if(!serial){showToast('请先选择设备','error');return;}
stopScreenView();
const img=document.getElementById('screen-img');
const loading=document.getElementById('screen-loading');
loading.style.display='flex';
document.getElementById('screen-status').textContent='';
img.src='/api/screen/stream?serial='+encodeURIComponent(serial);
img.onload=()=>{loading.style.display='none';_screenViewOn=true;document.getElementById('screen-status').textContent='观看中';};
img.onerror=()=>{loading.style.display='none';_screenViewOn=false;showToast('画面流中断(设备离线或已停止)','error');};
}
function stopScreenView(){
const img=document.getElementById('screen-img');
if(img)img.src='';
_screenViewOn=false;
const loading=document.getElementById('screen-loading');
if(loading)loading.style.display='none';
const st=document.getElementById('screen-status');
if(st)st.textContent='';
}
// 显示尺寸 -> 设备原生分辨率(img.naturalWidth/Height 即设备分辨率)
function _screenCoords(ev){
const img=document.getElementById('screen-img');
if(!img.naturalWidth)return [null,null];
const r=img.getBoundingClientRect();
return [
Math.round((ev.clientX-r.left)/r.width*img.naturalWidth),
Math.round((ev.clientY-r.top)/r.height*img.naturalHeight),
];
}
function _screenTap(x,y){
const serial=_screenSerial();
if(!serial||x==null||y==null)return;
apiPost('/api/screen/tap',{serial,x,y}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
}
function _screenSwipe(x1,y1,x2,y2){
const serial=_screenSerial();
if(!serial||x1==null)return;
apiPost('/api/screen/swipe',{serial,x1,y1,x2,y2}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
}
function screenKey(key){
const serial=_screenSerial();
if(!serial){showToast('请先选择设备','error');return;}
apiPost('/api/screen/key',{serial,key}).then(r=>{if(r&&!r.ok)showToast(r.error,'error');});
}
function screenSendText(){
const serial=_screenSerial();
const inp=document.getElementById('screen-text');
const text=(inp.value||'').trim();
if(!serial){showToast('请先选择设备','error');return;}
if(!text){showToast('请输入文字','error');return;}
apiPost('/api/screen/text',{serial,text}).then(r=>{
if(r&&!r.ok)showToast(r.error,'error');
else inp.value='';
});
}
// 鼠标 + 触控:拖拽=滑动,点按=轻触
(function(){
const img=document.getElementById('screen-img');
if(!img)return;
let dragging=false, sx=0, sy=0;
function onDown(ev){
if(!_screenViewOn)return;
ev.preventDefault();
dragging=true;
const [x,y]=_screenCoords(ev);
sx=x; sy=y;
}
function onUp(ev){
if(!dragging)return;
dragging=false;
const [x,y]=_screenCoords(ev);
if(x==null)return;
if(Math.abs(x-sx)<8 && Math.abs(y-sy)<8){
_screenTap(x,y); // 位移小 → 点击
}else{
_screenSwipe(sx,sy,x,y); // 有位移 → 滑动
}
}
img.addEventListener('mousedown',onDown);
img.addEventListener('mousemove',ev=>{if(dragging)ev.preventDefault();});
window.addEventListener('mouseup',onUp);
img.addEventListener('touchstart',onDown,{passive:false});
img.addEventListener('touchend',ev=>{
if(!dragging)return;
const t=ev.changedTouches[0];
onUp({clientX:t.clientX,clientY:t.clientY,preventDefault(){}});
},{passive:false});
})();
+37
View File
@@ -482,6 +482,7 @@ select.form-control{cursor:pointer}
<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="stfdev" onclick="showSubTab('tools','stfdev')">STF 设备管理</button>
<button class="sub-tab" data-sub="screen" onclick="showSubTab('tools','screen')">远程看屏</button>
</div>
<!-- 子分栏 1:剪贴板注入 -->
@@ -665,6 +666,42 @@ select.form-control{cursor:pointer}
脚本的 cron 每 5 分钟自动补连掉线设备。adb 操作发生在 220(STF provider 侧),不影响本机任务直连。
</div>
</div>
<!-- 子分栏 7:远程看屏(MJPEG 实时流 + 触控) -->
<div class="sub-panel" id="tools-sub-screen">
<div class="toolbar">
<select id="screen-device" class="form-control" style="width:300px;font-family:monospace"></select>
<button class="btn btn-primary" onclick="startScreenView()">开始看屏</button>
<button class="btn" onclick="stopScreenView()">停止</button>
<button class="btn" onclick="loadScreenDevices(true)">刷新设备</button>
<span id="screen-status" class="text-muted"></span>
</div>
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
<div style="position:relative" id="screen-stage">
<img id="screen-img" alt="远程画面"
style="max-width:420px;border-radius:12px;border:1px solid var(--border);background:#000;cursor:crosshair;user-select:none">
<div id="screen-loading" class="empty" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.6);color:#fff;border-radius:12px">连接中(首次连接需推送 atx-agent,最长约 30 秒)...</div>
</div>
<div style="display:flex;flex-direction:column;gap:6px;min-width:200px">
<div style="font-size:12px;color:var(--text-light)">操作(鼠标拖拽=滑动,点击=轻触)</div>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-xs" onclick="screenKey('back')">◀ 返回</button>
<button class="btn btn-xs" onclick="screenKey('home')">🏠 主页</button>
<button class="btn btn-xs" onclick="screenKey('recent')">📋 最近</button>
<button class="btn btn-xs" onclick="screenKey('menu')">☰ 菜单</button>
<button class="btn btn-xs" onclick="screenKey('power')">⏻ 电源</button>
<button class="btn btn-xs" onclick="screenKey('volume_up')">🔊 音量+</button>
<button class="btn btn-xs" onclick="screenKey('volume_down')">🔉 音量-</button>
</div>
<div style="display:flex;gap:6px;margin-top:4px">
<input type="text" id="screen-text" class="form-control" style="flex:1" placeholder="输入文字(需光标在输入框)"
onkeydown="if(event.key==='Enter')screenSendText()">
<button class="btn btn-xs" onclick="screenSendText()">发送</button>
</div>
<div class="help" style="margin-top:6px">画面为设备原生分辨率 JPEG 流(约 3-6 帧/秒)。设备息屏时画面为黑,可先点电源/亮屏。</div>
</div>
</div>
</div>
</div>
</div><!-- /.content -->
+123
View File
@@ -974,6 +974,129 @@ def api_uiauto_screenshot():
return jsonify({"ok": False, "error": data}), 503
# ================== API:远程看屏(阶段 2) ==================
# MJPEG 实时画面流 + u2 触控注入。数据源 u2(atx-agent minicap 截图,单帧 ~200ms),
# 零新依赖(原计划 ws-scrcpy 不在 npm 分发,GitHub 下载在国内不可靠,改自建)。
# 坐标约定:前端以 <img> 显示尺寸归一化后映射到设备原生分辨率(naturalWidth/Height)。
_SCREEN_JPEG_QUALITY = 65
_SCREEN_FRAME_GAP = 0.05 # 帧间最小间隔(秒),防止空转烧 CPU
@app.route("/api/screen/stream")
@perm_required(PERM_DEVICES)
def api_screen_stream():
"""远程看屏:MJPEG 实时画面流(multipart/x-mixed-replace)。
?serial=xxx 设备 serial
浏览器 <img> 直接渲染;客户端断开(GeneratorExit)自动停止,不占资源。
设备离线/atx-agent 无响应时流自然结束,前端提示重新连接。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
def generate():
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
except Exception as e:
_log.warning(f"远程看屏 {serial} u2 连接失败: {e}")
return
while True:
try:
img = d.screenshot()
if img is None:
break
if img.mode != "RGB":
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, "JPEG", quality=_SCREEN_JPEG_QUALITY)
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n"
+ buf.getvalue() + b"\r\n")
except GeneratorExit:
break
except Exception as e:
_log.debug(f"远程看屏 {serial} 流中断: {e}")
break
time.sleep(_SCREEN_FRAME_GAP)
return Response(generate(),
mimetype="multipart/x-mixed-replace; boundary=frame")
def _screen_get_device(serial):
"""远程看屏用的 u2 连接(连接失败抛异常由调用方转 503)。"""
import uiautomator2 as u2
return u2.connect(serial)
@app.route("/api/screen/tap", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap():
"""点击:{serial, x, y}(设备原生分辨率坐标)。"""
data = request.json or {}
serial, x, y = data.get("serial", ""), data.get("x"), data.get("y")
if not serial or x is None or y is None:
return jsonify({"ok": False, "error": "缺少 serial/x/y"}), 400
try:
_screen_get_device(serial).click(int(x), int(y))
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"点击失败: {e}"}), 503
@app.route("/api/screen/swipe", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_swipe():
"""滑动:{serial, x1, y1, x2, y2, duration?}(设备原生分辨率坐标)。"""
data = request.json or {}
serial = data.get("serial", "")
x1, y1, x2, y2 = (data.get(k) for k in ("x1", "y1", "x2", "y2"))
if not serial or None in (x1, y1, x2, y2):
return jsonify({"ok": False, "error": "缺少 serial/x1/y1/x2/y2"}), 400
try:
_screen_get_device(serial).swipe(int(x1), int(y1), int(x2), int(y2),
duration=float(data.get("duration", 0.2)))
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"滑动失败: {e}"}), 503
_SCREEN_KEYS = {"back", "home", "recent", "menu", "power", "volume_up",
"volume_down", "enter", "delete", "search", "camera"}
@app.route("/api/screen/key", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_key():
"""按键:{serial, key}(back/home/recent/menu/power 等,见 _SCREEN_KEYS)。"""
data = request.json or {}
serial, key = data.get("serial", ""), (data.get("key") or "").strip().lower()
if not serial or key not in _SCREEN_KEYS:
return jsonify({"ok": False, "error": "缺少 serial 或不支持的按键"}), 400
try:
_screen_get_device(serial).press(key)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"按键失败: {e}"}), 503
@app.route("/api/screen/text", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_text():
"""输入文字:{serial, text}(需焦点在输入框,u2 send_keys)。"""
data = request.json or {}
serial, text = data.get("serial", ""), (data.get("text") or "").strip()
if not serial or not text:
return jsonify({"ok": False, "error": "缺少 serial/text"}), 400
try:
_screen_get_device(serial).send_keys(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": f"输入失败: {e}"}), 503
# ================== API:应用管理(APK 上传/安装)==================
@app.route("/api/apks")
@login_required