XIVChat/XIVChat Desktop/Connection.cs

469 lines
19 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Threading;
using MessagePack;
using Sentry;
using XIVChatCommon;
using XIVChatCommon.Message;
using XIVChatCommon.Message.Client;
using XIVChatCommon.Message.Relay;
using XIVChatCommon.Message.Server;
namespace XIVChat_Desktop {
public class Connection : INotifyPropertyChanged {
#if DEBUG
private static readonly IEnumerable<byte> RelayPublicKey = new byte[] {
8, 202, 178, 253, 125, 176, 212, 227, 110, 108, 113, 80, 110, 126, 57, 248, 182, 251, 122, 48, 80, 49, 57, 202, 119, 126, 69, 66, 170, 25, 126, 115,
};
#else
private static readonly IEnumerable<byte> RelayPublicKey = new byte[] {
194, 81, 22, 123, 80, 172, 145, 167, 212, 251, 198, 173, 55, 160, 11, 18, 247, 11, 210, 6, 98, 43, 102, 73, 54, 255, 214, 233, 144, 193, 98, 47
};
#endif
#if DEBUG
private const string RelayHost = "localhost";
private const ushort RelayPort = 14725;
#else
private const string RelayHost = "relay.xiv.chat";
private const ushort RelayPort = 14777;
#endif
private readonly App app;
private bool _relay;
private readonly string? _host;
private readonly ushort? _port;
private readonly string? _relayAuth;
private readonly string? _relayTarget;
private TcpClient? client;
private readonly Channel<string> outgoing = Channel.CreateUnbounded<string>();
private readonly Channel<byte[]> outgoingMessages = Channel.CreateUnbounded<byte[]>();
private readonly Channel<byte[]> incoming = Channel.CreateUnbounded<byte[]>();
private readonly Channel<byte> cancelChannel = Channel.CreateBounded<byte>(2);
public readonly CancellationTokenSource cancel = new();
public delegate void ReceiveMessageDelegate(ServerMessage message);
public event ReceiveMessageDelegate? ReceiveMessage;
public event PropertyChangedEventHandler? PropertyChanged;
public string? CurrentChannel { get; private set; }
public bool Available { get; set; }
public Connection(App app, DirectServer server) {
this.app = app;
this._relay = false;
this._host = server.Host;
this._port = server.Port;
}
public Connection(App app, RelayServer server) {
this.app = app;
this._relay = true;
this._relayAuth = server.RelayAuth;
this._relayTarget = server.RelayTarget;
}
// ReSharper disable once UnusedMember.Local
private void OnAvailableChanged() {
this.app.Window.OnPropertyChanged(nameof(MainWindow.InputPlaceholder));
}
public void SendMessage(string message) {
this.outgoing.Writer.TryWrite(message);
}
public void RequestFriendList() {
var msg = new ClientPlayerList {
Type = PlayerListType.Friend,
};
this.outgoingMessages.Writer.TryWrite(msg.Encode());
}
public void ChangeChannel(InputChannel channel) {
var msg = new ClientChannel {
Channel = channel,
};
this.outgoingMessages.Writer.TryWrite(msg.Encode());
}
public void Disconnect() {
this.cancel.Cancel();
for (var i = 0; i < 2; i++) {
this.cancelChannel.Writer.TryWrite(1);
}
}
public async Task Connect() {
switch (this._relay) {
case false when this._host == null || this._port == null:
throw new ApplicationException("Not using relay but host or port was null");
case true when this._relayAuth == null || this._relayTarget == null:
throw new ApplicationException("Using relay but auth or target was null");
}
var host = this._host ?? RelayHost;
var port = this._port ?? RelayPort;
SentrySdk.AddBreadcrumb(
category: "connection",
message: "Attempted connection",
data: new Dictionary<string, string> {
["host"] = host,
["port"] = port.ToString(),
["relayTarget"] = this._relayAuth ?? "none",
}
);
this.client = new TcpClient(host, port);
var stream = this.client.GetStream();
// write the magic bytes
await stream.WriteAsync(new byte[] {
14, 20, 67,
});
// authenticate with relay if necessary
if (this._relay) {
var relayHandshake = await KeyExchange.ClientHandshake(this.app.Config.KeyPair, stream);
// ensure the relay's public key is what we expect
if (!relayHandshake.RemotePublicKey.SequenceEqual(RelayPublicKey)) {
this.app.Dispatch(() => {
MessageBox.Show("Unexpected relay public key.");
});
return;
}
async Task<RelaySuccess> ReadSuccess() {
var response = await SecretMessage.ReadSecretMessage(stream, relayHandshake.Keys.rx);
return MessagePackSerializer.Deserialize<RelaySuccess>(response);
}
// create registration message
var reg = new RelayRegister {
AuthToken = this._relayAuth!,
PublicKey = Util.StringToByteArray(this._relayTarget!),
};
var regBytes = MessagePackSerializer.Serialize(reg);
// send registration message
await SecretMessage.SendSecretMessage(stream, relayHandshake.Keys.tx, regBytes);
var regSuccess = await ReadSuccess();
if (!regSuccess.Success) {
this.app.Dispatch(() => MessageBox.Show($"Relay rejected connection:\n{regSuccess.Info}"));
return;
}
}
// do the handshake
var handshake = await KeyExchange.ClientHandshake(this.app.Config.KeyPair, stream);
// check for trust and prompt if not
if (!this.app.Config.TrustedKeys.Any(trusted => trusted.Key.SequenceEqual(handshake.RemotePublicKey))) {
var trustChannel = Channel.CreateBounded<bool>(1);
this.app.Dispatch(() => {
new TrustDialog(this.app.Window, trustChannel.Writer, handshake.RemotePublicKey).Show();
});
var trusted = await trustChannel.Reader.ReadAsync(this.cancel.Token);
if (!trusted) {
SentrySdk.AddBreadcrumb(
category: "connection",
message: "Failed trust process"
);
goto Close;
}
}
// clear messages if connecting to a different host
var currentHost = $"{this._host}:{this._port}";
var sameHost = this.app.LastHost == currentHost;
if (!sameHost) {
this.app.Dispatch(() => {
this.app.Window.ClearAllMessages();
this.app.LastHost = currentHost;
});
}
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Connected");
});
SentrySdk.AddBreadcrumb(
category: "connection",
message: "Established connection"
);
// tell the server our preferences
var preferences = new ClientPreferences {
Preferences = new Dictionary<ClientPreference, object> {
[ClientPreference.BacklogNewestMessagesFirst] = true,
[ClientPreference.TargetingListSupport] = true,
},
};
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, preferences, this.cancel.Token);
// check if backlog or catch-up is needed
if (sameHost) {
// catch-up
var lastRealMessage = this.app.Window.Messages.LastOrDefault(msg => msg.Channel != 0);
if (lastRealMessage != null) {
_backlogSequence += 1;
var catchUp = new ClientCatchUp(lastRealMessage.Timestamp);
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, catchUp, this.cancel.Token);
}
} else if (this.app.Config.BacklogMessages > 0) {
// backlog
_backlogSequence += 1;
var backlogReq = new ClientBacklog {
Amount = this.app.Config.BacklogMessages,
};
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, backlogReq, this.cancel.Token);
}
// start a task for accepting incoming messages and sending them down the channel
_ = Task.Run(async () => {
var inc = SecretMessage.ReadSecretMessage(stream, handshake.Keys.rx, this.cancel.Token);
var cancel = this.cancelChannel.Reader.ReadAsync().AsTask();
while (!this.cancel.IsCancellationRequested) {
var result = await Task.WhenAny(inc, cancel);
if (result == inc) {
var ex = inc.Exception;
if (ex != null) {
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Error reading incoming message.");
// ReSharper disable once LocalizableElement
Console.WriteLine($"Error reading incoming message: {ex.Message}");
foreach (var inner in ex.InnerExceptions) {
Console.WriteLine(inner.StackTrace);
}
});
if (ex.InnerException is not CryptographicException) {
this.app.Disconnect();
break;
}
}
var rawMessage = await inc;
inc = SecretMessage.ReadSecretMessage(stream, handshake.Keys.rx, this.cancel.Token);
await this.incoming.Writer.WriteAsync(rawMessage);
} else if (result == cancel) {
break;
}
}
});
var incoming = this.incoming.Reader.ReadAsync().AsTask();
var outgoing = this.outgoing.Reader.ReadAsync().AsTask();
var outgoingMessage = this.outgoingMessages.Reader.ReadAsync().AsTask();
var cancel = this.cancelChannel.Reader.ReadAsync().AsTask();
// listen for incoming and outgoing messages and cancel requests
while (!this.cancel.IsCancellationRequested) {
var result = await Task.WhenAny(incoming, outgoing, outgoingMessage, cancel);
if (result == incoming) {
if (this.incoming.Reader.Completion.IsCompleted) {
break;
}
var rawMessage = await incoming;
incoming = this.incoming.Reader.ReadAsync().AsTask();
await this.HandleIncoming(rawMessage);
} else if (result == outgoing) {
var toSend = await outgoing;
outgoing = this.outgoing.Reader.ReadAsync().AsTask();
var message = new ClientMessage(toSend);
try {
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, message, this.cancel.Token);
} catch (Exception ex) {
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Error sending message.");
// ReSharper disable once LocalizableElement
Console.WriteLine($"Error sending message: {ex.Message}");
Console.WriteLine(ex.StackTrace);
});
break;
}
} else if (result == outgoingMessage) {
var toSend = await outgoingMessage;
outgoingMessage = this.outgoingMessages.Reader.ReadAsync().AsTask();
try {
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, toSend, this.cancel.Token);
} catch (Exception ex) {
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Error sending message.");
// ReSharper disable once LocalizableElement
Console.WriteLine($"Error sending message: {ex.Message}");
Console.WriteLine(ex.StackTrace);
});
break;
}
} else if (result == cancel) {
try {
// NOTE: purposely not including cancellation token because it will already be cancelled here
// and we need to send this message
await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, ClientShutdown.Instance);
} catch (Exception ex) {
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Error sending message.");
// ReSharper disable once LocalizableElement
Console.WriteLine($"Error sending message: {ex.Message}");
Console.WriteLine(ex.StackTrace);
});
}
break;
}
}
// remove player data
this.SetPlayerData(null);
// set availability
this.Available = false;
// at this point, we are disconnected, so log it
this.app.Dispatch(() => {
this.app.Window.AddSystemMessage("Disconnected");
});
SentrySdk.AddBreadcrumb(
category: "connection",
message: "Disconnected from server"
);
// wait up to a second to send the shutdown packet
await Task.WhenAny(Task.Delay(1_000), SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, ClientShutdown.Instance));
Close:
try {
this.client.Close();
} catch (ObjectDisposedException) {
}
}
private async Task HandleIncoming(byte[] rawMessage) {
var type = (ServerOperation) rawMessage[0];
var payload = new byte[rawMessage.Length - 1];
Array.Copy(rawMessage, 1, payload, 0, payload.Length);
switch (type) {
case ServerOperation.Pong:
// no-op
break;
case ServerOperation.Message:
var message = ServerMessage.Decode(payload);
this.app.Dispatch(() => {
this.ReceiveMessage?.Invoke(message);
this.app.Window.AddMessage(message);
});
break;
case ServerOperation.Shutdown:
this.app.Disconnect();
break;
case ServerOperation.PlayerData:
var playerData = payload.Length == 0 ? null : PlayerData.Decode(payload);
this.SetPlayerData(playerData);
break;
case ServerOperation.Availability:
var availability = Availability.Decode(payload);
this.Available = availability.available;
break;
case ServerOperation.Channel:
var channel = ServerChannel.Decode(payload);
this.app.Dispatch(() => this.CurrentChannel = channel.name);
break;
case ServerOperation.Backlog:
var backlog = ServerBacklog.Decode(payload);
var seq = _backlogSequence;
foreach (var msg in backlog.messages.ToList().Chunks(100)) {
msg.Reverse();
var array = msg.ToArray();
this.app.Dispatch(DispatcherPriority.Background, () => {
this.app.Window.AddReversedChunk(array, seq);
});
}
break;
case ServerOperation.PlayerList:
var playerList = ServerPlayerList.Decode(payload);
switch (playerList.Type) {
case PlayerListType.Friend: {
var players = playerList.Players
.OrderBy(player => !player.HasStatus(PlayerStatus.Online));
this.app.Dispatch(() => {
this.app.Window.FriendList.Clear();
foreach (var player in players) {
this.app.Window.FriendList.Add(player);
}
});
break;
}
case PlayerListType.Targeting:
this.app.Dispatch(() => {
this.app.Window.UpdateTargeting(playerList.Players);
});
break;
}
break;
case ServerOperation.LinkshellList:
break;
}
}
private static int _backlogSequence = -1;
private void SetPlayerData(PlayerData? playerData) {
var visibility = playerData == null ? Visibility.Collapsed : Visibility.Visible;
this.app.Dispatch(() => {
var window = this.app.Window;
window.LoggedInAs.Content = playerData?.name ?? "Not logged in";
window.LoggedInAsSeparator.Visibility = visibility;
window.CurrentWorld.Content = playerData?.currentWorld;
window.CurrentWorld.Visibility = visibility;
window.CurrentWorldSeparator.Visibility = visibility;
window.Location.Content = playerData?.location;
window.Location.Visibility = visibility;
});
}
}
}