iOSBuildServer/backend/services/build_service.py
2026-07-20 14:34:18 +09:00

826 lines
32 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.

"""打包服务核心"""
import asyncio
import json
import os
import plistlib
import shutil
import subprocess
import zipfile
from datetime import datetime
from pathlib import Path
from typing import List
from ..config import (
BUILD_BASE_DIR,
COPY_ITEMS,
DEFAULT_BUILD_DIR_RETENTION_HOURS,
BUILD_TIMEOUT_HOURS,
SKINS_DIR,
AUTOMATION_DIR,
get_source_dir,
get_shared_source_dir,
get_git_remote_url,
mask_git_remote_url,
)
from .log_streamer import log_streamer
from .distribution import DistributionError, publish_ipa
from .project_patcher import ProjectPatchError, apply_project_config
class BuildError(Exception):
"""带分类的打包错误"""
def __init__(self, message: str, category: str = "unknown", detail: str = ""):
super().__init__(message)
self.category = category
self.detail = detail
# 共享源码目录只能同时被一个任务切换、清理和复制。
# 后续在各自 build_dir 中执行的配置、依赖安装和编译不受此锁限制。
source_prepare_lock = asyncio.Lock()
def _decode_provisioning_profile(profile_path: Path) -> dict:
"""沿用 AutoPacking 脚本的 security cms 方式读取描述文件。"""
try:
plist_xml = subprocess.check_output(
["security", "cms", "-D", "-i", str(profile_path)],
stderr=subprocess.DEVNULL,
)
# 与原脚本一致,跳过 security 可能输出在 XML 前面的 Apple 标记行。
lines = plist_xml.splitlines(keepends=True)
xml_start = next(
(
index
for index, line in enumerate(lines)
if line.lstrip().startswith((b"<?xml", b"<!DOCTYPE"))
),
0,
)
return plistlib.loads(b"".join(lines[xml_start:]))
except (OSError, subprocess.CalledProcessError, plistlib.InvalidFileException) as exc:
raise BuildError(f"读取描述文件失败: {profile_path.name}", category="provisioning") from exc
def _resolve_provisioning_profile(profile_reference: str) -> tuple[str, str]:
"""按 AutoPacking 的规则从描述文件名称提取 Name 和 TeamIdentifier。"""
configured_path = Path(profile_reference).expanduser()
if configured_path.is_file():
profile = _decode_provisioning_profile(configured_path)
else:
profiles_dir = (
Path.home()
/ "Library"
/ "Developer"
/ "Xcode"
/ "UserData"
/ "Provisioning Profiles"
)
if not profiles_dir.is_dir():
raise BuildError(f"Provisioning Profiles 目录不存在: {profiles_dir}", category="provisioning")
profile = None
for candidate in profiles_dir.iterdir():
if candidate.suffix != ".mobileprovision":
continue
try:
candidate_profile = _decode_provisioning_profile(candidate)
except BuildError:
continue
if candidate_profile.get("Name") == profile_reference:
profile = candidate_profile
break
if profile is None:
raise BuildError(f"未找到名称为 [{profile_reference}] 的描述文件", category="provisioning")
profile_name = profile.get("Name", "")
team_ids = profile.get("TeamIdentifier", [])
team_id = team_ids[0] if isinstance(team_ids, list) and team_ids else team_ids
if not profile_name or not team_id:
raise BuildError(f"描述文件缺少 Name 或 TeamIdentifier: {profile_reference}", category="provisioning")
return profile_name, team_id
# 错误分类规则:(关键词列表, category, 友好提示)
_ERROR_RULES = [
(["No signing certificate", "Signing certificate \"", "Code Signing Error",
"Code Sign error", "errSecInternalComponent", "CSSMERR_TP_NOT_TRUSTED",
"iPhone Distribution", "iPhone Developer: no identity found"],
"certificate", "签名证书问题:证书过期、未安装或不匹配,请检查钥匙串中的证书"),
(["Provisioning profile", "No matching provisioning profiles",
"embedded.mobileprovision", "PROVISIONING_PROFILE_SPECIFIER",
"provisioning profile", "No matching",
"requires a provisioning profile"],
"provisioning", "描述文件问题:描述文件过期、不匹配或未安装,请检查 Apple Developer 后台"),
(["Undefined symbols", "linker command failed", "ld: symbol(s) not found",
"ld: framework not found for"],
"compilation", "链接错误:符号未定义或框架缺失,请检查代码引用"),
(["Swift Compiler Error", "Use of undeclared identifier",
"cannot find type", "cannot find symbol in scope", "no such module"],
"compilation", "Swift 编译错误:请检查代码语法和类型引用"),
([" error:", "fatal error:"],
"compilation", "代码编译错误:请检查代码是否有语法或类型错误"),
(["CocoaPods", "pod install", "ld: library not found for",
"library not found for", "Sandbox is not in sync with Podfile.lock"],
"dependency", "依赖问题Pod 依赖缺失或版本不兼容,请检查 Podfile"),
(["xcodebuild: error", "Unable to access scheme", "does not contain a scheme"],
"build", "构建配置错误Scheme 或 Workspace 配置有误"),
]
def _git_env() -> dict:
"""为无人值守的构建任务禁用交互提示与凭据缓存写入。"""
env = os.environ.copy()
env["GIT_TERMINAL_PROMPT"] = "0"
return env
_GIT_NO_CREDENTIAL_HELPER = ("git", "-c", "credential.helper=")
def _classify_build_error(output_lines: list, step: str) -> tuple:
"""解析构建输出,返回 (category, friendly_message)
Args:
output_lines: 构建过程的输出行列表
step: 失败步骤 (archive/export)
Returns:
(category, friendly_message) 元组
"""
output_text = "\n".join(output_lines)
for keywords, category, hint in _ERROR_RULES:
for kw in keywords:
if kw.lower() in output_text.lower():
return category, hint
# 未匹配到特定分类
if step == "archive":
return "build", "Archive 失败:请查看详细日志排查原因"
elif step == "export":
return "build", "导出 IPA 失败:请查看详细日志排查原因"
return "unknown", "打包失败:请查看详细日志排查原因"
def _cleanup_build_dir(build_dir: Path):
"""删除整个打包目录"""
try:
if build_dir.exists():
shutil.rmtree(build_dir)
except Exception:
pass
def _cleanup_old_builds(db):
"""清理超过保留时间的打包目录"""
from ..models import BuildConfig
config = db.query(BuildConfig).filter(BuildConfig.key == "build_dir_retention_hours").first()
hours = int(config.value) if config else DEFAULT_BUILD_DIR_RETENTION_HOURS
cutoff = datetime.utcnow().timestamp() - hours * 3600
base = BUILD_BASE_DIR
if not base.exists():
return
for d in base.iterdir():
if d.is_dir() and d.name.startswith("build_readoor_"):
try:
if d.stat().st_mtime < cutoff:
shutil.rmtree(d)
except Exception:
pass
def _cleanup_old_logs():
"""清理超过 7 天的打包日志"""
logs_dir = Path(__file__).parent.parent / "logs"
if not logs_dir.exists():
return
cutoff = datetime.utcnow().timestamp() - 7 * 86400
for f in logs_dir.iterdir():
if f.is_file() and f.suffix == ".log":
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception:
pass
def _db_update(db, task, **fields):
"""同步更新 task 字段并 commit在线程中调用"""
for k, v in fields.items():
setattr(task, k, v)
db.commit()
async def update_source(task_id: str, source_dir: Path, branch: str):
"""将共享源码工作目录强制同步到指定远程分支。"""
remote_url = get_git_remote_url(with_credentials=True)
remote_url_masked = mask_git_remote_url(remote_url)
git_env = _git_env()
cloned = not source_dir.exists()
if cloned:
if not remote_url:
raise Exception(f"源码目录不存在且未配置 GIT_REMOTE_URL: {source_dir}")
source_dir.parent.mkdir(parents=True, exist_ok=True)
await log_streamer.emit(task_id, f"初始化共享源码目录: {remote_url_masked}")
process = await asyncio.create_subprocess_exec(
*_GIT_NO_CREDENTIAL_HELPER, "clone", remote_url, str(source_dir),
env=git_env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception("git clone 失败")
# git clone 成功即保证目录有效;已有目录则额外拦截误配置路径。
if not cloned and not (source_dir / ".git").exists():
raise Exception(f"共享源码目录不是 Git 仓库: {source_dir}")
await log_streamer.emit(task_id, f"同步共享源码到分支: {branch}")
if remote_url:
process = await asyncio.create_subprocess_exec(
*_GIT_NO_CREDENTIAL_HELPER, "remote", "set-url", "origin", remote_url,
env=git_env,
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
await process.wait()
if process.returncode != 0:
raise Exception("git remote set-url 失败")
commands = [
(("git", "fetch", "origin"), "git fetch 失败"),
(("git", "checkout", "-B", branch, f"origin/{branch}"), f"git checkout {branch} 失败"),
(("git", "reset", "--hard", f"origin/{branch}"), f"git reset {branch} 失败"),
(("git", "clean", "-ffdx"), "git clean 失败"),
]
for command, error_message in commands:
process = await asyncio.create_subprocess_exec(
*_GIT_NO_CREDENTIAL_HELPER, *command[1:], env=git_env,
cwd=str(source_dir), stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception(error_message)
await log_streamer.emit(task_id, f"源码已就绪: {source_dir}")
async def get_source_commit(source_dir: Path) -> str:
"""读取当前共享源码快照的 commit SHA。"""
process = await asyncio.create_subprocess_exec(
"git", "rev-parse", "HEAD", cwd=str(source_dir),
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT,
)
output = await process.stdout.read()
await process.wait()
if process.returncode != 0:
raise Exception("读取源码 commit 失败")
return output.decode("utf-8", errors="replace").strip()
async def prepare_source_snapshot(task_id: str, task) -> tuple[Path, str]:
"""准备任务独立源码快照;远程仓库模式下此过程全局串行。"""
if not get_git_remote_url():
source_dir = get_source_dir(task.branch)
if not source_dir.exists():
raise Exception(
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
)
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
return await copy_source_code(task_id, task, source_dir), ""
source_dir = get_shared_source_dir()
await log_streamer.emit(task_id, "等待共享源码准备队列...")
async with source_prepare_lock:
await log_streamer.emit(task_id, "开始准备共享源码快照")
await update_source(task_id, source_dir, task.branch)
commit = await get_source_commit(source_dir)
await log_streamer.emit(task_id, f"源码版本: {task.branch} @ {commit}")
build_dir = await copy_source_code(task_id, task, source_dir)
await log_streamer.emit(task_id, "共享源码快照已就绪,开始并行打包")
return build_dir, commit
async def run_build_task(task_id: str):
"""执行打包任务"""
from ..database import SessionLocal
from ..models import Task
db = SessionLocal()
try:
task = await asyncio.to_thread(
lambda: db.query(Task).filter(Task.id == task_id).first()
)
if not task:
return
# 创建日志队列(必须在 emit 之前)
log_streamer.create_queue(task_id)
# 更新状态
await asyncio.to_thread(_db_update, db, task,
status="running", started_at=datetime.utcnow())
await log_streamer.emit_step(task_id, "开始打包")
# 清理过期的打包目录和日志
await asyncio.to_thread(_cleanup_old_builds, db)
await asyncio.to_thread(_cleanup_old_logs)
build_dir = None
try:
timeout_seconds = BUILD_TIMEOUT_HOURS * 3600
async def _do_build():
nonlocal build_dir
# 1. 串行准备源码快照;后续步骤在任务独立目录中并行执行。
await asyncio.to_thread(_db_update, db, task, current_step="source_prepare")
build_dir, source_commit = await prepare_source_snapshot(task_id, task)
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
# 2. 生成配置
await asyncio.to_thread(_db_update, db, task, current_step="config")
config_data = await generate_config(task_id, task, build_dir)
config_data["SOURCE_BRANCH"] = task.branch
config_data["SOURCE_COMMIT"] = source_commit
await asyncio.to_thread(_db_update, db, task,
config_json=json.dumps(config_data, ensure_ascii=False))
# 3. 替换项目配置
await asyncio.to_thread(_db_update, db, task, current_step="patch")
await patch_project(task_id, task, config_data, build_dir)
# 3.5 安装依赖
await asyncio.to_thread(_db_update, db, task, current_step="pod_install")
await run_pod_install(task_id, build_dir)
# 4. 代码混淆(可选)
if task.obfuscation:
await asyncio.to_thread(_db_update, db, task, current_step="obfuscation")
await run_obfuscation(task_id, task, config_data, build_dir)
# 5. 构建项目
await asyncio.to_thread(_db_update, db, task, current_step="build")
ipa_path = await build_project(task_id, task, config_data, build_dir)
# 查找 dSYM
dsym_path = await find_dsym(task_id, build_dir)
# 查找混淆映射表
obf_maps_path = build_dir / "obfuscation_maps"
update_fields = {"ipa_path": str(ipa_path)}
if dsym_path:
update_fields["dsym_path"] = str(dsym_path)
if obf_maps_path.exists():
update_fields["obfuscation_maps_path"] = str(obf_maps_path)
await asyncio.to_thread(_db_update, db, task, **update_fields)
# 6. 上传产物Ad Hoc 发布安装页App Store 上传 IPA 供下载提交。
await asyncio.to_thread(_db_update, db, task, current_step="upload")
oss_url, qr_code_path = await upload_ipa(
task_id, task, config_data, ipa_path, build_dir
)
upload_fields = {}
if oss_url:
upload_fields["oss_url"] = oss_url
if qr_code_path:
upload_fields["qr_code_path"] = qr_code_path
if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 完成
await asyncio.to_thread(_db_update, db, task,
status="completed", completed_at=datetime.utcnow(),
current_step=None)
await log_streamer.emit_step(task_id, "打包完成")
await log_streamer.emit(task_id, f"IPA: {ipa_path}")
if task.oss_url:
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
# 带超时执行打包
await asyncio.wait_for(_do_build(), timeout=timeout_seconds)
# 保存打包日志
if build_dir and build_dir.exists():
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
# 清理打包目录(保留产物文件,删除源码拷贝)
await log_streamer.emit(task_id, "清理临时文件...")
await asyncio.to_thread(_cleanup_build_dir, build_dir)
except asyncio.TimeoutError:
error_msg = f"打包超时(已运行 {BUILD_TIMEOUT_HOURS} 小时)"
await asyncio.to_thread(_db_update, db, task,
status="failed", completed_at=datetime.utcnow(),
error_message=error_msg, error_category="timeout")
await log_streamer.emit_error(task_id, error_msg)
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
except asyncio.CancelledError:
await asyncio.to_thread(_db_update, db, task,
status="cancelled", completed_at=datetime.utcnow())
await log_streamer.emit(task_id, "任务已取消")
if build_dir and build_dir.exists():
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
await asyncio.to_thread(_cleanup_build_dir, build_dir)
else:
await asyncio.to_thread(log_streamer.save_log, task_id, None)
except Exception as e:
if isinstance(e, BuildError):
category = e.category
error_msg = str(e)
else:
# 根据异常消息推断分类
msg = str(e).lower()
if "git" in msg:
category = "git"
elif "配置替换" in msg:
category = "config"
elif "混淆" in msg:
category = "config"
else:
category = "unknown"
error_msg = str(e)
await asyncio.to_thread(_db_update, db, task,
status="failed", completed_at=datetime.utcnow(),
error_message=error_msg, error_category=category)
await log_streamer.emit_error(task_id, f"打包失败: {error_msg}")
if build_dir and build_dir.exists():
await log_streamer.emit(task_id, f"临时文件保留在: {build_dir}")
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
finally:
log_streamer.complete(task_id)
finally:
db.close()
async def copy_source_code(task_id: str, task, source_dir: Path) -> Path:
"""从分支源码目录拷贝代码到打包目录"""
await log_streamer.emit_step(task_id, "拷贝代码")
build_dir = BUILD_BASE_DIR / f"build_readoor_{task_id}"
if build_dir.exists():
shutil.rmtree(build_dir)
build_dir.mkdir(parents=True)
for item in COPY_ITEMS:
src = source_dir / item
# 历史分支中该文件可能仍是小写 podfile构建副本统一使用 Pods.xcodeproj
# 引用的标准名称 Podfile避免大小写敏感文件系统报路径不一致。
if item == "Podfile" and not src.is_file():
src = source_dir / "podfile"
dst = build_dir / item
if src.is_dir():
await log_streamer.emit(task_id, f"拷贝目录: {item}")
shutil.copytree(src, dst, ignore=shutil.ignore_patterns(".git", "DerivedData"))
elif src.is_file():
await log_streamer.emit(task_id, f"拷贝文件: {item}")
shutil.copy2(src, dst)
await log_streamer.emit(task_id, "代码拷贝完成")
return build_dir
async def generate_config(task_id: str, task, build_dir: Path) -> dict:
"""生成打包配置"""
await log_streamer.emit_step(task_id, "生成配置")
from ..routers.config import load_config, _config_lock
config = load_config()
apps = config.get("apps", {})
schemes = config.get("schemes", {})
versions = config.get("versions", {})
app = apps.get(task.app_id, {})
scheme = schemes.get(task.scheme_id, {})
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
app_ver = versions.get("app_ver", "2.180.0")
build_ver = versions.get("build_ver", "2.180.0.0")
# App_Store 打包时自动递增 Build_Ver 第四位版本号
if task.build_type == "App_Store":
async with _config_lock:
parts = build_ver.split(".")
if len(parts) >= 4:
parts[3] = str(int(parts[3]) + 1)
else:
parts.append("1")
build_ver = ".".join(parts)
config = load_config()
config["versions"] = {
"app_ver": app_ver,
"build_ver": build_ver,
}
from ..routers.config import save_config
save_config(config)
await log_streamer.emit(task_id, f"Build_Ver 已递增: {build_ver}")
# 构建配置
config_data = {
"VERSION": app_ver,
"BUILD_VERSION": build_ver,
"SERVER": app.get("server", ""),
"API": app.get("API", ""),
"APPID_NAME": app.get("name", ""),
"APPID": app.get("AppGuid", app.get("AppId", "")),
"UPLOAD_KEY": app.get("upload_key", task.app_id),
"SCHEME": scheme.get("name", ""),
"OSS_FLODER": scheme.get("ossFloder", ""),
"BUILD_TYPE": task.build_type,
"ENABLE_OBFUSCATION": task.obfuscation,
}
# 证书配置
certificates = app.get("certificates", {})
# 无需证书的应用(如英汉大词典),使用项目默认签名
if not certificates:
config_data["NO_CERT"] = True
await log_streamer.emit(task_id, "该应用无需配置证书,使用项目默认签名")
else:
cert = certificates.get(task.build_type, {})
if cert:
config_data["CERTIFICATE"] = cert.get("cer", "")
config_data["PROVISIONING_PROFILE"] = cert.get("pro", "")
config_data["BUNDLE_ID"] = cert.get("name", "")
theme_value = cert.get("theme", "")
config_data["THEME"] = theme_value
# 皮肤只从服务端托管的上传包或默认资源读取,不使用分支源码目录。
if theme_value:
skin_name = Path(theme_value).name
skin_zip = SKINS_DIR / app.get("upload_key", "") / f"{skin_name}.zip"
if skin_zip.exists():
skin_extract_dir = build_dir / "skin_temp"
skin_extract_dir.mkdir(exist_ok=True)
with zipfile.ZipFile(skin_zip, "r") as zf:
zf.extractall(skin_extract_dir)
entries = list(skin_extract_dir.iterdir())
if len(entries) == 1 and entries[0].is_dir():
config_data["THEME"] = str(entries[0])
else:
config_data["THEME"] = str(skin_extract_dir)
await log_streamer.emit(task_id, f"皮肤包 {skin_name}.zip 已解压")
else:
default_theme = AUTOMATION_DIR / "themes" / skin_name
if default_theme.is_dir():
config_data["THEME"] = str(default_theme)
await log_streamer.emit(task_id, f"使用服务默认皮肤: {skin_name}")
else:
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
# 严格沿用 AutoPacking/start_build_app.py 的 configure_provisioning
# 描述文件必须成功匹配,并从文件本身提取 Name 与 TeamIdentifier。
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
if provisioning_profile:
profile_name, team_id = await asyncio.to_thread(
_resolve_provisioning_profile, provisioning_profile
)
config_data["PROVISIONING_NAME"] = profile_name
config_data["TEAM_ID"] = team_id
# 关联域名等配置
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]:
if key in app:
config_data[key] = app[key]
# 写入配置文件
config_output_path = build_dir / "config_output.json"
with open(config_output_path, "w", encoding="utf-8") as f:
json.dump(config_data, f, indent=2, ensure_ascii=False)
await log_streamer.emit(task_id, "配置已生成")
return config_data
async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
"""替换项目配置"""
await log_streamer.emit_step(task_id, "替换项目配置")
try:
patch_lines = await asyncio.to_thread(apply_project_config, build_dir, config_data)
except ProjectPatchError as exc:
raise BuildError(str(exc), category="config") from exc
(build_dir / "patch_result.log").write_text("\n".join(patch_lines), encoding="utf-8")
for line in patch_lines:
await log_streamer.emit(task_id, line)
await log_streamer.emit(task_id, "项目配置替换完成")
async def run_pod_install(task_id: str, build_dir: Path):
"""安装 CocoaPods 依赖"""
await log_streamer.emit_step(task_id, "安装依赖")
process = await asyncio.create_subprocess_exec(
"pod", "install",
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise BuildError("pod install 失败", category="config")
await log_streamer.emit(task_id, "依赖安装完成")
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path):
"""执行代码混淆"""
await log_streamer.emit_step(task_id, "代码混淆")
env = os.environ.copy()
env["TARGET_NAME"] = config_data.get("SCHEME", "")
env["SKIP_OBF_PHASE"] = "1"
# 自检
self_check_script = AUTOMATION_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
process = await asyncio.create_subprocess_exec(
"bash", str(self_check_script), str(build_dir),
cwd=str(build_dir),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception("混淆自检失败")
await log_streamer.emit(task_id, "混淆自检通过")
# 执行混淆
obfuscate_script = AUTOMATION_DIR / "obfuscation" / "obfuscate_symbols.py"
process = await asyncio.create_subprocess_exec(
"python3", str(obfuscate_script), "--archive-mode",
cwd=str(build_dir),
env=env,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
raise Exception("代码混淆失败")
await log_streamer.emit(task_id, "代码混淆完成")
async def build_project(task_id: str, task, config_data: dict, build_dir: Path) -> Path:
"""构建项目"""
await log_streamer.emit_step(task_id, "构建项目")
scheme = config_data.get("SCHEME", "")
export_path = build_dir / "build"
archive_path = export_path / f"{scheme}.xcarchive"
workspace_path = build_dir / "readoor.xcworkspace"
export_path.mkdir(parents=True, exist_ok=True)
# 清理
await log_streamer.emit(task_id, "清理项目...")
process = await asyncio.create_subprocess_exec(
"xcodebuild", "clean",
"-workspace", workspace_path.name,
"-scheme", scheme,
"-configuration", "Release",
"-derivedDataPath", str(export_path / "derived_data"),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
await log_streamer.emit(task_id, decoded)
await process.wait()
# Archive
await log_streamer.emit(task_id, "开始 Archive...")
archive_output = []
process = await asyncio.create_subprocess_exec(
"xcodebuild", "archive",
"-workspace", workspace_path.name,
"-scheme", scheme,
"-configuration", "Release",
"-archivePath", str(archive_path),
"-derivedDataPath", str(export_path / "derived_data"),
"-destination", "generic/platform=ios",
"-quiet",
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
archive_output.append(decoded)
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
category, hint = _classify_build_error(archive_output, "archive")
raise BuildError(hint, category=category, detail="\n".join(archive_output[-20:]))
# 导出 IPA
await log_streamer.emit(task_id, "导出 IPA...")
export_plist = build_dir / "exportOptions.plist"
export_output = []
process = await asyncio.create_subprocess_exec(
"xcodebuild", "-exportArchive",
"-archivePath", str(archive_path),
"-exportPath", str(export_path),
"-exportOptionsPlist", str(export_plist),
cwd=str(build_dir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
async for line in process.stdout:
decoded = line.decode("utf-8", errors="replace").strip()
if decoded:
export_output.append(decoded)
await log_streamer.emit(task_id, decoded)
await process.wait()
if process.returncode != 0:
category, hint = _classify_build_error(export_output, "export")
raise BuildError(hint, category=category, detail="\n".join(export_output[-20:]))
# 查找 IPA 文件
ipa_files = list(export_path.glob("*.ipa"))
if not ipa_files:
raise Exception("未找到 IPA 文件")
ipa_path = ipa_files[0]
await log_streamer.emit(task_id, f"IPA 已导出: {ipa_path.name}")
return ipa_path
async def find_dsym(task_id: str, build_dir: Path) -> Path:
"""查找 dSYM 文件"""
derived_data = build_dir / "build" / "derived_data"
if not derived_data.exists():
return None
for dsym in derived_data.rglob("*.dSYM"):
return dsym
return None
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
"""上传 IPA返回 (download_url, qr_code_path)"""
await log_streamer.emit_step(task_id, "上传分发平台")
# 从当前服务的 config.json 读取上传配置,不执行分支源码中的上传脚本。
from ..routers.config import load_config
full_config = load_config()
config_data["_upload_config"] = full_config.get("upload", {})
try:
await log_streamer.emit(task_id, f"使用 {config_data['_upload_config'].get('mode', '')} 发布 IPA...")
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
except DistributionError as exc:
raise BuildError(str(exc), category="upload") from exc