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

314 lines
11 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.

"""
iOS Provisioning Profile 证书管理模块
功能:
1. 检查所有 .mobileprovision 是否即将过期
2. 过期时自动删除旧证书并重新编译安装 WDA、Fastbot
"""
import os
import glob
import subprocess
import logging
import re
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Tuple, List, Dict
logger = logging.getLogger(__name__)
# Provisioning Profiles 目录
PROFILE_DIR = os.path.expanduser(
"~/Library/Developer/Xcode/UserData/Provisioning Profiles"
)
# 项目路径
WDA_PROJECT_DIR = os.path.expanduser("~/yfz/code/WebDriverAgent")
FASTBOT_PROJECT_DIR = os.path.expanduser("~/yfz/code/Fastbot_iOS/Fastbot-iOS")
# test-without-building 超时(秒),安装完成后自动开始测试,超时自动结束
INSTALL_TIMEOUT = 180 # 3 分钟
def check_profiles_expiry(threshold_hours: int = 24) -> Tuple[bool, List[Dict]]:
"""
检查所有 Provisioning Profile 的过期时间
Args:
threshold_hours: 过期阈值(小时),不足此值则需要更新
Returns:
(needs_renewal, details):
needs_renewal: 是否有任意证书即将过期
details: 每个证书的详细信息列表
"""
profiles = glob.glob(os.path.join(PROFILE_DIR, "*.mobileprovision"))
if not profiles:
logger.warning(f"未找到任何 Provisioning Profile: {PROFILE_DIR}")
return True, [] # 没有证书也需要重新安装
now = datetime.now(timezone.utc)
threshold = timedelta(hours=threshold_hours)
needs_renewal = False
details = []
for profile_path in profiles:
try:
# 通过 security cms 解析 plist
result = subprocess.run(
["security", "cms", "-D", "-i", profile_path],
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
logger.warning(f"解析证书失败: {profile_path}")
continue
# 从完整 plist XML 中提取 ExpirationDate
# 格式: <key>ExpirationDate</key>\n\t\t<date>2026-02-21T03:33:14Z</date>
plist_output = result.stdout
date_match = re.search(
r'<key>ExpirationDate</key>\s*<date>([^<]+)</date>',
plist_output
)
if not date_match:
logger.warning(f"未找到 ExpirationDate: {os.path.basename(profile_path)}")
continue
# 解析日期:格式为 2026-02-21T03:33:14Z
date_str = date_match.group(1)
expiry_date = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
remaining = expiry_date - now
profile_name = os.path.basename(profile_path)
detail = {
'file': profile_name,
'expiry': expiry_date.isoformat(),
'remaining_hours': remaining.total_seconds() / 3600,
'expired': remaining <= timedelta(0),
'needs_renewal': remaining < threshold,
}
details.append(detail)
if detail['needs_renewal']:
needs_renewal = True
logger.warning(
f"证书即将过期: {profile_name} "
f"(剩余 {remaining.total_seconds()/3600:.1f}h, "
f"过期时间 {date_str})"
)
else:
logger.info(
f"证书有效: {profile_name} "
f"(剩余 {remaining.total_seconds()/3600:.1f}h)"
)
except Exception as e:
logger.error(f"检查证书异常 {profile_path}: {e}")
return needs_renewal, details
def _delete_all_profiles():
"""删除所有 Provisioning Profiles"""
profiles = glob.glob(os.path.join(PROFILE_DIR, "*.mobileprovision"))
for p in profiles:
try:
os.remove(p)
logger.info(f"已删除证书: {os.path.basename(p)}")
except Exception as e:
logger.error(f"删除证书失败 {p}: {e}")
logger.info(f"共删除 {len(profiles)} 个证书文件")
def _run_xcodebuild(cmd: list, desc: str, timeout: int = 600) -> bool:
"""执行 xcodebuild 命令"""
logger.info(f"[证书更新] {desc}: {' '.join(cmd[:6])}...")
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=timeout
)
if result.returncode == 0:
logger.info(f"[证书更新] {desc} 成功")
return True
else:
# xcodebuild 输出可能很长,只取最后几行
stderr_tail = '\n'.join(result.stderr.split('\n')[-5:]) if result.stderr else ''
stdout_tail = '\n'.join(result.stdout.split('\n')[-5:]) if result.stdout else ''
logger.error(f"[证书更新] {desc} 失败 (rc={result.returncode})\n{stderr_tail}\n{stdout_tail}")
return False
except subprocess.TimeoutExpired:
# test-without-building 超时是正常的(安装完成后测试在运行,超时即可)
logger.info(f"[证书更新] {desc} 超时结束(预期行为)")
return True
except Exception as e:
logger.error(f"[证书更新] {desc} 异常: {e}")
return False
def rebuild_wda_and_fastbot(udid: str) -> bool:
"""
删除旧证书并重新编译安装 WDA 和 Fastbot
Args:
udid: 设备 UDID
Returns:
是否成功
"""
logger.info("=" * 50)
logger.info("[证书更新] 开始重新编译安装 WDA 和 Fastbot")
logger.info("=" * 50)
# 1. 删除所有旧证书
_delete_all_profiles()
# 2. 编译并安装 WDA
# build-for-testing编译 + 自动更新证书)
wda_build_ok = _run_xcodebuild(
[
"xcodebuild",
"-project", os.path.join(WDA_PROJECT_DIR, "WebDriverAgent.xcodeproj"),
"-scheme", "WebDriverAgentRunner",
"build-for-testing",
"-destination", f"id={udid}",
"-allowProvisioningUpdates",
],
desc="WDA build-for-testing",
timeout=300,
)
if not wda_build_ok:
logger.error("[证书更新] WDA 编译失败,中止")
return False
# test-without-building安装到设备会自动启动测试超时结束即可
_run_xcodebuild(
[
"xcodebuild",
"-project", os.path.join(WDA_PROJECT_DIR, "WebDriverAgent.xcodeproj"),
"-scheme", "WebDriverAgentRunner",
"USE_IP=127.0.0.1",
"test-without-building",
"-destination", f"id={udid}",
],
desc="WDA test-without-building (安装)",
timeout=INSTALL_TIMEOUT,
)
# 3. 编译并安装 Fastbot
fastbot_build_ok = _run_xcodebuild(
[
"xcodebuild",
"-workspace", os.path.join(FASTBOT_PROJECT_DIR, "Fastbot-iOS.xcworkspace"),
"-scheme", "FastbotRunner",
"IPHONEOS_DEPLOYMENT_TARGET=13.0",
"build-for-testing",
"-destination", f"id={udid}",
"-allowProvisioningUpdates",
],
desc="Fastbot build-for-testing",
timeout=300,
)
if not fastbot_build_ok:
logger.error("[证书更新] Fastbot 编译失败,中止")
return False
# test-without-building安装到设备
_run_xcodebuild(
[
"xcodebuild",
"-workspace", os.path.join(FASTBOT_PROJECT_DIR, "Fastbot-iOS.xcworkspace"),
"-scheme", "FastbotRunner",
"IPHONEOS_DEPLOYMENT_TARGET=13.0",
"test-without-building",
"BUNDLEID=com.apple.AppStore",
"-destination", f"id={udid}",
],
desc="Fastbot test-without-building (安装)",
timeout=INSTALL_TIMEOUT,
)
logger.info("[证书更新] WDA 和 Fastbot 重新编译安装完成")
return True
def ensure_certs_valid(udid: str, runner=None, threshold_hours: int = 24) -> bool:
"""
检查证书有效性,过期则自动更新
Args:
udid: 设备 UDID
runner: GoIOSRunner 实例(用于更新后重启 WDA
threshold_hours: 过期阈值(小时)
Returns:
True 表示证书有效(或已成功更新)
"""
needs_renewal, details = check_profiles_expiry(threshold_hours)
if not needs_renewal:
logger.info("[证书检查] 所有证书有效,无需更新")
return True
logger.warning("[证书检查] 检测到证书即将过期,开始自动更新...")
#安装前关闭wda
if runner:
try:
runner.stop_wda()
except Exception as e:
logger.error(f"[证书检查] WDA 停止失败: {e}")
success = rebuild_wda_and_fastbot(udid)
if success and runner:
# 重启 WDA
logger.info("[证书检查] 重启 WDA...")
try:
runner.stop_wda()
import time
time.sleep(2)
runner.start_wda(port=8100)
# 等待 WDA 就绪(使用 USBClient 通过 USB 连接,更稳定)
from wda import USBClient as WDAUSBClient
client = WDAUSBClient(udid=udid)
client.wait_ready(timeout=30)
logger.info("[证书检查] WDA 重启成功")
except Exception as e:
logger.error(f"[证书检查] WDA 重启失败: {e}")
return False
return success
import requests
import time
wechat_tokens = {"ios": "d1d44f3a-0110-4496-804a-dd84d0661b02"}
def send_weCom_alert(message):
"""发送企业微信告警"""
title = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}, Mac mini"
content = f"{title}\n\n{message}"
if not wechat_tokens:
logger.warning("未配置企业微信 Webhook Key跳过发送。")
return
for name, key in wechat_tokens.items():
url = f'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}'
payload = {
"msgtype": "text",
"text": {
"content": content
}
}
try:
response = requests.post(url, json=payload, timeout=10)
if response.status_code == 200:
logger.info(f"企业微信告警已发送给: {name}")
else:
logger.warning(f"发送企业微信告警到 {name} 失败: HTTP {response.status_code}, {response.text}")
except Exception as e:
logger.warning(f"发送企业微信告警到 {name} 失败: {e}")