Reported from the field: "is there a possibility of renaming the moonlight paired
devices? as they're all named CN=NVidia Gamestream Client". They are, and it is not a
display bug — every moonlight-common-c client self-signs with that same fixed subject,
so the certificate carries no device identity at all. Until now the console listed that
string for every Moonlight row, which means a user with a phone, a TV and a Switch saw
three identical rows and had nothing but a fingerprint prefix to tell them apart — most
sharply when deciding which one to unpair.
The name is an operator-supplied label, stored host-side keyed by fingerprint:
* `client-labels.json`, a SIDECAR to `paired.json` rather than a field inside it.
`paired.json` is a bare `Vec<Vec<u8>>` of DERs, so giving it a shape would be a
migration on the one file that decides who may connect — and a label is not part of
that trust decision, so a corrupt or missing label file must never be able to lock
anyone out. Same atomic temp-file + rename as `save_paired`.
* `PATCH /api/v1/clients/{fingerprint}` sets or clears it; `GET /clients` grows a
`label`. A whitespace-only body clears rather than storing a blank name, and only an
already-paired fingerprint may be named (a label for an unknown one would be
invisible and never cleaned up). Unpairing forgets the label, so the file cannot grow
without bound and a re-pairing of the same certificate starts unnamed.
* Scrubbing reuses `native_pairing::sanitize_device_name` rather than growing a second
one: it already strips C0/C1 controls and Unicode bidi overrides and caps at 64.
That is not cosmetic here — the label is the ONLY thing distinguishing two paired
devices in the console, so an unscrubbed one could dress a stranger's device up as
the operator's TV and be spared an unpair on that basis. For the same reason the new
route takes the plugin/cert lanes of the DELETE beside it (neither may reach it),
not the roster GET's read permission; the lane test now pins that.
* Console: a pencil on Moonlight rows opens the existing `promptText` dialog seeded
with the current label (not the `CN=…` fallback, or every rename would start by
deleting boilerplate). Native rows keep their pairing-supplied name and get no
pencil.
Test: one round trip through the API — name it, see it in the list, watch the bidi
override and the whitespace collapse get scrubbed, clear it two ways, reject a
malformed and an unpaired fingerprint, and assert the unpair forgot it on disk.
VERIFIED on .173 (the Windows box, since punktfunk-host does not build on macOS):
`cargo test -p punktfunk-host mgmt::` → 58 passed, including the new
`client_label_round_trips_scrubs_and_is_forgotten_on_unpair` and both guardrails that
caught this work in progress (`every_route_is_classified_for_the_plugin_and_cert_lanes`
and `openapi_document_is_complete_and_checked_in`). Web `tsc --noEmit` clean.
Two notes on the diff, both PRE-EXISTING and verified as such rather than assumed:
* `sdk/src/gen/punktfunk.ts` is bigger than this feature. Regenerating it from the
UNCHANGED committed spec already produces a ~700-line diff, i.e. the checked-in copy
had drifted from its own pinned generator — nothing in CI regenerates or verifies
it. This lands the clean regeneration rather than hand-patching generated code.
* `api/openapi.json` was regenerated on Windows, not CI's Linux. Checked structurally
before committing: the only differences are `PATCH /clients/{fingerprint}`, the
`RenameClient` schema and `PairedClient.label` — no OS-driven drift.
Unrelated and NOT touched: `mgmt::tests::display_monitors_answers_even_with_no_compositor`
fails on Windows, at HEAD as well. It answers `compositor="windows", monitors=[],
error=null`, and the test's escape hatches only cover gamescope, an absent compositor or
an error. Either the test needs a Windows arm or Windows display enumeration is returning
nothing it should — that is a real question, so it is left for someone to answer rather
than papered over here.
punktfunk-docs
The Punktfunk documentation site: Fumadocs on TanStack Start (Vite + Nitro/bun preset).
Content lives in content/docs/ as .md/.mdx. This site is the source of truth
for the user-facing guides; design rationale lives in the internal punktfunk-planning repo, and
READMEs and the marketing site link here instead of restating anything — see "Where facts live" in
CONTRIBUTING.md. Pages serve one of two audiences, not both at once: the
get-started track (quickstart, install, pairing) assumes no Linux expertise — short pages, one
task each, happy path only; the reference track (configuration, CLI, API, per-compositor
pages) is allowed to be dense.
API reference
/api renders the host's management REST API as an interactive
Scalar reference (linked from the top nav, the docs
sidebar, and the landing page). It reads public/openapi.json — a
snapshot of the repo's generated spec. Refresh it after a management-API change:
# from the repo root — regenerate the spec, then copy the snapshot in:
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
CI keeps the pair honest: the docs-drift job fails unless the snapshot is a byte-for-byte copy
of api/openapi.json, and the rust job regenerates the spec and diffs it against the committed
one — so a management-API change can't publish stale API docs any more, it fails CI until you run
the two commands above.
Install commands and ports
src/data/platforms.json is a byte-identical snapshot of the repo-root
data/platforms.json — the single source for install commands, repo
URLs, port facts and the Sunshine/Apollo/Vibeshine conflict facts. The <Install platform="…" />
and <Ports /> MDX components (src/components/platforms.tsx) render from it, so no page restates
a command or a port. It's a snapshot for the same reason as openapi.json (the Docker build context
is this directory alone), and the same docs-drift job fails unless it matches:
cp data/platforms.json docs-site/src/data/platforms.json # from the repo root, after editing the canonical file
Develop
bun install
bun run dev # http://localhost:3001 (docs at /docs)
CI gates every change on bun run build followed by bun run lint (the TypeScript typecheck), in
that order — the build emits the .source typegen the typecheck imports. Run both before you push.
Build & serve
bun run build
bun run start # serves .output/ via Bun
Layout
source.config.ts Fumadocs MDX collection (content/docs)
content/docs/ the docs content (.md/.mdx) + meta.json nav
src/
routes/
__root.tsx RootProvider + html shell
index.tsx landing page
docs/$.tsx catch-all docs renderer (Fumadocs DocsLayout)
api/index.tsx Scalar API reference (reads public/openapi.json)
api/search.ts Orama search endpoint
lib/source.ts Fumadocs loader over the generated collection
lib/layout.shared.tsx shared nav chrome
components/mdx.tsx MDX component map
styles/app.css Tailwind 4 + Fumadocs preset