feat: 服务端集成自动打包流程
This commit is contained in:
@@ -52,6 +52,8 @@ class BuildQueue:
|
||||
from .build_service import run_build_task
|
||||
build_func = run_build_task
|
||||
|
||||
# 在任务入队前创建日志队列,避免前端 WebSocket 先连上却无队列可订阅。
|
||||
log_streamer.create_queue(task_id)
|
||||
await self._queue.put((task_id, build_func))
|
||||
await log_streamer.emit(task_id, "任务已加入队列,等待执行...")
|
||||
|
||||
|
||||
@@ -9,17 +9,19 @@ from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from ..config import (
|
||||
PROJECT_ROOT,
|
||||
AUTOPACKING_DIR,
|
||||
BUILD_BASE_DIR,
|
||||
COPY_ITEMS,
|
||||
DEFAULT_BUILD_DIR_RETENTION_HOURS,
|
||||
GIT_REMOTE_URL,
|
||||
BUILD_TIMEOUT_HOURS,
|
||||
SKINS_DIR,
|
||||
AUTOMATION_DIR,
|
||||
get_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):
|
||||
@@ -135,14 +137,17 @@ def _db_update(db, task, **fields):
|
||||
|
||||
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)
|
||||
|
||||
if not source_dir.exists():
|
||||
# 首次:从远程 clone
|
||||
if not GIT_REMOTE_URL:
|
||||
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"克隆仓库: {GIT_REMOTE_URL} → {branch}")
|
||||
await log_streamer.emit(task_id, f"克隆仓库: {remote_url_masked} → {branch}")
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "clone", "-b", branch, "--single-branch", GIT_REMOTE_URL, str(source_dir),
|
||||
"git", "clone", "--depth", "1", "-b", branch, "--single-branch", remote_url, str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
@@ -157,6 +162,15 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||
# 已存在:fetch + checkout + pull
|
||||
await log_streamer.emit(task_id, f"更新分支源码: {branch}")
|
||||
|
||||
if remote_url:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "remote", "set-url", "origin", remote_url,
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
await process.wait()
|
||||
|
||||
# fetch
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "fetch", "origin",
|
||||
@@ -174,7 +188,7 @@ async def update_source(task_id: str, source_dir: Path, branch: str):
|
||||
|
||||
# checkout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"git", "checkout", branch,
|
||||
"git", "checkout", "-B", branch, f"origin/{branch}",
|
||||
cwd=str(source_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
@@ -240,16 +254,16 @@ async def run_build_task(task_id: str):
|
||||
# 1. 更新分支源码并拷贝
|
||||
source_dir = get_source_dir(task.branch)
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="copy")
|
||||
if GIT_REMOTE_URL:
|
||||
if get_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}")
|
||||
raise Exception(
|
||||
f"分支源码目录不存在: {source_dir},请先在 GIT_SOURCE_BASE 下准备分支代码,或配置 GIT_REMOTE_URL"
|
||||
)
|
||||
build_dir = await copy_source_code(task_id, task, source_dir)
|
||||
await asyncio.to_thread(_db_update, db, task, build_dir=str(build_dir))
|
||||
|
||||
@@ -270,7 +284,7 @@ async def run_build_task(task_id: str):
|
||||
# 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)
|
||||
await run_obfuscation(task_id, task, config_data, build_dir)
|
||||
|
||||
# 5. 构建项目
|
||||
await asyncio.to_thread(_db_update, db, task, current_step="build")
|
||||
@@ -330,7 +344,7 @@ async def run_build_task(task_id: str):
|
||||
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)
|
||||
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
await asyncio.to_thread(_db_update, db, task,
|
||||
@@ -339,6 +353,8 @@ async def run_build_task(task_id: str):
|
||||
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):
|
||||
@@ -362,7 +378,7 @@ async def run_build_task(task_id: str):
|
||||
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)
|
||||
await asyncio.to_thread(log_streamer.save_log, task_id, build_dir)
|
||||
|
||||
finally:
|
||||
log_streamer.complete(task_id)
|
||||
@@ -403,41 +419,33 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
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, {})
|
||||
|
||||
# 读取版本号
|
||||
start_build_path = AUTOPACKING_DIR / "start_build_app.py"
|
||||
app_ver = "2.180.0"
|
||||
build_ver = "2.180.0.0"
|
||||
|
||||
if start_build_path.exists():
|
||||
content = start_build_path.read_text()
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("App_Ver"):
|
||||
app_ver = line.split('"')[1] if '"' in line else app_ver
|
||||
elif line.startswith("Build_Ver"):
|
||||
build_ver = line.split('"')[1] if '"' in line else build_ver
|
||||
# 版本号由当前服务配置统一管理,不读取分支源码中的手动打包脚本。
|
||||
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" and start_build_path.exists():
|
||||
if task.build_type == "App_Store":
|
||||
async with _config_lock:
|
||||
content = start_build_path.read_text(encoding="utf-8")
|
||||
new_lines = []
|
||||
for line in content.split("\n"):
|
||||
if line.startswith("Build_Ver") and '"' in line:
|
||||
old_ver = line.split('"')[1]
|
||||
parts = old_ver.split(".")
|
||||
if len(parts) >= 4:
|
||||
parts[3] = str(int(parts[3]) + 1)
|
||||
else:
|
||||
parts.append("1")
|
||||
build_ver = ".".join(parts)
|
||||
new_lines.append(f'Build_Ver = "{build_ver}"')
|
||||
else:
|
||||
new_lines.append(line)
|
||||
start_build_path.write_text("\n".join(new_lines), encoding="utf-8")
|
||||
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}")
|
||||
|
||||
# 构建配置
|
||||
@@ -471,33 +479,26 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
theme_value = cert.get("theme", "")
|
||||
config_data["THEME"] = theme_value
|
||||
|
||||
# 解析皮肤:优先识别旧式目录(AutoPacking/ymh),再尝试上传的 ZIP
|
||||
# 皮肤只从服务端托管的上传包或默认资源读取,不使用分支源码目录。
|
||||
if theme_value:
|
||||
theme_resolved = False
|
||||
# 1) 相对路径:在 build_dir(源码副本)下查找
|
||||
candidate = build_dir / theme_value
|
||||
if candidate.is_dir():
|
||||
config_data["THEME"] = str(candidate)
|
||||
theme_resolved = True
|
||||
# 2) 绝对路径:直接使用
|
||||
if not theme_resolved and os.path.isabs(theme_value) and os.path.isdir(theme_value):
|
||||
theme_resolved = True
|
||||
# 3) 上传的 ZIP 皮肤包
|
||||
if not theme_resolved:
|
||||
# 皮肤名可能是 "ymh" 或 "AutoPacking/ymh",取最后一段作为 ZIP 名
|
||||
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 已解压")
|
||||
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} 不存在")
|
||||
|
||||
@@ -561,54 +562,13 @@ async def patch_project(task_id: str, task, config_data: dict, build_dir: Path):
|
||||
"""替换项目配置"""
|
||||
await log_streamer.emit_step(task_id, "替换项目配置")
|
||||
|
||||
# 清理 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(
|
||||
*cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
patch_lines = []
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
patch_lines.append(decoded)
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
|
||||
await process.wait()
|
||||
|
||||
# 保存替换日志
|
||||
patch_log_path = build_dir / "patch_result.log"
|
||||
with open(patch_log_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(patch_lines))
|
||||
|
||||
if process.returncode != 0:
|
||||
raise Exception("项目配置替换失败")
|
||||
|
||||
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, "项目配置替换完成")
|
||||
|
||||
|
||||
@@ -636,18 +596,17 @@ async def run_pod_install(task_id: str, build_dir: Path):
|
||||
await log_streamer.emit(task_id, "依赖安装完成")
|
||||
|
||||
|
||||
async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path, source_root: Path):
|
||||
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 = AUTOPACKING_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
|
||||
self_check_script = AUTOMATION_DIR / "obfuscation" / "scripts" / "obfuscation_self_check.sh"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"bash", str(self_check_script), str(source_root),
|
||||
"bash", str(self_check_script), str(build_dir),
|
||||
cwd=str(build_dir),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
@@ -667,7 +626,7 @@ async def run_obfuscation(task_id: str, task, config_data: dict, build_dir: Path
|
||||
await log_streamer.emit(task_id, "混淆自检通过")
|
||||
|
||||
# 执行混淆
|
||||
obfuscate_script = AUTOPACKING_DIR / "obfuscation" / "obfuscate_symbols.py"
|
||||
obfuscate_script = AUTOMATION_DIR / "obfuscation" / "obfuscate_symbols.py"
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
"python3", str(obfuscate_script), "--archive-mode",
|
||||
cwd=str(build_dir),
|
||||
@@ -800,67 +759,12 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
|
||||
"""上传 IPA,返回 (download_url, qr_code_path)"""
|
||||
await log_streamer.emit_step(task_id, "上传分发平台")
|
||||
|
||||
qr_code_path = None
|
||||
app_guid = config_data.get("APPID", "")
|
||||
|
||||
# 从 config.json 读取上传配置并注入
|
||||
# 从当前服务的 config.json 读取上传配置,不执行分支源码中的上传脚本。
|
||||
from ..routers.config import load_config
|
||||
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-file",
|
||||
str(upload_config_file),
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(build_dir),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
oss_url = None
|
||||
async for line in process.stdout:
|
||||
decoded = line.decode("utf-8", errors="replace").strip()
|
||||
if decoded:
|
||||
await log_streamer.emit(task_id, decoded)
|
||||
# 解析上传结果
|
||||
if decoded.startswith("UPLOAD_RESULT:"):
|
||||
try:
|
||||
result = json.loads(decoded[len("UPLOAD_RESULT:"):])
|
||||
oss_url = result.get("download_url")
|
||||
qr_code_path = result.get("qr_code_path")
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await process.wait()
|
||||
|
||||
# 兜底:查找二维码文件
|
||||
if not qr_code_path and app_guid:
|
||||
expected_qr = PROJECT_ROOT / "build" / f"{app_guid}.png"
|
||||
if expected_qr.exists():
|
||||
qr_code_path = str(expected_qr)
|
||||
|
||||
if process.returncode != 0:
|
||||
raise BuildError("上传分发平台失败", category="upload")
|
||||
|
||||
return oss_url, qr_code_path
|
||||
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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""服务端 Ad_Hoc 分发:OSS/WebDAV 上传、manifest、下载页和二维码。"""
|
||||
import json
|
||||
import plistlib
|
||||
import posixpath
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class DistributionError(Exception):
|
||||
"""分发配置或上传过程失败。"""
|
||||
|
||||
|
||||
def _artifact_stem(config: dict) -> str:
|
||||
version = config.get("VERSION", "0").replace(".", "_")
|
||||
return f"{config.get('APPID', 'app')}_{version}"
|
||||
|
||||
|
||||
def _write_distribution_files(config: dict, ipa_path: Path, output_dir: Path) -> tuple[Path, Path, Path]:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
stem = _artifact_stem(config)
|
||||
ipa_output = output_dir / f"{stem}.ipa"
|
||||
manifest = output_dir / f"{stem}.plist"
|
||||
html = output_dir / f"{stem}.html"
|
||||
if ipa_path != ipa_output:
|
||||
shutil.copy2(ipa_path, ipa_output)
|
||||
|
||||
return ipa_output, manifest, html
|
||||
|
||||
|
||||
def _write_manifest(config: dict, manifest: Path, ipa_url: str):
|
||||
data = {
|
||||
"items": [{
|
||||
"assets": [{"kind": "software-package", "url": ipa_url}],
|
||||
"metadata": {
|
||||
"bundle-identifier": config.get("BUNDLE_ID", ""),
|
||||
"bundle-version": config.get("VERSION", ""),
|
||||
"kind": "software",
|
||||
"title": config.get("APPID_NAME", "iOS App"),
|
||||
},
|
||||
}],
|
||||
}
|
||||
with manifest.open("wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
|
||||
def _write_download_page(config: dict, html: Path, manifest_url: str):
|
||||
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&') }"
|
||||
title = config.get("APPID_NAME", "iOS App")
|
||||
html.write_text(
|
||||
"<!doctype html><html lang=\"zh-CN\"><meta charset=\"utf-8\">"
|
||||
f"<title>{title}</title><body><h2>{title}</h2>"
|
||||
f"<p>版本 {config.get('VERSION', '')}</p><a href=\"{install_url}\">安装 App</a>"
|
||||
"</body></html>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _remote_paths(config: dict) -> tuple[str, str, str]:
|
||||
stem = _artifact_stem(config)
|
||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||
root = posixpath.join(folder, "iOS")
|
||||
return (
|
||||
posixpath.join(root, f"{stem}.ipa"),
|
||||
posixpath.join(root, f"{stem}.plist"),
|
||||
posixpath.join(root, f"{stem}.html"),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||
path = ""
|
||||
for segment in remote_path.strip("/").split("/")[:-1]:
|
||||
path = f"{path}/{segment}"
|
||||
response = client.request("MKCOL", f"{server_url.rstrip('/')}{path}")
|
||||
if response.status_code not in (200, 201, 204, 301, 405):
|
||||
raise DistributionError(f"创建 WebDAV 目录失败: {path} ({response.status_code})")
|
||||
|
||||
|
||||
def _upload_webdav(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
|
||||
webdav = config.get("webdav", {})
|
||||
server_url = webdav.get("server_url", "").rstrip("/")
|
||||
public_url = (webdav.get("public_url") or server_url).rstrip("/")
|
||||
if not server_url or not webdav.get("username"):
|
||||
raise DistributionError("WebDAV 配置不完整")
|
||||
|
||||
base_path = webdav.get("base_path", "/ios-builds").strip("/")
|
||||
auth = (webdav.get("username", ""), webdav.get("password", ""))
|
||||
urls = {}
|
||||
with httpx.Client(auth=auth, timeout=120, follow_redirects=True) as client:
|
||||
for local_path, relative_path in files:
|
||||
remote_path = posixpath.join(base_path, relative_path)
|
||||
_ensure_webdav_dirs(client, server_url, remote_path)
|
||||
with local_path.open("rb") as f:
|
||||
response = client.put(f"{server_url}/{remote_path}", content=f.read())
|
||||
if response.status_code not in (200, 201, 204):
|
||||
raise DistributionError(f"WebDAV 上传失败: {local_path.name} ({response.status_code})")
|
||||
urls[local_path.suffix] = f"{public_url}/{quote(remote_path)}"
|
||||
return urls
|
||||
|
||||
|
||||
def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
|
||||
oss = config.get("oss", {})
|
||||
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||
if any(not oss.get(key) for key in required):
|
||||
raise DistributionError("OSS 配置不完整")
|
||||
try:
|
||||
import oss2
|
||||
except ImportError as exc:
|
||||
raise DistributionError("未安装 oss2,请重新执行 ./deploy.sh build") from exc
|
||||
|
||||
bucket = oss2.Bucket(
|
||||
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||
oss["endpoint"],
|
||||
oss["bucket_name"],
|
||||
connect_timeout=30,
|
||||
)
|
||||
base_url = (oss.get("base_url") or f"https://{oss['bucket_name']}.{oss['endpoint']}").rstrip("/")
|
||||
urls = {}
|
||||
for local_path, relative_path in files:
|
||||
result = bucket.put_object_from_file(relative_path, str(local_path))
|
||||
if result.status // 100 != 2:
|
||||
raise DistributionError(f"OSS 上传失败: {local_path.name}")
|
||||
urls[local_path.suffix] = f"{base_url}/{quote(relative_path)}"
|
||||
return urls
|
||||
|
||||
|
||||
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
|
||||
"""发布 IPA,返回下载页 URL 与二维码本地路径。"""
|
||||
upload = config.get("_upload_config", {})
|
||||
mode = upload.get("mode", "")
|
||||
if mode not in {"oss", "webdav"}:
|
||||
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
|
||||
|
||||
output_dir = build_dir / "distribution"
|
||||
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
|
||||
ipa_remote, manifest_remote, html_remote = _remote_paths(config)
|
||||
|
||||
# manifest 依赖 IPA URL,先发布 IPA。
|
||||
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
||||
urls = uploader(upload, [(ipa_file, ipa_remote)])
|
||||
_write_manifest(config, manifest, urls[".ipa"])
|
||||
urls.update(uploader(upload, [(manifest, manifest_remote)]))
|
||||
_write_download_page(config, html, urls[".plist"])
|
||||
urls.update(uploader(upload, [(html, html_remote)]))
|
||||
|
||||
qr_path = output_dir / f"{_artifact_stem(config)}.png"
|
||||
try:
|
||||
import qrcode
|
||||
except ImportError as exc:
|
||||
raise DistributionError("未安装 qrcode,请重新执行 ./deploy.sh build") from exc
|
||||
qrcode.make(urls[".html"]).save(qr_path)
|
||||
return urls[".html"], str(qr_path)
|
||||
@@ -15,12 +15,17 @@ class LogStreamer:
|
||||
self._queues: Dict[str, asyncio.Queue] = {}
|
||||
self._subscribers: Dict[str, list] = {}
|
||||
self._log_lines: Dict[str, list] = {}
|
||||
self._completed: set[str] = set()
|
||||
|
||||
def create_queue(self, task_id: str) -> asyncio.Queue:
|
||||
"""创建任务的日志队列"""
|
||||
"""创建任务的日志队列(重复调用时保留已有日志)"""
|
||||
if task_id in self._queues:
|
||||
return self._queues[task_id]
|
||||
|
||||
queue = asyncio.Queue(maxsize=LOG_QUEUE_MAX_SIZE)
|
||||
self._queues[task_id] = queue
|
||||
self._log_lines[task_id] = []
|
||||
self._log_lines.setdefault(task_id, [])
|
||||
self._completed.discard(task_id)
|
||||
return queue
|
||||
|
||||
async def emit(self, task_id: str, message: str, level: str = "info"):
|
||||
@@ -66,11 +71,19 @@ class LogStreamer:
|
||||
if not queue:
|
||||
return
|
||||
|
||||
# 回放已缓存的历史日志
|
||||
existing = self._log_lines.get(task_id, [])
|
||||
# Messages are stored both in history and the live queue. Replay the
|
||||
# history once, then discard its queue copies before waiting for new logs.
|
||||
existing = list(self._log_lines.get(task_id, []))
|
||||
completed = task_id in self._completed
|
||||
while not queue.empty():
|
||||
queue.get_nowait()
|
||||
|
||||
for entry in existing:
|
||||
yield entry
|
||||
|
||||
if completed:
|
||||
return
|
||||
|
||||
# 流式推送新消息
|
||||
while True:
|
||||
try:
|
||||
@@ -86,6 +99,7 @@ class LogStreamer:
|
||||
|
||||
def complete(self, task_id: str):
|
||||
"""标记任务日志结束"""
|
||||
self._completed.add(task_id)
|
||||
if task_id in self._queues:
|
||||
try:
|
||||
self._queues[task_id].put_nowait(None)
|
||||
@@ -95,6 +109,7 @@ class LogStreamer:
|
||||
def cleanup(self, task_id: str):
|
||||
"""清理任务日志队列"""
|
||||
self._queues.pop(task_id, None)
|
||||
self._completed.discard(task_id)
|
||||
|
||||
def save_log(self, task_id: str, build_dir: Path):
|
||||
"""将收集的日志保存到独立日志目录(不会随构建目录删除)"""
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""服务端自带的 iOS 工程配置替换逻辑。"""
|
||||
import plistlib
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProjectPatchError(Exception):
|
||||
"""工程配置无法完整写入。"""
|
||||
|
||||
|
||||
def _replace_lines(path: Path, keyword: str, replacement: str) -> int:
|
||||
if not path.exists():
|
||||
raise ProjectPatchError(f"配置文件不存在: {path.relative_to(path.parent.parent)}")
|
||||
|
||||
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
count = 0
|
||||
result = []
|
||||
for line in lines:
|
||||
if keyword in line and not line.lstrip().startswith("//"):
|
||||
result.append(f"{replacement}\n")
|
||||
count += 1
|
||||
else:
|
||||
result.append(line)
|
||||
path.write_text("".join(result), encoding="utf-8")
|
||||
return count
|
||||
|
||||
|
||||
def _update_plist(path: Path, config: dict):
|
||||
if not path.exists():
|
||||
raise ProjectPatchError(f"Info.plist 不存在: {path}")
|
||||
|
||||
with path.open("rb") as f:
|
||||
plist = plistlib.load(f)
|
||||
|
||||
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
|
||||
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
|
||||
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
|
||||
values = {
|
||||
"weixinlogin": config.get("weixinlogin", ""),
|
||||
"wechatpay": config.get("weixinpay", ""),
|
||||
"tencent": config.get("tencent", ""),
|
||||
"readoorUrlScheme": scheme,
|
||||
}
|
||||
url_types = []
|
||||
for item in plist.get("CFBundleURLTypes", []):
|
||||
name = item.get("CFBundleURLName")
|
||||
value = values.get(name)
|
||||
if name in values:
|
||||
if not value:
|
||||
continue
|
||||
item["CFBundleURLSchemes"] = [value]
|
||||
url_types.append(item)
|
||||
plist["CFBundleURLTypes"] = url_types
|
||||
|
||||
with path.open("wb") as f:
|
||||
plistlib.dump(plist, f, fmt=plistlib.FMT_XML)
|
||||
|
||||
|
||||
def _apply_theme(build_dir: Path, theme_dir: str, messages: list[str]):
|
||||
if not theme_dir:
|
||||
return
|
||||
source = Path(theme_dir)
|
||||
if not source.is_dir():
|
||||
raise ProjectPatchError(f"皮肤目录不存在: {source}")
|
||||
|
||||
target_assets = build_dir / "readoor" / "BookShelf" / "Resources" / "Images.xcassets"
|
||||
for item in source.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
destination = target_assets / item.name
|
||||
if destination.exists():
|
||||
shutil.rmtree(destination)
|
||||
shutil.copytree(item, destination)
|
||||
messages.append(f"已替换皮肤资源: {destination.relative_to(build_dir)}")
|
||||
|
||||
icon = source / "AppIcon.appiconset" / "icon-1024.png"
|
||||
logo = build_dir / "readoor" / "3.0" / "Resources" / "3.0.xcassets" / "AppLogo.imageset" / "icon-1024.png"
|
||||
if icon.exists() and logo.parent.exists():
|
||||
shutil.copy2(icon, logo)
|
||||
messages.append("已替换应用 Logo")
|
||||
|
||||
license_file = source / "license.crt"
|
||||
license_target = build_dir / "readoor" / "3.0" / "Resources" / "license.crt"
|
||||
if license_file.exists() and license_target.parent.exists():
|
||||
shutil.copy2(license_file, license_target)
|
||||
messages.append("已替换渠道 License")
|
||||
|
||||
|
||||
def _write_export_options(build_dir: Path, config: dict):
|
||||
if config.get("NO_CERT"):
|
||||
data = {
|
||||
"destination": "export",
|
||||
"method": "development" if config.get("BUILD_TYPE") == "Ad_Hoc" else "app-store-connect",
|
||||
"stripSwiftSymbols": True,
|
||||
"thinning": "<none>",
|
||||
}
|
||||
else:
|
||||
data = {
|
||||
"destination": "export",
|
||||
"method": "release-testing" if config.get("BUILD_TYPE") == "Ad_Hoc" else "app-store-connect",
|
||||
"teamID": config.get("TEAM_ID", ""),
|
||||
"provisioningProfiles": {config.get("BUNDLE_ID", ""): config.get("PROVISIONING_NAME", "")},
|
||||
"signingCertificate": config.get("CERTIFICATE", ""),
|
||||
"signingStyle": "manual",
|
||||
"stripSwiftSymbols": True,
|
||||
"thinning": "<none>",
|
||||
}
|
||||
with (build_dir / "exportOptions.plist").open("wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
|
||||
def _apply_dictionary_config(build_dir: Path, config: dict, messages: list[str]):
|
||||
macro = build_dir / "readoor" / "BookShelf" / "Classes" / "Macro" / "STSConstantsMacro.h"
|
||||
if not macro.exists():
|
||||
raise ProjectPatchError(f"词典配置文件不存在: {macro}")
|
||||
content = macro.read_text(encoding="utf-8")
|
||||
version = config.get("VERSION", "")
|
||||
content, count = re.subn(
|
||||
r'^(#define\s+SOURCE_VERSION\s+)@"[^"]*"',
|
||||
rf'\1@"{version}"',
|
||||
content,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count == 0:
|
||||
raise ProjectPatchError("未找到词典 SOURCE_VERSION 配置")
|
||||
macro.write_text(content, encoding="utf-8")
|
||||
messages.append("已替换词典版本配置")
|
||||
|
||||
|
||||
def apply_project_config(build_dir: Path, config: dict) -> list[str]:
|
||||
"""写入项目配置,返回适合实时日志展示的变更说明。"""
|
||||
messages: list[str] = []
|
||||
if config.get("SCHEME") == "readoorDict":
|
||||
_apply_dictionary_config(build_dir, config, messages)
|
||||
_write_export_options(build_dir, config)
|
||||
return messages
|
||||
|
||||
project = build_dir / "readoor.xcodeproj" / "project.pbxproj"
|
||||
swift = build_dir / "readoor" / "3.0" / "AppConfig" / "RDAppConfiguration.swift"
|
||||
entitlements = build_dir / "readoor" / "3.0" / "readoor31.entitlements"
|
||||
plist = build_dir / "readoor" / "3.0" / "readoor31.plist"
|
||||
|
||||
replacements = [
|
||||
(project, "MARKETING_VERSION =", f"\t\t\t\tMARKETING_VERSION = {config.get('VERSION', '')};"),
|
||||
(project, "CURRENT_PROJECT_VERSION =", f"\t\t\t\tCURRENT_PROJECT_VERSION = {config.get('BUILD_VERSION', '')};"),
|
||||
(swift, "let RD_APP_GUID: String =", f"let RD_APP_GUID: String = \"{config.get('APPID', '')}\""),
|
||||
(swift, "let RD_API_DOMAIN: String", f"let RD_API_DOMAIN: String = \"{config.get('API', '')}\""),
|
||||
(swift, "let RD_WECHAT_PAY_ID: String", f"let RD_WECHAT_PAY_ID: String = \"{config.get('weixinpay', '')}\""),
|
||||
(swift, "let RD_SOURCE_VERSION: String", f"let RD_SOURCE_VERSION: String = \"{config.get('VERSION', '')}\""),
|
||||
(entitlements, "<string>applinks:", f"\t<string>{config.get('ASSOCIATED_DOMAINS', '')}</string>"),
|
||||
]
|
||||
if config.get("BUNDLE_ID"):
|
||||
replacements.append((project, "PRODUCT_BUNDLE_IDENTIFIER =", f"\t\t\t\tPRODUCT_BUNDLE_IDENTIFIER = {config['BUNDLE_ID']};"))
|
||||
if config.get("TEAM_ID"):
|
||||
replacements.extend([
|
||||
(project, "DEVELOPMENT_TEAM =", f"\t\t\t\tDEVELOPMENT_TEAM = {config['TEAM_ID']};"),
|
||||
(project, '"DEVELOPMENT_TEAM[sdk=iphoneos*]" =', f"\t\t\t\t\"DEVELOPMENT_TEAM[sdk=iphoneos*]\" = {config['TEAM_ID']};"),
|
||||
])
|
||||
if config.get("PROVISIONING_NAME"):
|
||||
replacements.extend([
|
||||
(project, "PROVISIONING_PROFILE_SPECIFIER =", f"\t\t\t\tPROVISIONING_PROFILE_SPECIFIER = {config['PROVISIONING_NAME']};"),
|
||||
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {config['PROVISIONING_NAME']};"),
|
||||
])
|
||||
|
||||
missing = []
|
||||
for path, keyword, replacement in replacements:
|
||||
if _replace_lines(path, keyword, replacement) == 0:
|
||||
missing.append(keyword)
|
||||
if missing:
|
||||
raise ProjectPatchError(f"未找到待替换配置: {', '.join(missing)}")
|
||||
|
||||
_update_plist(plist, config)
|
||||
_apply_theme(build_dir, config.get("THEME", ""), messages)
|
||||
_write_export_options(build_dir, config)
|
||||
messages.append("工程、签名、版本和分发配置已更新")
|
||||
return messages
|
||||
Reference in New Issue
Block a user