feat: Scheme 显示名称、打包结果按类型显示、错误分类

- Scheme 配置新增 displayName 字段,下拉框和任务列表显示友好名称
- Ad_Hoc 类型显示下载二维码,App_Store 类型显示 IPA 下载链接
- 新增 error_category 错误分类(证书/描述文件/编译/依赖等)
- 打包服务增加详细错误分析和分类
This commit is contained in:
shen
2026-06-07 09:23:43 +08:00
parent 6f4f625c56
commit 898ec01560
8 changed files with 426 additions and 28 deletions
+86 -4
View File
@@ -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"))