This commit is contained in:
Pone Ding 2026-04-21 08:32:53 +08:00 committed by GitHub
commit 5a763dbbfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 282 additions and 79 deletions

View file

@ -852,6 +852,22 @@ List<TToggleMenu> toolbarKeyboardToggles(FFI ffi) {
child: Text(translate('Swap control-command key'))));
}
if (ffiModel.keyboard && isMacOS && pi.platform != kPeerPlatformMacOS) {
final option = 'allow_swap_option_command_key';
final value =
bind.sessionGetToggleOptionSync(sessionId: sessionId, arg: option);
onChanged(bool? value) {
if (value == null) return;
bind.sessionToggleOption(sessionId: sessionId, value: option);
}
final enabled = !ffi.ffiModel.viewOnly;
v.add(TToggleMenu(
value: value,
onChanged: enabled ? onChanged : null,
child: Text(translate('Swap option-command key'))));
}
// Relative mouse mode (gaming mode).
// Only show when server supports MOUSE_TYPE_MOVE_RELATIVE (version >= 1.4.5)
// Note: This feature is only available in Flutter client. Sciter client does not support this.

View file

@ -121,11 +121,9 @@ pub const LOGIN_SCREEN_WAYLAND: &str = "Wayland login screen is not supported";
#[cfg(target_os = "linux")]
pub const SCRAP_UBUNTU_HIGHER_REQUIRED: &str = "ubuntu-21-04-required";
#[cfg(target_os = "linux")]
pub const SCRAP_OTHER_VERSION_OR_X11_REQUIRED: &str =
"wayland-requires-higher-linux-version";
pub const SCRAP_OTHER_VERSION_OR_X11_REQUIRED: &str = "wayland-requires-higher-linux-version";
#[cfg(target_os = "linux")]
pub const SCRAP_XDP_PORTAL_UNAVAILABLE: &str =
"xdp-portal-unavailable";
pub const SCRAP_XDP_PORTAL_UNAVAILABLE: &str = "xdp-portal-unavailable";
pub const SCRAP_X11_REQUIRED: &str = "x11 expected";
pub const SCRAP_X11_REF_URL: &str = "https://rustdesk.com/docs/en/manual/linux/#x11-required";
@ -1760,6 +1758,43 @@ pub struct LoginConfigHandler {
pub record_permission: bool,
}
const OPTION_SWAP_OPTION_COMMAND_KEY: &str = "allow_swap_option_command_key";
fn get_option_command_swap_enabled(config: &PeerConfig) -> bool {
config::option2bool(
OPTION_SWAP_OPTION_COMMAND_KEY,
config
.options
.get(OPTION_SWAP_OPTION_COMMAND_KEY)
.map(String::as_str)
.unwrap_or(""),
)
}
fn set_option_command_swap_toggle(config: &mut PeerConfig, enabled: bool) {
if enabled {
config
.options
.insert(OPTION_SWAP_OPTION_COMMAND_KEY.to_owned(), "Y".to_owned());
} else {
config.options.remove(OPTION_SWAP_OPTION_COMMAND_KEY);
}
}
fn set_control_command_swap_enabled(config: &mut PeerConfig, enabled: bool) {
config.allow_swap_key.v = enabled;
if enabled {
set_option_command_swap_toggle(config, false);
}
}
fn set_option_command_swap_enabled(config: &mut PeerConfig, enabled: bool) {
set_option_command_swap_toggle(config, enabled);
if enabled {
config.allow_swap_key.v = false;
}
}
impl Deref for LoginConfigHandler {
type Target = PeerConfig;
@ -2124,7 +2159,11 @@ impl LoginConfigHandler {
} else if name == "show-quality-monitor" {
config.show_quality_monitor.v = !config.show_quality_monitor.v;
} else if name == "allow_swap_key" {
config.allow_swap_key.v = !config.allow_swap_key.v;
let enabled = !config.allow_swap_key.v;
set_control_command_swap_enabled(&mut config, enabled);
} else if name == "allow_swap_option_command_key" {
let enabled = !get_option_command_swap_enabled(&config);
set_option_command_swap_enabled(&mut config, enabled);
} else if name == "view-only" {
config.view_only.v = !config.view_only.v;
let f = |b: bool| {
@ -2336,6 +2375,8 @@ impl LoginConfigHandler {
self.config.show_quality_monitor.v
} else if name == "allow_swap_key" {
self.config.allow_swap_key.v
} else if name == "allow_swap_option_command_key" {
get_option_command_swap_enabled(&self.config)
} else if name == "view-only" {
self.config.view_only.v
} else if name == "show-my-cursor" {
@ -2630,16 +2671,15 @@ impl LoginConfigHandler {
};
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 =
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);
@ -4055,8 +4095,42 @@ pub mod peer_online {
#[cfg(test)]
mod tests {
use crate::client::{set_control_command_swap_enabled, set_option_command_swap_enabled};
use hbb_common::config::PeerConfig;
use hbb_common::tokio;
#[test]
fn enable_option_command_swap_stores_option_and_disables_control_command_swap() {
let mut config = PeerConfig {
allow_swap_key: hbb_common::config::AllowSwapKey { v: true },
..Default::default()
};
set_option_command_swap_enabled(&mut config, true);
assert_eq!(
config
.options
.get("allow_swap_option_command_key")
.map(String::as_str),
Some("Y")
);
assert!(!config.allow_swap_key.v);
}
#[test]
fn enable_control_command_swap_disables_option_command_swap() {
let mut config = PeerConfig::default();
config
.options
.insert("allow_swap_option_command_key".to_owned(), "Y".to_owned());
set_control_command_swap_enabled(&mut config, true);
assert!(config.allow_swap_key.v);
assert!(!config.options.contains_key("allow_swap_option_command_key"));
}
#[tokio::test]
async fn test_query_onlines() {
super::query_online_states(

View file

@ -568,6 +568,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input_source_1_tip", "输入源 1"),
("input_source_2_tip", "输入源 2"),
("Swap control-command key", "交换 Control 键和 Command 键"),
("Swap option-command key", "交换 Option 键和 Command 键"),
("swap-left-right-mouse", "交换鼠标左右键"),
("2FA code", "双重认证代码"),
("More", "更多"),

View file

@ -568,6 +568,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input_source_1_tip", ""),
("input_source_2_tip", ""),
("Swap control-command key", ""),
("Swap option-command key", ""),
("swap-left-right-mouse", ""),
("2FA code", ""),
("More", ""),

View file

@ -568,6 +568,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("input_source_1_tip", "輸入源 1"),
("input_source_2_tip", "輸入源 2"),
("Swap control-command key", "交換 Control 和 Command 按鍵"),
("Swap option-command key", "交換 Option 和 Command 按鍵"),
("swap-left-right-mouse", "交換滑鼠左右鍵"),
("2FA code", "二步驟驗證碼"),
("More", "更多"),

View file

@ -220,6 +220,7 @@ class Header: Reactor.Component {
{keyboard_enabled ? <li #lock-after-session-end .toggle-option><span>{svg_checkmark}</span>{translate('Lock after session end')}</li> : ""}
{keyboard_enabled && pi.platform == "Windows" ? <li #privacy-mode><span>{svg_checkmark}</span>{translate('Privacy mode')}</li> : ""}
{keyboard_enabled && ((is_osx && pi.platform != "Mac OS") || (!is_osx && pi.platform == "Mac OS")) ? <li #allow_swap_key .toggle-option><span>{svg_checkmark}</span>{translate('Swap control-command key')}</li> : ""}
{keyboard_enabled && is_osx && pi.platform != "Mac OS" ? <li #allow_swap_option_command_key .toggle-option><span>{svg_checkmark}</span>{translate('Swap option-command key')}</li> : ""}
{handler.version_cmp(pi.version, '1.2.4') >= 0 ? <li #i444><span>{svg_checkmark}</span>{translate('True color (4:4:4)')}</li> : ""}
</menu>
</popup>;
@ -489,7 +490,7 @@ function toggleMenuState() {
for (var el in $$(menu#keyboard-options>li)) {
el.attributes.toggleClass("selected", values.indexOf(el.id) >= 0);
}
for (var id in ["show-remote-cursor", "follow-remote-cursor", "follow-remote-window", "show-quality-monitor", "disable-audio", "enable-file-copy-paste", "disable-clipboard", "lock-after-session-end", "allow_swap_key", "i444"]) {
for (var id in ["show-remote-cursor", "follow-remote-cursor", "follow-remote-window", "show-quality-monitor", "disable-audio", "enable-file-copy-paste", "disable-clipboard", "lock-after-session-end", "allow_swap_key", "allow_swap_option_command_key", "i444"]) {
var el = self.select('#' + id);
if (el) {
var value = handler.get_toggle_option(id);

View file

@ -169,6 +169,82 @@ impl ChangeDisplayRecord {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ModifierSwapMode {
None,
ControlCommand,
OptionCommand,
}
fn modifier_swap_mode(
control_command_enabled: bool,
option_command_enabled: bool,
) -> ModifierSwapMode {
if option_command_enabled {
ModifierSwapMode::OptionCommand
} else if control_command_enabled {
ModifierSwapMode::ControlCommand
} else {
ModifierSwapMode::None
}
}
fn swap_control_command_control_key(ck: ControlKey) -> ControlKey {
match ck {
ControlKey::Control => ControlKey::Meta,
ControlKey::Meta => ControlKey::Control,
ControlKey::RControl => ControlKey::Meta,
ControlKey::RWin => ControlKey::Control,
_ => ck,
}
}
fn swap_option_command_control_key(ck: ControlKey) -> ControlKey {
match ck {
ControlKey::Alt => ControlKey::Meta,
ControlKey::Meta => ControlKey::Alt,
ControlKey::RAlt => ControlKey::Meta,
ControlKey::RWin => ControlKey::Alt,
_ => ck,
}
}
fn remap_control_key_for_mode(mode: ModifierSwapMode, ck: ControlKey) -> ControlKey {
match mode {
ModifierSwapMode::None => ck,
ModifierSwapMode::ControlCommand => swap_control_command_control_key(ck),
ModifierSwapMode::OptionCommand => swap_option_command_control_key(ck),
}
}
fn swap_control_command_rdev_key(key: rdev::Key) -> rdev::Key {
match key {
rdev::Key::ControlLeft => rdev::Key::MetaLeft,
rdev::Key::MetaLeft => rdev::Key::ControlLeft,
rdev::Key::ControlRight => rdev::Key::MetaLeft,
rdev::Key::MetaRight => rdev::Key::ControlLeft,
_ => key,
}
}
fn swap_option_command_rdev_key(key: rdev::Key) -> rdev::Key {
match key {
rdev::Key::Alt => rdev::Key::MetaLeft,
rdev::Key::MetaLeft => rdev::Key::Alt,
rdev::Key::AltGr => rdev::Key::MetaLeft,
rdev::Key::MetaRight => rdev::Key::Alt,
_ => key,
}
}
fn remap_rdev_key_for_mode(mode: ModifierSwapMode, key: rdev::Key) -> rdev::Key {
match mode {
ModifierSwapMode::None => key,
ModifierSwapMode::ControlCommand => swap_control_command_rdev_key(key),
ModifierSwapMode::OptionCommand => swap_option_command_rdev_key(key),
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
impl SessionPermissionConfig {
pub fn is_text_clipboard_required(&self) -> bool {
@ -186,6 +262,13 @@ impl SessionPermissionConfig {
}
impl<T: InvokeUiSession> Session<T> {
fn modifier_swap_mode(&self) -> ModifierSwapMode {
modifier_swap_mode(
self.get_toggle_option("allow_swap_key".to_string()),
self.get_toggle_option("allow_swap_option_command_key".to_string()),
)
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub fn get_permission_config(&self) -> SessionPermissionConfig {
SessionPermissionConfig {
@ -688,32 +771,20 @@ impl<T: InvokeUiSession> Session<T> {
}
pub fn swap_modifier_key(&self, msg: &mut KeyEvent) {
let allow_swap_key = self.get_toggle_option("allow_swap_key".to_string());
if allow_swap_key {
let mode = self.modifier_swap_mode();
if mode != ModifierSwapMode::None {
if let Some(key_event::Union::ControlKey(ck)) = msg.union {
let ck = ck.enum_value_or_default();
let ck = match ck {
ControlKey::Control => ControlKey::Meta,
ControlKey::Meta => ControlKey::Control,
ControlKey::RControl => ControlKey::Meta,
ControlKey::RWin => ControlKey::Control,
_ => ck,
};
let ck = remap_control_key_for_mode(mode, ck.enum_value_or_default());
msg.set_control_key(ck);
}
msg.modifiers = msg
.modifiers
.iter()
.map(|ck| {
let ck = ck.enum_value_or_default();
let ck = match ck {
ControlKey::Control => ControlKey::Meta,
ControlKey::Meta => ControlKey::Control,
ControlKey::RControl => ControlKey::Meta,
ControlKey::RWin => ControlKey::Control,
_ => ck,
};
hbb_common::protobuf::EnumOrUnknown::new(ck)
hbb_common::protobuf::EnumOrUnknown::new(remap_control_key_for_mode(
mode,
ck.enum_value_or_default(),
))
})
.collect();
@ -723,39 +794,21 @@ impl<T: InvokeUiSession> Session<T> {
peer.retain(|c| !c.is_whitespace());
let key = match peer.as_str() {
"windows" => {
let key = rdev::win_key_from_scancode(code);
let key = match key {
rdev::Key::ControlLeft => rdev::Key::MetaLeft,
rdev::Key::MetaLeft => rdev::Key::ControlLeft,
rdev::Key::ControlRight => rdev::Key::MetaLeft,
rdev::Key::MetaRight => rdev::Key::ControlLeft,
_ => key,
};
rdev::win_scancode_from_key(key).unwrap_or_default()
}
"macos" => {
let key = rdev::macos_key_from_code(code as _);
let key = match key {
rdev::Key::ControlLeft => rdev::Key::MetaLeft,
rdev::Key::MetaLeft => rdev::Key::ControlLeft,
rdev::Key::ControlRight => rdev::Key::MetaLeft,
rdev::Key::MetaRight => rdev::Key::ControlLeft,
_ => key,
};
rdev::macos_keycode_from_key(key).unwrap_or_default() as _
}
_ => {
let key = rdev::linux_key_from_code(code);
let key = match key {
rdev::Key::ControlLeft => rdev::Key::MetaLeft,
rdev::Key::MetaLeft => rdev::Key::ControlLeft,
rdev::Key::ControlRight => rdev::Key::MetaLeft,
rdev::Key::MetaRight => rdev::Key::ControlLeft,
_ => key,
};
rdev::linux_keycode_from_key(key).unwrap_or_default()
}
"windows" => rdev::win_scancode_from_key(remap_rdev_key_for_mode(
mode,
rdev::win_key_from_scancode(code),
))
.unwrap_or_default(),
"macos" => rdev::macos_keycode_from_key(remap_rdev_key_for_mode(
mode,
rdev::macos_key_from_code(code as _),
))
.unwrap_or_default() as _,
_ => rdev::linux_keycode_from_key(remap_rdev_key_for_mode(
mode,
rdev::linux_key_from_code(code),
))
.unwrap_or_default(),
};
msg.set_chr(key);
}
@ -1889,21 +1942,16 @@ impl<T: InvokeUiSession> Interface for Session<T> {
}
fn swap_modifier_mouse(&self, msg: &mut hbb_common::protos::message::MouseEvent) {
let allow_swap_key = self.get_toggle_option("allow_swap_key".to_string());
if allow_swap_key {
let mode = self.modifier_swap_mode();
if mode != ModifierSwapMode::None {
msg.modifiers = msg
.modifiers
.iter()
.map(|ck| {
let ck = ck.enum_value_or_default();
let ck = match ck {
ControlKey::Control => ControlKey::Meta,
ControlKey::Meta => ControlKey::Control,
ControlKey::RControl => ControlKey::Meta,
ControlKey::RWin => ControlKey::Control,
_ => ck,
};
hbb_common::protobuf::EnumOrUnknown::new(ck)
hbb_common::protobuf::EnumOrUnknown::new(remap_control_key_for_mode(
mode,
ck.enum_value_or_default(),
))
})
.collect();
};
@ -2053,3 +2101,64 @@ async fn send_note(url: String, id: String, sid: u64, note: String) {
let body = serde_json::json!({ "id": id, "session_id": sid, "note": note });
allow_err!(crate::post_request(url, body.to_string(), "").await);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modifier_swap_mode_prefers_option_command_when_both_enabled() {
assert_eq!(
modifier_swap_mode(true, true),
ModifierSwapMode::OptionCommand
);
}
#[test]
fn swap_option_command_maps_alt_to_meta() {
assert_eq!(
swap_option_command_control_key(ControlKey::Alt),
ControlKey::Meta
);
}
#[test]
fn swap_option_command_maps_meta_to_alt() {
assert_eq!(
swap_option_command_control_key(ControlKey::Meta),
ControlKey::Alt
);
}
#[test]
fn swap_option_command_maps_ralt_to_meta() {
assert_eq!(
swap_option_command_control_key(ControlKey::RAlt),
ControlKey::Meta
);
}
#[test]
fn swap_option_command_maps_rwin_to_alt() {
assert_eq!(
swap_option_command_control_key(ControlKey::RWin),
ControlKey::Alt
);
}
#[test]
fn swap_option_command_maps_alt_rdev_to_meta_left() {
assert_eq!(
swap_option_command_rdev_key(rdev::Key::Alt),
rdev::Key::MetaLeft
);
}
#[test]
fn swap_option_command_maps_meta_right_rdev_to_alt() {
assert_eq!(
swap_option_command_rdev_key(rdev::Key::MetaRight),
rdev::Key::Alt
);
}
}