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
|
||||
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"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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