diff --git a/backend/services/build_service.py b/backend/services/build_service.py index 348002e..49e63ba 100644 --- a/backend/services/build_service.py +++ b/backend/services/build_service.py @@ -2,7 +2,9 @@ import asyncio import json import os +import plistlib import shutil +import subprocess import zipfile from datetime import datetime from pathlib import Path @@ -39,6 +41,67 @@ class BuildError(Exception): 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" 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, 友好提示) _ERROR_RULES = [ (["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) await asyncio.to_thread(_db_update, db, task, **update_fields) - # 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) + # 6. 上传产物:Ad Hoc 发布安装页,App Store 上传 IPA 供下载提交。 + 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, @@ -526,46 +590,15 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict: else: 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", "") 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 + profile_name, team_id = await asyncio.to_thread( + _resolve_provisioning_profile, provisioning_profile + ) + config_data["PROVISIONING_NAME"] = profile_name + config_data["TEAM_ID"] = team_id # 关联域名等配置 config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "") diff --git a/backend/services/distribution.py b/backend/services/distribution.py index 491c014..4f25459 100644 --- a/backend/services/distribution.py +++ b/backend/services/distribution.py @@ -1,4 +1,4 @@ -"""服务端 Ad_Hoc 分发:OSS/WebDAV 上传、manifest、下载页和二维码。""" +"""服务端 IPA 分发:App Store IPA 直传及 Ad Hoc 安装页发布。""" import json import plistlib 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]: - """发布 IPA,返回下载页 URL 与二维码本地路径。""" + """发布 IPA,返回下载 URL 与二维码本地路径。""" upload = config.get("_upload_config", {}) mode = upload.get("mode", "") 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_remote, manifest_remote, html_remote = _remote_paths(config) - # manifest 依赖 IPA URL,先发布 IPA。 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)]) _write_manifest(config, manifest, urls[".ipa"]) urls.update(uploader(upload, [(manifest, manifest_remote)])) diff --git a/backend/services/project_patcher.py b/backend/services/project_patcher.py index 38208e5..47e0c57 100644 --- a/backend/services/project_patcher.py +++ b/backend/services/project_patcher.py @@ -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']};"), ]) + # 与 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: diff --git a/tests/test_build_service.py b/tests/test_build_service.py index 131e6b9..afa6d28 100644 --- a/tests/test_build_service.py +++ b/tests/test_build_service.py @@ -2,6 +2,7 @@ import asyncio import json import os +import plistlib import time import shutil import tempfile @@ -17,6 +18,7 @@ from backend.services.build_service import ( build_project, generate_config, _cleanup_old_builds, + _resolve_provisioning_profile, ) 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"}}, } - 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) 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["BUNDLE_ID"] == "com.test.app" assert result["CERTIFICATE"] == "cert" + assert result["PROVISIONING_NAME"] == "pro" + assert result["TEAM_ID"] == "TEAM123" config_file = build_dir / "config_output.json" assert config_file.exists() @@ -248,6 +256,15 @@ async def test_generate_config(tmp_path, log_streamer): 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): """从服务配置读取版本号,不依赖分支源码脚本""" build_dir = tmp_path / "build" diff --git a/tests/test_distribution.py b/tests/test_distribution.py index bea36f8..8e2c4b1 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -1,7 +1,13 @@ """服务端分发产物生成测试。""" 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): @@ -20,3 +26,27 @@ def test_distribution_files_use_current_service_config(tmp_path): assert ipa_file.read_bytes() == b"ipa" assert plist["items"][0]["metadata"]["bundle-identifier"] == "com.example.test" 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 == "" diff --git a/tests/test_project_patcher.py b/tests/test_project_patcher.py index 8cc2e0c..75a850f 100644 --- a/tests/test_project_patcher.py +++ b/tests/test_project_patcher.py @@ -19,7 +19,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path): "PRODUCT_BUNDLE_IDENTIFIER = old.id;\nDEVELOPMENT_TEAM = OLD;\n" '"DEVELOPMENT_TEAM[sdk=iphoneos*]" = 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", ) (swift / "RDAppConfiguration.swift").write_text( @@ -49,6 +51,10 @@ def test_apply_project_config_without_branch_autopacking(tmp_path): assert "工程、签名、版本和分发配置已更新" in messages 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 (logo / "icon-1024.png").read_bytes() == b"icon"