fix(console): a pinned card's library launches with that card's profile
Pinning a profile onto a host gives it its own card on the console home, and pressing A on that card has always connected with the pinned profile as the one-off the resolver prefers over the host's binding. Y on the same card opens a library — it is paired and saved, which is the only thing the hint bar asks — and every title launched off that shelf went out with no profile at all, so the host's default binding won. Connecting straight from the card honoured the profile; going through its library did not, which is the shape a user reads as "the pin works until I pick a game". The screen was the leak: LibraryScreen copied the row's address, port, fingerprint and mgmt port, and dropped `pin` — so its launch had nothing to send and hardcoded `profile: None`, under a comment asserting that game launches follow the binding. They should follow the card: a launch off a pinned card's shelf is that card's connect with a title attached. The screen now carries the row's pin and sends its id as the one-off. It also says so, in the card's own `host · profile` shape: the shelf's title names the profile, and so does the connecting card, so which of a host's cards you came in on is legible from the screen rather than inferred from the tile you pressed two screens ago. Off the host's primary tile there is no pin and nothing changes — `None`, and the binding decides. Console-only. The Apple and Android consoles keep Y off pinned cards outright (`hasLibrary: profile == nil`), the GTK client clones the card's whole ConnectRequest — profile included — into its library, and the Windows client offers "Browse library" from the primary tile only. The console's own copy-link and wake-then-connect paths already carried the pin. Both directions are tested, and the pinned one was checked against a reverted fix: it fails with exactly the reported symptom (left: None, right: "hdr").
This commit is contained in:
@@ -209,7 +209,7 @@ impl Screen {
|
||||
pub(crate) fn title(&self, _ctx: &Ctx) -> String {
|
||||
match self {
|
||||
Screen::Home(_) => "Select a Host".into(),
|
||||
Screen::Library(s) => s.host_name().to_string(),
|
||||
Screen::Library(s) => s.title(),
|
||||
Screen::Settings(_) => "Settings".into(),
|
||||
Screen::AddHost(s) => s.title(),
|
||||
Screen::Pair(s) => format!("Pair with {}", s.host_name()),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::library::{
|
||||
StepResult, BUMP_C, BUMP_K, BUMP_PX, FOCUS_GAP, JUMP, PERSPECTIVE, POSTER_H, POSTER_W,
|
||||
RECEDE_DIM, RECEDE_SCALE, ROTATE_DEG, SIDE_SPACING, SPRING_C, SPRING_K, VISIBLE_RANGE,
|
||||
};
|
||||
use crate::model::{ConsoleCmd, HostRow};
|
||||
use crate::model::{ConsoleCmd, HostRow, ProfileChip};
|
||||
use crate::pointer::{Pointer, PointerKind};
|
||||
use crate::screens::{ConnectIntent, Ctx, Outbox};
|
||||
use crate::theme::{accent, fg, Fonts, W};
|
||||
@@ -24,6 +24,11 @@ pub(crate) struct LibraryScreen {
|
||||
port: u16,
|
||||
fp_hex: String,
|
||||
mgmt: u16,
|
||||
/// `Some` when this library was opened from a PINNED host+profile card (§5.2a) rather
|
||||
/// than the host's primary tile: every launch off this shelf is that card's connect
|
||||
/// with a title attached, so it carries the same one-off profile the card's plain
|
||||
/// A-press would. `None` = the primary tile, where the host's binding decides.
|
||||
pin: Option<ProfileChip>,
|
||||
shared: Option<LibraryShared>,
|
||||
// Synced snapshot of the shared model (re-pulled when the generation bumps).
|
||||
generation: u64,
|
||||
@@ -48,6 +53,7 @@ impl LibraryScreen {
|
||||
port: host.port,
|
||||
fp_hex: host.fp_hex.clone(),
|
||||
mgmt: host.mgmt_port,
|
||||
pin: host.pin.clone(),
|
||||
shared: None, // adopted from Ctx on the first render (the shell owns it)
|
||||
generation: u64::MAX,
|
||||
phase: LibraryPhase::Loading,
|
||||
@@ -60,8 +66,13 @@ impl LibraryScreen {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn host_name(&self) -> &str {
|
||||
&self.host_name
|
||||
/// The screen's title: the host, and — when this shelf belongs to a pinned card — the
|
||||
/// profile every launch off it will use, in the card's own `host · profile` shape.
|
||||
pub(crate) fn title(&self) -> String {
|
||||
match &self.pin {
|
||||
Some(p) => format!("{} \u{b7} {}", self.host_name, p.name),
|
||||
None => self.host_name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_cmd(&self) -> ConsoleCmd {
|
||||
@@ -123,10 +134,18 @@ impl LibraryScreen {
|
||||
port: self.port,
|
||||
fp_hex: self.fp_hex.clone(),
|
||||
launch: Some(g.id.clone()),
|
||||
title: g.title.clone(),
|
||||
// A pinned card's shelf says which profile it is launching with,
|
||||
// the same way its tile and this screen's title do.
|
||||
title: match &self.pin {
|
||||
Some(p) => format!("{} \u{b7} {}", g.title, p.name),
|
||||
None => g.title.clone(),
|
||||
},
|
||||
request_access: false,
|
||||
// Game launches follow the host's default binding.
|
||||
profile: None,
|
||||
// A game launch off a PINNED card's shelf is that card's connect
|
||||
// with a title attached — it carries the card's profile as the
|
||||
// one-off. Off the primary tile there is none, and the host's
|
||||
// default binding decides.
|
||||
profile: self.pin.as_ref().map(|p| p.id.clone()),
|
||||
});
|
||||
Some(MenuPulse::Confirm)
|
||||
}
|
||||
|
||||
@@ -180,6 +180,87 @@ fn finish_motion(s: &mut Shell) {
|
||||
s.motion = Motion::None;
|
||||
}
|
||||
|
||||
/// A pinned host+profile card's library launches with THAT profile (design §5.2a).
|
||||
///
|
||||
/// The card's plain A-press always carried its profile; Y — which the card offers, being
|
||||
/// paired and saved — opened a library screen that knew only the host, so every title
|
||||
/// launched off it silently fell back to the host's default binding. The profile a user
|
||||
/// pinned is the whole reason they pressed that card.
|
||||
#[test]
|
||||
fn a_pinned_cards_library_launches_with_its_profile() {
|
||||
let mut rows = hosts();
|
||||
let card = HostRow {
|
||||
key: "aa11\u{0}hdr".into(),
|
||||
pin: Some(crate::model::ProfileChip {
|
||||
id: "hdr".into(),
|
||||
name: "HDR".into(),
|
||||
accent: None,
|
||||
}),
|
||||
..rows[0].clone()
|
||||
};
|
||||
rows.insert(1, card);
|
||||
let (mut s, console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
console.set_hosts(rows);
|
||||
s.sync();
|
||||
|
||||
// Focus the pinned card (it sits right after its host's primary tile), then Y.
|
||||
s.handle_menu(MenuEvent::Move(MenuDir::Right));
|
||||
s.handle_menu(MenuEvent::Secondary);
|
||||
finish_motion(&mut s);
|
||||
match s.stack.last() {
|
||||
Some(Screen::Library(l)) => assert_eq!(
|
||||
l.title(),
|
||||
"Living Room PC \u{b7} HDR",
|
||||
"the shelf names the profile it will launch with"
|
||||
),
|
||||
_ => panic!("Y on a pinned card opens its library"),
|
||||
}
|
||||
|
||||
library.set_games(vec![crate::library::LibraryGame {
|
||||
id: "steam:570".into(),
|
||||
title: "Dota 2".into(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
}]);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
match s.take_action() {
|
||||
Some(OverlayAction::Launch {
|
||||
launch, profile, ..
|
||||
}) => {
|
||||
assert_eq!(launch.as_deref(), Some("steam:570"));
|
||||
assert_eq!(
|
||||
profile.as_deref(),
|
||||
Some("hdr"),
|
||||
"the launch carries the pinned card's profile"
|
||||
);
|
||||
}
|
||||
_ => panic!("A on a title raises a launch"),
|
||||
}
|
||||
}
|
||||
|
||||
/// …and off the host's PRIMARY tile there is no one-off: the host's binding decides,
|
||||
/// which is what the resolver sees as `None`.
|
||||
#[test]
|
||||
fn a_primary_tiles_library_leaves_the_profile_to_the_binding() {
|
||||
let (mut s, _console, library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
s.sync();
|
||||
s.handle_menu(MenuEvent::Secondary); // paired+online host focused first
|
||||
finish_motion(&mut s);
|
||||
library.set_games(vec![crate::library::LibraryGame {
|
||||
id: "steam:570".into(),
|
||||
title: "Dota 2".into(),
|
||||
store: "steam".into(),
|
||||
launcher: false,
|
||||
icon: String::new(),
|
||||
}]);
|
||||
s.handle_menu(MenuEvent::Confirm);
|
||||
assert!(matches!(
|
||||
s.take_action(),
|
||||
Some(OverlayAction::Launch { profile: None, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_gates_input_in_the_same_press() {
|
||||
let (mut s, _console, _library) = shell(vec![Screen::Home(HomeScreen::new())]);
|
||||
|
||||
Reference in New Issue
Block a user