"""打包服务核心""" 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 .notification import NotificationError, send_dingtalk_notification 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 _patch_afnetworking_private_headers(build_dir: Path) -> int: """移除旧版 AFNetworking 对 Xcode 新 SDK 私有头的直接引用。""" afnetworking_dir = build_dir / "Pods" / "AFNetworking" if not afnetworking_dir.is_dir(): return 0 private_import = "#import " patched_files = 0 for source_path in afnetworking_dir.rglob("*"): if source_path.suffix not in {".h", ".m", ".mm"} or not source_path.is_file(): continue content = source_path.read_text(encoding="utf-8") if private_import not in content: continue updated_lines = [ line for line in content.splitlines(keepends=True) if line.strip() != private_import ] original_mode = source_path.stat().st_mode try: source_path.chmod(original_mode | 0o200) source_path.write_text("".join(updated_lines), encoding="utf-8") finally: source_path.chmod(original_mode) patched_files += 1 return patched_files 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" 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}") # App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。 if task.build_type == "Ad_Hoc": # 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。 dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {}) try: notified = await asyncio.to_thread( send_dingtalk_notification, dingtalk_config, config_data, task.oss_url or oss_url, task.qr_code_path or qr_code_path, ) if notified: await log_streamer.emit(task_id, "钉钉通知发送成功") except NotificationError as exc: await log_streamer.emit(task_id, f"钉钉通知未发送: {exc}", level="warn") # 带超时执行打包 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") patched_files = await asyncio.to_thread(_patch_afnetworking_private_headers, build_dir) if patched_files: await log_streamer.emit( task_id, f"已修复 AFNetworking 私有头引用: {patched_files} 个文件", ) 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", 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