diff --git a/clients/linux/src/cli.rs b/clients/linux/src/cli.rs index 346f2feb..48b32dcc 100644 --- a/clients/linux/src/cli.rs +++ b/clients/linux/src/cli.rs @@ -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() { diff --git a/clients/session/src/console.rs b/clients/session/src/console.rs index df4677f8..35358386 100644 --- a/clients/session/src/console.rs +++ b/clients/session/src/console.rs @@ -453,6 +453,7 @@ impl ServiceState { paired: false, last_used: None, mac: Vec::new(), + clipboard_sync: false, }); } if let Err(e) = known.save() { diff --git a/clients/session/src/main.rs b/clients/session/src/main.rs index 14a39f89..971d7b95 100644 --- a/clients/session/src/main.rs +++ b/clients/session/src/main.rs @@ -103,6 +103,14 @@ mod session_main { force_software: Arc, vulkan: Option, ) -> 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. diff --git a/clients/windows/src/app/connect.rs b/clients/windows/src/app/connect.rs index 98a71c77..83ea849c 100644 --- a/clients/windows/src/app/connect.rs +++ b/clients/windows/src/app/connect.rs @@ -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(); } diff --git a/clients/windows/src/app/hosts.rs b/clients/windows/src/app/hosts.rs index e0383188..890ccdaa 100644 --- a/clients/windows/src/app/hosts.rs +++ b/clients/windows/src/app/hosts.rs @@ -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. diff --git a/clients/windows/src/app/pair.rs b/clients/windows/src/app/pair.rs index bb96aa86..6852e40c 100644 --- a/clients/windows/src/app/pair.rs +++ b/clients/windows/src/app/pair.rs @@ -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); diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index 459d43c4..436b277c 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -41,6 +41,9 @@ pub struct SessionParams { pub display_hdr: Option, /// 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(|| { diff --git a/crates/pf-client-core/src/trust.rs b/crates/pf-client-core/src/trust.rs index 79f9900d..f4ee6f03 100644 --- a/crates/pf-client-core/src/trust.rs +++ b/crates/pf-client-core/src/trust.rs @@ -124,6 +124,12 @@ pub struct KnownHost { /// pre-existing stores load; empty until first learned. #[serde(default)] pub mac: Vec, + /// 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(); }