From 3d6b06e854523afe33d4263f4db4093032476b00 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sun, 17 May 2026 15:15:12 +0800 Subject: [PATCH 01/13] feat: add rendezvous WebRTC signaling fields --- Cargo.toml | 2 +- libs/hbb_common | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fa22dcd7b..e0f78c29a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ screencapturekit = ["cpal/screencapturekit"] [dependencies] async-trait = "0.1" scrap = { path = "libs/scrap", features = ["wayland"] } -hbb_common = { path = "libs/hbb_common" } +hbb_common = { path = "libs/hbb_common", features = ["webrtc"] } serde_derive = "1.0" serde = "1.0" serde_json = "1.0" diff --git a/libs/hbb_common b/libs/hbb_common index c8cbb6be2..fb4db58b9 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02 +Subproject commit fb4db58b93dc44547d4d59a331c050136bc8c4c9 From c50e7d078d723dfbcc62434c701a6ec0fb20a184 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sun, 17 May 2026 15:18:53 +0800 Subject: [PATCH 02/13] feat: support trickle ICE in WebRTCStream --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index fb4db58b9..c7f556786 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit fb4db58b93dc44547d4d59a331c050136bc8c4c9 +Subproject commit c7f55678653cae6abb3d28e80f91844f727c4be2 From 0e0ec5a551d8b6e14d115ed0d0cf86bca7286387 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sun, 17 May 2026 15:24:27 +0800 Subject: [PATCH 03/13] feat: route WebRTC ICE on controlled side --- src/rendezvous_mediator.rs | 149 ++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 89d7fa01e..1c15aecb5 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, net::SocketAddr, sync::{ atomic::{AtomicBool, Ordering}, @@ -21,8 +22,13 @@ use hbb_common::{ rendezvous_proto::*, sleep, socket_client::{self, connect_tcp, is_ipv4, new_direct_udp_for, new_udp_for}, - tokio::{self, select, sync::Mutex, time::interval}, + tokio::{ + self, select, + sync::{mpsc, Mutex}, + time::interval, + }, udp::FramedSocket, + webrtc::WebRTCStream, AddrMangle, IntoTargetAddr, ResultType, Stream, TargetAddr, }; @@ -32,11 +38,17 @@ use crate::{ }; type Message = RendezvousMessage; +type RendezvousSender = mpsc::UnboundedSender; + +fn webrtc_ice_key(peer_id: &str, session_key: &str) -> String { + format!("{}\n{}", peer_id, session_key) +} lazy_static::lazy_static! { static ref SOLVING_PK_MISMATCH: Mutex = Default::default(); static ref LAST_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now())); static ref LAST_RELAY_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now())); + static ref WEBRTC_ICE_TXS: Mutex>> = Default::default(); } static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false); @@ -72,6 +84,7 @@ pub struct RendezvousMediator { host: String, host_prefix: String, keep_alive: i32, + rz_sender: RendezvousSender, } impl RendezvousMediator { @@ -182,11 +195,13 @@ impl RendezvousMediator { let host = check_port(&host, RENDEZVOUS_PORT); log::info!("start udp: {host}"); let (mut socket, mut addr) = new_udp_for(&host, CONNECT_TIMEOUT).await?; + let (rz_sender, mut rz_out_rx) = mpsc::unbounded_channel::(); let mut rz = Self { addr: addr.clone(), host: host.clone(), host_prefix: Self::get_host_prefix(&host), keep_alive: crate::DEFAULT_KEEP_ALIVE, + rz_sender, }; let mut timer = crate::rustdesk_interval(interval(crate::TIMER_OUT)); @@ -246,6 +261,9 @@ impl RendezvousMediator { }, } }, + Some(msg_out) = rz_out_rx.recv() => { + Sink::Framed(&mut socket, &addr).send(&msg_out).await?; + }, _ = timer.tick() => { if SHOULD_EXIT.load(Ordering::SeqCst) { break; @@ -367,6 +385,22 @@ impl RendezvousMediator { allow_err!(rz.handle_intranet(fla, server).await); }); } + Some(rendezvous_message::Union::IceCandidate(ice)) => { + if ice.to_id != Config::get_id() { + return Ok(()); + } + let key = webrtc_ice_key(&ice.from_id, &ice.session_key); + let tx = WEBRTC_ICE_TXS.lock().await.get(&key).cloned(); + if let Some(tx) = tx { + let _ = tx.send(ice.candidate); + } else { + log::debug!( + "dropping ICE candidate for unknown WebRTC session from {} key {}", + ice.from_id, + ice.session_key + ); + } + } Some(rendezvous_message::Union::ConfigureUpdate(cu)) => { let v0 = Config::get_rendezvous_servers(); Config::set_option( @@ -389,11 +423,13 @@ impl RendezvousMediator { let mut conn = connect_tcp(host.clone(), CONNECT_TIMEOUT).await?; let key = crate::get_key(true).await; crate::secure_tcp(&mut conn, &key).await?; + let (rz_sender, mut rz_out_rx) = mpsc::unbounded_channel::(); let mut rz = Self { addr: conn.local_addr().into_target_addr()?, host: host.clone(), host_prefix: Self::get_host_prefix(&host), keep_alive: crate::DEFAULT_KEEP_ALIVE, + rz_sender, }; let mut timer = crate::rustdesk_interval(interval(crate::TIMER_OUT)); let mut last_register_sent: Option = None; @@ -421,6 +457,9 @@ impl RendezvousMediator { let msg = Message::parse_from_bytes(&bytes)?; rz.handle_resp(msg.union, Sink::Stream(&mut conn), &server, &mut update_latency).await? } + Some(msg_out) = rz_out_rx.recv() => { + Sink::Stream(&mut conn).send(&msg_out).await?; + } _ = timer.tick() => { if SHOULD_EXIT.load(Ordering::SeqCst) { break; @@ -472,6 +511,7 @@ impl RendezvousMediator { rr.secure, false, Default::default(), + String::new(), rr.control_permissions.clone().into_option(), ) .await @@ -486,6 +526,7 @@ impl RendezvousMediator { secure: bool, initiate: bool, socket_addr_v6: bytes::Bytes, + webrtc_sdp_answer: String, control_permissions: Option, ) -> ResultType<()> { let peer_addr = AddrMangle::decode(&socket_addr); @@ -504,6 +545,7 @@ impl RendezvousMediator { socket_addr: socket_addr.into(), version: crate::VERSION.to_owned(), socket_addr_v6, + webrtc_sdp_answer, ..Default::default() }; if initiate { @@ -571,6 +613,7 @@ impl RendezvousMediator { true, true, socket_addr_v6, + String::new(), fla.control_permissions.into_option(), ) .await @@ -613,6 +656,91 @@ impl RendezvousMediator { Ok(()) } + async fn spawn_webrtc_answerer( + &self, + ph: &PunchHole, + server: ServerPtr, + peer_addr: SocketAddr, + control_permissions: Option, + ) -> ResultType { + if ph.requester_id.is_empty() { + log::warn!("WebRTC offer missing requester_id; falling back to existing transports"); + return Ok(String::new()); + } + + let mut stream = + WebRTCStream::new(&ph.webrtc_sdp_offer, ph.force_relay, CONNECT_TIMEOUT).await?; + let answer = stream.get_local_endpoint().await?; + let session_key = stream.session_key().to_owned(); + let peer_id = ph.requester_id.clone(); + + let (remote_ice_tx, mut remote_ice_rx) = mpsc::unbounded_channel::(); + WEBRTC_ICE_TXS + .lock() + .await + .insert(webrtc_ice_key(&peer_id, &session_key), remote_ice_tx); + + let stream_for_remote_ice = stream.clone(); + tokio::spawn(async move { + while let Some(candidate) = remote_ice_rx.recv().await { + if let Err(err) = stream_for_remote_ice.add_remote_ice_candidate(&candidate).await + { + log::warn!("failed to add remote WebRTC ICE candidate: {}", err); + } + } + }); + + if let Some(mut local_ice_rx) = stream.take_local_ice_rx() { + let sender = self.rz_sender.clone(); + let my_id = Config::get_id(); + let target_id = peer_id.clone(); + let session_key_for_ice = session_key.clone(); + tokio::spawn(async move { + while let Some(candidate) = local_ice_rx.recv().await { + let mut msg = Message::new(); + msg.set_ice_candidate(IceCandidate { + from_id: my_id.clone(), + to_id: target_id.clone(), + session_key: session_key_for_ice.clone(), + candidate, + ..Default::default() + }); + let _ = sender.send(msg); + } + }); + } + + let peer_id_for_cleanup = peer_id.clone(); + let session_key_for_cleanup = session_key.clone(); + tokio::spawn(async move { + let result = stream.wait_connected(CONNECT_TIMEOUT).await; + WEBRTC_ICE_TXS + .lock() + .await + .remove(&webrtc_ice_key( + &peer_id_for_cleanup, + &session_key_for_cleanup, + )); + if let Err(err) = result { + log::warn!("webrtc wait_connected failed: {}", err); + return; + } + if let Err(err) = crate::server::create_tcp_connection( + server, + Stream::WebRTC(stream), + peer_addr, + true, + control_permissions, + ) + .await + { + log::warn!("failed to create WebRTC server connection: {}", err); + } + }); + + Ok(answer) + } + async fn handle_punch_hole(&self, ph: PunchHole, server: ServerPtr) -> ResultType<()> { let mut peer_addr = AddrMangle::decode(&ph.socket_addr); let last = *LAST_MSG.lock().await; @@ -624,7 +752,22 @@ impl RendezvousMediator { let peer_addr_v6 = hbb_common::AddrMangle::decode(&ph.socket_addr_v6); let relay = use_ws() || Config::is_proxy() || ph.force_relay; let mut socket_addr_v6 = Default::default(); - let control_permissions = ph.control_permissions.into_option(); + let control_permissions = ph.control_permissions.clone().into_option(); + let webrtc_sdp_answer = if !ph.webrtc_sdp_offer.is_empty() { + self.spawn_webrtc_answerer( + &ph, + server.clone(), + peer_addr, + control_permissions.clone(), + ) + .await + .unwrap_or_else(|err| { + log::warn!("failed to create WebRTC answer: {}", err); + String::new() + }) + } else { + String::new() + }; if peer_addr_v6.port() > 0 && !relay { socket_addr_v6 = start_ipv6( peer_addr_v6, @@ -651,6 +794,7 @@ impl RendezvousMediator { true, true, socket_addr_v6.clone(), + webrtc_sdp_answer.clone(), control_permissions, ) .await; @@ -664,6 +808,7 @@ impl RendezvousMediator { nat_type: nat_type.into(), version: crate::VERSION.to_owned(), socket_addr_v6, + webrtc_sdp_answer, ..Default::default() }; if ph.udp_port > 0 { From 03cbf609f65ce6bf6c9c9ee4a4a0134751e47332 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sun, 17 May 2026 15:30:50 +0800 Subject: [PATCH 04/13] feat: race WebRTC as a direct transport enhancement --- src/client.rs | 218 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 209 insertions(+), 9 deletions(-) diff --git a/src/client.rs b/src/client.rs index 321a49ee6..2f9f02ad5 100644 --- a/src/client.rs +++ b/src/client.rs @@ -65,11 +65,12 @@ use hbb_common::{ self, net::UdpSocket, sync::{ - mpsc::{unbounded_channel, UnboundedReceiver}, + mpsc::{error::TryRecvError, unbounded_channel, UnboundedReceiver}, oneshot, }, time::{interval, Duration, Instant}, }, + webrtc::WebRTCStream, AddrMangle, ResultType, Stream, }; pub use helper::*; @@ -330,6 +331,19 @@ impl Client { } else { (None, None) }; + let ipv6 = if crate::get_ipv6_punch_enabled() { + crate::get_ipv6_socket().await + } else { + None + }; + let webrtc_offerer = + match WebRTCStream::new("", interface.is_force_relay(), CONNECT_TIMEOUT).await { + Ok(stream) => Some(stream), + Err(err) => { + log::warn!("webrtc offerer setup failed: {}", err); + None + } + }; let fut = Self::_start_inner( peer.to_owned(), key.to_owned(), @@ -338,6 +352,8 @@ impl Client { interface.clone(), udp.clone(), Some(stop_udp_tx), + ipv6, + webrtc_offerer, rendezvous_server.clone(), servers.clone(), contained, @@ -355,6 +371,8 @@ impl Client { interface, (None, None), None, + None, + None, rendezvous_server, servers, contained, @@ -366,6 +384,68 @@ impl Client { } } + fn spawn_webrtc_ice_bridge( + mut socket: Stream, + mut local_ice_rx: Option>, + webrtc: WebRTCStream, + peer: String, + session_key: String, + ) -> oneshot::Sender<()> { + let (stop_tx, mut stop_rx) = oneshot::channel::<()>(); + let my_id = Config::get_id(); + tokio::spawn(async move { + loop { + match stop_rx.try_recv() { + Ok(_) | Err(tokio::sync::oneshot::error::TryRecvError::Closed) => break, + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {} + } + + if let Some(rx) = local_ice_rx.as_mut() { + loop { + match rx.try_recv() { + Ok(candidate) => { + let mut msg = RendezvousMessage::new(); + msg.set_ice_candidate(IceCandidate { + from_id: my_id.clone(), + to_id: peer.clone(), + session_key: session_key.clone(), + candidate, + ..Default::default() + }); + if let Err(err) = socket.send(&msg).await { + log::warn!("failed to send WebRTC ICE candidate: {}", err); + return; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + local_ice_rx = None; + break; + } + } + } + } + + if let Some(msg_in) = + crate::get_next_nonkeyexchange_msg(&mut socket, Some(100)).await + { + if let Some(rendezvous_message::Union::IceCandidate(ice)) = msg_in.union { + if ice.from_id == peer + && ice.to_id == my_id + && ice.session_key == session_key + { + if let Err(err) = webrtc.add_remote_ice_candidate(&ice.candidate).await + { + log::warn!("failed to add WebRTC ICE candidate: {}", err); + } + } + } + } + } + }); + stop_tx + } + async fn _start_inner( peer: String, key: String, @@ -374,6 +454,8 @@ impl Client { interface: impl Interface, mut udp: (Option>, Option>>), stop_udp_tx: Option>, + mut ipv6: Option<(Arc, bytes::Bytes)>, + mut webrtc_offerer: Option, mut rendezvous_server: String, servers: Vec, contained: bool, @@ -446,14 +528,20 @@ impl Client { // Stop UDP NAT test task if still running stop_udp_tx.map(|tx| tx.send(())); let mut msg_out = RendezvousMessage::new(); - let mut ipv6 = if crate::get_ipv6_punch_enabled() { - if let Some((socket, addr)) = crate::get_ipv6_socket().await { - (Some(socket), Some(addr)) - } else { - (None, None) + let mut ipv6 = ipv6 + .take() + .map(|(socket, addr)| (Some(socket), Some(addr))) + .unwrap_or((None, None)); + let webrtc_sdp_offer = if let Some(webrtc) = webrtc_offerer.as_ref() { + match webrtc.get_local_endpoint().await { + Ok(endpoint) => endpoint, + Err(err) => { + log::warn!("failed to read local WebRTC offer: {}", err); + String::new() + } } } else { - (None, None) + String::new() }; let udp_nat_port = udp.1.map(|x| *x.lock().unwrap()).unwrap_or(0); let punch_type = if udp_nat_port > 0 { "UDP" } else { "TCP" }; @@ -467,8 +555,15 @@ impl Client { udp_port: udp_nat_port as _, force_relay: interface.is_force_relay(), socket_addr_v6: ipv6.1.unwrap_or_default(), + webrtc_sdp_offer: webrtc_sdp_offer.clone(), ..Default::default() }); + let webrtc_session_key = webrtc_offerer + .as_ref() + .map(|webrtc| webrtc.session_key().to_owned()) + .unwrap_or_default(); + let mut webrtc_sdp_answer = String::new(); + let mut pending_webrtc_ice = Vec::::new(); for i in 1..=3 { log::info!( "#{} {} punch attempt with {}, id: {}", @@ -510,6 +605,7 @@ impl Client { relay_server = ph.relay_server; peer_addr = AddrMangle::decode(&ph.socket_addr); feedback = ph.feedback; + webrtc_sdp_answer = ph.webrtc_sdp_answer; let s = udp.0.take(); if ph.is_udp && s.is_some() { if let Some(s) = s { @@ -549,6 +645,38 @@ impl Client { } } signed_id_pk = rr.pk().into(); + let mut webrtc_bridge_stop = None; + let mut webrtc_for_connect = None; + if !rr.webrtc_sdp_answer.is_empty() { + if let Some(webrtc) = webrtc_offerer.take() { + if let Err(err) = + webrtc.set_remote_endpoint(&rr.webrtc_sdp_answer).await + { + log::warn!("failed to set WebRTC relay answer: {}", err); + } else { + for candidate in pending_webrtc_ice.drain(..) { + if let Err(err) = + webrtc.add_remote_ice_candidate(&candidate).await + { + log::warn!( + "failed to add buffered WebRTC ICE candidate: {}", + err + ); + } + } + let session_key = webrtc.session_key().to_owned(); + let local_ice_rx = webrtc.take_local_ice_rx(); + webrtc_bridge_stop = Some(Self::spawn_webrtc_ice_bridge( + socket, + local_ice_rx, + webrtc.clone(), + peer.clone(), + session_key, + )); + webrtc_for_connect = Some(webrtc); + } + } + } let fut = Self::create_relay( &peer, rr.uuid, @@ -564,30 +692,86 @@ impl Client { } .boxed(), ); + if let Some(mut webrtc) = webrtc_for_connect { + connect_futures.push( + async move { + webrtc.wait_connected(CONNECT_TIMEOUT).await?; + Ok((Stream::WebRTC(webrtc), None, "WebRTC")) + } + .boxed(), + ); + } // Run all connection attempts concurrently, return the first successful one let (conn, kcp, typ) = match select_ok(connect_futures).await { Ok(conn) => (Ok(conn.0 .0), conn.0 .1, conn.0 .2), Err(e) => (Err(e), None, ""), }; + if let Some(stop) = webrtc_bridge_stop { + let _ = stop.send(()); + } let mut conn = conn?; feedback = rr.feedback; log::info!("{:?} used to establish {typ} connection", start.elapsed()); let pk = Self::secure_connection(&peer, signed_id_pk, &key, &mut conn).await?; return Ok(( - (conn, typ == "IPv6", pk, kcp, typ), + (conn, typ == "IPv6" || typ == "WebRTC", pk, kcp, typ), (feedback, rendezvous_server), false, )); } + Some(rendezvous_message::Union::IceCandidate(ice)) => { + if !webrtc_session_key.is_empty() + && ice.from_id == peer + && ice.to_id == Config::get_id() + && ice.session_key == webrtc_session_key + { + pending_webrtc_ice.push(ice.candidate); + } else { + log::debug!( + "dropping ICE candidate for unexpected WebRTC session from {} key {}", + ice.from_id, + ice.session_key + ); + } + } _ => { log::error!("Unexpected protobuf msg received: {:?}", msg_in); } } } } - drop(socket); + let mut webrtc_bridge_stop = None; + let mut webrtc_for_connect = None; + if !webrtc_sdp_answer.is_empty() { + if let Some(webrtc) = webrtc_offerer.take() { + if let Err(err) = webrtc.set_remote_endpoint(&webrtc_sdp_answer).await { + log::warn!("failed to set WebRTC answer: {}", err); + drop(socket); + } else { + for candidate in pending_webrtc_ice.drain(..) { + if let Err(err) = webrtc.add_remote_ice_candidate(&candidate).await { + log::warn!("failed to add buffered WebRTC ICE candidate: {}", err); + } + } + let session_key = webrtc.session_key().to_owned(); + let local_ice_rx = webrtc.take_local_ice_rx(); + webrtc_bridge_stop = Some(Self::spawn_webrtc_ice_bridge( + socket, + local_ice_rx, + webrtc.clone(), + peer.clone(), + session_key, + )); + webrtc_for_connect = Some(webrtc); + } + } else { + drop(socket); + } + } else { + drop(socket); + } if peer_addr.port() == 0 { bail!("Failed to connect via rendezvous server"); } @@ -621,6 +805,8 @@ impl Client { interface, udp.0, ipv6.0, + webrtc_for_connect, + webrtc_bridge_stop, punch_type, ) .await?, @@ -647,6 +833,8 @@ impl Client { interface: impl Interface, udp_socket_nat: Option>, udp_socket_v6: Option>, + webrtc_offerer: Option, + webrtc_bridge_stop: Option>, punch_type: &str, ) -> ResultType<( Stream, @@ -705,11 +893,23 @@ impl Client { if let Some(udp_socket_v6) = udp_socket_v6 { connect_futures.push(udp_nat_connect(udp_socket_v6, "IPv6", connect_timeout).boxed()); } + if let Some(mut webrtc) = webrtc_offerer { + connect_futures.push( + async move { + webrtc.wait_connected(connect_timeout).await?; + Ok((Stream::WebRTC(webrtc), None, "WebRTC")) + } + .boxed(), + ); + } // Run all connection attempts concurrently, return the first successful one let (mut conn, kcp, mut typ) = match select_ok(connect_futures).await { Ok(conn) => (Ok(conn.0 .0), conn.0 .1, conn.0 .2), Err(e) => (Err(e), None, ""), }; + if let Some(stop) = webrtc_bridge_stop { + let _ = stop.send(()); + } let mut direct = !conn.is_err(); if interface.is_force_relay() || conn.is_err() { From 1040df03997d9144aaadb20df93c553147a73cc4 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Sun, 17 May 2026 15:43:48 +0800 Subject: [PATCH 05/13] fix: update CLI for current client APIs --- src/cli.rs | 21 ++++++++++---- src/main.rs | 81 ++++++++++++++++++++++++++++++++++------------------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2f3b3550f..351a580f1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use hbb_common::{ config::PeerConfig, config::READ_TIMEOUT, - futures::{SinkExt, StreamExt}, + futures::StreamExt, log, message_proto::*, protobuf::Message as _, @@ -46,6 +46,7 @@ impl Session { false, None, None, + None, ); session } @@ -53,7 +54,7 @@ impl Session { #[async_trait] impl Interface for Session { - fn get_login_config_handler(&self) -> Arc> { + fn get_lch(&self) -> Arc> { return self.lc.clone(); } @@ -61,14 +62,20 @@ impl Interface for Session { match msgtype { "input-password" => { self.sender - .send(Data::Login((self.password.clone(), true))) + .send(Data::Login(( + String::new(), + String::new(), + self.password.clone(), + true, + ))) .ok(); } "re-input-password" => { log::error!("{}: {}", title, text); match rpassword::prompt_password("Enter password: ") { Ok(password) => { - let login_data = Data::Login((password, true)); + let login_data = + Data::Login((String::new(), String::new(), password, true)); self.sender.send(login_data).ok(); } Err(e) => { @@ -93,6 +100,8 @@ impl Interface for Session { self.lc.write().unwrap().handle_peer_info(&pi); } + fn set_multiple_windows_session(&self, _sessions: Vec) {} + async fn handle_hash(&self, pass: &str, hash: Hash, peer: &mut Stream) { log::info!( "password={}", @@ -137,8 +146,8 @@ pub async fn connect_test(id: &str, key: String, token: String) { Err(err) => { log::error!("Failed to connect {}: {}", &id, err); } - Ok((mut stream, direct)) => { - log::info!("direct: {}", direct); + Ok(((mut stream, _direct, _secure, _kcp, _typ), direct)) => { + log::info!("direct: {:?}", direct); // rpassword::prompt_password("Input anything to exit").ok(); loop { tokio::select! { diff --git a/src/main.rs b/src/main.rs index 9bc90a8fa..3d5237ab5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,49 +38,68 @@ fn main() { if !common::global_init() { return; } - use clap::App; + use clap::{Arg, ArgAction, Command}; use hbb_common::log; - let args = format!( - "-p, --port-forward=[PORT-FORWARD-OPTIONS] 'Format: remote-id:local-port:remote-port[:remote-host]' - -c, --connect=[REMOTE_ID] 'test only' - -k, --key=[KEY] '' - -s, --server=[] 'Start server'", - ); - let matches = App::new("rustdesk") + let matches = Command::new("rustdesk") .version(crate::VERSION) .author("Purslane Ltd") .about("RustDesk command line tool") - .args_from_usage(&args) + .arg( + Arg::new("port-forward") + .short('p') + .long("port-forward") + .value_name("PORT-FORWARD-OPTIONS") + .help("Format: remote-id:local-port:remote-port[:remote-host]"), + ) + .arg( + Arg::new("connect") + .short('c') + .long("connect") + .value_name("REMOTE_ID") + .help("test only"), + ) + .arg(Arg::new("key").short('k').long("key").value_name("KEY")) + .arg( + Arg::new("server") + .short('s') + .long("server") + .action(ArgAction::SetTrue) + .help("Start server"), + ) .get_matches(); use hbb_common::{config::LocalConfig, env_logger::*}; init_from_env(Env::default().filter_or(DEFAULT_FILTER_ENV, "info")); - if let Some(p) = matches.value_of("port-forward") { - let options: Vec = p.split(":").map(|x| x.to_owned()).collect(); + if let Some(p) = matches.get_one::("port-forward") { + let options: Vec = p.split(':').map(|x| x.to_owned()).collect(); if options.len() < 3 { log::error!("Wrong port-forward options"); return; } - let mut port = 0; - if let Ok(v) = options[1].parse::() { - port = v; - } else { - log::error!("Wrong local-port"); - return; - } - let mut remote_port = 0; - if let Ok(v) = options[2].parse::() { - remote_port = v; - } else { - log::error!("Wrong remote-port"); - return; - } + let port = match options[1].parse::() { + Ok(v) => v, + Err(_) => { + log::error!("Wrong local-port"); + return; + } + }; + let remote_port = match options[2].parse::() { + Ok(v) => v, + Err(_) => { + log::error!("Wrong remote-port"); + return; + } + }; let mut remote_host = "localhost".to_owned(); if options.len() > 3 { remote_host = options[3].clone(); } common::test_rendezvous_server(); common::test_nat_type(); - let key = matches.value_of("key").unwrap_or("").to_owned(); + let key = matches + .get_one::("key") + .map(String::as_str) + .unwrap_or("") + .to_owned(); let token = LocalConfig::get_option("access_token"); cli::start_one_port_forward( options[0].clone(), @@ -90,13 +109,17 @@ fn main() { key, token, ); - } else if let Some(p) = matches.value_of("connect") { + } else if let Some(p) = matches.get_one::("connect") { common::test_rendezvous_server(); common::test_nat_type(); - let key = matches.value_of("key").unwrap_or("").to_owned(); + let key = matches + .get_one::("key") + .map(String::as_str) + .unwrap_or("") + .to_owned(); let token = LocalConfig::get_option("access_token"); cli::connect_test(p, key, token); - } else if let Some(p) = matches.value_of("server") { + } else if matches.get_flag("server") { log::info!("id={}", hbb_common::config::Config::get_id()); crate::start_server(true, false); } From 377547fa1128823d6c5a4ea17b1310640264713b Mon Sep 17 00:00:00 2001 From: IronCodeStudios Date: Sun, 17 May 2026 16:02:23 +0800 Subject: [PATCH 06/13] scrap/wayland: insert videoconvert to fix screencast on COSMIC / DMA-BUF portals (#15063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Wayland compositors whose xdg-desktop-portal backend exposes screencast frames as DMA-BUF buffers — notably xdg-desktop-portal-cosmic 0.1.0 on Pop!_OS 24.04 / COSMIC — inbound screen capture fails. PipeWireRecorder links pipewiresrc directly to an appsink whose caps only accept video/x-raw BGRx/RGBx in system memory. That format set is too narrow for the portal's buffer-type / modifier negotiation, which collapses with: pw.link: negotiating -> error no more output formats (-22) gstpipewiresrc: stream error: no more output formats gstbasesrc: streaming stopped, reason not-negotiated (-4) ERROR src/server/wayland.rs: Failed scrap Element failed to change its state Inserting a videoconvert element between pipewiresrc and appsink widens the negotiable format set to any system-memory video/x-raw format, giving the portal room to settle on a format it can deliver via its SHM path. videoconvert then converts to the BGRx/RGBx the appsink expects. Verified on Pop!_OS 24.04 / COSMIC with gst-launch, before and after: # fails (current behaviour): gst-launch-1.0 pipewiresrc path=N ! video/x-raw,format=BGRx ! fakesink # works (with this change): gst-launch-1.0 pipewiresrc path=N ! videoconvert ! video/x-raw,format=BGRx ! fakesink After the change, inbound connections capture and stream the desktop normally and the "Failed scrap" error no longer occurs. Co-authored-by: Claude Opus 4.7 (1M context) --- libs/scrap/src/wayland/pipewire.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/libs/scrap/src/wayland/pipewire.rs b/libs/scrap/src/wayland/pipewire.rs index aedf786b7..8859d0d3b 100644 --- a/libs/scrap/src/wayland/pipewire.rs +++ b/libs/scrap/src/wayland/pipewire.rs @@ -276,12 +276,21 @@ impl PipeWireRecorder { // see: https://gitlab.freedesktop.org/pipewire/pipewire/-/issues/982 src.set_property("always-copy", &true)?; + // COSMIC/Wayland fix: insert videoconvert between pipewiresrc and appsink. + // xdg-desktop-portal-cosmic's modifier negotiation fails when the downstream + // format set is too narrow (appsink only accepts BGRx/RGBx), producing + // "no more output formats" / not-negotiated (-4). videoconvert accepts any + // system-memory video/x-raw format, widening negotiation so the portal can + // settle on a format it can deliver via its SHM path. + let convert = gst::ElementFactory::make("videoconvert", None)?; + let sink = gst::ElementFactory::make("appsink", None)?; sink.set_property("drop", &true)?; sink.set_property("max-buffers", &1u32)?; - pipeline.add_many(&[&src, &sink])?; - src.link(&sink)?; + pipeline.add_many(&[&src, &convert, &sink])?; + src.link(&convert)?; + convert.link(&sink)?; let appsink = sink .dynamic_cast::() From bc2c36215d15dd2ec223a5d470e38ebc87b9de7d Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 16:32:46 +0800 Subject: [PATCH 07/13] fix(ipc): scope active-user IPC routing to root CLI main requests (#15058) * fix(ipc): scope active-user IPC routing to root CLI main requests Signed-off-by: fufesou * fix(ipc): cmdline, comments fails close Signed-off-by: fufesou * fix(ipc): cmdline, better check Signed-off-by: fufesou * fix(ipc): cmdline, try active uid when no --server processes Signed-off-by: fufesou * fix(ipc): cmdline, select active uid Signed-off-by: fufesou * fix(ipc): remove unused import Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/hbb_common | 2 +- src/core_main.rs | 63 ++++++++++++ src/ipc.rs | 210 +++++++++++++++++++++++++++++++++++++--- src/ipc/auth.rs | 71 +++++++++++--- src/platform/windows.rs | 1 + 5 files changed, 317 insertions(+), 30 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index c8cbb6be2..9043c15ac 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02 +Subproject commit 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0 diff --git a/src/core_main.rs b/src/core_main.rs index a0ca5eb95..ee2a9d90d 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -199,6 +199,20 @@ pub fn core_main() -> Option> { } std::thread::spawn(move || crate::start_server(false, no_server)); } else { + #[cfg(any(target_os = "linux", target_os = "macos"))] + // Root CLI management commands must talk to the user `--server` main IPC. + // Example: `sudo rustdesk --option custom-rendezvous-server` should query the + // user's IPC instead of root's `/tmp/-0/ipc`; `connect()` still limits this + // routing to empty-postfix main IPC only. + let _user_main_ipc_scope = if crate::platform::is_installed() + && is_root() + && is_user_main_ipc_scope_cli_command(&args) + { + Some(crate::ipc::UserMainIpcScope::new()) + } else { + None + }; + #[cfg(windows)] { use crate::platform; @@ -938,6 +952,55 @@ fn is_root() -> bool { crate::platform::is_root() } +#[cfg(any(target_os = "linux", target_os = "macos", test))] +fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { + matches!( + args.first().map(String::as_str), + Some("--password") + | Some("--set-unlock-pin") + | Some("--get-id") + | Some("--set-id") + | Some("--config") + | Some("--option") + | Some("--assign") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(values: &[&str]) -> Vec { + values.iter().map(|value| value.to_string()).collect() + } + + #[test] + fn user_main_ipc_scope_cli_command_matches_management_commands_only() { + for command in [ + "--password", + "--set-unlock-pin", + "--get-id", + "--set-id", + "--config", + "--option", + "--assign", + ] { + assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + + for command in [ + "--service", + "--server", + "--tray", + "--cm", + "--check-hwcodec-config", + "--connect", + ] { + assert!(!is_user_main_ipc_scope_cli_command(&args(&[command]))); + } + } +} + /// Check if the executable is a Quick Support version. /// Note: This function must be kept in sync with `libs/portable/src/main.rs`. #[cfg(windows)] diff --git a/src/ipc.rs b/src/ipc.rs index 0cd30634a..ffe1b08a5 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -33,25 +33,25 @@ use hbb_common::{ tokio_util::codec::Framed, ResultType, }; -#[cfg(any(target_os = "linux", target_os = "macos"))] -use ipc_auth::authorize_service_scoped_ipc_connection; #[cfg(windows)] pub(crate) use ipc_auth::authorize_windows_portable_service_ipc_connection; #[cfg(windows)] pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt; #[cfg(windows)] pub(crate) use ipc_auth::log_rejected_windows_ipc_connection; -#[cfg(target_os = "linux")] -pub(crate) use ipc_auth::{ - active_uid, ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, - log_rejected_uinput_connection, peer_uid_from_fd, -}; +#[cfg(any(target_os = "linux", target_os = "macos"))] +use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection}; #[cfg(windows)] use ipc_auth::{ authorize_windows_main_ipc_connection, portable_service_listener_security_attributes, should_allow_everyone_create_on_windows, }; #[cfg(target_os = "linux")] +pub(crate) use ipc_auth::{ + ensure_peer_executable_matches_current_by_fd, is_allowed_service_peer_uid, + log_rejected_uinput_connection, peer_uid_from_fd, +}; +#[cfg(target_os = "linux")] use ipc_fs::terminal_count_candidate_uids; #[cfg(any(target_os = "linux", target_os = "macos"))] use ipc_fs::{ @@ -63,6 +63,8 @@ use parity_tokio_ipc::{ }; use serde_derive::{Deserialize, Serialize}; #[cfg(any(target_os = "linux", target_os = "macos"))] +use std::cell::Cell; +#[cfg(any(target_os = "linux", target_os = "macos"))] use std::os::unix::fs::PermissionsExt; use std::{ collections::HashMap, @@ -71,12 +73,47 @@ use std::{ // IPC actions here. pub const IPC_ACTION_CLOSE: &str = "close"; +#[cfg(target_os = "windows")] const PORTABLE_SERVICE_IPC_HANDSHAKE_TIMEOUT_MS: u64 = 3_000; +#[cfg(target_os = "windows")] pub(crate) const IPC_TOKEN_LEN: usize = 64; +#[cfg(target_os = "windows")] const IPC_TOKEN_RANDOM_BYTES: usize = IPC_TOKEN_LEN / 2; +#[cfg(target_os = "windows")] const _: () = assert!(IPC_TOKEN_LEN % 2 == 0); pub static EXIT_RECV_CLOSE: AtomicBool = AtomicBool::new(true); +#[cfg(any(target_os = "linux", target_os = "macos"))] +thread_local! { + static USE_USER_MAIN_IPC: Cell = Cell::new(false); +} + +#[must_use = "bind this guard to a local variable to keep the IPC scope active"] +/// Thread-local guard for routing root main IPC to the active user on Linux/macOS. +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) struct UserMainIpcScope { + previous: bool, +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl UserMainIpcScope { + pub(crate) fn new() -> Self { + let previous = USE_USER_MAIN_IPC.with(|use_user_main| { + let previous = use_user_main.get(); + use_user_main.set(true); + previous + }); + Self { previous } + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +impl Drop for UserMainIpcScope { + fn drop(&mut self) { + USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.set(self.previous)); + } +} + #[inline] pub async fn connect_service(ms_timeout: u64) -> ResultType> { connect(ms_timeout, crate::POSTFIX_SERVICE).await @@ -1112,11 +1149,7 @@ async fn handle(data: Data, stream: &mut Connection) { }; } -pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { - let path = Config::ipc_path(postfix); - connect_with_path(ms_timeout, &path).await -} - +#[cfg(target_os = "windows")] pub(crate) fn generate_one_time_ipc_token() -> ResultType { use hbb_common::rand::{rngs::OsRng, RngCore as _}; use std::fmt::Write as _; @@ -1137,6 +1170,7 @@ pub(crate) fn generate_one_time_ipc_token() -> ResultType { Ok(token) } +#[cfg(target_os = "windows")] pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> bool { if expected.len() != IPC_TOKEN_LEN || candidate.len() != IPC_TOKEN_LEN { return false; @@ -1149,6 +1183,7 @@ pub(crate) fn constant_time_ipc_token_eq(expected: &str, candidate: &str) -> boo == 0 } +#[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_client( stream: &mut ConnectionTmpl, token: &str, @@ -1173,6 +1208,7 @@ where } } +#[cfg(target_os = "windows")] pub(crate) async fn portable_service_ipc_handshake_as_server( stream: &mut ConnectionTmpl, mut validate_token: F, @@ -1209,6 +1245,103 @@ async fn connect_with_path(ms_timeout: u64, path: &str) -> ResultType, + prefer_root: bool, +) -> ResultType { + let mut server_uids = server_uids.to_vec(); + server_uids.sort_unstable(); + server_uids.dedup(); + + match server_uids.as_slice() { + [] => { + if let Some(uid) = active_uid { + // If no `--server` processes are found but the active user is identifiable, + // try the active user anyway because the main process may also listen on "" IPC. + return Ok(uid); + } else { + bail!("No --server process found for user main IPC") + } + } + [uid] => return Ok(*uid), + _ => {} + } + + if prefer_root && server_uids.contains(&0) { + return Ok(0); + } + if let Some(active_uid) = active_uid.filter(|uid| server_uids.contains(uid)) { + return Ok(active_uid); + } + bail!("Multiple --server processes found for user main IPC"); +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn running_server_uids_for_current_exe() -> ResultType> { + let current_exe = std::env::current_exe()?; + let current_exe_path = std::fs::canonicalize(¤t_exe)?; + let current_pid = hbb_common::sysinfo::Pid::from_u32(std::process::id()); + let mut sys = hbb_common::sysinfo::System::new(); + sys.refresh_processes(); + let mut server_uids = Vec::new(); + for process in sys.processes().values() { + if process.pid() == current_pid { + continue; + } + if process.cmd().get(1).map_or(true, |arg| arg != "--server") { + continue; + } + let Ok(process_path) = std::fs::canonicalize(process.exe()) else { + continue; + }; + if process_path != current_exe_path { + continue; + } + let Some(uid) = process.user_id().map(|uid| **uid as u32) else { + // Root CLI management commands need a stable matching `--server` target. + // If this key process races during enumeration, failing the command is clearer + // than silently skipping it; `--server` is not expected to exit frequently. + bail!("Failed to read --server process uid"); + }; + server_uids.push(uid); + } + Ok(server_uids) +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +fn user_main_ipc_server_uid() -> ResultType { + let server_uids = running_server_uids_for_current_exe()?; + #[cfg(target_os = "linux")] + let prefer_root = crate::platform::linux::is_login_screen_wayland(); + #[cfg(target_os = "macos")] + let prefer_root = false; + select_server_uid_for_user_main_ipc(&server_uids, active_uid(), prefer_root) +} + +pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType> { + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + let use_user_main_ipc = USE_USER_MAIN_IPC.with(|use_user_main| use_user_main.get()); + let is_root_main_ipc = + unsafe { hbb_common::libc::geteuid() == 0 } && postfix.is_empty() && use_user_main_ipc; + if is_root_main_ipc { + let uid = user_main_ipc_server_uid()?; + let path = Config::ipc_path_for_uid(uid, postfix); + return connect_with_path(ms_timeout, &path).await; + } + let path = Config::ipc_path(postfix); + return connect_with_path(ms_timeout, &path).await; + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let path = Config::ipc_path(postfix); + connect_with_path(ms_timeout, &path).await + } +} + #[cfg(target_os = "linux")] pub async fn connect_for_uid( ms_timeout: u64, @@ -2002,7 +2135,16 @@ mod test { assert!(std::mem::size_of::() <= 120); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_service_ipc_path_is_shared_across_uids() { + assert_eq!( + Config::ipc_path_for_uid(0, crate::POSTFIX_SERVICE), + Config::ipc_path_for_uid(501, crate::POSTFIX_SERVICE) + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn test_ipc_path_differs_by_uid_for_cm() { let effective_uid = unsafe { hbb_common::libc::geteuid() as u32 }; @@ -2021,4 +2163,46 @@ mod test { Config::ipc_path_for_uid(other_uid, postfix) ); } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_active_uid_when_no_server_found() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_uses_single_server_uid() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[501], None, false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_active_uid_with_multiple_servers() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), false).unwrap(), + 501 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_prefers_root_on_wayland_login_screen() { + assert_eq!( + select_server_uid_for_user_main_ipc(&[0, 501], Some(501), true).unwrap(), + 0 + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn test_select_server_uid_fails_when_multiple_servers_are_ambiguous() { + assert!(select_server_uid_for_user_main_ipc(&[501, 502], None, false).is_err()); + } } diff --git a/src/ipc/auth.rs b/src/ipc/auth.rs index 746a32eed..77fd148c6 100644 --- a/src/ipc/auth.rs +++ b/src/ipc/auth.rs @@ -607,27 +607,30 @@ pub(crate) fn log_rejected_windows_ipc_connection( peer_session_id: Option, expected_session_id: Option, peer_is_system: Option, + peer_is_elevated: Option, ) { static LOG_THROTTLE: OnceLock> = OnceLock::new(); throttled_unauthorized_ipc_log(&LOG_THROTTLE, |suppressed| { if suppressed > 0 { log::warn!( - "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?} (suppressed {} similar events)", + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?} (suppressed {} similar events)", postfix, peer_pid, peer_session_id, expected_session_id, peer_is_system, + peer_is_elevated, suppressed ); } else { log::warn!( - "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}", + "Rejected unauthorized connection on ipc channel: postfix={}, peer_pid={:?}, peer_session_id={:?}, expected_session_id={:?}, peer_is_system={:?}, peer_is_elevated={:?}", postfix, peer_pid, peer_session_id, expected_session_id, - peer_is_system + peer_is_system, + peer_is_elevated ); } }); @@ -655,8 +658,14 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf #[cfg(windows)] pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool { - let (authorized, peer_pid, peer_session_id, server_session_id, peer_is_system) = - stream.server_authorization_status(); + let ( + authorized, + peer_pid, + peer_session_id, + server_session_id, + peer_is_system, + peer_is_elevated, + ) = stream.server_authorization_status(); if !authorized { log_rejected_windows_ipc_connection( postfix, @@ -664,6 +673,7 @@ pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix peer_session_id, server_session_id, peer_is_system, + peer_is_elevated, ); return false; } @@ -776,7 +786,14 @@ impl ConnectionTmpl { fn server_authorization_status( &self, - ) -> (bool, Option, Option, Option, Option) { + ) -> ( + bool, + Option, + Option, + Option, + Option, + Option, + ) { let peer_pid = self.peer_pid(); let server_session_id = crate::platform::windows::get_current_process_session_id(); let peer_session_id = @@ -786,20 +803,34 @@ impl ConnectionTmpl { let peer_is_system = peer_is_system_result .as_ref() .and_then(|r| r.as_ref().ok().copied()); - if server_session_id.is_none() && !peer_is_system.unwrap_or(false) { - // When the server session id cannot be determined, the session-id allow-path is - // disabled and only SYSTEM peers can be authorized. - log::debug!( - "IPC authorization: server session id unavailable; rejecting non-SYSTEM peer, peer_pid={:?}, peer_session_id={:?}", - peer_pid, - peer_session_id - ); - } - let authorized = is_allowed_windows_session_scoped_peer( + let session_authorized = is_allowed_windows_session_scoped_peer( peer_is_system.unwrap_or(false), peer_session_id, server_session_id, ); + let peer_is_elevated_result = if session_authorized { + None + } else { + peer_pid.map(|pid| crate::platform::windows::is_elevated(Some(pid))) + }; + let peer_is_elevated = peer_is_elevated_result + .as_ref() + .and_then(|r| r.as_ref().ok().copied()); + if server_session_id.is_none() + && !peer_is_system.unwrap_or(false) + && !peer_is_elevated.unwrap_or(false) + { + // When the server session id cannot be determined, the session-id allow-path is + // disabled and only privileged peers can be authorized. + log::debug!( + "IPC authorization: server session id unavailable; rejecting non-privileged peer, peer_pid={:?}, peer_session_id={:?}", + peer_pid, + peer_session_id + ); + } + // Main IPC trusts same-session peers, LocalSystem, and elevated administrators. + // Service-scoped IPC channels keep their own stricter authorization paths. + let authorized = session_authorized || peer_is_elevated.unwrap_or(false); if !authorized { if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_system_result.as_ref()) { log::debug!( @@ -808,6 +839,13 @@ impl ConnectionTmpl { err ); } + if let (Some(pid), Some(Err(err))) = (peer_pid, peer_is_elevated_result.as_ref()) { + log::debug!( + "Failed to determine whether peer process is elevated, pid={}, err={}", + pid, + err + ); + } } ( authorized, @@ -815,6 +853,7 @@ impl ConnectionTmpl { peer_session_id, server_session_id, peer_is_system, + peer_is_elevated, ) } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a755714f9..1dc4a788a 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -614,6 +614,7 @@ fn authorize_service_scoped_ipc_connection( peer_session_id, expected_active_session_id, peer_is_system, + None, ); return false; } From 78e8134ad56094f58c53eaff2edae07a3845da55 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 16:52:22 +0800 Subject: [PATCH 08/13] fix(ipc): cmdline, use scope, deploy (#15068) Signed-off-by: fufesou --- src/core_main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core_main.rs b/src/core_main.rs index ee2a9d90d..c9c1a658f 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -963,6 +963,7 @@ fn is_user_main_ipc_scope_cli_command(args: &[String]) -> bool { | Some("--config") | Some("--option") | Some("--assign") + | Some("--deploy") ) } From bb51c6aa4207b53904a2528615c5b94a37ecc053 Mon Sep 17 00:00:00 2001 From: fufesou Date: Mon, 18 May 2026 17:03:04 +0800 Subject: [PATCH 09/13] fix(ipc): cmdline, unit tests (#15069) Signed-off-by: fufesou --- src/core_main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core_main.rs b/src/core_main.rs index c9c1a658f..4515faa6b 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -985,6 +985,7 @@ mod tests { "--config", "--option", "--assign", + "--deploy", ] { assert!(is_user_main_ipc_scope_cli_command(&args(&[command]))); } From c19698fa524894657bc9631933197de436643ffb Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 18 May 2026 18:47:13 +0800 Subject: [PATCH 10/13] fix: route WebRTC ICE through rendezvous paths --- libs/hbb_common | 2 +- src/client.rs | 59 +++++++++++++++++++++++++------------- src/rendezvous_mediator.rs | 38 +++++++----------------- 3 files changed, 50 insertions(+), 49 deletions(-) diff --git a/libs/hbb_common b/libs/hbb_common index c7f556786..5a78ec423 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit c7f55678653cae6abb3d28e80f91844f727c4be2 +Subproject commit 5a78ec42303ca046d808742e911a156636a2432b diff --git a/src/client.rs b/src/client.rs index 2f9f02ad5..fd4a5e881 100644 --- a/src/client.rs +++ b/src/client.rs @@ -384,6 +384,10 @@ impl Client { } } + fn is_expected_webrtc_ice_candidate(ice: &IceCandidate, session_key: &str) -> bool { + !session_key.is_empty() && ice.session_key == session_key && !ice.candidate.is_empty() + } + fn spawn_webrtc_ice_bridge( mut socket: Stream, mut local_ice_rx: Option>, @@ -392,7 +396,6 @@ impl Client { session_key: String, ) -> oneshot::Sender<()> { let (stop_tx, mut stop_rx) = oneshot::channel::<()>(); - let my_id = Config::get_id(); tokio::spawn(async move { loop { match stop_rx.try_recv() { @@ -406,8 +409,7 @@ impl Client { Ok(candidate) => { let mut msg = RendezvousMessage::new(); msg.set_ice_candidate(IceCandidate { - from_id: my_id.clone(), - to_id: peer.clone(), + id: peer.clone(), session_key: session_key.clone(), candidate, ..Default::default() @@ -430,10 +432,7 @@ impl Client { crate::get_next_nonkeyexchange_msg(&mut socket, Some(100)).await { if let Some(rendezvous_message::Union::IceCandidate(ice)) = msg_in.union { - if ice.from_id == peer - && ice.to_id == my_id - && ice.session_key == session_key - { + if Self::is_expected_webrtc_ice_candidate(&ice, &session_key) { if let Err(err) = webrtc.add_remote_ice_candidate(&ice.candidate).await { log::warn!("failed to add WebRTC ICE candidate: {}", err); @@ -564,7 +563,7 @@ impl Client { .unwrap_or_default(); let mut webrtc_sdp_answer = String::new(); let mut pending_webrtc_ice = Vec::::new(); - for i in 1..=3 { + 'punch_attempts: for i in 1..=3 { log::info!( "#{} {} punch attempt with {}, id: {}", i, @@ -574,9 +573,20 @@ impl Client { ); socket.send(&msg_out).await?; // below timeout should not bigger than hbbs's connection timeout. - if let Some(msg_in) = - crate::get_next_nonkeyexchange_msg(&mut socket, Some(i * 3000)).await - { + let attempt_deadline = Instant::now() + Duration::from_millis((i * 3000) as u64); + loop { + let remaining = attempt_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + let timeout_ms = remaining + .as_millis() + .clamp(1, u64::MAX as u128) as u64; + let Some(msg_in) = + crate::get_next_nonkeyexchange_msg(&mut socket, Some(timeout_ms)).await + else { + break; + }; match msg_in.union { Some(rendezvous_message::Union::PunchHoleResponse(ph)) => { if ph.socket_addr.is_empty() { @@ -624,7 +634,7 @@ impl Client { } } log::info!("{} Hole Punched {} = {}", punch_type, peer, peer_addr); - break; + break 'punch_attempts; } } Some(rendezvous_message::Union::RelayResponse(rr)) => { @@ -722,17 +732,12 @@ impl Client { )); } Some(rendezvous_message::Union::IceCandidate(ice)) => { - if !webrtc_session_key.is_empty() - && ice.from_id == peer - && ice.to_id == Config::get_id() - && ice.session_key == webrtc_session_key - { + if Self::is_expected_webrtc_ice_candidate(&ice, &webrtc_session_key) { pending_webrtc_ice.push(ice.candidate); } else { log::debug!( - "dropping ICE candidate for unexpected WebRTC session from {} key {}", - ice.from_id, - ice.session_key + "dropping ICE candidate for unexpected WebRTC session key {}", + ice.session_key, ); } } @@ -4320,8 +4325,22 @@ pub mod peer_online { #[cfg(test)] mod tests { + use crate::client::Client; + use hbb_common::rendezvous_proto::IceCandidate; use hbb_common::tokio; + #[test] + fn accepts_webrtc_ice_by_session_key_only() { + let ice = IceCandidate { + session_key: "session-a".to_owned(), + candidate: "candidate-json".to_owned(), + ..Default::default() + }; + + assert!(Client::is_expected_webrtc_ice_candidate(&ice, "session-a")); + assert!(!Client::is_expected_webrtc_ice_candidate(&ice, "session-b")); + } + #[tokio::test] async fn test_query_onlines() { super::query_online_states( diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index 1c15aecb5..41fc75b18 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -40,10 +40,6 @@ use crate::{ type Message = RendezvousMessage; type RendezvousSender = mpsc::UnboundedSender; -fn webrtc_ice_key(peer_id: &str, session_key: &str) -> String { - format!("{}\n{}", peer_id, session_key) -} - lazy_static::lazy_static! { static ref SOLVING_PK_MISMATCH: Mutex = Default::default(); static ref LAST_MSG: Mutex<(SocketAddr, Instant)> = Mutex::new((SocketAddr::new([0; 4].into(), 0), Instant::now())); @@ -386,17 +382,12 @@ impl RendezvousMediator { }); } Some(rendezvous_message::Union::IceCandidate(ice)) => { - if ice.to_id != Config::get_id() { - return Ok(()); - } - let key = webrtc_ice_key(&ice.from_id, &ice.session_key); - let tx = WEBRTC_ICE_TXS.lock().await.get(&key).cloned(); + let tx = WEBRTC_ICE_TXS.lock().await.get(&ice.session_key).cloned(); if let Some(tx) = tx { let _ = tx.send(ice.candidate); } else { log::debug!( - "dropping ICE candidate for unknown WebRTC session from {} key {}", - ice.from_id, + "dropping ICE candidate for unknown WebRTC session key {}", ice.session_key ); } @@ -659,26 +650,22 @@ impl RendezvousMediator { async fn spawn_webrtc_answerer( &self, ph: &PunchHole, + force_relay: bool, server: ServerPtr, peer_addr: SocketAddr, control_permissions: Option, ) -> ResultType { - if ph.requester_id.is_empty() { - log::warn!("WebRTC offer missing requester_id; falling back to existing transports"); - return Ok(String::new()); - } - let mut stream = - WebRTCStream::new(&ph.webrtc_sdp_offer, ph.force_relay, CONNECT_TIMEOUT).await?; + WebRTCStream::new(&ph.webrtc_sdp_offer, force_relay, CONNECT_TIMEOUT).await?; let answer = stream.get_local_endpoint().await?; let session_key = stream.session_key().to_owned(); - let peer_id = ph.requester_id.clone(); + let return_route = ph.socket_addr.clone(); let (remote_ice_tx, mut remote_ice_rx) = mpsc::unbounded_channel::(); WEBRTC_ICE_TXS .lock() .await - .insert(webrtc_ice_key(&peer_id, &session_key), remote_ice_tx); + .insert(session_key.clone(), remote_ice_tx); let stream_for_remote_ice = stream.clone(); tokio::spawn(async move { @@ -692,15 +679,13 @@ impl RendezvousMediator { if let Some(mut local_ice_rx) = stream.take_local_ice_rx() { let sender = self.rz_sender.clone(); - let my_id = Config::get_id(); - let target_id = peer_id.clone(); + let socket_addr = return_route.clone(); let session_key_for_ice = session_key.clone(); tokio::spawn(async move { while let Some(candidate) = local_ice_rx.recv().await { let mut msg = Message::new(); msg.set_ice_candidate(IceCandidate { - from_id: my_id.clone(), - to_id: target_id.clone(), + socket_addr: socket_addr.clone(), session_key: session_key_for_ice.clone(), candidate, ..Default::default() @@ -710,17 +695,13 @@ impl RendezvousMediator { }); } - let peer_id_for_cleanup = peer_id.clone(); let session_key_for_cleanup = session_key.clone(); tokio::spawn(async move { let result = stream.wait_connected(CONNECT_TIMEOUT).await; WEBRTC_ICE_TXS .lock() .await - .remove(&webrtc_ice_key( - &peer_id_for_cleanup, - &session_key_for_cleanup, - )); + .remove(&session_key_for_cleanup); if let Err(err) = result { log::warn!("webrtc wait_connected failed: {}", err); return; @@ -756,6 +737,7 @@ impl RendezvousMediator { let webrtc_sdp_answer = if !ph.webrtc_sdp_offer.is_empty() { self.spawn_webrtc_answerer( &ph, + relay, server.clone(), peer_addr, control_permissions.clone(), From 546e9f1702572c4d5c9ce0d0f977cea17ba53c8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 09:07:43 +0800 Subject: [PATCH 11/13] Git submodule: Bump libs/hbb_common from `c8cbb6b` to `9043c15` (#15067) Bumps [libs/hbb_common](https://github.com/rustdesk/hbb_common) from `c8cbb6b` to `9043c15`. - [Release notes](https://github.com/rustdesk/hbb_common/releases) - [Commits](https://github.com/rustdesk/hbb_common/compare/c8cbb6be283e9215da87625016fe8838dda76c02...9043c15acc6d5b42b6c12ad284c16c1ec172f1f0) --- updated-dependencies: - dependency-name: libs/hbb_common dependency-version: 9043c15acc6d5b42b6c12ad284c16c1ec172f1f0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From b81ae6c8949a50f64bac175f32217b8897078a2d Mon Sep 17 00:00:00 2001 From: Maison da Silva Date: Fri, 22 May 2026 07:36:15 -0300 Subject: [PATCH 12/13] Translate various labels to Portuguese-BR (#15086) Update --- src/lang/ptbr.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 4eb2c1544..36581d4f1 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -740,9 +740,9 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-outgoing-sessions-label", "Manter tela ativa durante sessões de saída"), ("keep-awake-during-incoming-sessions-label", "Manter tela ativa durante sessões de entrada"), ("Continue with {}", "Continuar com {}"), - ("Display Name", ""), - ("password-hidden-tip", ""), - ("preset-password-in-use-tip", ""), - ("Enable privacy mode", ""), + ("Display Name", "Nome de Exibição"), + ("password-hidden-tip", "A senha permanente está definida como (oculta)."), + ("preset-password-in-use-tip", "A senha predefinida está sendo usada."), + ("Enable privacy mode", "Habilitar modo de privacidade"), ].iter().cloned().collect(); } From 6ad56075d6d6b809f5699963bc417c48a138347c Mon Sep 17 00:00:00 2001 From: Luke <81411590+LukeCGG@users.noreply.github.com> Date: Sun, 24 May 2026 21:08:45 +1000 Subject: [PATCH 13/13] Drag whole toolbar; snap to all four edges of the remote session window (#15051) * Drag whole toolbar; snap to all four edges Today the drag handle on the remote-session toolbar repositions only the handle row -- the icons themselves stay centered at the top. This change applies the position to the entire toolbar wrapper so dragging the handle moves the whole thing, and extends snapping from top-only to any of the four window edges. When docked left/right the toolbar reflows vertically. A live ghost preview shows where the toolbar will land while you drag, with a small hysteresis bias to keep the preview from flickering near corners. The legacy 'remote-menubar-drag-x' session option is read as a fallback on first load so existing users keep their saved horizontal position; new option keys are 'remote-menubar-edge' and 'remote-menubar-frac'. Tested locally on Windows. macOS / Linux / web desktop use the same shared widget with no platform-specific calls, but I did not verify them. * Load edge independently and clamp loaded fraction Addresses CodeRabbit review on #15051: parse the saved edge regardless of whether the new fraction option is present so a partial write of frac doesn't reset the toolbar back to top, and clamp the loaded fraction to the kOptionRemoteMenubarDragLeft/Right contract so a corrupted or out-of-range saved value can't bypass the bounds until the user drags again. * Require edge activation zone to switch dock; preserve horizontal slide Per review feedback on #15051: nearest-edge-wins made a low-intent horizontal slide too easy to escalate into a high-impact orientation change (vertical reflow on left/right dock). The default drag now keeps the toolbar on its current dock edge and just updates the fraction along that edge -- the prior horizontal-slide behavior. An alternate edge is only previewed/committed when the cursor enters its 32 px activation zone; once previewed, the cursor has to move back 64 px before reverting (hysteresis at the zone boundary). * Gate multi-edge docking behind a settings toggle; default = horizontal slide Replaces the activation-zone approach with an explicit opt-in setting in Settings -> Other ("Allow docking remote toolbar to any window edge"). This addresses the concern that a low-intent horizontal drag shouldn't be able to trigger a high-impact orientation change, while still letting users who want multi-edge docking opt in cleanly. Default (toggle off): - The original horizontal slide is preserved. - The bug fix from the first commit still applies: dragging the handle moves the whole toolbar, and the position persists across collapse/expand (no more re-center on re-open). - Draggable is axis-locked to horizontal so the feedback widget stays on the top line during drag. Opt-in (toggle on): - Full nearest-edge wins with the live preview ghost and corner hysteresis; toolbar reflows vertically on left/right docks. - Draggable is unlocked for 2D drag. Reads the option via mainGetLocalBoolOptionSync so the toolbar's default state matches what the settings checkbox shows; the option key uses the allow- prefix so unset defaults to off. Takes effect on next session (setting is read at session init). The setting key (allow-multi-edge-toolbar-dock) is read by the existing local-options machinery and persists per-install without needing to be registered in libs/hbb_common's KEYS_LOCAL_SETTINGS. Can add that registration in a parallel hbb_common PR if preferred. * Fix remote toolbar drag positioning & persistence Align drag fraction calculation with the toolbar's actual travel range, keep preview sizing stable during drag, and preserve legacy horizontal position storage when multi-edge docking is disabled. Signed-off-by: fufesou * Remote toolbar snap edges 1. Translations 2. Apply option to remote windows on changed Signed-off-by: fufesou * fix: avoid remote toolbar docking jumps on setting reload Signed-off-by: fufesou * Fix remote toolbar docking updates and drag sync Signed-off-by: fufesou * refact: translation key Signed-off-by: fufesou * feat(toolbar-snap-edges): test web Signed-off-by: fufesou * Fix remote toolbar docking sync and vertical layout Signed-off-by: fufesou * Fix remote toolbar monitor controls on side docks Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: fufesou --- flutter/lib/consts.dart | 4 + .../desktop/pages/desktop_setting_page.dart | 10 + .../lib/desktop/widgets/remote_toolbar.dart | 841 +++++++++++++++--- flutter/lib/models/input_model.dart | 2 +- src/lang/ar.rs | 1 + src/lang/be.rs | 1 + src/lang/bg.rs | 1 + src/lang/ca.rs | 1 + src/lang/cn.rs | 1 + src/lang/cs.rs | 1 + src/lang/da.rs | 1 + src/lang/de.rs | 1 + src/lang/el.rs | 1 + src/lang/en.rs | 1 + src/lang/eo.rs | 1 + src/lang/es.rs | 1 + src/lang/et.rs | 1 + src/lang/eu.rs | 1 + src/lang/fa.rs | 1 + src/lang/fi.rs | 1 + src/lang/fr.rs | 1 + src/lang/ge.rs | 1 + src/lang/gu.rs | 2 + src/lang/he.rs | 1 + src/lang/hi.rs | 3 + src/lang/hr.rs | 1 + src/lang/hu.rs | 1 + src/lang/id.rs | 1 + src/lang/it.rs | 1 + src/lang/ja.rs | 1 + src/lang/ko.rs | 1 + src/lang/kz.rs | 1 + src/lang/lt.rs | 1 + src/lang/lv.rs | 1 + src/lang/ml.rs | 3 + src/lang/nb.rs | 1 + src/lang/nl.rs | 1 + src/lang/pl.rs | 1 + src/lang/pt_PT.rs | 1 + src/lang/ptbr.rs | 1 + src/lang/ro.rs | 3 +- src/lang/ru.rs | 1 + src/lang/sc.rs | 1 + src/lang/sk.rs | 1 + src/lang/sl.rs | 1 + src/lang/sq.rs | 1 + src/lang/sr.rs | 1 + src/lang/sv.rs | 1 + src/lang/ta.rs | 1 + src/lang/template.rs | 1 + src/lang/th.rs | 1 + src/lang/tr.rs | 1 + src/lang/tw.rs | 1 + src/lang/uk.rs | 1 + src/lang/vi.rs | 1 + 55 files changed, 802 insertions(+), 113 deletions(-) diff --git a/flutter/lib/consts.dart b/flutter/lib/consts.dart index 832b96d24..adf7b1d45 100644 --- a/flutter/lib/consts.dart +++ b/flutter/lib/consts.dart @@ -142,6 +142,10 @@ const String kOptionSwapLeftRightMouse = "swap-left-right-mouse"; const String kOptionCodecPreference = "codec-preference"; const String kOptionRemoteMenubarDragLeft = "remote-menubar-drag-left"; const String kOptionRemoteMenubarDragRight = "remote-menubar-drag-right"; +const String kOptionRemoteMenubarEdge = "remote-menubar-edge"; +const String kOptionRemoteMenubarFraction = "remote-menubar-frac"; +const String kOptionAllowMultiEdgeToolbarDock = + "allow-multi-edge-toolbar-dock"; const String kOptionHideAbTagsPanel = "hideAbTagsPanel"; const String kOptionRemoteMenubarState = "remoteMenubarState"; const String kOptionPeerSorting = "peer-sorting"; diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 2841c1d27..d1d620014 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -488,6 +488,16 @@ class _GeneralState extends State<_General> { _OptionCheckBox(context, 'Confirm before closing multiple tabs', kOptionEnableConfirmClosingTabs, isServer: false), + if (!bind.isIncomingOnly()) + _OptionCheckBox( + context, + 'allow-remote-toolbar-docking-any-edge', + kOptionAllowMultiEdgeToolbarDock, + isServer: false, + update: (_) { + reloadAllWindows(); + }, + ), _OptionCheckBox(context, 'Adaptive bitrate', kOptionEnableAbr), if (!isWeb) wallpaper(), if (!isWeb && !bind.isIncomingOnly()) ...[ diff --git a/flutter/lib/desktop/widgets/remote_toolbar.dart b/flutter/lib/desktop/widgets/remote_toolbar.dart index 645cbe1cb..44a2dc1c7 100644 --- a/flutter/lib/desktop/widgets/remote_toolbar.dart +++ b/flutter/lib/desktop/widgets/remote_toolbar.dart @@ -28,6 +28,220 @@ import './kb_layout_type_chooser.dart'; import 'package:flutter_hbb/utils/scale.dart'; import 'package:flutter_hbb/common/widgets/custom_scale_base.dart'; +enum _ToolbarEdge { top, right, bottom, left } + +_ToolbarEdge _parseToolbarEdge(String? s) { + switch (s) { + case 'right': + return _ToolbarEdge.right; + case 'bottom': + return _ToolbarEdge.bottom; + case 'left': + return _ToolbarEdge.left; + default: + return _ToolbarEdge.top; + } +} + +String _toolbarEdgeToString(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return 'top'; + case _ToolbarEdge.right: + return 'right'; + case _ToolbarEdge.bottom: + return 'bottom'; + case _ToolbarEdge.left: + return 'left'; + } +} + +bool _isHorizontalEdge(_ToolbarEdge e) => + e == _ToolbarEdge.top || e == _ToolbarEdge.bottom; + +const _legacyRemoteMenubarDragX = 'remote-menubar-drag-x'; + +double _clampToolbarFraction(double fraction, double left, double right) { + if (fraction < left) fraction = left; + if (fraction > right) fraction = right; + return fraction; +} + +Size _toolbarSizeForEdge(_ToolbarEdge edge, Size? measured) { + final isHorizontal = _isHorizontalEdge(edge); + final fallback = isHorizontal ? const Size(360, 40) : const Size(40, 360); + final size = measured ?? fallback; + final long = size.longestSide; + final short = size.shortestSide; + return Size(isHorizontal ? long : short, isHorizontal ? short : long); +} + +Offset _toolbarOffsetForEdge({ + required _ToolbarEdge edge, + required double fraction, + required Size parentSize, + required Size toolbarSize, +}) { + final xTravel = parentSize.width - toolbarSize.width; + final yTravel = parentSize.height - toolbarSize.height; + switch (edge) { + case _ToolbarEdge.top: + return Offset(xTravel * fraction, 0); + case _ToolbarEdge.bottom: + return Offset(xTravel * fraction, yTravel); + case _ToolbarEdge.left: + return Offset(0, yTravel * fraction); + case _ToolbarEdge.right: + return Offset(xTravel, yTravel * fraction); + } +} + +double _fractionForAlignedDrag({ + required double cursor, + required double grabOffset, + required double parentExtent, + required double toolbarExtent, + required double left, + required double right, +}) { + final travelExtent = parentExtent - toolbarExtent; + if (travelExtent <= 0) { + return _clampToolbarFraction(0.5, left, right); + } + return _clampToolbarFraction( + (cursor - grabOffset) / travelExtent, left, right); +} + +({double left, double right}) _fractionBoundsForEdge( + _ToolbarEdge edge, + double left, + double right, +) { + return _isHorizontalEdge(edge) + ? (left: left, right: right) + : (left: 0, right: 1); +} + +String _toolbarRawFraction({ + required bool multiEdgeEnabled, + required _ToolbarEdge edge, + required String? savedFraction, + required String? legacyFraction, +}) { + if (!multiEdgeEnabled) { + return (legacyFraction != null && legacyFraction.isNotEmpty) + ? legacyFraction + : '0.5'; + } + if (savedFraction != null && savedFraction.isNotEmpty) { + return savedFraction; + } + if (edge == _ToolbarEdge.top && + legacyFraction != null && + legacyFraction.isNotEmpty) { + return legacyFraction; + } + return '0.5'; +} + +// Returns the alignment for the wrapper Align that positions the entire +// toolbar against the given edge at the given fraction along that edge. +// Alignment uses [-1, 1] coordinates (0 = center). +Alignment _alignmentForEdge(_ToolbarEdge edge, double fraction) { + final f = fraction * 2 - 1; + switch (edge) { + case _ToolbarEdge.top: + return Alignment(f, -1); + case _ToolbarEdge.bottom: + return Alignment(f, 1); + case _ToolbarEdge.left: + return Alignment(-1, f); + case _ToolbarEdge.right: + return Alignment(1, f); + } +} + +// The drag handle hangs off the side of the toolbar facing away from the +// docked edge, so the icons themselves sit flush against that edge. +BorderRadius _collapseHandleBorderRadius(_ToolbarEdge edge) { + const r = Radius.circular(5); + switch (edge) { + case _ToolbarEdge.top: + return const BorderRadius.vertical(bottom: r); + case _ToolbarEdge.bottom: + return const BorderRadius.vertical(top: r); + case _ToolbarEdge.left: + return const BorderRadius.horizontal(right: r); + case _ToolbarEdge.right: + return const BorderRadius.horizontal(left: r); + } +} + +int _monitorMenuQuarterTurns(_ToolbarEdge edge) { + switch (edge) { + case _ToolbarEdge.left: + return 1; + case _ToolbarEdge.right: + return 3; + case _ToolbarEdge.top: + case _ToolbarEdge.bottom: + return 0; + } +} + +IconData _toolbarCollapseIcon(_ToolbarEdge edge, bool isCollapsed) { + switch (edge) { + case _ToolbarEdge.top: + return isCollapsed ? Icons.expand_more : Icons.expand_less; + case _ToolbarEdge.bottom: + return isCollapsed ? Icons.expand_less : Icons.expand_more; + case _ToolbarEdge.left: + return isCollapsed ? Icons.chevron_right : Icons.chevron_left; + case _ToolbarEdge.right: + return isCollapsed ? Icons.chevron_left : Icons.chevron_right; + } +} + +class _ToolbarDockingOptions { + _ToolbarDockingOptions({ + required this.edge, + required this.fraction, + required this.multiEdgeEnabled, + }); + + _ToolbarEdge edge; + double fraction; + bool multiEdgeEnabled; +} + +final _toolbarDockingOptionsBySession = {}; + +String _toolbarDockingCacheKey(SessionID sessionId) => sessionId.toString(); + +_ToolbarDockingOptions? _cachedToolbarDockingOptions(SessionID sessionId) => + _toolbarDockingOptionsBySession[_toolbarDockingCacheKey(sessionId)]; + +void _cacheToolbarDockingOptions({ + required SessionID sessionId, + required _ToolbarEdge edge, + required double fraction, + required bool multiEdgeEnabled, +}) { + final key = _toolbarDockingCacheKey(sessionId); + final cached = _toolbarDockingOptionsBySession[key]; + if (cached == null) { + _toolbarDockingOptionsBySession[key] = _ToolbarDockingOptions( + edge: edge, + fraction: fraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + return; + } + cached.edge = edge; + cached.fraction = fraction; + cached.multiEdgeEnabled = multiEdgeEnabled; +} + class ToolbarState { late RxBool _pin; @@ -250,8 +464,26 @@ class RemoteToolbar extends StatefulWidget { class _RemoteToolbarState extends State { late Debouncer _debouncerHide; bool _isCursorOverImage = false; - final _fractionX = 0.5.obs; + final _fraction = 0.5.obs; + final _edge = _ToolbarEdge.top.obs; final _dragging = false.obs; + // Live drag preview: where the toolbar would dock if the user dropped now. + final _previewEdge = Rxn<_ToolbarEdge>(); + final _previewFraction = Rxn(); + // Measured size of the live toolbar, so the preview ghost matches reality + // (collapsed handle vs expanded toolbar). Updated after every layout pass. + final _toolbarSize = Rxn(); + final _toolbarKey = GlobalKey(debugLabel: 'remote_toolbar_root'); + // When false (default), the toolbar stays on the top edge and the drag + // handle just slides it horizontally — preserving long-standing UX while + // still fixing the bug where dragging only moved the handle. When true, + // the user has opted into multi-edge docking with nearest-edge snap. + // Kept in sync after settings-triggered rebuilds. + final _multiEdgeEnabled = false.obs; + final _dockingOptionsInitialized = false.obs; + bool _pendingDockingOptionSync = false; + int _dockingOptionSyncSerial = 0; + int _dragEpoch = 0; int get windowId => stateGlobal.windowId; @@ -273,16 +505,144 @@ class _RemoteToolbarState extends State { void _minimize() async => await WindowController.fromWindowId(windowId).minimize(); + Future _syncDockingOptions({required bool force}) async { + final syncSerial = ++_dockingOptionSyncSerial; + if (_dragging.isTrue) { + _deferDockingOptionsSync(); + return; + } + final dragEpoch = _dragEpoch; + + // Use the canonical helper so the option's documented default semantics + // apply (allow-* prefix => default false). Keeping it raw-string would + // diverge from how _OptionCheckBox displays the same key. + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + if (cached == null && pi.isSet.isFalse) { + return; + } + final hadDockingOptions = cached != null; + final wasMultiEdgeEnabled = + cached?.multiEdgeEnabled ?? _multiEdgeEnabled.value; + if (!force && + hadDockingOptions && + wasMultiEdgeEnabled == multiEdgeEnabled) { + _pendingDockingOptionSync = false; + return; + } + + final savedFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarFraction); + // Backward compat: legacy horizontal-only position. + final legacyFraction = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: _legacyRemoteMenubarDragX); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + + var nextEdge = _edge.value; + var savedFractionForNextEdge = savedFraction; + var keepCurrentPosition = false; + if (!multiEdgeEnabled) { + nextEdge = _ToolbarEdge.top; + } else if (force || wasMultiEdgeEnabled || cached == null) { + final edgeStr = await bind.sessionGetOption( + sessionId: widget.ffi.sessionId, arg: kOptionRemoteMenubarEdge); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + nextEdge = _parseToolbarEdge(edgeStr); + } else { + // The setting changed from top-only to multi-edge while this toolbar is + // already visible. Keep its current position instead of jumping to the + // last saved multi-edge dock. + nextEdge = cached.edge; + savedFractionForNextEdge = cached.fraction.toString(); + keepCurrentPosition = true; + } + + final rawFraction = _toolbarRawFraction( + multiEdgeEnabled: multiEdgeEnabled, + edge: nextEdge, + savedFraction: savedFractionForNextEdge, + legacyFraction: legacyFraction, + ); + // Clamp to the saved drag-bound contract so a corrupted or out-of-range + // saved value can't bypass it until the user drags again. + final dragLeft = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragLeft)) ?? + 0.0; + final dragRight = double.tryParse( + bind.mainGetLocalOption(key: kOptionRemoteMenubarDragRight)) ?? + 1.0; + final fractionBounds = + _fractionBoundsForEdge(nextEdge, dragLeft, dragRight); + final nextFraction = (double.tryParse(rawFraction) ?? 0.5) + .clamp(fractionBounds.left, fractionBounds.right) + .toDouble(); + if (!mounted || syncSerial != _dockingOptionSyncSerial) return; + if (_dragging.isTrue || dragEpoch != _dragEpoch) { + _deferDockingOptionsSync(); + return; + } + _edge.value = nextEdge; + _fraction.value = nextFraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + _cacheToolbarDockingOptions( + sessionId: widget.ffi.sessionId, + edge: nextEdge, + fraction: nextFraction, + multiEdgeEnabled: multiEdgeEnabled, + ); + _pendingDockingOptionSync = false; + if (!multiEdgeEnabled || keepCurrentPosition) { + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(nextEdge), + ); + bind.sessionPeerOption( + sessionId: widget.ffi.sessionId, + name: kOptionRemoteMenubarFraction, + value: nextFraction.toString(), + ); + } + } + + void _deferDockingOptionsSync() { + _pendingDockingOptionSync = true; + if (_dragging.isFalse) { + _syncDockingOptionsAfterDragIfNeeded(); + } + } + + void _markToolbarDragEpoch() { + ++_dragEpoch; + } + + void _syncDockingOptionsAfterDragIfNeeded() { + if (!_pendingDockingOptionSync) return; + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + @override initState() { super.initState(); + final cached = _cachedToolbarDockingOptions(widget.ffi.sessionId); + final multiEdgeEnabled = + mainGetLocalBoolOptionSync(kOptionAllowMultiEdgeToolbarDock); + final shouldResetToTop = + cached != null && cached.multiEdgeEnabled && !multiEdgeEnabled; + if (cached != null && !shouldResetToTop) { + _edge.value = cached.edge; + _fraction.value = cached.fraction; + _multiEdgeEnabled.value = multiEdgeEnabled; + _dockingOptionsInitialized.value = true; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { - _fractionX.value = double.tryParse(await bind.sessionGetOption( - sessionId: widget.ffi.sessionId, - arg: 'remote-menubar-drag-x') ?? - '0.5') ?? - 0.5; + await _syncDockingOptions(force: cached == null || shouldResetToTop); // Initialize toolbar states (collapse, hide) from session options widget.state.init(widget.ffi.sessionId); }); @@ -303,6 +663,14 @@ class _RemoteToolbarState extends State { }); } + @override + void didUpdateWidget(covariant RemoteToolbar oldWidget) { + super.didUpdateWidget(oldWidget); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await _syncDockingOptions(force: false); + }); + } + _debouncerHideProc(int v) { if (!pin && collapse.isFalse && _isCursorOverImage && _dragging.isFalse) { collapse.value = true; @@ -311,64 +679,130 @@ class _RemoteToolbarState extends State { @override dispose() { - super.dispose(); - + ++_dockingOptionSyncSerial; widget.onEnterOrLeaveImageCleaner(identityHashCode(this)); + super.dispose(); } @override Widget build(BuildContext context) { return Obx(() { // Wait for initialization to complete to prevent flickering - if (!widget.state.initialized.value) { + if (!widget.state.initialized.value || + !_dockingOptionsInitialized.value) { return const SizedBox.shrink(); } // If toolbar is hidden, return empty widget if (hide.value) { return const SizedBox.shrink(); } - return Align( - alignment: Alignment.topCenter, - child: collapse.isFalse - ? _buildToolbar(context) - : _buildDraggableCollapse(context), + final edge = _edge.value; + final isHorizontal = _isHorizontalEdge(edge); + + // Measure the live toolbar after every layout so the preview ghost can + // match its actual footprint (collapsed handle vs expanded toolbar). + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_dragging.isTrue) return; + final ro = _toolbarKey.currentContext?.findRenderObject(); + if (ro is RenderBox && ro.hasSize) { + final s = ro.size; + if (_toolbarSize.value != s) _toolbarSize.value = s; + } + }); + + final toolbar = Align( + alignment: _alignmentForEdge(edge, _fraction.value), + child: KeyedSubtree( + key: _toolbarKey, + child: collapse.isFalse + ? _buildToolbar(context, edge, isHorizontal) + : _buildDraggableCollapse(context, edge, isHorizontal), + ), + ); + + // Always return the Stack — even when not dragging — so the toolbar's + // position in the Element tree stays stable. Wrapping/unwrapping it + // mid-drag was killing the Draggable's gesture state. + return Stack( + fit: StackFit.expand, + children: [ + IgnorePointer( + child: Obx(() { + final pe = _previewEdge.value; + final pf = _previewFraction.value; + if (!_dragging.isTrue || pe == null || pf == null) { + return const SizedBox.shrink(); + } + return _buildDragPreview(context, pe, pf, _toolbarSize.value); + }), + ), + toolbar, + ], ); }); } - Widget _buildDraggableCollapse(BuildContext context) { + Widget _buildDragPreview(BuildContext context, _ToolbarEdge edge, + double fraction, Size? measured) { + final color = Theme.of(context).colorScheme.primary; + // Use the measured live toolbar size so collapsed vs expanded looks + // right. The current orientation may differ from the preview orientation + // (e.g. dragging a top-docked toolbar toward the left edge), so swap the + // long/short axes when previewing a different orientation. + final previewSize = _toolbarSizeForEdge(edge, measured); + return Align( + alignment: _alignmentForEdge(edge, fraction), + child: Container( + width: previewSize.width, + height: previewSize.height, + decoration: BoxDecoration( + color: color.withOpacity(0.10), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.55), width: 1.5), + ), + ), + ); + } + + Widget _buildDraggableCollapse( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { return Obx(() { if (collapse.isFalse && _dragging.isFalse) { triggerAutoHide(); } - final borderRadius = BorderRadius.vertical( - bottom: Radius.circular(5), - ); - return Align( - alignment: FractionalOffset(_fractionX.value, 0), - child: Offstage( - offstage: _dragging.isTrue, - child: Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, + final borderRadius = _collapseHandleBorderRadius(edge); + return Offstage( + offstage: _dragging.isTrue, + child: Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: borderRadius, + child: _DraggableShowHide( + id: widget.id, + sessionId: widget.ffi.sessionId, + dragging: _dragging, + fraction: _fraction, + edge: _edge, + previewEdge: _previewEdge, + previewFraction: _previewFraction, + toolbarSize: _toolbarSize, + markDragEpoch: _markToolbarDragEpoch, + syncDockingOptionsAfterDragIfNeeded: + _syncDockingOptionsAfterDragIfNeeded, + isHorizontal: isHorizontal, + multiEdgeEnabled: _multiEdgeEnabled.value, + toolbarState: widget.state, + setFullscreen: _setFullscreen, + setMinimize: _minimize, borderRadius: borderRadius, - child: _DraggableShowHide( - id: widget.id, - sessionId: widget.ffi.sessionId, - dragging: _dragging, - fractionX: _fractionX, - toolbarState: widget.state, - setFullscreen: _setFullscreen, - setMinimize: _minimize, - borderRadius: borderRadius, - ), ), ), ); }); } - Widget _buildToolbar(BuildContext context) { + Widget _buildToolbar( + BuildContext context, _ToolbarEdge edge, bool isHorizontal) { final List toolbarItems = []; toolbarItems.add(_PinMenu(state: widget.state)); if (!isWebDesktop) { @@ -382,6 +816,7 @@ class _RemoteToolbarState extends State { return _MonitorMenu( id: widget.id, ffi: widget.ffi, + edge: edge, setRemoteState: widget.setRemoteState); } else { return Offstage(); @@ -407,37 +842,53 @@ class _RemoteToolbarState extends State { if (!isWeb) toolbarItems.add(_RecordMenu()); toolbarItems.add(_CloseMenu(id: widget.id, ffi: widget.ffi)); final toolbarBorderRadius = BorderRadius.all(Radius.circular(4.0)); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Material( - elevation: _ToolbarTheme.elevation, - shadowColor: MyTheme.color(context).shadow, - borderRadius: toolbarBorderRadius, - color: Theme.of(context) - .menuBarTheme - .style - ?.backgroundColor - ?.resolve(MaterialState.values.toSet()), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Theme( - data: themeData(), - child: _ToolbarTheme.borderWrapper( - context, - Row( - children: [ - SizedBox(width: _ToolbarTheme.buttonHMargin * 2), - ...toolbarItems, - SizedBox(width: _ToolbarTheme.buttonHMargin * 2) - ], - ), - toolbarBorderRadius), - ), - ), + // innerAxis: how the toolbar icons themselves flow. + // outerAxis: how the toolbar block and the handle stack against each other + // (perpendicular to the dock edge, so the handle hangs off the interior face). + final innerAxis = isHorizontal ? Axis.horizontal : Axis.vertical; + final outerAxis = isHorizontal ? Axis.vertical : Axis.horizontal; + final spacer = isHorizontal + ? SizedBox(width: _ToolbarTheme.buttonHMargin * 2) + : SizedBox(height: _ToolbarTheme.buttonHMargin * 2); + final toolbarMaterial = Material( + elevation: _ToolbarTheme.elevation, + shadowColor: MyTheme.color(context).shadow, + borderRadius: toolbarBorderRadius, + color: Theme.of(context) + .menuBarTheme + .style + ?.backgroundColor + ?.resolve(MaterialState.values.toSet()), + child: SingleChildScrollView( + scrollDirection: innerAxis, + child: Theme( + data: themeData(), + child: _ToolbarTheme.borderWrapper( + context, + Flex( + direction: innerAxis, + mainAxisSize: MainAxisSize.min, + children: [ + spacer, + ...toolbarItems, + spacer, + ], + ), + toolbarBorderRadius), ), - _buildDraggableCollapse(context), - ], + ), + ); + final handle = _buildDraggableCollapse(context, edge, isHorizontal); + // The handle hangs off the interior face of the toolbar (away from the + // docked edge), centered along that face by the Flex's default cross-axis + // alignment, so the icons themselves sit flush against the docked edge. + final children = (edge == _ToolbarEdge.top || edge == _ToolbarEdge.left) + ? [toolbarMaterial, handle] + : [handle, toolbarMaterial]; + return Flex( + direction: outerAxis, + mainAxisSize: MainAxisSize.min, + children: children, ); } @@ -516,11 +967,13 @@ class _MobileActionMenu extends StatelessWidget { class _MonitorMenu extends StatelessWidget { final String id; final FFI ffi; + final _ToolbarEdge edge; final Function(VoidCallback) setRemoteState; const _MonitorMenu({ Key? key, required this.id, required this.ffi, + required this.edge, required this.setRemoteState, }) : super(key: key); @@ -531,9 +984,17 @@ class _MonitorMenu extends StatelessWidget { !isWeb && ffi.ffiModel.pi.isSupportMultiDisplay; @override - Widget build(BuildContext context) => showMonitorsToolbar - ? buildMultiMonitorMenu(context) - : Obx(() => buildMonitorMenu(context)); + Widget build(BuildContext context) { + final child = showMonitorsToolbar + ? buildMultiMonitorMenu(context) + : Obx(() => buildMonitorMenu(context)); + final quarterTurns = _monitorMenuQuarterTurns(edge); + if (quarterTurns == 0) return child; + return RotatedBox( + quarterTurns: quarterTurns, + child: child, + ); + } Widget buildMonitorMenu(BuildContext context) { final width = SimpleWrapper(0); @@ -665,7 +1126,8 @@ class _MonitorMenu extends StatelessWidget { } final scale = _ToolbarTheme.buttonSize / rect.height * 0.75; - final startY = (_ToolbarTheme.buttonSize - rect.height * scale) * 0.5; + final height = rect.height * scale; + final startY = (_ToolbarTheme.buttonSize - height) * 0.5; final startX = startY; final children = []; @@ -708,7 +1170,7 @@ class _MonitorMenu extends StatelessWidget { width.value = rect.width * scale + startX * 2; return SizedBox( width: width.value, - height: rect.height * scale + startY * 2, + height: height + startY * 2, child: Stack( children: children, ), @@ -2519,7 +2981,18 @@ class RdoMenuButton extends StatelessWidget { class _DraggableShowHide extends StatefulWidget { final String id; final SessionID sessionId; - final RxDouble fractionX; + final RxDouble fraction; + final Rx<_ToolbarEdge> edge; + final Rxn<_ToolbarEdge> previewEdge; + final Rxn previewFraction; + final Rxn toolbarSize; + final VoidCallback markDragEpoch; + final VoidCallback syncDockingOptionsAfterDragIfNeeded; + final bool isHorizontal; + // Whether multi-edge docking is enabled for this session (toggled in + // Settings -> Other). When false, the drag handle slides the toolbar + // horizontally on the top edge and never switches edges. + final bool multiEdgeEnabled; final RxBool dragging; final ToolbarState toolbarState; final BorderRadius borderRadius; @@ -2531,7 +3004,15 @@ class _DraggableShowHide extends StatefulWidget { Key? key, required this.id, required this.sessionId, - required this.fractionX, + required this.fraction, + required this.edge, + required this.previewEdge, + required this.previewFraction, + required this.toolbarSize, + required this.markDragEpoch, + required this.syncDockingOptionsAfterDragIfNeeded, + required this.isHorizontal, + required this.multiEdgeEnabled, required this.dragging, required this.toolbarState, required this.setFullscreen, @@ -2544,10 +3025,12 @@ class _DraggableShowHide extends StatefulWidget { } class _DraggableShowHideState extends State<_DraggableShowHide> { - Offset position = Offset.zero; - Size size = Size.zero; double left = 0.0; double right = 1.0; + Offset? _lastPointerDown; + Offset? _dragGrabOffset; + double? _dragLongAxisGrabOffset; + Size? _dragToolbarSize; RxBool get collapse => widget.toolbarState.collapse; @@ -2573,41 +3056,174 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { } } + // Bias applied to the currently-previewed edge so a drag hovering between + // two edges doesn't flicker. Only relevant when multi-edge is enabled. + static const double _switchHysteresisPx = 50.0; + + _ToolbarEdge _nearestToolbarEdge(Offset cursor, Size mediaSize) { + if (!widget.multiEdgeEnabled) return widget.edge.value; + + double rawDist(_ToolbarEdge e) { + switch (e) { + case _ToolbarEdge.top: + return cursor.dy; + case _ToolbarEdge.bottom: + return mediaSize.height - cursor.dy; + case _ToolbarEdge.left: + return cursor.dx; + case _ToolbarEdge.right: + return mediaSize.width - cursor.dx; + } + } + + final previewed = widget.previewEdge.value; + var winner = widget.edge.value; + var best = double.infinity; + for (final e in _ToolbarEdge.values) { + final biased = + e == previewed ? rawDist(e) - _switchHysteresisPx : rawDist(e); + if (biased < best) { + best = biased; + winner = e; + } + } + return winner; + } + + void _ensureDragGrabOffset(Offset cursor) { + if (_dragGrabOffset != null) return; + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final toolbarSize = + _toolbarSizeForEdge(widget.edge.value, widget.toolbarSize.value); + _dragToolbarSize = toolbarSize; + final toolbarOffset = _toolbarOffsetForEdge( + edge: widget.edge.value, + fraction: widget.fraction.value, + parentSize: mediaSize, + toolbarSize: toolbarSize, + ); + _dragGrabOffset = cursor - toolbarOffset; + _dragLongAxisGrabOffset = _isHorizontalEdge(widget.edge.value) + ? _dragGrabOffset?.dx + : _dragGrabOffset?.dy; + } + + double _dragGrabOffsetForEdge(_ToolbarEdge edge, Size toolbarSize) { + final offset = _dragLongAxisGrabOffset ?? 0; + final extent = + _isHorizontalEdge(edge) ? toolbarSize.width : toolbarSize.height; + return _clampToolbarFraction(offset, 0, extent); + } + + void _updatePreview(Offset cursor) { + _ensureDragGrabOffset(cursor); + final mediaSize = MediaQueryData.fromView(View.of(context)).size; + final winner = _nearestToolbarEdge(cursor, mediaSize); + widget.previewEdge.value = winner; + + final toolbarSize = _toolbarSizeForEdge(winner, _dragToolbarSize); + final grabOffset = _dragGrabOffsetForEdge(winner, toolbarSize); + final double frac; + if (winner == _ToolbarEdge.top || winner == _ToolbarEdge.bottom) { + frac = _fractionForAlignedDrag( + cursor: cursor.dx, + grabOffset: grabOffset, + parentExtent: mediaSize.width, + toolbarExtent: toolbarSize.width, + left: left, + right: right, + ); + } else { + final fractionBounds = _fractionBoundsForEdge(winner, left, right); + frac = _fractionForAlignedDrag( + cursor: cursor.dy, + grabOffset: grabOffset, + parentExtent: mediaSize.height, + toolbarExtent: toolbarSize.height, + left: fractionBounds.left, + right: fractionBounds.right, + ); + } + widget.previewFraction.value = frac; + } + + void _resetDragTracking() { + _lastPointerDown = null; + _dragGrabOffset = null; + _dragLongAxisGrabOffset = null; + _dragToolbarSize = null; + } + + void _commitPreview() { + final newEdge = widget.previewEdge.value; + final frac = widget.previewFraction.value; + widget.previewEdge.value = null; + widget.previewFraction.value = null; + widget.dragging.value = false; + widget.markDragEpoch(); + _resetDragTracking(); + widget.syncDockingOptionsAfterDragIfNeeded(); + if (newEdge == null || frac == null) return; + widget.edge.value = newEdge; + widget.fraction.value = frac; + _cacheToolbarDockingOptions( + sessionId: widget.sessionId, + edge: newEdge, + fraction: frac, + multiEdgeEnabled: widget.multiEdgeEnabled, + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarEdge, + value: _toolbarEdgeToString(newEdge), + ); + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: kOptionRemoteMenubarFraction, + value: frac.toString(), + ); + if (widget.multiEdgeEnabled) { + return; + } + bind.sessionPeerOption( + sessionId: widget.sessionId, + name: _legacyRemoteMenubarDragX, + value: frac.toString(), + ); + } + Widget _buildDraggable(BuildContext context) { - return Draggable( - axis: Axis.horizontal, - child: Icon( - Icons.drag_indicator, - size: 20, - color: MyTheme.color(context).drag_indicator, + return Listener( + onPointerDown: (event) => _lastPointerDown = event.position, + child: Draggable( + // When multi-edge docking is off the toolbar stays on the top edge, + // so lock the feedback to horizontal motion — otherwise the handle + // floats away from the top while dragging and the toolbar looks + // unmoored. When multi-edge is on we need 2D drag for snap-to-edge. + axis: widget.multiEdgeEnabled ? null : Axis.horizontal, + child: Icon( + widget.isHorizontal ? Icons.drag_indicator : Icons.drag_handle, + size: 20, + color: MyTheme.color(context).drag_indicator, + ), + feedback: widget, + onDragStarted: () { + widget.markDragEpoch(); + final pointerDown = _lastPointerDown; + if (pointerDown != null) { + _ensureDragGrabOffset(pointerDown); + } + widget.dragging.value = true; + // Seed the preview at the current docked edge/fraction so something + // shows the instant the drag begins, before the first onDragUpdate. + widget.previewEdge.value = widget.edge.value; + widget.previewFraction.value = widget.fraction.value; + }, + onDragUpdate: (details) { + _updatePreview(details.globalPosition); + }, + onDragEnd: (_) => _commitPreview(), ), - feedback: widget, - onDragStarted: (() { - final RenderObject? renderObj = context.findRenderObject(); - if (renderObj != null) { - final RenderBox renderBox = renderObj as RenderBox; - size = renderBox.size; - position = renderBox.localToGlobal(Offset.zero); - } - widget.dragging.value = true; - }), - onDragEnd: (details) { - final mediaSize = MediaQueryData.fromView(View.of(context)).size; - widget.fractionX.value += - (details.offset.dx - position.dx) / (mediaSize.width - size.width); - if (widget.fractionX.value < left) { - widget.fractionX.value = left; - } - if (widget.fractionX.value > right) { - widget.fractionX.value = right; - } - bind.sessionPeerOption( - sessionId: widget.sessionId, - name: 'remote-menubar-drag-x', - value: widget.fractionX.value.toString(), - ); - widget.dragging.value = false; - }, ); } @@ -2637,7 +3253,9 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { ); } - final child = Row( + final axis = widget.isHorizontal ? Axis.horizontal : Axis.vertical; + final child = Flex( + direction: axis, mainAxisSize: MainAxisSize.min, children: [ _buildDraggable(context), @@ -2678,7 +3296,7 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { message: translate( collapse.isFalse ? 'Hide Toolbar' : 'Show Toolbar'), child: Icon( - collapse.isFalse ? Icons.expand_less : Icons.expand_more, + _toolbarCollapseIcon(widget.edge.value, collapse.isTrue), size: iconSize, ), ))), @@ -2720,7 +3338,8 @@ class _DraggableShowHideState extends State<_DraggableShowHide> { borderRadius: widget.borderRadius, ), child: SizedBox( - height: 20, + height: widget.isHorizontal ? 20 : null, + width: widget.isHorizontal ? null : 20, child: child, ), ), diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 6fdffd796..984d6a25c 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -346,7 +346,7 @@ class InputModel { /// which runs per-engine, so each isolate registers its own handler tied /// to its own set of InputModels. static void initSideButtonChannel() { - if (!Platform.isLinux) return; + if (!isLinux) return; if (_sideButtonChannelInitialized) return; _sideButtonChannelInitialized = true; diff --git a/src/lang/ar.rs b/src/lang/ar.rs index 4113c1391..e13404802 100644 --- a/src/lang/ar.rs +++ b/src/lang/ar.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "كلمة المرور مخفية"), ("preset-password-in-use-tip", "كلمة المرور المحددة مسبقًا قيد الاستخدام"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/be.rs b/src/lang/be.rs index 1a3260c5a..9f6b69c8b 100644 --- a/src/lang/be.rs +++ b/src/lang/be.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Зададзены пастаянны пароль (скрыты)."), ("preset-password-in-use-tip", "Пададзены пароль цяпер выкарыстоўваецца"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/bg.rs b/src/lang/bg.rs index 17a89ce07..0aa61b1eb 100644 --- a/src/lang/bg.rs +++ b/src/lang/bg.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ca.rs b/src/lang/ca.rs index 799ca951f..2f706cc89 100644 --- a/src/lang/ca.rs +++ b/src/lang/ca.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cn.rs b/src/lang/cn.rs index 1ff10c49d..a90e5e194 100644 --- a/src/lang/cn.rs +++ b/src/lang/cn.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "永久密码已设置(已隐藏)"), ("preset-password-in-use-tip", "当前使用预设密码"), ("Enable privacy mode", "允许隐私模式"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/cs.rs b/src/lang/cs.rs index 2b9c6219e..7f50d826f 100644 --- a/src/lang/cs.rs +++ b/src/lang/cs.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/da.rs b/src/lang/da.rs index 7410124df..c9d3b4eb0 100644 --- a/src/lang/da.rs +++ b/src/lang/da.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/de.rs b/src/lang/de.rs index 030bc626d..e6233e91e 100644 --- a/src/lang/de.rs +++ b/src/lang/de.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Ein permanentes Passwort wurde festgelegt (ausgeblendet)."), ("preset-password-in-use-tip", "Das voreingestellte Passwort wird derzeit verwendet."), ("Enable privacy mode", "Datenschutzmodus aktivieren"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/el.rs b/src/lang/el.rs index 0633889a7..d03bb069c 100644 --- a/src/lang/el.rs +++ b/src/lang/el.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/en.rs b/src/lang/en.rs index 73974a2e5..595169b8a 100644 --- a/src/lang/en.rs +++ b/src/lang/en.rs @@ -274,5 +274,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-incoming-sessions-label", "Keep screen awake during incoming sessions"), ("password-hidden-tip", "Permanent password is set (hidden)."), ("preset-password-in-use-tip", "Preset password is currently in use."), + ("allow-remote-toolbar-docking-any-edge", "Allow docking remote toolbar to any window edge"), ].iter().cloned().collect(); } diff --git a/src/lang/eo.rs b/src/lang/eo.rs index 16d43c9b4..131a85fbf 100644 --- a/src/lang/eo.rs +++ b/src/lang/eo.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/es.rs b/src/lang/es.rs index b822432a0..5e73b58a8 100644 --- a/src/lang/es.rs +++ b/src/lang/es.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "La contraseña permanente está ajustada a (oculta)."), ("preset-password-in-use-tip", "Se está usando la contraseña predeterminada."), ("Enable privacy mode", "Habilitar modo privado"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/et.rs b/src/lang/et.rs index a00c312b8..76abc8563 100644 --- a/src/lang/et.rs +++ b/src/lang/et.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/eu.rs b/src/lang/eu.rs index aaf8a8be8..9e19d1fea 100644 --- a/src/lang/eu.rs +++ b/src/lang/eu.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fa.rs b/src/lang/fa.rs index d34e4239e..9e01b7eb0 100644 --- a/src/lang/fa.rs +++ b/src/lang/fa.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fi.rs b/src/lang/fi.rs index 1bddd39d1..f8283685b 100644 --- a/src/lang/fi.rs +++ b/src/lang/fi.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/fr.rs b/src/lang/fr.rs index 6f7bb2880..f21d9b0df 100644 --- a/src/lang/fr.rs +++ b/src/lang/fr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Le mot de passe permanent est défini (masqué)."), ("preset-password-in-use-tip", "Le mot de passe prédéfini est actuellement utilisé."), ("Enable privacy mode", "Activer le mode de confidentialité"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ge.rs b/src/lang/ge.rs index fba2fd83d..2fc8f282d 100644 --- a/src/lang/ge.rs +++ b/src/lang/ge.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/gu.rs b/src/lang/gu.rs index 8b8568c85..ac0a588a8 100644 --- a/src/lang/gu.rs +++ b/src/lang/gu.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "એક્સેસિબલ ઉપકરણો"), ("upgrade_remote_rustdesk_client_to_{}_tip", "રિમોટ ક્લાયન્ટને {} માં અપગ્રેડ કરો"), ("d3d_render_tip", "D3D રેન્ડરિંગ વાપરો"), + ("Use D3D rendering", ""), ("Printer", "પ્રિન્ટર"), ("printer-os-requirement-tip", "પ્રિન્ટિંગ માટે Windows જરૂરી છે."), ("printer-requires-installed-{}-client-tip", "આ માટે {} ક્લાયન્ટ ઇન્સ્ટોલ હોવું જોઈએ."), @@ -743,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "સુરક્ષા માટે પાસવર્ડ છુપાવેલ છે."), ("preset-password-in-use-tip", "પ્રીસેટ પાસવર્ડ વપરાશમાં છે."), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/he.rs b/src/lang/he.rs index 682ee0c46..44b940784 100644 --- a/src/lang/he.rs +++ b/src/lang/he.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hi.rs b/src/lang/hi.rs index d35095fd1..904d43118 100644 --- a/src/lang/hi.rs +++ b/src/lang/hi.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "सुलभ डिवाइस"), ("upgrade_remote_rustdesk_client_to_{}_tip", "रिमोट RustDesk क्लाइंट को संस्करण {} में अपग्रेड करें"), ("d3d_render_tip", "D3D रेंडरिंग का उपयोग करें"), + ("Use D3D rendering", ""), ("Printer", "प्रिंटर"), ("printer-os-requirement-tip", "प्रिंटिंग के लिए Windows आवश्यक है।"), ("printer-requires-installed-{}-client-tip", "इसके लिए क्लाइंट साइड पर {} इंस्टॉल होना चाहिए।"), @@ -742,5 +743,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "प्रदर्शित नाम"), ("password-hidden-tip", "पासवर्ड सुरक्षा के लिए छिपा हुआ है।"), ("preset-password-in-use-tip", "पूर्व-निर्धारित पासवर्ड उपयोग में है।"), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hr.rs b/src/lang/hr.rs index 505b01df9..0593ff6b7 100644 --- a/src/lang/hr.rs +++ b/src/lang/hr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/hu.rs b/src/lang/hu.rs index b4cbc1f23..3eb16890f 100644 --- a/src/lang/hu.rs +++ b/src/lang/hu.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Állandó jelszó lett beállítva (rejtett)."), ("preset-password-in-use-tip", "Jelenleg az alapértelmezett jelszót használja."), ("Enable privacy mode", "Adatvédelmi mód aktiválása"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/id.rs b/src/lang/id.rs index bbd95e79a..bcda0a3a8 100644 --- a/src/lang/id.rs +++ b/src/lang/id.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/it.rs b/src/lang/it.rs index 479551fcc..a5132e027 100644 --- a/src/lang/it.rs +++ b/src/lang/it.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "È impostata una password permanente (nascosta)."), ("preset-password-in-use-tip", "È attualmente in uso la password preimpostata."), ("Enable privacy mode", "Abilita modalità privacy"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ja.rs b/src/lang/ja.rs index b55a6664f..2879e86bf 100644 --- a/src/lang/ja.rs +++ b/src/lang/ja.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "永続的なパスワードが設定されています (非表示)"), ("preset-password-in-use-tip", "プリセットパスワードが現在使用されています"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ko.rs b/src/lang/ko.rs index de68574e1..350d570b0 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "영구 비밀번호가 설정되었습니다 (숨김)."), ("preset-password-in-use-tip", "현재 사전 설정된 비밀번호가 사용 중입니다."), ("Enable privacy mode", "개인정보 보호 모드 사용함"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/kz.rs b/src/lang/kz.rs index a2a1624f7..4476fadc7 100644 --- a/src/lang/kz.rs +++ b/src/lang/kz.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lt.rs b/src/lang/lt.rs index 82422c30a..47ace51ae 100644 --- a/src/lang/lt.rs +++ b/src/lang/lt.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/lv.rs b/src/lang/lv.rs index 906d056bd..4f8e1f59f 100644 --- a/src/lang/lv.rs +++ b/src/lang/lv.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ml.rs b/src/lang/ml.rs index 099f1d385..4dcfe9e74 100644 --- a/src/lang/ml.rs +++ b/src/lang/ml.rs @@ -654,6 +654,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Accessible devices", "ലഭ്യമായ ഉപകരണങ്ങൾ"), ("upgrade_remote_rustdesk_client_to_{}_tip", "റിമോട്ട് പതിപ്പ് {} ലേക്ക് മാറ്റുക"), ("d3d_render_tip", "D3D റെൻഡറിംഗ് ഉപയോഗിക്കുക"), + ("Use D3D rendering", ""), ("Printer", "പ്രിന്റർ"), ("printer-os-requirement-tip", "പ്രിന്റിംഗിന് വിൻഡോസ് വേണം."), ("printer-requires-installed-{}-client-tip", "ഇതിന് {} ക്ലയന്റ് ഇൻസ്റ്റാൾ ചെയ്യണം."), @@ -742,5 +743,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("Display Name", "ഡിസ്‌പ്ലേ പേര്"), ("password-hidden-tip", "സുരക്ഷയ്ക്കായി പാസ്‌വേഡ് മറച്ചിരിക്കുന്നു."), ("preset-password-in-use-tip", "പ്രീസെറ്റ് പാസ്‌വേഡ് ഉപയോഗത്തിലാണ്."), + ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nb.rs b/src/lang/nb.rs index 5795b9eeb..9325dfa1f 100644 --- a/src/lang/nb.rs +++ b/src/lang/nb.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/nl.rs b/src/lang/nl.rs index 0f91d6a61..55d272666 100644 --- a/src/lang/nl.rs +++ b/src/lang/nl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Er is een permanent wachtwoord ingesteld (verborgen)."), ("preset-password-in-use-tip", "Het basis wachtwoord is momenteel in gebruik."), ("Enable privacy mode", "Privacymodus inschakelen"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pl.rs b/src/lang/pl.rs index 972afc170..fdf4ae8c5 100644 --- a/src/lang/pl.rs +++ b/src/lang/pl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Ustawiono (ukryto) stare hasło."), ("preset-password-in-use-tip", "Obecnie używane jest hasło domyślne."), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/pt_PT.rs b/src/lang/pt_PT.rs index 899c8da71..4138b46e4 100644 --- a/src/lang/pt_PT.rs +++ b/src/lang/pt_PT.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ptbr.rs b/src/lang/ptbr.rs index 36581d4f1..1428a71d0 100644 --- a/src/lang/ptbr.rs +++ b/src/lang/ptbr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "A senha permanente está definida como (oculta)."), ("preset-password-in-use-tip", "A senha predefinida está sendo usada."), ("Enable privacy mode", "Habilitar modo de privacidade"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ro.rs b/src/lang/ro.rs index 45b22684e..bde4a4201 100644 --- a/src/lang/ro.rs +++ b/src/lang/ro.rs @@ -540,7 +540,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("auto_disconnect_option_tip", "Deconectează automat sesiunile de la distanță după o perioadă de inactivitate."), ("Connection failed due to inactivity", "Conexiunea a eșuat din cauza inactivității"), ("Check for software update on startup", "Verifică actualizări la pornire"), - ("upgrade_rustdesk_server_pro_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."), + ("upgrade_rustdesk_server_pro_to_{}_tip", "Versiunea serverului RustDesk Pro este mai mică decât {}. Te rugăm să o actualizezi."), ("pull_group_failed_tip", "Sincronizarea grupului a eșuat. Verifică conexiunea la rețea sau autentifică-te din nou."), ("Filter by intersection", "Filtrează prin intersecție"), ("Remove wallpaper during incoming sessions", "Elimină imaginea de fundal în timpul sesiunilor primite"), @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Parola este ascunsă din motive de securitate. Fă clic pe pictograma ochiului pentru a o afișa."), ("preset-password-in-use-tip", "Se folosește o parolă prestabilită. Se recomandă setarea unei parole personalizate pentru securitate sporită."), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ru.rs b/src/lang/ru.rs index 3917c6fa2..2605582f4 100644 --- a/src/lang/ru.rs +++ b/src/lang/ru.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Установлен постоянный пароль (скрытый)."), ("preset-password-in-use-tip", "Установленный пароль сейчас используется."), ("Enable privacy mode", "Использовать режим конфиденциальности"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sc.rs b/src/lang/sc.rs index 68ce541f2..06919b752 100644 --- a/src/lang/sc.rs +++ b/src/lang/sc.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sk.rs b/src/lang/sk.rs index 6b4e16688..963f48728 100644 --- a/src/lang/sk.rs +++ b/src/lang/sk.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sl.rs b/src/lang/sl.rs index 3f35dea88..0f85af0c3 100755 --- a/src/lang/sl.rs +++ b/src/lang/sl.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sq.rs b/src/lang/sq.rs index f7f6c16d4..7c965cd45 100644 --- a/src/lang/sq.rs +++ b/src/lang/sq.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sr.rs b/src/lang/sr.rs index bedbe4856..fc33e4671 100644 --- a/src/lang/sr.rs +++ b/src/lang/sr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/sv.rs b/src/lang/sv.rs index eda7851c1..664dc4745 100644 --- a/src/lang/sv.rs +++ b/src/lang/sv.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/ta.rs b/src/lang/ta.rs index 6e5652560..93aeb6462 100644 --- a/src/lang/ta.rs +++ b/src/lang/ta.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/template.rs b/src/lang/template.rs index 5e25801d2..33b359c5e 100644 --- a/src/lang/template.rs +++ b/src/lang/template.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/th.rs b/src/lang/th.rs index c2d058c98..a24c60bf6 100644 --- a/src/lang/th.rs +++ b/src/lang/th.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tr.rs b/src/lang/tr.rs index d93ad4f68..c28086cc9 100644 --- a/src/lang/tr.rs +++ b/src/lang/tr.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "Parola gizli"), ("preset-password-in-use-tip", "Önceden ayarlanmış parola kullanılıyor"), ("Enable privacy mode", "Gizlilik modunu etkinleştir"), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/tw.rs b/src/lang/tw.rs index b23b84949..6df025303 100644 --- a/src/lang/tw.rs +++ b/src/lang/tw.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", "固定密碼已設定(已隱藏)"), ("preset-password-in-use-tip", "目前正在使用預設密碼"), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/uk.rs b/src/lang/uk.rs index 3e1c4f25e..7107bc261 100644 --- a/src/lang/uk.rs +++ b/src/lang/uk.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); } diff --git a/src/lang/vi.rs b/src/lang/vi.rs index 3fadb0efc..0910025ed 100644 --- a/src/lang/vi.rs +++ b/src/lang/vi.rs @@ -744,5 +744,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("password-hidden-tip", ""), ("preset-password-in-use-tip", ""), ("Enable privacy mode", ""), + ("allow-remote-toolbar-docking-any-edge", ""), ].iter().cloned().collect(); }