feat: 服务端集成自动打包流程

This commit is contained in:
shen
2026-07-17 22:34:04 +08:00
parent a2437a039d
commit d1f070b251
221 changed files with 7284 additions and 447 deletions
+2
View File
@@ -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, "任务已加入队列,等待执行...")
+86 -182
View File
@@ -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
+154
View File
@@ -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)
+19 -4
View File
@@ -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):
"""将收集的日志保存到独立日志目录(不会随构建目录删除)"""
+177
View File
@@ -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