From 88ae00ba73be3d72fba38bf19570345226b51352 Mon Sep 17 00:00:00 2001 From: fufesou Date: Wed, 17 Jun 2026 17:50:18 +0800 Subject: [PATCH] refact: restart remote device, autoconnect (#15290) * refact: restart remote device, autoconnect Signed-off-by: fufesou * fix: guard restart reconnect timer after session close Signed-off-by: fufesou * Simple refactor Signed-off-by: fufesou * fix(restart): auto connect, comments Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/models/model.dart | 37 +++++++++++++++++++++++++-- src/client.rs | 48 +++++++++++++++++++++++++++++++---- src/client/io_loop.rs | 14 ++++++++-- src/ui_session_interface.rs | 4 +-- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e94834a2b..3054ffa96 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -55,6 +55,8 @@ import 'package:flutter_hbb/native/custom_cursor.dart' typedef HandleMsgBox = Function(Map evt, String id); typedef ReconnectHandle = Function(OverlayDialogManager, SessionID, bool); final _constSessionId = Uuid().v4obj(); +// Empirical restart reconnect cadence: keep the last frame briefly and retry quickly. +const _restartReconnectSilentDelaySecs = 5; class CachedPeerData { Map updatePrivacyMode = {}; @@ -119,6 +121,7 @@ class FfiModel with ChangeNotifier { bool _touchMode = false; late VirtualMouseMode virtualMouseMode; Timer? _timer; + Timer? _restartReconnectDelayTimer; var _reconnects = 1; DateTime? _offlineReconnectStartTime; bool _viewOnly = false; @@ -250,6 +253,7 @@ class FfiModel with ChangeNotifier { _inputBlocked = false; _timer?.cancel(); _timer = null; + resetRestartReconnectState(); clearPermissions(); waitForImageTimer?.cancel(); timerScreenshot?.cancel(); @@ -341,6 +345,7 @@ class FfiModel with ChangeNotifier { } else if (name == 'connection_ready') { setConnectionType(peerId, evt['secure'] == 'true', evt['direct'] == 'true', evt['stream_type'] ?? ''); + resetRestartReconnectState(); } else if (name == 'switch_display') { // switch display is kept for backward compatibility handleSwitchDisplay(evt, sessionId, peerId); @@ -922,8 +927,28 @@ class FfiModel with ChangeNotifier { enterUserLoginAndPasswordDialog( sessionId, dialogManager, 'terminal-admin-login-tip', false); } else if (type == 'restarting') { - showMsgBox(sessionId, type, title, text, link, false, dialogManager, - hasCancel: false); + // Treat restart messages as reconnect control events. Rust still sends + // title/text for legacy UI and translation reuse; Flutter keeps the last + // frame briefly, then shows the Connecting overlay. + if (_restartReconnectDelayTimer == null) { + parent.target?.inputModel.setRelativeMouseMode(false); + bind.sessionReconnect(sessionId: sessionId, forceRelay: false); + clearPermissions(); + // Retry once more after the silent window so restart reconnect attempts + // are spaced by the empirical short cadence instead of only updating UI. + _restartReconnectDelayTimer = + Timer(Duration(seconds: _restartReconnectSilentDelaySecs), () { + _restartReconnectDelayTimer = null; + if (parent.target?.closed == true) { + return; + } + reconnect(dialogManager, sessionId, false); + }); + } + } else if (type == 'restarting-show') { + _restartReconnectDelayTimer?.cancel(); + _restartReconnectDelayTimer = null; + reconnect(dialogManager, sessionId, false); } else if (type == 'wait-remote-accept-nook') { showWaitAcceptDialog(sessionId, type, title, text, dialogManager); } else if (type == 'on-uac' || type == 'on-foreground-elevated') { @@ -949,6 +974,11 @@ class FfiModel with ChangeNotifier { } } + void resetRestartReconnectState() { + _restartReconnectDelayTimer?.cancel(); + _restartReconnectDelayTimer = null; + } + /// Auto-retry check for "Remote desktop is offline" error. /// returns true to auto-retry, false otherwise. bool shouldAutoRetryOnOffline( @@ -1374,6 +1404,7 @@ class FfiModel with ChangeNotifier { if (displays.isNotEmpty) { _reconnects = 1; _offlineReconnectStartTime = null; + resetRestartReconnectState(); waitForFirstImage.value = true; isRefreshing = false; } @@ -3666,6 +3697,7 @@ class FFI { /// Mobile reuse FFI void mobileReset() { + ffiModel.resetRestartReconnectState(); ffiModel.waitForFirstImage.value = true; ffiModel.isRefreshing = false; ffiModel.waitForImageDialogShow.value = true; @@ -3879,6 +3911,7 @@ class FFI { } if (ffiModel.waitForFirstImage.value == true) { ffiModel.waitForFirstImage.value = false; + ffiModel.resetRestartReconnectState(); dialogManager.dismissAll(); await canvasModel.updateViewStyle(); await canvasModel.updateScrollStyle(); diff --git a/src/client.rs b/src/client.rs index 321a49ee6..87792d408 100644 --- a/src/client.rs +++ b/src/client.rs @@ -96,6 +96,8 @@ pub mod screenshot; pub const MILLI1: Duration = Duration::from_millis(1); pub const SEC30: Duration = Duration::from_secs(30); +// Empirical restart reconnect grace window. +const RESTART_REMOTE_DEVICE_GRACE: Duration = Duration::from_secs(5 * 60); pub const VIDEO_QUEUE_SIZE: usize = 120; const MAX_DECODE_FAIL_COUNTER: usize = 3; @@ -1740,7 +1742,10 @@ pub struct LoginConfigHandler { features: Option, pub session_id: u64, // used for local <-> server communication pub supported_encoding: SupportedEncoding, - pub restarting_remote_device: bool, + restarting_remote_device: bool, + // Start time of the restart grace window. On Windows the peer may briefly + // reconnect before the real reboot disconnect. + restart_remote_device_at: Option, pub force_relay: bool, pub direct: Option, pub received: bool, @@ -1849,7 +1854,7 @@ impl LoginConfigHandler { } self.session_id = sid; self.supported_encoding = Default::default(); - self.restarting_remote_device = false; + self.clear_restarting_remote_device(); self.force_relay = config::option2bool("force-always-relay", &self.get_option("force-always-relay")) || force_relay @@ -2779,6 +2784,30 @@ impl LoginConfigHandler { msg_out } + pub fn mark_restarting_remote_device(&mut self) { + self.restarting_remote_device = true; + self.restart_remote_device_at = Some(Instant::now()); + } + + pub fn clear_restarting_remote_device(&mut self) { + self.restarting_remote_device = false; + self.restart_remote_device_at = None; + } + + pub fn is_restarting_remote_device(&self) -> bool { + if !self.restarting_remote_device { + return false; + } + // Keep this flag alive for a short grace window instead of clearing it on + // connection_ready or the first peer bytes. During OS restart the peer can + // briefly reconnect before the real reboot disconnect, and clearing it too + // early would let the next disconnect escape the restart flow and fall back + // to the normal error dialog / manual reconnect path. + self.restart_remote_device_at + .map(|started_at| started_at.elapsed() < RESTART_REMOTE_DEVICE_GRACE) + .unwrap_or(false) + } + pub fn get_conn_token(&self) -> Option { if self.password.is_empty() { return None; @@ -3718,9 +3747,18 @@ pub trait Interface: Send + Clone + 'static + Sized { fn on_establish_connection_error(&self, err: String) { let title = "Connection Error"; let text = err.to_string(); - let lc = self.get_lch(); - let direct = lc.read().unwrap().direct; - let received = lc.read().unwrap().received; + let lch = self.get_lch(); + let (is_restarting, direct, received) = { + let lc = lch.read().unwrap(); + (lc.is_restarting_remote_device(), lc.direct, lc.received) + }; + if is_restarting { + log::info!("Restart remote device, suppress connection error: {err}"); + // Flutter treats this as a reconnect control event. The text is kept + // for legacy UI and existing translation reuse. + self.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", ""); + return; + } let mut relay_hint = false; let mut relay_hint_type = "relay-hint"; diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index fc6ab3414..012107f57 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -10,6 +10,10 @@ use crate::{ common::get_default_sound_input, ui_session_interface::{InvokeUiSession, Session}, }; + +// Empirical no-data window before exposing the restart reconnect state to the UI. +// Restart msgbox text is kept as a legacy UI fallback; Flutter handles the type as a control event. +const RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT: Duration = Duration::from_secs(5); #[cfg(feature = "unix-file-copy-paste")] use crate::{clipboard::try_empty_clipboard_files, clipboard_file::unix_file_clip}; #[cfg(any( @@ -153,7 +157,6 @@ impl Remote { } }; - let mut last_recv_time = Instant::now(); let mut received = false; let conn_type = if self.handler.is_file_transfer() { ConnType::FILE_TRANSFER @@ -219,6 +222,7 @@ impl Remote { let mut fps_instant = Instant::now(); let _keep_it = client::hc_connection(feedback, rendezvous_server, token).await; + let mut last_recv_time = Instant::now(); loop { tokio::select! { @@ -244,7 +248,7 @@ impl Remote { } else { if self.handler.is_restarting_remote_device() { log::info!("Restart remote device"); - self.handler.msgbox("restarting", "Restarting remote device", "remote_restarting_tip", ""); + self.handler.msgbox("restarting", "Restarting remote device", "Connection in progress. Please wait.", ""); } else { log::info!("Reset by the peer"); self.handler.msgbox("error", "Connection Error", "Reset by the peer", ""); @@ -279,6 +283,12 @@ impl Remote { } } _ = status_timer.tick() => { + if self.handler.is_restarting_remote_device() + && last_recv_time.elapsed() >= RESTART_REMOTE_DEVICE_NO_DATA_TIMEOUT + { + self.handler.msgbox("restarting-show", "Restarting remote device", "Connection in progress. Please wait.", ""); + break; + } let elapsed = fps_instant.elapsed().as_millis(); if elapsed < 1000 { continue; diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index ddc2574f2..33d7611c1 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -560,7 +560,7 @@ impl Session { pub fn restart_remote_device(&self) { let mut lc = self.lc.write().unwrap(); - lc.restarting_remote_device = true; + lc.mark_restarting_remote_device(); let msg = lc.restart_remote_device(); self.send(Data::Message(msg)); } @@ -656,7 +656,7 @@ impl Session { } pub fn is_restarting_remote_device(&self) -> bool { - self.lc.read().unwrap().restarting_remote_device + self.lc.read().unwrap().is_restarting_remote_device() } #[inline]