feat(client/linux): pin a host+profile as its own card
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Canceled after 4s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust (push) Canceled after 8s
ci / bench (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 5s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m57s
flatpak / build-publish (push) Canceled after 1m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m11s
android / android (push) Canceled after 1s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Canceled after 4s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / rust (push) Canceled after 8s
ci / bench (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 5s
deb / build-publish (push) Canceled after 3s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
decky / build-publish (push) Canceled after 8s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 20s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 25s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 31s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m7s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 1m57s
flatpak / build-publish (push) Canceled after 1m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m11s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 4m11s
§5.2a: a profile you reach for regularly shouldn't live behind a context menu. Pinning one puts *Desktop · Work* in the grid next to *Desktop*, and clicking it is a plain one-click connect with that profile. The pin is presentation state on the host record — `pinned_profiles`, which has been in the store since P0 and unused until now. Deliberately NOT a duplicated host entry: that would fork pairing, WoL MACs and renames, and every host-level action would then need a "which card owns this" rule. So a pinned card is the same record rendered again with a profile attached: it shares the host's live status because it reads the same record, and a pin whose profile was deleted simply doesn't render. What each card offers follows from what it is. The primary card keeps the host actions and gains "Pin as card ▸" (listing only profiles not already pinned; "Default settings" isn't pinnable — the primary card is that). A pinned card offers the one-offs and its own removal, and deliberately not pair/rename/forget — it is a shortcut, not a second host, and offering destructive host actions there would blur the distinction. One action serves both directions: which kind of card it sits on decides whether it pins or unpins. Verified on .21: a host bound to "Game" renders its primary card with a neutral Game chip and, right after it, the pinned "Work" card with an accent chip, both showing the same online/paired state. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+170
-38
@@ -70,6 +70,11 @@ enum CardKind {
|
|||||||
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
/// The profile catalog as `(id, name)`, for this card's menus and chip. Shared per
|
||||||
/// refresh rather than re-read per card.
|
/// refresh rather than re-read per card.
|
||||||
profiles: Rc<Vec<(String, String)>>,
|
profiles: Rc<Vec<(String, String)>>,
|
||||||
|
/// `Some((id, name))` when this card is a PINNED host+profile pair rather than the
|
||||||
|
/// host's primary card (design §5.2a): a one-click shortcut for a profile the user
|
||||||
|
/// reaches for often. It is presentation state on the host record — never a second
|
||||||
|
/// host entry, which would fork pairing, WoL and renames.
|
||||||
|
pinned: Option<(String, String)>,
|
||||||
},
|
},
|
||||||
Discovered(DiscoveredHost),
|
Discovered(DiscoveredHost),
|
||||||
}
|
}
|
||||||
@@ -101,12 +106,23 @@ pub enum CardOutput {
|
|||||||
mac: Vec<String>,
|
mac: Vec<String>,
|
||||||
addr: String,
|
addr: String,
|
||||||
},
|
},
|
||||||
|
/// Add or remove a pinned host+profile card (design §5.2a). Presentation only — it never
|
||||||
|
/// changes the host's default profile, and unpinning never touches the profile itself.
|
||||||
|
TogglePin {
|
||||||
|
fp_hex: String,
|
||||||
|
addr: String,
|
||||||
|
port: u16,
|
||||||
|
profile_id: String,
|
||||||
|
pin: bool,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HostCard {
|
impl HostCard {
|
||||||
fn request(&self) -> ConnectRequest {
|
fn request(&self) -> ConnectRequest {
|
||||||
match &self.kind {
|
match &self.kind {
|
||||||
CardKind::Saved { host: k, .. } => ConnectRequest {
|
CardKind::Saved {
|
||||||
|
host: k, pinned, ..
|
||||||
|
} => ConnectRequest {
|
||||||
name: k.name.clone(),
|
name: k.name.clone(),
|
||||||
addr: k.addr.clone(),
|
addr: k.addr.clone(),
|
||||||
port: k.port,
|
port: k.port,
|
||||||
@@ -115,7 +131,9 @@ impl HostCard {
|
|||||||
pair_optional: false,
|
pair_optional: false,
|
||||||
launch: None,
|
launch: None,
|
||||||
mac: k.mac.clone(),
|
mac: k.mac.clone(),
|
||||||
profile: None,
|
// A pinned card IS its profile: clicking it connects with that one, without
|
||||||
|
// touching the host's default.
|
||||||
|
profile: pinned.as_ref().map(|(id, _)| id.clone()),
|
||||||
},
|
},
|
||||||
CardKind::Discovered(a) => ConnectRequest {
|
CardKind::Discovered(a) => ConnectRequest {
|
||||||
name: a.name.clone(),
|
name: a.name.clone(),
|
||||||
@@ -201,6 +219,7 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
host: k,
|
host: k,
|
||||||
online,
|
online,
|
||||||
profiles,
|
profiles,
|
||||||
|
pinned,
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
// Presence pip + spelled-out state, then the trust pill.
|
// Presence pip + spelled-out state, then the trust pill.
|
||||||
@@ -220,16 +239,25 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
} else {
|
} else {
|
||||||
pill("Trusted", "pf-accent")
|
pill("Trusted", "pf-accent")
|
||||||
});
|
});
|
||||||
// The chip says what a plain click will do — the profile this host is bound
|
// The chip says what a plain click on THIS card will do: its own profile on a
|
||||||
// to. A binding whose profile was deleted shows nothing and resolves as the
|
// pinned card, the host's binding on the primary one. A binding whose profile
|
||||||
// defaults, which is exactly what will happen on connect (design §6).
|
// was deleted shows nothing and resolves as the defaults, which is exactly
|
||||||
if let Some(name) = k
|
// what will happen on connect (design §6).
|
||||||
.profile_id
|
let chip = pinned.as_ref().map(|(_, name)| name.as_str()).or_else(|| {
|
||||||
.as_ref()
|
k.profile_id
|
||||||
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
.as_ref()
|
||||||
.map(|(_, name)| name.as_str())
|
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
|
||||||
{
|
.map(|(_, name)| name.as_str())
|
||||||
status.append(&pill(name, "pf-neutral"));
|
});
|
||||||
|
if let Some(name) = chip {
|
||||||
|
status.append(&pill(
|
||||||
|
name,
|
||||||
|
if pinned.is_some() {
|
||||||
|
"pf-accent"
|
||||||
|
} else {
|
||||||
|
"pf-neutral"
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CardKind::Discovered(_) => {
|
CardKind::Discovered(_) => {
|
||||||
@@ -256,6 +284,7 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
recent,
|
recent,
|
||||||
library_enabled,
|
library_enabled,
|
||||||
profiles,
|
profiles,
|
||||||
|
pinned,
|
||||||
} => {
|
} => {
|
||||||
if *recent {
|
if *recent {
|
||||||
overlay.add_css_class("pf-recent");
|
overlay.add_css_class("pf-recent");
|
||||||
@@ -356,44 +385,103 @@ impl relm4::factory::FactoryComponent for HostCard {
|
|||||||
profile_id: id,
|
profile_id: id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// The same action pins from a primary card and unpins from a pinned one —
|
||||||
|
// which of the two this card is decides the direction.
|
||||||
|
let (fp, addr, port) = (k.fp_hex.clone(), k.addr.clone(), k.port);
|
||||||
|
let pinning = pinned.is_none();
|
||||||
|
profile_action(
|
||||||
|
"toggle-pin",
|
||||||
|
Box::new(move |id| CardOutput::TogglePin {
|
||||||
|
fp_hex: fp.clone(),
|
||||||
|
addr: addr.clone(),
|
||||||
|
port,
|
||||||
|
profile_id: id.unwrap_or_default(),
|
||||||
|
pin: pinning,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
overlay.insert_action_group("card", Some(&actions));
|
overlay.insert_action_group("card", Some(&actions));
|
||||||
|
|
||||||
let menu = gio::Menu::new();
|
let menu = gio::Menu::new();
|
||||||
if !profiles.is_empty() {
|
if let Some((pin_id, pin_name)) = pinned {
|
||||||
|
// A pinned card is a shortcut, not a second host: it offers the one-offs
|
||||||
|
// and its own removal, and deliberately NOT pair/rename/forget — those
|
||||||
|
// belong to the host, and offering them here would blur what the card is.
|
||||||
let with = gio::Menu::new();
|
let with = gio::Menu::new();
|
||||||
let bind = gio::Menu::new();
|
for (label, target) in std::iter::once(("Default settings", "")).chain(
|
||||||
for (label, id) in std::iter::once(("Default settings", "")).chain(
|
|
||||||
profiles
|
profiles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(id, name)| (name.as_str(), id.as_str())),
|
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||||
) {
|
) {
|
||||||
// A checkmark would be the natural cue for the current binding, but a
|
let item = gio::MenuItem::new(Some(label), None);
|
||||||
// GMenu radio needs shared state per card; the bound profile is named
|
item.set_action_and_target_value(
|
||||||
// on the card's chip instead, which is visible without opening a menu.
|
Some("card.connect-with"),
|
||||||
for (menu, action) in
|
Some(&target.to_variant()),
|
||||||
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
|
);
|
||||||
{
|
with.append_item(&item);
|
||||||
let item = gio::MenuItem::new(Some(label), None);
|
|
||||||
item.set_action_and_target_value(Some(action), Some(&id.to_variant()));
|
|
||||||
menu.append_item(&item);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
menu.append_submenu(Some("Connect with"), &with);
|
menu.append_submenu(Some("Connect with"), &with);
|
||||||
menu.append_submenu(Some("Default profile"), &bind);
|
let unpin = gio::MenuItem::new(
|
||||||
|
Some(&format!("Unpin \u{201c}{pin_name}\u{201d}")),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
unpin.set_action_and_target_value(
|
||||||
|
Some("card.toggle-pin"),
|
||||||
|
Some(&pin_id.as_str().to_variant()),
|
||||||
|
);
|
||||||
|
menu.append_item(&unpin);
|
||||||
|
} else {
|
||||||
|
if !profiles.is_empty() {
|
||||||
|
let with = gio::Menu::new();
|
||||||
|
let bind = gio::Menu::new();
|
||||||
|
let pin = gio::Menu::new();
|
||||||
|
for (label, id) in std::iter::once(("Default settings", "")).chain(
|
||||||
|
profiles
|
||||||
|
.iter()
|
||||||
|
.map(|(id, name)| (name.as_str(), id.as_str())),
|
||||||
|
) {
|
||||||
|
// A checkmark would be the natural cue for the current binding, but
|
||||||
|
// a GMenu radio needs shared state per card; the bound profile is
|
||||||
|
// named on the card's chip instead, visible without opening a menu.
|
||||||
|
for (menu, action) in
|
||||||
|
[(&with, "card.connect-with"), (&bind, "card.bind-profile")]
|
||||||
|
{
|
||||||
|
let item = gio::MenuItem::new(Some(label), None);
|
||||||
|
item.set_action_and_target_value(
|
||||||
|
Some(action),
|
||||||
|
Some(&id.to_variant()),
|
||||||
|
);
|
||||||
|
menu.append_item(&item);
|
||||||
|
}
|
||||||
|
// "Default settings" is not pinnable — the primary card is that.
|
||||||
|
if !id.is_empty() && !k.pinned_profiles.iter().any(|p| p == id) {
|
||||||
|
let item = gio::MenuItem::new(Some(label), None);
|
||||||
|
item.set_action_and_target_value(
|
||||||
|
Some("card.toggle-pin"),
|
||||||
|
Some(&id.to_variant()),
|
||||||
|
);
|
||||||
|
pin.append_item(&item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.append_submenu(Some("Connect with"), &with);
|
||||||
|
menu.append_submenu(Some("Default profile"), &bind);
|
||||||
|
if pin.n_items() > 0 {
|
||||||
|
menu.append_submenu(Some("Pin as card"), &pin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
menu.append(Some("Pair with PIN\u{2026}"), Some("card.pair"));
|
||||||
|
menu.append(Some("Test network speed\u{2026}"), Some("card.speed"));
|
||||||
|
// An explicit wake only when offline and a MAC is known.
|
||||||
|
if !online && !k.mac.is_empty() {
|
||||||
|
menu.append(Some("Wake host"), Some("card.wake"));
|
||||||
|
}
|
||||||
|
// Experimental (Preferences gate): browse the host's game library.
|
||||||
|
if *library_enabled {
|
||||||
|
menu.append(Some("Browse library\u{2026}"), Some("card.library"));
|
||||||
|
}
|
||||||
|
menu.append(Some("Rename\u{2026}"), Some("card.rename"));
|
||||||
|
menu.append(Some("Forget"), Some("card.forget"));
|
||||||
}
|
}
|
||||||
menu.append(Some("Pair with PIN…"), Some("card.pair"));
|
|
||||||
menu.append(Some("Test network speed…"), Some("card.speed"));
|
|
||||||
// An explicit wake only when offline and a MAC is known.
|
|
||||||
if !online && !k.mac.is_empty() {
|
|
||||||
menu.append(Some("Wake host"), Some("card.wake"));
|
|
||||||
}
|
|
||||||
// Experimental (Preferences gate): browse the host's game library.
|
|
||||||
if *library_enabled {
|
|
||||||
menu.append(Some("Browse library…"), Some("card.library"));
|
|
||||||
}
|
|
||||||
menu.append(Some("Rename…"), Some("card.rename"));
|
|
||||||
menu.append(Some("Forget"), Some("card.forget"));
|
|
||||||
let menu_btn = gtk::MenuButton::builder()
|
let menu_btn = gtk::MenuButton::builder()
|
||||||
.icon_name("view-more-symbolic")
|
.icon_name("view-more-symbolic")
|
||||||
.menu_model(&menu)
|
.menu_model(&menu)
|
||||||
@@ -795,6 +883,28 @@ impl SimpleComponent for HostsPage {
|
|||||||
}
|
}
|
||||||
self.rebuild(); // the chip follows immediately
|
self.rebuild(); // the chip follows immediately
|
||||||
}
|
}
|
||||||
|
CardOutput::TogglePin {
|
||||||
|
fp_hex,
|
||||||
|
addr,
|
||||||
|
port,
|
||||||
|
profile_id,
|
||||||
|
pin,
|
||||||
|
} => {
|
||||||
|
let mut known = KnownHosts::load();
|
||||||
|
if let Some(h) = known.hosts.iter_mut().find(|h| {
|
||||||
|
(!fp_hex.is_empty() && h.fp_hex == fp_hex)
|
||||||
|
|| (h.addr == addr && h.port == port)
|
||||||
|
}) {
|
||||||
|
h.pinned_profiles.retain(|p| p != &profile_id);
|
||||||
|
if pin {
|
||||||
|
h.pinned_profiles.push(profile_id);
|
||||||
|
}
|
||||||
|
if let Err(e) = known.save() {
|
||||||
|
tracing::warn!(error = %format!("{e:#}"), "saving the pinned cards");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.rebuild();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -850,8 +960,30 @@ impl HostsPage {
|
|||||||
profiles: profiles.clone(),
|
profiles: profiles.clone(),
|
||||||
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
|
recent: most_recent.as_deref() == Some(k.fp_hex.as_str()),
|
||||||
library_enabled,
|
library_enabled,
|
||||||
|
pinned: None,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// …then its pinned host+profile cards, in the order the user pinned them.
|
||||||
|
// They share the host's live status because they read the same record, and a
|
||||||
|
// pin whose profile is gone simply doesn't render (design §5.2a).
|
||||||
|
for id in &k.pinned_profiles {
|
||||||
|
let Some((id, name)) = profiles.iter().find(|(pid, _)| pid == id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
saved.push_back(HostCard {
|
||||||
|
// The spinner belongs to whichever card was clicked; a pin has its own
|
||||||
|
// key so two cards for one host don't both spin.
|
||||||
|
connecting: false,
|
||||||
|
kind: CardKind::Saved {
|
||||||
|
host: k.clone(),
|
||||||
|
online,
|
||||||
|
profiles: profiles.clone(),
|
||||||
|
recent: false,
|
||||||
|
library_enabled,
|
||||||
|
pinned: Some((id.clone(), name.clone())),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user