feat(rdp): title the mstsc window after the peer instead of "localhost" (#15781)

* feat(rdp): title the mstsc window after the peer instead of "localhost"

The RDP tunnel launched `mstsc /v:localhost:<port>`, so with several
sessions open every window is titled "localhost" and servers cannot be
told apart.

mstsc titles the session window after the launched .rdp file's base
name, so write a temp .rdp file (containing only the tunnel address)
named after the peer alias, cached hostname, or id, and launch that
instead. Falls back to the old /v: form when no usable name remains
after filename sanitization or the file cannot be written. Credential
handling is unchanged: cmdkey targets "localhost", which is still the
host mstsc resolves credentials against.

Fixes rustdesk/rustdesk#15775 (discussion)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(rdp): set mstsc title without temporary files

  Keep launching mstsc with /v so Default.rdp settings are preserved
  and unsigned RDP file warnings and policy restrictions are avoided.

  Track the launched mstsc process and reapply the peer name when the
  window title is reset during connection or reconnection.

Signed-off-by: 21pages <sunboeasy@gmail.com>

* docs(rdp): clarify mstsc title limitation

Signed-off-by: 21pages <sunboeasy@gmail.com>

* feat(rdp): show peer identity with hostname in mstsc title

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
RustDesk
2026-08-17 13:30:18 +08:00
committed by GitHub
parent edd0e5fbd4
commit 5a78be03e3
2 changed files with 117 additions and 5 deletions

View File

@@ -2642,6 +2642,91 @@ pub fn wide_string(s: &str) -> Vec<u16> {
.collect()
}
// This only changes mstsc's top-level window title. The full-screen connection
// bar is rendered separately and cannot be customized when mstsc.exe is
// launched as an independent process.
pub fn set_rdp_window_title(mut child: std::process::Child, name: String) {
let name: String = name.chars().filter(|c| !c.is_control()).take(120).collect();
if name.is_empty() {
return;
}
let process_id = child.id();
// mstsc owns the title and can restore "localhost" while connecting or
// reconnecting. Follow only the process we launched and reapply the peer
// name until it exits, so concurrent RDP sessions cannot rename each other.
if let Err(err) = std::thread::Builder::new()
.name("rdp-window-title".to_owned())
.spawn(move || {
let mut warned = false;
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Err(err) => {
log::warn!("Failed to query mstsc process: {}", err);
break;
}
Ok(None) => match set_process_rdp_window_title(process_id, &name) {
Ok(()) => warned = false,
Err(err) if !warned => {
log::warn!("Failed to set RDP window title: {}", err);
warned = true;
}
Err(_) => {}
},
}
std::thread::sleep(Duration::from_millis(500));
}
})
{
log::warn!("Failed to start RDP window title thread: {}", err);
}
}
fn set_process_rdp_window_title(process_id: DWORD, name: &str) -> io::Result<()> {
struct Context {
process_id: DWORD,
title: Vec<u16>,
error: Option<io::Error>,
}
unsafe extern "system" fn enum_window(hwnd: HWND, lparam: LPARAM) -> BOOL {
let context = &mut *(lparam as *mut Context);
let mut window_process_id = 0;
GetWindowThreadProcessId(hwnd, &mut window_process_id);
if window_process_id != context.process_id || IsWindowVisible(hwnd) == FALSE {
return TRUE;
}
let len = GetWindowTextLengthW(hwnd);
if len <= 0 {
return TRUE;
}
let mut title = vec![0u16; len as usize + 1];
let len = GetWindowTextW(hwnd, title.as_mut_ptr(), title.len() as _);
if len > 0 && String::from_utf16_lossy(&title[..len as usize]).contains("localhost") {
if SetWindowTextW(hwnd, context.title.as_ptr()) == FALSE {
context.error = Some(io::Error::last_os_error());
return FALSE;
}
}
TRUE
}
let mut context = Context {
process_id,
title: wide_string(name),
error: None,
};
let enumerated =
unsafe { EnumWindows(Some(enum_window), &mut context as *mut Context as LPARAM) };
if let Some(err) = context.error {
return Err(err);
}
if enumerated == FALSE {
return Err(io::Error::last_os_error());
}
Ok(())
}
/// send message to currently shown window
pub fn send_message_to_hnwd(
class_name: &str,

View File

@@ -15,7 +15,7 @@ use hbb_common::{
ResultType, Stream,
};
fn run_rdp(port: u16) {
fn run_rdp(port: u16, name: &str) {
std::process::Command::new("cmdkey")
.arg("/delete:localhost")
.output()
@@ -35,10 +35,37 @@ fn run_rdp(port: u16) {
.output()
.ok();
}
std::process::Command::new("mstsc")
// Keep using /v instead of a generated .rdp file: mstsc then preserves the
// user's Default.rdp settings and avoids unsigned-file warnings or policies.
match std::process::Command::new("mstsc")
.arg(format!("/v:localhost:{}", port))
.spawn()
.ok();
{
Ok(child) => {
#[cfg(windows)]
crate::platform::set_rdp_window_title(child, name.to_owned());
#[cfg(not(windows))]
let _ = (child, name);
}
Err(err) => log::warn!("Failed to launch mstsc: {}", err),
}
}
// Show the peer identity with its hostname, using the ID when no alias exists.
fn rdp_display_name(lc: &Arc<RwLock<LoginConfigHandler>>, id: &str) -> String {
let lc = lc.read().unwrap();
let alias = lc
.options
.get("alias")
.map(|s| s.trim())
.unwrap_or_default();
let hostname = lc.info.hostname.trim();
let identity = if !alias.is_empty() { alias } else { id };
if hostname.is_empty() || hostname == identity {
identity.to_owned()
} else {
format!("{} ({})", identity, hostname)
}
}
pub async fn listen(
@@ -58,7 +85,7 @@ pub async fn listen(
log::info!("listening on port {:?}", addr);
let is_rdp = port == 0;
if is_rdp {
run_rdp(addr.port());
run_rdp(addr.port(), &rdp_display_name(&lc, &id));
}
let mut ui_receiver = ui_receiver;
loop {
@@ -96,7 +123,7 @@ pub async fn listen(
}
Some(Data::NewRDP) => {
println!("receive run_rdp from ui_receiver");
run_rdp(addr.port());
run_rdp(addr.port(), &rdp_display_name(&lc, &id));
}
_ => {}
}