Files
punktfunk/packaging/winget/server/handler.mjs
T
enricobuehlerandClaude Opus 5 1839d7566b
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
feat(packaging/winget): install the Windows host with winget, from our own source
`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>
2026-07-26 19:09:13 +02:00

205 lines
7.8 KiB
JavaScript

// Punktfunk's winget REST source — the read path only.
//
// The winget client consumes exactly three endpoints. `microsoft/winget-cli-restsource`'s other 20
// (`/packages/**` CRUD) are that reference implementation's ADMIN API for mutating a CosmosDB —
// they are not part of what a client calls, and a source whose data is generated at release time
// has nothing to mutate. So this serves:
//
// GET /information server identity + declared capabilities
// POST /manifestSearch search; 204 when nothing matches
// GET /packageManifests/{PackageIdentifier} the full manifest (optional ?Version=)
//
// Contract: documentation/WinGet-1.1.0.yaml in that repo. Every response is wrapped in `Data`
// (ResponseObjectSchema). Note the spec's content-type key is literally `application/Json`; the
// client is not picky about the response's own header, so we send standard `application/json`.
//
// Pure request->response logic with NO I/O and no dependencies: the catalogue is passed in, so
// server.mjs owns reloading it and test.mjs can drive this directly without a server or a network.
// `data` is produced by build-data.mjs from the published release manifests — see the README.
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" };
const json = (body, status = 200) =>
new Response(JSON.stringify(body), { status, headers: JSON_HEADERS });
// A bare 204 carries no body — this is how the spec says "no results", distinct from an empty array.
const noContent = () => new Response(null, { status: 204 });
const error = (status, message) =>
json({ ErrorCode: status, ErrorMessage: message }, status);
// Match fields we deliberately do NOT claim to serve.
//
// `NormalizedPackageNameAndPublisher` is the notable one: winget derives it client-side from ARP
// entries using its own normalization (case folding plus stripping legal suffixes, punctuation and
// version-like tails). Claiming support would mean reimplementing that exactly, and a near-miss
// produces silently wrong correlation between an installed host and this package. ProductCode is
// exact and Inno already gives us one, so correlation rides on that instead.
//
// Command / PackageFamilyName are genuinely absent (no declared commands; not an MSIX). Market is
// not modelled.
const UNSUPPORTED_MATCH_FIELDS = [
"Command",
"PackageFamilyName",
"NormalizedPackageNameAndPublisher",
"Market",
];
/** Compare one candidate string against a SearchRequestMatch. */
function matches(value, request) {
if (value == null || request == null) return false;
const keyword = String(request.KeyWord ?? "");
const hay = String(value).toLowerCase();
const needle = keyword.toLowerCase();
switch (request.MatchType) {
case "Exact":
return String(value) === keyword;
case "StartsWith":
return hay.startsWith(needle);
// Fuzzy variants are served as substring rather than pretending to a real fuzzy ranker. That
// over-matches slightly, which is the safe direction: winget re-ranks and filters what we return.
case "Substring":
case "Fuzzy":
case "FuzzySubstring":
return hay.includes(needle);
case "Wildcard":
return true;
case "CaseInsensitive":
default:
return hay === needle;
}
}
/** The candidate strings a package offers for a given PackageMatchField. */
function fieldValues(pkg, field) {
switch (field) {
case "PackageIdentifier":
return [pkg.PackageIdentifier];
case "PackageName":
return [pkg.PackageName];
case "Moniker":
return pkg.Moniker ? [pkg.Moniker] : [];
case "Tag":
return pkg.Tags ?? [];
case "ProductCode":
return pkg.ProductCodes ?? [];
default:
return []; // including everything in UNSUPPORTED_MATCH_FIELDS
}
}
/** A free-text `Query` sweeps the fields a human would expect to type. */
function matchesQuery(pkg, query) {
if (!query) return true; // no Query block = match everything, narrowed by Inclusions/Filters
return ["PackageIdentifier", "PackageName", "Moniker", "Tag"].some((f) =>
fieldValues(pkg, f).some((v) => matches(v, query)),
);
}
function matchesOne(pkg, entry) {
// Both Inclusions and Filters use SearchRequestInclusionAndFilterSchema.
const req = entry?.RequestMatch;
return fieldValues(pkg, entry?.PackageMatchField).some((v) => matches(v, req));
}
function search(data, body) {
const { Query, Inclusions, Filters, FetchAllManifests, MaximumResults } = body ?? {};
let hits = data.packages.filter((pkg) => {
if (FetchAllManifests) return true;
if (!matchesQuery(pkg, Query)) return false;
// Inclusions widen (OR); an empty/absent list imposes nothing.
if (Array.isArray(Inclusions) && Inclusions.length > 0) {
if (!Inclusions.some((i) => matchesOne(pkg, i))) return false;
}
// Filters narrow (AND) — every filter must hold.
if (Array.isArray(Filters) && Filters.length > 0) {
if (!Filters.every((f) => matchesOne(pkg, f))) return false;
}
return true;
});
if (Number.isInteger(MaximumResults) && MaximumResults > 0) {
hits = hits.slice(0, MaximumResults);
}
return hits;
}
async function handle(request, data) {
const url = new URL(request.url);
// Tolerate a mount under a sub-path (e.g. /winget/information) so the same Worker can sit behind
// a reverse proxy that does not strip a prefix.
const path = url.pathname.replace(/\/+$/, "");
const tail = (name) => path === `/${name}` || path.endsWith(`/${name}`);
if (tail("information") && request.method === "GET") {
return json({
Data: {
SourceIdentifier: data.sourceIdentifier,
ServerSupportedVersions: data.serverSupportedVersions,
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
RequiredPackageMatchFields: [],
UnsupportedQueryParameters: [],
RequiredQueryParameters: [],
},
});
}
if (tail("manifestSearch")) {
if (request.method !== "POST") return error(405, "manifestSearch requires POST");
let body;
try {
body = await request.json();
} catch {
return error(400, "malformed JSON body");
}
const hits = search(data, body);
if (hits.length === 0) return noContent();
return json({
Data: hits.map((pkg) => ({
PackageIdentifier: pkg.PackageIdentifier,
PackageName: pkg.PackageName,
Publisher: pkg.Publisher,
Versions: pkg.Versions.map((v) => ({
PackageVersion: v.PackageVersion,
ProductCodes: v.ProductCodes ?? [],
})),
})),
RequiredPackageMatchFields: [],
UnsupportedPackageMatchFields: UNSUPPORTED_MATCH_FIELDS,
});
}
const manifestMatch = path.match(/\/packageManifests\/([^/]+)$/);
if (manifestMatch) {
if (request.method !== "GET") return error(405, "packageManifests requires GET");
const id = decodeURIComponent(manifestMatch[1]);
// PackageIdentifier is case-insensitive per the winget client's own handling.
const pkg = data.packages.find(
(p) => p.PackageIdentifier.toLowerCase() === id.toLowerCase(),
);
if (!pkg) return error(404, `unknown PackageIdentifier '${id}'`);
const wanted = url.searchParams.get("Version");
const versions = wanted
? pkg.Manifest.Versions.filter((v) => v.PackageVersion === wanted)
: pkg.Manifest.Versions;
if (versions.length === 0) return error(404, `version '${wanted}' not found`);
return json({ Data: { PackageIdentifier: pkg.Manifest.PackageIdentifier, Versions: versions } });
}
return error(404, `no route for ${request.method} ${url.pathname}`);
}
/** Wraps handle() so no request can escape as an unhandled rejection. */
export async function respond(request, data) {
try {
return await handle(request, data);
} catch (e) {
return error(500, `unhandled: ${e?.message ?? e}`);
}
}
export { handle, search, matches };