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():