Implement auto-update routine
build-windows / build-hello-agent-x64 (push) Successful in 5m7s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s

This commit is contained in:
2026-05-21 13:34:02 +02:00
parent d10e547b70
commit 8cff0c1863
11 changed files with 664 additions and 201 deletions
+100 -22
View File
@@ -94,11 +94,24 @@ pub mod input {
lazy_static::lazy_static! {
pub static ref SOFTWARE_UPDATE_URL: Arc<Mutex<String>> = Default::default();
// hello-agent local patch: assets resolved by the Gitea-backed
// `do_check_software_update`. `SOFTWARE_UPDATE_URL` is kept holding the
// human-facing tag URL (so `ui_interface::get_new_version` still works
// by rsplit('/')) while the actual binary + sha256 download URLs live
// here and are consumed by `updater::check_update`. None = no update.
pub static ref SOFTWARE_UPDATE_ASSETS: Arc<Mutex<Option<UpdateAssets>>> = Default::default();
pub static ref DEVICE_ID: Arc<Mutex<String>> = Default::default();
pub static ref DEVICE_NAME: Arc<Mutex<String>> = Default::default();
static ref PUBLIC_IPV6_ADDR: Arc<Mutex<(Option<SocketAddr>, Option<Instant>)>> = Default::default();
}
#[derive(Debug, Clone)]
pub struct UpdateAssets {
pub binary_url: String,
pub binary_name: String,
pub sha256_url: String,
}
lazy_static::lazy_static! {
// Is server process, with "--server" args
static ref IS_SERVER: bool = std::env::args().nth(1) == Some("--server".to_owned());
@@ -949,19 +962,45 @@ pub fn check_software_update() {
}
}
// No need to check `danger_accept_invalid_cert` for now.
// Because the url is always `https://api.rustdesk.com/version/latest`.
// hello-agent local patch: instead of POSTing to api.rustdesk.com (the
// upstream endpoint that resolves the latest stock-RustDesk release), this
// queries the Gitea Releases API on the hello-agent repo and resolves
// the binary + sha256 asset URLs of the latest release. The original
// rustdesk-api code path is intentionally gone: a stock-RustDesk auto-update
// would happily replace hello-agent's installation with vanilla rustdesk.
// The product version compared against the release tag is
// `hbb_common::config::AGENT_VERSION` (populated from `CARGO_PKG_VERSION`
// in hello-agent's `main`) — not `crate::VERSION`, which is the embedded
// rustdesk core version and would always be much higher than hello-agent's
// release tags, making the updater think no update is ever available.
const HELLO_AGENT_RELEASES_API: &str =
"https://gitea.cstudio.ch/api/v1/repos/mike/hello-agent/releases/latest";
const HELLO_AGENT_TAG_URL_PREFIX: &str =
"https://gitea.cstudio.ch/mike/hello-agent/releases/tag";
#[derive(Debug, serde::Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
#[derive(Debug, serde::Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
}
#[tokio::main(flavor = "current_thread")]
pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
let (request, url) =
hbb_common::version_check_request(hbb_common::VER_TYPE_RUSTDESK_CLIENT.to_string());
let url = HELLO_AGENT_RELEASES_API;
let proxy_conf = Config::get_socks();
let tls_url = get_url_for_tls(&url, &proxy_conf);
let tls_url = get_url_for_tls(url, &proxy_conf);
let tls_type = get_cached_tls_type(tls_url);
let is_tls_not_cached = tls_type.is_none();
let tls_type = tls_type.unwrap_or(TlsType::Rustls);
let client = create_http_client_async(tls_type, false);
let latest_release_response = match client.post(&url).json(&request).send().await {
let response = match client.get(url).send().await {
Ok(resp) => {
upsert_tls_cache(tls_url, tls_type, false);
resp
@@ -970,7 +1009,7 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
if is_tls_not_cached && err.is_request() {
let tls_type = TlsType::NativeTls;
let client = create_http_client_async(tls_type, false);
let resp = client.post(&url).json(&request).send().await?;
let resp = client.get(url).send().await?;
upsert_tls_cache(tls_url, tls_type, false);
resp
} else {
@@ -978,24 +1017,63 @@ pub async fn do_check_software_update() -> hbb_common::ResultType<()> {
}
}
};
let bytes = latest_release_response.bytes().await?;
let resp: hbb_common::VersionCheckResponse = serde_json::from_slice(&bytes)?;
let response_url = resp.url;
let latest_release_version = response_url.rsplit('/').next().unwrap_or_default();
if !response.status().is_success() {
bail!("Gitea releases API returned HTTP {}", response.status());
}
let bytes = response.bytes().await?;
let release: GiteaRelease = serde_json::from_slice(&bytes)?;
if get_version_number(&latest_release_version) > get_version_number(crate::VERSION) {
#[cfg(feature = "flutter")]
{
let mut m = HashMap::new();
m.insert("name", "check_software_update_finish");
m.insert("url", &response_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
}
*SOFTWARE_UPDATE_URL.lock().unwrap() = response_url;
let latest_version = release.tag_name.trim_start_matches('v');
let current_version = hbb_common::config::AGENT_VERSION.read().unwrap().clone();
let current_version = if current_version.is_empty() {
crate::VERSION.to_owned()
} else {
current_version
};
if get_version_number(latest_version) <= get_version_number(&current_version) {
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
}
// Pick the Windows binary asset and its SHA256 companion. The release
// is expected to carry a `*.exe` (or `*.exe.signed`) and a matching
// `*.sha256` file. We don't pair them by name — there should only be
// one of each per release.
let binary = release.assets.iter().find(|a| {
let n = a.name.to_lowercase();
(n.ends_with(".exe") || n.ends_with(".exe.signed")) && !n.ends_with(".sha256")
});
let sha256 = release
.assets
.iter()
.find(|a| a.name.to_lowercase().ends_with(".sha256"));
let (Some(binary), Some(sha256)) = (binary, sha256) else {
log::warn!(
"hello-agent release {} is missing a binary and/or .sha256 asset",
release.tag_name
);
*SOFTWARE_UPDATE_URL.lock().unwrap() = "".to_string();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = None;
return Ok(());
};
let tag_url = format!("{}/{}", HELLO_AGENT_TAG_URL_PREFIX, release.tag_name);
*SOFTWARE_UPDATE_URL.lock().unwrap() = tag_url.clone();
*SOFTWARE_UPDATE_ASSETS.lock().unwrap() = Some(UpdateAssets {
binary_url: binary.browser_download_url.clone(),
binary_name: binary.name.clone(),
sha256_url: sha256.browser_download_url.clone(),
});
#[cfg(feature = "flutter")]
{
let mut m = HashMap::new();
m.insert("name", "check_software_update_finish");
m.insert("url", &tag_url);
if let Ok(data) = serde_json::to_string(&m) {
let _ = crate::flutter::push_global_event(crate::flutter::APP_TYPE_MAIN, data);
}
}
Ok(())
}
+61 -20
View File
@@ -1339,14 +1339,30 @@ pub fn copy_raw_cmd(src_raw: &str, _raw: &str, _path: &str) -> ResultType<String
}
pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType<String> {
let main_exe = copy_raw_cmd(src_exe, exe, path)?;
// hello-agent local patch: upstream emits an `XCOPY <parent of src_exe>
// <install dir> /Y /E /H /C /I /K /R /Z`, which recursively copies the
// ENTIRE TEMP directory (the staged binary's parent) into the install
// dir — sweeping along every unrelated file that happens to share the
// temp folder. For hello-agent we ship a single binary, so a one-file
// copy is both correct and safe. We preserve the staged exe's original
// filename so the subsequent `rename_exe_cmd` step (which renames
// <staged-name>.exe → <app_name>.exe) keeps working exactly as upstream
// expects.
//
// We also drop the broker (RuntimeBroker.exe) copy on the second line:
// it's only needed for privacy-mode topmost-window injection, which
// hello-agent doesn't enable (we don't ship the broker as a separate
// artifact, and shipping a copy of a system file under a custom name
// is asking for AV false-positives).
let src_path = PathBuf::from(src_exe);
let src_filename = src_path
.file_name()
.ok_or_else(|| anyhow!("Can't get file name of {src_exe}"))?
.to_string_lossy()
.to_string();
let _ = exe; // upstream signature carries the resolved exe path; not needed for the single-file copy.
Ok(format!(
"
{main_exe}
copy /Y \"{ORIGIN_PROCESS_EXE}\" \"{path}\\{broker_exe}\"
",
ORIGIN_PROCESS_EXE = win_topmost_window::ORIGIN_PROCESS_EXE,
broker_exe = win_topmost_window::INJECTED_PROCESS_EXE,
"copy /Y \"{src_exe}\" \"{path}\\{src_filename}\"\n"
))
}
@@ -3110,21 +3126,27 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
)
}
// hello-agent local patch: only refresh the Add/Remove Programs entry if
// the install path actually created one. Hello-agent's `--install`
// (`src/service.rs::install`) does not write to
// `HKLM\...\Uninstall\<APP_NAME>` — the agent is a headless service
// that intentionally doesn't appear in Add/Remove Programs (there's
// nothing meaningful to uninstall through the shell-integrated UI;
// operators run `hello-agent.exe --uninstall`). Without this guard,
// the upstream `update_me` would *create* the uninstall key on every
// update — and then leave it behind as an orphan, since
// `service::uninstall()` doesn't remove it either. Upstream rustdesk's
// `install_me` does write the key, so stock-rustdesk installs continue
// to get their version display refreshed as before.
let subkey_exists = |sk: &str| {
let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);
hklm.open_subkey(sk.replace("HKEY_LOCAL_MACHINE\\", ""))
.is_ok()
};
let reg_cmd = {
let reg_cmd_main = get_reg_cmd(
&subkey,
is_msi,
&display_icon,
&version,
&build_date,
&version_major,
&version_minor,
&version_build,
size,
);
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
let reg_cmd_main = if subkey_exists(&subkey) {
get_reg_cmd(
&reg_msi_key,
&subkey,
is_msi,
&display_icon,
&version,
@@ -3137,6 +3159,25 @@ reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size}
} else {
"".to_owned()
};
let reg_cmd_msi = if let Some(reg_msi_key) = get_reg_msi_key(&subkey, is_msi) {
if subkey_exists(&reg_msi_key) {
get_reg_cmd(
&reg_msi_key,
is_msi,
&display_icon,
&version,
&build_date,
&version_major,
&version_minor,
&version_build,
size,
)
} else {
"".to_owned()
}
} else {
"".to_owned()
};
format!("{}{}", reg_cmd_main, reg_cmd_msi)
};
+101 -61
View File
@@ -118,8 +118,16 @@ fn start_auto_update_check_(rx_msg: Receiver<UpdateMsg>) {
}
fn check_update(manually: bool) -> ResultType<()> {
// hello-agent local patch: `is_msi_installed()` reads HKLM\...\Uninstall\HelloAgent
// and errors when the uninstall key (or `WindowsInstaller` value) is
// absent, which is the common case for a hello-agent install. The
// upstream code used `?` here, propagating the registry error and
// killing the update before our Gitea check ever ran. Swallow the
// error: `update_msi` will be `false` (correct — hello-agent is a
// custom client so the MSI branch is never the right one anyway).
#[cfg(target_os = "windows")]
let update_msi = crate::platform::is_msi_installed()? && !crate::is_custom_client();
let update_msi =
crate::platform::is_msi_installed().unwrap_or(false) && !crate::is_custom_client();
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(());
}
@@ -128,70 +136,102 @@ fn check_update(manually: bool) -> ResultType<()> {
return Ok(());
}
let update_url = crate::common::SOFTWARE_UPDATE_URL.lock().unwrap().clone();
if update_url.is_empty() {
// hello-agent local patch: the upstream code reconstructed the download
// URL from a GitHub "tag" URL and a hard-coded filename pattern. Both
// are now resolved by `do_check_software_update` against the Gitea
// Releases API and exposed via `SOFTWARE_UPDATE_ASSETS`.
let assets = crate::common::SOFTWARE_UPDATE_ASSETS.lock().unwrap().clone();
let Some(assets) = assets else {
log::debug!("No update available.");
} else {
let download_url = update_url.replace("tag", "download");
let version = download_url.split('/').last().unwrap_or_default();
#[cfg(target_os = "windows")]
let download_url = if cfg!(feature = "flutter") {
format!(
"{}/rustdesk-{}-x86_64.{}",
download_url,
version,
if update_msi { "msi" } else { "exe" }
)
return Ok(());
};
let download_url = assets.binary_url;
let sha256_url = assets.sha256_url;
let version = assets.binary_name;
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else {
format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version)
};
log::debug!("New version available: {}", &version);
let client = create_http_client_with_url(&download_url);
let Some(file_path) = get_download_file_from_url(&download_url) else {
bail!("Failed to get the file path from the URL: {}", download_url);
};
let mut is_file_exists = false;
if file_path.exists() {
// Check if the file size is the same as the server file size
// If the file size is the same, we don't need to download it again.
let file_size = std::fs::metadata(&file_path)?.len();
let response = client.head(&download_url).send()?;
if !response.status().is_success() {
bail!("Failed to get the file size: {}", response.status());
}
let total_size = response
.headers()
.get(reqwest::header::CONTENT_LENGTH)
.and_then(|ct_len| ct_len.to_str().ok())
.and_then(|ct_len| ct_len.parse::<u64>().ok());
let Some(total_size) = total_size else {
bail!("Failed to get content length");
};
if file_size == total_size {
is_file_exists = true;
} else {
std::fs::remove_file(&file_path)?;
}
std::fs::remove_file(&file_path)?;
}
if !is_file_exists {
let response = client.get(&download_url).send()?;
if !response.status().is_success() {
bail!(
"Failed to download the new version file: {}",
response.status()
);
}
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
}
if !is_file_exists {
let response = client.get(&download_url).send()?;
if !response.status().is_success() {
bail!(
"Failed to download the new version file: {}",
response.status()
);
}
let file_data = response.bytes()?;
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// hello-agent local patch: verify the downloaded binary's SHA256 against
// the `.sha256` companion asset published by the same Gitea release
// before launching. We're about to run this file with elevated rights —
// a mismatch means something went wrong in transit or the release was
// tampered with, and we must NOT launch it. The expected-hash file is a
// standard `sha256sum` output (`<hex> <filename>`) or just `<hex>`.
let sha_client = create_http_client_with_url(&sha256_url);
let sha_resp = sha_client.get(&sha256_url).send()?;
if !sha_resp.status().is_success() {
let _ = std::fs::remove_file(&file_path);
bail!("Failed to download SHA256 file: {}", sha_resp.status());
}
let sha_text = sha_resp.text()?;
let expected = sha_text
.split_whitespace()
.next()
.unwrap_or_default()
.to_lowercase();
if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = std::fs::remove_file(&file_path);
bail!("Malformed SHA256 file: {:?}", sha_text);
}
let file_bytes = std::fs::read(&file_path)?;
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(&file_bytes);
let actual = hex::encode(hasher.finalize());
if actual != expected {
log::error!(
"SHA256 mismatch for {}: expected {}, got {}",
version,
expected,
actual
);
let _ = std::fs::remove_file(&file_path);
bail!("SHA256 verification failed");
}
log::info!("SHA256 verified for {}", version);
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {
#[cfg(target_os = "windows")]
update_new_version(update_msi, &version, &file_path);
}
Ok(())
}
+1 -1
View File
@@ -1,3 +1,3 @@
pub const VERSION: &str = "1.4.6";
#[allow(dead_code)]
pub const BUILD_DATE: &str = "2026-05-09 10:43";
pub const BUILD_DATE: &str = "2026-05-21 13:02";