一、账号台账(新表 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 会读到上一次的内容 → 误报"写入可能被拒"(实测内容已写入却回失败)
113 lines
5.7 KiB
Python
113 lines
5.7 KiB
Python
"""剪贴板注入公共模块(设备端 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}"
|