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, paired: false,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
} }
match known.save() { match known.save() {
+1
View File
@@ -453,6 +453,7 @@ impl ServiceState {
paired: false, paired: false,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
} }
if let Err(e) = known.save() { if let Err(e) = known.save() {
+9
View File
@@ -103,6 +103,14 @@ mod session_main {
force_software: Arc<AtomicBool>, force_software: Arc<AtomicBool>,
vulkan: Option<pf_client_core::video::VulkanDecodeDevice>, vulkan: Option<pf_client_core::video::VulkanDecodeDevice>,
) -> SessionParams { ) -> 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` // 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 // 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 // process. Applied per params-build (idempotent; browse re-launches included) so
@@ -165,6 +173,7 @@ mod session_main {
// pump) pins one manually. // pump) pins one manually.
display_hdr: None, display_hdr: None,
mic_enabled: settings.mic_enabled, mic_enabled: settings.mic_enabled,
clipboard,
// The Settings preference (auto → VAAPI where it exists; the presenter // The Settings preference (auto → VAAPI where it exists; the presenter
// demotes to software on boxes whose Vulkan can't import the dmabufs). // demotes to software on boxes whose Vulkan can't import the dmabufs).
// PUNKTFUNK_DECODER still overrides inside the decoder for bisects. // PUNKTFUNK_DECODER still overrides inside the decoder for bisects.
+2
View File
@@ -274,6 +274,7 @@ fn connect_spawn(
paired: persist_paired, paired: persist_paired,
last_used: None, last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); let _ = k.save();
} }
@@ -473,6 +474,7 @@ fn wake_and_connect(
paired: false, paired: false,
last_used: None, last_used: None,
mac: target.mac.clone(), mac: target.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); 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_CONSOLE: &str = "Open console UI";
const MENU_SPEED: &str = "Test network speed\u{2026}"; const MENU_SPEED: &str = "Test network speed\u{2026}";
const MENU_WAKE: &str = "Wake host"; 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_RENAME: &str = "Rename\u{2026}";
const MENU_FORGET: &str = "Forget\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 menu = {
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());
// 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()); let (fp, name) = (k.fp_hex.clone(), k.name.clone());
button("") button("")
.icon(Symbol::More) .icon(Symbol::More)
@@ -474,6 +483,13 @@ pub(crate) fn hosts_page(props: &HostsProps, cx: &mut RenderCx) -> Element {
if CONSOLE_UI_AVAILABLE && k.paired { if CONSOLE_UI_AVAILABLE && k.paired {
items.push(menu_item(MENU_CONSOLE)); 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)); items.push(menu_item(MENU_SPEED));
// Offer an explicit wake only when the host is offline and we have a MAC. // Offer an explicit wake only when the host is offline and we have a MAC.
if can_wake { 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) open_console(&svc.ctx, target.clone(), &svc.set_screen, &svc.set_status)
} }
MENU_WAKE => crate::wol::wake(&target.mac, target.addr.parse().ok()), 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 => { MENU_SPEED => {
*svc.ctx.shared.target.lock().unwrap() = target.clone(); *svc.ctx.shared.target.lock().unwrap() = target.clone();
// New run: invalidate any still-in-flight probe, reset the screen. // 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, paired: true,
last_used: None, last_used: None,
mac: target3.mac.clone(), mac: target3.mac.clone(),
clipboard_sync: false,
}); });
let _ = k.save(); let _ = k.save();
connect(&ctx3, &target3, Some(fp), &ss, &st); 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>, pub display_hdr: Option<punktfunk_core::quic::HdrMeta>,
/// Stream the default microphone to the host's virtual mic source. /// Stream the default microphone to the host's virtual mic source.
pub mic_enabled: bool, 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 preference (Settings; `PUNKTFUNK_DECODER` overrides — see
/// `video::Decoder::new`). /// `video::Decoder::new`).
pub decoder: String, pub decoder: String,
@@ -342,14 +345,17 @@ fn pump(
// The shared clipboard (design/clipboard-and-file-transfer.md §5): its own thread, since // 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 // `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. // away when the host has no clipboard capability, so spawning is unconditional.
let clipboard_thread = { let clipboard_thread = params
let c = connector.clone(); .clipboard
let s = stop.clone(); .then(|| {
std::thread::Builder::new() let c = connector.clone();
.name("pf-clipboard".into()) let s = stop.clone();
.spawn(move || crate::clipboard::run(c, s)) std::thread::Builder::new()
.ok() .name("pf-clipboard".into())
}; .spawn(move || crate::clipboard::run(c, s))
.ok()
})
.flatten();
let _mic = params let _mic = params
.mic_enabled .mic_enabled
.then(|| { .then(|| {
+7
View File
@@ -124,6 +124,12 @@ pub struct KnownHost {
/// pre-existing stores load; empty until first learned. /// pre-existing stores load; empty until first learned.
#[serde(default)] #[serde(default)]
pub mac: Vec<String>, 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)] #[derive(Default, Serialize, Deserialize)]
@@ -201,6 +207,7 @@ pub fn persist_host(name: &str, addr: &str, port: u16, fp_hex: &str, paired: boo
paired, paired,
last_used: None, last_used: None,
mac: Vec::new(), mac: Vec::new(),
clipboard_sync: false,
}); });
let _ = known.save(); let _ = known.save();
} }