From 394079833efe8925af05ba7254095c5931f4b7bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?VenusGirl=E2=9D=A4?= Date: Fri, 27 Feb 2026 21:14:14 +0900 Subject: [PATCH 01/16] Update ko.rs (#14418) --- src/lang/ko.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lang/ko.rs b/src/lang/ko.rs index 1e3d4f9b8e9..7230d1a1f35 100644 --- a/src/lang/ko.rs +++ b/src/lang/ko.rs @@ -739,6 +739,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> = ("keep-awake-during-outgoing-sessions-label", "발신 세션 중 화면 켜짐 유지"), ("keep-awake-during-incoming-sessions-label", "수신 세션 중 화면 켜짐 유지"), ("Continue with {}", "{}(으)로 계속"), - ("Display Name", ""), + ("Display Name", "표시 이름"), ].iter().cloned().collect(); } From d49ae493b262cd876ac7be8a6fd4e65a518c9e17 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Fri, 27 Feb 2026 20:53:40 +0800 Subject: [PATCH 02/16] bump to 1.4.6 --- .github/workflows/flutter-build.yml | 2 +- .github/workflows/playground.yml | 2 +- .github/workflows/winget.yml | 4 ++-- Cargo.lock | 4 ++-- Cargo.toml | 2 +- appimage/AppImageBuilder-aarch64.yml | 2 +- appimage/AppImageBuilder-x86_64.yml | 2 +- flutter/pubspec.yaml | 2 +- libs/portable/Cargo.toml | 2 +- res/PKGBUILD | 2 +- res/rpm-flutter-suse.spec | 2 +- res/rpm-flutter.spec | 2 +- res/rpm.spec | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/flutter-build.yml b/.github/workflows/flutter-build.yml index 22b24d48350..eb101400dbd 100644 --- a/.github/workflows/flutter-build.yml +++ b/.github/workflows/flutter-build.yml @@ -39,7 +39,7 @@ env: # 2. Update the `VCPKG_COMMIT_ID` in `ci.yml` and `playground.yml`. VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" ARMV7_VCPKG_COMMIT_ID: "6f29f12e82a8293156836ad81cc9bf5af41fe836" # 2025.01.13, got "/opt/artifacts/vcpkg/vcpkg: No such file or directory" with latest version - VERSION: "1.4.5" + VERSION: "1.4.6" NDK_VERSION: "r27c" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/playground.yml b/.github/workflows/playground.yml index 0c7b450a3dd..110437e0f29 100644 --- a/.github/workflows/playground.yml +++ b/.github/workflows/playground.yml @@ -17,7 +17,7 @@ env: TAG_NAME: "nightly" VCPKG_BINARY_SOURCES: "clear;x-gha,readwrite" VCPKG_COMMIT_ID: "120deac3062162151622ca4860575a33844ba10b" - VERSION: "1.4.5" + VERSION: "1.4.6" NDK_VERSION: "r26d" #signing keys env variable checks ANDROID_SIGNING_KEY: "${{ secrets.ANDROID_SIGNING_KEY }}" diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index ce54723e934..90a3d4fb310 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -10,6 +10,6 @@ jobs: - uses: vedantmgoyal9/winget-releaser@main with: identifier: RustDesk.RustDesk - version: "1.4.5" - release-tag: "1.4.5" + version: "1.4.6" + release-tag: "1.4.6" token: ${{ secrets.WINGET_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 5aec389005b..06cfeeb964b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7134,7 +7134,7 @@ dependencies = [ [[package]] name = "rustdesk" -version = "1.4.5" +version = "1.4.6" dependencies = [ "android-wakelock", "android_logger", @@ -7249,7 +7249,7 @@ dependencies = [ [[package]] name = "rustdesk-portable-packer" -version = "1.4.5" +version = "1.4.6" dependencies = [ "brotli", "dirs 5.0.1", diff --git a/Cargo.toml b/Cargo.toml index ac1050bf794..d792d5cd5ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk" -version = "1.4.5" +version = "1.4.6" authors = ["rustdesk "] edition = "2021" build= "build.rs" diff --git a/appimage/AppImageBuilder-aarch64.yml b/appimage/AppImageBuilder-aarch64.yml index d4af2d13a9d..64d6c2cfac5 100644 --- a/appimage/AppImageBuilder-aarch64.yml +++ b/appimage/AppImageBuilder-aarch64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.5 + version: 1.4.6 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/appimage/AppImageBuilder-x86_64.yml b/appimage/AppImageBuilder-x86_64.yml index d85bd381eb7..933673cefb0 100644 --- a/appimage/AppImageBuilder-x86_64.yml +++ b/appimage/AppImageBuilder-x86_64.yml @@ -18,7 +18,7 @@ AppDir: id: rustdesk name: rustdesk icon: rustdesk - version: 1.4.5 + version: 1.4.6 exec: usr/share/rustdesk/rustdesk exec_args: $@ apt: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index b8360db58b0..eb6d7616186 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # 1.1.9-1 works for android, but for ios it becomes 1.1.91, need to set it to 1.1.9-a.1 for iOS, will get 1.1.9.1, but iOS store not allow 4 numbers -version: 1.4.5+63 +version: 1.4.6+64 environment: sdk: '^3.1.0' diff --git a/libs/portable/Cargo.toml b/libs/portable/Cargo.toml index a4a71e14f21..184079be872 100644 --- a/libs/portable/Cargo.toml +++ b/libs/portable/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustdesk-portable-packer" -version = "1.4.5" +version = "1.4.6" edition = "2021" description = "RustDesk Remote Desktop" diff --git a/res/PKGBUILD b/res/PKGBUILD index 3b4096760d9..dd266eb2a1f 100644 --- a/res/PKGBUILD +++ b/res/PKGBUILD @@ -1,5 +1,5 @@ pkgname=rustdesk -pkgver=1.4.5 +pkgver=1.4.6 pkgrel=0 epoch= pkgdesc="" diff --git a/res/rpm-flutter-suse.spec b/res/rpm-flutter-suse.spec index 2049b5f4f58..bb2b56af647 100644 --- a/res/rpm-flutter-suse.spec +++ b/res/rpm-flutter-suse.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.5 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm-flutter.spec b/res/rpm-flutter.spec index f8bc7a1a105..1a077ee7e70 100644 --- a/res/rpm-flutter.spec +++ b/res/rpm-flutter.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.5 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 diff --git a/res/rpm.spec b/res/rpm.spec index 26c4971216a..6a7377b8bcd 100644 --- a/res/rpm.spec +++ b/res/rpm.spec @@ -1,5 +1,5 @@ Name: rustdesk -Version: 1.4.5 +Version: 1.4.6 Release: 0 Summary: RPM package License: GPL-3.0 From 4abdb2e08bd84ccff59571996ba465a7209553fc Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:50:20 +0800 Subject: [PATCH 03/16] feat: windows, custom client, update (#13687) Signed-off-by: fufesou --- .gitignore | 1 + flutter/lib/common.dart | 4 +- .../lib/desktop/pages/desktop_home_page.dart | 8 +- .../desktop/pages/desktop_setting_page.dart | 3 +- flutter/windows/runner/win32_window.cpp | 56 +- libs/hbb_common | 2 +- res/msi/CustomActions/CustomActions.cpp | 192 ++++- src/common.rs | 6 +- src/core_main.rs | 26 +- src/flutter_ffi.rs | 32 +- src/hbbs_http/downloader.rs | 43 +- src/platform/windows.rs | 653 ++++++++++++++++-- src/rendezvous_mediator.rs | 2 +- src/ui/index.tis | 4 +- src/updater.rs | 51 +- 15 files changed, 958 insertions(+), 125 deletions(-) diff --git a/.gitignore b/.gitignore index b4ea6266046..d2e09a9066c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .vscode .idea .DS_Store +.env libsciter-gtk.so src/ui/inline.rs extractor diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index ab1b0b3c566..ca52c61e04a 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -3938,7 +3938,9 @@ void earlyAssert() { void checkUpdate() { if (!isWeb) { - if (!bind.isCustomClient()) { + final isWindowsInstalled = isWindows && bind.mainIsInstalled(); + final shouldCheckUpdate = isWindowsInstalled || !bind.isCustomClient(); + if (shouldCheckUpdate) { platformFFI.registerEventHandler( kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, (Map evt) async { diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index 339ecddb073..b9af2dc7be7 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -430,10 +430,12 @@ class _DesktopHomePageState extends State } Widget buildHelpCards(String updateUrl) { - if (!bind.isCustomClient() && - updateUrl.isNotEmpty && + final isWindowsInstalled = isWindows && bind.mainIsInstalled(); + if (updateUrl.isNotEmpty && !isCardClosed && - bind.mainUriPrefixSync().contains('rustdesk')) { + (isWindowsInstalled || + (!bind.isCustomClient() && + bind.mainUriPrefixSync().contains('rustdesk')))) { final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled(); String btnText = isToUpdate ? 'Update' : 'Download'; GestureTapCallback onPressed = () async { diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index 3314d82abce..d8239adea34 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -473,8 +473,7 @@ class _GeneralState extends State<_General> { } Widget other() { - final showAutoUpdate = - isWindows && bind.mainIsInstalled() && !bind.isCustomClient(); + final showAutoUpdate = isWindows && bind.mainIsInstalled(); final children = [ if (!isWeb && !bind.isIncomingOnly()) _OptionCheckBox(context, 'Confirm before closing multiple tabs', diff --git a/flutter/windows/runner/win32_window.cpp b/flutter/windows/runner/win32_window.cpp index 2c25f00dd90..606ef0aa329 100644 --- a/flutter/windows/runner/win32_window.cpp +++ b/flutter/windows/runner/win32_window.cpp @@ -7,6 +7,7 @@ #include // for getenv and _putenv #include // for strcmp +#include // for std::wstring namespace { @@ -15,6 +16,43 @@ constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; // The number of Win32Window objects that currently exist. static int g_active_window_count = 0; +// Static variable to hold the custom icon (needs cleanup on exit) +static HICON g_custom_icon_ = nullptr; + +// Try to load icon from data\flutter_assets\assets\icon.ico if it exists. +// Returns nullptr if the file doesn't exist or can't be loaded. +HICON LoadCustomIcon() { + if (g_custom_icon_ != nullptr) { + return g_custom_icon_; + } + wchar_t exe_path[MAX_PATH]; + if (!GetModuleFileNameW(nullptr, exe_path, MAX_PATH)) { + return nullptr; + } + + std::wstring icon_path = exe_path; + size_t last_slash = icon_path.find_last_of(L"\\/"); + if (last_slash == std::wstring::npos) { + return nullptr; + } + + icon_path = icon_path.substr(0, last_slash + 1); + icon_path += L"data\\flutter_assets\\assets\\icon.ico"; + + // Check file attributes - reject if missing, directory, or reparse point (symlink/junction) + DWORD file_attr = GetFileAttributesW(icon_path.c_str()); + if (file_attr == INVALID_FILE_ATTRIBUTES || + (file_attr & FILE_ATTRIBUTE_DIRECTORY) || + (file_attr & FILE_ATTRIBUTE_REPARSE_POINT)) { + return nullptr; + } + + g_custom_icon_ = (HICON)LoadImageW( + nullptr, icon_path.c_str(), IMAGE_ICON, 0, 0, + LR_LOADFROMFILE | LR_DEFAULTSIZE); + return g_custom_icon_; +} + using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); // Scale helper to convert logical scaler values to physical using passed in @@ -81,8 +119,16 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() { window_class.cbClsExtra = 0; window_class.cbWndExtra = 0; window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + + // Try to load icon from data\flutter_assets\assets\icon.ico if it exists + HICON custom_icon = LoadCustomIcon(); + if (custom_icon != nullptr) { + window_class.hIcon = custom_icon; + } else { + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + } + window_class.hbrBackground = 0; window_class.lpszMenuName = nullptr; window_class.lpfnWndProc = Win32Window::WndProc; @@ -95,6 +141,12 @@ const wchar_t* WindowClassRegistrar::GetWindowClass() { void WindowClassRegistrar::UnregisterWindowClass() { UnregisterClass(kWindowClassName, nullptr); class_registered_ = false; + + // Clean up the custom icon if it was loaded + if (g_custom_icon_ != nullptr) { + DestroyIcon(g_custom_icon_); + g_custom_icon_ = nullptr; + } } Win32Window::Win32Window() { diff --git a/libs/hbb_common b/libs/hbb_common index 0b60b9ffa05..5e07db74442 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 0b60b9ffa05259f72cd33e79010ef8e15d42b851 +Subproject commit 5e07db7444284006c008b5b1204f0968bc47b1a9 diff --git a/res/msi/CustomActions/CustomActions.cpp b/res/msi/CustomActions/CustomActions.cpp index fafbab6b5f6..f21cc7ee1d9 100644 --- a/res/msi/CustomActions/CustomActions.cpp +++ b/res/msi/CustomActions/CustomActions.cpp @@ -31,22 +31,168 @@ UINT __stdcall CustomActionHello( return WcaFinalize(er); } -// CAUTION: We can't simply remove the install folder here, because silent repair/upgrade will fail. -// `RemoveInstallFolder()` is a deferred custom action, it will be executed after the files are copied. -// `msiexec /i package.msi /qn` +// Helper function to safely delete a file or directory using handle-based deletion. +// This avoids TOCTOU (Time-Of-Check-Time-Of-Use) race conditions. +BOOL SafeDeleteItem(LPCWSTR fullPath) +{ + // Open the file/directory with DELETE access and FILE_FLAG_OPEN_REPARSE_POINT + // to prevent following symlinks. + // Use shared access to allow deletion even when other processes have the file open. + DWORD flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT; + HANDLE hFile = CreateFileW( + fullPath, + DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, // Allow shared access + NULL, + OPEN_EXISTING, + flags, + NULL + ); + + if (hFile == INVALID_HANDLE_VALUE) + { + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to open '%ls'. Error: %lu", fullPath, GetLastError()); + return FALSE; + } + + // Use SetFileInformationByHandle to mark for deletion. + // The file will be deleted when the handle is closed. + FILE_DISPOSITION_INFO dispInfo; + dispInfo.DeleteFile = TRUE; + + BOOL result = SetFileInformationByHandle( + hFile, + FileDispositionInfo, + &dispInfo, + sizeof(dispInfo) + ); + + if (!result) + { + DWORD error = GetLastError(); + WcaLog(LOGMSG_STANDARD, "SafeDeleteItem: Failed to mark '%ls' for deletion. Error: %lu", fullPath, error); + } + + CloseHandle(hFile); + return result; +} + +// Helper function to recursively delete a directory's contents with detailed logging. +void RecursiveDelete(LPCWSTR path) +{ + // Ensure the path is not empty or null. + if (path == NULL || path[0] == L'\0') + { + return; + } + + // Extra safety: never operate directly on a root path. + if (PathIsRootW(path)) + { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: refusing to operate on root path '%ls'.", path); + return; + } + + // MAX_PATH is enough here since the installer should not be using longer paths. + // No need to handle extended-length paths (\\?\) in this context. + WCHAR searchPath[MAX_PATH]; + HRESULT hr = StringCchPrintfW(searchPath, MAX_PATH, L"%s\\*", path); + if (FAILED(hr)) { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long to enumerate: %ls", path); + return; + } + + WIN32_FIND_DATAW findData; + HANDLE hFind = FindFirstFileW(searchPath, &findData); + + if (hFind == INVALID_HANDLE_VALUE) + { + // This can happen if the directory is empty or doesn't exist, which is not an error in our case. + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to enumerate directory '%ls'. It may be missing or inaccessible. Error: %lu", path, GetLastError()); + return; + } + + do + { + // Skip '.' and '..' directories. + if (wcscmp(findData.cFileName, L".") == 0 || wcscmp(findData.cFileName, L"..") == 0) + { + continue; + } + + // MAX_PATH is enough here since the installer should not be using longer paths. + // No need to handle extended-length paths (\\?\) in this context. + WCHAR fullPath[MAX_PATH]; + hr = StringCchPrintfW(fullPath, MAX_PATH, L"%s\\%s", path, findData.cFileName); + if (FAILED(hr)) { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Path too long for item '%ls' in '%ls', skipping.", findData.cFileName, path); + continue; + } + + // Before acting, ensure the read-only attribute is not set. + if (findData.dwFileAttributes & FILE_ATTRIBUTE_READONLY) + { + if (FALSE == SetFileAttributesW(fullPath, findData.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY)) + { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Failed to remove read-only attribute. Error: %lu", GetLastError()); + } + } + + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + { + // Check for reparse points (symlinks/junctions) to prevent directory traversal attacks. + // Do not follow reparse points, only remove the link itself. + if (findData.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) + { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: Not recursing into reparse point (symlink/junction), deleting link itself: %ls", fullPath); + SafeDeleteItem(fullPath); + } + else + { + // Recursively delete directory contents first + RecursiveDelete(fullPath); + // Then delete the directory itself + SafeDeleteItem(fullPath); + } + } + else + { + // Delete file using safe handle-based deletion + SafeDeleteItem(fullPath); + } + } while (FindNextFileW(hFind, &findData) != 0); + + DWORD lastError = GetLastError(); + if (lastError != ERROR_NO_MORE_FILES) + { + WcaLog(LOGMSG_STANDARD, "RecursiveDelete: FindNextFileW failed with error %lu", lastError); + } + + FindClose(hFind); +} + +// See `Package.wxs` for the sequence of this custom action. // -// So we need to delete the files separately in install folder. +// Upgrade/uninstall sequence: +// 1. InstallInitialize +// 2. RemoveExistingProducts +// ├─ TerminateProcesses +// ├─ TryStopDeleteService +// ├─ RemoveInstallFolder - <-- Here +// └─ RemoveFiles +// 3. InstallValidate +// 4. InstallFiles +// 5. InstallExecute +// 6. InstallFinalize UINT __stdcall RemoveInstallFolder( __in MSIHANDLE hInstall) { HRESULT hr = S_OK; DWORD er = ERROR_SUCCESS; - int nResult = 0; LPWSTR installFolder = NULL; LPWSTR pwz = NULL; LPWSTR pwzData = NULL; - WCHAR runtimeBroker[1024] = { 0, }; hr = WcaInitialize(hInstall, "RemoveInstallFolder"); ExitOnFailure(hr, "Failed to initialize"); @@ -58,24 +204,23 @@ UINT __stdcall RemoveInstallFolder( hr = WcaReadStringFromCaData(&pwz, &installFolder); ExitOnFailure(hr, "failed to read database key from custom action data: %ls", pwz); - StringCchPrintfW(runtimeBroker, sizeof(runtimeBroker) / sizeof(runtimeBroker[0]), L"%ls\\RuntimeBroker_rustdesk.exe", installFolder); - - SHFILEOPSTRUCTW fileOp; - ZeroMemory(&fileOp, sizeof(SHFILEOPSTRUCT)); - fileOp.wFunc = FO_DELETE; - fileOp.pFrom = runtimeBroker; - fileOp.fFlags = FOF_NOCONFIRMATION | FOF_SILENT; - - nResult = SHFileOperationW(&fileOp); - if (nResult == 0) - { - WcaLog(LOGMSG_STANDARD, "The external file \"%ls\" has been deleted.", runtimeBroker); + if (installFolder == NULL || installFolder[0] == L'\0') { + WcaLog(LOGMSG_STANDARD, "Install folder path is empty, skipping recursive delete."); + goto LExit; } - else - { - WcaLog(LOGMSG_STANDARD, "The external file \"%ls\" has not been deleted, error code: 0x%02X. Please refer to https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shfileoperationa for the error codes.", runtimeBroker, nResult); + + if (PathIsRootW(installFolder)) { + WcaLog(LOGMSG_STANDARD, "Refusing to recursively delete root folder '%ls'.", installFolder); + goto LExit; } + WcaLog(LOGMSG_STANDARD, "Attempting to recursively delete contents of install folder: %ls", installFolder); + + RecursiveDelete(installFolder); + + // The standard MSI 'RemoveFolders' action will take care of removing the (now empty) directories. + // We don't need to call RemoveDirectoryW on installFolder itself, as it might still be in use by the installer. + LExit: ReleaseStr(pwzData); @@ -109,9 +254,12 @@ bool TerminateProcessIfNotContainsParam(pfnNtQueryInformationProcess NtQueryInfo { if (pebUpp.CommandLine.Length > 0) { - WCHAR *commandLine = (WCHAR *)malloc(pebUpp.CommandLine.Length); + // Allocate extra space for null terminator + WCHAR *commandLine = (WCHAR *)malloc(pebUpp.CommandLine.Length + sizeof(WCHAR)); if (commandLine != NULL) { + // Initialize all bytes to zero for safety + memset(commandLine, 0, pebUpp.CommandLine.Length + sizeof(WCHAR)); if (ReadProcessMemory(process, pebUpp.CommandLine.Buffer, commandLine, pebUpp.CommandLine.Length, &dwBytesRead)) { diff --git a/src/common.rs b/src/common.rs index bba453c3471..d2c252869f5 100644 --- a/src/common.rs +++ b/src/common.rs @@ -39,7 +39,7 @@ use hbb_common::{ use crate::{ hbbs_http::{create_http_client_async, get_url_for_tls}, - ui_interface::{get_option, set_option}, + ui_interface::{get_option, is_installed, set_option}, }; #[derive(Debug, Eq, PartialEq)] @@ -940,7 +940,9 @@ pub fn is_modifier(evt: &KeyEvent) -> bool { } pub fn check_software_update() { - if is_custom_client() { + let is_windows_installed = cfg!(target_os = "windows") && is_installed(); + let should_check_update = is_windows_installed || !is_custom_client(); + if !should_check_update { return; } let opt = LocalConfig::get_option(keys::OPTION_ENABLE_CHECK_UPDATE); diff --git a/src/core_main.rs b/src/core_main.rs index 7962a693eb8..3119529c676 100644 --- a/src/core_main.rs +++ b/src/core_main.rs @@ -187,7 +187,10 @@ pub fn core_main() -> Option> { } #[cfg(windows)] - hbb_common::config::PeerConfig::preload_peers(); + { + crate::platform::try_remove_temp_update_files(); + hbb_common::config::PeerConfig::preload_peers(); + } std::thread::spawn(move || crate::start_server(false, no_server)); } else { #[cfg(windows)] @@ -202,17 +205,24 @@ pub fn core_main() -> Option> { if config::is_disable_installation() { return None; } - let res = platform::update_me(false); - let text = match res { - Ok(_) => translate("Update successfully!".to_string()), - Err(err) => { - log::error!("Failed with error: {err}"); - translate("Update failed!".to_string()) + + let text = match crate::platform::prepare_custom_client_update() { + Err(e) => { + log::error!("Error preparing custom client update: {}", e); + "Update failed!".to_string() } + Ok(false) => "Update failed!".to_string(), + Ok(true) => match platform::update_me(false) { + Ok(_) => "Update successfully!".to_string(), + Err(err) => { + log::error!("Failed with error: {err}"); + "Update failed!".to_string() + } + }, }; Toast::new(Toast::POWERSHELL_APP_ID) .title(&config::APP_NAME.read().unwrap()) - .text1(&text) + .text1(&translate(text)) .sound(Some(Sound::Default)) .duration(Duration::Short) .show() diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 864002d2499..ed13a76246b 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -2776,10 +2776,13 @@ pub fn main_get_common(key: String) -> String { } else if key.starts_with("download-file-") { let _version = key.replace("download-file-", ""); #[cfg(target_os = "windows")] - return match crate::platform::windows::is_msi_installed() { - Ok(true) => format!("rustdesk-{_version}-x86_64.msi"), - Ok(false) => format!("rustdesk-{_version}-x86_64.exe"), - Err(e) => { + return match ( + crate::platform::windows::is_msi_installed(), + crate::common::is_custom_client(), + ) { + (Ok(true), false) => format!("rustdesk-{_version}-x86_64.msi"), + (Ok(true), true) | (Ok(false), _) => format!("rustdesk-{_version}-x86_64.exe"), + (Err(e), _) => { log::error!("Failed to check if is msi: {}", e); format!("error:update-failed-check-msi-tip") } @@ -2876,30 +2879,17 @@ pub fn main_set_common(_key: String, _value: String) { if let Some(f) = new_version_file.to_str() { // 1.4.0 does not support "--update" // But we can assume that the new version supports it. - #[cfg(target_os = "windows")] - if f.ends_with(".exe") { - if let Err(e) = - crate::platform::run_exe_in_cur_session(f, vec!["--update"], false) - { - log::error!("Failed to run the update exe: {}", e); - } - } else if f.ends_with(".msi") { - if let Err(e) = crate::platform::update_me_msi(f, false) { - log::error!("Failed to run the update msi: {}", e); - } - } else { - // unreachable!() - } - #[cfg(target_os = "macos")] + + #[cfg(any(target_os = "windows", target_os = "macos"))] match crate::platform::update_to(f) { Ok(_) => { - log::info!("Update successfully!"); + log::info!("Update process is launched successfully!"); } Err(e) => { log::error!("Failed to update to new version, {}", e); + fs::remove_file(f).ok(); } } - fs::remove_file(f).ok(); } } } else if _key == "extract-update-dmg" { diff --git a/src/hbbs_http/downloader.rs b/src/hbbs_http/downloader.rs index 2afa2ba28c5..573e7e77c4e 100644 --- a/src/hbbs_http/downloader.rs +++ b/src/hbbs_http/downloader.rs @@ -53,8 +53,25 @@ pub fn download_file( auto_del_dur: Option, ) -> ResultType { let id = url.clone(); - if DOWNLOADERS.lock().unwrap().contains_key(&id) { - return Ok(id); + // First pass: if a non-error downloader exists for this URL, reuse it. + // If an errored downloader exists, remove it so this call can retry. + let mut stale_path = None; + { + let mut downloaders = DOWNLOADERS.lock().unwrap(); + if let Some(downloader) = downloaders.get(&id) { + if downloader.error.is_none() { + return Ok(id); + } + stale_path = downloader.path.clone(); + downloaders.remove(&id); + } + } + if let Some(p) = stale_path { + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + } + } } if let Some(path) = path.as_ref() { @@ -75,8 +92,26 @@ pub fn download_file( tx_cancel: tx, finished: false, }; - let mut downloaders = DOWNLOADERS.lock().unwrap(); - downloaders.insert(id.clone(), downloader); + // Second pass (atomic with insert) to avoid race with another concurrent caller. + let mut stale_path_after_check = None; + { + let mut downloaders = DOWNLOADERS.lock().unwrap(); + if let Some(existing) = downloaders.get(&id) { + if existing.error.is_none() { + return Ok(id); + } + stale_path_after_check = existing.path.clone(); + downloaders.remove(&id); + } + downloaders.insert(id.clone(), downloader); + } + if let Some(p) = stale_path_after_check { + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + log::warn!("Failed to remove stale download file {}: {}", p.display(), e); + } + } + } let id2 = id.clone(); std::thread::spawn( diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a45220eb4a7..ee8aa7c6f06 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -153,11 +153,7 @@ pub fn clip_cursor(rect: Option<(i32, i32, i32, i32)>) -> bool { }; if result == FALSE { let err = GetLastError(); - log::warn!( - "ClipCursor failed: rect={:?}, error_code={}", - rect, - err - ); + log::warn!("ClipCursor failed: rect={:?}, error_code={}", rect, err); return false; } true @@ -757,15 +753,37 @@ pub fn run_as_user(arg: Vec<&str>) -> ResultType> { run_exe_in_cur_session(std::env::current_exe()?.to_str().unwrap_or(""), arg, false) } +pub fn run_exe_direct( + exe: &str, + arg: Vec<&str>, + show: bool, +) -> ResultType> { + let mut cmd = std::process::Command::new(exe); + for a in arg { + cmd.arg(a); + } + if !show { + cmd.creation_flags(CREATE_NO_WINDOW); + } + match cmd.spawn() { + Ok(child) => Ok(Some(child)), + Err(e) => bail!("Failed to start process: {}", e), + } +} + pub fn run_exe_in_cur_session( exe: &str, arg: Vec<&str>, show: bool, ) -> ResultType> { - let Some(session_id) = get_current_process_session_id() else { - bail!("Failed to get current process session id"); - }; - run_exe_in_session(exe, arg, session_id, show) + if is_root() { + let Some(session_id) = get_current_process_session_id() else { + bail!("Failed to get current process session id"); + }; + run_exe_in_session(exe, arg, session_id, show) + } else { + run_exe_direct(exe, arg, show) + } } pub fn run_exe_in_session( @@ -1331,6 +1349,38 @@ pub fn copy_exe_cmd(src_exe: &str, exe: &str, path: &str) -> ResultType )) } +#[inline] +pub fn rename_exe_cmd(src_exe: &str, path: &str) -> ResultType { + let src_exe_filename = PathBuf::from(src_exe) + .file_name() + .ok_or(anyhow!("Can't get file name of {src_exe}"))? + .to_string_lossy() + .to_string(); + let app_name = crate::get_app_name().to_lowercase(); + if src_exe_filename.to_lowercase() == format!("{app_name}.exe") { + Ok("".to_owned()) + } else { + Ok(format!( + " + move /Y \"{path}\\{src_exe_filename}\" \"{path}\\{app_name}.exe\" + ", + )) + } +} + +#[inline] +pub fn remove_meta_toml_cmd(is_msi: bool, path: &str) -> String { + if is_msi && crate::is_custom_client() { + format!( + " + del /F /Q \"{path}\\meta.toml\" + ", + ) + } else { + "".to_owned() + } +} + fn get_after_install( exe: &str, reg_value_start_menu_shortcuts: Option, @@ -1417,7 +1467,11 @@ pub fn install_me(options: &str, path: String, silent: bool, debug: bool) -> Res } let app_name = crate::get_app_name(); + let current_exe = std::env::current_exe()?; + let tmp_path = std::env::temp_dir().to_string_lossy().to_string(); + let cur_exe = current_exe.to_str().unwrap_or("").to_owned(); + let shortcut_icon_location = get_shortcut_icon_location(&cur_exe); let mk_shortcut = write_cmds( format!( " @@ -1426,6 +1480,7 @@ sLinkFile = \"{tmp_path}\\{app_name}.lnk\" Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" + {shortcut_icon_location} oLink.Save " ), @@ -1482,8 +1537,13 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{start_menu}\\\" reg_value_printer = "1".to_owned(); } - let meta = std::fs::symlink_metadata(std::env::current_exe()?)?; - let size = meta.len() / 1024; + let meta = std::fs::symlink_metadata(¤t_exe)?; + let mut size = meta.len() / 1024; + if let Some(parent_dir) = current_exe.parent() { + if let Some(d) = parent_dir.to_str() { + size = get_directory_size_kb(d); + } + } // https://docs.microsoft.com/zh-cn/windows/win32/msi/uninstall-registry-key?redirectedfrom=MSDNa // https://www.windowscentral.com/how-edit-registry-using-command-prompt-windows-10 // https://www.tenforums.com/tutorials/70903-add-remove-allowed-apps-through-windows-firewall-windows-10-a.html @@ -1536,7 +1596,7 @@ chcp 65001 md \"{path}\" {copy_exe} reg add {subkey} /f -reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{exe}\" +reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{display_icon}\" reg add {subkey} /f /v DisplayName /t REG_SZ /d \"{app_name}\" reg add {subkey} /f /v DisplayVersion /t REG_SZ /d \"{version}\" reg add {subkey} /f /v Version /t REG_SZ /d \"{version}\" @@ -1560,6 +1620,7 @@ copy /Y \"{tmp_path}\\Uninstall {app_name}.lnk\" \"{path}\\\" {install_remote_printer} {sleep} ", + display_icon = get_custom_icon(&cur_exe).unwrap_or(exe.to_string()), version = crate::VERSION.replace("-", "."), build_date = crate::BUILD_DATE, after_install = get_after_install( @@ -1795,6 +1856,163 @@ fn get_reg_of(subkey: &str, name: &str) -> String { "".to_owned() } +fn get_public_base_dir() -> PathBuf { + if let Ok(allusersprofile) = std::env::var("ALLUSERSPROFILE") { + let path = PathBuf::from(&allusersprofile); + if path.exists() { + return path; + } + } + if let Ok(public) = std::env::var("PUBLIC") { + let path = PathBuf::from(public).join("Documents"); + if path.exists() { + return path; + } + } + let program_data_dir = PathBuf::from("C:\\ProgramData"); + if program_data_dir.exists() { + return program_data_dir; + } + std::env::temp_dir() +} + +#[inline] +pub fn get_custom_client_staging_dir() -> PathBuf { + get_public_base_dir() + .join("RustDesk") + .join("RustDeskCustomClientStaging") +} + +/// Removes the custom client staging directory. +/// +/// Current behavior: intentionally a no-op (does not delete). +/// +/// Rationale +/// - The staging directory only contains a small `custom.txt`, leaving it is harmless. +/// - Deleting directories under a public location (e.g., C:\\ProgramData\\RustDesk) is +/// susceptible to TOCTOU attacks if an unprivileged user can replace the path with a +/// symlink/junction between checks and deletion. +/// +/// Future work: +/// - Use the files (if needed) in the installation directory instead of a public location. +/// This directory only contains a small `custom.txt` file. +/// - Pass the custom client name directly via command line +/// or environment variable during update installation. Then no staging directory is needed. +#[inline] +pub fn remove_custom_client_staging_dir(staging_dir: &Path) -> ResultType { + if !staging_dir.exists() { + return Ok(false); + } + + // First explicitly removes `custom.txt` to ensure stale config is never replayed, + // even if the subsequent directory removal fails. + // + // `std::fs::remove_file` on a symlink removes the symlink itself, not the target, + // so this is safe even in a TOCTOU race. + let custom_txt_path = staging_dir.join("custom.txt"); + if custom_txt_path.exists() { + allow_err!(std::fs::remove_file(&custom_txt_path)); + } + + // Intentionally not deleting. See the function docs for rationale. + log::debug!( + "Skip deleting staging directory {:?} (intentional to avoid TOCTOU)", + staging_dir + ); + Ok(false) +} + +// Prepare custom client update by copying staged custom.txt to current directory and loading it. +// Returns: +// 1. Ok(true) if preparation was successful or no staging directory exists. +// 2. Ok(false) if custom.txt file exists but has invalid contents or fails security checks +// (e.g., is a symlink or has invalid contents). +// 3. Err if any unexpected error occurs during file operations. +pub fn prepare_custom_client_update() -> ResultType { + let custom_client_staging_dir = get_custom_client_staging_dir(); + let current_exe = std::env::current_exe()?; + let current_exe_dir = current_exe + .parent() + .ok_or(anyhow!("Cannot get parent directory of current exe"))?; + + let staging_dir = custom_client_staging_dir.clone(); + let clear_staging_on_exit = crate::SimpleCallOnReturn { + b: true, + f: Box::new( + move || match remove_custom_client_staging_dir(&staging_dir) { + Ok(existed) => { + if existed { + log::info!("Custom client staging directory removed successfully."); + } + } + Err(e) => { + log::error!( + "Failed to remove custom client staging directory {:?}: {}", + staging_dir, + e + ); + } + }, + ), + }; + + if custom_client_staging_dir.exists() { + let custom_txt_path = custom_client_staging_dir.join("custom.txt"); + if !custom_txt_path.exists() { + return Ok(true); + } + + let metadata = std::fs::symlink_metadata(&custom_txt_path)?; + if metadata.is_symlink() { + log::error!( + "custom.txt is a symlink. Refusing to load custom client for security reasons." + ); + drop(clear_staging_on_exit); + return Ok(false); + } + if metadata.is_file() { + // Copy custom.txt to current directory + let local_custom_file_path = current_exe_dir.join("custom.txt"); + log::debug!( + "Copying staged custom file from {:?} to {:?}", + custom_txt_path, + local_custom_file_path + ); + + // No need to check symlink before copying. + // `load_custom_client()` will fail if the file is not valid. + fs::copy(&custom_txt_path, &local_custom_file_path)?; + log::info!("Staged custom client file copied to current directory."); + + // Load custom client + let is_custom_file_exists = + local_custom_file_path.exists() && local_custom_file_path.is_file(); + crate::load_custom_client(); + + // Remove the copied custom.txt file + allow_err!(fs::remove_file(&local_custom_file_path)); + + // Check if loaded successfully + if is_custom_file_exists && !crate::common::is_custom_client() { + // The custom.txt file existed, but its contents are invalid. + log::error!("Failed to load custom client from custom.txt."); + drop(clear_staging_on_exit); + // ERROR_INVALID_DATA + return Ok(false); + } + } else { + log::info!("No custom client files found in staging directory."); + } + } else { + log::info!( + "Custom client staging directory {:?} does not exist.", + custom_client_staging_dir + ); + } + + Ok(true) +} + pub fn get_license_from_exe_name() -> ResultType { let mut exe = std::env::current_exe()?.to_str().unwrap_or("").to_owned(); // if defined portable appname entry, replace original executable name with it. @@ -1903,12 +2121,48 @@ unsafe fn set_default_dll_directories() -> bool { true } +fn get_custom_icon(exe: &str) -> Option { + if crate::is_custom_client() { + if let Some(p) = PathBuf::from(exe).parent() { + let alter_icon_path = p.join("data\\flutter_assets\\assets\\icon.ico"); + if alter_icon_path.exists() { + // Verify that the icon is not a symlink for security + if let Ok(metadata) = std::fs::symlink_metadata(&alter_icon_path) { + if metadata.is_symlink() { + log::warn!( + "Custom icon at {:?} is a symlink, refusing to use it.", + alter_icon_path + ); + return None; + } + if metadata.is_file() { + return Some(alter_icon_path.to_string_lossy().to_string()); + } + } + } + } + } + None +} + +#[inline] +fn get_shortcut_icon_location(exe: &str) -> String { + if exe.is_empty() { + return "".to_owned(); + } + + get_custom_icon(exe) + .map(|p| format!("oLink.IconLocation = \"{}\"", p)) + .unwrap_or_default() +} + pub fn create_shortcut(id: &str) -> ResultType<()> { 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 // https://github.com/rustdesk/hbb_common/blob/8b0e25867375ba9e6bff548acf44fe6d6ffa7c0e/src/config.rs#L1384 let filename = id.replace(':', "_"); + let shortcut_icon_location = get_shortcut_icon_location(&exe); let shortcut = write_cmds( format!( " @@ -1919,6 +2173,7 @@ sLinkFile = objFSO.BuildPath(strDesktop, \"{filename}.lnk\") Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" oLink.Arguments = \"--connect {id}\" + {shortcut_icon_location} oLink.Save " ), @@ -2724,6 +2979,44 @@ if exist \"{tray_shortcut}\" del /f /q \"{tray_shortcut}\" std::process::exit(0); } +/// Calculate the total size of a directory in KB +/// Does not follow symlinks to prevent directory traversal attacks. +fn get_directory_size_kb(path: &str) -> u64 { + let mut total_size = 0u64; + let mut stack = vec![PathBuf::from(path)]; + + while let Some(current_path) = stack.pop() { + let entries = match std::fs::read_dir(¤t_path) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + let metadata = match std::fs::symlink_metadata(entry.path()) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + + if metadata.is_symlink() { + continue; + } + + if metadata.is_dir() { + stack.push(entry.path()); + } else { + total_size = total_size.saturating_add(metadata.len()); + } + } + } + + total_size / 1024 +} + pub fn update_me(debug: bool) -> ResultType<()> { let app_name = crate::get_app_name(); let src_exe = std::env::current_exe()?.to_string_lossy().to_string(); @@ -2764,12 +3057,35 @@ pub fn update_me(debug: bool) -> ResultType<()> { if versions.len() > 2 { version_build = versions[2]; } - let meta = std::fs::symlink_metadata(std::env::current_exe()?)?; - let size = meta.len() / 1024; - - let reg_cmd = format!( - " -reg add {subkey} /f /v DisplayIcon /t REG_SZ /d \"{exe}\" + let version = crate::VERSION.replace("-", "."); + let size = get_directory_size_kb(&path); + let build_date = crate::BUILD_DATE; + let display_icon = get_custom_icon(&exe).unwrap_or(exe.to_string()); + + let is_msi = is_msi_installed().ok(); + + fn get_reg_cmd( + subkey: &str, + is_msi: Option, + display_icon: &str, + version: &str, + build_date: &str, + version_major: &str, + version_minor: &str, + version_build: &str, + size: u64, + ) -> String { + let reg_display_icon = if is_msi.unwrap_or(false) { + "".to_string() + } else { + format!( + "reg add {} /f /v DisplayIcon /t REG_SZ /d \"{}\"", + subkey, display_icon + ) + }; + format!( + " +{reg_display_icon} reg add {subkey} /f /v DisplayVersion /t REG_SZ /d \"{version}\" reg add {subkey} /f /v Version /t REG_SZ /d \"{version}\" reg add {subkey} /f /v BuildDate /t REG_SZ /d \"{build_date}\" @@ -2777,10 +3093,39 @@ reg add {subkey} /f /v VersionMajor /t REG_DWORD /d {version_major} reg add {subkey} /f /v VersionMinor /t REG_DWORD /d {version_minor} reg add {subkey} /f /v VersionBuild /t REG_DWORD /d {version_build} reg add {subkey} /f /v EstimatedSize /t REG_DWORD /d {size} - ", - version = crate::VERSION.replace("-", "."), - build_date = crate::BUILD_DATE, - ); + " + ) + } + + 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) { + get_reg_cmd( + ®_msi_key, + is_msi, + &display_icon, + &version, + &build_date, + &version_major, + &version_minor, + &version_build, + size, + ) + } else { + "".to_owned() + }; + format!("{}{}", reg_cmd_main, reg_cmd_msi) + }; let filter = format!(" /FI \"PID ne {}\"", get_current_pid()); let restore_service_cmd = if is_service_running { @@ -2820,6 +3165,8 @@ sc stop {app_name} taskkill /F /IM {app_name}.exe{filter} {reg_cmd} {copy_exe} +{rename_exe} +{remove_meta_toml} {restore_service_cmd} {uninstall_printer_cmd} {install_printer_cmd} @@ -2827,43 +3174,106 @@ taskkill /F /IM {app_name}.exe{filter} ", app_name = app_name, copy_exe = copy_exe_cmd(&src_exe, &exe, &path)?, + rename_exe = rename_exe_cmd(&src_exe, &path)?, + remove_meta_toml = remove_meta_toml_cmd(is_msi.unwrap_or(true), &path), sleep = if debug { "timeout 300" } else { "" }, ); + let _restore_session_guard = crate::common::SimpleCallOnReturn { + b: true, + f: Box::new(move || { + let is_root = is_root(); + if tray_sessions.is_empty() { + log::info!("No tray process found."); + } else { + log::info!( + "Try to restore the tray process..., sessions: {:?}", + &tray_sessions + ); + // When not running as root, only spawn once since run_exe_direct + // doesn't target specific sessions. + let mut spawned_non_root_tray = false; + for s in tray_sessions.clone().into_iter() { + if s != 0 { + // We need to check if is_root here because if `update_me()` is called from + // the main window running with administrator permission, + // `run_exe_in_session()` will fail with error 1314 ("A required privilege is + // not held by the client"). + // + // This issue primarily affects the MSI-installed version running in Administrator + // session during testing, but we check permissions here to be safe. + if is_root { + allow_err!(run_exe_in_session(&exe, vec!["--tray"], s, true)); + } else if !spawned_non_root_tray { + // Only spawn once for non-root since run_exe_direct doesn't take session parameter + allow_err!(run_exe_direct(&exe, vec!["--tray"], false)); + spawned_non_root_tray = true; + } + } + } + } + if main_window_sessions.is_empty() { + log::info!("No main window process found."); + } else { + log::info!("Try to restore the main window process..."); + std::thread::sleep(std::time::Duration::from_millis(2000)); + // When not running as root, only spawn once since run_exe_direct + // doesn't target specific sessions. + let mut spawned_non_root_main = false; + for s in main_window_sessions.clone().into_iter() { + if s != 0 { + if is_root { + allow_err!(run_exe_in_session(&exe, vec![], s, true)); + } else if !spawned_non_root_main { + // Only spawn once for non-root since run_exe_direct doesn't take session parameter + allow_err!(run_exe_direct(&exe, vec![], false)); + spawned_non_root_main = true; + } + } + } + } + std::thread::sleep(std::time::Duration::from_millis(300)); + }), + }; + run_cmds(cmds, debug, "update")?; std::thread::sleep(std::time::Duration::from_millis(2000)); - if tray_sessions.is_empty() { - log::info!("No tray process found."); - } else { - log::info!("Try to restore the tray process..."); - log::info!( - "Try to restore the tray process..., sessions: {:?}", - &tray_sessions - ); - for s in tray_sessions { - if s != 0 { - allow_err!(run_exe_in_session(&exe, vec!["--tray"], s, true)); - } - } - } - if main_window_sessions.is_empty() { - log::info!("No main window process found."); - } else { - log::info!("Try to restore the main window process..."); - std::thread::sleep(std::time::Duration::from_millis(2000)); - for s in main_window_sessions { - if s != 0 { - allow_err!(run_exe_in_session(&exe, vec![], s, true)); - } - } - } - std::thread::sleep(std::time::Duration::from_millis(300)); log::info!("Update completed."); Ok(()) } +fn get_reg_msi_key(subkey: &str, is_msi: Option) -> Option { + // Only proceed if it's a custom client and MSI is installed. + // `is_msi.unwrap_or(true)` is intentional: subsequent code validates the registry, + // hence no early return is required upon MSI detection failure. + if !(crate::common::is_custom_client() && is_msi.unwrap_or(true)) { + return None; + } + + // Get the uninstall string from registry + let uninstall_string = get_reg_of(subkey, "UninstallString"); + if uninstall_string.is_empty() { + return None; + } + + // Find the product code (GUID) in the uninstall string + // Handle both quoted and unquoted GUIDs: /X {GUID} or /X "{GUID}" + let start = uninstall_string.rfind('{')?; + let end = uninstall_string.rfind('}')?; + if start >= end { + return None; + } + let product_code = &uninstall_string[start..=end]; + + // Build the MSI registry key path + let pos = subkey.rfind('\\')?; + let reg_msi_key = format!("{}{}", &subkey[..=pos], product_code); + + Some(reg_msi_key) +} + // Double confirm the process name fn kill_process_by_pids(name: &str, pids: Vec) -> ResultType<()> { let name = name.to_lowercase(); @@ -2885,6 +3295,109 @@ fn kill_process_by_pids(name: &str, pids: Vec) -> ResultType<()> { Ok(()) } +pub fn handle_custom_client_staging_dir_before_update( + custom_client_staging_dir: &PathBuf, +) -> ResultType<()> { + let Some(current_exe_dir) = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + else { + bail!("Failed to get current exe directory"); + }; + + // Clean up existing staging directory + if custom_client_staging_dir.exists() { + log::debug!( + "Removing existing custom client staging directory: {:?}", + custom_client_staging_dir + ); + if let Err(e) = remove_custom_client_staging_dir(custom_client_staging_dir) { + bail!( + "Failed to remove existing custom client staging directory {:?}: {}", + custom_client_staging_dir, + e + ); + } + } + + let src_path = current_exe_dir.join("custom.txt"); + if src_path.exists() { + // Verify that custom.txt is not a symlink before copying + let metadata = match std::fs::symlink_metadata(&src_path) { + Ok(m) => m, + Err(e) => { + bail!( + "Failed to read metadata for custom.txt at {:?}: {}", + src_path, + e + ); + } + }; + + if metadata.is_symlink() { + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + bail!( + "custom.txt at {:?} is a symlink, refusing to stage for security reasons.", + src_path + ); + } + + if metadata.is_file() { + if !custom_client_staging_dir.exists() { + if let Err(e) = std::fs::create_dir_all(custom_client_staging_dir) { + bail!("Failed to create parent directory {:?} when staging custom client files: {}", custom_client_staging_dir, e); + } + } + let dst_path = custom_client_staging_dir.join("custom.txt"); + if let Err(e) = std::fs::copy(&src_path, &dst_path) { + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + bail!( + "Failed to copy custom txt from {:?} to {:?}: {}", + src_path, + dst_path, + e + ); + } + } else { + log::warn!( + "custom.txt at {:?} is not a regular file, skipping.", + src_path + ); + } + } else { + log::info!("No custom txt found to stage for update."); + } + + Ok(()) +} + +// Used for auto update and manual update in the main window. +pub fn update_to(file: &str) -> ResultType<()> { + if file.ends_with(".exe") { + let custom_client_staging_dir = get_custom_client_staging_dir(); + if crate::is_custom_client() { + handle_custom_client_staging_dir_before_update(&custom_client_staging_dir)?; + } else { + // Clean up any residual staging directory from previous custom client + allow_err!(remove_custom_client_staging_dir(&custom_client_staging_dir)); + } + if !run_uac(file, "--update")? { + bail!( + "Failed to run the update exe with UAC, error: {:?}", + std::io::Error::last_os_error() + ); + } + } else if file.ends_with(".msi") { + if let Err(e) = update_me_msi(file, false) { + bail!("Failed to run the update msi: {}", e); + } + } else { + // unreachable!() + bail!("Unsupported update file format: {}", file); + } + Ok(()) +} + // Don't launch tray app when running with `\qn`. // 1. Because `/qn` requires administrator permission and the tray app should be launched with user permission. // Or launching the main window from the tray app will cause the main window to be launched with administrator permission. @@ -2905,6 +3418,7 @@ pub fn update_me_msi(msi: &str, quiet: bool) -> ResultType<()> { } pub fn get_tray_shortcut(exe: &str, tmp_path: &str) -> ResultType { + let shortcut_icon_location = get_shortcut_icon_location(exe); Ok(write_cmds( format!( " @@ -2914,6 +3428,7 @@ sLinkFile = \"{tmp_path}\\{app_name} Tray.lnk\" Set oLink = oWS.CreateShortcut(sLinkFile) oLink.TargetPath = \"{exe}\" oLink.Arguments = \"--tray\" + {shortcut_icon_location} oLink.Save ", app_name = crate::get_app_name(), @@ -2976,6 +3491,44 @@ fn run_after_run_cmds(silent: bool) { std::thread::sleep(std::time::Duration::from_millis(300)); } +#[inline] +pub fn try_remove_temp_update_files() { + let temp_dir = std::env::temp_dir(); + let Ok(entries) = std::fs::read_dir(&temp_dir) else { + log::debug!("Failed to read temp directory: {:?}", temp_dir); + return; + }; + + let one_hour = std::time::Duration::from_secs(60 * 60); + for entry in entries { + if let Ok(entry) = entry { + let path = entry.path(); + if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) { + // Match files like rustdesk-*.msi or rustdesk-*.exe + if file_name.starts_with("rustdesk-") + && (file_name.ends_with(".msi") || file_name.ends_with(".exe")) + { + // Skip files modified within the last hour to avoid deleting files being downloaded + if let Ok(metadata) = std::fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + if let Ok(elapsed) = modified.elapsed() { + if elapsed < one_hour { + continue; + } + } + } + } + if let Err(e) = std::fs::remove_file(&path) { + log::debug!("Failed to remove temp update file {:?}: {}", path, e); + } else { + log::info!("Removed temp update file: {:?}", path); + } + } + } + } + } +} + #[inline] pub fn try_kill_broker() { allow_err!(std::process::Command::new("cmd") @@ -3151,7 +3704,8 @@ pub fn is_x64() -> bool { pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { // Kill rustdesk.exe without extra arg, should only be called by --server // We can find the exact process which occupies the ipc, see more from https://github.com/winsiderss/systeminformer - log::info!("try kill rustdesk main window process"); + let app_name = crate::get_app_name().to_lowercase(); + log::info!("try kill main window process"); use hbb_common::sysinfo::System; let mut sys = System::new(); sys.refresh_processes(); @@ -3160,7 +3714,6 @@ pub fn try_kill_rustdesk_main_window_process() -> ResultType<()> { .map(|x| x.user_id()) .unwrap_or_default(); let my_pid = std::process::id(); - let app_name = crate::get_app_name().to_lowercase(); if app_name.is_empty() { bail!("app name is empty"); } diff --git a/src/rendezvous_mediator.rs b/src/rendezvous_mediator.rs index b3ab6a52308..3ef280a2a48 100644 --- a/src/rendezvous_mediator.rs +++ b/src/rendezvous_mediator.rs @@ -66,7 +66,7 @@ impl RendezvousMediator { } crate::hbbs_http::sync::start(); #[cfg(target_os = "windows")] - if crate::platform::is_installed() && crate::is_server() && !crate::is_custom_client() { + if crate::platform::is_installed() && crate::is_server() { crate::updater::start_auto_update(); } check_zombie(); diff --git a/src/ui/index.tis b/src/ui/index.tis index d4934ba0bd1..edd69312e51 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -824,7 +824,9 @@ class UpdateMe: Reactor.Component { return
{translate('Status')}
There is a newer version of {handler.get_app_name()} ({handler.get_new_version()}) available.
-
{translate('Click to ' + update_or_download)}
+ {is_custom_client + ?
{translate('Enable \"Auto update\" or contact your administrator for the latest version.')}
+ :
{translate('Click to ' + update_or_download)}
}
; } diff --git a/src/updater.rs b/src/updater.rs index c1ff60b46fd..357f111a741 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -119,7 +119,7 @@ fn start_auto_update_check_(rx_msg: Receiver) { fn check_update(manually: bool) -> ResultType<()> { #[cfg(target_os = "windows")] - let is_msi = crate::platform::is_msi_installed()?; + 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)) { return Ok(()); } @@ -140,7 +140,7 @@ fn check_update(manually: bool) -> ResultType<()> { "{}/rustdesk-{}-x86_64.{}", download_url, version, - if is_msi { "msi" } else { "exe" } + if update_msi { "msi" } else { "exe" } ) } else { format!("{}/rustdesk-{}-x86-sciter.exe", download_url, version) @@ -190,21 +190,21 @@ fn check_update(manually: bool) -> ResultType<()> { // before the download, but not empty after the download. if has_no_active_conns() { #[cfg(target_os = "windows")] - update_new_version(is_msi, &version, &file_path); + update_new_version(update_msi, &version, &file_path); } } Ok(()) } #[cfg(target_os = "windows")] -fn update_new_version(is_msi: bool, version: &str, file_path: &PathBuf) { +fn update_new_version(update_msi: bool, version: &str, file_path: &PathBuf) { log::debug!( - "New version is downloaded, update begin, is msi: {is_msi}, version: {version}, file: {:?}", + "New version is downloaded, update begin, update msi: {update_msi}, version: {version}, file: {:?}", file_path.to_str() ); if let Some(p) = file_path.to_str() { if let Some(session_id) = crate::platform::get_current_process_session_id() { - if is_msi { + if update_msi { match crate::platform::update_me_msi(p, true) { Ok(_) => { log::debug!("New version \"{}\" updated.", version); @@ -215,21 +215,57 @@ fn update_new_version(is_msi: bool, version: &str, file_path: &PathBuf) { version, e ); + std::fs::remove_file(&file_path).ok(); } } } else { - match crate::platform::launch_privileged_process( + let custom_client_staging_dir = if crate::is_custom_client() { + let custom_client_staging_dir = + crate::platform::get_custom_client_staging_dir(); + if let Err(e) = crate::platform::handle_custom_client_staging_dir_before_update( + &custom_client_staging_dir, + ) { + log::error!( + "Failed to handle custom client staging dir before update: {}", + e + ); + std::fs::remove_file(&file_path).ok(); + return; + } + Some(custom_client_staging_dir) + } else { + // Clean up any residual staging directory from previous custom client + let staging_dir = crate::platform::get_custom_client_staging_dir(); + hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( + &staging_dir + )); + None + }; + let update_launched = match crate::platform::launch_privileged_process( session_id, &format!("{} --update", p), ) { Ok(h) => { if h.is_null() { log::error!("Failed to update to the new version: {}", version); + false + } else { + log::debug!("New version \"{}\" is launched.", version); + true } } Err(e) => { log::error!("Failed to run the new version: {}", e); + false + } + }; + if !update_launched { + if let Some(dir) = custom_client_staging_dir { + hbb_common::allow_err!(crate::platform::remove_custom_client_staging_dir( + &dir + )); } + std::fs::remove_file(&file_path).ok(); } } } else { @@ -237,6 +273,7 @@ fn update_new_version(is_msi: bool, version: &str, file_path: &PathBuf) { "Failed to get the current process session id, Error {}", std::io::Error::last_os_error() ); + std::fs::remove_file(&file_path).ok(); } } else { // unreachable!() From bb3501a4f9cba0d634c7f9e5908fbc605b3e0370 Mon Sep 17 00:00:00 2001 From: Amirhosein Akhlaghpoor Date: Sat, 28 Feb 2026 02:56:25 +0000 Subject: [PATCH 04/16] ui: scale wheel lines on Windows/Linux to Mac (#14395) * input: accelerate wheel bursts on Windows->Mac - boost fast wheel bursts without affecting single-step scrolls\n- use dominant-axis smooth detection and velocity gate\n- reset wheel timestamp on enter/leave\n- enforce single-axis scrolling\n- extract/tune Sciter wheel accel thresholds Signed-off-by: Amirhossein Akhlaghpour * input: clarify wheel burst tuning - add comments on acceleration rules and units\n- apply burst accel on Windows/Linux to macOS\n- reset wheel timing on enter/leave Signed-off-by: Amirhossein Akhlaghpour * input: align wheel burst velocity thresholds - match Flutter velocity gate with Sciter Signed-off-by: Amirhossein Akhlaghpour * input: restore flutter wheel velocity threshold - keep burst threshold at 0.002 delta/us Signed-off-by: Amirhossein Akhlaghpour --------- Signed-off-by: Amirhossein Akhlaghpour --- flutter/lib/models/input_model.dart | 50 +++++++++++++++++++++++++---- src/ui/remote.tis | 40 ++++++++++++++++++----- 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 134b21107ae..628b27fb227 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -365,6 +365,16 @@ class InputModel { final isPhysicalMouse = false.obs; int _lastButtons = 0; Offset lastMousePos = Offset.zero; + int _lastWheelTsUs = 0; + + // Wheel acceleration thresholds. + static const int _wheelAccelFastThresholdUs = 40000; // 40ms + static const int _wheelAccelMediumThresholdUs = 80000; // 80ms + static const double _wheelBurstVelocityThreshold = + 0.002; // delta units per microsecond + // Wheel burst acceleration (empirical tuning). + // Applies only to fast, non-smooth bursts to preserve single-step scrolling. + // Flutter uses microseconds for dt, so velocity is in delta/us. // Relative mouse mode (for games/3D apps). final relativeMouseMode = false.obs; @@ -964,6 +974,7 @@ class InputModel { toReleaseRawKeys.release(handleRawKeyEvent); _pointerMovedAfterEnter = false; _pointerInsideImage = enter; + _lastWheelTsUs = 0; // Fix status if (!enter) { @@ -1407,17 +1418,44 @@ class InputModel { if (isViewOnly) return; if (isViewCamera) return; if (e is PointerScrollEvent) { - var dx = e.scrollDelta.dx.toInt(); - var dy = e.scrollDelta.dy.toInt(); + final rawDx = e.scrollDelta.dx; + final rawDy = e.scrollDelta.dy; + final dominantDelta = rawDx.abs() > rawDy.abs() ? rawDx.abs() : rawDy.abs(); + final isSmooth = dominantDelta < 1; + final nowUs = DateTime.now().microsecondsSinceEpoch; + final dtUs = _lastWheelTsUs == 0 ? 0 : nowUs - _lastWheelTsUs; + _lastWheelTsUs = nowUs; + int accel = 1; + if (!isSmooth && + dtUs > 0 && + dtUs <= _wheelAccelMediumThresholdUs && + (isWindows || isLinux) && + peerPlatform == kPeerPlatformMacOS) { + final velocity = dominantDelta / dtUs; + if (velocity >= _wheelBurstVelocityThreshold) { + if (dtUs < _wheelAccelFastThresholdUs) { + accel = 3; + } else { + accel = 2; + } + } + } + var dx = rawDx.toInt(); + var dy = rawDy.toInt(); + if (rawDx.abs() > rawDy.abs()) { + dy = 0; + } else { + dx = 0; + } if (dx > 0) { - dx = -1; + dx = -accel; } else if (dx < 0) { - dx = 1; + dx = accel; } if (dy > 0) { - dy = -1; + dy = -accel; } else if (dy < 0) { - dy = 1; + dy = accel; } bind.sessionSendMouse( sessionId: sessionId, diff --git a/src/ui/remote.tis b/src/ui/remote.tis index 0dd574af737..7602432fed2 100644 --- a/src/ui/remote.tis +++ b/src/ui/remote.tis @@ -142,6 +142,14 @@ function resetWheel() { } var INERTIA_ACCELERATION = 30; +var WHEEL_ACCEL_VELOCITY_THRESHOLD = 5000; +var WHEEL_ACCEL_DT_FAST = 0.04; +var WHEEL_ACCEL_DT_MEDIUM = 0.08; +var WHEEL_ACCEL_VALUE_FAST = 3; +var WHEEL_ACCEL_VALUE_MEDIUM = 2; +// Wheel burst acceleration (empirical tuning). +// Applies only on fast, non-smooth wheel bursts to keep single-step scroll unchanged. +// Sciter uses seconds for dt, so velocity is in delta/sec. // not good, precision not enough to simulate acceleration effect, // seems have to use pixel based rather line based delta @@ -237,12 +245,28 @@ function handler.onMouse(evt) // mouseWheelDistance = 8 * [currentUserDefs floatForKey:@"com.apple.scrollwheel.scaling"]; mask = 3; { - var (dx, dy) = evt.wheelDeltas; - if (dx > 0) dx = 1; - else if (dx < 0) dx = -1; - if (dy > 0) dy = 1; - else if (dy < 0) dy = -1; - if (Math.abs(dx) > Math.abs(dy)) { + var now = getTime(); + var dt = last_wheel_time > 0 ? (now - last_wheel_time) / 1000 : 0; + var (raw_dx, raw_dy) = evt.wheelDeltas; + var dx = 0; + var dy = 0; + var abs_dx = Math.abs(raw_dx); + var abs_dy = Math.abs(raw_dy); + var dominant = abs_dx > abs_dy ? abs_dx : abs_dy; + var is_smooth = dominant < 1; + var accel = 1; + if (!is_smooth && dt > 0 && (is_win || is_linux) && get_peer_platform() == "Mac OS") { + var velocity = dominant / dt; + if (velocity >= WHEEL_ACCEL_VELOCITY_THRESHOLD) { + if (dt < WHEEL_ACCEL_DT_FAST) accel = WHEEL_ACCEL_VALUE_FAST; + else if (dt < WHEEL_ACCEL_DT_MEDIUM) accel = WHEEL_ACCEL_VALUE_MEDIUM; + } + } + if (raw_dx > 0) dx = accel; + else if (raw_dx < 0) dx = -accel; + if (raw_dy > 0) dy = accel; + else if (raw_dy < 0) dy = -accel; + if (abs_dx > abs_dy) { dy = 0; } else { dx = 0; @@ -253,8 +277,6 @@ function handler.onMouse(evt) wheel_delta_y = acc_wheel_delta_y.toInteger(); acc_wheel_delta_x -= wheel_delta_x; acc_wheel_delta_y -= wheel_delta_y; - var now = getTime(); - var dt = last_wheel_time > 0 ? (now - last_wheel_time) / 1000 : 0; if (dt > 0) { var vx = dx / dt; var vy = dy / dt; @@ -297,11 +319,13 @@ function handler.onMouse(evt) entered = true; stdout.println("enter"); handler.enter(handler.get_keyboard_mode()); + last_wheel_time = 0; return keyboard_enabled; case Event.MOUSE_LEAVE: entered = false; stdout.println("leave"); handler.leave(handler.get_keyboard_mode()); + last_wheel_time = 0; if (is_left_down && get_peer_platform() == "Android") { is_left_down = false; handler.send_mouse((1 << 3) | 2, 0, 0, evt.altKey, From e4208aa9cfa57586225668d50b76cfe4d95b9fc0 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:33:54 +0800 Subject: [PATCH 05/16] fix(update): revert check (#14423) Signed-off-by: fufesou --- flutter/lib/desktop/pages/desktop_home_page.dart | 8 +++----- src/common.rs | 4 +--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/flutter/lib/desktop/pages/desktop_home_page.dart b/flutter/lib/desktop/pages/desktop_home_page.dart index b9af2dc7be7..339ecddb073 100644 --- a/flutter/lib/desktop/pages/desktop_home_page.dart +++ b/flutter/lib/desktop/pages/desktop_home_page.dart @@ -430,12 +430,10 @@ class _DesktopHomePageState extends State } Widget buildHelpCards(String updateUrl) { - final isWindowsInstalled = isWindows && bind.mainIsInstalled(); - if (updateUrl.isNotEmpty && + if (!bind.isCustomClient() && + updateUrl.isNotEmpty && !isCardClosed && - (isWindowsInstalled || - (!bind.isCustomClient() && - bind.mainUriPrefixSync().contains('rustdesk')))) { + bind.mainUriPrefixSync().contains('rustdesk')) { final isToUpdate = (isWindows || isMacOS) && bind.mainIsInstalled(); String btnText = isToUpdate ? 'Update' : 'Download'; GestureTapCallback onPressed = () async { diff --git a/src/common.rs b/src/common.rs index d2c252869f5..3e23770c6d5 100644 --- a/src/common.rs +++ b/src/common.rs @@ -940,9 +940,7 @@ pub fn is_modifier(evt: &KeyEvent) -> bool { } pub fn check_software_update() { - let is_windows_installed = cfg!(target_os = "windows") && is_installed(); - let should_check_update = is_windows_installed || !is_custom_client(); - if !should_check_update { + if is_custom_client() { return; } let opt = LocalConfig::get_option(keys::OPTION_ENABLE_CHECK_UPDATE); From 1833cb0655d002a3beea4f972b8770c424d81d44 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:17:26 +0800 Subject: [PATCH 06/16] fix(update): revert check (#14424) Signed-off-by: fufesou --- flutter/lib/common.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index ca52c61e04a..ab1b0b3c566 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -3938,9 +3938,7 @@ void earlyAssert() { void checkUpdate() { if (!isWeb) { - final isWindowsInstalled = isWindows && bind.mainIsInstalled(); - final shouldCheckUpdate = isWindowsInstalled || !bind.isCustomClient(); - if (shouldCheckUpdate) { + if (!bind.isCustomClient()) { platformFFI.registerEventHandler( kCheckSoftwareUpdateFinish, kCheckSoftwareUpdateFinish, (Map evt) async { From cd7e3e45059d2ad061a075be21bb60dc469d5ae2 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:19:07 +0800 Subject: [PATCH 07/16] fix(update): macos, input password (#14430) Signed-off-by: fufesou --- src/platform/macos.rs | 46 +++++++++++---------- src/platform/privileges_scripts/update.scpt | 21 ++++++---- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b923c6c1728..b9db741e1f4 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -279,6 +279,9 @@ fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync Err(e) => { log::error!("run osascript failed: {}", e); } + Ok(status) if !status.success() => { + log::warn!("run osascript failed with status: {}", status); + } _ => { let installed = std::path::Path::new(&agent_plist_file).exists(); log::info!("Agent file {} installed: {}", &agent_plist_file, installed); @@ -851,32 +854,33 @@ pub fn update_me() -> ResultType<()> { if is_installed_daemon && !is_service_stopped { let agent = format!("{}_server.plist", crate::get_full_name()); let agent_plist_file = format!("/Library/LaunchAgents/{}", agent); - std::process::Command::new("launchctl") - .args(&["unload", "-w", &agent_plist_file]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .ok(); update_daemon_agent(agent_plist_file, app_dir, true); } else { // `kill -9` may not work without "administrator privileges" - let update_body = format!( - r#" -do shell script " -pgrep -x '{app_name}' | grep -v {pid} | xargs kill -9 && rm -rf '/Applications/{app_name}.app' && ditto '{app_dir}' '/Applications/{app_name}.app' && chown -R {user}:staff '/Applications/{app_name}.app' && xattr -r -d com.apple.quarantine '/Applications/{app_name}.app' -" with prompt "{app_name} wants to update itself" with administrator privileges - "#, - app_name = app_name, - pid = std::process::id(), - app_dir = app_dir, - user = get_active_username() - ); - match Command::new("osascript") + let update_body = r#" +on run {app_name, cur_pid, app_dir, user_name} + set app_bundle to "/Applications/" & app_name & ".app" + set app_bundle_q to quoted form of app_bundle + set app_dir_q to quoted form of app_dir + set user_name_q to quoted form of user_name + + set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" + set copy_files to "rm -rf " & app_bundle_q & " && ditto " & app_dir_q & " " & app_bundle_q & " && chown -R " & user_name_q & ":staff " & app_bundle_q & " && (xattr -r -d com.apple.quarantine " & app_bundle_q & " || true);" + set sh to "set -e;" & kill_others & copy_files + + do shell script sh with prompt app_name & " wants to update itself" with administrator privileges +end run + "#; + let active_user = get_active_username(); + let status = Command::new("osascript") .arg("-e") .arg(update_body) - .status() - { + .arg(app_name.to_string()) + .arg(std::process::id().to_string()) + .arg(app_dir) + .arg(active_user) + .status(); + match status { Ok(status) if !status.success() => { log::error!("osascript execution failed with status: {}", status); } diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index dffb70bd7d5..88f4bdde527 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -1,18 +1,21 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} - set unload_service to "launchctl unload -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist || true;" + set agent_plist to "/Library/LaunchAgents/com.carriez.RustDesk_server.plist" + set daemon_plist to "/Library/LaunchDaemons/com.carriez.RustDesk_service.plist" + set app_bundle to "/Applications/RustDesk.app" - set kill_others to "pgrep -x 'RustDesk' | grep -v " & cur_pid & " | xargs kill -9;" + set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" + set unload_agent to "if [ -n \"$uid\" ]; then launchctl bootout gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootout user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl unload -w " & quoted form of agent_plist & " || true; else launchctl unload -w " & quoted form of agent_plist & " || true; fi;" + set unload_service to "launchctl unload -w " & daemon_plist & " || true;" + set kill_others to "pids=$(pgrep -x 'RustDesk' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" - set copy_files to "rm -rf /Applications/RustDesk.app && ditto " & source_dir & " /Applications/RustDesk.app && chown -R " & quoted form of user & ":staff /Applications/RustDesk.app && xattr -r -d com.apple.quarantine /Applications/RustDesk.app;" + set copy_files to "(rm -rf " & quoted form of app_bundle & " && ditto " & quoted form of source_dir & " " & quoted form of app_bundle & " && chown -R " & quoted form of user & ":staff " & quoted form of app_bundle & " && (xattr -r -d com.apple.quarantine " & quoted form of app_bundle & " || true)) || exit 1;" - 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 write_daemon_plist to "echo " & quoted form of daemon_file & " > " & daemon_plist & " && chown root:wheel " & daemon_plist & ";" + set write_agent_plist to "echo " & quoted form of agent_file & " > " & agent_plist & " && chown root:wheel " & agent_plist & ";" + set load_service to "launchctl load -w " & daemon_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 "launchctl load -w /Library/LaunchDaemons/com.carriez.RustDesk_service.plist;" - - set sh to unload_service & kill_others & copy_files & sh1 & sh2 & sh3 + set sh to "set -e;" & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run From 9cb6f38aea4695dcb5b6d5a903f5d959b31d5df2 Mon Sep 17 00:00:00 2001 From: MichaIng Date: Sun, 1 Mar 2026 11:05:19 +0100 Subject: [PATCH 08/16] packaging: deb: remove obsolete Python version check (#14429) It was used to conditionally install a Python module in the past. But that is not the case anymore since https://github.com/rustdesk/rustdesk/commit/37dbfcc. Now the check is obsolete. Due to `set -e`, the check leads to a package configuration failure if Python is not installed, which however otherwise is not needed for RustDesk. The commit includes an indentation fix and trailing space removal. Signed-off-by: MichaIng --- res/DEBIAN/postinst | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/res/DEBIAN/postinst b/res/DEBIAN/postinst index dad333ee5b9..57bb30d61ce 100755 --- a/res/DEBIAN/postinst +++ b/res/DEBIAN/postinst @@ -6,15 +6,13 @@ if [ "$1" = configure ]; then INITSYS=$(ls -al /proc/1/exe | awk -F' ' '{print $NF}' | awk -F'/' '{print $NF}') ln -f -s /usr/share/rustdesk/rustdesk /usr/bin/rustdesk - + if [ "systemd" == "$INITSYS" ]; then if [ -e /etc/systemd/system/rustdesk.service ]; then rm /etc/systemd/system/rustdesk.service /usr/lib/systemd/system/rustdesk.service /usr/lib/systemd/user/rustdesk.service >/dev/null 2>&1 fi - version=$(python3 -V 2>&1 | grep -Po '(?<=Python )(.+)') - parsedVersion=$(echo "${version//./}") - mkdir -p /usr/lib/systemd/system/ + mkdir -p /usr/lib/systemd/system/ cp /usr/share/rustdesk/files/systemd/rustdesk.service /usr/lib/systemd/system/rustdesk.service # try fix error in Ubuntu 18.04 # Failed to reload rustdesk.service: Unit rustdesk.service is not loaded properly: Exec format error. From 80a5865db3a49036d4d57b64455e8ff87cc39854 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:06:04 +0800 Subject: [PATCH 09/16] macOS update: restore LaunchAgent in GUI session and isolate temp update dir by euid (#14434) * fix(update): macos, load agent Signed-off-by: fufesou * fix(update): macos, isolate temp update dir by euid Signed-off-by: fufesou * refact(update): macos script Signed-off-by: fufesou --------- Signed-off-by: fufesou --- src/platform/macos.rs | 48 ++++++++++----------- src/platform/privileges_scripts/update.scpt | 6 ++- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index b9db741e1f4..22a1085f689 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -42,9 +42,16 @@ static PRIVILEGES_SCRIPTS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/platform/privileges_scripts"); static mut LATEST_SEED: i32 = 0; -// Using a fixed temporary directory for updates is preferable to -// using one that includes the custom client name. -const UPDATE_TEMP_DIR: &str = "/tmp/.rustdeskupdate"; +#[inline] +fn get_update_temp_dir() -> PathBuf { + let euid = unsafe { hbb_common::libc::geteuid() }; + Path::new("/tmp").join(format!(".rustdeskupdate-{}", euid)) +} + +#[inline] +fn get_update_temp_dir_string() -> String { + get_update_temp_dir().to_string_lossy().into_owned() +} /// Global mutex to serialize CoreGraphics cursor operations. /// This prevents race conditions between cursor visibility (hide depth tracking) @@ -285,21 +292,6 @@ fn update_daemon_agent(agent_plist_file: String, update_source_dir: String, sync _ => { let installed = std::path::Path::new(&agent_plist_file).exists(); log::info!("Agent file {} installed: {}", &agent_plist_file, installed); - if installed { - // Unload first, or load may not work if already loaded. - // We hope that the load operation can immediately trigger a start. - std::process::Command::new("launchctl") - .args(&["unload", "-w", &agent_plist_file]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .ok(); - let status = std::process::Command::new("launchctl") - .args(&["load", "-w", &agent_plist_file]) - .status(); - log::info!("launch server, status: {:?}", &status); - } } } }; @@ -418,7 +410,9 @@ pub fn set_cursor_pos(x: i32, y: i32) -> bool { let _guard = match CG_CURSOR_MUTEX.try_lock() { Ok(guard) => guard, Err(std::sync::TryLockError::WouldBlock) => { - log::error!("[BUG] set_cursor_pos: CG_CURSOR_MUTEX is already held - potential deadlock!"); + log::error!( + "[BUG] set_cursor_pos: CG_CURSOR_MUTEX is already held - potential deadlock!" + ); debug_assert!(false, "Re-entrant call to set_cursor_pos detected"); return false; } @@ -825,7 +819,8 @@ pub fn quit_gui() { #[inline] pub fn try_remove_temp_update_dir(dir: Option<&str>) { - let target_path = Path::new(dir.unwrap_or(UPDATE_TEMP_DIR)); + let target_path_buf = dir.map(PathBuf::from).unwrap_or_else(get_update_temp_dir); + let target_path = target_path_buf.as_path(); if target_path.exists() { std::fs::remove_dir_all(target_path).ok(); } @@ -901,25 +896,28 @@ end run } pub fn update_from_dmg(dmg_path: &str) -> ResultType<()> { + let update_temp_dir = get_update_temp_dir_string(); println!("Starting update from DMG: {}", dmg_path); - extract_dmg(dmg_path, UPDATE_TEMP_DIR)?; + extract_dmg(dmg_path, &update_temp_dir)?; println!("DMG extracted"); - update_extracted(UPDATE_TEMP_DIR)?; + update_extracted(&update_temp_dir)?; println!("Update process started"); Ok(()) } pub fn update_to(_file: &str) -> ResultType<()> { - update_extracted(UPDATE_TEMP_DIR)?; + let update_temp_dir = get_update_temp_dir_string(); + update_extracted(&update_temp_dir)?; Ok(()) } pub fn extract_update_dmg(file: &str) { + let update_temp_dir = get_update_temp_dir_string(); let mut evt: HashMap<&str, String> = HashMap::from([("name", "extract-update-dmg".to_string())]); - match extract_dmg(file, UPDATE_TEMP_DIR) { + match extract_dmg(file, &update_temp_dir) { Ok(_) => { - log::info!("Extracted dmg file to {}", UPDATE_TEMP_DIR); + log::info!("Extracted dmg file to {}", update_temp_dir); } Err(e) => { evt.insert("err", e.to_string()); diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index 88f4bdde527..07dadb7c689 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -14,8 +14,12 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} set write_daemon_plist to "echo " & quoted form of daemon_file & " > " & daemon_plist & " && chown root:wheel " & daemon_plist & ";" set write_agent_plist to "echo " & quoted form of agent_file & " > " & agent_plist & " && chown root:wheel " & agent_plist & ";" set load_service to "launchctl load -w " & daemon_plist & ";" + set agent_label_cmd to "agent_label=$(basename " & quoted form of agent_plist & " .plist);" + set bootstrap_agent to "if [ -n \"$uid\" ]; then launchctl bootstrap gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootstrap user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl load -w " & quoted form of agent_plist & " || true; else launchctl load -w " & quoted form of agent_plist & " || true; fi;" + set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k gui/$uid/$agent_label 2>/dev/null || launchctl kickstart -k user/$uid/$agent_label 2>/dev/null || true; fi;" + set load_agent to agent_label_cmd & bootstrap_agent & kickstart_agent - set sh to "set -e;" & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service + set sh to "set -e;" & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run From 6ba23683d5d6b5412b1cf819a95fd471801a62d5 Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 2 Mar 2026 12:06:20 +0800 Subject: [PATCH 10/16] avatar in libs/hbb_comon --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index 5e07db74442..ae3726dd5f5 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 5e07db7444284006c008b5b1204f0968bc47b1a9 +Subproject commit ae3726dd5f505b87b8be66f2b2cf4e902a2dcde4 From 157dbdc543470292d87d18b93efc952ef380e66c Mon Sep 17 00:00:00 2001 From: rustdesk Date: Mon, 2 Mar 2026 12:14:26 +0800 Subject: [PATCH 11/16] fix avatar in hbb_common --- libs/hbb_common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/hbb_common b/libs/hbb_common index ae3726dd5f5..48c37de3e6c 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit ae3726dd5f505b87b8be66f2b2cf4e902a2dcde4 +Subproject commit 48c37de3e6c4e399af6f51ca20e8e3e1fd037976 From 732b2508159d66c21aa1250ef92aef4095f170e6 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Mon, 2 Mar 2026 19:07:09 +0800 Subject: [PATCH 12/16] fix(keyboard): legacy mode (#14435) * fix(keyboard): legacy mode Signed-off-by: fufesou * Simple refactor Signed-off-by: fufesou * fix(keyboard): legacy mode, chr to seq Signed-off-by: fufesou * fix(keyboard): legacy mode, early return if (!hotkey)&down Signed-off-by: fufesou * fix(keyboard): legacy mode, pair down/up Signed-off-by: fufesou --------- Signed-off-by: fufesou --- libs/enigo/src/win/win_impl.rs | 15 ++++++++++++-- src/server/input_service.rs | 37 +++++++++++++++++++++++++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/libs/enigo/src/win/win_impl.rs b/libs/enigo/src/win/win_impl.rs index 882dba1267d..a6b465ea115 100644 --- a/libs/enigo/src/win/win_impl.rs +++ b/libs/enigo/src/win/win_impl.rs @@ -269,7 +269,7 @@ impl KeyboardControllable for Enigo { for pos in 0..mod_len { let rpos = mod_len - 1 - pos; if flag & (0x0001 << rpos) != 0 { - self.key_up(modifiers[pos]); + self.key_up(modifiers[rpos]); } } @@ -298,7 +298,18 @@ impl KeyboardControllable for Enigo { } fn key_up(&mut self, key: Key) { - keybd_event(KEYEVENTF_KEYUP, self.key_to_keycode(key), 0); + match key { + Key::Layout(c) => { + let code = self.get_layoutdependent_keycode(c); + if code as u16 != 0xFFFF { + let vk = code & 0x00FF; + keybd_event(KEYEVENTF_KEYUP, vk, 0); + } + } + _ => { + keybd_event(KEYEVENTF_KEYUP, self.key_to_keycode(key), 0); + } + } } fn get_key_state(&mut self, key: Key) -> bool { diff --git a/src/server/input_service.rs b/src/server/input_service.rs index fb8441ddeca..97dc78755e8 100644 --- a/src/server/input_service.rs +++ b/src/server/input_service.rs @@ -809,7 +809,7 @@ fn record_key_is_control_key(record_key: u64) -> bool { #[inline] fn record_key_is_chr(record_key: u64) -> bool { - record_key < KEY_CHAR_START + record_key >= KEY_CHAR_START } #[inline] @@ -1513,6 +1513,27 @@ fn get_control_key_value(key_event: &KeyEvent) -> i32 { } } +#[inline] +fn has_hotkey_modifiers(key_event: &KeyEvent) -> bool { + key_event.modifiers.iter().any(|ck| { + let v = ck.value(); + v == ControlKey::Control.value() + || v == ControlKey::RControl.value() + || v == ControlKey::Meta.value() + || v == ControlKey::RWin.value() + || { + #[cfg(any(target_os = "windows", target_os = "linux"))] + { + v == ControlKey::Alt.value() || v == ControlKey::RAlt.value() + } + #[cfg(target_os = "macos")] + { + false + } + } + }) +} + fn release_unpressed_modifiers(en: &mut Enigo, key_event: &KeyEvent) { let ck_value = get_control_key_value(key_event); fix_modifiers(&key_event.modifiers[..], en, ck_value); @@ -1572,7 +1593,7 @@ fn need_to_uppercase(en: &mut Enigo) -> bool { get_modifier_state(Key::Shift, en) || get_modifier_state(Key::CapsLock, en) } -fn process_chr(en: &mut Enigo, chr: u32, down: bool) { +fn process_chr(en: &mut Enigo, chr: u32, down: bool, _hotkey: bool) { // On Wayland with uinput mode, use clipboard for character input #[cfg(target_os = "linux")] if !crate::platform::linux::is_x11() && wayland_use_uinput() { @@ -1587,6 +1608,16 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool) { } } + #[cfg(any(target_os = "macos", target_os = "windows"))] + if !_hotkey { + if down { + if let Ok(chr) = char::try_from(chr) { + en.key_sequence(&chr.to_string()); + } + } + return; + } + let key = char_value_to_key(chr); if down { @@ -1856,7 +1887,7 @@ fn legacy_keyboard_mode(evt: &KeyEvent) { let record_key = chr as u64 + KEY_CHAR_START; record_pressed_key(KeysDown::EnigoKey(record_key), down); - process_chr(&mut en, chr, down) + process_chr(&mut en, chr, down, has_hotkey_modifiers(evt)) } Some(key_event::Union::Unicode(chr)) => { // Same as Chr: release Shift for Unicode input From 41ab5bbdd8d6c56f99f59176ed3bc100762522f1 Mon Sep 17 00:00:00 2001 From: fufesou <13586388+fufesou@users.noreply.github.com> Date: Tue, 3 Mar 2026 10:47:32 +0800 Subject: [PATCH 13/16] fix(update): macos, test before update (#14446) Signed-off-by: fufesou --- src/platform/macos.rs | 3 ++- src/platform/privileges_scripts/update.scpt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 22a1085f689..2e68cf5d824 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -859,9 +859,10 @@ on run {app_name, cur_pid, app_dir, user_name} set app_dir_q to quoted form of app_dir set user_name_q to quoted form of user_name + set check_source to "test -d " & app_dir_q & " || exit 1;" set kill_others to "pids=$(pgrep -x '" & app_name & "' | grep -vx " & cur_pid & " || true); if [ -n \"$pids\" ]; then echo \"$pids\" | xargs kill -9 || true; fi;" set copy_files to "rm -rf " & app_bundle_q & " && ditto " & app_dir_q & " " & app_bundle_q & " && chown -R " & user_name_q & ":staff " & app_bundle_q & " && (xattr -r -d com.apple.quarantine " & app_bundle_q & " || true);" - set sh to "set -e;" & kill_others & copy_files + set sh to "set -e;" & check_source & kill_others & copy_files do shell script sh with prompt app_name & " wants to update itself" with administrator privileges end run diff --git a/src/platform/privileges_scripts/update.scpt b/src/platform/privileges_scripts/update.scpt index 07dadb7c689..0484c257ad9 100644 --- a/src/platform/privileges_scripts/update.scpt +++ b/src/platform/privileges_scripts/update.scpt @@ -4,6 +4,7 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} set daemon_plist to "/Library/LaunchDaemons/com.carriez.RustDesk_service.plist" set app_bundle to "/Applications/RustDesk.app" + set check_source to "test -d " & quoted form of source_dir & " || exit 1;" set resolve_uid to "uid=$(id -u " & quoted form of user & " 2>/dev/null || true);" set unload_agent to "if [ -n \"$uid\" ]; then launchctl bootout gui/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl bootout user/$uid " & quoted form of agent_plist & " 2>/dev/null || launchctl unload -w " & quoted form of agent_plist & " || true; else launchctl unload -w " & quoted form of agent_plist & " || true; fi;" set unload_service to "launchctl unload -w " & daemon_plist & " || true;" @@ -19,7 +20,7 @@ on run {daemon_file, agent_file, user, cur_pid, source_dir} set kickstart_agent to "if [ -n \"$uid\" ]; then launchctl kickstart -k gui/$uid/$agent_label 2>/dev/null || launchctl kickstart -k user/$uid/$agent_label 2>/dev/null || true; fi;" set load_agent to agent_label_cmd & bootstrap_agent & kickstart_agent - set sh to "set -e;" & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent + set sh to "set -e;" & check_source & resolve_uid & unload_agent & unload_service & kill_others & copy_files & write_daemon_plist & write_agent_plist & load_service & load_agent do shell script sh with prompt "RustDesk wants to update itself" with administrator privileges end run From 52b66e71d1a11bde30ab3347f210b8f81be7b85c Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:48:42 +0800 Subject: [PATCH 14/16] Move port mapping afterwards (#14448) * move port mapping after auth in port forwarding * fix(port-forward): try connect after 2fa Signed-off-by: fufesou * fix(security): gate port-forward connect on full auth and clarify login flow semantics Signed-off-by: fufesou * refact(port-forward): comments and logs Signed-off-by: fufesou --------- Signed-off-by: fufesou Co-authored-by: fufesou --- src/server/connection.rs | 130 ++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 43 deletions(-) diff --git a/src/server/connection.rs b/src/server/connection.rs index 1259054cd32..033aac0ce79 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -560,7 +560,9 @@ impl Connection { match data { ipc::Data::Authorize => { conn.require_2fa.take(); - conn.send_logon_response().await; + if !conn.send_logon_response_and_keep_alive().await { + break; + } if conn.port_forward_socket.is_some() { break; } @@ -1338,9 +1340,66 @@ impl Connection { crate::post_request(url, v.to_string(), "").await } - async fn send_logon_response(&mut self) { + fn normalize_port_forward_target(pf: &mut PortForward) -> (String, bool) { + let mut is_rdp = false; + if pf.host == "RDP" && pf.port == 0 { + pf.host = "localhost".to_owned(); + pf.port = 3389; + is_rdp = true; + } + if pf.host.is_empty() { + pf.host = "localhost".to_owned(); + } + (format!("{}:{}", pf.host, pf.port), is_rdp) + } + + async fn connect_port_forward_if_needed(&mut self) -> bool { + if self.port_forward_socket.is_some() { + return true; + } + let Some(login_request::Union::PortForward(pf)) = self.lr.union.as_ref() else { + return true; + }; + let mut pf = pf.clone(); + let (mut addr, is_rdp) = Self::normalize_port_forward_target(&mut pf); + self.port_forward_address = addr.clone(); + match timeout(3000, TcpStream::connect(&addr)).await { + Ok(Ok(sock)) => { + self.port_forward_socket = Some(Framed::new(sock, BytesCodec::new())); + true + } + Ok(Err(e)) => { + log::warn!("Port forward connect failed for {}: {}", addr, e); + if is_rdp { + addr = "RDP".to_owned(); + } + self.send_login_error(format!( + "Failed to access remote {}. Please make sure it is reachable/open.", + addr + )) + .await; + false + } + Err(e) => { + log::warn!("Port forward connect timed out for {}: {}", addr, e); + if is_rdp { + addr = "RDP".to_owned(); + } + self.send_login_error(format!( + "Failed to access remote {}. Please make sure it is reachable/open.", + addr + )) + .await; + false + } + } + } + + // Returns whether this connection should be kept alive. + // `true` does not necessarily mean authorization succeeded (e.g. REQUIRE_2FA case). + async fn send_logon_response_and_keep_alive(&mut self) -> bool { if self.authorized { - return; + return true; } if self.require_2fa.is_some() && !self.is_recent_session(true) && !self.from_switch { self.require_2fa.as_ref().map(|totp| { @@ -1371,7 +1430,11 @@ impl Connection { } }); self.send_login_error(crate::client::REQUIRE_2FA).await; - return; + // Keep the connection alive so the client can continue with 2FA. + return true; + } + if !self.connect_port_forward_if_needed().await { + return false; } self.authorized = true; let (conn_type, auth_conn_type) = if self.file_transfer.is_some() { @@ -1494,7 +1557,7 @@ impl Connection { res.set_peer_info(pi); msg_out.set_login_response(res); self.send(msg_out).await; - return; + return true; } #[cfg(target_os = "linux")] if self.is_remote() { @@ -1517,7 +1580,7 @@ impl Connection { let mut msg_out = Message::new(); msg_out.set_login_response(res); self.send(msg_out).await; - return; + return true; } } #[allow(unused_mut)] @@ -1671,6 +1734,7 @@ impl Connection { self.try_sub_monitor_services(); } } + true } fn try_sub_camera_displays(&mut self) { @@ -2178,33 +2242,8 @@ impl Connection { sleep(1.).await; return false; } - let mut is_rdp = false; - if pf.host == "RDP" && pf.port == 0 { - pf.host = "localhost".to_owned(); - pf.port = 3389; - is_rdp = true; - } - if pf.host.is_empty() { - pf.host = "localhost".to_owned(); - } - let mut addr = format!("{}:{}", pf.host, pf.port); - self.port_forward_address = addr.clone(); - match timeout(3000, TcpStream::connect(&addr)).await { - Ok(Ok(sock)) => { - self.port_forward_socket = Some(Framed::new(sock, BytesCodec::new())); - } - _ => { - if is_rdp { - addr = "RDP".to_owned(); - } - self.send_login_error(format!( - "Failed to access remote {}, please make sure if it is open", - addr - )) - .await; - return false; - } - } + let (addr, _is_rdp) = Self::normalize_port_forward_target(&mut pf); + self.port_forward_address = addr; } _ => { if !self.check_privacy_mode_on().await { @@ -2235,9 +2274,7 @@ impl Connection { // `is_logon_ui()` is a fallback for logon UI detection on Windows. #[cfg(target_os = "windows")] let is_logon = || { - crate::platform::is_prelogin() - || crate::platform::is_locked() - || { + crate::platform::is_prelogin() || crate::platform::is_locked() || { match crate::platform::is_logon_ui() { Ok(result) => result, Err(e) => { @@ -2276,7 +2313,9 @@ impl Connection { if err_msg.is_empty() { #[cfg(target_os = "linux")] self.linux_headless_handle.wait_desktop_cm_ready().await; - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm(lr.my_id.clone(), lr.my_name.clone(), self.authorized); } else { self.send_login_error(err_msg).await; @@ -2312,7 +2351,9 @@ impl Connection { if err_msg.is_empty() { #[cfg(target_os = "linux")] self.linux_headless_handle.wait_desktop_cm_ready().await; - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm(lr.my_id, lr.my_name, self.authorized); } else { self.send_login_error(err_msg).await; @@ -2330,7 +2371,9 @@ impl Connection { self.update_failure(failure, true, 1); self.require_2fa.take(); raii::AuthedConnID::set_session_2fa(self.session_key()); - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm( self.lr.my_id.to_owned(), self.lr.my_name.to_owned(), @@ -2381,7 +2424,9 @@ impl Connection { if let Some((_instant, uuid_old)) = uuid_old { if uuid == uuid_old { self.from_switch = true; - self.send_logon_response().await; + if !self.send_logon_response_and_keep_alive().await { + return false; + } self.try_start_cm( lr.my_id.clone(), lr.my_name.clone(), @@ -5347,9 +5392,8 @@ mod raii { } pub fn check_wake_lock_on_setting_changed() { - let current = config::Config::get_bool_option( - keys::OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS, - ); + let current = + config::Config::get_bool_option(keys::OPTION_KEEP_AWAKE_DURING_INCOMING_SESSIONS); let cached = *WAKELOCK_KEEP_AWAKE_OPTION.lock().unwrap(); if cached != Some(current) { Self::check_wake_lock(); From ab64a32f301fda0b010eb9d029531b455b0faf80 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Wed, 4 Mar 2026 21:43:19 +0800 Subject: [PATCH 15/16] avatar (#14440) * avatar * refactor avatar display: unify rendering and resolve at use time - Extract buildAvatarWidget() in common.dart to share avatar rendering logic across desktop settings, desktop CM and mobile CM - Add resolve_avatar_url() in Rust, exposed via FFI (SyncReturn), to resolve relative avatar paths (e.g. "/avatar/xxx") to absolute URLs - Store avatar as-is in local config, only resolve when displaying (settings page) or sending (LoginRequest) - Resolve avatar in LoginRequest before sending to remote peer - Add error handling for network image load failures - Guard against empty client.name[0] crash - Show avatar in mobile settings page account tile Signed-off-by: 21pages * web: implement mainResolveAvatarUrl via js getByName Signed-off-by: 21pages * increase ipc Data enum size limit to 120 bytes Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: 21pages --- flutter/lib/common.dart | 40 +++++++++++ flutter/lib/common/hbbs/hbbs.dart | 3 + .../desktop/pages/desktop_setting_page.dart | 71 ++++++++++++++----- flutter/lib/desktop/pages/server_page.dart | 47 +++++++----- flutter/lib/mobile/pages/server_page.dart | 22 ++++-- flutter/lib/mobile/pages/settings_page.dart | 10 ++- flutter/lib/models/server_model.dart | 3 + flutter/lib/models/user_model.dart | 5 ++ flutter/lib/web/bridge.dart | 4 ++ src/client.rs | 17 ++++- src/flutter_ffi.rs | 4 ++ src/hbbs_http/account.rs | 4 ++ src/ipc.rs | 3 +- src/server/connection.rs | 1 + src/ui/cm.css | 5 ++ src/ui/cm.rs | 1 + src/ui/cm.tis | 7 +- src/ui/index.tis | 3 + src/ui_cm_interface.rs | 9 ++- src/ui_interface.rs | 15 +++- 20 files changed, 225 insertions(+), 49 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index ab1b0b3c566..af87f980f5b 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -4118,3 +4118,43 @@ String mouseButtonsToPeer(int buttons) { return ''; } } + +/// Build an avatar widget from an avatar URL or data URI string. +/// Returns [fallback] if avatar is empty or cannot be decoded. +/// [borderRadius] defaults to [size]/2 (circle). +Widget? buildAvatarWidget({ + required String avatar, + required double size, + double? borderRadius, + Widget? fallback, +}) { + final trimmed = avatar.trim(); + if (trimmed.isEmpty) return fallback; + + ImageProvider? imageProvider; + if (trimmed.startsWith('data:image/')) { + final comma = trimmed.indexOf(','); + if (comma > 0) { + try { + imageProvider = MemoryImage(base64Decode(trimmed.substring(comma + 1))); + } catch (_) {} + } + } else if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + imageProvider = NetworkImage(trimmed); + } + + if (imageProvider == null) return fallback; + + final radius = borderRadius ?? size / 2; + return ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: Image( + image: imageProvider, + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + fallback ?? SizedBox.shrink(), + ), + ); +} diff --git a/flutter/lib/common/hbbs/hbbs.dart b/flutter/lib/common/hbbs/hbbs.dart index f3b21018486..0c729e4dfe5 100644 --- a/flutter/lib/common/hbbs/hbbs.dart +++ b/flutter/lib/common/hbbs/hbbs.dart @@ -26,6 +26,7 @@ enum UserStatus { kDisabled, kNormal, kUnverified } class UserPayload { String name = ''; String displayName = ''; + String avatar = ''; String email = ''; String note = ''; String? verifier; @@ -35,6 +36,7 @@ class UserPayload { UserPayload.fromJson(Map json) : name = json['name'] ?? '', displayName = json['display_name'] ?? '', + avatar = json['avatar'] ?? '', email = json['email'] ?? '', note = json['note'] ?? '', verifier = json['verifier'], @@ -49,6 +51,7 @@ class UserPayload { final Map map = { 'name': name, 'display_name': displayName, + 'avatar': avatar, 'status': status == UserStatus.kDisabled ? 0 : status == UserStatus.kUnverified diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index d8239adea34..bde40cf19e0 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2026,28 +2026,65 @@ class _AccountState extends State<_Account> { } Widget useInfo() { - text(String key, String value) { - return Align( - alignment: Alignment.centerLeft, - child: SelectionArea(child: Text('${translate(key)}: $value')) - .marginSymmetric(vertical: 4), - ); - } - return Obx(() => Offstage( offstage: gFFI.userModel.userName.value.isEmpty, - child: Column( - children: [ - if (gFFI.userModel.displayName.value.trim().isNotEmpty && - gFFI.userModel.displayName.value.trim() != - gFFI.userModel.userName.value.trim()) - text('Display Name', gFFI.userModel.displayName.value.trim()), - text('Username', gFFI.userModel.userName.value), - // text('Group', gFFI.groupModel.groupName.value), - ], + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Builder(builder: (context) { + final avatarWidget = _buildUserAvatar(); + return Row( + children: [ + if (avatarWidget != null) avatarWidget, + if (avatarWidget != null) const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + gFFI.userModel.displayNameOrUserName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + SelectionArea( + child: Text( + '@${gFFI.userModel.userName.value}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + color: + Theme.of(context).textTheme.bodySmall?.color, + ), + ), + ), + ], + ), + ), + ], + ); + }), ), )).marginOnly(left: 18, top: 16); } + + Widget? _buildUserAvatar() { + // Resolve relative avatar path at display time + final avatar = + bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 44, + ); + } } class _Checkbox extends StatefulWidget { diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index 4ee29756fc2..ea37c95e4ba 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -462,23 +462,7 @@ class _CmHeaderState extends State<_CmHeader> child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 70, - height: 70, - alignment: Alignment.center, - decoration: BoxDecoration( - color: str2color(client.name), - borderRadius: BorderRadius.circular(15.0), - ), - child: Text( - client.name[0], - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - fontSize: 55, - ), - ), - ).marginOnly(right: 10.0), + _buildClientAvatar().marginOnly(right: 10.0), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.start, @@ -582,6 +566,35 @@ class _CmHeaderState extends State<_CmHeader> @override bool get wantKeepAlive => true; + + Widget _buildClientAvatar() { + return buildAvatarWidget( + avatar: client.avatar, + size: 70, + borderRadius: 15, + fallback: _buildInitialAvatar(), + )!; + } + + Widget _buildInitialAvatar() { + return Container( + width: 70, + height: 70, + alignment: Alignment.center, + decoration: BoxDecoration( + color: str2color(client.name), + borderRadius: BorderRadius.circular(15.0), + ), + child: Text( + client.name.isNotEmpty ? client.name[0] : '?', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + fontSize: 55, + ), + ), + ); + } } class _PrivilegeBoard extends StatefulWidget { diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index d2a6ed8a8d0..d0a7b573ed8 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -841,13 +841,7 @@ class ClientInfo extends StatelessWidget { flex: -1, child: Padding( padding: const EdgeInsets.only(right: 12), - child: CircleAvatar( - backgroundColor: str2color( - client.name, - Theme.of(context).brightness == Brightness.light - ? 255 - : 150), - child: Text(client.name[0])))), + child: _buildAvatar(context))), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -860,6 +854,20 @@ class ClientInfo extends StatelessWidget { ), ])); } + + Widget _buildAvatar(BuildContext context) { + final fallback = CircleAvatar( + backgroundColor: str2color( + client.name, + Theme.of(context).brightness == Brightness.light ? 255 : 150), + child: Text(client.name.isNotEmpty ? client.name[0] : '?'), + ); + return buildAvatarWidget( + avatar: client.avatar, + size: 40, + fallback: fallback, + )!; + } } void androidChannelInit() { diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index afd3422d727..e047344aeda 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -689,7 +689,15 @@ class _SettingsState extends State with WidgetsBindingObserver { title: Obx(() => Text(gFFI.userModel.userName.value.isEmpty ? translate('Login') : '${translate('Logout')} (${gFFI.userModel.accountLabelWithHandle})')), - leading: Icon(Icons.person), + leading: Obx(() { + final avatar = bind.mainResolveAvatarUrl( + avatar: gFFI.userModel.avatar.value); + return buildAvatarWidget( + avatar: avatar, + size: 40, + ) ?? + Icon(Icons.person); + }), onPressed: (context) { if (gFFI.userModel.userName.value.isEmpty) { loginDialog(); diff --git a/flutter/lib/models/server_model.dart b/flutter/lib/models/server_model.dart index 8ead158ac1c..5892ed0fe51 100644 --- a/flutter/lib/models/server_model.dart +++ b/flutter/lib/models/server_model.dart @@ -820,6 +820,7 @@ class Client { bool isTerminal = false; String portForward = ""; String name = ""; + String avatar = ""; String peerId = ""; // peer user's id,show at app bool keyboard = false; bool clipboard = false; @@ -847,6 +848,7 @@ class Client { isTerminal = json['is_terminal'] ?? false; portForward = json['port_forward']; name = json['name']; + avatar = json['avatar'] ?? ''; peerId = json['peer_id']; keyboard = json['keyboard']; clipboard = json['clipboard']; @@ -870,6 +872,7 @@ class Client { data['is_terminal'] = isTerminal; data['port_forward'] = portForward; data['name'] = name; + data['avatar'] = avatar; data['peer_id'] = peerId; data['keyboard'] = keyboard; data['clipboard'] = clipboard; diff --git a/flutter/lib/models/user_model.dart b/flutter/lib/models/user_model.dart index c850c4cf6d7..cecb58eaa54 100644 --- a/flutter/lib/models/user_model.dart +++ b/flutter/lib/models/user_model.dart @@ -17,6 +17,7 @@ bool refreshingUser = false; class UserModel { final RxString userName = ''.obs; final RxString displayName = ''.obs; + final RxString avatar = ''.obs; final RxBool isAdmin = false.obs; final RxString networkError = ''.obs; bool get isLogin => userName.isNotEmpty; @@ -33,6 +34,7 @@ class UserModel { } return '$preferred (@$username)'; } + WeakReference parent; UserModel(this.parent) { @@ -114,6 +116,7 @@ class UserModel { if (userInfo != null) { userName.value = (userInfo['name'] ?? '').toString(); displayName.value = (userInfo['display_name'] ?? '').toString(); + avatar.value = (userInfo['avatar'] ?? '').toString(); } } @@ -126,11 +129,13 @@ class UserModel { } userName.value = ''; displayName.value = ''; + avatar.value = ''; } _parseAndUpdateUser(UserPayload user) { userName.value = user.name; displayName.value = user.displayName; + avatar.value = user.avatar; isAdmin.value = user.isAdmin; bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user)); if (isWeb) { diff --git a/flutter/lib/web/bridge.dart b/flutter/lib/web/bridge.dart index 4a4e89233bb..66191d004ce 100644 --- a/flutter/lib/web/bridge.dart +++ b/flutter/lib/web/bridge.dart @@ -2034,5 +2034,9 @@ class RustdeskImpl { return false; } + String mainResolveAvatarUrl({required String avatar, dynamic hint}) { + return js.context.callMethod('getByName', ['resolve_avatar_url', avatar])?.toString() ?? avatar; + } + void dispose() {} } diff --git a/src/client.rs b/src/client.rs index cb4ed3a243d..8ea70898fe8 100644 --- a/src/client.rs +++ b/src/client.rs @@ -33,7 +33,7 @@ use crate::{ create_symmetric_key_msg, decode_id_pk, get_rs_pk, is_keyboard_mode_supported, kcp_stream::KcpStream, secure_tcp, - ui_interface::{get_builtin_option, use_texture_render}, + ui_interface::{get_builtin_option, resolve_avatar_url, use_texture_render}, ui_session_interface::{InvokeUiSession, Session}, }; #[cfg(feature = "unix-file-copy-paste")] @@ -2625,6 +2625,20 @@ impl LoginConfigHandler { } else { (my_id, self.id.clone()) }; + let mut avatar = get_builtin_option(keys::OPTION_AVATAR); + if avatar.is_empty() { + avatar = serde_json::from_str::(&LocalConfig::get_option( + "user_info", + )) + .ok() + .and_then(|x| { + x.get("avatar") + .and_then(|x| x.as_str()) + .map(|x| x.trim().to_owned()) + }) + .unwrap_or_default(); + } + avatar = resolve_avatar_url(avatar); let mut display_name = get_builtin_option(keys::OPTION_DISPLAY_NAME); if display_name.is_empty() { display_name = @@ -2684,6 +2698,7 @@ impl LoginConfigHandler { }) .into(), hwid, + avatar, ..Default::default() }; match self.conn_type { diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index ed13a76246b..551ad799fbf 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1101,6 +1101,10 @@ pub fn main_get_api_server() -> String { get_api_server() } +pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn { + SyncReturn(resolve_avatar_url(avatar)) +} + pub fn main_http_request(url: String, method: String, body: Option, header: String) { http_request(url, method, body, header) } diff --git a/src/hbbs_http/account.rs b/src/hbbs_http/account.rs index 6644aee2829..8e614120062 100644 --- a/src/hbbs_http/account.rs +++ b/src/hbbs_http/account.rs @@ -17,6 +17,7 @@ lazy_static::lazy_static! { const QUERY_INTERVAL_SECS: f32 = 1.0; const QUERY_TIMEOUT_SECS: u64 = 60 * 3; + const REQUESTING_ACCOUNT_AUTH: &str = "Requesting account auth"; const WAITING_ACCOUNT_AUTH: &str = "Waiting account auth"; const LOGIN_ACCOUNT_AUTH: &str = "Login account auth"; @@ -82,6 +83,8 @@ pub struct UserPayload { #[serde(default)] pub display_name: Option, #[serde(default)] + pub avatar: Option, + #[serde(default)] pub email: Option, #[serde(default)] pub note: Option, @@ -273,6 +276,7 @@ impl OidcSession { serde_json::json!({ "name": auth_body.user.name, "display_name": auth_body.user.display_name, + "avatar": auth_body.user.avatar, "status": auth_body.user.status }) .to_string(), diff --git a/src/ipc.rs b/src/ipc.rs index a5d27ba8a0d..891ec81dd38 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -226,6 +226,7 @@ pub enum Data { is_terminal: bool, peer_id: String, name: String, + avatar: String, authorized: bool, port_forward: String, keyboard: bool, @@ -1583,6 +1584,6 @@ mod test { #[test] fn verify_ffi_enum_data_size() { println!("{}", std::mem::size_of::()); - assert!(std::mem::size_of::() <= 96); + assert!(std::mem::size_of::() <= 120); } } diff --git a/src/server/connection.rs b/src/server/connection.rs index 033aac0ce79..1ffb1a25e3b 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -1877,6 +1877,7 @@ impl Connection { port_forward: self.port_forward_address.clone(), peer_id, name, + avatar: self.lr.avatar.clone(), authorized, keyboard: self.keyboard, clipboard: self.clipboard, diff --git a/src/ui/cm.css b/src/ui/cm.css index baa7743099e..ba6de887baf 100644 --- a/src/ui/cm.css +++ b/src/ui/cm.css @@ -57,6 +57,11 @@ div.icon { font-weight: bold; } +img.icon { + size: 96px; + border-radius: 8px; +} + div.id { @ELLIPSIS; color: color(green-blue); diff --git a/src/ui/cm.rs b/src/ui/cm.rs index 92cd2e2f22b..15b7b943515 100644 --- a/src/ui/cm.rs +++ b/src/ui/cm.rs @@ -28,6 +28,7 @@ impl InvokeUiCM for SciterHandler { client.port_forward.clone(), client.peer_id.clone(), client.name.clone(), + client.avatar.clone(), client.authorized, client.keyboard, client.clipboard, diff --git a/src/ui/cm.tis b/src/ui/cm.tis index 0b0165b7374..a06fb9ff889 100644 --- a/src/ui/cm.tis +++ b/src/ui/cm.tis @@ -42,9 +42,11 @@ class Body: Reactor.Component return
+ {c.avatar ? + :
{c.name[0].toUpperCase()} -
+
}
{c.name}
({c.peer_id})
@@ -366,7 +368,7 @@ function bring_to_top(idx=-1) { } } -handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { +handler.addConnection = function(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input) { stdout.println("new connection #" + id + ": " + peer_id); var conn; connections.map(function(c) { @@ -385,6 +387,7 @@ handler.addConnection = function(id, is_file_transfer, is_view_camera, is_termin conn = { id: id, is_file_transfer: is_file_transfer, is_view_camera: is_view_camera, is_terminal: is_terminal, peer_id: peer_id, port_forward: port_forward, + avatar: avatar, name: name, authorized: authorized, time: new Date(), now: new Date(), keyboard: keyboard, clipboard: clipboard, msgs: [], unreaded: 0, audio: audio, file: file, restart: restart, recording: recording, diff --git a/src/ui/index.tis b/src/ui/index.tis index edd69312e51..5853fe3e227 100644 --- a/src/ui/index.tis +++ b/src/ui/index.tis @@ -1451,6 +1451,9 @@ function set_local_user_info(user) { if (user.display_name) { user_info.display_name = user.display_name; } + if (user.avatar) { + user_info.avatar = user.avatar; + } if (user.status) { user_info.status = user.status; } diff --git a/src/ui_cm_interface.rs b/src/ui_cm_interface.rs index 4e688429f98..75e72400729 100644 --- a/src/ui_cm_interface.rs +++ b/src/ui_cm_interface.rs @@ -134,6 +134,7 @@ pub struct Client { pub is_terminal: bool, pub port_forward: String, pub name: String, + pub avatar: String, pub peer_id: String, pub keyboard: bool, pub clipboard: bool, @@ -220,6 +221,7 @@ impl ConnectionManager { port_forward: String, peer_id: String, name: String, + avatar: String, authorized: bool, keyboard: bool, clipboard: bool, @@ -240,6 +242,7 @@ impl ConnectionManager { is_terminal, port_forward, name: name.clone(), + avatar, peer_id: peer_id.clone(), keyboard, clipboard, @@ -500,9 +503,9 @@ impl IpcTaskRunner { } Ok(Some(data)) => { match data { - Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { + Data::Login{id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, file_transfer_enabled: _file_transfer_enabled, restart, recording, block_input, from_switch} => { log::debug!("conn_id: {}", id); - self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); + self.cm.add_connection(id, is_file_transfer, is_view_camera, is_terminal, port_forward, peer_id, name, avatar, authorized, keyboard, clipboard, audio, file, restart, recording, block_input, from_switch, self.tx.clone()); self.conn_id = id; #[cfg(target_os = "windows")] { @@ -823,6 +826,7 @@ pub async fn start_listen( port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, @@ -843,6 +847,7 @@ pub async fn start_listen( port_forward, peer_id, name, + avatar, authorized, keyboard, clipboard, diff --git a/src/ui_interface.rs b/src/ui_interface.rs index c5f158c9de4..49098f2dbfd 100644 --- a/src/ui_interface.rs +++ b/src/ui_interface.rs @@ -245,7 +245,20 @@ pub fn get_builtin_option(key: &str) -> String { #[inline] pub fn set_local_option(key: String, value: String) { - LocalConfig::set_option(key.clone(), value.clone()); + LocalConfig::set_option(key.clone(), value); +} + +/// Resolve relative avatar path (e.g. "/avatar/xxx") to absolute URL +/// by prepending the API server address. +pub fn resolve_avatar_url(avatar: String) -> String { + let avatar = avatar.trim().to_owned(); + if avatar.starts_with('/') { + let api_server = get_api_server(); + if !api_server.is_empty() { + return format!("{}{}", api_server.trim_end_matches('/'), avatar); + } + } + avatar } #[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))] From 1abc897c451c8b5bbff3792509a7fef9d12f2ce3 Mon Sep 17 00:00:00 2001 From: 21pages Date: Thu, 5 Mar 2026 12:30:40 +0800 Subject: [PATCH 16/16] fix avatar fallback (#14458) * fix avatar fallback Signed-off-by: 21pages * fix(ui): improve avatar fallback handling and layout consistency - Always show spacing in account section regardless of avatar presence - Handle null return from buildAvatarWidget with proper fallback - Adjust mobile settings avatar size to 28 Signed-off-by: 21pages --------- Signed-off-by: 21pages --- flutter/lib/desktop/pages/desktop_setting_page.dart | 2 +- flutter/lib/desktop/pages/server_page.dart | 11 ++++++----- flutter/lib/mobile/pages/server_page.dart | 12 ++++++------ flutter/lib/mobile/pages/settings_page.dart | 12 ++++++++---- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/flutter/lib/desktop/pages/desktop_setting_page.dart b/flutter/lib/desktop/pages/desktop_setting_page.dart index bde40cf19e0..82212d19198 100644 --- a/flutter/lib/desktop/pages/desktop_setting_page.dart +++ b/flutter/lib/desktop/pages/desktop_setting_page.dart @@ -2039,7 +2039,7 @@ class _AccountState extends State<_Account> { return Row( children: [ if (avatarWidget != null) avatarWidget, - if (avatarWidget != null) const SizedBox(width: 12), + const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/flutter/lib/desktop/pages/server_page.dart b/flutter/lib/desktop/pages/server_page.dart index ea37c95e4ba..7d48452a81d 100644 --- a/flutter/lib/desktop/pages/server_page.dart +++ b/flutter/lib/desktop/pages/server_page.dart @@ -569,11 +569,12 @@ class _CmHeaderState extends State<_CmHeader> Widget _buildClientAvatar() { return buildAvatarWidget( - avatar: client.avatar, - size: 70, - borderRadius: 15, - fallback: _buildInitialAvatar(), - )!; + avatar: client.avatar, + size: 70, + borderRadius: 15, + fallback: _buildInitialAvatar(), + ) ?? + _buildInitialAvatar(); } Widget _buildInitialAvatar() { diff --git a/flutter/lib/mobile/pages/server_page.dart b/flutter/lib/mobile/pages/server_page.dart index d0a7b573ed8..54406ff2edc 100644 --- a/flutter/lib/mobile/pages/server_page.dart +++ b/flutter/lib/mobile/pages/server_page.dart @@ -857,16 +857,16 @@ class ClientInfo extends StatelessWidget { Widget _buildAvatar(BuildContext context) { final fallback = CircleAvatar( - backgroundColor: str2color( - client.name, + backgroundColor: str2color(client.name, Theme.of(context).brightness == Brightness.light ? 255 : 150), child: Text(client.name.isNotEmpty ? client.name[0] : '?'), ); return buildAvatarWidget( - avatar: client.avatar, - size: 40, - fallback: fallback, - )!; + avatar: client.avatar, + size: 40, + fallback: fallback, + ) ?? + fallback; } } diff --git a/flutter/lib/mobile/pages/settings_page.dart b/flutter/lib/mobile/pages/settings_page.dart index e047344aeda..5092606361e 100644 --- a/flutter/lib/mobile/pages/settings_page.dart +++ b/flutter/lib/mobile/pages/settings_page.dart @@ -617,7 +617,7 @@ class _SettingsState extends State with WidgetsBindingObserver { onToggle: (bool v) async { await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v); final newValue = - mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); + mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys); setState(() { _showTerminalExtraKeys = newValue; }); @@ -694,7 +694,9 @@ class _SettingsState extends State with WidgetsBindingObserver { avatar: gFFI.userModel.avatar.value); return buildAvatarWidget( avatar: avatar, - size: 40, + size: 28, + borderRadius: null, + fallback: Icon(Icons.person), ) ?? Icon(Icons.person); }), @@ -837,10 +839,12 @@ class _SettingsState extends State with WidgetsBindingObserver { ), if (!incomingOnly) SettingsTile.switchTile( - title: Text(translate('keep-awake-during-outgoing-sessions-label')), + title: + Text(translate('keep-awake-during-outgoing-sessions-label')), initialValue: _preventSleepWhileConnected, onToggle: (v) async { - await mainSetLocalBoolOption(kOptionKeepAwakeDuringOutgoingSessions, v); + await mainSetLocalBoolOption( + kOptionKeepAwakeDuringOutgoingSessions, v); setState(() { _preventSleepWhileConnected = v; });