From cc8eb7df0875db336891c33a4acb35b0753b9de7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 11 Aug 2026 22:13:54 +0200 Subject: [PATCH] feat(clients): every pinned card gets a library, and it launches with that card's profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/` 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. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 42 +++++++---- .../io/unom/punktfunk/ConnectPrompts.kt | 10 ++- .../kotlin/io/unom/punktfunk/ConnectScreen.kt | 6 +- .../kotlin/io/unom/punktfunk/GamepadHome.kt | 13 ++-- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 35 ++++++++- .../io/unom/punktfunk/models/UiModels.kt | 16 ++++ .../kotlin/io/unom/punktfunk/HomeTilesTest.kt | 6 +- .../Sources/PunktfunkClient/ContentView.swift | 55 ++++++++++---- .../Home/GamepadHomeView.swift | 27 ++++--- .../Home/GamepadLibraryScreen.swift | 8 +- .../PunktfunkClient/Home/GamepadShell.swift | 6 +- .../PunktfunkClient/Home/HomeView.swift | 17 +++-- .../PunktfunkClient/Home/HostCards.swift | 7 ++ .../PunktfunkClient/Home/LibraryView.swift | 54 +++++++++++++- .../Session/SessionModel.swift | 23 ++++-- .../PunktfunkShared/EffectiveSettings.swift | 2 +- clients/linux/src/ui_hosts.rs | 7 ++ clients/linux/src/ui_library.rs | 22 +++++- clients/windows/src/app/hosts.rs | 73 ++++++++++++++++++- 19 files changed, 347 insertions(+), 82 deletions(-) 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);