mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-09-08 05:20:59 +03:00
feat(macos): silent auto-update with security hardening (#15550)
Co-authored-by: bmmh1 <bmmh1@users.noreply.github.com>
This commit is contained in:
@@ -485,7 +485,8 @@ class _GeneralState extends State<_General> {
|
||||
Widget other() {
|
||||
final incomingOnly = bind.isIncomingOnly();
|
||||
final outgoingOnly = bind.isOutgoingOnly();
|
||||
final showAutoUpdate = isWindows && bind.mainIsInstalled();
|
||||
final showAutoUpdate = (isWindows && bind.mainIsInstalled()) ||
|
||||
(isMacOS && bind.mainIsInstalled() && bind.mainIsInstalledDaemon(prompt: false) && !bind.isCustomClient());
|
||||
final children = <Widget>[
|
||||
if (!isWeb && !incomingOnly)
|
||||
_OptionCheckBox(context, 'Confirm before closing multiple tabs',
|
||||
|
||||
25
src/ipc.rs
25
src/ipc.rs
@@ -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>),
|
||||
@@ -1006,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"))
|
||||
@@ -1340,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")]
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
316
src/updater.rs
316
src/updater.rs
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user