diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 3b1e0d85..63253bac 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -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(null) } + var reopenLibrary by remember { mutableStateOf(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(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(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 } } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt index 801bf0dd..7183aae7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectPrompts.kt @@ -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 }, diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt index 7116fe91..a8cdb595 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ConnectScreen.kt @@ -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 -> diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt index ea29e0fe..699be82d 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/GamepadHome.kt @@ -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 } /** diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 4e2cfb80..d2e287ff 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -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.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, ), ) } diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index 7fe3d279..2453d2d0 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -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"), diff --git a/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt index 4a75ea4e..daf24ba0 100644 --- a/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt +++ b/clients/android/app/src/test/kotlin/io/unom/punktfunk/HomeTilesTest.kt @@ -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) } /** diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index f07fc0ae..d2fa103f 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 { + private var touchLibraryTarget: Binding { 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 diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 98c5c23c..5ddca868 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -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)) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 297bc487..9d5fd493 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -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) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift index daa2e6dc..0e6bcf18 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadShell.swift @@ -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)" } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift index 917db98f..7c54e4b1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HomeView.swift @@ -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), diff --git a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift index d3aac670..3ed8d658 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/HostCards.swift @@ -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) } } diff --git a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift index 4ca9841f..f54c6029 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/LibraryView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 64591f29..c2636151 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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. diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 5bf45f45..8be60359 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -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. diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index e40a0894..360055fb 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -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(); diff --git a/clients/linux/src/ui_library.rs b/clients/linux/src/ui_library.rs index cf6bbb25..7ec5d6d3 100644 --- a/clients/linux/src/ui_library.rs +++ b/clients/linux/src/ui_library.rs @@ -44,6 +44,26 @@ struct State { mock: Cell, } +/// 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(); diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index eb56ba73..81ac236e 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -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);