Compare commits
7
Commits
1.2.0
...
9ace2c8aca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ace2c8aca | ||
|
|
fa559101b9 | ||
|
|
e10afb180d | ||
|
|
082c487cf8 | ||
|
|
1466e24c83 | ||
|
|
c17b7d5ad9 | ||
|
|
84a2d3db01 |
@@ -25,6 +25,7 @@ def _migrate_db():
|
||||
("tasks", "branch", "VARCHAR DEFAULT 'main'"),
|
||||
("tasks", "error_category", "VARCHAR"),
|
||||
("tasks", "superseded_at", "TIMESTAMP"),
|
||||
("tasks", "dsym_url", "VARCHAR"),
|
||||
]
|
||||
with engine.connect() as conn:
|
||||
for table, column, col_type in migrations:
|
||||
|
||||
@@ -32,6 +32,7 @@ class Task(Base):
|
||||
ipa_path = Column(String)
|
||||
oss_url = Column(String)
|
||||
dsym_path = Column(String)
|
||||
dsym_url = Column(String)
|
||||
obfuscation_maps_path = Column(String)
|
||||
qr_code_path = Column(String)
|
||||
build_dir = Column(String)
|
||||
|
||||
@@ -58,6 +58,8 @@ DEFAULT_UPLOAD = {
|
||||
"enabled": False,
|
||||
"webhook_url": "",
|
||||
"secret": "",
|
||||
"at_mobiles": [],
|
||||
"at_mobiles_app_store": [],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -300,8 +302,14 @@ def _ensure_upload_keys(config: dict) -> bool:
|
||||
|
||||
|
||||
def _migrate_old_themes(config: dict) -> bool:
|
||||
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更"""
|
||||
changed = False
|
||||
"""将旧式目录皮肤迁移为 ZIP 存入 data/skins/,返回是否有变更。
|
||||
|
||||
仅在首次执行(用旧配置升级时)生效,之后写入标记跳过:否则每次 load_config()
|
||||
都会重新执行——一旦某个皮肤被删除,只要对应证书的 theme 仍引用它、且旧版
|
||||
themes 目录还在,就会被立刻重新生成,导致皮肤包无法真正删除。
|
||||
"""
|
||||
if config.get("_legacy_themes_migrated"):
|
||||
return False
|
||||
themes_dir = AUTOMATION_DIR / "themes"
|
||||
for app_id, app in config.get("apps", {}).items():
|
||||
upload_key = app.get("upload_key", "")
|
||||
@@ -336,8 +344,8 @@ def _migrate_old_themes(config: dict) -> bool:
|
||||
app["skins"] = skins
|
||||
if theme != skin_name:
|
||||
cert["theme"] = skin_name
|
||||
changed = True
|
||||
return changed
|
||||
config["_legacy_themes_migrated"] = True
|
||||
return True
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
|
||||
@@ -3,6 +3,7 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
@@ -18,6 +19,10 @@ from ..deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
# 同一用户对同一任务连击下载按钮时,短时间内只记一次操作日志,避免刷屏
|
||||
_DOWNLOAD_LOG_DEBOUNCE_SECONDS = 5
|
||||
_recent_download_logs: dict[str, float] = {}
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse)
|
||||
async def create_task(task: TaskCreate, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
@@ -200,6 +205,25 @@ async def delete_task(task_id: str, request: Request, user: dict = Depends(get_c
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"远端产物删除失败,记录未删除:{exc}") from exc
|
||||
|
||||
# dSYM 压缩包的远端对象键与 IPA 独立,同一版本/分支重复打包会复用并覆盖同一个远端文件,
|
||||
# 仍有其他历史记录引用时不能删除。
|
||||
has_shared_dsym = bool(
|
||||
task.dsym_url and db.query(Task).filter(
|
||||
Task.id != task.id,
|
||||
Task.dsym_url == task.dsym_url,
|
||||
).first()
|
||||
)
|
||||
if task.dsym_url and not has_shared_dsym:
|
||||
if not task.config_json:
|
||||
raise HTTPException(status_code=400, detail="缺少打包配置快照,无法安全删除远端 dSYM 文件")
|
||||
try:
|
||||
build_config = json.loads(task.config_json)
|
||||
from .config import load_config
|
||||
from ..services.distribution import DistributionError, delete_published_dsym
|
||||
delete_published_dsym(build_config, load_config().get("upload", {}))
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"远端 dSYM 删除失败,记录未删除:{exc}") from exc
|
||||
|
||||
# 删除日志文件
|
||||
log_path = Path(__file__).parent.parent / "logs" / f"{task_id}.log"
|
||||
if log_path.exists():
|
||||
@@ -276,6 +300,17 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
|
||||
)
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
|
||||
|
||||
debounce_key = f"{user.get('username')}:{task_id}"
|
||||
now = time.monotonic()
|
||||
last = _recent_download_logs.get(debounce_key)
|
||||
if last is None or now - last > _DOWNLOAD_LOG_DEBOUNCE_SECONDS:
|
||||
if len(_recent_download_logs) > 500:
|
||||
cutoff = now - _DOWNLOAD_LOG_DEBOUNCE_SECONDS
|
||||
for key, ts in list(_recent_download_logs.items()):
|
||||
if ts < cutoff:
|
||||
del _recent_download_logs[key]
|
||||
_recent_download_logs[debounce_key] = now
|
||||
from ..services.audit_log import record_operation
|
||||
record_operation(db, user, "ipa_download_requested", request=request, task=task,
|
||||
resource_type="ipa", resource_name=task.oss_url,
|
||||
@@ -286,7 +321,7 @@ async def get_download_link(task_id: str, request: Request, user: dict = Depends
|
||||
|
||||
@router.get("/{task_id}/dsym")
|
||||
async def download_dsym(task_id: str, request: Request, user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
"""下载 dSYM 文件"""
|
||||
"""下载 dSYM 文件:本地文件已被清理时回退到构建时上传保存的远端地址"""
|
||||
import shutil
|
||||
import zipfile
|
||||
import tempfile
|
||||
@@ -294,17 +329,35 @@ async def download_dsym(task_id: str, request: Request, user: dict = Depends(get
|
||||
task = db.query(Task).filter(Task.id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
if not task.dsym_path:
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
||||
from ..services.audit_log import record_operation
|
||||
record_operation(db, user, "dsym_downloaded", request=request, task=task,
|
||||
resource_type="dsym", resource_name=os.path.basename(task.dsym_path))
|
||||
db.commit()
|
||||
|
||||
dsym_path = task.dsym_path
|
||||
if not os.path.exists(dsym_path):
|
||||
local_exists = bool(dsym_path) and os.path.exists(dsym_path)
|
||||
if not local_exists and not task.dsym_url:
|
||||
if not dsym_path:
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件不存在")
|
||||
raise HTTPException(status_code=404, detail="dSYM 文件已被清理")
|
||||
|
||||
from ..services.audit_log import record_operation
|
||||
resource_name = os.path.basename(dsym_path) if dsym_path else os.path.basename(task.dsym_url)
|
||||
record_operation(db, user, "dsym_downloaded", request=request, task=task,
|
||||
resource_type="dsym", resource_name=resource_name)
|
||||
db.commit()
|
||||
|
||||
if not local_exists:
|
||||
# dSYM 需要携带认证信息才能访问该接口,浏览器无法直接跳转到本接口的
|
||||
# 302 目标,因此改为返回 JSON 让前端自行发起下载。
|
||||
try:
|
||||
from .config import load_config
|
||||
from ..services.distribution import DistributionError, get_published_dsym_url
|
||||
download_url = get_published_dsym_url(
|
||||
json.loads(task.config_json or "{}"),
|
||||
load_config().get("upload", {}),
|
||||
task.dsym_url,
|
||||
)
|
||||
except (json.JSONDecodeError, DistributionError) as exc:
|
||||
raise HTTPException(status_code=502, detail=f"生成下载链接失败:{exc}") from exc
|
||||
return {"url": download_url}
|
||||
|
||||
# .dSYM 是目录(macOS bundle),需要压缩为 zip 再下载
|
||||
if os.path.isdir(dsym_path):
|
||||
zip_path = os.path.join(tempfile.gettempdir(), os.path.basename(dsym_path) + ".zip")
|
||||
|
||||
@@ -31,6 +31,7 @@ class TaskResponse(BaseModel):
|
||||
ipa_path: Optional[str]
|
||||
oss_url: Optional[str]
|
||||
dsym_path: Optional[str]
|
||||
dsym_url: Optional[str] = None
|
||||
obfuscation_maps_path: Optional[str]
|
||||
qr_code_path: Optional[str]
|
||||
build_dir: Optional[str]
|
||||
|
||||
@@ -23,7 +23,7 @@ from ..config import (
|
||||
mask_git_remote_url,
|
||||
)
|
||||
from .log_streamer import log_streamer
|
||||
from .distribution import DistributionError, publish_ipa
|
||||
from .distribution import DistributionError, publish_dsym, publish_ipa
|
||||
from .notification import NotificationError, send_dingtalk_notification
|
||||
from .project_patcher import ProjectPatchError, apply_project_config
|
||||
|
||||
@@ -455,7 +455,7 @@ async def run_build_task(task_id: str):
|
||||
ipa_path = await build_project(task_id, task, config_data, build_dir)
|
||||
|
||||
# 查找 dSYM
|
||||
dsym_path = await find_dsym(task_id, build_dir)
|
||||
dsym_path = await find_dsym(task_id, build_dir, config_data.get("SCHEME", ""))
|
||||
|
||||
# 查找混淆映射表
|
||||
obf_maps_path = build_dir / "obfuscation_maps"
|
||||
@@ -477,6 +477,13 @@ async def run_build_task(task_id: str):
|
||||
upload_fields["oss_url"] = oss_url
|
||||
if qr_code_path:
|
||||
upload_fields["qr_code_path"] = qr_code_path
|
||||
|
||||
# dSYM 用于后续崩溃符号化,按上传配置一并保存到远端;失败不影响本次打包结果。
|
||||
if dsym_path:
|
||||
dsym_url = await upload_dsym(task_id, task, config_data, dsym_path, build_dir)
|
||||
if dsym_url:
|
||||
upload_fields["dsym_url"] = dsym_url
|
||||
|
||||
if upload_fields:
|
||||
await asyncio.to_thread(_db_update, db, task, **upload_fields)
|
||||
|
||||
@@ -491,8 +498,8 @@ async def run_build_task(task_id: str):
|
||||
if task.oss_url:
|
||||
await log_streamer.emit(task_id, f"下载链接: {task.oss_url}")
|
||||
|
||||
# App Store 仅提供 IPA 下载地址,不发送下载通知;Ad Hoc 才发送安装页二维码通知。
|
||||
if task.build_type == "Ad_Hoc":
|
||||
# Ad Hoc 发安装页二维码通知,App Store 发 IPA 下载地址通知;
|
||||
# 两者标题与 @ 名单不同,均由 send_dingtalk_notification 按 BUILD_TYPE 区分。
|
||||
# 通知不影响已完成的打包结果,发送失败仅写入日志以便排查。
|
||||
dingtalk_config = config_data.get("_upload_config", {}).get("dingtalk", {})
|
||||
try:
|
||||
@@ -715,7 +722,7 @@ async def generate_config(task_id: str, task, build_dir: Path) -> dict:
|
||||
|
||||
# 关联域名等配置
|
||||
config_data["ASSOCIATED_DOMAINS"] = app.get("AssDom", "")
|
||||
for key in ["weixinlogin", "weixinpay", "tencent", "UniversalLink", "AlivcLicenseKey"]:
|
||||
for key in ["weixinlogin", "weixinpay", "wxworkSchema", "tencent", "UniversalLink", "AlivcLicenseKey"]:
|
||||
if key in app:
|
||||
config_data[key] = app[key]
|
||||
|
||||
@@ -914,16 +921,23 @@ async def build_project(task_id: str, task, config_data: dict, build_dir: Path)
|
||||
return ipa_path
|
||||
|
||||
|
||||
async def find_dsym(task_id: str, build_dir: Path) -> Path:
|
||||
"""查找 dSYM 文件"""
|
||||
async def find_dsym(task_id: str, build_dir: Path, scheme: str = "") -> Path:
|
||||
"""查找当前 target(主 App)对应的 dSYM 文件,忽略 Extension/Framework 各自的 dSYM"""
|
||||
derived_data = build_dir / "build" / "derived_data"
|
||||
if not derived_data.exists():
|
||||
return None
|
||||
|
||||
for dsym in derived_data.rglob("*.dSYM"):
|
||||
return dsym
|
||||
dsyms = list(derived_data.rglob("*.dSYM"))
|
||||
if not dsyms:
|
||||
return None
|
||||
|
||||
if scheme:
|
||||
for dsym in dsyms:
|
||||
if dsym.name == f"{scheme}.app.dSYM":
|
||||
return dsym
|
||||
|
||||
return dsyms[0]
|
||||
|
||||
|
||||
async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, build_dir: Path) -> tuple:
|
||||
"""上传 IPA,返回 (download_url, qr_code_path)"""
|
||||
@@ -938,3 +952,13 @@ async def upload_ipa(task_id: str, task, config_data: dict, ipa_path: Path, buil
|
||||
return await asyncio.to_thread(publish_ipa, config_data, ipa_path, build_dir)
|
||||
except DistributionError as exc:
|
||||
raise BuildError(str(exc), category="upload") from exc
|
||||
|
||||
|
||||
async def upload_dsym(task_id: str, task, config_data: dict, dsym_path: Path, build_dir: Path) -> str:
|
||||
"""上传 dSYM,返回下载 URL;上传失败仅记录警告,不影响已完成的打包结果"""
|
||||
await log_streamer.emit_step(task_id, "上传 dSYM")
|
||||
try:
|
||||
return await asyncio.to_thread(publish_dsym, config_data, dsym_path, build_dir)
|
||||
except DistributionError as exc:
|
||||
await log_streamer.emit(task_id, f"dSYM 上传失败: {exc}", level="warn")
|
||||
return ""
|
||||
|
||||
@@ -4,6 +4,7 @@ import plistlib
|
||||
import posixpath
|
||||
import re
|
||||
import shutil
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
@@ -54,18 +55,149 @@ def _write_manifest(config: dict, manifest: Path, ipa_url: str):
|
||||
plistlib.dump(data, f)
|
||||
|
||||
|
||||
DOWNLOAD_PAGE_TEMPLATE = """<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<title>{title} - 安装</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f2f3f7;
|
||||
--card: #ffffff;
|
||||
--text: #1c1c1e;
|
||||
--muted: #8a8a8e;
|
||||
--line: rgba(60, 60, 67, .12);
|
||||
--brand: #50a0ff;
|
||||
}}
|
||||
@media (prefers-color-scheme: dark) {{
|
||||
:root {{
|
||||
--bg: #000000;
|
||||
--card: #1c1c1e;
|
||||
--text: #f5f5f7;
|
||||
--muted: #98989d;
|
||||
--line: rgba(255, 255, 255, .12);
|
||||
}}
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
padding: 10vh 20px calc(32px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}}
|
||||
.card {{
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--card);
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px 24px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, .08);
|
||||
text-align: center;
|
||||
}}
|
||||
.name {{
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
word-break: break-word;
|
||||
}}
|
||||
.version {{
|
||||
margin: 6px 0 22px;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}}
|
||||
.install {{
|
||||
display: block;
|
||||
padding: 15px;
|
||||
border-radius: 14px;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}}
|
||||
.install:active {{ opacity: .75; }}
|
||||
.meta {{
|
||||
margin: 22px 0 0;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.meta div {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 5px 0;
|
||||
}}
|
||||
.meta dt {{ color: var(--muted); flex: none; }}
|
||||
.meta dd {{
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}}
|
||||
.tips {{
|
||||
margin: 18px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1 class="name">{title}</h1>
|
||||
<p class="version">版本 {version}</p>
|
||||
<a class="install" href="{install_url}">安装 App</a>
|
||||
<dl class="meta">{meta}</dl>
|
||||
<p class="tips">请使用 iPhone 自带的 Safari 浏览器打开本页面安装。<br>
|
||||
安装后如提示“未受信任的开发者”,请前往「设置 → 通用 → VPN 与设备管理」信任该描述文件。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
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")
|
||||
install_url = f"itms-services://?action=download-manifest&url={quote(manifest_url, safe=':/?=&')}"
|
||||
title = config.get("APPID_NAME") or "iOS App"
|
||||
meta_items = [
|
||||
("构建版本", config.get("BUILD_VERSION", "")),
|
||||
("Bundle ID", config.get("BUNDLE_ID", "")),
|
||||
("分支", config.get("SOURCE_BRANCH", "")),
|
||||
]
|
||||
meta = "".join(
|
||||
f"<div><dt>{escape(label)}</dt><dd>{escape(str(value))}</dd></div>"
|
||||
for label, value in meta_items
|
||||
if value
|
||||
)
|
||||
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>",
|
||||
DOWNLOAD_PAGE_TEMPLATE.format(
|
||||
title=escape(title),
|
||||
version=escape(str(config.get("VERSION", ""))),
|
||||
install_url=escape(install_url, quote=True),
|
||||
meta=meta,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _dsym_remote_path(config: dict) -> str:
|
||||
stem = _artifact_stem(config)
|
||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||
return posixpath.join(folder, "iOS", f"{stem}.dSYM.zip")
|
||||
|
||||
|
||||
def _remote_paths(config: dict) -> tuple[str, str, str, str]:
|
||||
stem = _artifact_stem(config)
|
||||
folder = config.get("OSS_FLODER", "ios-builds").strip("/") or "ios-builds"
|
||||
@@ -169,14 +301,21 @@ def delete_published_artifacts(
|
||||
deleter(upload_config, remote_paths)
|
||||
|
||||
|
||||
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
||||
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return published_url
|
||||
def delete_published_dsym(build_config: dict, upload_config: dict):
|
||||
"""删除任务对应的远端 dSYM 压缩包。
|
||||
|
||||
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
||||
if not remote_paths:
|
||||
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
||||
dSYM 的远端对象键完全由构建配置(APPID/版本/分支/构建类型)决定,
|
||||
无需像 IPA 那样从已保存的下载链接反推,直接按命名规则删除即可。
|
||||
"""
|
||||
mode = upload_config.get("mode", "")
|
||||
if mode not in {"oss", "webdav"}:
|
||||
raise DistributionError("请选择 OSS 或 WebDAV 上传方式")
|
||||
|
||||
deleter = _delete_oss if mode == "oss" else _delete_webdav
|
||||
deleter(upload_config, [_dsym_remote_path(build_config)])
|
||||
|
||||
|
||||
def _sign_oss_url(upload_config: dict, remote_path: str) -> str:
|
||||
oss = upload_config.get("oss", {})
|
||||
required = ["access_key_id", "access_key_secret", "endpoint", "bucket_name"]
|
||||
if any(not oss.get(key) for key in required):
|
||||
@@ -190,7 +329,29 @@ def get_published_download_url(build_config: dict, upload_config: dict, publishe
|
||||
oss2.Auth(oss["access_key_id"], oss["access_key_secret"]),
|
||||
oss["endpoint"], oss["bucket_name"], connect_timeout=30,
|
||||
)
|
||||
return bucket.sign_url("GET", remote_paths[0], DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
||||
return bucket.sign_url("GET", remote_path, DOWNLOAD_URL_EXPIRE_SECONDS, slash_safe=True)
|
||||
|
||||
|
||||
def get_published_download_url(build_config: dict, upload_config: dict, published_url: str) -> str:
|
||||
"""返回用于受控下载的短时链接;WebDAV 保持原公开链接。"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return published_url
|
||||
|
||||
remote_paths = _remote_paths_from_published_url(build_config, upload_config, published_url)
|
||||
if not remote_paths:
|
||||
raise DistributionError("无法识别 OSS 文件路径,无法生成受控下载链接")
|
||||
return _sign_oss_url(upload_config, remote_paths[0])
|
||||
|
||||
|
||||
def get_published_dsym_url(build_config: dict, upload_config: dict, dsym_url: str) -> str:
|
||||
"""返回 dSYM 受控下载的短时链接;WebDAV 保持原公开链接。
|
||||
|
||||
dSYM 的远端对象键完全由构建配置决定,直接按命名规则重新计算即可,
|
||||
不需要像 IPA 那样从已保存的下载链接反推。
|
||||
"""
|
||||
if upload_config.get("mode") != "oss":
|
||||
return dsym_url
|
||||
return _sign_oss_url(upload_config, _dsym_remote_path(build_config))
|
||||
|
||||
|
||||
def _ensure_webdav_dirs(client: httpx.Client, server_url: str, remote_path: str):
|
||||
@@ -283,3 +444,22 @@ def publish_ipa(config: dict, ipa_path: Path, build_dir: Path) -> tuple[str, str
|
||||
qrcode.make(urls[".html"]).save(qr_path)
|
||||
urls.update(uploader(upload, [(qr_path, qr_remote)]))
|
||||
return urls[".html"], urls[".png"]
|
||||
|
||||
|
||||
def publish_dsym(config: dict, dsym_path: Path, build_dir: Path) -> str:
|
||||
"""压缩并上传主 target 的 dSYM,返回下载 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"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_base = output_dir / f"{_artifact_stem(config)}.dSYM"
|
||||
zip_path = Path(shutil.make_archive(
|
||||
str(zip_base), "zip", root_dir=str(dsym_path.parent), base_dir=dsym_path.name,
|
||||
))
|
||||
|
||||
uploader = _upload_oss if mode == "oss" else _upload_webdav
|
||||
urls = uploader(upload, [(zip_path, _dsym_remote_path(config))])
|
||||
return urls[".zip"]
|
||||
|
||||
@@ -27,8 +27,32 @@ def _signed_webhook_url(webhook_url: str, secret: str) -> str:
|
||||
return f"{webhook_url}{separator}timestamp={timestamp}&sign={quote(base64.b64encode(signature))}"
|
||||
|
||||
|
||||
def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: str = "") -> dict:
|
||||
def _normalize_mobiles(at_mobiles) -> list:
|
||||
"""把手机号配置统一成去重、去空的字符串列表,兼容列表或逗号/换行分隔的字符串。"""
|
||||
if not at_mobiles:
|
||||
return []
|
||||
if isinstance(at_mobiles, str):
|
||||
raw = at_mobiles.replace("\n", ",").replace(",", ",").replace(" ", ",").split(",")
|
||||
else:
|
||||
raw = at_mobiles
|
||||
seen = []
|
||||
for item in raw:
|
||||
mobile = str(item).strip()
|
||||
if mobile and mobile not in seen:
|
||||
seen.append(mobile)
|
||||
return seen
|
||||
|
||||
|
||||
def build_dingtalk_payload(
|
||||
config_data: dict,
|
||||
download_url: str,
|
||||
qr_code_url: str = "",
|
||||
at_mobiles=None,
|
||||
) -> dict:
|
||||
"""生成与 AutoPacking/upload_iap.py 一致的钉钉 Markdown 内容。"""
|
||||
mobiles = _normalize_mobiles(at_mobiles)
|
||||
# App_Store 包与 Ad Hoc 用不同标题,App_Store 只有 IPA 下载地址、无二维码。
|
||||
heading = "【iOS】App_Store 包信息" if config_data.get("BUILD_TYPE") == "App_Store" else "【iOS】打包信息"
|
||||
details = (
|
||||
f"**环境:** {config_data.get('SERVER', '')}\n\n"
|
||||
f"**版本:** {config_data.get('VERSION', '')}\n\n"
|
||||
@@ -36,14 +60,18 @@ def build_dingtalk_payload(config_data: dict, download_url: str, qr_code_url: st
|
||||
f"**包名:** {config_data.get('BUNDLE_ID', '')}\n\n"
|
||||
f"**App Guid:** {config_data.get('APPID', '')}"
|
||||
)
|
||||
text = f"## 【iOS】打包信息\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
|
||||
text = f"## {heading}\n\n{details}\n\n**iOS 下载链接:** {download_url}\n"
|
||||
if qr_code_url:
|
||||
text += f"\n"
|
||||
# 钉钉 markdown 消息必须在正文内出现 @手机号 才会真正 @ 到人,
|
||||
# 仅靠 at.atMobiles 不生效。
|
||||
if mobiles:
|
||||
text += "\n\n" + " ".join(f"@{m}" for m in mobiles)
|
||||
|
||||
return {
|
||||
"msgtype": "markdown",
|
||||
"markdown": {"title": "iOS应用下载", "text": text},
|
||||
"at": {"atMobiles": [], "isAtAll": False},
|
||||
"at": {"atMobiles": mobiles, "isAtAll": False},
|
||||
}
|
||||
|
||||
|
||||
@@ -61,8 +89,11 @@ def send_dingtalk_notification(
|
||||
if not webhook_url:
|
||||
raise NotificationError("钉钉通知已启用,但未配置 Webhook URL")
|
||||
|
||||
payload = build_dingtalk_payload(config_data, download_url, qr_code_url)
|
||||
payload["at"]["atMobiles"] = dingtalk_config.get("at_mobiles", [])
|
||||
# Ad_Hoc 与 App_Store 使用各自的 @ 名单。
|
||||
mobiles_key = "at_mobiles_app_store" if config_data.get("BUILD_TYPE") == "App_Store" else "at_mobiles"
|
||||
payload = build_dingtalk_payload(
|
||||
config_data, download_url, qr_code_url, dingtalk_config.get(mobiles_key, [])
|
||||
)
|
||||
response = httpx.post(
|
||||
_signed_webhook_url(webhook_url, dingtalk_config.get("secret", "")),
|
||||
json=payload,
|
||||
|
||||
@@ -35,11 +35,15 @@ def _update_plist(path: Path, config: dict):
|
||||
|
||||
plist["CFBundleDisplayName"] = config.get("APPID_NAME", "")
|
||||
plist["AlivcLicenseKey"] = config.get("AlivcLicenseKey", "") or ""
|
||||
|
||||
scheme = (config.get("BUNDLE_ID", "") or "").replace(".", "").lower()
|
||||
values = {
|
||||
"weixinlogin": config.get("weixinlogin", ""),
|
||||
"wechatpay": config.get("weixinpay", ""),
|
||||
"tencent": config.get("tencent", ""),
|
||||
# 企业微信 Schema 与微信登录 AppID 同样存在 CFBundleURLTypes 中
|
||||
# (CFBundleURLName == "wxwork"),未配置则整条移除。
|
||||
"wxwork": (config.get("wxworkSchema", "") or "").strip(),
|
||||
"readoorUrlScheme": scheme,
|
||||
}
|
||||
url_types = []
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@
|
||||
"tencent": "",
|
||||
"weixinlogin": "",
|
||||
"weixinpay": "",
|
||||
"wxworkSchema": "",
|
||||
"skins": ["default.zip"],
|
||||
"certificates": {
|
||||
"Ad_Hoc": {
|
||||
@@ -66,7 +67,9 @@
|
||||
"dingtalk": {
|
||||
"enabled": false,
|
||||
"webhook_url": "",
|
||||
"secret": ""
|
||||
"secret": "",
|
||||
"at_mobiles": [],
|
||||
"at_mobiles_app_store": []
|
||||
}
|
||||
},
|
||||
"versions": {
|
||||
|
||||
@@ -268,6 +268,16 @@
|
||||
<label>加签密钥(可选)</label>
|
||||
<input v-model="uploadConfig.dingtalk.secret" type="password" placeholder="留空则不加签">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Ad_Hoc @ 开发人员手机号(可选)</label>
|
||||
<textarea v-model="dingtalkAtMobiles" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">须是钉钉账号绑定的手机号,Ad_Hoc 打包完成的通知会 @ 这些人。</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>App_Store @ 开发人员手机号(可选)</label>
|
||||
<textarea v-model="dingtalkAtMobilesAppStore" rows="3" placeholder="每行一个手机号,或用逗号分隔;留空则不 @ 人"></textarea>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">App_Store 打包完成的通知会 @ 这些人。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -488,6 +498,12 @@
|
||||
<input v-model="appForm.weixinpay" type="text" placeholder="wx...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>企业微信 Schema</label>
|
||||
<input v-model="appForm.wxworkSchema" type="text" placeholder="不填则不写入 Info.plist(与微信登录 AppID 相同)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>腾讯 AppID</label>
|
||||
@@ -667,6 +683,21 @@ const onAppVerInput = (track) => {
|
||||
const uploadConfig = ref({ mode: 'oss', oss: {}, webdav: {}, dingtalk: {} })
|
||||
const jsonContent = ref('{}')
|
||||
|
||||
// 钉钉 @ 手机号:配置里存字符串数组,输入框按逗号/换行分隔编辑。
|
||||
// Ad_Hoc 与 App_Store 各一份名单。
|
||||
const dingtalkAtMobilesModel = (key) => computed({
|
||||
get: () => (uploadConfig.value.dingtalk?.[key] || []).join('\n'),
|
||||
set: (val) => {
|
||||
if (!uploadConfig.value.dingtalk) uploadConfig.value.dingtalk = {}
|
||||
uploadConfig.value.dingtalk[key] = val
|
||||
.split(/[\s,,]+/)
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
},
|
||||
})
|
||||
const dingtalkAtMobiles = dingtalkAtMobilesModel('at_mobiles')
|
||||
const dingtalkAtMobilesAppStore = dingtalkAtMobilesModel('at_mobiles_app_store')
|
||||
|
||||
const showServerModal = ref(false)
|
||||
const editingServerName = ref(null)
|
||||
const serverForm = ref({ name: '', api: '', assDom: '', universalLink: '', app_id_prefix: null })
|
||||
@@ -883,6 +914,7 @@ const openAppModal = (id = null, app = null) => {
|
||||
UniversalLink: '',
|
||||
weixinlogin: '',
|
||||
weixinpay: '',
|
||||
wxworkSchema: '',
|
||||
tencent: '',
|
||||
AlivcLicenseKey: '',
|
||||
app_id_prefix_override: '',
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<img v-if="task.build_type === 'Ad_Hoc' && task.qr_code_path"
|
||||
:src="task.qr_code_path" alt="QR" class="qr-thumb"
|
||||
@click="showQrPreview(task)">
|
||||
<button v-if="task.build_type === 'App_Store'" class="download-link" @click="downloadIpa(task.id)">下载 IPA</button>
|
||||
<button v-if="task.build_type === 'App_Store'" class="download-link" :disabled="!!downloadingIds[task.id]" @click="downloadIpa(task.id)">{{ downloadingIds[task.id] ? '下载中...' : '下载 IPA' }}</button>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
@@ -159,7 +159,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, nextTick, onUnmounted, inject } from 'vue'
|
||||
|
||||
const getToken = inject('getToken', () => '')
|
||||
const isAdmin = inject('isAdmin', ref(false))
|
||||
@@ -182,6 +182,7 @@ const logContainer = ref(null)
|
||||
const showVerboseLogs = ref(false)
|
||||
const qrPreview = ref(null)
|
||||
const showSuperseded = ref(false)
|
||||
const downloadingIds = reactive({})
|
||||
let logWs = null
|
||||
|
||||
const getAppVersion = (task) => {
|
||||
@@ -308,11 +309,41 @@ onUnmounted(() => {
|
||||
if (logWs) { logWs.close(); logWs = null }
|
||||
})
|
||||
|
||||
const downloadDsym = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/dsym`)
|
||||
const triggerBlobDownload = (blob, filename) => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename || ''
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const filenameFromDisposition = (res, fallback) => {
|
||||
const cd = res.headers.get('content-disposition') || ''
|
||||
const match = cd.match(/filename\*?=(?:UTF-8'')?"?([^";]+)"?/i)
|
||||
return match ? decodeURIComponent(match[1]) : fallback
|
||||
}
|
||||
|
||||
const downloadDsym = async (taskId) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/dsym`)
|
||||
if (!res.ok) throw new Error()
|
||||
if ((res.headers.get('content-type') || '').includes('application/json')) {
|
||||
const { url } = await res.json()
|
||||
window.open(url, '_blank')
|
||||
} else {
|
||||
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'dsym.zip'))
|
||||
}
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const downloadIpa = async (taskId) => {
|
||||
if (downloadingIds[taskId]) return
|
||||
downloadingIds[taskId] = true
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/download-link`)
|
||||
if (!res.ok) throw new Error()
|
||||
@@ -320,11 +351,19 @@ const downloadIpa = async (taskId) => {
|
||||
window.open(url, '_blank')
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
} finally {
|
||||
delete downloadingIds[taskId]
|
||||
}
|
||||
}
|
||||
|
||||
const downloadObfMaps = (taskId) => {
|
||||
window.open(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
const downloadObfMaps = async (taskId) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/tasks/${taskId}/obfuscation-maps`)
|
||||
if (!res.ok) throw new Error()
|
||||
triggerBlobDownload(await res.blob(), filenameFromDisposition(res, 'obfuscation_maps.zip'))
|
||||
} catch {
|
||||
alert('下载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
@@ -524,6 +563,8 @@ const errorCategoryLabel = (cat) => {
|
||||
.btn-close-log:hover { border-color: #1890ff; color: #1890ff; }
|
||||
.download-link { color: #1890ff; text-decoration: none; font-size: 13px; border: none; background: none; cursor: pointer; padding: 0; }
|
||||
.download-link:hover { text-decoration: underline; }
|
||||
.download-link:disabled { color: #999; cursor: default; }
|
||||
.download-link:disabled:hover { text-decoration: none; }
|
||||
.text-muted { color: #999; font-size: 13px; }
|
||||
.qr-thumb { width: 40px; height: 40px; cursor: pointer; border-radius: 4px; border: 1px solid #e8e8e8; }
|
||||
.qr-thumb:hover { border-color: #1890ff; }
|
||||
|
||||
@@ -66,6 +66,20 @@ def test_download_dsym_nonexistent_task(client, tmp_config):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_download_dsym_remote_fallback(client, tmp_config):
|
||||
"""本地文件已被清理,但保存了远端地址时应返回远端下载链接(而非直接重定向,
|
||||
因为该接口需要认证信息,浏览器无法直接跳转到 302 目标)"""
|
||||
task_id = _create_task_with_files(
|
||||
client, tmp_config,
|
||||
dsym_path="/tmp/nonexistent.dSYM",
|
||||
dsym_url="https://oss.example.com/ios-builds/iOS/app_1_0_0_main_adhoc.dSYM.zip",
|
||||
)
|
||||
with patch("backend.services.distribution.get_published_dsym_url", return_value="https://signed.example.com/app.dSYM.zip?signature=1"):
|
||||
resp = client.get(f"/api/tasks/{task_id}/dsym")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["url"] == "https://signed.example.com/app.dSYM.zip?signature=1"
|
||||
|
||||
|
||||
# ---- 混淆映射表下载 ----
|
||||
|
||||
def test_download_obfuscation_maps_not_exist(client, tmp_config):
|
||||
|
||||
@@ -35,6 +35,79 @@ def test_dingtalk_payload_matches_autopacking_content():
|
||||
assert "" in text
|
||||
|
||||
|
||||
def test_dingtalk_payload_at_mobiles_appended_to_text_and_at_field():
|
||||
payload = build_dingtalk_payload(
|
||||
_build_config(),
|
||||
"https://download.example.com/app.html",
|
||||
at_mobiles=["13800000000", " 13900000001 ", "13800000000", ""],
|
||||
)
|
||||
|
||||
# 去重去空后写入 at.atMobiles,并在正文追加 @手机号(钉钉 markdown 必需)
|
||||
assert payload["at"]["atMobiles"] == ["13800000000", "13900000001"]
|
||||
assert "@13800000000" in payload["markdown"]["text"]
|
||||
assert "@13900000001" in payload["markdown"]["text"]
|
||||
|
||||
|
||||
def test_dingtalk_payload_without_at_mobiles_has_empty_at():
|
||||
payload = build_dingtalk_payload(_build_config(), "https://download.example.com/app.html")
|
||||
assert payload["at"]["atMobiles"] == []
|
||||
assert "@" not in payload["markdown"]["text"]
|
||||
|
||||
|
||||
def test_dingtalk_notification_forwards_at_mobiles():
|
||||
response = MagicMock(status_code=200)
|
||||
response.json.return_value = {"errcode": 0}
|
||||
with patch("backend.services.notification.httpx.post", return_value=response) as post:
|
||||
send_dingtalk_notification(
|
||||
{
|
||||
"enabled": True,
|
||||
"webhook_url": "https://example.com/robot",
|
||||
"at_mobiles": ["13800000000", "13900000001"],
|
||||
},
|
||||
_build_config(),
|
||||
"https://download.example.com/app.html",
|
||||
)
|
||||
|
||||
sent = post.call_args.kwargs["json"]
|
||||
assert sent["at"]["atMobiles"] == ["13800000000", "13900000001"]
|
||||
assert "@13800000000" in sent["markdown"]["text"]
|
||||
|
||||
|
||||
def test_dingtalk_payload_app_store_heading_and_no_qr():
|
||||
config = {**_build_config(), "BUILD_TYPE": "App_Store"}
|
||||
# App_Store 流程不传二维码地址(publish_ipa 返回空串)
|
||||
payload = build_dingtalk_payload(
|
||||
config, "https://oss.example.com/readoor/iOS/app_2_197_1.ipa"
|
||||
)
|
||||
text = payload["markdown"]["text"]
|
||||
assert "## 【iOS】App_Store 包信息" in text
|
||||
assert "## 【iOS】打包信息" not in text
|
||||
assert "**iOS 下载链接:** https://oss.example.com/readoor/iOS/app_2_197_1.ipa" in text
|
||||
assert "![image]" not in text
|
||||
|
||||
|
||||
def test_dingtalk_notification_app_store_uses_own_at_list():
|
||||
response = MagicMock(status_code=200)
|
||||
response.json.return_value = {"errcode": 0}
|
||||
dingtalk_config = {
|
||||
"enabled": True,
|
||||
"webhook_url": "https://example.com/robot",
|
||||
"at_mobiles": ["13800000000"],
|
||||
"at_mobiles_app_store": ["13911111111", "13922222222"],
|
||||
}
|
||||
with patch("backend.services.notification.httpx.post", return_value=response) as post:
|
||||
send_dingtalk_notification(
|
||||
dingtalk_config,
|
||||
{**_build_config(), "BUILD_TYPE": "App_Store"},
|
||||
"https://oss.example.com/app.ipa",
|
||||
)
|
||||
|
||||
sent = post.call_args.kwargs["json"]
|
||||
assert sent["at"]["atMobiles"] == ["13911111111", "13922222222"]
|
||||
assert "## 【iOS】App_Store 包信息" in sent["markdown"]["text"]
|
||||
assert "@13800000000" not in sent["markdown"]["text"]
|
||||
|
||||
|
||||
def test_dingtalk_notification_skips_when_disabled():
|
||||
with patch("backend.services.notification.httpx.post") as post:
|
||||
assert send_dingtalk_notification({}, _build_config(), "https://download.example.com/app") is False
|
||||
|
||||
@@ -33,7 +33,9 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
"<string>applinks:old</string>\n", encoding="utf-8"
|
||||
)
|
||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("wb") as f:
|
||||
plistlib.dump({"CFBundleURLTypes": []}, f)
|
||||
plistlib.dump({"CFBundleURLTypes": [
|
||||
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
|
||||
]}, f)
|
||||
|
||||
theme = tmp_path / "theme" / "AppIcon.appiconset"
|
||||
theme.mkdir(parents=True)
|
||||
@@ -42,6 +44,7 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
config = {
|
||||
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
|
||||
"API": "https://api.example.com", "weixinpay": "wx-pay", "APPID_NAME": "Test",
|
||||
"wxworkSchema": "wx1234567890abcdef",
|
||||
"BUNDLE_ID": "com.example.test", "TEAM_ID": "TEAM", "PROVISIONING_NAME": "profile",
|
||||
"ASSOCIATED_DOMAINS": "applinks:example.com", "THEME": str(theme.parent), "BUILD_TYPE": "Ad_Hoc",
|
||||
"CERTIFICATE": "Apple Distribution",
|
||||
@@ -58,3 +61,53 @@ def test_apply_project_config_without_branch_autopacking(tmp_path):
|
||||
assert "iPhone Developer" not in project_content
|
||||
assert (build_dir / "exportOptions.plist").exists()
|
||||
assert (logo / "icon-1024.png").read_bytes() == b"icon"
|
||||
with (build_dir / "readoor" / "3.0" / "readoor31.plist").open("rb") as f:
|
||||
url_types = plistlib.load(f)["CFBundleURLTypes"]
|
||||
wxwork = next(i for i in url_types if i["CFBundleURLName"] == "wxwork")
|
||||
assert wxwork["CFBundleURLSchemes"] == ["wx1234567890abcdef"]
|
||||
|
||||
|
||||
def _make_minimal_project(build_dir):
|
||||
"""构造 apply_project_config 所需的最小工程骨架,返回关键文件路径。"""
|
||||
project = build_dir / "readoor.xcodeproj"
|
||||
swift = build_dir / "readoor" / "3.0" / "AppConfig"
|
||||
entitlements_dir = build_dir / "readoor" / "3.0"
|
||||
for path in [project, swift, entitlements_dir]:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(project / "project.pbxproj").write_text(
|
||||
"MARKETING_VERSION = 1.0;\nCURRENT_PROJECT_VERSION = 1;\n"
|
||||
'CODE_SIGN_IDENTITY = "iPhone Developer";\n'
|
||||
'"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(swift / "RDAppConfiguration.swift").write_text(
|
||||
'let RD_APP_GUID: String = "old"\nlet RD_API_DOMAIN: String = "old"\n'
|
||||
'let RD_WECHAT_PAY_ID: String = "old"\nlet RD_SOURCE_VERSION: String = "old"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
(entitlements_dir / "readoor31.entitlements").write_text(
|
||||
"<string>applinks:old</string>\n", encoding="utf-8"
|
||||
)
|
||||
return entitlements_dir / "readoor31.plist"
|
||||
|
||||
|
||||
def test_wxwork_schema_removed_when_not_configured(tmp_path):
|
||||
build_dir = tmp_path / "build"
|
||||
plist_path = _make_minimal_project(build_dir)
|
||||
with plist_path.open("wb") as f:
|
||||
plistlib.dump({"CFBundleURLTypes": [
|
||||
{"CFBundleURLName": "wxwork", "CFBundleURLSchemes": ["1"]},
|
||||
]}, f)
|
||||
|
||||
config = {
|
||||
"VERSION": "2.0.0", "BUILD_VERSION": "2.0.0.1", "APPID": "guid",
|
||||
"API": "https://api.example.com", "APPID_NAME": "Test",
|
||||
"ASSOCIATED_DOMAINS": "applinks:example.com", "BUILD_TYPE": "Ad_Hoc",
|
||||
"CERTIFICATE": "Apple Distribution",
|
||||
}
|
||||
|
||||
apply_project_config(build_dir, config)
|
||||
|
||||
with plist_path.open("rb") as f:
|
||||
url_types = plistlib.load(f)["CFBundleURLTypes"]
|
||||
assert all(i.get("CFBundleURLName") != "wxwork" for i in url_types)
|
||||
|
||||
Reference in New Issue
Block a user