using System.Drawing;
using System.Drawing.Imaging;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using Google.Protobuf;
using Serilog;
using WindowStreamer.Client.Exceptions;
using WindowStreamer.Protocol;
namespace WindowStreamer.Client;
///
/// Contains all network logic related to communicating to the server.
/// TODO: Divide into multiple classes, to make this have a single concern instead of all network logic.
///
public class WindowClient : IDisposable
{
static readonly int DefaultMetastreamPort = 10063;
static readonly int PacketCount = 128;
readonly IPEndPoint serverEndpoint;
readonly int framerateCap;
readonly CancellationTokenSource metastreamToken;
readonly CancellationTokenSource videostreamToken;
IDisposable? videoClientDisposable;
IDisposable? tcpClientDisposable;
bool connectionClosedCalled;
///
/// Initializes a new instance of the class.
///
/// The IP address of the server to connect to.
/// The port of the TCP server for control messages (metastream).
/// The amount of image frames per second to ask the server to send.
public WindowClient(IPAddress serverIP, int serverPort, int framerateCap)
{
serverEndpoint = new IPEndPoint(serverIP, serverPort);
this.framerateCap = framerateCap;
metastreamToken = new CancellationTokenSource();
videostreamToken = new CancellationTokenSource();
}
///
/// Event called when a complete frame has been received.
///
public event Action? NewFrame;
///
/// Event called when the resolution is changed.
///
public event Action? ResolutionChanged;
///
/// Event called when the client has recieved a response from server, either by message (deny/accept), or action (socket forcefully closed).
///
public event Action? ConnectionAttemptFinished;
///
/// Event called when the client has been disconnected from server, either by server closing, or by being kicked from server.
///
public event Action? ConnectionClosed;
///
/// Tries to connect to server, returns whether the connection was successfully established.
/// ConnectionAttemptFinished is not invoked during this method.
///
/// Whether the connection was sucessful.
public async Task ConnectToServerAsync()
{
if (tcpClientDisposable is not null || videoClientDisposable is not null)
{
// Fatal because this should ever happen.
Log.Fatal("Client already connected, aborting new connection");
throw new InstanceAlreadyInUseException("This client has been connected previously. Please make a new instance instead.");
}
TcpClient newClient = new TcpClient();
tcpClientDisposable = newClient;
Log.Information($"Connecting to {serverEndpoint.Address}:{serverEndpoint.Port}...");
try
{
await newClient.ConnectAsync(serverEndpoint.Address, serverEndpoint.Port);
}
catch (SocketException)
{
Log.Information("Connection unsuccessful.");
return false;
}
Log.Information($"Awaiting response from {serverEndpoint}");
Task.Run(() => MetastreamLoop(newClient), metastreamToken.Token);
return true;
}
public void Dispose()
{
metastreamToken?.Cancel();
videostreamToken?.Cancel();
videoClientDisposable?.Dispose();
tcpClientDisposable?.Dispose();
}
void InvokeNewFrame(byte[] imageData, ushort width, ushort height)
{
Log.Debug($"New frame size: {imageData.Length}");
var bmp = new Bitmap(width, height);
BitmapData bmpData = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb);
IntPtr imageDataPtr = bmpData.Scan0;
Marshal.Copy(imageData, 0, imageDataPtr, imageData.Length);
bmp.UnlockBits(bmpData);
NewFrame?.Invoke(bmp);
}
void ClientDisconnected()
{
if (connectionClosedCalled)
{
return;
}
connectionClosedCalled = true;
ConnectionClosed?.Invoke();
}
///
/// Listens for videodatagrams, is stopped when videostreamToken is cancelled, or metaClient is disconnected.
/// Returns void because it's a loop.
///
async void ListenVideoDatagramAsync(TcpClient metaClient, int videoPort)
{
byte[]? image = null;
ushort imageWidth = 0;
ushort imageHeight = 0;
var chunks = new HashSet();
UdpClient videoClient = new UdpClient(videoPort);
videoClient.Client.ReceiveBufferSize = 1_024_000;
videoClientDisposable = videoClient;
while (metaClient.Connected)
{
UdpReceiveResult received;
try
{
received = await videoClient.ReceiveAsync(videostreamToken.Token);
}
catch (SocketException)
{
ClientDisconnected();
return;
}
catch (OperationCanceledException)
{
ClientDisconnected();
return;
}
ushort chunkIndex = BitConverter.ToUInt16(received.Buffer, 0);
int totalSizeBytes = BitConverter.ToInt32(received.Buffer, sizeof(ushort));
int chunkSizeBytes = ((totalSizeBytes - 1) / PacketCount) + 1;
ushort width = BitConverter.ToUInt16(received.Buffer, sizeof(ushort) + sizeof(int));
ushort height = BitConverter.ToUInt16(received.Buffer, sizeof(ushort) + sizeof(int) + sizeof(ushort));
if (image is null || width != imageWidth || height != imageHeight)
{
imageWidth = width;
imageHeight = height;
image = new byte[totalSizeBytes];
chunks.Clear();
}
chunks.Add(chunkIndex);
int chunkOffsetBytes = chunkSizeBytes * chunkIndex;
int imageDataOffset = sizeof(ushort) + sizeof(int) + sizeof(ushort) + sizeof(ushort);
Log.Debug($"Chunk {chunkIndex} size: {received.Buffer.Length - imageDataOffset} / {chunkSizeBytes}, offset value: {image[chunkOffsetBytes]}");
Buffer.BlockCopy(
received.Buffer,
imageDataOffset,
image,
chunkOffsetBytes,
received.Buffer.Length - imageDataOffset);
if (chunks.Count == PacketCount)
{
chunks.Clear();
// Bring new frame to display on different thread to speed up packet processing on this thread.
Task.Run(() => InvokeNewFrame((byte[])image.Clone(), imageWidth, imageHeight), videostreamToken.Token);
}
}
}
///
/// Listens for events from the server.
/// Returns void because it's a loop.
///
async void MetastreamLoop(TcpClient metaClient)
{
var stream = metaClient.GetStream();
while (metaClient.Connected)
{
ServerMessage msg;
try
{
msg = ServerMessage.Parser.ParseDelimitedFrom(stream);
}
catch (IOException ex)
{
Log.Debug(ex.ToString());
ClientDisconnected();
return;
}
catch (TaskCanceledException)
{
ClientDisconnected();
return;
}
switch (msg.MsgCase)
{
case ServerMessage.MsgOneofCase.None:
Log.Debug("Received unknown message");
break;
case ServerMessage.MsgOneofCase.ConnectionReply:
Log.Debug("Recieved connection reply");
var connReply = msg.ConnectionReply;
if (!connReply.Accepted)
{
// TODO: implement some connection ended logic.
Log.Information("Connection request denied :(");
ConnectionAttemptFinished?.Invoke(false);
return;
}
// Connection accepted, initialize udp-client and image-loop
Log.Information("Connection request accepted, awaiting handshake finish...");
Task.Run(() => ListenVideoDatagramAsync(metaClient, connReply.VideoPort), videostreamToken.Token);
// Finish handshake
new ClientMessage
{
UDPReady = new UDPReady
{
FramerateCap = framerateCap,
},
}.WriteDelimitedTo(stream);
Log.Information("Handshake finished");
ConnectionAttemptFinished?.Invoke(true);
break;
case ServerMessage.MsgOneofCase.ResolutionChange:
Log.Debug("Recieved resolution update");
ResolutionChanged?.Invoke(new Size(msg.ResolutionChange.Width, msg.ResolutionChange.Height));
break;
}
}
Log.Information("Connection lost... or disconnected");
}
}