462 lines
18 KiB
C#
462 lines
18 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
|
|
},
|
|
},
|
|
};
|
|
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);
|
|
|
|
if (playerList.Type == 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 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;
|
|
});
|
|
}
|
|
}
|
|
}
|