diff --git a/installer/DesktopMagic-Installer.exe b/installer/DesktopMagic-Installer.exe index 216629b..ef85580 100644 Binary files a/installer/DesktopMagic-Installer.exe and b/installer/DesktopMagic-Installer.exe differ diff --git a/installer/Installer.iss b/installer/Installer.iss index 71a2c34..80a7417 100644 --- a/installer/Installer.iss +++ b/installer/Installer.iss @@ -1,7 +1,7 @@ ; Script generated by the Inno Setup Script Wizard. ; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! -#expr Exec('cmd.exe /C ', 'dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\DesktopMagic\') +#expr Exec('cmd.exe', '/C dotnet build -o "' + SourcePath + '\publish" -c Release ' + SourcePath + '..\src\DesktopMagic\') #define MyAppName "DesktopMagic" #define MyAppVersion GetStringFileInfo("/publish/DesktopMagic.exe","ProductVersion") diff --git a/src/DesktopMagic/App.xaml b/src/DesktopMagic/App.xaml index e943dca..83dca8a 100644 --- a/src/DesktopMagic/App.xaml +++ b/src/DesktopMagic/App.xaml @@ -1,20 +1,25 @@  - - + + - - + - + + + + + + \ No newline at end of file diff --git a/src/DesktopMagic/App.xaml.cs b/src/DesktopMagic/App.xaml.cs index 55be20b..da9e781 100644 --- a/src/DesktopMagic/App.xaml.cs +++ b/src/DesktopMagic/App.xaml.cs @@ -5,6 +5,8 @@ using System.IO; using System.Threading; using System.Windows; +using Wpf.Ui; + namespace DesktopMagic; /// @@ -26,6 +28,8 @@ public partial class App : Application public static string PluginsPath => Path.Combine(ApplicationDataPath, "Plugins"); + public static IContentDialogService DialogService { get; } = new ContentDialogService(); + public static Logger Logger { get; } = new Logger() { Config = new() @@ -130,7 +134,7 @@ public partial class App : Application Logger.LogFatal(exception + (e.IsTerminating ? "\t Process terminating!" : ""), source: exception.Source ?? "Unknown"); } - private static void Setup(bool clearLogFile) + private static async void Setup(bool clearLogFile) { AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; @@ -156,7 +160,13 @@ public partial class App : Application } catch (Exception ex) { - _ = MessageBox.Show(ex.ToString(), AppName, MessageBoxButton.OK, MessageBoxImage.Error); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = AppName, + Content = ex.ToString(), + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); Logger.Log(ex.Message, "Setup", LogSeverity.Error); } diff --git a/src/DesktopMagic/BuiltInPlugins/CpuMonitorPlugin.cs b/src/DesktopMagic/BuiltInPlugins/CpuMonitorPlugin.cs index da4d02f..75d1763 100644 --- a/src/DesktopMagic/BuiltInPlugins/CpuMonitorPlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/CpuMonitorPlugin.cs @@ -56,7 +56,7 @@ internal class CpuMonitorPlugin : Plugin } else { - gr.DrawString(cpuUsageString, font, new SolidBrush(Application.Theme.PrimaryColor), 0, (size.Width / 2) - size.Height); + gr.DrawString(cpuUsageString, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0); } return bmp; diff --git a/src/DesktopMagic/BuiltInPlugins/DatePlugin.cs b/src/DesktopMagic/BuiltInPlugins/DatePlugin.cs index 9a6ad36..cefe11e 100644 --- a/src/DesktopMagic/BuiltInPlugins/DatePlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/DatePlugin.cs @@ -13,26 +13,23 @@ internal class DatePlugin : Plugin private readonly CheckBox shortDateCheckBox = new CheckBox(true); private DateTime oldDateTime = DateTime.MinValue; - private Color oldColor = Color.White; - private string oldFont = string.Empty; - private bool oldShortDateCheckBoxValue; + private bool themeChanged = false; + public override int UpdateInterval => 1000; public override Bitmap? Main() { - if (oldDateTime.Date == DateTime.Now.Date && oldColor == Application.Theme.PrimaryColor && oldFont == Application.Theme.Font && oldShortDateCheckBoxValue == shortDateCheckBox.Value) + if (oldDateTime.Date == DateTime.Now.Date && !themeChanged) { return null; } oldDateTime = DateTime.Now; - oldColor = Application.Theme.PrimaryColor; - oldFont = Application.Theme.Font; - oldShortDateCheckBoxValue = shortDateCheckBox.Value; + themeChanged = false; string date = shortDateCheckBox.Value ? DateTime.Now.ToShortDateString() : DateTime.Now.ToLongDateString(); - Font font = new Font(Application.Theme.Font, 200); + using Font font = new Font(Application.Theme.Font, 200); Bitmap bmp = new Bitmap(1, 1); bmp.SetResolution(100, 100); @@ -45,10 +42,16 @@ internal class DatePlugin : Plugin bmp.SetResolution(100, 100); using Graphics gr = Graphics.FromImage(bmp); + using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); gr.TextRenderingHint = TextRenderingHint.AntiAlias; - gr.DrawString(date, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0); + gr.DrawString(date, font, brush, 0, 0); return bmp; } + + public override void OnThemeChanged() + { + themeChanged = true; + } } \ No newline at end of file diff --git a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs index f56d699..c8030be 100644 --- a/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs +++ b/src/DesktopMagic/BuiltInPlugins/TimePlugin.cs @@ -13,26 +13,23 @@ internal class TimePlugin : Plugin private readonly CheckBox displaySecondsCheckBox = new CheckBox(true); private string oldTime = string.Empty; - private Color oldColor = Color.White; - private string oldFont = string.Empty; - private bool oldDisplaySecondsCheckBoxValue; + private bool themeChanged; + public override int UpdateInterval => 1000; public override Bitmap? Main() { string time = displaySecondsCheckBox.Value ? DateTime.Now.ToLongTimeString() : DateTime.Now.ToShortTimeString(); - if (oldTime == time && oldColor == Application.Theme.PrimaryColor && oldFont == Application.Theme.Font && oldDisplaySecondsCheckBoxValue == displaySecondsCheckBox.Value) + if (oldTime == time && !themeChanged) { return null; } oldTime = time; - oldColor = Application.Theme.PrimaryColor; - oldFont = Application.Theme.Font; - oldDisplaySecondsCheckBoxValue = displaySecondsCheckBox.Value; + themeChanged = false; - Font font = new Font(Application.Theme.Font, 200); + using Font font = new Font(Application.Theme.Font, 200); Bitmap bmp = new Bitmap(1, 1); bmp.SetResolution(100, 100); @@ -45,10 +42,21 @@ internal class TimePlugin : Plugin bmp.SetResolution(100, 100); using Graphics gr = Graphics.FromImage(bmp); + using SolidBrush brush = new SolidBrush(Application.Theme.PrimaryColor); gr.TextRenderingHint = TextRenderingHint.AntiAlias; - gr.DrawString(time, font, new SolidBrush(Application.Theme.PrimaryColor), 0, 0); + gr.DrawString(time, font, brush, 0, 0); return bmp; } + + public override void OnThemeChanged() + { + themeChanged = true; + } + + public override void OnSettingsChanged() + { + themeChanged = true; + } } \ No newline at end of file diff --git a/src/DesktopMagic/BuiltInPlugins/WeatherPlugin.cs b/src/DesktopMagic/BuiltInPlugins/WeatherPlugin.cs new file mode 100644 index 0000000..722cd2c --- /dev/null +++ b/src/DesktopMagic/BuiltInPlugins/WeatherPlugin.cs @@ -0,0 +1,208 @@ +using DesktopMagic.Api; +using DesktopMagic.Api.Settings; + +using System; +using System.Diagnostics; +using System.Drawing; +using System.Globalization; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.BuiltInPlugins; + +public class WeatherPlugin : AsyncPlugin +{ + [Setting("city-name", "City Name")] + private readonly TextBox cityInput = new TextBox("Tokyo"); + + [Setting("search-btn", "Update Location")] + private readonly Button searchButton = new Button("Update Location"); + + [Setting("show-city", "Show City Name")] + private readonly CheckBox showCity = new CheckBox(true); + + [Setting("show-temp", "Show Temperature")] + private readonly CheckBox showTemp = new CheckBox(true); + + [Setting("show-status", "Show Weather Status")] + private readonly CheckBox showStatus = new CheckBox(true); + + [Setting("show-time", "Show Last Updated")] + private readonly CheckBox showTime = new CheckBox(false); + + [Setting("font-size", "Base Font Size")] + private readonly Slider fontSizeSlider = new Slider(10, 100, 30); + + public override int UpdateInterval { get; set; } = 900000; // 15 Minutes + + private readonly HttpClient httpClient = new HttpClient(); + + private string? cachedLat; + private string? cachedLon; + private string currentTemp = "--"; + private string weatherStatus = "Enter city and search"; + private string foundCityName = "No City Selected"; + private string lastUpdated = ""; + private bool isLoading = false; + + public override async Task StartAsync(CancellationToken cancellationToken) + { + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (DesktopMagic)"); + + // Setup Event Handlers for instant UI updates when settings change + showCity.OnValueChanged += Application.UpdateWindow; + showTemp.OnValueChanged += Application.UpdateWindow; + showStatus.OnValueChanged += Application.UpdateWindow; + showTime.OnValueChanged += Application.UpdateWindow; + fontSizeSlider.OnValueChanged += Application.UpdateWindow; + + await UpdateLocationAndWeather(); + Application.UpdateWindow(); + + searchButton.OnClick += () => + { + isLoading = true; + Application.UpdateWindow(); + + _ = Task.Run(async () => + { + await UpdateLocationAndWeather(); + isLoading = false; + Application.UpdateWindow(); + }); + }; + } + + public override async Task MainAsync(CancellationToken cancellationToken) + { + // Periodic background update + if (!isLoading && cachedLat != null && cachedLon != null) + { + await UpdateWeatherOnly(cachedLat, cachedLon); + } + + Bitmap bmp = new Bitmap(1200, 800); + bmp.SetResolution(300, 300); + + using (Graphics g = Graphics.FromImage(bmp)) + { + g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAliasGridFit; + g.Clear(Color.Transparent); + + int lineCount = 0; + int baseSize = (int)fontSizeSlider.Value; + + if (isLoading) + { + DrawLine(bmp, g, "Fetching...", ref lineCount, baseSize, FontStyle.Italic); + } + else + { + if (showCity.Value) + { + DrawLine(bmp, g, foundCityName.ToUpper(), ref lineCount, baseSize, FontStyle.Bold); + } + + if (showTemp.Value) + { + DrawLine(bmp, g, currentTemp, ref lineCount, baseSize, FontStyle.Bold); + } + + if (showStatus.Value) + { + DrawLine(bmp, g, weatherStatus, ref lineCount, baseSize, FontStyle.Regular); + } + + if (showTime.Value && !string.IsNullOrEmpty(lastUpdated)) + { + DrawLine(bmp, g, $"Updated: {lastUpdated}", ref lineCount, baseSize, FontStyle.Italic); + } + } + } + + return bmp; + } + + private void DrawLine(Bitmap bitmap, Graphics graphics, string text, ref int lineCount, int fontSize, FontStyle style) + { + using Font font = new Font(Application.Theme.Font, fontSize, style); + SizeF textSize = graphics.MeasureString(text, font); + + float x = (bitmap.Width - textSize.Width) / 2; + // Adjust vertical spacing based on font size + float y = (lineCount == 0) ? 60 : (lineCount * (textSize.Height + 10)) + 60; + + using Brush brush = new SolidBrush(Application.Theme.PrimaryColor); + graphics.DrawString(text, font, brush, x, y); + + lineCount++; + } + + private async Task UpdateLocationAndWeather() + { + try + { + string geoUrl = $"https://geocoding-api.open-meteo.com/v1/search?name={Uri.EscapeDataString(cityInput.Value.Trim())}&count=1&language=en&format=json"; + string geoJson = await httpClient.GetStringAsync(geoUrl); + + using JsonDocument geoDoc = JsonDocument.Parse(geoJson); + if (!geoDoc.RootElement.TryGetProperty("results", out JsonElement results) || results.GetArrayLength() == 0) + { + foundCityName = cityInput.Value; + currentTemp = "--"; + weatherStatus = "City not found"; + return; + } + + JsonElement location = results[0]; + cachedLat = location.GetProperty("latitude").GetDouble().ToString(CultureInfo.InvariantCulture); + cachedLon = location.GetProperty("longitude").GetDouble().ToString(CultureInfo.InvariantCulture); + foundCityName = location.GetProperty("name").GetString() ?? cityInput.Value; + + // We update the textbox to the "official" name found by the API + cityInput.Value = foundCityName; + + await UpdateWeatherOnly(cachedLat, cachedLon); + } + catch (Exception ex) + { + Debug.WriteLine($"Error: {ex.Message}"); + weatherStatus = "Connection Error"; + } + } + + private async Task UpdateWeatherOnly(string lat, string lon) + { + try + { + string weatherUrl = $"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true"; + string weatherJson = await httpClient.GetStringAsync(weatherUrl); + + using JsonDocument weatherDoc = JsonDocument.Parse(weatherJson); + JsonElement current = weatherDoc.RootElement.GetProperty("current_weather"); + + currentTemp = $"{current.GetProperty("temperature").GetDouble()}°C"; + weatherStatus = MapWeatherCode(current.GetProperty("weathercode").GetInt32()); + lastUpdated = DateTime.Now.ToString("HH:mm"); + } + catch { /* Ignore background update errors */ } + } + + private string MapWeatherCode(int code) + { + return code switch + { + 0 => "Clear Sky", + 1 or 2 or 3 => "Partly Cloudy", + 45 or 48 => "Foggy", + 51 or 53 or 55 => "Drizzle", + 61 or 63 or 65 => "Rainy", + 71 or 73 or 75 => "Snowy", + 95 or 96 or 99 => "Thunderstorm", + _ => "Cloudy" + }; + } +} \ No newline at end of file diff --git a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs index 183d23b..8c3c0fd 100644 --- a/src/DesktopMagic/DataContexts/MainWindowDataContext.cs +++ b/src/DesktopMagic/DataContexts/MainWindowDataContext.cs @@ -11,8 +11,18 @@ internal class MainWindowDataContext : INotifyPropertyChanged public event PropertyChangedEventHandler? PropertyChanged; private static DesktopMagicSettings settings = new(); + private bool isLoading = true; + public string Title => +#if DEBUG + $"{App.AppName} - Dev {System.Windows.Forms.Application.ProductVersion}"; +#else + $"{App.AppName} - {System.Windows.Forms.Application.ProductVersion}"; +#endif + + public string AppName => App.AppName; + public DesktopMagicSettings Settings { get => settings; diff --git a/src/DesktopMagic/DataContexts/PluginEntryDataContext.cs b/src/DesktopMagic/DataContexts/PluginEntryDataContext.cs index c13876f..ae235fa 100644 --- a/src/DesktopMagic/DataContexts/PluginEntryDataContext.cs +++ b/src/DesktopMagic/DataContexts/PluginEntryDataContext.cs @@ -1,18 +1,17 @@ using DesktopMagic.Helpers; using DesktopMagic.Plugins; -using MaterialDesignThemes.Wpf; - using System; using System.ComponentModel; using System.Diagnostics; +using System.IO; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Input; namespace DesktopMagic.DataContexts; -internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null) : INotifyPropertyChanged +internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand command, PluginEntryDataContext.Mode mode, string? path = null, string? csprojPath = null) : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; @@ -20,7 +19,7 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co public string Name => pluginMetadata.Name; - public string? Description => pluginMetadata.Description; + public string? Description => pluginMetadata.Description?.Trim(); public string Author => pluginMetadata.Author ?? (string)App.LanguageDictionary["unknown"]; @@ -35,22 +34,27 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co public string? Path => path; - public bool IsLocalPlugin => string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString()); + public bool IsLocalPlugin => pluginMetadata.IsLocalPlugin; + public ICommand Command => command; - public ButtonData InstallUninstallButtonData => new(mode == Mode.Install ? PackIconKind.Download : PackIconKind.Remove, GetInstallUninstallButtonText(), true, Command); + public ButtonData InstallUninstallButtonData => new(mode == Mode.Install ? "Download24" : "Delete24", GetInstallUninstallButtonText(), true, Command); public ButtonData OpenButtonData { get { - if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString())) + if (File.Exists(csprojPath)) { - return new(PackIconKind.FolderOutline, (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!))); + return new("Code24", "IDE", true, new CommandHandler(OpenCsprojInIDE)); + } + else if (string.IsNullOrWhiteSpace(pluginMetadata.ProfileUri?.ToString())) + { + return new("Folder24", (string)App.LanguageDictionary["folder"], path is not null, new CommandHandler(() => Process.Start("explorer.exe", path!))); } else { - return new(PackIconKind.ExternalLink, "mod.io", true, new CommandHandler(OpenModIoPage)); + return new("Open24", "mod.io", true, new CommandHandler(OpenModIoPage)); } } } @@ -79,6 +83,34 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co _ = Process.Start(psi); } + private void OpenCsprojInIDE() + { + string? associatedProgram = FileUtilities.GetAssociatedProgram(".csproj"); + string pluginProjectPath = System.IO.Path.GetDirectoryName(csprojPath)!; + + if (string.IsNullOrWhiteSpace(pluginProjectPath)) + { + App.Logger.LogError("Could not determine plugin project directory.", source: "PluginManager"); + return; + } + + if (associatedProgram is null) + { + App.Logger.LogInfo($"Opening plugin project in Explorer: {pluginProjectPath}", source: "PluginManager"); + _ = Process.Start("explorer.exe", pluginProjectPath); + return; + } + + App.Logger.LogInfo($"Opening plugin project in IDE: {associatedProgram}", source: "PluginManager"); + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = associatedProgram, + Arguments = csprojPath, + }; + + _ = Process.Start(psi); + } + private string GetInstallUninstallButtonText() { return mode switch @@ -94,7 +126,7 @@ internal class PluginEntryDataContext(PluginMetadata pluginMetadata, ICommand co PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } - public record ButtonData(PackIconKind IconKind, string Text, bool IsEnabled, ICommand Command); + public record ButtonData(string Icon, string Text, bool IsEnabled, ICommand Command); public enum Mode { diff --git a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs index b1ca54f..896985a 100644 --- a/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs +++ b/src/DesktopMagic/DataContexts/PluginManagerDataContext.cs @@ -1,6 +1,10 @@ using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Media.Imaging; + +using Wpf.Ui.Appearance; namespace DesktopMagic.DataContexts; @@ -70,6 +74,23 @@ internal class PluginManagerDataContext : INotifyPropertyChanged } } + public BitmapImage ModIoIcon + { + get + { + ApplicationTheme theme = ApplicationThemeManager.GetAppTheme(); + + if (theme == ApplicationTheme.Light) + { + return Application.Current.Resources["ModioLogoBlueDark"] as BitmapImage ?? new BitmapImage(); + } + else + { + return Application.Current.Resources["ModioLogoBlueLight"] as BitmapImage ?? new BitmapImage(); + } + } + } + public string LoginButtonText => isAuthenticated ? (string)App.LanguageDictionary["logout"] : (string)App.LanguageDictionary["login"]; protected void OnPropertyChanged([CallerMemberName] string? name = null) diff --git a/src/DesktopMagic/DesktopMagic.csproj b/src/DesktopMagic/DesktopMagic.csproj index 7177fd5..6348d60 100644 --- a/src/DesktopMagic/DesktopMagic.csproj +++ b/src/DesktopMagic/DesktopMagic.csproj @@ -7,11 +7,11 @@ icon.ico OnBuildSuccess Stone_Red - 1.2.0.0 + 1.3.0.0 https://github.com/Stone-Red-Code/DesktopMagic https://github.com/Stone-Red-Code/DesktopMagic - 1.2.0.0 - 1.2.0.0 + 1.3.0.0 + 1.3.0.0 net8.0-windows7.0 win-x64 enable @@ -33,6 +33,7 @@ + @@ -46,9 +47,12 @@ + + + @@ -63,6 +67,7 @@ Always + diff --git a/src/DesktopMagic/Dialogs/ColorDialog.xaml b/src/DesktopMagic/Dialogs/ColorDialog.xaml index 18c4330..16bf62b 100644 --- a/src/DesktopMagic/Dialogs/ColorDialog.xaml +++ b/src/DesktopMagic/Dialogs/ColorDialog.xaml @@ -1,50 +1,74 @@ - + ExtendsContentIntoTitleBar="True"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Pages/MainPage.xaml.cs b/src/DesktopMagic/Pages/MainPage.xaml.cs new file mode 100644 index 0000000..65cc4d3 --- /dev/null +++ b/src/DesktopMagic/Pages/MainPage.xaml.cs @@ -0,0 +1,283 @@ +using DesktopMagic.DataContexts; +using DesktopMagic.Dialogs; +using DesktopMagic.Helpers; +using DesktopMagic.Plugins; +using DesktopMagic.Settings; + +using System.Collections.Generic; +using System.Linq; +using System.Windows; +using System.Windows.Controls; + +namespace DesktopMagic.Pages; + +/// +/// Interaction logic for MainPage.xaml +/// +public partial class MainPage : Page +{ + private readonly Manager _manager = Manager.Instance; + private readonly MainWindowDataContext _dataContext; + private bool _isLoadingLayout = false; + + public MainPage() + { + InitializeComponent(); + + _dataContext = new MainWindowDataContext + { + Settings = _manager.Settings + }; + + DataContext = _dataContext; + + // Subscribe to manager events + _manager.PluginsChanged += OnPluginsChanged; + _manager.EditModeChanged += OnEditModeChanged; + + Loaded += MainPage_Loaded; + Unloaded += MainPage_Unloaded; + } + + private void MainPage_Loaded(object sender, RoutedEventArgs e) + { + // Initialize edit checkbox state + editCheckBox.IsChecked = _manager.IsEditMode; + } + + private void MainPage_Unloaded(object sender, RoutedEventArgs e) + { + // Unsubscribe from events + _manager.PluginsChanged -= OnPluginsChanged; + _manager.EditModeChanged -= OnEditModeChanged; + } + + private void OnPluginsChanged() + { + Dispatcher.Invoke(() => + { + // Refresh the UI if needed + _dataContext.Settings = _manager.Settings; + }); + } + + private void OnEditModeChanged(bool editMode) + { + Dispatcher.Invoke(() => + { + editCheckBox.IsChecked = editMode; + }); + } + + private void EditCheckBox_Click(object sender, RoutedEventArgs e) + { + _manager.SetEditMode(editCheckBox.IsChecked == true); + } + + private void PluginCheckBox_Click(object sender, RoutedEventArgs e) + { + if (sender is not Control checkBox) + { + return; + } + + uint pluginId = uint.Parse(checkBox.Tag.ToString()!); + + _manager.LoadPlugin(pluginId, (internalPluginData) => + { + Dispatcher.Invoke(() => + { + // Update the card expander to show options when enabling a plugin + Wpf.Ui.Controls.CardExpander? cardExpander = ((checkBox.Parent as FrameworkElement)?.Parent) as Wpf.Ui.Controls.CardExpander; + + if (cardExpander is not null) + { + OptionsCardExpander_Expanded(cardExpander, new RoutedEventArgs()); + } + }); + }); + } + + private void ScrollViewer_PreviewMouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e) + { + if (sender is ScrollViewer scv) + { + scv.ScrollToVerticalOffset(scv.VerticalOffset - e.Delta); + e.Handled = true; + } + } + + #region Layout Management + + private void LayoutsComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + // Prevent recursive calls and only process if fully loaded + if (_isLoadingLayout || !_manager.IsLoaded || !IsLoaded) + { + return; + } + + // Check if this is actually a user-initiated change + // by verifying that the removed and added items are different + if (e.RemovedItems.Count > 0 && e.AddedItems.Count > 0) + { + if (e.RemovedItems[0] == e.AddedItems[0]) + { + return; + } + } + + try + { + _isLoadingLayout = true; + _manager.SaveSettings(); + _manager.LoadLayout(false); + } + finally + { + _isLoadingLayout = false; + } + } + + private async void NewLayoutButton_Click(object sender, RoutedEventArgs e) + { + InputDialog inputDialog = new((string)FindResource("enterLayoutName")) + { + Owner = Window.GetWindow(this) + }; + + if (inputDialog.ShowDialog() == true) + { + if (_manager.Settings.Layouts.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim())) + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("layoutAlreadyExists"), + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + return; + } + + try + { + _isLoadingLayout = true; + _manager.Settings.Layouts.Add(new Layout(inputDialog.ResponseText.Trim())); + _manager.Settings.CurrentLayoutName = inputDialog.ResponseText.Trim(); + _manager.SaveSettings(); + _manager.LoadLayout(false); + } + finally + { + _isLoadingLayout = false; + } + } + } + + private async void RemoveLayoutButton_Click(object sender, RoutedEventArgs e) + { + if (_manager.Settings.Layouts.Count <= 1) + { + Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("cannotDeleteLastLayout"), + CloseButtonText = "Ok" + }; + _ = await cannotDeleteMessageBox.ShowDialogAsync(); + return; + } + + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("confirmDeleteLayout"), + PrimaryButtonText = "Yes", + SecondaryButtonText = "No", + IsCloseButtonEnabled = false + }; + Wpf.Ui.Controls.MessageBoxResult result = await messageBox.ShowDialogAsync(); + if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) // Primary is "Yes" + { + return; + } + + try + { + _isLoadingLayout = true; + _ = _manager.Settings.Layouts.Remove(_manager.Settings.CurrentLayout); + _manager.SaveSettings(); + _manager.LoadLayout(false); + } + finally + { + _isLoadingLayout = false; + } + } + + #endregion + + private void OptionsCardExpander_Expanded(object sender, RoutedEventArgs e) + { + if (sender is not Wpf.Ui.Controls.CardExpander expander || expander.Tag is not KeyValuePair keyValuePair) + { + return; + } + + PluginSettings? pluginSettings = keyValuePair.Value; + uint pluginId = keyValuePair.Key; + + StackPanel optionsPanel = new StackPanel + { + Visibility = Visibility.Visible + }; + + expander.Content = optionsPanel; + optionsPanel.UpdateLayout(); + + // s.Input being null means the plugins has not been loaded yet but the settings are present in the saved configuration. + if (pluginSettings is null || pluginSettings.Settings.Count == 0 || pluginSettings.Settings.All(s => s.Input is null)) + { + _ = optionsPanel.Children.Add(new System.Windows.Controls.TextBlock() + { + Text = (string)FindResource(pluginSettings?.Enabled == true ? "noOptions" : "enablePluginToConfigure") + }); + + return; + } + + SettingElementGenerator settingElementGenerator = new SettingElementGenerator(pluginId); + + foreach (SettingElement settingElement in pluginSettings.Settings) + { + Wpf.Ui.Controls.CardControl card = new() + { + Margin = new Thickness(0, 0, 0, 5), + Padding = new Thickness(5) + }; + + System.Windows.Controls.TextBlock textBlock = new() + { + Text = string.IsNullOrWhiteSpace(settingElement.Name) ? string.Empty : settingElement.Name, + Padding = new Thickness(0, 0, 3, 0), + VerticalAlignment = VerticalAlignment.Center, + FontWeight = FontWeights.SemiBold + }; + + card.Header = textBlock; + + _ = optionsPanel.Children.Add(card); + + FrameworkElement? control = settingElementGenerator.Generate(settingElement, textBlock); + + if (control is not null) + { + control.MinWidth = 200; + card.Content = control; + } + } + + optionsPanel.UpdateLayout(); + } +} diff --git a/src/DesktopMagic/Pages/ThemePage.xaml b/src/DesktopMagic/Pages/ThemePage.xaml new file mode 100644 index 0000000..653b9eb --- /dev/null +++ b/src/DesktopMagic/Pages/ThemePage.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Pages/ThemePage.xaml.cs b/src/DesktopMagic/Pages/ThemePage.xaml.cs new file mode 100644 index 0000000..1bfa67d --- /dev/null +++ b/src/DesktopMagic/Pages/ThemePage.xaml.cs @@ -0,0 +1,126 @@ +using DesktopMagic.DataContexts; +using DesktopMagic.Dialogs; +using DesktopMagic.Plugins; + +using System.Linq; +using System.Windows; +using System.Windows.Controls; + +namespace DesktopMagic.Pages; + +/// +/// Interaction logic for ThemePage.xaml +/// +public partial class ThemePage : Page +{ + private readonly Manager _manager = Manager.Instance; + private readonly MainWindowDataContext _dataContext; + + public ThemePage() + { + InitializeComponent(); + + _dataContext = new MainWindowDataContext + { + Settings = _manager.Settings + }; + + DataContext = _dataContext; + + // Subscribe to manager events + _manager.SettingsChanged += OnSettingsChanged; + + Unloaded += ThemePage_Unloaded; + } + + private void ThemePage_Unloaded(object sender, RoutedEventArgs e) + { + // Unsubscribe from events + _manager.SettingsChanged -= OnSettingsChanged; + } + + private void OnSettingsChanged() + { + Dispatcher.Invoke(() => + { + _dataContext.Settings = _manager.Settings; + }); + } + + private async void AddThemeButton_Click(object sender, RoutedEventArgs e) + { + InputDialog inputDialog = new((string)FindResource("enterThemeName")) + { + Owner = Window.GetWindow(this) + }; + + if (inputDialog.ShowDialog() == true) + { + if (_manager.Settings.Themes.Any(l => l.Name.Trim() == inputDialog.ResponseText.Trim())) + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("themeAlreadyExists"), + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + return; + } + + _manager.Settings.Themes.Add(new Theme(inputDialog.ResponseText.Trim())); + _manager.Settings.CurrentLayout.CurrentThemeName = inputDialog.ResponseText.Trim(); + _manager.SaveSettings(); + } + } + + private async void DeleteThemeButton_Click(object sender, RoutedEventArgs e) + { + if (_manager.Settings.Themes.Count <= 1) + { + Wpf.Ui.Controls.MessageBox cannotDeleteMessageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("cannotDeleteLastTheme"), + CloseButtonText = "Ok" + }; + _ = await cannotDeleteMessageBox.ShowDialogAsync(); + return; + } + + var messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = App.AppName, + Content = (string)FindResource("confirmDeleteTheme"), + PrimaryButtonText = "Yes", + SecondaryButtonText = "No", + IsCloseButtonEnabled = false + }; + Wpf.Ui.Controls.MessageBoxResult result = await messageBox.ShowDialogAsync(); + if (result != Wpf.Ui.Controls.MessageBoxResult.Primary) // Primary is "Yes" + { + return; + } + + if (themesListBox.SelectedItem is Theme theme) + { + _ = _manager.Settings.Themes.Remove(theme); + _manager.SaveSettings(); + } + } + + private void EditThemeButton_Click(object sender, RoutedEventArgs e) + { + if (sender is not System.Windows.Controls.Button button || button.Tag is not Theme theme) + { + return; + } + + ThemeDialog themeDialog = new(theme.Name, theme, App.AppName) + { + Owner = Window.GetWindow(this) + }; + + _ = themeDialog.ShowDialog(); + } +} diff --git a/src/DesktopMagic/Plugins/IPluginWindow.cs b/src/DesktopMagic/Plugins/IPluginWindow.cs new file mode 100644 index 0000000..d5e8ec4 --- /dev/null +++ b/src/DesktopMagic/Plugins/IPluginWindow.cs @@ -0,0 +1,24 @@ +using DesktopMagic.Plugins; + +using System; + +namespace DesktopMagic; + +public interface IPluginWindow +{ + event Action? PluginLoaded; + event Action? OnExit; + + bool IsRunning { get; } + PluginMetadata PluginMetadata { get; } + string PluginFolderPath { get; } + string Title { get; set; } + + void Exit(); + void SetEditMode(bool enabled); + void Show(); + void Hide(); + void Close(); + + event System.ComponentModel.CancelEventHandler Closing; +} diff --git a/src/DesktopMagic/Plugins/PluginData.cs b/src/DesktopMagic/Plugins/PluginData.cs index 6a39238..92fd242 100644 --- a/src/DesktopMagic/Plugins/PluginData.cs +++ b/src/DesktopMagic/Plugins/PluginData.cs @@ -1,8 +1,12 @@ -using DesktopMagic.Api; +using CuteUtils.Logging; + +using DesktopMagic.Api; using DesktopMagic.Settings; using System.Drawing; +using Wpf.Ui.Controls; + namespace DesktopMagic.Plugins; internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : IPluginData @@ -19,8 +23,56 @@ internal class PluginData(PluginWindow window, PluginSettings pluginSettings) : public string PluginPath => window.PluginFolderPath; + public void Log(string message, LogLevel level = LogLevel.Info) + { + LogSeverity severity = level switch + { + LogLevel.Info => LogSeverity.Info, + LogLevel.Warning => LogSeverity.Warn, + LogLevel.Error => LogSeverity.Error, + _ => LogSeverity.Info, + }; + + App.Logger.Log($"\"{PluginName}\" - {message}", "Plugin", severity); + } + + public void ShowMessage(string message, string? title = null) + { + title ??= PluginName; + + _ = window.Dispatcher.Invoke(async () => + { + System.Windows.Window temporaryOwner = new() + { + AllowsTransparency = true, + ShowInTaskbar = false, + WindowStyle = System.Windows.WindowStyle.None, + Background = System.Windows.Media.Brushes.Transparent, + Topmost = true, + }; + + temporaryOwner.Show(); + + MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Owner = temporaryOwner, + Title = $"{App.AppName} - {title}", + Content = message, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + + temporaryOwner.Close(); + }); + } + public void UpdateWindow() { window.UpdatePluginWindow(); } + + public void SaveState() + { + window.SavePluginState(); + } } \ No newline at end of file diff --git a/src/DesktopMagic/Plugins/PluginEntry.xaml b/src/DesktopMagic/Plugins/PluginEntry.xaml index d29b84a..3710e77 100644 --- a/src/DesktopMagic/Plugins/PluginEntry.xaml +++ b/src/DesktopMagic/Plugins/PluginEntry.xaml @@ -2,8 +2,8 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" - xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:local="clr-namespace:DesktopMagic.Plugins" xmlns:dataContext="clr-namespace:DesktopMagic.DataContexts" d:DataContext="{d:DesignInstance Type=dataContext:PluginEntryDataContext}" @@ -12,52 +12,52 @@ Background="{DynamicResource MaterialDesignPaper}" FontFamily="{DynamicResource MaterialDesignFont}" Visibility="{Binding Visibility}"> - - - - - - + + + + + + + - - - - - - - - - - - - - - + + + - - + + + + + + + + + + + + + + + + + - - - - + \ No newline at end of file diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml b/src/DesktopMagic/Plugins/PluginManager.xaml index 8031cac..b994a6a 100644 --- a/src/DesktopMagic/Plugins/PluginManager.xaml +++ b/src/DesktopMagic/Plugins/PluginManager.xaml @@ -1,25 +1,21 @@ - + d:DesignHeight="450" + d:DesignWidth="800" + ScrollViewer.CanContentScroll="False" + Title="{DynamicResource pluginManager}"> - - + + @@ -28,21 +24,29 @@ + - + - \ No newline at end of file + \ No newline at end of file diff --git a/src/DesktopMagic/Plugins/PluginManager.xaml.cs b/src/DesktopMagic/Plugins/PluginManager.xaml.cs index f203336..a37a331 100644 --- a/src/DesktopMagic/Plugins/PluginManager.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginManager.xaml.cs @@ -8,7 +8,6 @@ using Modio.Models; using System; using System.Collections.Generic; -using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.IO; @@ -19,6 +18,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Windows; +using System.Windows.Controls; using System.Windows.Threading; using System.Xml.Linq; @@ -30,7 +30,7 @@ namespace DesktopMagic.Plugins; /// /// Interaction logic for PluginManager.xaml /// -public partial class PluginManager : Window +public partial class PluginManager : Page { private const int ModIoGameId = 5665; private const string ModIoApiKey = "88e6ea774c3a502b06114e7fee0829ac"; @@ -38,6 +38,9 @@ public partial class PluginManager : Window private readonly PluginManagerDataContext pluginManagerDataContext = new(); private readonly string pluginsPath = Path.Combine(App.ApplicationDataPath, "Plugins"); private readonly string pluginDevelopmentPath = Path.Combine(App.ApplicationDataPath, "PluginDevelopment"); + private readonly Manager _manager = Manager.Instance; + + private bool changed = false; private readonly DispatcherTimer searchTimer = new() { @@ -55,11 +58,13 @@ public partial class PluginManager : Window if (modIoAccessToken is null) { modIoClient = new Client(new Credentials(ModIoApiKey)); + App.Logger.LogInfo("Initialized mod.io client without authentication", source: "PluginManager"); } else { modIoClient = new Client(new Credentials(ModIoApiKey, modIoAccessToken)); pluginManagerDataContext.IsAuthenticated = true; + App.Logger.LogInfo("Initialized mod.io client with authentication", source: "PluginManager"); } InitializeComponent(); @@ -70,11 +75,115 @@ public partial class PluginManager : Window searchTimer.Stop(); await SearchAllPlugins(pluginManagerDataContext.AllPluginsSearchText); }; + + Loaded += PluginManager_Loaded; + Unloaded += PluginManager_Unloaded; + } + + private async void PluginManager_Loaded(object sender, RoutedEventArgs e) + { + await InitializePluginManager(); + } + + private void PluginManager_Unloaded(object sender, RoutedEventArgs e) + { + searchTimer.Stop(); + + if (changed) + { + _manager.ReloadPlugins(); + } + } + + private async Task InitializePluginManager() + { + App.Logger.LogInfo("Initializing Plugin Manager", source: "PluginManager"); + HashSet pluginIds = []; + + pluginManagerDataContext.IsLoading = true; + pluginManagerDataContext.InstalledPlugins.Clear(); + pluginManagerDataContext.AllPlugins.Clear(); + + foreach (string pluginPath in Directory.GetDirectories(pluginsPath)) + { + string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); + + if (!File.Exists(pluginMetadataPath)) + { + App.Logger.LogWarn($"Plugin metadata not found at: {pluginMetadataPath}", source: "PluginManager"); + continue; + } + + PluginMetadata? pluginMetadata = JsonSerializer.Deserialize(await File.ReadAllTextAsync(pluginMetadataPath)); + + if (pluginMetadata is null) + { + App.Logger.LogWarn($"Failed to deserialize plugin metadata from: {pluginMetadataPath}", source: "PluginManager"); + continue; + } + + string csprojPath = GetCsprojPath(pluginMetadata.Name); + + App.Logger.LogInfo($"Loaded plugin: {pluginMetadata.Name} (ID: {pluginMetadata.Id})", source: "PluginManager"); + pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(pluginMetadata, new CommandHandler(async () => await Remove(pluginPath, pluginMetadata.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath, csprojPath)); + _ = pluginIds.Add(pluginMetadata.Id); + + if (pluginMetadata.IsLocalPlugin) + { + continue; + } + + try + { + Mod mod = await modIoClient.Games[ModIoGameId].Mods[pluginMetadata.Id].Get(); + + if (DateTimeOffset.FromUnixTimeSeconds(mod.DateUpdated).DateTime > pluginMetadata.Updated && pluginMetadata.SupportsUnloading) + { + App.Logger.LogInfo($"Plugin {pluginMetadata.Id} has an update available, reinstalling", source: "PluginManager"); + await Remove(pluginPath, pluginMetadata.Id); + await Install(mod); + pluginManagerDataContext.IsLoading = true; + } + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to check for plugin {pluginMetadata.Id} updates: {ex.Message}", source: "PluginManager"); + } + } + + Filter filter = ModFilter.Popular.Desc().Limit(100); + + try + { + App.Logger.LogInfo("Fetching popular plugins from mod.io", source: "PluginManager"); + IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); + await foreach (Mod mod in mods) + { + if (pluginIds.Contains(mod.Id)) + { + continue; + } + + pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install)); + } + App.Logger.LogInfo($"Loaded {pluginManagerDataContext.AllPlugins.Count} available plugins", source: "PluginManager"); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to fetch plugins from mod.io: {ex.Message}", source: "PluginManager"); + } + + await SyncPlugins(); + + pluginManagerDataContext.IsLoading = false; + App.Logger.LogInfo("Plugin Manager initialization complete", source: "PluginManager"); } public async Task Remove(string pluginPath, uint id) { + App.Logger.LogInfo($"Removing plugin with ID {id} from path: {pluginPath}", source: "PluginManager"); pluginManagerDataContext.IsLoading = true; + changed = true; PluginEntryDataContext? pluginEntryDataContext = pluginManagerDataContext.InstalledPlugins.FirstOrDefault(p => p.Id == id); @@ -83,10 +192,17 @@ public partial class PluginManager : Window try { Directory.Delete(pluginPath, true); + App.Logger.LogInfo($"Successfully deleted plugin directory: {pluginPath}", source: "PluginManager"); } catch (Exception ex) { - _ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = ex.Message, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); App.Logger.LogError(ex.Message, source: "PluginManager"); } } @@ -94,71 +210,37 @@ public partial class PluginManager : Window if (pluginEntryDataContext is not null) { _ = pluginManagerDataContext.InstalledPlugins.Remove(pluginEntryDataContext); + App.Logger.LogInfo($"Removed plugin {id} from installed plugins list", source: "PluginManager"); } if (pluginManagerDataContext.IsAuthenticated) { - await modIoClient.Games[ModIoGameId].Mods.Unsubscribe(id); + try + { + await modIoClient.Games[ModIoGameId].Mods.Unsubscribe(id); + App.Logger.LogInfo($"Unsubscribed from plugin {id} on mod.io", source: "PluginManager"); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to unsubscribe from plugin {id}: {ex.Message}", source: "PluginManager"); + } } pluginManagerDataContext.IsLoading = false; } - protected override async void OnInitialized(EventArgs e) - { - base.OnInitialized(e); - - HashSet pluginIds = []; - - foreach (string pluginPath in Directory.GetDirectories(pluginsPath)) - { - string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); - if (!File.Exists(pluginMetadataPath)) - { - continue; - } - - PluginMetadata? pluginMetadata = JsonSerializer.Deserialize(await File.ReadAllTextAsync(pluginMetadataPath)); - - if (pluginMetadata is not null) - { - pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(pluginMetadata, new CommandHandler(async () => await Remove(pluginPath, pluginMetadata.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath)); - _ = pluginIds.Add(pluginMetadata.Id); - } - } - - Filter filter = ModFilter.Popular.Desc().Limit(100); - - IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); - await foreach (Mod mod in mods) - { - if (pluginIds.Contains(mod.Id)) - { - continue; - } - - pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install)); - } - - await SyncPlugins(); - - pluginManagerDataContext.IsLoading = false; - } - - protected override void OnClosing(CancelEventArgs e) - { - e.Cancel = pluginManagerDataContext.IsLoading; - } - [GeneratedRegex(@"[^a-zA-Z0-9]")] private static partial Regex IdentifierNameRegex(); private async Task Install(Mod mod) { + App.Logger.LogInfo($"Installing plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager"); pluginManagerDataContext.IsLoading = true; + changed = true; if (mod.Modfile?.Download?.BinaryUrl is null) { + App.Logger.LogWarn($"Plugin {mod.Id} has no download URL", source: "PluginManager"); return; } @@ -166,45 +248,77 @@ public partial class PluginManager : Window string pluginPath = Path.Combine(pluginsPath, pluginGuid); string zipFilePath = Path.Combine(pluginsPath, pluginGuid + ".zip"); - using (Stream fileStream = await httpClient.GetStreamAsync(mod.Modfile.Download.BinaryUrl)) + try { - using FileStream outputFileStream = new FileStream(zipFilePath, FileMode.Create); + App.Logger.LogInfo($"Downloading plugin from: {mod.Modfile.Download.BinaryUrl}", source: "PluginManager"); + using (Stream fileStream = await httpClient.GetStreamAsync(mod.Modfile.Download.BinaryUrl)) + { + using FileStream outputFileStream = new FileStream(zipFilePath, FileMode.Create); + await fileStream.CopyToAsync(outputFileStream); + } + App.Logger.LogInfo($"Plugin downloaded to: {zipFilePath}", source: "PluginManager"); - await fileStream.CopyToAsync(outputFileStream); + using (ZipArchive zipArchive = ZipFile.OpenRead(zipFilePath)) + { + zipArchive.ExtractToDirectory(pluginPath); + } + App.Logger.LogInfo($"Plugin extracted to: {pluginPath}", source: "PluginManager"); + + string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); + await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(new PluginMetadata(mod))); + + File.Delete(zipFilePath); + + if (!File.Exists(Path.Combine(pluginPath, "main.dll")) && !File.Exists(Path.Combine(pluginPath, "main.html"))) + { + App.Logger.LogError($"Plugin {mod.Id} does not contain main.dll or main.html", source: "PluginManager"); + await Remove(pluginPath, mod.Id); + pluginManagerDataContext.IsLoading = false; + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = "The plugin you are trying to install does not contain a \"main.dll\" or \"main.html\" file. Please contact the plugin author.", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + return; + } + + PluginEntryDataContext? pluginEntryDataContext = pluginManagerDataContext.AllPlugins.FirstOrDefault(p => p.Id == mod.Id); + + if (pluginEntryDataContext is not null) + { + _ = pluginManagerDataContext.AllPlugins.Remove(pluginEntryDataContext); + } + + if (pluginManagerDataContext.IsAuthenticated) + { + try + { + await modIoClient.Games[ModIoGameId].Mods.Subscribe(mod.Id); + App.Logger.LogInfo($"Subscribed to plugin {mod.Id} on mod.io", source: "PluginManager"); + } + catch (Exception ex) + { + App.Logger.LogError($"Failed to subscribe to plugin {mod.Id}: {ex.Message}", source: "PluginManager"); + } + } + + pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(new PluginMetadata(mod), new CommandHandler(async () => await Remove(pluginPath, mod.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath)); + App.Logger.LogInfo($"Successfully installed plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager"); } - - using (ZipArchive zipArchive = ZipFile.OpenRead(zipFilePath)) + catch (Exception ex) { - zipArchive.ExtractToDirectory(pluginPath); + App.Logger.LogError($"Failed to install plugin {mod.Id}: {ex.Message}", source: "PluginManager"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = $"Failed to install plugin: {ex.Message}", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); } - string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); - await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(new PluginMetadata(mod))); - - File.Delete(zipFilePath); - - if (!File.Exists(Path.Combine(pluginPath, "main.dll"))) - { - _ = Remove(pluginPath, mod.Id); - pluginManagerDataContext.IsLoading = false; - _ = MessageBox.Show("The plugin you are trying to install does not contain a \"main.dll\" file. Please contact the plugin author.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); - return; - } - - PluginEntryDataContext? pluginEntryDataContext = pluginManagerDataContext.AllPlugins.FirstOrDefault(p => p.Id == mod.Id); - - if (pluginEntryDataContext is not null) - { - _ = pluginManagerDataContext.AllPlugins.Remove(pluginEntryDataContext); - } - - if (pluginManagerDataContext.IsAuthenticated) - { - await modIoClient.Games[ModIoGameId].Mods.Subscribe(mod.Id); - } - - pluginManagerDataContext.InstalledPlugins.Add(new PluginEntryDataContext(new PluginMetadata(mod), new CommandHandler(async () => await Remove(pluginPath, mod.Id)), PluginEntryDataContext.Mode.Uninstall, pluginPath)); - pluginManagerDataContext.IsLoading = false; } @@ -217,6 +331,7 @@ public partial class PluginManager : Window FileName = uri }; _ = Process.Start(psi); + App.Logger.LogInfo($"Opened mod.io page: {uri}", source: "PluginManager"); } private void AllPluginsSearchTextBox_TextChanged(object sender, System.Windows.Controls.TextChangedEventArgs e) @@ -246,6 +361,7 @@ public partial class PluginManager : Window private async Task SearchAllPlugins(string searchString) { + App.Logger.LogInfo($"Searching plugins with query: {searchString}", source: "PluginManager"); pluginManagerDataContext.AllPlugins.Clear(); if (!searchString.Contains('*')) @@ -255,38 +371,68 @@ public partial class PluginManager : Window Filter filter = ModFilter.Name.Like($"{searchString}").And(ModFilter.Popular.Desc()).Limit(100); - IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); - await foreach (Mod mod in mods) + try { - if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id)) + IAsyncEnumerable mods = modIoClient.Games[ModIoGameId].Mods.Search(filter).ToEnumerable(); + await foreach (Mod mod in mods) { - continue; - } + if (pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == mod.Id)) + { + continue; + } - pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install)); + pluginManagerDataContext.AllPlugins.Add(new PluginEntryDataContext(new(mod), new CommandHandler(async () => await Install(mod)), PluginEntryDataContext.Mode.Install)); + } + App.Logger.LogInfo($"Search completed: {pluginManagerDataContext.AllPlugins.Count} plugins found", source: "PluginManager"); + } + catch (Exception ex) + { + App.Logger.LogError($"Plugin search failed: {ex.Message}", source: "PluginManager"); } pluginManagerDataContext.IsSearching = false; } - private void CreatePluginButton_Click(object sender, RoutedEventArgs e) + private async void ReloadPluginsButton_Click(object sender, RoutedEventArgs e) + { + _manager.ReloadPlugins(); + await InitializePluginManager(); + changed = false; + } + + private async void CreatePluginButton_Click(object sender, RoutedEventArgs e) { try { - CreateNewPlugin(); + await CreateNewPlugin(); } catch (Exception ex) { App.Logger.LogError(ex.Message, source: "PluginManager"); - _ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = ex.Message, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); } } - private void CreateNewPlugin() + private async Task CreateNewPlugin() { + App.Logger.LogInfo("Creating new plugin", source: "PluginManager"); + if (!FileUtilities.ExistsOnPath("dotnet.exe")) { - _ = MessageBox.Show("The .NET SDK is required to create a plugin. Please install it and try again.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); + App.Logger.LogError(".NET SDK not found on PATH", source: "PluginManager"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = "The .NET SDK is required to create a plugin. Please install it and try again.", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); return; } @@ -296,20 +442,19 @@ public partial class PluginManager : Window InputDialog inputDialog = new((string)FindResource("enterPluginName"), "Plugin Manager") { - Owner = this, + Owner = Window.GetWindow(this), }; if (inputDialog.ShowDialog() != true) { + App.Logger.LogInfo("Plugin creation cancelled by user", source: "PluginManager"); return; } string pluginName = inputDialog.ResponseText; - string pluginSafeName = pluginName.ToLower().Replace("_", " "); + string pluginSafeName = GetPluginSafeName(pluginName); - TextInfo info = CultureInfo.CurrentCulture.TextInfo; - pluginSafeName = info.ToTitleCase(pluginSafeName); - pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, ""); + App.Logger.LogInfo($"Creating plugin with name: {pluginName} (safe name: {pluginSafeName})", source: "PluginManager"); if (!Directory.Exists(pluginDevelopmentPath)) { @@ -320,25 +465,35 @@ public partial class PluginManager : Window if (Directory.Exists(pluginProjectPath)) { - _ = MessageBox.Show("A plugin with the same name already exists. Please choose a different name.", "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); + App.Logger.LogWarn($"Plugin project already exists: {pluginProjectPath}", source: "PluginManager"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = "A plugin with the same name already exists. Please choose a different name.", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); return; } + pluginManagerDataContext.IsLoading = true; + PluginMetadata pluginMetadata = new(pluginName, pluginId); _ = Directory.CreateDirectory(pluginPath); _ = Directory.CreateDirectory(pluginProjectPath); string pluginMetadataPath = Path.Combine(pluginPath, "metadata.json"); - File.WriteAllText(pluginMetadataPath, JsonSerializer.Serialize(pluginMetadata)); + await File.WriteAllTextAsync(pluginMetadataPath, JsonSerializer.Serialize(pluginMetadata)); + App.Logger.LogInfo($"Creating .NET project at: {pluginProjectPath}", source: "PluginManager"); string cmd = $"new classlib -n {pluginSafeName} -o {pluginProjectPath} -f net8.0 --target-framework-override net8.0-windows7"; Process process = Process.Start("dotnet", cmd); - process.WaitForExit(); + await process.WaitForExitAsync(); - // Install the required NuGet packages + App.Logger.LogInfo("Installing NuGet package: DesktopMagic.Api", source: "PluginManager"); process = Process.Start("dotnet", $"add {pluginProjectPath} package DesktopMagic.Api"); - process.WaitForExit(); + await process.WaitForExitAsync(); File.Move(Path.Combine(pluginProjectPath, "Class1.cs"), Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs")); @@ -352,26 +507,21 @@ public class {pluginSafeName}Plugin : Plugin public override Bitmap Main() {{ Bitmap bmp = new Bitmap(2000, 1000); + bmp.SetResolution(100, 100); // Set DPI to avoid scaling issues. using (Graphics g = Graphics.FromImage(bmp)) {{ - g.Clear(Application.Theme.PrimaryColor); // Set the background color to the color specified in the DesktopMagic application. - - g.DrawString(""Hello World"", new Font(Application.Theme.Font, 100), Brushes.Black, new PointF(0, 0)); // Draw ""Hello World"" to the image. + g.DrawString(""Hello World!"", new Font(Application.Theme.Font, 100), new SolidBrush(Application.Theme.PrimaryColor), new PointF(0, 0)); // Draw ""Hello World"" to the image. }} - bmp.SetResolution(300, 300); // Set DPI to avoid scaling issues. - return bmp; // Return the image. }} }} "; - // Update the .csproj file - string csprojPath = Path.Combine(pluginProjectPath, $"{pluginSafeName}.csproj"); - File.WriteAllText(Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs"), code); + await File.WriteAllTextAsync(Path.Combine(pluginProjectPath, $"{pluginSafeName}.cs"), code); XDocument doc = XDocument.Load(csprojPath); XElement? propertyGroup = doc.Root?.Element("PropertyGroup"); @@ -412,62 +562,88 @@ public class {pluginSafeName}Plugin : Plugin } doc.Save(csprojPath); + App.Logger.LogInfo("Updated .csproj file with custom build settings", source: "PluginManager"); } else { - _ = MessageBox.Show("PropertyGroup element not found in .csproj", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + App.Logger.LogError("PropertyGroup element not found in .csproj", source: "PluginManager"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Error", + Content = "PropertyGroup element not found in .csproj", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); return; } - // Open the project in the default IDE + App.Logger.LogInfo("Building plugin project", source: "PluginManager"); + process = Process.Start("dotnet", $"build {csprojPath} -c Release"); + await process.WaitForExitAsync(); + + changed = true; + await InitializePluginManager(); string? associatedProgram = FileUtilities.GetAssociatedProgram(".csproj"); if (associatedProgram is null) { + App.Logger.LogInfo($"Opening plugin project in Explorer: {pluginProjectPath}", source: "PluginManager"); _ = Process.Start("explorer.exe", pluginProjectPath); - return; + } + else + { + App.Logger.LogInfo($"Opening plugin project in IDE: {associatedProgram}", source: "PluginManager"); + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = associatedProgram, + Arguments = csprojPath, + }; + + _ = Process.Start(psi); } - ProcessStartInfo psi = new ProcessStartInfo - { - FileName = associatedProgram, - Arguments = csprojPath, - }; + App.Logger.LogInfo($"Successfully created plugin: {pluginName}", source: "PluginManager"); - _ = Process.Start(psi); + pluginManagerDataContext.IsLoading = false; } private async void LogInButton_Click(object sender, RoutedEventArgs e) { if (pluginManagerDataContext.IsAuthenticated) { + App.Logger.LogInfo("Logging out from mod.io", source: "PluginManager"); MainWindowDataContext.GetSettings().ModIoAccessToken = null; pluginManagerDataContext.IsAuthenticated = false; return; } + App.Logger.LogInfo("Starting mod.io authentication", source: "PluginManager"); + try { InputDialog inputDialog = new((string)FindResource("enterModIoEmail"), "Plugin Manager") { - Owner = this, + Owner = Window.GetWindow(this), }; if (inputDialog.ShowDialog() != true) { + App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager"); return; } + App.Logger.LogInfo($"Requesting authentication code for email: {inputDialog.ResponseText}", source: "PluginManager"); await modIoClient.Auth.RequestCode(ModIoApiKey, inputDialog.ResponseText); inputDialog = new((string)FindResource("enterModIoAccessToken"), "Plugin Manager") { - Owner = this, + Owner = Window.GetWindow(this), }; if (inputDialog.ShowDialog() != true) { + App.Logger.LogInfo("Authentication cancelled by user", source: "PluginManager"); return; } @@ -478,11 +654,13 @@ public class {pluginSafeName}Plugin : Plugin if (accessToken.Value is not null) { modIoClient = new Client(new Credentials(ModIoApiKey, accessToken.Value)); + App.Logger.LogInfo("Successfully authenticated with mod.io", source: "PluginManager"); } MainWindowDataContext.GetSettings().ModIoAccessToken = accessToken.Value; pluginManagerDataContext.IsAuthenticated = true; + App.Logger.LogInfo("Subscribing to installed plugins on mod.io", source: "PluginManager"); foreach (PluginEntryDataContext plugin in pluginManagerDataContext.InstalledPlugins) { if (!plugin.IsLocalPlugin) @@ -490,10 +668,11 @@ public class {pluginSafeName}Plugin : Plugin try { await modIoClient.Games[ModIoGameId].Mods.Subscribe(plugin.Id); + App.Logger.LogInfo($"Subscribed to plugin {plugin.Id}", source: "PluginManager"); } catch (Exception ex) { - App.Logger.LogError(ex.Message, source: "PluginManager"); + App.Logger.LogError($"Failed to subscribe to plugin {plugin.Id}: {ex.Message}", source: "PluginManager"); } } } @@ -502,8 +681,14 @@ public class {pluginSafeName}Plugin : Plugin } catch (Exception ex) { - App.Logger.LogError(ex.Message, source: "PluginManager"); - _ = MessageBox.Show(ex.Message, "Plugin Manager", MessageBoxButton.OK, MessageBoxImage.Error); + App.Logger.LogError($"Authentication failed: {ex.Message}", source: "PluginManager"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = "Plugin Manager", + Content = ex.Message, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); } pluginManagerDataContext.IsLoading = false; @@ -516,33 +701,64 @@ public class {pluginSafeName}Plugin : Plugin return; } - IReadOnlyList mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList(); + App.Logger.LogInfo("Syncing plugins with mod.io subscriptions", source: "PluginManager"); - List pluginsToRemove = []; - - foreach (PluginEntryDataContext plugin in pluginManagerDataContext.InstalledPlugins) + try { - if (!plugin.IsLocalPlugin && !mods.Any(m => m.Id == plugin.Id)) - { - pluginsToRemove.Add(plugin); - } - } + IReadOnlyList mods = await modIoClient.User.GetSubscriptions(ModFilter.GameId.Eq(ModIoGameId)).ToList(); + App.Logger.LogInfo($"Found {mods.Count} subscribed plugins on mod.io", source: "PluginManager"); - foreach (PluginEntryDataContext plugin in pluginsToRemove) - { - if (plugin.Path is null) + List pluginsToRemove = []; + + foreach (PluginEntryDataContext plugin in pluginManagerDataContext.InstalledPlugins) { - continue; + if (!plugin.IsLocalPlugin && !mods.Any(m => m.Id == plugin.Id)) + { + App.Logger.LogInfo($"Plugin {plugin.Id} is no longer subscribed, marking for removal", source: "PluginManager"); + pluginsToRemove.Add(plugin); + } } - await Remove(plugin.Path, plugin.Id); + foreach (PluginEntryDataContext plugin in pluginsToRemove) + { + if (plugin.Path is null) + { + continue; + } + + await Remove(plugin.Path, plugin.Id); + } + + IEnumerable notInstalledMods = mods.Where(m => !pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == m.Id)); + + foreach (Mod mod in notInstalledMods) + { + App.Logger.LogInfo($"Installing subscribed plugin: {mod.Name} (ID: {mod.Id})", source: "PluginManager"); + await Install(mod); + } + + App.Logger.LogInfo("Plugin sync completed", source: "PluginManager"); } - - IEnumerable notInstalledMods = mods.Where(m => !pluginManagerDataContext.InstalledPlugins.Any(p => p.Id == m.Id)); - - foreach (Mod mod in notInstalledMods) + catch (Exception ex) { - await Install(mod); + App.Logger.LogError($"Plugin sync failed: {ex.Message}", source: "PluginManager"); } } + + private static string GetPluginSafeName(string pluginName) + { + string pluginSafeName = pluginName.ToLower().Replace("_", " "); + + TextInfo info = CultureInfo.CurrentCulture.TextInfo; + pluginSafeName = info.ToTitleCase(pluginSafeName); + pluginSafeName = IdentifierNameRegex().Replace(pluginSafeName, ""); + + return pluginSafeName; + } + + private string GetCsprojPath(string pluginName) + { + string pluginSafeName = GetPluginSafeName(pluginName); + return Path.Combine(pluginDevelopmentPath, pluginSafeName, $"{pluginSafeName}.csproj"); + } } \ No newline at end of file diff --git a/src/DesktopMagic/Plugins/PluginMetadata.cs b/src/DesktopMagic/Plugins/PluginMetadata.cs index 00d64c4..9bd42da 100644 --- a/src/DesktopMagic/Plugins/PluginMetadata.cs +++ b/src/DesktopMagic/Plugins/PluginMetadata.cs @@ -1,6 +1,8 @@ using Modio.Models; using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; namespace DesktopMagic.Plugins; @@ -27,6 +29,14 @@ public class PluginMetadata public string? Version { get; set; } + public List Tags { get; set; } = []; + + [JsonIgnore] + public bool SupportsUnloading => !Tags.Contains("Does Not Support Unloading"); + + [JsonIgnore] + public bool IsLocalPlugin => string.IsNullOrWhiteSpace(ProfileUri?.ToString()); + public PluginMetadata(Mod mod) { Name = mod.Name ?? mod.Id.ToString(); @@ -39,6 +49,7 @@ public class PluginMetadata Description = mod.DescriptionPlaintext; Summary = mod.Summary; Version = mod.Modfile?.Version; + Tags = mod.Tags.Select(t => t.Name).Where(t => t is not null).ToList()!; } public PluginMetadata(string name, uint id) diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml b/src/DesktopMagic/Plugins/PluginWindow.xaml index 79b7dda..9d0533c 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml @@ -1,6 +1,7 @@  - - + + + - - - - - - - - - - + + + + + + + + + + + diff --git a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs index 097ee17..f45913b 100644 --- a/src/DesktopMagic/Plugins/PluginWindow.xaml.cs +++ b/src/DesktopMagic/Plugins/PluginWindow.xaml.cs @@ -13,7 +13,10 @@ using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using System.Timers; using System.Windows; using System.Windows.Interop; @@ -22,7 +25,7 @@ using System.Windows.Media.Imaging; namespace DesktopMagic; -public partial class PluginWindow : Window +public partial class PluginWindow : Window, IPluginWindow { public event Action? PluginLoaded; @@ -32,6 +35,12 @@ public partial class PluginWindow : Window private Thread? pluginThread; private System.Timers.Timer? updateTimer; private Plugin? pluginClassInstance; + private AssemblyLoadContext assemblyLoadContext; + + private CancellationTokenSource? pluginCancellationTokenSource; + private FileSystemWatcher? pluginFileWatcher; + private System.Timers.Timer? reloadDebounceTimer; + private bool isReloading = false; public bool IsRunning { get; private set; } = true; public PluginMetadata PluginMetadata { get; private set; } @@ -69,6 +78,11 @@ public partial class PluginWindow : Window } }; + settings.Theme.PropertyChanged += (se, ev) => + { + ThemeChanged(); + }; + PluginMetadata = pluginMetadata; this.settings = settings; @@ -78,6 +92,14 @@ public partial class PluginWindow : Window Height = settings.Size.Y; PluginFolderPath = pluginFolderPath; + + assemblyLoadContext = CreateAssemblyLoadContext(); + + // Initialize hot reload watcher if plugin supports unloading and is external + if (pluginMetadata.SupportsUnloading && !string.IsNullOrEmpty(pluginFolderPath)) + { + InitializeHotReload(); + } } public PluginWindow(Plugin pluginClassInstance, PluginMetadata pluginMetadata, PluginSettings settings) : this(pluginMetadata, settings, string.Empty) @@ -85,12 +107,190 @@ public partial class PluginWindow : Window this.pluginClassInstance = pluginClassInstance; } + private AssemblyLoadContext CreateAssemblyLoadContext() + { + AssemblyLoadContext context = new(PluginMetadata.Name, isCollectible: true); + context.Resolving += (ctx, assemblyName) => + { + string assemblyPath = Path.Combine(PluginFolderPath, assemblyName.Name + ".dll"); + if (File.Exists(assemblyPath)) + { + return ctx.LoadFromAssemblyPath(assemblyPath); + } + else + { + _ = ctx.LoadFromAssemblyName(assemblyName); + } + return null; + }; + return context; + } + + private void InitializeHotReload() + { + try + { + pluginFileWatcher = new FileSystemWatcher(PluginFolderPath) + { + Filter = "main.dll", + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size, + EnableRaisingEvents = false + }; + + pluginFileWatcher.Changed += OnPluginFileChanged; + + reloadDebounceTimer = new System.Timers.Timer(500); // 500ms debounce + reloadDebounceTimer.Elapsed += async (s, e) => + { + reloadDebounceTimer!.Stop(); + await ReloadPlugin(); + }; + + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Hot reload enabled", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to initialize hot reload: {ex.Message}", source: "Plugin"); + } + } + + private void OnPluginFileChanged(object sender, FileSystemEventArgs e) + { + if (isReloading) + { + return; + } + + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin file changed, scheduling reload", source: "Plugin"); + + // Debounce multiple file change events + reloadDebounceTimer?.Stop(); + reloadDebounceTimer?.Start(); + } + + private async Task StopPlugin(bool unloadAssembly = true) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping plugin", source: "Plugin"); + + try + { + pluginCancellationTokenSource?.Cancel(); + + if (pluginClassInstance is AsyncPlugin asyncPluginStop) + { + try + { + await asyncPluginStop.StopAsync(pluginCancellationTokenSource?.Token ?? CancellationToken.None); + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error stopping async plugin: {ex.Message}", source: "Plugin"); + } + } + + if (pluginClassInstance is not null) + { + SaveState(pluginClassInstance); + pluginClassInstance.Stop(); + } + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error stopping plugin: {ex}", source: "Plugin"); + } + + if (unloadAssembly) + { + assemblyLoadContext.Unload(); + } + + pluginCancellationTokenSource?.Dispose(); + pluginCancellationTokenSource = null; + } + + private async Task ReloadPlugin() + { + if (isReloading || !IsRunning) + { + return; + } + + isReloading = true; + + try + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Reloading plugin", source: "Plugin"); + + // Stop the file watcher during reload + if (pluginFileWatcher != null) + { + pluginFileWatcher.EnableRaisingEvents = false; + } + + updateTimer?.Stop(); + + await StopPlugin(unloadAssembly: true); + + // Wait for file to be fully written and released + await Task.Delay(200); + + // Clear the current instance + pluginClassInstance = null; + + // Create a new AssemblyLoadContext since the old one was unloaded + assemblyLoadContext = CreateAssemblyLoadContext(); + + // Show busy indicator + await Dispatcher.InvokeAsync(() => busyMask.IsBusy = true); + + // Reload the plugin + await ExecuteSource(); + + // Hide busy indicator + await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false); + + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Plugin reloaded successfully", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - Failed to reload plugin: {ex}", source: "Plugin"); + + await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Reload Error \"{PluginMetadata.Name}\"", + Content = $"Failed to reload plugin:\n{ex.Message}", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); + } + finally + { + isReloading = false; + + // Re-enable file watcher + if (pluginFileWatcher != null && IsRunning) + { + pluginFileWatcher.EnableRaisingEvents = true; + } + } + } + public void UpdatePluginWindow() { - Dispatcher.Invoke(ThemeChanged); UpdateTimer_Elapsed(updateTimer, null); } + public void SavePluginState() + { + if (pluginClassInstance is not null) + { + SaveState(pluginClassInstance); + } + } + public void Exit() { IsRunning = false; @@ -150,31 +350,35 @@ public partial class PluginWindow : Window bitmapData.Scan0, bitmapData.Stride * bitmapData.Height, bitmapData.Stride); bitmap.UnlockBits(bitmapData); + + bitmapSource.Freeze(); return bitmapSource; } private void Window_ContentRendered(object? sender, EventArgs e) { App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Starting plugin thread", source: "Plugin"); - pluginThread = new Thread(LoadPlugin); + pluginThread = new Thread(async () => await LoadPlugin()); pluginThread.Start(); } - private void ThemeChanged() - { - viewBox.Margin = new Thickness(settings.Theme.Margin); - border.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(settings.Theme.BackgroundColor)); - border.CornerRadius = new CornerRadius(settings.Theme.CornerRadius); - } - - private void LoadPlugin() + private async Task LoadPlugin() { App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin", source: "Plugin"); if (pluginClassInstance is null && !File.Exists($"{PluginFolderPath}\\main.dll")) { App.Logger.LogError($"\"{PluginMetadata.Name}\" - File \"main.dll\" does not exist", source: "Plugin"); - _ = MessageBox.Show("File \"main.dll\" does not exist!", $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "File \"main.dll\" does not exist!", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); return; @@ -182,31 +386,84 @@ public partial class PluginWindow : Window try { - ExecuteSource(); + await ExecuteSource(); } catch (Exception ex) { App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin"); - _ = MessageBox.Show("File execution error:\n" + ex, $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "File execution error:\n" + ex, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); return; } PluginLoaded?.Invoke(); + + _ = await Dispatcher.InvokeAsync(() => busyMask.IsBusy = false); + + // Enable hot reload after initial load + if (pluginFileWatcher != null) + { + pluginFileWatcher.EnableRaisingEvents = true; + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Hot reload monitoring started", source: "Plugin"); + } } - private void ExecuteSource() + private void ThemeChanged() + { + viewBox.Margin = new Thickness(settings.Theme.Margin); + border.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(settings.Theme.BackgroundColor)); + border.CornerRadius = new CornerRadius(settings.Theme.CornerRadius); + + pluginClassInstance?.OnThemeChanged(); + + if (pluginClassInstance?.UpdateInterval is 0 or > 500) + { + pluginClassInstance.Application.UpdateWindow(); + } + } + + private async Task ExecuteSource() { object? instance = pluginClassInstance; if (instance is null) { - Assembly dll = Assembly.LoadFrom($"{PluginFolderPath}\\main.dll"); + Assembly dll; + + if (PluginMetadata.SupportsUnloading) + { + byte[] assemblyData = await File.ReadAllBytesAsync($"{PluginFolderPath}\\main.dll"); + using MemoryStream assemblyStream = new(assemblyData); + + dll = assemblyLoadContext.LoadFromStream(assemblyStream); + } + else + { + dll = Assembly.LoadFrom($"{PluginFolderPath}\\main.dll"); + } + Type? instanceType = Array.Find(dll.GetTypes(), type => type.GetTypeInfo().BaseType == typeof(Plugin)); if (instanceType is null) { App.Logger.LogError($"\"{PluginMetadata.Name}\" - The \"Plugin\" class could not be found! It has to inherit from \"{typeof(Plugin).FullName}\"", source: "Plugin"); - _ = MessageBox.Show($"The \"Plugin\" class could not be found! It has to inherit from \"{typeof(Plugin).FullName}\"", $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); - + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = $"The \"Plugin\" class could not be found! It has to inherit from \"{typeof(Plugin).FullName}\"", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); return; } @@ -216,17 +473,27 @@ public partial class PluginWindow : Window if (instance is Plugin plugin) { pluginClassInstance = plugin; - pluginClassInstance.Application = new Plugins.PluginData(this, settings); + pluginClassInstance.Application = new PluginData(this, settings); } else { App.Logger.LogError($"\"{PluginMetadata.Name}\" - The \"Plugin\" class could not be found! It has to inherit from \"{typeof(Plugin).FullName}\"", source: "Plugin"); - _ = MessageBox.Show($"The \"Plugin\" class has to inherit from \"{typeof(Plugin).FullName}\"", $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = $"The \"Plugin\" class has to inherit from \"{typeof(Plugin).FullName}\"", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); return; } - LoadOptions(pluginClassInstance); + LoadState(pluginClassInstance); + await LoadOptions(pluginClassInstance); BindDefaultSettings(pluginClassInstance); updateTimer = new System.Timers.Timer @@ -236,7 +503,14 @@ public partial class PluginWindow : Window updateTimer.Elapsed += UpdateTimer_Elapsed; pluginClassInstance.Start(); + if (pluginClassInstance is AsyncPlugin asyncPluginStart) + { + pluginCancellationTokenSource?.Dispose(); + pluginCancellationTokenSource = new CancellationTokenSource(); + await asyncPluginStart.StartAsync(pluginCancellationTokenSource.Token); + } UpdatePluginWindow(); + await Dispatcher.InvokeAsync(ThemeChanged); if (pluginClassInstance.UpdateInterval > 0) { @@ -316,16 +590,13 @@ public partial class PluginWindow : Window } } - private void LoadOptions(object instance) + private async Task LoadOptions(object instance) { App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin options", source: "Plugin"); try { -#pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields FieldInfo[] props = instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.GetField); -#pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields - List settingElements = []; foreach (FieldInfo prop in props) { @@ -344,6 +615,16 @@ public partial class PluginWindow : Window settingElement.JsonValue = settingsSettingElement.JsonValue; } + element.OnValueChanged += () => + { + pluginClassInstance?.OnSettingsChanged(); + + if (pluginClassInstance?.UpdateInterval is 0 or > 500) + { + pluginClassInstance.Application.UpdateWindow(); + } + }; + settingElements.Add(settingElement); break; } @@ -357,18 +638,167 @@ public partial class PluginWindow : Window { IsRunning = false; App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin"); - _ = MessageBox.Show("File execution error:\n" + ex, $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "File execution error:\n" + ex, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); } } - private void UpdateTimer_Elapsed(object? sender, ElapsedEventArgs? e) + private void LoadState(object instance) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Loading plugin state", source: "Plugin"); + + try + { + if (settings.State.Count == 0) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - No state found, using defaults", source: "Plugin"); + return; + } + + FieldInfo[] fields = instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + PropertyInfo[] properties = instance.GetType().GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + // Load fields + foreach (FieldInfo field in fields) + { + PersistStateAttribute? persistAttribute = field.GetCustomAttribute(); + if (persistAttribute is null) + { + continue; + } + + if (settings.State.TryGetValue(field.Name, out JsonElement jsonElement)) + { + try + { + object? value = jsonElement.Deserialize(field.FieldType); + field.SetValue(instance, value); + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Restored state field: {field.Name}", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to restore field {field.Name}: {ex.Message}", source: "Plugin"); + } + } + } + + // Load properties + foreach (PropertyInfo property in properties) + { + PersistStateAttribute? persistAttribute = property.GetCustomAttribute(); + if (persistAttribute is null || !property.CanWrite) + { + continue; + } + + if (settings.State.TryGetValue(property.Name, out JsonElement jsonElement)) + { + try + { + object? value = jsonElement.Deserialize(property.PropertyType); + property.SetValue(instance, value); + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Restored state property: {property.Name}", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to restore property {property.Name}: {ex.Message}", source: "Plugin"); + } + } + } + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error loading state: {ex.Message}", source: "Plugin"); + } + } + + private void SaveState(object instance) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Saving plugin state", source: "Plugin"); + + try + { + FieldInfo[] fields = instance.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + PropertyInfo[] properties = instance.GetType().GetProperties(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + // Save fields + foreach (FieldInfo field in fields) + { + PersistStateAttribute? persistAttribute = field.GetCustomAttribute(); + if (persistAttribute is null) + { + continue; + } + + try + { + object? value = field.GetValue(instance); + JsonElement jsonElement = JsonSerializer.SerializeToElement(value, field.FieldType); + settings.State[field.Name] = jsonElement; + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Saved state field: {field.Name}", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to save field {field.Name}: {ex.Message}", source: "Plugin"); + } + } + + // Save properties + foreach (PropertyInfo property in properties) + { + PersistStateAttribute? persistAttribute = property.GetCustomAttribute(); + if (persistAttribute is null || !property.CanRead) + { + continue; + } + + try + { + object? value = property.GetValue(instance); + JsonElement jsonElement = JsonSerializer.SerializeToElement(value, property.PropertyType); + settings.State[property.Name] = jsonElement; + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Saved state property: {property.Name}", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogWarn($"\"{PluginMetadata.Name}\" - Failed to save property {property.Name}: {ex.Message}", source: "Plugin"); + } + } + + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - State saved successfully ({settings.State.Count} items)", source: "Plugin"); + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - Error saving state: {ex.Message}", source: "Plugin"); + } + } + + private async void UpdateTimer_Elapsed(object? sender, ElapsedEventArgs? e) { try { if (IsRunning && pluginClassInstance is not null) { - Bitmap? result = pluginClassInstance.Main(); + Bitmap? result; + + if (pluginClassInstance is AsyncPlugin asyncPlugin) + { + CancellationToken token = pluginCancellationTokenSource?.Token ?? CancellationToken.None; + result = await asyncPlugin.MainAsync(token); + } + else + { + result = pluginClassInstance.Main(); + } if (pluginClassInstance.UpdateInterval > 0) { @@ -389,11 +819,13 @@ public partial class PluginWindow : Window _ => BitmapScalingMode.Unspecified }; - //Update Image - Dispatcher.Invoke(() => + // Update Image + BitmapSource frozenSource = BitmapToImageSource(result); + + _ = Dispatcher.BeginInvoke(() => { RenderOptions.SetBitmapScalingMode(image, renderOptions); - image.Source = BitmapToImageSource(result); + image.Source = frozenSource; }); } } @@ -402,7 +834,16 @@ public partial class PluginWindow : Window { IsRunning = false; App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "Plugin"); - _ = MessageBox.Show("File execution error:\n" + ex, $"Error \"{PluginMetadata.Name}\"", MessageBoxButton.OK, MessageBoxImage.Error); + _ = await Dispatcher.InvokeAsync(async () => + { + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "File execution error:\n" + ex, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + }); Exit(); return; } @@ -426,9 +867,23 @@ public partial class PluginWindow : Window private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) { - App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping plugin", source: "Plugin"); IsRunning = false; - pluginClassInstance?.Stop(); + + // Cleanup hot reload resources + if (pluginFileWatcher != null) + { + pluginFileWatcher.EnableRaisingEvents = false; + pluginFileWatcher.Changed -= OnPluginFileChanged; + pluginFileWatcher.Dispose(); + pluginFileWatcher = null; + } + + reloadDebounceTimer?.Stop(); + reloadDebounceTimer?.Dispose(); + reloadDebounceTimer = null; + + // Stop plugin using the shared method (no need to await in synchronous event handler) + _ = StopPlugin(unloadAssembly: true); } #region Window Events diff --git a/src/DesktopMagic/Plugins/WebPluginWindow.xaml b/src/DesktopMagic/Plugins/WebPluginWindow.xaml new file mode 100644 index 0000000..deb7d11 --- /dev/null +++ b/src/DesktopMagic/Plugins/WebPluginWindow.xaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs new file mode 100644 index 0000000..ff92680 --- /dev/null +++ b/src/DesktopMagic/Plugins/WebPluginWindow.xaml.cs @@ -0,0 +1,256 @@ +using DesktopMagic.Helpers; +using DesktopMagic.Plugins; +using DesktopMagic.Settings; + +using Microsoft.Web.WebView2.Core; + +using System; +using System.IO; +using System.Windows; +using System.Windows.Interop; +using System.Windows.Media; + +namespace DesktopMagic; + +public partial class WebPluginWindow : Window, IPluginWindow +{ + public event Action? PluginLoaded; + + public event Action? OnExit; + + private readonly PluginSettings settings; + private bool isInitialized = false; + + public bool IsRunning { get; private set; } = true; + public PluginMetadata PluginMetadata { get; private set; } + public string PluginFolderPath { get; private set; } + + public WebPluginWindow(PluginMetadata pluginMetadata, PluginSettings settings, string pluginFolderPath) + { + InitializeComponent(); + + Window w = new() + { + Top = -100, + Left = -100, + Width = 0, + Height = 0, + + WindowStyle = WindowStyle.ToolWindow, + ShowInTaskbar = false + }; + + WindowInteropHelper helper = new WindowInteropHelper(w); + _ = helper.EnsureHandle(); + + Owner = w; + + settings.PropertyChanged += (e, s) => + { + if (s.PropertyName == nameof(PluginSettings.CurrentThemeName)) + { + settings.Theme.PropertyChanged += (se, ev) => + { + ThemeChanged(); + }; + ThemeChanged(); + } + }; + + settings.Theme.PropertyChanged += (se, ev) => + { + ThemeChanged(); + }; + + PluginMetadata = pluginMetadata; + this.settings = settings; + + Left = settings.Position.X; + Top = settings.Position.Y; + Width = settings.Size.X; + Height = settings.Size.Y; + + PluginFolderPath = pluginFolderPath; + } + + public void Exit() + { + IsRunning = false; + + Dispatcher.Invoke(() => + { + OnExit?.Invoke(); + }); + } + + public void SetEditMode(bool enabled) + { + if (enabled) + { + Topmost = true; + panel.Visibility = Visibility.Visible; + _ = W32.EnableWindow(webView.Handle, false); + WindowPos.SetIsLocked(this, false); + tileBar.CaptionHeight = tileBar.CaptionHeight = ActualHeight - 10 < 0 ? 0 : ActualHeight - 10; + ResizeMode = ResizeMode.CanResize; + } + else + { + Topmost = false; + panel.Visibility = Visibility.Collapsed; + _ = W32.EnableWindow(webView.Handle, true); + WindowPos.SendWpfWindowBack(this); + WindowPos.SendWpfWindowBack(this); + WindowPos.SetIsLocked(this, true); + tileBar.CaptionHeight = 0; + ResizeMode = ResizeMode.NoResize; + } + } + + protected override void OnSourceInitialized(EventArgs e) + { + base.OnSourceInitialized(e); + + WindowInteropHelper helper = new(this); + _ = WindowPos.SetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE, + WindowPos.GetWindowLong(helper.Handle, WindowPos.GWL_EXSTYLE) | WindowPos.WS_EX_NOACTIVATE); + } + + private async void Window_ContentRendered(object? sender, EventArgs e) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Starting web plugin", source: "WebPlugin"); + + try + { + await InitializeWebView(); + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "WebPlugin"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "WebView2 initialization error:\n" + ex, + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + Exit(); + } + } + + private void ThemeChanged() + { + webView.Margin = new Thickness(settings.Theme.Margin); + border.Background = new SolidColorBrush(MultiColorConverter.ConvertToMediaColor(settings.Theme.BackgroundColor)); + border.CornerRadius = new CornerRadius(settings.Theme.CornerRadius); + + string cssVariables = $@" + :root {{ + --background-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.BackgroundColor)}; + --primary-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.PrimaryColor)}; + --secondary-color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.SecondaryColor)}; + --font-family: {settings.Theme.Font}; + + font-family: {settings.Theme.Font}; + color: {MultiColorConverter.ConvertToHexRgba(settings.Theme.PrimaryColor)}; + }} + "; + + string script = $@" + (function() {{ + let style = document.getElementById('desktop-magic-theme'); + if (!style) {{ + style = document.createElement('style'); + style.id = 'desktop-magic-theme'; + document.head.appendChild(style); + }} + style.textContent = `{cssVariables}`; + }})(); + "; + + _ = webView.ExecuteScriptAsync(script); + } + + private async System.Threading.Tasks.Task InitializeWebView() + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Initializing WebView2", source: "WebPlugin"); + + string htmlPath = Path.Combine(PluginFolderPath, "main.html"); + + if (!File.Exists(htmlPath)) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - File \"main.html\" does not exist", source: "WebPlugin"); + Wpf.Ui.Controls.MessageBox messageBox = new Wpf.Ui.Controls.MessageBox + { + Title = $"Error \"{PluginMetadata.Name}\"", + Content = "File \"main.html\" does not exist!", + CloseButtonText = "Ok" + }; + _ = await messageBox.ShowDialogAsync(); + Exit(); + return; + } + + try + { + string userDataFolder = Path.Combine(Path.GetTempPath(), "DesktopMagic", "WebView2", PluginMetadata.Id.ToString()); + CoreWebView2Environment environment = await CoreWebView2Environment.CreateAsync(null, userDataFolder); + await webView.EnsureCoreWebView2Async(environment); + + webView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false; + webView.CoreWebView2.Settings.AreDevToolsEnabled = false; + webView.CoreWebView2.Settings.IsStatusBarEnabled = false; + webView.CoreWebView2.Settings.AreDefaultScriptDialogsEnabled = true; + + string htmlUri = new Uri(htmlPath).AbsoluteUri; + webView.Source = new Uri(htmlUri); + + isInitialized = true; + + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - WebView2 initialized successfully", source: "WebPlugin"); + PluginLoaded?.Invoke(); + + busyMask.IsBusy = false; + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - WebView2 initialization failed: {ex}", source: "WebPlugin"); + throw; + } + } + + private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e) + { + App.Logger.LogInfo($"\"{PluginMetadata.Name}\" - Stopping web plugin", source: "WebPlugin"); + IsRunning = false; + + try + { + if (isInitialized && webView.CoreWebView2 != null) + { + webView.Dispose(); + } + } + catch (Exception ex) + { + App.Logger.LogError($"\"{PluginMetadata.Name}\" - {ex}", source: "WebPlugin"); + } + } + + private void Window_LocationChanged(object sender, EventArgs e) + { + settings.Position = new System.Windows.Point(Left, Top); + } + + private void Window_SizeChanged(object sender, SizeChangedEventArgs e) + { + settings.Size = new System.Windows.Point(Width, Height); + + tileBar.CaptionHeight = ActualHeight - 10; + } + + private void WebView_CoreWebView2InitializationCompleted(object sender, CoreWebView2InitializationCompletedEventArgs e) + { + webView.CoreWebView2.DOMContentLoaded += (_, _) => ThemeChanged(); + } +} diff --git a/src/DesktopMagic/Resources/Images/ImageResources.xaml b/src/DesktopMagic/Resources/Images/ImageResources.xaml index a9578d0..7dc4db2 100644 --- a/src/DesktopMagic/Resources/Images/ImageResources.xaml +++ b/src/DesktopMagic/Resources/Images/ImageResources.xaml @@ -3,4 +3,5 @@ + \ No newline at end of file diff --git a/src/DesktopMagic/Resources/Images/modio-logo-bluelight.png b/src/DesktopMagic/Resources/Images/modio-logo-bluelight.png new file mode 100644 index 0000000..fb2e571 Binary files /dev/null and b/src/DesktopMagic/Resources/Images/modio-logo-bluelight.png differ diff --git a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml index 910d844..9218420 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.de.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.de.xaml @@ -3,6 +3,9 @@ xmlns:col="clr-namespace:System.Collections;assembly=mscorlib" xmlns:system="clr-namespace:System;assembly=mscorlib"> + Entwickelt von Stone_Red + Bug Melden + Feature Anfragen Linie Spiegeln Ordner @@ -15,13 +18,15 @@ Mod.io Account E-Mail eingeben Geben Sie Ihren mod.io Sicherheitscode ein Bearbeitungsmodus umschalten - Alle Plugins - Installierte Plugins - Neu Laden + Alle Plugins Suchen... + Installierte Plugins Suchen... + Plugins Neu Laden Plugins Ordner Plugins Manager Neues Plugin erstellen Pluginnamen eingeben + Plugins + Aktivieren Sie das Plugin, um seine Einstellungen zu konfigurieren. Farbe: Standard Wollen sie das Programm wirklich schließen? @@ -31,14 +36,18 @@ Datum CPU Auslastung Musik Visualisierer + Wetter Signalverstärkung: Neues Layout Layout Löschen Neues Theme Theme Löschen - Theme Ändern + Theme Bearbeiten + Themes Ok Abbrechen + Möchten Sie dieses Layout wirklich löschen? + Möchten Sie dieses Theme wirklich löschen? Layoutnamen eingeben: Layout existiert bereits! Themenamen eingeben: diff --git a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml index d19026b..c524080 100644 --- a/src/DesktopMagic/Resources/Strings/StringResources.en.xaml +++ b/src/DesktopMagic/Resources/Strings/StringResources.en.xaml @@ -3,6 +3,9 @@ xmlns:col="clr-namespace:System.Collections;assembly=mscorlib" xmlns:system="clr-namespace:System;assembly=mscorlib"> + Developed by Stone_Red + Report Bug + Request Feature Line Mirror Folder @@ -15,13 +18,15 @@ Enter your mod.io account E-mail Enter your mod.io security code Toggle Edit Mode - All Plugins - Installed Plugins - Reload + Search All Plugins... + Search Installed Plugins... + Reload Plugins Plugins Folder Plugins Manager Create New Plugin Enter plugin name + Plugins + Enable the plugin to configure its settings. Color: Default Do you really want to close the program? @@ -31,14 +36,18 @@ Date CPU Usage Music Visualizer + Weather Signal Amplification: New Layout Delete Layout New Theme Delete Theme - Change Theme + Edit Theme + Themes Ok Cancel + Do you really want to delete this layout? + Do you really want to delete this theme? Enter layout name Layout already exists! Can't delete last layout! diff --git a/src/DesktopMagic/Resources/Styles/ToggleSwitchContentLeftStyle.xaml b/src/DesktopMagic/Resources/Styles/ToggleSwitchContentLeftStyle.xaml new file mode 100644 index 0000000..3420ca0 --- /dev/null +++ b/src/DesktopMagic/Resources/Styles/ToggleSwitchContentLeftStyle.xaml @@ -0,0 +1,131 @@ + + + 40 + 20 + 1 + 0,0,8,0 + + + \ No newline at end of file diff --git a/src/DesktopMagic/Settings/DesktopMagicSettings.cs b/src/DesktopMagic/Settings/DesktopMagicSettings.cs index 62409e8..9cbb10d 100644 --- a/src/DesktopMagic/Settings/DesktopMagicSettings.cs +++ b/src/DesktopMagic/Settings/DesktopMagicSettings.cs @@ -8,7 +8,7 @@ using System.Text.Json.Serialization; namespace DesktopMagic.Settings; -internal class DesktopMagicSettings : INotifyPropertyChanged +public class DesktopMagicSettings : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; @@ -44,7 +44,16 @@ internal class DesktopMagicSettings : INotifyPropertyChanged public string? CurrentLayoutName { - get => currentLayoutName ?? Layouts.FirstOrDefault()?.Name; + get + { + if (!Layouts.Any(l => l.Name == currentLayoutName)) + { + currentLayoutName = null; + } + + return currentLayoutName ?? Layouts.FirstOrDefault()?.Name; + } + set { currentLayoutName = value; diff --git a/src/DesktopMagic/Settings/Layout.cs b/src/DesktopMagic/Settings/Layout.cs index 420f746..a92dff7 100644 --- a/src/DesktopMagic/Settings/Layout.cs +++ b/src/DesktopMagic/Settings/Layout.cs @@ -9,7 +9,7 @@ using System.Text.Json.Serialization; namespace DesktopMagic.Settings; -internal class Layout(string name) : INotifyPropertyChanged +public class Layout(string name) : INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; diff --git a/src/DesktopMagic/Settings/PluginSettings.cs b/src/DesktopMagic/Settings/PluginSettings.cs index 8261f7b..5655032 100644 --- a/src/DesktopMagic/Settings/PluginSettings.cs +++ b/src/DesktopMagic/Settings/PluginSettings.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Runtime.CompilerServices; +using System.Text.Json; using System.Text.Json.Serialization; using System.Windows; @@ -16,13 +17,14 @@ public class PluginSettings : INotifyPropertyChanged private string? currentThemeName; private List settings = []; + private Dictionary state = []; private bool enabled = false; private Point position = new Point(100, 100); private Point size = new Point(300, 300); // Only for internal use to show the name of the plugin in the main window [JsonIgnore] - public string Name { get; set; } = string.Empty; + public PluginMetadata Metadata { get; set; } = new(); [JsonIgnore] public Theme Theme @@ -61,6 +63,23 @@ public class PluginSettings : INotifyPropertyChanged } } + /// + /// Dictionary storing plugin state. Key is the field/property name, value is the JsonElement. + /// This is persisted per layout in settings.json. + /// + public Dictionary State + { + get => state; + set + { + if (state != value) + { + state = value; + OnPropertyChanged(); + } + } + } + public bool Enabled { get => enabled; diff --git a/src/DesktopMagicPluginAPI/AsyncPlugin.cs b/src/DesktopMagicPluginAPI/AsyncPlugin.cs new file mode 100644 index 0000000..70db345 --- /dev/null +++ b/src/DesktopMagicPluginAPI/AsyncPlugin.cs @@ -0,0 +1,47 @@ +using System; +using System.Drawing; +using System.Threading; +using System.Threading.Tasks; + +namespace DesktopMagic.Api; + +/// +/// Provides an abstract base class for creating asynchronous plugins that can be integrated into the application, supporting periodic updates, +/// rendering, and user interaction. +/// +/// Derive from this class to implement custom plugin functionality. The class defines lifecycle methods +/// such as , , and for activation, deactivation, and periodic +/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data +/// and rendering configuration. Implementations should override relevant methods to respond to user input, update +/// intervals, and configuration changes as needed. +public abstract class AsyncPlugin : Plugin +{ + /// + /// Occurs once when the plugin gets activated. Override for async initialization. + /// + /// Token signaled when the host requests cancellation. + public virtual Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Occurs once when the plugin gets deactivated. Override for async cleanup. + /// + /// Token signaled when the host requests cancellation. + public virtual Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// Occurs when the elapses. + /// + /// + public abstract Task MainAsync(CancellationToken cancellationToken); + + /// + /// This method should not be called directly! Override and use for asynchronous plugin operations. + /// + /// This method only exists to fulfill base class requirements. + /// Calling it will always result in an . + /// Thrown if this method is called directly. Use MainAsync for plugin execution instead. + public sealed override Bitmap? Main() + { + throw new InvalidOperationException($"AsyncPlugin.Main() should not be called directly. Override {nameof(MainAsync)} instead."); + } +} \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj b/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj index f19692b..1cd85a0 100644 --- a/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj +++ b/src/DesktopMagicPluginAPI/DesktopMagic.Api.csproj @@ -4,15 +4,17 @@ net8.0-windows Stone_Red Stone_Red - 0.0.0.5 + 1.0.0.0 https://github.com/Stone-Red-Code/DesktopMagic LICENSE true - 0.0.0.5 + 1.0.0.0 - 0.0.0.5 + enable AnyCPU;x64 + README.md + Square44x44Logo.altform-lightunplated_targetsize-256.png @@ -36,6 +38,14 @@ True \ + + True + \ + + + True + \ + diff --git a/src/DesktopMagicPluginAPI/IPluginData.cs b/src/DesktopMagicPluginAPI/IPluginData.cs index 3f0aaca..10a6639 100644 --- a/src/DesktopMagicPluginAPI/IPluginData.cs +++ b/src/DesktopMagicPluginAPI/IPluginData.cs @@ -36,4 +36,27 @@ public interface IPluginData /// Updates the plugin window. /// void UpdateWindow(); + + /// + /// Logs a message to the application log. + /// + /// The message to log. + /// The log level (Info, Warning, Error). + void Log(string message, LogLevel level = LogLevel.Info); + + /// + /// Shows a message box to the user. + /// + /// The message to display. + /// The title of the message box. + void ShowMessage(string message, string? title = null); + + /// + /// Saves the current state of fields and properties marked with + /// + /// + /// State is automatically saved when the plugin stops, but this method can be called + /// to save state at any time, such as after important user interactions. + /// + void SaveState(); } \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/LogLevel.cs b/src/DesktopMagicPluginAPI/LogLevel.cs new file mode 100644 index 0000000..f2d92dd --- /dev/null +++ b/src/DesktopMagicPluginAPI/LogLevel.cs @@ -0,0 +1,22 @@ +namespace DesktopMagic.Api; + +/// +/// Specifies the severity level of a log message. +/// +public enum LogLevel +{ + /// + /// Informational message for general information. + /// + Info, + + /// + /// Warning message indicating a potential issue. + /// + Warning, + + /// + /// Error message indicating a failure or critical issue. + /// + Error +} \ No newline at end of file diff --git a/src/DesktopMagicPluginAPI/Settings/MouseButton.cs b/src/DesktopMagicPluginAPI/MouseButton.cs similarity index 88% rename from src/DesktopMagicPluginAPI/Settings/MouseButton.cs rename to src/DesktopMagicPluginAPI/MouseButton.cs index 0a5bc71..d0b612b 100644 --- a/src/DesktopMagicPluginAPI/Settings/MouseButton.cs +++ b/src/DesktopMagicPluginAPI/MouseButton.cs @@ -1,4 +1,4 @@ -namespace DesktopMagic.Api.Settings; +namespace DesktopMagic.Api; /// /// Mouse Buttons diff --git a/src/DesktopMagicPluginAPI/PersistStateAttribute.cs b/src/DesktopMagicPluginAPI/PersistStateAttribute.cs new file mode 100644 index 0000000..b5420a7 --- /dev/null +++ b/src/DesktopMagicPluginAPI/PersistStateAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace DesktopMagic.Api; + +/// +/// Marks a field or property to be automatically persisted between plugin sessions. +/// +/// +/// The field or property must be JSON serializable. Complex types should have parameterless constructors. +/// State is saved when the plugin stops and loaded when it starts. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class PersistStateAttribute : Attribute +{ +} diff --git a/src/DesktopMagicPluginAPI/Plugin.cs b/src/DesktopMagicPluginAPI/Plugin.cs index 911db6c..5677365 100644 --- a/src/DesktopMagicPluginAPI/Plugin.cs +++ b/src/DesktopMagicPluginAPI/Plugin.cs @@ -7,8 +7,14 @@ using System.Drawing; namespace DesktopMagic.Api; /// -/// The plugin class. +/// Provides an abstract base class for creating plugins that can be integrated into the application, supporting periodic updates, +/// rendering, and user interaction. /// +/// Derive from this class to implement custom plugin functionality. The class defines lifecycle methods +/// such as , , and for activation, deactivation, and periodic +/// execution. It also provides event handlers for mouse and theme interactions, as well as access to application data +/// and rendering configuration. Implementations should override relevant methods to respond to user input, update +/// intervals, and configuration changes as needed. public abstract class Plugin { [Setting("desktopmagic-horizontal-alignment", "Horizontal Alignment", -999)] @@ -86,4 +92,18 @@ public abstract class Plugin public virtual void OnMouseWheel(Point position, int delta) { } -} \ No newline at end of file + + /// + /// Occurs when the application's theme has changed. + /// + public virtual void OnThemeChanged() + { + } + + /// + /// Invoked when the settings have changed to allow derived classes to respond to configuration updates. + /// + public virtual void OnSettingsChanged() + { + } +} diff --git a/src/DesktopMagicPluginAPI/Settings/ColorPicker.cs b/src/DesktopMagicPluginAPI/Settings/ColorPicker.cs new file mode 100644 index 0000000..ada24ee --- /dev/null +++ b/src/DesktopMagicPluginAPI/Settings/ColorPicker.cs @@ -0,0 +1,62 @@ +using System.Drawing; +using System.Globalization; + +namespace DesktopMagic.Api.Settings; + +/// +/// Represents a color picker control. +/// +public sealed class ColorPicker : Setting +{ + private Color _value; + + /// + /// Gets or sets the color value assigned to the element. + /// + public Color Value + { + get => _value; + set + { + if (_value != value) + { + _value = value; + ValueChanged(); + } + } + } + + /// + /// Initializes a new instance of the class with the provided . + /// + /// The default color value. + public ColorPicker(Color defaultColor) + { + _value = defaultColor; + } + + /// + /// Initializes a new instance of the class with a default color of White. + /// + public ColorPicker() : this(Color.White) + { + } + + internal override string GetJsonValue() + { + return $"{Value.A},{Value.R},{Value.G},{Value.B}"; + } + + internal override void SetJsonValue(string value) + { + string[] parts = value.Split(','); + if (parts.Length == 4 && + byte.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte a) && + byte.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte r) && + byte.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte g) && + byte.TryParse(parts[3], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte b)) + { + Value = Color.FromArgb(a, r, g, b); + } + } +} diff --git a/src/DesktopMagicPluginAPI/Settings/FileSelector.cs b/src/DesktopMagicPluginAPI/Settings/FileSelector.cs new file mode 100644 index 0000000..0fde70a --- /dev/null +++ b/src/DesktopMagicPluginAPI/Settings/FileSelector.cs @@ -0,0 +1,65 @@ +namespace DesktopMagic.Api.Settings; + +/// +/// Represents a file selector control that allows the user to browse for a file. +/// +public sealed class FileSelector : Setting +{ + private string _value = string.Empty; + + /// + /// Gets or sets the selected file path. + /// + public string Value + { + get => _value; + set + { + if (_value != value) + { + _value = value; + ValueChanged(); + } + } + } + + /// + /// Gets or sets the file filter for the file dialog (e.g., "Image Files|*.png;*.jpg|All Files|*.*"). + /// + public string Filter { get; set; } + + /// + /// Gets or sets the title of the file dialog. + /// + public string Title { get; set; } + + /// + /// Gets or sets a value indicating whether the file selector should select folders instead of files. + /// + public bool SelectFolder { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The default file path. + /// The file filter for the file dialog. + /// The title of the file dialog. + /// If true, selects folders instead of files. + public FileSelector(string defaultPath = "", string filter = "All Files|*.*", string title = "Select File", bool selectFolder = false) + { + _value = defaultPath; + Filter = filter; + Title = title; + SelectFolder = selectFolder; + } + + internal override string GetJsonValue() + { + return Value; + } + + internal override void SetJsonValue(string value) + { + Value = value; + } +} diff --git a/src/MsixPackaging/Package.appxmanifest b/src/MsixPackaging/Package.appxmanifest index 09355a6..9c328fd 100644 --- a/src/MsixPackaging/Package.appxmanifest +++ b/src/MsixPackaging/Package.appxmanifest @@ -9,7 +9,7 @@ + Version="1.3.0.0" /> Desktop Magic