From 1fdb62029e3d29c2a5053aba34f7e3726151a524 Mon Sep 17 00:00:00 2001 From: LiHaohua Date: Tue, 21 Jul 2026 18:16:37 +0800 Subject: [PATCH] feat(portal): let web submitters supply store metadata & 320x170 screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a store-info form to the upload flow so submitters without a source repo can provide title/summary/description/categories, an optional square icon, and up to 6 screenshots — equivalent to czdev's app-builder.json `store` section, filled in right after picking the .deb. - site: in-browser screenshot tool cover-fits any image to exactly 320x170 (the CardputerZero LCD) via canvas; icon is normalized to a square PNG. - worker: /api/submit accepts the metadata + PNGs, validates dimensions (screenshots 320x170, icon square <=512), uploads images to the same buffer Release as the .deb, and forwards a `store` client_payload. - workflow: process-web-submission builds meta.json from the web `store` (highest priority over source_repo/deb fallback) and commits the images as ordinary small PNGs into pool/main// — no Git LFS. Co-authored-by: Cursor --- packages-workflows/process-web-submission.yml | 51 +++++++++ site/app.js | 104 ++++++++++++++++++ site/index.html | 48 +++++++- worker/src/index.js | 86 +++++++++++++++ 4 files changed, 288 insertions(+), 1 deletion(-) diff --git a/packages-workflows/process-web-submission.yml b/packages-workflows/process-web-submission.yml index 738a2d4..683218c 100644 --- a/packages-workflows/process-web-submission.yml +++ b/packages-workflows/process-web-submission.yml @@ -129,6 +129,8 @@ jobs: env: SOURCE_REPO: ${{ github.event.client_payload.source_repo }} PKG: ${{ steps.validate.outputs.pkg }} + SUBMITTER: ${{ github.event.client_payload.login }} + STORE_JSON: ${{ toJSON(github.event.client_payload.store) }} run: | set -uo pipefail ERR="" @@ -148,6 +150,55 @@ jobs: dest = Path(f"pool/main/{pkg}") dest.mkdir(parents=True, exist_ok=True) + import hashlib, urllib.request + + PNG_SIG = b"\x89PNG\r\n\x1a\n" + def png_dims(data): + if len(data) < 24 or data[:8] != PNG_SIG: + return None + return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") + + def download(url, want_sha, out_path): + req = urllib.request.Request(url, headers={"User-Agent": "cz-web-submit"}) + data = urllib.request.urlopen(req, timeout=120).read() + if want_sha and hashlib.sha256(data).hexdigest() != want_sha: + fail(f"checksum mismatch for {url}") + out_path.write_bytes(data) + return data + + # Highest priority: store metadata + images supplied directly from the + # web form. Screenshots/icon are small PNGs uploaded to the buffer + # Release; we download and commit them into pool/main// (no LFS). + try: + web = json.loads(os.environ.get("STORE_JSON") or "null") + except Exception: + web = None + if isinstance(web, dict): + meta = {"title": web.get("title") or pkg, + "summary": web.get("summary", ""), + "categories": web.get("categories", []), + "screenshots": []} + if web.get("description"): + meta["description"] = web["description"] + shots_dir = dest / "screenshots" + shots_dir.mkdir(exist_ok=True) + for i, sh in enumerate(web.get("screenshots") or []): + name = Path(sh.get("name") or f"screenshot-{i}.png").name + data = download(sh["url"], sh.get("sha256"), shots_dir / name) + if png_dims(data) != (320, 170): + fail(f"screenshot {name} must be 320x170, got {png_dims(data)}") + meta["screenshots"].append(f"screenshots/{name}") + icon = web.get("icon") or {} + if icon.get("url"): + iname = Path(icon.get("name") or f"{pkg}_icon.png").name + idata = download(icon["url"], icon.get("sha256"), dest / iname) + if png_dims(idata) is None: + fail("icon must be a PNG") + meta["icon"] = iname + meta["author"] = {"github": os.environ.get("SUBMITTER", "")} + (dest / "meta.json").write_text(json.dumps(meta, indent=2, ensure_ascii=False)) + sys.exit(0) + # Preferred: app-builder.json store section from the source repo. src_root = Path("/tmp/src") if src_root.is_dir(): diff --git a/site/app.js b/site/app.js index de82321..ee070c2 100644 --- a/site/app.js +++ b/site/app.js @@ -166,6 +166,7 @@ async function pick(f) { parsed = null; say("", "本地解析中…"); $("preview-box").classList.add("hidden"); + resetStoreForm(); try { const buf = await f.arrayBuffer(); parsed = await parseDeb(buf, decompressors); @@ -188,6 +189,8 @@ async function renderPreview() { $("p-size").textContent = `${(parsed.totalInstalledSize / 1048576).toFixed(1)} MB`; $("p-maint").textContent = c.Maintainer || "(缺失)"; + $("s-title").placeholder = (parsed.desktop && parsed.desktop.Name) || c.Package || "显示在 AppStore 里的名字"; + if (parsed.icon && parsed.icon.isPng) { const url = URL.createObjectURL(new Blob([parsed.icon.bytes], { type: "image/png" })); $("p-icon").src = url; @@ -257,6 +260,98 @@ async function renderPreview() { $("preview-box").classList.remove("hidden"); } +/* ---------------------------- store metadata form ------------------------ */ + +let storeIcon = null; // Blob | null — optional icon override (square PNG) +const storeShots = []; // [{ blob, url }] — 320×170 PNG screenshots + +// Draw an image into a w×h canvas using "cover" fit (scale to fill, then +// center-crop) and return a PNG blob. This is how the browser turns an +// arbitrary user image into an exact 320×170 screenshot or a square icon. +function coverToPng(img, w, h) { + const canvas = document.createElement("canvas"); + canvas.width = w; canvas.height = h; + const ctx = canvas.getContext("2d"); + const scale = Math.max(w / img.naturalWidth, h / img.naturalHeight); + const dw = img.naturalWidth * scale, dh = img.naturalHeight * scale; + ctx.drawImage(img, (w - dw) / 2, (h - dh) / 2, dw, dh); + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} + +function fileToImage(f) { + return new Promise((resolve, reject) => { + const img = new Image(); + const url = URL.createObjectURL(f); + img.onload = () => { URL.revokeObjectURL(url); resolve(img); }; + img.onerror = () => { URL.revokeObjectURL(url); reject(new Error("图片无法读取")); }; + img.src = url; + }); +} + +function resetStoreForm() { + storeIcon = null; + for (const sh of storeShots) URL.revokeObjectURL(sh.url); + storeShots.length = 0; + for (const id of ["s-title", "s-summary", "s-desc", "s-cats"]) $(id).value = ""; + $("s-icon-preview").classList.add("hidden"); + $("s-icon-preview").removeAttribute("src"); + $("s-icon-clear").classList.add("hidden"); + renderShots(); +} + +function renderShots() { + const box = $("s-shots"); + box.innerHTML = ""; + storeShots.forEach((sh, i) => { + const div = document.createElement("div"); + div.className = "shot"; + div.innerHTML = `截图 ${i + 1}`; + div.querySelector("img").src = sh.url; + box.appendChild(div); + }); + $("s-shot-btn").disabled = storeShots.length >= 6; + $("s-shot-btn").textContent = storeShots.length >= 6 ? "已达 6 张上限" : "+ 添加截图"; +} + +$("s-shots").addEventListener("click", (e) => { + const i = e.target.getAttribute && e.target.getAttribute("data-i"); + if (i === null || i === undefined) return; + const [removed] = storeShots.splice(Number(i), 1); + if (removed) URL.revokeObjectURL(removed.url); + renderShots(); +}); + +$("s-shot-btn").addEventListener("click", () => $("s-shot-file").click()); +$("s-shot-file").addEventListener("change", async (e) => { + const f = e.target.files[0]; + e.target.value = ""; + if (!f || storeShots.length >= 6) return; + try { + const blob = await coverToPng(await fileToImage(f), 320, 170); + storeShots.push({ blob, url: URL.createObjectURL(blob) }); + renderShots(); + } catch (err) { say("err", `截图处理失败:${err.message}`); } +}); + +$("s-icon-btn").addEventListener("click", () => $("s-icon").click()); +$("s-icon-clear").addEventListener("click", () => { + storeIcon = null; + $("s-icon-preview").classList.add("hidden"); + $("s-icon-preview").removeAttribute("src"); + $("s-icon-clear").classList.add("hidden"); +}); +$("s-icon").addEventListener("change", async (e) => { + const f = e.target.files[0]; + e.target.value = ""; + if (!f) return; + try { + storeIcon = await coverToPng(await fileToImage(f), 128, 128); + $("s-icon-preview").src = URL.createObjectURL(storeIcon); + $("s-icon-preview").classList.remove("hidden"); + $("s-icon-clear").classList.remove("hidden"); + } catch (err) { say("err", `图标处理失败:${err.message}`); } +}); + /* --------------------------------- submit -------------------------------- */ $("submit-btn").addEventListener("click", async () => { @@ -272,6 +367,15 @@ $("submit-btn").addEventListener("click", async () => { const repo = $("source-repo").value.trim(); if (repo) body.append("source_repo", repo); + // Optional store metadata (empty strings + no images ⇒ Worker treats it as + // "not supplied" and falls back to source_repo/deb-derived metadata). + body.append("title", $("s-title").value.trim()); + body.append("summary", $("s-summary").value.trim()); + body.append("description", $("s-desc").value.trim()); + body.append("categories", $("s-cats").value.trim()); + if (storeIcon) body.append("icon", storeIcon, "icon.png"); + storeShots.forEach((sh, i) => body.append("screenshots", sh.blob, `shot${i}.png`)); + try { const r = await fetch("/api/submit", { method: "POST", body }); const data = await r.json(); diff --git a/site/index.html b/site/index.html index d31e8ee..97b99be 100644 --- a/site/index.html +++ b/site/index.html @@ -32,10 +32,27 @@ .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 22px; } .hidden { display: none !important; } label { display: block; margin: 14px 0 6px; font-size: 13px; color: var(--dim); } - input[type="url"] { + input[type="url"], input[type="text"], textarea { width: 100%; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--text); font: inherit; } + textarea { resize: vertical; } + fieldset.store-fieldset { margin-top: 18px; border: 1px solid var(--line); + border-radius: 8px; padding: 4px 16px 18px; } + fieldset.store-fieldset legend { color: var(--dim); font-size: 13px; padding: 0 6px; } + button.ghost { background: transparent; border: 1px solid var(--line); color: var(--dim); + border-radius: 6px; padding: 6px 14px; font: inherit; font-size: 13px; cursor: pointer; margin-top: 8px; } + button.ghost:hover { border-color: var(--accent); color: var(--text); } + .img-row { display: flex; align-items: center; gap: 10px; } + .icon-thumb { width: 48px; height: 48px; border-radius: 10px; background: #000; + object-fit: contain; image-rendering: pixelated; border: 1px solid var(--line); } + .shots { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; } + .shot { position: relative; line-height: 0; } + .shot img { width: 160px; height: 85px; border: 1px solid var(--line); border-radius: 6px; + background: #000; object-fit: cover; image-rendering: pixelated; } + .shot button { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,.7); + border: 1px solid var(--danger); color: var(--danger); border-radius: 4px; font-size: 11px; + line-height: 1.4; padding: 1px 6px; cursor: pointer; } .drop { border: 1.5px dashed var(--line); border-radius: 8px; padding: 30px 16px; text-align: center; color: var(--dim); cursor: pointer; transition: border-color .15s; @@ -140,6 +157,35 @@

