155 lines
6.1 KiB
Python
155 lines
6.1 KiB
Python
"""服务端 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)
|