Compare commits

...

13 Commits

Author SHA1 Message Date
fufesou
0d3016fcd8 fix(flutter): reduce accidental horizontal trackpad scrolling during vertical pan (#14460)
* fix(flutter): reduce accidental horizontal trackpad scrolling during vertical pan

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact: comments

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-05 23:10:39 +08:00
21pages
1abc897c45 fix avatar fallback (#14458)
* fix avatar fallback

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

* 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 <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-03-05 12:30:40 +08:00
RustDesk
ab64a32f30 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 <sunboeasy@gmail.com>

* web: implement mainResolveAvatarUrl via js getByName

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

* increase ipc Data enum size limit to 120 bytes

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

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
Co-authored-by: 21pages <sunboeasy@gmail.com>
2026-03-04 21:43:19 +08:00
RustDesk
52b66e71d1 Move port mapping afterwards (#14448)
* move port mapping after auth in port forwarding

* fix(port-forward): try connect after 2fa

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(security): gate port-forward connect on full auth and clarify login flow semantics

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(port-forward): comments and logs

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-03-04 15:48:42 +08:00
fufesou
41ab5bbdd8 fix(update): macos, test before update (#14446)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-03 10:47:32 +08:00
fufesou
732b250815 fix(keyboard): legacy mode (#14435)
* fix(keyboard): legacy mode

Signed-off-by: fufesou <linlong1266@gmail.com>

* Simple refactor

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(keyboard): legacy mode, chr to seq

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(keyboard): legacy mode, early return if (!hotkey)&down

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(keyboard): legacy mode, pair down/up

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-02 19:07:09 +08:00
rustdesk
157dbdc543 fix avatar in hbb_common 2026-03-02 12:14:26 +08:00
rustdesk
6ba23683d5 avatar in libs/hbb_comon 2026-03-02 12:06:20 +08:00
fufesou
80a5865db3 macOS update: restore LaunchAgent in GUI session and isolate temp update dir by euid (#14434)
* fix(update): macos, load agent

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(update): macos, isolate temp update dir by euid

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(update): macos script

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-01 20:06:04 +08:00
MichaIng
9cb6f38aea 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 <micha@dietpi.com>
2026-03-01 18:05:19 +08:00
fufesou
cd7e3e4505 fix(update): macos, input password (#14430)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-03-01 15:19:07 +08:00
fufesou
1833cb0655 fix(update): revert check (#14424)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-28 18:17:26 +08:00
fufesou
e4208aa9cf fix(update): revert check (#14423)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-28 16:33:54 +08:00
29 changed files with 466 additions and 171 deletions

View File

@@ -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<String, dynamic> evt) async {
@@ -4120,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(),
),
);
}

View File

@@ -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<String, dynamic> 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<String, dynamic> map = {
'name': name,
'display_name': displayName,
'avatar': avatar,
'status': status == UserStatus.kDisabled
? 0
: status == UserStatus.kUnverified

View File

@@ -430,12 +430,10 @@ class _DesktopHomePageState extends State<DesktopHomePage>
}
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 {

View File

@@ -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,
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 {

View File

@@ -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,36 @@ class _CmHeaderState extends State<_CmHeader>
@override
bool get wantKeepAlive => true;
Widget _buildClientAvatar() {
return buildAvatarWidget(
avatar: client.avatar,
size: 70,
borderRadius: 15,
fallback: _buildInitialAvatar(),
) ??
_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 {

View File

@@ -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,
) ??
fallback;
}
}
void androidChannelInit() {

View File

@@ -617,7 +617,7 @@ class _SettingsState extends State<SettingsPage> with WidgetsBindingObserver {
onToggle: (bool v) async {
await mainSetLocalBoolOption(kOptionEnableShowTerminalExtraKeys, v);
final newValue =
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
setState(() {
_showTerminalExtraKeys = newValue;
});
@@ -689,7 +689,17 @@ class _SettingsState extends State<SettingsPage> 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: 28,
borderRadius: null,
fallback: Icon(Icons.person),
) ??
Icon(Icons.person);
}),
onPressed: (context) {
if (gFFI.userModel.userName.value.isEmpty) {
loginDialog();
@@ -829,10 +839,12 @@ class _SettingsState extends State<SettingsPage> 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;
});

View File

@@ -348,6 +348,12 @@ class InputModel {
final _trackpadAdjustPeerLinux = 0.06;
// This is an experience value.
final _trackpadAdjustMacToWin = 2.50;
// Ignore directional locking for very small deltas on both axes (including
// tiny single-axis movement) to avoid over-filtering near zero.
static const double _trackpadAxisNoiseThreshold = 0.2;
// Lock to dominant axis only when one axis is clearly stronger.
// 1.6 means the dominant axis must be >= 60% larger than the other.
static const double _trackpadAxisLockRatio = 1.6;
int _trackpadSpeed = kDefaultTrackpadSpeed;
double _trackpadSpeedInner = kDefaultTrackpadSpeed / 100.0;
var _trackpadScrollUnsent = Offset.zero;
@@ -1172,6 +1178,7 @@ class InputModel {
if (isMacOS && peerPlatform == kPeerPlatformWindows) {
delta *= _trackpadAdjustMacToWin;
}
delta = _filterTrackpadDeltaAxis(delta);
_trackpadLastDelta = delta;
var x = delta.dx.toInt();
@@ -1204,6 +1211,24 @@ class InputModel {
}
}
Offset _filterTrackpadDeltaAxis(Offset delta) {
final absDx = delta.dx.abs();
final absDy = delta.dy.abs();
// Keep diagonal intent when movement is tiny on both axes.
if (absDx < _trackpadAxisNoiseThreshold &&
absDy < _trackpadAxisNoiseThreshold) {
return delta;
}
// Dominant-axis lock to reduce accidental cross-axis scrolling noise.
if (absDy >= absDx * _trackpadAxisLockRatio) {
return Offset(0, delta.dy);
}
if (absDx >= absDy * _trackpadAxisLockRatio) {
return Offset(delta.dx, 0);
}
return delta;
}
void _scheduleFling(double x, double y, int delay) {
if (isViewCamera) return;
if ((x == 0 && y == 0) || _stopFling) {

View File

@@ -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;

View File

@@ -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<FFI> 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) {

View File

@@ -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() {}
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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::<serde_json::Value>(&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 {

View File

@@ -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);

View File

@@ -1101,6 +1101,10 @@ pub fn main_get_api_server() -> String {
get_api_server()
}
pub fn main_resolve_avatar_url(avatar: String) -> SyncReturn<String> {
SyncReturn(resolve_avatar_url(avatar))
}
pub fn main_http_request(url: String, method: String, body: Option<String>, header: String) {
http_request(url, method, body, header)
}

View File

@@ -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<String>,
#[serde(default)]
pub avatar: Option<String>,
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub note: Option<String>,
@@ -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(),

View File

@@ -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::<Data>());
assert!(std::mem::size_of::<Data>() <= 96);
assert!(std::mem::size_of::<Data>() <= 120);
}
}

View File

@@ -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)
@@ -279,24 +286,12 @@ 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);
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);
}
}
}
};
@@ -415,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;
}
@@ -822,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();
}
@@ -851,32 +849,34 @@ 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 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;" & check_source & 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);
}
@@ -897,25 +897,28 @@ pgrep -x '{app_name}' | grep -v {pid} | xargs kill -9 && rm -rf '/Applications/{
}
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());

