feat(client/windows): bind a host to a profile, connect with one, pin one, copy its link
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m25s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m43s
apple / swift (push) Successful in 5m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
decky / build-publish (push) Canceled after 0s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
android / android (push) Canceled after 2m34s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bench (push) Canceled after 0s
ci / rust (push) Canceled after 2m30s
arch / build-publish (push) Canceled after 2m39s

The WinUI half of §5.2: `Target` carries a one-off profile into the session's `--profile`,
the host editor gains the binding picker, and the tile names its bound profile on a chip so
what a plain click will do is visible without opening anything.

Shape notes, both forced by this toolkit rather than chosen:

- The flyout has no submenus, so "Connect with", "Pin as card" and "Unpin card" are flat
  items, one per profile, matched by prefix. The prefixes end in ": " so a profile named
  "Copy link" can't collide with a fixed entry.
- The binding picker commits on change rather than at Save. The rest of that sheet is text
  boxes with draft refs, which need a Save; a ComboBox doesn't, and making it wait would be
  the odd one out.

Semantics stay identical to the Linux client, because they are the design's, not the
platform's: "Connect with" never rebinds, "Default settings" is `Some("")` so it really
does override a bound host for one session, and a binding whose profile was deleted reads
as Default settings and resolves to it.

`pf_client_core::clipboard::set_text` is the small addition the crate needed for "Copy
link" — the OS clipboard plumbing was already there, just private to the streaming bridge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 00:17:15 +02:00
co-authored by Claude Opus 5
parent b220daf537
commit 2a088e13a4
5 changed files with 179 additions and 1 deletions
+2
View File
@@ -239,6 +239,7 @@ fn connect_spawn(
let target = target.clone(); let target = target.clone();
// The closure owns `target`/`fp_hex`; the call itself borrows copies. // The closure owns `target`/`fp_hex`; the call itself borrows copies.
let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone()); let (addr, port, fp_arg) = (target.addr.clone(), target.port, fp_hex.clone());
let profile_arg = target.profile.clone();
let spawned = crate::spawn::spawn_session( let spawned = crate::spawn::spawn_session(
&addr, &addr,
port, port,
@@ -246,6 +247,7 @@ fn connect_spawn(
opts.connect_timeout.as_secs(), opts.connect_timeout.as_secs(),
fullscreen, fullscreen,
opts.launch.as_deref(), opts.launch.as_deref(),
profile_arg.as_deref(),
child, child,
move |event| { move |event| {
use crate::spawn::SpawnEvent; use crate::spawn::SpawnEvent;
+156 -1
View File
@@ -20,6 +20,13 @@ const MENU_WAKE: &str = "Wake host";
/// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones /// Apple client's add/edit sheet. A menu item per field read as clutter and buried the ones
/// that matter. /// that matter.
const MENU_EDIT: &str = "Edit\u{2026}"; const MENU_EDIT: &str = "Edit\u{2026}";
/// Dynamic menu-item prefixes: this flyout has no submenus, so the profile entries are flat
/// items matched by prefix. The trailing space keeps them readable AND keeps a profile named
/// e.g. "Copy link" from colliding with a fixed entry.
const MENU_WITH: &str = "Connect with: ";
const MENU_PIN: &str = "Pin as card: ";
const MENU_UNPIN: &str = "Unpin card: ";
const MENU_COPY_LINK: &str = "Copy link";
const MENU_FORGET: &str = "Forget\u{2026}"; const MENU_FORGET: &str = "Forget\u{2026}";
/// Whether the console (gamepad) UI is available in this build: the session binary ships /// Whether the console (gamepad) UI is available in this build: the session binary ships
@@ -163,6 +170,19 @@ pub(crate) struct Hover {
/// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip. /// The status row at the bottom of a tile: presence dot + Online/Offline, plus the trust chip.
fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element { fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
status_row_with(online, badge, kind, None)
}
/// [`status_row`] plus the profile chip: what a plain click on THIS tile will use — its own
/// profile on a pinned tile, the host's binding on the primary one. A binding whose profile
/// was deleted shows nothing and resolves as the defaults, which is what will happen on
/// connect (design §6).
fn status_row_with(
online: Option<bool>,
badge: &str,
kind: Pill,
profile: Option<(&str, Pill)>,
) -> Element {
let mut items: Vec<Element> = Vec::new(); let mut items: Vec<Element> = Vec::new();
if let Some(online) = online { if let Some(online) = online {
items.push( items.push(
@@ -183,6 +203,13 @@ fn status_row(online: Option<bool>, badge: &str, kind: Pill) -> Element {
.vertical_alignment(VerticalAlignment::Center) .vertical_alignment(VerticalAlignment::Center)
.into(), .into(),
); );
if let Some((name, kind)) = profile {
items.push(
pill(name, kind)
.vertical_alignment(VerticalAlignment::Center)
.into(),
);
}
hstack(items) hstack(items)
.spacing(6.0) .spacing(6.0)
.margin(edges(0.0, 12.0, 0.0, 0.0)) .margin(edges(0.0, 12.0, 0.0, 0.0))
@@ -250,6 +277,43 @@ fn edit_editor(
se.call(None); se.call(None);
} }
}; };
// The profile binding: what a plain click on this tile will use. It commits on change
// rather than at Save — it is a picker with no draft ref, and the rest of the sheet's
// fields are text boxes that genuinely need one.
let profile_picker = {
let catalog = pf_client_core::profiles::ProfilesFile::load();
let stored = KnownHosts::load()
.hosts
.iter()
.find(|h| h.fp_hex == fp)
.and_then(|h| h.profile_id.clone());
let mut names = vec!["Default settings".to_string()];
let mut ids: Vec<String> = vec![String::new()];
for p in &catalog.profiles {
names.push(p.name.clone());
ids.push(p.id.clone());
}
// A binding whose profile is gone reads as Default settings — the same "dangling
// resolves as none" rule the connect path follows — and is cleaned up on the next pick.
let current = stored
.as_ref()
.and_then(|id| ids.iter().position(|i| i == id))
.unwrap_or(0);
let fp = fp.to_string();
ComboBox::new(names)
.header("Profile")
.selected_index(current as i32)
.on_selection_changed(move |i: i32| {
let Some(id) = ids.get(i.max(0) as usize) else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.profile_id = (!id.is_empty()).then(|| id.clone());
let _ = known.save();
}
})
};
let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| { let field = |label: &str, value: String, placeholder: &str, draft: HookRef<String>| {
vstack(( vstack((
text_block(label) text_block(label)
@@ -281,6 +345,18 @@ fn edit_editor(
"auto-filled when known", "auto-filled when known",
mac_draft, mac_draft,
), ),
vstack((
profile_picker,
text_block(
"The settings a plain click on this host uses. \u{201c}Connect with\u{201d} \
in the tile\u{2019}s menu overrides it for one session without changing it.",
)
.font_size(12.0)
.foreground(ThemeRef::SecondaryText)
.wrap()
.horizontal_alignment(HorizontalAlignment::Left),
))
.spacing(4.0),
vstack(( vstack((
ToggleSwitch::new(clip0) ToggleSwitch::new(clip0)
.header("Share clipboard with this host") .header("Share clipboard with this host")
@@ -481,6 +557,12 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if !known.hosts.is_empty() { if !known.hosts.is_empty() {
body.push(section("SAVED HOSTS")); body.push(section("SAVED HOSTS"));
let mut tiles: Vec<Element> = Vec::new(); let mut tiles: Vec<Element> = Vec::new();
// One catalog read per render, shared by every tile's menu and chip.
let profiles: Vec<(String, String)> = pf_client_core::profiles::ProfilesFile::load()
.profiles
.into_iter()
.map(|p| (p.id, p.name))
.collect();
for k in &known.hosts { for k in &known.hosts {
// Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly. // Rust 2021 (no let-chains): match the "this tile is being edited" case explicitly.
if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) { if matches!(&rename, Some((fp, _)) if fp == &k.fp_hex) {
@@ -504,6 +586,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: Some(k.fp_hex.clone()), fp_hex: Some(k.fp_hex.clone()),
pair_optional: false, pair_optional: false,
mac: k.mac.clone(), mac: k.mac.clone(),
profile: None,
}; };
// Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter // Online = advertising on mDNS OR proven reachable by the last probe sweep (the latter
// covers a routed/Tailscale host that never advertises — the display companion to // covers a routed/Tailscale host that never advertises — the display companion to
@@ -525,6 +608,8 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let (svc, target) = (props.svc.clone(), target.clone()); let (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone()); let (sf, sr) = (set_forget.clone(), set_rename.clone());
let (fp, name) = (k.fp_hex.clone(), k.name.clone()); let (fp, name) = (k.fp_hex.clone(), k.name.clone());
let menu_profiles = profiles.clone();
let (link_host, link_profile) = (k.clone(), None::<String>);
button("") button("")
.icon(Symbol::More) .icon(Symbol::More)
.subtle() .subtle()
@@ -532,6 +617,24 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
.automation_name("More options") .automation_name("More options")
.menu_flyout({ .menu_flyout({
let mut items = vec![menu_item(MENU_CONNECT)]; let mut items = vec![menu_item(MENU_CONNECT)];
// One-off connects, flat: this flyout has no submenus, so each
// profile is its own item. "Connect with" NEVER rebinds — the default
// is changed in the host editor (design §5.2).
for (_, name) in profiles.iter() {
items.push(menu_item(format!("{MENU_WITH}{name}")));
}
if !profiles.is_empty() {
items.push(menu_item(format!("{MENU_WITH}Default settings")));
for (id, name) in profiles.iter() {
let label = if k.pinned_profiles.iter().any(|p| p == id) {
format!("{MENU_UNPIN}{name}")
} else {
format!("{MENU_PIN}{name}")
};
items.push(menu_item(label));
}
}
items.push(menu_item(MENU_COPY_LINK));
// The library surfaces — mouse/KB page and the gamepad console UI — // The library surfaces — mouse/KB page and the gamepad console UI —
// for paired hosts only (the mgmt API needs the paired identity); // for paired hosts only (the mgmt API needs the paired identity);
// the page additionally sits behind the experimental toggle, the // the page additionally sits behind the experimental toggle, the
@@ -550,6 +653,52 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
items items
}) })
.on_item_clicked(move |item: String| match item.as_str() { .on_item_clicked(move |item: String| match item.as_str() {
// The profile items are dynamic, so they are matched by prefix before
// the fixed ones.
_ if item.starts_with(MENU_WITH) => {
let name = item.trim_start_matches(MENU_WITH);
let mut target = target.clone();
// `Some("")` — not `None` — so "Default settings" really does
// override a bound host for this one connect.
target.profile = Some(
menu_profiles
.iter()
.find(|(_, n)| n == name)
.map(|(id, _)| id.clone())
.unwrap_or_default(),
);
initiate(&svc.ctx, target, &svc.set_screen, &svc.set_status)
}
_ if item.starts_with(MENU_PIN) || item.starts_with(MENU_UNPIN) => {
let pin = item.starts_with(MENU_PIN);
let name = item
.trim_start_matches(MENU_PIN)
.trim_start_matches(MENU_UNPIN);
let Some((id, _)) =
menu_profiles.iter().find(|(_, n)| n == name).cloned()
else {
return;
};
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.pinned_profiles.retain(|p| p != &id);
if pin {
h.pinned_profiles.push(id);
}
let _ = known.save();
}
// The tile list re-reads the store on the next render; nudge it.
sr.call(None);
}
MENU_COPY_LINK => {
let url = pf_client_core::deeplink::DeepLink::for_host(
&link_host,
None,
link_profile.as_deref(),
)
.to_url();
pf_client_core::clipboard::set_text(&url);
}
MENU_CONNECT => { MENU_CONNECT => {
initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status) initiate(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
} }
@@ -580,10 +729,14 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
&hover, &hover,
&k.name, &k.name,
&format!("{}:{}", k.addr, k.port), &format!("{}:{}", k.addr, k.port),
status_row( status_row_with(
Some(online), Some(online),
if k.paired { "Paired" } else { "Trusted" }, if k.paired { "Paired" } else { "Trusted" },
if k.paired { Pill::Good } else { Pill::Info }, if k.paired { Pill::Good } else { Pill::Info },
k.profile_id
.as_ref()
.and_then(|id| profiles.iter().find(|(pid, _)| pid == id))
.map(|(_, name)| (name.as_str(), Pill::Neutral)),
), ),
Some(menu), Some(menu),
Some(Box::new(move || { Some(Box::new(move || {
@@ -634,6 +787,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()), fp_hex: (!h.fp_hex.is_empty()).then(|| h.fp_hex.clone()),
pair_optional: h.pair == "optional", pair_optional: h.pair == "optional",
mac: h.mac.clone(), mac: h.mac.clone(),
profile: None,
}; };
let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone()); let (ctx2, ss, st) = (ctx.clone(), set_screen.clone(), set_status.clone());
let (badge, kind) = if h.pair == "required" { let (badge, kind) = if h.pair == "required" {
@@ -716,6 +870,7 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
fp_hex: None, fp_hex: None,
pair_optional: false, pair_optional: false,
mac: Vec::new(), mac: Vec::new(),
profile: None,
}, },
&ss, &ss,
&st, &st,
+5
View File
@@ -80,6 +80,11 @@ pub(crate) struct Target {
/// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a /// Wake-on-LAN MAC(s) for this host (from the saved store or the live advert) — used to send a
/// magic packet before connecting to an offline host. Empty when none is known. /// magic packet before connecting to an offline host. Empty when none is known.
pub(crate) mac: Vec<String>, pub(crate) mac: Vec<String>,
/// A ONE-OFF settings profile for this connect ("Connect with"): `Some(id)` overrides the
/// host's binding for this launch, `Some("")` forces the global defaults on a bound host,
/// `None` honors the binding. It never rebinds anything — the default changes only through
/// the picker in the host editor (design/client-settings-profiles.md §5.2).
pub(crate) profile: Option<String>,
} }
/// Stable app services handed to the page components as props. Each routed screen that uses /// Stable app services handed to the page components as props. Each routed screen that uses
+6
View File
@@ -101,6 +101,7 @@ pub(crate) fn spawn_session(
connect_timeout_secs: u64, connect_timeout_secs: u64,
fullscreen: bool, fullscreen: bool,
launch: Option<&str>, launch: Option<&str>,
profile: Option<&str>,
slot: SessionChild, slot: SessionChild,
on_event: impl FnMut(SpawnEvent) + Send + 'static, on_event: impl FnMut(SpawnEvent) + Send + 'static,
) -> Result<(), String> { ) -> Result<(), String> {
@@ -117,6 +118,11 @@ pub(crate) fn spawn_session(
if let Some(id) = launch { if let Some(id) = launch {
cmd.arg("--launch").arg(id); cmd.arg("--launch").arg(id);
} }
// Only a ONE-OFF pick rides the flag: without it the session resolves the host's own
// binding through the same helper this shell would have used, so the two can't disagree.
if let Some(reference) = profile {
cmd.arg("--profile").arg(reference);
}
add_window_pos(&mut cmd); add_window_pos(&mut cmd);
spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event) spawn_with(cmd, &format!("{addr}:{port}"), slot, on_event)
} }
+10
View File
@@ -260,6 +260,16 @@ fn handle_event(client: &NativeClient, state: &mut State, ev: ClipEventCore) {
} }
} }
/// Put plain text on this machine's clipboard — the "Copy link" affordance, which has nothing
/// to do with the streaming bridge above but wants the same OS plumbing. Best-effort: a
/// clipboard another process is holding open is a transient nuisance, never worth failing a
/// user action over. A no-op where this build has no OS clipboard.
pub fn set_text(text: &str) {
if let Err(e) = os::set(MIME_TEXT, text.as_bytes()) {
tracing::warn!(error = %format!("{e:#}"), "copying to the clipboard");
}
}
#[cfg(windows)] #[cfg(windows)]
mod os { mod os {
//! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and //! The Win32 clipboard seam. Every entry point opens the clipboard, does one thing and