Files
auto_control/core/clipboard_helper.py
butubb 7ce4ae9016 feat(账号): 账号台账(web 页 + 任务取号 + 设备端身份页);fix(剪贴板): 注入通道改走设备端 Agent
一、账号台账(新表 device_account,schema v8)
- core/ledger.py:CRUD、Excel 粘贴解析(Tab 分隔 / 表头乱序缺列 / 是·否布尔 / 逐行留痕)、
  按范围取号(本机台账 / 全部 / 按设备分组)、设备端账号块、dry_run 预览
- 新「账号」顶级 Tab(权限 devices):列表 + 搜索 + 设备筛选 + 增删改 +
  粘贴导入(默认先预览再写,逐行显示 新增/覆盖/跳过/失败 + 留痕)
- 设备池加「账号 N」列(0 个显示"未登记"),点开看该设备账号明细
- 任务「条件判断」新增 cmp_source / cmp_group:候选值可直接来自台账,
  与手填值**合并(OR)**;取不到号时回落手填值,并把原因写进步骤明细与日志
  (否则"永远走 else 分支"而任务照样显示成功,最难查)
- 设备端契约:身份页多推 accounts_b64(base64 JSON;默认不含手机号;
  超预算按整条丢且绝不算字节切),见 doc/DEVICE_AGENT.md §5.2.1
- 备份/文档红线:TABLE_LABELS 加「账号台账」;DATA_MODEL/API/ARCHITECTURE/DEPLOY/
  TASK_DEV/DEVICE_AGENT/DEVELOPMENT/README 同步;顺手补上 DATA_MODEL 漏列的 done_mark

⚠ 台账里的抖音号是**纯号**,只当"比对用的候选值":绝不能拿去填「去重」的身份元素
  (身份是元素原文逐字算 key,格式不同会让去重静默失效)。代码与文档都写明了。

二、剪贴板注入通道(修:平台还在调早期的独立 APK)
- 改为按顺序尝试:设备端 Agent(com.example.deviceagent/.ClipActivity)→
  旧版独立 ClipInject 兜底;两个都没有时报"需要设备端 Agent"(不再只说 ClipInject)
- 读回验证改成**轮询到 3 秒**:透明 Activity 要等窗口拿到焦点才写,
  原来只睡 0.5s 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
2026-09-24 16:12:58 +08:00

113 lines
5.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""剪贴板注入公共模块(设备端 Agent 通道)。
背景:Android 10+ 禁止后台(atx-agent/shell)写剪贴板——u2.set_clipboard
调用"成功"但内容被系统静默丢弃(Android 12/13 实测)。设备端 APK 通过 am start
启动**透明 Activity**,窗口聚焦后 setPrimaryClip;shell 身份启动 Activity 不受
后台启动限制(广播方式在 MIUI 会被 Background activity start 拦截,必须用 am start)。
**注入目标按顺序尝试**:先设备端聚合 Agent(`com.example.deviceagent`,现在的标准通道),
再兜底老设备上可能残留的独立 ClipInject(`com.example.clipinject`)。
两者只有一个 `--es text_b64` 的契约差异为零,所以是"换个包名再试一次"。
工具页(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")
# (包名, am start 目标, 给用户看的名字);顺序 = 尝试顺序
_CLIP_TARGETS = (
("com.example.deviceagent", "com.example.deviceagent/.ClipActivity", "设备端 Agent"),
("com.example.clipinject", "com.example.clipinject/.ClipActivity", "旧版 ClipInject"),
)
def _am_start(serial, activity, b64):
"""拉起透明 Activity 写剪贴板。返回 (输出文本, 是否执行成功)。"""
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "shell", "am", "start",
"-n", activity, "--es", "text_b64", b64],
capture_output=True, timeout=30)
except subprocess.TimeoutExpired:
return "am start 超时(adb 无响应)", False
except Exception as e:
return f"{type(e).__name__}: {str(e)[:120]}", False
out = ((r.stdout or b"") + (r.stderr or b"")).decode(errors="replace")
# "Starting: Intent {…}" 后面跟 "Error type 3 / Error: Activity class … does not exist."
# 时也是失败——所以不能只看有没有 Starting
if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out:
return out, False
if "Error" in out or ("Starting" not in out and r.returncode != 0):
return out, False
return out, True
def inject_clipboard(serial, text, d=None):
"""向设备剪贴板注入文字,返回 (ok, msg)。
- 依次尝试 `_CLIP_TARGETS`(设备端 Agent → 旧版 ClipInject):设备上装哪个用哪个,
两个都没有时明确报"需要装设备端 Agent"(不再只说 ClipInject,用户会以为要装老 APK)
- d:可选,任务执行器已持有 u2 Device 时传入复用(跳过重复连接),
读回验证直接用它;不传则临时 u2.connect(工具页场景,带超时保护)
- IP:port 设备先 adb connect(已连接自动跳过;绝不 disconnect,红线)
- 通道缺失 / 读回不一致都会明确报错(不再静默失败)
"""
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 身份,不受后台启动限制)
tried = []
for pkg, activity, label in _CLIP_TARGETS:
out, started = _am_start(serial, activity, b64)
if started:
if tried:
# 走到兜底通道:记一条,好提醒"这台设备该装 Agent 了"
_log.info(f"[{serial}] 剪贴板注入:{'、'.join(tried)} 都没有,"
f"改用{label}({pkg})成功 —— 建议给这台设备装设备端 Agent")
break
if "does not exist" in out or "unable to resolve Intent" in out or "Error type 3" in out:
tried.append(label)
continue
# 装了但启动失败:直接报错,不再往下试(下一个包大概率也没装)
return False, f"am start 失败({label}): {out.strip()[:120]}"
else:
return False, ("设备未安装剪贴板注入通道:需要设备端 Agent"
"(com.example.deviceagent)——从「应用管理 → 设备端应用商店」安装;"
"旧版独立 ClipInject(com.example.clipinject)也可以,但已不再随平台分发")
# 2. 读回验证:**必须轮询**。透明 Activity 要等窗口拿到焦点才写(还有 800ms 兜底),
# 只睡一次 0.5s 会读到**上一次的剪贴板内容** → 误报"写入可能被拒"(实测踩过:
# 内容明明写进去了,接口却回 1 台失败)。轮询到 ~3 秒:一致即成功;
# 一直不一致才算真的被拒。
try:
if d is None:
with ThreadPoolExecutor(max_workers=1) as pool:
d = pool.submit(u2.connect, serial).result(timeout=30)
except Exception as e:
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
deadline = time.time() + 3.0
got = None
while True:
time.sleep(0.4)
try:
with ThreadPoolExecutor(max_workers=1) as pool:
got = pool.submit(lambda: d.clipboard).result(timeout=15)
except Exception as e:
# 读回不可用(旧版 u2/连接失败)时按写入成功处理,避免误报,但带上具体原因
return True, f"已注入(读回验证不可用: {type(e).__name__}: {str(e)[:80]})"
if got == text:
return True, "已注入"
if time.time() >= deadline:
return False, f"剪贴板读回不一致(写入可能被拒): {str(got)[:60]!r}"