Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8eb7df08 | ||
|
|
f5fa9649b7 |
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (EGL→CUDA 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 (EGL→CUDA 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
-160
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user