feat(packaging/winget): install the Windows host with winget, from our own source
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
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>
This commit is contained in:
@@ -99,3 +99,41 @@ jobs:
|
|||||||
mkdir -p ~/unom-flatpak/site/repo
|
mkdir -p ~/unom-flatpak/site/repo
|
||||||
cd ~/unom-flatpak
|
cd ~/unom-flatpak
|
||||||
docker compose -f compose.production.yml up -d
|
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"
|
||||||
|
|||||||
@@ -359,3 +359,82 @@ jobs:
|
|||||||
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
foreach ($f in @($env:HOST_SETUP_PATH, $env:HOST_CER_PATH)) {
|
||||||
if ($f -and (Test-Path $f)) { Upsert-GiteaAsset -ReleaseId $rid -File $f }
|
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 }}
|
||||||
|
|||||||
@@ -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) |
|
| **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) |
|
| **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) |
|
| **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).
|
`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;
|
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
|
||||||
|
|||||||
@@ -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 `<AppId>_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.
|
||||||
@@ -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/
|
||||||
@@ -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 (`<AppId>_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 <deploy-user>@<unom-1>:~/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.
|
||||||
@@ -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(", ")})`);
|
||||||
@@ -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
|
||||||
@@ -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 };
|
||||||
+29
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 <AppId>_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
|
||||||
@@ -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://<this-PC>: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
|
||||||
@@ -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
|
||||||
@@ -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: <OutDir>/unom.PunktfunkHost.yaml
|
||||||
|
# <OutDir>/unom.PunktfunkHost.installer.yaml
|
||||||
|
# <OutDir>/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"
|
||||||
Reference in New Issue
Block a user