Compare commits

..
Author SHA1 Message Date
enricobuehler cc8eb7df08 feat(clients): every pinned card gets a library, and it launches with that card's profile
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.

Two shapes of bug, one per client:

**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.

**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.

So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:

  * a game that exits returns to the shelf it was launched from, pin and all,
    rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
    `ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
    drops a pin that was unpinned while the game ran, rather than reopening a card
    that no longer exists.
  * Android's stats overlay finally names the profile a library launch used
    (`profileName` was never set on that path).
  * Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
    ignored — and refuses an unknown or ambiguous reference exactly as the connect
    route does (§10.6) rather than degrading to the binding.
  * every shelf says which one it is, in the card's own `host · profile` shape:
    the console's title, GTK's page title, Android's ConsoleHeader, Apple's
    navigation title and its console heading.

Verified per platform, none of it on trust:
  * console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
    pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
  * Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
    pass alone caught `navigationDestination(item:)` needing Hashable, and an
    iOS-only screen was confirmed genuinely compiled by planting a type error.
  * Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
    expectation flipped to match.
  * Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
    cold (3m10s) — that client cannot compile on a Mac.
2026-08-11 22:13:54 +02:00
enricobuehler f5fa9649b7 fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and
pressing A on that card has always connected with the pinned profile as the
one-off the resolver prefers over the host's binding. Y on the same card opens
a library — it is paired and saved, which is the only thing the hint bar asks —
and every title launched off that shelf went out with no profile at all, so the
host's default binding won. Connecting straight from the card honoured the
profile; going through its library did not, which is the shape a user reads as
"the pin works until I pick a game".

The screen was the leak: LibraryScreen copied the row's address, port,
fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to
send and hardcoded `profile: None`, under a comment asserting that game
launches follow the binding. They should follow the card: a launch off a pinned
card's shelf is that card's connect with a title attached.

The screen now carries the row's pin and sends its id as the one-off. It also
says so, in the card's own `host · profile` shape: the shelf's title names the
profile, and so does the connecting card, so which of a host's cards you came
in on is legible from the screen rather than inferred from the tile you pressed
two screens ago. Off the host's primary tile there is no pin and nothing
changes — `None`, and the binding decides.

Console-only. The Apple and Android consoles keep Y off pinned cards outright
(`hasLibrary: profile == nil`), the GTK client clones the card's whole
ConnectRequest — profile included — into its library, and the Windows client
offers "Browse library" from the primary tile only. The console's own
copy-link and wake-then-connect paths already carried the pin.

Both directions are tested, and the pinned one was checked against a reverted
fix: it fails with exactly the reported symptom (left: None, right: "hdr").
2026-08-11 21:42:14 +02:00
29 changed files with 526 additions and 573 deletions
-29
View File
@@ -63,35 +63,6 @@ and `disable_environment` is then consulted last and wins on **presence alone**,
session script never mentions that second variable, so it is the one that survives. Both spellings
go out, on the transient unit and on the box's own session drop-in.
### punktfunk-gamescope `+pfhdr6` — a NO_FOCUS window can no longer steal the composite
🛑 **A mapped-but-unpainted window carrying `GAMESCOPE_NO_FOCUS=1` could win gamescope's focus
selection and turn the composite — and the stream fed from it — black while every health signal
stayed green.** Bazzite's hhd-ui (Handheld Daemon overlay) sets that atom once at init, stamps
Steam's appid, and crash-loops under a headless takeover; each respawn remapped a fullscreen black
window that steamcompmgr then chose over Big Picture (observed on a Bazzite box: client stats
happily decoding 60 fps at 0.1 Mb/s of black; killing hhd-ui restored the picture instantly). No
gamescope — upstream or Bazzite's fork — ever consumed the atom; its setters (hhd-ui, MangoHud)
show and hide via the `STEAM_OVERLAY` protocol and rely on never being focusable. Patch 0008 wires
`GAMESCOPE_NO_FOCUS` exactly like `GAMESCOPE_EXTERNAL_OVERLAY` (read at map, PropertyNotify-tracked,
skipped by both focus-candidate collectors) without touching compositing or `appID`. Banner
`+pfhdr5``+pfhdr6`; no new capability — the bump is so a field box's banner tells the two
behaviors apart.
### Linux capture — the truncated first attempt no longer latches sticky downgrades
🛑 **The pipeline retry loop's deliberately short (2.5 s) first-frame attempt could permanently
downgrade the whole host process.** On expiry, the portal capturer's timeout diagnosis latched
whichever offer it implicated — HDR capture off (per source), the raw-dmabuf offer off, the
EGL→CUDA offer off — as if the compositor had refused it, when the budget was truncated by design
and a gamescope cold start routinely needs longer before delivering anything. One lost race at
connect then pinned every later session to SDR and/or CPU capture until the host restarted. The
truncated attempt is now declared provisional end to end
(`Capturer::next_frame_within_provisional`): its expiry names the same suspect in the error text
but latches nothing; only the full-length attempts that follow hand down negotiation verdicts. The
classification is a pure function with tests
(`pf_capture::linux::first_frame_timeout_tests`).
## v0.27.0
87 commits since v0.26.0.
@@ -56,6 +56,7 @@ import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@@ -74,7 +75,7 @@ fun App(forceGamepadUi: Boolean = false) {
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
@@ -139,9 +140,9 @@ fun App(forceGamepadUi: Boolean = false) {
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
reopenLibrary =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
} else {
null
}
@@ -154,8 +155,8 @@ fun App(forceGamepadUi: Boolean = false) {
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -282,15 +283,19 @@ fun GamepadShell(
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
* Open this library shelf instead of Home on the way in set when a game launched from it has
* just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
var libraryPinId by remember { mutableStateOf<String?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
@@ -301,15 +306,21 @@ fun GamepadShell(
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
?.let { kh ->
libraryHost = kh
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
screen = GamepadScreen.Library
}
onReopenLibraryHandled()
}
@@ -377,7 +388,11 @@ fun GamepadShell(
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
onOpenLibrary = { host, pinId ->
libraryHost = host
libraryPinId = pinId
screen = GamepadScreen.Library
},
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
@@ -407,8 +422,9 @@ fun GamepadShell(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
navActive = s == screen,
pinnedProfileId = libraryPinId,
)
} ?: run { screen = GamepadScreen.Home }
}
@@ -42,7 +42,7 @@ internal fun ConnectPrompts(
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost) -> Unit,
onOpenLibrary: (KnownHost, String?) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
@@ -119,9 +119,11 @@ internal fun ConnectPrompts(
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (libraryEnabled && pin == null) {
{ onDismissOptions(); onOpenLibrary(kh) }
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
// start the card, not a property of the host.
onLibrary = if (libraryEnabled) {
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
} else {
null
},
@@ -100,7 +100,9 @@ fun ConnectScreen(
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
@@ -772,7 +774,7 @@ fun ConnectScreen(
awaiting == null && editTarget == null && optionsTarget == null &&
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
@@ -86,8 +86,10 @@ class HomeTile(
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
* its own shelf and every launch off it carries this profile.
*/
val pinnedProfileId: String? = null,
/**
@@ -101,9 +103,10 @@ class HomeTile(
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
// returns a clear "pair first" message if the host hasn't authorized this device for its
// management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
@@ -94,6 +94,13 @@ fun LibraryScreen(
onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
/**
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
* connect. Null = the host's tile, and the host's binding decides the same rule
* [ProfileStore.resolveFor] applies to every other connect.
*/
pinnedProfileId: String? = null,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
@@ -104,6 +111,14 @@ fun LibraryScreen(
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
// titles on one shelf stream differently.
val profile = remember(host.id, pinnedProfileId) {
ProfileStore(context).resolveFor(host, pinnedProfileId)
}
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
@@ -133,7 +148,16 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("${host.name} — Library")
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
// launch here will use is a property of the shelf, not something to remember from
// the tile two screens back.
ConsoleHeader(
if (pinnedProfileId != null && profile != null) {
"${host.name} · ${profile.name} — Library"
} else {
"${host.name} — Library"
},
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
is LibState.Loading -> LoadingState()
@@ -145,7 +169,7 @@ fun LibraryScreen(
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
context, streamSettings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
@@ -153,11 +177,14 @@ fun LibraryScreen(
onLaunched(
ActiveSession(
handle,
settings,
streamSettings,
host.clipboardSync,
profileName = profile?.name,
hostId = host.id,
// Where to come back to when this game exits.
// Where to come back to when this game exits
// this shelf, pin and all, not the host's default one.
launchedFromLibrary = true,
libraryProfileId = pinnedProfileId,
),
)
}
@@ -71,8 +71,24 @@ data class ActiveSession(
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
/**
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
* trip above lands back on the SAME shelf a player who launched from a pinned card is still
* on that card when the game exits, and coming back to the host's default shelf would silently
* change what the next title streams with.
*/
val libraryProfileId: String? = null,
)
/**
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
* ones, because a hostId that arrives without its profile is not "the same shelf" it is the
* default one wearing the same name.
*/
data class LibraryReturn(val hostId: String, val profileId: String? = null)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -4,7 +4,6 @@ import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -177,9 +176,10 @@ class HomeTilesTest {
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Host tile library (Y); pin tile → none, because a pin is a shortcut, not a second host.
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
// the pinned profile — the library is a way to start a card, not a host-level action.
assertTrue(result[0].hasLibrary)
assertFalse(result[1].hasLibrary)
assertTrue(result[1].hasLibrary)
}
/**
@@ -71,7 +71,7 @@ struct ContentView: View {
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
@State private var awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
@State private var libraryTarget: LibraryTarget?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" phase of the connect overlay). Available on every platform now that the iOS/tvOS
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
@@ -412,10 +412,10 @@ struct ContentView: View {
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
.onChange(of: model.returnToLibrary) { _, shelf in
guard let shelf else { return }
model.returnToLibrary = nil
libraryTarget = host
libraryTarget = shelf
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -448,9 +448,9 @@ struct ContentView: View {
// (the coverflow is a GeometryReader, ideal zero), so without a frame it collapses to a
// tiny panel.
#if os(macOS)
.sheet(item: $libraryTarget) { host in
.sheet(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
}
@@ -461,9 +461,9 @@ struct ContentView: View {
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
.fullScreenCover(item: touchLibraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
}
#endif
@@ -573,7 +573,7 @@ struct ContentView: View {
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
private var touchLibraryTarget: Binding<LibraryTarget?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
@@ -743,6 +743,25 @@ struct ContentView: View {
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
// connect link and refuses the same way (§10.6): an unknown or ambiguous reference must
// never quietly degrade to the host's binding, which is a different shelf wearing the same
// host's name.
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
@@ -755,7 +774,7 @@ struct ContentView: View {
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
@@ -833,9 +852,9 @@ struct ContentView: View {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
}
@@ -1234,6 +1253,10 @@ struct ContentView: View {
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
launchID: launchID,
// Where a game exit returns to, when this connect launched a title: the shelf that
// title was picked on the host's own, or the pinned card whose profile this connect
// is using. Ignored by the model unless there is a launchID.
shelf: LibraryTarget(host: host, profile: profile),
allowTofu: allowTofu,
requestAccess: requestAccess,
onUnreachable: onUnreachable)
@@ -1289,9 +1312,13 @@ struct ContentView: View {
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
/// asks the host to launch it.
private func launchTitle(_ host: StoredHost, _ id: String) {
/// A title picked on a library shelf: dial its host, booting straight into that title with
/// the shelf's profile. A pinned card's shelf carries its card's profile as the one-off, so a
/// launch made there streams with the profile the card promises; the host's own shelf carries
/// `.inherit` and the binding decides, exactly as a plain card tap does.
private func launchTitle(_ shelf: LibraryTarget, _ id: String) {
libraryTarget = nil
connect(host, launchID: id)
connect(shelf.host, launchID: id, profile: shelf.profile)
}
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
@@ -75,7 +75,7 @@ struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
@@ -90,7 +90,7 @@ struct GamepadHomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
let launchTitle: (LibraryTarget, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -263,7 +263,7 @@ struct GamepadHomeView: View {
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
if let shelf = libraryTarget { return .library(shelf) }
return nil
}
@@ -289,10 +289,10 @@ struct GamepadHomeView: View {
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
case .library(let shelf):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
store: store, target: shelf,
onLaunch: { launchTitle(shelf, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
@@ -501,9 +501,10 @@ struct GamepadHomeView: View {
isPaired: host.pinnedSHA256 != nil,
isConnecting: connecting,
filled: true,
// A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -539,12 +540,14 @@ struct GamepadHomeView: View {
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
}
}
@@ -12,24 +12,26 @@ import SwiftUI
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
let host: StoredHost
let target: LibraryTarget
let onLaunch: (String) -> Void
let close: () -> Void
var controllerActive = true
/// `.compact` in a landscape phone window tighter chrome, like every gamepad screen.
@Environment(\.verticalSizeClass) private var vSizeClass
/// Resolves a pinned shelf's profile name for the title.
@ObservedObject private var profiles = ProfileStore.shared
private var compact: Bool { vSizeClass == .compact }
var body: some View {
LibraryView(
store: store, host: host, onLaunch: onLaunch,
store: store, target: target, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(host.displayName) — Library")
Text("\(target.title(in: profiles)) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
@@ -22,14 +22,16 @@ enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
case library(LibraryTarget)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
case .library(let shelf): return "library-\(shelf.id)"
}
}
@@ -24,7 +24,7 @@ struct HomeView: View {
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
@@ -34,8 +34,9 @@ struct HomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Picked a title in the (experimental) library start a session that launches it, with the
/// shelf's profile (a pinned card's own; the host's binding on its primary card).
let onLaunchTitle: (LibraryTarget, String) -> Void
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
@@ -154,8 +155,8 @@ struct HomeView: View {
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
.navigationDestination(item: $libraryTarget) { shelf in
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
}
#endif
#if !os(tvOS)
@@ -263,9 +264,13 @@ struct HomeView: View {
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
host: host,
isOnline: isOnline(host),
@@ -219,6 +219,13 @@ struct HostCardView: View {
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action it is this card's connect with a title picked
// first so a pinned card offers it and opens its own shelf, whose launches carry the
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
// are about the machine, and a shortcut has no business claiming them.)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
@@ -6,11 +6,53 @@
import PunktfunkKit
import SwiftUI
/// Which library shelf is open: a host, and when it was opened from a PINNED host+profile card
/// (design/client-settings-profiles.md §5.2a) that card's profile, which every title launched off
/// the shelf then runs with, exactly as the card's own tap would.
///
/// One value rather than a host plus a profile carried beside it: a host and its pinned cards are
/// different cards on the grid, so "which library" is not answered by the host alone. That is also
/// why `id` folds the profile in a presentation keyed on the host would not re-present when you
/// move between a host's own shelf and one of its pins.
struct LibraryTarget: Identifiable, Hashable {
let host: StoredHost
/// `.inherit` from the host's own card (its binding decides, as it always has); `.profile` from
/// a pinned card. `.defaults` never reaches here nothing opens a library "with the globals".
var profile: ProfileSelection = .inherit
var id: String {
switch profile {
case .inherit: host.id.uuidString
case .defaults: "\(host.id.uuidString)#defaults"
case .profile(let id): "\(host.id.uuidString)#\(id)"
}
}
/// The pinned profile's id, if this shelf belongs to a pinned card.
var pinnedProfileID: String? {
if case .profile(let id) = profile { return id }
return nil
}
/// What the screen calls itself: the host, and the profile when a pinned card opened it the
/// same `host · profile` shape that card wears, so which shelf you are on is on screen rather
/// than remembered from the card you pressed. A pin whose profile has since been deleted
/// resolves as no profile everywhere else, and reads as the plain host here.
@MainActor func title(in catalog: ProfileStore) -> String {
guard let id = pinnedProfileID, let profile = catalog.profile(id: id) else {
return host.displayName
}
return "\(host.displayName) \u{b7} \(profile.name)"
}
}
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
/// The shelf being browsed the host, plus the pinned profile when a pinned card opened it.
let target: LibraryTarget
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
/// through). `nil` browse-only (cards aren't tappable). The PROFILE a launch runs with is the
/// caller's to apply: it holds `target` and connects with `target.profile`.
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil every sheet/cover
/// presentation falls back to the environment dismiss.
@@ -20,6 +62,12 @@ struct LibraryView: View {
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
@ObservedObject private var profiles = ProfileStore.shared
/// The host this shelf belongs to every fetch, every poster URL and the launch itself address
/// it, and a pinned shelf is the same host seen through one of its cards.
private var host: StoredHost { target.host }
@State private var games: [GameEntry] = []
@State private var loading = false
@@ -50,7 +98,7 @@ struct LibraryView: View {
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
.navigationTitle("\(target.title(in: profiles)) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -70,9 +70,14 @@ final class SessionModel: ObservableObject {
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// WHICH library shelf that title was launched from a host's own, or one of its pinned
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
/// launches with that card's profile, so returning to the host's default shelf would quietly
/// change what the next title streams with.
private var launchedShelf: LibraryTarget?
/// Set when a session ended because its game exited and it began as a library launch: the
/// shelf to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: LibraryTarget?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -275,6 +280,9 @@ final class SessionModel: ObservableObject {
func connect(to host: StoredHost, effective: EffectiveSettings,
gamepad: PunktfunkConnection.GamepadType = .auto,
launchID: String? = nil,
/// The library shelf `launchID` was picked on, so a game exit can return to it.
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
shelf: LibraryTarget? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false,
@@ -283,6 +291,7 @@ final class SessionModel: ObservableObject {
phase = .connecting
activeHost = host
launchedTitleID = launchID
launchedShelf = shelf
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -663,6 +672,7 @@ final class SessionModel: ObservableObject {
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
launchedShelf = nil
phase = .idle
fps = 0
mbps = 0
@@ -692,13 +702,16 @@ final class SessionModel: ObservableObject {
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title so no banner, and back to the library it came from.
if cameFromLibrary, let host {
returnToLibrary = host
if cameFromLibrary, host != nil, let shelf {
returnToLibrary = shelf
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
@@ -229,7 +229,7 @@ public struct EffectiveSettings: Equatable, Sendable {
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
public enum ProfileSelection: Hashable, Sendable {
/// No pick the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
+7
View File
@@ -466,6 +466,13 @@ impl relm4::factory::FactoryComponent for HostCard {
// offering them here would blur what the card is.
let launch = gio::Menu::new();
launch.append(Some("Connect"), Some("card.connect"));
// …and the same stream with a title picked first. The library is a way to
// START this card, not a property of the host, so it belongs to a shortcut
// as much as Connect does — and the card's request carries its profile, so
// what launches from that grid is this card's profile, not the binding.
if *library_enabled {
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append_section(None, &launch);
let links = gio::Menu::new();
+21 -1
View File
@@ -44,6 +44,26 @@ struct State {
mock: Cell<bool>,
}
/// What the page calls the host it is browsing. A request that carries a one-off profile
/// came from a PINNED card (design §5.2a), and every title launched off this grid inherits
/// it — so the page names it, the same `host · profile` shape the card wears. A plain card
/// says nothing extra: its binding is the host's own default, not a second thing to read.
/// A one-off whose profile has since been deleted resolves as no profile everywhere else,
/// and reads as a plain host here.
fn page_host_label(req: &ConnectRequest) -> String {
let Some(id) = req.profile.as_deref().filter(|id| !id.is_empty()) else {
return req.name.clone();
};
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.find(|p| p.id == id)
.map_or_else(
|| req.name.clone(),
|p| format!("{} \u{b7} {}", req.name, p.name),
)
}
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
pub fn open(
@@ -194,7 +214,7 @@ fn build(
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title(format!("{} — Library", req.name))
.title(format!("{} — Library", page_host_label(&req)))
.child(&toolbar)
.build();
+69 -4
View File
@@ -913,9 +913,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
// record, and a pin whose profile is gone simply doesn't render. Their menu is
// deliberately short: a pinned tile is a shortcut, not a second host, so it carries
// only what STARTS it (the library — this tile's connect with a title picked first,
// which is why the grid it opens launches with the tile's profile), the link that
// reproduces it, and the way to remove it. Everything that configures the machine —
// pair, speed test, wake, edit, forget, and pinning itself — stays on the primary
// tile's menu, the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
continue;
@@ -923,6 +927,67 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
let pinned_menu = {
let (svc, target) = (props.svc.clone(), pinned_target.clone());
let (fp, pin_id) = (k.fp_hex.clone(), id.clone());
let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone());
let link_host = k.clone();
let link_profile = id.clone();
let unpin_label = format!("{MENU_UNPIN}{name}");
let unpin_item = unpin_label.clone();
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
let mut items = Vec::new();
// Same gate as the primary tile's: the mgmt API needs the paired
// identity, and the page is behind the experimental toggle.
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_separator());
items.push(menu_item(unpin_label));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
MENU_LIBRARY => {
// The shared target IS what the library page launches through, so
// parking THIS tile's target here is what makes its grid launch
// with the pinned profile.
*svc.ctx.shared.target.lock().unwrap() = target.clone();
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
Some(link_profile.as_str()),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
other if other == unpin_item => {
tracing::info!(pin = %pin_id, host = %fp, on = false, "pin toggle");
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|x| x != &pin_id);
if let Err(e) = known.save() {
tracing::warn!(
error = %format!("{e:#}"), "saving a pin"
);
}
}
// Same reason as the primary tile's toggle: nothing the page reads
// as state changed, so the bump is what makes this tile vanish NOW.
set_hosts_rev.call(hosts_rev + 1);
}
_ => {}
})
};
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
@@ -935,7 +1000,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
(!k.paired).then_some(("Trusted", Pill::Info)),
Some((name.as_str(), accent.clone())),
),
None,
Some(pinned_menu),
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
-15
View File
@@ -43,21 +43,6 @@ pub trait Capturer: Send {
self.next_frame()
}
/// [`next_frame_within`](Self::next_frame_within), but the caller declares the budget
/// PROVISIONAL: its expiry is the retry schedule firing (the deliberately truncated first
/// attempt), not a verdict on anything this capture offered. The portal backend must NOT
/// latch its sticky process-wide downgrades (HDR capture, either dmabuf-only offer) from a
/// provisional expiry — a gamescope cold start routinely outlives the short window while it
/// would have accepted every offer, and one latched race used to pin the whole host process
/// to SDR/CPU capture. The full-length attempt that follows delivers the honest verdict.
/// Backends that latch nothing from a timeout just delegate.
fn next_frame_within_provisional(
&mut self,
budget: std::time::Duration,
) -> Result<CapturedFrame> {
self.next_frame_within(budget)
}
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
/// just produces a frame each call — fine for instant synthetic sources; the portal
+64 -252
View File
@@ -533,7 +533,7 @@ fn spawn_pipewire(
impl Capturer for PortalCapturer {
fn next_frame(&mut self) -> Result<CapturedFrame> {
self.frame_within(Duration::from_secs(10), TimeoutVerdict::Conclusive)
self.frame_within(Duration::from_secs(10))
}
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
@@ -563,13 +563,7 @@ impl Capturer for PortalCapturer {
}
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
self.frame_within(budget, TimeoutVerdict::Conclusive)
}
fn next_frame_within_provisional(&mut self, budget: Duration) -> Result<CapturedFrame> {
// The retry loop's truncated first attempt: its expiry re-runs the schedule, it does not
// convict an offer — see `TimeoutVerdict` and the latch arms in `next_frame_timed_out`.
self.frame_within(budget, TimeoutVerdict::Provisional)
self.frame_within(budget)
}
fn supports_arrival_wait(&self) -> bool {
@@ -705,73 +699,12 @@ impl Capturer for PortalCapturer {
}
}
/// Whether an expired first-frame budget is allowed to CONVICT an offer. The retry loop's
/// deliberately truncated first attempt passes `Provisional`: its expiry means the schedule
/// moved on, not that the compositor refused anything — a gamescope cold start regularly needs
/// longer than that window to accept every offer it would have accepted. Latching from it pinned
/// the whole host process to SDR + CPU capture off a race the attempt lost by design; only a
/// full-length wait carries a verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutVerdict {
Conclusive,
Provisional,
}
/// Which offer a first-frame timeout implicates — the diagnosis behind
/// [`PortalCapturer::next_frame_timed_out`], split out pure so the latch policy is testable.
/// Mirrors the negotiation state exactly: a negotiated format clears every offer (the compositor
/// accepted, it just produced nothing), and a forced `PUNKTFUNK_ZEROCOPY=1` keeps both dmabuf
/// arms erroring loudly instead of implicating them (the operator asked for exactly that path).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TimeoutOffer {
/// Format negotiated; no offer implicated — the compositor produced no buffers.
NoBuffers,
/// The 10-bit PQ/BT.2020 (HDR) dmabuf offer was never accepted.
Hdr,
/// The dmabuf-only raw-passthrough offer was never accepted.
RawDmabuf,
/// The dmabuf-only EGL→CUDA offer was never accepted.
GpuDmabuf,
/// Nothing negotiated and no offer implicated — format/modifier mismatch.
NoFormat,
}
fn classify_first_frame_timeout(
negotiated: bool,
hdr_offer: bool,
vaapi_dmabuf: bool,
gpu_dmabuf_offer: bool,
zerocopy_forced: bool,
) -> TimeoutOffer {
if negotiated {
TimeoutOffer::NoBuffers
} else if hdr_offer {
TimeoutOffer::Hdr
} else if vaapi_dmabuf && !zerocopy_forced {
TimeoutOffer::RawDmabuf
} else if gpu_dmabuf_offer && !zerocopy_forced {
TimeoutOffer::GpuDmabuf
} else {
TimeoutOffer::NoFormat
}
}
/// The latch policy: only a conclusive expiry of an offer-implicating timeout fires the offer's
/// sticky process-wide downgrade.
fn timeout_convicts(offer: TimeoutOffer, verdict: TimeoutVerdict) -> bool {
verdict == TimeoutVerdict::Conclusive
&& matches!(
offer,
TimeoutOffer::Hdr | TimeoutOffer::RawDmabuf | TimeoutOffer::GpuDmabuf
)
}
impl PortalCapturer {
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
fn frame_within(&mut self, budget: Duration, verdict: TimeoutVerdict) -> Result<CapturedFrame> {
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
let deadline = std::time::Instant::now() + budget;
loop {
if self.signals.broken.load(Ordering::Relaxed) {
@@ -797,7 +730,7 @@ impl PortalCapturer {
if let Some(f) = self.take_frame() {
return Ok(f);
}
return self.next_frame_timed_out(e, budget, verdict);
return self.next_frame_timed_out(e, budget);
}
}
}
@@ -819,118 +752,83 @@ impl PortalCapturer {
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error, and fire the offer's sticky downgrade latch when — and
/// only when — the expiry convicts the offer (see [`timeout_convicts`]).
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
&self,
err: RecvTimeoutError,
budget: Duration,
verdict: TimeoutVerdict,
) -> Result<CapturedFrame> {
let within = budget.as_secs_f32();
match err {
RecvTimeoutError::Timeout => {
let offer = classify_first_frame_timeout(
self.signals.negotiated.load(Ordering::Relaxed),
self.hdr_offer,
self.vaapi_dmabuf,
self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed),
pf_zerocopy::zerocopy_forced(),
);
let convicted = timeout_convicts(offer, verdict);
// A provisional expiry names the same suspect but hands down no sentence — the
// full-length retry that follows is the one whose timeout latches.
let sentence = if convicted {
"" // each arm below states its own downgrade
} else {
" (short first-attempt window — nothing is latched; the full-length retry \
decides)"
};
match offer {
TimeoutOffer::NoBuffers => Err(anyhow!(
// Split the two black-screen root causes apart so the operator gets a cause, not
// just a symptom: did the format negotiate (compositor produced no buffers) or
// not (no acceptable format / node never emitted a param)?
if self.signals.negotiated.load(Ordering::Relaxed) {
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiated but no \
buffers arrived the compositor produced no frames (virtual output \
idle/unmapped, capture never started, or a stream bound during a \
compositor (re)start that will never deliver a reconnect fixes that)",
self.node_id
)),
TimeoutOffer::Hdr => {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the SDR downgrade for THIS source (`HdrSource`, not process-wide — one
// shared flag let either Linux HDR source disable the other) so the next session
// (Moonlight auto-reconnects) negotiates SDR instead of re-running this timeout.
if convicted {
super::note_hdr_capture_failed(self.hdr_source);
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+?{}",
self.node_id,
if convicted {
" Downgrading this host to SDR capture; reconnect to stream SDR"
} else {
sentence
}
))
}
TimeoutOffer::RawDmabuf => {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
if convicted {
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough){}",
self.node_id,
if convicted {
" — downgrading THIS path to CPU capture for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::GpuDmabuf => {
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One FULL-LENGTH timeout
// is conclusive: a compositor that allocates none of the importer's
// modifiers refuses them identically on every retry, so latch the offer off
// and let the pipeline rebuild renegotiate the CPU path instead of
// re-running this same 10 s timeout on every reconnect. A forced
// PUNKTFUNK_ZEROCOPY=1 keeps erroring loudly instead (same rule as the raw
// arm).
if convicted {
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
}
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import){}",
self.node_id,
if convicted {
" — downgrading THIS offer to the CPU path for the rest of the \
process; the pipeline rebuild will renegotiate without dmabuf"
} else {
sentence
}
))
}
TimeoutOffer::NoFormat => Err(anyhow!(
))
} else if self.hdr_offer {
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
// mode between the probe and the negotiation, the compositor pre-dates the
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
// Latch the process-wide SDR downgrade so the next session (Moonlight
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
super::note_hdr_capture_failed(self.hdr_source);
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer is the mirrored \
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
reconnect to stream SDR",
self.node_id
))
} else if self.vaapi_dmabuf && !pf_zerocopy::zerocopy_forced() {
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (raw-dmabuf passthrough) downgrading \
THIS path to CPU capture for the rest of the process; the pipeline \
rebuild will renegotiate without dmabuf",
self.node_id
))
} else if self.signals.gpu_dmabuf_offer.load(Ordering::Relaxed)
&& !pf_zerocopy::zerocopy_forced()
{
// The EGL→CUDA dmabuf-only offer was never accepted — the twin of the raw-
// passthrough arm above (the offer the thread ACTUALLY made, per the signal
// it set — see `CaptureSignals::gpu_dmabuf_offer`). One timeout is conclusive:
// a compositor that allocates none of the importer's modifiers refuses them
// identically on every retry, so latch the offer off and let the pipeline
// rebuild renegotiate the CPU path instead of re-running this same 10 s
// timeout on every reconnect. A forced PUNKTFUNK_ZEROCOPY=1 keeps erroring
// loudly instead (same rule as the raw arm).
pf_zerocopy::note_gpu_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the dmabuf-only offer (EGLCUDA GPU import) downgrading THIS \
offer to the CPU path for the rest of the process; the pipeline rebuild \
will renegotiate without dmabuf",
self.node_id
))
} else {
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): format negotiation never \
completed the compositor offered no format this consumer accepts \
(pixel-format/modifier mismatch) or the node never emitted a Format param",
self.node_id
)),
))
}
}
RecvTimeoutError::Disconnected => Err(anyhow!(
@@ -976,89 +874,3 @@ mod pipewire;
// unit-test without a compositor, which is the point.
mod pw_cursor;
mod pw_pods;
#[cfg(test)]
mod first_frame_timeout_tests {
use super::{classify_first_frame_timeout, timeout_convicts, TimeoutOffer, TimeoutVerdict};
#[test]
fn a_provisional_expiry_convicts_no_offer_whatever_was_on_the_table() {
// The bug this pins down: the retry loop's truncated 2.5 s first attempt latched all
// three sticky process-wide downgrades as if the compositor had refused the offers — a
// gamescope HDR cold start then streamed SDR (and CPU-copied) for the process lifetime.
for offer in [
TimeoutOffer::NoBuffers,
TimeoutOffer::Hdr,
TimeoutOffer::RawDmabuf,
TimeoutOffer::GpuDmabuf,
TimeoutOffer::NoFormat,
] {
assert!(
!timeout_convicts(offer, TimeoutVerdict::Provisional),
"provisional expiry must not latch {offer:?}"
);
}
}
#[test]
fn a_conclusive_expiry_convicts_exactly_the_offer_bearing_diagnoses() {
assert!(timeout_convicts(
TimeoutOffer::Hdr,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::RawDmabuf,
TimeoutVerdict::Conclusive
));
assert!(timeout_convicts(
TimeoutOffer::GpuDmabuf,
TimeoutVerdict::Conclusive
));
// A negotiated-but-idle stream and a plain format mismatch implicate no offer — nothing
// to latch even on a full-length wait.
assert!(!timeout_convicts(
TimeoutOffer::NoBuffers,
TimeoutVerdict::Conclusive
));
assert!(!timeout_convicts(
TimeoutOffer::NoFormat,
TimeoutVerdict::Conclusive
));
}
#[test]
fn classification_mirrors_the_negotiation_state_precedence() {
// A negotiated format clears every offer, whatever else was on the table.
assert_eq!(
classify_first_frame_timeout(true, true, true, true, false),
TimeoutOffer::NoBuffers
);
// The HDR offer outranks the dmabuf arms (it is the offer that failed to negotiate).
assert_eq!(
classify_first_frame_timeout(false, true, true, true, false),
TimeoutOffer::Hdr
);
assert_eq!(
classify_first_frame_timeout(false, false, true, true, false),
TimeoutOffer::RawDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, true, false),
TimeoutOffer::GpuDmabuf
);
assert_eq!(
classify_first_frame_timeout(false, false, false, false, false),
TimeoutOffer::NoFormat
);
}
#[test]
fn a_forced_zerocopy_keeps_both_dmabuf_arms_erroring_loudly_instead_of_implicated() {
// PUNKTFUNK_ZEROCOPY=1 is the operator insisting on the path — the timeout falls through
// to the generic diagnosis (and so never latches), exactly as the old else-if chain did.
assert_eq!(
classify_first_frame_timeout(false, false, true, true, true),
TimeoutOffer::NoFormat
);
}
}
+1 -1
View File
@@ -209,7 +209,7 @@ impl Screen {
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Library(s) => s.title(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
+25 -6
View File
@@ -10,7 +10,7 @@ use crate::library::{
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use crate::model::{ConsoleCmd, HostRow};
use crate::model::{ConsoleCmd, HostRow, ProfileChip};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{accent, fg, Fonts, W};
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
port: u16,
fp_hex: String,
mgmt: u16,
/// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather
/// than the host's primary tile: every launch off this shelf is that card's connect
/// with a title attached, so it carries the same one-off profile the card's plain
/// A-press would. `None` = the primary tile, where the host's binding decides.
pin: Option<ProfileChip>,
shared: Option<LibraryShared>,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
@@ -48,6 +53,7 @@ impl LibraryScreen {
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
pin: host.pin.clone(),
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
@@ -60,8 +66,13 @@ impl LibraryScreen {
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
/// profile every launch off it will use, in the card's own `host · profile` shape.
pub(crate) fn title(&self) -> String {
match &self.pin {
Some(p) => format!("{} \u{b7} {}", self.host_name, p.name),
None => self.host_name.clone(),
}
}
fn fetch_cmd(&self) -> ConsoleCmd {
@@ -123,10 +134,18 @@ impl LibraryScreen {
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
// A pinned card's shelf says which profile it is launching with,
// the same way its tile and this screen's title do.
title: match &self.pin {
Some(p) => format!("{} \u{b7} {}", g.title, p.name),
None => g.title.clone(),
},
request_access: false,
// Game launches follow the host's default binding.
profile: None,
// A game launch off a PINNED card's shelf is that card's connect
// with a title attached — it carries the card's profile as the
// one-off. Off the primary tile there is none, and the host's
// default binding decides.
profile: self.pin.as_ref().map(|p| p.id.clone()),
});
Some(MenuPulse::Confirm)
}
+81
View File
@@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) {
s.motion = Motion::None;
}
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
///
/// The card's plain A-press always carried its profile; Y — which the card offers, being
/// paired and saved — opened a library screen that knew only the host, so every title
/// launched off it silently fell back to the host's default binding. The profile a user
/// pinned is the whole reason they pressed that card.
#[test]
fn a_pinned_cards_library_launches_with_its_profile() {
let mut rows = hosts();
let card = HostRow {
key: "aa11\u{0}hdr".into(),
pin: Some(crate::model::ProfileChip {
id: "hdr".into(),
name: "HDR".into(),
accent: None,
}),
..rows[0].clone()
};
rows.insert(1, card);
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
console.set_hosts(rows);
s.sync();
// Focus the pinned card (it sits right after its host's primary tile), then Y.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Secondary);
finish_motion(&mut s);
match s.stack.last() {
Some(Screen::Library(l)) => assert_eq!(
l.title(),
"Living Room PC \u{b7} HDR",
"the shelf names the profile it will launch with"
),
_ => panic!("Y on a pinned card opens its library"),
}
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
match s.take_action() {
Some(OverlayAction::Launch {
launch, profile, ..
}) => {
assert_eq!(launch.as_deref(), Some("steam:570"));
assert_eq!(
profile.as_deref(),
Some("hdr"),
"the launch carries the pinned card's profile"
);
}
_ => panic!("A on a title raises a launch"),
}
}
/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides,
/// which is what the resolver sees as `None`.
#[test]
fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Secondary); // paired+online host focused first
finish_motion(&mut s);
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { profile: None, .. })
));
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
+2 -11
View File
@@ -4129,10 +4129,7 @@ fn build_pipeline_with_retry(
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
// 10 s window on every later attempt. The truncated attempt is PROVISIONAL end to end: its
// expiry must not latch the capturer's sticky downgrades (see
// `Capturer::next_frame_within_provisional`) — only the full-length attempts hand down
// negotiation verdicts.
// 10 s window on every later attempt.
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=max_attempts {
@@ -4487,13 +4484,7 @@ fn build_pipeline(
}
capturer.set_active(true);
let first = match first_frame_budget {
// Provisional: this is the retry loop's deliberately truncated first attempt, and its
// expiry is the schedule firing, not a negotiation verdict — the capturer must not latch
// its sticky process-wide downgrades (HDR capture, the dmabuf-only offers) from it. A
// gamescope cold start regularly outlives this window and then accepts every offer on the
// full-length attempt that follows (observed on .41: one truncated expiry pinned the whole
// host process to SDR + CPU capture).
Some(budget) => capturer.next_frame_within_provisional(budget),
Some(budget) => capturer.next_frame_within(budget),
None => capturer.next_frame(),
};
let frame = match first.context("first frame") {
+1 -7
View File
@@ -19,7 +19,7 @@ pkgname=punktfunk-gamescope
# bump it with the marker so pacman sees a new version when only our patches moved.
_gsver=3.16.25
_gsrev=5fb8dce4a09d0a68d097b9faf9513782106bc843
pkgver="${_gsver}.pfhdr6"
pkgver="${_gsver}.pfhdr5"
# 2: patch 0006 (never destroy the Vulkan device/output at exit). No capability moved, so the
# `.pfhdrN` level deliberately stays put — see README.md.
# 3: pin moved 8c676c39 -> 5fb8dce4 (3.16.25-1 -> 3.16.25-11), which brings upstream's own
@@ -33,12 +33,6 @@ pkgver="${_gsver}.pfhdr6"
# a session on every capture renegotiation — i.e. on every client connect, since the host sets the
# session to the client's mode. This one DOES move `.pfhdrN`, even though it adds no capability:
# every deployed pfhdr4 binary crash-loops, so an operator has to be able to tell them apart.
#
# pfhdr6 / rel 1: patch 0008 honors GAMESCOPE_NO_FOCUS — a mapped-but-unpainted window carrying it
# (Bazzite's hhd-ui crash-looping under a headless takeover) used to WIN focus selection and turn
# the composite (and the stream) black while every health signal stayed green. No capability the
# host probes for, but a field box's banner has to distinguish a build that can lose its composite
# this way from one that cannot.
pkgrel=1
pkgdesc="gamescope with 10-bit BT.2020/PQ PipeWire capture, for punktfunk HDR streaming"
arch=('x86_64' 'aarch64')
+5 -10
View File
@@ -18,7 +18,6 @@ The patches here add the missing half, and nothing else. See
| `0005-punktfunk-stamp-the-version-banner-with-pfhdrN.patch` | Append `+pfhdr<N>` to the `--version` banner | **No** — ours only, retired when the functional patches above land upstream |
| `0006-punktfunk-never-destroy-the-Vulkan-device-or-output-.patch` | Give `g_device` and `g_output` storage that is never destroyed, so their destructors cannot call a Vulkan driver glibc has already unloaded at `exit()` | **Yes** — a plain static-destruction-order bug, not punktfunk-specific |
| `0007-pipewire-never-leave-pw_buffer-user_data-pointing-at.patch` | Associate `pw_buffer->user_data` with its `pipewire_buffer` for every path out of `add_buffer`, clear it in `remove_buffer` (the last point both halves are known), and null-check the consumers — killing the use-after-free that aborted the session on every capture renegotiation | **Yes** — a plain use-after-free in the PipeWire buffer lifecycle |
| `0008-steamcompmgr-honor-GAMESCOPE_NO_FOCUS-never-a-focus-.patch` | Honor `GAMESCOPE_NO_FOCUS` (set by hhd-ui and MangoHud, consumed by nobody): such windows are skipped by both focus-candidate collectors, so a mapped-but-unpainted overlay app can no longer win focus and turn the composite black. Compositing is untouched — only focus SELECTION is barred | **Yes** — the atom's setters already exist in the wild; some compositor has to keep the promise |
### Why the headless patch matters
@@ -85,18 +84,14 @@ The number is a **monotonic patch-set revision**, so one probe answers every cap
| `+pfhdr2` | …and `--pipewire-composite-cursor` |
| `+pfhdr3` | …and the headless connector advertises its mode + `--custom-refresh-rates` |
| `+pfhdr4` | …and `--pipewire-composite-external-overlay` |
| `+pfhdr5` | …and the PipeWire buffer use-after-free is fixed (no new capability) |
| `+pfhdr6` | …and `GAMESCOPE_NO_FOCUS` windows are never focus candidates (no new capability) |
Bump it whenever a patch adds or changes something the host must know about before it spawns.
A patch that only fixes a crash does **not** automatically bump it: `0006` (the exit-time Vulkan
teardown fix) changes nothing the host probes for, so it shipped as a `pkgrel` bump at `+pfhdr4`
exactly the split the PKGBUILD's own comment describes. Since every host probe is `>=`, a bump for
a bugfix is safe but must earn its place: `0007` and `0008` moved the level anyway because their
absence is invisible until a stream fails (a crash-loop per connect; a composite lost to a
NO_FOCUS window), so field triage has to be able to read the difference off a box's banner.
Bumping without either reason would advertise a capability tier that does not exist.
A patch that only fixes a crash does **not** bump it: `0006` (the exit-time Vulkan teardown fix)
changes nothing the host probes for, so the level stays `+pfhdr4` and the rebuild ships as a
`pkgrel` bump instead — exactly the split the PKGBUILD's own comment describes. Bumping the level
for a bugfix would be worse than useless: it would advertise a capability tier that does not exist
and strand hosts that gate on it.
⚠️ The two indirect spawn modes (the `GAMESCOPE_BIN` wrapper for gamescope-session-plus, and the
SteamOS PATH shim) pass these flags through `PF_HDR_ARGS`, so they share one dependency: if the
@@ -1,160 +0,0 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: enricobuehler <enrico.buehler@unom.io>
Date: Tue, 11 Aug 2026 21:48:29 +0200
Subject: [PATCH] =?UTF-8?q?steamcompmgr:=20honor=20GAMESCOPE=5FNO=5FFOCUS?=
=?UTF-8?q?=20=E2=80=94=20such=20windows=20are=20never=20focus=20candidate?=
=?UTF-8?q?s?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hhd (Handheld Daemon) sets GAMESCOPE_NO_FOCUS=1 on its hhd-ui overlay window once at init and
never clears it (hhd src/hhd/plugins/overlay/x11.py, prepare_hhd); MangoHud sets the same atom.
The show/hide protocol for these clients is STEAM_OVERLAY / STEAM_INPUT_FOCUS — the window is
never meant to win focus selection on its own.
Nothing consumed the atom: neither this tree nor Bazzite's fork (checked ba148) interns it, so a
mapped-but-unpainted hhd-ui window — it crash-loops under a headless punktfunk takeover and
remaps on every respawn, stamping Steam's appid 769 — was a perfectly ordinary focus candidate.
steamcompmgr picked it over Big Picture, and the composite (and the stream fed from it) went
black while every other health signal stayed green (observed on Bazzite .41, 2026-08-11:
GAMESCOPE_FOCUSED_WINDOW = the hhd-ui window, GAMESCOPE_NO_FOCUS(CARDINAL)=1 on that window,
client stats 60 fps at 0.1 Mb/s of black; killing hhd-ui flipped focus back to Steam and the
picture returned instantly).
Wire the atom exactly like GAMESCOPE_EXTERNAL_OVERLAY — read at map, tracked on PropertyNotify
(with MakeFocusDirty), skipped in both focus-candidate collectors (X11 and XDG). Unlike the
overlay flags it does NOT zero appID and does not change compositing: the window still paints
normally if something else (the baselayer protocol) brings it into view; it is only barred from
being CHOSEN.
Banner: +pfhdr6 (no new capability — the bump exists so a field box's banner distinguishes a
build that can lose its composite to a NO_FOCUS window from one that cannot).
---
src/meson.build | 3 ++-
src/steamcompmgr.cpp | 24 ++++++++++++++++++++----
src/steamcompmgr_shared.hpp | 4 ++++
src/xwayland_ctx.hpp | 1 +
4 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/src/meson.build b/src/meson.build
index 9a3a287..acfcaea 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -185,7 +185,8 @@ vcs_tag = run_command(vcs_tag_cmd, check: false).stdout().strip()
# +pfhdr3 — …and the headless connector advertises its mode + `--custom-refresh-rates`
# +pfhdr4 — …and `--pipewire-composite-external-overlay`
# +pfhdr5 — …and the PipeWire buffer use-after-free is fixed (no new capability)
-version_tag = vcs_tag + '+pfhdr5' + ' (' + compiler_name + ' ' + compiler_version + ')'
+# +pfhdr6 — …and GAMESCOPE_NO_FOCUS windows are never focus candidates (no new capability)
+version_tag = vcs_tag + '+pfhdr6' + ' (' + compiler_name + ' ' + compiler_version + ')'
gamescope_version_conf = configuration_data()
gamescope_version_conf.set('VCS_TAG', version_tag)
diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp
index 64e1a8c..14596ae 100644
--- a/src/steamcompmgr.cpp
+++ b/src/steamcompmgr.cpp
@@ -1109,6 +1109,7 @@ bool g_bPendingFade = false;
#define STEAM_PROP "STEAM_BIGPICTURE"
#define OVERLAY_PROP "STEAM_OVERLAY"
#define EXTERNAL_OVERLAY_PROP "GAMESCOPE_EXTERNAL_OVERLAY"
+#define NO_FOCUS_PROP "GAMESCOPE_NO_FOCUS"
#define GAMES_RUNNING_PROP "STEAM_GAMES_RUNNING"
#define SCREEN_SCALE_PROP "STEAM_SCREEN_SCALE"
#define SCREEN_MAGNIFICATION_PROP "STEAM_SCREEN_MAGNIFICATION"
@@ -3848,8 +3849,8 @@ found:;
for (steamcompmgr_win_t *w = this->list; w; w = w->xwayland().next)
{
- // Always skip system tray icons and overlays
- if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay )
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
+ if ( w->isSysTrayIcon || w->isOverlay || w->isExternalOverlay || w->isNoFocus )
{
continue;
}
@@ -4197,8 +4198,8 @@ steamcompmgr_xdg_get_possible_focus_windows()
std::vector< steamcompmgr_win_t* > windows;
for ( auto &win : g_steamcompmgr_xdg_wins )
{
- // Always skip system tray icons and overlays
- if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay )
+ // Always skip system tray icons, overlays, and windows that asked never to be focused
+ if ( win->isSysTrayIcon || win->isOverlay || win->isExternalOverlay || win->isNoFocus )
{
continue;
}
@@ -4960,6 +4961,10 @@ map_win(xwayland_ctx_t* ctx, Window id, unsigned long sequence)
if ( w->isExternalOverlay )
w->appID = 0;
+ // Never a focus candidate; appID stays — the window may share the focused app's id (hhd-ui
+ // stamps Steam's) and zeroing it here is not needed to keep it out of focus selection.
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
+
w->oulTargetVROverlay = get_u64_prop(ctx, w->xwayland().id, ctx->atoms.steamGamescopeVROverlayTarget);
if ( w->oulTargetVROverlay )
{
@@ -5243,6 +5248,7 @@ add_win(xwayland_ctx_t *ctx, Window id, Window prev, unsigned long sequence)
new_win->isOverlay = false;
new_win->isExternalOverlay = false;
+ new_win->isNoFocus = false;
new_win->isSteamLegacyBigPicture = false;
new_win->isSteamStreamingClient = false;
new_win->isSteamStreamingClientVideo = false;
@@ -6193,6 +6199,15 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev)
MakeFocusDirty();
}
}
+ if (ev->atom == ctx->atoms.noFocusAtom)
+ {
+ steamcompmgr_win_t * w = find_win(ctx, ev->window);
+ if (w)
+ {
+ w->isNoFocus = get_prop(ctx, w->xwayland().id, ctx->atoms.noFocusAtom, 0);
+ MakeFocusDirty();
+ }
+ }
if (ev->atom == ctx->atoms.winTypeAtom)
{
steamcompmgr_win_t * w = find_win(ctx, ev->window);
@@ -7927,6 +7942,7 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_
ctx->atoms.gameAtom = XInternAtom(ctx->dpy, GAME_PROP, false);
ctx->atoms.overlayAtom = XInternAtom(ctx->dpy, OVERLAY_PROP, false);
ctx->atoms.externalOverlayAtom = XInternAtom(ctx->dpy, EXTERNAL_OVERLAY_PROP, false);
+ ctx->atoms.noFocusAtom = XInternAtom(ctx->dpy, NO_FOCUS_PROP, false);
ctx->atoms.opacityAtom = XInternAtom(ctx->dpy, OPACITY_PROP, false);
ctx->atoms.gamesRunningAtom = XInternAtom(ctx->dpy, GAMES_RUNNING_PROP, false);
ctx->atoms.screenScaleAtom = XInternAtom(ctx->dpy, SCREEN_SCALE_PROP, false);
diff --git a/src/steamcompmgr_shared.hpp b/src/steamcompmgr_shared.hpp
index 21ddc5f..924e0d2 100644
--- a/src/steamcompmgr_shared.hpp
+++ b/src/steamcompmgr_shared.hpp
@@ -116,6 +116,10 @@ struct steamcompmgr_win_t {
uint32_t appID = 0;
bool isOverlay = false;
bool isExternalOverlay = false;
+ // GAMESCOPE_NO_FOCUS on the window: the client asks never to be a focus candidate (hhd-ui and
+ // MangoHud set it once at init). Unlike an overlay it still composites normally if something
+ // else focuses it into view; it is only excluded from focus selection.
+ bool isNoFocus = false;
bool bIsSteamPid = false;
bool bIsSteamWebHelperPid = false;
diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp
index 978728a..d6ce90f 100644
--- a/src/xwayland_ctx.hpp
+++ b/src/xwayland_ctx.hpp
@@ -105,6 +105,7 @@ struct xwayland_ctx_t final : public gamescope::IWaitable
Atom gameAtom;
Atom overlayAtom;
Atom externalOverlayAtom;
+ Atom noFocusAtom;
Atom gamesRunningAtom;
Atom screenZoomAtom;
Atom screenScaleAtom;
--
2.50.1 (Apple Git-155)