Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8eb7df08 | ||
|
|
f5fa9649b7 | ||
|
|
21f43d7f48 | ||
|
|
8d1e5ab5dd | ||
|
|
9e28cd101c |
@@ -42,6 +42,27 @@ availability probe. The `comm` fast path is still one read for every ordinary di
|
||||
Also reached by the same rung: `gamescope` carries `cap_sys_nice` on a number of distros, so a
|
||||
*wrapped and capped* gamescope was equally invisible to the foreign-gamescope probe.
|
||||
|
||||
### Game Mode on Nobara — the WSI opt-out never reached the games
|
||||
|
||||
🛑 **v0.27.0's fix for the distro Vulkan WSI layer was clobbered by the session script, so games ran
|
||||
on a black screen** while the host's own log claimed the layer had been disabled. Steam Big Picture
|
||||
came up, showed the right mode, showed the perf overlay — and then every game played sound and took
|
||||
input over a black picture, with no error on either side.
|
||||
|
||||
The layer (`VkLayer_FROG_gamescope_wsi`) ships with the *distro's* gamescope and speaks its
|
||||
`gamescope_swapchain` protocol; ours disagrees, so the compositor rejects the client's
|
||||
`swapchain_feedback` and kills it. v0.27.0 turned the layer off with `ENABLE_GAMESCOPE_WSI=0` on the
|
||||
session unit. `gamescope-session-plus` then runs an unconditional `export ENABLE_GAMESCOPE_WSI=1`
|
||||
near the top of the script — before it launches anything — so the opt-out survived exactly as long
|
||||
as it took the script to start, and every process the session spawned got the layer back. Nothing
|
||||
looked wrong because the casualty is Vulkan clients specifically: Steam's own UI is not one.
|
||||
|
||||
The opt-out is now `DISABLE_GAMESCOPE_WSI=1` as well. The Vulkan loader reads an implicit layer's
|
||||
two manifest knobs in a fixed order: `enable_environment` must equal `"1"` to switch the layer on,
|
||||
and `disable_environment` is then consulted last and wins on **presence alone**, at any value. The
|
||||
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.
|
||||
|
||||
## 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);
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -1032,7 +1032,7 @@ fn write_session_plus_dropin(
|
||||
wsi = if wsi_ok {
|
||||
String::new()
|
||||
} else {
|
||||
"Environment=ENABLE_GAMESCOPE_WSI=0\n".to_string()
|
||||
wsi_off_unit_lines()
|
||||
},
|
||||
);
|
||||
std::fs::write(&path, body).with_context(|| format!("write drop-in {}", path.display()))?;
|
||||
@@ -3521,6 +3521,52 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
|
||||
Some(bind)
|
||||
}
|
||||
|
||||
/// The environment that turns the distro's `VkLayer_FROG_gamescope_wsi` off for a whole session
|
||||
/// tree — applied wherever we start one: [`launch_session`]'s transient unit and the box-session
|
||||
/// drop-in ([`write_session_plus_dropin`]).
|
||||
///
|
||||
/// ⭐⭐⭐ **`DISABLE_GAMESCOPE_WSI` is the one that actually works, and it is not the obvious one.**
|
||||
/// `gamescope-session-plus` does an unconditional `export ENABLE_GAMESCOPE_WSI=1` near the top of
|
||||
/// the script, before it launches anything — so a `--setenv=ENABLE_GAMESCOPE_WSI=0` on the unit is
|
||||
/// CLOBBERED for the script and every child it spawns: gamescope, Steam, and every game Steam
|
||||
/// launches. The Vulkan loader resolves an implicit layer's two manifest knobs in a fixed order
|
||||
/// (`loader.c`, `loader_implicit_layer_is_enabled`): `enable_environment` switches the layer on
|
||||
/// only when the variable equals exactly `"1"`, and then `disable_environment` is consulted last —
|
||||
/// *"has priority over everything else"* — where the mere PRESENCE of the variable, at any value,
|
||||
/// forces the layer off. Nothing in the session script mentions `DISABLE_GAMESCOPE_WSI`, so it is
|
||||
/// the only one of the two that survives the script.
|
||||
///
|
||||
/// ⚠️⚠️ What getting this wrong looks like in the field (Nobara 44, 2026-08-11): the layer stayed
|
||||
/// on for games while this host's own log said it had been disabled. Neither Steam Big Picture nor
|
||||
/// mangoapp is a Vulkan client, so both paint regardless and the session looks perfectly healthy —
|
||||
/// right up until a game starts. Then the game runs with sound and input while its swapchain is
|
||||
/// dead: a black screen, and not one line of error anywhere.
|
||||
///
|
||||
/// `ENABLE_GAMESCOPE_WSI=0` is kept alongside for a layer built without a `disable_environment`
|
||||
/// (the loader warns about such a layer but honours its `enable_environment`), and because it is
|
||||
/// what an operator reading the unit will look for.
|
||||
const WSI_OFF_ENV: [(&str, &str); 2] = [
|
||||
("DISABLE_GAMESCOPE_WSI", "1"),
|
||||
("ENABLE_GAMESCOPE_WSI", "0"),
|
||||
];
|
||||
|
||||
/// [`WSI_OFF_ENV`] as `systemd-run` arguments, for the transient unit.
|
||||
fn wsi_off_setenv_args() -> Vec<String> {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("--setenv={name}={value}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`WSI_OFF_ENV`] as unit-file lines, for the box-session drop-in. Trailing newline included, so
|
||||
/// whatever the body puts after it still parses — same contract as [`SessionBind::unit_lines`].
|
||||
fn wsi_off_unit_lines() -> String {
|
||||
WSI_OFF_ENV
|
||||
.iter()
|
||||
.map(|(name, value)| format!("Environment={name}={value}\n"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether the box's `VkLayer_FROG_gamescope_wsi` can be trusted against the gamescope we run.
|
||||
///
|
||||
/// The layer ships with the DISTRO's gamescope and speaks its `gamescope_swapchain` protocol; we
|
||||
@@ -3533,8 +3579,10 @@ fn arm_session_bind(wrapper: &std::path::Path) -> Option<SessionBind> {
|
||||
/// byte-identical between those commits, so this is the distro PATCHING gamescope, not a version
|
||||
/// bump — which is why the check is "do the version triples differ", not a floor.
|
||||
///
|
||||
/// `ENABLE_GAMESCOPE_WSI=0` is gamescope's own opt-out and costs only the layer's extras
|
||||
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
|
||||
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
|
||||
/// metadata) — far cheaper than a client that cannot start.
|
||||
///
|
||||
/// ⚠️ **`ENABLE_GAMESCOPE_WSI=0` is NOT enough on its own**, which is what [`WSI_OFF_ENV`] is for.
|
||||
fn wsi_layer_matches_our_gamescope() -> bool {
|
||||
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
|
||||
let distro = discovery::gamescope_version_of(std::path::Path::new(DISTRO_GAMESCOPE_PATH));
|
||||
@@ -3589,14 +3637,17 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
// an armed bind is the one thing here that can stop the session coming up at all.
|
||||
let mut bind = arm_session_bind(&wrapper);
|
||||
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
|
||||
// mismatch kills every Vulkan client (Steam included) with no error but a black screen.
|
||||
// mismatch kills every Vulkan client with no error but a black screen. Steam Big Picture is not
|
||||
// one of them, so the casualty is the GAMES — see [`WSI_OFF_ENV`] for why both variables go.
|
||||
let wsi_ok = wsi_layer_matches_our_gamescope();
|
||||
if !wsi_ok {
|
||||
tracing::warn!(
|
||||
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
|
||||
than the one we run — disabling it for this session (ENABLE_GAMESCOPE_WSI=0). Left \
|
||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
|
||||
which shows up as a black screen with no other symptom."
|
||||
than the one we run — disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
|
||||
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
|
||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies; \
|
||||
Steam's own UI is not one, so what you see is a game that runs with sound and input \
|
||||
on a black screen, with no other symptom."
|
||||
);
|
||||
}
|
||||
let start_unit = |bind: Option<&SessionBind>| -> Result<()> {
|
||||
@@ -3606,7 +3657,9 @@ fn launch_session(client: &str, unit_name: &str, mode: Mode, hdr: bool) -> Resul
|
||||
cmd.arg(arg);
|
||||
}
|
||||
if !wsi_ok {
|
||||
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
|
||||
for arg in wsi_off_setenv_args() {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
}
|
||||
let status = cmd
|
||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
||||
@@ -4043,9 +4096,9 @@ mod tests {
|
||||
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz, hdr_args,
|
||||
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
|
||||
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
|
||||
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
|
||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, wsi_off_setenv_args,
|
||||
wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
|
||||
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
|
||||
};
|
||||
|
||||
/// The HDR spawn flags are what make a nested game render HDR at all — and their absence is
|
||||
@@ -4706,4 +4759,33 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// `gamescope-session-plus` runs `export ENABLE_GAMESCOPE_WSI=1` before it launches anything,
|
||||
/// so that variable alone cannot turn the layer off for Steam or for the games Steam launches
|
||||
/// — only `DISABLE_GAMESCOPE_WSI`, which the script never mentions and which the Vulkan loader
|
||||
/// treats as an unconditional force-off, survives. Dropping it would restore the field bug
|
||||
/// (a game with sound and input on a black screen) while the host's log still claimed the
|
||||
/// layer was disabled, which is what made it so expensive to find. See [`WSI_OFF_ENV`].
|
||||
#[test]
|
||||
fn the_wsi_opt_out_carries_the_variable_the_session_script_cannot_clobber() {
|
||||
assert!(
|
||||
WSI_OFF_ENV.contains(&("DISABLE_GAMESCOPE_WSI", "1")),
|
||||
"the clobber-proof variable is the whole point of the opt-out"
|
||||
);
|
||||
|
||||
// Both spellings reach both launch paths, and neither may lose the other.
|
||||
let args = wsi_off_setenv_args();
|
||||
let lines = wsi_off_unit_lines();
|
||||
for (name, value) in WSI_OFF_ENV {
|
||||
assert!(args.contains(&format!("--setenv={name}={value}")), "{name}");
|
||||
assert!(
|
||||
lines.contains(&format!("Environment={name}={value}\n")),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
|
||||
// Trailing newline: the drop-in body appends nothing after this block today, but the bind
|
||||
// lines above it rely on the same contract and the order has changed before.
|
||||
assert!(lines.ends_with('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user