Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96f75f4e52 | ||
|
|
4c5b97cfe4 | ||
|
|
4beee17953 | ||
|
|
4ad0055416 | ||
|
|
db9cd40079 | ||
|
|
c63e8cee39 | ||
|
|
b670b5d844 | ||
|
|
064ea3de7d | ||
|
|
ec278c0478 | ||
|
|
5d91176500 | ||
|
|
551d0c3294 | ||
|
|
7f77fa68af | ||
|
|
ece8b16a78 | ||
|
|
2b91339cb8 | ||
|
|
ffa4577793 | ||
|
|
4a32c8fb36 | ||
|
|
9bb8d84f12 | ||
|
|
f42aca690f | ||
|
|
34a02fdac5 | ||
|
|
539ac2f2a5 | ||
|
|
f5a75d9edc | ||
|
|
430499bdab | ||
|
|
92578803c2 |
@@ -50,7 +50,10 @@ on:
|
||||
- 'crates/pf-vaadec/**'
|
||||
- 'packaging/flatpak/**'
|
||||
- 'Cargo.lock'
|
||||
# Both halves of this job's correctness, not of the bundle's content: a change to either
|
||||
# can only be proven by a real run, and there is no other trigger that would give it one.
|
||||
- '.gitea/workflows/flatpak.yml'
|
||||
- 'scripts/ci/flatpak-deps-present.sh'
|
||||
tags: ['v*']
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -270,12 +273,13 @@ jobs:
|
||||
|
||||
- name: Prefetch deps + sources (retried — the network phase, split off the build)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# All of the job's heavy network I/O happens HERE, retried, so a dropped DNS lookup
|
||||
# or TCP dial costs a backoff-retry instead of the whole (long) compile:
|
||||
# 1) --install-deps-only pulls everything the manifest declares from Flathub: the
|
||||
# GNOME 50 runtime/SDK + the rust-stable (//25.08, rustc 1.96) and llvm20 SDK
|
||||
# extensions. (No codec extension: the client links no FFmpeg — see the
|
||||
# manifest header.)
|
||||
# 1) the Flathub deps the manifest declares — the GNOME 50 runtime/SDK + the
|
||||
# rust-stable (//25.08, rustc 1.96) and llvm20 SDK extensions — but ONLY the ones
|
||||
# genuinely MISSING; see the block below. (No codec extension: the client links no
|
||||
# FFmpeg — see the manifest header.)
|
||||
# 2) --download-only fetches every source (all crates in cargo-sources.json) into
|
||||
# the .flatpak-builder state dir. Both are resumable/idempotent, so re-running
|
||||
# after a partial failure is safe and cheap.
|
||||
@@ -288,9 +292,40 @@ jobs:
|
||||
# for the mechanism.
|
||||
# 10 attempts (~9min budget), matching the remote-add bootstrap above — same shared,
|
||||
# load-sensitive runner, same flathub.org resolution path.
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
#
|
||||
# WHY THIS IS NOT AN UNCONDITIONAL `--install-deps-only` ANY MORE (2026-08-22):
|
||||
# that flag does not install what is missing, it UPDATES what is present.
|
||||
# builder_manifest_install_dep() branches on `flatpak info --show-commit <ref>` succeeding
|
||||
# and runs `flatpak update` for every dep already installed — with no fallback to a
|
||||
# plain install when that update fails — and ci/flatpak-ci.Dockerfile bakes
|
||||
# the entire runtime set, so on a healthy run it was a pure no-op that nonetheless made
|
||||
# every build depend on Flathub being healthy at that minute. It bit on 2026-08-22:
|
||||
# Updating runtime/org.freedesktop.Sdk.Extension.rust-stable/x86_64/25.08
|
||||
# Error: Failed to update org.freedesktop.Sdk.Extension.rust-stable: While pulling …
|
||||
# .filez: Server returned HTTP 404
|
||||
# dl.flathub.org served a 404 for one object of the then-current rust-stable//25.08
|
||||
# commit, deterministically — all 10 retry.sh attempts died on the SAME object over
|
||||
# ~9 min — and flatpak-builder SEGFAULTED on its own error path (rc=139), so retry.sh
|
||||
# saw a crash rather than a clean "this will never work" either. The build never wanted
|
||||
# that newer commit: the manifest pins a runtime VERSION, not a commit, and the baked
|
||||
# one satisfies it. Updating bought nothing and imported an upstream outage.
|
||||
#
|
||||
# So: assert what the image already has, and reach for Flathub only on a real miss —
|
||||
# the same "guard, don't install on top of a stale image" doctrine as the Tooling step.
|
||||
# The check lives in scripts/ci/flatpak-deps-present.sh (run its --self-test after
|
||||
# touching it): a bug in it that reports "satisfied" when it is not would build against
|
||||
# whatever runtime happened to be lying around, which is worth more than an inline
|
||||
# if-statement. It deliberately fails OPEN — anything it cannot parse takes the slow
|
||||
# install path below.
|
||||
if bash scripts/ci/flatpak-deps-present.sh "$MANIFEST"; then
|
||||
echo "deps satisfied by the baked image — not touching Flathub"
|
||||
flatpak list --user --columns=ref
|
||||
else
|
||||
echo "::warning::$MANIFEST declares deps punktfunk-flatpak-ci does not have — pulling from Flathub (~1.5 GB). Bump GNOME_VERSION/FREEDESKTOP_VERSION in ci/flatpak-ci.Dockerfile so this stays off the hot path."
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--install-deps-from=flathub --install-deps-only \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
fi
|
||||
bash scripts/ci/retry.sh 10 flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--download-only --disable-updates \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
@@ -298,7 +333,17 @@ jobs:
|
||||
- name: Build the flatpak (offline — deps + sources prefetched above)
|
||||
run: |
|
||||
# Everything is already local (state dir warmed by the prefetch step), so this long
|
||||
# step needs no network; --install-deps-from stays as a no-op safety net.
|
||||
# step needs no network.
|
||||
#
|
||||
# --install-deps-from=flathub USED to sit here, commented as "a no-op safety net". It
|
||||
# was neither. builder-main.c calls builder_manifest_install_deps() whenever that flag
|
||||
# is set — --install-deps-only only decides whether it EXITS afterwards — so this step
|
||||
# re-ran the same `flatpak update` of the runtimes that killed the prefetch step on
|
||||
# 2026-08-22 (Flathub HTTP 404 on a rust-stable//25.08 object; see there). A live pull
|
||||
# of multi-GB runtimes is a strange thing to call a safety net in the step whose whole
|
||||
# design is to be offline, and it could only ever fire if the prefetch step above had
|
||||
# already failed the job. Dropped: the prefetch step is the one place that talks to
|
||||
# Flathub, and it is the one place with retries.
|
||||
#
|
||||
# --disable-updates is LOAD-BEARING, not tidiness: without it this step was never
|
||||
# actually offline. flatpak-builder runs the DOWNLOAD PHASE again as part of every
|
||||
@@ -326,7 +371,6 @@ jobs:
|
||||
flatpak-builder --user --force-clean --disable-rofiles-fuse \
|
||||
--default-branch="$FLATPAK_BRANCH" \
|
||||
--disable-updates \
|
||||
--install-deps-from=flathub \
|
||||
--repo="$PWD/repo" \
|
||||
"$PWD/build-dir" "$MANIFEST"
|
||||
|
||||
|
||||
+94
-1
@@ -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.",
|
||||
|
||||
@@ -515,8 +515,21 @@ class MainActivity : ComponentActivity() {
|
||||
else -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
}
|
||||
|
||||
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
|
||||
/**
|
||||
* Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate].
|
||||
*
|
||||
* NEVER on a TV, which leaves the id at `0` and makes every [setConsoleHighRefreshRate] call a
|
||||
* no-op. The pin exists for phone refresh governors that cap third-party apps at 60 Hz; a TV has
|
||||
* no such governor, and there it does active harm. `display.mode` is what [nativeDisplayMode]
|
||||
* reads to resolve "Native" refresh at connect, so a menu-time pin makes the session negotiate
|
||||
* the PINNED rate rather than the TV's real HDMI output — and [StreamScreen] then releases the
|
||||
* pin on TV (the decoder's own mode switch governs there), dropping the panel back to 60 while
|
||||
* the host is already serving 120. Every frame then waits out that mismatch, which is the
|
||||
* "latency explodes unless I set the refresh by hand" field report: picking a refresh explicitly
|
||||
* is precisely what bypasses the corrupted `nativeDisplayMode` answer.
|
||||
*/
|
||||
private fun resolveHighRefreshMode() {
|
||||
if (isTvDevice(this)) return
|
||||
@Suppress("DEPRECATION")
|
||||
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
|
||||
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
|
||||
|
||||
@@ -478,7 +478,12 @@ fun nativeDisplayMode(context: Context): Triple<Int, Int, Int> {
|
||||
val mode = display.mode
|
||||
val w = mode.physicalWidth
|
||||
val h = mode.physicalHeight
|
||||
val hz = mode.refreshRate.toInt().coerceAtLeast(1)
|
||||
// ROUNDED, not truncated: TVs report the fractional NTSC rates over HDMI (59.94, 29.97,
|
||||
// 23.976), and `toInt()` turns 59.94 into 59 — a rate no display mode anywhere has, which the
|
||||
// host then serves by clamping DOWN to the highest mode it advertises at or below it. Rounding
|
||||
// also keeps this agreeing with `MainActivity.streamPanelFps`, which already rounds; the two
|
||||
// describe the same panel and must not disagree.
|
||||
val hz = kotlin.math.round(mode.refreshRate).toInt().coerceAtLeast(1)
|
||||
return Triple(maxOf(w, h), minOf(w, h), hz)
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|
||||
|| state.forced_screen_env
|
||||
}
|
||||
|
||||
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
|
||||
/// a host that died holding one ([`restore_takeover_on_startup`]).
|
||||
///
|
||||
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
|
||||
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
|
||||
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
|
||||
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
|
||||
fn hand_back_idled_units_after_crash() {
|
||||
if box_session_live() {
|
||||
return; // something is already drawing — the drop-in was inert
|
||||
}
|
||||
let units: Vec<String> = listed_autologin_units()
|
||||
.into_iter()
|
||||
.filter(|(_, active)| active == "active")
|
||||
.map(|(unit, _)| unit)
|
||||
.collect();
|
||||
if units.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
?units,
|
||||
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
|
||||
is dark — restarting it"
|
||||
);
|
||||
for unit in &units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
|
||||
tracing::error!(unit, status = %why, "gamescope: could not restart it");
|
||||
}
|
||||
}
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
|
||||
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
|
||||
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
|
||||
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
|
||||
@@ -399,6 +431,13 @@ pub fn restore_takeover_on_startup() {
|
||||
"gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
|
||||
own Game Mode session would have started and then done nothing"
|
||||
);
|
||||
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
|
||||
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
|
||||
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
|
||||
// — and all three of those exits used to leave the box sitting on a dark panel with its
|
||||
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
|
||||
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
|
||||
hand_back_idled_units_after_crash();
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
@@ -2984,6 +3023,48 @@ fn replay_switch_under_restored_dm(dm: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
|
||||
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
|
||||
///
|
||||
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
|
||||
/// takeover then frees nothing rather than killing a session it could not see properly, and the
|
||||
/// crash hand-back restarts nothing rather than bouncing one.
|
||||
fn listed_autologin_units() -> Vec<(String, String)> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
|
||||
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
|
||||
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
|
||||
/// holding the instance our own launch then collides with, and a dead one read as live idles a
|
||||
/// session nobody was in.
|
||||
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||
@@ -3011,33 +3092,9 @@ fn replay_switch_under_restored_dm(dm: &str) {
|
||||
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
|
||||
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let listed = listed_autologin_units();
|
||||
if listed.is_empty() {
|
||||
return Ok(()); // nothing autologged in — Steam is already free
|
||||
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
|
||||
}
|
||||
let dm = display_manager_unit();
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
@@ -3439,7 +3496,13 @@ pub fn restore_takeover_now() {
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
|
||||
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
|
||||
do_restore_tv_session();
|
||||
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
|
||||
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
|
||||
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
|
||||
// The next host start is what covers a shutdown that left the box dark
|
||||
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
|
||||
// drop-in off it).
|
||||
do_restore_tv_session(false);
|
||||
}
|
||||
|
||||
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
|
||||
@@ -3503,11 +3566,168 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// How long a hand-back waits for the box to show something on its own panel before it starts
|
||||
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
|
||||
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
|
||||
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
|
||||
|
||||
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
|
||||
/// the ordinary start has already had its full grace and not delivered.
|
||||
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Poll slice for the two waits above.
|
||||
const HANDBACK_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
|
||||
///
|
||||
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
|
||||
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
|
||||
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
|
||||
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
|
||||
/// gamescope cannot still be answering for the box.
|
||||
fn box_session_live() -> bool {
|
||||
super::detect_active_session().kind != super::ActiveKind::None
|
||||
}
|
||||
|
||||
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
|
||||
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
|
||||
/// and every remedy below would now be fighting a live stream for the box's session.
|
||||
enum HandbackWait {
|
||||
Live,
|
||||
Superseded,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn wait_for_box_session(grace: Duration) -> HandbackWait {
|
||||
let deadline = Instant::now() + grace;
|
||||
loop {
|
||||
if takeover_live() {
|
||||
return HandbackWait::Superseded;
|
||||
}
|
||||
if box_session_live() {
|
||||
return HandbackWait::Live;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return HandbackWait::TimedOut;
|
||||
}
|
||||
std::thread::sleep(HANDBACK_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
|
||||
/// checks whether the restore it just performed actually WORKED.
|
||||
///
|
||||
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
|
||||
/// not the same question as "does the box show a picture again", and the gap between the two is
|
||||
/// where every "my screen stays black after disconnecting" report lives — including ones whose
|
||||
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
|
||||
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
|
||||
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
|
||||
/// f44, 2026-08-22), and say loudly at every rung what is happening.
|
||||
///
|
||||
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
|
||||
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
|
||||
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
|
||||
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
|
||||
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
|
||||
/// ordinary restart already failed to bring back.
|
||||
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
|
||||
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
|
||||
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
|
||||
///
|
||||
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
|
||||
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
|
||||
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
|
||||
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
|
||||
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
|
||||
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
|
||||
/// new one and stands down immediately.
|
||||
///
|
||||
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
|
||||
fn ensure_box_session_or_escalate(units: &[String]) {
|
||||
let units: Vec<String> = units.to_vec();
|
||||
std::thread::spawn(move || handback_watch(&units));
|
||||
}
|
||||
|
||||
fn handback_watch(units: &[String]) {
|
||||
match wait_for_box_session(HANDBACK_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the box is driving its own panel again — hand-back complete"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
tracing::warn!(
|
||||
secs = HANDBACK_GRACE.as_secs(),
|
||||
units = ?units,
|
||||
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
|
||||
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
|
||||
session with a seat",
|
||||
HANDBACK_GRACE.as_secs()
|
||||
);
|
||||
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
|
||||
for unit in units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
|
||||
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
|
||||
}
|
||||
}
|
||||
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the display manager relogged the box into its own session — panel back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
// Rung 2 — put the display manager itself through a restart.
|
||||
if let Some(dm) = display_manager_unit() {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: the box is still dark — restarting its display manager"
|
||||
);
|
||||
match restore_display_manager(&dm) {
|
||||
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(%dm, "gamescope: the display manager brought the box back");
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
},
|
||||
Err(why) => tracing::warn!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"gamescope: could not restart the display manager"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
|
||||
if crate::try_recover_session() {
|
||||
tracing::warn!(
|
||||
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
units = ?units,
|
||||
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
|
||||
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
|
||||
these, or `sudo systemctl restart display-manager.service`. Set \
|
||||
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session() {
|
||||
fn do_restore_tv_session(verify: bool) {
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
@@ -3574,6 +3794,9 @@ fn do_restore_tv_session() {
|
||||
),
|
||||
}
|
||||
clear_takeover(); // A3: consumed — after the restart, not before it
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3699,14 +3922,14 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
// (The idle drop-in is already gone — removed above every early return, so the restarts
|
||||
// below bring the box's real session back rather than another idle one.)
|
||||
for unit in units {
|
||||
for unit in &units {
|
||||
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
|
||||
// that logged an unconditional success over a thrown-away exit status. A `--user start`
|
||||
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
|
||||
// and the DM branch thirty lines up already shows the shape — say what happened.
|
||||
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
|
||||
// active unit is a no-op that would report success over a session still running nothing.
|
||||
match issue_restore_verb(&["restart", &unit]) {
|
||||
match issue_restore_verb(&["restart", unit]) {
|
||||
RestoreVerb::Done => tracing::info!(
|
||||
unit,
|
||||
"restored the TV's autologin gaming session (debounce elapsed, no client)"
|
||||
@@ -3731,6 +3954,12 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
|
||||
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
|
||||
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
|
||||
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
|
||||
@@ -3767,7 +3996,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
}
|
||||
};
|
||||
if still_due {
|
||||
do_restore_tv_session();
|
||||
// The disconnect restore: verified. This is the path the field reports
|
||||
// are about, it is on a worker thread with no deadline over it, and a box
|
||||
// left dark here stays dark until someone walks up to it.
|
||||
do_restore_tv_session(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5334,12 +5566,12 @@ mod tests {
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
|
||||
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
|
||||
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
|
||||
release_autologin_mask, remove_idle_dropin, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
|
||||
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
|
||||
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE,
|
||||
RESTORE_FLIGHT, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -5538,6 +5770,39 @@ mod tests {
|
||||
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
|
||||
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
|
||||
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
|
||||
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
|
||||
/// list-units` output from both distro families. Read the wrong column and a live gaming
|
||||
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
|
||||
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
|
||||
#[test]
|
||||
fn listed_units_take_the_active_column_not_the_load_column() {
|
||||
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
|
||||
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
|
||||
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
|
||||
assert_eq!(
|
||||
parse_listed_units(out),
|
||||
vec![
|
||||
(
|
||||
"gamescope-session-plus@ogui-steam.service".to_string(),
|
||||
"active".to_string()
|
||||
),
|
||||
(
|
||||
"gamescope-session-plus@steam.service".to_string(),
|
||||
"inactive".to_string()
|
||||
),
|
||||
]
|
||||
);
|
||||
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
|
||||
// off-by-one this pins.
|
||||
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
|
||||
// Anything that is not one of our template's instances is not ours to touch.
|
||||
assert!(
|
||||
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(parse_listed_units("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_dropin_replaces_exec_start_rather_than_appending() {
|
||||
let body = idle_dropin_body("/usr/bin/sleep");
|
||||
|
||||
@@ -26,17 +26,26 @@
|
||||
|
||||
use super::{audio_control, audio_probe, minted, pad_endpoint as pe};
|
||||
use anyhow::Result;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::SetupDiEnumDeviceInfo;
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
|
||||
/// The `Device Parameters` REG_DWORD each punktfunk-minted devnode family stamps on itself. The
|
||||
/// VALUE is what differs per family; presence of the NAME is "this one is ours", which is all a
|
||||
/// sweep needs.
|
||||
const OWNER_MARKERS: [&str; 3] = [
|
||||
pub(crate) const OWNER_MARKERS: [&str; 3] = [
|
||||
pe::PAD_INDEX_VALUE,
|
||||
minted::ROLE_MARKER,
|
||||
audio_probe::PROBE_MARKER,
|
||||
];
|
||||
|
||||
/// The Steam streaming hardware ids every audio devnode this product mints is created with —
|
||||
/// the second half of the ABANDONED-devnode test in [`owned_devnodes`].
|
||||
const MINTED_HWIDS: [&str; 2] = [
|
||||
"ROOT\\SteamStreamingSpeakers",
|
||||
"ROOT\\SteamStreamingMicrophone",
|
||||
];
|
||||
|
||||
/// What one sweep removed. `endpoint_records` is counted separately from `devnodes` because the
|
||||
/// registry half is best-effort by design — see [`delete_endpoint_record`].
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -117,11 +126,91 @@ fn owned_devnodes() -> Result<Vec<String>> {
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
out.push(inst);
|
||||
continue;
|
||||
}
|
||||
// ABANDONED: `ROOT\MEDIA\NNNN` carrying one of our minting hardware ids but no marker at
|
||||
// all — a devnode registered by a host that died before the marker write landed. It is
|
||||
// still bound and still serving endpoints, so leaving it behind is the "uninstalling
|
||||
// punktfunk left Sound settings full of Punktfunk devices forever" report all over again.
|
||||
//
|
||||
// The instance prefix is what makes this safe, and it is NOT redundant with
|
||||
// [`is_removable_instance`]: Steam's own devnodes carry these very hardware ids and are
|
||||
// ROOT-enumerated too, but live under `ROOT\SteamStreamingSpeakers\*` /
|
||||
// `ROOT\SteamStreamingMicrophone\*`. Only `ROOT\MEDIA\*` can have come from our
|
||||
// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)`.
|
||||
if is_abandoned_mint(
|
||||
&inst,
|
||||
&pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID),
|
||||
) {
|
||||
out.push(inst);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The ABANDONED-devnode test, split out from the PnP enumeration so the rule that keeps this
|
||||
/// sweep off VALVE'S OWN devices is checkable without a live devinfo set. See [`owned_devnodes`].
|
||||
fn is_abandoned_mint(instance_id: &str, hwids: &[String]) -> bool {
|
||||
instance_id
|
||||
.to_ascii_uppercase()
|
||||
.starts_with("ROOT\\MEDIA\\")
|
||||
&& MINTED_HWIDS
|
||||
.iter()
|
||||
.any(|want| hwids.iter().any(|h| h.eq_ignore_ascii_case(want)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod abandoned_tests {
|
||||
use super::is_abandoned_mint;
|
||||
|
||||
fn hw(s: &str) -> Vec<String> {
|
||||
vec![s.to_string()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adopts_our_own_unmarked_devnodes() {
|
||||
// What a host that died mid-mint leaves behind, either role.
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0004",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(is_abandoned_mint(
|
||||
r"ROOT\MEDIA\0002",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
// PnP casing is not guaranteed on either half.
|
||||
assert!(is_abandoned_mint(
|
||||
r"root\media\0009",
|
||||
&hw(r"root\steamstreamingspeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_valves_own_devices() {
|
||||
// THE safety rule: Steam's devnodes carry the very same hardware ids and are ROOT-
|
||||
// enumerated too — only the instance prefix separates them from ours.
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGMICROPHONE\0000",
|
||||
&hw(r"ROOT\SteamStreamingMicrophone")
|
||||
));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"ROOT\STEAMSTREAMINGSPEAKERS\0000",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_matches_other_vendors_or_real_hardware() {
|
||||
// VB-Cable mints ROOT\MEDIA devnodes too — a different hardware id is all that saves it.
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0000", &hw("VBAudioVACWDM")));
|
||||
assert!(!is_abandoned_mint(
|
||||
r"HDAUDIO\FUNC_01&VEN_10EC&DEV_0897",
|
||||
&hw(r"ROOT\SteamStreamingSpeakers")
|
||||
));
|
||||
assert!(!is_abandoned_mint(r"ROOT\MEDIA\0001", &[]));
|
||||
}
|
||||
}
|
||||
|
||||
/// A devnode this sweep is allowed to remove: ROOT-enumerated, i.e. software-created.
|
||||
///
|
||||
/// Every devnode we mint comes from `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA
|
||||
|
||||
@@ -275,13 +275,22 @@ fn ensure_role(role: Role) -> Result<(String, String, Option<String>)> {
|
||||
let (hwid, inf) = discover_driver(role.needle(), role.inf_name())?;
|
||||
let devnode = match find_role_devnode(role)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
// Before minting a SECOND devnode, reclaim an abandoned one. Minting is two PnP steps
|
||||
// (register, then mark), and a host that dies between them — the 0.30.0 teardown abort
|
||||
// did exactly this, five times on one box — leaves a registered, driver-bound, endpoint-
|
||||
// serving devnode that carries no marker. Nothing then resolves it: the next pass mints
|
||||
// a fresh one and the orphan lingers as a duplicate "Punktfunk Speakers"/"Punktfunk
|
||||
// Microphone" in the Sound zoo, invisible to the marker-matched uninstall sweep.
|
||||
None => match adopt_orphan_devnode(role, &hwid)? {
|
||||
Some(inst) => inst,
|
||||
None => {
|
||||
let inst = pe::create_media_devnode(role.desc(), &hwid, |set, did| {
|
||||
pe::write_devparam_dword(set, did, ROLE_MARKER, role.value())
|
||||
})?;
|
||||
tracing::info!(role = role.label(), devnode = %inst, "minted an audio devnode");
|
||||
inst
|
||||
}
|
||||
},
|
||||
};
|
||||
pe::bind_driver(&hwid, &inf)?;
|
||||
|
||||
@@ -531,6 +540,61 @@ fn find_role_devnode(role: Role) -> Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Reclaim an ABANDONED punktfunk devnode for `role`, re-marking it so it resolves normally from
|
||||
/// here on; `None` when there is nothing to adopt (the ordinary first-mint path).
|
||||
///
|
||||
/// The shape adopted is `ROOT\MEDIA\NNNN` + the role's Steam hardware id + NO owner marker.
|
||||
/// That triple can only be ours: `ROOT\MEDIA\NNNN` is what
|
||||
/// `SetupDiCreateDeviceInfoW(… DICD_GENERATE_ID)` on the MEDIA class yields, and STEAM'S OWN
|
||||
/// devnodes are enumerated under `ROOT\SteamStreamingSpeakers\*` /
|
||||
/// `ROOT\SteamStreamingMicrophone\*` — they carry the same hardware id but never that instance
|
||||
/// prefix, which is precisely what keeps this from adopting (and later sweeping) Steam's devices.
|
||||
/// A marker of ANY family is left alone: it is a live devnode, ours but spoken for.
|
||||
///
|
||||
/// Which family the orphan came from does not matter. Every one is a plain instance of the same
|
||||
/// Valve driver; roles are ours to assign, and re-marking it here is what makes the assignment
|
||||
/// stick across restarts.
|
||||
fn adopt_orphan_devnode(role: Role, hwid: &str) -> Result<Option<String>> {
|
||||
use windows::Win32::Devices::DeviceAndDriverInstallation::{
|
||||
SetupDiEnumDeviceInfo, SPDRP_HARDWAREID,
|
||||
};
|
||||
let set = pe::media_class_devs()?;
|
||||
for i in 0.. {
|
||||
let mut did = pe::devinfo_data();
|
||||
// SAFETY: live set; `did` is a live out-param with cbSize set.
|
||||
if unsafe { SetupDiEnumDeviceInfo(set.0, i, &mut did) }.is_err() {
|
||||
break; // ERROR_NO_MORE_ITEMS
|
||||
}
|
||||
let Some(inst) = pe::instance_id(&set, &did) else {
|
||||
continue;
|
||||
};
|
||||
if !inst.to_ascii_uppercase().starts_with("ROOT\\MEDIA\\") {
|
||||
continue;
|
||||
}
|
||||
if !pe::devnode_multi_sz_prop(&set, &did, SPDRP_HARDWAREID)
|
||||
.iter()
|
||||
.any(|h| h.eq_ignore_ascii_case(hwid))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if super::devnode_cleanup::OWNER_MARKERS
|
||||
.iter()
|
||||
.any(|m| pe::read_devparam_dword(&set, &did, m).is_some())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
pe::write_devparam_dword(&set, &mut did, ROLE_MARKER, role.value())?;
|
||||
tracing::warn!(
|
||||
role = role.label(),
|
||||
devnode = %inst,
|
||||
"adopted an abandoned audio devnode — one of ours whose owner marker never landed \
|
||||
(a host that died mid-mint). Re-marked and reused instead of minting a duplicate"
|
||||
);
|
||||
return Ok(Some(inst));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Find the (exact hardware id, INF path) for one of Steam's streaming drivers: prefer any
|
||||
/// installed devnode whose hardware-id list contains `needle` (its `oemNN.inf` is the driver
|
||||
/// Windows already trusts), else fall back to Steam's driver directory. Shared with the
|
||||
|
||||
@@ -1192,6 +1192,17 @@ fn grant_system_full_control(subkey_path: &str) -> Result<()> {
|
||||
result
|
||||
}
|
||||
|
||||
/// The MMDevices hive an endpoint's record lives in, chosen by the direction its id encodes
|
||||
/// (`{0.0.1.…}` = capture, anything else = render). Render is the safe default: it is what every
|
||||
/// non-capture id resolves to, and the pad program only ever has render endpoints.
|
||||
fn mmdev_path_for(endpoint_id: &str) -> &'static str {
|
||||
if endpoint_id.starts_with(CAPTURE_ENDPOINT_ID_PREFIX) {
|
||||
MMDEV_CAPTURE_PATH
|
||||
} else {
|
||||
MMDEV_RENDER_PATH
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw-registry stamp route: repair the Properties key ACL, then write the serialized
|
||||
/// values (see [`reg_registry_value`]). Values written here are STORED but possibly not
|
||||
/// SERVED until an AudioEndpointBuilder restart — the caller's read-back decides.
|
||||
@@ -1199,7 +1210,14 @@ fn registry_stamp(endpoint_id: &str, stamps: &[&Stamp]) -> Result<()> {
|
||||
use winreg::enums::HKEY_LOCAL_MACHINE;
|
||||
use winreg::RegKey;
|
||||
let guid = endpoint_guid_part(endpoint_id)?;
|
||||
let path = format!(r"{MMDEV_RENDER_PATH}\{guid}\Properties");
|
||||
// The hive follows the endpoint's DIRECTION. This was hardcoded to Render, which is
|
||||
// invisible for the pad program (its endpoints are render-only) but wrong for the minted
|
||||
// provider, which stamps the virtual microphone's CAPTURE endpoint through the same
|
||||
// writer: the fallback then reached for `…\Render\{capture-guid}\Properties`, a key that
|
||||
// cannot exist, so every registry-route stamp of a capture endpoint failed on a box where
|
||||
// the property store was denied — silently, since the caller degrades to "keeps the
|
||||
// driver's default name".
|
||||
let path = format!(r"{}\{guid}\Properties", mmdev_path_for(endpoint_id));
|
||||
grant_system_full_control(&path)
|
||||
.with_context(|| format!("make {path} writable (registry stamp route)"))?;
|
||||
let key = RegKey::predef(HKEY_LOCAL_MACHINE)
|
||||
@@ -2109,6 +2127,23 @@ fn pad_capture_thread(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The registry stamp route must reach for the hive matching the endpoint's DIRECTION —
|
||||
/// it was hardcoded to Render, so a capture endpoint's fallback stamp could never land.
|
||||
#[test]
|
||||
fn registry_stamp_hive_follows_the_endpoint_direction() {
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.1.00000000}.{2753f927-2093-4ab4-aa90-9d880e959128}"),
|
||||
MMDEV_CAPTURE_PATH,
|
||||
"the minted microphone's capture endpoint records under Capture"
|
||||
);
|
||||
assert_eq!(
|
||||
mmdev_path_for("{0.0.0.00000000}.{5da9b5c9-8a10-4b54-8cf6-ce02b8354f16}"),
|
||||
MMDEV_RENDER_PATH,
|
||||
);
|
||||
// Anything unrecognised keeps the old behaviour rather than inventing a hive.
|
||||
assert_eq!(mmdev_path_for("nonsense"), MMDEV_RENDER_PATH);
|
||||
}
|
||||
|
||||
/// The serialized container blob for pad 0 must be byte-for-byte the on-glass-measured
|
||||
/// value, and byte 23 must be the pad index.
|
||||
#[test]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1111,6 +1111,21 @@ fn spawn_sender(
|
||||
|
||||
use crate::send_pacing::percentile;
|
||||
|
||||
/// How long to ignore further keyframe requests after emitting one.
|
||||
///
|
||||
/// The window bounds IDR emission in TIME, so it needs an absolute floor rather than a frame
|
||||
/// count: it has to outlast the round trip in which the client receives and decodes the IDR it
|
||||
/// already asked for. The original `frame_interval * 2` closes long before that at high refresh —
|
||||
/// 16.7 ms at 120 fps, while a Moonlight client under loss re-asks every ~30 ms — so every request
|
||||
/// passed the gate and the stream became ~32 full IDRs/s, whose bulk causes the very loss that
|
||||
/// prompts the next request. That storm sustains itself and reads as stutter at a flat latency
|
||||
/// (field log, AMD RX 7800 XT / Bazzite 44 HEVC, 2026-08-22: 1118 requests, 1115 honoured, 3
|
||||
/// coalesced). 100 ms matches the encoder-reset backoff below and is about one IDR's service time
|
||||
/// on a saturated link.
|
||||
fn keyframe_coalesce_window(frame_interval: Duration) -> Duration {
|
||||
(frame_interval * 2).max(Duration::from_millis(100))
|
||||
}
|
||||
|
||||
/// The encode → packetize loop, over a borrowed capturer. Sending runs on a dedicated thread
|
||||
/// (see [`spawn_sender`]) so a send spike can never stall capture/encode.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1194,6 +1209,11 @@ fn stream_body(
|
||||
// also fails safe when nobody tells it, but pass the REAL depth: `idd_depth` is configurable
|
||||
// and a deeper ring is free pipelining the fallback would forfeit.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
// What `enc` was opened against. The capture source can change size/format UNDER this loop with
|
||||
// nothing negotiating it (see the follow-the-source guard below); tracked so the loop can notice.
|
||||
// Both sites that swap `enc` re-bind `frame` with it, so this is always
|
||||
// `(frame.format, frame.width, frame.height)` right after one.
|
||||
let mut enc_src = (frame.format, frame.width, frame.height);
|
||||
// FEC overhead percent (Sunshine default 20). Override with PUNKTFUNK_FEC_PCT (0 = data-only).
|
||||
let fec_pct: u8 = std::env::var("PUNKTFUNK_FEC_PCT")
|
||||
.ok()
|
||||
@@ -1273,9 +1293,9 @@ fn stream_body(
|
||||
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
|
||||
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
|
||||
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
|
||||
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
|
||||
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = frame_interval * 2;
|
||||
// we ignore further keyframe requests for the in-flight window below. NVENC ref-invalidation
|
||||
// (cheap, no IDR spike) is never rate-limited — only full keyframes are.
|
||||
let keyframe_coalesce = keyframe_coalesce_window(frame_interval);
|
||||
let mut last_keyframe: Option<Instant> = None;
|
||||
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
|
||||
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
|
||||
@@ -1362,6 +1382,7 @@ fn stream_body(
|
||||
.context("reopen encoder after rebuild")?;
|
||||
// A rebuilt encoder starts unconfigured — same reason as the first open above.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
@@ -1375,6 +1396,82 @@ fn stream_body(
|
||||
}
|
||||
}
|
||||
let t_cap = tick.elapsed();
|
||||
// Follow an AUTONOMOUS source mode change — one nothing negotiated. The IDD-push capturer
|
||||
// re-opens its ring on a confirmed display-descriptor change (a fullscreen game mode-setting
|
||||
// the virtual display, or an HDR flip changing the format), and the encoder is the one
|
||||
// component that cannot follow a resolution change in place. Every `submit` below then
|
||||
// refuses the frame ("captured WxH != encoder AxB"), and the submit ladder only rebuilds the
|
||||
// encoder IN PLACE — at the SAME configured size — which cannot fix a size the source has
|
||||
// already left, so all five resets burn on it and the stream ends (native/stream.rs carried
|
||||
// the identical gap; a 2026-08-22 field report hit it there at 4K→1080p).
|
||||
//
|
||||
// GameStream has no mid-stream mode-change message, so the client is NOT told: Moonlight
|
||||
// decodes a bitstream that disagrees with the resolution it configured its decoder from.
|
||||
// That is the same bargain the first open above already takes whenever the captured size
|
||||
// differs from the negotiated one (the monitor-mirror case) — tolerant decoders re-init off
|
||||
// the SPS and scale, a strict one (Media Foundation on Xbox) may stall and drop the session.
|
||||
// Taking it here too is strictly better than the alternative, which is ending every stream
|
||||
// the moment a game changes mode.
|
||||
if enc_src != (frame.format, frame.width, frame.height) {
|
||||
match encode::open_video(
|
||||
cfg.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
cfg.fps,
|
||||
cfg.bitrate_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
// Derived from the delivered format, so an HDR flip re-opens at the right depth.
|
||||
gs_bit_depth(frame.format),
|
||||
encode::ChromaFormat::Yuv420, // GameStream stays 4:2:0 — see the first open
|
||||
cursor_blend, // same capture cursor mode — see the first open
|
||||
cfg.slices, // client slicing ceiling — see the first open
|
||||
) {
|
||||
Ok(e) => {
|
||||
tracing::info!(
|
||||
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
|
||||
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
|
||||
negotiated = ?(cfg.width, cfg.height),
|
||||
"gamestream: the capture source changed mode mid-stream — reopened the \
|
||||
encoder at the delivered size (the client is not told; a strict decoder \
|
||||
may not follow — see the note at this guard)"
|
||||
);
|
||||
enc = e;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// A rebuilt encoder starts unconfigured — same reasons as the first open.
|
||||
enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
supports_rfi = enc.caps().supports_rfi;
|
||||
enc.request_keyframe();
|
||||
last_keyframe = Some(Instant::now());
|
||||
// The old encoder died with its in-flight submissions — their AUs will never
|
||||
// arrive, so the numbering prediction restarts at `au_seq` (same reasoning as
|
||||
// the capture rebuild above). Restart the stall clock for the fresh encoder and
|
||||
// give it the full reset budget.
|
||||
enc_inflight = 0;
|
||||
encoder_resets = 0;
|
||||
last_au_at = Instant::now();
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spend the stream on the FIRST failed open: the mode-set that triggered
|
||||
// this is exactly the kind of event that leaves the driver settling, which is
|
||||
// what the submit ladder's backoff exists for. Spend the shared reset budget at
|
||||
// the same exponential pace, re-entering this guard each round — the old encoder
|
||||
// stays installed and mismatched meanwhile, so it simply keeps failing submit.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS {
|
||||
return Err(e).context("reopen encoder at the source's new mode");
|
||||
}
|
||||
let backoff = frame_interval
|
||||
.max(Duration::from_millis(100u64 << (encoder_resets - 1).min(4)));
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"gamestream: reopening the encoder at the source's new mode failed — retrying");
|
||||
next_frame = Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
|
||||
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
|
||||
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
|
||||
@@ -1716,6 +1813,27 @@ mod tests {
|
||||
assert_eq!(t.game.title, "/opt/game/run");
|
||||
}
|
||||
|
||||
/// The coalesce window must bound forced IDRs in time, not in frames. A frame-scaled window
|
||||
/// vanishes exactly where it matters most — at high refresh, where a client's recovery spam
|
||||
/// arrives far slower than two frame intervals and so passes the gate every time.
|
||||
#[test]
|
||||
fn keyframe_coalesce_window_outlasts_a_clients_request_cadence() {
|
||||
// The observed storm: a 120 fps session against a client re-asking every ~30 ms. The
|
||||
// pre-floor window was 16.7 ms, so every request became a full IDR.
|
||||
let at_120 = keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 120.0));
|
||||
assert!(
|
||||
at_120 >= Duration::from_millis(100),
|
||||
"120 fps window {at_120:?} does not outlast a ~30 ms request cadence"
|
||||
);
|
||||
// 60 fps was under the floor too (33.3 ms), which is why this is not a 120-only fix.
|
||||
assert!(keyframe_coalesce_window(Duration::from_secs_f64(1.0 / 60.0)) >= at_120);
|
||||
// A slow stream keeps the frame-scaled window — the floor only ever raises it.
|
||||
assert_eq!(
|
||||
keyframe_coalesce_window(Duration::from_millis(200)),
|
||||
Duration::from_millis(400)
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end check of the send thread: batches pushed on the channel arrive, complete and
|
||||
/// byte-identical, at a peer socket via the paced sendmmsg path.
|
||||
#[test]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
(
|
||||
|
||||
@@ -1875,6 +1875,14 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
mut cur_display_gen,
|
||||
built_bitrate,
|
||||
) = pipe;
|
||||
// What `enc` was opened against. The capture source can change format/size UNDER this loop with
|
||||
// no client `Reconfigure` at all — the IDD-push capturer re-opens its ring on a confirmed
|
||||
// display-descriptor change (a fullscreen game mode-setting the virtual display, an HDR flip) —
|
||||
// and every backend's `submit` then refuses the frame. Tracked so the loop can FOLLOW the
|
||||
// source (see the guard in the submit path) instead of dying against an error no in-place
|
||||
// encoder reset can fix. Every site below that swaps `enc` re-binds `frame` with it, so this is
|
||||
// always `(frame.format, frame.width, frame.height)` immediately after one.
|
||||
let mut enc_src = (frame.format, frame.width, frame.height);
|
||||
// The display exists now, so the portal has answered: settle the cursor plan against what it
|
||||
// actually negotiated rather than what this session asked for (see `settle_portal_cursor`).
|
||||
// `mut`: every capture-loss rebuild re-runs `create`, hence re-negotiates.
|
||||
@@ -2613,6 +2621,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
);
|
||||
cur_mode = new_mode;
|
||||
next = std::time::Instant::now();
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// H2/H3: the backend may have honored a different mode than requested — KWin caps
|
||||
// a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its
|
||||
// running monitor doesn't advertise and the host falls back to the actual display
|
||||
@@ -2695,6 +2704,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
trace.as_ref(),
|
||||
true,
|
||||
) {
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
@@ -3388,6 +3398,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
interval = new_interval;
|
||||
cur_node_id = new_node_id;
|
||||
cur_display_gen = new_display_gen;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
// The rebuild re-ran `create`, so the portal answered again — possibly a different
|
||||
// backend's portal (the retarget above), possibly with a different verdict. Settle
|
||||
// the cursor plan against THIS display, exactly as bring-up did: the retarget arm
|
||||
@@ -3650,6 +3661,106 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// exactly that volume, so host apps already tone-mapped the content into it and the honest
|
||||
// mastering description IS the client's panel. (The IDD capturer only knows the generic
|
||||
// baseline; if the driver ever forwards per-content IDDCX_HDR10_METADATA, prefer that here.)
|
||||
// Follow an AUTONOMOUS source change — one no client `Reconfigure` announced. The IDD-push
|
||||
// capturer re-opens its ring on a confirmed display-descriptor change: a fullscreen game
|
||||
// mode-setting the virtual display (2026-08-22 field report: a 4K60 HEVC session, the game
|
||||
// switched the display to 1080p mid-play), or an HDR flip changing the frame format. The
|
||||
// encoder is the one component that cannot follow that in place (same note as
|
||||
// `try_inplace_resize`), so every `submit` below refuses the frame — and the submit-error
|
||||
// path only rebuilds the encoder IN PLACE, at the SAME configured size, which cannot fix a
|
||||
// size the source has already left. All five resets burn on it and the session ends while
|
||||
// audio keeps running. Reopen at what the source actually delivers instead; the client
|
||||
// learns the new mode from the `Reconfigured` below and its decoder from the opening IDR.
|
||||
if enc_src != (frame.format, frame.width, frame.height) {
|
||||
let actual = delivered_mode(frame.width, frame.height, interval);
|
||||
// Same per-mode pin the client-initiated resize re-resolves: PyroWave's Automatic rate
|
||||
// IS a function of the mode, so carrying the old one across a source-driven mode change
|
||||
// hands it the wrong operating point. H.26x rates are mode-independent (ABR owns them),
|
||||
// and an explicit client rate is never second-guessed.
|
||||
let src_kbps = if bitrate_auto && plan.codec == crate::encode::Codec::PyroWave {
|
||||
resolve_bitrate_kbps_for(plan.codec, 0, &actual, plan.chroma, plan.bit_depth)
|
||||
} else {
|
||||
bitrate_kbps
|
||||
};
|
||||
let opened = crate::encode::open_video(
|
||||
plan.codec,
|
||||
frame.format,
|
||||
frame.width,
|
||||
frame.height,
|
||||
actual.refresh_hz,
|
||||
src_kbps as u64 * 1000,
|
||||
frame.is_cuda(),
|
||||
bit_depth,
|
||||
plan.chroma,
|
||||
plan.cursor_blend,
|
||||
plan.max_slices,
|
||||
)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"the capture source changed to {}x{} {:?} mid-session and the encoder could not \
|
||||
be reopened at it",
|
||||
frame.width, frame.height, frame.format
|
||||
)
|
||||
});
|
||||
let mut new_enc = match opened {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
// Don't spend the session on the FIRST failed open. The mode-set that triggered
|
||||
// this is exactly the kind of event that leaves the driver settling — the same
|
||||
// transient the submit path's backoff exists for ("NVENC session open failing
|
||||
// after a codec switch", 2026-07) — so spend the shared reset budget on it at
|
||||
// the same exponential pace, re-entering this guard each round. The old encoder
|
||||
// is still installed and still mismatched; it simply keeps failing submit until
|
||||
// an open succeeds or the budget runs out.
|
||||
encoder_resets += 1;
|
||||
if encoder_resets > MAX_ENCODER_RESETS {
|
||||
return Err(e).context("encoder reopen at the source's new mode");
|
||||
}
|
||||
let backoff = std::cmp::max(
|
||||
interval,
|
||||
std::time::Duration::from_millis(100u64 << (encoder_resets - 1).min(4)),
|
||||
);
|
||||
tracing::warn!(error = %format!("{e:#}"), reset = encoder_resets,
|
||||
max = MAX_ENCODER_RESETS,
|
||||
"reopening the encoder at the source's new mode failed — retrying");
|
||||
next = std::time::Instant::now() + backoff;
|
||||
std::thread::sleep(backoff);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(c) = plan.wire_chunk {
|
||||
new_enc.set_wire_chunking(c);
|
||||
}
|
||||
// A rebuilt encoder starts with the ring bound unset — re-report it, as every other
|
||||
// rebuild site does, or an in-place backend can encode a texture the capturer has
|
||||
// already rotated and overwritten.
|
||||
new_enc.set_input_ring_depth(capturer.pipeline_depth().max(1));
|
||||
tracing::info!(
|
||||
from = %format!("{}x{} {:?}", enc_src.1, enc_src.2, enc_src.0),
|
||||
to = %format!("{}x{} {:?}", frame.width, frame.height, frame.format),
|
||||
"the capture source changed mode mid-session with no client reconfigure — reopened \
|
||||
the encoder at the delivered size"
|
||||
);
|
||||
enc = new_enc;
|
||||
enc_src = (frame.format, frame.width, frame.height);
|
||||
adopt_built_bitrate(&mut bitrate_kbps, src_kbps, &live_bitrate, &retarget_tx);
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
// A fresh encoder opens on an IDR — anchor the cooldown.
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
// The client's mode slot still says the old size, and its stats/aspect follow it.
|
||||
// Publish what it is really decoding now, exactly as an accepted resize does.
|
||||
live_mode.store(
|
||||
pack_mode(actual.width, actual.height, actual.refresh_hz),
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
let _ = reconfig_result_tx.send(Reconfigured {
|
||||
accepted: true,
|
||||
mode: actual,
|
||||
});
|
||||
}
|
||||
let hdr_meta = capturer.hdr_meta().map(|m| client_hdr.unwrap_or(m));
|
||||
enc.set_hdr_meta(hdr_meta);
|
||||
let mut resend_meta = hdr_meta != last_hdr_meta;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# shellcheck shell=bash
|
||||
# Does this box already have every Flathub dep a flatpak manifest declares?
|
||||
# bash scripts/ci/flatpak-deps-present.sh <manifest.yml> -> exit 0 = yes, 1 = no
|
||||
# bash scripts/ci/flatpak-deps-present.sh --self-test -> run the asserts below
|
||||
#
|
||||
# WHY THIS EXISTS: flatpak.yml used to prefetch deps with `flatpak-builder --install-deps-only`,
|
||||
# which does NOT mean "install what is missing". builder_manifest_install_dep() branches on
|
||||
# `flatpak info --show-commit <ref>` succeeding and runs `flatpak update` for every dep that IS
|
||||
# installed (a failed update is fatal there — it never falls back to install) — and
|
||||
# ci/flatpak-ci.Dockerfile bakes the whole runtime set, so on a healthy run that flag did nothing
|
||||
# except make the build depend on Flathub being up at that minute. On 2026-08-22 it took the job
|
||||
# down: dl.flathub.org returned HTTP 404 for one .filez object of the then-current
|
||||
# rust-stable//25.08 commit, identically on all 10 retry.sh attempts (~9 min), and flatpak-builder
|
||||
# segfaulted on its own error path (rc=139) so the retry wrapper could not tell a dead end from a
|
||||
# blip. Nothing about the build wanted that newer commit: the manifest pins a runtime VERSION, not
|
||||
# a commit, and the baked one satisfies it.
|
||||
#
|
||||
# So the workflow asks this first and only reaches for Flathub on a real miss.
|
||||
#
|
||||
# FAILS OPEN, deliberately: an unreadable/unexpected manifest reports "not present" (1), so the
|
||||
# caller does the full install. Silently skipping the install on a manifest we stopped
|
||||
# understanding is how you build against the wrong runtime.
|
||||
set -uo pipefail
|
||||
|
||||
deps_present() {
|
||||
local manifest="$1" runtime rt_ver sdk exts e
|
||||
|
||||
runtime=$(sed -n 's/^runtime: *//p' "$manifest" | head -1)
|
||||
rt_ver=$(sed -n 's/^runtime-version: *//p' "$manifest" | tr -d "\"'" | head -1)
|
||||
sdk=$(sed -n 's/^sdk: *//p' "$manifest" | head -1)
|
||||
exts=$(sed -n '/^sdk-extensions:/,/^[^ #-]/p' "$manifest" | sed -n 's/^ *- *//p')
|
||||
|
||||
[ -n "$runtime" ] && [ -n "$rt_ver" ] && [ -n "$sdk" ] && [ -n "$exts" ] || return 1
|
||||
|
||||
flatpak info --user "$runtime//$rt_ver" >/dev/null 2>&1 || return 1
|
||||
flatpak info --user "$sdk//$rt_ver" >/dev/null 2>&1 || return 1
|
||||
# Extensions are checked for PRESENCE, not version: flatpak-builder resolves their version from
|
||||
# the SDK's own metadata (it prints "Dependency Extension: … 25.08"), never from the manifest.
|
||||
# Any bump that moves them moves runtime-version too, which the two checks above already catch.
|
||||
for e in $exts; do
|
||||
flatpak info --user "$e" >/dev/null 2>&1 || return 1
|
||||
done
|
||||
}
|
||||
|
||||
self_test() {
|
||||
local rc fails=0 full
|
||||
# NOT `local`: the EXIT trap fires after this function has returned.
|
||||
SELFTEST_TMP=$(mktemp -d) || return 1
|
||||
trap 'rm -rf "$SELFTEST_TMP"' EXIT
|
||||
local tmp="$SELFTEST_TMP"
|
||||
|
||||
cat > "$tmp/ok.yml" <<'YML'
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '50'
|
||||
sdk: org.gnome.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
- org.freedesktop.Sdk.Extension.llvm20
|
||||
command: punktfunk-client
|
||||
YML
|
||||
# A manifest this script cannot read (the fail-open case).
|
||||
printf 'app-id: io.unom.Punktfunk\n' > "$tmp/unparseable.yml"
|
||||
|
||||
# Stub `flatpak`: $INSTALLED is the newline-separated set of refs it admits to having.
|
||||
mkdir -p "$tmp/bin"
|
||||
cat > "$tmp/bin/flatpak" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
# only `flatpak info --user <ref>` is exercised here
|
||||
# args are: info --user <ref>
|
||||
[ "$1" = info ] || exit 0
|
||||
printf '%s\n' "$INSTALLED" | grep -qxF "$3"
|
||||
STUB
|
||||
chmod +x "$tmp/bin/flatpak"
|
||||
PATH="$tmp/bin:$PATH"
|
||||
|
||||
check() { # <expected rc> <label> <installed set> <manifest>
|
||||
INSTALLED="$3" deps_present "$4"; rc=$?
|
||||
if [ "$rc" != "$1" ]; then
|
||||
echo "FAIL: $2 (expected rc=$1, got $rc)" >&2; fails=$((fails + 1))
|
||||
else
|
||||
echo "ok: $2"
|
||||
fi
|
||||
}
|
||||
|
||||
full='org.gnome.Platform//50
|
||||
org.gnome.Sdk//50
|
||||
org.freedesktop.Sdk.Extension.rust-stable
|
||||
org.freedesktop.Sdk.Extension.llvm20'
|
||||
|
||||
check 0 "everything baked -> skip Flathub" "$full" "$tmp/ok.yml"
|
||||
check 1 "cold box -> install" "" "$tmp/ok.yml"
|
||||
check 1 "runtime missing -> install" "${full/org.gnome.Platform\/\/50/x}" "$tmp/ok.yml"
|
||||
check 1 "sdk missing -> install" "${full/org.gnome.Sdk\/\/50/x}" "$tmp/ok.yml"
|
||||
# The regression that started all this: llvm20 fine, rust-stable not.
|
||||
check 1 "one sdk-extension missing -> install" "${full/*.rust-stable/x}" "$tmp/ok.yml"
|
||||
# A runtime installed at ANOTHER version must not pass just because the name matches.
|
||||
check 1 "runtime at the wrong version" 'org.gnome.Platform//51
|
||||
org.gnome.Sdk//51
|
||||
org.freedesktop.Sdk.Extension.rust-stable
|
||||
org.freedesktop.Sdk.Extension.llvm20' "$tmp/ok.yml"
|
||||
check 1 "unreadable manifest -> fail open" "$full" "$tmp/unparseable.yml"
|
||||
|
||||
[ "$fails" = 0 ] || { echo "$fails check(s) failed" >&2; return 1; }
|
||||
echo "all checks passed"
|
||||
}
|
||||
|
||||
case "${1:---help}" in
|
||||
--self-test) self_test ;;
|
||||
--help|-h) sed -n '2,4p' "$0"; exit 2 ;;
|
||||
*) deps_present "$1" ;;
|
||||
esac
|
||||
+379
-62
File diff suppressed because one or more lines are too long
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user