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
+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)