314 lines
11 KiB
Python
314 lines
11 KiB
Python
"""
|
||
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}") |