Files
magpie/site/worker.js
T
yetone aa42142ea5 update: What's new and the waiting update's notes follow the app's language (freecss on Discord: 更新后的公告的语言是不是应该设置里面的走) — the release notes were English alone. A release's notes are to carry a Simplified Chinese translation below a <!-- lang:zh --> marker (the magpie-releases workflow, changed separately); usemagpie.ai's /api/latest and /api/notes take ?lang=zh for the Chinese where a release has it, and give anything else, an app from before included, the English alone, everything above the marker. The page asks /api/whatsnew, /api/update and /api/update/check in its own language (?lang=); magpie asks the site in it, keeps What's new's notes per language, and asks again for the waiting update's notes when a page's language isn't the one they are held in (at most once a minute); a check by itself asks in the pages' last language, else the app's, as the tray menu has it. Notes that come back whole (a site not yet deployed) are cut in the app too; notes without the marker (every release so far) are English, in either language. The worker must be deployed (npx wrangler deploy in site/) before the workflow change ships, or apps would get both languages. TestInLang, TestFeedsAskInLang, TestAskedLang, TestWhatsNewInPageLang, TestUpdateNotesInPageLang, site/worker.test.mjs (node --test site/worker.test.mjs), whats-new.test.cjs's language check (Chromium and WebKit, en and zh); update-check, update-hide, update-auto, sync-update pass in WebKit. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build, go test -tags nogui ./... pass (TestClaudeRunEndsWhenTheClientLeaves flaked once under the full run, passes alone; gateway untouched).
2026-10-02 11:31:11 +08:00

216 lines
9.0 KiB
JavaScript