CardputerZero 开发者中心

+
+ 商店信息(可选,填了会覆盖源码仓库/deb 自动带入的信息) + + + + + + + + + + + + + + +
+ + + + +
+ + +
+ + +
+
diff --git a/worker/src/index.js b/worker/src/index.js index 37bdca4..9a63a18 100644 --- a/worker/src/index.js +++ b/worker/src/index.js @@ -230,6 +230,18 @@ async function apiSubmit(request, env) { if (!upload.ok) return json(502, { error: "upload_failed", detail: await safeText(upload) }); const asset = await upload.json(); + // Optional store metadata + screenshots/icon supplied directly from the web + // form (equivalent to czdev's app-builder.json `store` section). Images go to + // the same buffer release as the .deb; the Action downloads and commits them + // as small PNGs into pool/main// (no Git LFS). + let store; + try { + store = await collectStoreAssets(form, env, release, s.l, pkg); + } catch (e) { + return json(502, { error: "image_upload_failed", detail: String(e && e.message || e) }); + } + if (store && store.error) return json(store.status || 400, { error: store.error, detail: store.detail }); + const dispatch = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/dispatches`, { method: "POST", body: JSON.stringify({ @@ -246,6 +258,7 @@ async function apiSubmit(request, env) { sha256, size: buf.byteLength, source_repo: sourceRepo, + ...(store && store.payload ? { store: store.payload } : {}), }, }), }); @@ -373,6 +386,79 @@ async function deleteAsset(env, release, name) { } } +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +// Read a PNG's pixel dimensions from its IHDR chunk without decoding it, or +// null when the bytes are not a PNG. IHDR is always the first chunk, so width +// and height are the two big-endian u32 at byte offset 16. +function pngDims(bytes) { + if (bytes.length < 24) return null; + for (let i = 0; i < 8; i++) if (bytes[i] !== PNG_SIG[i]) return null; + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { w: dv.getUint32(16), h: dv.getUint32(20) }; +} + +async function uploadImageAsset(env, release, name, buf) { + await deleteAsset(env, release, name); + const up = await fetch( + `https://uploads.github.com/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}` + + `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, + { method: "POST", headers: { ...bot(env), "content-type": "image/png" }, body: buf }, + ); + if (!up.ok) throw new Error(await safeText(up)); + return { url: (await up.json()).browser_download_url, sha256: hex(await crypto.subtle.digest("SHA-256", buf)) }; +} + +// Gather optional store metadata + screenshots/icon from the submit form. Returns +// {} when nothing was supplied (submission falls back to source_repo/deb), a +// {payload} describing the store section, or an {error, detail, status} on +// validation failure. Screenshots are enforced at 320×170 (the CardputerZero +// LCD); icons must be square PNGs. +async function collectStoreAssets(form, env, release, login, pkg) { + const title = String(form.get("title") || "").trim(); + const summary = String(form.get("summary") || "").trim(); + const description = String(form.get("description") || "").trim(); + const categoriesRaw = String(form.get("categories") || "").trim(); + const iconFile = form.get("icon"); + const hasIcon = iconFile && typeof iconFile.arrayBuffer === "function" && iconFile.size > 0; + const shots = form.getAll("screenshots").filter((f) => f && typeof f.arrayBuffer === "function" && f.size > 0); + + if (!(title || summary || description || categoriesRaw || hasIcon || shots.length)) return {}; + + const MAX_IMG = 512 * 1024; + const categories = categoriesRaw + ? categoriesRaw.split(",").map((c) => c.trim()).filter(Boolean).slice(0, 6) + : []; + const payload = { title, summary, description, categories, icon: null, screenshots: [] }; + + if (shots.length > 6) return { error: "too_many_screenshots", detail: "最多 6 张截图" }; + let idx = 0; + for (const f of shots) { + const b = await f.arrayBuffer(); + if (b.byteLength > MAX_IMG) return { error: "screenshot_too_large", detail: "单张截图需 < 512KB" }; + const d = pngDims(new Uint8Array(b)); + if (!d) return { error: "screenshot_not_png", detail: "截图必须是 PNG" }; + if (d.w !== 320 || d.h !== 170) { + return { error: "screenshot_bad_size", detail: `截图必须是 320×170(收到 ${d.w}×${d.h})` }; + } + const up = await uploadImageAsset(env, release, `${login}__${pkg}__shot${idx}.png`, b); + payload.screenshots.push({ name: `screenshot-${idx}.png`, url: up.url, sha256: up.sha256 }); + idx++; + } + + if (hasIcon) { + const b = await iconFile.arrayBuffer(); + if (b.byteLength > MAX_IMG) return { error: "icon_too_large", detail: "图标需 < 512KB" }; + const d = pngDims(new Uint8Array(b)); + if (!d) return { error: "icon_not_png", detail: "图标必须是 PNG" }; + if (d.w !== d.h || d.w > 512) return { error: "icon_bad_size", detail: "图标必须是正方形 PNG(≤512×512)" }; + const up = await uploadImageAsset(env, release, `${login}__${pkg}__icon.png`, b); + payload.icon = { name: `${pkg}_icon.png`, url: up.url, sha256: up.sha256 }; + } + + return { payload }; +} + /* -------------------------------- sessions ------------------------------- */ async function sealSession(env, obj) {