feat(client/windows): per-host clipboard toggle

The bridge landed always-on whenever the host permitted it; sharing a
clipboard is a trust decision about a specific host, so it needs to be
opted into. Mirrors the Apple client's per-host model
(StoredHost.clipboardSync, "Share clipboard with this host") rather than a
global switch: KnownHost::clipboard_sync, toggled from the host card's
overflow menu, default off.

The session binary resolves the stored flag itself in session_params, so a
direct connect and the console's own launches honor the same decision
without every caller having to remember to pass it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:41:02 +02:00
parent cab6350723
commit aa45757a72
8 changed files with 64 additions and 8 deletions
+1
View File
@@ -352,6 +352,7 @@ pub fn headless_add_host(target: &str) -> glib::ExitCode {
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
match known.save() {
+1
View File
@@ -453,6 +453,7 @@ impl ServiceState {
paired: false,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
}
if let Err(e) = known.save() {
+9
View File
@@ -103,6 +103,14 @@ mod session_main {
force_software: Arc<AtomicBool>,
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
) -> SessionParams {
// Per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3), resolved
// here rather than passed in so every caller — a direct connect and the console's
// own launches — honors the same stored decision. `addr` is moved into the struct
// below, so read it first.
let clipboard = trust::KnownHosts::load()
.hosts
.iter()
.any(|h| h.addr == addr && h.port == port && h.clipboard_sync);
// Re-apply the shell-persisted forwarded-controller pin (stable `vid:pid:name`
// key) to OUR gamepad service — the shells' in-process services can't reach this
// process. Applied per params-build (idempotent; browse re-launches included) so
@@ -165,6 +173,7 @@ mod session_main {
// pump) pins one manually.
display_hdr: None,
mic_enabled: settings.mic_enabled,
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
+2
View File
@@ -274,6 +274,7 @@ fn connect_spawn(
paired: persist_paired,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
}
@@ -473,6 +474,7 @@ fn wake_and_connect(
paired: false,
last_used: None,
mac: target.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
}
+29
View File
@@ -17,6 +17,11 @@ const MENU_LIBRARY: &str = "Browse library\u{2026}";
const MENU_CONSOLE: &str = "Open console UI";
const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host";
/// The per-host clipboard opt-in (design/clipboard-and-file-transfer.md §5.3 — the Apple
/// client's "Share clipboard with this host"). Two labels rather than a checkable item: the
/// flyout reports the clicked item BY TEXT, so the item has to say which way it's going.
const MENU_CLIP_ON: &str = "Share clipboard with this host";
const MENU_CLIP_OFF: &str = "Stop sharing clipboard";
const MENU_RENAME: &str = "Rename\u{2026}";
const MENU_FORGET: &str = "Forget\u{2026}";
@@ -456,6 +461,10 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
let menu = {
let (svc, target) = (props.svc.clone(), target.clone());
let (sf, sr) = (set_forget.clone(), set_rename.clone());
// Re-render lever for the clipboard toggle: `hover` is a value field, so
// clearing it flips the menu label to its opposite immediately (and dropping
// the hover highlight after a menu action is right regardless).
let sh = props.set_hover.clone();
let (fp, name) = (k.fp_hex.clone(), k.name.clone());
button("")
.icon(Symbol::More)
@@ -474,6 +483,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if CONSOLE_UI_AVAILABLE && k.paired {
items.push(menu_item(MENU_CONSOLE));
}
if k.paired {
items.push(menu_item(if k.clipboard_sync {
MENU_CLIP_OFF
} else {
MENU_CLIP_ON
}));
}
items.push(menu_item(MENU_SPEED));
// Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake {
@@ -497,6 +513,19 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
}
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()),
MENU_CLIP_ON | MENU_CLIP_OFF => {
let mut known = KnownHosts::load();
if let Some(h) = known.hosts.iter_mut().find(|h| h.fp_hex == fp) {
h.clipboard_sync = !h.clipboard_sync;
tracing::info!(
host = %h.name,
on = h.clipboard_sync,
"clipboard sharing toggled for host"
);
}
known.save();
sh.call(None);
}
MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe, reset the screen.
+1
View File
@@ -59,6 +59,7 @@ pub(crate) fn pair_page(props: &Svc, cx: &mut RenderCx) -> Element {
paired: true,
last_used: None,
mac: target3.mac.clone(),
clipboard_sync: false,
});
let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st);
+14 -8
View File
@@ -41,6 +41,9 @@ pub struct SessionParams {
pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool,
/// Share the clipboard with this host (the per-host `KnownHost::clipboard_sync`). The
/// bridge additionally needs the host to advertise `HOST_CAP_CLIPBOARD`.
pub clipboard: bool,
/// Video decoder preference (Settings; `PUNKTFUNK_DECODER` overrides — see
/// `video::Decoder::new`).
pub decoder: String,
@@ -342,14 +345,17 @@ fn pump(
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since
// `next_clip` blocks and the OS clipboard calls can wait on other apps. Returns straight
// away when the host has no clipboard capability, so spawning is unconditional.
let clipboard_thread = {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
};
let clipboard_thread = params
.clipboard
.then(|| {
let c = connector.clone();
let s = stop.clone();
std::thread::Builder::new()
.name("pf-clipboard".into())
.spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
let _mic = params
.mic_enabled
.then(|| {
+7
View File
@@ -124,6 +124,12 @@ pub struct KnownHost {
/// pre-existing stores load; empty until first learned.
#[serde(default)]
pub mac: Vec<String>,
/// Share this machine's clipboard with THIS host (design/clipboard-and-file-transfer.md
/// §5.3 — the Apple client's `StoredHost.clipboardSync`). Per-host, not global: handing a
/// host your clipboard is a trust decision about that host. Default off; the host must
/// also advertise `HOST_CAP_CLIPBOARD` and have its own policy enabled.
#[serde(default)]
pub clipboard_sync: bool,
}
#[derive(Default, Serialize, Deserialize)]
@@ -201,6 +207,7 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
paired,
last_used: None,
mac: Vec::new(),
clipboard_sync: false,
});
let _ = known.save();
}