|
|
|
@@ -18,6 +18,67 @@ from ..config import (
|
|
|
|
|
from .log_streamer import log_streamer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BuildError(Exception):
|
|
|
|
|
"""带分类的打包错误"""
|
|
|
|
|
|
|
|
|
|
def __init__(self, message: str, category: str = "unknown", detail: str = ""):
|
|
|
|
|
super().__init__(message)
|
|
|
|
|
self.category = category
|
|
|
|
|
self.detail = detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# 错误分类规则:(关键词列表, 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 _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:
|
|
|
|
@@ -234,10 +295,25 @@ async def run_build_task(task_id: str):
|
|
|
|
|
await asyncio.to_thread(_cleanup_build_dir, build_dir)
|
|
|
|
|
|
|
|
|
|
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=str(e))
|
|
|
|
|
await log_streamer.emit_error(task_id, f"打包失败: {str(e)}")
|
|
|
|
|
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 asyncio.to_thread(_cleanup_build_dir, build_dir)
|
|
|
|
|
|
|
|
|
@@ -470,6 +546,7 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
|
|
|
|
f"-derivedDataPath {export_path / 'derived_data'} "
|
|
|
|
|
f"-destination generic/platform=ios -quiet"
|
|
|
|
|
)
|
|
|
|
|
archive_output = []
|
|
|
|
|
process = await asyncio.create_subprocess_shell(
|
|
|
|
|
archive_cmd,
|
|
|
|
|
cwd=str(build_dir),
|
|
|
|
@@ -479,11 +556,13 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
|
|
|
|
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:
|
|
|
|
|
raise Exception("Archive 失败")
|
|
|
|
|
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...")
|
|
|
|
@@ -494,6 +573,7 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
|
|
|
|
f"-exportPath {export_path} "
|
|
|
|
|
f"-exportOptionsPlist {export_plist}"
|
|
|
|
|
)
|
|
|
|
|
export_output = []
|
|
|
|
|
process = await asyncio.create_subprocess_shell(
|
|
|
|
|
export_cmd,
|
|
|
|
|
cwd=str(build_dir),
|
|
|
|
@@ -503,11 +583,13 @@ async def build_project(task_id: str, task, build_dir: Path) -> Path:
|
|
|
|
|
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:
|
|
|
|
|
raise Exception("导出 IPA 失败")
|
|
|
|
|
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"))
|
|
|
|
|