docker / build-push-arm64cross (push) Successful in 10s
docker / deploy-docs (push) Successful in 31s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m54s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m56s
apple / screenshots (push) Canceled after 11m30s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
apple / swift (push) Successful in 6m2s
ci / web (push) Successful in 1m53s
ci / docs-site (push) Successful in 1m29s
ci / rust-arm64 (push) Successful in 9m56s
android / android (push) Successful in 12m33s
ci / bench (push) Successful in 7m17s
decky / build-publish (push) Successful in 29s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 19s
arch / build-publish (push) Successful in 18m18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish (push) Successful in 9m2s
ci / rust (push) Failing after 19m38s
deb / build-publish-client-arm64 (push) Successful in 7m21s
deb / build-publish-host (push) Successful in 9m43s
`winget install unom.PunktfunkHost` after adding the source, and `winget upgrade`
from then on. Windows had no update path at all before this — no self-update, no
package manager — so keeping a host current meant noticing a release and
re-running an installer by hand.
Ships the manifest trio in winget-pkgs' own format (so submitting upstream later
is a copy, not a rewrite) plus a release-time generator that substitutes only
version, URL, hash and release-notes link. Everything reviewable — the silent
switches, the agreements, the installation notes — stays in the checked-in
templates rather than buried in a generator.
Silent installs deliberately take the SAME task defaults the wizard shows: a
per-channel default is a support trap. The disclosures the wizard puts on screen
travel as manifest Agreements instead, shown before install and requiring
acceptance — VB-Audio's bundling grant wants the user to see VB-CABLE's origin
and donationware status, and a silent install shows them nothing otherwise. The
console password can only be pointed at, never printed: it is generated per
install and the notes are fixed at publish time.
Self-hosted rather than the community repo, which gates on Defender/SmartScreen
validation the self-signed installer cert would not clear today. The source is
three endpoints — /information, /manifestSearch, /packageManifests/{id}. The
reference implementation's other twenty are its admin API for mutating a
CosmosDB; a catalogue generated at release time has nothing to mutate.
It runs on unom-1 as a stock bun image with the two .mjs files bind-mounted, the
same shape as the flatpak server, so there is no image to build or pull. The
catalogue is derived from the RELEASES rather than local files: winget resolves
--version and upgrade against the version list, so a source that only knew the
newest release could neither pin an older one nor show an upgrade path from it.
That also leaves no state to drift — re-running the build reproduces it exactly.
NormalizedPackageNameAndPublisher is declared unsupported on purpose. winget
derives it client-side with its own normalization, and a near-miss silently
mis-correlates an installed host; ProductCode is exact and Inno gives us one.
28 checks drive the handler directly, and CI gates on them before anything
reaches the box — a wrong response shape does not fail loudly, it just makes
winget report "no package found".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
110 lines
4.7 KiB
JavaScript
110 lines
4.7 KiB
JavaScript
// HTTP front for the winget REST source. Runs on unom-1 under a stock `oven/bun` image with this
|
|
// file and the data bind-mounted — same shape as the flatpak server (stock caddy:2-alpine + a
|
|
// bind-mounted Caddyfile), so there is no image to build, publish or pull.
|
|
//
|
|
// Plain HTTP on :3240. Caddy on the same box terminates TLS for winget.punktfunk.unom.io and
|
|
// reverse-proxies here — that TLS is not optional decoration: `winget source add` refuses anything
|
|
// but HTTPS with a publicly trusted certificate.
|
|
//
|
|
// Zero dependencies on purpose. The catalogue is a generated JSON file; parsing YAML and talking to
|
|
// Gitea is build-data.mjs's job, which runs in CI. Nothing here needs node_modules, so the deploy
|
|
// is two files and `docker compose up -d`.
|
|
import { readFileSync, statSync } from "node:fs";
|
|
import { dirname, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createServer } from "node:http";
|
|
import { respond } from "./handler.mjs";
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const DATA_PATH = resolve(process.env.PF_WINGET_DATA ?? `${HERE}/data/data.json`);
|
|
const PORT = Number(process.env.PORT ?? 3240);
|
|
|
|
// Reload on mtime change rather than caching for the process lifetime. Publishing a release rsyncs
|
|
// a new data.json onto the box; without this it would serve the old catalogue until someone
|
|
// remembered to restart the container. Stat-per-request on an ~8 KB local file is free, and it
|
|
// keeps "deploy" and "publish" decoupled the way the flatpak repo already is.
|
|
let cache = { mtimeMs: 0, data: null, error: null };
|
|
|
|
function catalogue() {
|
|
let mtimeMs;
|
|
try {
|
|
mtimeMs = statSync(DATA_PATH).mtimeMs;
|
|
} catch (e) {
|
|
// Keep serving the last good catalogue if the file goes missing mid-flight (e.g. a partial
|
|
// rsync) — a transient publish must not take the source down.
|
|
if (cache.data) return cache.data;
|
|
cache.error = `cannot stat ${DATA_PATH}: ${e.message}`;
|
|
return null;
|
|
}
|
|
if (cache.data && mtimeMs === cache.mtimeMs) return cache.data;
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(DATA_PATH, "utf8"));
|
|
if (!Array.isArray(parsed?.packages)) throw new Error("no `packages` array");
|
|
cache = { mtimeMs, data: parsed, error: null };
|
|
console.log(`[winget] loaded ${parsed.packages.length} package(s) from ${DATA_PATH}`);
|
|
return parsed;
|
|
} catch (e) {
|
|
// A half-written file during rsync parses as garbage; prefer stale-but-valid over an outage.
|
|
if (cache.data) {
|
|
console.error(`[winget] reload failed, keeping previous catalogue: ${e.message}`);
|
|
return cache.data;
|
|
}
|
|
cache.error = `cannot parse ${DATA_PATH}: ${e.message}`;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** node:http request -> WHATWG Request, so handler.mjs stays runtime-agnostic. */
|
|
function toRequest(req) {
|
|
const url = `http://${req.headers.host ?? "localhost"}${req.url}`;
|
|
const init = { method: req.method, headers: req.headers };
|
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
init.body = req;
|
|
init.duplex = "half"; // required when a stream is used as a body
|
|
}
|
|
return new Request(url, init);
|
|
}
|
|
|
|
const server = createServer(async (req, res) => {
|
|
// Liveness for compose/monitoring — deliberately outside the winget routes so a broken catalogue
|
|
// is visible as 503 here rather than as a confusing "no package found" in the client.
|
|
if (req.url === "/healthz") {
|
|
const data = catalogue();
|
|
const ok = !!data;
|
|
res.writeHead(ok ? 200 : 503, { "content-type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify(ok ? { status: "ok", packages: data.packages.length } : { status: "error", error: cache.error }));
|
|
return;
|
|
}
|
|
|
|
const data = catalogue();
|
|
if (!data) {
|
|
res.writeHead(503, { "content-type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ ErrorCode: 503, ErrorMessage: cache.error ?? "catalogue unavailable" }));
|
|
return;
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await respond(toRequest(req), data);
|
|
} catch (e) {
|
|
res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
|
|
res.end(JSON.stringify({ ErrorCode: 500, ErrorMessage: String(e?.message ?? e) }));
|
|
return;
|
|
}
|
|
|
|
const headers = Object.fromEntries(response.headers.entries());
|
|
const body = response.status === 204 ? null : await response.text();
|
|
res.writeHead(response.status, headers);
|
|
res.end(body ?? undefined);
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`[winget] REST source on :${PORT}, data=${DATA_PATH}`);
|
|
// Warm the cache at boot so a bad mount is loud immediately, not on the first user request.
|
|
if (!catalogue()) console.error(`[winget] WARNING: ${cache.error}`);
|
|
});
|
|
|
|
for (const sig of ["SIGTERM", "SIGINT"]) {
|
|
process.on(sig, () => server.close(() => process.exit(0)));
|
|
}
|