From 1839d7566b3ed3b270a644a49cd29403582d03ad Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 19:09:07 +0200 Subject: [PATCH] feat(packaging/winget): install the Windows host with winget, from our own source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .gitea/workflows/deploy-services.yml | 38 ++++ .gitea/workflows/windows-host.yml | 79 +++++++ README.md | 2 +- packaging/winget/README.md | 116 ++++++++++ packaging/winget/server/.gitignore | 5 + packaging/winget/server/README.md | 140 ++++++++++++ packaging/winget/server/build-data.mjs | 155 +++++++++++++ .../winget/server/compose.production.yml | 41 ++++ packaging/winget/server/handler.mjs | 204 ++++++++++++++++++ packaging/winget/server/package-lock.json | 29 +++ packaging/winget/server/package.json | 15 ++ packaging/winget/server/server.mjs | 109 ++++++++++ packaging/winget/server/test.mjs | 122 +++++++++++ .../winget/unom.PunktfunkHost.installer.yaml | 53 +++++ .../unom.PunktfunkHost.locale.en-US.yaml | 72 +++++++ packaging/winget/unom.PunktfunkHost.yaml | 10 + scripts/ci/winget-manifest.ps1 | 75 +++++++ 17 files changed, 1264 insertions(+), 1 deletion(-) create mode 100644 packaging/winget/README.md create mode 100644 packaging/winget/server/.gitignore create mode 100644 packaging/winget/server/README.md create mode 100644 packaging/winget/server/build-data.mjs create mode 100644 packaging/winget/server/compose.production.yml create mode 100644 packaging/winget/server/handler.mjs create mode 100644 packaging/winget/server/package-lock.json create mode 100644 packaging/winget/server/package.json create mode 100644 packaging/winget/server/server.mjs create mode 100644 packaging/winget/server/test.mjs create mode 100644 packaging/winget/unom.PunktfunkHost.installer.yaml create mode 100644 packaging/winget/unom.PunktfunkHost.locale.en-US.yaml create mode 100644 packaging/winget/unom.PunktfunkHost.yaml create mode 100644 scripts/ci/winget-manifest.ps1 diff --git a/.gitea/workflows/deploy-services.yml b/.gitea/workflows/deploy-services.yml index 53a594ae..bdecf2e7 100644 --- a/.gitea/workflows/deploy-services.yml +++ b/.gitea/workflows/deploy-services.yml @@ -99,3 +99,41 @@ jobs: mkdir -p ~/unom-flatpak/site/repo cd ~/unom-flatpak docker compose -f compose.production.yml up -d + + winget: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Sync winget source compose + server + uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 + with: + host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + port: ${{ secrets.DEPLOY_PORT }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + # Land all three flat in ~/unom-winget/ (drop the packaging/winget/server/ prefix). + source: "packaging/winget/server/compose.production.yml,packaging/winget/server/server.mjs,packaging/winget/server/handler.mjs" + target: "~/unom-winget" + strip_components: 3 + overwrite: true + + - name: Start winget REST source + uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 + with: + host: ${{ inputs.deploy_host || secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + port: ${{ secrets.DEPLOY_PORT }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + # ./data/data.json is NOT shipped by this workflow — windows-host.yml rsyncs it on each + # stable tag (same content/config split as the flatpak repo). Ensure the bind-mount + # source exists so the container starts; it serves 503 until the first catalogue lands. + mkdir -p ~/unom-winget/data + cd ~/unom-winget + docker compose -f compose.production.yml up -d + # Surface a missing catalogue here rather than letting winget report "no package found". + sleep 3 + curl -fsS http://127.0.0.1:3240/healthz || echo "NOTE: no catalogue yet - publish a stable tag to populate it" diff --git a/.gitea/workflows/windows-host.yml b/.gitea/workflows/windows-host.yml index 2f6ed844..ff0fc47a 100644 --- a/.gitea/workflows/windows-host.yml +++ b/.gitea/workflows/windows-host.yml @@ -359,3 +359,82 @@ jobs: foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) { if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f } } + + # winget manifests for the release just attached above. Runs AFTER the attach step so the + # InstallerUrl the manifest pins is already live — winget validates the URL + hash, and a + # manifest published ahead of its artifact is a hard 404 for every client that picks it up. + # Stable tags only: winget pins one immutable artifact per version, so the rolling `canary/` + # alias has nothing it could point at. + - name: Emit + attach winget manifests (stable tags only) + if: startsWith(gitea.ref, 'refs/tags/v') + shell: pwsh + env: + GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + & scripts/ci/winget-manifest.ps1 ` + -Version $env:HOST_VERSION -InstallerPath $env:HOST_SETUP_PATH -OutDir C:\t\out\winget + . scripts/ci/gitea-release.ps1 + $rid = Ensure-GiteaRelease -Tag $env:GITHUB_REF_NAME -Name $env:GITHUB_REF_NAME -Prerelease 'auto' + foreach ($f in (Get-ChildItem C:\t\out\winget -Filter *.yaml)) { + Upsert-GiteaAsset -ReleaseId $rid -File $f.FullName + } + + # Republish the winget REST source on unom-1 once the release above carries its manifests. + # + # A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do + # not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data + # reads the manifests from the release, so it must not run before they are attached. + winget-source: + needs: package + if: startsWith(gitea.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + # build-data re-derives the WHOLE catalogue from the releases rather than appending this one, + # so the result cannot drift and re-running any tag reproduces it byte for byte. + - name: Build + test the source catalogue + working-directory: packaging/winget/server + run: | + set -euo pipefail + npm install --no-audit --no-fund + node build-data.mjs --out data/data.json + # A wrong response SHAPE does not fail loudly — winget just reports "no package found". + # Gate on the suite before anything reaches the box. + node test.mjs + + # Content only. server.mjs/handler.mjs/compose land via deploy-services.yml, matching how the + # flatpak repo's content and config deploy on separate paths. + - name: Ship the catalogue to unom-1 + uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + port: ${{ secrets.DEPLOY_PORT }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + source: "packaging/winget/server/data/data.json" + target: "~/unom-winget/data" + strip_components: 4 + overwrite: true + + # No restart: server.mjs reloads on mtime change. This only proves the new catalogue is the + # one actually being served, and fails the release if it is not. + - name: Verify the served catalogue + uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + port: ${{ secrets.DEPLOY_PORT }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + script: | + set -euo pipefail + curl -fsS http://127.0.0.1:3240/healthz + echo + curl -fsS -X POST http://127.0.0.1:3240/manifestSearch \ + -H 'content-type: application/json' -d '{"FetchAllManifests":true}' \ + | grep -q "${GITHUB_REF_NAME#v}" \ + || { echo "served catalogue does not contain ${GITHUB_REF_NAME#v}"; exit 1; } + echo "winget source serving ${GITHUB_REF_NAME#v}" + env: + GITHUB_REF_NAME: ${{ gitea.ref_name }} diff --git a/README.md b/README.md index e6c5c205..1c63b029 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel). | **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) | | **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | -| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | +| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) | `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). After install, run `punktfunk-host serve` inside your desktop session (the secure native default; diff --git a/packaging/winget/README.md b/packaging/winget/README.md new file mode 100644 index 00000000..90901588 --- /dev/null +++ b/packaging/winget/README.md @@ -0,0 +1,116 @@ +# winget manifests — Windows host + +The reviewed source of truth for the `unom.PunktfunkHost` winget package. Everything except +`PackageVersion` / `InstallerUrl` / `InstallerSha256` / `ReleaseNotesUrl` is edited **here**; +`scripts/ci/winget-manifest.ps1` only substitutes those four per release, so the switches, +agreements and installation notes stay under normal code review. + +| File | Purpose | +| --- | --- | +| `unom.PunktfunkHost.yaml` | Version manifest — ties the other two together. | +| `unom.PunktfunkHost.installer.yaml` | Installer type, scope, silent switches, `ProductCode`, URL + hash. | +| `unom.PunktfunkHost.locale.en-US.yaml` | User-facing metadata, `Agreements`, `InstallationNotes`. | + +## Why these choices + +- **`InstallerType: inno`, `Scope: machine`, `ElevationRequirement: elevatesSelf`.** The host + registers a SYSTEM service, installs drivers and opens firewall ports; `PrivilegesRequired=admin` + in the `.iss` means Setup raises its own UAC prompt. There is no per-user scope. +- **`ProductCode: {7C9E6A52-…}_is1`** — Inno's ARP key is `_is1`. This is what correlates an + installed host with the package for `winget list` / `winget upgrade`. **It must track `AppId` in + `packaging/windows/punktfunk-host.iss`** — if that GUID ever changes, change it here too or + upgrades silently stop being detected. +- **`interactive` is in `InstallModes`.** `winget install unom.PunktfunkHost --interactive` runs the + full existing wizard: every task checkbox, the web-console password page, the VB-CABLE notice. + Nothing about the installer changes to support it. +- **No `/MERGETASKS` in the silent switches.** A silent install deliberately takes the *same* task + defaults the wizard shows, so the product does not differ by install channel — a per-channel + default is a support trap ("it works when I install it by hand"). The disclosures the wizard puts + on screen are carried by `Agreements` instead, which winget shows *before* install and requires + the user to accept. +- **`UpgradeBehavior: install`** — Inno upgrades in place (`UsePreviousAppDir=yes`). Uninstalling + first would run the `[UninstallRun]` service + driver teardown between versions. + +## Opting out of individual tasks + +Inno's `/MERGETASKS` takes `!` prefixes to deselect a default-checked task. Use `--override` +(replaces winget's switches) rather than `--custom` (appends — you would end up with two +`/MERGETASKS` on one command line): + +```powershell +winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream" +``` + +Task names: `installdriver`, `installgamepad`, `installaudiocable`, `installhdrlayer`, +`gamestream`, `allowpublicfw`, `startservice`, `trayicon`. + +## Two installer behaviours that exist for this path + +Both are in `packaging/windows/punktfunk-host.iss` and both also fix pre-existing bugs on the +plain double-click upgrade path: + +- **`InitializeSetup` uses `SuppressibleMsgBox`, not `MsgBox`.** A plain `MsgBox` ignores + `/SUPPRESSMSGBOXES` and displays even under `/VERYSILENT` — an unattended install on a box that + already runs Sunshine/Apollo would block on an invisible modal dialog. Suppressed it returns + `IDNO`, so that install aborts (Setup exits non-zero) rather than proceeding into the unsupported + dual-host state. +- **`GamestreamParam` is fresh-install-only.** On an upgrade the flag is omitted entirely, which + `service install` reads as "keep host.env as-is". Passing an explicit on/off would rewrite + `PUNKTFUNK_HOST_CMD` whenever it still holds either canonical value — so a silent upgrade, where + no wizard carries the old choice forward, would flip a user's GameStream setting with nothing on + screen. +- **`PublicFwParam` is fresh-install-only too**, and `--allow-public-network` is now tri-state + (`=on` / `=off` / absent → keep the recorded choice, resolved from the `fw-allow-public` marker in + `windows/service.rs`). This task is default-*unchecked*, so without the change a silent upgrade + would have silently **revoked** a Public-network opt-in the user made once. The bare + `--allow-public-network` form still means `on` for existing scripts; a malformed value is a hard + error rather than a fall-through, since a typo'd opt-*out* must never resolve to "keep Public + open". + +## Release flow + +`.gitea/workflows/windows-host.yml` runs on stable `v*` tags only, **after** the installer is +attached to the Gitea release — winget validates the URL and hash, so a manifest must never be +published ahead of its artifact: + +```powershell +scripts/ci/winget-manifest.ps1 -Version 0.19.2 ` + -InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget +``` + +The generated trio is attached to the same release. Canary builds are excluded: winget pins one +immutable artifact per version, so the rolling `canary/` alias has nothing it could point at. + +## Validating a change + +```powershell +winget validate --manifest packaging\winget +winget install --manifest packaging\winget # local install from the manifest +``` + +For a throwaway check, `winget-pkgs`' `Tools\SandboxTest.ps1` runs a manifest in Windows Sandbox. +Note the host needs a real GPU and installs drivers, so a Sandbox run exercises the *manifest* +(download, hash, switches, ARP correlation) rather than a working stream. + +## Publishing + +Through **our own REST source** on unom-1 — see [`server/`](server/README.md). It sits alongside the +docs (3220) and flatpak (3230) services, behind the same edge Caddy; `windows-host.yml` rebuilds and +ships its catalogue on every stable tag, so releasing is one pipeline with no manual step. + +```powershell +winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest # elevated, once +winget install unom.PunktfunkHost +``` + +These manifests stay in winget-pkgs' own format rather than a bespoke one, so submitting upstream +later is a copy, not a rewrite. Two things would need attention on that path: the signing note +below, and `Agreements` being verified-developers-only in the community repo. + +> **Signing.** The installer is currently signed with a self-signed cert (`CN=unom`, subject == +> issuer) and ships a `.cer` users import manually. winget does not sign anything; it downloads and +> runs the same binary, so SmartScreen behaves exactly as it does for a browser download. That is a +> pre-existing condition rather than something winget introduces — but the community repo +> (`microsoft/winget-pkgs`) gates on it via its `Binary-Validation-Error` / +> `Validation-Defender-Error` checks, so a submission there needs a publicly-trusted cert (Azure +> Trusted Signing is the cheap path). A self-hosted source has no such gate. diff --git a/packaging/winget/server/.gitignore b/packaging/winget/server/.gitignore new file mode 100644 index 00000000..08b4f0f0 --- /dev/null +++ b/packaging/winget/server/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +# Generated by build-data.mjs from the published release manifests, then rsynced to the box by +# windows-host.yml on each stable tag. Never hand-edited, and regenerating it is one command, so +# committing it would only produce a merge conflict on every release. +data/ diff --git a/packaging/winget/server/README.md b/packaging/winget/server/README.md new file mode 100644 index 00000000..58d1cd4c --- /dev/null +++ b/packaging/winget/server/README.md @@ -0,0 +1,140 @@ +# Punktfunk winget REST source + +A self-hosted [winget REST source](https://github.com/microsoft/winget-cli-restsource) serving the +Punktfunk Windows host, so users can install and upgrade with `winget` instead of downloading a +`setup.exe` by hand. + +```powershell +# one-time, from an ELEVATED prompt (winget requires admin to add a source) +winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest + +winget install unom.PunktfunkHost # silent, wizard defaults +winget install unom.PunktfunkHost --interactive # the full wizard +winget upgrade unom.PunktfunkHost +``` + +## Why self-hosted rather than the community repo + +`microsoft/winget-pkgs` gates submissions on its `Binary-Validation-Error` / +`Validation-Defender-Error` checks, and the host installer is currently signed with a self-signed +cert (`CN=unom`). That is a pre-existing condition — winget does not sign anything, so SmartScreen +behaves identically whether the installer arrives by browser or by `winget` — but it does block that +route until a publicly trusted cert is in place. A self-hosted source has no such gate, can carry +`Agreements` (verified-developers-only upstream), and can serve channels the community repo would +never accept. + +## What it implements + +The winget client consumes exactly three endpoints. The reference implementation's other twenty +(`/packages/**`) are its **admin** API for mutating a CosmosDB; a catalogue generated at release +time has nothing to mutate, so they are not implemented. + +| Endpoint | Notes | +| --- | --- | +| `GET /information` | Source identity, `ServerSupportedVersions`, declared capabilities. | +| `POST /manifestSearch` | `Query` / `Inclusions` (OR) / `Filters` (AND) / `FetchAllManifests`. **204** on no match — not an empty 200. | +| `GET /packageManifests/{id}` | Full manifest; honours `?Version=`. Identifier match is case-insensitive. | +| `GET /healthz` | Not part of the spec — liveness for compose. | + +`NormalizedPackageNameAndPublisher` is deliberately declared **unsupported**. winget derives it +client-side from ARP entries with 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 silently mis-correlates an installed host. Correlation rides on `ProductCode` instead, +which Inno gives us exactly (`_is1`). + +## Layout + +| File | Role | +| --- | --- | +| `handler.mjs` | The three endpoints. Pure `(Request, catalogue) -> Response`, no I/O, no deps. | +| `server.mjs` | node:http front. Loads the catalogue, reloads on mtime change. | +| `build-data.mjs` | Generates `data/data.json` from the published release manifests. Build-time only. | +| `test.mjs` | 28 checks driving `handler.mjs` directly. | +| `compose.production.yml` | The unom-1 service. | + +## How it runs + +``` +Gitea releases (manifest trio per stable tag) + │ build-data.mjs ← walks ALL releases, no local state [CI, Linux job] + ▼ + data/data.json (~8 KB) ← rsynced to unom-1 on each stable tag + ▼ + bun + server.mjs on unom-1:3240 ← edge Caddy TLS ← winget.punktfunk.unom.io +``` + +Stock `oven/bun` image with the two `.mjs` files bind-mounted — the same shape as the flatpak server +(stock `caddy:2-alpine` + a bind-mounted Caddyfile). There is no image to build, publish or pull, so +a code change deploys by scp plus `docker compose up -d`. + +The server has **zero dependencies**: it reads a generated JSON file. Parsing YAML and talking to +Gitea is `build-data.mjs`'s job, and that runs in CI. Only that build step needs `node_modules`. + +**Content and config deploy separately**, matching the flatpak repo: + +- `deploy-services.yml` (`winget` job) ships `compose.production.yml` + the two `.mjs` files. +- `windows-host.yml` (`winget-source` job) builds and ships `data/data.json` on each stable tag. + +`server.mjs` reloads the catalogue when its mtime changes, so publishing a release needs no restart. +A half-written file mid-rsync keeps the previous catalogue rather than taking the source down. + +### Why the catalogue is derived from releases + +`build-data.mjs` walks the Gitea releases rather than reading local files. That is what makes +`winget install --version` and `winget upgrade` work: winget resolves both against the version +*list*, so a source that only knew the newest release could neither pin an older version nor show an +upgrade path from one. + +It also means the source holds no state — re-running the build reproduces the same `data.json`, so a +lost deployment is one command away and nothing can drift. + +## Local development + +```bash +npm install +npm run build:local # data/data.json from ../*.yaml (single version — fine for dev) +npm test # 28 checks, no network, no server +npm start # http://localhost:3240 +``` + +`npm test` is the gate that matters. A malformed response does not fail loudly — winget reports "no +package found", so a shape regression looks like a missing package rather than a broken source. CI +runs it before anything reaches the box. + +Note a real `winget source add` will not accept a local instance: it requires HTTPS with a publicly +trusted certificate, which is what the edge vhost provides in production. + +## First-time setup + +1. **DNS** — `winget.punktfunk.unom.io` → the unom-1 hcloud box, in the `unom.io` Cloudflare zone + (DNS-only, same as `docs`). ✅ *done* +2. **Caddy vhost** on unom-1, next to the existing `docs.punktfunk.unom.io` block: + + ```caddyfile + winget.punktfunk.unom.io { + reverse_proxy 127.0.0.1:3240 + } + ``` + + Until this exists the hostname resolves but the TLS handshake fails — Caddy has no certificate + for a name it does not serve. That is the expected state, not a broken deploy. +3. Run `deploy-services.yml` to place the compose file and start the container. It serves 503 until + a catalogue exists — expected, and visible on `/healthz`. +4. Publish a stable tag, or build and ship the catalogue by hand: + +```bash +cd packaging/winget/server && npm install && npm run build && npm test +scp data/data.json @:~/unom-winget/data/data.json +``` + +Then verify from anywhere: + +```bash +curl -s https://winget.punktfunk.unom.io/information | jq . +curl -s -X POST https://winget.punktfunk.unom.io/manifestSearch \ + -H 'content-type: application/json' -d '{"FetchAllManifests":true}' | jq '.Data[].PackageIdentifier' +``` + +> Releases published before winget support shipped have no manifests attached, so `npm run build` +> skips them. Until the first stable tag lands with them, build with `--local ..` to serve the +> checked-in manifests, or backfill the manifest trio onto an existing release. diff --git a/packaging/winget/server/build-data.mjs b/packaging/winget/server/build-data.mjs new file mode 100644 index 00000000..766a94b4 --- /dev/null +++ b/packaging/winget/server/build-data.mjs @@ -0,0 +1,155 @@ +// Build src/data.json — the payload the Worker serves — from published winget manifests. +// +// Source of truth is the Gitea RELEASES, not local files: windows-host.yml attaches the manifest +// trio to each stable tag, so walking releases yields every version the source should offer. That +// matters because winget resolves `--version` and `upgrade` against the version LIST; a source that +// only knows the newest release can neither pin an older one nor show an upgrade path from it. +// +// Deriving from releases also means this script holds no state. Re-running it reproduces the same +// data.json, so a lost deployment is one command away and there is no accumulated file to drift. +// +// node build-data.mjs # from Gitea releases (what CI runs) +// node build-data.mjs --local ../ # from a local manifest dir (dev, single version) +// node build-data.mjs --out src/data.json +// +// The `yaml` dep is a BUILD-time dependency only — the Worker ships data.json, never a parser. +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const API = process.env.PF_GITEA_API ?? "https://git.unom.io/api/v1"; +const REPO = process.env.PF_GITEA_REPO ?? "unom/punktfunk"; +const PACKAGE_ID = "unom.PunktfunkHost"; +const SOURCE_IDENTIFIER = process.env.PF_SOURCE_ID ?? "unom.punktfunk"; +// The API contract this source implements. Advertised via /information so a future client can +// negotiate; bump only alongside a real change to the response shapes. +const SERVER_SUPPORTED_VERSIONS = ["1.1.0"]; + +const args = process.argv.slice(2); +const argVal = (flag) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +}; +const outPath = resolve(HERE, argVal("--out") ?? "src/data.json"); +const localDir = argVal("--local"); + +/** One version's three manifests -> the shape the Worker serves. */ +function toVersionEntry(installer, locale, productCodeFallback) { + // The REST manifest nests DefaultLocale + Installers under each version, whereas the YAML splits + // them across files keyed by PackageVersion. Strip the per-file bookkeeping that does not belong + // in the merged object. + const { PackageIdentifier: _i, PackageVersion, ManifestType: _t, ManifestVersion: _v, Installers, ...installerRest } = installer; + const { PackageIdentifier: _i2, PackageVersion: _pv, ManifestType: _t2, ManifestVersion: _v2, PackageLocale, ...localeRest } = locale; + + // Installer-level fields are inherited by each entry in Installers unless overridden there. The + // REST shape has no inheritance, so fold the parent down into every installer. + const installers = (Installers ?? []).map((inst) => ({ ...installerRest, ...inst })); + + return { + PackageVersion, + DefaultLocale: { PackageLocale, ...localeRest }, + Installers: installers, + ProductCodes: [installer.ProductCode ?? productCodeFallback].filter(Boolean), + }; +} + +async function fetchJson(url) { + const res = await fetch(url, { headers: { accept: "application/json" } }); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return res.json(); +} + +async function fetchText(url) { + const res = await fetch(url); + if (!res.ok) throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + return res.text(); +} + +async function versionsFromReleases() { + const releases = await fetchJson(`${API}/repos/${REPO}/releases?limit=100`); + const out = []; + for (const rel of releases) { + if (rel.draft) continue; + const assets = rel.assets ?? []; + const find = (suffix) => + assets.find((a) => a.name === `${PACKAGE_ID}${suffix}`)?.browser_download_url; + const installerUrl = find(".installer.yaml"); + const localeUrl = find(".locale.en-US.yaml"); + // Releases from before winget support shipped have no manifests — skip, don't fail. + if (!installerUrl || !localeUrl) continue; + const [installer, locale] = await Promise.all([ + fetchText(installerUrl).then(parseYaml), + fetchText(localeUrl).then(parseYaml), + ]); + out.push({ entry: toVersionEntry(installer, locale), locale, prerelease: rel.prerelease }); + console.log(` + ${rel.tag_name} (${installer.PackageVersion})`); + } + return out; +} + +function versionsFromLocal(dir) { + const d = resolve(HERE, dir); + const names = readdirSync(d); + const read = (suffix) => { + const n = names.find((x) => x === `${PACKAGE_ID}${suffix}`); + if (!n) throw new Error(`missing ${PACKAGE_ID}${suffix} in ${d}`); + return parseYaml(readFileSync(join(d, n), "utf8")); + }; + const installer = read(".installer.yaml"); + const locale = read(".locale.en-US.yaml"); + console.log(` + local (${installer.PackageVersion})`); + return [{ entry: toVersionEntry(installer, locale), locale, prerelease: false }]; +} + +/** Newest first, so winget's "latest" is the head of the list. */ +function compareVersionsDesc(a, b) { + const parts = (v) => v.PackageVersion.split(/[.\-+]/).map((p) => (/^\d+$/.test(p) ? Number(p) : p)); + const [pa, pb] = [parts(a), parts(b)]; + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const x = pa[i] ?? 0; + const y = pb[i] ?? 0; + if (x === y) continue; + // A numeric segment outranks a string one (0.20.0 > 0.20.0-rc1). + if (typeof x === typeof y) return x > y ? -1 : 1; + return typeof x === "number" ? -1 : 1; + } + return 0; +} + +console.log(localDir ? `building from local dir ${localDir}` : `building from ${API}/repos/${REPO} releases`); +const collected = localDir ? versionsFromLocal(localDir) : await versionsFromReleases(); +if (collected.length === 0) { + throw new Error("no winget manifests found - refusing to write an empty source"); +} + +const versions = collected.map((c) => c.entry).sort(compareVersionsDesc); +// Package-level search metadata comes from the NEWEST release's locale manifest, so a rename or a +// retagged description takes effect without rewriting history. +const newestLocale = collected.sort((a, b) => compareVersionsDesc(a.entry, b.entry))[0].locale; + +const data = { + sourceIdentifier: SOURCE_IDENTIFIER, + serverSupportedVersions: SERVER_SUPPORTED_VERSIONS, + packages: [ + { + PackageIdentifier: PACKAGE_ID, + PackageName: newestLocale.PackageName, + Publisher: newestLocale.Publisher, + Moniker: newestLocale.Moniker ?? null, + Tags: newestLocale.Tags ?? [], + // Union across versions: an installed 0.19.2 must still correlate after 0.20.0 ships. + ProductCodes: [...new Set(versions.flatMap((v) => v.ProductCodes))], + Versions: versions.map((v) => ({ + PackageVersion: v.PackageVersion, + ProductCodes: v.ProductCodes, + })), + Manifest: { PackageIdentifier: PACKAGE_ID, Versions: versions }, + }, + ], +}; + +mkdirSync(dirname(outPath), { recursive: true }); +writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`); +console.log(`wrote ${outPath} (${versions.length} version(s): ${versions.map((v) => v.PackageVersion).join(", ")})`); diff --git a/packaging/winget/server/compose.production.yml b/packaging/winget/server/compose.production.yml new file mode 100644 index 00000000..db4abd1d --- /dev/null +++ b/packaging/winget/server/compose.production.yml @@ -0,0 +1,41 @@ +# winget REST source for the punktfunk Windows host, on unom-1 (Hetzner Cloud). +# +# Caddy on that same box terminates TLS for winget.punktfunk.unom.io and reverse_proxies to :3240, +# exactly as it already does for docs.punktfunk.unom.io -> :3220. This inner service speaks plain +# HTTP and is not published beyond the box. `winget source add` refuses anything but HTTPS with a +# publicly trusted certificate, so that edge vhost is a hard requirement, not decoration. +# +# (Note: the sibling docs/flatpak compose files still carry stale comments about a +# `home-reverse-proxy-1` and 192.168.50.50 from an earlier home-lab topology. The public hostnames +# resolve straight to the hcloud box and are served by Caddy there — no local proxy is involved.) +# +# Stock bun image + bind-mounted sources, mirroring the flatpak server's stock-caddy approach: no +# image to build, publish or pull, so a code change deploys by scp + `docker compose up -d`. +# +# ./data/data.json is NOT shipped by deploy-services.yml — windows-host.yml rsyncs it on each stable +# tag (the same split the flatpak repo uses: config deploys separately from content). server.mjs +# reloads it on mtime change, so publishing a release needs no restart here. +name: punktfunk-winget-prod +services: + winget: + image: oven/bun:1-alpine + restart: unless-stopped + command: ["bun", "run", "/app/server.mjs"] + working_dir: /app + environment: + PORT: "3240" + PF_WINGET_DATA: /app/data/data.json + ports: + - "3240:3240" + volumes: + - ./server.mjs:/app/server.mjs:ro + - ./handler.mjs:/app/handler.mjs:ro + - ./data:/app/data:ro + healthcheck: + # Fails while the catalogue is missing or unparseable, which is otherwise invisible — a broken + # source answers requests fine and just reports "no package found" to the client. + test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3240/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 60s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/packaging/winget/server/handler.mjs b/packaging/winget/server/handler.mjs new file mode 100644 index 00000000..2adb1e8d --- /dev/null +++ b/packaging/winget/server/handler.mjs @@ -0,0 +1,204 @@ +// 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 }; diff --git a/packaging/winget/server/package-lock.json b/packaging/winget/server/package-lock.json new file mode 100644 index 00000000..0519d34d --- /dev/null +++ b/packaging/winget/server/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "punktfunk-winget-source", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "punktfunk-winget-source", + "devDependencies": { + "yaml": "^2.5.0" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/packaging/winget/server/package.json b/packaging/winget/server/package.json new file mode 100644 index 00000000..c95e7b86 --- /dev/null +++ b/packaging/winget/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "punktfunk-winget-source", + "private": true, + "type": "module", + "description": "winget REST source for the Punktfunk Windows host (read path only).", + "scripts": { + "build": "node build-data.mjs --out data/data.json", + "build:local": "node build-data.mjs --local .. --out data/data.json", + "test": "node test.mjs", + "start": "node server.mjs" + }, + "devDependencies": { + "yaml": "^2.5.0" + } +} diff --git a/packaging/winget/server/server.mjs b/packaging/winget/server/server.mjs new file mode 100644 index 00000000..def18c9f --- /dev/null +++ b/packaging/winget/server/server.mjs @@ -0,0 +1,109 @@ +// 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))); +} diff --git a/packaging/winget/server/test.mjs b/packaging/winget/server/test.mjs new file mode 100644 index 00000000..a3c3f6f4 --- /dev/null +++ b/packaging/winget/server/test.mjs @@ -0,0 +1,122 @@ +// Drives the Worker's handle() directly — no Workers runtime, no network, no deploy. +// +// This is the gate that catches a broken source BEFORE winget sees it. A source that returns the +// wrong shape does not error loudly: winget reports "no package found" and the failure looks like a +// missing package rather than a malformed response. +// +// node test.mjs (run `npm run build:local` first so data/data.json exists) +import { readFileSync } from "node:fs"; +import { handle } from "./handler.mjs"; + +// The catalogue is an argument, not an import — same way server.mjs passes it — so this suite runs +// against a generated data.json with no server, no network and no container. +const DATA = JSON.parse(readFileSync(new URL("./data/data.json", import.meta.url), "utf8")); +const BASE = "https://winget.example/"; +let failed = 0; + +function check(name, cond, detail = "") { + if (cond) { + console.log(`ok ${name}`); + } else { + failed++; + console.log(`FAIL ${name}${detail ? ` — ${detail}` : ""}`); + } +} + +const get = (p) => handle(new Request(BASE + p, { method: "GET" }), DATA); +const post = (p, body) => + handle(new Request(BASE + p, { method: "POST", body: JSON.stringify(body), headers: { "content-type": "application/json" } }), DATA); + +// ── /information ──────────────────────────────────────────────────────────────────────────────── +{ + const res = await get("information"); + const body = await res.json(); + check("information: 200", res.status === 200, `got ${res.status}`); + check("information: wrapped in Data", !!body.Data); + check("information: has SourceIdentifier", typeof body.Data?.SourceIdentifier === "string"); + check("information: advertises a version", Array.isArray(body.Data?.ServerSupportedVersions) && body.Data.ServerSupportedVersions.length > 0); + check( + "information: declares NormalizedPackageNameAndPublisher unsupported", + body.Data?.UnsupportedPackageMatchFields?.includes("NormalizedPackageNameAndPublisher"), + ); +} + +// ── /manifestSearch ───────────────────────────────────────────────────────────────────────────── +{ + const res = await post("manifestSearch", { Query: { KeyWord: "punktfunk", MatchType: "Substring" } }); + const body = await res.json(); + check("search substring 'punktfunk': 200", res.status === 200, `got ${res.status}`); + check("search: Data is a non-empty array", Array.isArray(body.Data) && body.Data.length > 0); + const hit = body.Data?.[0]; + check("search: PackageIdentifier present", hit?.PackageIdentifier === "unom.PunktfunkHost"); + check("search: PackageName + Publisher present (schema-required)", !!hit?.PackageName && !!hit?.Publisher); + check("search: Versions non-empty (minItems 1)", Array.isArray(hit?.Versions) && hit.Versions.length > 0); + check("search: version carries ProductCodes for ARP correlation", Array.isArray(hit?.Versions?.[0]?.ProductCodes) && hit.Versions[0].ProductCodes.length > 0); +} +{ + const res = await post("manifestSearch", { Query: { KeyWord: "definitely-not-a-package", MatchType: "Substring" } }); + check("search miss: 204 No Content, not an empty 200", res.status === 204, `got ${res.status}`); +} +{ + const res = await post("manifestSearch", { FetchAllManifests: true }); + const body = await res.json(); + check("FetchAllManifests returns everything", res.status === 200 && body.Data.length >= 1); +} +{ + const res = await post("manifestSearch", { + Inclusions: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "unom.PunktfunkHost", MatchType: "CaseInsensitive" } }], + }); + check("Inclusions on PackageIdentifier match", res.status === 200); +} +{ + // A filter that cannot hold must exclude the package (Filters are AND). + const res = await post("manifestSearch", { + Filters: [{ PackageMatchField: "PackageIdentifier", RequestMatch: { KeyWord: "some.OtherPackage", MatchType: "Exact" } }], + }); + check("non-matching Filter excludes (AND semantics)", res.status === 204, `got ${res.status}`); +} +{ + // An unsupported field must never accidentally match — we told the client we don't serve it. + const res = await post("manifestSearch", { + Filters: [{ PackageMatchField: "NormalizedPackageNameAndPublisher", RequestMatch: { KeyWord: "punktfunkhostunom", MatchType: "Exact" } }], + }); + check("unsupported match field yields no match", res.status === 204, `got ${res.status}`); +} +{ + const res = await handle(new Request(BASE + "manifestSearch", { method: "GET" }), DATA); + check("manifestSearch rejects GET", res.status === 405, `got ${res.status}`); +} + +// ── /packageManifests/{id} ────────────────────────────────────────────────────────────────────── +{ + const res = await get("packageManifests/unom.PunktfunkHost"); + const body = await res.json(); + check("manifest: 200", res.status === 200, `got ${res.status}`); + const v = body.Data?.Versions?.[0]; + check("manifest: Versions present", !!v); + check("manifest: version has DefaultLocale", !!v?.DefaultLocale?.PackageName); + check("manifest: version has Installers", Array.isArray(v?.Installers) && v.Installers.length > 0); + const inst = v?.Installers?.[0]; + check("manifest: installer has URL + sha256", !!inst?.InstallerUrl && /^[0-9A-F]{64}$/i.test(inst?.InstallerSha256 ?? "")); + check("manifest: installer-level fields folded into the entry", inst?.InstallerType === "inno" && inst?.Scope === "machine"); + check("manifest: ProductCode preserved for correlation", !!inst?.ProductCode || !!v?.ProductCodes?.length); +} +{ + const res = await get("packageManifests/UNOM.PUNKTFUNKHOST"); + check("manifest: PackageIdentifier is case-insensitive", res.status === 200, `got ${res.status}`); +} +{ + const res = await get("packageManifests/nope.NotHere"); + check("manifest: unknown id -> 404", res.status === 404, `got ${res.status}`); +} +{ + const res = await get("packageManifests/unom.PunktfunkHost?Version=0.0.0-nope"); + check("manifest: unknown ?Version -> 404", res.status === 404, `got ${res.status}`); +} +{ + const res = await get("no/such/route"); + check("unknown route -> 404", res.status === 404, `got ${res.status}`); +} + +console.log(failed === 0 ? "\nall checks passed" : `\n${failed} CHECK(S) FAILED`); +process.exit(failed === 0 ? 0 : 1); diff --git a/packaging/winget/unom.PunktfunkHost.installer.yaml b/packaging/winget/unom.PunktfunkHost.installer.yaml new file mode 100644 index 00000000..42fff801 --- /dev/null +++ b/packaging/winget/unom.PunktfunkHost.installer.yaml @@ -0,0 +1,53 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.installer.1.6.0.schema.json +# +# Installer manifest for the Windows host (packaging/windows/punktfunk-host.iss -> Inno Setup 6). +# +# The host is a machine-wide, elevated install: it registers a SYSTEM service, installs the +# pf-vdisplay + gamepad drivers, and opens firewall ports. There is no per-user scope. +PackageIdentifier: unom.PunktfunkHost +PackageVersion: 0.19.2 + +InstallerType: inno +Scope: machine +# PrivilegesRequired=admin in the .iss -> Setup raises its own UAC prompt. +ElevationRequirement: elevatesSelf +# Windows 11 22H2 floor: pf-vdisplay is built against IddCx 1.10 with no runtime downgrade, so on +# anything older the device fails to start with Code 10 (see the MinVersion gate in the .iss). +MinimumOSVersion: 10.0.22621.0 + +InstallModes: + # interactive keeps the FULL wizard — every task checkbox, the web-console password page, and the + # VB-CABLE notice text. `winget install unom.PunktfunkHost --interactive`. + - interactive + - silent + - silentWithProgress + +InstallerSwitches: + # Spelled out rather than relying on winget's built-in `inno` defaults, so the unattended + # behaviour is reviewable here. No /MERGETASKS: a silent install deliberately takes the SAME + # task defaults the wizard shows, so the product does not differ by install channel. The + # disclosures the wizard puts on screen are carried by Agreements/InstallationNotes in the + # locale manifest instead. + # + # To opt out of individual tasks, callers use Inno's ! negation via --override, e.g. + # winget install unom.PunktfunkHost --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream" + # Task names: installdriver, installgamepad, installaudiocable, installhdrlayer, + # gamestream, allowpublicfw, startservice, trayicon + Silent: /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- + SilentWithProgress: /SILENT /SUPPRESSMSGBOXES /NORESTART /SP- + Log: /LOG="|LOGPATH|" + +# Inno writes its ARP entry under _is1; this is what correlates an installed host with the +# package for `winget list` / `winget upgrade`. Must track AppId in the .iss. +ProductCode: '{7C9E6A52-1F4B-4E8D-A3C7-2B5D8F1E0A93}_is1' +# Inno upgrades in place (UsePreviousAppDir=yes) — do not uninstall first, that would run the +# [UninstallRun] service/driver teardown between versions. +UpgradeBehavior: install + +Installers: + - Architecture: x64 + InstallerUrl: https://git.unom.io/unom/punktfunk/releases/download/v0.19.2/punktfunk-host-setup-0.19.2.exe + InstallerSha256: 96964117125BFD5AC4556987DAFAA3966A4A57BBC29C838803857852D595BF4D + +ManifestType: installer +ManifestVersion: 1.6.0 diff --git a/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml b/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml new file mode 100644 index 00000000..8d0aaeef --- /dev/null +++ b/packaging/winget/unom.PunktfunkHost.locale.en-US.yaml @@ -0,0 +1,72 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.defaultLocale.1.6.0.schema.json +PackageIdentifier: unom.PunktfunkHost +PackageVersion: 0.19.2 +PackageLocale: en-US + +Publisher: unom +PublisherUrl: https://git.unom.io/unom/punktfunk +PublisherSupportUrl: https://git.unom.io/unom/punktfunk/issues +PackageName: Punktfunk Host +PackageUrl: https://docs.punktfunk.unom.io/docs/windows-host +License: MIT OR Apache-2.0 +LicenseUrl: https://git.unom.io/unom/punktfunk/src/branch/main/LICENSE-MIT +Copyright: Copyright (c) unom +ShortDescription: Low-latency game-streaming host — stream your PC's games to any Punktfunk or Moonlight client. +Description: |- + Punktfunk Host turns a Windows 11 PC into a low-latency game-streaming server. It captures the + desktop or a dedicated virtual display, encodes with NVENC / AMF / QSV / Vulkan, and streams over + its native QUIC protocol to Punktfunk clients on Windows, macOS, iOS, tvOS, Android and Linux — + or over GameStream to stock Moonlight clients. + + The installer bundles the pf-vdisplay virtual display driver (native-resolution and HDR streaming + without a physical monitor), virtual DualSense / DualShock 4 / Xbox 360 gamepads, and a web + management console served on port 47992. +Moniker: punktfunk-host +Tags: + - game-streaming + - remote-desktop + - moonlight + - gamestream + - streaming + - remote-play +ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/branch/main/docs/releases/v0.19.2.md +Documentations: + - DocumentLabel: Windows Host Setup + DocumentUrl: https://docs.punktfunk.unom.io/docs/windows-host + +# Shown BEFORE download/install; the user must accept or the install does not proceed. This is what +# carries — on the unattended path, where no wizard page is on screen — the disclosures the wizard +# puts in its task text. VB-Audio's bundling grant specifically requires the end user to see +# VB-CABLE's origin + donationware status at install time. +Agreements: + - AgreementLabel: Bundled virtual audio (VB-CABLE by VB-Audio) + Agreement: >- + Punktfunk's streaming microphone uses VB-CABLE by VB-Audio (www.vb-cable.com), which this + installer bundles and installs. VB-CABLE is donationware — all participations welcome. It is + redistributed under VB-Audio's bundling grant; the full notice is installed to + %ProgramFiles%\punktfunk\licenses\VB-CABLE-NOTICE.txt. + AgreementUrl: https://vb-audio.com/Cable/ + - AgreementLabel: GameStream (Moonlight) compatibility is enabled by default + Agreement: >- + The default install enables GameStream so stock Moonlight clients can connect. GameStream uses + legacy plain-HTTP pairing and is intended for trusted LANs only. To install without it, use + --override "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /MERGETASKS=!gamestream", or run + --interactive and clear the GameStream checkbox. It can also be changed later via + PUNKTFUNK_HOST_CMD in %ProgramData%\punktfunk\host.env. + AgreementUrl: https://docs.punktfunk.unom.io/docs/windows-host + +# Displayed after the install completes. The console password is generated per install, so it can +# only be pointed at, never printed here — this text is fixed at publish time. +InstallationNotes: |- + Web management console: https://:47992 + The login password was generated during installation and is stored in + %ProgramData%\punktfunk\web-password (edit that file to change it). + + Configuration: %ProgramData%\punktfunk\host.env + Logs: %ProgramData%\punktfunk\logs\ + + The host service starts automatically at boot. `punktfunk-host` is on the machine PATH — run + `punktfunk-host service status` from an elevated prompt to check it. + +ManifestType: defaultLocale +ManifestVersion: 1.6.0 diff --git a/packaging/winget/unom.PunktfunkHost.yaml b/packaging/winget/unom.PunktfunkHost.yaml new file mode 100644 index 00000000..014f3044 --- /dev/null +++ b/packaging/winget/unom.PunktfunkHost.yaml @@ -0,0 +1,10 @@ +# yaml-language-server: $schema=https://aka.ms/winget-manifest.version.1.6.0.schema.json +# +# Version manifest — the pointer that ties the installer + locale manifests together. +# Generated per stable tag by scripts/ci/winget-manifest.ps1; this checked-in copy is the +# template + the reference for what a release looks like. Validate with `winget validate`. +PackageIdentifier: unom.PunktfunkHost +PackageVersion: 0.19.2 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.6.0 diff --git a/scripts/ci/winget-manifest.ps1 b/scripts/ci/winget-manifest.ps1 new file mode 100644 index 00000000..2cb4e910 --- /dev/null +++ b/scripts/ci/winget-manifest.ps1 @@ -0,0 +1,75 @@ +# Emit the winget manifest trio for a released Windows host installer. +# +# winget pins the installer by SHA256, so a manifest is only ever valid for ONE immutable artifact. +# The `latest/` channel alias in the generic registry is therefore NOT usable here — this always +# writes the versioned Gitea release URL, which the registry makes immutable (a re-upload 409s). +# +# The three files are templated from packaging/winget/, which holds the reviewed, hand-maintained +# copy: everything except PackageVersion / InstallerUrl / InstallerSha256 is edited THERE, not here. +# That keeps the switches, agreements and installation notes under normal code review rather than +# buried in a generator. +# +# Usage (from windows-host.yml, stable tags only): +# & scripts/ci/winget-manifest.ps1 -Version 0.19.2 -InstallerPath C:\t\out\punktfunk-host-setup-0.19.2.exe -OutDir C:\t\out\winget +# +# Emits: /unom.PunktfunkHost.yaml +# /unom.PunktfunkHost.installer.yaml +# /unom.PunktfunkHost.locale.en-US.yaml +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Version, + # The packed setup.exe — hashed here so the manifest can never disagree with what shipped. + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$OutDir, + [string]$TemplateDir = (Join-Path $PSScriptRoot '..\..\packaging\winget'), + # Overridable so a fork/mirror can retarget without editing the templates. + [string]$UrlBase = 'https://git.unom.io/unom/punktfunk/releases/download' +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path $InstallerPath)) { throw "installer not found: $InstallerPath" } +$TemplateDir = (Resolve-Path $TemplateDir).Path + +# winget's own tooling emits UPPERCASE hex; match it so a manifest diff against a wingetcreate- +# generated one is empty rather than a case-only churn. +$sha = (Get-FileHash -Path $InstallerPath -Algorithm SHA256).Hash.ToUpperInvariant() +$url = "$UrlBase/v$Version/$(Split-Path $InstallerPath -Leaf)" +Write-Host "winget manifest: version=$Version sha256=$sha" +Write-Host " url=$url" + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# PackageVersion appears in all three files; InstallerUrl/Sha256 only in the installer manifest. +# Anchored replacements (line-leading key) so a version string inside prose — e.g. the release-notes +# URL in the locale manifest — is never rewritten by accident. +$files = @( + 'unom.PunktfunkHost.yaml', + 'unom.PunktfunkHost.installer.yaml', + 'unom.PunktfunkHost.locale.en-US.yaml' +) +foreach ($f in $files) { + $src = Join-Path $TemplateDir $f + if (-not (Test-Path $src)) { throw "template missing: $src" } + $text = Get-Content -Path $src -Raw + + $text = [regex]::Replace($text, '(?m)^PackageVersion:.*$', "PackageVersion: $Version") + $text = [regex]::Replace($text, '(?m)^(\s*)InstallerUrl:.*$', "`${1}InstallerUrl: $url") + $text = [regex]::Replace($text, '(?m)^(\s*)InstallerSha256:.*$', "`${1}InstallerSha256: $sha") + # The release-notes link is per-version and lives outside the anchored keys above. + $text = [regex]::Replace($text, '(?m)^ReleaseNotesUrl:.*$', + "ReleaseNotesUrl: https://git.unom.io/unom/punktfunk/src/tag/v$Version/docs/releases/v$Version.md") + + $dest = Join-Path $OutDir $f + # UTF-8 *without* BOM: winget's YAML parser rejects a BOM'd manifest. + [IO.File]::WriteAllText($dest, $text, (New-Object Text.UTF8Encoding $false)) + Write-Host " wrote $dest" +} + +# Fail loudly if a template ever loses one of the fields we rewrite — a silently un-substituted +# manifest would publish the PREVIOUS release's hash, which winget would then refuse to install. +$inst = Get-Content -Path (Join-Path $OutDir 'unom.PunktfunkHost.installer.yaml') -Raw +foreach ($needle in @($Version, $sha, $url)) { + if ($inst -notmatch [regex]::Escape($needle)) { throw "substitution failed - '$needle' missing from the installer manifest" } +} +Write-Host "winget manifests ready in $OutDir"