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
4 changes: 3 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ jobs:
with:
node-version: 22
- name: Install parser test dependencies
run: sudo apt-get update && sudo apt-get install -y dpkg-dev xz-utils zstd
# zstd/bzip2/lzma are decompressed by the vendored pure-JS libs in the
# tests; xz-utils also provides the lzma compressor for legacy fixtures.
run: sudo apt-get update && sudo apt-get install -y dpkg-dev xz-utils bzip2
- name: Run deb parser tests
run: node --test test/*.test.js

Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

```
浏览器 dev.cardputer.cc
│ ① 选择 .deb → 本地解析(ar/tar/gz/xz/zstd 纯前端解包)
│ ① 选择 .deb → 本地解析(ar/tar 纯前端解包,支持 deb(5) 全部压缩:
│ gz / xz / zstd / bz2 / lzma / 无压缩,解压库全部同域 vendored)
│ 展示图标 / 包名 / 版本 / .desktop / 邮箱 / 文件清单 / 安全报告
│ 并对照线上索引预检:包名归属、版本是否重复/倒退
│ ② GitHub OAuth 登录(scope: user:email,读取已验证邮箱)
Expand Down Expand Up @@ -86,7 +87,8 @@ Cloudflare Workers(不使用 Cloudflare 的 git 集成 / 自动拉取部署)
cd worker && wrangler dev # http://localhost:8787,页面 + API 同域
```

解析器单元测试(用系统 gzip/xz/zstd 模拟浏览器解压):
解析器单元测试(zstd/bzip2/lzma 直接用页面同款 vendored 纯 JS 库解压,
gzip 用 node:zlib,xz 用系统 xz;需要 `apt install dpkg xz-utils bzip2`):

```bash
node --test test/*.test.js
Expand Down
118 changes: 83 additions & 35 deletions site/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,33 @@

import { parseDeb, compareDebVersions, extractEmail } from "/debparse.js";

// xz-decompress (WASM) is vendored at /vendor/ and served same-origin, so there
// is zero runtime dependency on a third-party CDN (availability, version drift,
// supply-chain, or network reachability). It is loaded lazily and only when an
// .xz-compressed package is actually parsed: a static top-level import would
// abort the whole module (blanking the page) if the bundle's evaluation or
// export shape ever failed, whereas a dynamic import keeps any such failure
// contained to the xz code path. The bundle is a CommonJS package transformed
// to ESM, so XzReadableStream is exposed on the default export (jsDelivr's
// cjs->esm lexer cannot surface it as a named export); we accept both shapes.
const XZ_URL = "/vendor/xz-decompress.js";
let _xzPromise = null;
function loadXz() {
if (!_xzPromise) {
_xzPromise = import(/* @vite-ignore */ XZ_URL).then(
(m) => m.XzReadableStream
|| (m.default && m.default.XzReadableStream)
|| (typeof m.default === "function" ? m.default : null),
);
// All decompression libraries are vendored at /vendor/ and served same-origin,
// so there is zero runtime dependency on a third-party CDN (availability,
// version drift, supply-chain, or network reachability). Each one is loaded
// lazily and only when a package actually uses that compression: a static
// top-level import would abort the whole module (blanking the page) if a
// bundle's evaluation or export shape ever failed, whereas a dynamic import
// keeps any such failure contained to that one code path.
// xz-decompress (WASM) — .tar.xz (jsDelivr cjs->esm build: the class may
// sit on the default export instead of a named one)
// fzstd (pure JS) — .tar.zst fallback when DecompressionStream("zstd")
// is unavailable (only Chrome 133+ has it natively)
// bz2 (pure JS) — .tar.bz2 (legacy debs)
// LZMA-JS d-min (pure JS)— .tar.lzma (legacy debs, LZMA "alone" format)
const lazyImports = new Map();
function lazyImport(url, pickExport) {
if (!lazyImports.has(url)) {
lazyImports.set(url, import(/* @vite-ignore */ url).then(pickExport));
}
return _xzPromise;
return lazyImports.get(url);
}
const loadXz = () => lazyImport("/vendor/xz-decompress.js",
(m) => m.XzReadableStream
|| (m.default && m.default.XzReadableStream)
|| (typeof m.default === "function" ? m.default : null));
const loadFzstd = () => lazyImport("/vendor/fzstd.js", (m) => m.decompress);
const loadBz2 = () => lazyImport("/vendor/bz2.js", (m) => m.decompress);
const loadLzma = () => lazyImport("/vendor/lzma-d.js", (m) => m.LZMA);

