Merge branch 'master' into id-whitelist

This commit is contained in:
21pages
2026-07-27 11:51:04 +08:00
48 changed files with 3585 additions and 235 deletions

View File

@@ -1401,6 +1401,10 @@ impl AudioHandler {
/// Handle audio format and create an audio decoder.
pub fn handle_format(&mut self, f: AudioFormat) {
if !is_supported_audio_channel_count(f.channels) {
log::error!("Unsupported audio channel count: {}", f.channels);
return;
}
match AudioDecoder::new(f.sample_rate, if f.channels > 1 { Stereo } else { Mono }) {
Ok(d) => {
let buffer = vec![0.; f.sample_rate as usize * f.channels as usize];
@@ -1540,6 +1544,23 @@ impl AudioHandler {
}
}
fn is_supported_audio_channel_count(channels: u32) -> bool {
(1..=2).contains(&channels)
}
#[cfg(test)]
mod audio_format_tests {
use super::is_supported_audio_channel_count;
#[test]
fn only_mono_and_stereo_are_supported() {
assert!(is_supported_audio_channel_count(1));
assert!(is_supported_audio_channel_count(2));
assert!(!is_supported_audio_channel_count(0));
assert!(!is_supported_audio_channel_count(u32::MAX));
}
}
/// Video handler for the [`Client`].
pub struct VideoHandler {
decoder: Decoder,

View File

@@ -36,6 +36,17 @@ const CLIPBOARD_GET_MAX_RETRY: usize = 3;
#[cfg(not(target_os = "android"))]
const CLIPBOARD_GET_RETRY_INTERVAL_DUR: Duration = Duration::from_millis(33);
#[cfg(not(target_os = "android"))]
fn valid_rgba_dimensions(width: i32, height: i32, data_len: usize) -> Option<(usize, usize)> {
let width = usize::try_from(width).ok()?;
let height = usize::try_from(height).ok()?;
if width == 0 || height == 0 {
return None;
}
let expected_len = width.checked_mul(height)?.checked_mul(4)?;
(data_len == expected_len).then_some((width, height))
}
#[cfg(not(target_os = "android"))]
const SUPPORTED_FORMATS: &[ClipboardFormat] = &[
ClipboardFormat::Text,
@@ -722,11 +733,15 @@ mod proto {
Ok(ClipboardFormat::Text) => String::from_utf8(data).ok().map(ClipboardData::Text),
Ok(ClipboardFormat::Rtf) => String::from_utf8(data).ok().map(ClipboardData::Rtf),
Ok(ClipboardFormat::Html) => String::from_utf8(data).ok().map(ClipboardData::Html),
Ok(ClipboardFormat::ImageRgba) => Some(ClipboardData::Image(arboard::ImageData::rgba(
clipboard.width as _,
clipboard.height as _,
data.into(),
))),
Ok(ClipboardFormat::ImageRgba) => {
let (width, height) =
super::valid_rgba_dimensions(clipboard.width, clipboard.height, data.len())?;
Some(ClipboardData::Image(arboard::ImageData::rgba(
width,
height,
data.into(),
)))
}
Ok(ClipboardFormat::ImagePng) => {
Some(ClipboardData::Image(arboard::ImageData::png(data.into())))
}
@@ -770,6 +785,22 @@ mod proto {
}
}
#[cfg(all(test, not(target_os = "android")))]
mod rgba_tests {
use super::valid_rgba_dimensions;
#[test]
fn validates_dimensions_against_content_length() {
assert_eq!(valid_rgba_dimensions(1, 1, 4), Some((1, 1)));
assert_eq!(valid_rgba_dimensions(1, 1, 3), None);
assert_eq!(valid_rgba_dimensions(-1, 1, 4), None);
assert_eq!(valid_rgba_dimensions(0, 1, 0), None);
assert_eq!(valid_rgba_dimensions(i32::MAX, i32::MAX, 4), None);
#[cfg(target_pointer_width = "32")]
assert_eq!(valid_rgba_dimensions(i32::MAX, 2, 0), None);
}
}
#[cfg(target_os = "android")]
pub fn handle_msg_clipboard(mut cb: Clipboard) {
use hbb_common::protobuf::Message;

View File

@@ -1024,7 +1024,7 @@ pub fn get_full_name() -> String {
}
pub fn is_setup(name: &str) -> bool {
name.to_lowercase().ends_with("install.exe")
!config::is_disable_installation() && name.to_lowercase().ends_with("install.exe")
}
pub fn get_custom_rendezvous_server(custom: String) -> String {
@@ -2623,6 +2623,20 @@ pub fn is_direct_ip_access(peer: &str) -> bool {
hbb_common::is_ip_str(peer) || hbb_common::is_domain_port_str(peer)
}
// Align the maximum length of the peer id to the maximum length of the peer id in the server.
const MAX_UNTRUSTED_PEER_ID_LEN: usize = 253;
const UNTRUSTED_PEER_ID_FORBIDDEN_CHARS: &[char] = &['"', '<', '>', '/', '\\', '|', '?', '*'];
// Shared validation for peer/connect ids that cross untrusted boundaries before
// they are stored or written into command/script contexts.
pub fn is_valid_untrusted_peer_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_UNTRUSTED_PEER_ID_LEN
&& !id.chars().any(|ch| {
ch.is_control() || ch.is_whitespace() || UNTRUSTED_PEER_ID_FORBIDDEN_CHARS.contains(&ch)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2653,6 +2667,29 @@ mod tests {
)
}
#[test]
fn untrusted_peer_id_validation() {
let cases = [
("123456789", true),
("m\u{00FC}nchen-pc", true),
("192.168.1.10:21118", true),
("9123456234@public", true),
(
r#"1" & oWS.Run("cmd.exe /k whoami /priv",1,False) & ""#,
false,
),
("", false),
("peer id", false),
("peer\nid", false),
("peer/id", false),
("peer?id", false),
];
for (id, expected) in cases {
assert_eq!(is_valid_untrusted_peer_id(id), expected, "{id:?}");
}
}
// ThrottledInterval tick at the same time as tokio interval, if no sleeps
#[allow(non_snake_case)]
#[tokio::test]

View File

@@ -127,6 +127,13 @@ pub fn core_main() -> Option<Vec<String>> {
if args.contains(&"--noinstall".to_string()) {
args.clear();
}
// The portable wrapper injects `--install` when its name ends with `install.exe`,
// including `no-install.exe`. Drop the argument instead of exiting so disabled
// clients can continue running as portable applications.
if config::is_disable_installation() {
args.retain(|arg| arg != "--install");
flutter_args.retain(|arg| arg != "--install");
}
if args.len() > 0 {
if args[0] == "--version" {
println!("{}", crate::VERSION);
@@ -660,7 +667,8 @@ pub fn core_main() -> Option<Vec<String>> {
None
}
};
let new_id = get_value("--id");
// An empty --id (e.g. an unset var) would deploy a blank id; the Android flow guards this too (#15146).
let new_id = get_value("--id").filter(|s| !s.is_empty());
match crate::ui_interface::deploy_device(token, new_id) {
crate::ui_interface::DeployResult::Ok => {
println!("Device deployed.");

View File

@@ -136,6 +136,12 @@ pub extern "C" fn rustdesk_core_main_args(args_len: *mut c_int) -> *mut *mut c_c
return std::ptr::null_mut() as _;
}
#[cfg(windows)]
#[no_mangle]
pub extern "C" fn rustdesk_is_disable_installation() -> c_int {
hbb_common::config::is_disable_installation() as c_int
}
// https://gist.github.com/iskakaushik/1c5b8aa75c77479c33c4320913eebef6
#[cfg(windows)]
fn rust_args_to_c_args(args: Vec<String>, outlen: *mut c_int) -> *mut *mut c_char {

View File

@@ -41,6 +41,8 @@ pub(crate) use ipc_auth::ensure_peer_executable_matches_current_by_pid_opt;
pub(crate) use ipc_auth::log_rejected_windows_ipc_connection;
#[cfg(any(target_os = "linux", target_os = "macos"))]
use ipc_auth::{active_uid, authorize_service_scoped_ipc_connection};
#[cfg(target_os = "macos")]
use ipc_auth::authorize_user_server_process;
#[cfg(windows)]
use ipc_auth::{
authorize_windows_main_ipc_connection, portable_service_listener_security_attributes,
@@ -472,6 +474,8 @@ pub enum Data {
#[cfg(target_os = "windows")]
PortForwardSessionCount(Option<usize>),
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
#[cfg(target_os = "macos")]
HasNoActiveConns(Option<bool>),
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Whiteboard((String, crate::whiteboard::CustomEvent)),
ControlPermissionsRemoteModify(Option<bool>),
@@ -881,8 +885,14 @@ async fn handle(data: Data, stream: &mut Connection) {
Some(value) => {
let mut updated = true;
if name == "id" {
Config::set_key_confirmed(false);
Config::set_id(&value);
// An empty id would wipe the local id and unconfirm the key (cf. #15626).
if value.is_empty() {
log::warn!("Ignoring empty id write over IPC");
updated = false;
} else {
Config::set_key_confirmed(false);
Config::set_id(&value);
}
} else if name == "temporary-password" {
password::update_temporary_password();
} else if name == "permanent-password" {
@@ -1000,6 +1010,16 @@ async fn handle(data: Data, stream: &mut Connection) {
.await
);
}
#[cfg(target_os = "macos")]
Data::HasNoActiveConns(None) => {
allow_err!(
stream
.send(&Data::HasNoActiveConns(Some(
crate::updater::has_no_active_conns()
)))
.await
);
}
#[cfg(all(
feature = "flutter",
not(any(target_os = "android", target_os = "ios"))
@@ -1334,14 +1354,21 @@ pub async fn connect(ms_timeout: u64, postfix: &str) -> ResultType<ConnectionTmp
}
}
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub async fn connect_for_uid(
ms_timeout: u64,
uid: u32,
postfix: &str,
) -> ResultType<ConnectionTmpl<ConnClient>> {
let path = Config::ipc_path_for_uid(uid, postfix);
connect_with_path(ms_timeout, &path).await
let conn = connect_with_path(ms_timeout, &path).await?;
#[cfg(target_os = "macos")]
if postfix.is_empty()
&& !authorize_user_server_process(conn.peer_uid(), conn.peer_pid(), uid)
{
bail!("Rejected user IPC peer for uid {}", uid);
}
Ok(conn)
}
#[cfg(target_os = "linux")]
@@ -1689,19 +1716,24 @@ pub fn clear_trusted_devices() {
}
pub fn get_id() -> String {
// An empty id may come from a process that took over the main IPC with a
// config scope that has no id yet (e.g. a user GUI that became the server
// while the installed service was restarting). Treat it as no answer,
// otherwise the empty id is adopted below and wipes the local one.
if let Ok(Some(v)) = get_config("id") {
// update salt also, so that next time reinstallation not causing first-time auto-login failure
if let Ok(Some(v2)) = get_config("salt") {
Config::set_salt(&v2);
if !v.is_empty() {
// update salt also, so that next time reinstallation not causing first-time auto-login failure
if let Ok(Some(v2)) = get_config("salt") {
Config::set_salt(&v2);
}
if v != Config::get_id() {
Config::set_key_confirmed(false);
Config::set_id(&v);
}
return v;
}
if v != Config::get_id() {
Config::set_key_confirmed(false);
Config::set_id(&v);
}
v
} else {
Config::get_id()
}
Config::get_id()
}
pub async fn get_rendezvous_server(ms_timeout: u64) -> (String, Vec<String>) {

View File

@@ -656,6 +656,32 @@ pub(crate) fn authorize_service_scoped_ipc_connection(stream: &Connection, postf
true
}
#[cfg(target_os = "macos")]
pub(crate) fn authorize_user_server_process(
peer_uid: Option<u32>,
peer_pid: Option<u32>,
expected_uid: u32,
) -> bool {
if peer_uid != Some(expected_uid) {
return false;
}
let Some(peer_pid) = peer_pid else {
return false;
};
let Ok(peer_exe) = peer_exe_canonical_path_by_pid(peer_pid) else {
return false;
};
let expected_path = PathBuf::from(format!(
"/Applications/{}.app/Contents/MacOS/{}",
crate::get_app_name(),
crate::get_app_name()
));
let Ok(expected_path) = fs::canonicalize(expected_path) else {
return false;
};
paths_refer_to_same_file(&peer_exe, &expected_path)
}
#[cfg(windows)]
pub(crate) fn authorize_windows_main_ipc_connection(stream: &Connection, postfix: &str) -> bool {
let (

View File

@@ -241,6 +241,14 @@ fn wait_response(
Some(rendezvous_message::Union::PeerDiscovery(p)) => {
last_recv_time = Instant::now();
if p.cmd == "pong" {
if !crate::common::is_valid_untrusted_peer_id(&p.id) {
log::warn!(
"Ignoring LAN discovery response from {} with invalid peer id",
addr
);
continue;
}
let local_mac = if try_get_ip_by_peer {
if let Some(self_addr) = get_ipaddr_by_peer(&addr) {
get_mac(&self_addr)

View File

@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Relay Connection", "中继连接"),
("Secure Connection", "安全连接"),
("Insecure Connection", "非安全连接"),
("Continue", ""),
("Continue", "继续"),
("Scale original", "原始尺寸"),
("Scale adaptive", "适应窗口"),
("General", "常规"),

View File

@@ -332,7 +332,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Relay Connection", "Połączenie przez bramkę"),
("Secure Connection", "Połączenie szyfrowane"),
("Insecure Connection", "Połączenie nieszyfrowane"),
("Continue", ""),
("Continue", "Kontynuuj"),
("Scale original", "Skalowanie oryginalne"),
("Scale adaptive", "Dopasuj do wyświetlacza"),
("General", "Ogólne"),

View File

@@ -646,6 +646,16 @@ fn try_start_server_(desktop: Option<&Desktop>) -> ResultType<Option<Child>> {
if !desktop.dbus.is_empty() {
envs.push(("DBUS_SESSION_BUS_ADDRESS", desktop.dbus.clone()));
}
if let Ok(forced_display_server) =
std::env::var("RUSTDESK_FORCED_DISPLAY_SERVER")
{
if !forced_display_server.is_empty() {
envs.push((
"RUSTDESK_FORCED_DISPLAY_SERVER",
forced_display_server,
));
}
}
envs.push((
"TERM",
get_cur_term(&desktop.uid).unwrap_or_else(|| suggest_best_term()),

View File

@@ -118,16 +118,18 @@ extern "C" bool MacCheckAdminAuthorization() {
// https://gist.github.com/briankc/025415e25900750f402235dbf1b74e42
extern "C" float BackingScaleFactor(uint32_t display) {
NSArray<NSScreen *> *screens = [NSScreen screens];
for (NSScreen *screen in screens) {
NSDictionary *deviceDescription = [screen deviceDescription];
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
if (screenDisplayID == display) {
return [screen backingScaleFactor];
@autoreleasepool {
NSArray<NSScreen *> *screens = [NSScreen screens];
for (NSScreen *screen in screens) {
NSDictionary *deviceDescription = [screen deviceDescription];
NSNumber *screenNumber = [deviceDescription objectForKey:@"NSScreenNumber"];
CGDirectDisplayID screenDisplayID = [screenNumber unsignedIntValue];
if (screenDisplayID == display) {
return [screen backingScaleFactor];
}
}
return 1;
}
return 1;
}
// https://github.com/jhford/screenresolution/blob/master/cg_utils.c

View File

@@ -312,6 +312,55 @@ fn correct_app_name(s: &str) -> String {
s
}
fn write_plist_atomically(path: &str, body: &str) -> ResultType<()> {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let temporary = format!("{}.tmp.{}", path, std::process::id());
let result = (|| {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)?;
file.set_permissions(std::fs::Permissions::from_mode(0o644))?;
file.write_all(body.as_bytes())?;
file.sync_all()?;
std::fs::rename(&temporary, path)?;
Ok::<(), std::io::Error>(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result.map_err(Into::into)
}
pub fn write_plists() -> ResultType<()> {
let daemon_plist_path = format!(
"/Library/LaunchDaemons/com.carriez.{}_service.plist",
crate::get_app_name()
);
let agent_plist_path = format!(
"/Library/LaunchAgents/com.carriez.{}_server.plist",
crate::get_app_name()
);
let Some(daemon_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("daemon.plist") else {
bail!("daemon.plist not found in embedded resources");
};
let Some(daemon_plist_body) = daemon_plist.contents_utf8().map(correct_app_name) else {
bail!("Failed to read daemon.plist");
};
let Some(agent_plist) = PRIVILEGES_SCRIPTS_DIR.get_file("agent.plist") else {
bail!("agent.plist not found in embedded resources");
};
let Some(agent_plist_body) = agent_plist.contents_utf8().map(correct_app_name) else {
bail!("Failed to read agent.plist");
};
write_plist_atomically(&daemon_plist_path, &daemon_plist_body)?;
write_plist_atomically(&agent_plist_path, &agent_plist_body)?;
log::info!("[write-plists] Wrote daemon and agent plists");
Ok(())
}
pub fn uninstall_service(show_new_window: bool, sync: bool) -> bool {
// to-do: do together with win/linux about refactory start/stop service
if !is_installed_daemon(false) {
@@ -659,6 +708,61 @@ pub fn get_active_userid() -> String {
get_active_user("-n")
}
/// Return every UID with a login-window/session entry. Fast user switching
/// can leave several GUI bootstrap domains alive at once, so updating only
/// the console user can leave another user's agent on the old bundle.
pub(crate) fn get_logged_in_uids() -> Vec<u32> {
let mut uids = std::collections::BTreeSet::new();
if let Ok(output) = std::process::Command::new("/usr/bin/who").output() {
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Some(username) = line.split_whitespace().next() else {
continue;
};
let Ok(output) = std::process::Command::new("/usr/bin/id")
.args(["-u", username])
.output()
else {
continue;
};
let Ok(uid) = String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
else {
continue;
};
let gui_domain = format!("gui/{}", uid);
if std::process::Command::new("/bin/launchctl")
.args(["print", &gui_domain])
.output()
.is_ok_and(|output| output.status.success())
{
uids.insert(uid);
}
}
}
if let Ok(active_uid) = get_active_userid().parse::<u32>() {
if active_uid == 0 {
// UID 0 owns /dev/console while the LoginWindow session is active.
// Query that server even when fast-switched GUI domains also exist.
uids.insert(0);
} else {
let gui_domain = format!("gui/{}", active_uid);
if std::process::Command::new("/bin/launchctl")
.args(["print", &gui_domain])
.output()
.is_ok_and(|output| output.status.success())
{
uids.insert(active_uid);
}
}
}
if uids.is_empty() {
// The login window has no ordinary gui/0 bootstrap domain.
uids.insert(0);
}
uids.into_iter().collect()
}
pub fn get_active_user_home() -> Option<PathBuf> {
let username = get_active_username();
if !username.is_empty() {
@@ -728,8 +832,12 @@ pub fn lock_screen() {
.ok();
}
/// Starts the macOS system service IPC listener and the background
/// silent auto-update thread.
pub fn start_os_service() {
log::info!("Username: {}", crate::username());
// Silent auto-update — runs as root via LaunchDaemon, no osascript dialog needed
crate::updater::start_auto_update_macos();
if let Err(err) = crate::ipc::start("_service") {
log::error!("Failed to start ipc_service: {}", err);
}
@@ -912,6 +1020,760 @@ pub fn update_to(_file: &str) -> ResultType<()> {
Ok(())
}
fn backup_update_plist(source: &str, backup: &str) -> ResultType<()> {
match std::fs::symlink_metadata(source) {
Ok(metadata) => {
if !metadata.file_type().is_file() {
bail!("[root-update] plist is not a regular file: {}", source);
}
std::fs::copy(source, backup)?;
Ok(())
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
bail!("[root-update] required installed plist is missing: {}", source)
}
Err(err) => Err(err.into()),
}
}
fn validate_update_tree(path: &Path, framework_root: Option<&Path>) -> ResultType<()> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
// Frameworks legitimately use internal symlinks (Resources,
// Versions/Current), but never allow a link to leave its framework.
let Some(framework_root) = framework_root else {
bail!("[root-update] symlink outside framework: {}", path.display());
};
let target = std::fs::read_link(path)?;
let target = if target.is_absolute() {
target
} else {
path.parent().unwrap_or(Path::new("/")).join(target)
};
let target = std::fs::canonicalize(target)?;
let framework_root = std::fs::canonicalize(framework_root)?;
if target.starts_with(&framework_root) {
return Ok(());
}
bail!("[root-update] symlink in update bundle: {}", path.display());
}
if metadata.file_type().is_dir() {
for entry in std::fs::read_dir(path)? {
let child = entry?.path();
let child_framework_root = if child
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".framework"))
{
Some(child.as_path())
} else {
framework_root
};
validate_update_tree(&child, child_framework_root)?;
}
} else if !metadata.file_type().is_file() {
bail!("[root-update] unsupported file in update bundle: {}", path.display());
}
Ok(())
}
/// Performs a silent update from a DMG file without any osascript dialog.
/// Must be called from a process running as root (e.g. the service binary).
pub fn update_from_dmg_as_root(dmg_path: &str, expected_version: &str) -> ResultType<()> {
let app_name = crate::get_app_name();
if app_name.is_empty()
|| !app_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
{
bail!("[root-update] unsafe application name");
}
let app_bundle = format!("/Applications/{}.app", app_name);
let tmp_dir_output = std::process::Command::new("/usr/bin/mktemp")
.args(&["-d", "/tmp/.rustdeskupdate-root-XXXXXX"])
.output()?;
let tmp_dir = String::from_utf8(tmp_dir_output.stdout)
.map_err(|e| anyhow!("[root-update] mktemp output error: {}", e))?
.trim()
.to_string();
if tmp_dir.is_empty() {
bail!("[root-update] Failed to create temp directory");
}
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700))?;
}
let agent_plist = format!("/Library/LaunchAgents/com.carriez.{}_server.plist", app_name);
let daemon_plist = format!("/Library/LaunchDaemons/com.carriez.{}_service.plist", app_name);
log::info!("[root-update] Starting silent root update from {}", dmg_path);
// Check sessions before extracting to avoid unnecessary work
if !crate::updater::has_no_active_conns_ipc() {
bail!("[root-update] Active session detected, deferring update.");
}
// Extract DMG to temp dir
extract_dmg_into_existing_dir(dmg_path, &tmp_dir)?;
let src_app = format!("{}/{}.app", tmp_dir, app_name);
log::info!("[root-update] DMG extracted to {}", tmp_dir);
validate_update_tree(Path::new(&src_app), None)?;
// Bind the downloaded asset to the version returned by the update
// service before changing plists or executing anything from the staged
// bundle. A release asset with the right filename but the wrong bundle
// must not be allowed to replace the installed application.
let info_plist = format!("{}/Contents/Info.plist", src_app);
let staged_version_result = (|| -> ResultType<String> {
let output = Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleShortVersionString", &info_plist])
.output()?;
if !output.status.success() {
bail!(
"[root-update] failed to read staged bundle version: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let version = String::from_utf8(output.stdout)
.map_err(|err| anyhow!("[root-update] staged bundle version is not UTF-8: {}", err))?;
if version.trim().is_empty() {
bail!("[root-update] staged bundle version is empty");
}
Ok(version.trim().to_owned())
})();
let staged_version = match staged_version_result {
Ok(version) => version,
Err(err) => {
if let Err(cleanup_err) = std::fs::remove_dir_all(&tmp_dir) {
log::warn!(
"[root-update] Failed to remove temp dir {}: {}",
tmp_dir,
cleanup_err
);
}
return Err(err);
}
};
if staged_version != expected_version {
if let Err(err) = std::fs::remove_dir_all(&tmp_dir) {
log::warn!(
"[root-update] Failed to remove temp dir {}: {}",
tmp_dir,
err
);
}
bail!(
"[root-update] staged bundle version mismatch: expected {:?}, found {:?}",
expected_version,
staged_version
);
}
// A leftover backup makes `mv app app.bak` nest the live bundle inside
// the old directory instead of creating a transaction backup. Never
// overwrite or guess at recovery state left by an earlier interrupted
// update; require an administrator to inspect it first.
let app_backup = format!("{}.bak", app_bundle);
let failed_bundle = format!("{}.failed-update", app_bundle);
for recovery_path in [&app_backup, &failed_bundle] {
match std::fs::symlink_metadata(recovery_path) {
Ok(_) => {
let _ = std::fs::remove_dir_all(&tmp_dir);
bail!(
"[root-update] stale application recovery path requires inspection: {}",
recovery_path
);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(err.into());
}
}
}
// Backup current plists before overwriting — needed for restore on reload failure
let daemon_plist_bak = format!("{}/daemon_plist.bak", tmp_dir);
let agent_plist_bak = format!("{}/agent_plist.bak", tmp_dir);
// Backups are part of the update transaction. Do not allow the new
// service binary to overwrite either live plist unless both installed
// definitions have been captured successfully.
backup_update_plist(&daemon_plist, &daemon_plist_bak)?;
backup_update_plist(&agent_plist, &agent_plist_bak)?;
// Ensure the staged release contains the service executable before we
// proceed. Plist generation itself is done in this already-root process;
// launching a freshly extracted service binary from /tmp is not required.
let new_service = format!("{}/Contents/MacOS/service", src_app);
if !std::path::Path::new(&new_service).is_file() {
bail!("[root-update] staged service binary is missing: {}", new_service);
}
// The new binary writes its own plist definitions after the bundle is
// moved into its final root-owned location. This avoids executing code
// directly from /tmp while ensuring the plist matches the new release.
// Final session check after extraction — minimize race window
if !crate::updater::has_no_active_conns_ipc() {
let _ = std::fs::remove_dir_all(&tmp_dir);
bail!("[root-update] Active session detected after extraction, deferring update.");
}
// Let the detached-script launch settle before taking the affected-user
// snapshot. The final IPC check then happens after the delay and as close
// as possible to stopping those exact launchd domains.
std::thread::sleep(std::time::Duration::from_secs(3));
if !crate::updater::has_no_active_conns_ipc() {
bail!("[root-update] active session started before update launch");
}
let logged_in_uids = get_logged_in_uids();
// UIDs are parsed as integers before embedding in the root-run shell
// script, so they cannot alter its command structure.
let uid_list = logged_in_uids
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(" ");
// Write a shell script that runs detached after this function returns.
// We cannot directly replace /Applications/RustDesk.app while it is running,
// so we spawn a script that waits, kills processes, copies, and restarts.
let daemon_label = format!("com.carriez.{}_service", app_name);
let agent_label = format!("com.carriez.{}_server", app_name);
let script_path = format!("{}/rustdesk_update.sh", tmp_dir);
let script = format!(
r#"#!/bin/sh
rollback_done=0
bundle_swapped=0
bootstrap_agent() {{
agent_uid="$1"
if [ "$agent_uid" != "0" ]; then
launchctl bootstrap gui/"$agent_uid" "{agent_plist}" 2>/dev/null || \
launchctl bootstrap user/"$agent_uid" "{agent_plist}" 2>/dev/null || \
launchctl load -w "{agent_plist}" 2>/dev/null
else
# At the login window there is no gui/0 domain. launchctl load uses
# the plist's LoginWindow/Aqua session policy instead.
launchctl load -w -S LoginWindow "{agent_plist}" 2>/dev/null || \
launchctl load -w "{agent_plist}" 2>/dev/null
fi
}}
bootstrap_agents() {{
for agent_uid in {uid_list}; do
bootstrap_agent "$agent_uid" || return 1
done
}}
loginwindow_asid() {{
root_user_info=$(launchctl print user/0 2>/dev/null || true)
root_login_asid=$(printf '%s\n' "$root_user_info" | \
awk '/^[[:space:]]*asid = [0-9]+[[:space:]]*$/ {{print $3; exit}}')
case "$root_login_asid" in
''|*[!0-9]*) return 1 ;;
esac
printf '%s\n' "$root_login_asid"
}}
bootout_agents() {{
# Legacy launchctl commands can report success despite operational
# failure. Treat these as requests; stop_agents verifies the result.
stopping_loginwindow_asid=""
for agent_uid in {uid_list}; do
if [ "$agent_uid" != "0" ]; then
launchctl bootout gui/"$agent_uid"/{agent_label} 2>/dev/null || true
launchctl bootout user/"$agent_uid"/{agent_label} 2>/dev/null || true
else
# LoginWindow jobs run in a login/<asid> domain even though
# legacy root `launchctl load` is issued from the system context.
# Remove every applicable registration before killing the process
# so KeepAlive cannot immediately respawn it.
launchctl unload -w -S LoginWindow "{agent_plist}" 2>/dev/null || true
stopping_loginwindow_asid=$(loginwindow_asid || true)
if [ -n "$stopping_loginwindow_asid" ]; then
launchctl bootout login/"$stopping_loginwindow_asid"/{agent_label} 2>/dev/null || true
fi
launchctl bootout user/0/{agent_label} 2>/dev/null || true
launchctl bootout system/{agent_label} 2>/dev/null || true
launchctl unload -w "{agent_plist}" 2>/dev/null || true
fi
done
}}
find_agent_pid() {{
agent_uid="$1"
for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do
process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true)
if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \
printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null; then
printf '%s\n' "$candidate_pid"
return 0
fi
done
return 1
}}
launchd_agent_pid() {{
agent_uid="$1"
agent_info=$(launchctl print gui/"$agent_uid"/{agent_label} 2>/dev/null || \
launchctl print user/"$agent_uid"/{agent_label} 2>/dev/null || true)
agent_job_pid=$(printf '%s\n' "$agent_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}')
if [ -n "$agent_job_pid" ] && \
printf '%s\n' "$agent_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null; then
printf '%s\n' "$agent_job_pid"
return 0
fi
return 1
}}
agent_pid_for_uid() {{
agent_uid="$1"
if [ "$agent_uid" = "0" ]; then
# LoginWindow agents have no ordinary gui/0 bootstrap domain. Locate
# the root-owned --server process and validate it below instead.
find_agent_pid "$agent_uid"
else
launchd_agent_pid "$agent_uid"
fi
}}
agent_process_matches() {{
agent_uid="$1"
agent_pid="$2"
process_uid=$(ps -p "$agent_pid" -o uid= 2>/dev/null | tr -d '[:space:]')
process_args=$(ps -p "$agent_pid" -o args= 2>/dev/null || true)
[ "$process_uid" = "$agent_uid" ] && \
printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/Contents/MacOS/{app_name}" >/dev/null && \
printf '%s\n' "$process_args" | grep -E '(^|[[:space:]])--server([[:space:]]|$)' >/dev/null
}}
capture_stopping_agent_pids() {{
stopping_agent_pids=""
for agent_uid in {uid_list}; do
for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do
if agent_process_matches "$agent_uid" "$candidate_pid"; then
stopping_agent_pids="$stopping_agent_pids $candidate_pid"
fi
done
done
}}
terminate_agent_processes() {{
for agent_uid in {uid_list}; do
for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do
if agent_process_matches "$agent_uid" "$candidate_pid"; then
kill -KILL "$candidate_pid" 2>/dev/null || true
fi
done
done
}}
terminate_user_bundle_processes() {{
for agent_uid in {uid_list}; do
for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do
process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true)
if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then
kill -KILL "$candidate_pid" 2>/dev/null || true
fi
done
done
}}
user_bundle_processes_absent() {{
for agent_uid in {uid_list}; do
for candidate_pid in $(pgrep -u "$agent_uid" -x {app_name} 2>/dev/null || true); do
process_args=$(ps -p "$candidate_pid" -o args= 2>/dev/null || true)
if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null; then
return 1
fi
done
done
return 0
}}
stop_user_bundle_processes() {{
terminate_user_bundle_processes
for _ in $(/usr/bin/seq 1 30); do
if user_bundle_processes_absent; then
sleep 2
user_bundle_processes_absent && return 0
fi
terminate_user_bundle_processes
sleep 1
done
return 1
}}
agent_jobs_absent() {{
for agent_uid in {uid_list}; do
if [ "$agent_uid" != "0" ]; then
if launchctl print gui/"$agent_uid"/{agent_label} >/dev/null 2>&1 || \
launchctl print user/"$agent_uid"/{agent_label} >/dev/null 2>&1; then
return 1
fi
else
if launchctl print system/{agent_label} >/dev/null 2>&1 || \
launchctl print user/0/{agent_label} >/dev/null 2>&1; then
return 1
fi
if [ -n "$stopping_loginwindow_asid" ] && \
launchctl print login/"$stopping_loginwindow_asid"/{agent_label} >/dev/null 2>&1; then
return 1
fi
fi
find_agent_pid "$agent_uid" >/dev/null 2>&1 && return 1
done
return 0
}}
captured_agent_pids_gone() {{
for stopped_pid in $stopping_agent_pids; do
kill -0 "$stopped_pid" 2>/dev/null && return 1
done
return 0
}}
agents_stopped() {{
captured_agent_pids_gone && agent_jobs_absent
}}
stop_agents() {{
bootout_agents
terminate_agent_processes
for _ in $(/usr/bin/seq 1 30); do
if agents_stopped; then
sleep 2
agents_stopped && return 0
fi
terminate_agent_processes
sleep 1
done
return 1
}}
capture_agent_snapshot() {{
agent_pids=""
for agent_uid in {uid_list}; do
agent_pid=$(agent_pid_for_uid "$agent_uid" || true)
[ -n "$agent_pid" ] || return 1
[ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1
kill -0 "$agent_pid" 2>/dev/null || return 1
agent_process_matches "$agent_uid" "$agent_pid" || return 1
agent_pids="$agent_pids $agent_uid:$agent_pid"
done
return 0
}}
agent_snapshot_stable() {{
for agent_entry in $agent_pids; do
agent_uid=$(printf '%s\n' "$agent_entry" | cut -d: -f1)
expected_pid=$(printf '%s\n' "$agent_entry" | cut -d: -f2)
current_pid=$(agent_pid_for_uid "$agent_uid" || true)
[ -n "$current_pid" ] && [ "$current_pid" = "$expected_pid" ] || return 1
[ -S "/tmp/{app_name}-$agent_uid/ipc" ] || return 1
kill -0 "$current_pid" 2>/dev/null || return 1
agent_process_matches "$agent_uid" "$current_pid" || return 1
done
return 0
}}
agent_ready() {{
for _ in $(/usr/bin/seq 1 30); do
if capture_agent_snapshot; then
sleep 2
agent_snapshot_stable && return 0
fi
sleep 1
done
return 1
}}
daemon_snapshot_stable() {{
stable_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true)
stable_daemon_pid=$(printf '%s\n' "$stable_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}')
[ -n "$daemon_pid" ] && \
[ "$stable_daemon_pid" = "$daemon_pid" ] && \
printf '%s\n' "$stable_daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \
[ -S "/tmp/{app_name}-service/ipc_service" ] && \
kill -0 "$daemon_pid" 2>/dev/null
}}
daemon_ready() {{
daemon_pid=""
for _ in $(/usr/bin/seq 1 30); do
daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true)
daemon_pid=$(printf '%s\n' "$daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}')
if [ -n "$daemon_pid" ] && \
printf '%s\n' "$daemon_info" | grep -E '^[[:space:]]*state = running[[:space:]]*$' >/dev/null && \
[ -S "/tmp/{app_name}-service/ipc_service" ] && \
kill -0 "$daemon_pid" 2>/dev/null; then
sleep 2
daemon_snapshot_stable && return 0
fi
sleep 1
done
return 1
}}
capture_stopping_daemon_pid() {{
stopping_daemon_info=$(launchctl print system/{daemon_label} 2>/dev/null || true)
stopping_daemon_pid=$(printf '%s\n' "$stopping_daemon_info" | awk '/^[[:space:]]*pid = / {{print $3; exit}}')
}}
daemon_stopped() {{
if [ -n "$stopping_daemon_pid" ] && kill -0 "$stopping_daemon_pid" 2>/dev/null; then
return 1
fi
! launchctl print system/{daemon_label} >/dev/null 2>&1
}}
stop_daemon() {{
capture_stopping_daemon_pid
# Command status is advisory. daemon_stopped verifies that both the
# captured process generation and launchd registration are gone.
launchctl bootout system/{daemon_label} 2>/dev/null || \
launchctl unload -w "{daemon_plist}" 2>/dev/null || true
for _ in $(/usr/bin/seq 1 30); do
if daemon_stopped; then
sleep 2
daemon_stopped && return 0
fi
sleep 1
done
return 1
}}
write_new_plists() {{
/Applications/{app_name}.app/Contents/MacOS/service --write-plists \
>"{tmp_dir}/write-plists.log" 2>&1 &
write_pid=$!
for _ in $(/usr/bin/seq 1 60); do
if ! kill -0 "$write_pid" 2>/dev/null; then
wait "$write_pid"
return $?
fi
sleep 1
done
kill -TERM "$write_pid" 2>/dev/null || true
sleep 1
kill -KILL "$write_pid" 2>/dev/null || true
wait "$write_pid" 2>/dev/null || true
return 124
}}
restore_old_bundle() {{
[ "$bundle_swapped" -eq 1 ] || return 0
if [ ! -d "{app_bundle}.bak" ] || [ -L "{app_bundle}.bak" ]; then
echo "[root-update] CRITICAL: valid application backup is unavailable" >> {tmp_dir}/rustdesk_root_update.log
return 1
fi
if [ -e "{app_bundle}" ] || [ -L "{app_bundle}" ]; then
if [ -e "{app_bundle}.failed-update" ] || [ -L "{app_bundle}.failed-update" ] || \
! mv "{app_bundle}" "{app_bundle}.failed-update"; then
echo "[root-update] CRITICAL: could not vacate failed bundle safely" >> {tmp_dir}/rustdesk_root_update.log
return 1
fi
fi
if ! mv "{app_bundle}.bak" "{app_bundle}"; then
echo "[root-update] CRITICAL: failed to restore application bundle" >> {tmp_dir}/rustdesk_root_update.log
if [ ! -e "{app_bundle}" ] && [ ! -L "{app_bundle}" ]; then
mv "{app_bundle}.failed-update" "{app_bundle}" 2>/dev/null || true
fi
return 1
fi
rm -rf "{app_bundle}.failed-update" 2>/dev/null || true
bundle_swapped=0
return 0
}}
rollback_transaction() {{
# Rollback restores and verifies unattended service state. It does not
# guarantee relaunching GUI windows that were stopped by the transaction.
[ "$rollback_done" -eq 0 ] || return 0
rollback_done=1
restore_failed=0
stop_daemon || restore_failed=1
capture_stopping_agent_pids
stop_agents || restore_failed=1
restore_old_bundle || restore_failed=1
cp "{daemon_plist_bak}" "{daemon_plist}" || restore_failed=1
cp "{agent_plist_bak}" "{agent_plist}" || restore_failed=1
touch /var/root/.rustdeskupdate_failed || restore_failed=1
if ! launchctl load -w "{daemon_plist}" 2>/dev/null && \
! launchctl bootstrap system "{daemon_plist}" 2>/dev/null; then
restore_failed=1
fi
daemon_ready || restore_failed=1
bootstrap_agents || restore_failed=1
agent_ready || restore_failed=1
if [ "$restore_failed" -eq 0 ] && \
{{ ! daemon_snapshot_stable || ! agent_snapshot_stable; }}; then
restore_failed=1
fi
if [ "$restore_failed" -ne 0 ]; then
echo "[root-update] CRITICAL: rollback restoration failed" >> {tmp_dir}/rustdesk_root_update.log
else
echo "[root-update] Rollback daemon and agents verified healthy" >> {tmp_dir}/rustdesk_root_update.log
fi
}}
trap rollback_transaction EXIT
gui_uids=""
for agent_uid in {uid_list}; do
for pid in $(pgrep -u "$agent_uid" -x {app_name} || true); do
process_args=$(ps -p "$pid" -o args= 2>/dev/null || true)
if printf '%s\n' "$process_args" | grep -F "/Applications/{app_name}.app/" >/dev/null && \
! printf '%s\n' "$process_args" | grep -E "(^|[[:space:]])(--server|--service|--update)([[:space:]]|$)" >/dev/null; then
gui_uids="$gui_uids $agent_uid"
break
fi
done
done
if ! capture_agent_snapshot; then
echo "[root-update] old LaunchAgent readiness check failed before shutdown" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
capture_stopping_agent_pids
if ! stop_daemon; then
echo "[root-update] daemon did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
if ! stop_agents; then
echo "[root-update] old LaunchAgent did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Agents have already been verified absent. Stop and verify any remaining GUI
# processes as well so no process keeps the old bundle mapped across the swap.
if ! stop_user_bundle_processes; then
echo "[root-update] RustDesk GUI process did not stop before bundle swap" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
staged_bundle="{tmp_dir}/staged.app"
if [ -e "$staged_bundle" ] || [ -L "$staged_bundle" ]; then
echo "[root-update] staged bundle path already exists, aborting" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
if ! ditto {src_app} "$staged_bundle" 2>/dev/null; then
echo "[root-update] ditto failed, aborting update" >> {tmp_dir}/rustdesk_root_update.log
rm -rf "$staged_bundle"
exit 1
fi
# Validate staged bundle before atomic swap
if [ ! -d "$staged_bundle/Contents/MacOS" ] || \
[ ! -f "$staged_bundle/Contents/MacOS/{app_name}" ] || \
[ ! -f "$staged_bundle/Contents/MacOS/service" ] || \
[ ! -f "$staged_bundle/Contents/Info.plist" ]; then
echo "[root-update] staged bundle validation failed, aborting" >> {tmp_dir}/rustdesk_root_update.log
rm -rf "$staged_bundle"
exit 1
fi
if ! mv {app_bundle} {app_bundle}.bak; then
echo "[root-update] backup mv failed, aborting" >> {tmp_dir}/rustdesk_root_update.log
rm -rf "$staged_bundle"
exit 1
fi
bundle_swapped=1
if ! mv "$staged_bundle" {app_bundle}; then
echo "[root-update] replacement mv failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Install the entire bundle as root-owned. The LaunchDaemon executes code
# from this bundle, so no nested framework, helper, or resource may remain
# user-writable.
if ! chown -R root:wheel {app_bundle} || ! chmod -R go-w {app_bundle}; then
echo "[root-update] chown failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
xattr -r -d com.apple.quarantine {app_bundle} || true
# Keep root-executed files AND entire ancestor chain root-owned — prevent privilege escalation
if ! chown root:wheel {app_bundle} || \
! chmod 755 {app_bundle} || \
! chown root:wheel {app_bundle}/Contents || \
! chmod 755 {app_bundle}/Contents || \
! chown root:wheel {app_bundle}/Contents/MacOS || \
! chmod 755 {app_bundle}/Contents/MacOS || \
! chown root:wheel {app_bundle}/Contents/MacOS/service || \
! chmod 755 {app_bundle}/Contents/MacOS/service || \
! chown root:wheel {app_bundle}/Contents/MacOS/{app_name} || \
! chmod 755 {app_bundle}/Contents/MacOS/{app_name}; then
echo "[root-update] hardening failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Generate launchd definitions from the new, final-location binary. The
# subprocess is bounded and its output is retained for diagnosis; failure
# causes the existing bundle/plists to be restored by the EXIT trap.
if ! write_new_plists; then
echo "[root-update] CRITICAL: new binary failed to write plists" >> {tmp_dir}/rustdesk_root_update.log
cat "{tmp_dir}/write-plists.log" >> {tmp_dir}/rustdesk_root_update.log 2>/dev/null || true
exit 1
fi
echo "[root-update] Plist definitions written by new binary" >> {tmp_dir}/rustdesk_root_update.log
# Check daemon registration and readiness BEFORE removing backup. launchctl
# load/bootstrap only registers the job; the service can still exit immediately.
if ! launchctl load -w {daemon_plist} 2>/dev/null && \
! launchctl bootstrap system {daemon_plist} 2>/dev/null; then
echo "[root-update] CRITICAL: daemon reload failed, restoring backup" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
if ! daemon_ready; then
echo "[root-update] CRITICAL: daemon failed readiness check, restoring" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Bootstrap agent BEFORE removing backup — needed for rollback on failure.
# This also uses launchctl load for the login-window/no-console-user case.
if ! bootstrap_agents || ! agent_ready; then
echo "[root-update] CRITICAL: agent bootstrap failed, rolling back" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Recheck daemon liveness after the agent is restored and immediately before
# deleting the only rollback bundle.
if ! daemon_snapshot_stable || ! agent_snapshot_stable; then
echo "[root-update] CRITICAL: daemon or agent stopped before commit, restoring" >> {tmp_dir}/rustdesk_root_update.log
exit 1
fi
# Only remove backup after BOTH daemon AND agent confirmed running
rollback_done=1
bundle_swapped=0
if ! rm -rf "{app_bundle}.bak"; then
echo "[root-update] WARNING: committed update but could not remove backup" >> {tmp_dir}/rustdesk_root_update.log
fi
for gui_uid in $gui_uids; do
launchctl asuser "$gui_uid" open -a "{app_bundle}" || true
done
echo "[root-update] Done!" >> {tmp_dir}/rustdesk_root_update.log
rm -rf {tmp_dir}
"#,
app_name = app_name,
app_bundle = app_bundle,
src_app = src_app,
uid_list = uid_list,
daemon_plist = daemon_plist,
agent_plist = agent_plist,
tmp_dir = tmp_dir,
daemon_label = daemon_label,
agent_label = agent_label,
daemon_plist_bak = daemon_plist_bak,
agent_plist_bak = agent_plist_bak,
);
{
use std::io::Write;
if let Err(err) = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&script_path)
.and_then(|mut f| f.write_all(script.as_bytes()))
{
return Err(err.into());
}
}
match Command::new("/bin/chmod")
.args(&["+x", &script_path])
.status()
{
Ok(status) if status.success() => {}
Ok(status) => {
bail!(
"[root-update] failed to make update script executable: {}",
status
);
}
Err(err) => {
return Err(err.into());
}
}
// Reject session changes observed before launch, but this snapshot is
// best-effort: it is not atomic with shutdown in the detached script.
if get_logged_in_uids() != logged_in_uids {
bail!("[root-update] GUI session set changed before update launch");
}
if !crate::updater::has_no_active_conns_ipc() {
bail!("[root-update] active session started before update launch");
}
if let Err(err) = Command::new("/bin/bash")
.arg(&script_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.process_group(0)
.spawn()
{
return Err(err.into());
}
log::info!("[root-update] Update script launched.");
Ok(())
}
pub fn extract_update_dmg(file: &str) {
let update_temp_dir = get_update_temp_dir_string();
let mut evt: HashMap<&str, String> =
@@ -931,37 +1793,63 @@ pub fn extract_update_dmg(file: &str) {
}
fn extract_dmg(dmg_path: &str, target_dir: &str) -> ResultType<()> {
let mount_point = "/Volumes/RustDeskUpdate";
let target_path = Path::new(target_dir);
if target_path.exists() {
std::fs::remove_dir_all(target_path)?;
}
std::fs::create_dir_all(target_path)?;
extract_dmg_inner(dmg_path, target_dir)
}
let status = Command::new("hdiutil")
.args(&["attach", "-nobrowse", "-mountpoint", mount_point, dmg_path])
fn extract_dmg_into_existing_dir(dmg_path: &str, target_dir: &str) -> ResultType<()> {
let target_path = Path::new(target_dir);
if !target_path.exists() {
bail!("[root-update] Temp directory does not exist: {:?}", target_path);
}
extract_dmg_inner(dmg_path, target_dir)
}
fn extract_dmg_inner(dmg_path: &str, target_dir: &str) -> ResultType<()> {
let mount_output = Command::new("/usr/bin/mktemp")
.args(["-d", "/tmp/.rustdeskmount-XXXXXX"])
.output()?;
if !mount_output.status.success() {
bail!("Failed to create a private DMG mount directory");
}
let mount_point = String::from_utf8(mount_output.stdout)
.map_err(|e| anyhow!("Invalid DMG mount directory: {}", e))?
.trim()
.to_owned();
if mount_point.is_empty() {
bail!("Failed to create a private DMG mount directory");
}
let status = Command::new("/usr/bin/hdiutil")
.args(["attach", "-nobrowse", "-mountpoint"])
.arg(&mount_point)
.arg(dmg_path)
.status()?;
if !status.success() {
let _ = std::fs::remove_dir(&mount_point);
bail!("Failed to attach DMG image at {}: {:?}", dmg_path, status);
}
struct DmgGuard(&'static str);
struct DmgGuard(String);
impl Drop for DmgGuard {
fn drop(&mut self) {
let _ = Command::new("hdiutil")
.args(&["detach", self.0, "-force"])
let _ = Command::new("/usr/bin/hdiutil")
.args(["detach", self.0.as_str(), "-force"])
.status();
let _ = std::fs::remove_dir(&self.0);
}
}
let _guard = DmgGuard(mount_point);
let _guard = DmgGuard(mount_point.clone());
let app_name = format!("{}.app", crate::get_app_name());
let src_path = format!("{}/{}", mount_point, app_name);
let dest_path = format!("{}/{}", target_dir, app_name);
let copy_status = Command::new("ditto")
let copy_status = Command::new("/usr/bin/ditto")
.args(&[&src_path, &dest_path])
.status()?;

View File

@@ -23,8 +23,8 @@
<key>WorkingDirectory</key>
<string>/Applications/RustDesk.app/Contents/MacOS/</string>
<key>StandardErrorPath</key>
<string>/tmp/rustdesk_service.err</string>
<string>/var/log/rustdesk_service.err</string>
<key>StandardOutPath</key>
<string>/tmp/rustdesk_service.out</string>
<string>/var/log/rustdesk_service.out</string>
</dict>
</plist>

View File

@@ -1,14 +1,18 @@
on run {daemon_file, agent_file, user}
set prefs_dir to "/Users/" & user & "/Library/Preferences/com.carriez.RustDesk/"
set prefs_toml to quoted form of (prefs_dir & "RustDesk.toml")
set prefs2_toml to quoted form of (prefs_dir & "RustDesk2.toml")
set sh1 to "echo " & quoted form of daemon_file & " > /Library/LaunchDaemons/com.carriez.RustDesk_service.plist && chown root:wheel /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
set sh2 to "echo " & quoted form of agent_file & " > /Library/LaunchAgents/com.carriez.RustDesk_server.plist && chown root:wheel /Library/LaunchAgents/com.carriez.RustDesk_server.plist;"
set sh3 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk.toml /var/root/Library/Preferences/com.carriez.RustDesk/;"
set sh3 to "cp -rf " & prefs_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;"
set sh4 to "cp -rf /Users/" & user & "/Library/Preferences/com.carriez.RustDesk/RustDesk2.toml /var/root/Library/Preferences/com.carriez.RustDesk/;"
set sh4 to "cp -rf " & prefs2_toml & " /var/root/Library/Preferences/com.carriez.RustDesk/;"
set sh5 to "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
set sh5 to "launchctl bootout system/com.carriez.RustDesk_service 2>/dev/null || launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || true; launchctl bootstrap system /Library/LaunchDaemons/com.carriez.RustDesk_service.plist 2>/dev/null || launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;"
set sh to sh1 & sh2 & sh3 & sh4 & sh5

View File

@@ -2278,6 +2278,10 @@ fn get_shortcut_icon_location(install_dir: &str, exe: &str) -> String {
}
pub fn create_shortcut(id: &str) -> ResultType<()> {
if !crate::common::is_valid_untrusted_peer_id(id) {
bail!("Invalid peer id for shortcut");
}
let exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned();
// https://github.com/rustdesk/rustdesk/issues/13735
// Replace ':' with '_' for filename since ':' is not allowed in Windows filenames

View File

@@ -357,15 +357,13 @@ impl Server {
}
}
pub fn try_add_primay_video_service(&mut self) {
let primary_video_service_name = video_service::get_service_name(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
);
if !self.contains(&primary_video_service_name) {
pub fn try_add_monitor_service(&mut self, display_idx: usize) {
let monitor_service_name =
video_service::get_service_name(VideoSource::Monitor, display_idx);
if !self.contains(&monitor_service_name) {
self.add_service(Box::new(video_service::new(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
display_idx,
)));
}
}
@@ -381,14 +379,17 @@ impl Server {
self.connections.insert(conn.id(), conn);
}
pub fn add_connection(&mut self, conn: ConnInner, noperms: &Vec<&'static str>) {
let primary_video_service_name = video_service::get_service_name(
VideoSource::Monitor,
*display_service::PRIMARY_DISPLAY_IDX,
);
pub fn add_monitor_connection(
&mut self,
conn: ConnInner,
noperms: &Vec<&'static str>,
display_idx: usize,
) {
let monitor_service_name =
video_service::get_service_name(VideoSource::Monitor, display_idx);
for s in self.services.values() {
let name = s.name();
if Self::is_video_service_name(&name) && name != primary_video_service_name {
if Self::is_video_service_name(&name) && name != monitor_service_name {
continue;
}
if !noperms.contains(&(&name as _)) {
@@ -783,8 +784,7 @@ async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Se
loop {
sleep(CONFIG_SYNC_INTERVAL_SECS).await;
let cfg = (Config::get(), Config2::get());
let should_sync =
cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
let should_sync = cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
if should_sync {
if is_root_config_empty {
log::info!("root config is empty, sync our config to root");

View File

@@ -509,7 +509,9 @@ impl Connection {
tx_video: Some(tx_video),
},
require_2fa: crate::auth_2fa::get_2fa(None),
display_idx: *display_service::PRIMARY_DISPLAY_IDX,
// Defer display enumeration until login succeeds. Monitor login replaces this
// with the primary index returned with the refreshed display snapshot.
display_idx: 0,
stream,
server,
hash,
@@ -1923,13 +1925,15 @@ impl Connection {
Err(err) => {
res.set_error(format!("{}", err));
}
Ok(displays) => {
Ok((displays, primary_display_idx)) => {
// For compatibility with old versions, we need to send the displays to the peer.
// But the displays may be updated later, before creating the video capturer.
#[cfg(target_os = "macos")]
{
self.retina.set_displays(&displays);
}
// A separate primary lookup here could race with display hot-plug.
self.display_idx = primary_display_idx;
pi.displays = displays;
pi.current_display = self.display_idx as _;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -2038,8 +2042,8 @@ impl Connection {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let _h = try_start_record_cursor_pos();
self.auto_disconnect_timer = Self::get_auto_disconenct_timer();
s.try_add_primay_video_service();
s.add_connection(self.inner.clone(), &noperms);
s.try_add_monitor_service(self.display_idx);
s.add_monitor_connection(self.inner.clone(), &noperms, self.display_idx);
}
}
}
@@ -2842,7 +2846,6 @@ impl Connection {
#[cfg(feature = "flutter")]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if let Some(lr) = _s.lr.clone().take() {
self.handle_login_request_without_validation(&lr).await;
// Switching sides authorizes without a password, so it must not bypass the
// whitelist, which can be a locked policy pushed by the server.
if !self.check_id_whitelist().await {
@@ -2856,6 +2859,15 @@ impl Connection {
if let Ok(uuid) = uuid::Uuid::from_slice(_s.uuid.to_vec().as_ref()) {
if let Some((_instant, uuid_old)) = uuid_old {
if uuid == uuid_old {
if lr.union.is_some() {
log::warn!(
"Rejected switch sides response for non-remote-desktop session; closing connection"
);
self.send_login_error("Connection not allowed").await;
return false;
}
self.reset_session_scope_for_login();
self.handle_login_request_without_validation(&lr).await;
self.from_switch = true;
self.set_conn_audit_primary_auth(ConnAuditPrimaryAuth::SwitchSides);
if !self.send_logon_response_and_keep_alive().await {
@@ -4182,7 +4194,9 @@ impl Connection {
let display_idx = s.display as usize;
if self.display_idx != display_idx {
if let Some(server) = self.server.upgrade() {
self.switch_display_to(display_idx, server.clone());
if !self.switch_display_to(display_idx, server.clone()) {
return;
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
if !self.view_camera && s.width != 0 && s.height != 0 {
@@ -4209,6 +4223,13 @@ impl Connection {
}
}
fn video_source_count(video_source: VideoSource) -> usize {
match video_source {
VideoSource::Monitor => display_service::get_sync_displays().len(),
VideoSource::Camera => camera::Cameras::get_sync_cameras().len(),
}
}
fn video_source(&self) -> VideoSource {
if self.view_camera {
VideoSource::Camera
@@ -4217,18 +4238,28 @@ impl Connection {
}
}
fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) {
fn switch_display_to(&mut self, display_idx: usize, server: Arc<RwLock<Server>>) -> bool {
let source_count = Self::video_source_count(self.video_source());
if display_idx >= source_count {
// Do not remap an explicit switch: its resolution belongs to the requested source.
log::warn!(
"Ignore switch to invalid {:?} index {}, available source count: {}",
self.video_source(),
display_idx,
source_count
);
return false;
}
let new_service_name = video_service::get_service_name(self.video_source(), display_idx);
let old_service_name =
video_service::get_service_name(self.video_source(), self.display_idx);
let mut lock = server.write().unwrap();
if display_idx != *display_service::PRIMARY_DISPLAY_IDX {
if !lock.contains(&new_service_name) {
lock.add_service(Box::new(video_service::new(
self.video_source(),
display_idx,
)));
}
if !lock.contains(&new_service_name) {
lock.add_service(Box::new(video_service::new(
self.video_source(),
display_idx,
)));
}
// For versions greater than 1.2.4, a `CaptureDisplays` message will be sent immediately.
// Unnecessary capturers will be removed then.
@@ -4237,6 +4268,7 @@ impl Connection {
}
lock.subscribe(&new_service_name, self.inner.clone(), true);
self.display_idx = display_idx;
true
}
#[cfg(windows)]
@@ -4263,26 +4295,61 @@ impl Connection {
async fn capture_displays(&mut self, add: &[usize], sub: &[usize], set: &[usize]) {
let video_source = self.video_source();
if let Some(sever) = self.server.upgrade() {
let mut lock = sever.write().unwrap();
for display in add.iter() {
let source_count = Self::video_source_count(video_source);
// Only add/set can create services; sub only narrows existing subscriptions.
let valid_add = add
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
let valid_sub = sub
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
let valid_set = set
.iter()
.copied()
.filter(|display| *display < source_count)
.collect::<Vec<_>>();
let invalid_count =
add.len() + sub.len() + set.len() - valid_add.len() - valid_sub.len() - valid_set.len();
if invalid_count != 0 {
log::warn!(
"Ignore {} invalid {:?} indices, available source count: {}",
invalid_count,
video_source,
source_count
);
}
// Passing an invalid sub request as an empty exclude list would unsubscribe all services.
if (!add.is_empty() && valid_add.is_empty())
|| (add.is_empty() && !sub.is_empty() && valid_sub.is_empty())
|| (add.is_empty() && sub.is_empty() && !set.is_empty() && valid_set.is_empty())
{
return;
}
if let Some(server) = self.server.upgrade() {
let mut lock = server.write().unwrap();
for display in valid_add.iter() {
let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(video_source, *display)));
}
}
for display in set.iter() {
for display in valid_set.iter() {
let service_name = video_service::get_service_name(video_source, *display);
if !lock.contains(&service_name) {
lock.add_service(Box::new(video_service::new(video_source, *display)));
}
}
if !add.is_empty() {
lock.capture_displays(self.inner.clone(), video_source, add, true, false);
lock.capture_displays(self.inner.clone(), video_source, &valid_add, true, false);
} else if !sub.is_empty() {
lock.capture_displays(self.inner.clone(), video_source, sub, false, true);
lock.capture_displays(self.inner.clone(), video_source, &valid_sub, false, true);
} else {
lock.capture_displays(self.inner.clone(), video_source, set, true, true);
lock.capture_displays(self.inner.clone(), video_source, &valid_set, true, true);
}
self.multi_ui_session = lock.get_subbed_displays_count(self.inner.id()) > 1;
if self.follow_remote_window {
@@ -5696,6 +5763,7 @@ impl Connection {
Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution",
Some(misc::Union::MessageQuery(_)) => "misc.message_query",
Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display",
Some(misc::Union::SwitchSidesRequest(_)) => "misc.switch_sides_request",
Some(_) => "misc.other",
None => "misc.empty",
}
@@ -6947,6 +7015,10 @@ mod test {
misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())),
Some("misc.capture_displays"),
),
(
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
Some("misc.switch_sides_request"),
),
(msg(|m| m.set_clipboard(Clipboard::new())), None),
(
msg(|m| m.set_multi_clipboards(MultiClipboards::new())),
@@ -6991,6 +7063,10 @@ mod test {
misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())),
Some("misc.toggle_privacy_mode"),
),
(
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
Some("misc.switch_sides_request"),
),
(misc_msg(|m| m.set_chat_message(ChatMessage::new())), None),
(msg(|m| m.set_clipboard(Clipboard::new())), None),
(
@@ -7076,6 +7152,10 @@ mod test {
msg(|m| m.set_terminal_action(TerminalAction::new())),
Some("terminal_action"),
),
(
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
Some("misc.switch_sides_request"),
),
],
),
(
@@ -7086,6 +7166,10 @@ mod test {
None,
),
(msg(|m| m.set_terminal_action(TerminalAction::new())), None),
(
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
None,
),
],
),
(
@@ -7105,6 +7189,10 @@ mod test {
msg(|m| m.set_screenshot_request(ScreenshotRequest::new())),
Some("screenshot_request"),
),
(
misc_msg(|m| m.set_switch_sides_request(SwitchSidesRequest::new())),
Some("misc.switch_sides_request"),
),
(misc_msg(|m| m.set_refresh_video(true)), None),
(misc_msg(|m| m.set_refresh_video_display(0)), None),
(

View File

@@ -25,12 +25,147 @@ struct ChangedResolution {
lazy_static::lazy_static! {
static ref IS_CAPTURER_MAGNIFIER_SUPPORTED: bool = is_capturer_mag_supported();
static ref CHANGED_RESOLUTIONS: Arc<RwLock<HashMap<String, ChangedResolution>>> = Default::default();
// Initial primary display index.
// It should not be updated when displays changed.
pub static ref PRIMARY_DISPLAY_IDX: usize = get_primary();
static ref SYNC_DISPLAYS: Arc<Mutex<SyncDisplaysInfo>> = Default::default();
}
#[cfg(target_os = "linux")]
lazy_static::lazy_static! {
static ref WAYLAND_UINPUT_RECT: Mutex<WaylandUinputRect> = Default::default();
static ref WAYLAND_LAYOUT: Mutex<WaylandLayout> = Default::default();
}
#[cfg(target_os = "linux")]
const WAYLAND_LAYOUT_CHECK_INTERVAL: Duration = Duration::from_millis(1500);
#[cfg(target_os = "linux")]
#[derive(Default)]
struct WaylandUinputRect {
rect: Option<(i32, i32, i32, i32)>,
last_check: Option<std::time::Instant>,
}
// Per-display layout used to correct injected coordinates when the compositor moves a
// monitor mid-session. The client keeps sending coordinates offset by the layout it was
// told at session init (`baseline`); we remap them onto the current layout (`live`).
// https://github.com/rustdesk/rustdesk/issues/15601
#[cfg(target_os = "linux")]
#[derive(Default)]
struct WaylandLayout {
baseline: Vec<scrap::wayland::display::DisplayRect>,
live: Vec<scrap::wayland::display::DisplayRect>,
}
// Whether `live` differs from `baseline`. Read on every mouse move, so it is an atomic:
// the common (no-drift) case never touches the layout mutex.
#[cfg(target_os = "linux")]
static WAYLAND_LAYOUT_DRIFTED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "linux")]
pub(super) fn set_wayland_uinput_rect(rect: (i32, i32, i32, i32)) {
WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect);
}
#[cfg(target_os = "linux")]
pub(super) fn set_wayland_layout_baseline(baseline: Vec<scrap::wayland::display::DisplayRect>) {
WAYLAND_LAYOUT_DRIFTED.store(false, Ordering::Relaxed);
let mut lock = WAYLAND_LAYOUT.lock().unwrap();
lock.baseline = baseline;
lock.live.clear();
}
// Remap an injected coordinate onto the live compositor layout when it has drifted from
// what the client was told at session init. Lock-free no-op otherwise.
#[cfg(target_os = "linux")]
pub(super) fn remap_wayland_uinput_coord(x: i32, y: i32) -> (i32, i32) {
if !WAYLAND_LAYOUT_DRIFTED.load(Ordering::Relaxed) {
return (x, y);
}
let lock = WAYLAND_LAYOUT.lock().unwrap();
scrap::wayland::display::remap_to_live_layout(x, y, &lock.baseline, &lock.live)
}
// The uinput absolute range is set when the session inits. If the compositor layout
// changes afterwards (monitor scale/position change, or a portal virtual output
// appearing once the capture starts), injected coordinates get rescaled by the stale
// range and land offset, https://github.com/rustdesk/rustdesk/issues/15601
#[cfg(target_os = "linux")]
fn refresh_wayland_uinput_rect_if_changed() {
if is_x11() || !crate::input_service::wayland_use_uinput() {
return;
}
{
let mut lock = WAYLAND_UINPUT_RECT.lock().unwrap();
if let Some(last_check) = lock.last_check {
if last_check.elapsed() < WAYLAND_LAYOUT_CHECK_INTERVAL {
return;
}
}
lock.last_check = Some(std::time::Instant::now());
}
let Some((rect, live_rects)) = scrap::wayland::display::get_layout_for_uinput_live() else {
return;
};
// Refresh the per-display layout every poll: monitor origins can shift (e.g. two
// displays swap positions) without changing the overall desktop rect, and the mouse
// path needs the current per-display geometry to correct coordinates.
let drifted = {
let mut layout = WAYLAND_LAYOUT.lock().unwrap();
let drifted = !layout.baseline.is_empty()
&& !live_rects.is_empty()
&& layout.baseline != live_rects;
layout.live = live_rects;
drifted
};
// The remap corrects for per-display origin shifts; the uinput ABS range corrects for
// the overall bounding box. Only enable the remap once the range matches the live
// layout, otherwise moves would be remapped into a range the device is not yet using.
// A drift with no bbox change (origins swapped) needs no range update and enables now.
let mut range_ok = WAYLAND_UINPUT_RECT.lock().unwrap().rect == Some(rect);
if !range_ok {
let (minx, maxx, miny, maxy) = rect;
log::info!(
"desktop layout changed, update mouse resolution: ({}, {}), ({}, {})",
minx,
maxx,
miny,
maxy
);
match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => {
// Bound the IPC wait, this runs on the display service loop and
// `set_resolution()` has no timeout on the response read.
// timeout must be built inside the runtime, or it panics
// "there is no reactor running". See clipboard_service.rs.
match rt.block_on(async {
timeout(
3_000,
crate::input_service::update_mouse_resolution(minx, maxx, miny, maxy),
)
.await
}) {
// Record the rect only after a successful apply, so a transient
// failure is retried on the next check.
Ok(Ok(())) => {
WAYLAND_UINPUT_RECT.lock().unwrap().rect = Some(rect);
range_ok = true;
}
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
}
}
Err(err) => {
log::error!("Failed to build tokio runtime: {}", err);
}
}
}
// Publish the flag last: a `true` read is always backed by a current `live` and a
// matching uinput range. A failed range apply leaves this false and retries next poll.
WAYLAND_LAYOUT_DRIFTED.store(drifted && range_ok, Ordering::Relaxed);
}
// https://github.com/rustdesk/rustdesk/pull/8537
static TEMP_IGNORE_DISPLAYS_CHANGED: AtomicBool = AtomicBool::new(false);
@@ -41,22 +176,14 @@ struct SyncDisplaysInfo {
}
impl SyncDisplaysInfo {
fn check_changed(&mut self, displays: Vec<DisplayInfo>) {
if self.displays.len() != displays.len() {
self.displays = displays;
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
self.is_synced = false;
}
fn check_changed(&mut self, displays: &[DisplayInfo]) {
if self.displays.as_slice() == displays {
return;
}
for (i, d) in displays.iter().enumerate() {
if d != &self.displays[i] {
self.displays = displays;
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
self.is_synced = false;
}
return;
}
self.displays = displays.to_vec();
if !TEMP_IGNORE_DISPLAYS_CHANGED.load(Ordering::Relaxed) {
self.is_synced = false;
}
}
@@ -242,6 +369,12 @@ fn run(sp: EmptyExtraFieldService) -> ResultType<()> {
sp.send(msg_out);
log::info!("Displays changed");
}
#[cfg(target_os = "linux")]
if sp.has_subscribes() {
refresh_wayland_uinput_rect_if_changed();
}
std::thread::sleep(Duration::from_millis(300));
}
@@ -304,6 +437,11 @@ pub(super) fn get_display_info(idx: usize) -> Option<DisplayInfo> {
// Display to DisplayInfo
// The DisplayInfo is be sent to the peer.
pub(super) fn check_update_displays(all: &Vec<Display>) {
let _ = update_sync_displays(all);
}
// Return the converted input snapshot while updating the shared display cache.
pub(super) fn update_sync_displays(all: &Vec<Display>) -> Vec<DisplayInfo> {
// For compatibility: if only one display, scale remains 1.0 and we use the physical size for `uinput`.
// If there are multiple displays, we use the logical size for `uinput` by setting scale to d.scale().
#[cfg(target_os = "linux")]
@@ -346,7 +484,8 @@ pub(super) fn check_update_displays(all: &Vec<Display>) {
}
})
.collect::<Vec<DisplayInfo>>();
SYNC_DISPLAYS.lock().unwrap().check_changed(displays);
SYNC_DISPLAYS.lock().unwrap().check_changed(&displays);
displays
}
pub fn is_inited_msg() -> Option<Message> {
@@ -357,34 +496,38 @@ pub fn is_inited_msg() -> Option<Message> {
None
}
pub async fn update_get_sync_displays_on_login() -> ResultType<Vec<DisplayInfo>> {
// Return the primary index with the refreshed list so login cannot mix display snapshots.
pub async fn update_get_sync_displays_on_login() -> ResultType<(Vec<DisplayInfo>, usize)> {
#[cfg(target_os = "linux")]
{
if !is_x11() {
return super::wayland::get_displays().await;
let (displays, primary_display_idx) =
super::wayland::get_displays_and_primary().await?;
let primary_display_idx =
normalize_primary_display_idx(primary_display_idx, displays.len());
return Ok((displays, primary_display_idx));
}
}
#[cfg(not(windows))]
let displays = display_service::try_get_displays();
#[cfg(windows)]
let displays = display_service::try_get_displays_add_amyuni_headless();
check_update_displays(&displays?);
Ok(SYNC_DISPLAYS.lock().unwrap().displays.clone())
let displays = displays?;
let primary_display_idx = get_primary_2(&displays);
let sync_displays = update_sync_displays(&displays);
let primary_display_idx =
normalize_primary_display_idx(primary_display_idx, sync_displays.len());
Ok((sync_displays, primary_display_idx))
}
#[inline]
pub fn get_primary() -> usize {
#[cfg(target_os = "linux")]
{
if !is_x11() {
return match super::wayland::get_primary() {
Ok(n) => n,
Err(_) => 0,
};
}
fn normalize_primary_display_idx(primary_display_idx: usize, display_len: usize) -> usize {
// Zero is the protocol fallback when the list is empty or its primary index is stale.
if primary_display_idx < display_len {
primary_display_idx
} else {
0
}
try_get_displays().map(|d| get_primary_2(&d)).unwrap_or(0)
}
#[inline]
@@ -486,3 +629,16 @@ pub fn try_get_displays_(add_amyuni_headless: bool) -> ResultType<Vec<Display>>
}
Ok(displays)
}
#[cfg(test)]
mod tests {
use super::normalize_primary_display_idx;
#[test]
fn normalize_primary_display_idx_bounds() {
assert_eq!(normalize_primary_display_idx(0, 0), 0);
assert_eq!(normalize_primary_display_idx(0, 2), 0);
assert_eq!(normalize_primary_display_idx(1, 2), 1);
assert_eq!(normalize_primary_display_idx(2, 2), 0);
}
}

View File

@@ -661,20 +661,22 @@ pub async fn setup_rdp_input() -> ResultType<(), Box<dyn std::error::Error>> {
pub async fn update_mouse_resolution(minx: i32, maxx: i32, miny: i32, maxy: i32) -> ResultType<()> {
set_uinput_resolution(minx, maxx, miny, maxy).await?;
std::thread::spawn(|| {
// Confirm the device adopted the new range before the caller caches it.
// spawn_blocking because ENIGO is a std Mutex and send_refresh blocks on IPC.
tokio::task::spawn_blocking(move || {
if let Some(mouse) = ENIGO.lock().unwrap().get_custom_mouse() {
if let Some(mouse) = mouse
.as_mut_any()
.downcast_mut::<super::uinput::client::UInputMouse>()
{
allow_err!(mouse.send_refresh());
} else {
log::error!("failed downcast uinput mouse");
return mouse.send_refresh();
}
bail!("failed to downcast custom mouse to UInputMouse");
}
});
Ok(())
// No custom mouse: nothing to refresh.
Ok(())
})
.await?
}
#[cfg(target_os = "linux")]
@@ -1098,12 +1100,23 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
MOUSE_TYPE_MOVE => {
// Switching back to absolute movement implicitly disables relative mouse mode.
set_relative_mouse_active(conn, false);
en.mouse_move_to(evt.x, evt.y);
// On Wayland with uinput, the client sends coordinates in the layout it was
// told at session init. If the compositor has since moved a monitor, correct
// them onto the current layout. https://github.com/rustdesk/rustdesk/issues/15601
#[cfg(target_os = "linux")]
let (mx, my) = if wayland_use_uinput() {
super::display_service::remap_wayland_uinput_coord(evt.x, evt.y)
} else {
(evt.x, evt.y)
};
#[cfg(not(target_os = "linux"))]
let (mx, my) = (evt.x, evt.y);
en.mouse_move_to(mx, my);
*LATEST_PEER_INPUT_CURSOR.lock().unwrap() = Input {
conn,
time: get_time(),
x: evt.x,
y: evt.y,
x: mx,
y: my,
};
}
// MOUSE_TYPE_MOVE_RELATIVE: Relative mouse movement for gaming/3D applications.

View File

@@ -130,7 +130,16 @@ pub mod client {
}
pub fn send_refresh(&mut self) -> ResultType<()> {
self.send(Data::Mouse(DataMouse::Refresh))
self.rt
.block_on(self.conn.send(&Data::Mouse(DataMouse::Refresh)))?;
// Wait for the service to confirm it recreated the device, so a
// failed refresh is distinguishable from a good one.
match self.rt.block_on(self.conn.next_timeout(IPC_REQUEST_TIMEOUT)) {
Ok(Some(Data::Empty)) => Ok(()),
Ok(Some(resp)) => bail!("unexpected uinput mouse refresh response: {:?}", &resp),
Ok(None) => bail!("uinput mouse refresh failed, connection closed"),
Err(e) => bail!("uinput mouse refresh timeout {}, {}", IPC_REQUEST_TIMEOUT, e),
}
}
}
@@ -851,9 +860,10 @@ pub mod service {
match data {
Data::Mouse(data) => {
if let DataMouse::Refresh = data {
let resolution = RESOLUTION.lock().unwrap();
let rng_x = resolution.0.clone();
let rng_y = resolution.1.clone();
let (rng_x, rng_y) = {
let resolution = RESOLUTION.lock().unwrap();
(resolution.0.clone(), resolution.1.clone())
};
log::info!(
"Refresh uinput mouce with rng_x: ({}, {}), rng_y: ({}, {})",
rng_x.0,
@@ -861,11 +871,19 @@ pub mod service {
rng_y.0,
rng_y.1
);
mouse = match mouce::UInputMouseManager::new(rng_x, rng_y) {
Ok(mouse) => mouse,
match mouce::UInputMouseManager::new(rng_x, rng_y) {
Ok(m) => {
mouse = m;
// Ack: device adopted the new range.
allow_err!(stream.send(&Data::Empty).await);
}
Err(e) => {
log::error!("Failed to create mouse, {}", e);
return;
// Keep the current device; withhold the ack
// so the client times out and retries.
log::error!(
"Failed to recreate uinput mouse, keeping current: {}",
e
);
}
}
} else {

View File

@@ -137,6 +137,9 @@ pub(super) async fn check_init() -> ResultType<()> {
if !is_x11() {
if CAP_DISPLAY_INFO.read().unwrap().is_empty() {
if crate::input_service::wayland_use_uinput() {
// The cached layout may predate compositor changes made while no session
// was active, https://github.com/rustdesk/rustdesk/issues/15601
scrap::wayland::display::clear_wayland_displays_cache();
if let Some((minx, maxx, miny, maxy)) =
scrap::wayland::display::get_desktop_rect_for_uinput()
{
@@ -147,9 +150,28 @@ pub(super) async fn check_init() -> ResultType<()> {
miny,
maxy
);
allow_err!(
input_service::update_mouse_resolution(minx, maxx, miny, maxy).await
);
// Bound the IPC wait like the periodic refresh does, so a hung
// response can't stall session init.
match timeout(
3_000,
input_service::update_mouse_resolution(minx, maxx, miny, maxy),
)
.await
{
Ok(Ok(())) => {
super::display_service::set_wayland_uinput_rect((
minx, maxx, miny, maxy,
));
// Snapshot the per-display layout the client's coordinates
// will be based on, so the mouse path can correct them if
// the compositor moves a monitor mid-session.
super::display_service::set_wayland_layout_baseline(
scrap::wayland::display::get_display_rects_for_uinput(),
);
}
Ok(Err(err)) => log::error!("Failed to update mouse resolution: {}", err),
Err(err) => log::error!("Failed to update mouse resolution: {}", err),
}
} else {
log::warn!("Failed to get desktop rect for uinput");
}
@@ -175,8 +197,7 @@ pub(super) async fn check_init() -> ResultType<()> {
*PIPEWIRE_INITIALIZED.write().unwrap() = true;
let num = all.len();
let primary = super::display_service::get_primary_2(&all);
super::display_service::check_update_displays(&all);
let mut displays = super::display_service::get_sync_displays();
let mut displays = super::display_service::update_sync_displays(&all);
for display in displays.iter_mut() {
display.cursor_embedded = is_cursor_embedded();
}
@@ -220,27 +241,15 @@ pub(super) async fn check_init() -> ResultType<()> {
Ok(())
}
pub(super) async fn get_displays() -> ResultType<Vec<DisplayInfo>> {
pub(super) async fn get_displays_and_primary() -> ResultType<(Vec<DisplayInfo>, usize)> {
check_init().await?;
// Keep one read guard so clear/reinitialization cannot split these across cache snapshots.
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
if let Some(addr) = cap_map.values().next() {
let cap_display_info: *const CapDisplayInfo = *addr as _;
unsafe {
let cap_display_info = &*cap_display_info;
Ok(cap_display_info.displays.clone())
}
} else {
bail!("Failed to get capturer display info");
}
}
pub(super) fn get_primary() -> ResultType<usize> {
let cap_map = CAP_DISPLAY_INFO.read().unwrap();
if let Some(addr) = cap_map.values().next() {
let cap_display_info: *const CapDisplayInfo = *addr as _;
unsafe {
let cap_display_info = &*cap_display_info;
Ok(cap_display_info.primary)
Ok((cap_display_info.displays.clone(), cap_display_info.primary))
}
} else {
bail!("Failed to get capturer display info");

View File

@@ -5,6 +5,14 @@ fn main() {}
#[cfg(target_os = "macos")]
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() > 1 && args[1] == "--write-plists" {
if let Err(e) = librustdesk::platform::write_plists() {
eprintln!("Failed to write plists: {}", e);
std::process::exit(1);
}
std::process::exit(0);
}
crate::common::load_custom_client();
hbb_common::init_log(false, "service");
crate::start_os_service();

View File

@@ -151,7 +151,7 @@ class Header: Reactor.Component {
<span #action>{svg_action}</span>
<span #display>{svg_display}</span>
<span #keyboard>{svg_keyboard}</span>
{recording_enabled ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
{recording_enabled && show_recording_button ? <span #recording>{recording ? svg_recording_on : svg_recording_off}</span> : ""}
{this.renderKeyboardPop()}
{this.renderDisplayPop()}
{this.renderActionPop()}

View File

@@ -504,6 +504,7 @@ impl sciter::EventHandler for SciterSession {
fn get_id();
fn get_default_pi();
fn get_option(String);
fn get_local_option(String);
fn t(String);
fn set_option(String, String);
fn input_os_password(String, bool);
@@ -638,6 +639,10 @@ impl SciterSession {
crate::client::translate(name)
}
pub fn get_local_option(&self, key: String) -> String {
crate::ui_interface::get_local_option(key)
}
pub fn get_icon(&self) -> String {
super::get_icon()
}

View File

@@ -17,6 +17,7 @@ var audio_enabled = true; // server side
var file_enabled = true; // server side
var restart_enabled = true; // server side
var recording_enabled = true; // server side
var show_recording_button = handler.get_local_option("hide-recording-button") != "Y";
var privacy_mode_enabled = true; // server side
var scroll_body = $(body);
var peer_platform = "";

View File

@@ -911,6 +911,29 @@ pub fn get_langs() -> String {
json!(x).to_string()
}
// Preserve relative paths for existing configurations and only remove accidental
// surrounding whitespace. Config values are not shell-expanded (for example, `~`).
fn trim_video_save_directory(value: &str) -> Option<&str> {
let value = value.trim();
if !value.is_empty() {
Some(value)
} else {
None
}
}
// A Windows service typically runs with System32 as its working directory, so
// require an absolute path to avoid resolving recordings there unexpectedly.
#[cfg(any(windows, test))]
fn validate_windows_service_video_save_directory(value: &str) -> Option<&str> {
let value = trim_video_save_directory(value)?;
if std::path::Path::new(value).is_absolute() {
Some(value)
} else {
None
}
}
#[inline]
pub fn video_save_directory(root: bool) -> String {
let appname = crate::get_app_name();
@@ -930,6 +953,15 @@ pub fn video_save_directory(root: bool) -> String {
// Currently, only installed windows run as root
#[cfg(windows)]
{
let dir = Config::get_option(OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY);
if let Some(dir) = validate_windows_service_video_save_directory(&dir) {
return dir.to_owned();
}
if !dir.trim().is_empty() {
log::warn!(
"Ignoring {OPTION_WINDOWS_SERVICE_VIDEO_SAVE_DIRECTORY}: path must be absolute"
);
}
let drive = std::env::var("SystemDrive").unwrap_or("C:".to_owned());
let dir =
std::path::PathBuf::from(format!("{drive}\\ProgramData\\{appname}\\recording",));
@@ -941,8 +973,8 @@ pub fn video_save_directory(root: bool) -> String {
let dir = LocalConfig::get_option_from_file(OPTION_VIDEO_SAVE_DIRECTORY);
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
let dir = LocalConfig::get_option(OPTION_VIDEO_SAVE_DIRECTORY);
if !dir.is_empty() {
return dir;
if let Some(dir) = trim_video_save_directory(&dir) {
return dir.to_owned();
}
#[cfg(any(target_os = "android", target_os = "ios"))]
if let Ok(home) = config::APP_HOME_DIR.read() {
@@ -1705,3 +1737,41 @@ pub fn is_remote_modify_enabled_by_control_permissions() -> Option<bool> {
.lock()
.unwrap()
}
#[cfg(test)]
mod tests {
use super::{trim_video_save_directory, validate_windows_service_video_save_directory};
#[test]
fn trim_configured_video_save_directory() {
assert_eq!(
trim_video_save_directory(" relative/recordings "),
Some("relative/recordings")
);
assert_eq!(trim_video_save_directory(" "), None);
}
#[test]
fn validate_service_video_save_directory() {
let absolute = if cfg!(windows) {
r"C:\recordings"
} else {
"/recordings"
};
let padded = format!(" {absolute} ");
assert_eq!(
validate_windows_service_video_save_directory(&padded),
Some(absolute)
);
assert_eq!(
validate_windows_service_video_save_directory("recordings"),
None
);
assert_eq!(
validate_windows_service_video_save_directory(&format!("\"{absolute}\"")),
None
);
assert_eq!(validate_windows_service_video_save_directory(" "), None);
}
}

View File

@@ -11,6 +11,51 @@ use std::{
time::{Duration, Instant},
};
#[cfg(target_os = "macos")]
use std::os::{
fd::AsRawFd,
unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt},
};
#[cfg(target_os = "macos")]
struct MacUpdateLock {
_file: std::fs::File,
}
#[cfg(target_os = "macos")]
fn acquire_mac_update_lock() -> ResultType<MacUpdateLock> {
let path = std::path::PathBuf::from("/var/run/rustdesk-update.lock");
let handle = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.custom_flags(hbb_common::libc::O_NOFOLLOW | hbb_common::libc::O_CLOEXEC)
.open(&path)?;
let metadata = handle.metadata()?;
if !metadata.file_type().is_file() || metadata.uid() != 0 {
bail!("[root-update] update lock is not a root-owned regular file");
}
handle.set_permissions(std::fs::Permissions::from_mode(0o600))?;
// Keep the descriptor open through update preparation and detached-script
// launch. O_CLOEXEC means this lock does not cover the detached bundle
// swap; flock is released when this guard is dropped or the process exits.
let lock_result = unsafe {
hbb_common::libc::flock(
handle.as_raw_fd(),
hbb_common::libc::LOCK_EX | hbb_common::libc::LOCK_NB,
)
};
if lock_result != 0 {
let err = std::io::Error::last_os_error();
if err.kind() == std::io::ErrorKind::WouldBlock {
bail!("[root-update] another update is already running");
}
return Err(err.into());
}
Ok(MacUpdateLock { _file: handle })
}
enum UpdateMsg {
CheckUpdate,
Exit,
@@ -22,7 +67,17 @@ lazy_static::lazy_static! {
static CONTROLLING_SESSION_COUNT: AtomicUsize = AtomicUsize::new(0);
const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24);
/// Initial wait after startup before the first update check (30 seconds).
pub const INITIAL_CHECK_DELAY: Duration = Duration::from_secs(30);
/// One full day — default interval between update checks.
pub const DUR_ONE_DAY: Duration = Duration::from_secs(60 * 60 * 24);
/// Minimum interval between consecutive update checks (10 minutes).
pub const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10);
/// Retry interval when an update check fails or a session is active (30 minutes).
pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30);
pub fn update_controlling_session_count(count: usize) {
CONTROLLING_SESSION_COUNT.store(count, Ordering::SeqCst);
@@ -47,7 +102,9 @@ pub fn stop_auto_update() {
}
#[inline]
fn has_no_active_conns() -> bool {
/// Returns true when there are no active incoming or outgoing connections.
/// Used to avoid updating while a remote session is in progress.
pub fn has_no_active_conns() -> bool {
let conns = crate::Connection::alive_conns();
conns.is_empty() && has_no_controlling_conns()
}
@@ -82,13 +139,11 @@ fn start_auto_update_check() -> Sender<UpdateMsg> {
}
fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
std::thread::sleep(Duration::from_secs(30));
std::thread::sleep(INITIAL_CHECK_DELAY);
if let Err(e) = check_update(false) {
log::error!("Error checking for updates: {}", e);
}
const MIN_INTERVAL: Duration = Duration::from_secs(60 * 10);
const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 30);
let mut last_check_time = Instant::now();
let mut check_interval = DUR_ONE_DAY;
loop {
@@ -118,6 +173,12 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
}
fn check_update(manually: bool) -> ResultType<()> {
// On macOS, auto-update is handled by check_update_as_root() in the service process.
// The shared check_update() path is only used for manual update checks from the GUI.
#[cfg(target_os = "macos")]
if !manually {
return Ok(());
}
#[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
@@ -348,6 +409,251 @@ pub fn get_download_file_from_url(url: &str) -> Option<PathBuf> {
get_update_download_file_from_url(url)
}
/// Queries all active connections (remote, file-transfer, port-forward, camera, terminal)
/// from every logged-in user's --server process via IPC.
/// The root service cannot read connection state directly since connections
/// live in user --server processes. Handles fast user switching by querying
/// all GUI users, including the login-window server at UID 0. Falls back to
/// false (assumes sessions active) on any IPC error to avoid updating during
/// an unknown session state.
#[cfg(target_os = "macos")]
pub fn has_no_active_conns_ipc() -> bool {
let rt = match hbb_common::tokio::runtime::Runtime::new() {
Ok(rt) => rt,
Err(_) => return false,
};
rt.block_on(async {
// Use the same GUI-domain-filtered UID set as the update script.
// Shell-only SSH/TTY users are excluded, while an empty GUI set maps
// to UID 0 so the LoginWindow server is queried rather than assumed idle.
let uids = crate::platform::get_logged_in_uids();
// Check each user's server — fail closed if any has active connections
for uid in uids {
if let Ok(mut conn) = crate::ipc::connect_for_uid(1000, uid, "").await {
if conn.send(&crate::ipc::Data::HasNoActiveConns(None)).await.is_ok() {
match conn.next_timeout(1000).await {
Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(true)))) => {
// Explicit no active connections — safe to continue
}
Ok(Some(crate::ipc::Data::HasNoActiveConns(Some(false)))) => {
return false; // Explicit active connections
}
_ => {
return false; // Timeout/error/unexpected — fail closed
}
}
} else {
return false; // Send failed — fail closed
}
} else {
return false; // Connection failed — fail closed
}
}
true // All users explicitly confirmed no active connections
})
}
#[cfg(target_os = "macos")]
fn wait_for_failed_update_retry() {
const FAILURE_MARKER: &str = "/var/root/.rustdeskupdate_failed";
let marker = std::path::Path::new(FAILURE_MARKER);
if !marker.exists() {
return;
}
// The updater script records failure immediately before launchd restarts
// the old daemon. Preserve the retry deadline across that restart instead
// of consuming the marker and retrying the same broken release in 30 sec.
let remaining = std::fs::metadata(marker)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| {
std::time::SystemTime::now()
.duration_since(modified)
.ok()
})
.map(|elapsed| RETRY_INTERVAL.saturating_sub(elapsed))
.unwrap_or(RETRY_INTERVAL);
if !remaining.is_zero() {
log::info!(
"[root-update] Previous update failed; retrying in {} seconds.",
remaining.as_secs()
);
std::thread::sleep(remaining);
}
match std::fs::remove_file(marker) {
Ok(()) => log::info!("[root-update] Previous update retry interval elapsed."),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => log::warn!("[root-update] Failed to clear failure marker: {}", err),
}
}
/// Starts the background silent auto-update scheduler for macOS.
/// Called from `start_os_service()` which runs as root via LaunchDaemon.
#[cfg(target_os = "macos")]
pub fn start_auto_update_macos() {
let spawn_result = std::thread::Builder::new()
.name("rustdesk-auto-update".to_owned())
.spawn(|| {
log::info!("[root-update] Auto-update scheduler thread started.");
std::thread::sleep(INITIAL_CHECK_DELAY);
wait_for_failed_update_retry();
let mut interval = DUR_ONE_DAY;
loop {
log::info!("[root-update] Running scheduled update check...");
let no_active_conns = has_no_active_conns_ipc();
if !no_active_conns {
log::info!("[root-update] Active session in progress, retrying in 10 min.");
interval = MIN_INTERVAL;
} else {
match check_update_as_root() {
Ok(update_started) => {
if update_started {
// The replacement script is detached and may fail
// after this process returns. Always retry at the
// failure interval until the new daemon replaces us.
interval = RETRY_INTERVAL;
} else {
interval = DUR_ONE_DAY;
}
}
Err(e) => {
log::error!("[root-update] Update check failed: {}", e);
interval = RETRY_INTERVAL;
}
}
}
std::thread::sleep(interval);
}
});
if let Err(err) = spawn_result {
log::error!("[root-update] Failed to start scheduler thread: {}", err);
}
}
#[cfg(target_os = "macos")]
pub fn check_update_as_root() -> ResultType<bool> {
let _update_lock = acquire_mac_update_lock()?;
// Allow-auto-update setting
if !config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE) {
log::info!("[root-update] Auto update is disabled, skipping.");
return Ok(false);
}
if crate::is_custom_client() {
log::info!("[root-update] Custom client detected, skipping stock update.");
return Ok(false);
}
// Clean up only old temp dirs from previous failed updates. The detached
// installer keeps using its update directory after this process exits and
// releases the advisory lock, so a newly-started daemon must not remove a
// directory that still belongs to the active transaction.
if let Ok(entries) = std::fs::read_dir("/tmp") {
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with(".rustdeskupdate-root-")
|| name_str.starts_with(".rustdeskdownload-")
{
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
let mode = metadata.mode() & 0o7777;
let is_stale = metadata
.modified()
.ok()
.and_then(|modified| std::time::SystemTime::now().duration_since(modified).ok())
.is_some_and(|age| age >= RETRY_INTERVAL);
if metadata.file_type().is_dir() && metadata.uid() == 0 && mode == 0o700 && is_stale
{
if let Err(err) = std::fs::remove_dir_all(&path) {
log::warn!(
"[root-update] Failed to remove stale temp dir {}: {}",
path.display(),
err
);
}
}
}
}
}
if let Err(e) = do_check_software_update() {
bail!("[root-update] Failed to check for software update: {}", e);
}
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone();
if update_url.is_empty() {
log::info!("[root-update] No update available.");
return Ok(false);
}
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default().to_string();
let arch = if std::env::consts::ARCH == "aarch64" { "aarch64" } else { "x86_64" };
let dmg_url = format!("{}/rustdesk-{}-{}.dmg", download_url, version, arch);
log::info!("[root-update] New version: {}, downloading from {}", version, dmg_url);
// Validate URL against GitHub release allowlist before downloading as root
let Some(file_path_validated) = get_update_download_file_from_url(&dmg_url) else {
bail!("[root-update] URL failed allowlist check: {}", dmg_url);
};
drop(file_path_validated);
let client = create_http_client_with_url_strict(&dmg_url)?;
// Use mktemp so a local user cannot pre-create a predictable path and
// permanently deny updates for a reused service PID.
let private_tmp_output = std::process::Command::new("/usr/bin/mktemp")
.args(["-d", "/tmp/.rustdeskdownload-XXXXXX"])
.output()?;
if !private_tmp_output.status.success() {
bail!(
"[root-update] Failed to create private download directory: {}",
String::from_utf8_lossy(&private_tmp_output.stderr).trim()
);
}
let private_tmp = String::from_utf8(private_tmp_output.stdout)
.map_err(|err| hbb_common::anyhow::anyhow!("[root-update] mktemp output error: {}", err))?
.trim()
.to_owned();
if private_tmp.is_empty() {
bail!("[root-update] mktemp returned an empty download directory");
}
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&private_tmp, std::fs::Permissions::from_mode(0o700))?;
}
let filename = dmg_url.split('/').last().unwrap_or("rustdesk.dmg");
let file_path = std::path::PathBuf::from(format!("{}/{}", private_tmp, filename));
let tmp_path = file_path.to_string_lossy().to_string();
// Download
let mut response = client.get(&dmg_url).send()?;
if !response.status().is_success() {
let _ = std::fs::remove_dir_all(&private_tmp);
bail!("[root-update] Failed to download: {}", response.status());
}
// Create file exclusively (O_EXCL) and stream response directly into it
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file_path)
.map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?;
std::io::copy(&mut response, &mut file)
.map_err(|e| { let _ = std::fs::remove_dir_all(&private_tmp); e })?;
}
log::info!("[root-update] Downloaded to {}", tmp_path);
// Recheck active sessions before installing — download can take minutes
if !has_no_active_conns_ipc() {
if let Err(e) = std::fs::remove_dir_all(&private_tmp) {
log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e);
}
bail!("[root-update] Active session started during download, deferring update.");
}
// Install silently as root
let result = crate::platform::update_from_dmg_as_root(&tmp_path, &version);
// Clean up download directory
if let Err(e) = std::fs::remove_dir_all(&private_tmp) {
log::warn!("[root-update] Failed to remove temp dir {}: {}", private_tmp, e);
}
result.map(|_| true)
}
#[cfg(test)]
mod tests {
use super::get_download_file_from_url;