修复打包签名并支持 App Store IPA 上传

This commit is contained in:
shenlei 2026-07-20 14:16:45 +09:00
parent 7fd3845447
commit 2661435273
6 changed files with 156 additions and 56 deletions

View File

@ -2,7 +2,9 @@
import asyncio import asyncio
import json import json
import os import os
import plistlib
import shutil import shutil
import subprocess
import zipfile import zipfile
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -39,6 +41,67 @@ class BuildError(Exception):
source_prepare_lock = asyncio.Lock() source_prepare_lock = asyncio.Lock()
def _decode_provisioning_profile(profile_path: Path) -> dict:
"""沿用 AutoPacking 脚本的 security cms 方式读取描述文件。"""
try:
plist_xml = subprocess.check_output(
["security", "cms", "-D", "-i", str(profile_path)],
stderr=subprocess.DEVNULL,
)
# 与原脚本一致,跳过 security 可能输出在 XML 前面的 Apple 标记行。
lines = plist_xml.splitlines(keepends=True)
xml_start = next(
(
index
for index, line in enumerate(lines)
if line.lstrip().startswith((b"<?xml", b"<!DOCTYPE"))
),
0,
)
return plistlib.loads(b"".join(lines[xml_start:]))
except (OSError, subprocess.CalledProcessError, plistlib.InvalidFileException) as exc:
raise BuildError(f"读取描述文件失败: {profile_path.name}", category="provisioning") from exc
def _resolve_provisioning_profile(profile_reference: str) -> tuple[str, str]:
"""按 AutoPacking 的规则从描述文件名称提取 Name 和 TeamIdentifier。"""
configured_path = Path(profile_reference).expanduser()
if configured_path.is_file():
profile = _decode_provisioning_profile(configured_path)
else:
profiles_dir = (
Path.home()
/ "Library"
/ "Developer"
/ "Xcode"
/ "UserData"
/ "Provisioning Profiles"
)
if not profiles_dir.is_dir():
raise BuildError(f"Provisioning Profiles 目录不存在: {profiles_dir}", category="provisioning")
profile = None
for candidate in profiles_dir.iterdir():
if candidate.suffix != ".mobileprovision":
continue
try:
candidate_profile = _decode_provisioning_profile(candidate)
except BuildError:
continue
if candidate_profile.get("Name") == profile_reference:
profile = candidate_profile
break
if profile is None:
raise BuildError(f"未找到名称为 [{profile_reference}] 的描述文件", category="provisioning")
profile_name = profile.get("Name", "")
team_ids = profile.get("TeamIdentifier", [])
team_id = team_ids[0] if isinstance(team_ids, list) and team_ids else team_ids
if not profile_name or not team_id:
raise BuildError(f"描述文件缺少 Name 或 TeamIdentifier: {profile_reference}", category="provisioning")
return profile_name, team_id
# 错误分类规则:(关键词列表, category, 友好提示) # 错误分类规则:(关键词列表, category, 友好提示)
_ERROR_RULES = [ _ERROR_RULES = [
(["No signing certificate", "Signing certificate \"", "Code Signing Error", (["No signing certificate", "Signing certificate \"", "Code Signing Error",
@ -327,17 +390,18 @@ async def run_build_task(task_id: str):
update_fields["obfuscation_maps_path"] = str(obf_maps_path) update_fields["obfuscation_maps_path"] = str(obf_maps_path)
await asyncio.to_thread(_db_update, db, task, **update_fields) await asyncio.to_thread(_db_update, db, task, **update_fields)
# 6. 上传分发(仅 Ad_Hoc # 6. 上传产物Ad Hoc 发布安装页App Store 上传 IPA 供下载提交。
if task.build_type == "Ad_Hoc": await asyncio.to_thread(_db_update, db, task, current_step="upload")
await asyncio.to_thread(_db_update, db, task, current_step="upload") oss_url, qr_code_path = await upload_ipa(
oss_url, qr_code_path = await upload_ipa(task_id, task, config_data, ipa_path, build_dir) task_id, task, config_data, ipa_path, build_dir
upload_fields = {} )
if oss_url: upload_fields = {}
upload_fields["oss_url"] = oss_url if oss_url:
if qr_code_path: upload_fields["oss_url"] = oss_url
upload_fields["qr_code_path"] = qr_code_path if qr_code_path:
if upload_fields: upload_fields["qr_code_path"] = qr_code_path
await asyncio.to_thread(_db_update, db, task, **upload_fields) if upload_fields:
await asyncio.to_thread(_db_update, db, task, **upload_fields)
# 完成 # 完成
await asyncio.to_thread(_db_update, db, task, await asyncio.to_thread(_db_update, db, task,
@ -526,46 +590,15 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
else: else:
await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在") await log_streamer.emit(task_id, f"皮肤 {theme_value} 不存在")
# 从 Provisioning Profile 提取 TEAM_ID 和 PROVISIONING_NAME # 严格沿用 AutoPacking/start_build_app.py 的 configure_provisioning
# 描述文件必须成功匹配,并从文件本身提取 Name 与 TeamIdentifier。
provisioning_profile = config_data.get("PROVISIONING_PROFILE", "") provisioning_profile = config_data.get("PROVISIONING_PROFILE", "")
if provisioning_profile: if provisioning_profile:
profile_path = provisioning_profile profile_name, team_id = await asyncio.to_thread(
# 如果不是绝对路径或文件不存在,按名称在 Provisioning Profiles 目录中搜索 _resolve_provisioning_profile, provisioning_profile
if not os.path.exists(profile_path): )
profiles_dir = os.path.expanduser("~/Library/Developer/Xcode/UserData/Provisioning Profiles") config_data["PROVISIONING_NAME"] = profile_name
if os.path.isdir(profiles_dir): config_data["TEAM_ID"] = team_id
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", "") config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")

View File

@ -1,4 +1,4 @@
"""服务端 Ad_Hoc 分发OSS/WebDAV 上传、manifest、下载页和二维码""" """服务端 IPA 分发App Store IPA 直传及 Ad Hoc 安装页发布"""
import json import json
import plistlib import plistlib
import posixpath import posixpath
@ -127,7 +127,7 @@ def _upload_oss(config: dict, files: list[tuple[Path, str]]) -> dict[str, str]:
def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]: def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str]:
"""发布 IPA返回下载 URL 与二维码本地路径。""" """发布 IPA返回下载 URL 与二维码本地路径。"""
upload = config.get("_upload_config", {}) upload = config.get("_upload_config", {})
mode = upload.get("mode", "") mode = upload.get("mode", "")
if mode not in {"oss", "webdav"}: if mode not in {"oss", "webdav"}:
@ -137,8 +137,14 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir) ipa_file, manifest, html = _write_distribution_files(config, ipa_path, output_dir)
ipa_remote, manifest_remote, html_remote = _remote_paths(config) ipa_remote, manifest_remote, html_remote = _remote_paths(config)
# manifest 依赖 IPA URL先发布 IPA。
uploader = _upload_oss if mode == "oss" else _upload_webdav uploader = _upload_oss if mode == "oss" else _upload_webdav
# App Store 包只需要提供 IPA 下载地址,不能生成 itms-services 安装页。
if config.get("BUILD_TYPE") == "App_Store":
urls = uploader(upload, [(ipa_file, ipa_remote)])
return urls[".ipa"], ""
# Ad Hoc manifest 依赖 IPA URL先发布 IPA。
urls = uploader(upload, [(ipa_file, ipa_remote)]) urls = uploader(upload, [(ipa_file, ipa_remote)])
_write_manifest(config, manifest, urls[".ipa"]) _write_manifest(config, manifest, urls[".ipa"])
urls.update(uploader(upload, [(manifest, manifest_remote)])) urls.update(uploader(upload, [(manifest, manifest_remote)]))

View File

@ -163,6 +163,14 @@ def apply_project_config(build_dir: Path, config: dict) -> list[str]:
(project, '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" =', f"\t\t\t\t\"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]\" = {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 = [] missing = []
for path, keyword, replacement in replacements: for path, keyword, replacement in replacements:
if _replace_lines(path, keyword, replacement) == 0: if _replace_lines(path, keyword, replacement) == 0:

View File

@ -2,6 +2,7 @@
import asyncio import asyncio
import json import json
import os import os
import plistlib
import time import time
import shutil import shutil
import tempfile import tempfile
@ -17,6 +18,7 @@ from backend.services.build_service import (
build_project, build_project,
generate_config, generate_config,
_cleanup_old_builds, _cleanup_old_builds,
_resolve_provisioning_profile,
) )
from backend.services.log_streamer import LogStreamer from backend.services.log_streamer import LogStreamer
@ -233,7 +235,11 @@ async def test_generate_config(tmp_path, log_streamer):
"schemes": {"1": {"name": "testScheme", "ossFloder": "test"}}, "schemes": {"1": {"name": "testScheme", "ossFloder": "test"}},
} }
with patch("backend.routers.config.load_config", return_value=mock_config): with patch("backend.routers.config.load_config", return_value=mock_config), \
patch(
"backend.services.build_service._resolve_provisioning_profile",
return_value=("pro", "TEAM123"),
):
result = await generate_config("t1", task, build_dir) result = await generate_config("t1", task, build_dir)
assert result["APPID"] == "guid-123" assert result["APPID"] == "guid-123"
@ -241,6 +247,8 @@ async def test_generate_config(tmp_path, log_streamer):
assert result["BUILD_TYPE"] == "Ad_Hoc" assert result["BUILD_TYPE"] == "Ad_Hoc"
assert result["BUNDLE_ID"] == "com.test.app" assert result["BUNDLE_ID"] == "com.test.app"
assert result["CERTIFICATE"] == "cert" assert result["CERTIFICATE"] == "cert"
assert result["PROVISIONING_NAME"] == "pro"
assert result["TEAM_ID"] == "TEAM123"
config_file = build_dir / "config_output.json" config_file = build_dir / "config_output.json"
assert config_file.exists() assert config_file.exists()
@ -248,6 +256,15 @@ async def test_generate_config(tmp_path, log_streamer):
assert saved["APPID"] == "guid-123" assert saved["APPID"] == "guid-123"
def test_resolve_provisioning_profile_extracts_name_and_team(tmp_path):
profile_path = tmp_path / "profile.mobileprovision"
profile_path.write_bytes(b"signed profile")
decoded = plistlib.dumps({"Name": "a4YX061_adHoc", "TeamIdentifier": ["MG4Z7FU83W"]})
with patch("backend.services.build_service.subprocess.check_output", return_value=decoded):
assert _resolve_provisioning_profile(str(profile_path)) == ("a4YX061_adHoc", "MG4Z7FU83W")
async def test_generate_config_uses_saved_version(tmp_path, log_streamer): async def test_generate_config_uses_saved_version(tmp_path, log_streamer):
"""从服务配置读取版本号,不依赖分支源码脚本""" """从服务配置读取版本号,不依赖分支源码脚本"""
build_dir = tmp_path / "build" build_dir = tmp_path / "build"

View File

@ -1,7 +1,13 @@
"""服务端分发产物生成测试。""" """服务端分发产物生成测试。"""
import plistlib import plistlib
from unittest.mock import patch
from backend.services.distribution import _write_distribution_files, _write_download_page, _write_manifest from backend.services.distribution import (
_write_distribution_files,
_write_download_page,
_write_manifest,
publish_ipa,
)
def test_distribution_files_use_current_service_config(tmp_path): def test_distribution_files_use_current_service_config(tmp_path):
@ -20,3 +26,27 @@ def test_distribution_files_use_current_service_config(tmp_path):
assert ipa_file.read_bytes() == b"ipa" assert ipa_file.read_bytes() == b"ipa"
assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test" assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test"
assert "itms-services://" in html.read_text(encoding="utf-8") assert "itms-services://" in html.read_text(encoding="utf-8")
def test_app_store_distribution_uploads_only_ipa(tmp_path):
ipa = tmp_path / "source.ipa"
ipa.write_bytes(b"app-store-ipa")
config = {
"APPID": "100",
"VERSION": "2.0.0",
"BUILD_TYPE": "App_Store",
"OSS_FLODER": "readoor",
"_upload_config": {"mode": "oss", "oss": {}},
}
with patch(
"backend.services.distribution._upload_oss",
return_value={".ipa": "https://files.example.com/readoor/iOS/100_2_0_0.ipa"},
) as upload:
download_url, qr_path = publish_ipa(config, ipa, tmp_path / "build")
uploaded_files = upload.call_args.args[1]
assert len(uploaded_files) == 1
assert uploaded_files[0][0].suffix == ".ipa"
assert download_url.endswith("100_2_0_0.ipa")
assert qr_path == ""

View File

@ -19,7 +19,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
"PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n" "PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n"
'"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n' '"DEVELOPMENT_TEAM[sdk=iphoneos*]" = OLD;\n'
"PROVISIONING_PROFILE_SPECIFIER = old;\n" "PROVISIONING_PROFILE_SPECIFIER = old;\n"
'"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n', '"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = old;\n'
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
encoding="utf-8", encoding="utf-8",
) )
(swift / "RDAppConfiguration.swift").write_text( (swift / "RDAppConfiguration.swift").write_text(
@ -49,6 +51,10 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
assert "工程、签名、版本和分发配置已更新" in messages assert "工程、签名、版本和分发配置已更新" in messages
assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text() assert 'let RD_APP_GUID: String = "guid"' in (swift / "RDAppConfiguration.swift").read_text()
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in (project / "project.pbxproj").read_text() project_content = (project / "project.pbxproj").read_text()
assert "PRODUCT_BUNDLE_IDENTIFIER = com.example.test;" in project_content
assert 'CODE_SIGN_IDENTITY = "iPhone Distribution";' in project_content
assert '"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution";' in project_content
assert "iPhone Developer" not in project_content
assert (build_dir / "exportOptions.plist").exists() assert (build_dir / "exportOptions.plist").exists()
assert (logo / "icon-1024.png").read_bytes() == b"icon" assert (logo / "icon-1024.png").read_bytes() == b"icon"