feat: show my cursor (#12745)

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2025-08-28 15:20:01 +08:00
committed by GitHub
parent ac70f380a6
commit d0e9c6dc57
62 changed files with 1276 additions and 27 deletions

View File

@@ -2132,7 +2132,19 @@ impl LoginConfigHandler {
option.show_remote_cursor = f(self.get_toggle_option("show-remote-cursor"));
option.enable_file_transfer = f(self.config.enable_file_copy_paste.v);
option.lock_after_session_end = f(self.config.lock_after_session_end.v);
if config.show_my_cursor.v {
config.show_my_cursor.v = false;
option.show_my_cursor = BoolOption::No.into();
}
}
} else if name == "show-my-cursor" {
config.show_my_cursor.v = !config.show_my_cursor.v;
option.show_my_cursor = if config.show_my_cursor.v {
BoolOption::Yes
} else {
BoolOption::No
}
.into();
} else {
let is_set = self
.options
@@ -2225,6 +2237,9 @@ impl LoginConfigHandler {
if view_only || self.get_toggle_option("show-remote-cursor") {
msg.show_remote_cursor = BoolOption::Yes.into();
}
if view_only && self.get_toggle_option("show-my-cursor") {
msg.show_my_cursor = BoolOption::Yes.into();
}
if self.get_toggle_option("follow-remote-cursor") {
msg.follow_remote_cursor = BoolOption::Yes.into();
}
@@ -2309,6 +2324,8 @@ impl LoginConfigHandler {
self.config.allow_swap_key.v
} else if name == "view-only" {
self.config.view_only.v
} else if name == "show-my-cursor" {
self.config.show_my_cursor.v
} else if name == "follow-remote-cursor" {
self.config.follow_remote_cursor.v
} else if name == "follow-remote-window" {

View File

@@ -2040,6 +2040,22 @@ pub async fn get_ipv6_socket() -> Option<(Arc<UdpSocket>, bytes::Bytes)> {
None
}
// The color is the same to `str2color()` in flutter.
pub fn str2color(s: &str, alpha: u8) -> u32 {
let bytes = s.as_bytes();
// dart code `160 << 16 + 114 << 8 + 91` results `0`.
let mut hash: u32 = 0;
for &byte in bytes {
let code = byte as u32;
hash = code.wrapping_add((hash << 5).wrapping_sub(hash));
}
hash = hash % 16777216;
let rgb = hash & 0xFF7FFF;
(alpha as u32) << 24 | rgb
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -574,6 +574,12 @@ pub fn core_main() -> Option<Vec<String>> {
crate::flutter::connection_manager::start_cm_no_ui();
}
return None;
} else if args[0] == "--whiteboard" {
#[cfg(target_os = "windows")]
{
crate::whiteboard::run();
}
return None;
} else if args[0] == "-gtk-sudo" {
// rustdesk service kill `rustdesk --` processes
#[cfg(target_os = "linux")]

View File

@@ -177,7 +177,7 @@ pub enum DataPortableService {
Ping,
Pong,
ConnCount(Option<usize>),
Mouse((Vec<u8>, i32)),
Mouse((Vec<u8>, i32, String, u32, bool, bool)),
Pointer((Vec<u8>, i32)),
Key(Vec<u8>),
RequestStart,
@@ -289,6 +289,8 @@ pub enum Data {
#[cfg(target_os = "windows")]
PortForwardSessionCount(Option<usize>),
SocksWs(Option<Box<(Option<config::Socks5Server>, String)>>),
#[cfg(target_os = "windows")]
Whiteboard((String, crate::whiteboard::CustomEvent)),
}
#[tokio::main(flavor = "current_thread")]

View File

@@ -708,6 +708,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Failed to check if the user is an administrator.", "فشل التحقق مما إذا كان المستخدم لديه صلاحيات المسؤول."),
("Supported only in the installed version.", "مدعوم فقط في النسخة المُثبتة."),
("elevation_username_tip", "يرجى إدخال اسم مستخدم بصلاحيات المسؤول للمتابعة."),
("Preparing for installation ...", "جارٍ التحضير للتثبيت...")
("Preparing for installation ...", "جارٍ التحضير للتثبيت..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "仅在以安装版本受支持。"),
("elevation_username_tip", "输入用户名或域名\\用户名"),
("Preparing for installation ...", "准备安装..."),
("Show my cursor", "显示我的光标"),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Wird nur in der installierten Version unterstützt."),
("elevation_username_tip", "Geben Sie Benutzername oder Domäne\\Benutzername ein"),
("Preparing for installation ...", "Installation wird vorbereitet …"),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Soportado solo en la versión instalada."),
("elevation_username_tip", "Introduzca el nombre de usuario o dominio\\NombreDeUsuario"),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "فقط در نسخه نصب‌شده پشتیبانی می‌شود."),
("elevation_username_tip", "لطفاً نام کاربری مدیریتی را برای ارتقاء دسترسی وارد کنید."),
("Preparing for installation ...", "در حال آماده‌سازی برای نصب..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Uniquement pris en charge dans la version installée."),
("elevation_username_tip", "Saisissez un nom dutilisateur ou un domaine\\utilisateur"),
("Preparing for installation ...", "Préparation de linstallation…"),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "נתמך רק בגרסה המותקנת"),
("elevation_username_tip", "רמז_ליוזר_להעלאת_הרשאה"),
("Preparing for installation ...", "הכנה להתקנה..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Csak a telepített változatban támogatott."),
("elevation_username_tip", "Felhasználónév vagy tartománynév megadása\\felhasználónév"),
("Preparing for installation ...", "Felkészülés a telepítésre ..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Supportato solo nella versione installata."),
("elevation_username_tip", "Inserisci Nome utente o dominio sorgente\\nome Utente"),
("Preparing for installation ...", "Preparazione per l'installazione..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "インストールされたバージョンでのみサポートされます。"),
("elevation_username_tip", "ユーザー名またはドメインのユーザー名を入力してください。"),
("Preparing for installation ...", "インストールの準備中です..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "설치된 버전에서만 지원됩니다."),
("elevation_username_tip", "사용자 이름 또는 도메인\\사용자 이름 입력"),
("Preparing for installation ...", "설치 준비 중 ..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Alleen ondersteund in de geïnstalleerde versie."),
("elevation_username_tip", "Voer je gebruikersnaam of domeinnaam in"),
("Preparing for installation ...", "Installatie voorbereiden ..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Wspierane tylko dla zainstalowanej aplikacji."),
("elevation_username_tip", "Podaj nazwę użytkownika lub domena\\użytkownik"),
("Preparing for installation ...", "Przygotowywanie do instalacji ..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Поддерживается только в установочной версии."),
("elevation_username_tip", "Введите пользователя или домен\\пользователя"),
("Preparing for installation ...", "Подготовка к установке..."),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "Suportadu petzi in sa versione installada."),
("elevation_username_tip", "Inserta Nùmene utente o domìniu de fonte\\nùmene Utente"),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", "僅支援於已安裝的版本"),
("elevation_username_tip", "輸入使用者名稱或網域\\使用者名稱"),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -709,5 +709,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Supported only in the installed version.", ""),
("elevation_username_tip", ""),
("Preparing for installation ...", ""),
("Show my cursor", ""),
].iter().cloned().collect();
}

View File

@@ -55,6 +55,9 @@ pub mod plugin;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod tray;
#[cfg(target_os = "windows")]
mod whiteboard;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
mod updater;

View File

@@ -126,9 +126,18 @@ pub struct ConnInner {
tx_video: Option<Sender>,
}
struct InputMouse {
msg: MouseEvent,
conn_id: i32,
username: String,
argb: u32,
simulate: bool,
show_cursor: bool,
}
enum MessageInput {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Mouse((MouseEvent, i32)),
Mouse(InputMouse),
#[cfg(not(any(target_os = "android", target_os = "ios")))]
Key((KeyEvent, bool)),
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -225,6 +234,9 @@ pub struct Connection {
// by peer
disable_keyboard: bool,
// by peer
#[cfg(not(any(target_os = "android", target_os = "ios")))]
show_my_cursor: bool,
// by peer
disable_clipboard: bool,
// by peer
disable_audio: bool,
@@ -240,6 +252,7 @@ pub struct Connection {
server_audit_conn: String,
server_audit_file: String,
lr: LoginRequest,
peer_argb: u32,
session_last_recv_time: Option<Arc<Mutex<Instant>>>,
chat_unanswered: bool,
file_transferred: bool,
@@ -403,11 +416,14 @@ impl Connection {
enable_file_transfer: false,
disable_clipboard: false,
disable_keyboard: false,
#[cfg(not(any(target_os = "android", target_os = "ios")))]
show_my_cursor: false,
tx_input,
video_ack_required: false,
server_audit_conn: "".to_owned(),
server_audit_file: "".to_owned(),
lr: Default::default(),
peer_argb: 0u32,
session_last_recv_time: None,
chat_unanswered: false,
file_transferred: false,
@@ -938,8 +954,15 @@ impl Connection {
loop {
match receiver.recv_timeout(std::time::Duration::from_millis(500)) {
Ok(v) => match v {
MessageInput::Mouse((msg, id)) => {
handle_mouse(&msg, id);
MessageInput::Mouse(mouse_input) => {
handle_mouse(
&mouse_input.msg,
mouse_input.conn_id,
mouse_input.username,
mouse_input.argb,
mouse_input.simulate,
mouse_input.show_cursor,
);
}
MessageInput::Key((mut msg, press)) => {
// Set the press state to false, use `down` only in `handle_key()`.
@@ -1784,8 +1807,25 @@ impl Connection {
#[inline]
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn input_mouse(&self, msg: MouseEvent, conn_id: i32) {
self.tx_input.send(MessageInput::Mouse((msg, conn_id))).ok();
fn input_mouse(
&self,
msg: MouseEvent,
conn_id: i32,
username: String,
argb: u32,
simulate: bool,
show_cursor: bool,
) {
self.tx_input
.send(MessageInput::Mouse(InputMouse {
msg,
conn_id,
username,
argb,
simulate,
show_cursor,
}))
.ok();
}
#[inline]
@@ -1900,6 +1940,7 @@ impl Connection {
async fn handle_login_request_without_validation(&mut self, lr: &LoginRequest) {
self.lr = lr.clone();
self.peer_argb = crate::str2color(&format!("{}{}", &lr.my_id, &lr.my_platform), 0xff);
if let Some(o) = lr.option.as_ref() {
self.options_in_login = Some(o.clone());
}
@@ -2279,7 +2320,23 @@ impl Connection {
}
#[cfg(target_os = "macos")]
self.retina.on_mouse_event(&mut me, self.display_idx);
self.input_mouse(me, self.inner.id());
self.input_mouse(
me,
self.inner.id(),
self.lr.my_name.clone(),
self.peer_argb,
true,
self.show_my_cursor,
);
} else if self.show_my_cursor {
self.input_mouse(
me,
self.inner.id(),
self.lr.my_name.clone(),
self.peer_argb,
false,
true,
);
}
self.update_auto_disconnect_timer();
}
@@ -3640,6 +3697,18 @@ impl Connection {
self.update_terminal_persistence(q == BoolOption::Yes).await;
}
}
#[cfg(target_os = "windows")]
if let Ok(q) = o.show_my_cursor.enum_value() {
if q != BoolOption::NotSet {
use crate::whiteboard;
self.show_my_cursor = q == BoolOption::Yes;
if q == BoolOption::Yes {
whiteboard::register_whiteboard(whiteboard::get_key_cursor(self.inner.id));
} else {
whiteboard::unregister_whiteboard(whiteboard::get_key_cursor(self.inner.id));
}
}
}
}
async fn turn_on_privacy(&mut self, impl_key: String) {
@@ -4792,6 +4861,11 @@ mod raii {
scrap::wayland::pipewire::try_close_session();
}
Self::check_wake_lock();
#[cfg(target_os = "windows")]
{
use crate::whiteboard;
whiteboard::unregister_whiteboard(whiteboard::get_key_cursor(self.0));
}
}
}
}

View File

@@ -2,6 +2,8 @@
use super::rdp_input::client::{RdpInputKeyboard, RdpInputMouse};
use super::*;
use crate::input::*;
#[cfg(target_os = "windows")]
use crate::whiteboard;
#[cfg(target_os = "macos")]
use dispatch::Queue;
use enigo::{Enigo, Key, KeyboardControllable, MouseButton, MouseControllable};
@@ -698,18 +700,25 @@ fn get_modifier_state(key: Key, en: &mut Enigo) -> bool {
}
#[allow(unreachable_code)]
pub fn handle_mouse(evt: &MouseEvent, conn: i32) {
pub fn handle_mouse(
evt: &MouseEvent,
conn: i32,
username: String,
argb: u32,
simulate: bool,
show_cursor: bool,
) {
#[cfg(target_os = "macos")]
{
// having GUI (--server has tray, it is GUI too), run main GUI thread, otherwise crash
let evt = evt.clone();
QUEUE.exec_async(move || handle_mouse_(&evt, conn));
QUEUE.exec_async(move || handle_mouse_(&evt, conn, username, argb, simulate, show_cursor));
return;
}
#[cfg(windows)]
crate::portable_service::client::handle_mouse(evt, conn);
crate::portable_service::client::handle_mouse(evt, conn, username, argb, simulate, show_cursor);
#[cfg(not(windows))]
handle_mouse_(evt, conn);
handle_mouse_(evt, conn, username, argb, simulate, show_cursor);
}
// to-do: merge handle_mouse and handle_pointer
@@ -979,7 +988,24 @@ pub fn handle_pointer_(evt: &PointerDeviceEvent, conn: i32) {
}
}
pub fn handle_mouse_(evt: &MouseEvent, conn: i32) {
pub fn handle_mouse_(
evt: &MouseEvent,
conn: i32,
username: String,
argb: u32,
simulate: bool,
_show_cursor: bool,
) {
if simulate {
handle_mouse_simulation_(evt, conn);
}
#[cfg(target_os = "windows")]
if _show_cursor {
handle_mouse_show_cursor_(evt, conn, username, argb);
}
}
pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
if !active_mouse_(conn) {
return;
}
@@ -1122,6 +1148,41 @@ pub fn handle_mouse_(evt: &MouseEvent, conn: i32) {
}
}
#[cfg(target_os = "windows")]
pub fn handle_mouse_show_cursor_(evt: &MouseEvent, conn: i32, username: String, argb: u32) {
let buttons = evt.mask >> 3;
let evt_type = evt.mask & 0x7;
match evt_type {
MOUSE_TYPE_MOVE => {
whiteboard::update_whiteboard(
whiteboard::get_key_cursor(conn),
whiteboard::CustomEvent::Cursor(whiteboard::Cursor {
x: evt.x as _,
y: evt.y as _,
argb,
btns: 0,
text: username,
}),
);
}
MOUSE_TYPE_UP => {
if buttons == MOUSE_BUTTON_LEFT {
whiteboard::update_whiteboard(
whiteboard::get_key_cursor(conn),
whiteboard::CustomEvent::Cursor(whiteboard::Cursor {
x: evt.x as _,
y: evt.y as _,
argb,
btns: buttons,
text: username,
}),
);
}
}
_ => {}
}
}
#[cfg(target_os = "windows")]
fn handle_scale(scale: i32) {
let mut en = ENIGO.lock().unwrap();

View File

@@ -476,9 +476,9 @@ pub mod server {
break;
}
}
Mouse((v, conn)) => {
Mouse((v, conn, username, argb, simulate, show_cursor)) => {
if let Ok(evt) = MouseEvent::parse_from_bytes(&v) {
crate::input_service::handle_mouse_(&evt, conn);
crate::input_service::handle_mouse_(&evt, conn, username, argb, simulate, show_cursor);
}
}
Pointer((v, conn)) => {
@@ -875,11 +875,23 @@ pub mod client {
}
}
fn handle_mouse_(evt: &MouseEvent, conn: i32) -> ResultType<()> {
fn handle_mouse_(
evt: &MouseEvent,
conn: i32,
username: String,
argb: u32,
simulate: bool,
show_cursor: bool,
) -> ResultType<()> {
let mut v = vec![];
evt.write_to_vec(&mut v)?;
ipc_send(Data::DataPortableService(DataPortableService::Mouse((
v, conn,
v,
conn,
username,
argb,
simulate,
show_cursor,
))))
}
@@ -927,12 +939,19 @@ pub mod client {
}
}
pub fn handle_mouse(evt: &MouseEvent, conn: i32) {
pub fn handle_mouse(
evt: &MouseEvent,
conn: i32,
username: String,
argb: u32,
simulate: bool,
show_cursor: bool,
) {
if RUNNING.lock().unwrap().clone() {
crate::input_service::update_latest_input_cursor_time(conn);
handle_mouse_(evt, conn).ok();
handle_mouse_(evt, conn, username, argb, simulate, show_cursor).ok();
} else {
crate::input_service::handle_mouse_(evt, conn);
crate::input_service::handle_mouse_(evt, conn, username, argb, simulate, show_cursor);
}
}

731
src/whiteboard.rs Normal file
View File

@@ -0,0 +1,731 @@
use crate::ipc::{self, new_listener, Connection, Data};
use hbb_common::{
allow_err,
anyhow::anyhow,
bail, log, sleep,
tokio::{
self,
sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender},
time::interval_at,
},
ResultType,
};
use lazy_static::lazy_static;
use serde_derive::{Deserialize, Serialize};
use softbuffer::{Context, Surface};
use std::{
collections::HashMap,
num::NonZeroU32,
sync::{Arc, RwLock},
time::Instant,
};
#[cfg(target_os = "linux")]
use tao::platform::unix::WindowBuilderExtUnix;
#[cfg(target_os = "windows")]
use tao::platform::windows::WindowBuilderExtWindows;
use tao::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy},
window::WindowBuilder,
};
use tiny_skia::{Color, FillRule, Paint, PathBuilder, PixmapMut, Point, Stroke, Transform};
use ttf_parser::Face;
lazy_static! {
static ref EVENT_PROXY: RwLock<Option<EventLoopProxy<(String, CustomEvent)>>> =
RwLock::new(None);
static ref TX_WHITEBOARD: RwLock<Option<UnboundedSender<(String, CustomEvent)>>> =
RwLock::new(None);
static ref CONNS: RwLock<HashMap<String, Conn>> = Default::default();
}
struct Conn {
last_cursor_pos: (f32, f32), // For click ripple
last_cursor_evt: LastCursorEvent,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "t", content = "c")]
pub enum CustomEvent {
Cursor(Cursor),
Clear,
Exit,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "t")]
pub struct Cursor {
pub x: f32,
pub y: f32,
pub argb: u32,
pub btns: i32,
pub text: String,
}
struct LastCursorEvent {
evt: Option<CustomEvent>,
tm: Instant,
c: usize,
}
// A helper struct to bridge `ttf-parser` and `tiny-skia`.
struct PathBuilderWrapper<'a> {
path_builder: &'a mut PathBuilder,
transform: Transform,
}
impl ttf_parser::OutlineBuilder for PathBuilderWrapper<'_> {
fn move_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.move_to(pt.x, pt.y);
}
fn line_to(&mut self, x: f32, y: f32) {
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.line_to(pt.x, pt.y);
}
fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder.quad_to(pt1.x, pt1.y, pt.x, pt.y);
}
fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
let mut pt1 = Point::from_xy(x1, y1);
self.transform.map_point(&mut pt1);
let mut pt2 = Point::from_xy(x2, y2);
self.transform.map_point(&mut pt2);
let mut pt = Point::from_xy(x, y);
self.transform.map_point(&mut pt);
self.path_builder
.cubic_to(pt1.x, pt1.y, pt2.x, pt2.y, pt.x, pt.y);
}
fn close(&mut self) {
self.path_builder.close();
}
}
// Draws a string of text onto the pixmap.
fn draw_text(
pixmap: &mut PixmapMut,
face: &Face,
text: &str,
x: f32,
y: f32,
paint: &Paint,
font_size: f32,
) {
let units_per_em = face.units_per_em() as f32;
let scale = font_size / units_per_em;
let transform = Transform::from_translate(x, y).pre_scale(scale, -scale);
let mut path_builder = PathBuilder::new();
let mut current_x = 0.0;
for ch in text.chars() {
let glyph_id = face.glyph_index(ch).unwrap_or_default();
let mut builder = PathBuilderWrapper {
path_builder: &mut path_builder,
transform: transform.post_translate(current_x, 0.0),
};
face.outline_glyph(glyph_id, &mut builder);
if let Some(h_advance) = face.glyph_hor_advance(glyph_id) {
current_x += h_advance as f32 * scale;
}
}
if let Some(path) = path_builder.finish() {
pixmap.fill_path(&path, paint, FillRule::Winding, Transform::identity(), None);
}
}
#[inline]
pub fn get_key_cursor(conn_id: i32) -> String {
format!("{}-cursor", conn_id)
}
pub fn register_whiteboard(k: String) {
std::thread::spawn(|| {
allow_err!(start_whiteboard_());
});
let mut conns = CONNS.write().unwrap();
if !conns.contains_key(&k) {
conns.insert(
k,
Conn {
last_cursor_pos: (0.0, 0.0),
last_cursor_evt: LastCursorEvent {
evt: None,
tm: Instant::now(),
c: 0,
},
},
);
}
}
pub fn unregister_whiteboard(k: String) {
let mut conns = CONNS.write().unwrap();
conns.remove(&k);
let is_conns_empty = conns.is_empty();
drop(conns);
TX_WHITEBOARD.read().unwrap().as_ref().map(|tx| {
allow_err!(tx.send((k, CustomEvent::Clear)));
});
if is_conns_empty {
std::thread::spawn(|| {
let mut whiteboard = TX_WHITEBOARD.write().unwrap();
whiteboard.as_ref().map(|tx| {
allow_err!(tx.send(("".to_string(), CustomEvent::Exit)));
// Simple sleep to wait the whiteboard process exiting.
std::thread::sleep(std::time::Duration::from_millis(3_00));
});
whiteboard.take();
});
}
}
pub fn update_whiteboard(k: String, e: CustomEvent) {
let mut conns = CONNS.write().unwrap();
let Some(conn) = conns.get_mut(&k) else {
return;
};
match &e {
CustomEvent::Cursor(cursor) => {
conn.last_cursor_evt.c += 1;
conn.last_cursor_evt.tm = Instant::now();
if cursor.btns == 0 {
// Send one movement event every 4.
if conn.last_cursor_evt.c > 3 {
conn.last_cursor_evt.c = 0;
conn.last_cursor_evt.evt = None;
tx_send_event(conn, k, e);
} else {
conn.last_cursor_evt.evt = Some(e);
}
} else {
if let Some(evt) = conn.last_cursor_evt.evt.take() {
tx_send_event(conn, k.clone(), evt);
conn.last_cursor_evt.c = 0;
}
let click_evt = CustomEvent::Cursor(Cursor {
x: conn.last_cursor_pos.0,
y: conn.last_cursor_pos.1,
argb: cursor.argb,
btns: cursor.btns,
text: cursor.text.clone(),
});
tx_send_event(conn, k, click_evt);
}
}
_ => {
tx_send_event(conn, k, e);
}
}
}
#[inline]
fn tx_send_event(conn: &mut Conn, k: String, event: CustomEvent) {
if let CustomEvent::Cursor(cursor) = &event {
if cursor.btns == 0 {
conn.last_cursor_pos = (cursor.x, cursor.y);
}
}
TX_WHITEBOARD.read().unwrap().as_ref().map(|tx| {
allow_err!(tx.send((k, event)));
});
}
#[tokio::main(flavor = "current_thread")]
async fn start_whiteboard_() -> ResultType<()> {
let mut tx_whiteboard = TX_WHITEBOARD.write().unwrap();
if tx_whiteboard.is_some() {
log::warn!("Whiteboard already started");
return Ok(());
}
loop {
if !crate::platform::is_prelogin() {
break;
}
sleep(1.).await;
}
let mut stream = None;
if let Ok(s) = ipc::connect(1000, "_whiteboard").await {
stream = Some(s);
} else {
#[allow(unused_mut)]
#[allow(unused_assignments)]
let mut args = vec!["--whiteboard"];
#[allow(unused_mut)]
#[cfg(target_os = "linux")]
let mut user = None;
let run_done;
if crate::platform::is_root() {
let mut res = Ok(None);
for _ in 0..10 {
#[cfg(not(any(target_os = "linux")))]
{
log::debug!("Start whiteboard");
res = crate::platform::run_as_user(args.clone());
}
#[cfg(target_os = "linux")]
{
log::debug!("Start whiteboard");
res = crate::platform::run_as_user(
args.clone(),
user.clone(),
None::<(&str, &str)>,
);
}
if res.is_ok() {
break;
}
log::error!("Failed to run whiteboard: {res:?}");
sleep(1.).await;
}
if let Some(task) = res? {
super::CHILD_PROCESS.lock().unwrap().push(task);
}
run_done = true;
} else {
run_done = false;
}
if !run_done {
log::debug!("Start whiteboard");
super::CHILD_PROCESS
.lock()
.unwrap()
.push(crate::run_me(args)?);
}
for _ in 0..20 {
sleep(0.3).await;
if let Ok(s) = ipc::connect(1000, "_whiteboard").await {
stream = Some(s);
break;
}
}
if stream.is_none() {
bail!("Failed to connect to connection manager");
}
}
let mut stream = stream.ok_or(anyhow!("none stream"))?;
let (tx, mut rx) = unbounded_channel();
tx_whiteboard.replace(tx);
drop(tx_whiteboard);
let _call_on_ret = crate::common::SimpleCallOnReturn {
b: true,
f: Box::new(move || {
let _ = TX_WHITEBOARD.write().unwrap().take();
}),
};
let dur = tokio::time::Duration::from_millis(300);
let mut timer = interval_at(tokio::time::Instant::now() + dur, dur);
timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
res = rx.recv() => {
match res {
Some(data) => {
if matches!(data.1, CustomEvent::Exit) {
break;
} else {
allow_err!(stream.send(&Data::Whiteboard(data)).await);
timer.reset();
}
}
None => {
bail!("expected");
}
}
},
_ = timer.tick() => {
let mut conns = CONNS.write().unwrap();
for (k, conn) in conns.iter_mut() {
if conn.last_cursor_evt.tm.elapsed().as_millis() > 300 {
if let Some(evt) = conn.last_cursor_evt.evt.take() {
allow_err!(stream.send(&Data::Whiteboard((k.clone(), evt))).await);
conn.last_cursor_evt.c = 0;
}
}
}
}
}
}
allow_err!(
stream
.send(&Data::Whiteboard(("".to_string(), CustomEvent::Exit)))
.await
);
Ok(())
}
pub fn run() {
let (tx_exit, rx_exit) = unbounded_channel();
std::thread::spawn(move || {
start_ipc(rx_exit);
});
if let Err(e) = create_event_loop() {
log::error!("Failed to create event loop: {}", e);
tx_exit.send(()).ok();
return;
}
}
#[tokio::main(flavor = "current_thread")]
async fn start_ipc(mut rx_exit: UnboundedReceiver<()>) {
match new_listener("_whiteboard").await {
Ok(mut incoming) => loop {
tokio::select! {
_ = rx_exit.recv() => {
log::info!("Exiting IPC");
break;
}
res = incoming.next() => match res {
Some(result) => match result {
Ok(stream) => {
log::debug!("Got new connection");
tokio::spawn(handle_new_stream(Connection::new(stream)));
}
Err(err) => {
log::error!("Couldn't get whiteboard client: {:?}", err);
}
},
None => {
log::error!("Failed to get whiteboard client");
}
}
}
},
Err(err) => {
log::error!("Failed to start whiteboard ipc server: {}", err);
}
}
}
async fn handle_new_stream(mut conn: Connection) {
loop {
tokio::select! {
res = conn.next() => {
match res {
Err(err) => {
log::info!("whiteboard ipc connection closed: {}", err);
break;
}
Ok(Some(data)) => {
match data {
Data::Whiteboard((k, evt)) => {
if matches!(evt, CustomEvent::Exit) {
log::info!("whiteboard ipc connection closed");
break;
} else {
EVENT_PROXY.read().unwrap().as_ref().map(|ep| {
allow_err!(ep.send_event((k, evt)));
});
}
}
_ => {
}
}
}
Ok(None) => {
log::info!("whiteboard ipc connection closed");
break;
}
}
}
}
}
EVENT_PROXY.read().unwrap().as_ref().map(|ep| {
allow_err!(ep.send_event(("".to_string(), CustomEvent::Exit)));
});
}
fn create_font_face() -> ResultType<Face<'static>> {
let mut font_db = fontdb::Database::new();
font_db.load_system_fonts();
let query = fontdb::Query {
families: &[fontdb::Family::Monospace],
..fontdb::Query::default()
};
let Some(font_id) = font_db.query(&query) else {
bail!("No monospace font found!");
};
let Some((font_source, face_index)) = font_db.face_source(font_id) else {
bail!("No face found for font!");
};
let font_data: &'static [u8] = Box::leak(match font_source {
fontdb::Source::File(path) => std::fs::read(path)?.into_boxed_slice(),
fontdb::Source::Binary(data) => data.as_ref().as_ref().to_vec().into_boxed_slice(),
fontdb::Source::SharedFile(path, _) => std::fs::read(path)?.into_boxed_slice(),
});
let face = Face::parse(font_data, face_index)?;
Ok(face)
}
fn create_event_loop() -> ResultType<()> {
let face = match create_font_face() {
Ok(face) => Some(face),
Err(err) => {
log::error!("Failed to create font face: {}", err);
None
}
};
let event_loop = EventLoopBuilder::<(String, CustomEvent)>::with_user_event().build();
let mut window_builder = WindowBuilder::new()
.with_title("RustDesk whiteboard")
.with_transparent(true)
.with_always_on_top(true)
.with_decorations(false);
use tao::dpi::{PhysicalPosition, PhysicalSize};
let mut final_size = None;
if let Ok(displays) = crate::server::display_service::try_get_displays() {
let mut min_x = i32::MAX;
let mut min_y = i32::MAX;
let mut max_x = i32::MIN;
let mut max_y = i32::MIN;
for display in displays {
let (x, y) = (display.origin().0 as i32, display.origin().1 as i32);
let (w, h) = (display.width() as i32, display.height() as i32);
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x + w);
max_y = max_y.max(y + h);
}
let (x, y) = (min_x, min_y);
let (w, h) = ((max_x - min_x) as u32, (max_y - min_y) as u32);
if w > 0 && h > 0 {
final_size = Some(PhysicalSize::new(w, h));
window_builder = window_builder
.with_position(PhysicalPosition::new(x, y))
.with_inner_size(PhysicalSize::new(1, 1));
} else {
window_builder =
window_builder.with_fullscreen(Some(tao::window::Fullscreen::Borderless(None)));
}
} else {
window_builder =
window_builder.with_fullscreen(Some(tao::window::Fullscreen::Borderless(None)));
}
#[cfg(any(target_os = "windows", target_os = "linux"))]
{
window_builder = window_builder.with_skip_taskbar(true);
}
let window = Arc::new(window_builder.build::<(String, CustomEvent)>(&event_loop)?);
window.set_ignore_cursor_events(true)?;
let context = Context::new(window.clone()).map_err(|e| {
log::error!("Failed to create context: {}", e);
anyhow!(e.to_string())
})?;
let mut surface = Surface::new(&context, window.clone()).map_err(|e| {
log::error!("Failed to create surface: {}", e);
anyhow!(e.to_string())
})?;
let proxy = event_loop.create_proxy();
EVENT_PROXY.write().unwrap().replace(proxy);
let _call_on_ret = crate::common::SimpleCallOnReturn {
b: true,
f: Box::new(move || {
let _ = EVENT_PROXY.write().unwrap().take();
}),
};
struct Ripple {
x: f32,
y: f32,
start_time: Instant,
}
let mut ripples: Vec<Ripple> = Vec::new();
let mut last_cursors: HashMap<String, Cursor> = HashMap::new();
let mut resized = final_size.is_none();
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Poll;
match event {
Event::WindowEvent { event, .. } => match event {
WindowEvent::CloseRequested => {
*control_flow = ControlFlow::Exit;
}
_ => {}
},
Event::RedrawRequested(_) => {
if !resized {
if let Some(size) = final_size.take() {
window.set_inner_size(size);
}
resized = true;
return;
}
let (width, height) = {
let size = window.inner_size();
(size.width, size.height)
};
let (Some(width), Some(height)) = (NonZeroU32::new(width), NonZeroU32::new(height))
else {
return;
};
if let Err(e) = surface.resize(width, height) {
log::error!("Failed to resize surface: {}", e);
return;
}
let mut buffer = match surface.buffer_mut() {
Ok(buf) => buf,
Err(e) => {
log::error!("Failed to get buffer: {}", e);
return;
}
};
let Some(mut pixmap) = PixmapMut::from_bytes(
bytemuck::cast_slice_mut(&mut buffer),
width.get(),
height.get(),
) else {
log::error!("Failed to create pixmap from buffer");
return;
};
pixmap.fill(Color::TRANSPARENT);
let ripple_duration = std::time::Duration::from_millis(500);
ripples.retain(|r| r.start_time.elapsed() < ripple_duration);
for ripple in &ripples {
let elapsed = ripple.start_time.elapsed();
let progress = elapsed.as_secs_f32() / ripple_duration.as_secs_f32();
let radius = 45.0 * progress;
let alpha = 1.0 - progress;
let mut ripple_paint = Paint::default();
// Note: The real color is bgra here.
ripple_paint.set_color_rgba8(128, 128, 255, (alpha * 128.0) as u8);
ripple_paint.anti_alias = true;
let mut ripple_pb = PathBuilder::new();
let (rx, ry) = (ripple.x as f64, ripple.y as f64);
ripple_pb.push_circle(rx as f32, ry as f32, radius as f32);
if let Some(path) = ripple_pb.finish() {
pixmap.fill_path(
&path,
&ripple_paint,
FillRule::Winding,
Transform::identity(),
None,
);
}
}
for cursor in last_cursors.values() {
let (x, y) = (cursor.x as f64, cursor.y as f64);
let (x, y) = (x as f32, y as f32);
let size = 1.5 as f32;
let mut pb = PathBuilder::new();
pb.move_to(x, y);
pb.line_to(x, y + 16.0 * size);
pb.line_to(x + 4.0 * size, y + 13.0 * size);
pb.line_to(x + 7.0 * size, y + 20.0 * size);
pb.line_to(x + 9.0 * size, y + 19.0 * size);
pb.line_to(x + 6.0 * size, y + 12.0 * size);
pb.line_to(x + 11.0 * size, y + 12.0 * size);
pb.close();
if let Some(path) = pb.finish() {
let mut arrow_paint = Paint::default();
// Note: The real color is bgra here.
arrow_paint.set_color_rgba8(
(cursor.argb & 0xFF) as u8,
(cursor.argb >> 8 & 0xFF) as u8,
(cursor.argb >> 16 & 0xFF) as u8,
(cursor.argb >> 24 & 0xFF) as u8,
);
arrow_paint.anti_alias = true;
pixmap.fill_path(
&path,
&arrow_paint,
FillRule::Winding,
Transform::identity(),
None,
);
let mut black_paint = Paint::default();
black_paint.set_color_rgba8(0, 0, 0, 255);
black_paint.anti_alias = true;
let mut stroke = Stroke::default();
stroke.width = 1.0 as f32;
pixmap.stroke_path(
&path,
&black_paint,
&stroke,
Transform::identity(),
None,
);
face.as_ref().map(|face| {
draw_text(
&mut pixmap,
face,
&cursor.text,
x + 24.0 * size,
y + 24.0 * size,
&arrow_paint,
24.0 as f32,
);
});
}
}
if let Err(e) = buffer.present() {
log::error!("Failed to present surface: {}", e);
return;
}
}
Event::MainEventsCleared => {
window.request_redraw();
}
Event::UserEvent((k, evt)) => match evt {
CustomEvent::Cursor(cursor) => {
if cursor.btns != 0 {
ripples.push(Ripple {
x: cursor.x,
y: cursor.y,
start_time: Instant::now(),
});
}
last_cursors.insert(k, cursor);
}
CustomEvent::Exit => {
*control_flow = ControlFlow::Exit;
}
_ => {}
},
_ => (),
}
});
}