const INDEX_URL = "https://cardputer.cc/packages/dists/stable/main/binary-arm64/Packages";

Expand All @@ -49,31 +55,50 @@ async function streamToBytes(stream) {
return out;
}

async function loadOrExplain(loader, what) {
let impl = null;
try {
impl = await loader();
} catch { /* fall through to the user-facing error below */ }
if (!impl) {
throw new Error(`无法加载 ${what} 解压组件(仅用于本地预览),请刷新重试;仍可直接提交,服务器会完成校验`);
}
return impl;
}

const decompressors = {
gzip: async (data) => streamToBytes(
new Blob([data]).stream().pipeThrough(new DecompressionStream("gzip")),
),
xz: async (data) => {
let XzReadableStream;
try {
XzReadableStream = await loadXz();
} catch {
XzReadableStream = null;
}
if (!XzReadableStream) {
throw new Error("无法加载 xz 解压组件(仅用于本地预览),请刷新重试;仍可直接提交,服务器会完成校验");
}
const XzReadableStream = await loadOrExplain(loadXz, "xz");
return streamToBytes(new XzReadableStream(new Blob([data]).stream()));
},
zstd: async (data) => {
// Chrome 133+/Edge expose zstd in DecompressionStream; fall back with a hint.
// Chrome 133+/Edge expose zstd in DecompressionStream natively; every
// other browser falls back to the vendored pure-JS fzstd decoder.
try {
return await streamToBytes(
new Blob([data]).stream().pipeThrough(new DecompressionStream("zstd")),
);
} catch {
throw new Error("此浏览器不支持 zstd 解压,无法本地预览。建议用 dpkg-deb -Zxz 重新打包,或换 Chrome 133+ 浏览器");
}
} catch { /* native zstd unavailable (or stream failed) — use fzstd */ }
const fzstdDecompress = await loadOrExplain(loadFzstd, "zstd");
return fzstdDecompress(data);
},
bzip2: async (data) => {
const bz2Decompress = await loadOrExplain(loadBz2, "bzip2");
return bz2Decompress(data);
},
lzma: async (data) => {
const LZMA = await loadOrExplain(loadLzma, "lzma");
// LZMA-JS is callback-based; the result is an array of byte values, or a
// string when the payload happens to decode as UTF-8 text.
const result = await new Promise((resolve, reject) => {
LZMA.decompress(data, (out, err) => (err ? reject(new Error(String(err))) : resolve(out)));
});
return typeof result === "string"
? new TextEncoder().encode(result)
: new Uint8Array(result);
},
};

