864 lines
34 KiB
Python
864 lines
34 KiB
Python
"""打包服务核心"""
|
||
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 _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 <netinet6/in6.h>"
|
||
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"<?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")
|
||
|
||
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",
|
||
"-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
|