Commit 033861e9 by 周田
parents 1fe37dcd e77e72fc
*
!pyproject.toml
!uv.lock
!distribution-server/
distribution-server/*
!distribution-server/__init__.py
!distribution-server/app.py
!distribution-server/server.py
!distribution-server/settings.py
!distribution-server/api/
!distribution-server/core/
!distribution-server/frontend/
**/__pycache__/
**/*.py[cod]
FROM ghcr.io/astral-sh/uv:0.12.13 AS uv
FROM python:3.12-slim AS dependencies
COPY --from=uv /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --extra server --no-dev --no-install-project --no-cache
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY --from=dependencies /app/.venv /app/.venv
COPY distribution-server/ ./distribution-server/
RUN groupadd --system --gid 10001 distribution \
&& useradd --system --uid 10001 --gid distribution --no-create-home distribution \
&& mkdir -p /app/distribution-server/data \
&& chown distribution:distribution /app/distribution-server/data
USER distribution
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/app/.venv/bin/python", "-c", "from urllib.request import urlopen; urlopen('http://127.0.0.1:8000/healthz', timeout=3).close()"]
CMD ["/app/.venv/bin/python", "-m", "distribution-server.server"]
...@@ -8,6 +8,14 @@ ...@@ -8,6 +8,14 @@
默认安装只包含 updater 运行依赖;管理 Web 的 FastAPI/Uvicorn 通过 `server` extra 启用,不会进入目标应用的基础安装。 默认安装只包含 updater 运行依赖;管理 Web 的 FastAPI/Uvicorn 通过 `server` extra 启用,不会进入目标应用的基础安装。
Docker Compose 部署分发服务(默认仅本机访问,数据保存在 Docker 命名卷):
```bash
docker compose up -d --build
```
局域网访问、安全限制及数据迁移说明见 [`distribution-server/README.md`](./distribution-server/README.md)。
## 启动 Web 与测试 ## 启动 Web 与测试
需要 Python 3.12 和 uv。从仓库根目录使用 `python -m` 启动,不需要先设置环境变量;uv 会根据根 `pyproject.toml` 自动准备服务器依赖: 需要 Python 3.12 和 uv。从仓库根目录使用 `python -m` 启动,不需要先设置环境变量;uv 会根据根 `pyproject.toml` 自动准备服务器依赖:
......
services:
distribution-server:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "18000:8000"
volumes:
- distribution-data:/app/distribution-server/data
volumes:
distribution-data:
...@@ -15,7 +15,7 @@ distribution-server/ ...@@ -15,7 +15,7 @@ distribution-server/
└─ settings.py └─ settings.py
``` ```
管理 API 以 `/api` 开头;上传时服务器流式计算文件大小和 SHA-256,并记录文件名、格式、平台、架构、Markdown 版本说明、上传时间和发布状态。支持 EXE、MSI、ZIP、7Z、DMG、PKG、DEB、RPM、AppImage 和 TAR.GZ。Markdown 说明支持标题、列表、粗体、链接和代码。 管理 API 以 `/api` 开头;创建应用时可填写应用说明。上传版本必须填写 Markdown 更新内容;服务器流式计算文件大小和 SHA-256,并记录文件名、格式、平台、架构、更新内容、上传时间和发布状态。支持 EXE、MSI、ZIP、7Z、DMG、PKG、DEB、RPM、AppImage 和 TAR.GZ。Markdown 说明支持标题、列表、粗体、链接和代码。
版本说明也可以在管理页的程序包记录中单独编辑,无需重新上传程序包;保存后会同时显示在管理记录和公开下载页。 版本说明也可以在管理页的程序包记录中单独编辑,无需重新上传程序包;保存后会同时显示在管理记录和公开下载页。
...@@ -23,6 +23,41 @@ distribution-server/ ...@@ -23,6 +23,41 @@ distribution-server/
管理后台在 `/`;公开程序列表在 `/downloads`,单个程序及其已发布版本在 `/download/{app_id}`。只有已发布的程序包会出现在公开下载页并允许下载。每个程序的更新清单地址为 `/updates/{app_id}/latest.json`,格式继续兼容 updater 当前的 `app_id`、`version`、`url`、`size` 和 `sha256` 协议。标记为 Helper 更新包的 Windows EXE 会更新此清单;其他格式用于公开下载,不会交给当前仅支持 Windows EXE 替换流程的 updater helper。 管理后台在 `/`;公开程序列表在 `/downloads`,单个程序及其已发布版本在 `/download/{app_id}`。只有已发布的程序包会出现在公开下载页并允许下载。每个程序的更新清单地址为 `/updates/{app_id}/latest.json`,格式继续兼容 updater 当前的 `app_id`、`version`、`url`、`size` 和 `sha256` 协议。标记为 Helper 更新包的 Windows EXE 会更新此清单;其他格式用于公开下载,不会交给当前仅支持 Windows EXE 替换流程的 updater helper。
管理页、下载页和静态资源要求浏览器在使用缓存前重新验证;更新前端后如仍看到旧页面,可重新加载页面。
## Docker Compose 部署
安装 Docker Engine 和 Docker Compose 后,在仓库根目录运行:
```bash
docker compose up -d --build
docker compose ps
docker compose logs -f distribution-server
```
默认仅在部署主机的 `127.0.0.1:8000` 监听,访问 `http://127.0.0.1:8000/`。
容器以非 root 用户运行,并从根目录的 `uv.lock` 安装服务器依赖。SQLite 数据库和
程序包保存在 Docker 命名卷 `distribution-data` 中,不会随容器重新创建而丢失;
该卷与本地直接运行时的 `distribution-server/data/` 相互独立。升级时重新运行
`docker compose up -d --build`,并定期备份整个卷。`docker compose down` 保留数据,
`docker compose down -v` 会删除数据卷,请谨慎操作。
可通过环境变量调整宿主机绑定地址和端口。例如,限制好防火墙后允许局域网访问:
```bash
DISTRIBUTION_BIND_ADDRESS=192.168.1.20 DISTRIBUTION_PORT=8000 docker compose up -d --build
```
将地址替换为部署主机的局域网 IP。管理接口没有登录验证,Docker 端口映射或反向代理
可能使服务看到的是网关/代理地址,而不是访问者地址,因此不能依赖服务内的
`ALLOWED_CIDRS` 在容器环境中可靠地限制外部访问。开放非回环地址前,必须在宿主机、
网络或可信反向代理上限制管理接口的访问;不要将整个端口直接暴露给不可信网络。
反向代理只开放公开下载路径时,也应正确转发 Host 并按实际情况限制管理路径。
如需迁移现有数据,先停止所有使用该数据库的服务,再将原数据目录完整备份并复制到
Docker 数据卷,确保容器内 UID/GID `10001` 可读写。不要让本地服务和容器同时使用
同一份数据。容器内运行参数沿用 `settings.py`,修改后需重新构建镜像。
## 直接启动 ## 直接启动
分发服务器可运行在 Linux、Windows 或 macOS。它只接收、保存并分发 Windows exe,不会在服务器上运行这些 exe。开发或试用时不必手动创建虚拟环境、设置环境变量或执行 `pip install`;在仓库根目录运行: 分发服务器可运行在 Linux、Windows 或 macOS。它只接收、保存并分发 Windows exe,不会在服务器上运行这些 exe。开发或试用时不必手动创建虚拟环境、设置环境变量或执行 `pip install`;在仓库根目录运行:
...@@ -69,7 +104,7 @@ http://192.168.1.20:8000/updates/{app_id}/latest.json ...@@ -69,7 +104,7 @@ http://192.168.1.20:8000/updates/{app_id}/latest.json
清单包含 `app_id`、`version`、`url`、`size` 和 `sha256`,与 updater 当前协议一致。只有上传并发布为 Helper 更新包的 Windows EXE 会成为该清单的目标;发布其他格式不会覆盖最近一次可用的 Helper 版本。 清单包含 `app_id`、`version`、`url`、`size` 和 `sha256`,与 updater 当前协议一致。只有上传并发布为 Helper 更新包的 Windows EXE 会成为该清单的目标;发布其他格式不会覆盖最近一次可用的 Helper 版本。
SQLite 数据库和程序文件默认存储在 `distribution-server/data/`,备份时应备份整个目录。上传大小默认上限为 2 GiB。已有数据库会在服务启动时自动补充程序包元数据字段。端口、存储目录、上传大小和允许网段集中配置在 `distribution-server/settings.py`,修改后重启服务即可生效。 SQLite 数据库和程序文件默认存储在 `distribution-server/data/`,备份时应备份整个目录。上传大小默认上限为 2 GiB。已有数据库会在服务启动时自动补充应用说明和程序包元数据字段。端口、存储目录、上传大小和允许网段集中配置在 `distribution-server/settings.py`,修改后重启服务即可生效。
## 网络访问 ## 网络访问
......
...@@ -12,6 +12,7 @@ release_router = APIRouter() ...@@ -12,6 +12,7 @@ release_router = APIRouter()
class ApplicationRequest(BaseModel): class ApplicationRequest(BaseModel):
app_id: str = Field(min_length=1, max_length=128) app_id: str = Field(min_length=1, max_length=128)
display_name: str = Field(min_length=1, max_length=120) display_name: str = Field(min_length=1, max_length=120)
description: str = Field(default="", max_length=2_000)
class ReleaseDescriptionRequest(BaseModel): class ReleaseDescriptionRequest(BaseModel):
...@@ -42,7 +43,11 @@ def create_application( ...@@ -42,7 +43,11 @@ def create_application(
payload: ApplicationRequest, payload: ApplicationRequest,
request: Request, request: Request,
) -> dict[str, object]: ) -> dict[str, object]:
return _service(request).create_application(payload.app_id, payload.display_name) return _service(request).create_application(
payload.app_id,
payload.display_name,
payload.description,
)
@router.delete("/apps/{app_id}", status_code=204) @router.delete("/apps/{app_id}", status_code=204)
...@@ -79,7 +84,7 @@ async def upload_release( ...@@ -79,7 +84,7 @@ async def upload_release(
platform: str = Form(default="windows"), platform: str = Form(default="windows"),
architecture: str = Form(default="x64"), architecture: str = Form(default="x64"),
helper_compatible: bool | None = Form(default=None), helper_compatible: bool | None = Form(default=None),
description: str = Form(default=""), description: str = Form(...),
) -> dict[str, object]: ) -> dict[str, object]:
try: try:
return await run_in_threadpool( return await run_in_threadpool(
......
...@@ -95,7 +95,13 @@ def create_app(settings: DistributionSettings | None = None) -> FastAPI: ...@@ -95,7 +95,13 @@ def create_app(settings: DistributionSettings | None = None) -> FastAPI:
content={"detail": "Package upload exceeds the configured size limit."}, content={"detail": "Package upload exceeds the configured size limit."},
) )
return await call_next(request) response = await call_next(request)
if request.method in {"GET", "HEAD"} and (
request.url.path in {"/", "/downloads"}
or request.url.path.startswith(("/download/", "/assets/"))
):
response.headers["Cache-Control"] = "no-cache"
return response
@application.get("/", include_in_schema=False) @application.get("/", include_in_schema=False)
def frontend() -> FileResponse: def frontend() -> FileResponse:
......
...@@ -14,6 +14,7 @@ from ..settings import DistributionSettings ...@@ -14,6 +14,7 @@ from ..settings import DistributionSettings
_CHUNK_SIZE = 1024 * 1024 _CHUNK_SIZE = 1024 * 1024
_MAX_DESCRIPTION_LENGTH = 20_000 _MAX_DESCRIPTION_LENGTH = 20_000
_MAX_APPLICATION_DESCRIPTION_LENGTH = 2_000
_SUPPORTED_SUFFIXES = ( _SUPPORTED_SUFFIXES = (
".appimage", ".appimage",
".deb", ".deb",
...@@ -50,9 +51,25 @@ class DistributionService: ...@@ -50,9 +51,25 @@ class DistributionService:
def list_applications(self) -> list[dict[str, object]]: def list_applications(self) -> list[dict[str, object]]:
return self.store.list_applications() return self.store.list_applications()
def create_application(self, app_id: str, display_name: str) -> dict[str, object]: def create_application(
self,
app_id: str,
display_name: str,
description: str = "",
) -> dict[str, object]:
normalized_description = description.strip()
if len(normalized_description) > _MAX_APPLICATION_DESCRIPTION_LENGTH:
raise DistributionError(
422,
"Application description must be at most "
f"{_MAX_APPLICATION_DESCRIPTION_LENGTH} characters.",
)
try: try:
return self.store.create_application(app_id, display_name) return self.store.create_application(
app_id,
display_name,
normalized_description,
)
except ProtocolError as exc: except ProtocolError as exc:
raise DistributionError(422, str(exc)) from exc raise DistributionError(422, str(exc)) from exc
except ValueError as exc: except ValueError as exc:
...@@ -136,6 +153,8 @@ class DistributionService: ...@@ -136,6 +153,8 @@ class DistributionService:
self._validate_app_id(app_id) self._validate_app_id(app_id)
self._validate_version(version) self._validate_version(version)
normalized_description = description.strip() normalized_description = description.strip()
if not normalized_description:
raise DistributionError(422, "Release description is required.")
if len(normalized_description) > _MAX_DESCRIPTION_LENGTH: if len(normalized_description) > _MAX_DESCRIPTION_LENGTH:
raise DistributionError( raise DistributionError(
422, 422,
...@@ -187,6 +206,8 @@ class DistributionService: ...@@ -187,6 +206,8 @@ class DistributionService:
f"description must be at most {_MAX_DESCRIPTION_LENGTH} characters.", f"description must be at most {_MAX_DESCRIPTION_LENGTH} characters.",
) )
description = description.strip() description = description.strip()
if not description:
raise DistributionError(422, "Release description is required.")
if helper_compatible is None: if helper_compatible is None:
helper_compatible = suffix == ".exe" and platform == "windows" helper_compatible = suffix == ".exe" and platform == "windows"
if helper_compatible and (suffix != ".exe" or platform != "windows"): if helper_compatible and (suffix != ".exe" or platform != "windows"):
...@@ -269,6 +290,8 @@ class DistributionService: ...@@ -269,6 +290,8 @@ class DistributionService:
release = self.store.get_release(app_id, version) release = self.store.get_release(app_id, version)
if release is None: if release is None:
raise DistributionError(404, "Release not found.") raise DistributionError(404, "Release not found.")
if not str(release["description"]).strip():
raise DistributionError(422, "Release description is required before publishing.")
package_path = self.store.package_path(app_id, version) package_path = self.store.package_path(app_id, version)
if not package_path.is_file(): if not package_path.is_file():
raise DistributionError(409, "Release package is missing.") raise DistributionError(409, "Release package is missing.")
...@@ -342,6 +365,7 @@ class DistributionService: ...@@ -342,6 +365,7 @@ class DistributionService:
return { return {
"app_id": app_id, "app_id": app_id,
"display_name": application["display_name"], "display_name": application["display_name"],
"description": application["description"],
"current_version": application["current_version"], "current_version": application["current_version"],
"packages": packages, "packages": packages,
} }
......
...@@ -39,6 +39,7 @@ class DistributionStore: ...@@ -39,6 +39,7 @@ class DistributionStore:
CREATE TABLE IF NOT EXISTS applications ( CREATE TABLE IF NOT EXISTS applications (
app_id TEXT PRIMARY KEY, app_id TEXT PRIMARY KEY,
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
current_version TEXT, current_version TEXT,
helper_version TEXT, helper_version TEXT,
created_at TEXT NOT NULL created_at TEXT NOT NULL
...@@ -66,6 +67,10 @@ class DistributionStore: ...@@ -66,6 +67,10 @@ class DistributionStore:
application_columns = { application_columns = {
row["name"] for row in connection.execute("PRAGMA table_info(applications)") row["name"] for row in connection.execute("PRAGMA table_info(applications)")
} }
if "description" not in application_columns:
connection.execute(
"ALTER TABLE applications ADD COLUMN description TEXT NOT NULL DEFAULT ''"
)
if "helper_version" not in application_columns: if "helper_version" not in application_columns:
connection.execute("ALTER TABLE applications ADD COLUMN helper_version TEXT") connection.execute("ALTER TABLE applications ADD COLUMN helper_version TEXT")
...@@ -116,7 +121,12 @@ class DistributionStore: ...@@ -116,7 +121,12 @@ class DistributionStore:
""" """
) )
def create_application(self, app_id: str, display_name: str) -> dict[str, object]: def create_application(
self,
app_id: str,
display_name: str,
description: str = "",
) -> dict[str, object]:
validate_app_id(app_id) validate_app_id(app_id)
normalized_name = display_name.strip() normalized_name = display_name.strip()
if not normalized_name or len(normalized_name) > 120: if not normalized_name or len(normalized_name) > 120:
...@@ -125,10 +135,10 @@ class DistributionStore: ...@@ -125,10 +135,10 @@ class DistributionStore:
with self._connection() as connection: with self._connection() as connection:
connection.execute( connection.execute(
""" """
INSERT INTO applications (app_id, display_name, created_at) INSERT INTO applications (app_id, display_name, description, created_at)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?)
""", """,
(app_id, normalized_name, _now()), (app_id, normalized_name, description, _now()),
) )
application = self.get_application(app_id) application = self.get_application(app_id)
if application is None: if application is None:
...@@ -147,8 +157,9 @@ class DistributionStore: ...@@ -147,8 +157,9 @@ class DistributionStore:
with self._connection() as connection: with self._connection() as connection:
rows = connection.execute( rows = connection.execute(
""" """
SELECT a.app_id, a.display_name, a.current_version, a.helper_version, SELECT a.app_id, a.display_name, a.description, a.current_version,
a.created_at, r.size AS current_size, r.sha256 AS current_sha256, a.helper_version, a.created_at,
r.size AS current_size, r.sha256 AS current_sha256,
r.filename AS current_filename, r.platform AS current_platform, r.filename AS current_filename, r.platform AS current_platform,
r.architecture AS current_architecture r.architecture AS current_architecture
FROM applications AS a FROM applications AS a
...@@ -163,7 +174,8 @@ class DistributionStore: ...@@ -163,7 +174,8 @@ class DistributionStore:
with self._connection() as connection: with self._connection() as connection:
row = connection.execute( row = connection.execute(
""" """
SELECT app_id, display_name, current_version, helper_version, created_at SELECT app_id, display_name, description, current_version,
helper_version, created_at
FROM applications FROM applications
WHERE app_id = ? WHERE app_id = ?
""", """,
...@@ -291,7 +303,7 @@ class DistributionStore: ...@@ -291,7 +303,7 @@ class DistributionStore:
with self._connection() as connection: with self._connection() as connection:
rows = connection.execute( rows = connection.execute(
""" """
SELECT a.app_id, a.display_name, a.current_version, SELECT a.app_id, a.display_name, a.description, a.current_version,
r.filename, r.description, r.platform, r.architecture, r.filename, r.description, r.platform, r.architecture,
r.size, r.sha256, r.size, r.sha256,
r.published_at r.published_at
......
...@@ -82,6 +82,8 @@ h2 { margin-top: 4px; font: 700 17px Manrope, "PingFang SC", sans-serif; } ...@@ -82,6 +82,8 @@ h2 { margin-top: 4px; font: 700 17px Manrope, "PingFang SC", sans-serif; }
.detail-row { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eff1f5; color: var(--muted); font-size: 11px; } .detail-row { display: flex; justify-content: space-between; gap: 12px; padding: 11px 0; border-bottom: 1px solid #eff1f5; color: var(--muted); font-size: 11px; }
.detail-row:last-child { border-bottom: 0; } .detail-row:last-child { border-bottom: 0; }
.detail-row strong, .detail-row code { color: var(--ink); font-size: 11px; font-weight: 600; } .detail-row strong, .detail-row code { color: var(--ink); font-size: 11px; font-weight: 600; }
.application-description-row { display: grid; gap: 5px; padding: 11px 0; border-bottom: 1px solid #eff1f5; color: var(--muted); font-size: 13px; }
.application-description-row p { color: var(--ink); line-height: 1.6; overflow-wrap: anywhere; }
.upload-panel, .releases-panel { padding: 20px 22px; margin-bottom: 17px; } .upload-panel, .releases-panel { padding: 20px 22px; margin-bottom: 17px; }
.section-heading { margin-bottom: 17px; } .section-heading { margin-bottom: 17px; }
...@@ -213,8 +215,9 @@ h2 { font-size: 19px; } ...@@ -213,8 +215,9 @@ h2 { font-size: 19px; }
.publish-button { font-size: 12px; } .publish-button { font-size: 12px; }
.no-releases { font-size: 14px; } .no-releases { font-size: 14px; }
.upload-form .markdown-field { grid-column: 1 / -1; } .upload-form .markdown-field { grid-column: 1 / -1; }
.release-description { grid-column: 1 / -1; min-width: 0; padding: 12px 14px; border-radius: 8px; color: var(--muted); background: #f7f8fc; font-size: 13px; } .release-description { grid-column: 1 / -1; min-width: 0; color: var(--muted); font-size: 13px; }
.release-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; } .release-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; }
.release-description-content { padding: 12px 14px; border-radius: 8px; background: #f7f8fc; }
.description-dialog { width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; } .description-dialog { width: min(640px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow: auto; }
.description-preview { max-height: 220px; overflow: auto; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f7f8fc; } .description-preview { max-height: 220px; overflow: auto; padding: 12px 14px; border: 1px solid var(--line); border-radius: 8px; background: #f7f8fc; }
.markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; } .markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
......
...@@ -96,6 +96,7 @@ async function renderApplication() { ...@@ -96,6 +96,7 @@ async function renderApplication() {
document.querySelector("#page-title").textContent = application.display_name; document.querySelector("#page-title").textContent = application.display_name;
document.querySelector("#app-name").textContent = application.display_name; document.querySelector("#app-name").textContent = application.display_name;
document.querySelector("#app-id").textContent = application.app_id; document.querySelector("#app-id").textContent = application.app_id;
document.querySelector("#app-description").textContent = application.description || "—";
document.querySelector("#current-version").textContent = application.current_version || "未发布"; document.querySelector("#current-version").textContent = application.current_version || "未发布";
document.querySelector("#current-status").textContent = application.current_version ? "正在分发" : "未发布"; document.querySelector("#current-status").textContent = application.current_version ? "正在分发" : "未发布";
document.querySelector("#current-status").className = `badge ${application.current_version ? "badge-live" : "badge-neutral"}`; document.querySelector("#current-status").className = `badge ${application.current_version ? "badge-live" : "badge-neutral"}`;
...@@ -146,9 +147,11 @@ function renderReleases(releases, currentVersion, helperVersion) { ...@@ -146,9 +147,11 @@ function renderReleases(releases, currentVersion, helperVersion) {
let notes; let notes;
if (release.description) { if (release.description) {
notes = element("section", "release-description"); notes = element("section", "release-description");
const content = element("div", "release-description-content");
content.append(renderMarkdown(release.description));
notes.append( notes.append(
element("strong", "release-description-title", "版本更新内容"), element("strong", "release-description-title", "版本更新内容"),
renderMarkdown(release.description), content,
); );
} }
const size = element("div", "release-size", `${(release.size / 1024 / 1024).toFixed(1)} MiB`); const size = element("div", "release-size", `${(release.size / 1024 / 1024).toFixed(1)} MiB`);
...@@ -337,6 +340,7 @@ document.querySelector("#create-form").addEventListener("submit", async event => ...@@ -337,6 +340,7 @@ document.querySelector("#create-form").addEventListener("submit", async event =>
body: JSON.stringify({ body: JSON.stringify({
app_id: data.get("app_id"), app_id: data.get("app_id"),
display_name: data.get("display_name"), display_name: data.get("display_name"),
description: data.get("description"),
}), }),
}); });
form.reset(); form.reset();
......
...@@ -29,8 +29,10 @@ h1 { margin: 8px 0 7px; font-size: clamp(24px, 4vw, 32px); letter-spacing: -.03e ...@@ -29,8 +29,10 @@ h1 { margin: 8px 0 7px; font-size: clamp(24px, 4vw, 32px); letter-spacing: -.03e
.package-list { display: grid; gap: 12px; } .package-list { display: grid; gap: 12px; }
.package-card { min-width: 0; padding: 20px 22px; border: 1px solid var(--line); border-radius: 13px; background: white; box-shadow: 0 2px 8px #25314b05; } .package-card { min-width: 0; padding: 20px 22px; border: 1px solid var(--line); border-radius: 13px; background: white; box-shadow: 0 2px 8px #25314b05; }
.package-heading { display: flex; justify-content: space-between; gap: 16px; align-items: start; } .package-heading { display: flex; justify-content: space-between; gap: 16px; align-items: start; }
.package-version { font-size: 18px; font-weight: 750; letter-spacing: -.02em; } .package-identity { display: flex; min-width: 0; flex-wrap: wrap; gap: 9px; align-items: center; }
.package-name { margin-top: 3px; color: var(--muted); font-size: 11px; } .package-version { flex: 0 0 auto; padding: 4px 8px; border-radius: 999px; color: #536077; background: #f0f2f6; font-size: 11px; font-weight: 700; }
.package-name { min-width: 0; color: var(--ink); font-size: 18px; font-weight: 700; letter-spacing: -.02em; overflow-wrap: anywhere; }
.package-app-description { margin: 10px 0 0; color: var(--muted); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.package-badge { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #56647a; background: #f0f2f6; font-size: 10px; font-weight: 700; } .package-badge { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #56647a; background: #f0f2f6; font-size: 10px; font-weight: 700; }
.package-badge-helper { color: #6652b8; background: #f0edff; } .package-badge-helper { color: #6652b8; background: #f0edff; }
.package-metadata { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 18px 0; padding: 13px 0; border-top: 1px solid #eff1f5; border-bottom: 1px solid #eff1f5; } .package-metadata { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 18px 0; padding: 13px 0; border-top: 1px solid #eff1f5; border-bottom: 1px solid #eff1f5; }
...@@ -70,8 +72,8 @@ body { font-size: 15px; } ...@@ -70,8 +72,8 @@ body { font-size: 15px; }
.brand small, .live-status { font-size: 12px; } .brand small, .live-status { font-size: 12px; }
.eyebrow { font-size: 11px; } .eyebrow { font-size: 11px; }
.hero p { font-size: 15px; } .hero p { font-size: 15px; }
.package-version { font-size: 20px; } .package-version { font-size: 12px; }
.package-name { font-size: 12px; } .package-name { font-size: 20px; }
.package-badge { font-size: 12px; } .package-badge { font-size: 12px; }
.metadata-item span { font-size: 12px; } .metadata-item span { font-size: 12px; }
.metadata-item strong { font-size: 13px; } .metadata-item strong { font-size: 13px; }
...@@ -81,8 +83,10 @@ body { font-size: 15px; } ...@@ -81,8 +83,10 @@ body { font-size: 15px; }
.published-date, .history-link { font-size: 12px; } .published-date, .history-link { font-size: 12px; }
.empty-card, .error-card { font-size: 14px; } .empty-card, .error-card { font-size: 14px; }
footer { font-size: 12px; } footer { font-size: 12px; }
.package-description { margin: 14px 0 0; padding: 13px 15px; border-radius: 8px; color: #68758a; background: #f7f8fc; font-size: 13px; } .package-description { margin: 14px 0 0; color: #68758a; font-size: 13px; }
.package-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; } .package-description-title { display: block; margin-bottom: 6px; color: #536077; font-size: 13px; }
.package-description-content { padding: 13px 15px; border-radius: 8px; background: #f7f8fc; }
.application-description { margin-top: 8px !important; color: var(--muted); font-size: 15px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; } .markdown-body { color: #465268; font-size: 14px; line-height: 1.7; overflow-wrap: anywhere; }
.markdown-body > :first-child { margin-top: 0; } .markdown-body > :first-child { margin-top: 0; }
.markdown-body > :last-child { margin-bottom: 0; } .markdown-body > :last-child { margin-bottom: 0; }
......
...@@ -36,13 +36,17 @@ function formatSize(bytes) { ...@@ -36,13 +36,17 @@ function formatSize(bytes) {
return `${value.toFixed(1)} ${units[unit]}`; return `${value.toFixed(1)} ${units[unit]}`;
} }
function packageCard(packageInfo, appName, { compact = false } = {}) { function packageCard(
packageInfo,
appName,
{ compact = false, applicationDescription = "" } = {},
) {
const card = node("article", `package-card${compact ? " package-card-compact" : ""}`); const card = node("article", `package-card${compact ? " package-card-compact" : ""}`);
const heading = node("div", "package-heading"); const heading = node("div", "package-heading");
const identity = node("div", ""); const identity = node("div", "package-identity");
identity.append( identity.append(
node("div", "package-version", packageInfo.version),
node("div", "package-name", appName), node("div", "package-name", appName),
node("span", "package-version", packageInfo.version),
); );
const badge = node( const badge = node(
"span", "span",
...@@ -50,15 +54,19 @@ function packageCard(packageInfo, appName, { compact = false } = {}) { ...@@ -50,15 +54,19 @@ function packageCard(packageInfo, appName, { compact = false } = {}) {
packageInfo.helper_compatible ? "Helper 更新包" : packageInfo.package_type.toUpperCase(), packageInfo.helper_compatible ? "Helper 更新包" : packageInfo.package_type.toUpperCase(),
); );
heading.append(identity, badge); heading.append(identity, badge);
card.append(heading);
if (applicationDescription) {
card.append(node("p", "package-app-description", applicationDescription));
}
if (packageInfo.description) { if (packageInfo.description) {
const notes = node("section", "package-description"); const notes = node("section", "package-description");
const content = node("div", "package-description-content");
content.append(renderMarkdown(packageInfo.description));
notes.append( notes.append(
node("strong", "package-description-title", "版本更新内容"), node("strong", "package-description-title", "版本更新内容"),
renderMarkdown(packageInfo.description), content,
); );
card.append(heading, notes); card.append(notes);
} else {
card.append(heading);
} }
const metadata = node("div", "package-metadata"); const metadata = node("div", "package-metadata");
...@@ -105,7 +113,10 @@ function renderLibrary(applications) { ...@@ -105,7 +113,10 @@ function renderLibrary(applications) {
const card = packageCard({ const card = packageCard({
...application, ...application,
version: application.current_version, version: application.current_version,
}, application.display_name, { compact: true }); }, application.display_name, {
compact: true,
applicationDescription: application.description,
});
const detail = node("a", "history-link", "查看已发布版本"); const detail = node("a", "history-link", "查看已发布版本");
detail.href = `/download/${encodeURIComponent(application.app_id)}`; detail.href = `/download/${encodeURIComponent(application.app_id)}`;
card.querySelector(".package-actions").append(detail); card.querySelector(".package-actions").append(detail);
...@@ -117,6 +128,9 @@ function renderApplication(application) { ...@@ -117,6 +128,9 @@ function renderApplication(application) {
document.querySelector("#page-title").textContent = application.display_name; document.querySelector("#page-title").textContent = application.display_name;
document.querySelector("#page-description").textContent = document.querySelector("#page-description").textContent =
`${application.packages.length} 个已发布程序包 · App ID:${application.app_id}`; `${application.packages.length} 个已发布程序包 · App ID:${application.app_id}`;
const applicationDescription = document.querySelector("#application-description");
applicationDescription.textContent = application.description || "";
applicationDescription.hidden = !application.description;
packageList.replaceChildren(); packageList.replaceChildren();
for (const packageInfo of application.packages) { for (const packageInfo of application.packages) {
packageList.append(packageCard(packageInfo, application.display_name)); packageList.append(packageCard(packageInfo, application.display_name));
......
...@@ -5,8 +5,8 @@ ...@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light"> <meta name="color-scheme" content="light">
<title>程序下载中心</title> <title>程序下载中心</title>
<link rel="stylesheet" href="/assets/downloads.css"> <link rel="stylesheet" href="/assets/downloads.css?v=20261028-1">
<script type="module" src="/assets/downloads.js"></script> <script type="module" src="/assets/downloads.js?v=20261028-1"></script>
</head> </head>
<body> <body>
<main class="page"> <main class="page">
...@@ -21,6 +21,7 @@ ...@@ -21,6 +21,7 @@
<div class="eyebrow">DOWNLOAD LIBRARY</div> <div class="eyebrow">DOWNLOAD LIBRARY</div>
<h1 id="page-title">获取程序与更新包</h1> <h1 id="page-title">获取程序与更新包</h1>
<p id="page-description">浏览已发布的程序版本,下载前请核对平台与架构。</p> <p id="page-description">浏览已发布的程序版本,下载前请核对平台与架构。</p>
<p id="application-description" class="application-description" hidden></p>
</section> </section>
<div id="error" class="error-card" role="status" hidden></div> <div id="error" class="error-card" role="status" hidden></div>
<section id="package-list" class="package-list" aria-live="polite"></section> <section id="package-list" class="package-list" aria-live="polite"></section>
......
...@@ -4,8 +4,8 @@ ...@@ -4,8 +4,8 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>程序分发管理</title> <title>程序分发管理</title>
<link rel="stylesheet" href="/assets/app.css"> <link rel="stylesheet" href="/assets/app.css?v=20261028-1">
<script type="module" src="/assets/app.js"></script> <script type="module" src="/assets/app.js?v=20261028-1"></script>
</head> </head>
<body> <body>
<div class="shell"> <div class="shell">
...@@ -70,6 +70,10 @@ ...@@ -70,6 +70,10 @@
<div class="detail-row"><span>应用名称</span><strong id="app-name">—</strong></div> <div class="detail-row"><span>应用名称</span><strong id="app-name">—</strong></div>
<div class="detail-row"><span>App ID</span><code id="app-id">—</code></div> <div class="detail-row"><span>App ID</span><code id="app-id">—</code></div>
<div class="detail-row"><span>版本总数</span><strong id="release-count">0</strong></div> <div class="detail-row"><span>版本总数</span><strong id="release-count">0</strong></div>
<div class="application-description-row">
<span>应用说明</span>
<p id="app-description">—</p>
</div>
</article> </article>
</section> </section>
...@@ -120,9 +124,9 @@ ...@@ -120,9 +124,9 @@
</label> </label>
<button id="upload-button" class="button button-primary" type="submit">上传版本</button> <button id="upload-button" class="button button-primary" type="submit">上传版本</button>
<label class="field markdown-field"> <label class="field markdown-field">
<span>版本说明(Markdown,可选)</span> <span>版本更新内容(必填)</span>
<textarea name="description" rows="4" maxlength="20000" placeholder="# 更新内容&#10;- 新增功能&#10;- 修复问题"></textarea> <textarea name="description" rows="4" maxlength="20000" placeholder="- 新增功能&#10;- 修复问题" required></textarea>
<small>支持标题、列表、粗体、链接和代码;会显示在公开下载页。</small> <small>支持 Markdown 标题、列表、粗体、链接和代码;会显示在公开下载页。</small>
</label> </label>
</form> </form>
<div id="upload-progress" class="progress-wrap" hidden> <div id="upload-progress" class="progress-wrap" hidden>
...@@ -160,6 +164,11 @@ ...@@ -160,6 +164,11 @@
<span>显示名称</span> <span>显示名称</span>
<input name="display_name" placeholder="我的程序" maxlength="120" required> <input name="display_name" placeholder="我的程序" maxlength="120" required>
</label> </label>
<label class="field">
<span>应用说明</span>
<textarea name="description" rows="3" maxlength="2000" placeholder="介绍程序用途、主要功能或适用人群"></textarea>
<small>可选,创建后会显示在公开下载页。</small>
</label>
<div class="dialog-actions"> <div class="dialog-actions">
<button class="button button-muted" type="button" data-close-dialog>取消</button> <button class="button button-muted" type="button" data-close-dialog>取消</button>
<button id="create-button" class="button button-primary" type="submit" value="default">创建应用</button> <button id="create-button" class="button button-primary" type="submit" value="default">创建应用</button>
...@@ -175,8 +184,8 @@ ...@@ -175,8 +184,8 @@
</div> </div>
<p id="description-version" class="muted"></p> <p id="description-version" class="muted"></p>
<label class="field"> <label class="field">
<span>Markdown 说明</span> <span>版本更新内容(必填)</span>
<textarea name="description" rows="8" maxlength="20000" placeholder="# 更新内容&#10;- 新增功能&#10;- 修复问题"></textarea> <textarea name="description" rows="8" maxlength="20000" placeholder="- 新增功能&#10;- 修复问题" required></textarea>
<small>保存后会立即显示在程序包记录和公开下载页。</small> <small>保存后会立即显示在程序包记录和公开下载页。</small>
</label> </label>
<div id="description-preview" class="markdown-body description-preview" hidden></div> <div id="description-preview" class="markdown-body description-preview" hidden></div>
......
...@@ -33,17 +33,25 @@ class DistributionTests(unittest.TestCase): ...@@ -33,17 +33,25 @@ class DistributionTests(unittest.TestCase):
self.client.__exit__(None, None, None) self.client.__exit__(None, None, None)
self.temp_dir.cleanup() self.temp_dir.cleanup()
def create_application(self, app_id: str = "my-program") -> None: def create_application(
self,
app_id: str = "my-program",
description: str = "",
) -> None:
response = self.client.post( response = self.client.post(
"/api/apps", "/api/apps",
json={"app_id": app_id, "display_name": app_id}, json={
"app_id": app_id,
"display_name": app_id,
"description": description,
},
) )
self.assertEqual(response.status_code, 201, response.text) self.assertEqual(response.status_code, 201, response.text)
def upload(self, app_id: str, version: str, content: bytes) -> dict[str, object]: def upload(self, app_id: str, version: str, content: bytes) -> dict[str, object]:
response = self.client.post( response = self.client.post(
f"/api/apps/{app_id}/releases", f"/api/apps/{app_id}/releases",
data={"version": version}, data={"version": version, "description": "Release notes."},
files={"file": (f"{app_id}.exe", content, "application/octet-stream")}, files={"file": (f"{app_id}.exe", content, "application/octet-stream")},
) )
self.assertEqual(response.status_code, 201, response.text) self.assertEqual(response.status_code, 201, response.text)
...@@ -104,11 +112,37 @@ class DistributionTests(unittest.TestCase): ...@@ -104,11 +112,37 @@ class DistributionTests(unittest.TestCase):
self.create_application() self.create_application()
response = self.client.post( response = self.client.post(
"/api/apps/my-program/releases", "/api/apps/my-program/releases",
data={"version": "1.0.0"}, data={"version": "1.0.0", "description": "Package description."},
files={"file": ("program.exe", b"not an executable", "application/octet-stream")}, files={"file": ("program.exe", b"not an executable", "application/octet-stream")},
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
def test_release_description_is_required(self) -> None:
self.create_application()
for description in (None, " "):
data = {"version": "1.0.0"}
if description is not None:
data["description"] = description
response = self.client.post(
"/api/apps/my-program/releases",
data=data,
files={"file": ("program.exe", b"MZ-package", "application/octet-stream")},
)
self.assertEqual(response.status_code, 422, response.text)
self.assertEqual(self.client.get("/api/apps/my-program/releases").json(), [])
def test_application_description_is_returned_to_management_and_public_pages(self) -> None:
description = "一个用于展示更新说明的示例程序。"
self.create_application(description=description)
self.assertEqual(self.client.get("/api/apps").json()[0]["description"], description)
self.upload("my-program", "1.0.0", b"MZ-package")
self.client.post("/api/apps/my-program/releases/1.0.0/publish")
public_application = self.client.get("/api/public/apps/my-program")
public_library = self.client.get("/api/public/apps")
self.assertEqual(public_application.json()["description"], description)
self.assertEqual(public_library.json()[0]["description"], description)
def test_generic_package_is_recorded_and_published_for_public_download(self) -> None: def test_generic_package_is_recorded_and_published_for_public_download(self) -> None:
self.create_application() self.create_application()
package = b"example mac package" package = b"example mac package"
...@@ -216,6 +250,12 @@ class DistributionTests(unittest.TestCase): ...@@ -216,6 +250,12 @@ class DistributionTests(unittest.TestCase):
) )
self.assertEqual(response.status_code, 422) self.assertEqual(response.status_code, 422)
empty_response = self.client.patch(
"/api/apps/my-program/releases/1.0.0/description",
json={"description": " "},
)
self.assertEqual(empty_response.status_code, 422)
def test_non_helper_release_does_not_replace_updater_manifest(self) -> None: def test_non_helper_release_does_not_replace_updater_manifest(self) -> None:
self.create_application() self.create_application()
executable = b"MZ-original" executable = b"MZ-original"
...@@ -229,6 +269,7 @@ class DistributionTests(unittest.TestCase): ...@@ -229,6 +269,7 @@ class DistributionTests(unittest.TestCase):
"platform": "linux", "platform": "linux",
"architecture": "x64", "architecture": "x64",
"helper_compatible": "false", "helper_compatible": "false",
"description": "Linux build.",
}, },
files={"file": ("sample.tar.gz", b"linux package", "application/gzip")}, files={"file": ("sample.tar.gz", b"linux package", "application/gzip")},
) )
...@@ -298,6 +339,7 @@ class DistributionTests(unittest.TestCase): ...@@ -298,6 +339,7 @@ class DistributionTests(unittest.TestCase):
manifest_response = legacy_client.get("/updates/legacy-app/latest.json") manifest_response = legacy_client.get("/updates/legacy-app/latest.json")
download_response = legacy_client.get("/packages/legacy-app/1.0.0/download") download_response = legacy_client.get("/packages/legacy-app/1.0.0/download")
releases_response = legacy_client.get("/api/apps/legacy-app/releases") releases_response = legacy_client.get("/api/apps/legacy-app/releases")
applications_response = legacy_client.get("/api/apps")
self.assertEqual(manifest_response.status_code, 200, manifest_response.text) self.assertEqual(manifest_response.status_code, 200, manifest_response.text)
self.assertEqual(manifest_response.json()["version"], "1.0.0") self.assertEqual(manifest_response.json()["version"], "1.0.0")
...@@ -305,6 +347,7 @@ class DistributionTests(unittest.TestCase): ...@@ -305,6 +347,7 @@ class DistributionTests(unittest.TestCase):
self.assertEqual(download_response.content, package) self.assertEqual(download_response.content, package)
self.assertEqual(releases_response.status_code, 200, releases_response.text) self.assertEqual(releases_response.status_code, 200, releases_response.text)
self.assertEqual(releases_response.json()[0]["description"], "") self.assertEqual(releases_response.json()[0]["description"], "")
self.assertEqual(applications_response.json()[0]["description"], "")
def test_upload_rejects_duplicate_version(self) -> None: def test_upload_rejects_duplicate_version(self) -> None:
self.create_application() self.create_application()
...@@ -312,7 +355,7 @@ class DistributionTests(unittest.TestCase): ...@@ -312,7 +355,7 @@ class DistributionTests(unittest.TestCase):
self.upload("my-program", "1.0.0", executable) self.upload("my-program", "1.0.0", executable)
response = self.client.post( response = self.client.post(
"/api/apps/my-program/releases", "/api/apps/my-program/releases",
data={"version": "1.0.0"}, data={"version": "1.0.0", "description": "Release notes."},
files={"file": ("program.exe", executable, "application/octet-stream")}, files={"file": ("program.exe", executable, "application/octet-stream")},
) )
self.assertEqual(response.status_code, 409) self.assertEqual(response.status_code, 409)
...@@ -352,6 +395,34 @@ class DistributionTests(unittest.TestCase): ...@@ -352,6 +395,34 @@ class DistributionTests(unittest.TestCase):
self.assertIn("body { font-size: 15px; }", stylesheet.text) self.assertIn("body { font-size: 15px; }", stylesheet.text)
self.assertIn("body { font-size: 15px; }", download_stylesheet.text) self.assertIn("body { font-size: 15px; }", download_stylesheet.text)
def test_frontend_pages_and_assets_revalidate_cached_content(self) -> None:
for path in (
"/",
"/downloads",
"/download/my-program",
"/assets/app.js?v=20261028-1",
"/assets/app.css?v=20261028-1",
"/assets/downloads.js?v=20261028-1",
"/assets/downloads.css?v=20261028-1",
):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-cache")
conditional = self.client.get(
path,
headers={"If-None-Match": response.headers["etag"]},
)
self.assertEqual(conditional.status_code, 304 if path.startswith("/assets/") else 200)
self.assertEqual(conditional.headers["cache-control"], "no-cache")
dashboard = self.client.get("/")
downloads = self.client.get("/downloads")
for asset in ("app.js", "app.css"):
self.assertIn(f"/assets/{asset}?v=20261028-1", dashboard.text)
for asset in ("downloads.js", "downloads.css"):
self.assertIn(f"/assets/{asset}?v=20261028-1", downloads.text)
def test_public_download_surfaces_are_remote_but_management_is_not(self) -> None: def test_public_download_surfaces_are_remote_but_management_is_not(self) -> None:
self.create_application() self.create_application()
executable = b"MZ-public" executable = b"MZ-public"
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment