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>
205 lines
7.8 KiB
JavaScript
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 };
|