From bb77c6a4b9d247c1b3bb955e5992b44cb852c3c3 Mon Sep 17 00:00:00 2001 From: SylveonDeko <59923820+SylveonDeko@users.noreply.github.com> Date: Wed, 30 Oct 2024 09:56:55 -0400 Subject: [PATCH] V3.1 add cross platform support. --- PresenceClient/PresenceClient-GUI/App.axaml | 50 +- .../Helpers/TaskExtensions.cs | 18 - .../Helpers/TrayIconManager.cs | 143 ++- .../Platform/NetworkUtils.cs | 142 +++ .../Platform/PlatformHelper.cs | 62 ++ .../Platform/SystemUtils.cs | 187 ++++ .../PresenceClient-GUI.csproj | 59 +- PresenceClient/PresenceClient-GUI/Program.cs | 89 +- PresenceClient/PresenceClient-GUI/Utils.cs | 2 +- .../ViewModels/MainWindowViewModel.cs | 839 +++++++++--------- .../Views/MainWindow.axaml.cs | 50 +- 11 files changed, 1127 insertions(+), 514 deletions(-) delete mode 100644 PresenceClient/PresenceClient-GUI/Helpers/TaskExtensions.cs create mode 100644 PresenceClient/PresenceClient-GUI/Platform/NetworkUtils.cs create mode 100644 PresenceClient/PresenceClient-GUI/Platform/PlatformHelper.cs create mode 100644 PresenceClient/PresenceClient-GUI/Platform/SystemUtils.cs diff --git a/PresenceClient/PresenceClient-GUI/App.axaml b/PresenceClient/PresenceClient-GUI/App.axaml index 211cade..f6c9ce2 100644 --- a/PresenceClient/PresenceClient-GUI/App.axaml +++ b/PresenceClient/PresenceClient-GUI/App.axaml @@ -1,23 +1,55 @@  + RequestedThemeVariant="Dark"> + + + - - - + + + + + + + + - #FFFFFF - #000000 - #0078D7 + #0078D4 + #005A9E + #004275 + #002642 + #429CE3 + #76B9ED + #A6D8F7 \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Helpers/TaskExtensions.cs b/PresenceClient/PresenceClient-GUI/Helpers/TaskExtensions.cs deleted file mode 100644 index a1f47ce..0000000 --- a/PresenceClient/PresenceClient-GUI/Helpers/TaskExtensions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System; -using System.Threading.Tasks; - -namespace PresenceClient.Helpers; - -public static class TaskExtensions -{ - public static void FireAndForget(this Task task) - { - task.ContinueWith(t => - { - if (t.IsFaulted) - { - Console.WriteLine($"An error occurred: {t.Exception}"); - } - }, TaskContinuationOptions.OnlyOnFaulted); - } -} \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Helpers/TrayIconManager.cs b/PresenceClient/PresenceClient-GUI/Helpers/TrayIconManager.cs index cc746a5..2a15675 100644 --- a/PresenceClient/PresenceClient-GUI/Helpers/TrayIconManager.cs +++ b/PresenceClient/PresenceClient-GUI/Helpers/TrayIconManager.cs @@ -1,77 +1,134 @@ -using Avalonia; -using Avalonia.Controls; +using Avalonia.Controls; using Avalonia.Platform; using System; -using Avalonia.Media.Imaging; using Avalonia.Threading; using PresenceClient.ViewModels; -namespace PresenceClient.Helpers; - -public class TrayIconManager : IDisposable +namespace PresenceClient.Helpers { - private TrayIcon? trayIcon; - private readonly MainWindowViewModel _viewModel; - private NativeMenuItem connectMenuItem; - - public TrayIconManager(MainWindowViewModel viewModel) + public class TrayIconManager : IDisposable { - _viewModel = viewModel; - InitializeTrayIcon(); - } + private readonly MainWindowViewModel viewModel; + private TrayIcon? trayIcon; + private NativeMenuItem? connectMenuItem; + private bool disposed; + private bool isEnabled; - private void InitializeTrayIcon() - { - Dispatcher.UIThread.Post(() => + public TrayIconManager(MainWindowViewModel viewModel) { - trayIcon = new TrayIcon(); + this.viewModel = viewModel; + } + + public void EnableTrayIcon(bool enable) + { + if (disposed || enable == isEnabled) return; + + Dispatcher.UIThread.Post(() => + { + if (enable) + { + InitializeTrayIcon(); + } + else + { + DisposeTrayIcon(); + } + isEnabled = enable; + }); + } + private void InitializeTrayIcon() + { + if (trayIcon != null || disposed) return; + + trayIcon = new TrayIcon(); var menu = new NativeMenu(); + var showItem = new NativeMenuItem("Show"); - showItem.Click += (sender, e) => _viewModel.ShowMainWindow(); + showItem.Click += ShowItem_Click; menu.Add(showItem); connectMenuItem = new NativeMenuItem("Connect"); - connectMenuItem.Click += (sender, e) => _viewModel.ToggleConnection(); + connectMenuItem.Click += ConnectMenuItem_Click; menu.Add(connectMenuItem); var exitItem = new NativeMenuItem("Exit"); - exitItem.Click += (sender, e) => _viewModel.ExitApplication(); + exitItem.Click += ExitItem_Click; menu.Add(exitItem); trayIcon.Menu = menu; trayIcon.Clicked += TrayIcon_Clicked; - UpdateIcon(false); // Start with disconnected icon + UpdateIcon(false); trayIcon.IsVisible = true; - }); - } + } - private void TrayIcon_Clicked(object? sender, EventArgs e) - { - _viewModel.ShowMainWindow(); - } + private void ShowItem_Click(object? sender, EventArgs e) + { + viewModel.ShowMainWindow(); + } - public void UpdateIcon(bool isConnected) - { - Dispatcher.UIThread.Post(() => + private void ConnectMenuItem_Click(object? sender, EventArgs e) + { + viewModel.ToggleConnection(); + } + + private void ExitItem_Click(object? sender, EventArgs e) + { + viewModel.ExitApplication(); + } + + private void TrayIcon_Clicked(object? sender, EventArgs e) + { + viewModel.ShowMainWindow(); + } + + public void UpdateIcon(bool isConnected) + { + if (disposed) return; + + Dispatcher.UIThread.Post(() => + { + if (trayIcon == null || connectMenuItem == null) return; + + var iconName = isConnected ? "Connected.ico" : "Disconnected.ico"; + using var assets = AssetLoader.Open(new Uri($"avares://PresenceClient-GUI/Assets/{iconName}")); + + trayIcon.Icon = new WindowIcon(assets); + trayIcon.ToolTipText = $"PresenceClient ({(isConnected ? "Connected" : "Disconnected")})"; + connectMenuItem.Header = isConnected ? "Disconnect" : "Connect"; + }); + } + + private void DisposeTrayIcon() { if (trayIcon == null) return; - var iconName = isConnected ? "Connected.ico" : "Disconnected.ico"; - var assets = AssetLoader.Open(new Uri($"avares://PresenceClient-GUI/Assets/{iconName}")); - trayIcon.Icon = new WindowIcon(assets); - trayIcon.ToolTipText = $"PresenceClient ({(isConnected ? "Connected" : "Disconnected")})"; + trayIcon.IsVisible = false; + trayIcon.Clicked -= TrayIcon_Clicked; - connectMenuItem.Header = isConnected ? "Disconnect" : "Connect"; - }); - } + if (trayIcon.Menu != null) + { + foreach (var item in trayIcon.Menu.Items) + { + if (item is not NativeMenuItem menuItem) continue; + menuItem.Click -= ShowItem_Click; + menuItem.Click -= ConnectMenuItem_Click; + menuItem.Click -= ExitItem_Click; + } + } - public void Dispose() - { - Dispatcher.UIThread.Post(() => + trayIcon.Dispose(); + trayIcon = null; + connectMenuItem = null; + } + + public void Dispose() { - trayIcon?.Dispose(); - }); + if (disposed) return; + + Dispatcher.UIThread.Post(DisposeTrayIcon); + disposed = true; + } } } \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Platform/NetworkUtils.cs b/PresenceClient/PresenceClient-GUI/Platform/NetworkUtils.cs new file mode 100644 index 0000000..0aa593e --- /dev/null +++ b/PresenceClient/PresenceClient-GUI/Platform/NetworkUtils.cs @@ -0,0 +1,142 @@ +using System; +using System.Diagnostics; +using System.Text.RegularExpressions; + +namespace PresenceClient.Platform +{ + public static class NetworkUtils + { + public static string GetMacByIp(string ip) + { + if (string.IsNullOrEmpty(ip)) + return string.Empty; + + try + { + if (PlatformHelper.IsWindows) + return GetWindowsMacByIp(ip); + else + return GetUnixMacByIp(ip); + } + catch (Exception ex) + { + Console.WriteLine($"Error getting MAC address: {ex.Message}"); + return string.Empty; + } + } + + public static string GetIpByMac(string mac) + { + if (string.IsNullOrEmpty(mac)) + return string.Empty; + + try + { + if (PlatformHelper.IsWindows) + return GetWindowsIpByMac(mac); + else + return GetUnixIpByMac(mac); + } + catch (Exception ex) + { + Console.WriteLine($"Error getting IP address: {ex.Message}"); + return string.Empty; + } + } + + private static string GetWindowsMacByIp(string ip) + { + var output = RunCommand("arp", $"-a {ip}"); + var match = Regex.Match(output, @"(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}"); + return match.Success ? match.Value.Replace("-", ":").ToLower() : string.Empty; + } + + private static string GetWindowsIpByMac(string mac) + { + var output = RunCommand("arp", "-a"); + mac = mac.Replace(":", "-").ToUpper(); + var match = Regex.Match(output, $@"([0-9.]{{7,15}})\s+{mac}"); + return match.Success ? match.Groups[1].Value : string.Empty; + } + + private static string GetUnixMacByIp(string ip) + { + string command, arguments; + if (PlatformHelper.IsMacOS) + { + command = "arp"; + arguments = $"-n {ip}"; + } + else // Linux + { + command = "ip"; + arguments = $"neigh show {ip}"; + } + + var output = RunCommand(command, arguments); + var match = Regex.Match(output, @"(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}"); + return match.Success ? match.Value.ToLower() : string.Empty; + } + + private static string GetUnixIpByMac(string mac) + { + string command, arguments; + if (PlatformHelper.IsMacOS) + { + command = "arp"; + arguments = "-an"; + } + else // Linux + { + command = "ip"; + arguments = "neigh show"; + } + + var output = RunCommand(command, arguments); + mac = mac.ToLower(); + var match = Regex.Match(output, $@"([0-9.]{{7,15}}).*{mac}"); + return match.Success ? match.Groups[1].Value : string.Empty; + } + + private static string RunCommand(string command, string arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = command, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + try + { + process.Start(); + var output = process.StandardOutput.ReadToEnd(); + process.WaitForExit(); + return output; + } + catch (Exception ex) + { + Console.WriteLine($"Error running command {command}: {ex.Message}"); + return string.Empty; + } + } + + public static bool IsValidMacAddress(string mac) + { + return !string.IsNullOrEmpty(mac) && + Regex.IsMatch(mac, "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"); + } + + public static bool IsValidIpAddress(string ip) + { + return !string.IsNullOrEmpty(ip) && + Regex.IsMatch(ip, @"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"); + } + } +} \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Platform/PlatformHelper.cs b/PresenceClient/PresenceClient-GUI/Platform/PlatformHelper.cs new file mode 100644 index 0000000..163cf9f --- /dev/null +++ b/PresenceClient/PresenceClient-GUI/Platform/PlatformHelper.cs @@ -0,0 +1,62 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace PresenceClient.Platform; + +public static class PlatformHelper +{ + public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + public static bool IsMacOS => RuntimeInformation.IsOSPlatform(OSPlatform.OSX); + public static bool IsLinux => RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + public static string GetConfigDirectory() + { + if (IsWindows) + return AppContext.BaseDirectory; + else if (IsMacOS) + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library/Application Support/PresenceClient"); + else // Linux + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config/presenceclient"); + } + + public static string GetConfigPath() + { + EnsureConfigDirectory(); + return Path.Combine(GetConfigDirectory(), "Config.json"); + } + + public static string GetResourcePath(string filename) + { + if (IsWindows) + return Path.Combine(AppContext.BaseDirectory, "Assets", filename); + else if (IsMacOS) + return Path.Combine(AppContext.BaseDirectory, "Contents", "Resources", filename); + else // Linux + return Path.Combine("/usr/share/presenceclient", filename); + } + + public static void EnsureConfigDirectory() + { + var configDir = GetConfigDirectory(); + if (!Directory.Exists(configDir)) + { + Directory.CreateDirectory(configDir); + } + } + + public static bool CanUseTrayIcon() + { + if (IsWindows) + return true; + if (IsMacOS) + return true; + if (IsLinux) + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("XDG_CURRENT_DESKTOP")); + return false; + } +} \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Platform/SystemUtils.cs b/PresenceClient/PresenceClient-GUI/Platform/SystemUtils.cs new file mode 100644 index 0000000..3a4d7ae --- /dev/null +++ b/PresenceClient/PresenceClient-GUI/Platform/SystemUtils.cs @@ -0,0 +1,187 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; + +namespace PresenceClient.Platform +{ + public static class SystemUtils + { + public static async Task SetAutoStartAsync(bool enable) + { + try + { + if (PlatformHelper.IsWindows) + { + await SetWindowsAutoStartAsync(enable); + } + else if (PlatformHelper.IsMacOS) + { + await SetMacOSAutoStartAsync(enable); + } + else if (PlatformHelper.IsLinux) + { + await SetLinuxAutoStartAsync(enable); + } + } + catch (Exception ex) + { + Console.WriteLine($"Error setting auto start: {ex.Message}"); + } + } + + private static async Task SetWindowsAutoStartAsync(bool enable) + { + var startupFolder = Environment.GetFolderPath(Environment.SpecialFolder.Startup); + var shortcutPath = Path.Combine(startupFolder, "PresenceClient.lnk"); + var exePath = Process.GetCurrentProcess().MainModule?.FileName ?? + Path.Combine(AppContext.BaseDirectory, "PresenceClient.exe"); + + if (enable) + { + var shellLink = (IShellLink)new ShellLink(); + shellLink.SetPath(exePath); + shellLink.SetWorkingDirectory(Path.GetDirectoryName(exePath)); + + var persistFile = (IPersistFile)shellLink; + persistFile.Save(shortcutPath, false); + + Marshal.ReleaseComObject(shellLink); + Marshal.ReleaseComObject(persistFile); + } + else + { + if (File.Exists(shortcutPath)) + { + File.Delete(shortcutPath); + } + } + } + + [ComImport] + [Guid("00021401-0000-0000-C000-000000000046")] + private class ShellLink { } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("000214F9-0000-0000-C000-000000000046")] + private interface IShellLink + { + void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile, int cchMaxPath, out IntPtr pfd, int fFlags); + void GetIDList(out IntPtr ppidl); + void SetIDList(IntPtr pidl); + void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszName, int cchMaxName); + void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName); + void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszDir, int cchMaxPath); + void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir); + void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszArgs, int cchMaxPath); + void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs); + void GetHotkey(out short pwHotkey); + void SetHotkey(short wHotkey); + void GetShowCmd(out int piShowCmd); + void SetShowCmd(int iShowCmd); + void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszIconPath, int cchIconPath, out int piIcon); + void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon); + void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, int dwReserved); + void Resolve(IntPtr hwnd, int fFlags); + void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile); + } + + [ComImport] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [Guid("0000010B-0000-0000-C000-000000000046")] + internal interface IPersistFile + { + void GetCurFile([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder pszFile); + void IsDirty(); + void Load([In, MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode); + void Save([In, MarshalAs(UnmanagedType.LPWStr)] string pszFileName, [In, MarshalAs(UnmanagedType.Bool)] bool fRemember); + void SaveCompleted([In, MarshalAs(UnmanagedType.LPWStr)] string pszFileName); + } + + private static async Task SetMacOSAutoStartAsync(bool enable) + { + var plistPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library/LaunchAgents/com.presenceclient.app.plist"); + + if (enable) + { + var plistContent = $@" + + + + Label + com.presenceclient.app + ProgramArguments + + {Process.GetCurrentProcess().MainModule?.FileName} + + RunAtLoad + + +"; + await File.WriteAllTextAsync(plistPath, plistContent); + await RunCommandAsync("launchctl", $"load {plistPath}"); + } + else + { + if (File.Exists(plistPath)) + { + await RunCommandAsync("launchctl", $"unload {plistPath}"); + File.Delete(plistPath); + } + } + } + + private static async Task SetLinuxAutoStartAsync(bool enable) + { + var autostartDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".config/autostart"); + var desktopFile = Path.Combine(autostartDir, "presenceclient.desktop"); + + if (enable) + { + Directory.CreateDirectory(autostartDir); + var desktopEntry = $@"[Desktop Entry] +Type=Application +Name=PresenceClient +Exec={Process.GetCurrentProcess().MainModule?.FileName} +Hidden=false +NoDisplay=false +X-GNOME-Autostart-enabled=true"; + await File.WriteAllTextAsync(desktopFile, desktopEntry); + } + else + { + if (File.Exists(desktopFile)) + { + File.Delete(desktopFile); + } + } + } + + private static async Task RunCommandAsync(string command, string arguments) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = command, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(); + await process.WaitForExitAsync(); + return output; + } + } +} \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/PresenceClient-GUI.csproj b/PresenceClient/PresenceClient-GUI/PresenceClient-GUI.csproj index 8609dfb..460ed02 100644 --- a/PresenceClient/PresenceClient-GUI/PresenceClient-GUI.csproj +++ b/PresenceClient/PresenceClient-GUI/PresenceClient-GUI.csproj @@ -6,32 +6,57 @@ true app.manifest true - Assets\Icon.ico true + full + true + + + + DEBUG;TRACE - - - - - - - - - - - - + + $(DefineConstants);WINDOWS + Assets\Icon.ico + + + + $(DefineConstants);OSX + PresenceClient + com.presenceclient.app + 1.0.0 + 1.0.0 + APPL + ???? + PresenceClient + Icon.icns + true + false + + + + $(DefineConstants);LINUX + - + + + + + + + + + + + + - + - - + \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Program.cs b/PresenceClient/PresenceClient-GUI/Program.cs index 93b3ab3..9ff22a8 100644 --- a/PresenceClient/PresenceClient-GUI/Program.cs +++ b/PresenceClient/PresenceClient-GUI/Program.cs @@ -1,27 +1,84 @@ using System; using Avalonia; using Avalonia.ReactiveUI; +using PresenceClient.Platform; -namespace PresenceClient; - -internal class Program +namespace PresenceClient { - [STAThread] - public static void Main(string[] args) + internal class Program { - BuildAvaloniaApp() - .StartWithClassicDesktopLifetime(args); - } + [STAThread] + public static void Main(string[] args) + { + try + { + BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + } + catch (Exception ex) + { + Console.WriteLine($"Application crashed: {ex}"); + throw; + } + } + + public static AppBuilder BuildAvaloniaApp() + { + var builder = AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .UseReactiveUI(); + + if (PlatformHelper.IsLinux) + { + builder.With(new X11PlatformOptions + { + EnableMultiTouch = true, + UseDBusMenu = true, + UseDBusFilePicker = true, + RenderingMode = + [ + X11RenderingMode.Glx, + X11RenderingMode.Egl, + X11RenderingMode.Software + ] + }); + } + else if (PlatformHelper.IsWindows) + { + builder.With(new Win32PlatformOptions + { + RenderingMode = + [ + Win32RenderingMode.AngleEgl, + Win32RenderingMode.Wgl, + Win32RenderingMode.Software + ], + CompositionMode = + [ + Win32CompositionMode.WinUIComposition, + Win32CompositionMode.DirectComposition, + Win32CompositionMode.RedirectionSurface + ], + DpiAwareness = Win32DpiAwareness.PerMonitorDpiAware + }); + } + else if (PlatformHelper.IsMacOS) + { + builder.With(new MacOSPlatformOptions + { + ShowInDock = true, + DisableDefaultApplicationMenuItems = false, + DisableNativeMenus = false, + DisableSetProcessName = false, + }); + } - private static AppBuilder BuildAvaloniaApp() - { - return AppBuilder.Configure() - .UsePlatformDetect() - .WithInterFont() #if DEBUG - .LogToTrace() + builder.LogToTrace(); #endif - .UseReactiveUI() - .UseSkia(); + + return builder; + } } } \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Utils.cs b/PresenceClient/PresenceClient-GUI/Utils.cs index 121ad95..548729d 100644 --- a/PresenceClient/PresenceClient-GUI/Utils.cs +++ b/PresenceClient/PresenceClient-GUI/Utils.cs @@ -5,7 +5,7 @@ using System.Runtime.InteropServices; using System.Text.RegularExpressions; -namespace PresenceClient_GUI; +namespace PresenceClient; public static partial class Utils { diff --git a/PresenceClient/PresenceClient-GUI/ViewModels/MainWindowViewModel.cs b/PresenceClient/PresenceClient-GUI/ViewModels/MainWindowViewModel.cs index 2cb9ccf..05c9f51 100644 --- a/PresenceClient/PresenceClient-GUI/ViewModels/MainWindowViewModel.cs +++ b/PresenceClient/PresenceClient-GUI/ViewModels/MainWindowViewModel.cs @@ -7,529 +7,578 @@ using System.Threading; using System.Threading.Tasks; using Avalonia.Controls; -using Avalonia.Media; -using Avalonia.Media.Transformation; using Avalonia.Threading; using DiscordRPC; -using Newtonsoft.Json; -using PresenceClient_GUI; using PresenceClient.Helpers; +using PresenceClient.Platform; using PresenceClient.Views; using PresenceCommon.Types; +using System.Text.Json; +using System.Text.Json.Serialization; using ReactiveUI; -namespace PresenceClient.ViewModels; - -public class MainWindowViewModel : ReactiveObject, IDisposable +namespace PresenceClient.ViewModels { - private static TrayIconManager? trayIconManager; - private bool autoConvertIpToMac; - private string bigImageKey = ""; - private string bigImageText = ""; - - private CancellationTokenSource? cancellationTokenSource; - private Socket? client; - private string clientId = ""; - private bool displayHomeMenu = true; - private bool hasSeenMacPrompt; - private string ipAddress = ""; - private bool isConnected; - private string lastProgramName = string.Empty; - private bool manualUpdate; - private bool minimizeToTray; - private IPAddress? resolvedIpAddress; - private DiscordRpcClient? rpc; - private bool showTimeLapsed = true; - private string smallImageKey = ""; - private string stateText = ""; - private string status = ""; - private Timestamps? time; - - public MainWindowViewModel() + public class MainWindowViewModel : ReactiveObject, IDisposable { - LoadConfig(); - var canConnect = this.WhenAnyValue(x => x.IsConnected).Select(connected => !connected); - ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync, canConnect); - - var canDisconnect = this.WhenAnyValue(x => x.IsConnected); - DisconnectCommand = ReactiveCommand.Create(Disconnect, canDisconnect); + private static TrayIconManager? trayIconManager; + private bool autoConvertIpToMac; + private string bigImageKey = ""; + private string bigImageText = ""; + private CancellationTokenSource? cancellationTokenSource; + private Socket? client; + private string clientId = ""; + private bool displayHomeMenu = true; + private bool hasSeenMacPrompt; + private string ipAddress = ""; + private bool isConnected; + private string lastProgramName = string.Empty; + private bool manualUpdate; + private bool minimizeToTray; + private IPAddress? resolvedIpAddress; + private DiscordRpcClient? rpc; + private bool showTimeLapsed = true; + private string smallImageKey = ""; + private string stateText = ""; + private string status = ""; + private Timestamps? time; + private UserControl currentPage; + private readonly string _configPath; + + public MainWindowViewModel() + { + _configPath = PlatformHelper.GetConfigPath(); + currentPage = new MainPage(); + + if (PlatformHelper.CanUseTrayIcon()) + { + trayIconManager ??= new TrayIconManager(this); + } - ShowMainCommand = ReactiveCommand.Create(ShowMain); - ShowSettingsCommand = ReactiveCommand.Create(ShowSettings); + LoadConfig(); - trayIconManager ??= new TrayIconManager(this); - } + var canConnect = this.WhenAnyValue(x => x.IsConnected).Select(connected => !connected); + ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync, canConnect); - public ReactiveCommand ConnectCommand { get; } - public ReactiveCommand DisconnectCommand { get; } + var canDisconnect = this.WhenAnyValue(x => x.IsConnected); + DisconnectCommand = ReactiveCommand.Create(Disconnect, canDisconnect); - private UserControl currentPage = new MainPage(); - public UserControl CurrentPage - { - set => this.RaiseAndSetIfChanged(ref currentPage, value); - } + ReactiveCommand.Create(ShowMain); + ReactiveCommand.Create(ShowSettings); + } - public ReactiveCommand ShowMainCommand; - public ReactiveCommand ShowSettingsCommand; + public ReactiveCommand ConnectCommand { get; } + public ReactiveCommand DisconnectCommand { get; } - public string IpAddress - { - get - { - return ipAddress; - } - set + public UserControl CurrentPage { - this.RaiseAndSetIfChanged(ref ipAddress, value); - SaveConfig(); + get => currentPage; + set => this.RaiseAndSetIfChanged(ref currentPage, value); } - } - public string ClientId - { - get - { - return clientId; - } - set + public string IpAddress { - this.RaiseAndSetIfChanged(ref clientId, value); - SaveConfig(); + get => ipAddress; + set + { + this.RaiseAndSetIfChanged(ref ipAddress, value); + SaveConfig(); + } } - } - public string BigImageKey - { - get + public string ClientId { - return bigImageKey; - } - set - { - this.RaiseAndSetIfChanged(ref bigImageKey, value); - SaveConfig(); + get => clientId; + set + { + this.RaiseAndSetIfChanged(ref clientId, value); + SaveConfig(); + } } - } - public string BigImageText - { - get - { - return bigImageText; - } - set + public string BigImageKey { - this.RaiseAndSetIfChanged(ref bigImageText, value); - SaveConfig(); + get => bigImageKey; + set + { + this.RaiseAndSetIfChanged(ref bigImageKey, value); + SaveConfig(); + } } - } - public string SmallImageKey - { - get - { - return smallImageKey; - } - set + public string BigImageText { - this.RaiseAndSetIfChanged(ref smallImageKey, value); - SaveConfig(); + get => bigImageText; + set + { + this.RaiseAndSetIfChanged(ref bigImageText, value); + SaveConfig(); + } } - } - public string StateText - { - get + public string SmallImageKey { - return stateText; - } - set - { - this.RaiseAndSetIfChanged(ref stateText, value); - SaveConfig(); + get => smallImageKey; + set + { + this.RaiseAndSetIfChanged(ref smallImageKey, value); + SaveConfig(); + } } - } - public bool ShowTimeLapsed - { - get - { - return showTimeLapsed; - } - set + public string StateText { - this.RaiseAndSetIfChanged(ref showTimeLapsed, value); - SaveConfig(); + get => stateText; + set + { + this.RaiseAndSetIfChanged(ref stateText, value); + SaveConfig(); + } } - } - public bool MinimizeToTray - { - get + public bool ShowTimeLapsed { - return minimizeToTray; - } - set - { - this.RaiseAndSetIfChanged(ref minimizeToTray, value); - SaveConfig(); + get => showTimeLapsed; + set + { + this.RaiseAndSetIfChanged(ref showTimeLapsed, value); + SaveConfig(); + } } - } - public bool DisplayHomeMenu - { - get + public bool MinimizeToTray { - return displayHomeMenu; - } - set - { - this.RaiseAndSetIfChanged(ref displayHomeMenu, value); - SaveConfig(); + get => minimizeToTray; + set + { + this.RaiseAndSetIfChanged(ref minimizeToTray, value); + SaveConfig(); + } } - } - public bool AutoConvertIpToMac - { - get - { - return autoConvertIpToMac; - } - set + public bool DisplayHomeMenu { - this.RaiseAndSetIfChanged(ref autoConvertIpToMac, value); - SaveConfig(); + get => displayHomeMenu; + set + { + this.RaiseAndSetIfChanged(ref displayHomeMenu, value); + SaveConfig(); + } } - } - public string Status - { - get + public bool AutoConvertIpToMac { - return status; - } - set - { - this.RaiseAndSetIfChanged(ref status, value); - SaveConfig(); + get => autoConvertIpToMac; + set + { + this.RaiseAndSetIfChanged(ref autoConvertIpToMac, value); + SaveConfig(); + } } - } - public bool IsConnected - { - get + public string Status { - return isConnected; + get => status; + set => this.RaiseAndSetIfChanged(ref status, value); } - set + + public bool IsConnected { - this.RaiseAndSetIfChanged(ref isConnected, value); - SaveConfig(); + get => isConnected; + set + { + this.RaiseAndSetIfChanged(ref isConnected, value); + SaveConfig(); + } } - } - - public event EventHandler? ShowMainWindowRequested; - public void ShowMainWindow() - { - ShowMainWindowRequested?.Invoke(this, EventArgs.Empty); - } + public event EventHandler? ShowMainWindowRequested; - public void ToggleConnection() - { - if (IsConnected) + public void ShowMainWindow() { - Disconnect(); + ShowMainWindowRequested?.Invoke(this, EventArgs.Empty); } - else + + public void ToggleConnection() { - ConnectAsync().FireAndForget(); + if (IsConnected) + { + Disconnect(); + } + else + { + _ = ConnectAsync(); + } } - } - private async Task UpdateConnectionStatusAsync(bool isConnected) - { - await Dispatcher.UIThread.InvokeAsync(() => + private async Task UpdateConnectionStatusAsync(bool isConnectedSecond) { - IsConnected = isConnected; - trayIconManager?.UpdateIcon(isConnected); - }); - } - - public void ExitApplication() - { - SaveConfig(); - Disconnect(); - Environment.Exit(0); - } - - public void Dispose() - { - if (trayIconManager == null) return; - trayIconManager.Dispose(); - trayIconManager = null; - } + await Dispatcher.UIThread.InvokeAsync(() => + { + IsConnected = isConnectedSecond; + trayIconManager?.UpdateIcon(isConnectedSecond); + }); + } - private void ShowMain() - { - Dispatcher.UIThread.Post(() => + public void ExitApplication() { - CurrentPage = new MainPage { DataContext = this }; - }); - } + Disconnect(); + Environment.Exit(0); + } - private void ShowSettings() - { - Dispatcher.UIThread.Post(() => - { - CurrentPage = new SettingsPage { DataContext = this }; - }); - } - private async Task ConnectAsync() - { - if (isConnected) + public void Dispose() { Disconnect(); - return; + if (trayIconManager != null) + { + trayIconManager.Dispose(); + trayIconManager = null; + } } - if (string.IsNullOrWhiteSpace(clientId)) + private void ShowMain() { - Status = "Client ID cannot be empty"; - return; + Dispatcher.UIThread.Post(() => + { + CurrentPage = new MainPage + { + DataContext = this + }; + }); } - // Check and see if we have an IP - if (IPAddress.TryParse(ipAddress, out resolvedIpAddress)) + private void ShowSettings() { - if (!hasSeenMacPrompt) + Dispatcher.UIThread.Post(() => { - hasSeenMacPrompt = true; - autoConvertIpToMac = true; - await IpToMacAsync(); - } - else if (autoConvertIpToMac) - { - await IpToMacAsync(); - } + CurrentPage = new SettingsPage + { + DataContext = this + }; + }); } - else + + private async Task ConnectAsync() { - // If in this block, means we don't have a valid IP. - // Check and see if it's a MAC Address - try + if (isConnected) { - resolvedIpAddress = IPAddress.Parse(Utils.GetIpByMac(ipAddress)); + Disconnect(); + return; } - catch (FormatException) + + if (string.IsNullOrWhiteSpace(clientId)) { - Status = "Invalid IP or MAC Address"; + Status = "Client ID cannot be empty"; return; } - } - cancellationTokenSource = new CancellationTokenSource(); - isConnected = true; + try + { + if (IPAddress.TryParse(ipAddress, out resolvedIpAddress)) + { + if (!hasSeenMacPrompt) + { + hasSeenMacPrompt = true; + autoConvertIpToMac = true; + await IpToMacAsync(); + } + else if (autoConvertIpToMac) + { + await IpToMacAsync(); + } + } + else + { + var resolvedIp = NetworkUtils.GetIpByMac(ipAddress); + if (string.IsNullOrEmpty(resolvedIp)) + { + Status = "Invalid IP or MAC Address"; + return; + } - try - { - await Task.Run(() => TryConnect(cancellationTokenSource.Token)); - } - catch (OperationCanceledException) - { - Status = "Connection was cancelled"; - } - catch (Exception ex) - { - Status = $"Connection error: {ex.Message}"; - isConnected = false; - } - } + resolvedIpAddress = IPAddress.Parse(resolvedIp); + } - private void Disconnect() - { - cancellationTokenSource?.Cancel(); - rpc?.Dispose(); - rpc = null; - client?.Close(); - client = null; - isConnected = false; - Status = "Disconnected"; - lastProgramName = string.Empty; - time = null; - UpdateConnectionStatusAsync(false).FireAndForget(); - } + cancellationTokenSource = new CancellationTokenSource(); + isConnected = true; - private async Task TryConnect(CancellationToken cancellationToken) - { - rpc = new DiscordRpcClient(clientId); - rpc.Initialize(); + await Task.Run(() => TryConnect(cancellationTokenSource.Token)); + } + catch (OperationCanceledException) + { + Status = "Connection was cancelled"; + } + catch (Exception ex) + { + Status = $"Connection error: {ex.Message}"; + isConnected = false; + } + } - while (!cancellationToken.IsCancellationRequested) + private void Disconnect() { try { - client = new Socket(SocketType.Stream, ProtocolType.Tcp) - { - ReceiveTimeout = 5500, SendTimeout = 5500 - }; + cancellationTokenSource?.Cancel(); - await Dispatcher.UIThread.InvokeAsync(() => + if (rpc is { IsDisposed: false }) { - Status = "Attempting to connect to server..."; - }); + rpc.ClearPresence(); + rpc.Dispose(); + } - var localEndPoint = new IPEndPoint(resolvedIpAddress!, 0xCAFE); + rpc = null; - await client.ConnectAsync(localEndPoint, cancellationToken); - await Dispatcher.UIThread.InvokeAsync(() => Status = "Connected to the server!"); - await UpdateConnectionStatusAsync(true); + if (client != null) + { + client.Close(); + client.Dispose(); + } - await DataListenAsync(cancellationToken); - } - catch (ArgumentNullException) - { - await Task.Delay(1000, cancellationToken); - resolvedIpAddress = IPAddress.Parse(Utils.GetIpByMac(ipAddress)); - } - catch (SocketException) - { - client?.Close(); - if (rpc != null && !rpc.IsDisposed) rpc.ClearPresence(); - await Task.Delay(5000, cancellationToken); + client = null; + + isConnected = false; + Status = "Disconnected"; + lastProgramName = string.Empty; + time = null; + _ = UpdateConnectionStatusAsync(false); } catch (Exception ex) { - await Dispatcher.UIThread.InvokeAsync(() => Status = $"Connection error: {ex.Message}"); - await Task.Delay(5000, cancellationToken); + Status = $"Error during disconnect: {ex.Message}"; } } - } - private async Task DataListenAsync(CancellationToken cancellationToken) - { - manualUpdate = true; - while (!cancellationToken.IsCancellationRequested) + private async Task TryConnect(CancellationToken cancellationToken) { - try + rpc = new DiscordRpcClient(clientId); + rpc.Initialize(); + + while (!cancellationToken.IsCancellationRequested) { - var bytes = await PresenceCommon.Utils.ReceiveExactlyAsync(client!, - cancellationToken: cancellationToken); - await Dispatcher.UIThread.InvokeAsync(() => Status = "Connected to the server!"); + try + { + client = new Socket(SocketType.Stream, ProtocolType.Tcp) + { + ReceiveTimeout = 5500, SendTimeout = 5500 + }; + + await Dispatcher.UIThread.InvokeAsync(() => + { + Status = "Attempting to connect to server..."; + }); + + var localEndPoint = new IPEndPoint(resolvedIpAddress!, 0xCAFE); - var title = new Title(bytes); - if (title.Magic == 0xffaadd23) + await client.ConnectAsync(localEndPoint, cancellationToken); + await Dispatcher.UIThread.InvokeAsync(() => Status = "Connected to the server!"); + await UpdateConnectionStatusAsync(true); + + await DataListenAsync(cancellationToken); + } + catch (ArgumentNullException) { - if (lastProgramName != title.Name) + await Task.Delay(1000, cancellationToken); + var newIp = NetworkUtils.GetIpByMac(ipAddress); + if (!string.IsNullOrEmpty(newIp)) { - time = Timestamps.Now; + resolvedIpAddress = IPAddress.Parse(newIp); } + } + catch (SocketException) + { + if (client != null) + { + client.Close(); + client.Dispose(); + client = null; + } + + if (rpc is { IsDisposed: false }) rpc.ClearPresence(); + await Task.Delay(5000, cancellationToken); + } + catch (Exception ex) + { + await Dispatcher.UIThread.InvokeAsync(() => Status = $"Connection error: {ex.Message}"); + await Task.Delay(5000, cancellationToken); + } + } + } - if (lastProgramName != title.Name || manualUpdate) + private async Task DataListenAsync(CancellationToken cancellationToken) + { + manualUpdate = true; + while (!cancellationToken.IsCancellationRequested) + { + try + { + var bytes = await PresenceCommon.Utils.ReceiveExactlyAsync(client!, + cancellationToken: cancellationToken); + await Dispatcher.UIThread.InvokeAsync(() => Status = "Connected to the server!"); + + var title = new Title(bytes); + if (title.Magic == 0xffaadd23) { - await UpdatePropertiesFromTitle(title); + if (lastProgramName != title.Name) + { + time = Timestamps.Now; + } - if (rpc != null) + if (lastProgramName != title.Name || manualUpdate) { - if (!DisplayHomeMenu && title.Name == "Home Menu") - rpc.ClearPresence(); - else + await UpdatePropertiesFromTitle(title); + + if (rpc is { IsDisposed: false }) { - rpc.SetPresence(PresenceCommon.Utils.CreateDiscordPresence(title, time, BigImageKey, - BigImageText, SmallImageKey, StateText, ShowTimeLapsed)); + if (!DisplayHomeMenu && title.Name == "Home Menu") + rpc.ClearPresence(); + else + { + rpc.SetPresence(PresenceCommon.Utils.CreateDiscordPresence(title, time, BigImageKey, + BigImageText, SmallImageKey, StateText, ShowTimeLapsed)); + } } - } - manualUpdate = false; - lastProgramName = title.Name; + manualUpdate = false; + lastProgramName = title.Name; + } + } + else + { + if (rpc is { IsDisposed: false }) rpc.ClearPresence(); + if (client == null) return; + client.Close(); + client.Dispose(); + return; } } - else + catch (SocketException) + { + if (rpc is { IsDisposed: false }) rpc.ClearPresence(); + if (client == null) return; + client.Close(); + client.Dispose(); + return; + } + catch (Exception ex) { - if (rpc != null && !rpc.IsDisposed) rpc.ClearPresence(); - client!.Close(); + await Dispatcher.UIThread.InvokeAsync(() => Status = $"Error during data listen: {ex.Message}"); return; } } - catch (SocketException) + } + + private async Task UpdatePropertiesFromTitle(Title title) + { + await Dispatcher.UIThread.InvokeAsync(() => { - if (rpc != null && !rpc.IsDisposed) rpc.ClearPresence(); - client!.Close(); - return; + BigImageKey = $"0{title.ProgramId:x}"; + BigImageText = title.Name; + StateText = $"Playing {title.Name}"; + }); + } + + private async Task IpToMacAsync() + { + if (resolvedIpAddress == null) return; + + var macAddress = NetworkUtils.GetMacByIp(resolvedIpAddress.ToString()); + if (!string.IsNullOrEmpty(macAddress)) + { + ipAddress = macAddress; + } + else + { + await Dispatcher.UIThread.InvokeAsync(() => Status = "Can't convert to MAC Address! Sorry!"); } } - } - private async Task UpdatePropertiesFromTitle(Title title) - { - await Dispatcher.UIThread.InvokeAsync(() => + private void LoadConfig() { - BigImageKey = $"0{title.ProgramId:x}"; - BigImageText = title.Name; - StateText = $"Playing {title.Name}"; - }); - } + try + { + if (!File.Exists(_configPath)) return; + + var jsonString = File.ReadAllText(_configPath); + var cfg = JsonSerializer.Deserialize(jsonString, SourceGenerationContext.Default.Config); + if (cfg == null) return; + + showTimeLapsed = cfg.DisplayTimer; + bigImageKey = cfg.BigKey; + bigImageText = cfg.BigText; + smallImageKey = cfg.SmallKey; + ipAddress = cfg.Ip; + stateText = cfg.State; + clientId = cfg.Client; + minimizeToTray = cfg.AllowTray; + displayHomeMenu = cfg.DisplayMainMenu; + hasSeenMacPrompt = cfg.SeenAutoMacPrompt; + autoConvertIpToMac = cfg.AutoToMac; + trayIconManager?.EnableTrayIcon(cfg.AllowTray); + } + catch (Exception ex) + { + Status = $"Error loading config: {ex.Message}"; + minimizeToTray = false; + } + } - private async Task IpToMacAsync() - { - var macAddress = Utils.GetMacByIp(resolvedIpAddress!.ToString()); - if (macAddress != string.Empty) - ipAddress = macAddress; - else + + private void SaveConfig() { - await Dispatcher.UIThread.InvokeAsync(() => Status = "Can't convert to MAC Address! Sorry!"); + try + { + PlatformHelper.EnsureConfigDirectory(); + + var cfg = new Config + { + Ip = ipAddress, + Client = clientId, + BigKey = bigImageKey, + SmallKey = smallImageKey, + State = stateText, + BigText = bigImageText, + DisplayTimer = showTimeLapsed, + AllowTray = minimizeToTray, + DisplayMainMenu = displayHomeMenu, + SeenAutoMacPrompt = hasSeenMacPrompt, + AutoToMac = autoConvertIpToMac + }; + + var jsonString = JsonSerializer.Serialize(cfg, SourceGenerationContext.Default.Config); + File.WriteAllText(_configPath, jsonString); + trayIconManager?.EnableTrayIcon(minimizeToTray); + } + catch (Exception ex) + { + Status = $"Error saving config: {ex.Message}"; + Console.WriteLine("Error saving config: " + ex.Message); + } } } - private void LoadConfig() - { - if (!File.Exists("Config.json")) return; - var cfg = JsonConvert.DeserializeObject(File.ReadAllText("Config.json")); - if (cfg == null) return; - showTimeLapsed = cfg.DisplayTimer; - bigImageKey = cfg.BigKey; - bigImageText = cfg.BigText; - smallImageKey = cfg.SmallKey; - ipAddress = cfg.Ip; - stateText = cfg.State; - clientId = cfg.Client; - minimizeToTray = cfg.AllowTray; - displayHomeMenu = cfg.DisplayMainMenu; - hasSeenMacPrompt = cfg.SeenAutoMacPrompt; - autoConvertIpToMac = cfg.AutoToMac; - } + [JsonSourceGenerationOptions(WriteIndented = true)] + [JsonSerializable(typeof(Config))] + internal partial class SourceGenerationContext : JsonSerializerContext { } - private void SaveConfig() + public class Config { - var cfg = new Config - { - Ip = ipAddress, - Client = clientId, - BigKey = bigImageKey, - SmallKey = smallImageKey, - State = stateText, - BigText = bigImageText, - DisplayTimer = showTimeLapsed, - AllowTray = minimizeToTray, - DisplayMainMenu = displayHomeMenu, - SeenAutoMacPrompt = hasSeenMacPrompt, - AutoToMac = autoConvertIpToMac - }; - File.WriteAllText("Config.json", JsonConvert.SerializeObject(cfg, Formatting.Indented)); + public string Ip { get; set; } = ""; + public string Client { get; set; } = ""; + public string BigKey { get; set; } = ""; + public string SmallKey { get; set; } = ""; + public string State { get; set; } = ""; + public string BigText { get; set; } = ""; + public bool DisplayTimer { get; set; } + public bool AllowTray { get; set; } + public bool DisplayMainMenu { get; set; } + public bool SeenAutoMacPrompt { get; set; } + public bool AutoToMac { get; set; } + + public Config() + { + DisplayMainMenu = true; + DisplayTimer = true; + } } -} - -public class Config -{ - public string Ip { get; set; } = ""; - public string Client { get; set; } = ""; - public string BigKey { get; set; } = ""; - public string SmallKey { get; set; } = ""; - public string State { get; set; } = ""; - public string BigText { get; set; } = ""; - public bool DisplayTimer { get; set; } - public bool AllowTray { get; set; } - public bool DisplayMainMenu { get; set; } - public bool SeenAutoMacPrompt { get; set; } - public bool AutoToMac { get; set; } } \ No newline at end of file diff --git a/PresenceClient/PresenceClient-GUI/Views/MainWindow.axaml.cs b/PresenceClient/PresenceClient-GUI/Views/MainWindow.axaml.cs index 7dc2b99..33af3c0 100644 --- a/PresenceClient/PresenceClient-GUI/Views/MainWindow.axaml.cs +++ b/PresenceClient/PresenceClient-GUI/Views/MainWindow.axaml.cs @@ -3,10 +3,10 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; using PresenceClient.ViewModels; +using PresenceClient.Platform; namespace PresenceClient.Views; - -public partial class MainWindow : Window, IDisposable +public partial class MainWindow : Window { private MainWindowViewModel? viewModel; @@ -16,19 +16,43 @@ public MainWindow() #if DEBUG this.AttachDevTools(); #endif + + if (PlatformHelper.IsMacOS) + { + this.ExtendClientAreaToDecorationsHint = true; + this.ExtendClientAreaChromeHints = Avalonia.Platform.ExtendClientAreaChromeHints.OSXThickTitleBar; + } + viewModel = new MainWindowViewModel(); DataContext = viewModel; viewModel.ShowMainWindowRequested += (sender, args) => ShowMainWindow(); + + SetupPlatformSpecifics(); } - private void InitializeComponent() + private void SetupPlatformSpecifics() { - AvaloniaXamlLoader.Load(this); + if (PlatformHelper.IsMacOS) + { + var mainMenu = new NativeMenu(); + var appMenu = new NativeMenu(); + var appMenuItem = new NativeMenuItem("PresenceClient"); + appMenuItem.Menu = appMenu; + + appMenu.Add(new NativeMenuItemSeparator()); + + var quitItem = new NativeMenuItem("Quit PresenceClient"); + quitItem.Click += (sender, e) => viewModel?.ExitApplication(); + appMenu.Add(quitItem); + + mainMenu.Add(appMenuItem); + NativeMenu.SetMenu(this, mainMenu); + } } protected override void OnClosing(WindowClosingEventArgs e) { - if (viewModel != null && viewModel.MinimizeToTray) + if (viewModel is { MinimizeToTray: true } && PlatformHelper.CanUseTrayIcon()) { e.Cancel = true; this.Hide(); @@ -36,7 +60,7 @@ protected override void OnClosing(WindowClosingEventArgs e) else { base.OnClosing(e); - Dispose(); + viewModel?.ExitApplication(); // This will properly clean up and exit } } @@ -49,17 +73,13 @@ public void ShowMainWindow() this.Show(); this.Activate(); - this.Topmost = true; - this.Topmost = false; - this.Focus(); - } - public void Dispose() - { - if (viewModel != null) + if (!PlatformHelper.IsMacOS) // macOS handles window focusing differently { - viewModel.Dispose(); - viewModel = null; + this.Topmost = true; + this.Topmost = false; } + + this.Focus(); } } \ No newline at end of file