feat: 构建超时控制、日志持久化、任务删除、pod install 及多项 UI 优化
- 新增 BUILD_TIMEOUT_HOURS 配置,超时自动标记失败 - 日志保存到独立 backend/logs/ 目录,不随构建目录删除 - 新增任务删除 API 及前端删除按钮 - 构建流程增加 pod install 步骤 - 通过文件传递配置避免命令行参数截断 - Provisioning Profile 支持按名称搜索 - dSYM 下载支持目录打包为 zip - 前端端口可配置、历史页增加下载列和耗时显示 - 日志弹窗支持展开/收起详细日志 - 自动生成 upload_key 标识
This commit is contained in:
@@ -49,6 +49,7 @@ COPY_ITEMS = [
|
||||
|
||||
# 服务端口
|
||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
|
||||
FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "3000"))
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_URL = f"sqlite:///{Path(__file__).parent / 'build_server.db'}"
|
||||
@@ -62,6 +63,9 @@ DEFAULT_MAX_CONCURRENT_BUILDS = int(os.getenv("MAX_CONCURRENT_BUILDS", "2"))
|
||||
# 默认打包目录保留时间(小时)
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS = int(os.getenv("BUILD_DIR_RETENTION_HOURS", "24"))
|
||||
|
||||
# 打包超时时间(小时)
|
||||
BUILD_TIMEOUT_HOURS = int(os.getenv("BUILD_TIMEOUT_HOURS", "1"))
|
||||
|
||||
# 管理员账号配置
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin123")
|
||||
|
||||
@@ -64,6 +64,17 @@ DEFAULT_SERVERS = {
|
||||
}
|
||||
|
||||
|
||||
def _ensure_upload_keys(config: dict) -> bool:
|
||||
"""为没有 upload_key 的 app 自动生成,返回是否有变更"""
|
||||
import uuid
|
||||
changed = False
|
||||
for app_id, app in config.get("apps", {}).items():
|
||||
if not app.get("upload_key"):
|
||||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
"""读取 config.json"""
|
||||
if not CONFIG_JSON_PATH.exists():
|
||||
@@ -77,6 +88,9 @@ def load_config() -> dict:
|
||||
config["branches"] = ["main"]
|
||||
if "upload" not in config:
|
||||
config["upload"] = DEFAULT_UPLOAD
|
||||
# 自动为缺少 upload_key 的 app 生成唯一标识
|
||||
if _ensure_upload_keys(config):
|
||||
save_config(config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -119,6 +133,7 @@ async def get_apps():
|
||||
@router.post("/apps")
|
||||
async def create_app(app: dict):
|
||||
"""新增 app"""
|
||||
import uuid
|
||||
async with _config_lock:
|
||||
config = load_config()
|
||||
apps = config.get("apps", {})
|
||||
@@ -127,6 +142,10 @@ async def create_app(app: dict):
|
||||
numeric_keys = [int(k) for k in apps.keys() if k.isdigit()]
|
||||
new_id = str(max(numeric_keys) + 1) if numeric_keys else "1"
|
||||
|
||||
# 自动生成 upload_key
|
||||
if not app.get("upload_key"):
|
||||
app["upload_key"] = uuid.uuid4().hex[:8]
|
||||
|
||||
apps[new_id] = app
|
||||
config["apps"] = apps
|
||||
save_config(config)
|
||||
|
||||
@@ -57,6 +57,30 @@ async def create_task(task: TaskCreate, db: Session = Depends(get_db)):
|
||||
return db_task
|
||||
|
||||
|
||||
def _enrich_tasks(tasks):
|
||||
"""为任务列表补充 has_log 字段"""
|
||||
from pathlib import Path
|
||||
logs_dir = Path(__file__).parent.parent / "logs"
|
||||
result = []
|
||||
for task in tasks:
|
||||
resp = TaskResponse.from_orm(task)
|
||||
# 检查独立日志目录
|
||||
if logs_dir.exists() and (logs_dir / f"{task.id}.log").exists():
|
||||
resp.has_log = True
|
||||
# 检查构建目录
|
||||
elif task.build_dir:
|
||||
log_path = Path(task.build_dir) / "build.log"
|
||||
if log_path.exists():
|
||||
resp.has_log = True
|
||||
# 运行中的任务,内存中有日志
|
||||
elif task.status == "running":
|
||||
from ..services.log_streamer import log_streamer
|
||||
if task.id in log_streamer._log_lines:
|
||||
resp.has_log = True
|
||||
result.append(resp)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("", response_model=List[TaskResponse])
|
||||
async def list_tasks(
|
||||
status: str = None,
|
||||
@@ -69,7 +93,7 @@ async def list_tasks(
|
||||
if status:
|
||||
query = query.filter(Task.status == status)
|
||||
tasks = query.order_by(Task.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return tasks
|
||||
return _enrich_tasks(tasks)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
@@ -78,7 +102,7 @@ async def get_task(task_id: str, db: Session = Depends(get_db)):
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return task
|
||||
return _enrich_tasks([task])[0]
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
@@ -98,9 +122,50 @@ async def cancel_task(task_id: str, db: Session = Depends(get_db)):
|
||||
from ..services.build_queue import build_queue
|
||||
build_queue.cancel(task_id)
|
||||
|
||||
# 清理残留文件
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
if task.build_dir:
|
||||
build_path = Path(task.build_dir)
|
||||
if build_path.exists():
|
||||
shutil.rmtree(build_path, ignore_errors=True)
|
||||
|
||||
return {"message": "任务已取消"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}/delete")
|
||||
async def delete_task(task_id: str, db: Session = Depends(get_db)):
|
||||
"""删除打包记录"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
if task.status in ("running", "pending"):
|
||||
raise HTTPException(status_code=400, detail="任务正在运行中,无法删除")
|
||||
|
||||
# 删除日志文件
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
|
||||
# 删除构建目录
|
||||
if task.build_dir:
|
||||
build_path = Path(task.build_dir)
|
||||
if build_path.exists():
|
||||
shutil.rmtree(build_path, ignore_errors=True)
|
||||
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
|
||||
return {"message": "记录已删除"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/log")
|
||||
async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
"""获取打包日志"""
|
||||
@@ -121,9 +186,14 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
content += f"[{ts}] [{level}] {msg}\n"
|
||||
return {"log": content}
|
||||
|
||||
# 从文件获取(任务已完成)
|
||||
# 从独立日志目录获取
|
||||
from pathlib import Path
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
return {"log": log_path.read_text(encoding="utf-8")}
|
||||
|
||||
# 从构建目录获取(兼容旧数据)
|
||||
if task.build_dir:
|
||||
from pathlib import Path
|
||||
log_path = Path(task.build_dir) / "build.log"
|
||||
if log_path.exists():
|
||||
return {"log": log_path.read_text(encoding="utf-8")}
|
||||
@@ -134,16 +204,32 @@ async def get_build_log(task_id: str, db: Session = Depends(get_db)):
|
||||
@router.get("/{task_id}/dsym")
|
||||
async def download_dsym(task_id: str, db: Session = Depends(get_db)):
|
||||
"""下载 dSYM 文件"""
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if not task.dsym_path:
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
||||
|
||||
if not os.path.exists(task.dsym_path):
|
||||
dsym_path = task.dsym_path
|
||||
if not os.path.exists(dsym_path):
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
|
||||
|
||||
return FileResponse(task.dsym_path, filename=os.path.basename(task.dsym_path))
|
||||
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
|
||||
if os.path.isdir(dsym_path):
|
||||
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for root, dirs, files in os.walk(dsym_path):
|
||||
for f in files:
|
||||
full = os.path.join(root, f)
|
||||
arcname = os.path.relpath(full, os.path.dirname(dsym_path))
|
||||
zf.write(full, arcname)
|
||||
return FileResponse(zip_path, filename=os.path.basename(zip_path), media_type="application/zip")
|
||||
|
||||
return FileResponse(dsym_path, filename=os.path.basename(dsym_path))
|
||||
|
||||
|
||||
@router.get("/{task_id}/obfuscation-maps")
|
||||
|
||||
@@ -36,6 +36,7 @@ class TaskResponse(BaseModel):
|
||||
error_message: Optional[str]
|
||||
error_category: Optional[str] = None
|
||||
config_json: Optional[str]
|
||||
has_log: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
+209
-100
@@ -14,6 +14,7 @@ from ..config import (
|
||||
COPY_ITEMS,
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||
GIT_REMOTE_URL,
|
||||
BUILD_TIMEOUT_HOURS,
|
||||
get_source_dir,
|
||||
)
|
||||
from .log_streamer import log_streamer
|
||||
@@ -108,6 +109,21 @@ def _cleanup_old_builds(db):
|
||||
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():
|
||||
@@ -209,80 +225,92 @@ async def run_build_task(task_id: str):
|
||||
|
||||
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:
|
||||
# 1. 更新分支源码并拷贝
|
||||
source_dir = get_source_dir(task.branch)
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
||||
if GIT_REMOTE_URL:
|
||||
# 配置了远程仓库,从分支目录获取源码
|
||||
await update_source(task_id, source_dir, task.branch)
|
||||
elif source_dir.exists():
|
||||
# 分支目录已存在,直接使用
|
||||
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||
else:
|
||||
# 未配置远程仓库且分支目录不存在,使用默认源码目录
|
||||
source_dir = PROJECT_ROOT
|
||||
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}")
|
||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||
timeout_seconds = BUILD_TIMEOUT_HOURS * 3600
|
||||
|
||||
# 2. 生成配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||
config_data = await generate_config(task_id, task, build_dir)
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||
async def _do_build():
|
||||
nonlocal build_dir
|
||||
# 1. 更新分支源码并拷贝
|
||||
source_dir = get_source_dir(task.branch)
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
||||
if GIT_REMOTE_URL:
|
||||
# 配置了远程仓库,从分支目录获取源码
|
||||
await update_source(task_id, source_dir, task.branch)
|
||||
elif source_dir.exists():
|
||||
# 分支目录已存在,直接使用
|
||||
await log_streamer.emit(task_id, f"使用分支源码: {source_dir}")
|
||||
else:
|
||||
# 未配置远程仓库且分支目录不存在,使用默认源码目录
|
||||
source_dir = PROJECT_ROOT
|
||||
await log_streamer.emit(task_id, f"未配置 GIT_REMOTE_URL,使用默认源码: {source_dir}")
|
||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||
|
||||
# 3. 替换项目配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="patch")
|
||||
await patch_project(task_id, task, config_data, build_dir)
|
||||
# 2. 生成配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="config")
|
||||
config_data = await generate_config(task_id, task, build_dir)
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
config_json=json.dumps(config_data, ensure_ascii=False))
|
||||
|
||||
# 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, source_dir)
|
||||
# 3. 替换项目配置
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="patch")
|
||||
await patch_project(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)
|
||||
# 3.5 安装依赖
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="pod_install")
|
||||
await run_pod_install(task_id, build_dir)
|
||||
|
||||
# 查找 dSYM
|
||||
dsym_path = await find_dsym(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, source_dir)
|
||||
|
||||
# 查找混淆映射表
|
||||
obf_maps_path = build_dir / "obfuscation_maps"
|
||||
# 5. 构建项目
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="build")
|
||||
ipa_path = await build_project(task_id, task, config_data, build_dir)
|
||||
|
||||
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)
|
||||
# 查找 dSYM
|
||||
dsym_path = await find_dsym(task_id, build_dir)
|
||||
|
||||
# 6. 上传分发(仅 Ad_Hoc)
|
||||
if task.build_type == "Ad_Hoc":
|
||||
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)
|
||||
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)
|
||||
# 查找混淆映射表
|
||||
obf_maps_path = build_dir / "obfuscation_maps"
|
||||
|
||||
# 完成
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
status="completed", completed_at=datetime.utcnow(),
|
||||
current_step=None)
|
||||
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)
|
||||
|
||||
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}")
|
||||
# 6. 上传分发(仅 Ad_Hoc)
|
||||
if task.build_type == "Ad_Hoc":
|
||||
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():
|
||||
@@ -292,6 +320,16 @@ async def run_build_task(task_id: str):
|
||||
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())
|
||||
@@ -408,6 +446,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
"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,
|
||||
@@ -416,30 +455,59 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
|
||||
# 证书配置
|
||||
certificates = app.get("certificates", {})
|
||||
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", "")
|
||||
config_data["THEME"] = cert.get("theme", "")
|
||||
# 无需证书的应用(如英汉大词典),使用项目默认签名
|
||||
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", "")
|
||||
config_data["THEME"] = cert.get("theme", "")
|
||||
|
||||
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME
|
||||
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
|
||||
if provisioning_profile and os.path.exists(provisioning_profile):
|
||||
try:
|
||||
import subprocess as _sp
|
||||
plist_xml = _sp.check_output(
|
||||
["security", "cms", "-D", "-i", provisioning_profile],
|
||||
stderr=_sp.DEVNULL,
|
||||
)
|
||||
import plistlib as _pl
|
||||
plist = _pl.loads(plist_xml)
|
||||
team_ids = plist.get("TeamIdentifier", [])
|
||||
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
|
||||
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
|
||||
except Exception:
|
||||
pass
|
||||
if provisioning_profile:
|
||||
profile_path = provisioning_profile
|
||||
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索
|
||||
if not os.path.exists(profile_path):
|
||||
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles")
|
||||
if os.path.isdir(profiles_dir):
|
||||
import subprocess as _sp
|
||||
import plistlib as _pl
|
||||
for filename in os.listdir(profiles_dir):
|
||||
if not filename.endswith(".mobileprovision"):
|
||||
continue
|
||||
candidate = os.path.join(profiles_dir, filename)
|
||||
try:
|
||||
plist_xml = _sp.check_output(
|
||||
["security", "cms", "-D", "-i", candidate],
|
||||
stderr=_sp.DEVNULL,
|
||||
)
|
||||
plist = _pl.loads(plist_xml)
|
||||
if plist.get("Name") == provisioning_profile:
|
||||
profile_path = candidate
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if os.path.exists(profile_path):
|
||||
try:
|
||||
import subprocess as _sp
|
||||
import plistlib as _pl
|
||||
plist_xml = _sp.check_output(
|
||||
["security", "cms", "-D", "-i", profile_path],
|
||||
stderr=_sp.DEVNULL,
|
||||
)
|
||||
plist = _pl.loads(plist_xml)
|
||||
team_ids = plist.get("TeamIdentifier", [])
|
||||
config_data["TEAM_ID"] = team_ids[0] if team_ids else ""
|
||||
config_data["PROVISIONING_NAME"] = plist.get("Name", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 关联域名等配置
|
||||
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
||||
@@ -460,21 +528,28 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
|
||||
"""替换项目配置"""
|
||||
await log_streamer.emit_step(task_id, "替换项目配置")
|
||||
|
||||
# 根据 scheme 选择不同的替换逻辑
|
||||
if config_data.get("SCHEME") == "readoorDict":
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--dict-config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
]
|
||||
# 清理 RDAppConfiguration.swift 中的注释配置行,避免 replace_line_by_keyword 替换到注释行
|
||||
swift_config = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
|
||||
if swift_config.exists():
|
||||
for keyword in [r"\/\/let RD_APP_GUID:", r"\/\/let RD_API_DOMAIN:"]:
|
||||
sed_proc = await asyncio.create_subprocess_exec(
|
||||
"sed", "-i", "", f"/^[[:space:]]*{keyword}/d", str(swift_config),
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
await sed_proc.wait()
|
||||
await log_streamer.emit(task_id, "已清理注释配置行")
|
||||
|
||||
# 通过 config_output.json 文件传递配置(避免命令行参数过长截断)
|
||||
config_file = build_dir / "config_output.json"
|
||||
mode_flag = "--dict-config-file" if config_data.get("SCHEME") == "readoorDict" else "--config-file"
|
||||
cmd = [
|
||||
"python3",
|
||||
str(AUTOPACKING_DIR / "replace_build_info.py"),
|
||||
mode_flag,
|
||||
str(config_file),
|
||||
]
|
||||
|
||||
# 使用 subprocess 执行替换
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
@@ -504,6 +579,30 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
|
||||
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, source_root: Path):
|
||||
"""执行代码混淆"""
|
||||
await log_streamer.emit_step(task_id, "代码混淆")
|
||||
@@ -664,7 +763,7 @@ async def find_dsym(task_id: str, build_dir: Path) -> Path:
|
||||
return None
|
||||
|
||||
|
||||
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> tuple:
|
||||
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, "上传分发平台")
|
||||
|
||||
@@ -676,22 +775,32 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
|
||||
full_config = load_config()
|
||||
config_data["_upload_config"] = full_config.get("upload", {})
|
||||
|
||||
# 注入路径信息,供 upload_iap.py 使用绝对路径
|
||||
config_data["IPA_PATH"] = str(ipa_path)
|
||||
config_data["BUILD_DIR"] = str(build_dir)
|
||||
config_data["AUTOPACKING_DIR"] = str(AUTOPACKING_DIR)
|
||||
|
||||
# 使用现有的 upload_iap 脚本
|
||||
upload_script = AUTOPACKING_DIR / "upload_iap.py"
|
||||
if not upload_script.exists():
|
||||
await log_streamer.emit_warning(task_id, "上传脚本不存在,跳过上传")
|
||||
return None, None
|
||||
|
||||
# 通过临时文件传递配置
|
||||
upload_config_file = build_dir / "upload_config.json"
|
||||
with open(upload_config_file, "w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
cmd = [
|
||||
"python3",
|
||||
str(upload_script),
|
||||
"--config",
|
||||
json.dumps(config_data, ensure_ascii=False),
|
||||
"--config-file",
|
||||
str(upload_config_file),
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(PROJECT_ROOT),
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
@@ -719,6 +828,6 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path) -> t
|
||||
qr_code_path = str(expected_qr)
|
||||
|
||||
if process.returncode != 0:
|
||||
await log_streamer.emit_warning(task_id, "上传可能失败")
|
||||
raise BuildError("上传分发平台失败", category="upload")
|
||||
|
||||
return oss_url, qr_code_path
|
||||
|
||||
@@ -64,6 +64,12 @@ class LogStreamer:
|
||||
if not queue:
|
||||
return
|
||||
|
||||
# 回放已缓存的历史日志
|
||||
existing = self._log_lines.get(task_id, [])
|
||||
for entry in existing:
|
||||
yield entry
|
||||
|
||||
# 流式推送新消息
|
||||
while True:
|
||||
try:
|
||||
msg = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
@@ -89,12 +95,15 @@ class LogStreamer:
|
||||
self._queues.pop(task_id, None)
|
||||
|
||||
def save_log(self, task_id: str, build_dir: Path):
|
||||
"""将收集的日志保存到打包目录"""
|
||||
lines = self._log_lines.pop(task_id, None)
|
||||
"""将收集的日志保存到独立日志目录(不会随构建目录删除)"""
|
||||
lines = self._log_lines.get(task_id)
|
||||
if not lines:
|
||||
return
|
||||
|
||||
log_path = build_dir / "build.log"
|
||||
# 保存到 backend/logs/ 目录
|
||||
logs_dir = Path(__file__).parent.parent / "logs"
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
log_path = logs_dir / f"{task_id}.log"
|
||||
with open(log_path, "w", encoding="utf-8") as f:
|
||||
for entry in lines:
|
||||
ts = entry["timestamp"]
|
||||
@@ -102,11 +111,28 @@ class LogStreamer:
|
||||
msg = entry["message"]
|
||||
f.write(f"[{ts}] [{level}] {msg}\n")
|
||||
|
||||
def get_log(self, build_dir: Path) -> str:
|
||||
"""读取已保存的打包日志"""
|
||||
log_path = build_dir / "build.log"
|
||||
if log_path.exists():
|
||||
return log_path.read_text(encoding="utf-8")
|
||||
# 同时保存到构建目录(如果存在)
|
||||
if build_dir and build_dir.exists():
|
||||
with open(build_dir / "build.log", "w", encoding="utf-8") as f:
|
||||
for entry in lines:
|
||||
ts = entry["timestamp"]
|
||||
level = entry["level"].upper()
|
||||
msg = entry["message"]
|
||||
f.write(f"[{ts}] [{level}] {msg}\n")
|
||||
|
||||
def get_log(self, build_dir: Path, task_id: str = None) -> str:
|
||||
"""读取已保存的打包日志(优先从独立日志目录读取)"""
|
||||
# 优先从独立日志目录读取
|
||||
if task_id:
|
||||
logs_dir = Path(__file__).parent.parent / "logs"
|
||||
log_path = logs_dir / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
return log_path.read_text(encoding="utf-8")
|
||||
# 回退到构建目录
|
||||
if build_dir:
|
||||
log_path = build_dir / "build.log"
|
||||
if log_path.exists():
|
||||
return log_path.read_text(encoding="utf-8")
|
||||
return ""
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user