Compare commits

...
Author SHA1 Message Date
enricobuehler 2b91339cb8 Merge pull request 'A paired Moonlight device can be given a name, and the pad-silence theory is measured and dropped' (#374) from worktree-gamestream-pad-heartbeat into main
ci / bun-nix (push) Successful in 28s
ci / docs-drift (push) Successful in 44s
ci / web (push) Successful in 1m8s
ci / rust-arm64 (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m50s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 14s
deb / build-publish-client-arm64 (push) Successful in 1m35s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 15s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 13s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m7s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
deb / build-publish-host (push) Successful in 4m44s
deb / build-publish-gamescope (push) Successful in 52s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
arch / build-publish (push) Successful in 9m0s
android / android (push) Failing after 9m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 43s
deb / build-publish (push) Successful in 4m52s
deb / smoke-install (push) Successful in 2m52s
windows-host / package (push) Successful in 18m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 18m30s
windows-host / canary-manifest (push) Successful in 35s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m45s
2026-08-22 19:22:01 +00:00
enricobuehler 4a32c8fb36 test(mgmt): one config-dir override for the file, not one per test
ci / web (pull_request) Successful in 2m6s
ci / bun-nix (pull_request) Successful in 37s
ci / docs-site (pull_request) Successful in 1m48s
ci / docs-drift (pull_request) Successful in 4m18s
android / android (pull_request) Successful in 6m0s
ci / rust (pull_request) Successful in 8m49s
ci / rust-arm64 (pull_request) Successful in 13m14s
`ci / rust` failed the unsafe-hygiene gate: mgmt/tests.rs went to 6 process-global-API
mentions against a baseline of 3. The new rename test had copy-pasted the existing
`EnvGuard` + CONFIG_DIR_TEST_LOCK + tempdir dance, which is exactly the duplication gate C
exists to catch — its advice is to fix the call site rather than raise the baseline.

So there is now ONE `ConfigDirOverride` both tests use. It also makes the pairing harder to
get wrong than the copies were: the lock is a FIELD rather than a separate `_serial` binding
a test could forget, and since `Drop::drop` runs before any field drops, the environment is
restored while the guard still holds the lock.

Back to 3 mentions, and `sh scripts/ci/check-unsafe-hygiene.sh` reports all three gates clean.
Note the last one is a PROSE mention: the grep counts comments too (deliberately — "keep it
dumb and stable"), so the doc comment had to stop naming the function it warns about.

Not re-run on .173: the box went off-network mid-change. It does not need to be — this is
`mgmt/tests.rs`, which is not Windows-gated, so Linux CI compiles and runs it. The Windows-only
verification (clippy over the `cfg(target_os = "windows")` devtest change) was already done and
that file is untouched here.
2026-08-22 20:57:05 +02:00
enricobuehler 34a02fdac5 Merge pull request 'Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch' (#372) from worktree-steam-prelaunch-latch into main
ci / bun-nix (push) Successful in 51s
ci / web (push) Successful in 1m28s
ci / docs-drift (push) Successful in 32s
ci / docs-site (push) Successful in 1m27s
ci / rust-arm64 (push) Successful in 1m59s
deb / build-publish-gamescope (push) Successful in 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 45s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m34s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 13s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 23s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m15s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
deb / build-publish (push) Successful in 5m40s
deb / build-publish-host (push) Successful in 5m51s
ci / rust (push) Successful in 8m36s
arch / build-publish (push) Successful in 9m41s
android / android (push) Successful in 10m14s
docker / builders-arm64cross (push) Successful in 13s
docker / deploy-docs (push) Successful in 42s
deb / smoke-install (push) Successful in 7m27s
windows-host / package (push) Successful in 17m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m39s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m11s
2026-08-22 18:32:30 +00:00
enricobuehler 8670b412c7 fix(host): Steam's pre-launch work was mistaken for the game, dropping the stream mid-launch
ci / bun-nix (pull_request) Successful in 31s
ci / docs-drift (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m17s
ci / web (pull_request) Successful in 1m33s
ci / rust-arm64 (pull_request) Successful in 2m4s
android / android (pull_request) Successful in 6m6s
ci / rust (pull_request) Successful in 7m12s
A player had to launch Rocket League twice: the first launch streamed the
"Processing Vulkan shaders" dialog and then dropped, ten seconds in. The host
did that to itself.

`reaper SteamLaunch AppId=<appid>` is the *appid's* wrapper, not the game's.
Steam wraps its pre-launch work for a title in one too, so a launch is a chain
of appid-tagged trees and only the last is the game. The lease matched the
first tree two seconds in, and that single sighting latched it out of the start
phase (START_GRACE, five minutes, ending nothing) into the exit watch
(EXIT_CONFIRM, three seconds, ending the session). When the tree exited with
the game still starting, the watch called it the game exiting and closed the
connection with APP_EXITED.

Linux has nothing to catch that: `procscan::running_hint` is Windows-only, and
no provider reports runstate for Steam, so an appid scan with three seconds of
slack is the whole signal. (Steam's registry.vdf is not an option — RunningAppID
is no longer set on modern Steam Linux, and the per-app Running key is
unreliable.)

Two layers, because only one of them can be certain:

* The matcher now rejects a `SteamLaunch AppId=` reaper whose payload is
  `fossilize_replay` — Steam's shader replayer, never a game.
* A scan match must be seen continuously for SHIM_WINDOW before it latches.
  This is the rule already applied to a spawned child ("a launcher about to
  hand off looks exactly like the game for its first few seconds"); the scan
  side never had it. It bounds the pre-launch trees nobody has named yet, at
  the cost of a few seconds of GameRunning latency. Exit detection is
  untouched, and a provider report still latches immediately — that is the
  launcher's own statement, not an inference from a lookalike.

The log said `procs=1` and never which process, which is what made this
unclosable from a log alone; `procscan::names` puts that on the line.
2026-08-22 20:12:36 +02:00
enricobuehler 539ac2f2a5 feat(host,web): name a paired Moonlight device, because its certificate never will
android / android (pull_request) Successful in 9m22s
ci / rust (pull_request) Canceled after 0s
ci / rust-arm64 (pull_request) Canceled after 0s
ci / web (pull_request) Canceled after 0s
ci / docs-site (pull_request) Canceled after 0s
ci / bun-nix (pull_request) Canceled after 0s
ci / docs-drift (pull_request) Canceled after 0s
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.
2026-08-22 19:31:43 +02:00
enricobuehler f5a75d9edc test(devtest): drive a Windows HID pad through silence and back, to test what a Moonlight client actually does
Chasing "gamepad still dead on GameStream clients after dfcffcdd" (Artemis on
Android, Moonlight on a Switch; both report only mouse/touch working). dfcffcdd
moved this plane from the XUSB companion to the UMDF HID Xbox pad and was verified
by `cargo check` + `clippy` only, so nothing about it had ever run.

The suspicion this flag was built to test: `UhidManager` has a `heartbeat` whose
own doc says a UMDF pad "treats a multi-second input silence as an unplugged
controller", the native plane calls it every tick, and `SessionPads::pump_rumble`
does not. That asymmetry looked decisive because the two planes differ in exactly
the way that would expose it: punktfunk's own client re-sends every live pad's
snapshot every 100 ms unconditionally (`input_task.rs` refresh tick), so a native
pad is never silent, while moonlight-common-c sends a controller packet only on
CHANGE — an untouched pad emits nothing at all.

`--idle-after N` stops the state frames while still pumping; `--resume-after M`
starts them again, because enumeration surviving a silence proves nothing on its
own (a pad can stay listed and deliver no input) — what matters is whether a report
written after the silence still lands.

MEASURED on .173 (Win11 26200), and it does NOT reproduce: with `--xboxhid
--idle-after 12 --seconds 75`, the pad sat through 58 s of total input silence with
`SWD\PUNKTFUNK\PF_XBOX_0` at Status=OK and its promoted `HID\PUNKTFUNK&IG_00` child
still present the whole time. So the heartbeat gap is NOT the field bug, and the
one-line "add a heartbeat to the GameStream arm" fix this was going to justify is
not warranted — which is the point of landing the probe rather than the guess.

Also measured with the same binary, and worth recording because it IS real:
  * two LIVE processes wanting pad index 0 collide exactly as `PadCreateFault::
    IndexOwnedElsewhere` describes (`Global\pfds-boot-0`, ACCESS_DENIED because the
    mailbox DACL is SYSTEM+LocalService). dfcffcdd put BOTH input planes on that one
    name — before it, GameStream used `Global\pfxusb-boot-0` and the two could never
    collide — so the hazard is new, even if it is not what the reporter hit.
  * a clean release-then-retake does NOT collide: back-to-back runs at 0 s, 1 s and
    3 s gaps all created their pad, so an ordinary client reconnect is not the trigger.

Ruled out on the same box while here: the driver package (`pf_gamepad.inf` 08/18
declares all three Xbox hwids and the `xinputhid` promotion), stale drivers in the
field (the Windows updater is a full Inno Setup run that re-runs `driver install
--gamepad`), and access grants (a Moonlight fingerprint has no grants record, which
`control.rs` reads as GRANT_ALL).

Still open, and it needs a live session: .173 runs `PUNKTFUNK_HOST_CMD=serve`, i.e.
GameStream is switched OFF, so this box has never exercised the plane dfcffcdd
changed. That is how a compile-only fix reached users unexercised, and it is the
first thing to change before the next attempt.
2026-08-22 18:58:57 +02:00
enricobuehler ca2ff7093a Merge pull request '0.31.2 — the address the host used, from three directions' (#369) from worktree-release-0312-prep into main
audit / docs-site-audit (push) Successful in 23s
audit / bun-audit (web) (push) Successful in 24s
audit / bun-audit (sdk) (push) Successful in 26s
audit / pnpm-audit (push) Successful in 19s
audit / bun-audit (plugin-kit) (push) Successful in 26s
audit / cargo-audit (push) Failing after 40s
ci / web (push) Successful in 1m21s
ci / docs-site (push) Successful in 1m13s
ci / bun-nix (push) Successful in 16s
ci / docs-drift (push) Successful in 21s
audit / license-gate (push) Successful in 4m57s
audit / miri (push) Successful in 6m17s
audit / c-abi-asan (push) Successful in 6m24s
android-screenshots / screenshots (push) Successful in 1m54s
ci / rust-arm64 (push) Successful in 1m38s
ci / rust (push) Successful in 8m29s
android / android (push) Successful in 11m48s
arch / build-publish (push) Successful in 9m23s
linux-client-screenshots / screenshots (push) Successful in 4m53s
sbom / sbom (push) Successful in 33s
web-screenshots / screenshots (push) Successful in 6m46s
decky / build-publish (push) Successful in 48s
docker / builders-arm64cross (push) Successful in 10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/flatpak-ci.Dockerfile, punktfunk-flatpak-ci) (push) Successful in 11s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m31s
docker / deploy-docs (push) Successful in 17s
nix / flake (push) Successful in 22m13s
deb / smoke-install (push) Successful in 2m52s
deb / build-publish (push) Successful in 5m12s
deb / build-publish-host (push) Successful in 5m39s
deb / build-publish-gamescope (push) Successful in 1m5s
deb / build-publish-client-arm64 (push) Successful in 2m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m49s
apple / distribute (push) Successful in 13m6s
apple / swift (push) Successful in 2m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 21m22s
apple / screenshots (push) Successful in 9m40s
windows-host / package (push) Successful in 13m30s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Successful in 28s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 5m57s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 9m45s
flatpak / build-publish (push) Failing after 8m56s
2026-08-21 20:50:22 +00:00
19 changed files with 1298 additions and 100 deletions
+94 -1
View File
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
+47 -1
View File
@@ -573,6 +573,29 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
// (device_type 3, the MI_02-promoted identity) — watch Steam claim it live.
let edge = args.iter().any(|a| a == "--edge");
let deck = args.iter().any(|a| a == "--deck");
// `--idle-after N` drives normally for N seconds, then STOPS sending state frames while still
// pumping. That is Moonlight's cadence: moonlight-common-c sends a controller packet only on
// CHANGE, so an untouched pad produces no wire events at all. The native plane never sees this
// because punktfunk's own client re-sends every live pad's snapshot every 100 ms (the
// `input_task.rs` refresh tick) — which is exactly why a manager that needs a periodic re-emit
// can look healthy on one plane and die on the other.
let idle_after: u64 = args
.iter()
.skip_while(|a| *a != "--idle-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
// `--resume-after M` ends the silence at M seconds and drives again. That is the half that
// actually answers the question: enumeration surviving a silence proves nothing, because a pad
// can stay listed and still deliver no input. What matters is whether a report written AFTER
// the silence still reaches a consumer — check it with `win-input-matrix --watch` while this
// runs, and watch whether the timestamps start advancing again.
let resume_after: u64 = args
.iter()
.skip_while(|a| *a != "--resume-after")
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let extra_buttons: u32 = if edge || deck {
punktfunk_core::input::gamepad::BTN_PADDLE1 | punktfunk_core::input::gamepad::BTN_PADDLE2
} else {
@@ -612,6 +635,9 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
$label
);
let deadline = Instant::now() + Duration::from_secs(secs);
let started = Instant::now();
let mut announced_silence = false;
let mut announced_resume = false;
let (mut i, mut last) = (0i32, Instant::now());
while Instant::now() < deadline {
mgr.pump(
@@ -620,7 +646,27 @@ pub fn dualsense_windows_test(args: &[String]) -> Result<()> {
),
|o| println!(" hid output from game: {o:?}"),
);
if last.elapsed() >= Duration::from_millis(400) {
let el = started.elapsed();
let resumed =
resume_after != 0 && el >= Duration::from_secs(resume_after.max(idle_after));
let silent =
idle_after != 0 && el >= Duration::from_secs(idle_after) && !resumed;
if silent && !announced_silence {
announced_silence = true;
println!(
" --- going SILENT (no more state frames, still pumping) at {}s ---",
idle_after
);
}
if resumed && !announced_resume {
announced_resume = true;
println!(
" --- RESUMING state frames at {}s (after {}s of silence) ---",
resume_after,
resume_after.saturating_sub(idle_after)
);
}
if !silent && last.elapsed() >= Duration::from_millis(400) {
last = Instant::now();
i += 1;
let buttons = if i % 2 == 0 {
+106 -2
View File
@@ -590,6 +590,9 @@ fn watch(
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
// How long the scan has *continuously* seen something for this title — the scan-side twin of
// [`SHIM_WINDOW`]. See `scan_settled` below for what it is protecting against.
let mut seen_since: Option<Instant> = None;
loop {
if cancelled() {
return;
@@ -713,12 +716,39 @@ fn watch(
&& (child.is_some() || spawned.is_some())
&& spawned_at.elapsed() >= SHIM_WINDOW;
let live = scanner.find(&shared.spec, shared.launch_stamp);
// The same rule for what the *scan* finds, and for the same reason. A store's launch is a
// chain of process trees, and the ones that run before the game carry the signals the game
// carries: Steam wraps its shader pre-caching and its Proton prefix work in the very
// `reaper SteamLaunch AppId=<appid>` the game gets, so the first poll of a launch can match
// a tree that was never the game.
//
// Latching on one poll is what costs, because the two phases are patient in opposite ways.
// This one waits [`START_GRACE`] — five minutes — and ending it never ends the session.
// Phase 2 waits [`EXIT_CONFIRM`] — three seconds — and ending it *does*. A single sighting
// flips the lease from the first to the second, permanently; when that tree then exits with
// the real game not yet started, the stream drops mid-launch. On Linux that ended a Rocket
// League session 10 s after launch, while Steam was still compiling its shaders, and the
// player had to launch a second time to get one that stayed up (field report 2026-08-22).
//
// Requiring the sighting to persist buys that back for a few seconds of `GameRunning`
// latency and nothing else — exit detection is untouched. ⚠ It is a window, not a proof: a
// pre-launch tree that outlives the window still latches. Signals sharp enough to tell one
// from the other belong in [`crate::procscan`] (where Steam's shader job is already excluded
// by name); this bounds what no signal caught.
let scan_settled = if live.is_empty() {
seen_since = None;
false
} else {
seen_since.get_or_insert_with(Instant::now).elapsed() >= SHIM_WINDOW
};
// A provider saying so is as good as seeing it — better, for a title there is nothing to
// see: it is the launcher that started the game telling us it did. This is the only way a
// [`LeaseKind::Reported`] lease ever leaves this phase, and for a `Matched` one it just
// gets there sooner than the scan would.
// gets there sooner than the scan would. Not gated by the window above: a report is the
// launcher's own statement about the game, not an inference from a process that resembles
// it, so there is nothing to wait out.
let said_running = reported().is_some_and(|l| l.running);
if !live.is_empty() || child_alive || said_running {
if scan_settled || child_alive || said_running {
known = live.clone();
publish(&live);
shared.was_running.store(true, Ordering::Relaxed);
@@ -731,6 +761,8 @@ fn watch(
title = %shared.game.title,
kind = kind.as_str(),
procs = live.len(),
// Which processes, not just how many: see [`crate::procscan::names`].
names = ?crate::procscan::names(&live),
"the launched game is running"
);
break;
@@ -2019,6 +2051,78 @@ mod tests {
);
}
/// 🛑 The 2026-08-22 field report: a **pre-launch** process tree must not be mistaken for the
/// game.
///
/// Steam wraps its shader pre-caching in the same `SteamLaunch AppId=` reaper the game itself
/// gets, so the first poll of a launch matches a tree that was never the game. What shipped
/// latched on that single sighting: the lease left the start phase immediately, and when the
/// compile finished and that tree exited — with Rocket League still starting — the exit watch
/// called it the game exiting and closed the session with `APP_EXITED`, 10 s after launch. On
/// the player's screen the stream dropped mid-"Processing Vulkan shaders"; their workaround was
/// to launch the game twice.
///
/// The scanner now knows Steam's replayer by name ([`crate::procscan`]). This pins the bound
/// behind that: a matched process that does not outlive [`SHIM_WINDOW`] never arms the exit
/// watch, whatever it was — which is what covers the pre-launch trees nobody has named yet.
///
/// Ignored by default: it outlives the shim window and then waits out [`EXIT_CONFIRM`], ~11 s.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~11s (shim window + exit confirmation)"]
fn a_pre_launch_tree_that_exits_never_ends_the_session() {
use std::sync::atomic::AtomicUsize;
// The stand-in has to keep the name `sleep`: coreutils is a multi-call binary that
// dispatches on `argv[0]`, and under any other name it exits instantly — which would pass
// this test for entirely the wrong reason. (Same trap as the live matcher test in
// [`crate::procscan`].)
let td = tempfile::tempdir().expect("tempdir");
let stand_in = td.path().join("sleep");
std::fs::copy("/bin/sleep", &stand_in).expect("copy a stand-in pre-launch binary");
let launch_stamp = launch_clock();
// Alive for less than the shim window — Steam's shader job, in miniature.
let mut child = std::process::Command::new(&stand_in)
.arg("3")
.spawn()
.expect("spawn the fake pre-launch tree");
// Reaped on its own thread: a zombie keeps its `/proc` entry with an unchanged start time,
// so the scan would call it alive forever and the exit under test never happen.
std::thread::spawn(move || {
let _ = child.wait();
});
static PRE_EXITS: AtomicUsize = AtomicUsize::new(0);
PRE_EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
launch_stamp,
// No child and no pid: the scan is the only signal, which is the field-report shape
// (`steam steam://rungameid/…` had already handed off and exited).
..req("steam:pre-launch", DetectSpec::dir(td.path()), false)
},
Box::new(|| {
PRE_EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Matched));
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(3));
assert_eq!(
PRE_EXITS.load(Ordering::SeqCst),
0,
"a tree that ran before the game must not end the session when it exits — this is the \
field report"
);
assert_ne!(
shared.state(),
GameState::Exited,
"the game never started, so nothing of it can have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
+100
View File
@@ -760,6 +760,106 @@ pub(crate) fn save_paired(paired: &[Vec<u8>]) {
}
}
/// Where the operator's per-client display labels persist, keyed by certificate fingerprint.
///
/// A SIDECAR to [`paired_path`] rather than a field inside it, for two reasons. `paired.json` is a
/// bare `Vec<Vec<u8>>` of certificate DERs — 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 anybody out. Losing this file loses names, nothing
/// else.
///
/// Why labels have to exist at all: every moonlight-common-c client self-signs with the SAME
/// subject (`CN=NVIDIA GameStream Client`), so the certificate carries no device identity
/// whatsoever. Without an operator-supplied name, a list of five paired devices is five identical
/// rows and the only way to tell them apart — or to know which one to unpair — is the fingerprint.
fn labels_path() -> Option<std::path::PathBuf> {
Some(pf_paths::config_dir().join("client-labels.json"))
}
/// Serializes the read-modify-write in [`set_client_label`]. Two concurrent renames would
/// otherwise race on a whole-file rewrite and silently drop one of the two names.
static LABELS_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Load the fingerprint → label map (empty on first run, unreadable file, or parse failure — a
/// label is cosmetic, so every failure degrades to "no names" and never to an error).
pub(crate) fn load_client_labels() -> std::collections::BTreeMap<String, String> {
let Some(path) = labels_path() else {
return Default::default();
};
let Ok(raw) = std::fs::read(&path) else {
return Default::default();
};
serde_json::from_slice(&raw).unwrap_or_else(|e| {
tracing::warn!(error = %e, "client-labels.json unreadable — listing clients without names");
Default::default()
})
}
/// Set (`Some`) or clear (`None`) one client's label, persisted atomically. Returns the stored
/// label. Fingerprints are normalized to lowercase hex so a rename and a later lookup agree
/// regardless of how the caller cased the path parameter.
pub(crate) fn set_client_label(fp_hex: &str, label: Option<&str>) -> Option<String> {
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let fp = fp_hex.to_ascii_lowercase();
let mut labels = load_client_labels();
let stored = match label {
Some(l) => {
let clean = crate::native_pairing::sanitize_device_name(l, &fp);
labels.insert(fp, clean.clone());
Some(clean)
}
None => {
labels.remove(&fp);
None
}
};
save_client_labels(&labels);
stored
}
/// Drop the labels of fingerprints that are no longer paired. Called from the unpair paths so the
/// file cannot grow without bound as devices come and go, and so a re-pairing of the same
/// certificate starts unnamed rather than inheriting a stranger's name.
pub(crate) fn retain_client_labels(still_paired: &[Vec<u8>]) {
use sha2::{Digest, Sha256};
let _guard = LABELS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let live: std::collections::BTreeSet<String> = still_paired
.iter()
.map(|der| hex::encode(Sha256::digest(der)))
.collect();
let mut labels = load_client_labels();
let before = labels.len();
labels.retain(|fp, _| live.contains(fp));
if labels.len() != before {
save_client_labels(&labels);
}
}
/// Persist the label map — same atomic temp-file + rename as [`save_paired`], so a crash mid-write
/// cannot truncate it.
fn save_client_labels(labels: &std::collections::BTreeMap<String, String>) {
let Some(path) = labels_path() else { return };
if let Some(dir) = path.parent() {
let _ = pf_paths::create_private_dir(dir);
}
let bytes = match serde_json::to_vec(labels) {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = %e, "serializing client labels failed");
return;
}
};
let tmp = path.with_extension("json.tmp");
if let Err(e) = pf_paths::write_secret_file(&tmp, &bytes) {
tracing::warn!(error = %e, "persisting client labels failed (temp write)");
return;
}
if let Err(e) = std::fs::rename(&tmp, &path) {
tracing::warn!(error = %e, "persisting client labels failed (rename)");
let _ = std::fs::remove_file(&tmp);
}
}
#[cfg(test)]
mod host_name_tests {
use super::sanitize_display_name;
+8 -1
View File
@@ -55,7 +55,14 @@ pub struct DetectSpec {
/// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper
/// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the
/// sharpest signal available: Steam wraps every launch — native or Proton — in
/// `reaper SteamLaunch AppId=<appid>`, whose lifetime is exactly the game's.
/// `reaper SteamLaunch AppId=<appid>`.
///
/// ⚠ That reaper is the *appid's*, not the game's. Steam wraps its **pre-launch** work for a
/// title in one too — shader pre-caching most visibly — so a launch is a chain of reaper trees
/// and only the last of them is the game. Reading the first as the game is what dropped a
/// stream 10 s into a Rocket League launch, mid-shader-compile (field report 2026-08-22); the
/// shader job is excluded by name in [`crate::procscan`], and [`crate::gamelease`] waits out a
/// window before believing any of them.
pub steam_appid: Option<u32>,
/// A launcher-stamped environment marker.
pub env_marker: Option<EnvMarker>,
+2 -1
View File
@@ -328,7 +328,8 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
clients::list_paired_clients,
clients::unpair_all_clients
))
.routes(routes!(clients::unpair_client));
// DELETE and PATCH share `/clients/{fingerprint}` — one `routes!`, same rule as above.
.routes(routes!(clients::unpair_client, clients::rename_client));
// The GameStream PIN flow exists only when the compat planes do (WP19) — a native-only
// build's API (and its OpenAPI document) simply has no such endpoints.
#[cfg(feature = "gamestream")]
+104 -4
View File
@@ -11,7 +11,17 @@ pub(crate) struct PairedClient {
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
fingerprint: String,
/// Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.
///
/// Do not display this as a device name. Every moonlight-common-c client self-signs with that
/// same fixed subject, so it identifies the *protocol*, not the device — a list of paired
/// phones, TVs and handhelds all read identically. [`Self::label`] is the field to show.
subject: Option<String>,
/// Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).
///
/// This is the ONLY thing that can tell two paired Moonlight devices apart in a list, because
/// their certificates cannot: see [`Self::subject`]. Absent until somebody names the device.
#[schema(example = "Living Room TV")]
label: Option<String>,
/// Certificate validity start (unix seconds).
not_before_unix: Option<i64>,
/// Certificate validity end (unix seconds).
@@ -55,27 +65,112 @@ pub(crate) async fn list_paired_clients(
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
Json(ders.iter().map(|der| client_info(der)).collect())
// One read of the label sidecar for the whole list, not one per row.
let labels = crate::gamestream::load_client_labels();
Json(ders.iter().map(|der| client_info(der, &labels)).collect())
}
pub(crate) fn client_info(der: &[u8]) -> PairedClient {
pub(crate) fn client_info(
der: &[u8],
labels: &std::collections::BTreeMap<String, String>,
) -> PairedClient {
let fingerprint = hex::encode(Sha256::digest(der));
let label = labels.get(&fingerprint).cloned();
match x509_parser::parse_x509_certificate(der) {
Ok((_, x509)) => PairedClient {
fingerprint,
subject: Some(x509.subject().to_string()),
not_before_unix: Some(x509.validity().not_before.timestamp()),
not_after_unix: Some(x509.validity().not_after.timestamp()),
label,
fingerprint,
},
Err(_) => PairedClient {
fingerprint,
subject: None,
not_before_unix: None,
not_after_unix: None,
label,
fingerprint,
},
}
}
/// Body of `PATCH /clients/{fingerprint}` — the device's display name.
#[derive(Deserialize, ToSchema)]
pub(crate) struct RenameClient {
/// The name to show for this device. `null` (or an empty/whitespace-only string) clears it and
/// the device goes back to being listed by fingerprint alone.
///
/// Scrubbed before storage by the same sanitizer the native plane runs on device names:
/// control characters and Unicode bidi overrides are stripped (they could make one paired
/// device impersonate another in this very list), whitespace collapsed, and the result capped
/// at 64 characters.
#[schema(example = "Living Room TV")]
label: Option<String>,
}
/// Rename a paired client
///
/// Sets or clears the operator-visible display name for one paired Moonlight client. This is
/// purely cosmetic — it touches no certificate and no trust decision — but it is the only way to
/// tell paired devices apart: every moonlight-common-c client self-signs with the identical
/// subject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable
/// only by fingerprint. The name is stored beside the pairing store and survives host restarts;
/// unpairing the device forgets it.
#[utoipa::path(
patch,
path = "/clients/{fingerprint}",
tag = "clients",
operation_id = "renameClient",
params(
("fingerprint" = String, Path,
description = "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)")
),
request_body = RenameClient,
responses(
(status = OK, description = "The client as it now reads", body = PairedClient),
(status = BAD_REQUEST, description = "Malformed fingerprint", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
(status = NOT_FOUND, description = "No paired client with that fingerprint", body = ApiError),
)
)]
pub(crate) async fn rename_client(
State(st): State<Arc<MgmtState>>,
Path(fingerprint): Path<String>,
Json(body): Json<RenameClient>,
) -> Response {
if fingerprint.len() != 64 || !fingerprint.bytes().all(|b| b.is_ascii_hexdigit()) {
return api_error(
StatusCode::BAD_REQUEST,
"fingerprint must be the 64-char hex SHA-256 of the client certificate DER",
);
}
// Only name a device that is actually paired: a label for an unknown fingerprint would be
// invisible (nothing lists it) and would sit in the file forever, since the unpair cleanup
// only ever removes labels whose device WAS paired.
let paired = st.app.paired.lock().unwrap_or_else(|e| e.into_inner());
let Some(der) = paired
.iter()
.find(|der| hex::encode(Sha256::digest(der)).eq_ignore_ascii_case(&fingerprint))
.cloned()
else {
return api_error(
StatusCode::NOT_FOUND,
"no paired client with that fingerprint",
);
};
drop(paired);
// An all-whitespace name is a cleared name, not a device called " ": the sanitizer would
// otherwise turn it into the "device <fp8>" fallback and the row would look renamed.
let wanted = body
.label
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty());
crate::gamestream::set_client_label(&fingerprint, wanted);
let labels = crate::gamestream::load_client_labels();
(StatusCode::OK, Json(client_info(&der, &labels))).into_response()
}
/// Unpair a client
///
/// Removes the client's certificate from the pairing store (persisted — the removal survives a
@@ -119,6 +214,9 @@ pub(crate) async fn unpair_client(
// restart, which now also matters below: a resurrected pairing would silently
// re-open the control port.
crate::gamestream::save_paired(&paired);
// Forget this device's display name with it, so the file can't grow without bound and a
// later re-pairing of the same certificate starts unnamed.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// Revocation reaches a LIVE session too: a mid-stream client whose pairing was just
// removed must not keep streaming until it chooses to leave. Clearing the launch makes
@@ -187,6 +285,8 @@ pub(crate) async fn unpair_all_clients(State(st): State<Arc<MgmtState>>) -> Resp
// Persist under the lock, as the single unpair does: a pairing resurrected by a restart would
// silently re-open the control port.
crate::gamestream::save_paired(&paired);
// Nothing is paired any more, so no label can still belong to anyone.
crate::gamestream::retain_client_labels(&paired);
drop(paired);
// A mid-stream client must not keep streaming once its pairing is gone. Clearing the launch
// makes the ENet control thread send the standard TERMINATION+disconnect. (An owner-less
+186 -20
View File
@@ -819,6 +819,54 @@ async fn status_reflects_runtime_state() {
assert!(!body.to_string().contains("gcm"));
}
/// Point `PUNKTFUNK_CONFIG_DIR` at a throwaway tempdir for the body of a test, and put the previous
/// value back on drop even if an assertion panics.
///
/// ONE of these for the whole file on purpose. Mutating the process environment is safe to call and
/// unsound from a live multithreaded process, so `check-unsafe-hygiene.sh` (gate C) holds this file
/// to a fixed count of such call sites — and counts plain prose mentions too, deliberately, since
/// its grep is the contract. A second test that copy-pastes the dance trips it, which is exactly
/// what it is for. This also bundles the serialization: the lock is a FIELD, so it cannot be
/// forgotten, and `Drop::drop` runs before any field drops, meaning the environment is restored
/// while this still holds the lock.
struct ConfigDirOverride {
tmp: tempfile::TempDir,
prev: Option<std::ffi::OsString>,
_serial: std::sync::MutexGuard<'static, ()>,
}
impl ConfigDirOverride {
fn new() -> ConfigDirOverride {
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let prev = std::env::var_os("PUNKTFUNK_CONFIG_DIR");
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK, which serializes every test in this binary
// that reads or writes this variable.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
ConfigDirOverride { tmp, prev, _serial }
}
/// The throwaway config dir itself — used verbatim by `pf_paths`, with no `punktfunk`
/// subdirectory appended.
fn path(&self) -> &std::path::Path {
self.tmp.path()
}
}
impl Drop for ConfigDirOverride {
fn drop(&mut self) {
match self.prev.take() {
// SAFETY: `self._serial` is still alive here (fields drop after `Drop::drop`), so this
// runs under the same serialization as the `set_var` in `new`.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
// Holding `CONFIG_DIR_TEST_LOCK` across the awaits is the POINT: the env override must cover
// the whole test body, and `#[tokio::test]` is a single-threaded runtime — nothing else can
// need the executor while we hold it.
@@ -828,26 +876,7 @@ async fn paired_clients_list_and_unpair() {
// Unpair PERSISTS (save_paired → paired.json in the config dir), so point the config dir
// at a throwaway tempdir — this test must never rewrite the dev box's real pairing store.
// The guard restores the previous value even if an assertion below panics.
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
// SAFETY: dropped while this test still holds CONFIG_DIR_TEST_LOCK, which
// serializes every test that writes or reads this variable in the binary.
Some(v) => unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", v) },
// SAFETY: as above.
None => unsafe { std::env::remove_var("PUNKTFUNK_CONFIG_DIR") },
}
}
}
let _serial = crate::identity::CONFIG_DIR_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
let _env = EnvGuard(std::env::var_os("PUNKTFUNK_CONFIG_DIR"));
// SAFETY: `_serial` holds CONFIG_DIR_TEST_LOCK (taken above), serializing every test that
// writes or reads this variable in the binary.
unsafe { std::env::set_var("PUNKTFUNK_CONFIG_DIR", tmp.path()) };
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
@@ -1001,6 +1030,137 @@ async fn paired_clients_list_and_unpair() {
assert_eq!(body["unpaired"], 0);
}
/// Renaming a paired Moonlight client: the round trip, the scrub, the clear, and the cleanup.
///
/// Worth a test because the label is the ONLY thing that distinguishes two paired Moonlight
/// devices — their certificates all carry the same subject — so "the name silently didn't stick"
/// is indistinguishable from "the device is the other one" in the console.
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn client_label_round_trips_scrubs_and_is_forgotten_on_unpair() {
let tmp = ConfigDirOverride::new();
let state = test_state();
let app = test_app(state.clone(), None);
let stand_in = crate::identity::ephemeral().unwrap();
let (_, pem) = x509_parser::pem::parse_x509_pem(stand_in.cert_pem.as_bytes()).unwrap();
let der = pem.contents.clone();
let fingerprint = hex::encode(Sha256::digest(&der));
{
let mut p = state.paired.lock().unwrap();
p.clear();
p.push(der.clone());
}
let patch = |fp: String, body: serde_json::Value| {
axum::http::Request::patch(format!("/api/v1/clients/{fp}"))
.header("content-type", "application/json")
.body(Body::from(body.to_string()))
.unwrap()
};
// Unnamed until somebody names it — the field is absent, not an empty string.
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert!(body[0]["label"].is_null());
// Name it (uppercase fingerprint must match too — the path is documented case-insensitive).
let (status, body) = send(
&app,
patch(
fingerprint.to_uppercase(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["label"], "Living Room TV");
let (_, body) = send(&app, get_req("/api/v1/clients")).await;
assert_eq!(body[0]["label"], "Living Room TV");
// The scrub runs: a bidi override could make one paired device read like another in the very
// list an operator uses to decide what to unpair, and the whitespace collapse keeps the name
// one line. (`\u{202E}` = RIGHT-TO-LEFT OVERRIDE.)
let (_, body) = send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": " Deck\u{202E}evil\n\nx " }),
),
)
.await;
assert_eq!(body["label"], "Deckevil x");
// Whitespace-only clears rather than storing a device called " " (or the sanitizer's
// "device <fp8>" fallback, which would look like a successful rename).
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": " " })),
)
.await;
assert!(body["label"].is_null());
// …and an explicit null clears too.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Bedroom" }),
),
)
.await;
let (_, body) = send(
&app,
patch(fingerprint.clone(), serde_json::json!({ "label": null })),
)
.await;
assert!(body["label"].is_null());
// Malformed fingerprint → 400; unknown-but-well-formed → 404 (naming a device that is not
// paired would write a label nothing can ever list or clean up).
assert_eq!(
send(
&app,
patch("zz".into(), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
send(
&app,
patch("aa".repeat(32), serde_json::json!({ "label": "x" }))
)
.await
.0,
StatusCode::NOT_FOUND
);
// Unpairing forgets the name: it must not survive to be inherited by a later re-pairing of
// the same certificate.
send(
&app,
patch(
fingerprint.clone(),
serde_json::json!({ "label": "Living Room TV" }),
),
)
.await;
let del = axum::http::Request::delete(format!("/api/v1/clients/{fingerprint}"))
.body(Body::empty())
.unwrap();
assert_eq!(send(&app, del).await.0, StatusCode::NO_CONTENT);
let on_disk: std::collections::BTreeMap<String, String> =
std::fs::read(tmp.path().join("client-labels.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
assert!(
!on_disk.contains_key(&fingerprint),
"unpair must forget the device's label, got {on_disk:?}"
);
}
#[cfg(feature = "gamestream")]
#[tokio::test]
async fn submit_pin_validates_and_requires_pending_pairing() {
@@ -1378,6 +1538,12 @@ fn every_route_is_classified_for_the_plugin_and_cert_lanes() {
// roster's read permission must never carry over to emptying it.
("DELETE", "/api/v1/clients", false, false),
("DELETE", "/api/v1/clients/{fingerprint}", false, false),
// Renaming is cosmetic but NOT harmless, so it takes the same lanes as removal rather than
// the roster's read permission: the label is the only thing distinguishing one paired
// Moonlight device from another in the console, so anything that could set it could dress
// its own device up as the operator's TV — and be trusted, or spared an unpair, on that
// basis. Sharing a path with the plugin-forbidden DELETE, it needs its own row anyway.
("PATCH", "/api/v1/clients/{fingerprint}", false, false),
("GET", "/api/v1/native/clients", true, false),
("DELETE", "/api/v1/native/clients", false, false),
(
+20
View File
@@ -87,6 +87,26 @@ pub fn resolve(pid: u32) -> Option<ProcRef> {
}
}
/// Short names for the processes a lease adopted, in `procs` order.
///
/// Diagnostics only — nothing decides anything on these, and they are deliberately not part of
/// [`ProcRef`], which is compared for equality. They exist because a launch that adopted the game
/// and a launch that adopted a *pre-launch* tree logged identically (`procs=1`), which is what left
/// the 2026-08-22 field report unclosable from its log: the one question worth asking of that line
/// is which process the lease latched onto.
pub fn names(procs: &[ProcRef]) -> Vec<String> {
#[cfg(any(target_os = "linux", windows))]
{
let scanner = Scanner::system();
procs.iter().map(|p| scanner.name_of(*p)).collect()
}
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = procs;
Vec::new()
}
}
/// An out-of-band opinion on whether a spec's game is still running, independent of the process scan.
///
/// Consulted **only to veto** declaring a game gone — never to declare it running, and never as the
+67 -1
View File
@@ -126,6 +126,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's `comm` — its short name, as `ps` shows it. Diagnostics only (see
/// [`super::names`]); `?` for a process that has already gone, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
std::fs::read_to_string(self.root.join(p.pid.to_string()).join("comm"))
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** start time unchanged,
/// so a recycled pid is never reported alive (rule 2).
pub fn alive(&self, procs: &[ProcRef]) -> Vec<ProcRef> {
@@ -180,16 +188,29 @@ impl Scanner {
if let Some(tok) = steam_tok {
// Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and
// Steam's own (non-reaper) helper steps aren't mistaken for the game.
//
// …with one exception, because the reaper is *not* only the game's: Steam wraps its
// shader pre-caching for a title in the same `SteamLaunch AppId=<appid>` reaper it
// wraps the game in, so that job satisfies this recipe exactly while the game has
// not started yet. Adopting it points the lease at a tree that exits when the
// compile finishes, which reads as the game exiting — on Linux that dropped a
// Rocket League stream 10 s into a launch, mid-"Processing Vulkan shaders", and the
// player had to launch a second time to get a session that stayed up (field report
// 2026-08-22). The payload names itself: `fossilize_replay` is Steam's replayer and
// is never a game.
let mut launch = false;
let mut appid = false;
let mut shader = false;
for arg in cmdline.split(|&b| b == 0) {
if arg == b"SteamLaunch" {
launch = true;
} else if arg == tok.as_bytes() {
appid = true;
} else if program_name(arg) == b"fossilize_replay" {
shader = true;
}
}
if launch && appid {
if launch && appid && !shader {
return true;
}
}
@@ -247,6 +268,15 @@ impl Scanner {
}
}
/// The last `/`-separated component of an argv entry — the program's own name, when the entry is a
/// path to one. Bytes rather than `str` because an argv entry is not required to be UTF-8.
fn program_name(arg: &[u8]) -> &[u8] {
match arg.iter().rposition(|&b| b == b'/') {
Some(i) => &arg[i + 1..],
None => arg,
}
}
/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished
/// or the file is unreadable — both routine during a scan.
fn read_capped(path: &Path) -> Option<Vec<u8>> {
@@ -472,6 +502,42 @@ mod tests {
assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]);
}
/// The 2026-08-22 field report: Steam's **shader pre-caching** runs under the game's own
/// `SteamLaunch AppId=` reaper, so it satisfies the appid recipe while the game has not started.
///
/// Adopting it is what dropped a Rocket League stream 10 s into a launch — the lease called that
/// tree the game, and its exit (the compile finishing) the game exiting. The reaper's payload is
/// the whole tell, and it is only ever Steam's replayer.
#[test]
fn steam_shader_pre_caching_is_not_the_game() {
let td = fake_proc_root(
1000.0,
&[
// The shader job for this very appid — the game is still being brought up.
FakeProc::new(35, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/SteamLinuxRuntime/fossilize_replay",
"/home/p/.steam/steamapps/shadercache/252950/fozpipelinesv6/steamapprun_pipeline_cache.foz",
]),
// The game itself, same appid, same reaper. This one IS the game.
FakeProc::new(36, 50_000).cmdline(&[
"/home/p/.steam/ubuntu12_32/reaper",
"SteamLaunch",
"AppId=252950",
"--",
"/home/p/.steam/steamapps/common/Proton/proton",
"waitforexitandrun",
"/home/p/.steam/steamapps/common/rocketleague/RocketLeague.exe",
]),
],
);
let s = scanner(td.path());
assert_eq!(pids(s.find(&DetectSpec::steam(252_950), None)), vec![36]);
}
#[test]
fn matches_env_marker_by_exact_value_or_presence() {
let td = fake_proc_root(
@@ -120,6 +120,14 @@ impl Scanner {
Some(ProcRef { pid, start })
}
/// This process's image file name. Diagnostics only (see [`super::names`]); `?` for a process
/// that has already gone or cannot be opened, which is routine.
pub fn name_of(&self, p: ProcRef) -> String {
process_start_and_image(p.pid)
.and_then(|(_, image)| image.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "?".into())
}
/// Which of `procs` are still the same live processes — pid present **and** creation time
/// unchanged, so a recycled pid is never reported alive (rule 2). Windows reuses pids briskly, so
/// this check is what makes signalling a remembered pid safe at all.
+94 -1
View File
@@ -364,6 +364,77 @@
}
}
}
},
"patch": {
"tags": [
"clients"
],
"summary": "Rename a paired client",
"description": "Sets or clears the operator-visible display name for one paired Moonlight client. This is\npurely cosmetic — it touches no certificate and no trust decision — but it is the only way to\ntell paired devices apart: every moonlight-common-c client self-signs with the identical\nsubject `CN=NVIDIA GameStream Client`, so an unnamed list is a row of clones distinguishable\nonly by fingerprint. The name is stored beside the pairing store and survives host restarts;\nunpairing the device forgets it.",
"operationId": "renameClient",
"parameters": [
{
"name": "fingerprint",
"in": "path",
"description": "Hex SHA-256 fingerprint of the client certificate DER (64 chars, case-insensitive)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RenameClient"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The client as it now reads",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PairedClient"
}
}
}
},
"400": {
"description": "Malformed fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"404": {
"description": "No paired client with that fingerprint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/compositors": {
@@ -7375,6 +7446,14 @@
"description": "Lowercase hex SHA-256 of the client certificate DER — the client's stable id here.",
"example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
},
"label": {
"type": [
"string",
"null"
],
"description": "Operator-assigned display name for this device, if one has been set (`PATCH /clients/{fp}`).\n\nThis is the ONLY thing that can tell two paired Moonlight devices apart in a list, because\ntheir certificates cannot: see [`Self::subject`]. Absent until somebody names the device.",
"example": "Living Room TV"
},
"not_after_unix": {
"type": [
"integer",
@@ -7396,7 +7475,7 @@
"string",
"null"
],
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses."
"description": "Certificate subject (e.g. `CN=NVIDIA GameStream Client`), if the DER parses.\n\nDo not display this as a device name. Every moonlight-common-c client self-signs with that\nsame fixed subject, so it identifies the *protocol*, not the device — a list of paired\nphones, TVs and handhelds all read identically. [`Self::label`] is the field to show."
}
}
},
@@ -7949,6 +8028,20 @@
}
}
},
"RenameClient": {
"type": "object",
"description": "Body of `PATCH /clients/{fingerprint}` — the device's display name.",
"properties": {
"label": {
"type": [
"string",
"null"
],
"description": "The name to show for this device. `null` (or an empty/whitespace-only string) clears it and\nthe device goes back to being listed by fingerprint alone.\n\nScrubbed before storage by the same sanitizer the native plane runs on device names:\ncontrol characters and Unicode bidi overrides are stripped (they could make one paired\ndevice impersonate another in this very list), whitespace collapsed, and the result capped\nat 64 characters.",
"example": "Living Room TV"
}
}
},
"RunningTitle": {
"type": "object",
"description": "One running title in a provider's liveness report.",
+379 -62
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -119,6 +119,7 @@
"action_request_idr": "Keyframe anfordern",
"action_unpair": "Entkoppeln",
"action_unpair_all": "Alle entkoppeln",
"action_rename": "Umbenennen",
"connect_title": "Gerät verbinden",
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
"connect_address": "Host-Adresse",
@@ -246,6 +247,10 @@
"display_discard_confirm": "Du hast nicht gespeicherte eigene Einstellungen. Verwerfen?",
"clients_name": "Name",
"clients_fingerprint": "Fingerabdruck",
"clients_rename_title": "Gerät umbenennen",
"clients_rename_body": "Moonlight-Clients melden sich alle gleich, deshalb vergibst du diesen Namen selbst. Leer lassen, um ihn zu entfernen.",
"clients_rename_label": "Anzeigename",
"clients_rename_failed": "Gerät konnte nicht umbenannt werden",
"pairing_title": "Kopplung",
"pairing_idle": "Keine Kopplung aktiv. Starte die Kopplung in einem Moonlight-Client und gib hier die PIN ein.",
"pairing_waiting": "Ein Gerät wartet auf Kopplung. Gib die angezeigte PIN ein:",
+5
View File
@@ -119,6 +119,7 @@
"action_request_idr": "Request keyframe",
"action_unpair": "Unpair",
"action_unpair_all": "Unpair all",
"action_rename": "Rename",
"connect_title": "Connect a device",
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
"connect_address": "Host address",
@@ -246,6 +247,10 @@
"display_discard_confirm": "You have unsaved custom settings. Discard them?",
"clients_name": "Name",
"clients_fingerprint": "Fingerprint",
"clients_rename_title": "Rename device",
"clients_rename_body": "Moonlight clients all identify themselves the same way, so this name is yours to set. Leave it empty to remove it.",
"clients_rename_label": "Display name",
"clients_rename_failed": "Could not rename the device",
"pairing_title": "Pairing",
"pairing_idle": "No pairing in progress. Start pairing from a Moonlight client, then enter its PIN here.",
"pairing_waiting": "A client is waiting to pair. Enter the PIN it shows:",
+64 -4
View File
@@ -1,10 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { SlidersHorizontal, Trash2 } from "lucide-react";
import { Pencil, SlidersHorizontal, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
getListPairedClientsQueryKey,
useListPairedClients,
useRenameClient,
useUnpairAllClients,
useUnpairClient,
} from "@/api/gen/clients/clients";
@@ -40,8 +41,18 @@ export type PairedProtocol = "native" | "moonlight";
export interface PairedRow {
protocol: PairedProtocol;
fingerprint: string;
/** Native devices carry a name; Moonlight clients carry a cert subject; either may be empty. */
/**
* What to show in the Name column. Native devices carry a name from pairing; a Moonlight client
* shows its operator-given label if it has one, and otherwise falls back to its cert subject
* which is the same fixed string for every Moonlight client alive, hence [`label`].
*/
name: string;
/**
* The operator-assigned label, Moonlight rows only `null` when the device has never been
* named. Distinct from `name` because the rename dialog must open on the label alone: seeding
* it with the `CN=…` fallback would make every rename start by deleting boilerplate.
*/
label?: string | null;
/**
* Access fields native rows only, and only from hosts that have them (the console pairs
* against older hosts: all four stay `undefined` then, and the Access column shows "—").
@@ -67,13 +78,14 @@ const hasAccess = (r: PairedRow): boolean =>
*/
export const PairedDevicesSection: FC = () => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const { confirm, promptText } = useDialogs();
const native = useListNativeClients();
const moonlight = useListPairedClients();
const unpairNative = useUnpairNativeClient();
const unpairMoonlight = useUnpairClient();
const unpairAllNative = useUnpairAllNativeClients();
const unpairAllMoonlight = useUnpairAllClients();
const renameMoonlight = useRenameClient();
const patchAccess = useUpdateNativeClientAccess();
// One clock for every countdown in the card AND the sheet — recomputed client-side from
// `expires_unix`, so the tick never refetches anything.
@@ -97,7 +109,8 @@ export const PairedDevicesSection: FC = () => {
(c): PairedRow => ({
protocol: "moonlight",
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}),
),
];
@@ -129,6 +142,32 @@ export const PairedDevicesSection: FC = () => {
}
};
/**
* Name a Moonlight device. Every Moonlight client presents the identical certificate subject,
* so without this the list is a column of `CN=NVIDIA GameStream Client` rows and the only way
* to tell a phone from a TV or to know which one you are about to unpair is the
* fingerprint. Submitting an empty field clears the name (the host reads that as "unnamed"),
* which is why cancel (`null`) and empty are handled differently here.
*/
const onRename = async (row: PairedRow) => {
const next = await promptText({
title: m.clients_rename_title(),
description: m.clients_rename_body(),
label: m.clients_rename_label(),
defaultValue: row.label ?? "",
confirmLabel: m.action_rename(),
});
if (next === null) return;
renameMoonlight.mutate(
{ fingerprint: row.fingerprint, data: { label: next.trim() || null } },
{
onSuccess: () =>
qc.invalidateQueries({ queryKey: getListPairedClientsQueryKey() }),
onError: () => toast.error(m.clients_rename_failed()),
},
);
};
const savedAccess = () => {
setEditing(null);
qc.invalidateQueries({ queryKey: getListNativeClientsQueryKey() });
@@ -218,6 +257,7 @@ export const PairedDevicesSection: FC = () => {
expiresUnix: r.expiresUnix,
})
}
onRename={onRename}
onUnpair={onUnpair}
onUnpairAll={onUnpairAll}
pendingFingerprint={pendingFingerprint}
@@ -246,6 +286,11 @@ export const PairedDevices: FC<{
nowUnix: number;
/** Open the access editor for a native row (only offered where `hasAccess`). */
onEditAccess: (row: PairedRow) => void;
/**
* Name a Moonlight row. Offered only on those: a native device already carries the name it gave
* at pairing, while a Moonlight certificate carries nothing that identifies the device at all.
*/
onRename: (row: PairedRow) => void;
onUnpair: (protocol: PairedProtocol, fingerprint: string) => void;
/** Unpair every row, behind one confirmation. */
onUnpairAll: () => void;
@@ -260,6 +305,7 @@ export const PairedDevices: FC<{
refetch,
nowUnix,
onEditAccess,
onRename,
onUnpair,
onUnpairAll,
pendingFingerprint,
@@ -342,6 +388,20 @@ export const PairedDevices: FC<{
</TableCell>
<TableCell>
<div className="flex justify-end">
{r.protocol === "moonlight" && (
<Button
variant="ghost"
size="icon"
aria-label={m.action_rename()}
disabled={
isUnpairingAll ||
pendingFingerprint === r.fingerprint
}
onClick={() => onRename(r)}
>
<Pencil className="size-4" />
</Button>
)}
{hasAccess(r) && (
<Button
variant="ghost"
+3 -1
View File
@@ -29,7 +29,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Renders the REAL page layout (PairingView) — the same component index.tsx uses. The live page
@@ -84,6 +85,7 @@ export const Armed: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3 -1
View File
@@ -25,7 +25,8 @@ const nativeRows: PairedRow[] = nativeClients.map((c) => ({
const moonlightRows: PairedRow[] = pairedClients.map((c) => ({
protocol: "moonlight" as const,
fingerprint: c.fingerprint,
name: c.subject ?? "",
name: c.label ?? c.subject ?? "",
label: c.label,
}));
// Per-client access states, separate from Pages/Pairing: these stories render single components
@@ -106,6 +107,7 @@ export const AccessColumn: Story = {
refetch={noop}
nowUnix={accessNowUnix}
onEditAccess={noop}
onRename={noop}
onUnpair={noop}
onUnpairAll={noop}
pendingFingerprint={null}
+3
View File
@@ -120,6 +120,9 @@ export const pairedClients: PairedClient[] = [
fingerprint:
"ff00eeddccbbaa998877665544332211009f8e7d6c5b4a39281706f5e4d3c2b1",
subject: "living-room-tv",
// Named by the operator — the row that shows what a rename buys you next to a sibling that
// still reads as its (identical-for-everyone) certificate subject.
label: "Living Room TV",
not_before_unix: 1_718_500_000,
not_after_unix: 2_030_000_000,
},