Compare commits

..

5 Commits

Author SHA1 Message Date
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
17 changed files with 244 additions and 165 deletions

View File

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

View File

@@ -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,
@@ -2061,7 +2061,8 @@ class _AccountState extends State<_Account> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.bodySmall?.color,
color:
Theme.of(context).textTheme.bodySmall?.color,
),
),
),
@@ -2076,28 +2077,13 @@ class _AccountState extends State<_Account> {
}
Widget? _buildUserAvatar() {
final avatar = gFFI.userModel.avatar.value.trim();
if (avatar.isEmpty) return null;
const radius = 22.0;
if (avatar.startsWith('data:image/')) {
final comma = avatar.indexOf(',');
if (comma > 0) {
try {
return CircleAvatar(
radius: radius,
backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))),
);
} catch (_) {
return null;
}
}
} else if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
return CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatar),
);
}
return null;
// Resolve relative avatar path at display time
final avatar =
bind.mainResolveAvatarUrl(avatar: gFFI.userModel.avatar.value);
return buildAvatarWidget(
avatar: avatar,
size: 44,
);
}
}

View File

@@ -1,7 +1,6 @@
// original cm window in Sciter version.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
@@ -569,37 +568,13 @@ class _CmHeaderState extends State<_CmHeader>
bool get wantKeepAlive => true;
Widget _buildClientAvatar() {
const borderRadius = BorderRadius.all(Radius.circular(15.0));
final avatar = client.avatar.trim();
if (avatar.startsWith('data:image/')) {
final comma = avatar.indexOf(',');
if (comma > 0) {
try {
final bytes = base64Decode(avatar.substring(comma + 1));
return ClipRRect(
borderRadius: borderRadius,
child: Image.memory(
bytes,
width: 70,
height: 70,
fit: BoxFit.cover,
),
);
} catch (_) {}
}
} else if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
return ClipRRect(
borderRadius: borderRadius,
child: Image.network(
avatar,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildInitialAvatar(),
),
);
}
return _buildInitialAvatar();
return buildAvatarWidget(
avatar: client.avatar,
size: 70,
borderRadius: 15,
fallback: _buildInitialAvatar(),
) ??
_buildInitialAvatar();
}
Widget _buildInitialAvatar() {
@@ -612,7 +587,7 @@ class _CmHeaderState extends State<_CmHeader>
borderRadius: BorderRadius.circular(15.0),
),
child: Text(
client.name[0],
client.name.isNotEmpty ? client.name[0] : '?',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.white,

View File

@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -857,28 +856,17 @@ class ClientInfo extends StatelessWidget {
}
Widget _buildAvatar(BuildContext context) {
final avatar = client.avatar.trim();
if (avatar.isNotEmpty) {
if (avatar.startsWith('data:image/')) {
final comma = avatar.indexOf(',');
if (comma > 0) {
try {
return CircleAvatar(
backgroundImage: MemoryImage(base64Decode(avatar.substring(comma + 1))),
);
} catch (_) {}
}
} else if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
return CircleAvatar(backgroundImage: NetworkImage(avatar));
}
}
// Show character as before if no avatar
return CircleAvatar(
backgroundColor: str2color(
client.name,
final fallback = CircleAvatar(
backgroundColor: str2color(client.name,
Theme.of(context).brightness == Brightness.light ? 255 : 150),
child: Text(client.name[0]),
child: Text(client.name.isNotEmpty ? client.name[0] : '?'),
);
return buildAvatarWidget(
avatar: client.avatar,
size: 40,
fallback: fallback,
) ??
fallback;
}
}

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

@@ -34,6 +34,7 @@ class UserModel {
}
return '$preferred (@$username)';
}
WeakReference<FFI> parent;
UserModel(this.parent) {
@@ -88,7 +89,6 @@ class UserModel {
}
final user = UserPayload.fromJson(data);
user.avatar = _resolveAvatar(user.avatar, url);
_parseAndUpdateUser(user);
} catch (e) {
debugPrint('Failed to refreshCurrentUser: $e');
@@ -138,7 +138,6 @@ class UserModel {
avatar.value = user.avatar;
isAdmin.value = user.isAdmin;
bind.mainSetLocalOption(key: 'user_info', value: jsonEncode(user));
_updateLocalUserInfo();
if (isWeb) {
// ugly here, tmp solution
bind.mainSetLocalOption(key: 'verifier', value: user.verifier ?? '');

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

@@ -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")]
@@ -2638,6 +2638,7 @@ impl LoginConfigHandler {
})
.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 =

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

View File

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

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

View File

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

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) {
@@ -2179,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 {
@@ -2236,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) => {
@@ -2277,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;
@@ -2313,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;
@@ -2331,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(),
@@ -2382,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(),
@@ -5348,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

@@ -245,39 +245,20 @@ pub fn get_builtin_option(key: &str) -> String {
#[inline]
pub fn set_local_option(key: String, value: String) {
let value = normalize_local_option_value(&key, value);
LocalConfig::set_option(key.clone(), value);
}
fn normalize_local_option_value(key: &str, value: String) -> String {
if key != "user_info" || value.is_empty() {
return 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);
}
}
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(&value) else {
return value;
};
let Some(obj) = v.as_object_mut() else {
return value;
};
let Some(avatar) = obj
.get("avatar")
.and_then(|x| x.as_str())
.map(|x| x.trim().to_owned())
else {
return value;
};
if !avatar.starts_with('/') {
return value;
}
let api_server = get_api_server();
if api_server.is_empty() {
return value;
}
obj.insert(
"avatar".to_owned(),
serde_json::Value::String(format!("{}{}", api_server.trim_end_matches('/'), avatar)),
);
serde_json::to_string(&v).unwrap_or(value)
avatar
}
#[cfg(any(target_os = "android", target_os = "ios", feature = "flutter"))]