sources/Google.Solutions.Terminal/Controls/ClientBase.cs (147 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 Google.Solutions.Mvvm.Binding;
using Google.Solutions.Mvvm.Controls;
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace Google.Solutions.Terminal.Controls
{
/// <summary>
/// Base class for terminal clients.
///
/// Some operations only work reliably when the control is in a certain
/// state. In particular, this applies to the MSTSCAX client (which won't
/// reliably tell us which state it is in), but applies to other clients
/// as well.
///
/// Thus, we maintain a state machine to track the control's state.
/// </summary>
public abstract class ClientBase : ParentedUserControl
{
private ConnectionState state = ConnectionState.NotConnected;
/// <summary>
/// Connection state has changed.
/// </summary>
public event EventHandler? StateChanged;
/// <summary>
/// Connection closed abnormally.
/// </summary>
public event EventHandler<ExceptionEventArgs>? ConnectionFailed;
/// <summary>
/// Connection closed normally.
/// </summary>
public event EventHandler<ConnectionClosedEventArgs>? ConnectionClosed;
/// <summary>
/// Connect to server.
/// </summary>
public abstract void Connect();
/// <summary>
/// Simulate key strokes to send a piece of text.
/// </summary>
public abstract void SendText(string text);
private readonly ClientStatePanel statePanel;
protected ClientBase()
{
#if DEBUG
//
// Show label in top-right corner that indicates the current state.
//
var stateLabel = new Label()
{
AutoSize = true,
BackColor = Color.Black,
ForeColor = Color.White,
};
this.Controls.Add(stateLabel);
this.StateChanged += (_, args) => stateLabel.Text = this.State.ToString();
#endif
//
// Show an overlay panel whenever the client is not connected.
//
this.statePanel = new ClientStatePanel();
this.Controls.Add(this.statePanel);
this.Resize += (_, args) =>
{
this.statePanel.Size = this.Size;
};
this.StateChanged += (_, args) =>
{
this.statePanel.State = this.State;
//
// NB. We check IsContainerFullScreen as opposed to
// IsFullScreen here because IsFullScreen (at least
// in the case of the RdpClient) is only updated
// asynchronously after leaving full-screen.
//
// One particular example where this difference shows
// is when the RDP session is disconnected because of
// a session timeout while in full-screen mode.
//
this.statePanel.Visible = !this.IsContainerFullScreen && (
this.State == ConnectionState.NotConnected ||
this.State == ConnectionState.Disconnecting ||
this.State == ConnectionState.Connecting);
};
this.statePanel.ConnectButtonClicked += (_, args) => Connect();
}
public virtual void Bind(IBindingContext bindingContext)
{ }
/// <summary>
/// Check if the client is currently hosted in a full-screen container.
/// </summary>
[Browsable(false)]
public virtual bool IsContainerFullScreen
{
get => false;
}
//---------------------------------------------------------------------
// Connection state tracking.
//---------------------------------------------------------------------
/// <summary>
/// Current state of the connection.
/// </summary>
[Browsable(false)]
public ConnectionState State
{
get => this.state;
private set // Only to be mutated by OnXxx methods.
{
Debug.Assert(!this.InvokeRequired);
if (this.state != value)
{
this.state = value;
OnStateChanged();
}
}
}
protected virtual void OnStateChanged()
{
this.StateChanged?.Invoke(this, EventArgs.Empty);
}
protected void ExpectState(ConnectionState expectedState)
{
if (this.State != expectedState)
{
throw new InvalidOperationException(
$"Operation is not allowed in state {this.State}");
}
}
/// <summary>
/// Wait until a certain state has been reached. Mainly
/// intended for testing.
/// </summary>
internal virtual async Task AwaitStateAsync(ConnectionState state)
{
Debug.Assert(!this.InvokeRequired);
if (this.State == state)
{
return;
}
var completionSource = new TaskCompletionSource<ConnectionState>();
void onStateChanged(object sender, EventArgs args)
{
if (this.State == state)
{
this.StateChanged -= onStateChanged;
completionSource.SetResult(this.State);
}
}
this.StateChanged += onStateChanged;
await completionSource
.Task
.ConfigureAwait(true);
}
protected virtual void OnBeforeConnect()
{
this.State = ConnectionState.Connecting;
}
protected virtual void OnConnectionFailed(Exception e)
{
this.ConnectionFailed?.Invoke(this, new ExceptionEventArgs(e));
this.State = ConnectionState.NotConnected;
}
protected virtual void OnConnectionClosed(DisconnectReason reason)
{
this.ConnectionClosed?.Invoke(
this,
new ConnectionClosedEventArgs(reason));
this.State = ConnectionState.NotConnected;
}
protected virtual void OnAfterConnect()
{
this.State = ConnectionState.Connected;
}
protected virtual void OnAfterLogin()
{
this.State = ConnectionState.LoggedOn;
}
protected virtual void OnBeforeDisconnect()
{
this.State = ConnectionState.Disconnecting;
}
//---------------------------------------------------------------------
// Inner types.
//---------------------------------------------------------------------
public class ConnectionClosedEventArgs : EventArgs
{
internal ConnectionClosedEventArgs(DisconnectReason reason)
{
this.Reason = reason;
}
public DisconnectReason Reason { get; }
}
public enum DisconnectReason
{
/// <summary>
/// The session timed out and was disconnected by the server.
/// </summary>
SessionTimeout,
/// <summary>
/// The session was disconnected by the user.
/// </summary>
DisconnectedByUser,
/// <summary>
/// The user closed the window/form that controlled the session.
/// </summary>
FormClosed,
/// <summary>
/// The user requested the session to be reconnected.
/// </summary>
ReconnectInitiatedByUser,
}
}
}