diff --git a/core/apk_manager.py b/core/apk_manager.py
index 42bfc93..0766ac6 100644
--- a/core/apk_manager.py
+++ b/core/apk_manager.py
@@ -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")
diff --git a/static/admin/monitor.js b/static/admin/monitor.js
index 9580eed..d0c37a4 100644
--- a/static/admin/monitor.js
+++ b/static/admin/monitor.js
@@ -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')?'':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='';
+ // 定位按钮:定位中显示"结束定位"(关闭定位页),否则显示"定位"(点亮+大字页)
+ const locating=_locatingSerials.has(dev.serial);
+ const locateBtn=canDev?(locating
+ ?''
+ :''):'';
// 异常/失败设备提供"清除异常"(把状态恢复为未启动)
const clearBtn=canDev&&(dev.worker_status==='error'||dev.worker_status==='failed')
?'':'';
- const btns='
'+shotBtn+clearBtn+stopBtn+'
';
+ const btns=''+shotBtn+locateBtn+clearBtn+stopBtn+'
';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?''+esc(dev.serial)+'
'+esc(devName)+'':''+esc(dev.serial)+'';
// 复选框列
diff --git a/templates/admin/monitor.html b/templates/admin/monitor.html
index 2f1e24c..f04f263 100644
--- a/templates/admin/monitor.html
+++ b/templates/admin/monitor.html
@@ -314,6 +314,8 @@ select.form-control{cursor:pointer}
+
+
· 每 5 秒自动刷新 · · 未选中
diff --git a/web_server.py b/web_server.py
index 9b5dace..938137c 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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(
+ """
+
+设备定位
+{{ serial }}
+{{ ip }}
+定位完成 · 按返回键退出
+""",
+ 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():