feat: 强制安装APK(推送+pm install 绕过厂商弹窗)+ 定位设备(亮屏+大字定位页,结束定位关闭浏览器含后台)+ 一键亮屏/息屏(并发全部设备)
This commit is contained in:
+29
-3
@@ -305,12 +305,38 @@ class ApkManager:
|
||||
"设备未在本地 adb 列表中,请检查 USB 连接/驱动")
|
||||
return
|
||||
|
||||
# 安装
|
||||
self._set_item(serial, "installing", "正在安装...")
|
||||
# 强制安装:绕过厂商安全防护(MIUI 等用 adb install 会弹"通过 USB 安装应用"
|
||||
# 确认框需要手点;改为 推送 + pm install,shell 安装不弹窗)
|
||||
# 1. 关闭 adb 安装校验(包校验器/安全守护拦截)
|
||||
try:
|
||||
_adb("-s", serial, "shell", "settings", "put", "global",
|
||||
"verifier_verify_adb_installs", "0")
|
||||
except Exception:
|
||||
pass
|
||||
# 2. 推送 APK 到设备(大 APK 经 WiFi 推送较慢,超时与安装共用)
|
||||
self._set_item(serial, "installing", "正在推送 APK...")
|
||||
r = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "install", "-r", apk_path],
|
||||
[ADB_PATH, "-s", serial, "push", apk_path, "/data/local/tmp/_install.apk"],
|
||||
capture_output=True, timeout=_INSTALL_TIMEOUT
|
||||
)
|
||||
push_out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
|
||||
if r.returncode != 0 or "1 file pushed" not in push_out:
|
||||
msg = push_out.strip().replace("\n", " ")[:150] or f"推送失败(returncode={r.returncode})"
|
||||
self._set_item(serial, "failed", msg)
|
||||
_log.warning(f"[{serial}] APK 推送失败: {msg}")
|
||||
return
|
||||
# 3. pm install 强制安装(-r 覆盖安装 / -t 允许测试包 / -d 允许降级版本)
|
||||
self._set_item(serial, "installing", "正在安装...")
|
||||
r = subprocess.run(
|
||||
[ADB_PATH, "-s", serial, "shell", "pm", "install", "-r", "-t", "-d",
|
||||
"/data/local/tmp/_install.apk"],
|
||||
capture_output=True, timeout=_INSTALL_TIMEOUT
|
||||
)
|
||||
# 4. 清理设备临时文件(失败残留下次推送会覆盖,不影响)
|
||||
try:
|
||||
_adb("-s", serial, "shell", "rm", "-f", "/data/local/tmp/_install.apk")
|
||||
except Exception:
|
||||
pass
|
||||
out = ((r.stdout or b"") + (r.stderr or b""))
|
||||
try:
|
||||
out = out.decode("utf-8", errors="replace")
|
||||
|
||||
+42
-3
@@ -1,6 +1,7 @@
|
||||
// 监控页:设备状态表(排序/多选/截图/异常汇总/任务运行概况)
|
||||
// ================== 设备列表排序与多选 ==================
|
||||
let _selectedSerials=new Set(); // 已勾选的 serial 集合
|
||||
let _locatingSerials=new Set(); // 定位中的设备(按钮显示"结束定位")
|
||||
|
||||
// 排序比较:从设备对象取排序值(统一排序用,见 setListPager/sortValue)
|
||||
function _sortValue(dev,key){
|
||||
@@ -71,6 +72,38 @@ async function stopSelectedDevices(){
|
||||
setTimeout(loadMonitor,500);
|
||||
}
|
||||
|
||||
// 定位设备:点亮屏幕 + 打开大字定位页(显示设备 IP),便于在机架上找到目标
|
||||
// 注意:打开定位页会切换设备前台,任务运行中慎用
|
||||
async function locateDevice(serial){
|
||||
if(!confirm('点亮 '+serial+' 屏幕并打开大字定位页?\n\n(会切换设备前台显示,任务运行中慎用)'))return;
|
||||
const r=await apiPost('/api/device/locate',{serial:serial,show:true});
|
||||
if(!r)return;
|
||||
if(r.ok){
|
||||
_locatingSerials.add(serial);
|
||||
showToast(r.msg||'已定位','success');
|
||||
renderDevices();
|
||||
}else showToast(r.error||'定位失败','error');
|
||||
}
|
||||
|
||||
// 结束定位:关闭设备上的定位页(浏览器),按钮恢复为"定位"
|
||||
async function stopLocate(serial){
|
||||
const r=await apiPost('/api/device/locate/stop',{serial:serial});
|
||||
if(!r)return;
|
||||
_locatingSerials.delete(serial);
|
||||
renderDevices();
|
||||
if(r.ok)showToast(r.msg||'已结束定位','success');
|
||||
else showToast(r.error||'结束定位失败','error');
|
||||
}
|
||||
|
||||
// 批量亮屏/息屏所有在线设备(息屏会中断运行中任务,前端已确认)
|
||||
async function screenAllDevices(mode){
|
||||
if(mode==='off'&&!confirm('确定让所有在线设备息屏?\n\n运行中的任务会因屏幕熄灭而中断,请谨慎操作。'))return;
|
||||
const r=await apiPost('/api/device/screen_all',{mode:mode});
|
||||
if(!r)return;
|
||||
if(r.ok)showToast((mode==='off'?'已息屏':'已亮屏')+' '+r.success+'/'+r.total+' 台设备','success');
|
||||
else showToast(r.error||'操作失败','error');
|
||||
}
|
||||
|
||||
// 清除单台设备的异常状态(error/failed → 未启动)
|
||||
async function clearDeviceError(serial){
|
||||
if(!confirm('确认清除设备 '+serial+' 的异常状态?'))return;
|
||||
@@ -266,9 +299,10 @@ async function loadSummary(){
|
||||
let _devicesSig='';
|
||||
function renderDevices(){
|
||||
if(!_statusData||!_statusData.devices){return;}
|
||||
// 5秒自动刷新时若设备数据/分页/搜索/排序都没变化,跳过整表重渲染(避免DOM抖动)
|
||||
// 5秒自动刷新时若设备数据/分页/搜索/排序/定位状态都没变化,跳过整表重渲染(避免DOM抖动)
|
||||
const _st=_lp('devices');
|
||||
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort});
|
||||
const _sig=JSON.stringify({d:_statusData.devices,p:_st.page,k:_st.kw,s:_st.sort,
|
||||
l:[..._locatingSerials].sort().join(',')});
|
||||
if(_sig===_devicesSig)return;
|
||||
_devicesSig=_sig;
|
||||
// 排序由 applyListPager 统一处理(sortValue + _lpSort)
|
||||
@@ -306,10 +340,15 @@ function renderDeviceRow(dev){
|
||||
const stopBtn=canDev&&(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
|
||||
// 截图按钮(任何状态都可看,离线时会报错)
|
||||
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
|
||||
// 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
|
||||
const locating=_locatingSerials.has(dev.serial);
|
||||
const locateBtn=canDev?(locating
|
||||
?'<button class="btn btn-xs btn-danger" onclick="stopLocate(\''+esc(dev.serial)+'\')" title="关闭定位页(退出浏览器)">结束定位</button>'
|
||||
:'<button class="btn btn-xs" onclick="locateDevice(\''+esc(dev.serial)+'\')" title="点亮屏幕并显示设备IP大字页(会切换前台,任务运行中慎用)">定位</button>'):'';
|
||||
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
|
||||
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
|
||||
?'<button class="btn btn-xs" onclick="clearDeviceError(\''+esc(dev.serial)+'\')" title="清除该设备的异常状态,恢复为未启动">清除异常</button>':'';
|
||||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+clearBtn+stopBtn+'</div>';
|
||||
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+locateBtn+clearBtn+stopBtn+'</div>';
|
||||
const devName=dev.device_name||dev.model||'';
|
||||
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
|
||||
// 复选框列
|
||||
|
||||
@@ -314,6 +314,8 @@ select.form-control{cursor:pointer}
|
||||
<button class="btn btn-danger" onclick="clearAllErrors()" title="清除所有异常/失败设备的错误状态(运行中/等待重试的跳过)" data-perm="devices">清除全部异常</button>
|
||||
<button class="btn" onclick="apiAction('/api/release')" data-perm="devices">强制释放占用</button>
|
||||
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()" data-perm="devices">扫描前台App</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="loadMonitor()">刷新</button>
|
||||
<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>
|
||||
|
||||
+181
-1
@@ -22,11 +22,14 @@ import functools
|
||||
import shlex
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeout
|
||||
|
||||
from flask import (Flask, jsonify, request, redirect, url_for, render_template,
|
||||
Response, make_response, session)
|
||||
render_template_string, Response, make_response, session)
|
||||
from markupsafe import escape as _esc
|
||||
from flask_login import (LoginManager, login_user, logout_user,
|
||||
login_required, current_user)
|
||||
|
||||
@@ -547,6 +550,183 @@ def api_stop_device():
|
||||
return jsonify({"ok": False, "error": f"{serial} 没有运行中的任务"}), 400
|
||||
|
||||
|
||||
@app.route("/locate")
|
||||
def locate_page():
|
||||
"""设备端定位页(免登录):点亮后浏览器打开,大字显示设备 IP/序列号。
|
||||
|
||||
只展示 serial 文本,无任何平台数据,免登录可接受。
|
||||
"""
|
||||
serial = request.args.get("serial", "")
|
||||
return render_template_string(
|
||||
"""<!DOCTYPE html><html><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>设备定位</title><style>
|
||||
body{margin:0;background:#000;color:#fff;display:flex;flex-direction:column;
|
||||
align-items:center;justify-content:center;height:100vh;font-family:monospace}
|
||||
.serial{font-size:min(9vw,72px);font-weight:700;color:#ffd700;word-break:break-all;padding:0 20px;text-align:center}
|
||||
.ip{font-size:min(5vw,36px);color:#7dd3fc;margin-top:24px;word-break:break-all;padding:0 20px;text-align:center}
|
||||
.hint{font-size:min(3vw,16px);color:#64748b;margin-top:40px}
|
||||
</style></head><body>
|
||||
<div class="serial">{{ serial }}</div>
|
||||
<div class="ip">{{ ip }}</div>
|
||||
<div class="hint">定位完成 · 按返回键退出</div>
|
||||
</body></html>""",
|
||||
serial=_esc(serial), ip=_esc(serial.split(":")[0]))
|
||||
|
||||
|
||||
# 常见浏览器包名(结束定位时 force-stop 用)
|
||||
_BROWSER_PKGS = {"com.android.browser", "com.miui.browser", "com.android.chrome",
|
||||
"com.brave.browser", "com.opera.browser", "org.mozilla.firefox",
|
||||
"com.UCMobile", "com.tencent.mtt", "com.baidu.browser.apps"}
|
||||
# 定位时启动的浏览器:serial -> 包名(结束定位时无论前后台都关闭它)
|
||||
_locate_browsers = {}
|
||||
_locate_lock = threading.Lock()
|
||||
|
||||
|
||||
def _current_focus_pkg(serial):
|
||||
"""取设备当前前台包名(dumpsys window 解析,失败返回空串)。"""
|
||||
try:
|
||||
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "dumpsys", "window"],
|
||||
capture_output=True, timeout=20)
|
||||
out = (r.stdout or b"").decode("utf-8", errors="replace")
|
||||
m = re.search(r"mCurrentFocus=.*?([\w.]+)/", out)
|
||||
return m.group(1) if m else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@app.route("/api/device/locate/stop", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_device_locate_stop():
|
||||
"""结束定位:关闭定位启动的浏览器(无论当前是否前台)。
|
||||
|
||||
优先 force-stop 定位时记录下的浏览器包名(切到后台也能关掉);
|
||||
无记录时退回:前台是浏览器 → force-stop;否则按返回键(不误杀任务应用)。
|
||||
"""
|
||||
serial = str((request.json or {}).get("serial", "")).strip()
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
|
||||
try:
|
||||
if ":" in serial:
|
||||
adb_connect(serial)
|
||||
# 1. 优先关闭定位时启动的浏览器(无论前后台)
|
||||
with _locate_lock:
|
||||
started = _locate_browsers.pop(serial, None)
|
||||
if started:
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", started],
|
||||
capture_output=True, timeout=20)
|
||||
msg = f"已关闭定位浏览器 {started}"
|
||||
else:
|
||||
# 2. 退回:前台是浏览器则关闭,否则返回键轻量退出
|
||||
pkg = _current_focus_pkg(serial)
|
||||
if pkg in _BROWSER_PKGS:
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "force-stop", pkg],
|
||||
capture_output=True, timeout=20)
|
||||
msg = f"已关闭浏览器 {pkg}"
|
||||
else:
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "4"],
|
||||
capture_output=True, timeout=15) # BACK 轻量退出
|
||||
msg = "已按返回键退出" + (f"(前台 {pkg})" if pkg else "")
|
||||
_log.info(f"结束定位 {serial}: {msg}")
|
||||
return jsonify({"ok": True, "msg": msg})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"结束定位失败: {e}"}), 502
|
||||
|
||||
|
||||
@app.route("/api/device/screen_all", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_device_screen_all():
|
||||
"""批量亮屏/息屏所有在线设备(并发)。mode: on=亮屏解锁 / off=息屏。
|
||||
|
||||
息屏会中断运行中的任务(u2 无法操作),前端已有确认提示。
|
||||
"""
|
||||
data = request.json or {}
|
||||
mode = data.get("mode", "on")
|
||||
try:
|
||||
serials = [d["serial"] for d in stf.list_all_devices() if d.get("present")]
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"获取设备列表失败: {e}"}), 502
|
||||
if not serials:
|
||||
return jsonify({"ok": False, "error": "无在线设备"}), 400
|
||||
|
||||
def _do(serial):
|
||||
try:
|
||||
if ":" in serial:
|
||||
adb_connect(serial)
|
||||
if mode == "off":
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "26"],
|
||||
capture_output=True, timeout=15) # KEYCODE_POWER 息屏
|
||||
else:
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "input", "keyevent", "224"],
|
||||
capture_output=True, timeout=15) # WAKEUP
|
||||
subprocess.run([ADB_PATH, "-s", serial, "shell", "wm", "dismiss-keyguard"],
|
||||
capture_output=True, timeout=15)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(10, len(serials))) as pool:
|
||||
results = list(pool.map(_do, serials))
|
||||
ok_n = sum(1 for x in results if x)
|
||||
_log.info(f"批量{'息屏' if mode == 'off' else '亮屏'}: 成功 {ok_n}/{len(serials)}")
|
||||
return jsonify({"ok": True, "success": ok_n, "total": len(serials)})
|
||||
|
||||
|
||||
@app.route("/api/device/locate", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_device_locate():
|
||||
"""定位设备:点亮屏幕并解除锁屏,可选打开大字定位页(查找设备用)。
|
||||
|
||||
请求 {"serial": "...", "show": true}:show=true 时额外用浏览器打开
|
||||
平台 /locate 定位页,全屏大字显示设备 IP(更醒目,但会切换前台,
|
||||
任务运行中慎用)。点亮组合:WAKEUP → dismiss-keyguard → MENU 兜底。
|
||||
绝不 disconnect,红线。
|
||||
"""
|
||||
data = request.json or {}
|
||||
serial = str(data.get("serial", "")).strip()
|
||||
if not serial:
|
||||
return jsonify({"ok": False, "error": "缺少设备 serial"}), 400
|
||||
try:
|
||||
if ":" in serial:
|
||||
adb_connect(serial)
|
||||
for cmd in (["shell", "input", "keyevent", "224"],
|
||||
["shell", "wm", "dismiss-keyguard"],
|
||||
["shell", "input", "keyevent", "82"]):
|
||||
try:
|
||||
subprocess.run([ADB_PATH, "-s", serial, *cmd],
|
||||
capture_output=True, timeout=15)
|
||||
except Exception:
|
||||
pass # 单步失败不阻塞,继续下一步
|
||||
msgs = ["屏幕已点亮"]
|
||||
if data.get("show"):
|
||||
# 用设备浏览器打开定位页(设备走 tailnet 访问本机 100.100.10.2:18050)
|
||||
locate_url = f"http://100.100.10.2:18050/locate?serial={urllib.parse.quote(serial)}"
|
||||
r = subprocess.run([ADB_PATH, "-s", serial, "shell", "am", "start",
|
||||
"-a", "android.intent.action.VIEW", "-d", locate_url],
|
||||
capture_output=True, timeout=20)
|
||||
out = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", errors="replace")
|
||||
if "error" in out.lower() or "exception" in out.lower():
|
||||
msgs.append(f"打开定位页失败: {out.strip()[:80]}")
|
||||
else:
|
||||
msgs.append("已打开大字定位页(按返回键退出)")
|
||||
# 记录启动的浏览器包名:结束定位时无论前后台都关闭它。
|
||||
# 优先从 am start 输出解析 pkg=(浏览器可能尚未到前台,前台检测会扑空)
|
||||
pkg = ""
|
||||
m2 = re.search(r"pkg=([\w.]+)", out)
|
||||
if m2 and m2.group(1) in _BROWSER_PKGS:
|
||||
pkg = m2.group(1)
|
||||
else:
|
||||
time.sleep(1.5) # 等浏览器切到前台再查
|
||||
pkg = _current_focus_pkg(serial)
|
||||
if pkg in _BROWSER_PKGS:
|
||||
with _locate_lock:
|
||||
_locate_browsers[serial] = pkg
|
||||
_log.info(f"定位设备 {serial}: {'; '.join(msgs)}")
|
||||
return jsonify({"ok": True, "msg": f"{serial} {';'.join(msgs)}"})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": f"定位失败: {e}"}), 502
|
||||
|
||||
|
||||
@app.route("/api/stop_all", methods=["POST"])
|
||||
@perm_required(PERM_DEVICES)
|
||||
def api_stop_all():
|
||||
|
||||
Reference in New Issue
Block a user