Expand Down Expand Up @@ -276,17 +301,40 @@ async function renderMine() {
return;
}
rows.innerHTML = "";
for (const p of mine.sort((a, b) => a.name.localeCompare(b.name))) {
for (const p of mine.sort((a, b) => a.name.localeCompare(b.name) || compareDebVersions(a.Version, b.Version))) {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${p.name}</td><td>${p.Version}</td><td>${p.email}</td><td></td>`;
tr.innerHTML = `<td>${p.name}</td><td>${p.Version}</td><td>${p.email}</td><td class="row-actions"></td>`;
const actions = tr.lastElementChild;
const dl = debDownloadUrl(p);
if (dl) {
const a = document.createElement("a");
a.className = "dl-btn";
a.textContent = "下载 .deb";
a.href = dl;
// Hint the browser to save instead of navigate; the canonical
// pkg_version_arch.deb name also survives cross-origin redirects.
a.download = `${p.name}_${p.Version}_${p.Architecture || "arm64"}.deb`;
a.title = `${p.name} ${p.Version}(${p.Size ? (p.Size / 1048576).toFixed(1) + " MB" : "大小未知"})`;
actions.appendChild(a);
}
const btn = document.createElement("button");
btn.textContent = "下架";
btn.addEventListener("click", () => unpublish(p, btn));
tr.lastElementChild.appendChild(btn);
actions.appendChild(btn);
rows.appendChild(tr);
}
}

/** Resolve the .deb download URL from the APT index entry (Filename field). */
function debDownloadUrl(entry) {
const filename = (entry.Filename || "").trim();
if (!filename) return null;
// Absolute URL (this repo publishes pool files on GitHub Releases) or a
// conventional pool path relative to the APT repository root.
if (/^https?:\/\//.test(filename)) return filename;
return new URL(filename, INDEX_URL.replace(/dists\/.*$/, "")).toString();
}

async function unpublish(p, btn) {
if (!confirm(`确认下架 ${p.name} ${p.Version}?将生成移除 PR。`)) return;
btn.disabled = true;
Expand Down
18 changes: 11 additions & 7 deletions site/debparse.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@
* Browser-side .deb inspector for the CardputerZero developer portal.
*
* Parses a Debian package entirely in the browser (no upload needed):
* ar archive -> control.tar.{gz,xz,zst} + data.tar.{gz,xz,zst}
* and produces control fields, the .desktop entry, the app icon bytes,
* a full file listing and a preliminary safety report.
* ar archive -> control.tar{,.gz,.xz,.zst} + data.tar{,.gz,.xz,.zst,.bz2,.lzma}
* (every compression the deb(5) format allows) and produces control fields,
* the .desktop entry, the app icon bytes, a full file listing and a
* preliminary safety report.
*
* Decompressors are injected so the same module works in the page (CDN
* libs) and in Node tests (npm libs):
* parseDeb(buffer, { gzip, xz, zstd }) — each: (Uint8Array) => Promise<Uint8Array>
* Decompressors are injected so the same module works in the page (vendored
* libs) and in Node tests (system tools / the same vendored libs):
* parseDeb(buffer, { gzip, xz, zstd, bzip2, lzma })
* — each: (Uint8Array) => Promise<Uint8Array>
*/

/* ------------------------------ ar archive ------------------------------- */
Expand Down Expand Up @@ -78,9 +80,11 @@ function parseTar(bytes) {

/* ----------------------------- decompression ----------------------------- */

// Every compression deb(5) allows for control.tar / data.tar members.
async function extractMember(entries, base, decompressors) {
for (const [suffix, kind] of [
[".tar.gz", "gzip"], [".tar.xz", "xz"], [".tar.zst", "zstd"], [".tar", "raw"],
[".tar.gz", "gzip"], [".tar.xz", "xz"], [".tar.zst", "zstd"],
[".tar.bz2", "bzip2"], [".tar.lzma", "lzma"], [".tar", "raw"],
]) {
const entry = entries.find((e) => e.name === base + suffix);
if (!entry) continue;
Expand Down
7 changes: 6 additions & 1 deletion site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@
th { color: var(--dim); font-weight: 500; }
td button { background: transparent; border: 1px solid var(--danger); color: var(--danger);
border-radius: 5px; padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer; }
td.row-actions { white-space: nowrap; }
td.row-actions > * + * { margin-left: 8px; }
a.dl-btn { display: inline-block; border: 1px solid var(--accent); color: var(--accent);
border-radius: 5px; padding: 4px 12px; font-size: 12px; text-decoration: none; }
a.dl-btn:hover { background: var(--accent); color: #04220b; }
.login-box { text-align: center; padding: 48px 20px; }
.login-box button { width: auto; padding: 12px 40px; }
.muted { color: var(--dim); font-size: 13px; }
Expand Down Expand Up @@ -146,7 +151,7 @@ <h1>CardputerZero 开发者中心</h1>
<thead><tr><th>包名</th><th>版本</th><th>Maintainer</th><th></th></tr></thead>
<tbody id="mine-rows"><tr><td colspan="4" class="muted">加载中…</td></tr></tbody>
</table>
<p class="muted">列表来自线上 APT 索引,只显示 Maintainer 邮箱属于你的包。下架会生成移除 PR。</p>
<p class="muted">列表来自线上 APT 索引,只显示 Maintainer 邮箱属于你的包。可直接下载已发布的 .deb;下架会生成移除 PR。</p>
</div>
</section>
</main>
Expand Down
Loading
Loading