iOSBuildServer/backend/services/distribution.py

155 lines
6.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""服务端 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)