ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.
Two shapes of bug, one per client:
**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.
**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.
So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:
* a game that exits returns to the shelf it was launched from, pin and all,
rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
`ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
drops a pin that was unpinned while the game ran, rather than reopening a card
that no longer exists.
* Android's stats overlay finally names the profile a library launch used
(`profileName` was never set on that path).
* Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
ignored — and refuses an unknown or ambiguous reference exactly as the connect
route does (§10.6) rather than degrading to the binding.
* every shelf says which one it is, in the card's own `host · profile` shape:
the console's title, GTK's page title, Android's ConsoleHeader, Apple's
navigation title and its console heading.
Verified per platform, none of it on trust:
* console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
* Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
pass alone caught `navigationDestination(item:)` needing Hashable, and an
iOS-only screen was confirmed genuinely compiled by planting a type error.
* Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
expectation flipped to match.
* Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
cold (3m10s) — that client cannot compile on a Mac.
100 lines
5.0 KiB
Swift
100 lines
5.0 KiB
Swift
// The gamepad UI's screen-shell vocabulary (iOS): which screen sits over the launcher, and the
|
||
// console push/pop choreography that presents it. On iOS the launcher's sub-screens (settings,
|
||
// add-host, library) are NOT system covers — they are transparent layers composited in
|
||
// GamepadHomeView's ZStack over ONE persistent living backdrop, exactly the model
|
||
// `pf-console-ui`'s shell renders on the desktop clients: a push slides the incoming screen up
|
||
// out of a fade while the outgoing one recedes; a pop mirrors it; the field underneath never
|
||
// moves and never leaves. A system `fullScreenCover` — an opaque sheet sliding up from the
|
||
// bottom edge, mounting its own backdrop — was exactly the wrong grammar for a console.
|
||
// (macOS keeps its windowed sheets and tvOS its focus-engine covers; this file's motion
|
||
// constants are iOS-only in practice, but compile everywhere for the shared call sites.)
|
||
|
||
import PunktfunkKit
|
||
import SwiftUI
|
||
#if os(iOS) || os(macOS) || os(tvOS)
|
||
|
||
/// The screen the shell currently shows over the launcher. Derived, not stored: the presentation
|
||
/// triggers (`showSettings`, `showAddHost`, `libraryTarget`) stay authoritative on every
|
||
/// platform — this enum is just their iOS rendering. Depth is ≤ 1 by construction (the settings
|
||
/// pin picker is an in-screen layer, and every trigger is only reachable from the launcher), so
|
||
/// there is no stack to model.
|
||
enum GamepadScreen: Identifiable {
|
||
case settings
|
||
case addHost
|
||
case pair(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)"
|
||
// 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)"
|
||
}
|
||
}
|
||
|
||
/// The backdrop's calm target while this screen is up: the form screens quiet the field
|
||
/// (`Bg::Form` in the console); the library keeps the launcher's full aurora.
|
||
var isForm: Bool {
|
||
switch self {
|
||
case .settings, .addHost, .pair: return true
|
||
case .library: return false
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The console shell's motion constants, mapped to SwiftUI. Source of truth:
|
||
/// `crates/pf-console-ui/src/shell/render.rs` (push/pop) and `shell.rs` (`TRANSITION_S`).
|
||
enum GamepadShellMotion {
|
||
/// One transition, both layers — the console's `TRANSITION_S`.
|
||
static let duration: TimeInterval = 0.26
|
||
/// `1-(1-t)³` as a bezier: the standard ease-out-cubic control points.
|
||
static let screen = Animation.timingCurve(0.33, 1, 0.68, 1, duration: duration)
|
||
/// The backdrop's calm chase. The console runs an exponential approach (τ 0.12 s); the same
|
||
/// ease-out at 0.30 s lands within a few percent of it and settles together with the screen.
|
||
static let calm = Animation.timingCurve(0.33, 1, 0.68, 1, duration: 0.30)
|
||
/// The push/pop travel — the console's `36 * k`, k-floored for a landscape phone.
|
||
static func slide(compact: Bool) -> CGFloat { compact ? 27 : 36 }
|
||
/// The incoming screen grows from this; the revealed launcher grows back from `underScale`.
|
||
static let inScale: CGFloat = 0.985
|
||
static let underScale: CGFloat = 0.96
|
||
}
|
||
|
||
extension AnyTransition {
|
||
/// The console push/pop for the top layer. Insertion: up out of a fade, growing from 0.985.
|
||
/// Removal: down into a fade at full size (the console's pop leaves scale alone). The
|
||
/// launcher's recede underneath is NOT a transition — it never unmounts — it is the
|
||
/// `covered` opacity/scale in GamepadHomeView, animated in the same transaction.
|
||
///
|
||
/// Known deviation from the console: a pop there re-reveals the launcher from α 0.4; a
|
||
/// SwiftUI opacity animates from 0. Same duration, same landing — the revealed screen just
|
||
/// reads a beat later in the fade, not worth an explicitly-driven progress machine.
|
||
static func gamepadScreen(slide: CGFloat) -> AnyTransition {
|
||
.asymmetric(
|
||
insertion: .opacity
|
||
.combined(with: .offset(y: slide))
|
||
.combined(with: .scale(scale: GamepadShellMotion.inScale)),
|
||
removal: .opacity.combined(with: .offset(y: slide)))
|
||
}
|
||
}
|
||
|
||
private struct GamepadHostedInShellKey: EnvironmentKey {
|
||
static let defaultValue = false
|
||
}
|
||
|
||
extension EnvironmentValues {
|
||
/// True for a screen mounted as one of the shell's layers: it must NOT mount its own
|
||
/// backdrop (the shell's single persistent field is behind everything already — a second
|
||
/// one would double the mesh cost and break the "field never moves" illusion). The same
|
||
/// screens presented as macOS sheets / tvOS covers read the default `false` and keep
|
||
/// mounting their own, exactly as before.
|
||
var gamepadHostedInShell: Bool {
|
||
get { self[GamepadHostedInShellKey.self] }
|
||
set { self[GamepadHostedInShellKey.self] = newValue }
|
||
}
|
||
}
|
||
|
||
#endif
|