// usemagpie.ai. Releases live on GitHub (yetone/magpie-releases); this
// worker turns the newest one into the update feed the app reads and into
// download links that never go stale.
//
// /api/latest {version, notes, url, published, assets: {name: {url, size, sha256}}}
// /api/notes?after=&upto= {releases: [{version, notes, url, published}]}, newest
// first: what changed since the version an app last ran
// Both take ?lang=zh for the notes in Chinese, where a
// release has them (below its <!-- lang:zh --> marker);
// any other lang, or none, is the English alone.
// /download the Apple Silicon dmg
// /download/mac-arm64 the same; /download/mac-intel the Intel dmg
// /download/windows the Windows app (x64); /download/windows-arm64
// /download/linux the Linux app (x86-64); /download/linux-arm64
// /download/<file> any file of the newest release, by name
// /docs, /docs/zh the getting-started guide: /docs/start, /docs/zh/start
//
// Everything else is the static site in public/.
const REPO = "yetone/magpie-releases";
const TTL = 300; // seconds the newest release is remembered
const SHORT = {
"mac-arm64": "magpie-darwin-arm64.dmg",
"mac-intel": "magpie-darwin-amd64.dmg",
"mac-amd64": "magpie-darwin-amd64.dmg",
windows: "magpie-windows-amd64.exe",
"windows-amd64": "magpie-windows-amd64.exe",
"windows-arm64": "magpie-windows-arm64.exe",
linux: "magpie-linux-amd64",
"linux-amd64": "magpie-linux-amd64",
"linux-arm64": "magpie-linux-arm64",
};
// The bare docs paths open the getting-started guide.
const DOCS = { "/docs": "/docs/start", "/docs/zh": "/docs/zh/start" };
export default {
async fetch(req, env, ctx) {
const url = new URL(req.url);
if (url.hostname.startsWith("www.")) {
url.hostname = url.hostname.slice(4);
return Response.redirect(url.toString(), 301);
}
if (url.pathname === "/api/latest") {
const rel = await latest(ctx);
if (!rel) return json({ error: "no release yet" }, 503);
const lang = url.searchParams.get("lang");
return json({ ...rel, notes: inLang(rel.notes, lang) }, 200, { "Cache-Control": `public, max-age=${TTL}` });
}
if (url.pathname === "/api/notes") {
const list = await releases(ctx);
if (!list) return json({ error: "no releases" }, 503);
const after = url.searchParams.get("after"), upto = url.searchParams.get("upto");
const lang = url.searchParams.get("lang");
const pick = list
.filter((r) => (!after || newer(r.version, after)) && (!upto || !newer(r.version, upto)))
.map((r) => ({ ...r, notes: inLang(r.notes, lang) }));
return json({ releases: pick }, 200, { "Cache-Control": `public, max-age=${TTL}` });
}
if (url.pathname === "/download" || url.pathname.startsWith("/download/")) {
const want = url.pathname.split("/")[2] || "mac-arm64";
const rel = await latest(ctx);
const asset = rel && rel.assets[SHORT[want] || want];
if (!asset) return new Response("not found\n", { status: 404 });
return Response.redirect(asset.url, 302);
}
const guide = DOCS[url.pathname.replace(/\/+$/, "")];
if (guide) return Response.redirect(new URL(guide, url).toString(), 302);
return beacon(await env.ASSETS.fetch(req));
},
};
// beacon adds Cloudflare Web Analytics to a page. The dashboard's automatic
// injection skips whatever a worker returns, and every page passes through
// this one.
const BEACON = `<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "c09e76abf16b40b2aa5571c196efb847"}'></script>`;
function beacon(res) {
if (!(res.headers.get("Content-Type") || "").startsWith("text/html")) return res;
return new HTMLRewriter().on("body", { element: (el) => el.append(BEACON, { html: true }) }).transform(res);
}
// latest is the newest release, condensed, with each file's SHA-256 taken
// from the release's SHA256SUMS. GitHub's API gives the notes and sizes but
// limits anonymous callers by IP, and a worker shares its IP with many
// others; when the API says no, the release page's redirect gives the
// version and SHA256SUMS the files, which is all an update needs.
async function latest(ctx) {
const cache = caches.default;
const key = new Request("https://usemagpie.ai/__latest");
const hit = await cache.match(key);
if (hit) return hit.json();
const rel = (await fromAPI()) || (await fromPages());
if (!rel) return null;
ctx.waitUntil(cache.put(key, json(rel, 200, { "Cache-Control": `max-age=${TTL}` })));
return rel;
}
const UA = { "User-Agent": "usemagpie.ai" };
async function fromAPI() {
const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
headers: { ...UA, Accept: "application/vnd.github+json" },
});
if (!res.ok) {
console.log("github api", res.status, await res.text());
return null;
}
const gh = await res.json();
const tag = gh.tag_name;
const sums = await checksums(tag);
if (!sums) return null;
const rel = { version: tag.replace(/^v/, ""), notes: gh.body || "", url: gh.html_url, published: gh.published_at, assets: {} };
for (const a of gh.assets) {
if (a.name === "SHA256SUMS") continue;
rel.assets[a.name] = { url: a.browser_download_url, size: a.size, sha256: sums[a.name] || "" };
}
return rel;
}
async function fromPages() {
const res = await fetch(`https://github.com/${REPO}/releases/latest`, { headers: UA, redirect: "manual" });
const tag = (res.headers.get("Location") || "").split("/tag/")[1];
if (!tag) {
console.log("github latest redirect", res.status);
return null;
}
const sums = await checksums(tag);
if (!sums) return null;
const url = `https://github.com/${REPO}/releases/tag/${tag}`;
const rel = { version: tag.replace(/^v/, ""), notes: "", url, published: null, assets: {} };
for (const [name, sha256] of Object.entries(sums)) {
rel.assets[name] = { url: `https://github.com/${REPO}/releases/download/${tag}/${name}`, size: 0, sha256 };
}
return rel;
}
// releases is the newest hundred releases' notes, newest first, kept as
// long as the newest release is. Drafts and pre-releases are left out.
async function releases(ctx) {
const cache = caches.default;
const key = new Request("https://usemagpie.ai/__releases");
const hit = await cache.match(key);
if (hit) return hit.json();
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100`, {
headers: { ...UA, Accept: "application/vnd.github+json" },
});
if (!res.ok) {
console.log("github api releases", res.status, await res.text());
return null;
}
const list = (await res.json())
.filter((r) => !r.draft && !r.prerelease)
.map((r) => ({ version: r.tag_name.replace(/^v/, ""), notes: r.body || "", url: r.html_url, published: r.published_at }));
ctx.waitUntil(cache.put(key, json(list, 200, { "Cache-Control": `max-age=${TTL}` })));
return list;
}
// A release's notes are in English, then (since the release workflow
// translates them) in Chinese below this marker. The edge keeps the notes
// whole; each answer is cut to one language, and the browser's and the
// edge's caches tell answers apart by their URL, lang and all.
const ZH = "<!-- lang:zh -->";
// inLang is the notes in lang: zh (zh-CN, zh-Hans, ...) the Chinese when
// there is some, else the English, which is everything above the marker.
// An app from before lang asks with none, and gets the English alone.
function inLang(notes, lang) {
notes = notes || "";
const i = notes.indexOf(ZH);
if (i < 0) return notes;
if (/^zh($|[-_])/i.test(lang || "")) {
const zh = notes.slice(i + ZH.length).trim();
if (zh) return zh;
}
return notes.slice(0, i).trim();
}
// newer says whether version a comes after b (x.y.z, a pre-release before
// its release), as the app's update.Newer does.
function newer(a, b) {
const p = (v) => {
const [core, pre = ""] = String(v).replace(/^v/, "").split(/-(.*)/s);
const n = core.split(".").map(Number);
return n.length === 3 && n.every((x) => Number.isInteger(x) && x >= 0) ? { n, pre } : null;
};
const x = p(a), y = p(b);
if (!x || !y) return false;
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
if (x.pre === y.pre) return false;
if (!x.pre) return true;
if (!y.pre) return false;
return x.pre > y.pre;
}
// checksums reads a release's SHA256SUMS: {file name: hash}.
async function checksums(tag) {
const r = await fetch(`https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS`, { headers: UA });
if (!r.ok) return null;
const sums = {};
for (const line of (await r.text()).split("\n")) {
const [hash, name] = line.trim().split(/\s+\*?/);
if (hash && name) sums[name] = hash;
}
return sums;
}
function json(v, status = 200, headers = {}) {
return new Response(JSON.stringify(v), {
status,
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", ...headers },
});
}