diff --git a/XIVChat Desktop/App.xaml b/XIVChat Desktop/App.xaml index ceab6dd..bffbd17 100644 --- a/XIVChat Desktop/App.xaml +++ b/XIVChat Desktop/App.xaml @@ -2,8 +2,19 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:XIVChat_Desktop" - StartupUri="MainWindow.xaml"> + xmlns:ui="http://schemas.modernwpf.com/2019" + Startup="Application_Startup"> - + + + + + + + + + + + - + \ No newline at end of file diff --git a/XIVChat Desktop/App.xaml.cs b/XIVChat Desktop/App.xaml.cs index e1eeccd..6458f98 100644 --- a/XIVChat Desktop/App.xaml.cs +++ b/XIVChat Desktop/App.xaml.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; -using System.Configuration; -using System.Data; -using System.Linq; +using System.ComponentModel; using System.Threading.Tasks; using System.Windows; @@ -10,6 +7,74 @@ namespace XIVChat_Desktop { /// /// Interaction logic for App.xaml /// - public partial class App : Application { + public partial class App : INotifyPropertyChanged { + public MainWindow Window { get; private set; } = null!; + public Configuration Config { get; private set; } = null!; + + public string? LastHost { get; set; } + + private Connection? connection; + + public Connection? Connection { + get => this.connection; + set { + this.connection = value; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Connection))); + this.ConnectionStatusChanged(); + } + } + + public bool Connected => this.Connection != null; + public bool Disconnected => this.Connection == null; + + public event PropertyChangedEventHandler? PropertyChanged; + + private void Application_Startup(object sender, StartupEventArgs e) { + try { + this.Config = Configuration.Load() ?? new Configuration(); + } catch (Exception) { + this.Config = new Configuration(); + } + + try { + this.Config.Save(); + } catch (Exception) { + // TODO + } + + var wnd = new MainWindow(); + this.Window = wnd; + + // I guess this gets initialised where you call it the first time, so initialise it on the UI thread + this.Dispatcher.Invoke(() => { }); + + wnd.Show(); + + // initialise a config window to apply all our settings + _ = new ConfigWindow(wnd, this.Config); + } + + private void ConnectionStatusChanged() { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Connected))); + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Disconnected))); + } + + public void Connect(string host, ushort port) { + if (this.Connected) { + return; + } + + this.Connection = new Connection(this, host, port); + Task.Run(this.Connection.Connect); + } + + public void Disconnect() { + if (!this.Connected) { + return; + } + + this.Connection?.Disconnect(); + this.Connection = null; + } } } diff --git a/XIVChat Desktop/ConfigWindow.xaml b/XIVChat Desktop/ConfigWindow.xaml new file mode 100644 index 0000000..231f5be --- /dev/null +++ b/XIVChat Desktop/ConfigWindow.xaml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChat Desktop/ConfigWindow.xaml.cs b/XIVChat Desktop/ConfigWindow.xaml.cs new file mode 100644 index 0000000..9ec34f0 --- /dev/null +++ b/XIVChat Desktop/ConfigWindow.xaml.cs @@ -0,0 +1,51 @@ +using System; +using System.Linq; +using System.Windows; +using System.Windows.Input; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for ConfigWindow.xaml + /// + public partial class ConfigWindow { + public Configuration Config { get; private set; } + + public ConfigWindow(Window owner, Configuration config) { + this.Owner = owner; + this.Config = config; + + this.InitializeComponent(); + this.DataContext = this; + } + + private void AlwaysOnTop_Checked(object? sender, RoutedEventArgs e) { + this.SetAlwaysOnTop(true); + } + + private void AlwaysOnTop_Unchecked(object? sender, RoutedEventArgs e) { + this.SetAlwaysOnTop(false); + } + + private void SetAlwaysOnTop(bool onTop) { + this.Owner.Topmost = onTop; + this.Config.AlwaysOnTop = onTop; + } + + private void Save_Click(object? sender, RoutedEventArgs e) { + this.Config.Save(); + } + + private void SavedServers_ItemDoubleClick(SavedServer? server) { + new ManageServer(this, server).ShowDialog(); + } + + private void ConfigWindow_OnContentRendered(object? sender, EventArgs e) { + this.InvalidateVisual(); + } + + private void NumericInputFilter(object sender, TextCompositionEventArgs e) { + var allDigits = e.Text.All(c => char.IsDigit(c)); + e.Handled = !allDigits; + } + } +} diff --git a/XIVChat Desktop/Configuration.cs b/XIVChat Desktop/Configuration.cs new file mode 100644 index 0000000..74362ce --- /dev/null +++ b/XIVChat Desktop/Configuration.cs @@ -0,0 +1,192 @@ +using Newtonsoft.Json; +using Sodium; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.IO; +using System.Linq; +using XIVChatCommon; + +namespace XIVChat_Desktop { + [JsonObject] + public class Configuration : INotifyPropertyChanged { + public event PropertyChangedEventHandler? PropertyChanged; + + public KeyPair KeyPair { get; set; } = PublicKeyBox.GenerateKeyPair(); + + public ObservableCollection Servers { get; set; } = new ObservableCollection(); + public HashSet TrustedKeys { get; set; } = new HashSet(); + + public ObservableCollection Tabs { get; set; } = Tab.Defaults(); + + public bool AlwaysOnTop { get; set; } + + private double fontSize = 14d; + + public double FontSize { + get => this.fontSize; + set { + this.fontSize = value; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.FontSize))); + } + } + + public ushort BacklogMessages { get; set; } = 500; + + #region io + + private static string FilePath() => Path.Join( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XIVChat for Windows", + "config.json" + ); + + public static Configuration? Load() { + var path = FilePath(); + if (!File.Exists(path)) { + return null; + } + + using var reader = File.OpenText(path); + using var json = new JsonTextReader(reader); + + var serializer = new JsonSerializer { + ObjectCreationHandling = ObjectCreationHandling.Replace, + }; + return serializer.Deserialize(json); + } + + public void Save() { + var path = FilePath(); + if (!File.Exists(path)) { + var dir = Path.GetDirectoryName(path); + Directory.CreateDirectory(dir); + } + + using var file = File.CreateText(path); + using var json = new JsonTextWriter(file); + + var serialiser = new JsonSerializer(); + serialiser.Serialize(json, this); + } + + #endregion + } + + [JsonObject] + public class SavedServer { + public string Name { get; set; } + public string Host { get; set; } + public ushort Port { get; set; } + + public SavedServer(string name, string host, ushort port) { + this.Name = name; + this.Host = host; + this.Port = port; + } + } + + [JsonObject] + public class TrustedKey { + public string Name { get; set; } + public byte[] Key { get; set; } + + public TrustedKey(string name, byte[] key) { + this.Name = name; + this.Key = key; + } + } + + [JsonObject] + public class Tab : INotifyPropertyChanged { + private string name; + + public Tab(string name) { + this.name = name; + } + + public string Name { + get => this.name; + set { + this.name = value; + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Name))); + } + } + + public Filter Filter { get; set; } = new Filter(); + + [JsonIgnore] + public ObservableCollection Messages { get; } = new ObservableCollection(); + + public event PropertyChangedEventHandler? PropertyChanged; + + public void RepopulateMessages(ConcurrentStack mainMessages) { + this.Messages.Clear(); + + foreach (var message in mainMessages.Where(msg => this.Filter.Allowed(msg)).Reverse()) { + this.Messages.Add(message); + } + + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages))); + } + + public void AddMessage(ServerMessage message) { + if (message.Channel != 0 && !this.Filter.Allowed(message)) { + return; + } + + this.Messages.Add(message); + + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages))); + } + + public void ClearMessages() { + this.Messages.Clear(); + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.Messages))); + } + + public static Filter GeneralFilter() { + var generalFilters = FilterCategory.Chat.Types() + .Concat(FilterCategory.Announcements.Types()) + .ToHashSet(); + generalFilters.Remove(FilterType.OwnBattleSystem); + generalFilters.Remove(FilterType.OthersBattleSystem); + generalFilters.Remove(FilterType.NpcDialogue); + generalFilters.Remove(FilterType.OthersFishing); + return new Filter { + Types = generalFilters, + }; + } + + public static ObservableCollection Defaults() { + var battleFilters = FilterCategory.Battle.Types() + .Append(FilterType.OwnBattleSystem) + .Append(FilterType.OthersBattleSystem) + .ToHashSet(); + + return new ObservableCollection { + new Tab("General") { + Filter = GeneralFilter(), + }, + new Tab("Battle") { + Filter = new Filter { + Types = battleFilters, + }, + }, + }; + } + } + + [JsonObject] + public class Filter { + public HashSet Types { get; set; } = new HashSet(); + + public bool Allowed(ServerMessage message) { + return this.Types + .SelectMany(type => type.Types()) + .Contains(new ChatCode((ushort)message.Channel).Type); + } + } +} diff --git a/XIVChat Desktop/ConnectDialog.xaml b/XIVChat Desktop/ConnectDialog.xaml new file mode 100644 index 0000000..dc4c049 --- /dev/null +++ b/XIVChat Desktop/ConnectDialog.xaml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChat Desktop/ConnectDialog.xaml.cs b/XIVChat Desktop/ConnectDialog.xaml.cs new file mode 100644 index 0000000..03ff6a0 --- /dev/null +++ b/XIVChat Desktop/ConnectDialog.xaml.cs @@ -0,0 +1,42 @@ +using System; +using System.Windows; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for ConnectDialog.xaml + /// + public partial class ConnectDialog { + public App App => (App)Application.Current; + + public ConnectDialog() { + this.InitializeComponent(); + this.DataContext = this; + } + + private void Connect_Clicked(object? sender, RoutedEventArgs e) { + this.ConnectTo(this.Servers.SelectedServer); + } + + private void Cancel_Click(object? sender, RoutedEventArgs e) { + this.Close(); + } + + private void Servers_ItemDoubleClick(SavedServer? server) { + this.ConnectTo(server); + } + + private void ConnectTo(SavedServer? server) { + if (server == null) { + return; + } + + this.App.Connect(server.Host, server.Port); + + this.Close(); + } + + private void ConnectDialog_OnContentRendered(object? sender, EventArgs e) { + this.InvalidateVisual(); + } + } +} diff --git a/XIVChat Desktop/Connection.cs b/XIVChat Desktop/Connection.cs new file mode 100644 index 0000000..30d5893 --- /dev/null +++ b/XIVChat Desktop/Connection.cs @@ -0,0 +1,267 @@ +using System; +using System.ComponentModel; +using System.Linq; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using System.Windows; +using XIVChatCommon; + +namespace XIVChat_Desktop { + public class Connection : INotifyPropertyChanged { + private readonly App app; + + private readonly string host; + private readonly ushort port; + + private TcpClient? client; + + private readonly Channel outgoing = Channel.CreateUnbounded(); + private readonly Channel incoming = Channel.CreateUnbounded(); + private readonly Channel cancelChannel = Channel.CreateBounded(2); + + public readonly CancellationTokenSource cancel = new CancellationTokenSource(); + + public event PropertyChangedEventHandler? PropertyChanged; + public string? CurrentChannel { get; private set; } + + public Connection(App app, string host, ushort port) { + this.app = app; + + this.host = host; + this.port = port; + } + + public void SendMessage(string message) { + this.outgoing.Writer.TryWrite(message); + } + + public void Disconnect() { + this.cancel.Cancel(); + for (var i = 0; i < 2; i++) { + this.cancelChannel.Writer.TryWrite(1); + } + } + + public async Task Connect() { + this.client = new TcpClient(this.host, this.port); + var stream = this.client.GetStream(); + + await stream.WriteAsync(new byte[] { + 14, 20, 67, + }); + + var handshake = await KeyExchange.ClientHandshake(this.app.Config.KeyPair, stream); + + if (!this.app.Config.TrustedKeys.Any(trusted => trusted.Key.SequenceEqual(handshake.RemotePublicKey))) { + var trustChannel = Channel.CreateBounded(1); + + this.Dispatch(() => { + new TrustDialog(this.app.Window, trustChannel.Writer, handshake.RemotePublicKey).Show(); + }); + + var trusted = await trustChannel.Reader.ReadAsync(this.cancel.Token); + + if (!trusted) { + 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.Dispatch(() => { + this.app.Window.ClearAllMessages(); + this.app.LastHost = currentHost; + }); + } + + this.Dispatch(() => { + this.app.Window.AddSystemMessage("Connected"); + }); + + // 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) { + var catchUp = new ClientCatchUp { + After = lastRealMessage.Timestamp, + }; + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, catchUp, this.cancel.Token); + } + } else if (this.app.Config.BacklogMessages > 0) { + // backlog + 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) { + if (inc.Exception != null) { + this.Dispatch(() => { + this.app.Window.AddSystemMessage("Error reading incoming message."); + // ReSharper disable once LocalizableElement + Console.WriteLine($"Error reading incoming message: {inc.Exception.Message}"); + foreach (var inner in inc.Exception.InnerExceptions) { + Console.WriteLine(inner.StackTrace); + } + }); + 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 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, 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 { + Content = toSend, + }; + try { + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, message, this.cancel.Token); + } catch (Exception ex) { + this.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 { + await SecretMessage.SendSecretMessage(stream, handshake.Keys.tx, ClientShutdown.Instance, this.cancel.Token); + } catch (Exception ex) { + this.Dispatch(() => { + this.app.Window.AddSystemMessage("Error sending message."); + // ReSharper disable once LocalizableElement + Console.WriteLine($"Error sending message: {ex.Message}"); + Console.WriteLine(ex.StackTrace); + }); + } + + break; + } + } + + // at this point, we are disconnected, so log it + this.Dispatch(() => { + this.app.Window.AddSystemMessage("Disconnected"); + }); + + // 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: + break; + case ServerOperation.Message: + var message = ServerMessage.Decode(payload); + + this.Dispatch(() => { + this.app.Window.AddMessage(message); + }); + break; + case ServerOperation.Shutdown: + this.Disconnect(); + break; + case ServerOperation.PlayerData: + var playerData = payload.Length == 0 ? null : PlayerData.Decode(payload); + + var visibility = playerData == null ? Visibility.Collapsed : Visibility.Visible; + + this.Dispatch(() => { + var window = this.app.Window; + + window.LoggedInAs.Content = playerData?.name; + window.LoggedInAs.Visibility = visibility; + + 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; + }); + break; + case ServerOperation.Availability: + break; + case ServerOperation.Channel: + var channel = ServerChannel.Decode(payload); + + this.CurrentChannel = channel.name; + + this.Dispatch(() => { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.CurrentChannel))); + }); + break; + case ServerOperation.Backlog: + var backlog = ServerBacklog.Decode(payload); + + foreach (var msg in backlog.messages) { + this.Dispatch(() => { + this.app.Window.AddMessage(msg); + }); + } + + break; + case ServerOperation.PlayerList: + break; + case ServerOperation.LinkshellList: + break; + } + } + + private void Dispatch(Action action) { + this.app.Dispatcher.BeginInvoke(action); + } + } +} diff --git a/XIVChat Desktop/Controls/SavedServers.xaml b/XIVChat Desktop/Controls/SavedServers.xaml new file mode 100644 index 0000000..62978d2 --- /dev/null +++ b/XIVChat Desktop/Controls/SavedServers.xaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChat Desktop/Controls/SavedServers.xaml.cs b/XIVChat Desktop/Controls/SavedServers.xaml.cs new file mode 100644 index 0000000..7117fb7 --- /dev/null +++ b/XIVChat Desktop/Controls/SavedServers.xaml.cs @@ -0,0 +1,73 @@ +using System.Collections.Generic; +using System.Windows; + +namespace XIVChat_Desktop.Controls { + /// + /// Interaction logic for SavedServers.xaml + /// + public partial class SavedServers { + public App App => (App)Application.Current; + private Configuration Config => this.App.Config; + private Window Window => Window.GetWindow(this)!; + + public IEnumerable ItemsSource { + get { return (IEnumerable)this.GetValue(ItemsSourceProperty); } + set { this.SetValue(ItemsSourceProperty, value); } + } + + public SavedServer? SelectedServer { + get { + var item = this.Servers.SelectedItem; + + return item as SavedServer; + } + } + + public static readonly DependencyProperty ItemsSourceProperty = DependencyProperty.Register( + "ItemsSource", + typeof(IEnumerable), + typeof(SavedServers), + new PropertyMetadata(null) + ); + + public SavedServers() { + this.InitializeComponent(); + } + + private void AddServer_Click(object sender, RoutedEventArgs e) { + new ManageServer(this.Window, null).ShowDialog(); + } + + private void DeleteServer_Click(object sender, RoutedEventArgs e) { + var server = this.SelectedServer; + if (server == null) { + return; + } + + this.Config.Servers.Remove(server); + this.Config.Save(); + } + + private void EditServer_Click(object sender, RoutedEventArgs e) { + var server = this.SelectedServer; + if (server == null) { + return; + } + + new ManageServer(this.Window, server).ShowDialog(); + } + + private void Item_MouseDoubleClick(object sender, System.Windows.Input.MouseButtonEventArgs e) { + var server = ((FrameworkElement)e.OriginalSource).DataContext; + if (!(server is SavedServer)) { + return; + } + + this.ItemDoubleClick?.Invoke((SavedServer)server); + } + + public delegate void MouseDoubleClickHandler(SavedServer server); + + public event MouseDoubleClickHandler? ItemDoubleClick; + } +} diff --git a/XIVChat Desktop/Converters.cs b/XIVChat Desktop/Converters.cs new file mode 100644 index 0000000..e363fee --- /dev/null +++ b/XIVChat Desktop/Converters.cs @@ -0,0 +1,33 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace XIVChat_Desktop { + public class DoubleConverter : IValueConverter { + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) { + return value.ToString(); + } + + public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { + if (double.TryParse(value.ToString(), out var res)) { + return res; + } + + return null; + } + } + + public class UShortConverter : IValueConverter { + public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) { + return value.ToString(); + } + + public object? ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { + if (ushort.TryParse(value.ToString(), out var res)) { + return res; + } + + return null; + } + } +} diff --git a/XIVChat Desktop/Filters.cs b/XIVChat Desktop/Filters.cs new file mode 100644 index 0000000..a61a1d7 --- /dev/null +++ b/XIVChat Desktop/Filters.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using XIVChatCommon; + +namespace XIVChat_Desktop { + public enum FilterCategory { + [Category("Chat", + FilterType.Say, + FilterType.Yell, + FilterType.Shout, + FilterType.Tell, + FilterType.Party, + FilterType.Alliance, + FilterType.FreeCompany, + FilterType.PvpTeam, + FilterType.CrossLinkshell1, + FilterType.CrossLinkshell2, + FilterType.CrossLinkshell3, + FilterType.CrossLinkshell4, + FilterType.CrossLinkshell5, + FilterType.CrossLinkshell6, + FilterType.CrossLinkshell7, + FilterType.CrossLinkshell8, + FilterType.Linkshell1, + FilterType.Linkshell2, + FilterType.Linkshell3, + FilterType.Linkshell4, + FilterType.Linkshell5, + FilterType.Linkshell6, + FilterType.Linkshell7, + FilterType.Linkshell8, + FilterType.NoviceNetwork, + FilterType.StandardEmote, + FilterType.CustomEmote, + FilterType.Gm + )] + Chat, + + [Category("Battle", FilterType.Battle)] + Battle, + + [Category("Announcements", + FilterType.Debug, + FilterType.Urgent, + FilterType.Notice, + FilterType.SystemMessages, + FilterType.OwnBattleSystem, + FilterType.OthersBattleSystem, + FilterType.GatheringSystem, + FilterType.ErrorMessages, + FilterType.Echo, + FilterType.NoviceNetworkAnnouncements, + FilterType.FreeCompanyAnnouncements, + FilterType.PvpTeamAnnouncements, + FilterType.FreeCompanyLoginLogout, + FilterType.PvpTeamLoginLogout, + FilterType.RetainerSale, + FilterType.NpcDialogue, + FilterType.NpcAnnouncement, + FilterType.Loot, + FilterType.OwnProgression, + FilterType.PartyProgression, + FilterType.OthersProgression, + FilterType.OwnLoot, + FilterType.OthersLoot, + FilterType.OwnCrafting, + FilterType.OthersCrafting, + FilterType.OwnGathering, + FilterType.OthersFishing, + FilterType.PeriodicRecruitment, + FilterType.Sign, + FilterType.Random, + FilterType.Orchestrion, + FilterType.MessageBook, + FilterType.Alarm + )] + Announcements, + } + + public class CategoryAttribute : Attribute { + public string Name { get; } + public FilterType[] Types { get; } + + public CategoryAttribute(string name, params FilterType[] types) { + this.Name = name; + this.Types = types; + } + } + + public static class FilterCategoryExtensions { + private static CategoryAttribute? Info(this FilterCategory filter) => filter + .GetType() + .GetField(filter.ToString()) + ?.GetCustomAttribute(false); + + public static string? Name(this FilterCategory category) => category.Info()?.Name; + + public static IEnumerable Types(this FilterCategory category) => category.Info()?.Types ?? new FilterType[0]; + } + + // NOTE: Changing the order of these is a breaking change + public enum FilterType { + [Filter("Say", ChatType.Say)] Say, + [Filter("Shout", ChatType.Shout)] Shout, + [Filter("Yell", ChatType.Yell)] Yell, + + [Filter("Tell", ChatType.TellOutgoing, ChatType.TellIncoming)] + Tell, + + [Filter("Party", ChatType.Party, ChatType.CrossParty)] + Party, + + [Filter("Alliance", ChatType.Alliance)] + Alliance, + + [Filter("Free Company", ChatType.FreeCompany)] + FreeCompany, + [Filter("PvP Team", ChatType.PvpTeam)] PvpTeam, + + [Filter("Cross-world Linkshell [1]", ChatType.CrossLinkshell1)] + CrossLinkshell1, + + [Filter("Cross-world Linkshell [2]", ChatType.CrossLinkshell2)] + CrossLinkshell2, + + [Filter("Cross-world Linkshell [3]", ChatType.CrossLinkshell3)] + CrossLinkshell3, + + [Filter("Cross-world Linkshell [4]", ChatType.CrossLinkshell4)] + CrossLinkshell4, + + [Filter("Cross-world Linkshell [5]", ChatType.CrossLinkshell5)] + CrossLinkshell5, + + [Filter("Cross-world Linkshell [6]", ChatType.CrossLinkshell6)] + CrossLinkshell6, + + [Filter("Cross-world Linkshell [7]", ChatType.CrossLinkshell7)] + CrossLinkshell7, + + [Filter("Cross-world Linkshell [8]", ChatType.CrossLinkshell8)] + CrossLinkshell8, + + [Filter("Linkshell [1]", ChatType.Linkshell1)] + Linkshell1, + + [Filter("Linkshell [2]", ChatType.Linkshell2)] + Linkshell2, + + [Filter("Linkshell [3]", ChatType.Linkshell3)] + Linkshell3, + + [Filter("Linkshell [4]", ChatType.Linkshell4)] + Linkshell4, + + [Filter("Linkshell [5]", ChatType.Linkshell5)] + Linkshell5, + + [Filter("Linkshell [6]", ChatType.Linkshell6)] + Linkshell6, + + [Filter("Linkshell [7]", ChatType.Linkshell7)] + Linkshell7, + + [Filter("Linkshell [8]", ChatType.Linkshell8)] + Linkshell8, + + [Filter("Novice Network", ChatType.NoviceNetwork)] + NoviceNetwork, + + [Filter("Standard Emotes", ChatType.StandardEmote)] + StandardEmote, + + [Filter("Custom Emotes", ChatType.CustomEmote)] + CustomEmote, + + [Filter("Battle", + ChatType.Damage, + ChatType.Miss, + ChatType.Action, + ChatType.Item, + ChatType.Healing, + ChatType.GainBuff, + ChatType.LoseBuff, + ChatType.GainDebuff, + ChatType.LoseDebuff, + ChatType.BattleSystem + )] + Battle, + + [Filter("Debug", ChatType.Debug)] Debug, + [Filter("Urgent", ChatType.Urgent)] Urgent, + [Filter("Notice", ChatType.Notice)] Notice, + + [Filter("System Messages", ChatType.System)] + SystemMessages, + + [Filter("Own Battle System Messages", ChatType.BattleSystem, Source = FilterSource.Self)] + OwnBattleSystem, + + [Filter("Others' Battle System Messages", ChatType.BattleSystem, Source = FilterSource.Others)] + OthersBattleSystem, + + [Filter("Gathering System Messages", ChatType.GatheringSystem)] + GatheringSystem, + + [Filter("Error Messages", ChatType.Error)] + ErrorMessages, + [Filter("Echo", ChatType.Echo)] Echo, + + [Filter("Novice Network Notifications", ChatType.NoviceNetworkSystem)] + NoviceNetworkAnnouncements, + + [Filter("Free Company Announcements", ChatType.FreeCompanyAnnouncement)] + FreeCompanyAnnouncements, + + [Filter("PvP Team Announcements", ChatType.PvpTeamAnnouncement)] + PvpTeamAnnouncements, + + [Filter("Free Company Member Login Notifications", ChatType.FreeCompanyLoginLogout)] + FreeCompanyLoginLogout, + + [Filter("PvP Team Member Login Notifications", ChatType.PvpTeamLoginLogout)] + PvpTeamLoginLogout, + + [Filter("Retainer Sale Notifications", ChatType.RetainerSale)] + RetainerSale, + + [Filter("NPC Dialogue", ChatType.NpcDialogue)] + NpcDialogue, + + [Filter("NPC Dialogue (Announcements)", ChatType.NpcAnnouncement)] + NpcAnnouncement, + + [Filter("Loot Notices", ChatType.LootNotice)] + Loot, + + [Filter("Own Progression Messages", ChatType.Progress, Source = FilterSource.Self)] + OwnProgression, + + [Filter("Party Members' Progression Messages", ChatType.Progress, Source = FilterSource.Party)] + PartyProgression, + + [Filter("Others' Progression Messages", ChatType.Progress, Source = FilterSource.Others)] + OthersProgression, + + [Filter("Own Loot Messages", ChatType.LootRoll, Source = FilterSource.Self)] + OwnLoot, + + [Filter("Others' Loot Messages", ChatType.LootRoll, Source = FilterSource.Others)] + OthersLoot, + + [Filter("Own Synthesis Messages", ChatType.Crafting, Source = FilterSource.Self)] + OwnCrafting, + + [Filter("Others' Synthesis Messages", ChatType.Crafting, Source = FilterSource.Self)] + OthersCrafting, + + [Filter("Own Gathering Messages", ChatType.Gathering, Source = FilterSource.Self)] + OwnGathering, + + [Filter("Others' Fishing Messages", ChatType.Gathering, Source = FilterSource.Others)] + OthersFishing, + + [Filter("Periodic Recruitment Notifications", ChatType.PeriodicRecruitmentNotification)] + PeriodicRecruitment, + + [Filter("Sign Messages for PC Targets", ChatType.Sign)] + Sign, + + [Filter("Random Number Messages", ChatType.RandomNumber)] + Random, + + [Filter("Current Orchestrion Track Messages", ChatType.Orchestrion)] + Orchestrion, + + [Filter("Message Book Alert", ChatType.MessageBook)] + MessageBook, + + [Filter("Alarm Notifications", ChatType.Alarm)] + Alarm, + + [Filter("GM Messages", + ChatType.GmTell, + ChatType.GmSay, + ChatType.GmShout, + ChatType.GmYell, + ChatType.GmParty, + ChatType.GmFreeCompany, + ChatType.GmLinkshell1, + ChatType.GmLinkshell2, + ChatType.GmLinkshell3, + ChatType.GmLinkshell4, + ChatType.GmLinkshell5, + ChatType.GmLinkshell6, + ChatType.GmLinkshell7, + ChatType.GmLinkshell8, + ChatType.GmNoviceNetwork + )] + Gm, + } + + public enum FilterSource { + None, + Self, + Party, + Others, + } + + public class FilterAttribute : Attribute { + public string Name { get; } + public ChatType[] Types { get; } + public FilterSource Source { get; set; } = FilterSource.None; + + public FilterAttribute(string name, params ChatType[] types) { + this.Name = name; + this.Types = types; + } + } + + public static class FilterTypeExtensions { + private static readonly ChatSource[] Others = { + ChatSource.PartyMember, ChatSource.AllianceMember, ChatSource.Other, ChatSource.EngagedEnemy, ChatSource.UnengagedEnemy, ChatSource.FriendlyNpc, ChatSource.PartyPet, ChatSource.AlliancePet, ChatSource.OtherPet, + }; + + private static FilterAttribute? Info(this FilterType filter) => filter + .GetType() + .GetField(filter.ToString()) + ?.GetCustomAttribute(false); + + public static string? Name(this FilterType filter) => filter.Info()?.Name; + + public static IEnumerable Types(this FilterType filter) => filter.Info()?.Types ?? new ChatType[0]; + + public static ChatSource[] Sources(this FilterType filter) => filter.Info()?.Source switch { + FilterSource.Self => new[] { + ChatSource.Self, + }, + FilterSource.Party => new[] { + ChatSource.PartyMember, + }, + FilterSource.Others => Others, + _ => new ChatSource[0], + }; + } +} diff --git a/XIVChat Desktop/MainWindow.xaml b/XIVChat Desktop/MainWindow.xaml index 95e683b..543560b 100644 --- a/XIVChat Desktop/MainWindow.xaml +++ b/XIVChat Desktop/MainWindow.xaml @@ -4,22 +4,110 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:local="clr-namespace:XIVChat_Desktop" + xmlns:ui="http://schemas.modernwpf.com/2019" + ui:WindowHelper.UseModernWindowStyle="True" mc:Ignorable="d" - Title="MainWindow" Height="450" Width="800"> + Title="XIVChat for Windows" + Height="450" + Width="800" + x:Name="Main" + Icon="/ic_launcher-playstore.png" + d:DataContext="{d:DesignInstance local:MainWindow}"> - - - + + + - + - - + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/XIVChat Desktop/MainWindow.xaml.cs b/XIVChat Desktop/MainWindow.xaml.cs index 4333cfc..c5a795e 100644 --- a/XIVChat Desktop/MainWindow.xaml.cs +++ b/XIVChat Desktop/MainWindow.xaml.cs @@ -1,25 +1,136 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using System.Text; -using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; -using System.Windows.Data; -using System.Windows.Documents; using System.Windows.Input; using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Navigation; -using System.Windows.Shapes; +using XIVChatCommon; namespace XIVChat_Desktop { /// /// Interaction logic for MainWindow.xaml /// - public partial class MainWindow : Window { + public partial class MainWindow { + public App App => (App)Application.Current; + + public ConcurrentStack Messages { get; } = new ConcurrentStack(); + public MainWindow() { - InitializeComponent(); + this.InitializeComponent(); + this.DataContext = this; + } + + private T? FindElementByName(DependencyObject element, string sChildName) where T : FrameworkElement { + T? childElement = null; + var nChildCount = VisualTreeHelper.GetChildrenCount(element); + for (int i = 0; i < nChildCount; i++) { + if (!(VisualTreeHelper.GetChild(element, i) is FrameworkElement child)) { + continue; + } + + if (child is T t && child.Name.Equals(sChildName)) { + childElement = t; + break; + } + + childElement = this.FindElementByName(child, sChildName); + + if (childElement != null) { + break; + } + } + + return childElement; + } + + public void ClearAllMessages() { + this.Messages.Clear(); + foreach (var tab in this.App.Config.Tabs) { + tab.ClearMessages(); + } + } + + public void AddSystemMessage(string content) { + var message = new ServerMessage { + Channel = 0, + Content = Encoding.UTF8.GetBytes(content), + Timestamp = DateTime.UtcNow, + Chunks = new List { + new TextChunk { + Foreground = 0xb38cffff, + Content = content, + }, + }, + }; + this.AddMessage(message); + } + + public void AddMessage(ServerMessage message) { + // detect if scroller is at the bottom + var scroller = this.FindElementByName(this.Tabs, "scroller"); + var wasAtBottom = Math.Abs(scroller!.VerticalOffset - scroller.ScrollableHeight) < .0001; + + // add message to main list + this.Messages.Push(message); + // add message to each tab if the filter allows for it + foreach (var tab in this.App.Config.Tabs) { + tab.AddMessage(message); + } + + // scroll to the bottom if previously at the bottom + if (wasAtBottom) { + scroller.ScrollToBottom(); + } + } + + private void Connect_Click(object sender, RoutedEventArgs e) { + var dialog = new ConnectDialog { + Owner = this, + }; + dialog.ShowDialog(); + } + + private void Disconnect_Click(object sender, RoutedEventArgs e) { + this.App.Disconnect(); + } + + private void Input_Submit(object sender, KeyEventArgs e) { + if (e.Key != Key.Return) { + return; + } + + var conn = this.App.Connection; + if (conn == null) { + return; + } + + if (!(sender is TextBox)) { + return; + } + + var textBox = (TextBox)sender; + + conn.SendMessage(textBox.Text); + textBox.Text = ""; + } + + private void Configuration_Click(object sender, RoutedEventArgs e) { + new ConfigWindow(this, this.App.Config).Show(); + } + + private void Tabs_Loaded(object sender, RoutedEventArgs e) { + this.Tabs.SelectedIndex = 0; + } + + private void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e) { + var scroller = this.FindElementByName(this.Tabs, "scroller"); + scroller?.ScrollToBottom(); + } + + private void ManageTabs_Click(object sender, RoutedEventArgs e) { + new ManageTabs(this).Show(); } } } diff --git a/XIVChat Desktop/ManageServer.xaml b/XIVChat Desktop/ManageServer.xaml new file mode 100644 index 0000000..97c5ab2 --- /dev/null +++ b/XIVChat Desktop/ManageServer.xaml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/XIVChat Desktop/ManageServer.xaml.cs b/XIVChat Desktop/ManageServer.xaml.cs new file mode 100644 index 0000000..5059af8 --- /dev/null +++ b/XIVChat Desktop/ManageServer.xaml.cs @@ -0,0 +1,64 @@ +using System; +using System.Windows; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for ManageServer.xaml + /// + public partial class ManageServer { + public App App => (App)Application.Current; + public SavedServer? Server { get; private set; } + + private readonly bool isNewServer; + + public ManageServer(Window owner, SavedServer? server) { + this.Owner = owner; + this.Server = server; + this.isNewServer = server == null; + + this.InitializeComponent(); + this.DataContext = this; + } + + private void Save_Click(object sender, RoutedEventArgs e) { + var serverName = this.ServerName.Text; + var serverHost = this.ServerHost.Text; + + if (serverName.Length == 0 || serverHost.Length == 0) { + MessageBox.Show("Server must have a name and host."); + return; + } + + ushort port; + if (this.ServerPort.Text.Length == 0) { + port = 14777; + } else { + if (!ushort.TryParse(this.ServerPort.Text, out port) || port < 1) { + MessageBox.Show("Port was not valid. It must be a number between 1 and 65535."); + return; + } + } + + if (this.isNewServer) { + this.Server = new SavedServer( + serverName, + serverHost, + port + ); + this.App.Config.Servers.Add(this.Server); + } + + this.App.Config.Save(); + + this.Close(); + } + + private void Cancel_Click(object sender, RoutedEventArgs e) { + this.Close(); + } + + private void ManageServer_OnContentRendered(object? sender, EventArgs e) { + this.InvalidateVisual(); + } + } +} diff --git a/XIVChat Desktop/ManageTab.xaml b/XIVChat Desktop/ManageTab.xaml new file mode 100644 index 0000000..dcd38c8 --- /dev/null +++ b/XIVChat Desktop/ManageTab.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + diff --git a/XIVChat Desktop/ManageTabs.xaml.cs b/XIVChat Desktop/ManageTabs.xaml.cs new file mode 100644 index 0000000..698f70f --- /dev/null +++ b/XIVChat Desktop/ManageTabs.xaml.cs @@ -0,0 +1,59 @@ +using System.Windows; +using System.Windows.Input; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for ManageTabs.xaml + /// + public partial class ManageTabs { + public App App => (App)Application.Current; + + private Tab? SelectedTab { + get { + var item = this.Tabs.SelectedItem; + + return item as Tab; + } + } + + public ManageTabs(Window owner) { + this.Owner = owner; + this.InitializeComponent(); + this.DataContext = this; + } + + private void AddTab_Click(object sender, RoutedEventArgs e) { + new FiltersSelection(this, null).ShowDialog(); + } + + private void EditTab_Click(object sender, RoutedEventArgs e) { + var tab = this.SelectedTab; + if (tab == null) { + return; + } + new FiltersSelection(this, tab).ShowDialog(); + } + + private void DeleteTab_Click(object sender, RoutedEventArgs e) { + var tab = this.SelectedTab; + if (tab == null) { + return; + } + + this.App.Config.Tabs.Remove(tab); + this.App.Config.Save(); + + } + + private void Tab_MouseDoubleClick(object sender, MouseButtonEventArgs e) { + var item = ((FrameworkElement)e.OriginalSource).DataContext; + if (!(item is Tab)) { + return; + } + + var tab = item as Tab; + + new FiltersSelection(this, tab).ShowDialog(); + } + } +} diff --git a/XIVChat Desktop/MessageFormatter.cs b/XIVChat Desktop/MessageFormatter.cs new file mode 100644 index 0000000..a27b5dc --- /dev/null +++ b/XIVChat Desktop/MessageFormatter.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Documents; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using XIVChatCommon; + +namespace XIVChat_Desktop { + public class MessageFormatter { + private static readonly BitmapFrame FontIcon = + BitmapFrame.Create(new Uri("pack://application:,,,/Resources/fonticon_ps4.tex.png")); + + public static readonly DependencyProperty FormattedTextProperty = DependencyProperty.RegisterAttached( + "FormattedText", + typeof(ServerMessage), + typeof(MessageFormatter), + new PropertyMetadata(null, FormattedTextPropertyChanged) + ); + + public static void SetFormattedText(DependencyObject textBlock, ServerMessage value) { + textBlock.SetValue(FormattedTextProperty, value); + } + + public static string GetFormattedText(DependencyObject textBlock) { + return (string)textBlock.GetValue(FormattedTextProperty); + } + + private static void FormattedTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { + // Clear current textBlock + if (!(d is TextBlock textBlock)) { + return; + } + + textBlock.ClearValue(TextBlock.TextProperty); + textBlock.Inlines.Clear(); + + // Create new formatted text + var lineHeight = textBlock.FontFamily.LineSpacing * textBlock.FontSize; + foreach (var inline in ChunksToTextBlock(lineHeight, (ServerMessage)e.NewValue)) { + textBlock.Inlines.Add(inline); + } + } + + private static IEnumerable ChunksToTextBlock(double lineHeight, ServerMessage message) { + var elements = new List(); + + var timestampString = message.Timestamp.ToLocalTime().ToString("t", CultureInfo.CurrentUICulture); + elements.Add(new Run($"[{timestampString}]") { + Foreground = new SolidColorBrush(Colors.White), + }); + + foreach (var chunk in message.Chunks) { + switch (chunk) { + case TextChunk textChunk: + var colour = textChunk.Foreground ?? textChunk.FallbackColour ?? 0; + + var r = (byte)((colour >> 24) & 0xFF); + var g = (byte)((colour >> 16) & 0xFF); + var b = (byte)((colour >> 8) & 0xFF); + var a = (byte)(colour & 0xFF); + + var brush = new SolidColorBrush(Color.FromArgb(a, r, g, b)); + var style = textChunk.Italic ? FontStyles.Italic : FontStyles.Normal; + + //var part = string.Empty; + //foreach (char c in textChunk.Content) { + // if (c >= '\ue000' && c <= '\uf8ff') { + // // private use + + // // add existing text if necessary + // if (part.Length != 0) { + // elements.Add(new Run(part) { + // Foreground = brush, + // FontStyle = style, + // }); + // part = string.Empty; + // } + + // // add private use segment with font + // elements.Add(new Run(c.ToString()) { + // Foreground = brush, + // FontStyle = style, + // FontFamily = new FontFamily(new Uri("pack://application:,,,/"), "/fonts/#XIV AXIS Std ATK"), + // }); + // continue; + // } + + // part += c; + //} + + elements.Add(new Run(textChunk.Content) { + Foreground = brush, + FontStyle = style, + }); + break; + case IconChunk iconChunk: + var bounds = GetBounds(iconChunk.Index); + if (bounds == null) { + break; + } + + var width = lineHeight / bounds.Value.Height * bounds.Value.Width; + + var cropped = new CroppedBitmap(FontIcon, bounds.Value); + var image = new Image { + Source = cropped, + Width = width, + Height = lineHeight, + }; + elements.Add(new InlineUIContainer(image) { + BaselineAlignment = BaselineAlignment.Bottom, + }); + break; + } + } + + return elements; + } + + private static Int32Rect? GetBounds(byte id) => id switch { + 1 => new Int32Rect(0, 0, 20, 20), + 2 => new Int32Rect(20, 0, 20, 20), + 3 => new Int32Rect(40, 0, 20, 20), + 4 => new Int32Rect(60, 0, 20, 20), + 5 => new Int32Rect(80, 0, 20, 20), + 6 => new Int32Rect(0, 20, 20, 20), + 7 => new Int32Rect(20, 20, 20, 20), + 8 => new Int32Rect(40, 20, 20, 20), + 9 => new Int32Rect(60, 20, 20, 20), + 10 => new Int32Rect(80, 20, 20, 20), + 11 => new Int32Rect(0, 40, 20, 20), + 12 => new Int32Rect(20, 40, 20, 20), + 13 => new Int32Rect(40, 40, 20, 20), + 14 => new Int32Rect(60, 40, 20, 20), + 15 => new Int32Rect(80, 40, 20, 20), + 16 => new Int32Rect(60, 100, 20, 20), + 17 => new Int32Rect(80, 100, 20, 20), + 18 => new Int32Rect(0, 60, 54, 20), + 19 => new Int32Rect(54, 60, 54, 20), + 20 => new Int32Rect(60, 80, 20, 20), + 21 => new Int32Rect(0, 80, 28, 20), + 22 => new Int32Rect(28, 80, 32, 20), + 23 => new Int32Rect(80, 80, 20, 20), + 24 => new Int32Rect(0, 100, 28, 20), + 25 => new Int32Rect(28, 100, 32, 20), + 51 => new Int32Rect(124, 0, 20, 20), + 52 => new Int32Rect(144, 0, 20, 20), + 53 => new Int32Rect(164, 0, 20, 20), + 54 => new Int32Rect(100, 0, 12, 20), + 55 => new Int32Rect(112, 0, 12, 20), + 56 => new Int32Rect(100, 20, 20, 20), + 57 => new Int32Rect(120, 20, 20, 20), + 58 => new Int32Rect(140, 20, 20, 20), + 59 => new Int32Rect(100, 40, 20, 20), + 60 => new Int32Rect(120, 40, 20, 20), + 61 => new Int32Rect(140, 40, 20, 20), + 62 => new Int32Rect(160, 20, 20, 20), + 63 => new Int32Rect(160, 40, 20, 20), + 64 => new Int32Rect(184, 0, 20, 20), + 65 => new Int32Rect(204, 0, 20, 20), + 66 => new Int32Rect(224, 0, 20, 20), + 67 => new Int32Rect(180, 20, 20, 20), + 68 => new Int32Rect(200, 20, 20, 20), + 69 => new Int32Rect(236, 236, 20, 20), + 70 => new Int32Rect(180, 40, 20, 20), + 71 => new Int32Rect(200, 40, 20, 20), + 72 => new Int32Rect(220, 40, 20, 20), + 73 => new Int32Rect(220, 20, 20, 20), + 74 => new Int32Rect(108, 60, 20, 20), + 75 => new Int32Rect(128, 60, 20, 20), + 76 => new Int32Rect(148, 60, 20, 20), + 77 => new Int32Rect(168, 60, 20, 20), + 78 => new Int32Rect(188, 60, 20, 20), + 79 => new Int32Rect(208, 60, 20, 20), + 80 => new Int32Rect(228, 60, 20, 20), + 81 => new Int32Rect(100, 80, 20, 20), + 82 => new Int32Rect(120, 80, 20, 20), + 83 => new Int32Rect(140, 80, 20, 20), + 84 => new Int32Rect(160, 80, 20, 20), + 85 => new Int32Rect(180, 80, 20, 20), + 86 => new Int32Rect(200, 80, 20, 20), + 87 => new Int32Rect(220, 80, 20, 20), + 88 => new Int32Rect(100, 100, 20, 20), + 89 => new Int32Rect(120, 100, 20, 20), + 90 => new Int32Rect(140, 100, 20, 20), + 91 => new Int32Rect(160, 100, 20, 20), + 92 => new Int32Rect(180, 100, 20, 20), + 93 => new Int32Rect(200, 100, 20, 20), + 94 => new Int32Rect(220, 100, 20, 20), + 95 => new Int32Rect(0, 120, 20, 20), + 96 => new Int32Rect(20, 120, 20, 20), + 97 => new Int32Rect(40, 120, 20, 20), + 98 => new Int32Rect(60, 120, 20, 20), + 99 => new Int32Rect(80, 120, 20, 20), + 100 => new Int32Rect(100, 120, 20, 20), + 101 => new Int32Rect(120, 120, 20, 20), + 102 => new Int32Rect(140, 120, 20, 20), + 103 => new Int32Rect(160, 120, 20, 20), + 104 => new Int32Rect(180, 120, 20, 20), + 105 => new Int32Rect(200, 120, 20, 20), + 106 => new Int32Rect(220, 120, 20, 20), + 107 => new Int32Rect(0, 140, 20, 20), + 108 => new Int32Rect(20, 140, 20, 20), + 109 => new Int32Rect(40, 140, 20, 20), + 110 => new Int32Rect(60, 140, 20, 20), + 111 => new Int32Rect(80, 140, 20, 20), + _ => null, + }; + } +} diff --git a/XIVChat Desktop/Properties/DesignTimeResources.xaml b/XIVChat Desktop/Properties/DesignTimeResources.xaml new file mode 100644 index 0000000..b269956 --- /dev/null +++ b/XIVChat Desktop/Properties/DesignTimeResources.xaml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/XIVChat Desktop/Properties/Resources.Designer.cs b/XIVChat Desktop/Properties/Resources.Designer.cs new file mode 100644 index 0000000..669e441 --- /dev/null +++ b/XIVChat Desktop/Properties/Resources.Designer.cs @@ -0,0 +1,72 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace XIVChat_Desktop.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("XIVChat_Desktop.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to XIVChat for Windows. + /// + public static string AppName { + get { + return ResourceManager.GetString("AppName", resourceCulture); + } + } + } +} diff --git a/XIVChat Desktop/Properties/Resources.resx b/XIVChat Desktop/Properties/Resources.resx new file mode 100644 index 0000000..845753f --- /dev/null +++ b/XIVChat Desktop/Properties/Resources.resx @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + XIVChat for Windows + + \ No newline at end of file diff --git a/XIVChat Desktop/Resources/fonticon_ps4.tex.png b/XIVChat Desktop/Resources/fonticon_ps4.tex.png new file mode 100644 index 0000000..5026b15 Binary files /dev/null and b/XIVChat Desktop/Resources/fonticon_ps4.tex.png differ diff --git a/XIVChat Desktop/TrustDialog.xaml b/XIVChat Desktop/TrustDialog.xaml new file mode 100644 index 0000000..b68925a --- /dev/null +++ b/XIVChat Desktop/TrustDialog.xaml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + You are attempting to connect to a server you have never connected to before. Please check the server and ensure the two keys below match. + + + + + + + + + + Server: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Client: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Give the key a name to remember it. + No name + + + Do both keys match? + + + + + + + diff --git a/XIVChat Desktop/TrustDialog.xaml.cs b/XIVChat Desktop/TrustDialog.xaml.cs new file mode 100644 index 0000000..a3bbd26 --- /dev/null +++ b/XIVChat Desktop/TrustDialog.xaml.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Channels; +using System.Windows; +using System.Windows.Media; +using System.Windows.Shapes; + +namespace XIVChat_Desktop { + /// + /// Interaction logic for TrustDialog.xaml + /// + public partial class TrustDialog { + private readonly ChannelWriter trustChannel; + private readonly byte[] remoteKey; + + private App App => (App)Application.Current; + + public TrustDialog(Window owner, ChannelWriter trustChannel, byte[] remoteKey) { + this.Owner = owner; + this.trustChannel = trustChannel; + this.remoteKey = remoteKey; + + this.InitializeComponent(); + + this.ClientPublicKey.Text = ToHexString(this.App.Config.KeyPair.PublicKey); + var clientColours = BreakIntoColours(this.App.Config.KeyPair.PublicKey); + for (int i = 0; i < this.ClientPublicKeyColours.Children.Count; i++) { + var rect = (Rectangle)this.ClientPublicKeyColours.Children[i]; + rect.Fill = new SolidColorBrush(clientColours[i]); + } + + this.ServerPublicKey.Text = ToHexString(remoteKey); + var serverColours = BreakIntoColours(remoteKey); + for (int i = 0; i < this.ServerPublicKeyColours.Children.Count; i++) { + var rect = (Rectangle)this.ServerPublicKeyColours.Children[i]; + rect.Fill = new SolidColorBrush(serverColours[i]); + } + } + + private static List BreakIntoColours(IEnumerable key) { + var colours = new List(); + + // ReSharper disable once LoopCanBeConvertedToQuery + foreach (var chunk in SplitList(key.ToList(), 3)) { + var r = chunk[0]; + var g = chunk.Count > 1 ? chunk[1] : (byte)0; + var b = chunk.Count > 2 ? chunk[2] : (byte)0; + + colours.Add(Color.FromRgb(r, g, b)); + } + + return colours; + } + + private static IEnumerable> SplitList(List locations, int nSize) { + for (int i = 0; i < locations.Count; i += nSize) { + yield return locations.GetRange(i, Math.Min(nSize, locations.Count - i)); + } + } + + private static string ToHexString(IEnumerable bytes) { + return string.Join("", bytes.Select(b => b.ToString("X2"))); + } + + private async void Yes_Click(object sender, RoutedEventArgs e) { + var keyName = this.KeyName.Text; + if (keyName.Length == 0) { + MessageBox.Show("You must give this key a name."); + return; + } + + var trustedKey = new TrustedKey(keyName, this.remoteKey); + this.App.Config.TrustedKeys.Add(trustedKey); + this.App.Config.Save(); + await this.trustChannel.WriteAsync(true); + this.Close(); + } + + private async void No_Click(object sender, RoutedEventArgs e) { + await this.trustChannel.WriteAsync(false); + this.Close(); + } + } +} diff --git a/XIVChat Desktop/XIVChat Desktop.csproj b/XIVChat Desktop/XIVChat Desktop.csproj index 2c2d56c..868bbeb 100644 --- a/XIVChat Desktop/XIVChat Desktop.csproj +++ b/XIVChat Desktop/XIVChat Desktop.csproj @@ -1,10 +1,59 @@  - - WinExe - netcoreapp3.1 - XIVChat_Desktop - true - + + WinExe + netcoreapp3.1 + XIVChat_Desktop + true + XIVChat Desktop + enable + XIVChat + 1.0.0 + 1.0.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + PublicResXFileCodeGenerator + Resources.Designer.cs + + + + + + Designer + true + + \ No newline at end of file diff --git a/XIVChat Desktop/fonts/ffxiv.ttf b/XIVChat Desktop/fonts/ffxiv.ttf new file mode 100644 index 0000000..a627f77 Binary files /dev/null and b/XIVChat Desktop/fonts/ffxiv.ttf differ diff --git a/XIVChat Desktop/ic_launcher-playstore.png b/XIVChat Desktop/ic_launcher-playstore.png new file mode 100644 index 0000000..3bb6883 Binary files /dev/null and b/XIVChat Desktop/ic_launcher-playstore.png differ diff --git a/XIVChat.sln b/XIVChat.sln index 50a3c2b..c6279dc 100644 --- a/XIVChat.sln +++ b/XIVChat.sln @@ -12,7 +12,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChatCommon", "XIVChatCommon\XIVChatCommon.csproj", "{6F426EAB-15D4-48AC-80D2-2D2DF2CE5EE0}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "XIVChat Desktop", "XIVChat Desktop\XIVChat Desktop.csproj", "{D2773EAE-6B9E-4017-9681-585AAB5A99F4}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "XIVChat Desktop", "XIVChat Desktop\XIVChat Desktop.csproj", "{D2773EAE-6B9E-4017-9681-585AAB5A99F4}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution