sources/Google.Solutions.Terminal/Controls/RdpClient.cs (750 lines of code) (raw):

// // Copyright 2024 Google LLC // // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. // using AxMSTSCLib; using Google.Solutions.Common.Diagnostics; using Google.Solutions.Common.Util; using Google.Solutions.Mvvm.Controls; using Google.Solutions.Mvvm.Input; using Google.Solutions.Platform.Interop; using MSTSCLib; using System; using System.ComponentModel; using System.Diagnostics; using System.Drawing; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using System.Windows.Forms; namespace Google.Solutions.Terminal.Controls { /// <summary> /// Wrapper control for the native RDP client. Implements /// a smooth full-screen experience and uses a state machine /// to ensure reliable operation. /// </summary> public partial class RdpClient : ClientBase { private const string WebAuthnPlugin = "webauthn.dll"; /// <summary> /// Maximum length of strings to use for SendString. Beyond a certain /// length, results get dicey. /// </summary> private const int MaxSendStringLength = 256; private readonly Google.Solutions.Tsc.MsRdpClient client; private readonly IMsRdpClientNonScriptable5 clientNonScriptable; private readonly IMsRdpClientAdvancedSettings7 clientAdvancedSettings; private readonly IMsRdpClientSecuredSettings clientSecuredSettings; private readonly IMsRdpExtendedSettings clientExtendedSettings; private readonly DeferredCallback deferResize; private bool reconnectPending = false; private int keysSent = 0; public RdpClient() { this.client = new Google.Solutions.Tsc.MsRdpClient { Enabled = true, Location = new Point(0, 0), Name = "client", Size = new Size(100, 100), }; this.deferResize = new DeferredCallback(PerformDeferredResize, TimeSpan.FromMilliseconds(200)); ((System.ComponentModel.ISupportInitialize)(this.client)).BeginInit(); SuspendLayout(); // // Hook up events. // this.client.OnConnecting += new System.EventHandler(OnRdpConnecting); this.client.OnConnected += new System.EventHandler(OnRdpConnected); this.client.OnLoginComplete += new System.EventHandler(OnRdpLoginComplete); this.client.OnDisconnected += new AxMSTSCLib.IMsTscAxEvents_OnDisconnectedEventHandler(OnRdpDisconnected); this.client.OnRequestGoFullScreen += new System.EventHandler(OnRdpRequestGoFullScreen); this.client.OnRequestLeaveFullScreen += new System.EventHandler(OnRdpRequestLeaveFullScreen); this.client.OnFatalError += new AxMSTSCLib.IMsTscAxEvents_OnFatalErrorEventHandler(OnRdpFatalError); this.client.OnWarning += new AxMSTSCLib.IMsTscAxEvents_OnWarningEventHandler(OnRdpWarning); this.client.OnRemoteDesktopSizeChange += new AxMSTSCLib.IMsTscAxEvents_OnRemoteDesktopSizeChangeEventHandler(OnRdpRemoteDesktopSizeChange); this.client.OnRequestContainerMinimize += new System.EventHandler(OnRdpRequestContainerMinimize); this.client.OnAuthenticationWarningDisplayed += new System.EventHandler(OnRdpAuthenticationWarningDisplayed); this.client.OnLogonError += new AxMSTSCLib.IMsTscAxEvents_OnLogonErrorEventHandler(OnRdpLogonError); this.client.OnFocusReleased += new AxMSTSCLib.IMsTscAxEvents_OnFocusReleasedEventHandler(OnRdpFocusReleased); this.client.OnServiceMessageReceived += new AxMSTSCLib.IMsTscAxEvents_OnServiceMessageReceivedEventHandler(OnRdpServiceMessageReceived); this.client.OnAutoReconnected += new System.EventHandler(OnRdpAutoReconnected); this.client.OnAutoReconnecting2 += new AxMSTSCLib.IMsTscAxEvents_OnAutoReconnecting2EventHandler(OnRdpAutoReconnecting2); this.Controls.Add(this.client); ((System.ComponentModel.ISupportInitialize)(this.client)).EndInit(); // // Set basic configuration. // this.clientSecuredSettings = this.client.SecuredSettings2; this.clientNonScriptable = (IMsRdpClientNonScriptable5)this.client.GetOcx(); this.clientNonScriptable.AllowCredentialSaving = false; this.clientNonScriptable.PromptForCredentials = false; this.clientNonScriptable.NegotiateSecurityLayer = true; this.clientAdvancedSettings = this.client.AdvancedSettings8; this.clientAdvancedSettings.EnableCredSspSupport = true; this.clientAdvancedSettings.keepAliveInterval = 60000; this.clientAdvancedSettings.PerformanceFlags = 0; // Enable all features. this.clientAdvancedSettings.EnableAutoReconnect = true; this.clientAdvancedSettings.MaxReconnectAttempts = 10; this.clientAdvancedSettings.EnableWindowsKey = 1; this.clientAdvancedSettings.allowBackgroundInput = 1; // // Bitmap persistence consumes vast amounts of memory, so keep // it disabled. // this.clientAdvancedSettings.BitmapPersistence = 0; // // Let us handle full-screen mode ourselves. // this.clientAdvancedSettings.ContainerHandledFullScreen = 1; this.clientExtendedSettings = (IMsRdpExtendedSettings)this.client.GetOcx(); } /// <summary> /// The server authentication warning has been displayed. /// </summary> public event EventHandler? ServerAuthenticationWarningDisplayed; /// <summary> /// Wait until a certain state has been reached. Mainly /// intended for testing. /// </summary> internal override async Task AwaitStateAsync(ConnectionState state) { await base .AwaitStateAsync(state) .ConfigureAwait(true); // // There might be a resize pending, await that too. // await this.deferResize .WaitForCompletionAsync() .ConfigureAwait(true); } protected override void OnCurrentParentFormChanged() { this.client.ContainingControl = this.CurrentParentForm; } //--------------------------------------------------------------------- // Closing & disposing. //--------------------------------------------------------------------- protected override void Dispose(bool disposing) { base.Dispose(disposing); this.client.Dispose(); this.deferResize.Dispose(); } protected override void OnFormClosing(object sender, FormClosingEventArgs args) { base.OnFormClosing(sender, args); if (this.State == ConnectionState.Disconnecting) { // // Form is being closed as a result of a disconnect // (not the other way round). // } else if (this.State == ConnectionState.Connecting) { // // Veto this event as it might cause the ActiveX to crash. // TerminalTraceSource.Log.TraceVerbose( "RdpCLient: Aborting FormClosing because control is in connecting"); args.Cancel = true; return; } else if (this.IsContainerFullScreen) { // // Veto this event as it would leave an orphaned full-screen // window. // TerminalTraceSource.Log.TraceVerbose( "RdpCLient: Aborting FormClosing because control is full-screen"); args.Cancel = true; return; } else if ( this.State == ConnectionState.Connected || this.State == ConnectionState.LoggedOn) { // // Attempt an orderly disconnect. // try { TerminalTraceSource.Log.TraceVerbose( "RdpCLient: Disconnecting because form is closing"); // // NB. This does not trigger an OnDisconnected event. // this.client.Disconnect(); OnConnectionClosed(DisconnectReason.FormClosed); } catch (Exception e) { TerminalTraceSource.Log.TraceVerbose( "RdpCLient: Disconnecting failed"); OnConnectionFailed(e); } } // // Eagerly dispose the control. If we don't do it here, // the ActiveX might lock up later. // this.client.Dispose(); } //--------------------------------------------------------------------- // Scaling. //--------------------------------------------------------------------- private static readonly uint DefaultScaleFactor = 100; /// <summary> /// Valid values according to MSDN. /// </summary> private static readonly uint[] ValidDesktopScaleFactors = new uint[] { 500, 400, 300, 250, 200, 175, 150, 125, DefaultScaleFactor }; /// <summary> /// Valid values according to [MS-RDPBCGR]. /// </summary> private static readonly uint[] ValidDeviceScaleFactors = new uint[] { 180, 140, DefaultScaleFactor }; /// <summary> /// The scale factor (as a percentage) applied to Windows Desktop // applications. See [MS-RDPBCGR] for details. /// </summary> internal uint DesktopScaleFactor { get { if (!this.EnableDpiScaling) { return DefaultScaleFactor; } // // Take local DPI scaling factor and round it // to the next lowest valid value. // var factor = LogicalToDeviceUnits(100); return ValidDesktopScaleFactors .SkipWhile(f => f > factor) .First(); } } /// <summary> /// The scale factor as a percentage is applied to Windows Store apps. /// ee [MS-RDPBCGR] for details. /// </summary> internal uint DeviceScaleFactor { get { if (!this.EnableDpiScaling) { return DefaultScaleFactor; } // // Take local DPI scaling factor and round it // to the next lowest valid value. // var factor = LogicalToDeviceUnits(100); return ValidDeviceScaleFactors .SkipWhile(f => f > factor) .First(); } } //--------------------------------------------------------------------- // Resizing. //--------------------------------------------------------------------- protected override void OnResize(EventArgs e) { base.OnResize(e); // // Do not resize immediately since there might be another resize // event coming in a few milliseconds. // this.deferResize.Schedule(); } private void PerformDeferredResize(IDeferredCallbackContext context) { using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { if (this.client.Size == this.Size) { // // Nothing to do here. // } else if (!this.Visible) { // // Form is closing, better not touch anything. // } else if ( fullScreenForm != null && fullScreenForm.WindowState == FormWindowState.Minimized) { // // During a restore, we might receive a request to resize // to normal size. We must ignore that. // } else if (this.State == ConnectionState.NotConnected) { // // Resize control only, no RDP involved yet. // this.client.Size = this.Size; } else if (this.State == ConnectionState.LoggedOn) { // // It's safe to resize in this state. // DangerousResizeClient(this.Size); } else if ( this.State == ConnectionState.Connecting || this.State == ConnectionState.Connected) { // // It's not safe to resize now, but it will // be once we're connected. So try again later. // context.Defer(); } } } private void DangerousResizeClient(Size newSize) { if (this.Size.Width == 0 || this.Size.Height == 0) { // // Probably the window is being minimized. Ignore // that event since it merely causes stress on the // RDP control. // return; } Debug.Assert(!this.client.IsDisposed); // // First, resize the control. // // NB. newSize might be different from this.Size if we're in // full-screen mode. // this.client.Size = newSize; if (!this.EnableAutoResize) { // // Leave remote desktop size as-is. // return; } // // Resize the session. // try { // // Try to adjust settings without reconnecting - this works when // // (1) The server is running 2012R2 or newer // (2) The logon process has completed. // this.client.UpdateSessionDisplaySettings( (uint)newSize.Width, (uint)newSize.Height, (uint)newSize.Width, (uint)newSize.Height, 0, // Landscape this.DesktopScaleFactor, this.DeviceScaleFactor); } catch (COMException e) when (e.HResult == (int)HRESULT.E_UNEXPECTED) { TerminalTraceSource.Log.TraceWarning( "Adjusting desktop size (w/o) reconnect failed."); // // Revert to classic, reconnect-based resizing. // base.OnBeforeConnect(); this.client.Reconnect((uint)newSize.Width, (uint)newSize.Height); } } //--------------------------------------------------------------------- // RDP callbacks. //--------------------------------------------------------------------- private void OnRdpFatalError( object sender, IMsTscAxEvents_OnFatalErrorEvent args) { using (TerminalTraceSource.Log.TraceMethod().WithParameters(args.errorCode)) { // // Make sure to leave full-screen mode. // this.ContainerFullScreen = false; OnConnectionFailed(new RdpFatalException(args.errorCode)); } } private void OnRdpLogonError( object sender, IMsTscAxEvents_OnLogonErrorEvent args) { var e = new RdpLogonException(args.lError); using (TerminalTraceSource.Log.TraceMethod().WithParameters(e)) { // // Make sure to leave full-screen mode. // this.ContainerFullScreen = false; if (!e.IsIgnorable) { OnConnectionFailed(e); } } } private void OnRdpLoginComplete(object sender, EventArgs e) { using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { base.OnAfterLogin(); } } private void OnRdpDisconnected( object sender, IMsTscAxEvents_OnDisconnectedEvent args) { var e = new RdpDisconnectedException( args.discReason, this.client.GetErrorDescription((uint)args.discReason, 0)); using (TerminalTraceSource.Log.TraceMethod().WithParameters(e.Message)) { // // Make sure to leave full-screen mode, otherwise // we're showing a dead control full-screen. // this.ContainerFullScreen = false; // // Force focus back to main window. // this.MainWindow?.Focus(); base.OnBeforeDisconnect(); if (this.reconnectPending) { // // This disconnect is part of a "reconnect" sequence. // OnConnectionClosed(DisconnectReason.ReconnectInitiatedByUser); // // Trigger the reconnect. // Connect(); } else if (this.State != ConnectionState.Connecting && e.IsTimeout) { // // An already-established connection timed out, this is common when // connecting to Windows 10 VMs. // // NB. The same error code can occur during the initial connection, // but then it should be treated as an error. // OnConnectionClosed(DisconnectReason.SessionTimeout); } else if (e.IsUserDisconnectedRemotely) { // // User signed out or clicked Start > Disconnect. // OnConnectionClosed(DisconnectReason.DisconnectedByUser); } else if (e.IsUserDisconnectedLocally) { // // User clicked X in the connection bar or aborted a reconnect. // OnConnectionClosed(DisconnectReason.DisconnectedByUser); } else if (e.IsLogonAborted) { // // User canceled the logon prompt. // OnConnectionClosed(DisconnectReason.DisconnectedByUser); } else if (!e.IsIgnorable) { OnConnectionFailed(e); } } } private void OnRdpConnected(object sender, EventArgs e) { using (TerminalTraceSource.Log.TraceMethod() .WithParameters(this.client.ConnectedStatusText)) { base.OnAfterConnect(); } } private void OnRdpConnecting(object sender, EventArgs e) { Debug.Assert(this.State == ConnectionState.Connecting); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { } } private void OnRdpAuthenticationWarningDisplayed(object sender, EventArgs _) { Debug.Assert(this.State == ConnectionState.Connecting); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { this.ServerAuthenticationWarningDisplayed?.Invoke(this, EventArgs.Empty); } } private void OnRdpWarning( object sender, IMsTscAxEvents_OnWarningEvent args) { using (TerminalTraceSource.Log.TraceMethod().WithParameters(args.warningCode)) { } } private void OnRdpAutoReconnecting2( object sender, IMsTscAxEvents_OnAutoReconnecting2Event args) { Debug.Assert( this.State == ConnectionState.Connecting || this.State == ConnectionState.Connected || this.State == ConnectionState.LoggedOn); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { var e = new RdpDisconnectedException( args.disconnectReason, this.client.GetErrorDescription((uint)args.disconnectReason, 0)); TerminalTraceSource.Log.TraceVerbose( "Reconnect attempt {0}/{1} - {2} - {3}", args.attemptCount, args.maxAttemptCount, e.Message, args.networkAvailable); if (args.networkAvailable) { // // The control is about to connect again. // base.OnBeforeConnect(); } else { // // We're now in a limbo state in which the control // might try to connect again, but it might also // be stuck showing a message that the network // has been lost. If the user cancels, then // the Disconnect procedure is initiated. // // Either way, it's best to leave the state // as is to avoid becoming stuck in Connecting state. // } } } private void OnRdpAutoReconnected(object sender, EventArgs e) { Debug.Assert(this.State == ConnectionState.Connecting); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { base.OnAfterLogin(); } } private void OnRdpFocusReleased( object sender, IMsTscAxEvents_OnFocusReleasedEvent e) { Debug.Assert(this.MainWindow != null); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { // // Release focus and move it to the main window. This ensures // that any other shortcuts start applying again. // this.MainWindow?.Focus(); } } private void OnRdpRemoteDesktopSizeChange( object sender, IMsTscAxEvents_OnRemoteDesktopSizeChangeEvent e) { Debug.Assert( this.State == ConnectionState.Connecting || this.State == ConnectionState.Connected || this.State == ConnectionState.LoggedOn); using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { } } private void OnRdpServiceMessageReceived( object sender, IMsTscAxEvents_OnServiceMessageReceivedEvent e) { using (TerminalTraceSource.Log.TraceMethod().WithParameters(e.serviceMessage)) { } } private void OnRdpRequestGoFullScreen(object sender, EventArgs e) { Debug.Assert( this.State == ConnectionState.Connected || this.State == ConnectionState.LoggedOn); if (this.fullScreenContext == null) { // // Request was initiated by shortcut, not from the // application. Use a default context. // this.fullScreenContext = new FullScreenContext(null); } this.ContainerFullScreen = true; } private void OnRdpRequestLeaveFullScreen(object sender, EventArgs e) { Debug.Assert( this.State == ConnectionState.LoggedOn || (this.State == ConnectionState.Connecting && !this.ContainerFullScreen)); this.ContainerFullScreen = false; } private void OnRdpRequestContainerMinimize(object sender, EventArgs e) { using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { Debug.Assert(fullScreenForm != null); // // Minimize this window. // fullScreenForm!.WindowState = FormWindowState.Minimized; // // Minimize the main form (which is still running in the // back) // if (this.MainWindow != null) { this.MainWindow.WindowState = FormWindowState.Minimized; } } } //--------------------------------------------------------------------- // Public methods. //--------------------------------------------------------------------- /// <summary> /// Outmost window that can be used to pass the focus to. /// In case of an MDI environment, this should be the outmost /// window, not the direct parent window. /// </summary> [Browsable(false)] public Form? MainWindow { get; set; } /// <summary> /// Connect to server. /// </summary> /// <remarks>Errors are reported via events, not exceptions</remarks> public override void Connect() { Debug.Assert(!this.client.IsDisposed); ExpectState(ConnectionState.NotConnected); Precondition.ExpectNotEmpty(this.Server, nameof(this.Server)); Precondition.ExpectNotEmpty(this.Server, nameof(this.Username)); // // Clear reconnect flag so that it doesn't cause multiple // reconnects in sequence. // this.reconnectPending = false; if (this.EnableWebAuthnRedirection) { // // Load WebAuthn plugin. This requires at least 22H2, both // client- and server-side. // // Once the plugin DLL is loaded, WebAuthn redirection is // enabled automatically unless there's a client- or server- // side policy that disabled WebAuthn redirection. // // See also: // https://interopevents.blob.core.windows.net/events/2023/RDP%20IO%20Lab/ \ // PDF/DavidBelanger_Authentication%20-%20RDP%20IO%20Labs%20March%202023.pdf // var webauthnPluginPath = Path.Combine(Environment.SystemDirectory, WebAuthnPlugin); if (File.Exists(webauthnPluginPath)) { try { this.clientAdvancedSettings.PluginDlls = webauthnPluginPath; TerminalTraceSource.Log.TraceInformation( "Loaded RDP plugin {0}", webauthnPluginPath); } catch (Exception e) { TerminalTraceSource.Log.TraceWarning( "Loading RDP plugin {0} failed: {1}", webauthnPluginPath, e.Message); } } } // // Adjust DPI settings of remote session. // // NB. Values must be uint-typed. // NB. The factors must be reapplied when the session // is resized. // NB. When auto-resizing is off and the app is running in // high-DPI mode, the control automatically applies a // factor > 1. This behavior might interfere with // what the user has configured, so it's best to always // set the factor explicitly. // // Given the inter-dependence between local DPI settings, // EnableAutoResize and EnableDpiScaling, we must consider // (and verify) all of the following combinations: // // Local DPI EnableAutoResize EnableDpiScaling // --------- ---------------- ---------------- // 100 On On // 100 On Off // 100 Off On // 100 Off Off // > 100 On On // > 100 On Off // > 100 Off On // > 100 Off Off // if (this.DesktopScaleFactor is var desktopFactor) { this.clientExtendedSettings.set_Property( "DesktopScaleFactor", desktopFactor); } if (this.DeviceScaleFactor is var deviceFactor) { this.clientExtendedSettings.set_Property( "DeviceScaleFactor", deviceFactor); } // // Reset state in case we're connecting for the second time. // base.OnBeforeConnect(); this.client.FullScreen = false; this.client.Size = this.Size; if (this.EnableAutoResize) { // // Size to fit. // this.client.DesktopHeight = this.Size.Height; this.client.DesktopWidth = this.Size.Width; } else { // // Set to current screen's resolution, which means // the desktop size is the same in full-screen and // regular mode. // var screenSize = Screen.GetBounds(this); this.client.DesktopHeight = screenSize.Height; this.client.DesktopWidth = screenSize.Width; } try { this.client.Connect(); } catch (Exception e) { OnConnectionFailed(e); } } /// <summary> /// Disconnect and connect again. /// </summary> /// <remarks>Errors are reported via events, not exceptions</remarks> public void Reconnect() { Debug.Assert(this.State == ConnectionState.LoggedOn); if (this.State != ConnectionState.LoggedOn) { return; } // // Set reconnect flag and disconnect. This triggers // an Disconnected event, which in turn should trigger // a new connection attempt. // this.reconnectPending = true; this.client.Disconnect(); } //--------------------------------------------------------------------- // Synthetic input. //--------------------------------------------------------------------- private void SendScanCodes( short[] keyUp, int[] keyData) { // // NB. It's crucial to set the focus here again, otherwise // key chords don't work. // this.client.Focus(); // // NB. The tlbimp-generated IMsRdpClientNonScriptable5 uses // a signature for SendKeys that doesn't work with C-style arrays. // Therefore, we use a manually fixed version of IMsRdpClientNonScriptable5. // // According to MSDN, scan codes need to be sent in // "WM_KEYDOWN lParam format", but that seems incorrect. Instead, // the API expects raw scan codes. // var obj = (IMsRdpClientNonScriptable5_SendKeys)this.client.GetOcx(); obj.SendKeys(keyData.Length, keyUp, keyData); } /// <summary> /// Send a sequence of virtual keys. Keys may use modifiers. /// </summary> private void SendVirtualKey(Keys virtualKey) { var keyboard = KeyboardLayout.Current; // // The RDP control sometimes swallows the first key combination // that is sent. So start by a harmless ESC. // if (this.keysSent++ == 0) { var escScanCode = keyboard.ToScanCodes(Keys.Escape).First(); SendScanCodes( new short[] { 0 }, new int[] { (int)escScanCode }); } // // Convert virtual key code (which might contain modifiers) // into a sequence of scan codes. // var scanCodes = keyboard .ToScanCodes(virtualKey) .Select(c => (int)c) .ToArray(); // // If the key has modifers other than Shift, we have to send // separate DOWN and UP keystrokes for each scan code. // // Curiously, we must not do this for "normal" characters // (single scan code), otherwise we end up with duplicate // characters. // short[] keyUp; int[] keyData; if ((virtualKey & (Keys.Control | Keys.Alt)) != 0 && scanCodes.Length > 1) { // // Convert scan codes into simulated DOWN- and UP- key // presses. // keyUp = new short[scanCodes.Length * 2]; keyData = new int[scanCodes.Length * 2]; for (var i = 0; i < scanCodes.Length; i++) { // // Generate DOWN key presses. // keyUp[i] = VariantBool.False; keyData[i] = scanCodes[i]; // // Generate UP key presses (in reverse order). // keyUp[keyUp.Length - 1 - i] = VariantBool.True; keyData[keyData.Length - 1 - i] = scanCodes[i]; } } else { // // Generate DOWN key press only. // keyUp = new short[scanCodes.Length]; // DOWN. keyData = scanCodes; } SendScanCodes(keyUp, keyData); } /// <summary> /// Simulate a key chord to show the security screen. /// </summary> public void ShowSecurityScreen() { Debug.Assert(this.State == ConnectionState.LoggedOn); if (this.State != ConnectionState.LoggedOn) { return; } using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { SendVirtualKey(Keys.Control | Keys.Alt | Keys.Delete); } } /// <summary> /// Simulate a key chord toopen task manager. /// </summary> public void ShowTaskManager() { Debug.Assert(this.State == ConnectionState.LoggedOn); if (this.State != ConnectionState.LoggedOn) { return; } using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { SendVirtualKey(Keys.Control | Keys.Shift | Keys.Escape); } } /// <summary> /// Log off user (as opposed to disconnecting the session). /// </summary> /// <remarks> /// There's no API to log off the user programatically, so /// we have to make a best-effort attempt of initiating a logoff /// by sending keystrokes. /// </remarks> public void Logoff() { Debug.Assert(this.State == ConnectionState.LoggedOn); if (this.State != ConnectionState.LoggedOn) { return; } using (TerminalTraceSource.Log.TraceMethod().WithoutParameters()) { SendVirtualKey(Keys.Control | Keys.Alt | Keys.Delete); // // We have to wait a bit before we can send the next // keys. // DeferredCallback? deferredCallback = null; deferredCallback = new DeferredCallback( ctx => { // // Select the second option on the list. // // NB. Navigating to the second item is slightly // better than sending an Alt+S, because accelerators // might vary by display language. // try { SendVirtualKey(Keys.Down); SendVirtualKey(Keys.Enter); } catch { } deferredCallback?.Dispose(); }, TimeSpan.FromSeconds(1)); deferredCallback.Schedule(); } } /// <summary> /// Simulate key strokes to send a piece of text. /// </summary> public override void SendText(string text) { Debug.Assert(this.State == ConnectionState.LoggedOn); if (this.State != ConnectionState.LoggedOn) { return; } if (string.IsNullOrEmpty(text)) { return; } var keyboardLayout = KeyboardLayout.Current; for (var i = 0; i < text.Length && i < MaxSendStringLength; i++) { var ch = text[i]; if (ch == '\r' && i < text.Length - 2 && text[i + 1] == '\n') { // // Ignore a CR if it's part of a CRLF. // continue; } if (keyboardLayout.TryMapVirtualKey(ch, out var vk)) { // // This is a "mormal" character with a corresponding // virtual key on the current keyboard layout. // SendVirtualKey(vk); } else { // // This is a ligature or any kind of character that // has no corresponding virtual on the current keyboard layout. // // Converting the character to a Alt+0nnn sequence doesn't // work reliably, so we just send a '?'. // if (keyboardLayout.TryMapVirtualKey('?', out var questionMark)) { SendVirtualKey(questionMark); } } } } //--------------------------------------------------------------------- // Full-screen mode. // // NB. In container-handled mode, setting FullScreen to true.. // // - calls the OnRequestGoFullScreen event, // - shows the connection bar (if enabled) // - changes hotkeys // // However, it does not resize the control automatically. //--------------------------------------------------------------------- private FullScreenContext? fullScreenContext = null; private static Form? fullScreenForm = null; private static void MoveControls(Control source, Control target) { var controls = new Control[source.Controls.Count]; source.Controls.CopyTo(controls, 0); source.Controls.Clear(); target.Controls.AddRange(controls); Debug.Assert(source.Controls.Count == 0); } public override bool IsContainerFullScreen { get => this.ContainerFullScreen; } /// <summary> /// Gets or sets full-scren mode for the containing window. /// /// This property should only be changed from within RDP /// callbacks. /// </summary> protected bool ContainerFullScreen { get => fullScreenForm != null && fullScreenForm.Visible; private set { if (value == this.ContainerFullScreen) { // // Nothing to do. // return; } else if (value) { Debug.Assert(this.fullScreenContext != null); Debug.Assert(this.CurrentParentForm != null); // // Enter full-screen. // // To provide a true full screen experience, we create a // new window and temporarily move all controls to this window. // // NB. The RDP ActiveX has some quirk where the connection bar // disappears when you go full-screen a second time and the // hosting window is different from the first time. // By using a single/static window and keeping it around // after first use, we ensure that the form is always the // same, thus circumventing the quirk. // if (fullScreenForm == null) { // // First time to go full screen, create the // full-screen window. // fullScreenForm = new Form() { Icon = this.CurrentParentForm!.Icon, FormBorderStyle = FormBorderStyle.None, StartPosition = FormStartPosition.Manual, TopMost = true, ShowInTaskbar = false }; } // // Use current screen bounds if none specified. // fullScreenForm.Bounds = this.fullScreenContext!.Bounds ?? Screen.FromControl(this).Bounds; MoveControls(this, fullScreenForm); // // Set the parent to the window we want to bring to the front // when the user clicks minimize on the conection bar. // Debug.Assert(this.MainWindow != null); fullScreenForm.Show(this.MainWindow); // // Resize to fit new form. // DangerousResizeClient(fullScreenForm.Size); } else { Debug.Assert(fullScreenForm != null); // // Return from full-screen. // MoveControls(fullScreenForm!, this); // // Only hide the window, we might need it again. // fullScreenForm!.Hide(); // // Resize back to original size. // this.deferResize.Schedule(); this.fullScreenContext = null; Debug.Assert(!this.ContainerFullScreen); } } } /// <summary> /// Check if any instance of this control currently uses /// full-screen mode. /// </summary> private static bool IsFullScreenFormVisible { get => fullScreenForm != null && fullScreenForm.Visible; } /// <summary> /// Check if the client is currently in full-screen mode. /// </summary> [Browsable(false)] public bool IsFullScreen { get { try { return this.client.FullScreen; } catch { return false; } } } /// <summary> /// Check if the current state is suitable for entering /// full-screen mode. /// </summary> [Browsable(false)] public bool CanEnterFullScreen { get => this.State == ConnectionState.LoggedOn && !IsFullScreenFormVisible; } /// <summary> /// Enter full screen mode. /// </summary> public bool TryEnterFullScreen(Rectangle? customBounds) { if (this.MainWindow == null) { throw new InvalidOperationException("Main window must be set"); } if (!this.CanEnterFullScreen) { return false; } this.fullScreenContext = new FullScreenContext(customBounds); this.client.FullScreenTitle = this.ConnectionBarText; this.client.FullScreen = true; return true; } /// <summary> /// Enter full screen mode. /// </summary> public bool TryEnterFullScreen() { return TryEnterFullScreen(null); } /// <summary> /// Leave full-screen mode. /// </summary> public bool TryLeaveFullScreen() { if (!this.IsFullScreen) { return false; } this.client.FullScreen = false; return true; } //--------------------------------------------------------------------- // Inner classes. //--------------------------------------------------------------------- private class FullScreenContext { public Rectangle? Bounds { get; } public FullScreenContext(Rectangle? bounds) { this.Bounds = bounds; } } } }