autool/utils_ios/package_init/ios_appstore_installer.py
2026-06-17 19:44:18 +08:00

590 lines
24 KiB
Python
Raw 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.

# coding: utf-8
"""
App Store 应用安装器
基于 IOSAutomationBase 实现的 App Store 自动安装功能
"""
from typing import Optional, Tuple, Dict
import time
import subprocess
import csv
import os
import re
from datetime import datetime, timezone, timedelta
# 使用绝对导入
try:
from utils_ios.package_init.ios_automation_base import IOSAutomationBase, logger
except ImportError:
from ios_automation_base import IOSAutomationBase, logger
class AppStoreInstaller(IOSAutomationBase):
"""App Store 应用安装器"""
# CSV文件路径
CSV_FILE = "output/ios/appstore_mapping.csv"
def __init__(self, server_url: str, udid: Optional[str] = None,
ssh_host: Optional[str] = None, go_ios: Optional[str] = 'ios',
alert_strategy: str = "allow", health_monitor=None):
"""
初始化安装器
Args:
server_url: WDA Server URL
udid: 设备UDID用于go-ios
ssh_host: SSH主机地址用于远程执行go-ios
go_ios: go-ios可执行文件位置
alert_strategy: Alert 处理策略 - "allow""deny"
health_monitor: WDAHealthMonitor 实例(可选)
"""
super().__init__(server_url, default_alert_strategy=alert_strategy,
udid=udid, ssh_host=ssh_host, go_ios=go_ios, health_monitor=health_monitor)
self.udid = udid
self.ssh_host = ssh_host
self.go_ios = go_ios
self._health_monitor = health_monitor # 保存引用,供等待循环直接使用
def _format_app_id(self, app_id: str) -> str:
"""格式化app_id"""
if re.match(r'^\d+$', app_id):
formatted = f"id{app_id}"
logger.debug(f"格式化 app_id: {app_id} -> {formatted}")
return formatted
elif re.match(r'^id\d+$', app_id):
logger.debug(f"app_id 已是标准格式: {app_id}")
return app_id
else:
logger.debug(f"app_id 使用 slug 格式: {app_id}")
return app_id
def _open_appstore_page(self, app_id: str) -> bool:
"""打开App Store详情页"""
target_url = f"itms-apps://itunes.apple.com/app/{app_id}"
return self._open_url(target_url)
def _check_network(self) -> bool:
"""检查网络连接(通过 Alert API 检测网络错误弹窗)"""
network_texts = ["无互联网连接"]
try:
alert = self.client.alert
if alert.exists:
alert_text = alert.text or ""
for keyword in network_texts:
if keyword in alert_text:
logger.error(f"网络连接异常 (Alert 文本): {alert_text}")
return False
except Exception as e:
logger.debug(f"检查网络 Alert 异常: {e}")
return True
def _check_app_unavailable(self) -> bool:
"""检查App是否不可用通过 WDA Alert API 直接读取弹窗文本)
系统弹窗不属于页面元素树,必须通过 alert API 获取文本,
不能通过 finder.check_exists 搜索页面元素。
"""
unavailable_texts = [
"不可用", "App不可用",
"無法取得", "无法获取"
]
try:
alert = self.client.alert
if alert.exists:
alert_text = alert.text or ""
for keyword in unavailable_texts:
if keyword in alert_text:
logger.warning(f"应用不可用 (Alert 文本): {alert_text}")
return True
# Alert 存在但不是不可用弹窗,记录日志供调试
logger.debug(f"检测到 Alert 但非不可用弹窗: {alert_text}")
except Exception as e:
logger.debug(f"检查应用不可用 Alert 异常: {e}")
return False
def _check_already_installed(self) -> bool:
"""检查是否已安装"""
open_buttons = ["打开", "Open", "open"]
found, label = self.finder.check_exists(open_buttons, check_types=['label'])
if found:
logger.info(f"应用已安装 (发现 '{label}' 按钮)")
return True
return False
def _find_and_click_get_button(self, max_retries: int = 2) -> bool:
"""查找并点击获取按钮,失败时重试"""
get_buttons = ["获取", "重新下载", "更新", "继续"]
for attempt in range(max_retries):
clicked, label = self.finder.find_and_click(get_buttons, timeout=2.0)
if clicked:
logger.info(f"点击获取按钮成功: {label}")
time.sleep(1)
return True
if attempt < max_retries - 1:
logger.warning(f"点击获取按钮失败 (第{attempt + 1}次),等待 WDA 恢复后重试...")
try:
self.client.wait_ready(3)
except Exception as e:
logger.warning(f"WDA wait_ready 异常: {e}")
time.sleep(1)
logger.error("点击获取按钮最终失败")
return False
def _check_paid_app(self) -> Tuple[bool, Optional[str]]:
"""
检查是否为付费应用。
通过 name="AppStore.offerButton[state=get]" 定位应用详情页的下载/价格按钮,
避免因页面其他金额文本(如订阅价格)导致误判。
调试发现:
- 免费应用:按钮 label == '重新下载''获取'
- 付费应用:按钮 label == 'US$8.99' 等价格字符串
"""
# 价格格式正则:货币代码+符号+金额,如 US$8.99、¥12、€4.99
price_pattern = re.compile(
r'^[A-Z]{0,3}[$¥€£₹₩₽฿₫₺]\d+(?:[.,]\d+)?$'
r'|^[$¥€£₹₩₽฿₫₺]\d+(?:[.,]\d+)?$',
re.IGNORECASE
)
# 确认为免费下载的按钮 label排除误判
free_labels = {"获取", "重新下载", "更新", "打开", "Open", "Get"}
try:
buttons = self.client(name="AppStore.offerButton[state=get]").find_elements()
if not buttons:
logger.debug("未找到 offerButton跳过付费检查")
return False, None
btn = buttons[0]
# find_elements() 返回 WDA Element 对象,通过 .label 属性访问
label = getattr(btn, 'label', None) or getattr(btn, 'text', None) or ""
logger.debug(f"付费检查按钮: label='{label}'")
if label in free_labels:
# 明确是免费下载按钮
return False, None
if price_pattern.match(label.strip()):
logger.warning(f"这是付费应用: {label}")
return True, label
except Exception as e:
logger.debug(f"检查付费应用异常: {e}")
return False, None
def _wait_loading_finish(self, loading_timeout: float = 30.0) -> bool:
"""等待"正在载入"状态结束
点击获取按钮后App Store 可能显示"正在载入"控件,
网络较慢时可能持续十几秒,需要等待其消失后安装按钮才会出现。
Returns:
True: 载入状态已结束(或从未出现)
False: 超时仍在载入
"""
loading_labels = ["正在载入", "正在載入", "Loading"]
# 先检查是否存在"正在载入"控件
found, label = self.finder.check_exists(loading_labels, check_types=['label'])
if not found:
logger.debug("未检测到'正在载入'状态,直接继续")
return True
logger.info(f"检测到'{label}'状态,等待加载完成(超时: {loading_timeout}秒)...")
start_time = time.time()
while time.time() - start_time < loading_timeout:
time.sleep(2)
try:
found, label = self.finder.check_exists(loading_labels, check_types=['label'])
except Exception as e:
logger.warning(f"检查载入状态异常: {e}")
continue
if not found:
elapsed = time.time() - start_time
logger.info(f"'正在载入'状态已结束,耗时 {elapsed:.1f}")
return True
logger.warning(f"等待'正在载入'超时 ({loading_timeout}秒)")
return False
def _confirm_install(self, max_retries: int = 3) -> bool:
"""确认安装,失败时重试
流程:
1. 等待"正在载入"状态结束(网络慢时可能需要较长时间)
2. 查找并点击"安装"确认按钮
WDA 可能在此步骤超时(如 wait_ready 超时导致点击未执行),
因此需要重试机制保证安装确认可靠执行。
"""
# 先等待"正在载入"状态结束
self._wait_loading_finish(loading_timeout=30.0)
install_buttons = ["安装", "Install", "install"]
for attempt in range(max_retries):
clicked, label = self.finder.find_and_click(install_buttons, timeout=3.0)
if clicked:
logger.info(f"点击安装确认按钮成功: {label}")
return True
if attempt < max_retries - 1:
logger.warning(f"点击安装确认按钮失败 (第{attempt + 1}次),等待 WDA 恢复后重试...")
try:
self.client.wait_ready(3)
except Exception as e:
logger.warning(f"WDA wait_ready 异常: {e}")
time.sleep(2)
# 安装确认按钮可能本身就不存在(不需要二次确认的情况),这不算错误
logger.debug("未找到安装确认按钮(可能无需二次确认)")
return False
def _handle_password_input(self) -> bool:
"""处理密码输入"""
# 等待页面稳定
self.client.wait_ready(2)
# 检查是否需要登录
signin_labels = ["登录", "登錄", "Sign In", "sign in"]
needs_password, found_label = self.finder.check_exists(signin_labels, auto_handle_alert=False)
if not needs_password:
logger.debug("无需密码验证")
return True
# 获取密码
ios_password = os.environ.get('IOS_PASSWORD', '')
if not ios_password:
logger.error("需要密码但未设置 IOS_PASSWORD 环境变量")
return False
# 输入密码
try:
logger.info("输入 Apple ID 密码")
self.client.send_keys(list(ios_password))
time.sleep(1)
# 点击登录按钮
clicked, label = self.finder.find_and_click(signin_labels, timeout=1.0, auto_handle_alert=False)
if not clicked:
logger.error("未找到登录按钮")
return False
# 等待验证完成
self.client.wait_ready(3)
logger.info("密码验证完成")
return True
except Exception as e:
logger.error(f"密码输入失败: {e}")
return False
def _get_installed_apps_info(self) -> Dict[str, Dict[str, str]]:
"""调用go-ios获取已安装应用信息"""
try:
base_cmd = [self.go_ios, "apps", "--list"]
if self.udid:
base_cmd.extend(["--udid", self.udid])
if self.ssh_host:
cmd = ["ssh", self.ssh_host, " ".join(base_cmd)]
else:
cmd = base_cmd
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
return {}
apps_info = {}
for line in result.stdout.strip().split('\n'):
line = line.strip()
if not line:
continue
# 输出格式: bundle_id app_name version
# 版本号是最后一个 token不含空格应用名可能含空格
# 先从右侧切出版本号,再从左侧切出 bundle_id中间全为应用名
right_parts = line.rsplit(None, 1) # ['bundle_id app_name', 'version']
if len(right_parts) == 2:
left, version = right_parts
left_parts = left.split(None, 1) # ['bundle_id', 'app_name']
if len(left_parts) == 2:
bundle_id, app_name = left_parts
apps_info[bundle_id] = {"app_name": app_name, "version": version}
elif len(left_parts) == 1:
# 行只有两个词bundle_id 和 version没有应用名
apps_info[left_parts[0]] = {"app_name": "", "version": version}
elif len(right_parts) == 1:
# 仅 bundle_id无应用名和版本
apps_info[right_parts[0]] = {"app_name": "", "version": ""}
return apps_info
except Exception:
return {}
def _load_app_mapping(self) -> Dict[str, Dict[str, str]]:
"""从CSV加载映射"""
mapping = {}
if not os.path.exists(self.CSV_FILE):
return mapping
try:
with open(self.CSV_FILE, 'r', encoding='utf-8') as f:
reader = csv.DictReader(f)
for row in reader:
app_id = row.get('app_id', '').strip()
if app_id:
mapping[app_id] = {
"bundle_id": row.get('bundle_id', ''),
"app_name": row.get('app_name', ''),
"version": row.get('version', ''),
"last_updated": row.get('last_updated', '')
}
except Exception:
pass
return mapping
def _save_app_mapping(self, app_id: str, bundle_id: str,
app_name: str, version: str):
"""保存映射到CSV"""
try:
# 使用东八区时间UTC+8
tz_cn = timezone(timedelta(hours=8))
current_time = datetime.now(tz_cn).strftime("%Y-%m-%d %H:%M:%S")
mapping = self._load_app_mapping()
mapping[app_id] = {
"bundle_id": bundle_id,
"app_name": app_name,
"version": version,
"last_updated": current_time
}
with open(self.CSV_FILE, 'w', encoding='utf-8', newline='') as f:
fieldnames = ['app_id', 'bundle_id', 'app_name', 'version', 'last_updated']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for aid, info in mapping.items():
writer.writerow({
'app_id': aid,
'bundle_id': info['bundle_id'],
'app_name': info['app_name'],
'version': info['version'],
'last_updated': info['last_updated']
})
except Exception:
pass
def _wait_for_installation(self, app_id: str, timeout: float, apps_before: Optional[Dict] = None) -> bool:
"""
等待安装完成
WDA 断联处理:等待循环中捕获到 WDA 网络异常时,触发自动恢复并继续等待,
而不是直接退出。恢复失败时才真正抛出异常。
Args:
app_id: App Store 应用 ID
timeout: 超时时间(秒)
apps_before: 安装前的应用列表(用于对比检测新安装的应用)
"""
logger.info(f"等待安装完成 (超时: {timeout}秒)...")
# 等待安装完成
start_time = time.time()
open_buttons = ["打开", "Open", "open"]
while time.time() - start_time < timeout:
try:
# 检查是否出现"打开"按钮
# find.check_exists 内部已有 _check_health_fast_fail检测到不健康时等待恢复
# 此处额外 try/except 兜底捕获 WDA 网络层直接抛出的连接异常
found, label = self.finder.check_exists(open_buttons, check_types=['label'])
except RuntimeError:
# RuntimeError 来自 _check_health_fast_fail恢复失败时直接上抛
raise
except Exception as e:
# WDA 网络层直接抛出的连接异常(连接拒绝、超时等)
logger.warning(f"安装等待中检测到 WDA 异常: {e}")
if self._health_monitor:
logger.info("触发 WDA 恢复并等待...")
recovered = self._health_monitor.trigger_recovery_and_wait(timeout=120)
if recovered:
logger.info("WDA 已恢复,继续等待安装完成...")
continue # 恢复成功,继续等待
else:
raise RuntimeError("WDA 恢复失败,安装等待中止") from e
else:
raise # 无 health_monitor 时原样上抛
if found:
logger.info(f"安装完成 (发现 '{label}' 按钮)")
# 通过对比 apps_before 和 apps_after 检测新安装的应用
if apps_before is not None:
apps_after = self._get_installed_apps_info()
if apps_after:
new_bundle_ids = set(apps_after.keys()) - set(apps_before.keys())
if len(new_bundle_ids) == 1:
bundle_id = list(new_bundle_ids)[0]
app_info = apps_after[bundle_id]
app_name = app_info.get('app_name', '')
version = app_info.get('version', '')
# 保存到CSV
self._save_app_mapping(app_id, bundle_id, app_name, version)
logger.info(f"Bundle ID (检测): {bundle_id}, 已保存到 CSV")
elif len(new_bundle_ids) == 0:
logger.warning("未检测到新安装的应用(可能是更新或重新安装)")
# 尝试从 CSV 缓存读取
app_mapping = self._load_app_mapping()
cached_info = app_mapping.get(app_id)
if cached_info and cached_info.get('bundle_id'):
logger.info(f"Bundle ID (缓存): {cached_info['bundle_id']}")
elif len(new_bundle_ids) > 1:
logger.warning(f"检测到多个新应用: {list(new_bundle_ids)}")
else:
logger.warning("未提供安装前应用列表,无法检测新安装的应用")
return True
time.sleep(2)
# 超时
logger.error(f"安装超时 ({timeout}秒)")
return False
def execute(self, app_id: str, timeout: float = 600.0) -> Tuple[bool, str]:
"""
执行安装流程
Args:
app_id: App Store应用ID
timeout: 安装超时时间(秒)
Returns:
Tuple[bool, str]: (应用是否成功安装在设备中, 失败原因说明)
"""
# 初始化
self._init_session()
app_id = self._format_app_id(app_id)
logger.info(f"开始安装应用: {app_id}")
try:
# 1. 检查WDA就绪
if not self._check_wda_ready():
return False, "WDA未就绪"
# 2. 打开App Store页面
if not self._open_appstore_page(app_id):
return False, "无法打开App Store页面"
# Alert 会在下次操作前自动处理
time.sleep(3) # 等待页面加载,确保弹窗有时间弹出
# 3. 先通过 Alert API 检查是否弹出"应用不可用"弹窗
if self._check_app_unavailable():
# 检测到不可用弹窗后主动 dismiss避免残留弹窗干扰后续测试
self.popup_handler.handle_alert(strategy="allow")
return False, "应用不可用"
# 4. 通过 Alert API 检查网络状态弹窗
if not self._check_network():
self.popup_handler.handle_alert(strategy="allow")
return False, "网络未连接"
# 5. 检查是否已安装
if self._check_already_installed():
logger.info(f"应用已在设备中: {app_id}")
return True, ""
# 6. 检查是否为付费应用
is_paid, price = self._check_paid_app()
if is_paid:
return False, f"付费应用: {price}"
# 7. 获取安装前的应用列表(在点击获取按钮之前)
logger.debug("获取安装前应用列表")
apps_before = self._get_installed_apps_info()
if apps_before:
logger.debug(f"记录了 {len(apps_before)} 个已安装应用")
else:
logger.warning("无法获取已安装应用列表,将无法检测新安装的应用")
# 8. 点击获取按钮(含重试)
if not self._find_and_click_get_button():
return False, "未找到获取/下载按钮"
# 9. 点击确认安装(含重试),处理密码输入
confirm_clicked = self._confirm_install()
logger.debug(f"安装确认步骤结果: {'需要确认且已点击' if confirm_clicked else '无需确认或未找到按钮'}")
if confirm_clicked and (not self._handle_password_input()):
return False, "密码验证失败"
# 10. 等待安装完成(传入 apps_before
if self._wait_for_installation(app_id, timeout, apps_before):
logger.info(f"应用已成功安装到设备: {app_id}")
return True, ""
else:
return False, f"安装超时 ({timeout}秒)"
except Exception as e:
logger.error(f"安装异常: {app_id} - {str(e)}")
return False, f"安装异常: {str(e)}"
def install_from_appstore(server_url: str, app_id: str, timeout: float = 600.0,
udid: Optional[str] = None,
ssh_host: Optional[str] = None,
go_ios: Optional[str] = 'ios',
alert_strategy: str = "allow",
health_monitor=None) -> Tuple[bool, str]:
"""
使用App Store安装应用
Args:
server_url: WDA Server URL
app_id: App Store应用ID
timeout: 安装超时时间(秒)
udid: 设备UDID
ssh_host: SSH主机地址
go_ios: go-ios可执行文件路径
alert_strategy: Alert 处理策略 - "allow""deny"
health_monitor: WDAHealthMonitor 实例(可选)
Returns:
Tuple[bool, str]: (应用是否成功安装在设备中, 失败原因说明)
"""
installer = AppStoreInstaller(server_url, udid, ssh_host, go_ios, alert_strategy, health_monitor=health_monitor)
return installer.execute(app_id, timeout)
if __name__ == "__main__":
# 使用示例
success, reason = install_from_appstore(
server_url="http://192.168.7.7:8100",
app_id="6523690380",
timeout=600,
ssh_host="nbt_sh_tplink@192.168.7.7",
go_ios='/Users/nbt_sh_tplink/Public/yfz/ios'
)
if success:
print("应用已成功安装到设备")
else:
print(f"应用安装失败: {reason}")