From 90ca2c2100e18e1f0a335455e1527d90907f60f8 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Thu, 20 Aug 2026 10:51:54 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BB=BB=E5=8A=A1=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=99=A8=E5=89=AA=E8=B4=B4=E6=9D=BF=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E6=94=B9=E7=94=A8=20ClipInject=20=E9=80=9A=E9=81=93?= =?UTF-8?q?=E2=80=94=E2=80=94u2=20set=5Fclipboard=20=E5=9C=A8=20Android=20?= =?UTF-8?q?10+=20=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88=EF=BC=88=E7=B2=98?= =?UTF-8?q?=E8=B4=B4=E6=A8=A1=E5=BC=8F=E5=BF=85=E6=8A=9B=20UiAutomationErr?= =?UTF-8?q?or=EF=BC=89=EF=BC=9B=E6=8F=90=E5=8F=96=20core/clipboard=5Fhelpe?= =?UTF-8?q?r=20=E5=85=AC=E5=85=B1=E6=A8=A1=E5=9D=97=E4=BE=9B=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E9=A1=B5=E4=B8=8E=E4=BB=BB=E5=8A=A1=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E7=AB=AF=E5=85=B1=E7=94=A8=EF=BC=8C=E7=B2=98=E8=B4=B4=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20atx-agent=20pasteClipboard=20+=20set=5Ftext=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/clipboard_helper.py | 69 ++++++++++++++++++++++++++++++++++++++++ tasks/generic/task.py | 31 ++++++++++++------ web/tools_api.py | 54 ++----------------------------- 3 files changed, 93 insertions(+), 61 deletions(-) create mode 100644 core/clipboard_helper.py diff --git a/core/clipboard_helper.py b/core/clipboard_helper.py new file mode 100644 index 0000000..4846382 --- /dev/null +++ b/core/clipboard_helper.py @@ -0,0 +1,69 @@ +"""剪贴板注入公共模块(ClipInject APK 通道)。 + +背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard +调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。ClipInject +(com.example.clipinject,用户自研 APK)通过 am start 启动透明 Activity, +窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受后台启动限制 +(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。 + +工具页(web/tools_api.py)与任务执行器(tasks/generic/task.py)共用本模块。 +""" +import base64 +import subprocess +import time +from concurrent.futures import ThreadPoolExecutor + +from config import ADB_PATH +from core.adb_helper import adb_connect +from core.logger import get_logger + +_log = get_logger("core.clip") + +_CLIP_PACKAGE = "com.example.clipinject" +_CLIP_ACTIVITY = "com.example.clipinject/.ClipActivity" + + +def inject_clipboard(serial, text, d=None): + """向设备剪贴板注入文字,返回 (ok, msg)。 + + - d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接), + 读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护) + - IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线) + - 未装 ClipInject / 读回不一致都会明确报错(不再静默失败) + """ + import uiautomator2 as u2 + if ":" in serial: + try: + adb_connect(serial) + except Exception as e: + return False, f"adb 连接失败: {e}" + b64 = base64.b64encode(text.encode("utf-8")).decode() + # 1. am start 透明 Activity 写剪贴板(shell 身份,不受后台启动限制) + try: + r = subprocess.run( + [ADB_PATH, "-s", serial, "shell", "am", "start", + "-n", _CLIP_ACTIVITY, "--es", "text_b64", b64], + capture_output=True, timeout=30) + out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace") + if "does not exist" in out or "unable to resolve Intent" in out: + return False, f"设备未安装 ClipInject({_CLIP_PACKAGE}),请先安装" + if "Error" in out or ("Starting" not in out and r.returncode != 0): + return False, f"am start 失败: {out.strip()[:120]}" + except subprocess.TimeoutExpired: + return False, "am start 超时(adb 无响应)" + except Exception as e: + return False, f"{type(e).__name__}: {str(e)[:120]}" + # 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用) + time.sleep(0.5) + try: + if d is None: + with ThreadPoolExecutor(max_workers=1) as pool: + d = pool.submit(u2.connect, serial).result(timeout=30) + with ThreadPoolExecutor(max_workers=1) as pool: + got = pool.submit(lambda: d.clipboard).result(timeout=15) + if got == text: + return True, "已注入" + return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}" + except Exception as e: + # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因 + return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" diff --git a/tasks/generic/task.py b/tasks/generic/task.py index 8a5155d..02697fd 100644 --- a/tasks/generic/task.py +++ b/tasks/generic/task.py @@ -478,26 +478,37 @@ class GenericStepsWorker(BaseWorker): return found # 命中结果,"测试此步骤"用 def _exec_clipboard(self, d, params, depth=0): - """剪贴板注入:写入指定内容到剪贴板,可选立即粘贴到当前聚焦的输入框。 + """剪贴板注入(ClipInject 通道,Android 10+ 可用),可选立即粘贴。 批量发链接场景:先聚焦评论区输入框 → 注入+粘贴链接1 → 换链接 → 注入+粘贴链接2…… - 粘贴模式复用 u2 send_keys(内部 setClipboard + pasteClipboard,与"输入文字"同机制)。 + 粘贴模式:ClipInject 写入剪贴板后调 atx-agent pasteClipboard 粘贴到当前 + 焦点输入框;动作失败时兜底 u2 set_text 直接设置聚焦输入框文本(不依赖 + 剪贴板,与"输入文字"兜底同机制)。 """ text = str(params.get("text", "") or "") if not text: _log.warning(f"[{self.serial}] clipboard 内容为空,跳过") return False + from core.clipboard_helper import inject_clipboard + ok, msg = inject_clipboard(self.serial, text, d=d) + if not ok: + _log.warning(f"[{self.serial}] 剪贴板注入失败: {msg}") + return False + _log.info(f"[{self.serial}] 剪贴板注入: {text[:60]}({msg})") + if not params.get("paste"): + return True try: - if params.get("paste"): - d.send_keys(text) # setClipboard + pasteClipboard - _log.info(f"[{self.serial}] 剪贴板注入并粘贴: {text[:60]}") - else: - d.set_clipboard(text) - _log.info(f"[{self.serial}] 剪贴板注入(不粘贴): {text[:60]}") + d.jsonrpc.pasteClipboard() + _log.info(f"[{self.serial}] 已粘贴: {text[:60]}") return True except Exception as e: - _log.warning(f"[{self.serial}] 剪贴板注入异常: {e}") - return False + try: + d(focused=True).set_text(text) + _log.info(f"[{self.serial}] 粘贴兜底 set_text: {text[:60]}") + return True + except Exception as e2: + _log.warning(f"[{self.serial}] 粘贴失败: {e} / {e2}") + return True # 剪贴板已写入,粘贴失败不致命 def _exec_wait(self, d, params, depth=0): min_s = float(params.get("min", 1.0)) diff --git a/web/tools_api.py b/web/tools_api.py index 6b55401..e61fb6d 100644 --- a/web/tools_api.py +++ b/web/tools_api.py @@ -94,57 +94,9 @@ def api_tools_clipboard_set(): def _set_device_clipboard(serial, text): - """向单台设备注入剪贴板文字(ClipInject APK:透明 Activity 聚焦后写入)。 - - Android 10+ 禁止后台(atx-agent/shell)写剪贴板,u2.set_clipboard 在 - Android 12/13 上调用"成功"但内容被系统静默丢弃。ClipInject - (com.example.clipinject)通过 am start 启动透明 Activity,窗口聚焦后 - setPrimaryClip,shell 启动 Activity 不受后台启动限制(广播方式在 MIUI - 会被 Background activity start 拦截,必须用 am start)。 - - - IP:port 设备:先 adb connect(已连接自动跳过;绝不 disconnect,红线) - - 设备未装 ClipInject:am start 返回 unable to resolve Intent,明确报错 - - 注入后 u2 读回比对验证(后台读剪贴板实测可用),不一致也明确报错 - """ - import base64 - import time - import uiautomator2 as u2 - if ":" in serial: - try: - adb_connect(serial) - except Exception as e: - return False, f"adb 连接失败: {e}" - b64 = base64.b64encode(text.encode("utf-8")).decode() - # 1. am start 透明 Activity 写剪贴板(shell 身份启动,不受后台启动限制) - try: - r = subprocess.run( - [ADB_PATH, "-s", serial, "shell", "am", "start", - "-n", "com.example.clipinject/.ClipActivity", - "--es", "text_b64", b64], - capture_output=True, timeout=30) - out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace") - if "does not exist" in out or "unable to resolve Intent" in out: - return False, ("设备未安装 ClipInject(com.example.clipinject)," - f"请先安装: {out.strip()[:100]}") - if "Error" in out or ("Starting" not in out and r.returncode != 0): - return False, f"am start 失败: {out.strip()[:120]}" - except subprocess.TimeoutExpired: - return False, "am start 超时(adb 无响应)" - except Exception as e: - return False, f"{type(e).__name__}: {str(e)[:120]}" - # 2. 读回验证(等透明 Activity 完成写入;u2 读剪贴板在 Android 12 实测可用) - time.sleep(0.5) - try: - with ThreadPoolExecutor(max_workers=1) as pool: - d = pool.submit(u2.connect, serial).result(timeout=30) - with ThreadPoolExecutor(max_workers=1) as pool: - got = pool.submit(lambda: d.clipboard).result(timeout=15) - if got == text: - return True, "已注入" - return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}" - except Exception as e: - # 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因 - return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})" + """向单台设备注入剪贴板文字(ClipInject 通道,见 core.clipboard_helper)。""" + from core.clipboard_helper import inject_clipboard + return inject_clipboard(serial, text) def _app_ver_on_device(serial, pkg):