From b9a71635563814a0a2567bd1f2401f40d358682f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 28 Jul 2026 23:53:20 +0200 Subject: [PATCH] feat(client/linux): the speed test writes where the tested host reads, and cards can copy their link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two WP3 pieces. **Speed test (§5.3).** Measuring the slow retro box downstairs used to re-tune the desktop upstairs: Apply always wrote the GLOBAL bitrate, which the design calls the sharpest existing symptom of the missing feature. It now writes the layer that host actually resolves bitrate from — the bound profile's override if it has one, the global if the host is unbound. A bound host whose profile inherits bitrate could legitimately mean either, so it gets both buttons instead of a guess. The target depends only on the host, so it is resolved before the test finishes and the button says where it will write ("Set in “Game”"). A one-off (a pinned card's profile) wins over the binding, and a dangling binding falls back to the global — the same precedence a connect uses. **Copy link.** Host and pinned cards can copy their own `punktfunk://` URL — the self-emitted form carrying the stable id *and* host+fp, so a link pasted into a Playnite entry or a Stream Deck macro still resolves after the host re-addresses or the store is wiped. A pinned card copies its profile with it, which is the whole point of pinning it. Co-Authored-By: Claude Opus 5 (1M context) --- clients/linux/src/app.rs | 108 ++++++++++++++++++++++++++++++++-- clients/linux/src/ui_hosts.rs | 31 ++++++++++ 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/clients/linux/src/app.rs b/clients/linux/src/app.rs index 3a010c3a..9d9ffb1a 100644 --- a/clients/linux/src/app.rs +++ b/clients/linux/src/app.rs @@ -230,6 +230,7 @@ impl SimpleComponent for AppModel { HostsOutput::Pair(req) => AppMsg::Pair(req), HostsOutput::SpeedTest(req) => AppMsg::SpeedTest(req), HostsOutput::Library(req, mgmt) => AppMsg::OpenLibrary(req, mgmt), + HostsOutput::Toast(msg) => AppMsg::Toast(msg), }); let nav = adw::NavigationView::new(); @@ -629,7 +630,33 @@ impl AppModel { let status = gtk::Label::new(Some("Connecting…")); let dialog = adw::AlertDialog::new(Some("Network Speed Test"), Some(&req.name)); dialog.set_extra_child(Some(&status)); - dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); + // Where a measured bitrate belongs is "the layer this host actually resolves bitrate + // from" (design/client-settings-profiles.md §5.3) — the long-standing wrong answer was + // always the global, so measuring the slow retro box downstairs re-tuned the desktop + // too. The target depends only on the host, so it is known before the result lands and + // the button can say where it will write. + let target = SpeedTestTarget::resolve(&req); + match &target { + SpeedTestTarget::Global => { + dialog.add_responses(&[("close", "Close"), ("apply", "Apply")]); + } + SpeedTestTarget::Profile(p) => { + dialog.add_responses(&[ + ("close", "Close"), + ("apply", &format!("Apply to “{}”", p.name)), + ]); + } + // A bound host whose profile doesn't override bitrate could legitimately mean + // either: the user gets both, rather than us guessing which layer they meant. + SpeedTestTarget::Ask(p) => { + dialog.add_responses(&[ + ("close", "Close"), + ("apply-global", "Set as default"), + ("apply", &format!("Set in “{}”", p.name)), + ]); + dialog.set_response_enabled("apply-global", false); + } + } dialog.set_response_enabled("apply", false); dialog.set_close_response("close"); dialog.present(Some(&self.window)); @@ -699,13 +726,36 @@ impl AppModel { )); dialog.set_response_enabled("apply", true); dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); - dialog.connect_response(Some("apply"), move |_, _| { + if matches!(target, SpeedTestTarget::Ask(_)) { + dialog.set_response_enabled("apply-global", true); + } + let mbit = f64::from(recommended_kbps) / 1000.0; + { + let (settings, toasts) = (settings.clone(), toasts.clone()); + dialog.connect_response(Some("apply"), move |_, _| { + let where_to = match &target { + SpeedTestTarget::Global => { + let mut s = settings.borrow_mut(); + s.bitrate_kbps = recommended_kbps; + s.save(); + "the default bitrate".to_string() + } + SpeedTestTarget::Profile(p) | SpeedTestTarget::Ask(p) => { + write_profile_bitrate(&p.id, recommended_kbps); + format!("“{}”", p.name) + } + }; + toasts.add_toast(adw::Toast::new(&format!( + "{mbit:.0} Mbit/s set in {where_to}" + ))); + }); + } + dialog.connect_response(Some("apply-global"), move |_, _| { let mut s = settings.borrow_mut(); s.bitrate_kbps = recommended_kbps; s.save(); toasts.add_toast(adw::Toast::new(&format!( - "Bitrate set to {:.0} Mbit/s", - f64::from(recommended_kbps) / 1000.0 + "{mbit:.0} Mbit/s set in the default bitrate" ))); }); } @@ -716,6 +766,56 @@ impl AppModel { } } +/// Which layer a measured bitrate should land in for the host that was tested +/// (design/client-settings-profiles.md §5.3). +enum SpeedTestTarget { + /// No profile bound — the global default, i.e. what has always happened. + Global, + /// The bound profile already overrides bitrate, so that override is what this host reads. + Profile(pf_client_core::profiles::StreamProfile), + /// Bound, but the profile inherits bitrate: writing either layer is defensible, so ask. + Ask(pf_client_core::profiles::StreamProfile), +} + +impl SpeedTestTarget { + fn resolve(req: &crate::ui_hosts::ConnectRequest) -> SpeedTestTarget { + // Resolved exactly the way a connect resolves it: the one-off pick this test was + // started with (a pinned card carries one), else the host's binding. + let bound = trust::KnownHosts::load() + .hosts + .iter() + .find(|h| h.addr == req.addr && h.port == req.port) + .and_then(|h| h.profile_id.clone()); + let reference = match req.profile.as_deref() { + Some("") => return SpeedTestTarget::Global, + Some(id) => Some(id.to_string()), + None => bound, + }; + let Some(reference) = reference else { + return SpeedTestTarget::Global; + }; + let catalog = pf_client_core::profiles::ProfilesFile::load(); + match catalog.resolve(&reference).0 { + Some(p) if p.overrides.bitrate_kbps.is_some() => SpeedTestTarget::Profile(p.clone()), + Some(p) => SpeedTestTarget::Ask(p.clone()), + // A dangling binding resolves as no profile everywhere else; here too. + None => SpeedTestTarget::Global, + } + } +} + +/// Write a measured bitrate into one profile's overlay, leaving everything else alone. +fn write_profile_bitrate(id: &str, kbps: u32) { + let mut catalog = pf_client_core::profiles::ProfilesFile::load(); + let Some(p) = catalog.profiles.iter_mut().find(|p| p.id == id) else { + return; // deleted while the test ran — the toast still tells the truth about the test + }; + p.overrides.bitrate_kbps = Some(kbps); + if let Err(e) = catalog.save() { + tracing::warn!(error = %format!("{e:#}"), "saving the measured bitrate"); + } +} + thread_local! { /// Where a delivered URL goes once the window exists. Both ends of this live on the GTK /// main thread: `connect_open` fires there, and so does the model's `init`. diff --git a/clients/linux/src/ui_hosts.rs b/clients/linux/src/ui_hosts.rs index 73dcfb58..083fd3d1 100644 --- a/clients/linux/src/ui_hosts.rs +++ b/clients/linux/src/ui_hosts.rs @@ -106,6 +106,8 @@ pub enum CardOutput { mac: Vec, addr: String, }, + /// Put this card's `punktfunk://` URL on the clipboard. + CopyLink(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 { @@ -385,6 +387,25 @@ impl relm4::factory::FactoryComponent for HostCard { profile_id: id, }), ); + // "Copy link": the self-emitted URL for this card, which is the pairing + // an external tool (a Playnite entry, a Stream Deck macro) is configured + // with. It carries the stable id AND host+fp, so it still resolves after a + // re-address or a reinstall (design/client-deep-links.md §2/§5). + { + let (host, profile) = (k.clone(), pinned.clone()); + let a = gio::SimpleAction::new("copy-link", None); + let sender = sender.clone(); + a.connect_activate(move |_, _| { + let url = pf_client_core::deeplink::DeepLink::for_host( + &host, + None, + profile.as_ref().map(|(id, _)| id.as_str()), + ) + .to_url(); + let _ = sender.output(CardOutput::CopyLink(url)); + }); + actions.add_action(&a); + } // 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); @@ -430,6 +451,7 @@ impl relm4::factory::FactoryComponent for HostCard { Some(&pin_id.as_str().to_variant()), ); menu.append_item(&unpin); + menu.append(Some("Copy link"), Some("card.copy-link")); } else { if !profiles.is_empty() { let with = gio::Menu::new(); @@ -479,6 +501,7 @@ impl relm4::factory::FactoryComponent for HostCard { if *library_enabled { menu.append(Some("Browse library\u{2026}"), Some("card.library")); } + menu.append(Some("Copy link"), Some("card.copy-link")); menu.append(Some("Rename\u{2026}"), Some("card.rename")); menu.append(Some("Forget"), Some("card.forget")); } @@ -583,6 +606,8 @@ pub enum HostsMsg { #[derive(Debug)] pub enum HostsOutput { Connect(ConnectRequest), + /// A one-line confirmation for the window's toast overlay. + Toast(String), WakeConnect(ConnectRequest), Pair(ConnectRequest), SpeedTest(ConnectRequest), @@ -883,6 +908,12 @@ impl SimpleComponent for HostsPage { } self.rebuild(); // the chip follows immediately } + CardOutput::CopyLink(url) => { + if let Some(display) = gtk::gdk::Display::default() { + display.clipboard().set_text(&url); + } + let _ = sender.output(HostsOutput::Toast("Link copied".into())); + } CardOutput::TogglePin { fp_hex, addr,