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
|
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.
|
*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
|
## v0.27.0
|
||||||
|
|
||||||
87 commits since v0.26.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.SessionEndReason
|
||||||
import io.unom.punktfunk.kit.security.KnownHostStore
|
import io.unom.punktfunk.kit.security.KnownHostStore
|
||||||
import io.unom.punktfunk.models.ActiveSession
|
import io.unom.punktfunk.models.ActiveSession
|
||||||
|
import io.unom.punktfunk.models.LibraryReturn
|
||||||
import io.unom.punktfunk.models.Tab
|
import io.unom.punktfunk.models.Tab
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.launch
|
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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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) {
|
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
|
||||||
active.hostId
|
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@@ -154,8 +155,8 @@ fun App(forceGamepadUi: Boolean = false) {
|
|||||||
onConnected = { session = it },
|
onConnected = { session = it },
|
||||||
deepLink = pendingLink,
|
deepLink = pendingLink,
|
||||||
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
onDeepLinkHandled = { activity?.pendingDeepLink = null },
|
||||||
reopenLibraryHostId = reopenLibraryHostId,
|
reopenLibrary = reopenLibrary,
|
||||||
onReopenLibraryHandled = { reopenLibraryHostId = null },
|
onReopenLibraryHandled = { reopenLibrary = null },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
|
||||||
@@ -282,15 +283,19 @@ fun GamepadShell(
|
|||||||
deepLink: String? = null,
|
deepLink: String? = null,
|
||||||
onDeepLinkHandled: () -> Unit = {},
|
onDeepLinkHandled: () -> Unit = {},
|
||||||
/**
|
/**
|
||||||
* Open this saved host's library instead of Home on the way in — set when a game launched from
|
* Open this library shelf instead of Home on the way in — set when a game launched from it has
|
||||||
* it has just exited. Null (the default) starts on Home exactly as before.
|
* just exited. Null (the default) starts on Home exactly as before.
|
||||||
*/
|
*/
|
||||||
reopenLibraryHostId: String? = null,
|
reopenLibrary: LibraryReturn? = null,
|
||||||
onReopenLibraryHandled: () -> Unit = {},
|
onReopenLibraryHandled: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
var screen by remember { mutableStateOf(GamepadScreen.Home) }
|
||||||
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
|
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
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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.
|
// A host that has since been forgotten simply leaves us on Home rather than failing.
|
||||||
LaunchedEffect(reopenLibraryHostId) {
|
LaunchedEffect(reopenLibrary) {
|
||||||
val id = reopenLibraryHostId ?: return@LaunchedEffect
|
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
|
||||||
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
|
// 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
|
// 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
|
// 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.
|
// suspending call. A host that has since been forgotten just leaves us on Home.
|
||||||
KnownHostStore(context).all()
|
KnownHostStore(context).all()
|
||||||
.firstOrNull { it.id == id }
|
.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()
|
onReopenLibraryHandled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +388,11 @@ fun GamepadShell(
|
|||||||
onDeepLinkHandled = onDeepLinkHandled,
|
onDeepLinkHandled = onDeepLinkHandled,
|
||||||
gamepadUi = true,
|
gamepadUi = true,
|
||||||
onOpenSettings = { screen = GamepadScreen.Settings },
|
onOpenSettings = { screen = GamepadScreen.Settings },
|
||||||
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
|
onOpenLibrary = { host, pinId ->
|
||||||
|
libraryHost = host
|
||||||
|
libraryPinId = pinId
|
||||||
|
screen = GamepadScreen.Library
|
||||||
|
},
|
||||||
navGate = s == screen,
|
navGate = s == screen,
|
||||||
)
|
)
|
||||||
GamepadScreen.Settings -> GamepadSettingsScreen(
|
GamepadScreen.Settings -> GamepadSettingsScreen(
|
||||||
@@ -407,8 +422,9 @@ fun GamepadShell(
|
|||||||
host = host,
|
host = host,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
onLaunched = onConnected,
|
onLaunched = onConnected,
|
||||||
onBack = { screen = GamepadScreen.Home; libraryHost = null },
|
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
|
||||||
navActive = s == screen,
|
navActive = s == screen,
|
||||||
|
pinnedProfileId = libraryPinId,
|
||||||
)
|
)
|
||||||
} ?: run { screen = GamepadScreen.Home }
|
} ?: run { screen = GamepadScreen.Home }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ internal fun ConnectPrompts(
|
|||||||
optionsTarget: HostCardEntry?,
|
optionsTarget: HostCardEntry?,
|
||||||
onDismissOptions: () -> Unit,
|
onDismissOptions: () -> Unit,
|
||||||
libraryEnabled: Boolean,
|
libraryEnabled: Boolean,
|
||||||
onOpenLibrary: (KnownHost) -> Unit,
|
onOpenLibrary: (KnownHost, String?) -> Unit,
|
||||||
onWake: (KnownHost) -> Unit,
|
onWake: (KnownHost) -> Unit,
|
||||||
onSpeedTest: (KnownHost) -> Unit,
|
onSpeedTest: (KnownHost) -> Unit,
|
||||||
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
|
||||||
@@ -119,9 +119,11 @@ internal fun ConnectPrompts(
|
|||||||
canWake = kh.mac.isNotEmpty() && offline,
|
canWake = kh.mac.isNotEmpty() && offline,
|
||||||
onWake = { onDismissOptions(); onWake(kh) },
|
onWake = { onDismissOptions(); onWake(kh) },
|
||||||
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
|
// 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.
|
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
|
||||||
onLibrary = if (libraryEnabled && pin == null) {
|
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
|
||||||
{ onDismissOptions(); onOpenLibrary(kh) }
|
// start the card, not a property of the host.
|
||||||
|
onLibrary = if (libraryEnabled) {
|
||||||
|
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ fun ConnectScreen(
|
|||||||
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
|
||||||
gamepadUi: Boolean = false,
|
gamepadUi: Boolean = false,
|
||||||
onOpenSettings: () -> Unit = {},
|
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
|
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
|
// 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
|
// 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 &&
|
awaiting == null && editTarget == null && optionsTarget == null &&
|
||||||
speedTest == null && waker.waking == null && !lnpPrompt,
|
speedTest == null && waker.waking == null && !lnpPrompt,
|
||||||
onActivate = { it.activate() },
|
onActivate = { it.activate() },
|
||||||
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
|
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
onOptions = { tile ->
|
onOptions = { tile ->
|
||||||
tile.knownHost?.let { kh ->
|
tile.knownHost?.let { kh ->
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ class HomeTile(
|
|||||||
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
|
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.
|
* 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)
|
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
|
||||||
* belong to the host's own tile, and this one offers only Unpin.
|
* 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,
|
val pinnedProfileId: String? = null,
|
||||||
/**
|
/**
|
||||||
@@ -101,9 +103,10 @@ class HomeTile(
|
|||||||
val profileAccent: Color? = null,
|
val profileAccent: Color? = null,
|
||||||
val activate: () -> Unit,
|
val activate: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
|
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
|
||||||
// first" message if the host hasn't authorized this device for its management API.
|
// returns a clear "pair first" message if the host hasn't authorized this device for its
|
||||||
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
|
// management API.
|
||||||
|
val hasLibrary: Boolean get() = knownHost != null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ fun LibraryScreen(
|
|||||||
onLaunched: (ActiveSession) -> Unit,
|
onLaunched: (ActiveSession) -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
navActive: Boolean = true,
|
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
|
val ink = LocalGamepadInk.current
|
||||||
BackHandler(onBack = onBack)
|
BackHandler(onBack = onBack)
|
||||||
@@ -104,6 +111,14 @@ fun LibraryScreen(
|
|||||||
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
|
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.
|
// 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) }
|
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) {
|
LaunchedEffect(host.address, host.port, host.fpHex) {
|
||||||
state = LibState.Loading
|
state = LibState.Loading
|
||||||
@@ -133,7 +148,16 @@ fun LibraryScreen(
|
|||||||
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
|
||||||
GamepadAuroraBackground(Modifier.fillMaxSize())
|
GamepadAuroraBackground(Modifier.fillMaxSize())
|
||||||
Column(Modifier.fillMaxSize().consoleSafeArea()) {
|
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) {
|
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is LibState.Loading -> LoadingState()
|
is LibState.Loading -> LoadingState()
|
||||||
@@ -145,7 +169,7 @@ fun LibraryScreen(
|
|||||||
// Dial the host over the same pinned mTLS trust, booting straight
|
// Dial the host over the same pinned mTLS trust, booting straight
|
||||||
// into this title (the host resolves `launch` = its library id).
|
// into this title (the host resolves `launch` = its library id).
|
||||||
val handle = connectToHost(
|
val handle = connectToHost(
|
||||||
context, settings, s.identity,
|
context, streamSettings, s.identity,
|
||||||
host.address, host.port, host.fpHex, launch = game.id,
|
host.address, host.port, host.fpHex, launch = game.id,
|
||||||
)
|
)
|
||||||
launching = false
|
launching = false
|
||||||
@@ -153,11 +177,14 @@ fun LibraryScreen(
|
|||||||
onLaunched(
|
onLaunched(
|
||||||
ActiveSession(
|
ActiveSession(
|
||||||
handle,
|
handle,
|
||||||
settings,
|
streamSettings,
|
||||||
host.clipboardSync,
|
host.clipboardSync,
|
||||||
|
profileName = profile?.name,
|
||||||
hostId = host.id,
|
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,
|
launchedFromLibrary = true,
|
||||||
|
libraryProfileId = pinnedProfileId,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,8 +71,24 @@ data class ActiveSession(
|
|||||||
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
|
||||||
*/
|
*/
|
||||||
val launchedFromLibrary: Boolean = false,
|
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. */
|
/** Trust state of a host, shown as a colored pill on its card. */
|
||||||
enum class HostStatus(val label: String) {
|
enum class HostStatus(val label: String) {
|
||||||
PAIRED("Paired"),
|
PAIRED("Paired"),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
import io.unom.punktfunk.kit.discovery.DiscoveredHost
|
||||||
import io.unom.punktfunk.kit.security.KnownHost
|
import io.unom.punktfunk.kit.security.KnownHost
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -177,9 +176,10 @@ class HomeTilesTest {
|
|||||||
assertTrue(it.paired)
|
assertTrue(it.paired)
|
||||||
assertNotNull(it.knownHost)
|
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)
|
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.
|
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
|
||||||
@State private var awaitingApproval: ApprovalRequest?
|
@State private var awaitingApproval: ApprovalRequest?
|
||||||
@State private var speedTestTarget: StoredHost?
|
@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
|
/// 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
|
/// "Waking…" phase of the connect overlay). Available on every platform now that the iOS/tvOS
|
||||||
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
|
/// 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
|
// (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
|
// 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.
|
// later manual dismiss of the library can't be undone by a stale value.
|
||||||
.onChange(of: model.returnToLibrary) { _, host in
|
.onChange(of: model.returnToLibrary) { _, shelf in
|
||||||
guard let host else { return }
|
guard let shelf else { return }
|
||||||
model.returnToLibrary = nil
|
model.returnToLibrary = nil
|
||||||
libraryTarget = host
|
libraryTarget = shelf
|
||||||
}
|
}
|
||||||
// On the outer Group so the sheet survives the trust-prompt → home transition
|
// 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
|
// (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
|
// (the coverflow is a GeometryReader, ideal ≈ zero), so without a frame it collapses to a
|
||||||
// tiny panel.
|
// tiny panel.
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
.sheet(item: $libraryTarget) { host in
|
.sheet(item: $libraryTarget) { shelf in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||||
}
|
}
|
||||||
.frame(minWidth: 940, minHeight: 620)
|
.frame(minWidth: 940, minHeight: 620)
|
||||||
}
|
}
|
||||||
@@ -461,9 +461,9 @@ struct ContentView: View {
|
|||||||
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
|
||||||
// controller arriving or leaving mid-browse hands the open library to whichever
|
// controller arriving or leaving mid-browse hands the open library to whichever
|
||||||
// presentation the new mode owns.
|
// presentation the new mode owns.
|
||||||
.fullScreenCover(item: touchLibraryTarget) { host in
|
.fullScreenCover(item: touchLibraryTarget) { shelf in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -573,7 +573,7 @@ struct ContentView: View {
|
|||||||
|
|
||||||
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
|
||||||
/// the library in place (see the cover's comment).
|
/// the library in place (see the cover's comment).
|
||||||
private var touchLibraryTarget: Binding<StoredHost?> {
|
private var touchLibraryTarget: Binding<LibraryTarget?> {
|
||||||
Binding(
|
Binding(
|
||||||
get: { gamepadUIActive ? nil : libraryTarget },
|
get: { gamepadUIActive ? nil : libraryTarget },
|
||||||
set: { libraryTarget = $0 })
|
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
|
/// 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).
|
/// is saved (the notice says what to do instead).
|
||||||
private func openLibrary(from link: DeepLink) {
|
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) {
|
switch link.resolveHost(in: store.hosts) {
|
||||||
case .known(let host):
|
case .known(let host):
|
||||||
guard !link.pinConflict(with: host) else {
|
guard !link.pinConflict(with: host) else {
|
||||||
@@ -755,7 +774,7 @@ struct ContentView: View {
|
|||||||
deepLinkNotice = "Already streaming \(current). End that session first."
|
deepLinkNotice = "Already streaming \(current). End that session first."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
libraryTarget = host
|
libraryTarget = LibraryTarget(host: host, profile: selection)
|
||||||
case .unknown(let address, _, let name, _):
|
case .unknown(let address, _, let name, _):
|
||||||
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
|
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."
|
+ "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) }
|
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
|
||||||
.onExitCommand { pairingTarget = nil }
|
.onExitCommand { pairingTarget = nil }
|
||||||
}
|
}
|
||||||
.fullScreenCover(item: $libraryTarget) { host in
|
.fullScreenCover(item: $libraryTarget) { shelf in
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
|
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
|
||||||
}
|
}
|
||||||
.onExitCommand { libraryTarget = nil }
|
.onExitCommand { libraryTarget = nil }
|
||||||
}
|
}
|
||||||
@@ -1234,6 +1253,10 @@ struct ContentView: View {
|
|||||||
setting: PunktfunkConnection.GamepadType(
|
setting: PunktfunkConnection.GamepadType(
|
||||||
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
|
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
|
||||||
launchID: launchID,
|
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,
|
allowTofu: allowTofu,
|
||||||
requestAccess: requestAccess,
|
requestAccess: requestAccess,
|
||||||
onUnreachable: onUnreachable)
|
onUnreachable: onUnreachable)
|
||||||
@@ -1289,9 +1312,13 @@ struct ContentView: View {
|
|||||||
|
|
||||||
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
|
||||||
/// asks the host to launch it.
|
/// 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
|
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
|
/// 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 store: HostStore
|
||||||
@ObservedObject var model: SessionModel
|
@ObservedObject var model: SessionModel
|
||||||
@ObservedObject var discovery: HostDiscovery
|
@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,
|
/// 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 —
|
/// 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
|
/// 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
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
|
/// 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).
|
/// 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
|
/// 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
|
/// 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
|
/// 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 let host = pairingTarget { return .pair(host) }
|
||||||
if showSettings { return .settings }
|
if showSettings { return .settings }
|
||||||
if showAddHost { return .addHost }
|
if showAddHost { return .addHost }
|
||||||
if let host = libraryTarget { return .library(host) }
|
if let shelf = libraryTarget { return .library(shelf) }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,10 +289,10 @@ struct GamepadHomeView: View {
|
|||||||
onPaired: { onPaired(host, $0) },
|
onPaired: { onPaired(host, $0) },
|
||||||
close: { if !transitioning { pairingTarget = nil } },
|
close: { if !transitioning { pairingTarget = nil } },
|
||||||
controllerActive: active)
|
controllerActive: active)
|
||||||
case .library(let host):
|
case .library(let shelf):
|
||||||
GamepadLibraryScreen(
|
GamepadLibraryScreen(
|
||||||
store: store, host: host,
|
store: store, target: shelf,
|
||||||
onLaunch: { launchTitle(host, $0) },
|
onLaunch: { launchTitle(shelf, $0) },
|
||||||
close: { if !transitioning { libraryTarget = nil } },
|
close: { if !transitioning { libraryTarget = nil } },
|
||||||
controllerActive: active)
|
controllerActive: active)
|
||||||
}
|
}
|
||||||
@@ -501,9 +501,10 @@ struct GamepadHomeView: View {
|
|||||||
isPaired: host.pinnedSHA256 != nil,
|
isPaired: host.pinnedSHA256 != nil,
|
||||||
isConnecting: connecting,
|
isConnecting: connecting,
|
||||||
filled: true,
|
filled: true,
|
||||||
// A pinned card is a shortcut, not a second host — Y (library) stays on the
|
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
|
||||||
// host's own tile, where the host-level actions live.
|
// this card's connect with a title picked first, not a host-level action like
|
||||||
hasLibrary: profile == nil,
|
// wake or forget.
|
||||||
|
hasLibrary: true,
|
||||||
osChain: host.osChain,
|
osChain: host.osChain,
|
||||||
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
|
||||||
&& !online && !host.wakeMacs.isEmpty,
|
&& !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
|
/// 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() {
|
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 })
|
let host = store.hosts.first(where: { $0.id == id })
|
||||||
else { return }
|
else { return }
|
||||||
libraryTarget = host
|
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,24 +12,26 @@ import SwiftUI
|
|||||||
struct GamepadLibraryScreen: View {
|
struct GamepadLibraryScreen: View {
|
||||||
@Environment(\.gamepadInk) private var ink
|
@Environment(\.gamepadInk) private var ink
|
||||||
@ObservedObject var store: HostStore
|
@ObservedObject var store: HostStore
|
||||||
let host: StoredHost
|
let target: LibraryTarget
|
||||||
let onLaunch: (String) -> Void
|
let onLaunch: (String) -> Void
|
||||||
let close: () -> Void
|
let close: () -> Void
|
||||||
var controllerActive = true
|
var controllerActive = true
|
||||||
|
|
||||||
/// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen.
|
/// `.compact` in a landscape phone window — tighter chrome, like every gamepad screen.
|
||||||
@Environment(\.verticalSizeClass) private var vSizeClass
|
@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 }
|
private var compact: Bool { vSizeClass == .compact }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LibraryView(
|
LibraryView(
|
||||||
store: store, host: host, onLaunch: onLaunch,
|
store: store, target: target, onLaunch: onLaunch,
|
||||||
onClose: close, controllerActive: controllerActive)
|
onClose: close, controllerActive: controllerActive)
|
||||||
.safeAreaInset(edge: .top, spacing: 0) {
|
.safeAreaInset(edge: .top, spacing: 0) {
|
||||||
// Leading, like every gamepad heading — no close chrome, B is the exit (the
|
// 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).
|
// 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))
|
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
|
||||||
.foregroundStyle(ink.fg)
|
.foregroundStyle(ink.fg)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ enum GamepadScreen: Identifiable {
|
|||||||
case settings
|
case settings
|
||||||
case addHost
|
case addHost
|
||||||
case pair(StoredHost)
|
case pair(StoredHost)
|
||||||
case library(StoredHost)
|
case library(LibraryTarget)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .settings: return "settings"
|
case .settings: return "settings"
|
||||||
case .addHost: return "addHost"
|
case .addHost: return "addHost"
|
||||||
case .pair(let host): return "pair-\(host.id.uuidString)"
|
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 showAddHost: Bool
|
||||||
@Binding var pairingTarget: StoredHost?
|
@Binding var pairingTarget: StoredHost?
|
||||||
@Binding var speedTestTarget: StoredHost?
|
@Binding var speedTestTarget: StoredHost?
|
||||||
@Binding var libraryTarget: StoredHost?
|
@Binding var libraryTarget: LibraryTarget?
|
||||||
#if !os(macOS)
|
#if !os(macOS)
|
||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
#endif
|
#endif
|
||||||
@@ -34,8 +34,9 @@ struct HomeView: View {
|
|||||||
let connectDiscovered: (DiscoveredHost) -> Void
|
let connectDiscovered: (DiscoveredHost) -> Void
|
||||||
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
/// Pairing succeeded (tvOS PairSheet route) — pin + connect (ContentView guards staleness).
|
||||||
let onPaired: (StoredHost, Data) -> Void
|
let onPaired: (StoredHost, Data) -> Void
|
||||||
/// Picked a title in the (experimental) library — start a session that launches it.
|
/// Picked a title in the (experimental) library — start a session that launches it, with the
|
||||||
let onLaunchTitle: (StoredHost, String) -> Void
|
/// 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
|
/// 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.
|
/// (the "Waking…" overlay), without connecting. Routed through ContentView's HostWaker.
|
||||||
let wake: (StoredHost) -> Void
|
let wake: (StoredHost) -> Void
|
||||||
@@ -154,8 +155,8 @@ struct HomeView: View {
|
|||||||
.navigationDestination(item: $speedTestTarget) { host in
|
.navigationDestination(item: $speedTestTarget) { host in
|
||||||
SpeedTestSheet(host: host)
|
SpeedTestSheet(host: host)
|
||||||
}
|
}
|
||||||
.navigationDestination(item: $libraryTarget) { host in
|
.navigationDestination(item: $libraryTarget) { shelf in
|
||||||
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
|
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
@@ -263,9 +264,13 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some 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.
|
// A pinned card connects with ITS profile; the primary card follows the binding.
|
||||||
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
|
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(
|
return HostCardView(
|
||||||
host: host,
|
host: host,
|
||||||
isOnline: isOnline(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 way to remove the shortcut itself. Unpinning touches neither the profile nor
|
||||||
// the host's default binding.
|
// the host's default binding.
|
||||||
connectWithMenu(menu)
|
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 {
|
if LinkClipboard.isAvailable {
|
||||||
Button("Copy Link") { menu.copyLink(pinned.id) }
|
Button("Copy Link") { menu.copyLink(pinned.id) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,53 @@
|
|||||||
import PunktfunkKit
|
import PunktfunkKit
|
||||||
import SwiftUI
|
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 {
|
struct LibraryView: View {
|
||||||
@ObservedObject var store: HostStore
|
@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
|
/// 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
|
var onLaunch: ((String) -> Void)? = nil
|
||||||
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
|
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil — every sheet/cover
|
||||||
/// presentation — falls back to the environment dismiss.
|
/// presentation — falls back to the environment dismiss.
|
||||||
@@ -20,6 +62,12 @@ struct LibraryView: View {
|
|||||||
/// default (their being up IS the launcher's gate).
|
/// default (their being up IS the launcher's gate).
|
||||||
var controllerActive = true
|
var controllerActive = true
|
||||||
@Environment(\.dismiss) private var dismiss
|
@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 games: [GameEntry] = []
|
||||||
@State private var loading = false
|
@State private var loading = false
|
||||||
@@ -50,7 +98,7 @@ struct LibraryView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content
|
content
|
||||||
.navigationTitle("\(host.displayName) — Library")
|
.navigationTitle("\(target.title(in: profiles)) — Library")
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#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
|
/// 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.
|
/// in that library when its game exits, not on the host-selection screen.
|
||||||
private var launchedTitleID: String?
|
private var launchedTitleID: String?
|
||||||
/// Set when a session ended because its game exited and it began as a library launch: the host
|
/// WHICH library shelf that title was launched from — a host's own, or one of its pinned
|
||||||
/// whose library to reopen. The view layer consumes it and sets it back to nil.
|
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
|
||||||
@Published var returnToLibrary: StoredHost?
|
/// 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
|
/// 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
|
/// 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.
|
/// 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,
|
func connect(to host: StoredHost, effective: EffectiveSettings,
|
||||||
gamepad: PunktfunkConnection.GamepadType = .auto,
|
gamepad: PunktfunkConnection.GamepadType = .auto,
|
||||||
launchID: String? = nil,
|
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,
|
allowTofu: Bool = false,
|
||||||
autoTrust: Bool = false,
|
autoTrust: Bool = false,
|
||||||
requestAccess: Bool = false,
|
requestAccess: Bool = false,
|
||||||
@@ -283,6 +291,7 @@ final class SessionModel: ObservableObject {
|
|||||||
phase = .connecting
|
phase = .connecting
|
||||||
activeHost = host
|
activeHost = host
|
||||||
launchedTitleID = launchID
|
launchedTitleID = launchID
|
||||||
|
launchedShelf = shelf
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
settings = effective
|
settings = effective
|
||||||
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
|
||||||
@@ -663,6 +672,7 @@ final class SessionModel: ObservableObject {
|
|||||||
activeHost = nil
|
activeHost = nil
|
||||||
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
|
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
|
||||||
launchedTitleID = nil
|
launchedTitleID = nil
|
||||||
|
launchedShelf = nil
|
||||||
phase = .idle
|
phase = .idle
|
||||||
fps = 0
|
fps = 0
|
||||||
mbps = 0
|
mbps = 0
|
||||||
@@ -692,13 +702,16 @@ final class SessionModel: ObservableObject {
|
|||||||
// a plain desktop session has no library to return to.
|
// a plain desktop session has no library to return to.
|
||||||
let host = activeHost
|
let host = activeHost
|
||||||
let cameFromLibrary = launchedTitleID != nil
|
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
|
disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect
|
||||||
switch reason {
|
switch reason {
|
||||||
case .gameExited:
|
case .gameExited:
|
||||||
// The player quit their own game. Not a failure, and they are probably after the next
|
// 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.
|
// title — so no banner, and back to the library it came from.
|
||||||
if cameFromLibrary, let host {
|
if cameFromLibrary, host != nil, let shelf {
|
||||||
returnToLibrary = host
|
returnToLibrary = shelf
|
||||||
}
|
}
|
||||||
case .hostEnded, .local:
|
case .hostEnded, .local:
|
||||||
// Someone asked for this: an operator "End" on the host, or our own close racing in.
|
// 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
|
/// 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
|
/// settings" silently connect with the host's profile. It is the same distinction the session
|
||||||
/// binary's `--profile ""` reserves on the desktop clients.
|
/// 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).
|
/// No pick — the host's default binding applies (a plain click/tap).
|
||||||
case inherit
|
case inherit
|
||||||
/// Force the global defaults for this one connect, whatever the host is bound to.
|
/// 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.
|
// offering them here would blur what the card is.
|
||||||
let launch = gio::Menu::new();
|
let launch = gio::Menu::new();
|
||||||
launch.append(Some("Connect"), Some("card.connect"));
|
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);
|
menu.append_section(None, &launch);
|
||||||
|
|
||||||
let links = gio::Menu::new();
|
let links = gio::Menu::new();
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ struct State {
|
|||||||
mock: Cell<bool>,
|
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
|
/// 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).
|
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
|
||||||
pub fn open(
|
pub fn open(
|
||||||
@@ -194,7 +214,7 @@ fn build(
|
|||||||
toolbar.set_content(Some(&stack));
|
toolbar.set_content(Some(&stack));
|
||||||
|
|
||||||
let page = adw::NavigationPage::builder()
|
let page = adw::NavigationPage::builder()
|
||||||
.title(format!("{} — Library", req.name))
|
.title(format!("{} — Library", page_host_label(&req)))
|
||||||
.child(&toolbar)
|
.child(&toolbar)
|
||||||
.build();
|
.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
|
// …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
|
// (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
|
// record, and a pin whose profile is gone simply doesn't render. Their menu is
|
||||||
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
|
// deliberately short: a pinned tile is a shortcut, not a second host, so it carries
|
||||||
// on the primary tile's menu — the one place you decide it.
|
// 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 {
|
for id in &k.pinned_profiles {
|
||||||
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
|
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
|
||||||
continue;
|
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 (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
|
||||||
let mut pinned_target = pinned_base.clone();
|
let mut pinned_target = pinned_base.clone();
|
||||||
pinned_target.profile = Some(id.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(
|
tiles.push(host_tile(
|
||||||
// Its own hover key: two tiles for one host must not light up together.
|
// Its own hover key: two tiles for one host must not light up together.
|
||||||
&format!("{}#{id}", k.fp_hex),
|
&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)),
|
(!k.paired).then_some(("Trusted", Pill::Info)),
|
||||||
Some((name.as_str(), accent.clone())),
|
Some((name.as_str(), accent.clone())),
|
||||||
),
|
),
|
||||||
None,
|
Some(pinned_menu),
|
||||||
Some(Box::new(move || {
|
Some(Box::new(move || {
|
||||||
if can_wake {
|
if can_wake {
|
||||||
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
|
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ impl Screen {
|
|||||||
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
|
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
|
||||||
match self {
|
match self {
|
||||||
Screen::Home(_) => "Select a Host".into(),
|
Screen::Home(_) => "Select a Host".into(),
|
||||||
Screen::Library(s) => s.host_name().to_string(),
|
Screen::Library(s) => s.title(),
|
||||||
Screen::Settings(_) => "Settings".into(),
|
Screen::Settings(_) => "Settings".into(),
|
||||||
Screen::AddHost(s) => s.title(),
|
Screen::AddHost(s) => s.title(),
|
||||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
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,
|
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,
|
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::pointer::{Pointer, PointerKind};
|
||||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||||
use crate::theme::{accent, fg, Fonts, W};
|
use crate::theme::{accent, fg, Fonts, W};
|
||||||
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
|
|||||||
port: u16,
|
port: u16,
|
||||||
fp_hex: String,
|
fp_hex: String,
|
||||||
mgmt: u16,
|
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>,
|
shared: Option<LibraryShared>,
|
||||||
// Synced snapshot of the shared model (re-pulled when the generation bumps).
|
// Synced snapshot of the shared model (re-pulled when the generation bumps).
|
||||||
generation: u64,
|
generation: u64,
|
||||||
@@ -48,6 +53,7 @@ impl LibraryScreen {
|
|||||||
port: host.port,
|
port: host.port,
|
||||||
fp_hex: host.fp_hex.clone(),
|
fp_hex: host.fp_hex.clone(),
|
||||||
mgmt: host.mgmt_port,
|
mgmt: host.mgmt_port,
|
||||||
|
pin: host.pin.clone(),
|
||||||
shared: None, // adopted from Ctx on the first render (the shell owns it)
|
shared: None, // adopted from Ctx on the first render (the shell owns it)
|
||||||
generation: u64::MAX,
|
generation: u64::MAX,
|
||||||
phase: LibraryPhase::Loading,
|
phase: LibraryPhase::Loading,
|
||||||
@@ -60,8 +66,13 @@ impl LibraryScreen {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn host_name(&self) -> &str {
|
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
|
||||||
&self.host_name
|
/// 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 {
|
fn fetch_cmd(&self) -> ConsoleCmd {
|
||||||
@@ -123,10 +134,18 @@ impl LibraryScreen {
|
|||||||
port: self.port,
|
port: self.port,
|
||||||
fp_hex: self.fp_hex.clone(),
|
fp_hex: self.fp_hex.clone(),
|
||||||
launch: Some(g.id.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,
|
request_access: false,
|
||||||
// Game launches follow the host's default binding.
|
// A game launch off a PINNED card's shelf is that card's connect
|
||||||
profile: None,
|
// 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)
|
Some(MenuPulse::Confirm)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) {
|
|||||||
s.motion = Motion::None;
|
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]
|
#[test]
|
||||||
fn wake_gates_input_in_the_same_press() {
|
fn wake_gates_input_in_the_same_press() {
|
||||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||||
|
|||||||
@@ -1032,7 +1032,7 @@ fn write_session_plus_dropin(
|
|||||||
wsi = if wsi_ok {
|
wsi = if wsi_ok {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} 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()))?;
|
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)
|
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.
|
/// 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
|
/// 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
|
/// 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.
|
/// 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
|
/// Disabling it costs only the layer's extras (XWayland bypass, present-mode control, client HDR
|
||||||
/// (present-mode control, client HDR metadata) — far cheaper than a client that cannot start.
|
/// 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 {
|
fn wsi_layer_matches_our_gamescope() -> bool {
|
||||||
let ours = discovery::gamescope_version_of(std::path::Path::new(gamescope_bin()));
|
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));
|
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.
|
// an armed bind is the one thing here that can stop the session coming up at all.
|
||||||
let mut bind = arm_session_bind(&wrapper);
|
let mut bind = arm_session_bind(&wrapper);
|
||||||
// The distro's Vulkan WSI layer speaks the distro gamescope's protocol; ours may differ, and a
|
// 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();
|
let wsi_ok = wsi_layer_matches_our_gamescope();
|
||||||
if !wsi_ok {
|
if !wsi_ok {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"gamescope: this box's VkLayer_FROG_gamescope_wsi was built for a different gamescope \
|
"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 \
|
than the one we run — disabling it for this session (DISABLE_GAMESCOPE_WSI=1, which \
|
||||||
enabled it rejects the client's swapchain_feedback and every Vulkan client dies, \
|
the session script cannot clobber the way it clobbers ENABLE_GAMESCOPE_WSI). Left \
|
||||||
which shows up as a black screen with no other symptom."
|
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<()> {
|
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);
|
cmd.arg(arg);
|
||||||
}
|
}
|
||||||
if !wsi_ok {
|
if !wsi_ok {
|
||||||
cmd.arg("--setenv=ENABLE_GAMESCOPE_WSI=0");
|
for arg in wsi_off_setenv_args() {
|
||||||
|
cmd.arg(arg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let status = cmd
|
let status = cmd
|
||||||
// Same headless-must-not-attach rule as [`spawn`]: the transient unit inherits the
|
// 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,
|
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,
|
is_steam_launch, mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, plan_bind,
|
||||||
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
|
||||||
shape_dedicated_command, switch_ends_mask_window, unmask_unit, xwayland_refusal_marker,
|
shape_dedicated_command, switch_ends_mask_window, unmask_unit, wsi_off_setenv_args,
|
||||||
BindOff, BindPlan, DmHelperError, SessionBind, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH,
|
wsi_off_unit_lines, xwayland_refusal_marker, BindOff, BindPlan, DmHelperError, SessionBind,
|
||||||
STOPPED_AUTOLOGIN, X11_SOCKET_DIR,
|
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
|
/// 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
|
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