Compare commits

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

Two shapes of bug, one per client:

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

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

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

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

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

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

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

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

Both directions are tested, and the pinned one was checked against a reverted
fix: it fails with exactly the reported symptom (left: None, right: "hdr").
2026-08-11 21:42:14 +02:00
22 changed files with 454 additions and 89 deletions
@@ -56,6 +56,7 @@ import io.unom.punktfunk.kit.link.HostResolution
import io.unom.punktfunk.kit.SessionEndReason
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.ActiveSession
import io.unom.punktfunk.models.LibraryReturn
import io.unom.punktfunk.models.Tab
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
@@ -74,7 +75,7 @@ fun App(forceGamepadUi: Boolean = false) {
// whose library the console shell should come back to. Held HERE because the shell's own
// navigation state does not outlive the stream. Cleared once the shell has consumed it, so a
// later manual Back out of the library is not undone by a stale value.
var reopenLibraryHostId by remember { mutableStateOf<String?>(null) }
var reopenLibrary by remember { mutableStateOf<LibraryReturn?>(null) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (its mode says Always OR a
// pad is attached OR this is a TV OR the dev force flag). Flips live as controllers
@@ -139,9 +140,9 @@ fun App(forceGamepadUi: Boolean = false) {
// than all the way out to host selection. The console shell's own screen state does
// not survive the stream (StreamScreen replaces it in the composition, discarding
// its `remember`s), so the intent is hoisted here and handed back on the way in.
reopenLibraryHostId =
reopenLibrary =
if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) {
active.hostId
active.hostId?.let { LibraryReturn(it, active.libraryProfileId) }
} else {
null
}
@@ -154,8 +155,8 @@ fun App(forceGamepadUi: Boolean = false) {
onConnected = { session = it },
deepLink = pendingLink,
onDeepLinkHandled = { activity?.pendingDeepLink = null },
reopenLibraryHostId = reopenLibraryHostId,
onReopenLibraryHandled = { reopenLibraryHostId = null },
reopenLibrary = reopenLibrary,
onReopenLibraryHandled = { reopenLibrary = null },
)
} else {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
@@ -282,15 +283,19 @@ fun GamepadShell(
deepLink: String? = null,
onDeepLinkHandled: () -> Unit = {},
/**
* Open this saved host's library instead of Home on the way in — set when a game launched from
* it has just exited. Null (the default) starts on Home exactly as before.
* Open this library shelf instead of Home on the way in — set when a game launched from it has
* just exited. Null (the default) starts on Home exactly as before.
*/
reopenLibraryHostId: String? = null,
reopenLibrary: LibraryReturn? = null,
onReopenLibraryHandled: () -> Unit = {},
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// Which of that host's shelves is open: the pinned card's profile id, or null for the host's
// own tile (design §5.2a). Held beside `libraryHost` because it is the same navigation fact —
// a pinned card and its host are two tiles, and the library belongs to whichever you pressed.
var libraryPinId by remember { mutableStateOf<String?>(null) }
// Where the settings screen was when a sub-screen took over. The shell's AnimatedContent
// discards a screen's `remember`s the moment it stops being the target, so a trip out to the
// Controllers view and back would otherwise land on the Stream tab's first row — the couch
@@ -301,15 +306,21 @@ fun GamepadShell(
// Consume the "come back to this library" intent once, on entry. Keyed on the id so a second
// game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out.
// A host that has since been forgotten simply leaves us on Home rather than failing.
LaunchedEffect(reopenLibraryHostId) {
val id = reopenLibraryHostId ?: return@LaunchedEffect
LaunchedEffect(reopenLibrary) {
val (id, pinId) = reopenLibrary ?: return@LaunchedEffect
// Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys
// this effect and cancels the coroutine running it. Nothing suspends in between today, so
// either order happens to work — but this one cannot be broken by a later edit that adds a
// suspending call. A host that has since been forgotten just leaves us on Home.
KnownHostStore(context).all()
.firstOrNull { it.id == id }
?.let { libraryHost = it; screen = GamepadScreen.Library }
// A pin unpinned while the game was running is no longer a shelf: fall back to the
// host's own, rather than a card that no longer exists.
?.let { kh ->
libraryHost = kh
libraryPinId = pinId?.takeIf { it in kh.pinnedProfileIds }
screen = GamepadScreen.Library
}
onReopenLibraryHandled()
}
@@ -377,7 +388,11 @@ fun GamepadShell(
onDeepLinkHandled = onDeepLinkHandled,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
onOpenLibrary = { host, pinId ->
libraryHost = host
libraryPinId = pinId
screen = GamepadScreen.Library
},
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
@@ -407,8 +422,9 @@ fun GamepadShell(
host = host,
settings = settings,
onLaunched = onConnected,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
onBack = { screen = GamepadScreen.Home; libraryHost = null; libraryPinId = null },
navActive = s == screen,
pinnedProfileId = libraryPinId,
)
} ?: run { screen = GamepadScreen.Home }
}
@@ -42,7 +42,7 @@ internal fun ConnectPrompts(
optionsTarget: HostCardEntry?,
onDismissOptions: () -> Unit,
libraryEnabled: Boolean,
onOpenLibrary: (KnownHost) -> Unit,
onOpenLibrary: (KnownHost, String?) -> Unit,
onWake: (KnownHost) -> Unit,
onSpeedTest: (KnownHost) -> Unit,
onCopyLink: (KnownHost, StreamProfile?) -> Unit,
@@ -119,9 +119,11 @@ internal fun ConnectPrompts(
canWake = kh.mac.isNotEmpty() && offline,
onWake = { onDismissOptions(); onWake(kh) },
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (libraryEnabled && pin == null) {
{ onDismissOptions(); onOpenLibrary(kh) }
// so a TV remote reaches the library here instead of via the Y face button. A PIN card
// gets it too, opening its own shelf: unlike wake/edit/forget, the library is a way to
// start the card, not a property of the host.
onLibrary = if (libraryEnabled) {
{ onDismissOptions(); onOpenLibrary(kh, pin?.id) }
} else {
null
},
@@ -100,7 +100,9 @@ fun ConnectScreen(
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
// (host, pinned profile id) — a pinned host+profile card opens ITS shelf, and the id is the
// one-off every launch off that shelf runs with (design §5.2a). Null = the host's own tile.
onOpenLibrary: (KnownHost, String?) -> Unit = { _, _ -> },
navGate: Boolean = true, // false while the console home is cross-fading out
// A `punktfunk://` URL to route (design/client-deep-links.md §3). This screen owns it because
// it owns the connect path — trust decisions, the local-network grant, wake-and-retry — and a
@@ -772,7 +774,7 @@ fun ConnectScreen(
awaiting == null && editTarget == null && optionsTarget == null &&
speedTest == null && waker.waking == null && !lnpPrompt,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenLibrary = { tile -> tile.knownHost?.let { onOpenLibrary(it, tile.pinnedProfileId) } },
onOpenSettings = onOpenSettings,
onOptions = { tile ->
tile.knownHost?.let { kh ->
@@ -86,8 +86,10 @@ class HomeTile(
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
/**
* Set when this tile is a PINNED host+profile combination rather than the host's own tile.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget, library)
* belong to the host's own tile, and this one offers only Unpin.
* A pin is a shortcut, not a second host: the host-level actions (wake, edit, forget) belong
* to the host's own tile, and this one offers only Unpin. The library is NOT one of those —
* it is a way to start this card (a connect with a title picked first), so a pinned tile opens
* its own shelf and every launch off it carries this profile.
*/
val pinnedProfileId: String? = null,
/**
@@ -101,9 +103,10 @@ class HomeTile(
val profileAccent: Color? = null,
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null && pinnedProfileId == null
// Any SAVED host offers the library (matches Apple), pinned cards included — the fetch itself
// returns a clear "pair first" message if the host hasn't authorized this device for its
// management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
@@ -94,6 +94,13 @@ fun LibraryScreen(
onLaunched: (ActiveSession) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true,
/**
* The profile this shelf launches with, when it was opened from a PINNED host+profile card
* (design §5.2a) rather than the host's own tile: a one-off, exactly like the card's plain
* connect. Null = the host's tile, and the host's binding decides the same rule
* [ProfileStore.resolveFor] applies to every other connect.
*/
pinnedProfileId: String? = null,
) {
val ink = LocalGamepadInk.current
BackHandler(onBack = onBack)
@@ -104,6 +111,14 @@ fun LibraryScreen(
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
// A launch (connect) in flight: shows an overlay + gates the pad so a second press can't dial twice.
var launching by remember { mutableStateOf(false) }
// The profile every launch off this shelf runs with, resolved ONCE per shelf by the same rule
// the host-list connect uses: this card's pin as the one-off, else the host's binding, else the
// globals. Resolved here rather than per launch so a profile edited mid-browse cannot make two
// titles on one shelf stream differently.
val profile = remember(host.id, pinnedProfileId) {
ProfileStore(context).resolveFor(host, pinnedProfileId)
}
val streamSettings = remember(settings, profile) { settings.effectiveFor(profile) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
@@ -133,7 +148,16 @@ fun LibraryScreen(
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().consoleSafeArea()) {
ConsoleHeader("${host.name} — Library")
// A pinned card's shelf says so, in the card's own `host · profile` shape: what a
// launch here will use is a property of the shelf, not something to remember from
// the tile two screens back.
ConsoleHeader(
if (pinnedProfileId != null && profile != null) {
"${host.name} · ${profile.name} — Library"
} else {
"${host.name} — Library"
},
)
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
is LibState.Loading -> LoadingState()
@@ -145,7 +169,7 @@ fun LibraryScreen(
// Dial the host over the same pinned mTLS trust, booting straight
// into this title (the host resolves `launch` = its library id).
val handle = connectToHost(
context, settings, s.identity,
context, streamSettings, s.identity,
host.address, host.port, host.fpHex, launch = game.id,
)
launching = false
@@ -153,11 +177,14 @@ fun LibraryScreen(
onLaunched(
ActiveSession(
handle,
settings,
streamSettings,
host.clipboardSync,
profileName = profile?.name,
hostId = host.id,
// Where to come back to when this game exits.
// Where to come back to when this game exits
// this shelf, pin and all, not the host's default one.
launchedFromLibrary = true,
libraryProfileId = pinnedProfileId,
),
)
}
@@ -71,8 +71,24 @@ data class ActiveSession(
* [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending.
*/
val launchedFromLibrary: Boolean = false,
/**
* Which of [hostId]'s shelves that library launch came off: the pinned host+profile card's
* profile id (design §5.2a), or null for the host's own tile. Carried purely so the return
* trip above lands back on the SAME shelf a player who launched from a pinned card is still
* on that card when the game exits, and coming back to the host's default shelf would silently
* change what the next title streams with.
*/
val libraryProfileId: String? = null,
)
/**
* The library shelf a finished game launch should return to: the saved host's id, and the pinned
* profile card it was opened from (null = the host's own tile). One value rather than two parallel
* ones, because a hostId that arrives without its profile is not "the same shelf" it is the
* default one wearing the same name.
*/
data class LibraryReturn(val hostId: String, val profileId: String? = null)
/** Trust state of a host, shown as a colored pill on its card. */
enum class HostStatus(val label: String) {
PAIRED("Paired"),
@@ -4,7 +4,6 @@ import androidx.compose.ui.graphics.Color
import io.unom.punktfunk.kit.discovery.DiscoveredHost
import io.unom.punktfunk.kit.security.KnownHost
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
@@ -177,9 +176,10 @@ class HomeTilesTest {
assertTrue(it.paired)
assertNotNull(it.knownHost)
}
// Host tile library (Y); pin tile → none, because a pin is a shortcut, not a second host.
// Both tiles reach the library (Y): a pin card opens its OWN shelf, whose launches carry
// the pinned profile — the library is a way to start a card, not a host-level action.
assertTrue(result[0].hasLibrary)
assertFalse(result[1].hasLibrary)
assertTrue(result[1].hasLibrary)
}
/**
@@ -71,7 +71,7 @@ struct ContentView: View {
/// drives the cancelable "Waiting for approval" prompt and the pin-as-paired on success.
@State private var awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
@State private var libraryTarget: LibraryTarget?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" phase of the connect overlay). Available on every platform now that the iOS/tvOS
/// multicast entitlement is granted (see PunktfunkConnection.wakeOnLANAvailable).
@@ -412,10 +412,10 @@ struct ContentView: View {
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
.onChange(of: model.returnToLibrary) { _, shelf in
guard let shelf else { return }
model.returnToLibrary = nil
libraryTarget = host
libraryTarget = shelf
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
@@ -448,9 +448,9 @@ struct ContentView: View {
// (the coverflow is a GeometryReader, ideal zero), so without a frame it collapses to a
// tiny panel.
#if os(macOS)
.sheet(item: $libraryTarget) { host in
.sheet(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.frame(minWidth: 940, minHeight: 620)
}
@@ -461,9 +461,9 @@ struct ContentView: View {
// tile, `returnToLibrary`) keeps writing the same `libraryTarget` either way, and a
// controller arriving or leaving mid-browse hands the open library to whichever
// presentation the new mode owns.
.fullScreenCover(item: touchLibraryTarget) { host in
.fullScreenCover(item: touchLibraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
}
#endif
@@ -573,7 +573,7 @@ struct ContentView: View {
/// The iOS library cover's item: `libraryTarget`, hidden while the gamepad shell presents
/// the library in place (see the cover's comment).
private var touchLibraryTarget: Binding<StoredHost?> {
private var touchLibraryTarget: Binding<LibraryTarget?> {
Binding(
get: { gamepadUIActive ? nil : libraryTarget },
set: { libraryTarget = $0 })
@@ -743,6 +743,25 @@ struct ContentView: View {
/// library fetch rides the paired mTLS identity, so there is nothing to show before the host
/// is saved (the notice says what to do instead).
private func openLibrary(from link: DeepLink) {
// A `profile=` on a browse link picks the shelf, exactly as it picks the settings on a
// connect link and refuses the same way (§10.6): an unknown or ambiguous reference must
// never quietly degrade to the host's binding, which is a different shelf wearing the same
// host's name.
var selection = ProfileSelection.inherit
if let reference = link.profile {
let (profile, resolution) = profiles.catalog.resolve(reference)
switch resolution {
case .found:
selection = .profile(profile?.id ?? "")
case .notFound:
deepLinkNotice = "No settings profile called “\(reference)” on this device."
return
case .ambiguous:
deepLinkNotice = "More than one settings profile is called “\(reference)”. "
+ "Rename one, or link to it by its id."
return
}
}
switch link.resolveHost(in: store.hosts) {
case .known(let host):
guard !link.pinConflict(with: host) else {
@@ -755,7 +774,7 @@ struct ContentView: View {
deepLinkNotice = "Already streaming \(current). End that session first."
return
}
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: selection)
case .unknown(let address, _, let name, _):
deepLinkNotice = "\(name ?? address) isn't saved on this device yet. "
+ "Add it with the + button first — a library can only be browsed on a saved host."
@@ -833,9 +852,9 @@ struct ContentView: View {
PairSheet(host: host) { fingerprint in handlePaired(host, fingerprint: fingerprint) }
.onExitCommand { pairingTarget = nil }
}
.fullScreenCover(item: $libraryTarget) { host in
.fullScreenCover(item: $libraryTarget) { shelf in
NavigationStack {
LibraryView(store: store, host: host, onLaunch: { launchTitle(host, $0) })
LibraryView(store: store, target: shelf, onLaunch: { launchTitle(shelf, $0) })
}
.onExitCommand { libraryTarget = nil }
}
@@ -1234,6 +1253,10 @@ struct ContentView: View {
setting: PunktfunkConnection.GamepadType(
rawValue: UInt32(clamping: effective.gamepadType)) ?? .auto),
launchID: launchID,
// Where a game exit returns to, when this connect launched a title: the shelf that
// title was picked on the host's own, or the pinned card whose profile this connect
// is using. Ignored by the model unless there is a launchID.
shelf: LibraryTarget(host: host, profile: profile),
allowTofu: allowTofu,
requestAccess: requestAccess,
onUnreachable: onUnreachable)
@@ -1289,9 +1312,13 @@ struct ContentView: View {
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
/// asks the host to launch it.
private func launchTitle(_ host: StoredHost, _ id: String) {
/// A title picked on a library shelf: dial its host, booting straight into that title with
/// the shelf's profile. A pinned card's shelf carries its card's profile as the one-off, so a
/// launch made there streams with the profile the card promises; the host's own shelf carries
/// `.inherit` and the binding decides, exactly as a plain card tap does.
private func launchTitle(_ shelf: LibraryTarget, _ id: String) {
libraryTarget = nil
connect(host, launchID: id)
connect(shelf.host, launchID: id, profile: shelf.profile)
}
/// Tap a discovered host: save it (so the session has a stored identity and the trust pin
@@ -75,7 +75,7 @@ struct GamepadHomeView: View {
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
/// The host awaiting a PIN ceremony, if any. Owned by ContentView (a connect attempt sets it,
/// as does the trust card's "Pair with PIN instead"), presented here as a shell screen
/// PairSheet's `Form` is unreachable with a controller on iOS/macOS, which made pairing the
@@ -90,7 +90,7 @@ struct GamepadHomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
let launchTitle: (LibraryTarget, String) -> Void
/// A console prompt (GamepadPromptView) is up over the home it polls the same controller, so
/// this screen must stand down for as long as it is. Same handoff contract as the connect
/// takeover and the shell's own layers; without it the carousel keeps scrolling underneath the
@@ -263,7 +263,7 @@ struct GamepadHomeView: View {
if let host = pairingTarget { return .pair(host) }
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
if let shelf = libraryTarget { return .library(shelf) }
return nil
}
@@ -289,10 +289,10 @@ struct GamepadHomeView: View {
onPaired: { onPaired(host, $0) },
close: { if !transitioning { pairingTarget = nil } },
controllerActive: active)
case .library(let host):
case .library(let shelf):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
store: store, target: shelf,
onLaunch: { launchTitle(shelf, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
@@ -501,9 +501,10 @@ struct GamepadHomeView: View {
isPaired: host.pinnedSHA256 != nil,
isConnecting: connecting,
filled: true,
// A pinned card is a shortcut, not a second host Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
// A pinned card reaches the library too, and gets its OWN shelf: browsing is
// this card's connect with a title picked first, not a host-level action like
// wake or forget.
hasLibrary: true,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
@@ -539,12 +540,14 @@ struct GamepadHomeView: View {
}
/// Only saved hosts have a library matches the touch grid, where "Browse Library" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
/// `HostCardView`-only action never offered on `DiscoveredCardView`. A pinned card opens its
/// own shelf: the selection already names which card Y was pressed on, and that card's profile
/// is what its launches run with.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
guard libraryEnabled, case .saved(let id, let profileID) = selection,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
libraryTarget = LibraryTarget(host: host, profile: ProfileSelection(profileID: profileID))
}
}
@@ -12,24 +12,26 @@ import SwiftUI
struct GamepadLibraryScreen: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
let host: StoredHost
let target: LibraryTarget
let onLaunch: (String) -> Void
let close: () -> Void
var controllerActive = true
/// `.compact` in a landscape phone window tighter chrome, like every gamepad screen.
@Environment(\.verticalSizeClass) private var vSizeClass
/// Resolves a pinned shelf's profile name for the title.
@ObservedObject private var profiles = ProfileStore.shared
private var compact: Bool { vSizeClass == .compact }
var body: some View {
LibraryView(
store: store, host: host, onLaunch: onLaunch,
store: store, target: target, onLaunch: onLaunch,
onClose: close, controllerActive: controllerActive)
.safeAreaInset(edge: .top, spacing: 0) {
// Leading, like every gamepad heading no close chrome, B is the exit (the
// coverflow's, or LibraryView's own back-catcher before the coverflow exists).
Text("\(host.displayName) — Library")
Text("\(target.title(in: profiles)) — Library")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
@@ -22,14 +22,16 @@ enum GamepadScreen: Identifiable {
case settings
case addHost
case pair(StoredHost)
case library(StoredHost)
case library(LibraryTarget)
var id: String {
switch self {
case .settings: return "settings"
case .addHost: return "addHost"
case .pair(let host): return "pair-\(host.id.uuidString)"
case .library(let host): return "library-\(host.id.uuidString)"
// Keyed on the SHELF, not the host: a host and each of its pinned cards open different
// libraries, and sharing an id would let one stand in for another mid-transition.
case .library(let shelf): return "library-\(shelf.id)"
}
}
@@ -24,7 +24,7 @@ struct HomeView: View {
@Binding var showAddHost: Bool
@Binding var pairingTarget: StoredHost?
@Binding var speedTestTarget: StoredHost?
@Binding var libraryTarget: StoredHost?
@Binding var libraryTarget: LibraryTarget?
#if !os(macOS)
@Binding var showSettings: Bool
#endif
@@ -34,8 +34,9 @@ struct HomeView: View {
let connectDiscovered: (DiscoveredHost) -> Void
/// Pairing succeeded (tvOS PairSheet route) pin + connect (ContentView guards staleness).
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Picked a title in the (experimental) library start a session that launches it, with the
/// shelf's profile (a pinned card's own; the host's binding on its primary card).
let onLaunchTitle: (LibraryTarget, String) -> Void
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
@@ -154,8 +155,8 @@ struct HomeView: View {
.navigationDestination(item: $speedTestTarget) { host in
SpeedTestSheet(host: host)
}
.navigationDestination(item: $libraryTarget) { host in
LibraryView(store: store, host: host, onLaunch: { onLaunchTitle(host, $0) })
.navigationDestination(item: $libraryTarget) { shelf in
LibraryView(store: store, target: shelf, onLaunch: { onLaunchTitle(shelf, $0) })
}
#endif
#if !os(tvOS)
@@ -263,9 +264,13 @@ struct HomeView: View {
}
private func hostCard(_ host: StoredHost, pinned: StreamProfile?) -> some View {
let onBrowseLibrary: (() -> Void)? = libraryEnabled ? { libraryTarget = host } : nil
// A pinned card connects with ITS profile; the primary card follows the binding.
let selection: ProfileSelection = pinned.map { .profile($0.id) } ?? .inherit
// and browsing is that same connect with a title picked first, so a pinned card opens its
// OWN shelf: every launch off it carries the card's profile rather than the host's binding.
let onBrowseLibrary: (() -> Void)? = libraryEnabled
? { libraryTarget = LibraryTarget(host: host, profile: selection) }
: nil
return HostCardView(
host: host,
isOnline: isOnline(host),
@@ -219,6 +219,13 @@ struct HostCardView: View {
// the way to remove the shortcut itself. Unpinning touches neither the profile nor
// the host's default binding.
connectWithMenu(menu)
// Browsing IS a connect-shaped action it is this card's connect with a title picked
// first so a pinned card offers it and opens its own shelf, whose launches carry the
// pinned profile. (Pair / speed test / wake / forget stay on the host's card: those
// are about the machine, and a shortcut has no business claiming them.)
if let onBrowseLibrary {
Button("Browse Library…", action: onBrowseLibrary)
}
if LinkClipboard.isAvailable {
Button("Copy Link") { menu.copyLink(pinned.id) }
}
@@ -6,11 +6,53 @@
import PunktfunkKit
import SwiftUI
/// Which library shelf is open: a host, and when it was opened from a PINNED host+profile card
/// (design/client-settings-profiles.md §5.2a) that card's profile, which every title launched off
/// the shelf then runs with, exactly as the card's own tap would.
///
/// One value rather than a host plus a profile carried beside it: a host and its pinned cards are
/// different cards on the grid, so "which library" is not answered by the host alone. That is also
/// why `id` folds the profile in a presentation keyed on the host would not re-present when you
/// move between a host's own shelf and one of its pins.
struct LibraryTarget: Identifiable, Hashable {
let host: StoredHost
/// `.inherit` from the host's own card (its binding decides, as it always has); `.profile` from
/// a pinned card. `.defaults` never reaches here nothing opens a library "with the globals".
var profile: ProfileSelection = .inherit
var id: String {
switch profile {
case .inherit: host.id.uuidString
case .defaults: "\(host.id.uuidString)#defaults"
case .profile(let id): "\(host.id.uuidString)#\(id)"
}
}
/// The pinned profile's id, if this shelf belongs to a pinned card.
var pinnedProfileID: String? {
if case .profile(let id) = profile { return id }
return nil
}
/// What the screen calls itself: the host, and the profile when a pinned card opened it the
/// same `host · profile` shape that card wears, so which shelf you are on is on screen rather
/// than remembered from the card you pressed. A pin whose profile has since been deleted
/// resolves as no profile everywhere else, and reads as the plain host here.
@MainActor func title(in catalog: ProfileStore) -> String {
guard let id = pinnedProfileID, let profile = catalog.profile(id: id) else {
return host.displayName
}
return "\(host.displayName) \u{b7} \(profile.name)"
}
}
struct LibraryView: View {
@ObservedObject var store: HostStore
let host: StoredHost
/// The shelf being browsed the host, plus the pinned profile when a pinned card opened it.
let target: LibraryTarget
/// Tapping a title starts a session that asks the host to launch it (the library id is passed
/// through). `nil` browse-only (cards aren't tappable).
/// through). `nil` browse-only (cards aren't tappable). The PROFILE a launch runs with is the
/// caller's to apply: it holds `target` and connects with `target.profile`.
var onLaunch: ((String) -> Void)? = nil
/// How the gamepad shell (GamepadLibraryScreen) closes this screen; nil every sheet/cover
/// presentation falls back to the environment dismiss.
@@ -20,6 +62,12 @@ struct LibraryView: View {
/// default (their being up IS the launcher's gate).
var controllerActive = true
@Environment(\.dismiss) private var dismiss
/// Resolves a pinned shelf's profile NAME for the title (the target carries only its id).
@ObservedObject private var profiles = ProfileStore.shared
/// The host this shelf belongs to every fetch, every poster URL and the launch itself address
/// it, and a pinned shelf is the same host seen through one of its cards.
private var host: StoredHost { target.host }
@State private var games: [GameEntry] = []
@State private var loading = false
@@ -50,7 +98,7 @@ struct LibraryView: View {
var body: some View {
content
.navigationTitle("\(host.displayName) — Library")
.navigationTitle("\(target.title(in: profiles)) — Library")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
@@ -70,9 +70,14 @@ final class SessionModel: ObservableObject {
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// WHICH library shelf that title was launched from a host's own, or one of its pinned
/// host+profile cards (§5.2a). The host alone would not answer it: a pinned card's shelf
/// launches with that card's profile, so returning to the host's default shelf would quietly
/// change what the next title streams with.
private var launchedShelf: LibraryTarget?
/// Set when a session ended because its game exited and it began as a library launch: the
/// shelf to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: LibraryTarget?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -275,6 +280,9 @@ final class SessionModel: ObservableObject {
func connect(to host: StoredHost, effective: EffectiveSettings,
gamepad: PunktfunkConnection.GamepadType = .auto,
launchID: String? = nil,
/// The library shelf `launchID` was picked on, so a game exit can return to it.
/// Only meaningful alongside a `launchID`; nil for a plain desktop connect.
shelf: LibraryTarget? = nil,
allowTofu: Bool = false,
autoTrust: Bool = false,
requestAccess: Bool = false,
@@ -283,6 +291,7 @@ final class SessionModel: ObservableObject {
phase = .connecting
activeHost = host
launchedTitleID = launchID
launchedShelf = shelf
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -663,6 +672,7 @@ final class SessionModel: ObservableObject {
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
launchedShelf = nil
phase = .idle
fps = 0
mbps = 0
@@ -692,13 +702,16 @@ final class SessionModel: ObservableObject {
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
// The shelf it came off falling back to the host's own if a caller launched a title
// without naming one, which is what that launch effectively browsed.
let shelf = launchedShelf ?? activeHost.map { LibraryTarget(host: $0) }
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
switch reason {
case .gameExited:
// The player quit their own game. Not a failure, and they are probably after the next
// title so no banner, and back to the library it came from.
if cameFromLibrary, let host {
returnToLibrary = host
if cameFromLibrary, host != nil, let shelf {
returnToLibrary = shelf
}
case .hostEnded, .local:
// Someone asked for this: an operator "End" on the host, or our own close racing in.
@@ -229,7 +229,7 @@ public struct EffectiveSettings: Equatable, Sendable {
/// through to the binding. Collapsing the two would make the menu item that says "Default
/// settings" silently connect with the host's profile. It is the same distinction the session
/// binary's `--profile ""` reserves on the desktop clients.
public enum ProfileSelection: Equatable, Sendable {
public enum ProfileSelection: Hashable, Sendable {
/// No pick the host's default binding applies (a plain click/tap).
case inherit
/// Force the global defaults for this one connect, whatever the host is bound to.
+7
View File
@@ -466,6 +466,13 @@ impl relm4::factory::FactoryComponent for HostCard {
// offering them here would blur what the card is.
let launch = gio::Menu::new();
launch.append(Some("Connect"), Some("card.connect"));
// …and the same stream with a title picked first. The library is a way to
// START this card, not a property of the host, so it belongs to a shortcut
// as much as Connect does — and the card's request carries its profile, so
// what launches from that grid is this card's profile, not the binding.
if *library_enabled {
launch.append(Some("Browse library\u{2026}"), Some("card.library"));
}
menu.append_section(None, &launch);
let links = gio::Menu::new();
+21 -1
View File
@@ -44,6 +44,26 @@ struct State {
mock: Cell<bool>,
}
/// What the page calls the host it is browsing. A request that carries a one-off profile
/// came from a PINNED card (design §5.2a), and every title launched off this grid inherits
/// it — so the page names it, the same `host · profile` shape the card wears. A plain card
/// says nothing extra: its binding is the host's own default, not a second thing to read.
/// A one-off whose profile has since been deleted resolves as no profile everywhere else,
/// and reads as a plain host here.
fn page_host_label(req: &ConnectRequest) -> String {
let Some(id) = req.profile.as_deref().filter(|id| !id.is_empty()) else {
return req.name.clone();
};
pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.find(|p| p.id == id)
.map_or_else(
|| req.name.clone(),
|p| format!("{} \u{b7} {}", req.name, p.name),
)
}
/// Open the library page for a saved host and start the fetch. `mgmt_port` comes from
/// the live mDNS `mgmt` TXT when the host is advertising (the hosts page resolves it).
pub fn open(
@@ -194,7 +214,7 @@ fn build(
toolbar.set_content(Some(&stack));
let page = adw::NavigationPage::builder()
.title(format!("{} — Library", req.name))
.title(format!("{} — Library", page_host_label(&req)))
.child(&toolbar)
.build();
+69 -4
View File
@@ -913,9 +913,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
// …then this host's pinned host+profile tiles, in the order they were pinned
// (design §5.2a). They share the host's live status because they read the same
// record, and a pin whose profile is gone simply doesn't render. No menu of their
// own: a pinned tile is a shortcut, not a second host, and pin/unpin already live
// on the primary tile's menu — the one place you decide it.
// record, and a pin whose profile is gone simply doesn't render. Their menu is
// deliberately short: a pinned tile is a shortcut, not a second host, so it carries
// only what STARTS it (the library — this tile's connect with a title picked first,
// which is why the grid it opens launches with the tile's profile), the link that
// reproduces it, and the way to remove it. Everything that configures the machine —
// pair, speed test, wake, edit, forget, and pinning itself — stays on the primary
// tile's menu, the one place you decide it.
for id in &k.pinned_profiles {
let Some((id, name, accent)) = profiles.iter().find(|(pid, ..)| pid == id) else {
continue;
@@ -923,6 +927,67 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (ctx3, ss3, st3) = (ctx.clone(), set_screen.clone(), set_status.clone());
let mut pinned_target = pinned_base.clone();
pinned_target.profile = Some(id.clone());
let pinned_menu = {
let (svc, target) = (props.svc.clone(), pinned_target.clone());
let (fp, pin_id) = (k.fp_hex.clone(), id.clone());
let (hosts_rev, set_hosts_rev) = (props.hosts_rev, props.set_hosts_rev.clone());
let link_host = k.clone();
let link_profile = id.clone();
let unpin_label = format!("{MENU_UNPIN}{name}");
let unpin_item = unpin_label.clone();
button("")
.icon(Symbol::More)
.subtle()
.tooltip("More options")
.automation_name("More options")
.menu_flyout({
let mut items = Vec::new();
// Same gate as the primary tile's: the mgmt API needs the paired
// identity, and the page is behind the experimental toggle.
if library_enabled && k.paired {
items.push(menu_item(MENU_LIBRARY));
}
items.push(menu_item(MENU_COPY_LINK));
items.push(menu_separator());
items.push(menu_item(unpin_label));
items
})
.on_item_clicked(move |item: String| match item.as_str() {
MENU_LIBRARY => {
// The shared target IS what the library page launches through, so
// parking THIS tile's target here is what makes its grid launch
// with the pinned profile.
*svc.ctx.shared.target.lock().unwrap() = target.clone();
super::library::start_fetch(&svc.ctx, &svc.set_library);
svc.set_screen.call(Screen::Library);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
Some(link_profile.as_str()),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
other if other == unpin_item => {
tracing::info!(pin = %pin_id, host = %fp, on = false, "pin toggle");
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|x| x != &pin_id);
if let Err(e) = known.save() {
tracing::warn!(
error = %format!("{e:#}"), "saving a pin"
);
}
}
// Same reason as the primary tile's toggle: nothing the page reads
// as state changed, so the bump is what makes this tile vanish NOW.
set_hosts_rev.call(hosts_rev + 1);
}
_ => {}
})
};
tiles.push(host_tile(
// Its own hover key: two tiles for one host must not light up together.
&format!("{}#{id}", k.fp_hex),
@@ -935,7 +1000,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
(!k.paired).then_some(("Trusted", Pill::Info)),
Some((name.as_str(), accent.clone())),
),
None,
Some(pinned_menu),
Some(Box::new(move || {
if can_wake {
initiate_waking(&ctx3, pinned_target.clone(), &ss3, &st3);
+1 -1
View File
@@ -209,7 +209,7 @@ impl Screen {
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
match self {
Screen::Home(_) => "Select a Host".into(),
Screen::Library(s) => s.host_name().to_string(),
Screen::Library(s) => s.title(),
Screen::Settings(_) => "Settings".into(),
Screen::AddHost(s) => s.title(),
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
+25 -6
View File
@@ -10,7 +10,7 @@ use crate::library::{
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
};
use crate::model::{ConsoleCmd, HostRow};
use crate::model::{ConsoleCmd, HostRow, ProfileChip};
use crate::pointer::{Pointer, PointerKind};
use crate::screens::{ConnectIntent, Ctx, Outbox};
use crate::theme::{accent, fg, Fonts, W};
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
port: u16,
fp_hex: String,
mgmt: u16,
/// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather
/// than the host's primary tile: every launch off this shelf is that card's connect
/// with a title attached, so it carries the same one-off profile the card's plain
/// A-press would. `None` = the primary tile, where the host's binding decides.
pin: Option<ProfileChip>,
shared: Option<LibraryShared>,
// Synced snapshot of the shared model (re-pulled when the generation bumps).
generation: u64,
@@ -48,6 +53,7 @@ impl LibraryScreen {
port: host.port,
fp_hex: host.fp_hex.clone(),
mgmt: host.mgmt_port,
pin: host.pin.clone(),
shared: None, // adopted from Ctx on the first render (the shell owns it)
generation: u64::MAX,
phase: LibraryPhase::Loading,
@@ -60,8 +66,13 @@ impl LibraryScreen {
}
}
pub(crate) fn host_name(&self) -> &str {
&self.host_name
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
/// profile every launch off it will use, in the card's own `host · profile` shape.
pub(crate) fn title(&self) -> String {
match &self.pin {
Some(p) => format!("{} \u{b7} {}", self.host_name, p.name),
None => self.host_name.clone(),
}
}
fn fetch_cmd(&self) -> ConsoleCmd {
@@ -123,10 +134,18 @@ impl LibraryScreen {
port: self.port,
fp_hex: self.fp_hex.clone(),
launch: Some(g.id.clone()),
title: g.title.clone(),
// A pinned card's shelf says which profile it is launching with,
// the same way its tile and this screen's title do.
title: match &self.pin {
Some(p) => format!("{} \u{b7} {}", g.title, p.name),
None => g.title.clone(),
},
request_access: false,
// Game launches follow the host's default binding.
profile: None,
// A game launch off a PINNED card's shelf is that card's connect
// with a title attached — it carries the card's profile as the
// one-off. Off the primary tile there is none, and the host's
// default binding decides.
profile: self.pin.as_ref().map(|p| p.id.clone()),
});
Some(MenuPulse::Confirm)
}
+81
View File
@@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) {
s.motion = Motion::None;
}
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
///
/// The card's plain A-press always carried its profile; Y — which the card offers, being
/// paired and saved — opened a library screen that knew only the host, so every title
/// launched off it silently fell back to the host's default binding. The profile a user
/// pinned is the whole reason they pressed that card.
#[test]
fn a_pinned_cards_library_launches_with_its_profile() {
let mut rows = hosts();
let card = HostRow {
key: "aa11\u{0}hdr".into(),
pin: Some(crate::model::ProfileChip {
id: "hdr".into(),
name: "HDR".into(),
accent: None,
}),
..rows[0].clone()
};
rows.insert(1, card);
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
console.set_hosts(rows);
s.sync();
// Focus the pinned card (it sits right after its host's primary tile), then Y.
s.handle_menu(MenuEvent::Move(MenuDir::Right));
s.handle_menu(MenuEvent::Secondary);
finish_motion(&mut s);
match s.stack.last() {
Some(Screen::Library(l)) => assert_eq!(
l.title(),
"Living Room PC \u{b7} HDR",
"the shelf names the profile it will launch with"
),
_ => panic!("Y on a pinned card opens its library"),
}
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
match s.take_action() {
Some(OverlayAction::Launch {
launch, profile, ..
}) => {
assert_eq!(launch.as_deref(), Some("steam:570"));
assert_eq!(
profile.as_deref(),
Some("hdr"),
"the launch carries the pinned card's profile"
);
}
_ => panic!("A on a title raises a launch"),
}
}
/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides,
/// which is what the resolver sees as `None`.
#[test]
fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
s.sync();
s.handle_menu(MenuEvent::Secondary); // paired+online host focused first
finish_motion(&mut s);
library.set_games(vec![crate::library::LibraryGame {
id: "steam:570".into(),
title: "Dota 2".into(),
store: "steam".into(),
launcher: false,
icon: String::new(),
}]);
s.handle_menu(MenuEvent::Confirm);
assert!(matches!(
s.take_action(),
Some(OverlayAction::Launch { profile: None, .. })
));
}
#[test]
fn wake_gates_input_in_the_same_press() {
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);