From cf1de4de629b987082bf2f920174257bbb86da71 Mon Sep 17 00:00:00 2001 From: StealUrKill <35749471+StealUrKill@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:32:47 -0500 Subject: [PATCH 1/2] Feature: Restore the last viewed monitor on auto reconnect (#15441) * Feature: Restore the last viewed monitor on auto reconnect Remembers the users last manually selected remote monitor and returns to it after an auto reconnect. In memory, reconnect only, and bounds checked against the current display count. It is skipped in "use all my displays" mode. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Address review on reconnect monitor restore Avoid a crash if the session closes during a reconnect. Don't overwrite the remembered monitor on auto restore. Defer the switch until the view is ready so a monitor with a different size renders correctly. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Guard all-displays reconnect restore against empty display list * Harden reconnect monitor restore against races and multi-UI sessions Cancel a queued restore when the user manually selects a monitor, so a newer choice is not overridden by a stale pending restore. Compare the remembered monitor against the reconnect event's display instead of the stale _pi.currentDisplay, which is intentionally left unchanged when the peer has multiple sessions. Add a frame-independent fallback so a multi-UI tab that never receives the first-image event (its display is filtered to the owning tab) still restores the remembered monitor. Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> * Harden reconnect monitor restore: fallback timer, lifecycle, cursor Follow-up hardening on the auto-reconnect monitor restore: - Cancel the fallback timer synchronously once this tab owns the restore, so it can no longer fire while onEvent2UIRgba is awaiting canvas setup and switch displays before the canvas is ready (the offset the deferred restore exists to avoid). The multi-UI no-frame fallback stays intact. - Apply the restore in a finally so a throwing canvas init still runs it instead of stranding a queued restore with the timer already cancelled. - Cancel the fallback timer on a manual monitor switch, so a newer user selection supersedes a queued restore instead of racing it. - Restore with updateCursorPos: false, matching other programmatic display switches so an auto-restore does not reposition the cursor. --------- Signed-off-by: StealUrKill <35749471+StealUrKill@users.noreply.github.com> --- flutter/lib/common.dart | 7 +++- flutter/lib/models/model.dart | 65 ++++++++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index 58ddc0cb05d..14a53b354cf 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -3350,7 +3350,12 @@ Future> getScreenRectList() async { } openMonitorInTheSameTab(int i, FFI ffi, PeerInfo pi, - {bool updateCursorPos = true}) { + {bool updateCursorPos = true, bool recordSelection = true}) { + if (recordSelection) { + ffi.ffiModel.lastUserDisplay = i; + ffi.ffiModel.cancelPendingRestoreTimer(); + ffi.ffiModel.pendingMonitorRestore = null; + } final displays = i == kAllDisplayValue ? List.generate(pi.displays.length, (index) => index) : [i]; diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 3054ffa96d1..175e3ff2da3 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -112,6 +112,9 @@ class CachedPeerData { class FfiModel with ChangeNotifier { CachedPeerData cachedPeerData = CachedPeerData(); PeerInfo _pi = PeerInfo(); + int? lastUserDisplay; + int? pendingMonitorRestore; + Timer? _pendingRestoreTimer; Rect? _rect; var _inputBlocked = false; @@ -248,6 +251,8 @@ class FfiModel with ChangeNotifier { clear() { _pi = PeerInfo(); + lastUserDisplay = null; + _cancelPendingMonitorRestore(); _secure = null; _direct = null; _inputBlocked = false; @@ -932,6 +937,7 @@ class FfiModel with ChangeNotifier { // frame briefly, then shows the Connecting overlay. if (_restartReconnectDelayTimer == null) { parent.target?.inputModel.setRelativeMouseMode(false); + _cancelPendingMonitorRestore(); bind.sessionReconnect(sessionId: sessionId, forceRelay: false); clearPermissions(); // Retry once more after the silent window so restart reconnect attempts @@ -1084,10 +1090,22 @@ class FfiModel with ChangeNotifier { } } + void _cancelPendingMonitorRestore() { + _pendingRestoreTimer?.cancel(); + _pendingRestoreTimer = null; + pendingMonitorRestore = null; + } + + void cancelPendingRestoreTimer() { + _pendingRestoreTimer?.cancel(); + _pendingRestoreTimer = null; + } + void reconnect(OverlayDialogManager dialogManager, SessionID sessionId, bool forceRelay) { // Disable relative mouse mode before reconnecting to ensure cursor is released. parent.target?.inputModel.setRelativeMouseMode(false); + _cancelPendingMonitorRestore(); bind.sessionReconnect(sessionId: sessionId, forceRelay: forceRelay); clearPermissions(); dialogManager.dismissAll(); @@ -1401,6 +1419,25 @@ class FfiModel with ChangeNotifier { // now replaced to _updateCurDisplay updateCurDisplay(sessionId); } + // After reconnecting, restore the last selected monitor once the canvas is ready. + // Switching earlier can offset the view if the monitor sizes differ. + final last = lastUserDisplay; + pendingMonitorRestore = (!isCache && + last != null && + last != currentDisplay && + bind.sessionGetUseAllMyDisplaysForTheRemoteSession( + sessionId: sessionId) != + 'Y' && + ((last == kAllDisplayValue && _pi.displays.isNotEmpty) || + (last >= 0 && last < _pi.displays.length))) + ? last + : null; + // Fallback if the first image event never reaches this tab (multi-UI). + _pendingRestoreTimer?.cancel(); + if (pendingMonitorRestore != null) { + _pendingRestoreTimer = Timer(const Duration(milliseconds: 1500), + () => parent.target?._applyPendingMonitorRestore()); + } if (displays.isNotEmpty) { _reconnects = 1; _offlineReconnectStartTime = null; @@ -3911,17 +3948,35 @@ class FFI { } if (ffiModel.waitForFirstImage.value == true) { ffiModel.waitForFirstImage.value = false; + ffiModel.cancelPendingRestoreTimer(); ffiModel.resetRestartReconnectState(); dialogManager.dismissAll(); - await canvasModel.updateViewStyle(); - await canvasModel.updateScrollStyle(); - await canvasModel.initializeEdgeScrollEdgeThickness(); - for (final cb in imageModel.callbacksOnFirstImage) { - cb(id); + try { + await canvasModel.updateViewStyle(); + await canvasModel.updateScrollStyle(); + await canvasModel.initializeEdgeScrollEdgeThickness(); + for (final cb in imageModel.callbacksOnFirstImage) { + cb(id); + } + } finally { + _applyPendingMonitorRestore(); } } } + void _applyPendingMonitorRestore() { + final restore = ffiModel.pendingMonitorRestore; + ffiModel._cancelPendingMonitorRestore(); + if (restore == null || closed) return; + // The display list may have changed since the restore was queued. + final displays = ffiModel.pi.displays; + if ((restore == kAllDisplayValue && displays.isNotEmpty) || + (restore >= 0 && restore < displays.length)) { + openMonitorInTheSameTab(restore, this, ffiModel.pi, + recordSelection: false, updateCursorPos: false); + } + } + /// Login with [password], choose if the client should [remember] it. void login(String osUsername, String osPassword, SessionID sessionId, String password, bool remember) { From 493b14ba78abc3dfb33f109c7f93c1c95a1dabc4 Mon Sep 17 00:00:00 2001 From: fufesou Date: Sun, 5 Jul 2026 23:28:32 +0800 Subject: [PATCH 2/2] Fix/session scope permission audit (#15469) * fix: enforce session-scoped permissions Restrict non-remote sessions to their allowed message types, filter out-of-scope login options, and audit rejected or filtered messages. Hide screenshot controls outside default remote sessions. Signed-off-by: fufesou * fix: typo Signed-off-by: fufesou * fix: prevent privacy mode in view-camera sessions Signed-off-by: fufesou * fix: switch display, check non-view-camera Signed-off-by: fufesou * fix: avoid sending unsupported messages Signed-off-by: fufesou * fix: session scope, add option to control close/alarm Signed-off-by: fufesou * Fix: scoped session handling for view-camera compatibility - Skip view-camera auto-login and display-management side effects - Allow harmless render broadcasts without affecting non-video sessions - Keep legacy view-camera management messages compatible as no-ops - Preserve stricter scope violations for non-video session types Signed-off-by: fufesou * update libs/hbb_common Signed-off-by: fufesou * fix: ignore repeated login request Signed-off-by: fufesou * fix: view camera, support "Take screenshot" Signed-off-by: fufesou * fix: session scoped messages, check update options Signed-off-by: fufesou * fix: session scope, check portforward before conn type voolations Signed-off-by: fufesou * fix: scoped messages, reduce changes. Signed-off-by: fufesou * fix: session scope, comments Signed-off-by: fufesou * fix: keep scoped sessions compatible with render broadcasts Allow legacy render-broadcast no-op messages for file transfer and terminal sessions while keeping port forward and mixed options scoped. Also avoid sending new render updates to non-video Flutter sessions. Signed-off-by: fufesou * fix: scope screenshot requests by video source Key screenshot requests by video source and display index so camera and monitor sessions cannot consume each other's requests. Deduplicate the Flutter render-target predicate while keeping render updates limited to video sessions. Signed-off-by: fufesou * fix: Harden scoped session message handling Filter option updates by authenticated connection type, keep legacy no-op messages compatible, and avoid noisy repeated scope violation alarms. Signed-off-by: fufesou * fix: session scope, comments Signed-off-by: fufesou * fix: Send close reason for scoped session violations Signed-off-by: fufesou * fix: Enforce scoped session message filtering - filter out-of-scope messages for limited session types - scope option updates by authenticated connection type - keep render-broadcast no-op compatibility for non-video scoped sessions - restore view-camera screenshot handling - improve session scope violation audit labels - avoid cloning option messages on the remote hot path Signed-off-by: fufesou * Fix scoped session clipboard broadcast compatibility Treat text clipboard broadcasts as no-op compatibility messages for FileTransfer and Terminal sessions, matching existing handler behavior and preventing optional scope-violation close from disconnecting those sessions. Keep ViewCamera and PortForward clipboard messages subject to normal scope enforcement. Signed-off-by: fufesou * fix: log warn Signed-off-by: fufesou * fix: restrict Flutter clipboard sync to default sessions Signed-off-by: fufesou * fix: session scope, comments and tests Signed-off-by: fufesou * fix: session scope, reset sessions in login handle Signed-off-by: fufesou * fix: session scope, view camera, allow clipboard noop Signed-off-by: fufesou --------- Signed-off-by: fufesou --- flutter/lib/common/widgets/toolbar.dart | 2 +- libs/hbb_common | 2 +- src/client/io_loop.rs | 6 + src/flutter.rs | 7 +- src/flutter_ffi.rs | 8 + src/server/connection.rs | 818 +++++++++++++++++++++++- src/server/video_service.rs | 16 +- src/ui_session_interface.rs | 10 +- 8 files changed, 837 insertions(+), 32 deletions(-) diff --git a/flutter/lib/common/widgets/toolbar.dart b/flutter/lib/common/widgets/toolbar.dart index 9653b547823..83638000b3d 100644 --- a/flutter/lib/common/widgets/toolbar.dart +++ b/flutter/lib/common/widgets/toolbar.dart @@ -606,7 +606,7 @@ List toolbarControls(BuildContext context, String id, FFI ffi) { // to-do: // 1. Web desktop // 2. Mobile, copy the image to the clipboard - if (isDesktop) { + if ((isDefaultConn || ffi.connType == ConnType.viewCamera) && isDesktop) { final isScreenshotSupported = bind.sessionGetCommonSync( sessionId: sessionId, key: 'is_screenshot_supported', param: ''); if ('true' == isScreenshotSupported) { diff --git a/libs/hbb_common b/libs/hbb_common index a920d00945e..7e1c392c62d 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit a920d00945e1d2441b3f77b2677054cb8c3d9dd2 +Subproject commit 7e1c392c62d39c364127307cd408421dd5f8cfb0 diff --git a/src/client/io_loop.rs b/src/client/io_loop.rs index 68cd6970046..a97b6ea7017 100644 --- a/src/client/io_loop.rs +++ b/src/client/io_loop.rs @@ -1090,6 +1090,9 @@ impl Remote { } async fn send_toggle_virtual_display_msg(&self, peer: &mut Stream) { + if self.handler.is_view_camera() { + return; + } if !self.peer_info.is_support_virtual_display() { return; } @@ -1111,6 +1114,9 @@ impl Remote { } async fn send_toggle_privacy_mode_msg(&self, peer: &mut Stream) { + if self.handler.is_view_camera() { + return; + } let lc = self.handler.lc.read().unwrap(); if lc.version >= hbb_common::get_version_number("1.2.4") && lc.get_toggle_option("privacy-mode") diff --git a/src/flutter.rs b/src/flutter.rs index 73f2dbde325..e6b325cbeb6 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -1437,7 +1437,7 @@ fn try_send_close_event(event_stream: &Option>) { pub fn update_text_clipboard_required() { let is_required = sessions::get_sessions() .iter() - .any(|s| s.is_text_clipboard_required()); + .any(|s| s.is_default() && s.is_text_clipboard_required()); #[cfg(target_os = "android")] let _ = scrap::android::ffi::call_clipboard_manager_enable_client_clipboard(is_required); Client::set_is_text_clipboard_required(is_required); @@ -1447,13 +1447,16 @@ pub fn update_text_clipboard_required() { pub fn update_file_clipboard_required() { let is_required = sessions::get_sessions() .iter() - .any(|s| s.is_file_clipboard_required()); + .any(|s| s.is_default() && s.is_file_clipboard_required()); Client::set_is_file_clipboard_required(is_required); } #[cfg(not(target_os = "ios"))] pub fn send_clipboard_msg(msg: Message, _is_file: bool) { for s in sessions::get_sessions() { + if !s.is_default() { + continue; + } #[cfg(feature = "unix-file-copy-paste")] if _is_file { if crate::is_support_file_copy_paste_num(s.lc.read().unwrap().version) diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9595ddd3160..d83cc37202c 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1224,9 +1224,14 @@ pub fn main_set_local_option(key: String, value: String) { let is_texture_render_key = key.eq(config::keys::OPTION_TEXTURE_RENDER); let is_d3d_render_key = key.eq(config::keys::OPTION_ALLOW_D3D_RENDER); set_local_option(key, value.clone()); + let is_render_target = + |session: &crate::flutter::FlutterSession| session.is_default() || session.is_view_camera(); if is_texture_render_key { let session_event = [("v", &value)]; for session in sessions::get_sessions() { + if !is_render_target(&session) { + continue; + } session.push_event("use_texture_render", &session_event, &[]); session.use_texture_render_changed(); session.ui_handler.update_use_texture_render(); @@ -1234,6 +1239,9 @@ pub fn main_set_local_option(key: String, value: String) { } if is_d3d_render_key { for session in sessions::get_sessions() { + if !is_render_target(&session) { + continue; + } session.update_supported_decodings(); } } diff --git a/src/server/connection.rs b/src/server/connection.rs index a3e0ccafab8..40a1606ef32 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -240,6 +240,18 @@ pub enum AuthConnType { Terminal, } +impl AuthConnType { + fn as_str(self) -> &'static str { + match self { + AuthConnType::Remote => "remote", + AuthConnType::FileTransfer => "file_transfer", + AuthConnType::PortForward => "port_forward", + AuthConnType::ViewCamera => "view_camera", + AuthConnType::Terminal => "terminal", + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(i64)] enum ConnAuditPrimaryAuth { @@ -384,6 +396,8 @@ pub struct Connection { cm_read_job_ids: HashSet, terminal_service_id: String, terminal_persistent: bool, + // Used to avoid too many repeated scope violation warnings. + scope_violation_messages: HashSet<&'static str>, // The user token must be set when terminal is enabled. // 0 indicates SYSTEM user // other values indicate current user @@ -571,6 +585,7 @@ impl Connection { cm_read_job_ids: HashSet::new(), terminal_service_id: "".to_owned(), terminal_persistent: false, + scope_violation_messages: HashSet::new(), #[cfg(not(any(target_os = "android", target_os = "ios")))] terminal_user_token: None, terminal_generic_service: None, @@ -1501,6 +1516,23 @@ impl Connection { }); } + fn post_session_scope_violation_alarm(&self, message: &'static str) { + let conn_type = self + .authed_conn_type() + .map(AuthConnType::as_str) + .unwrap_or("unknown"); + self.post_alarm_audit( + AlarmAuditType::SessionScopeViolation, + json!({ + "id": self.lr.my_id.clone(), + "name": self.lr.my_name.clone(), + "ip": &self.ip, + "conn_type": conn_type, + "message": message, + }), + ); + } + #[inline] async fn post_audit_async(url: String, v: Value) -> ResultType { crate::post_request(url, v.to_string(), "").await @@ -1897,10 +1929,9 @@ impl Connection { let mut msg_out = Message::new(); msg_out.set_login_response(res); self.send(msg_out).await; - if let Some(o) = self.options_in_login.take() { - self.update_options(&o).await; - } + self.update_scoped_login_options().await; if let Some((dir, show_hidden)) = self.file_transfer.clone() { + self.keyboard = false; let dir = if !dir.is_empty() && std::path::Path::new(&dir).is_dir() { &dir } else { @@ -2407,6 +2438,14 @@ impl Connection { ) } + fn reset_session_scope_for_login(&mut self) { + self.file_transfer = None; + self.view_camera = false; + self.terminal = false; + self.port_forward_address.clear(); + self.terminal_persistent = false; + } + 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); @@ -2474,12 +2513,21 @@ impl Connection { return false; } } + if self.authorized { + if matches!(msg.union.as_ref(), Some(message::Union::LoginRequest(_))) { + return true; + } + if let Some(message) = self.authorized_scope_violation(&msg) { + return self.handle_authorized_scope_violation(message).await; + } + } // After handling CloseReason messages, proceed to process other message types if let Some(message::Union::LoginRequest(lr)) = msg.union { self.handle_login_request_without_validation(&lr).await; if self.authorized { return true; } + self.reset_session_scope_for_login(); match lr.union { Some(login_request::Union::FileTransfer(ft)) => { if !Self::permission( @@ -2989,7 +3037,7 @@ impl Connection { self.update_auto_disconnect_timer(); } Some(message::Union::Clipboard(cb)) => { - if self.clipboard_enabled() { + if self.should_handle_text_clipboard_message() && self.clipboard_enabled() { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(vec![cb], ClipboardSide::Host); // ios as the controlled side is actually not supported for now. @@ -3017,7 +3065,7 @@ impl Connection { } } Some(message::Union::MultiClipboards(_mcb)) => { - if self.clipboard_enabled() { + if self.should_handle_text_clipboard_message() && self.clipboard_enabled() { #[cfg(not(any(target_os = "android", target_os = "ios")))] update_clipboard(_mcb.clipboards, ClipboardSide::Host); #[cfg(target_os = "android")] @@ -3404,10 +3452,14 @@ impl Connection { } #[cfg(windows)] Some(misc::Union::ToggleVirtualDisplay(t)) => { - self.toggle_virtual_display(t).await; + if !self.view_camera { + self.toggle_virtual_display(t).await; + } } Some(misc::Union::TogglePrivacyMode(t)) => { - self.toggle_privacy_mode(t).await; + if !self.view_camera { + self.toggle_privacy_mode(t).await; + } } Some(misc::Union::ChatMessage(c)) => { self.send_to_cm(ipc::Data::ChatMessage { text: c.text }); @@ -3415,19 +3467,27 @@ impl Connection { self.update_auto_disconnect_timer(); } Some(misc::Union::Option(o)) => { - self.update_options(&o).await; + if self.authed_conn_type() == Some(AuthConnType::Remote) { + self.update_options(&o).await; + } else if let Some(option) = self.scoped_update_option_message(&o) { + self.update_options(&option).await; + } } Some(misc::Union::RefreshVideo(r)) => { - if r { - // Refresh all videos. - // Compatibility with old versions and sciter(remote). - self.refresh_video_display(None); + if self.should_handle_render_broadcast_message() { + if r { + // Refresh all videos. + // Compatibility with old versions and sciter(remote). + self.refresh_video_display(None); + } + self.update_auto_disconnect_timer(); } - self.update_auto_disconnect_timer(); } Some(misc::Union::RefreshVideoDisplay(display)) => { - self.refresh_video_display(Some(display as usize)); - self.update_auto_disconnect_timer(); + if self.should_handle_render_broadcast_message() { + self.refresh_video_display(Some(display as usize)); + self.update_auto_disconnect_timer(); + } } Some(misc::Union::VideoReceived(_)) => { video_service::notify_video_frame_fetched_by_conn_id( @@ -3495,10 +3555,16 @@ impl Connection { } } #[cfg(not(any(target_os = "android", target_os = "ios")))] - Some(misc::Union::ChangeResolution(r)) => self.change_resolution(None, &r), + Some(misc::Union::ChangeResolution(r)) => { + if !self.view_camera { + self.change_resolution(None, &r); + } + } #[cfg(not(any(target_os = "android", target_os = "ios")))] Some(misc::Union::ChangeDisplayResolution(dr)) => { - self.change_resolution(Some(dr.display as _), &dr.resolution) + if !self.view_camera { + self.change_resolution(Some(dr.display as _), &dr.resolution); + } } #[cfg(all(feature = "flutter", feature = "plugin_framework"))] #[cfg(not(any(target_os = "android", target_os = "ios")))] @@ -3584,6 +3650,7 @@ impl Connection { Some(message::Union::ScreenshotRequest(request)) => { if let Some(tx) = self.inner.tx.clone() { crate::video_service::set_take_screenshot( + self.video_source(), request.display as _, request.sid.clone(), tx, @@ -4080,7 +4147,7 @@ impl Connection { self.switch_display_to(display_idx, server.clone()); #[cfg(not(any(target_os = "android", target_os = "ios")))] - if s.width != 0 && s.height != 0 { + if !self.view_camera && s.width != 0 && s.height != 0 { self.change_resolution( None, &Resolution { @@ -5204,6 +5271,398 @@ impl Connection { false } + fn should_handle_render_broadcast_message(&self) -> bool { + matches!( + self.authed_conn_type(), + Some(AuthConnType::Remote | AuthConnType::ViewCamera) + ) + } + + fn should_handle_text_clipboard_message(&self) -> bool { + matches!(self.authed_conn_type(), Some(AuthConnType::Remote)) + } + + fn scoped_update_option_message(&self, option: &OptionMessage) -> Option { + match self.authed_conn_type() { + Some(AuthConnType::ViewCamera) => Self::scoped_view_camera_option(option).0, + Some(AuthConnType::Terminal) => Self::scoped_terminal_login_option(option).0, + Some(AuthConnType::Remote | AuthConnType::FileTransfer | AuthConnType::PortForward) + | None => None, + } + } + + fn authed_conn_type(&self) -> Option { + self.authed_conn_id.as_ref().map(|id| id.conn_type()) + } + + async fn handle_authorized_scope_violation(&mut self, message: &'static str) -> bool { + let conn_type = self + .authed_conn_type() + .map(AuthConnType::as_str) + .unwrap_or("unknown"); + let is_first = self.scope_violation_messages.insert(message); + if is_first { + log::warn!( + "Received out-of-scope message in {} session: {}", + conn_type, + message + ); + } else { + log::debug!( + "Received repeated out-of-scope message in {} session: {}", + conn_type, + message + ); + } + if is_first && Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_ALARM) { + self.post_session_scope_violation_alarm(message); + } + if Config::get_bool_option(keys::OPTION_ALLOW_SCOPE_VIOLATION_CLOSE) { + self.send_close_reason_no_retry("Connection not allowed") + .await; + self.on_close("Session scope violation", true).await; + return false; + } + true + } + + fn authorized_scope_violation(&self, msg: &Message) -> Option<&'static str> { + let Some(conn_type) = self.authed_conn_type() else { + return (!Self::is_connection_housekeeping_message(msg)).then_some("session.auth_type"); + }; + Self::authorized_message_scope_violation(conn_type, msg) + } + + async fn update_scoped_login_options(&mut self) { + let Some(option) = self.options_in_login.take() else { + return; + }; + let Some(conn_type) = self.authed_conn_type() else { + // Unreachable, but just in case, we drop the options if the connection type is unknown. + log::warn!( + "Dropping scoped login options because authorized connection type is unknown" + ); + return; + }; + let (scoped, violation) = Self::scoped_login_option(conn_type, &option); + if let Some(message) = violation { + log::debug!( + "Filtering {} session login options outside scope: {}", + conn_type.as_str(), + message + ); + } + if let Some(option) = scoped { + self.update_options(&option).await; + } + } + + fn scoped_login_option( + conn_type: AuthConnType, + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + match conn_type { + AuthConnType::Remote => (Some(option.clone()), None), + AuthConnType::ViewCamera => Self::scoped_view_camera_option(option), + AuthConnType::Terminal => Self::scoped_terminal_login_option(option), + AuthConnType::FileTransfer | AuthConnType::PortForward => { + let violation = Self::option_has_any_field(option).then_some("login.option"); + (None, violation) + } + } + } + + fn scoped_terminal_login_option( + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + let mut scoped = OptionMessage::new(); + let mut violation = false; + match option.terminal_persistent.enum_value() { + Ok(value) => scoped.terminal_persistent = value.into(), + Err(_) => violation = true, + } + if Self::option_has_non_terminal_login_field(option) { + violation = true; + } + let scoped = Self::option_has_any_field(&scoped).then_some(scoped); + (scoped, violation.then_some("login.option")) + } + + fn authorized_message_scope_violation( + conn_type: AuthConnType, + msg: &Message, + ) -> Option<&'static str> { + if Self::is_connection_housekeeping_message(msg) { + return None; + } + // Legacy clients can broadcast render-refresh messages to all opened sessions. + // Clipboard messages may also be broadcast to FileTransfer/Terminal sessions while + // the client still considers text clipboard sync required, and handlers ignore them. + let noop_compat = match conn_type { + AuthConnType::FileTransfer | AuthConnType::Terminal => { + Self::is_render_broadcast_noop_compat_message(msg) + || Self::is_text_clipboard_noop_compat_message(msg) + } + AuthConnType::PortForward => Self::is_render_broadcast_noop_compat_message(msg), + AuthConnType::ViewCamera => Self::is_text_clipboard_noop_compat_message(msg), + _ => false, + }; + if noop_compat { + return None; + } + let allowed = match conn_type { + AuthConnType::Remote => true, + AuthConnType::FileTransfer => Self::is_file_transfer_scoped_message(msg), + AuthConnType::PortForward => false, + AuthConnType::ViewCamera => Self::is_view_camera_scoped_message(msg), + AuthConnType::Terminal => Self::is_terminal_scoped_message(msg), + }; + (!allowed).then(|| Self::message_family(msg)) + } + + fn is_render_broadcast_noop_compat_message(msg: &Message) -> bool { + let Some(message::Union::Misc(misc)) = msg.union.as_ref() else { + return false; + }; + match misc.union.as_ref() { + Some(misc::Union::RefreshVideo(_)) | Some(misc::Union::RefreshVideoDisplay(_)) => true, + Some(misc::Union::Option(option)) => Self::is_supported_decoding_only_option(option), + _ => false, + } + } + + fn is_text_clipboard_noop_compat_message(msg: &Message) -> bool { + matches!( + msg.union.as_ref(), + Some(message::Union::Clipboard(_)) | Some(message::Union::MultiClipboards(_)) + ) + } + + fn is_supported_decoding_only_option(option: &OptionMessage) -> bool { + option.supported_decoding.is_some() + && option.image_quality.enum_value() == Ok(ImageQuality::NotSet) + && option.custom_image_quality == 0 + && option.custom_fps == 0 + && Self::is_bool_option_not_set(option.lock_after_session_end) + && Self::is_bool_option_not_set(option.show_remote_cursor) + && Self::is_bool_option_not_set(option.privacy_mode) + && Self::is_bool_option_not_set(option.block_input) + && Self::is_bool_option_not_set(option.disable_audio) + && Self::is_bool_option_not_set(option.disable_clipboard) + && Self::is_bool_option_not_set(option.enable_file_transfer) + && Self::is_bool_option_not_set(option.disable_keyboard) + && Self::is_bool_option_not_set(option.follow_remote_cursor) + && Self::is_bool_option_not_set(option.follow_remote_window) + && Self::is_bool_option_not_set(option.disable_camera) + && Self::is_bool_option_not_set(option.terminal_persistent) + && Self::is_bool_option_not_set(option.show_my_cursor) + } + + fn is_connection_housekeeping_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::LoginRequest(_)) => true, + Some(message::Union::TestDelay(_)) => true, + Some(message::Union::Misc(misc)) => { + matches!(misc.union.as_ref(), Some(misc::Union::CloseReason(_))) + } + _ => false, + } + } + + fn is_file_transfer_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::FileAction(_)) | Some(message::Union::FileResponse(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_file_transfer_scoped_misc(misc), + _ => false, + } + } + + fn is_file_transfer_scoped_misc(misc: &Misc) -> bool { + #[cfg(windows)] + if matches!(misc.union.as_ref(), Some(misc::Union::SelectedSid(_))) { + return true; + } + #[cfg(not(windows))] + let _ = misc; + false + } + + fn is_terminal_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::TerminalAction(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_terminal_scoped_misc(misc), + _ => false, + } + } + + fn is_terminal_scoped_misc(misc: &Misc) -> bool { + match misc.union.as_ref() { + Some(misc::Union::ChatMessage(_)) => true, + Some(misc::Union::Option(option)) => Self::is_terminal_scoped_option(option), + _ => false, + } + } + + fn is_terminal_scoped_option(option: &OptionMessage) -> bool { + Self::scoped_terminal_login_option(option).1.is_none() + } + + fn is_view_camera_scoped_message(msg: &Message) -> bool { + match msg.union.as_ref() { + Some(message::Union::ScreenshotRequest(_)) => true, + Some(message::Union::Misc(misc)) => Self::is_view_camera_scoped_misc(misc), + // Legacy clients may send auto-login input during view-camera connect. + // The handlers intentionally ignore these messages for view-camera sessions. + Some(message::Union::MouseEvent(_)) + | Some(message::Union::PointerDeviceEvent(_)) + | Some(message::Union::KeyEvent(_)) => true, + Some(message::Union::AudioFrame(_)) + | Some(message::Union::VoiceCallRequest(_)) + | Some(message::Union::VoiceCallResponse(_)) => true, + _ => false, + } + } + + fn is_view_camera_scoped_misc(misc: &Misc) -> bool { + match misc.union.as_ref() { + Some(misc::Union::SwitchDisplay(_)) + | Some(misc::Union::CaptureDisplays(_)) + | Some(misc::Union::RefreshVideo(_)) + | Some(misc::Union::RefreshVideoDisplay(_)) + | Some(misc::Union::VideoReceived(_)) + | Some(misc::Union::ChatMessage(_)) + | Some(misc::Union::AudioFormat(_)) + | Some(misc::Union::ClientRecordStatus(_)) + // Though these messages are not expected in normal view-camera sessions, + // keep them allowed to avoid breaking existing clients that may send them. + | Some(misc::Union::MessageQuery(_)) + | Some(misc::Union::TogglePrivacyMode(_)) + | Some(misc::Union::ToggleVirtualDisplay(_)) + | Some(misc::Union::ChangeResolution(_)) + | Some(misc::Union::ChangeDisplayResolution(_)) => true, + Some(misc::Union::Option(option)) => Self::is_view_camera_scoped_option(option), + #[cfg(windows)] + Some(misc::Union::SelectedSid(_)) => true, + _ => false, + } + } + + fn is_view_camera_scoped_option(option: &OptionMessage) -> bool { + Self::scoped_view_camera_option(option).1.is_none() + } + + // Keep these OptionMessage field lists in sync with message.proto and update_options(). + // New fields must be classified here before limited session types can receive them. + fn scoped_view_camera_option( + option: &OptionMessage, + ) -> (Option, Option<&'static str>) { + let mut scoped = OptionMessage::new(); + let mut violation = false; + if option.image_quality.enum_value().is_ok() { + scoped.image_quality = option.image_quality; + } + if option.custom_image_quality >= 0 { + scoped.custom_image_quality = option.custom_image_quality; + } + if option.custom_fps >= 0 { + scoped.custom_fps = option.custom_fps; + } + scoped.supported_decoding = option.supported_decoding.clone(); + if let Ok(value) = option.disable_audio.enum_value() { + scoped.disable_audio = value.into(); + } + if Self::option_has_non_view_camera_login_field(option) { + violation = true; + } + let scoped = Self::option_has_any_field(&scoped).then_some(scoped); + (scoped, violation.then_some("login.option")) + } + + fn option_has_non_view_camera_login_field(option: &OptionMessage) -> bool { + !(Self::is_bool_option_not_set(option.lock_after_session_end) + && Self::is_bool_option_not_set(option.show_remote_cursor) + && Self::is_bool_option_not_set(option.privacy_mode) + && Self::is_bool_option_not_set(option.block_input) + && Self::is_bool_option_not_set(option.disable_clipboard) + && Self::is_bool_option_not_set(option.enable_file_transfer) + && Self::is_bool_option_not_set(option.disable_keyboard) + && Self::is_bool_option_not_set(option.follow_remote_cursor) + && Self::is_bool_option_not_set(option.follow_remote_window) + && Self::is_bool_option_not_set(option.disable_camera) + && Self::is_bool_option_not_set(option.terminal_persistent) + && Self::is_bool_option_not_set(option.show_my_cursor)) + } + + fn option_has_non_terminal_login_field(option: &OptionMessage) -> bool { + option.image_quality.enum_value() != Ok(ImageQuality::NotSet) + || option.custom_image_quality != 0 + || option.custom_fps != 0 + || option.supported_decoding.is_some() + || !Self::is_bool_option_not_set(option.lock_after_session_end) + || !Self::is_bool_option_not_set(option.show_remote_cursor) + || !Self::is_bool_option_not_set(option.privacy_mode) + || !Self::is_bool_option_not_set(option.block_input) + || !Self::is_bool_option_not_set(option.disable_audio) + || !Self::is_bool_option_not_set(option.disable_clipboard) + || !Self::is_bool_option_not_set(option.enable_file_transfer) + || !Self::is_bool_option_not_set(option.disable_keyboard) + || !Self::is_bool_option_not_set(option.follow_remote_cursor) + || !Self::is_bool_option_not_set(option.follow_remote_window) + || !Self::is_bool_option_not_set(option.disable_camera) + || !Self::is_bool_option_not_set(option.show_my_cursor) + } + + fn option_has_any_field(option: &OptionMessage) -> bool { + Self::option_has_non_terminal_login_field(option) + || !Self::is_bool_option_not_set(option.terminal_persistent) + } + + fn is_bool_option_not_set(option: hbb_common::protobuf::EnumOrUnknown) -> bool { + option.enum_value() == Ok(BoolOption::NotSet) + } + + fn message_family(msg: &Message) -> &'static str { + match msg.union.as_ref() { + Some(message::Union::MouseEvent(_)) => "mouse_event", + Some(message::Union::AudioFrame(_)) => "audio_frame", + Some(message::Union::PointerDeviceEvent(_)) => "pointer_device_event", + Some(message::Union::KeyEvent(_)) => "key_event", + Some(message::Union::Clipboard(_)) => "clipboard", + Some(message::Union::FileAction(_)) => "file_action", + Some(message::Union::FileResponse(_)) => "file_response", + Some(message::Union::VoiceCallRequest(_)) => "voice_call_request", + Some(message::Union::VoiceCallResponse(_)) => "voice_call_response", + Some(message::Union::MultiClipboards(_)) => "multi_clipboards", + Some(message::Union::ScreenshotRequest(_)) => "screenshot_request", + Some(message::Union::ScreenshotResponse(_)) => "screenshot_response", + Some(message::Union::TerminalAction(_)) => "terminal_action", + Some(message::Union::TerminalResponse(_)) => "terminal_response", + Some(message::Union::Misc(misc)) => Self::misc_message_family(misc), + Some(_) => "message.other", + None => "empty", + } + } + + fn misc_message_family(misc: &Misc) -> &'static str { + match misc.union.as_ref() { + Some(misc::Union::ChatMessage(_)) => "misc.chat_message", + Some(misc::Union::SwitchDisplay(_)) => "misc.switch_display", + Some(misc::Union::Option(_)) => "misc.option", + Some(misc::Union::AudioFormat(_)) => "misc.audio_format", + Some(misc::Union::CaptureDisplays(_)) => "misc.capture_displays", + Some(misc::Union::ClientRecordStatus(_)) => "misc.client_record_status", + Some(misc::Union::TogglePrivacyMode(_)) => "misc.toggle_privacy_mode", + Some(misc::Union::ToggleVirtualDisplay(_)) => "misc.toggle_virtual_display", + Some(misc::Union::SelectedSid(_)) => "misc.selected_sid", + Some(misc::Union::ChangeResolution(_)) => "misc.change_resolution", + Some(misc::Union::ChangeDisplayResolution(_)) => "misc.change_display_resolution", + Some(misc::Union::MessageQuery(_)) => "misc.message_query", + Some(misc::Union::FollowCurrentDisplay(_)) => "misc.follow_current_display", + Some(_) => "misc.other", + None => "misc.empty", + } + } + #[cfg(feature = "unix-file-copy-paste")] async fn handle_file_clip(&mut self, clip: clipboard::ClipboardFile) { let is_stopping_allowed = clip.is_stopping_allowed(); @@ -5599,6 +6058,7 @@ pub enum AlarmAuditType { ExceedIPv6PrefixAttempts = 6, TerminalOsLoginBackoff = 7, TerminalOsLoginConcurrency = 8, + SessionScopeViolation = 9, } pub enum FileAuditType { @@ -6216,6 +6676,7 @@ mod raii { } } +#[cfg(test)] mod test { #[allow(unused)] use super::*; @@ -6258,4 +6719,325 @@ mod test { assert!(Ipv6Addr::from_str("127.0.0.1").is_err()); assert!(Ipv6Addr::from_str("0").is_err()); } + + fn msg(set: impl FnOnce(&mut Message)) -> Message { + let mut msg = Message::new(); + set(&mut msg); + msg + } + + fn misc_msg(set: impl FnOnce(&mut Misc)) -> Message { + msg(|msg| { + let mut misc = Misc::new(); + set(&mut misc); + msg.set_misc(misc); + }) + } + + fn option_msg(set: impl FnOnce(&mut OptionMessage)) -> Message { + misc_msg(|misc| { + let mut option = OptionMessage::new(); + set(&mut option); + misc.set_option(option); + }) + } + + fn set_supported_decoding(option: &mut OptionMessage) { + option.supported_decoding = hbb_common::protobuf::MessageField::some(Default::default()); + } + + fn assert_scopes( + conn_type: AuthConnType, + cases: impl IntoIterator)>, + ) { + for (msg, expected) in cases { + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &msg), + expected + ); + } + } + + #[test] + fn session_scope_allows_only_messages_for_authenticated_session_type() { + let cases = [ + ( + AuthConnType::FileTransfer, + vec![ + (msg(|m| m.set_file_action(FileAction::new())), None), + (msg(|m| m.set_file_response(FileResponse::new())), None), + (msg(|m| m.set_login_request(LoginRequest::new())), None), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + ( + misc_msg(|m| m.set_capture_displays(CaptureDisplays::new())), + Some("misc.capture_displays"), + ), + (msg(|m| m.set_clipboard(Clipboard::new())), None), + ( + msg(|m| m.set_multi_clipboards(MultiClipboards::new())), + None, + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()); + o.disable_audio = BoolOption::Yes.into(); + }), + Some("misc.option"), + ), + ], + ), + ( + AuthConnType::Terminal, + vec![ + (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ( + option_msg(|o| o.terminal_persistent = BoolOption::Yes.into()), + None, + ), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + ( + msg(|m| m.set_file_action(FileAction::new())), + Some("file_action"), + ), + ( + misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), + Some("misc.toggle_privacy_mode"), + ), + (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), + (msg(|m| m.set_clipboard(Clipboard::new())), None), + ( + msg(|m| m.set_multi_clipboards(MultiClipboards::new())), + None, + ), + ( + misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())), + Some("misc.toggle_virtual_display"), + ), + ( + misc_msg(|m| m.set_change_resolution(Resolution::new())), + Some("misc.change_resolution"), + ), + ( + misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())), + Some("misc.change_display_resolution"), + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()); + o.disable_audio = BoolOption::Yes.into(); + }), + Some("misc.option"), + ), + ], + ), + ( + AuthConnType::ViewCamera, + vec![ + ( + misc_msg(|m| m.set_switch_display(SwitchDisplay::new())), + None, + ), + (misc_msg(|m| m.set_chat_message(ChatMessage::new())), None), + ( + msg(|m| m.set_voice_call_request(VoiceCallRequest::new())), + None, + ), + (msg(|m| m.set_audio_frame(AudioFrame::new())), None), + ( + option_msg(|o| o.image_quality = ImageQuality::Balanced.into()), + None, + ), + ( + misc_msg(|m| m.set_toggle_privacy_mode(TogglePrivacyMode::new())), + None, + ), + ( + misc_msg(|m| m.set_toggle_virtual_display(ToggleVirtualDisplay::new())), + None, + ), + ( + misc_msg(|m| m.set_change_resolution(Resolution::new())), + None, + ), + ( + misc_msg(|m| m.set_change_display_resolution(DisplayResolution::new())), + None, + ), + (msg(|m| m.set_mouse_event(MouseEvent::new())), None), + ( + msg(|m| m.set_pointer_device_event(PointerDeviceEvent::new())), + None, + ), + (msg(|m| m.set_key_event(KeyEvent::new())), None), + (misc_msg(|m| m.set_client_record_status(true)), None), + ( + msg(|m| m.set_file_response(FileResponse::new())), + Some("file_response"), + ), + ( + msg(|m| m.set_terminal_action(TerminalAction::new())), + Some("terminal_action"), + ), + ], + ), + ( + AuthConnType::Remote, + vec![ + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + None, + ), + (msg(|m| m.set_terminal_action(TerminalAction::new())), None), + ], + ), + ( + AuthConnType::PortForward, + vec![ + (msg(|m| m.set_test_delay(TestDelay::new())), None), + (misc_msg(|m| m.set_close_reason("closed".to_owned())), None), + ( + msg(|m| m.set_file_action(FileAction::new())), + Some("file_action"), + ), + ( + msg(|m| m.set_terminal_action(TerminalAction::new())), + Some("terminal_action"), + ), + ( + msg(|m| m.set_screenshot_request(ScreenshotRequest::new())), + Some("screenshot_request"), + ), + (misc_msg(|m| m.set_refresh_video(true)), None), + (misc_msg(|m| m.set_refresh_video_display(0)), None), + ( + option_msg(|o| { + o.supported_decoding = + hbb_common::protobuf::MessageField::some(Default::default()) + }), + None, + ), + ], + ), + ]; + + for (conn_type, messages) in cases { + assert_scopes(conn_type, messages); + } + } + + #[test] + fn session_scope_login_options_are_limited_to_authenticated_session_type() { + let mut option = OptionMessage::new(); + option.image_quality = ImageQuality::Balanced.into(); + option.disable_audio = BoolOption::Yes.into(); + option.block_input = BoolOption::Yes.into(); + option.privacy_mode = BoolOption::Yes.into(); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::ViewCamera, &option); + let scoped = scoped.unwrap(); + assert_eq!(violation, Some("login.option")); + assert_eq!( + scoped.image_quality.enum_value(), + Ok(ImageQuality::Balanced) + ); + assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes)); + assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet)); + assert_eq!(scoped.privacy_mode.enum_value(), Ok(BoolOption::NotSet)); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::FileTransfer, &option); + assert!(scoped.is_none()); + assert_eq!(violation, Some("login.option")); + } + + #[test] + fn session_scope_limited_render_noop_options_reject_mixed_fields() { + for conn_type in [ + AuthConnType::FileTransfer, + AuthConnType::Terminal, + AuthConnType::PortForward, + ] { + let supported_decoding_only = option_msg(set_supported_decoding); + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &supported_decoding_only), + None + ); + + let mixed_option = option_msg(|o| { + set_supported_decoding(o); + o.disable_audio = BoolOption::Yes.into(); + }); + assert_eq!( + Connection::authorized_message_scope_violation(conn_type, &mixed_option), + Some("misc.option") + ); + } + } + + #[test] + fn session_scope_view_camera_options_keep_only_camera_fields() { + let mut option = OptionMessage::new(); + option.image_quality = ImageQuality::Balanced.into(); + option.custom_image_quality = 80; + option.custom_fps = 24; + set_supported_decoding(&mut option); + option.disable_audio = BoolOption::Yes.into(); + option.block_input = BoolOption::Yes.into(); + option.disable_clipboard = BoolOption::Yes.into(); + option.enable_file_transfer = BoolOption::Yes.into(); + option.terminal_persistent = BoolOption::Yes.into(); + + let (scoped, violation) = + Connection::scoped_login_option(AuthConnType::ViewCamera, &option); + let scoped = scoped.unwrap(); + assert_eq!(violation, Some("login.option")); + assert_eq!( + scoped.image_quality.enum_value(), + Ok(ImageQuality::Balanced) + ); + assert_eq!(scoped.custom_image_quality, 80); + assert_eq!(scoped.custom_fps, 24); + assert!(scoped.supported_decoding.is_some()); + assert_eq!(scoped.disable_audio.enum_value(), Ok(BoolOption::Yes)); + assert_eq!(scoped.block_input.enum_value(), Ok(BoolOption::NotSet)); + assert_eq!( + scoped.disable_clipboard.enum_value(), + Ok(BoolOption::NotSet) + ); + assert_eq!( + scoped.enable_file_transfer.enum_value(), + Ok(BoolOption::NotSet) + ); + assert_eq!( + scoped.terminal_persistent.enum_value(), + Ok(BoolOption::NotSet) + ); + } } diff --git a/src/server/video_service.rs b/src/server/video_service.rs index 15f0ef89364..9d97b1ce984 100644 --- a/src/server/video_service.rs +++ b/src/server/video_service.rs @@ -77,7 +77,7 @@ lazy_static::lazy_static! { pub static ref VIDEO_QOS: Arc> = Default::default(); pub static ref IS_UAC_RUNNING: Arc> = Default::default(); pub static ref IS_FOREGROUND_WINDOW_ELEVATED: Arc> = Default::default(); - static ref SCREENSHOTS: Mutex> = Default::default(); + static ref SCREENSHOTS: Mutex> = Default::default(); } struct Screenshot { @@ -192,7 +192,7 @@ impl VideoFrameController { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] pub enum VideoSource { Monitor, Camera, @@ -725,7 +725,8 @@ fn run(vs: VideoService) -> ResultType<()> { Ok(frame) => { repeat_encode_counter = 0; if frame.valid() { - let screenshot = SCREENSHOTS.lock().unwrap().remove(&display_idx); + let screenshot_key = (vs.source, display_idx); + let screenshot = SCREENSHOTS.lock().unwrap().remove(&screenshot_key); if let Some(mut screenshot) = screenshot { let restore_vram = screenshot.restore_vram; let (msg, w, h, data) = match &frame { @@ -754,7 +755,10 @@ fn run(vs: VideoService) -> ResultType<()> { #[cfg(all(windows, feature = "vram"))] VRamEncoder::set_not_use(sp.name(), true); screenshot.restore_vram = true; - SCREENSHOTS.lock().unwrap().insert(display_idx, screenshot); + SCREENSHOTS + .lock() + .unwrap() + .insert(screenshot_key, screenshot); _raii.try_vram = false; bail!("SWITCH"); } @@ -1348,9 +1352,9 @@ fn check_qos( Ok(()) } -pub fn set_take_screenshot(display_idx: usize, sid: String, tx: Sender) { +pub fn set_take_screenshot(source: VideoSource, display_idx: usize, sid: String, tx: Sender) { SCREENSHOTS.lock().unwrap().insert( - display_idx, + (source, display_idx), Screenshot { sid, tx, diff --git a/src/ui_session_interface.rs b/src/ui_session_interface.rs index 1e35672ec8e..59f81562dd9 100644 --- a/src/ui_session_interface.rs +++ b/src/ui_session_interface.rs @@ -1809,10 +1809,12 @@ impl Interface for Session { self.msgbox("error", "Error", msg, ""); return; } - self.try_change_init_resolution(pi.current_display); - let p = self.lc.read().unwrap().should_auto_login(); - if !p.is_empty() { - input_os_password(p, true, self.clone()); + if !self.is_view_camera() { + self.try_change_init_resolution(pi.current_display); + let p = self.lc.read().unwrap().should_auto_login(); + if !p.is_empty() { + input_os_password(p, true, self.clone()); + } } let current = &pi.displays[pi.current_display as usize]; self.set_display(