Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages-workflows/process-web-submission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand All @@ -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/<pkg>/ (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():
Expand Down
104 changes: 104 additions & 0 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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 = `<img alt="截图 ${i + 1}"><button type="button" data-i="${i}">✕</button>`;
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 () => {
Expand All @@ -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();
Expand Down
48 changes: 47 additions & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -140,6 +157,35 @@ <h1>CardputerZero 开发者中心</h1>
<label for="source-repo">源码仓库(可选,公开仓库;含 app-builder.json 的 store 段可自动带入截图等商店信息)</label>
<input id="source-repo" type="url" placeholder="https://github.com/you/your-app">

<fieldset class="store-fieldset">
<legend>商店信息(可选,填了会覆盖源码仓库/deb 自动带入的信息)</legend>

<label for="s-title">应用名称</label>
<input id="s-title" type="text" placeholder="显示在 AppStore 里的名字">

<label for="s-summary">一句话简介</label>
<input id="s-summary" type="text" maxlength="80" placeholder="80 字以内">

<label for="s-desc">详细描述</label>
<textarea id="s-desc" rows="4" placeholder="支持多行"></textarea>

<label for="s-cats">分类(逗号分隔,最多 6 个)</label>
<input id="s-cats" type="text" placeholder="Games, Emulators">

<label>图标(可选,正方形 PNG;留空则用 deb 内图标)</label>
<div class="img-row">
<img id="s-icon-preview" class="icon-thumb hidden" alt="icon 预览">
<input id="s-icon" type="file" accept="image/png,image/jpeg,image/webp" class="hidden">
<button type="button" class="ghost" id="s-icon-btn">选择图标</button>
<button type="button" class="ghost hidden" id="s-icon-clear">移除</button>
</div>

<label>应用截图(320×170,选图后自动裁剪缩放;最多 6 张)</label>
<div id="s-shots" class="shots"></div>
<input id="s-shot-file" type="file" accept="image/png,image/jpeg,image/webp" class="hidden">
<button type="button" class="ghost" id="s-shot-btn">+ 添加截图</button>
</fieldset>

<button class="primary" id="submit-btn" disabled>提交到 AppStore</button>
</div>
<div class="status" id="status"></div>
Expand Down
86 changes: 86 additions & 0 deletions worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pkg>/ (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({
Expand All @@ -246,6 +258,7 @@ async function apiSubmit(request, env) {
sha256,
size: buf.byteLength,
source_repo: sourceRepo,
...(store && store.payload ? { store: store.payload } : {}),
},
}),
});
Expand Down Expand Up @@ -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) {
Expand Down
Loading