View File

@@ -1,18 +1,26 @@
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 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;"
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 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 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;" & 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

View File

@@ -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) {
@@ -1813,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,
@@ -2178,33 +2243,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 +2275,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 +2314,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 +2352,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 +2372,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 +2425,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 +5393,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();

View File

@@ -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

View File

@@ -57,6 +57,11 @@ div.icon {
font-weight: bold;
}
img.icon {
size: 96px;
border-radius: 8px;
}
div.id {
@ELLIPSIS;
color: color(green-blue);

View File

@@ -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,

View File

@@ -42,9 +42,11 @@ class Body: Reactor.Component
return <div .content style="size:*">
<div .left-panel>
<div .icon-and-id>
{c.avatar ?
<img .icon src={c.avatar} /> :
<div .icon style={"background: " + string2RGB(c.name, 1)}>
{c.name[0].toUpperCase()}
</div>
</div>}
<div>
<div .id style="font-weight: bold; font-size: 1.2em;">{c.name}</div>
<div .id>({c.peer_id})</div>
@@ -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,

View File

@@ -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;
}

View File

@@ -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<T: InvokeUiCM> ConnectionManager<T> {
port_forward: String,
peer_id: String,
name: String,
avatar: String,
authorized: bool,
keyboard: bool,
clipboard: bool,
@@ -240,6 +242,7 @@ impl<T: InvokeUiCM> ConnectionManager<T> {
is_terminal,
port_forward,
name: name.clone(),
avatar,
peer_id: peer_id.clone(),
keyboard,
clipboard,
@@ -500,9 +503,9 @@ impl<T: InvokeUiCM> IpcTaskRunner<T> {
}
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<T: InvokeUiCM>(
port_forward,
peer_id,
name,
avatar,
authorized,
keyboard,
clipboard,
@@ -843,6 +847,7 @@ pub async fn start_listen<T: InvokeUiCM>(
port_forward,
peer_id,
name,
avatar,
authorized,
keyboard,
clipboard,

View File

@@ -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"))]