iOSBuildServer/backend/services/project_patcher.py

186 lines
7.9 KiB
Python

"""服务端自带的 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']};"),
])
# 与 AutoPacking/replace_build_info.py 保持一致:工程内所有构建配置都使用
# Distribution identity。只更新 exportOptions.plist 不足以覆盖 Archive 阶段,
# 否则 pbxproj 中残留的 iPhone/iOS Developer 会让 Xcode 查找开发证书。
replacements.extend([
(project, "CODE_SIGN_IDENTITY =", '\t\t\t\tCODE_SIGN_IDENTITY = "iPhone Distribution";'),
(project, '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" =', '\t\t\t\t"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";'),
])
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